From 65200e1c32e52f5ecc76490f60418c38371c0d28 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Nov 2025 21:16:06 -0800 Subject: [PATCH 1/9] refactor namespace to be tracked explicitly --- app/actions/project_forks/fork_project.rb | 2 + app/actions/projects/create.rb | 5 +- app/actions/projects/set_up_namespace.rb | 15 +++ app/actions/projects/validate_namespace.rb | 58 ++++++++++++ .../validate_namespace_availability.rb | 31 ------- app/controllers/clusters_controller.rb | 4 +- .../projects/processes_controller.rb | 6 +- .../projects/services/jobs_controller.rb | 4 +- .../expandable_optional_input_controller.js | 17 ++++ app/jobs/projects/deployment_job.rb | 10 +- app/jobs/projects/destroy_job.rb | 8 +- app/jobs/scheduled/check_health_job.rb | 2 +- app/models/add_on.rb | 23 +++-- app/models/cluster.rb | 2 +- app/models/concerns/namespaced.rb | 7 ++ app/models/project.rb | 5 + app/services/k8/stateless/cron_job.rb | 2 +- app/services/k8/stateless/deployment.rb | 2 +- app/services/k8/stateless/ingress.rb | 4 +- app/services/k8/stateless/service.rb | 2 +- .../projects/processes/index_view_model.rb | 2 +- app/views/add_ons/new.html.erb | 40 ++++---- ...ng_container_registry_credentials.html.erb | 4 +- .../create/_missing_git_credentials.html.erb | 2 +- .../_new_form_container_registry.html.erb | 2 + .../projects/create/_new_form_git.html.erb | 12 +++ app/views/providers/_index.html.erb | 2 +- .../partials/_namespace_input_group.html.erb | 30 ++++++ .../shared/partials/_namespace_show.html.erb | 11 +++ ...4503_add_existing_namespace_to_projects.rb | 20 ++++ db/schema.rb | 83 ++++++++++++++++- lib/portainer/stack.rb | 2 +- resources/k8/secrets/registry_secret.yaml | 2 +- resources/k8/stateless/command.yaml | 2 +- resources/k8/stateless/config_map.yaml | 2 +- resources/k8/stateless/cron_job.yaml | 2 +- resources/k8/stateless/deployment.yaml | 2 +- resources/k8/stateless/ingress.yaml | 2 +- resources/k8/stateless/pod.yaml | 2 +- resources/k8/stateless/pv.yaml | 2 +- resources/k8/stateless/pvc.yaml | 2 +- resources/k8/stateless/secrets.yaml | 2 +- resources/k8/stateless/service.yaml | 2 +- spec/actions/projects/create_spec.rb | 6 +- .../actions/projects/set_up_namespace_spec.rb | 26 ++++++ .../validate_namespace_availability_spec.rb | 53 ----------- .../projects/validate_namespace_spec.rb | 93 +++++++++++++++++++ spec/factories/add_ons.rb | 22 +++-- spec/factories/projects.rb | 4 + spec/models/project_spec.rb | 2 + spec/models/service_spec.rb | 8 ++ spec/services/k8/stateless/cron_job_spec.rb | 2 +- 52 files changed, 490 insertions(+), 167 deletions(-) create mode 100644 app/actions/projects/set_up_namespace.rb create mode 100644 app/actions/projects/validate_namespace.rb delete mode 100644 app/actions/projects/validate_namespace_availability.rb create mode 100644 app/javascript/controllers/expandable_optional_input_controller.js create mode 100644 app/models/concerns/namespaced.rb create mode 100644 app/views/shared/partials/_namespace_input_group.html.erb create mode 100644 app/views/shared/partials/_namespace_show.html.erb create mode 100644 db/migrate/20251126014503_add_existing_namespace_to_projects.rb create mode 100644 spec/actions/projects/set_up_namespace_spec.rb delete mode 100644 spec/actions/projects/validate_namespace_availability_spec.rb create mode 100644 spec/actions/projects/validate_namespace_spec.rb diff --git a/app/actions/project_forks/fork_project.rb b/app/actions/project_forks/fork_project.rb index 1aba5db7..fe83ed08 100644 --- a/app/actions/project_forks/fork_project.rb +++ b/app/actions/project_forks/fork_project.rb @@ -10,6 +10,8 @@ class ProjectForks::ForkProject child_project = parent_project.dup child_project.branch = pull_request.branch 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 # Duplicate the project_credential_provider child_project_credential_provider = parent_project.project_credential_provider.dup diff --git a/app/actions/projects/create.rb b/app/actions/projects/create.rb index 602db875..468af95c 100644 --- a/app/actions/projects/create.rb +++ b/app/actions/projects/create.rb @@ -6,6 +6,8 @@ module Projects def self.create_params(params) params.require(:project).permit( :name, + :namespace, + :managed_namespace, :repository_url, :branch, :cluster_id, @@ -68,7 +70,8 @@ module Projects steps << Projects::ValidateGitRepository end - steps << Projects::ValidateNamespaceAvailability + steps << Projects::SetUpNamespace + steps << Projects::ValidateNamespace steps << Projects::InitializeBuildPacks steps << Projects::Save diff --git a/app/actions/projects/set_up_namespace.rb b/app/actions/projects/set_up_namespace.rb new file mode 100644 index 00000000..fa390e49 --- /dev/null +++ b/app/actions/projects/set_up_namespace.rb @@ -0,0 +1,15 @@ +class Projects::SetUpNamespace + extend LightService::Action + expects :project + + executed do |context| + project = context.project + if project.namespace.blank? && project.managed_namespace + # autoset the namespace to the project name + project.namespace = project.name + elsif project.namespace.blank? && !project.managed_namespace + project.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 diff --git a/app/actions/projects/validate_namespace.rb b/app/actions/projects/validate_namespace.rb new file mode 100644 index 00000000..c26a522e --- /dev/null +++ b/app/actions/projects/validate_namespace.rb @@ -0,0 +1,58 @@ +module Projects + class ValidateNamespace + extend LightService::Action + + expects :project, :user + + def self.validate_namespace_does_not_exist_or_is_managed( + context, + project, + client, + existing_namespaces + ) + namespace_exists = existing_namespaces.any? do |ns| + ns.metadata.name == project.namespace && ns.metadata&.labels&.caninemanaged != "true" + end + if namespace_exists + error_message = "Namespace `#{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 + end + + def self.validate_namespace_exists( + context, + project, + client, + existing_namespaces + ) + existing_namespace = existing_namespaces.any? do |ns| + ns.metadata.name == project.namespace + end + unless existing_namespace + error_message = "`#{project.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable auto create namespace" + project.errors.add(:base, error_message) + context.fail_and_return!(error_message) + end + end + + 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 + + if project.managed_namespace + validate_namespace_does_not_exist_or_is_managed(context, project, client, existing_namespaces) + else + validate_namespace_exists(context, project, 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 diff --git a/app/actions/projects/validate_namespace_availability.rb b/app/actions/projects/validate_namespace_availability.rb deleted file mode 100644 index 9884c200..00000000 --- a/app/actions/projects/validate_namespace_availability.rb +++ /dev/null @@ -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 diff --git a/app/controllers/clusters_controller.rb b/app/controllers/clusters_controller.rb index 93333935..b717153f 100644 --- a/app/controllers/clusters_controller.rb +++ b/app/controllers/clusters_controller.rb @@ -89,8 +89,8 @@ class ClustersController < ApplicationController %w[services deployments ingress cronjobs].each do |resource| yaml_content = K8::Kubectl.new( K8::Connection.new(@cluster, current_user) - ).call("get #{resource} -n #{project.name} -o yaml") - export(@cluster.name, project.name, yaml_content, zio) + ).call("get #{resource} -n #{project.namespace} -o yaml") + export(@cluster.name, project.namespace, yaml_content, zio) end end end diff --git a/app/controllers/projects/processes_controller.rb b/app/controllers/projects/processes_controller.rb index 9b245219..373d3c13 100644 --- a/app/controllers/projects/processes_controller.rb +++ b/app/controllers/projects/processes_controller.rb @@ -13,8 +13,8 @@ class Projects::ProcessesController < Projects::BaseController def show client = K8::Client.new(active_connection) - @logs = client.get_pod_log(params[:id], @project.name) - @pod_events = client.get_pod_events(params[:id], @project.name) + @logs = client.get_pod_log(params[:id], @project.namespace) + @pod_events = client.get_pod_events(params[:id], @project.namespace) respond_to do |format| format.html @@ -30,7 +30,7 @@ class Projects::ProcessesController < Projects::BaseController def destroy 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..." end diff --git a/app/controllers/projects/services/jobs_controller.rb b/app/controllers/projects/services/jobs_controller.rb index d5e6277a..b5472f40 100644 --- a/app/controllers/projects/services/jobs_controller.rb +++ b/app/controllers/projects/services/jobs_controller.rb @@ -7,7 +7,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController job_name = "#{@service.name}-manual-#{timestamp}" kubectl = K8::Kubectl.new(active_connection) 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 end @@ -16,7 +16,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController job_name = params[:id] kubectl = K8::Kubectl.new(active_connection) 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 diff --git a/app/javascript/controllers/expandable_optional_input_controller.js b/app/javascript/controllers/expandable_optional_input_controller.js new file mode 100644 index 00000000..43ec28a0 --- /dev/null +++ b/app/javascript/controllers/expandable_optional_input_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["container"] + + connect() { + this.containerTarget.classList.add("hidden", "opacity-0", "transition-all") + } + + show(e) { + e.preventDefault(); + + e.target.classList.add("hidden") + this.containerTarget.classList.remove("hidden", "opacity-0") + this.containerTarget.classList.add("opacity-100", "duration-500") + } +} \ No newline at end of file diff --git a/app/jobs/projects/deployment_job.rb b/app/jobs/projects/deployment_job.rb index cfa040ef..2c0d9357 100644 --- a/app/jobs/projects/deployment_job.rb +++ b/app/jobs/projects/deployment_job.rb @@ -108,11 +108,11 @@ class Projects::DeploymentJob < ApplicationJob end 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 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 kubectl.apply_yaml(namespace_yaml) end @@ -124,7 +124,7 @@ class Projects::DeploymentJob < ApplicationJob kubectl = K8::Kubectl.new(connection) 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| if @marked_resources.select do |r| r.is_a?(K8::Stateless.const_get(resource_type)) @@ -132,7 +132,7 @@ class Projects::DeploymentJob < ApplicationJob applied_resource.name == resource['metadata']['name'] end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true' @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 @@ -150,7 +150,7 @@ class Projects::DeploymentJob < ApplicationJob def restart_deployment(service, kubectl) @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 def upload_registry_secrets(kubectl, deployment) diff --git a/app/jobs/projects/destroy_job.rb b/app/jobs/projects/destroy_job.rb index 021ae7ae..20d61768 100644 --- a/app/jobs/projects/destroy_job.rb +++ b/app/jobs/projects/destroy_job.rb @@ -14,9 +14,11 @@ class Projects::DestroyJob < ApplicationJob end def delete_namespace(project, user) - client = K8::Client.new(K8::Connection.new(project.cluster, user)) - if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.name }).present? - client.delete_namespace(namespace.metadata.name) + if project.managed_namespace + client = K8::Client.new(K8::Connection.new(project.cluster, user)) + if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.namespace }).present? + client.delete_namespace(namespace.metadata.name) + end end end diff --git a/app/jobs/scheduled/check_health_job.rb b/app/jobs/scheduled/check_health_job.rb index ec6cd2b0..1603a653 100644 --- a/app/jobs/scheduled/check_health_job.rb +++ b/app/jobs/scheduled/check_health_job.rb @@ -3,7 +3,7 @@ class Scheduled::CheckHealthJob < ApplicationJob def perform 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}") if service.domains.any? url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url) diff --git a/app/models/add_on.rb b/app/models/add_on.rb index b45459ba..3dfb8f02 100644 --- a/app/models/add_on.rb +++ b/app/models/add_on.rb @@ -2,16 +2,18 @@ # # Table name: add_ons # -# id :bigint not null, primary key -# chart_type :string not null -# chart_url :string -# metadata :jsonb -# name :string not null -# status :integer default("installing"), not null -# values :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# cluster_id :bigint not null +# id :bigint not null, primary key +# chart_type :string not null +# chart_url :string +# managed_namespace :boolean default(TRUE) +# metadata :jsonb +# name :string not null +# namespace :string not null +# status :integer default("installing"), not null +# values :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# cluster_id :bigint not null # # Indexes # @@ -24,6 +26,7 @@ # class AddOn < ApplicationRecord include Loggable + include Namespaced belongs_to :cluster has_one :account, through: :cluster diff --git a/app/models/cluster.rb b/app/models/cluster.rb index 58571a27..e28a3b4c 100644 --- a/app/models/cluster.rb +++ b/app/models/cluster.rb @@ -58,7 +58,7 @@ class Cluster < ApplicationRecord ] def namespaces - RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name) + RESERVED_NAMESPACES + projects.pluck(:namespace) + add_ons.pluck(:namespace) end def create_build_cloud!(attributes = {}) diff --git a/app/models/concerns/namespaced.rb b/app/models/concerns/namespaced.rb new file mode 100644 index 00000000..501552d5 --- /dev/null +++ b/app/models/concerns/namespaced.rb @@ -0,0 +1,7 @@ +module Namespaced + def self.included(base) + base.class_eval do + validates_presence_of :namespace + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 6cb47e2f..63b19a57 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -9,7 +9,9 @@ # container_registry_url :string # docker_build_context_directory :string default("."), not null # dockerfile_path :string default("./Dockerfile"), not null +# managed_namespace :boolean default(TRUE) # name :string not null +# namespace :string not null # postdeploy_command :text # postdestroy_command :text # predeploy_command :text @@ -32,6 +34,7 @@ # fk_rails_... (project_fork_cluster_id => clusters.id) # class Project < ApplicationRecord + include Namespaced broadcasts_refreshes belongs_to :cluster has_one :account, through: :cluster @@ -54,6 +57,8 @@ class Project < ApplicationRecord validates :name, presence: true, 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 :repository_url, presence: true, format: { diff --git a/app/services/k8/stateless/cron_job.rb b/app/services/k8/stateless/cron_job.rb index 5d151101..c33718e7 100644 --- a/app/services/k8/stateless/cron_job.rb +++ b/app/services/k8/stateless/cron_job.rb @@ -18,7 +18,7 @@ class K8::Stateless::CronJob < K8::Base private 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 # Filter jobs owned by this CronJob diff --git a/app/services/k8/stateless/deployment.rb b/app/services/k8/stateless/deployment.rb index a7a78a98..a5d4ed55 100644 --- a/app/services/k8/stateless/deployment.rb +++ b/app/services/k8/stateless/deployment.rb @@ -9,6 +9,6 @@ class K8::Stateless::Deployment < K8::Base end def restart - kubectl.call("rollout restart deployment/#{service.name} -n #{project.name}") + kubectl.call("rollout restart deployment/#{service.name} -n #{project.namespace}") end end diff --git a/app/services/k8/stateless/ingress.rb b/app/services/k8/stateless/ingress.rb index 1291f99b..677cabfc 100644 --- a/app/services/k8/stateless/ingress.rb +++ b/app/services/k8/stateless/ingress.rb @@ -15,7 +15,7 @@ class K8::Stateless::Ingress < K8::Base return nil unless @service.domains.any? 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 def certificate_name @@ -25,7 +25,7 @@ class K8::Stateless::Ingress < K8::Base def get_ingress result = kubectl.call('get ingresses -o yaml') 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 def self.ip_address(client) diff --git a/app/services/k8/stateless/service.rb b/app/services/k8/stateless/service.rb index 00a3ea6a..fe9baff5 100644 --- a/app/services/k8/stateless/service.rb +++ b/app/services/k8/stateless/service.rb @@ -7,7 +7,7 @@ class K8::Stateless::Service < K8::Base end def internal_url - "#{name}.#{project.name}.svc.cluster.local:80" + "#{name}.#{project.namespace}.svc.cluster.local:80" end def name diff --git a/app/view_models/async/projects/processes/index_view_model.rb b/app/view_models/async/projects/processes/index_view_model.rb index 448b1bf5..7944ed72 100644 --- a/app/view_models/async/projects/processes/index_view_model.rb +++ b/app/view_models/async/projects/processes/index_view_model.rb @@ -19,6 +19,6 @@ class Async::Projects::Processes::IndexViewModel < Async::BaseViewModel def get_pods_for_project(project) # Get all pods for a given namespace 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 diff --git a/app/views/add_ons/new.html.erb b/app/views/add_ons/new.html.erb index b9fc3ca5..0222b39f 100644 --- a/app/views/add_ons/new.html.erb +++ b/app/views/add_ons/new.html.erb @@ -12,25 +12,29 @@
<%= form_with(model: @add_on) do |form| %> - <%= render(FormFieldComponent.new( - label: "Name", - description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed." - )) do %> - <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %> - - <% end %> +
+ <%= render(FormFieldComponent.new( + label: "Name", + description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed." + )) do %> + <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %> + + <% end %> - <%= render(FormFieldComponent.new( - label: "Cluster", - description: "The cluster to deploy your add on to." - )) do %> - <%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %> - - <% end %> + <%= render(FormFieldComponent.new( + label: "Cluster", + description: "The cluster to deploy your add on to." + )) do %> + <%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %> + + <% end %> + + <%= render "shared/partials/namespace_input_group", form: %> +
diff --git a/app/views/projects/create/_missing_container_registry_credentials.html.erb b/app/views/projects/create/_missing_container_registry_credentials.html.erb index 85b6724a..6ae6c506 100644 --- a/app/views/projects/create/_missing_container_registry_credentials.html.erb +++ b/app/views/projects/create/_missing_container_registry_credentials.html.erb @@ -1,7 +1,7 @@
-

Missing Credentials for Docker Hub

-

Please provide your Docker Hub credentials to continue.

+

Missing credentials for container registry

+

Please provide your container registry credentials to continue.

<%= link_to "Add Credentials", providers_path, class: "mt-6 btn btn-primary", data: { turbo: false } %>
<%= link_to( diff --git a/app/views/projects/create/_missing_git_credentials.html.erb b/app/views/projects/create/_missing_git_credentials.html.erb index f87fd93a..d1ed7671 100644 --- a/app/views/projects/create/_missing_git_credentials.html.erb +++ b/app/views/projects/create/_missing_git_credentials.html.erb @@ -1,6 +1,6 @@
-

Missing Credentials for Git

+

Missing credentials for Git repository

Please provide your Github or Gitlab credentials to continue.

<% end %> -
+
<%= link_to project.link_to_view, target: "_blank" do %> <% if project.git? %> - <% if project.github? %> - - <% elsif project.gitlab? %> - - <% end %> - <%= project.repository_url %> - - <%= project.branch %> +
+ <% if project.github? %> + + <% elsif project.gitlab? %> + + <% end %> + <%= project.repository_url %> + + <%= project.branch %> +
<% else %> - - <%= project.repository_url %> +
+ + <%= project.repository_url %> +
<% end %> <% end %> - <%= link_to project.cluster.name, project.cluster, target: "_blank", class: "underline" %> + + <%= link_to( + project.cluster, + target: "_blank", + class: "underline", + ) do %> +
+ +
<%= project.cluster.name %>
+
+
<%= project.namespace %>
+
+ <% end %>
diff --git a/app/views/projects/edit.html.erb b/app/views/projects/edit.html.erb index 8c5fc80e..30588837 100644 --- a/app/views/projects/edit.html.erb +++ b/app/views/projects/edit.html.erb @@ -4,6 +4,9 @@

General


+ <% unless @project.managed_namespace %> + <%= render "shared/partials/namespace_show", namespaced: @project %> + <% end %> <%= render "edit_form", project: @project %>
diff --git a/app/views/shared/partials/_namespace_show.html.erb b/app/views/shared/partials/_namespace_show.html.erb index 39224ba6..37cdbd0e 100644 --- a/app/views/shared/partials/_namespace_show.html.erb +++ b/app/views/shared/partials/_namespace_show.html.erb @@ -1,11 +1,24 @@ -
-
- - -
-
/
-
- - +
+
+
+
+
+ +
/
+
+
+
+
+ +
+
\ No newline at end of file diff --git a/spec/factories/add_ons.rb b/spec/factories/add_ons.rb index 9b877511..a0dad5f6 100644 --- a/spec/factories/add_ons.rb +++ b/spec/factories/add_ons.rb @@ -30,6 +30,8 @@ FactoryBot.define do chart_url { 'bitnami/redis' } chart_type { "helm_chart" } sequence(:name) { |n| "example-addon-#{n}" } + sequence(:namespace) { |n| "example-addon-#{n}" } + managed_namespace { true } status { :installing } values { {} } metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } } From d2b2de01c44ec4ebb4194887cbd0032649b6fa15 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 Nov 2025 23:25:36 -0800 Subject: [PATCH 3/9] lots of refactoring... --- .../add_ons/apply_template_to_values.rb | 25 +++ app/actions/add_ons/create.rb | 68 +++---- app/actions/add_ons/save.rb | 10 + app/actions/add_ons/set_package_details.rb | 18 ++ app/actions/namespaced/set_up_namespace.rb | 16 ++ .../validate_namespace.rb | 30 +-- app/actions/projects/create.rb | 186 +++++++++--------- app/actions/projects/set_up_namespace.rb | 15 -- app/controllers/add_ons_controller.rb | 26 +-- app/models/add_on.rb | 7 - app/models/concerns/namespaced.rb | 8 + app/models/project.rb | 7 - .../add_ons/apply_template_to_values_spec.rb | 22 +++ spec/actions/add_ons/create_spec.rb | 69 +++---- .../add_ons/set_package_details_spec.rb | 31 +++ .../namespaced/set_up_namespace_spec.rb | 26 +++ .../validate_namespace_spec.rb | 12 +- spec/actions/projects/create_spec.rb | 10 +- .../actions/projects/set_up_namespace_spec.rb | 10 +- spec/models/cluster_spec.rb | 4 +- spec/models/project_spec.rb | 4 +- 21 files changed, 350 insertions(+), 254 deletions(-) create mode 100644 app/actions/add_ons/apply_template_to_values.rb create mode 100644 app/actions/add_ons/save.rb create mode 100644 app/actions/add_ons/set_package_details.rb create mode 100644 app/actions/namespaced/set_up_namespace.rb rename app/actions/{projects => namespaced}/validate_namespace.rb (54%) delete mode 100644 app/actions/projects/set_up_namespace.rb create mode 100644 spec/actions/add_ons/apply_template_to_values_spec.rb create mode 100644 spec/actions/add_ons/set_package_details_spec.rb create mode 100644 spec/actions/namespaced/set_up_namespace_spec.rb rename spec/actions/{projects => namespaced}/validate_namespace_spec.rb (83%) diff --git a/app/actions/add_ons/apply_template_to_values.rb b/app/actions/add_ons/apply_template_to_values.rb new file mode 100644 index 00000000..a388cdb6 --- /dev/null +++ b/app/actions/add_ons/apply_template_to_values.rb @@ -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 \ No newline at end of file diff --git a/app/actions/add_ons/create.rb b/app/actions/add_ons/create.rb index 44b01691..5994d24c 100644 --- a/app/actions/add_ons/create.rb +++ b/app/actions/add_ons/create.rb @@ -1,47 +1,37 @@ class AddOns::Create - extend LightService::Action - expects :add_on - promises :add_on + def self.parse_params(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: {} + ) + end - executed do |context| - add_on = context.add_on - apply_template_to_values(add_on) - fetch_package_details(context, add_on) - unless add_on.save - context.fail_and_return!("Failed to create add on") + class ToNamespaced + extend LightService::Action + expects :add_on + promises :namespaced + executed do |context| + context.namespaced = context.add_on end end - def self.fetch_package_details(context, add_on) - result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url) + extend LightService::Organizer - if result.failure? - add_on.errors.add(:base, "Failed to fetch package details") - context.fail_and_return!("Failed to fetch package details") - end - - result.response.delete('readme') - add_on.metadata['package_details'] = result.response - end - - 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 + def self.call(add_on) + with(add_on:).reduce( + AddOns::ApplyTemplateToValues, + AddOns::SetPackageDetails, + AddOns::Save + ) end end diff --git a/app/actions/add_ons/save.rb b/app/actions/add_ons/save.rb new file mode 100644 index 00000000..a3b0c522 --- /dev/null +++ b/app/actions/add_ons/save.rb @@ -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 \ No newline at end of file diff --git a/app/actions/add_ons/set_package_details.rb b/app/actions/add_ons/set_package_details.rb new file mode 100644 index 00000000..dc42bb10 --- /dev/null +++ b/app/actions/add_ons/set_package_details.rb @@ -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 \ No newline at end of file diff --git a/app/actions/namespaced/set_up_namespace.rb b/app/actions/namespaced/set_up_namespace.rb new file mode 100644 index 00000000..d8dbf164 --- /dev/null +++ b/app/actions/namespaced/set_up_namespace.rb @@ -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 diff --git a/app/actions/projects/validate_namespace.rb b/app/actions/namespaced/validate_namespace.rb similarity index 54% rename from app/actions/projects/validate_namespace.rb rename to app/actions/namespaced/validate_namespace.rb index c26a522e..88f29656 100644 --- a/app/actions/projects/validate_namespace.rb +++ b/app/actions/namespaced/validate_namespace.rb @@ -1,53 +1,53 @@ -module Projects +module Namespaced class ValidateNamespace extend LightService::Action - expects :project, :user + expects :namespaced, :user def self.validate_namespace_does_not_exist_or_is_managed( context, - project, + namespaced, client, existing_namespaces ) namespace_exists = existing_namespaces.any? do |ns| - ns.metadata.name == project.namespace && ns.metadata&.labels&.caninemanaged != "true" + ns.metadata.name == namespaced.namespace && ns.metadata&.labels&.caninemanaged != "true" end if namespace_exists - error_message = "Namespace `#{project.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name." - project.errors.add(:name, error_message) + 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, - project, + namespaced, client, existing_namespaces ) existing_namespace = existing_namespaces.any? do |ns| - ns.metadata.name == project.namespace + ns.metadata.name == namespaced.namespace end unless existing_namespace - error_message = "`#{project.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable auto create namespace" - project.errors.add(:base, error_message) + error_message = "`#{namespaced.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable auto create namespace" + namespaced.errors.add(:base, error_message) context.fail_and_return!(error_message) end end executed do |context| - project = context.project - cluster = project.cluster + namespaced = context.namespaced + cluster = namespaced.cluster begin client = K8::Client.new(K8::Connection.new(cluster, context.user)) existing_namespaces = client.get_namespaces - if project.managed_namespace - validate_namespace_does_not_exist_or_is_managed(context, project, client, existing_namespaces) + if namespaced.managed_namespace + validate_namespace_does_not_exist_or_is_managed(context, namespaced, client, existing_namespaces) else - validate_namespace_exists(context, project, client, existing_namespaces) + 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 diff --git a/app/actions/projects/create.rb b/app/actions/projects/create.rb index 468af95c..cc18d813 100644 --- a/app/actions/projects/create.rb +++ b/app/actions/projects/create.rb @@ -1,93 +1,101 @@ # frozen_string_literal: true -module Projects - class Create - 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::SetUpNamespace - steps << Projects::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" +class Projects::Create + class ToNamespaced + extend LightService::Action + expects :project + promises :namespaced + executed do |context| + context.namespaced = context.project 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 \ No newline at end of file diff --git a/app/actions/projects/set_up_namespace.rb b/app/actions/projects/set_up_namespace.rb deleted file mode 100644 index fa390e49..00000000 --- a/app/actions/projects/set_up_namespace.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Projects::SetUpNamespace - extend LightService::Action - expects :project - - executed do |context| - project = context.project - if project.namespace.blank? && project.managed_namespace - # autoset the namespace to the project name - project.namespace = project.name - elsif project.namespace.blank? && !project.managed_namespace - project.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 diff --git a/app/controllers/add_ons_controller.rb b/app/controllers/add_ons_controller.rb index ab7911ee..3b804349 100644 --- a/app/controllers/add_ons_controller.rb +++ b/app/controllers/add_ons_controller.rb @@ -39,7 +39,8 @@ class AddOnsController < ApplicationController # POST /add_ons or /add_ons.json def create - result = AddOns::Create.execute(add_on: AddOn.new(add_on_params)) + params = AddOns::Create.parse_params(params) + result = AddOns::Create.execute(add_on: AddOn.new(params)) @add_on = result.add_on # Uncomment to authorize with Pundit # authorize @add_on @@ -58,7 +59,7 @@ class AddOnsController < ApplicationController # PATCH/PUT /add_ons/1 or /add_ons/1.json 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) respond_to do |format| @@ -123,25 +124,4 @@ class AddOnsController < ApplicationController rescue ActiveRecord::RecordNotFound redirect_to add_ons_path 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 diff --git a/app/models/add_on.rb b/app/models/add_on.rb index 3dfb8f02..fff705b7 100644 --- a/app/models/add_on.rb +++ b/app/models/add_on.rb @@ -42,7 +42,6 @@ class AddOn < ApplicationRecord validates :chart_type, presence: true validate :chart_type_exists 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 validate :has_package_details, if: :helm_chart? @@ -59,12 +58,6 @@ class AddOn < ApplicationRecord metadata['install_stage'] || 0 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 if metadata['package_details'].blank? errors.add(:metadata, "is missing required keys: package_details") diff --git a/app/models/concerns/namespaced.rb b/app/models/concerns/namespaced.rb index 501552d5..ebdf8dd9 100644 --- a/app/models/concerns/namespaced.rb +++ b/app/models/concerns/namespaced.rb @@ -1,7 +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 diff --git a/app/models/project.rb b/app/models/project.rb index 63b19a57..c377e8aa 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -70,7 +70,6 @@ class Project < ApplicationRecord validate :project_fork_cluster_id_is_owned_by_account validates_presence_of :build_configuration, if: :git? - validate :name_is_unique_to_cluster, on: :create after_save_commit do broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self } end @@ -97,12 +96,6 @@ class Project < ApplicationRecord 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 deployments.order(created_at: :desc).where(status: :completed).first end diff --git a/spec/actions/add_ons/apply_template_to_values_spec.rb b/spec/actions/add_ons/apply_template_to_values_spec.rb new file mode 100644 index 00000000..736a3d8f --- /dev/null +++ b/spec/actions/add_ons/apply_template_to_values_spec.rb @@ -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 diff --git a/spec/actions/add_ons/create_spec.rb b/spec/actions/add_ons/create_spec.rb index 1a7d60d0..ff821b1b 100644 --- a/spec/actions/add_ons/create_spec.rb +++ b/spec/actions/add_ons/create_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe AddOns::Create do 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 allow(AddOns::HelmChartDetails).to receive(:execute).and_return( @@ -12,53 +12,40 @@ RSpec.describe AddOns::Create do describe 'errors' 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 - result = described_class.execute(add_on:) + result = described_class.call(add_on) expect(result.failure?).to be_truthy end end end - describe '#execute' do - it 'applies template and fetches package details' do - expect(add_on).to receive(:save) - 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 - - describe '.apply_template_to_values' do - let(:template) do - { - 'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' }, - 'replica.replicaCount' => '5' + let(:params) do + ActionController::Parameters.new({ + add_on: { + name: 'redis-main', + chart_type: 'redis', + metadata: { + redis: { + template: { + 'replicas' => 3, + 'master.persistence.size' => { + 'type' => 'size', + 'value' => '2', + 'unit' => 'Gi' + } + } + } + } } - end + }) + end - before do - add_on.metadata['template'] = template - add_on.chart_type = "redis" - end - - 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 + it 'can create an add on successfully' do + add_on = AddOn.new(AddOns::Create.parse_params(params)) + result = described_class.call(add_on) + expect(result.success?).to be_truthy end end diff --git a/spec/actions/add_ons/set_package_details_spec.rb b/spec/actions/add_ons/set_package_details_spec.rb new file mode 100644 index 00000000..09be11a4 --- /dev/null +++ b/spec/actions/add_ons/set_package_details_spec.rb @@ -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 diff --git a/spec/actions/namespaced/set_up_namespace_spec.rb b/spec/actions/namespaced/set_up_namespace_spec.rb new file mode 100644 index 00000000..714db2a6 --- /dev/null +++ b/spec/actions/namespaced/set_up_namespace_spec.rb @@ -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 diff --git a/spec/actions/projects/validate_namespace_spec.rb b/spec/actions/namespaced/validate_namespace_spec.rb similarity index 83% rename from spec/actions/projects/validate_namespace_spec.rb rename to spec/actions/namespaced/validate_namespace_spec.rb index bebaa1ef..e9f0b644 100644 --- a/spec/actions/projects/validate_namespace_spec.rb +++ b/spec/actions/namespaced/validate_namespace_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -RSpec.describe Projects::ValidateNamespace do +RSpec.describe Namespaced::ValidateNamespace do let(:cluster) { create(:cluster) } let(:user) { create(:user) } let(:k8_client) { instance_double(K8::Client) } @@ -23,7 +23,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'fails' do - expect(described_class.execute(project:, user: user)).to be_failure + expect(described_class.execute(namespaced: project, user:)).to be_failure end end @@ -35,7 +35,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'succeeds' do - expect(described_class.execute(project:, user:)).to be_success + expect(described_class.execute(namespaced: project, user:)).to be_success end end end @@ -56,7 +56,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'succeeds' do - expect(described_class.execute(project: project, user: user)).to be_success + expect(described_class.execute(namespaced: project, user: user)).to be_success end end @@ -71,7 +71,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'fails with error message' do - result = described_class.execute(project: project, user: user) + result = described_class.execute(namespaced: project, user: user) expect(result).to be_failure expect(result.message).to include("already exists") end @@ -83,7 +83,7 @@ RSpec.describe Projects::ValidateNamespace do end it 'succeeds' do - result = described_class.execute(project: project, user: user) + result = described_class.execute(namespaced: project, user: user) expect(result).to be_success end end diff --git a/spec/actions/projects/create_spec.rb b/spec/actions/projects/create_spec.rb index c72a83da..ad5bab9c 100644 --- a/spec/actions/projects/create_spec.rb +++ b/spec/actions/projects/create_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Projects::Create do before do allow(Projects::ValidateGitRepository).to receive(:execute) - allow(Projects::ValidateNamespace).to receive(:execute) + allow(Namespaced::ValidateNamespace).to receive(:execute) allow(Projects::RegisterGitWebhook).to receive(:execute) end @@ -156,7 +156,9 @@ RSpec.describe Projects::Create do it 'validates with github and registers webhooks' do expect(subject).to eq([ Projects::ValidateGitRepository, - Projects::ValidateNamespace, + Projects::Create::ToNamespaced, + Namespaced::SetUpNamespace, + Namespaced::ValidateNamespace, Projects::InitializeBuildPacks, Projects::Save, Projects::RegisterGitWebhook @@ -172,7 +174,9 @@ RSpec.describe Projects::Create do it 'validates with github and does not register webhooks' do expect(subject).to eq([ Projects::ValidateGitRepository, - Projects::ValidateNamespace, + Projects::Create::ToNamespaced, + Namespaced::SetUpNamespace, + Namespaced::ValidateNamespace, Projects::InitializeBuildPacks, Projects::Save ]) diff --git a/spec/actions/projects/set_up_namespace_spec.rb b/spec/actions/projects/set_up_namespace_spec.rb index 9ab81eef..714db2a6 100644 --- a/spec/actions/projects/set_up_namespace_spec.rb +++ b/spec/actions/projects/set_up_namespace_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' -RSpec.describe Projects::SetUpNamespace do - let(:subject) { described_class.execute(project:) } +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: "") } @@ -9,8 +9,8 @@ RSpec.describe Projects::SetUpNamespace do it "autosets the name" do result = subject - expect(result.project.namespace).to eq(project.name) - expect(result.project.errors).to be_empty + expect(result.namespaced.namespace).to eq(project.name) + expect(result.namespaced.errors).to be_empty end end @@ -19,7 +19,7 @@ RSpec.describe Projects::SetUpNamespace do it "raises error" do result = subject - expect(result.project.errors).to be_present + expect(result.namespaced.errors).to be_present expect(result.failure?).to be_truthy end end diff --git a/spec/models/cluster_spec.rb b/spec/models/cluster_spec.rb index 372143d2..ea6dccdc 100644 --- a/spec/models/cluster_spec.rb +++ b/spec/models/cluster_spec.rb @@ -29,8 +29,8 @@ RSpec.describe Cluster, type: :model do let!(:add_on) { create(:add_on, cluster: cluster) } it 'returns the reserved namespaces and project/add_on names' do - expect(cluster.namespaces).to include(project.name) - expect(cluster.namespaces).to include(add_on.name) + expect(cluster.namespaces).to include(project.namespace) + expect(cluster.namespaces).to include(add_on.namespace) end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a2ca4809..76b384c5 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -37,12 +37,12 @@ require 'rails_helper' RSpec.describe Project, type: :model do 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 context 'when name is not unique to the cluster' do before do - create(:project, name: project.name, cluster: cluster) + create(:project, name: project.name, cluster:, namespace: "taken") end it 'is not valid' do From 0ab942f21ed03900273e57df4d91680cbc0bdab3 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 26 Nov 2025 01:54:10 -0800 Subject: [PATCH 4/9] specs passing --- app/actions/add_ons/apply_template_to_values.rb | 2 +- app/actions/add_ons/create.rb | 7 +++++-- app/actions/add_ons/save.rb | 2 +- app/actions/add_ons/set_package_details.rb | 2 +- app/actions/projects/create.rb | 2 +- app/controllers/add_ons_controller.rb | 2 +- spec/actions/add_ons/create_spec.rb | 10 +++++++--- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/actions/add_ons/apply_template_to_values.rb b/app/actions/add_ons/apply_template_to_values.rb index a388cdb6..8d508e52 100644 --- a/app/actions/add_ons/apply_template_to_values.rb +++ b/app/actions/add_ons/apply_template_to_values.rb @@ -22,4 +22,4 @@ class AddOns::ApplyTemplateToValues end end end -end \ No newline at end of file +end diff --git a/app/actions/add_ons/create.rb b/app/actions/add_ons/create.rb index 5994d24c..ef0f1d0a 100644 --- a/app/actions/add_ons/create.rb +++ b/app/actions/add_ons/create.rb @@ -27,8 +27,11 @@ class AddOns::Create extend LightService::Organizer - def self.call(add_on) - with(add_on:).reduce( + def self.call(add_on, user) + with(add_on:, user:).reduce( + AddOns::Create::ToNamespaced, + Namespaced::SetUpNamespace, + Namespaced::ValidateNamespace, AddOns::ApplyTemplateToValues, AddOns::SetPackageDetails, AddOns::Save diff --git a/app/actions/add_ons/save.rb b/app/actions/add_ons/save.rb index a3b0c522..3b0c564b 100644 --- a/app/actions/add_ons/save.rb +++ b/app/actions/add_ons/save.rb @@ -7,4 +7,4 @@ class AddOns::Save context.fail_and_return!("Failed to create add on") end end -end \ No newline at end of file +end diff --git a/app/actions/add_ons/set_package_details.rb b/app/actions/add_ons/set_package_details.rb index dc42bb10..02e2033c 100644 --- a/app/actions/add_ons/set_package_details.rb +++ b/app/actions/add_ons/set_package_details.rb @@ -15,4 +15,4 @@ class AddOns::SetPackageDetails result.response.delete('readme') add_on.metadata['package_details'] = result.response end -end \ No newline at end of file +end diff --git a/app/actions/projects/create.rb b/app/actions/projects/create.rb index cc18d813..b4394654 100644 --- a/app/actions/projects/create.rb +++ b/app/actions/projects/create.rb @@ -98,4 +98,4 @@ class Projects::Create rescue ActiveRecord::RecordNotFound raise "Provider #{provider_id} not found" end -end \ No newline at end of file +end diff --git a/app/controllers/add_ons_controller.rb b/app/controllers/add_ons_controller.rb index 3b804349..83819787 100644 --- a/app/controllers/add_ons_controller.rb +++ b/app/controllers/add_ons_controller.rb @@ -40,7 +40,7 @@ class AddOnsController < ApplicationController # POST /add_ons or /add_ons.json def create params = AddOns::Create.parse_params(params) - result = AddOns::Create.execute(add_on: AddOn.new(params)) + result = AddOns::Create.call(AddOn.new(params), user) @add_on = result.add_on # Uncomment to authorize with Pundit # authorize @add_on diff --git a/spec/actions/add_ons/create_spec.rb b/spec/actions/add_ons/create_spec.rb index ff821b1b..8cbc4a46 100644 --- a/spec/actions/add_ons/create_spec.rb +++ b/spec/actions/add_ons/create_spec.rb @@ -1,10 +1,12 @@ require 'rails_helper' RSpec.describe AddOns::Create do + let(:cluster) { create(:cluster) } let(:add_on) { build(:add_on) } let(:chart_details) { { 'name' => 'redis', 'version' => '1.0.0' } } before do + allow(Namespaced::ValidateNamespace).to receive(:execute) allow(AddOns::HelmChartDetails).to receive(:execute).and_return( double(success?: true, failure?: false, response: chart_details) ) @@ -16,7 +18,7 @@ RSpec.describe AddOns::Create do let(:add_on) { build(:add_on, namespace: 'taken') } it 'raises an error' do - result = described_class.call(add_on) + result = described_class.call(add_on, cluster.account.owner) expect(result.failure?).to be_truthy end end @@ -27,10 +29,12 @@ RSpec.describe AddOns::Create do add_on: { name: 'redis-main', chart_type: 'redis', + chart_url: 'bitnami/redis', + cluster_id: cluster.id, metadata: { redis: { template: { - 'replicas' => 3, + 'replica.replicaCount' => 3, 'master.persistence.size' => { 'type' => 'size', 'value' => '2', @@ -45,7 +49,7 @@ RSpec.describe AddOns::Create do it 'can create an add on successfully' do add_on = AddOn.new(AddOns::Create.parse_params(params)) - result = described_class.call(add_on) + result = described_class.call(add_on, cluster.account.owner) expect(result.success?).to be_truthy end end From 9332f62afe48cf77c4b9c7bde857906947429b76 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 26 Nov 2025 14:57:02 -0800 Subject: [PATCH 5/9] added fix for pod_yaml --- .../projects/services/_advanced.html.erb | 2 +- config/initializers/core_extensions.rb | 8 ++++++++ resources/k8/stateless/cron_job.yaml | 2 +- resources/k8/stateless/deployment.yaml | 2 +- spec/initializers/core_extensions_spec.rb | 20 +++++++++++++++++++ 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 config/initializers/core_extensions.rb create mode 100644 spec/initializers/core_extensions_spec.rb diff --git a/app/views/projects/services/_advanced.html.erb b/app/views/projects/services/_advanced.html.erb index 2cec9e48..0279d8b5 100644 --- a/app/views/projects/services/_advanced.html.erb +++ b/app/views/projects/services/_advanced.html.erb @@ -40,7 +40,7 @@ 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", 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 : "" %>
-
- - <%= bc_form.text_field( - :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'" - ) %> - + +
+ + +
+
+ + <%= bc_form.text_field( + :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'" + ) %> + +
+
+
<% end %> - <%= render(FormFieldComponent.new( - label: "Namespace", - description: "The namespace your application is deployed to." - )) do %> - <%= form.text_field :namespace, current_account.clusters.running, :id, :name, {}, { class: "select select-bordered w-full" } %> - - <% end %> - <%= render(FormFieldComponent.new( label: "Git Credentials", description: "The credentials to use to connect to your Git repository" diff --git a/app/views/projects/create/_select_credentials.html.erb b/app/views/projects/create/_select_credentials.html.erb index aa0dfca8..85f73013 100644 --- a/app/views/projects/create/_select_credentials.html.erb +++ b/app/views/projects/create/_select_credentials.html.erb @@ -10,11 +10,12 @@ }, { class: "select select-bordered w-full", + required: true, data: { "new-project-target": "provider", action: "change->new-project#selectProvider", } - } + }, ) %> <% end %>
diff --git a/app/views/shared/partials/_namespace_input_group.html.erb b/app/views/shared/partials/_namespace_input_group.html.erb index 2268998f..63cbf5f7 100644 --- a/app/views/shared/partials/_namespace_input_group.html.erb +++ b/app/views/shared/partials/_namespace_input_group.html.erb @@ -4,12 +4,12 @@ + Add namespace configuration
-
+
<%= 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, required: true %> + <%= form.text_field :namespace, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true %> @@ -19,7 +19,7 @@
Automatically create namespace
- <%= form.check_box :managed_namespace, class: "checkbox" %> + <%= form.check_box :managed_namespace, class: "checkbox", data: { action: "namespace-input-group#toggleManagedNamespace" } %>
<%= render "static/landing_page/announcement" %> Canine -

A modern, open source alternative to Heroku

+

A Developer-friendly PaaS for your Kubernetes

Canine is an open source deployment platform that makes it easy to deploy and manage your applications.

<%= 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" %>