mirror of
https://github.com/czhu12/canine.git
synced 2025-12-20 10:19:50 -06:00
added
This commit is contained in:
25
app/actions/add_ons/apply_template_to_values.rb
Normal file
25
app/actions/add_ons/apply_template_to_values.rb
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
class AddOns::ApplyTemplateToValues
|
||||||
|
extend LightService::Action
|
||||||
|
expects :add_on
|
||||||
|
|
||||||
|
executed do |context|
|
||||||
|
add_on = context.add_on
|
||||||
|
add_on.values.extend(DotSettable)
|
||||||
|
|
||||||
|
variables = add_on.metadata['template'] || {}
|
||||||
|
variables.keys.each do |key|
|
||||||
|
variable = variables[key]
|
||||||
|
|
||||||
|
if variable.is_a?(Hash) && variable['type'] == 'size'
|
||||||
|
add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}")
|
||||||
|
else
|
||||||
|
variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key }
|
||||||
|
if variable_definition['type'] == 'integer'
|
||||||
|
add_on.values.dotset(key, variable.to_i)
|
||||||
|
else
|
||||||
|
add_on.values.dotset(key, variable)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,47 +1,40 @@
|
|||||||
class AddOns::Create
|
class AddOns::Create
|
||||||
extend LightService::Action
|
def self.parse_params(params)
|
||||||
expects :add_on
|
if params[:add_on][:values_yaml].present?
|
||||||
promises :add_on
|
params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml])
|
||||||
|
end
|
||||||
|
if params[:add_on][:metadata].present?
|
||||||
|
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
|
||||||
|
end
|
||||||
|
params.require(:add_on).permit(
|
||||||
|
:cluster_id,
|
||||||
|
:chart_type,
|
||||||
|
:chart_url,
|
||||||
|
:name,
|
||||||
|
metadata: {},
|
||||||
|
values: {}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
executed do |context|
|
class ToNamespaced
|
||||||
add_on = context.add_on
|
extend LightService::Action
|
||||||
apply_template_to_values(add_on)
|
expects :add_on
|
||||||
fetch_package_details(context, add_on)
|
promises :namespaced
|
||||||
unless add_on.save
|
executed do |context|
|
||||||
context.fail_and_return!("Failed to create add on")
|
context.namespaced = context.add_on
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.fetch_package_details(context, add_on)
|
extend LightService::Organizer
|
||||||
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
|
|
||||||
|
|
||||||
if result.failure?
|
def self.call(add_on, user)
|
||||||
add_on.errors.add(:base, "Failed to fetch package details")
|
with(add_on:, user:).reduce(
|
||||||
context.fail_and_return!("Failed to fetch package details")
|
AddOns::Create::ToNamespaced,
|
||||||
end
|
Namespaced::SetUpNamespace,
|
||||||
|
Namespaced::ValidateNamespace,
|
||||||
result.response.delete('readme')
|
AddOns::ApplyTemplateToValues,
|
||||||
add_on.metadata['package_details'] = result.response
|
AddOns::SetPackageDetails,
|
||||||
end
|
AddOns::Save
|
||||||
|
)
|
||||||
def self.apply_template_to_values(add_on)
|
|
||||||
# Merge the values from the form with the values.yaml object and create a new values.yaml file
|
|
||||||
add_on.values.extend(DotSettable)
|
|
||||||
|
|
||||||
variables = add_on.metadata['template'] || {}
|
|
||||||
variables.keys.each do |key|
|
|
||||||
variable = variables[key]
|
|
||||||
|
|
||||||
if variable.is_a?(Hash) && variable['type'] == 'size'
|
|
||||||
add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}")
|
|
||||||
else
|
|
||||||
variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key }
|
|
||||||
if variable_definition['type'] == 'integer'
|
|
||||||
add_on.values.dotset(key, variable.to_i)
|
|
||||||
else
|
|
||||||
add_on.values.dotset(key, variable)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
10
app/actions/add_ons/save.rb
Normal file
10
app/actions/add_ons/save.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
class AddOns::Save
|
||||||
|
extend LightService::Action
|
||||||
|
expects :add_on
|
||||||
|
|
||||||
|
executed do |context|
|
||||||
|
unless context.add_on.save
|
||||||
|
context.fail_and_return!("Failed to create add on")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
18
app/actions/add_ons/set_package_details.rb
Normal file
18
app/actions/add_ons/set_package_details.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
class AddOns::SetPackageDetails
|
||||||
|
extend LightService::Action
|
||||||
|
expects :add_on
|
||||||
|
|
||||||
|
executed do |context|
|
||||||
|
add_on = context.add_on
|
||||||
|
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
|
||||||
|
|
||||||
|
if result.failure?
|
||||||
|
add_on.errors.add(:base, "Failed to fetch package details")
|
||||||
|
context.fail_and_return!("Failed to fetch package details")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Readme is too large
|
||||||
|
result.response.delete('readme')
|
||||||
|
add_on.metadata['package_details'] = result.response
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -12,7 +12,7 @@ class AddOns::UninstallHelmChart
|
|||||||
end
|
end
|
||||||
|
|
||||||
client = K8::Client.new(connection)
|
client = K8::Client.new(connection)
|
||||||
if (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.name }).present?
|
if add_on.managed_namespace? && (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.namespace }).present?
|
||||||
client.delete_namespace(namespace.metadata.name)
|
client.delete_namespace(namespace.metadata.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
16
app/actions/namespaced/set_up_namespace.rb
Normal file
16
app/actions/namespaced/set_up_namespace.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class Namespaced::SetUpNamespace
|
||||||
|
extend LightService::Action
|
||||||
|
expects :namespaced
|
||||||
|
promises :namespaced
|
||||||
|
|
||||||
|
executed do |context|
|
||||||
|
namespaced = context.namespaced
|
||||||
|
if namespaced.namespace.blank? && namespaced.managed_namespace
|
||||||
|
# autoset the namespace to the namespaced name
|
||||||
|
namespaced.namespace = namespaced.name
|
||||||
|
elsif namespaced.namespace.blank? && !namespaced.managed_namespace
|
||||||
|
namespaced.errors.add(:base, "A namespace must be provided if it is not managed by Canine")
|
||||||
|
context.fail_and_return!("Failed to set up namespace")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
58
app/actions/namespaced/validate_namespace.rb
Normal file
58
app/actions/namespaced/validate_namespace.rb
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
module Namespaced
|
||||||
|
class ValidateNamespace
|
||||||
|
extend LightService::Action
|
||||||
|
|
||||||
|
expects :namespaced, :user
|
||||||
|
|
||||||
|
def self.validate_namespace_does_not_exist_or_is_managed(
|
||||||
|
context,
|
||||||
|
namespaced,
|
||||||
|
client,
|
||||||
|
existing_namespaces
|
||||||
|
)
|
||||||
|
namespace_exists = existing_namespaces.any? do |ns|
|
||||||
|
ns.metadata.name == namespaced.namespace && ns.metadata&.labels&.caninemanaged != "true"
|
||||||
|
end
|
||||||
|
if namespace_exists
|
||||||
|
error_message = "Namespace `#{namespaced.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name."
|
||||||
|
namespaced.errors.add(:name, error_message)
|
||||||
|
context.fail_and_return!(error_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.validate_namespace_exists(
|
||||||
|
context,
|
||||||
|
namespaced,
|
||||||
|
client,
|
||||||
|
existing_namespaces
|
||||||
|
)
|
||||||
|
existing_namespace = existing_namespaces.any? do |ns|
|
||||||
|
ns.metadata.name == namespaced.namespace
|
||||||
|
end
|
||||||
|
unless existing_namespace
|
||||||
|
error_message = "`#{namespaced.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable <b>auto create namespace</b>"
|
||||||
|
namespaced.errors.add(:base, error_message)
|
||||||
|
context.fail_and_return!(error_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
executed do |context|
|
||||||
|
namespaced = context.namespaced
|
||||||
|
cluster = namespaced.cluster
|
||||||
|
|
||||||
|
begin
|
||||||
|
client = K8::Client.new(K8::Connection.new(cluster, context.user))
|
||||||
|
existing_namespaces = client.get_namespaces
|
||||||
|
|
||||||
|
if namespaced.managed_namespace
|
||||||
|
validate_namespace_does_not_exist_or_is_managed(context, namespaced, client, existing_namespaces)
|
||||||
|
else
|
||||||
|
validate_namespace_exists(context, namespaced, client, existing_namespaces)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
# If we can't connect to check, we'll let it proceed and fail later if needed
|
||||||
|
Rails.logger.warn("Could not check namespace availability: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,8 @@ class ProjectForks::ForkProject
|
|||||||
child_project = parent_project.dup
|
child_project = parent_project.dup
|
||||||
child_project.branch = pull_request.branch
|
child_project.branch = pull_request.branch
|
||||||
child_project.name = "#{parent_project.name}-#{pull_request.number}"
|
child_project.name = "#{parent_project.name}-#{pull_request.number}"
|
||||||
|
child_project.namespace = child_project.name
|
||||||
|
child_project.managed_namespace = parent_project.managed_namespace
|
||||||
child_project.cluster_id = parent_project.project_fork_cluster_id
|
child_project.cluster_id = parent_project.project_fork_cluster_id
|
||||||
# Duplicate the project_credential_provider
|
# Duplicate the project_credential_provider
|
||||||
child_project_credential_provider = parent_project.project_credential_provider.dup
|
child_project_credential_provider = parent_project.project_credential_provider.dup
|
||||||
|
|||||||
@@ -1,90 +1,101 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Projects
|
class Projects::Create
|
||||||
class Create
|
class ToNamespaced
|
||||||
extend LightService::Organizer
|
extend LightService::Action
|
||||||
def self.create_params(params)
|
expects :project
|
||||||
params.require(:project).permit(
|
promises :namespaced
|
||||||
:name,
|
executed do |context|
|
||||||
:repository_url,
|
context.namespaced = context.project
|
||||||
:branch,
|
|
||||||
:cluster_id,
|
|
||||||
:container_registry_url,
|
|
||||||
:predeploy_command,
|
|
||||||
:project_fork_status,
|
|
||||||
:project_fork_cluster_id
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.call(
|
|
||||||
params,
|
|
||||||
user
|
|
||||||
)
|
|
||||||
project = Project.new(create_params(params))
|
|
||||||
provider = find_provider(user, params)
|
|
||||||
project_credential_provider = build_project_credential_provider(project, provider)
|
|
||||||
build_configuration = build_build_configuration(project, params)
|
|
||||||
|
|
||||||
steps = create_steps(provider)
|
|
||||||
with(
|
|
||||||
project:,
|
|
||||||
project_credential_provider:,
|
|
||||||
build_configuration:,
|
|
||||||
params:,
|
|
||||||
user:
|
|
||||||
).reduce(*steps)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.build_project_credential_provider(project, provider)
|
|
||||||
ProjectCredentialProvider.new(
|
|
||||||
project:,
|
|
||||||
provider:,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.build_build_configuration(project, params)
|
|
||||||
return unless project.git?
|
|
||||||
build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new
|
|
||||||
default_params = build_default_build_configuration(project)
|
|
||||||
merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank)
|
|
||||||
build_configuration = project.build_build_configuration(merged_params)
|
|
||||||
build_configuration
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.build_default_build_configuration(project)
|
|
||||||
{
|
|
||||||
provider: project.project_credential_provider.provider,
|
|
||||||
driver: BuildConfiguration::DEFAULT_BUILDER,
|
|
||||||
build_type: :dockerfile,
|
|
||||||
image_repository: project.repository_url,
|
|
||||||
context_directory: ".",
|
|
||||||
dockerfile_path: "./Dockerfile"
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.create_steps(provider)
|
|
||||||
steps = []
|
|
||||||
if provider.git?
|
|
||||||
steps << Projects::ValidateGitRepository
|
|
||||||
end
|
|
||||||
|
|
||||||
steps << Projects::ValidateNamespaceAvailability
|
|
||||||
steps << Projects::InitializeBuildPacks
|
|
||||||
steps << Projects::Save
|
|
||||||
|
|
||||||
# Only register webhook in cloud mode
|
|
||||||
if Rails.application.config.cloud_mode && provider.git?
|
|
||||||
steps << Projects::RegisterGitWebhook
|
|
||||||
end
|
|
||||||
|
|
||||||
steps
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.find_provider(user, params)
|
|
||||||
provider_id = params[:project][:project_credential_provider][:provider_id]
|
|
||||||
user.providers.find(provider_id)
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
raise "Provider #{provider_id} not found"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
extend LightService::Organizer
|
||||||
|
def self.create_params(params)
|
||||||
|
params.require(:project).permit(
|
||||||
|
:name,
|
||||||
|
:namespace,
|
||||||
|
:managed_namespace,
|
||||||
|
:repository_url,
|
||||||
|
:branch,
|
||||||
|
:cluster_id,
|
||||||
|
:container_registry_url,
|
||||||
|
:predeploy_command,
|
||||||
|
:project_fork_status,
|
||||||
|
:project_fork_cluster_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.call(
|
||||||
|
params,
|
||||||
|
user
|
||||||
|
)
|
||||||
|
project = Project.new(create_params(params))
|
||||||
|
provider = find_provider(user, params)
|
||||||
|
project_credential_provider = build_project_credential_provider(project, provider)
|
||||||
|
build_configuration = build_build_configuration(project, params)
|
||||||
|
|
||||||
|
steps = create_steps(provider)
|
||||||
|
with(
|
||||||
|
project:,
|
||||||
|
project_credential_provider:,
|
||||||
|
build_configuration:,
|
||||||
|
params:,
|
||||||
|
user:
|
||||||
|
).reduce(*steps)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.build_project_credential_provider(project, provider)
|
||||||
|
ProjectCredentialProvider.new(
|
||||||
|
project:,
|
||||||
|
provider:,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.build_build_configuration(project, params)
|
||||||
|
return unless project.git?
|
||||||
|
build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new
|
||||||
|
default_params = build_default_build_configuration(project)
|
||||||
|
merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank)
|
||||||
|
build_configuration = project.build_build_configuration(merged_params)
|
||||||
|
build_configuration
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.build_default_build_configuration(project)
|
||||||
|
{
|
||||||
|
provider: project.project_credential_provider.provider,
|
||||||
|
driver: BuildConfiguration::DEFAULT_BUILDER,
|
||||||
|
build_type: :dockerfile,
|
||||||
|
image_repository: project.repository_url,
|
||||||
|
context_directory: ".",
|
||||||
|
dockerfile_path: "./Dockerfile"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_steps(provider)
|
||||||
|
steps = []
|
||||||
|
if provider.git?
|
||||||
|
steps << Projects::ValidateGitRepository
|
||||||
|
end
|
||||||
|
|
||||||
|
steps << Projects::Create::ToNamespaced
|
||||||
|
steps << Namespaced::SetUpNamespace
|
||||||
|
steps << Namespaced::ValidateNamespace
|
||||||
|
steps << Projects::InitializeBuildPacks
|
||||||
|
steps << Projects::Save
|
||||||
|
|
||||||
|
# Only register webhook in cloud mode
|
||||||
|
if Rails.application.config.cloud_mode && provider.git?
|
||||||
|
steps << Projects::RegisterGitWebhook
|
||||||
|
end
|
||||||
|
|
||||||
|
steps
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_provider(user, params)
|
||||||
|
provider_id = params[:project][:project_credential_provider][:provider_id]
|
||||||
|
user.providers.find(provider_id)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
raise "Provider #{provider_id} not found"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
module Projects
|
|
||||||
class ValidateNamespaceAvailability
|
|
||||||
extend LightService::Action
|
|
||||||
|
|
||||||
expects :project, :user
|
|
||||||
|
|
||||||
executed do |context|
|
|
||||||
project = context.project
|
|
||||||
cluster = project.cluster
|
|
||||||
|
|
||||||
begin
|
|
||||||
client = K8::Client.new(K8::Connection.new(cluster, context.user))
|
|
||||||
existing_namespaces = client.get_namespaces
|
|
||||||
|
|
||||||
# Check if namespace already exists in Kubernetes
|
|
||||||
namespace_exists = existing_namespaces.any? do |ns|
|
|
||||||
ns.metadata.name == project.name && ns.metadata&.labels&.caninemanaged != "true"
|
|
||||||
end
|
|
||||||
|
|
||||||
if namespace_exists
|
|
||||||
error_message = "'#{project.name}' already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name."
|
|
||||||
project.errors.add(:name, error_message)
|
|
||||||
context.fail_and_return!(error_message)
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
# If we can't connect to check, we'll let it proceed and fail later if needed
|
|
||||||
Rails.logger.warn("Could not check namespace availability: #{e.message}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -6,8 +6,9 @@ class Services::Update
|
|||||||
|
|
||||||
executed do |context|
|
executed do |context|
|
||||||
context.service.update(Service.permitted_params(context.params))
|
context.service.update(Service.permitted_params(context.params))
|
||||||
if context.service.cron_job?
|
if context.service.cron_job? && context.params[:service][:cron_schedule].present?
|
||||||
context.service.cron_schedule.update(context.params[:service][:cron_schedule].permit(:schedule))
|
context.service.cron_schedule.update(
|
||||||
|
context.params[:service][:cron_schedule].permit(:schedule))
|
||||||
end
|
end
|
||||||
context.service.updated!
|
context.service.updated!
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ class AddOnsController < ApplicationController
|
|||||||
|
|
||||||
# POST /add_ons or /add_ons.json
|
# POST /add_ons or /add_ons.json
|
||||||
def create
|
def create
|
||||||
result = AddOns::Create.execute(add_on: AddOn.new(add_on_params))
|
add_on_params = AddOns::Create.parse_params(params)
|
||||||
|
result = AddOns::Create.call(AddOn.new(add_on_params), current_user)
|
||||||
@add_on = result.add_on
|
@add_on = result.add_on
|
||||||
# Uncomment to authorize with Pundit
|
# Uncomment to authorize with Pundit
|
||||||
# authorize @add_on
|
# authorize @add_on
|
||||||
@@ -59,7 +60,7 @@ class AddOnsController < ApplicationController
|
|||||||
|
|
||||||
# PATCH/PUT /add_ons/1 or /add_ons/1.json
|
# PATCH/PUT /add_ons/1 or /add_ons/1.json
|
||||||
def update
|
def update
|
||||||
@add_on.assign_attributes(add_on_params)
|
@add_on.assign_attributes(AddOns::Create.parse_params(params))
|
||||||
result = AddOns::Update.execute(add_on: @add_on)
|
result = AddOns::Update.execute(add_on: @add_on)
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
@@ -125,25 +126,4 @@ class AddOnsController < ApplicationController
|
|||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to add_ons_path
|
redirect_to add_ons_path
|
||||||
end
|
end
|
||||||
|
|
||||||
# Only allow a list of trusted parameters through.
|
|
||||||
def add_on_params
|
|
||||||
if params[:add_on][:values_yaml].present?
|
|
||||||
params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml])
|
|
||||||
end
|
|
||||||
if params[:add_on][:metadata].present?
|
|
||||||
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
|
|
||||||
end
|
|
||||||
params.require(:add_on).permit(
|
|
||||||
:cluster_id,
|
|
||||||
:chart_type,
|
|
||||||
:chart_url,
|
|
||||||
:name,
|
|
||||||
metadata: {},
|
|
||||||
values: {}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Uncomment to use Pundit permitted attributes
|
|
||||||
# params.require(:add_on).permit(policy(@add_on).permitted_attributes)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ class ClustersController < ApplicationController
|
|||||||
%w[services deployments ingress cronjobs].each do |resource|
|
%w[services deployments ingress cronjobs].each do |resource|
|
||||||
yaml_content = K8::Kubectl.new(
|
yaml_content = K8::Kubectl.new(
|
||||||
K8::Connection.new(@cluster, current_user)
|
K8::Connection.new(@cluster, current_user)
|
||||||
).call("get #{resource} -n #{project.name} -o yaml")
|
).call("get #{resource} -n #{project.namespace} -o yaml")
|
||||||
export(@cluster.name, project.name, yaml_content, zio)
|
export(@cluster.name, project.namespace, yaml_content, zio)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ class Projects::ProcessesController < Projects::BaseController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
client = K8::Client.new(active_connection)
|
client = K8::Client.new(active_connection)
|
||||||
@logs = client.get_pod_log(params[:id], @project.name)
|
@logs = client.get_pod_log(params[:id], @project.namespace)
|
||||||
@pod_events = client.get_pod_events(params[:id], @project.name)
|
@pod_events = client.get_pod_events(params[:id], @project.namespace)
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html
|
format.html
|
||||||
@@ -30,7 +30,7 @@ class Projects::ProcessesController < Projects::BaseController
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
client = K8::Client.new(active_connection)
|
client = K8::Client.new(active_connection)
|
||||||
client.delete_pod(params[:id], @project.name)
|
client.delete_pod(params[:id], @project.namespace)
|
||||||
redirect_to project_processes_path(@project), notice: "Pod #{params[:id]} terminating..."
|
redirect_to project_processes_path(@project), notice: "Pod #{params[:id]} terminating..."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
|
|||||||
job_name = "#{@service.name}-manual-#{timestamp}"
|
job_name = "#{@service.name}-manual-#{timestamp}"
|
||||||
kubectl = K8::Kubectl.new(active_connection)
|
kubectl = K8::Kubectl.new(active_connection)
|
||||||
kubectl.call(
|
kubectl.call(
|
||||||
"-n #{@project.name} create job #{job_name} --from=cronjob/#{@service.name}"
|
"-n #{@project.namespace} create job #{job_name} --from=cronjob/#{@service.name}"
|
||||||
)
|
)
|
||||||
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
|
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
|
||||||
end
|
end
|
||||||
@@ -16,7 +16,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
|
|||||||
job_name = params[:id]
|
job_name = params[:id]
|
||||||
kubectl = K8::Kubectl.new(active_connection)
|
kubectl = K8::Kubectl.new(active_connection)
|
||||||
kubectl.call(
|
kubectl.call(
|
||||||
"-n #{@project.name} delete job #{job_name}"
|
"-n #{@project.namespace} delete job #{job_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
|
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["container"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Initial setup: hidden + transparent + transition classes
|
||||||
|
this.containerTarget.classList.add(
|
||||||
|
"hidden", // prevent layout
|
||||||
|
"opacity-0", // starting transparency
|
||||||
|
"transition-all",
|
||||||
|
"duration-500" // control speed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
show(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Hide the button
|
||||||
|
e.target.classList.add("hidden")
|
||||||
|
|
||||||
|
// Step 1: unhide but keep opacity-0
|
||||||
|
this.containerTarget.classList.remove("hidden")
|
||||||
|
|
||||||
|
// Step 2: allow browser to register this initial state
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// Step 3: now fade to opacity-100 → animation occurs
|
||||||
|
this.containerTarget.classList.add("opacity-100")
|
||||||
|
this.containerTarget.classList.remove("opacity-0")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
console.log("NamespaceInputGroupController connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleManagedNamespace() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,14 @@ class Projects::DeploymentJob < ApplicationJob
|
|||||||
project = deployment.project
|
project = deployment.project
|
||||||
connection = K8::Connection.new(project, user, allow_anonymous: true)
|
connection = K8::Connection.new(project, user, allow_anonymous: true)
|
||||||
kubectl = create_kubectl(deployment, connection)
|
kubectl = create_kubectl(deployment, connection)
|
||||||
kubectl.register_after_apply do |yaml_content|
|
kubectl.register_before_apply do |yaml_content|
|
||||||
deployment.add_manifest(yaml_content)
|
deployment.add_manifest(yaml_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create namespace
|
# Create namespace
|
||||||
apply_namespace(project, kubectl)
|
if project.managed_namespace?
|
||||||
|
apply_namespace(project, kubectl)
|
||||||
|
end
|
||||||
|
|
||||||
# Upload container registry secrets
|
# Upload container registry secrets
|
||||||
upload_registry_secrets(kubectl, deployment)
|
upload_registry_secrets(kubectl, deployment)
|
||||||
@@ -108,11 +110,11 @@ class Projects::DeploymentJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def kill_one_off_containers(project, kubectl)
|
def kill_one_off_containers(project, kubectl)
|
||||||
kubectl.call("-n #{project.name} delete pods -l oneoff=true")
|
kubectl.call("-n #{project.namespace} delete pods -l oneoff=true")
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_namespace(project, kubectl)
|
def apply_namespace(project, kubectl)
|
||||||
@logger.info("Creating namespace: #{project.name}", color: :yellow)
|
@logger.info("Creating namespace: #{project.namespace}", color: :yellow)
|
||||||
namespace_yaml = K8::Namespace.new(project).to_yaml
|
namespace_yaml = K8::Namespace.new(project).to_yaml
|
||||||
kubectl.apply_yaml(namespace_yaml)
|
kubectl.apply_yaml(namespace_yaml)
|
||||||
end
|
end
|
||||||
@@ -124,7 +126,7 @@ class Projects::DeploymentJob < ApplicationJob
|
|||||||
kubectl = K8::Kubectl.new(connection)
|
kubectl = K8::Kubectl.new(connection)
|
||||||
|
|
||||||
resources_to_sweep.each do |resource_type|
|
resources_to_sweep.each do |resource_type|
|
||||||
results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.name}"))
|
results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.namespace}"))
|
||||||
results['items'].each do |resource|
|
results['items'].each do |resource|
|
||||||
if @marked_resources.select do |r|
|
if @marked_resources.select do |r|
|
||||||
r.is_a?(K8::Stateless.const_get(resource_type))
|
r.is_a?(K8::Stateless.const_get(resource_type))
|
||||||
@@ -132,7 +134,7 @@ class Projects::DeploymentJob < ApplicationJob
|
|||||||
applied_resource.name == resource['metadata']['name']
|
applied_resource.name == resource['metadata']['name']
|
||||||
end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true'
|
end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true'
|
||||||
@logger.info("Deleting #{resource_type}: #{resource['metadata']['name']}", color: :yellow)
|
@logger.info("Deleting #{resource_type}: #{resource['metadata']['name']}", color: :yellow)
|
||||||
kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.name}")
|
kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.namespace}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -150,7 +152,7 @@ class Projects::DeploymentJob < ApplicationJob
|
|||||||
|
|
||||||
def restart_deployment(service, kubectl)
|
def restart_deployment(service, kubectl)
|
||||||
@logger.info("Restarting deployment: #{service.name}", color: :yellow)
|
@logger.info("Restarting deployment: #{service.name}", color: :yellow)
|
||||||
kubectl.call("-n #{service.project.name} rollout restart deployment/#{service.name}")
|
kubectl.call("-n #{service.project.namespace} rollout restart deployment/#{service.name}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def upload_registry_secrets(kubectl, deployment)
|
def upload_registry_secrets(kubectl, deployment)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
class Projects::DestroyJob < ApplicationJob
|
class Projects::DestroyJob < ApplicationJob
|
||||||
def perform(project, user)
|
def perform(project, user)
|
||||||
project.destroying!
|
project.destroying!
|
||||||
delete_namespace(project, user)
|
if project.managed_namespace
|
||||||
|
delete_namespace(project, user)
|
||||||
|
end
|
||||||
|
|
||||||
# Delete the github webhook for the project IF there are no more projects that refer to that repository
|
# Delete the github webhook for the project IF there are no more projects that refer to that repository
|
||||||
# TODO: This might have overlapping repository urls across different providers.
|
# TODO: This might have overlapping repository urls across different providers.
|
||||||
@@ -15,7 +17,7 @@ class Projects::DestroyJob < ApplicationJob
|
|||||||
|
|
||||||
def delete_namespace(project, user)
|
def delete_namespace(project, user)
|
||||||
client = K8::Client.new(K8::Connection.new(project.cluster, user))
|
client = K8::Client.new(K8::Connection.new(project.cluster, user))
|
||||||
if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.name }).present?
|
if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.namespace }).present?
|
||||||
client.delete_namespace(namespace.metadata.name)
|
client.delete_namespace(namespace.metadata.name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Scheduled::CheckHealthJob < ApplicationJob
|
|||||||
|
|
||||||
def perform
|
def perform
|
||||||
Service.web_service.where('healthcheck_url IS NOT NULL').each do |service|
|
Service.web_service.where('healthcheck_url IS NOT NULL').each do |service|
|
||||||
# url = File.join("http://#{service.name}-service.#{service.project.name}.svc.cluster.local", service.healthcheck_url)
|
# url = File.join("http://#{service.name}-service.#{service.project.namespace}.svc.cluster.local", service.healthcheck_url)
|
||||||
# K8::Client.from_project(service.project).run_command("curl -s -o /dev/null -w '%{http_code}' #{url}")
|
# K8::Client.from_project(service.project).run_command("curl -s -o /dev/null -w '%{http_code}' #{url}")
|
||||||
if service.domains.any?
|
if service.domains.any?
|
||||||
url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url)
|
url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url)
|
||||||
|
|||||||
@@ -2,16 +2,18 @@
|
|||||||
#
|
#
|
||||||
# Table name: add_ons
|
# Table name: add_ons
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# chart_type :string not null
|
# chart_type :string not null
|
||||||
# chart_url :string
|
# chart_url :string
|
||||||
# metadata :jsonb
|
# managed_namespace :boolean default(TRUE)
|
||||||
# name :string not null
|
# metadata :jsonb
|
||||||
# status :integer default("installing"), not null
|
# name :string not null
|
||||||
# values :jsonb
|
# namespace :string not null
|
||||||
# created_at :datetime not null
|
# status :integer default("installing"), not null
|
||||||
# updated_at :datetime not null
|
# values :jsonb
|
||||||
# cluster_id :bigint not null
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# cluster_id :bigint not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
class AddOn < ApplicationRecord
|
class AddOn < ApplicationRecord
|
||||||
include Loggable
|
include Loggable
|
||||||
include TeamAccessible
|
include TeamAccessible
|
||||||
|
include Namespaced
|
||||||
belongs_to :cluster
|
belongs_to :cluster
|
||||||
has_one :account, through: :cluster
|
has_one :account, through: :cluster
|
||||||
|
|
||||||
@@ -40,9 +43,9 @@ class AddOn < ApplicationRecord
|
|||||||
validates :chart_type, presence: true
|
validates :chart_type, presence: true
|
||||||
validate :chart_type_exists
|
validate :chart_type_exists
|
||||||
validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
||||||
validate :name_is_unique_to_cluster, on: :create
|
|
||||||
validates_presence_of :chart_url
|
validates_presence_of :chart_url
|
||||||
validate :has_package_details, if: :helm_chart?
|
validate :has_package_details, if: :helm_chart?
|
||||||
|
validates_uniqueness_of :name, scope: :cluster_id
|
||||||
|
|
||||||
after_update_commit do
|
after_update_commit do
|
||||||
broadcast_replace_later_to [ self, :install_stage ], target: dom_id(self, :install_stage), partial: "add_ons/install_stage", locals: { add_on: self }
|
broadcast_replace_later_to [ self, :install_stage ], target: dom_id(self, :install_stage), partial: "add_ons/install_stage", locals: { add_on: self }
|
||||||
@@ -57,12 +60,6 @@ class AddOn < ApplicationRecord
|
|||||||
metadata['install_stage'] || 0
|
metadata['install_stage'] || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
def name_is_unique_to_cluster
|
|
||||||
if cluster.namespaces.include?(name)
|
|
||||||
errors.add(:name, "must be unique to this cluster")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def has_package_details
|
def has_package_details
|
||||||
if metadata['package_details'].blank?
|
if metadata['package_details'].blank?
|
||||||
errors.add(:metadata, "is missing required keys: package_details")
|
errors.add(:metadata, "is missing required keys: package_details")
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class Cluster < ApplicationRecord
|
|||||||
]
|
]
|
||||||
|
|
||||||
def namespaces
|
def namespaces
|
||||||
RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name)
|
RESERVED_NAMESPACES + projects.pluck(:namespace) + add_ons.pluck(:namespace)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_build_cloud!(attributes = {})
|
def create_build_cloud!(attributes = {})
|
||||||
|
|||||||
15
app/models/concerns/namespaced.rb
Normal file
15
app/models/concerns/namespaced.rb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module Namespaced
|
||||||
|
def name_is_unique_to_cluster
|
||||||
|
if cluster.namespaces.include?(namespace)
|
||||||
|
errors.add(:name, "must be unique to this cluster")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.included(base)
|
||||||
|
base.class_eval do
|
||||||
|
validates_presence_of :namespace
|
||||||
|
|
||||||
|
validate :name_is_unique_to_cluster, on: :create
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
# container_registry_url :string
|
# container_registry_url :string
|
||||||
# docker_build_context_directory :string default("."), not null
|
# docker_build_context_directory :string default("."), not null
|
||||||
# dockerfile_path :string default("./Dockerfile"), not null
|
# dockerfile_path :string default("./Dockerfile"), not null
|
||||||
|
# managed_namespace :boolean default(TRUE)
|
||||||
# name :string not null
|
# name :string not null
|
||||||
|
# namespace :string not null
|
||||||
# postdeploy_command :text
|
# postdeploy_command :text
|
||||||
# postdestroy_command :text
|
# postdestroy_command :text
|
||||||
# predeploy_command :text
|
# predeploy_command :text
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
#
|
#
|
||||||
class Project < ApplicationRecord
|
class Project < ApplicationRecord
|
||||||
include TeamAccessible
|
include TeamAccessible
|
||||||
|
include Namespaced
|
||||||
broadcasts_refreshes
|
broadcasts_refreshes
|
||||||
belongs_to :cluster
|
belongs_to :cluster
|
||||||
has_one :account, through: :cluster
|
has_one :account, through: :cluster
|
||||||
@@ -55,6 +58,8 @@ class Project < ApplicationRecord
|
|||||||
|
|
||||||
validates :name, presence: true,
|
validates :name, presence: true,
|
||||||
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
||||||
|
validates :namespace, presence: true,
|
||||||
|
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
|
||||||
validates :branch, presence: true
|
validates :branch, presence: true
|
||||||
validates :repository_url, presence: true,
|
validates :repository_url, presence: true,
|
||||||
format: {
|
format: {
|
||||||
@@ -65,8 +70,8 @@ class Project < ApplicationRecord
|
|||||||
validates_presence_of :project_fork_cluster_id, unless: :forks_disabled?
|
validates_presence_of :project_fork_cluster_id, unless: :forks_disabled?
|
||||||
validate :project_fork_cluster_id_is_owned_by_account
|
validate :project_fork_cluster_id_is_owned_by_account
|
||||||
validates_presence_of :build_configuration, if: :git?
|
validates_presence_of :build_configuration, if: :git?
|
||||||
|
validates_uniqueness_of :name, scope: :cluster_id
|
||||||
|
|
||||||
validate :name_is_unique_to_cluster, on: :create
|
|
||||||
after_save_commit do
|
after_save_commit do
|
||||||
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
|
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
|
||||||
end
|
end
|
||||||
@@ -93,12 +98,6 @@ class Project < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def name_is_unique_to_cluster
|
|
||||||
if cluster.namespaces.include?(name)
|
|
||||||
errors.add(:name, "must be unique to this cluster")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_deployment
|
def current_deployment
|
||||||
deployments.order(created_at: :desc).where(status: :completed).first
|
deployments.order(created_at: :desc).where(status: :completed).first
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,14 +11,23 @@ class K8::Kubectl
|
|||||||
raise "Kubeconfig is required"
|
raise "Kubeconfig is required"
|
||||||
end
|
end
|
||||||
@runner = runner
|
@runner = runner
|
||||||
|
@before_apply_blocks = []
|
||||||
@after_apply_blocks = []
|
@after_apply_blocks = []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def register_before_apply(&block)
|
||||||
|
@before_apply_blocks << block
|
||||||
|
end
|
||||||
|
|
||||||
def register_after_apply(&block)
|
def register_after_apply(&block)
|
||||||
@after_apply_blocks << block
|
@after_apply_blocks << block
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_yaml(yaml_content)
|
def apply_yaml(yaml_content)
|
||||||
|
@before_apply_blocks.each do |block|
|
||||||
|
block.call(yaml_content)
|
||||||
|
end
|
||||||
|
|
||||||
with_kube_config do |kubeconfig_file|
|
with_kube_config do |kubeconfig_file|
|
||||||
# Create a temporary file for the YAML content
|
# Create a temporary file for the YAML content
|
||||||
Tempfile.open([ "k8s", ".yaml" ]) do |yaml_file|
|
Tempfile.open([ "k8s", ".yaml" ]) do |yaml_file|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class K8::Stateless::CronJob < K8::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def fetch_jobs_for_cronjob
|
def fetch_jobs_for_cronjob
|
||||||
result = kubectl.call("get jobs -n #{project.name} -o json")
|
result = kubectl.call("get jobs -n #{project.namespace} -o json")
|
||||||
all_jobs = JSON.parse(result, object_class: OpenStruct).items
|
all_jobs = JSON.parse(result, object_class: OpenStruct).items
|
||||||
|
|
||||||
# Filter jobs owned by this CronJob
|
# Filter jobs owned by this CronJob
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ class K8::Stateless::Deployment < K8::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def restart
|
def restart
|
||||||
kubectl.call("rollout restart deployment/#{service.name} -n #{project.name}")
|
kubectl.call("rollout restart deployment/#{service.name} -n #{project.namespace}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class K8::Stateless::Ingress < K8::Base
|
|||||||
return nil unless @service.domains.any?
|
return nil unless @service.domains.any?
|
||||||
return nil unless @service.allow_public_networking?
|
return nil unless @service.allow_public_networking?
|
||||||
|
|
||||||
kubectl.call("get certificate #{certificate_name} -n #{@project.name} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True"
|
kubectl.call("get certificate #{certificate_name} -n #{@project.namespace} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True"
|
||||||
end
|
end
|
||||||
|
|
||||||
def certificate_name
|
def certificate_name
|
||||||
@@ -25,7 +25,7 @@ class K8::Stateless::Ingress < K8::Base
|
|||||||
def get_ingress
|
def get_ingress
|
||||||
result = kubectl.call('get ingresses -o yaml')
|
result = kubectl.call('get ingresses -o yaml')
|
||||||
results = YAML.safe_load(result)
|
results = YAML.safe_load(result)
|
||||||
results['items'].find { |r| r['metadata']['name'] == "#{@service.project.name}-ingress" }
|
results['items'].find { |r| r['metadata']['name'] == "#{@service.project.namespace}-ingress" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.ip_address(client)
|
def self.ip_address(client)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class K8::Stateless::Service < K8::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def internal_url
|
def internal_url
|
||||||
"#{name}.#{project.name}.svc.cluster.local:80"
|
"#{name}.#{project.namespace}.svc.cluster.local:80"
|
||||||
end
|
end
|
||||||
|
|
||||||
def name
|
def name
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ class Async::Projects::Processes::IndexViewModel < Async::BaseViewModel
|
|||||||
def get_pods_for_project(project)
|
def get_pods_for_project(project)
|
||||||
# Get all pods for a given namespace
|
# Get all pods for a given namespace
|
||||||
client = K8::Client.new(K8::Connection.new(project.cluster, current_user))
|
client = K8::Client.new(K8::Connection.new(project.cluster, current_user))
|
||||||
client.get_pods(namespace: project.name)
|
client.get_pods(namespace: project.namespace)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,25 +12,29 @@
|
|||||||
<div class="card card-bordered bg-base-100">
|
<div class="card card-bordered bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<%= form_with(model: @add_on) do |form| %>
|
<%= form_with(model: @add_on) do |form| %>
|
||||||
<%= render(FormFieldComponent.new(
|
<div class="space-y-8">
|
||||||
label: "Name",
|
<%= render(FormFieldComponent.new(
|
||||||
description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed."
|
label: "Name",
|
||||||
)) do %>
|
description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed."
|
||||||
<%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %>
|
)) do %>
|
||||||
<label class="label">
|
<%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %>
|
||||||
<span class="label-text-alt">* Required</span>
|
<label class="label">
|
||||||
</label>
|
<span class="label-text-alt">* Required</span>
|
||||||
<% end %>
|
</label>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= render(FormFieldComponent.new(
|
<%= render(FormFieldComponent.new(
|
||||||
label: "Cluster",
|
label: "Cluster",
|
||||||
description: "The cluster to deploy your add on to."
|
description: "The cluster to deploy your add on to."
|
||||||
)) do %>
|
)) do %>
|
||||||
<%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %>
|
<%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">* Required</span>
|
<span class="label-text-alt">* Required</span>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "shared/partials/namespace_input_group", form: %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div data-controller="card-select">
|
<div data-controller="card-select">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -22,23 +22,39 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="text-sm">
|
<div class="text-sm flex flex-row">
|
||||||
<%= link_to project.link_to_view, target: "_blank" do %>
|
<%= link_to project.link_to_view, target: "_blank" do %>
|
||||||
<% if project.git? %>
|
<% if project.git? %>
|
||||||
<% if project.github? %>
|
<div class="flex flex-row items-center gap-1">
|
||||||
<iconify-icon icon="lucide:github"></iconify-icon>
|
<% if project.github? %>
|
||||||
<% elsif project.gitlab? %>
|
<iconify-icon icon="lucide:github"></iconify-icon>
|
||||||
<iconify-icon icon="lucide:gitlab"></iconify-icon>
|
<% elsif project.gitlab? %>
|
||||||
<% end %>
|
<iconify-icon icon="lucide:gitlab"></iconify-icon>
|
||||||
<span class="underline mr-2"><%= project.repository_url %></span>
|
<% end %>
|
||||||
<iconify-icon icon="lucide:git-branch"></iconify-icon>
|
<span class="underline mr-2"><%= project.repository_url %></span>
|
||||||
<span class="underline"><%= project.branch %></span>
|
<iconify-icon icon="lucide:git-branch"></iconify-icon>
|
||||||
|
<span class="underline"><%= project.branch %></span>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<iconify-icon icon="logos:docker-icon"></iconify-icon>
|
<div class="flex flex-row items-center gap-1">
|
||||||
<span class="underline"><%= project.repository_url %></span>
|
<iconify-icon icon="logos:docker-icon"></iconify-icon>
|
||||||
|
<span class="underline"><%= project.repository_url %></span>
|
||||||
|
<div class="flex flex-row items-center gap-1">
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<span class="ml-6"><iconify-icon icon="devicon:kubernetes"></iconify-icon> <%= link_to project.cluster.name, project.cluster, target: "_blank", class: "underline" %></span>
|
|
||||||
|
<%= link_to(
|
||||||
|
project.cluster,
|
||||||
|
target: "_blank",
|
||||||
|
class: "underline",
|
||||||
|
) do %>
|
||||||
|
<div class="ml-6 flex flex-row items-center gap-1">
|
||||||
|
<iconify-icon icon="devicon:kubernetes"></iconify-icon>
|
||||||
|
<div><%= project.cluster.name %></div>
|
||||||
|
<div><iconify-icon icon="lucide:slash"></iconify-icon></div>
|
||||||
|
<div><%= project.namespace %></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col self-stretch mt-4 lg:mt-0">
|
<div class="flex flex-col self-stretch mt-4 lg:mt-0">
|
||||||
|
|||||||
@@ -67,22 +67,34 @@
|
|||||||
<span class="label-text-alt">Select a credential that gives access to a container registry</span>
|
<span class="label-text-alt">Select a credential that gives access to a container registry</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control mt-1 mb-2 w-full max-w-md">
|
|
||||||
<label class="label">
|
<div data-controller="expandable-optional-input">
|
||||||
<span class="label-text">Image repository</span>
|
<div>
|
||||||
</label>
|
<a data-action="expandable-optional-input#show" class="btn btn-ghost">
|
||||||
<%= bc_form.text_field(
|
+ Choose custom image registry
|
||||||
:image_repository,
|
</a>
|
||||||
class: "input input-bordered w-full focus:outline-offset-0",
|
</div>
|
||||||
value: build_configuration.image_repository,
|
|
||||||
placeholder: "namespace/repo",
|
<div data-expandable-optional-input-target="container">
|
||||||
pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+",
|
<div class="form-control mt-1 mb-2 w-full max-w-md">
|
||||||
title: "Must be in the format 'namespace/repo'"
|
<label class="label">
|
||||||
) %>
|
<span class="label-text">Image repository</span>
|
||||||
<label class="label">
|
</label>
|
||||||
<span class="label-text-alt">If this is left blank, a container registry will be automatically created for you.</span>
|
<%= bc_form.text_field(
|
||||||
</label>
|
:image_repository,
|
||||||
|
class: "input input-bordered w-full focus:outline-offset-0",
|
||||||
|
value: build_configuration.image_repository,
|
||||||
|
placeholder: "namespace/repo",
|
||||||
|
pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+",
|
||||||
|
title: "Must be in the format 'namespace/repo'"
|
||||||
|
) %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">If this is left blank, a container registry will be automatically created for you.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control mt-4">
|
<div class="form-control mt-4">
|
||||||
<label class="label mb-2">
|
<label class="label mb-2">
|
||||||
<span class="label-text">Build method</span>
|
<span class="label-text">Build method</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="p-10">
|
<div class="p-10">
|
||||||
<h1 class="text-2xl font-bold">Missing Credentials for Docker Hub</h1>
|
<h1 class="text-2xl font-bold">Missing credentials for container registry</h1>
|
||||||
<p class="mt-2 text-gray-400">Please provide your Docker Hub credentials to continue.</p>
|
<p class="mt-2 text-gray-400">Please provide your container registry credentials to continue.</p>
|
||||||
<%= link_to "Add Credentials", providers_path, class: "mt-6 btn btn-primary", data: { turbo: false } %>
|
<%= link_to "Add Credentials", providers_path, class: "mt-6 btn btn-primary", data: { turbo: false } %>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<%= link_to(
|
<%= link_to(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="p-10">
|
<div class="p-10">
|
||||||
<h1 class="text-2xl font-bold">Missing Credentials for Git</h1>
|
<h1 class="text-2xl font-bold">Missing credentials for Git repository</h1>
|
||||||
<p class="mt-2 text-gray-400">Please provide your Github or Gitlab credentials to continue.</p>
|
<p class="mt-2 text-gray-400">Please provide your Github or Gitlab credentials to continue.</p>
|
||||||
<div class="flex flex-row gap-4">
|
<div class="flex flex-row gap-4">
|
||||||
<div role="tooltip" data-tip="This feature is coming soon!" class="tooltip">
|
<div role="tooltip" data-tip="This feature is coming soon!" class="tooltip">
|
||||||
|
|||||||
@@ -55,6 +55,8 @@
|
|||||||
)) do %>
|
)) do %>
|
||||||
<%= form.text_field :predeploy_command, class: "input input-bordered w-full focus:outline-offset-0" %>
|
<%= form.text_field :predeploy_command, class: "input input-bordered w-full focus:outline-offset-0" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "shared/partials/namespace_input_group", form: %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
|
|||||||
@@ -105,6 +105,8 @@
|
|||||||
<%= render "projects/build_configurations/form", form: form, project: project %>
|
<%= render "projects/build_configurations/form", form: form, project: project %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= render "shared/partials/namespace_input_group", form: %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
class: "select select-bordered w-full",
|
class: "select select-bordered w-full",
|
||||||
|
required: true,
|
||||||
data: {
|
data: {
|
||||||
"new-project-target": "provider",
|
"new-project-target": "provider",
|
||||||
action: "change->new-project#selectProvider",
|
action: "change->new-project#selectProvider",
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) %>
|
) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="label">
|
<div class="label">
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold">General</h2>
|
<h2 class="text-2xl font-bold">General</h2>
|
||||||
<hr class="mt-3 mb-4 border-t border-base-300" />
|
<hr class="mt-3 mb-4 border-t border-base-300" />
|
||||||
|
<% unless @project.managed_namespace %>
|
||||||
|
<%= render "shared/partials/namespace_show", namespaced: @project %>
|
||||||
|
<% end %>
|
||||||
<%= render "edit_form", project: @project %>
|
<%= render "edit_form", project: @project %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
placeholder: "# Example:\ncontainers:\n - name: sidecar\n image: nginx:latest\nvolumes:\n - name: cache\n emptyDir: {}",
|
placeholder: "# Example:\ncontainers:\n - name: sidecar\n image: nginx:latest\nvolumes:\n - name: cache\n emptyDir: {}",
|
||||||
class: "textarea textarea-bordered font-mono text-sm w-full",
|
class: "textarea textarea-bordered font-mono text-sm w-full",
|
||||||
data: { yaml_editor_target: "textarea" },
|
data: { yaml_editor_target: "textarea" },
|
||||||
value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml : "" %>
|
value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml_raw : "" %>
|
||||||
<div data-yaml-editor-target="editor"></div>
|
<div data-yaml-editor-target="editor"></div>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt">Enter valid YAML for pod template spec fields</span>
|
<span class="label-text-alt">Enter valid YAML for pod template spec fields</span>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<% if provider.access_token %>
|
<% if provider.access_token %>
|
||||||
<%= provider.access_token.first(6) %><%= "*" * [10, provider.access_token.length - 12].min %><%= provider.access_token.last(6) %>
|
<%= provider.access_token.first(4) %><%= "*" * [0, [10, provider.access_token.length - 6].min].max %><%= provider.access_token.last(2) %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-500">
|
<span class="text-gray-500">
|
||||||
N/A
|
N/A
|
||||||
|
|||||||
30
app/views/shared/partials/_namespace_input_group.html.erb
Normal file
30
app/views/shared/partials/_namespace_input_group.html.erb
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<div data-controller="expandable-optional-input">
|
||||||
|
<div>
|
||||||
|
<a data-action="expandable-optional-input#show" class="btn btn-ghost">
|
||||||
|
+ Add namespace configuration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div data-expandable-optional-input-target="container" data-controller="namespace-input-group">
|
||||||
|
<%= render(FormFieldComponent.new(
|
||||||
|
label: "Namespace",
|
||||||
|
description: "The namespace your application is deployed to."
|
||||||
|
)) do %>
|
||||||
|
<%= form.text_field :namespace, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true %>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">If this is left blank, a namespace will be created automatically.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mt-3 form-control rounded-lg bg-base-200 p-2 px-4">
|
||||||
|
<label class="label mt-1">
|
||||||
|
<div class="label-text cursor-pointer">
|
||||||
|
Automatically create namespace
|
||||||
|
</div>
|
||||||
|
<%= form.check_box :managed_namespace, class: "checkbox", data: { action: "namespace-input-group#toggleManagedNamespace" } %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">Canine will automatically create a namespace when the project is deployed.</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
24
app/views/shared/partials/_namespace_show.html.erb
Normal file
24
app/views/shared/partials/_namespace_show.html.erb
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<div>
|
||||||
|
<div class="flex flex-row items-end">
|
||||||
|
<div>
|
||||||
|
<div><label>Namespace</label></div>
|
||||||
|
<div class="flex flex-row items-center">
|
||||||
|
<input value="<%= namespaced.namespace %>" class="input input-bordered w-full focus:outline-offset-0" readonly />
|
||||||
|
<div class="mx-2 font-medium text-lg">/</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><label>Name</label></div>
|
||||||
|
<input value="<%= namespaced.name %>" class="input input-bordered w-full focus:outline-offset-0" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt">
|
||||||
|
<% if namespaced.managed_namespace %>
|
||||||
|
This namespace is managed by Canine. If the <%= namespaced.class.to_s.titleize %> is deleted, the namespace will be deleted.
|
||||||
|
<% else %>
|
||||||
|
This namespace is managed by Canine. If the <%= namespaced.class.to_s.titleize %> is deleted, the namespace will not be deleted.
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<%= render "static/landing_page/announcement" %>
|
<%= render "static/landing_page/announcement" %>
|
||||||
<img class="h-[70px]" src="/images/logo-full.webp" alt="Canine">
|
<img class="h-[70px]" src="/images/logo-full.webp" alt="Canine">
|
||||||
<h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-6xl">A modern, open source alternative to Heroku</h1>
|
<h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-6xl">A Developer-friendly PaaS for your Kubernetes</h1>
|
||||||
<p class="mt-6 text-lg leading-8 text-gray-300">Canine is an open source deployment platform that makes it easy to deploy and manage your applications.</p>
|
<p class="mt-6 text-lg leading-8 text-gray-300">Canine is an open source deployment platform that makes it easy to deploy and manage your applications.</p>
|
||||||
<div class="mt-10 flex items-center gap-x-6">
|
<div class="mt-10 flex items-center gap-x-6">
|
||||||
<%= link_to "Get started", new_user_registration_path, class: "rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400" %>
|
<%= link_to "Get started", new_user_registration_path, class: "rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400" %>
|
||||||
|
|||||||
8
config/initializers/core_extensions.rb
Normal file
8
config/initializers/core_extensions.rb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Hash
|
||||||
|
# Converts hash to YAML without the document start marker (---)
|
||||||
|
def to_yaml_raw
|
||||||
|
to_yaml.sub(/\A---\n?/, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class AddExistingNamespaceToProjects < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :projects, :namespace, :string
|
||||||
|
add_column :projects, :managed_namespace, :boolean, default: true
|
||||||
|
|
||||||
|
Project.all.each do |project|
|
||||||
|
project.namespace = project.name
|
||||||
|
project.save!
|
||||||
|
end
|
||||||
|
change_column_null :projects, :namespace, false
|
||||||
|
|
||||||
|
add_column :add_ons, :namespace, :string
|
||||||
|
add_column :add_ons, :managed_namespace, :boolean, default: true
|
||||||
|
AddOn.all.each do |add_on|
|
||||||
|
add_on.namespace = add_on.name
|
||||||
|
add_on.save!
|
||||||
|
end
|
||||||
|
change_column_null :add_ons, :namespace, false
|
||||||
|
end
|
||||||
|
end
|
||||||
47
db/schema.rb
generated
47
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
ActiveRecord::Schema[7.2].define(version: 2025_11_26_014503) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
@@ -71,6 +71,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.jsonb "values", default: {}
|
t.jsonb "values", default: {}
|
||||||
t.string "chart_url"
|
t.string "chart_url"
|
||||||
|
t.string "namespace", null: false
|
||||||
|
t.boolean "managed_namespace", default: true
|
||||||
t.index ["cluster_id", "name"], name: "index_add_ons_on_cluster_id_and_name", unique: true
|
t.index ["cluster_id", "name"], name: "index_add_ons_on_cluster_id_and_name", unique: true
|
||||||
t.index ["cluster_id"], name: "index_add_ons_on_cluster_id"
|
t.index ["cluster_id"], name: "index_add_ons_on_cluster_id"
|
||||||
end
|
end
|
||||||
@@ -333,6 +335,21 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "ldap_configurations", force: :cascade do |t|
|
||||||
|
t.string "host", null: false
|
||||||
|
t.integer "port", default: 389, null: false
|
||||||
|
t.integer "encryption", null: false
|
||||||
|
t.string "base_dn", null: false
|
||||||
|
t.string "bind_dn"
|
||||||
|
t.string "bind_password"
|
||||||
|
t.string "uid_attribute", default: "uid", null: false
|
||||||
|
t.string "email_attribute", default: "mail"
|
||||||
|
t.string "name_attribute", default: "cn"
|
||||||
|
t.string "filter"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "log_outputs", force: :cascade do |t|
|
create_table "log_outputs", force: :cascade do |t|
|
||||||
t.bigint "loggable_id", null: false
|
t.bigint "loggable_id", null: false
|
||||||
t.string "loggable_type", null: false
|
t.string "loggable_type", null: false
|
||||||
@@ -375,6 +392,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
|||||||
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
|
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "oidc_configurations", force: :cascade do |t|
|
||||||
|
t.string "issuer", null: false
|
||||||
|
t.string "client_id", null: false
|
||||||
|
t.string "client_secret", null: false
|
||||||
|
t.string "authorization_endpoint"
|
||||||
|
t.string "token_endpoint"
|
||||||
|
t.string "userinfo_endpoint"
|
||||||
|
t.string "jwks_uri"
|
||||||
|
t.string "scopes", default: "openid email profile"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "project_add_ons", force: :cascade do |t|
|
create_table "project_add_ons", force: :cascade do |t|
|
||||||
t.bigint "project_id", null: false
|
t.bigint "project_id", null: false
|
||||||
t.bigint "add_on_id", null: false
|
t.bigint "add_on_id", null: false
|
||||||
@@ -427,6 +457,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
|||||||
t.text "postdestroy_command"
|
t.text "postdestroy_command"
|
||||||
t.bigint "project_fork_cluster_id"
|
t.bigint "project_fork_cluster_id"
|
||||||
t.integer "project_fork_status", default: 0
|
t.integer "project_fork_status", default: 0
|
||||||
|
t.string "namespace", null: false
|
||||||
|
t.boolean "managed_namespace", default: true
|
||||||
t.index ["cluster_id"], name: "index_projects_on_cluster_id"
|
t.index ["cluster_id"], name: "index_projects_on_cluster_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -477,6 +509,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
|||||||
t.index ["project_id"], name: "index_services_on_project_id"
|
t.index ["project_id"], name: "index_services_on_project_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "sso_providers", force: :cascade do |t|
|
||||||
|
t.bigint "account_id", null: false
|
||||||
|
t.string "configuration_type", null: false
|
||||||
|
t.bigint "configuration_id", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.boolean "enabled", default: true, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_sso_providers_on_account_id", unique: true
|
||||||
|
t.index ["configuration_type", "configuration_id"], name: "index_sso_providers_on_configuration"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stack_managers", force: :cascade do |t|
|
create_table "stack_managers", force: :cascade do |t|
|
||||||
t.string "provider_url", null: false
|
t.string "provider_url", null: false
|
||||||
t.integer "stack_manager_type", default: 0, null: false
|
t.integer "stack_manager_type", default: 0, null: false
|
||||||
@@ -576,6 +620,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
|
|||||||
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
|
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
|
||||||
add_foreign_key "providers", "users"
|
add_foreign_key "providers", "users"
|
||||||
add_foreign_key "services", "projects"
|
add_foreign_key "services", "projects"
|
||||||
|
add_foreign_key "sso_providers", "accounts"
|
||||||
add_foreign_key "stack_managers", "accounts"
|
add_foreign_key "stack_managers", "accounts"
|
||||||
add_foreign_key "team_memberships", "teams"
|
add_foreign_key "team_memberships", "teams"
|
||||||
add_foreign_key "team_memberships", "users"
|
add_foreign_key "team_memberships", "users"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class Portainer::Stack
|
|||||||
def logs_url(service, pod_name)
|
def logs_url(service, pod_name)
|
||||||
service = service.name
|
service = service.name
|
||||||
container = service.project.name
|
container = service.project.name
|
||||||
namespace = service.project.name
|
namespace = service.project.namespace
|
||||||
cluster = service.project.cluster
|
cluster = service.project.cluster
|
||||||
|
|
||||||
"/#{cluster.external_id}/kubernetes/applications/#{namespace}/#{service}/#{pod_name}/#{container}/logs"
|
"/#{cluster.external_id}/kubernetes/applications/#{namespace}/#{service}/#{pod_name}/#{container}/logs"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= nameable.name %>
|
name: <%= nameable.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ type: kubernetes.io/dockerconfigjson
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
metadata:
|
metadata:
|
||||||
name: dockerconfigjson-github-com
|
name: dockerconfigjson-github-com
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
data:
|
data:
|
||||||
.dockerconfigjson: <%= docker_config_json %>
|
.dockerconfigjson: <%= docker_config_json %>
|
||||||
@@ -2,7 +2,7 @@ apiVersion: batch/v1
|
|||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= name %>
|
name: <%= name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
app: <%= project.name %>
|
app: <%= project.name %>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
namespace: <%= @project.name %>
|
namespace: <%= @project.namespace %>
|
||||||
name: <%= @project.name %>
|
name: <%= @project.name %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: batch/v1
|
|||||||
kind: CronJob
|
kind: CronJob
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= service.name %>
|
name: <%= service.name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
app: <%= service.name %>
|
app: <%= service.name %>
|
||||||
@@ -15,6 +15,9 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
template:
|
template:
|
||||||
spec:
|
spec:
|
||||||
|
<% if service.pod_yaml.present? %>
|
||||||
|
<%= service.pod_yaml.to_yaml_raw.to_s.lines.map { |line| " #{line}" }.join %>
|
||||||
|
<% end %>
|
||||||
containers:
|
containers:
|
||||||
- name: <%= project.name %>
|
- name: <%= project.name %>
|
||||||
image: <%= project.container_image_reference %>
|
image: <%= project.container_image_reference %>
|
||||||
@@ -74,6 +77,3 @@ spec:
|
|||||||
# Add your volume specifications here (e.g., persistentVolumeClaim, configMap, etc.)
|
# Add your volume specifications here (e.g., persistentVolumeClaim, configMap, etc.)
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if service.pod_yaml.present? %>
|
|
||||||
<%= service.pod_yaml %>
|
|
||||||
<% end %>
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= service.name %>
|
name: <%= service.name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
app: <%= service.name %>
|
app: <%= service.name %>
|
||||||
@@ -21,6 +21,9 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: <%= service.name %>
|
app: <%= service.name %>
|
||||||
spec:
|
spec:
|
||||||
|
<% if service.pod_yaml.present? %>
|
||||||
|
<%= service.pod_yaml.to_yaml_raw.to_s.lines.map { |line| " #{line}" }.join %>
|
||||||
|
<% end %>
|
||||||
containers:
|
containers:
|
||||||
- name: <%= project.name %>
|
- name: <%= project.name %>
|
||||||
image: <%= project.container_image_reference %>
|
image: <%= project.container_image_reference %>
|
||||||
@@ -94,6 +97,3 @@ spec:
|
|||||||
claimName: <%= volume.name %>
|
claimName: <%= volume.name %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if service.pod_yaml.present? %>
|
|
||||||
<%= service.pod_yaml %>
|
|
||||||
<% end %>
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
|
|||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= name %>
|
name: <%= name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
annotations:
|
annotations:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: Pod
|
kind: Pod
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= name %>
|
name: <%= name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
oneoff: 'true'
|
oneoff: 'true'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: PersistentVolume
|
kind: PersistentVolume
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= name %>
|
name: <%= name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
spec:
|
spec:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= name %>
|
name: <%= name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
spec:
|
spec:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
namespace: <%= @project.name %>
|
namespace: <%= @project.namespace %>
|
||||||
name: <%= @project.name %>
|
name: <%= @project.name %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: <%= name %>
|
name: <%= name %>
|
||||||
namespace: <%= project.name %>
|
namespace: <%= project.namespace %>
|
||||||
labels:
|
labels:
|
||||||
caninemanaged: 'true'
|
caninemanaged: 'true'
|
||||||
app: <%= service.name %>
|
app: <%= service.name %>
|
||||||
|
|||||||
22
spec/actions/add_ons/apply_template_to_values_spec.rb
Normal file
22
spec/actions/add_ons/apply_template_to_values_spec.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AddOns::ApplyTemplateToValues do
|
||||||
|
let(:add_on) { build(:add_on) }
|
||||||
|
let(:template) do
|
||||||
|
{
|
||||||
|
'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' },
|
||||||
|
'replica.replicaCount' => '5'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
add_on.metadata['template'] = template
|
||||||
|
add_on.chart_type = "redis"
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'applies template values correctly' do
|
||||||
|
described_class.execute(add_on:)
|
||||||
|
expect(add_on.values['master']['persistence']['size']).to eq('10Gi')
|
||||||
|
expect(add_on.values['replica']['replicaCount']).to eq(5)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe AddOns::Create do
|
RSpec.describe AddOns::Create do
|
||||||
|
let(:cluster) { create(:cluster) }
|
||||||
let(:add_on) { build(:add_on) }
|
let(:add_on) { build(:add_on) }
|
||||||
let(:chart_details) { { 'name' => 'test-chart', 'version' => '1.0.0' } }
|
let(:chart_details) { { 'name' => 'redis', 'version' => '1.0.0' } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
allow(Namespaced::ValidateNamespace).to receive(:execute)
|
||||||
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
|
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
|
||||||
double(success?: true, failure?: false, response: chart_details)
|
double(success?: true, failure?: false, response: chart_details)
|
||||||
)
|
)
|
||||||
@@ -12,53 +14,42 @@ RSpec.describe AddOns::Create do
|
|||||||
|
|
||||||
describe 'errors' do
|
describe 'errors' do
|
||||||
context 'there is a project with the same name in the same cluster' do
|
context 'there is a project with the same name in the same cluster' do
|
||||||
let!(:project) { create(:project, name: add_on.name, cluster: add_on.cluster) }
|
let!(:project) { create(:project, name: add_on.name, cluster: add_on.cluster, namespace: 'taken') }
|
||||||
|
let(:add_on) { build(:add_on, namespace: 'taken') }
|
||||||
|
|
||||||
it 'raises an error' do
|
it 'raises an error' do
|
||||||
result = described_class.execute(add_on:)
|
result = described_class.call(add_on, cluster.account.owner)
|
||||||
expect(result.failure?).to be_truthy
|
expect(result.failure?).to be_truthy
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#execute' do
|
let(:params) do
|
||||||
it 'applies template and fetches package details' do
|
ActionController::Parameters.new({
|
||||||
expect(add_on).to receive(:save)
|
add_on: {
|
||||||
result = described_class.execute(add_on:)
|
name: 'redis-main',
|
||||||
expect(result.add_on.metadata['package_details']).to eq(chart_details)
|
chart_type: 'redis',
|
||||||
end
|
chart_url: 'bitnami/redis',
|
||||||
|
cluster_id: cluster.id,
|
||||||
context 'when package details fetch fails' do
|
metadata: {
|
||||||
before do
|
redis: {
|
||||||
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
|
template: {
|
||||||
double(success?: false, failure?: true)
|
'replica.replicaCount' => 3,
|
||||||
)
|
'master.persistence.size' => {
|
||||||
end
|
'type' => 'size',
|
||||||
|
'value' => '2',
|
||||||
it 'adds error and returns' do
|
'unit' => 'Gi'
|
||||||
result = described_class.execute(add_on:)
|
}
|
||||||
expect(result.failure?).to be_truthy
|
}
|
||||||
expect(result.add_on.errors[:base]).to include('Failed to fetch package details')
|
}
|
||||||
end
|
}
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.apply_template_to_values' do
|
|
||||||
let(:template) do
|
|
||||||
{
|
|
||||||
'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' },
|
|
||||||
'replica.replicaCount' => '5'
|
|
||||||
}
|
}
|
||||||
end
|
})
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
it 'can create an add on successfully' do
|
||||||
add_on.metadata['template'] = template
|
add_on = AddOn.new(AddOns::Create.parse_params(params))
|
||||||
add_on.chart_type = "redis"
|
result = described_class.call(add_on, cluster.account.owner)
|
||||||
end
|
expect(result.success?).to be_truthy
|
||||||
|
|
||||||
it 'applies template values correctly' do
|
|
||||||
described_class.apply_template_to_values(add_on)
|
|
||||||
expect(add_on.values['master']['persistence']['size']).to eq('10Gi')
|
|
||||||
expect(add_on.values['replica']['replicaCount']).to eq(5)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
31
spec/actions/add_ons/set_package_details_spec.rb
Normal file
31
spec/actions/add_ons/set_package_details_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AddOns::SetPackageDetails do
|
||||||
|
let(:add_on) { build(:add_on) }
|
||||||
|
let(:chart_details) { { 'name' => 'test-chart', 'version' => '1.0.0' } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
|
||||||
|
double(success?: true, failure?: false, response: chart_details)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fetches package details and saves to add on' do
|
||||||
|
result = described_class.execute(add_on:)
|
||||||
|
expect(result.add_on.metadata['package_details']).to eq(chart_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when package details fetch fails' do
|
||||||
|
before do
|
||||||
|
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
|
||||||
|
double(success?: false, failure?: true)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds error and returns' do
|
||||||
|
result = described_class.execute(add_on:)
|
||||||
|
expect(result.failure?).to be_truthy
|
||||||
|
expect(result.add_on.errors[:base]).to include('Failed to fetch package details')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,6 +18,23 @@ RSpec.describe AddOns::UninstallHelmChart do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#execute' do
|
describe '#execute' do
|
||||||
|
context 'with an unmanaged namespace' do
|
||||||
|
let(:add_on) { create(:add_on, managed_namespace: false) }
|
||||||
|
it 'does not delete the namespace' do
|
||||||
|
expect(client).not_to receive(:delete_namespace)
|
||||||
|
described_class.execute(connection:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a managed namespace' do
|
||||||
|
it 'deletes the namespace' do
|
||||||
|
allow(client).to receive(:get_namespaces).and_return([ OpenStruct.new(metadata: OpenStruct.new(name: add_on.namespace)) ])
|
||||||
|
expect(client).to receive(:delete_namespace)
|
||||||
|
|
||||||
|
described_class.execute(connection:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'uninstalls the helm chart successfully' do
|
it 'uninstalls the helm chart successfully' do
|
||||||
expect(add_on).to receive(:uninstalled!)
|
expect(add_on).to receive(:uninstalled!)
|
||||||
expect(add_on).to receive(:destroy!)
|
expect(add_on).to receive(:destroy!)
|
||||||
|
|||||||
26
spec/actions/namespaced/set_up_namespace_spec.rb
Normal file
26
spec/actions/namespaced/set_up_namespace_spec.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Namespaced::SetUpNamespace do
|
||||||
|
let(:subject) { described_class.execute(namespaced: project) }
|
||||||
|
|
||||||
|
context "canine managed namespace" do
|
||||||
|
let(:project) { build(:project, managed_namespace: true, namespace: "") }
|
||||||
|
|
||||||
|
it "autosets the name" do
|
||||||
|
result = subject
|
||||||
|
|
||||||
|
expect(result.namespaced.namespace).to eq(project.name)
|
||||||
|
expect(result.namespaced.errors).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "self managed" do
|
||||||
|
let(:project) { build(:project, managed_namespace: false, namespace: "") }
|
||||||
|
|
||||||
|
it "raises error" do
|
||||||
|
result = subject
|
||||||
|
expect(result.namespaced.errors).to be_present
|
||||||
|
expect(result.failure?).to be_truthy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
93
spec/actions/namespaced/validate_namespace_spec.rb
Normal file
93
spec/actions/namespaced/validate_namespace_spec.rb
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Namespaced::ValidateNamespace do
|
||||||
|
let(:cluster) { create(:cluster) }
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:k8_client) { instance_double(K8::Client) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(K8::Client).to receive(:new).and_return(k8_client)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.execute' do
|
||||||
|
context 'with unmanaged namespace' do
|
||||||
|
let(:project) do
|
||||||
|
build(:project, namespace: 'my-custom-namespace', managed_namespace: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'that doesnt exist' do
|
||||||
|
before do
|
||||||
|
allow(k8_client).to receive(
|
||||||
|
:get_namespaces
|
||||||
|
).and_return([ OpenStruct.new(metadata: OpenStruct.new(name: "test-app")) ])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails' do
|
||||||
|
expect(described_class.execute(namespaced: project, user:)).to be_failure
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'that does exist' do
|
||||||
|
before do
|
||||||
|
allow(k8_client).to receive(
|
||||||
|
:get_namespaces,
|
||||||
|
).and_return([ OpenStruct.new(metadata: OpenStruct.new(name: "my-custom-namespace")) ])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'succeeds' do
|
||||||
|
expect(described_class.execute(namespaced: project, user:)).to be_success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with managed namespace' do
|
||||||
|
let(:project) do
|
||||||
|
build(
|
||||||
|
:project,
|
||||||
|
name: 'test-app',
|
||||||
|
cluster: cluster,
|
||||||
|
managed_namespace: true,
|
||||||
|
namespace: 'test-app'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
context 'when namespace does not exist' do
|
||||||
|
before do
|
||||||
|
allow(k8_client).to receive(:get_namespaces).and_return([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'succeeds' do
|
||||||
|
expect(described_class.execute(namespaced: project, user: user)).to be_success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when namespace already exists' do
|
||||||
|
before do
|
||||||
|
allow(k8_client).to receive(:get_namespaces).and_return([ existing_namespace ])
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when namespace is not managed by Canine' do
|
||||||
|
let(:existing_namespace) do
|
||||||
|
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails with error message' do
|
||||||
|
result = described_class.execute(namespaced: project, user: user)
|
||||||
|
expect(result).to be_failure
|
||||||
|
expect(result.message).to include("already exists")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when namespace is managed by Canine' do
|
||||||
|
let(:existing_namespace) do
|
||||||
|
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app', labels: OpenStruct.new(caninemanaged: "true")))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'succeeds' do
|
||||||
|
result = described_class.execute(namespaced: project, user: user)
|
||||||
|
expect(result).to be_success
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -24,7 +24,7 @@ RSpec.describe Projects::Create do
|
|||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Projects::ValidateGitRepository).to receive(:execute)
|
allow(Projects::ValidateGitRepository).to receive(:execute)
|
||||||
allow(Projects::ValidateNamespaceAvailability).to receive(:execute)
|
allow(Namespaced::ValidateNamespace).to receive(:execute)
|
||||||
allow(Projects::RegisterGitWebhook).to receive(:execute)
|
allow(Projects::RegisterGitWebhook).to receive(:execute)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -156,7 +156,9 @@ RSpec.describe Projects::Create do
|
|||||||
it 'validates with github and registers webhooks' do
|
it 'validates with github and registers webhooks' do
|
||||||
expect(subject).to eq([
|
expect(subject).to eq([
|
||||||
Projects::ValidateGitRepository,
|
Projects::ValidateGitRepository,
|
||||||
Projects::ValidateNamespaceAvailability,
|
Projects::Create::ToNamespaced,
|
||||||
|
Namespaced::SetUpNamespace,
|
||||||
|
Namespaced::ValidateNamespace,
|
||||||
Projects::InitializeBuildPacks,
|
Projects::InitializeBuildPacks,
|
||||||
Projects::Save,
|
Projects::Save,
|
||||||
Projects::RegisterGitWebhook
|
Projects::RegisterGitWebhook
|
||||||
@@ -172,7 +174,9 @@ RSpec.describe Projects::Create do
|
|||||||
it 'validates with github and does not register webhooks' do
|
it 'validates with github and does not register webhooks' do
|
||||||
expect(subject).to eq([
|
expect(subject).to eq([
|
||||||
Projects::ValidateGitRepository,
|
Projects::ValidateGitRepository,
|
||||||
Projects::ValidateNamespaceAvailability,
|
Projects::Create::ToNamespaced,
|
||||||
|
Namespaced::SetUpNamespace,
|
||||||
|
Namespaced::ValidateNamespace,
|
||||||
Projects::InitializeBuildPacks,
|
Projects::InitializeBuildPacks,
|
||||||
Projects::Save
|
Projects::Save
|
||||||
])
|
])
|
||||||
|
|||||||
26
spec/actions/projects/set_up_namespace_spec.rb
Normal file
26
spec/actions/projects/set_up_namespace_spec.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Namespaced::SetUpNamespace do
|
||||||
|
let(:subject) { described_class.execute(namespaced: project) }
|
||||||
|
|
||||||
|
context "canine managed namespace" do
|
||||||
|
let(:project) { build(:project, managed_namespace: true, namespace: "") }
|
||||||
|
|
||||||
|
it "autosets the name" do
|
||||||
|
result = subject
|
||||||
|
|
||||||
|
expect(result.namespaced.namespace).to eq(project.name)
|
||||||
|
expect(result.namespaced.errors).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "self managed" do
|
||||||
|
let(:project) { build(:project, managed_namespace: false, namespace: "") }
|
||||||
|
|
||||||
|
it "raises error" do
|
||||||
|
result = subject
|
||||||
|
expect(result.namespaced.errors).to be_present
|
||||||
|
expect(result.failure?).to be_truthy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Projects::ValidateNamespaceAvailability do
|
|
||||||
let(:cluster) { create(:cluster) }
|
|
||||||
let(:project) { build(:project, name: 'test-app', cluster: cluster) }
|
|
||||||
let(:user) { create(:user) }
|
|
||||||
let(:k8_client) { instance_double(K8::Client) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(K8::Client).to receive(:new).and_return(k8_client)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.execute' do
|
|
||||||
context 'when namespace does not exist' do
|
|
||||||
before do
|
|
||||||
allow(k8_client).to receive(:get_namespaces).and_return([])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'succeeds' do
|
|
||||||
expect(described_class.execute(project: project, user: user)).to be_success
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when namespace already exists' do
|
|
||||||
before do
|
|
||||||
allow(k8_client).to receive(:get_namespaces).and_return([ existing_namespace ])
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when namespace is not managed by Canine' do
|
|
||||||
let(:existing_namespace) do
|
|
||||||
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app'))
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'fails with error message' do
|
|
||||||
result = described_class.execute(project: project, user: user)
|
|
||||||
expect(result).to be_failure
|
|
||||||
expect(result.message).to include("already exists")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when namespace is managed by Canine' do
|
|
||||||
let(:existing_namespace) do
|
|
||||||
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app', labels: OpenStruct.new(caninemanaged: "true")))
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'succeeds' do
|
|
||||||
result = described_class.execute(project: project, user: user)
|
|
||||||
expect(result).to be_success
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -2,16 +2,18 @@
|
|||||||
#
|
#
|
||||||
# Table name: add_ons
|
# Table name: add_ons
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# chart_type :string not null
|
# chart_type :string not null
|
||||||
# chart_url :string
|
# chart_url :string
|
||||||
# metadata :jsonb
|
# managed_namespace :boolean default(TRUE)
|
||||||
# name :string not null
|
# metadata :jsonb
|
||||||
# status :integer default("installing"), not null
|
# name :string not null
|
||||||
# values :jsonb
|
# namespace :string not null
|
||||||
# created_at :datetime not null
|
# status :integer default("installing"), not null
|
||||||
# updated_at :datetime not null
|
# values :jsonb
|
||||||
# cluster_id :bigint not null
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# cluster_id :bigint not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
@@ -28,6 +30,8 @@ FactoryBot.define do
|
|||||||
chart_url { 'bitnami/redis' }
|
chart_url { 'bitnami/redis' }
|
||||||
chart_type { "helm_chart" }
|
chart_type { "helm_chart" }
|
||||||
sequence(:name) { |n| "example-addon-#{n}" }
|
sequence(:name) { |n| "example-addon-#{n}" }
|
||||||
|
sequence(:namespace) { |n| "example-addon-#{n}" }
|
||||||
|
managed_namespace { true }
|
||||||
status { :installing }
|
status { :installing }
|
||||||
values { {} }
|
values { {} }
|
||||||
metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } }
|
metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } }
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
# container_registry_url :string
|
# container_registry_url :string
|
||||||
# docker_build_context_directory :string default("."), not null
|
# docker_build_context_directory :string default("."), not null
|
||||||
# dockerfile_path :string default("./Dockerfile"), not null
|
# dockerfile_path :string default("./Dockerfile"), not null
|
||||||
|
# managed_namespace :boolean default(TRUE)
|
||||||
# name :string not null
|
# name :string not null
|
||||||
|
# namespace :string not null
|
||||||
# postdeploy_command :text
|
# postdeploy_command :text
|
||||||
# postdestroy_command :text
|
# postdestroy_command :text
|
||||||
# predeploy_command :text
|
# predeploy_command :text
|
||||||
@@ -36,6 +38,8 @@ FactoryBot.define do
|
|||||||
cluster
|
cluster
|
||||||
account
|
account
|
||||||
sequence(:name) { |n| "example-project-#{n}" }
|
sequence(:name) { |n| "example-project-#{n}" }
|
||||||
|
sequence(:namespace) { |n| "example-project-#{n}" }
|
||||||
|
managed_namespace { true }
|
||||||
repository_url { "owner/repository" }
|
repository_url { "owner/repository" }
|
||||||
status { :creating }
|
status { :creating }
|
||||||
autodeploy { true }
|
autodeploy { true }
|
||||||
|
|||||||
20
spec/initializers/core_extensions_spec.rb
Normal file
20
spec/initializers/core_extensions_spec.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Hash#to_yaml_raw" do
|
||||||
|
it "returns YAML without the document start marker" do
|
||||||
|
hash = { "nodeSelector" => { "gpu" => "true" } }
|
||||||
|
|
||||||
|
result = hash.to_yaml_raw
|
||||||
|
|
||||||
|
expect(result).to eq("nodeSelector:\n gpu: 'true'\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "produces valid YAML that can be parsed back" do
|
||||||
|
hash = { name: "test", items: [ 1, 2, 3 ] }
|
||||||
|
|
||||||
|
result = hash.to_yaml_raw
|
||||||
|
parsed = YAML.safe_load(result, permitted_classes: [ Symbol ], permitted_symbols: [ :name, :items ])
|
||||||
|
|
||||||
|
expect(parsed).to eq(hash)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -29,8 +29,8 @@ RSpec.describe Cluster, type: :model do
|
|||||||
let!(:add_on) { create(:add_on, cluster: cluster) }
|
let!(:add_on) { create(:add_on, cluster: cluster) }
|
||||||
|
|
||||||
it 'returns the reserved namespaces and project/add_on names' do
|
it 'returns the reserved namespaces and project/add_on names' do
|
||||||
expect(cluster.namespaces).to include(project.name)
|
expect(cluster.namespaces).to include(project.namespace)
|
||||||
expect(cluster.namespaces).to include(add_on.name)
|
expect(cluster.namespaces).to include(add_on.namespace)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
# container_registry_url :string
|
# container_registry_url :string
|
||||||
# docker_build_context_directory :string default("."), not null
|
# docker_build_context_directory :string default("."), not null
|
||||||
# dockerfile_path :string default("./Dockerfile"), not null
|
# dockerfile_path :string default("./Dockerfile"), not null
|
||||||
|
# managed_namespace :boolean default(TRUE)
|
||||||
# name :string not null
|
# name :string not null
|
||||||
|
# namespace :string not null
|
||||||
# postdeploy_command :text
|
# postdeploy_command :text
|
||||||
# postdestroy_command :text
|
# postdestroy_command :text
|
||||||
# predeploy_command :text
|
# predeploy_command :text
|
||||||
@@ -35,12 +37,12 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe Project, type: :model do
|
RSpec.describe Project, type: :model do
|
||||||
let(:cluster) { create(:cluster) }
|
let(:cluster) { create(:cluster) }
|
||||||
let(:project) { build(:project, cluster: cluster, account: cluster.account) }
|
let(:project) { build(:project, cluster:, account: cluster.account, namespace: "taken") }
|
||||||
|
|
||||||
describe 'validations' do
|
describe 'validations' do
|
||||||
context 'when name is not unique to the cluster' do
|
context 'when name is not unique to the cluster' do
|
||||||
before do
|
before do
|
||||||
create(:project, name: project.name, cluster: cluster)
|
create(:project, name: project.name, cluster:, namespace: "taken")
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'is not valid' do
|
it 'is not valid' do
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ RSpec.describe K8::Stateless::CronJob do
|
|||||||
before do
|
before do
|
||||||
kubectl = instance_double(K8::Kubectl)
|
kubectl = instance_double(K8::Kubectl)
|
||||||
allow(K8::Kubectl).to receive(:new).and_return(kubectl)
|
allow(K8::Kubectl).to receive(:new).and_return(kubectl)
|
||||||
allow(kubectl).to receive(:call).with("get jobs -n #{project.name} -o json").and_return(job_response)
|
allow(kubectl).to receive(:call).with("get jobs -n #{project.namespace} -o json").and_return(job_response)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns job runs for the service' do
|
it 'returns job runs for the service' do
|
||||||
|
|||||||
Reference in New Issue
Block a user