From f701cb09f4bd05852ca02cb9c61b9ec28eb6a6ed Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 13 Dec 2025 14:48:42 -0800 Subject: [PATCH] added deployment split --- app/jobs/projects/deployment_job.rb | 122 +-------------- app/models/deployment_configuration.rb | 29 ++++ app/models/project.rb | 1 + app/models/provider.rb | 6 +- .../deployments/base_deployment_service.rb | 76 ++++++++++ .../deployments/deployment_failure.rb | 1 + .../deployments/helm_deployment_service.rb | 71 +++++++++ .../deployments/legacy_deployment_service.rb | 96 ++++++++++++ app/services/k8/stateless/command.rb | 4 +- ...221841_create_deployment_configurations.rb | 18 +++ db/schema.rb | 31 +++- spec/factories/deployment_configurations.rb | 24 +++ spec/factories/providers.rb | 6 +- .../helm_deployment_service_spec.rb} | 6 +- .../legacy_deployment_service_spec.rb | 142 ++++++++++++++++++ 15 files changed, 511 insertions(+), 122 deletions(-) create mode 100644 app/models/deployment_configuration.rb create mode 100644 app/services/deployments/base_deployment_service.rb create mode 100644 app/services/deployments/deployment_failure.rb create mode 100644 app/services/deployments/helm_deployment_service.rb create mode 100644 app/services/deployments/legacy_deployment_service.rb create mode 100644 db/migrate/20251213221841_create_deployment_configurations.rb create mode 100644 spec/factories/deployment_configurations.rb rename spec/{jobs/projects/helm_deployment_job_spec.rb => services/deployments/helm_deployment_service_spec.rb} (95%) create mode 100644 spec/services/deployments/legacy_deployment_service_spec.rb diff --git a/app/jobs/projects/deployment_job.rb b/app/jobs/projects/deployment_job.rb index 4ae866ca..accb959b 100644 --- a/app/jobs/projects/deployment_job.rb +++ b/app/jobs/projects/deployment_job.rb @@ -1,121 +1,15 @@ class Projects::DeploymentJob < ApplicationJob - DEPLOYABLE_RESOURCES = %w[ConfigMap Secrets Deployment CronJob Service Ingress Pv Pvc] - def perform(deployment, user) - @logger = deployment + def perform(deployment, user = nil) project = deployment.project - connection = K8::Connection.new(project, user, allow_anonymous: true) - @kubectl = K8::Kubectl.new(connection) + deployment_method = project.deployment_configuration&.deployment_method || "legacy" - chart_builder = K8::Helm::ChartBuilder.new( - project.name, - deployment, - ).connect(connection) - chart_builder.register_before_install do |yaml_content| - deployment.add_manifest(yaml_content) + service_class = case deployment_method + when "helm" + Deployments::HelmDeploymentService + else + Deployments::LegacyDeploymentService end - apply_namespace(project) if project.managed_namespace? - upload_registry_secrets(@kubectl, deployment) - chart_builder << apply_config_map(project) - chart_builder << apply_secrets(project) - - deploy_volumes(project, chart_builder) - predeploy(project, connection) - deploy_services(project, chart_builder) - chart_builder.install_chart(project.name) - kill_one_off_containers(project) - postdeploy(project, connection) - - mark_services_healthy(project) - deployment.completed! - project.deployed! - #rescue StandardError => e - # @logger.error("Deployment failed: #{e.message}") - # puts e.full_message - # deployment.failed! - end - - def apply_namespace(project) - namespace_yaml = K8::Namespace.new(project).to_yaml - @kubectl.apply_yaml(namespace_yaml) - end - - def upload_registry_secrets(kubectl, deployment) - project = deployment.project - @logger.info("Creating registry secret for #{project.container_image_reference}", color: :yellow) - provider = project.build_provider - result = Providers::GenerateConfigJson.execute( - provider:, - ) - raise StandardError, result.message if result.failure? - - secret_yaml = K8::Secrets::RegistrySecret.new(project, result.docker_config_json).to_yaml - kubectl.apply_yaml(secret_yaml) - end - - DEPLOYABLE_RESOURCES.each do |resource_type| - define_method(:"apply_#{resource_type.underscore}") do |service| - K8::Stateless.const_get(resource_type).new(service) - end - end - - def deploy_volumes(project, chart_builder) - project.volumes.each do |volume| - begin - chart_builder << apply_pv(volume) - chart_builder << apply_pvc(volume) - volume.deployed! - rescue StandardError => e - volume.failed! - raise e - end - end - end - - def deploy_services(project, chart_builder) - project.services.each do |service| - deploy_service(service, chart_builder) - end - end - - def deploy_service(service, chart_builder) - if service.background_service? - chart_builder << apply_deployment(service) - elsif service.cron_job? - chart_builder << apply_cron_job(service) - elsif service.web_service? - chart_builder << apply_deployment(service) - chart_builder << apply_service(service) - if service.domains.any? && service.allow_public_networking? - chart_builder << apply_ingress(service) - end - end - end - - def mark_services_healthy(project) - project.services.each(&:healthy!) - end - - def kill_one_off_containers(project) - @kubectl.call("-n #{project.namespace} delete pods -l oneoff=true") - end - - def predeploy(project, connection) - return unless project.predeploy_command.present? - - run_command(project.predeploy_command, project, "predeploy", connection) - end - - def postdeploy(project, connection) - return unless project.postdeploy_command.present? - - run_command(project.postdeploy_command, project, "postdeploy", connection) - end - - def run_command(command, project, type, connection) - command_job = K8::Stateless::Command.new(project, type, command).connect(connection) - command_job.delete_if_exists! - @kubectl.apply_yaml(command_job.to_yaml) - command_job.wait_for_completion + service_class.new(deployment, user).deploy end end diff --git a/app/models/deployment_configuration.rb b/app/models/deployment_configuration.rb new file mode 100644 index 00000000..95851f4d --- /dev/null +++ b/app/models/deployment_configuration.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: deployment_configurations +# +# id :bigint not null, primary key +# deployment_method :integer default("legacy"), not null +# created_at :datetime not null +# updated_at :datetime not null +# project_id :bigint not null +# +# Indexes +# +# index_deployment_configurations_on_project_id (project_id) +# +# Foreign Keys +# +# fk_rails_... (project_id => projects.id) +# +class DeploymentConfiguration < ApplicationRecord + belongs_to :project + + validates :project, presence: true + validates :deployment_method, presence: true + + enum :deployment_method, { + legacy: 0, + helm: 1 + } +end diff --git a/app/models/project.rb b/app/models/project.rb index bbb015fd..2765d18a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -51,6 +51,7 @@ class Project < ApplicationRecord has_one :project_credential_provider, dependent: :destroy has_one :build_configuration, dependent: :destroy + has_one :deployment_configuration, dependent: :destroy has_one :child_fork, class_name: "ProjectFork", foreign_key: :child_project_id, dependent: :destroy has_many :forks, class_name: "ProjectFork", foreign_key: :parent_project_id, dependent: :destroy diff --git a/app/models/provider.rb b/app/models/provider.rb index 4b650668..37b0b41e 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -15,14 +15,18 @@ # created_at :datetime not null # updated_at :datetime not null # external_id :string +# sso_provider_id :bigint # user_id :bigint not null # # Indexes # -# index_providers_on_user_id (user_id) +# index_providers_on_sso_provider_id (sso_provider_id) +# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL) +# index_providers_on_user_id (user_id) # # Foreign Keys # +# fk_rails_... (sso_provider_id => sso_providers.id) # fk_rails_... (user_id => users.id) # class Provider < ApplicationRecord diff --git a/app/services/deployments/base_deployment_service.rb b/app/services/deployments/base_deployment_service.rb new file mode 100644 index 00000000..8c3a9f0d --- /dev/null +++ b/app/services/deployments/base_deployment_service.rb @@ -0,0 +1,76 @@ +class Deployments::BaseDeploymentService + DEPLOYABLE_RESOURCES = %w[ConfigMap Secrets Deployment CronJob Service Ingress Pv Pvc].freeze + + def initialize(deployment, user) + @deployment = deployment + @user = user + @project = deployment.project + @logger = deployment + end + + def deploy + raise NotImplementedError, "Subclasses must implement #deploy" + end + + private + + def setup_connection + @connection = K8::Connection.new(@project, @user, allow_anonymous: true) + @kubectl = K8::Kubectl.new(@connection) + end + + def apply_namespace + @logger.info("Creating namespace: #{@project.namespace}", color: :yellow) + namespace_yaml = K8::Namespace.new(@project).to_yaml + @kubectl.apply_yaml(namespace_yaml) + end + + def upload_registry_secrets + @logger.info("Creating registry secret for #{@project.container_image_reference}", color: :yellow) + provider = @project.build_provider + result = Providers::GenerateConfigJson.execute(provider:) + raise StandardError, result.message if result.failure? + + secret_yaml = K8::Secrets::RegistrySecret.new(@project, result.docker_config_json).to_yaml + @kubectl.apply_yaml(secret_yaml) + end + + def deploy_services + @project.services.each do |service| + deploy_service(service) + end + end + + def deploy_service(service) + raise NotImplementedError, "Subclasses must implement #deploy_service" + end + + def kill_one_off_containers + @kubectl.call("-n #{@project.namespace} delete pods -l oneoff=true") + end + + def predeploy + return unless @project.predeploy_command.present? + + run_command(@project.predeploy_command, "predeploy") + end + + def postdeploy + return unless @project.postdeploy_command.present? + + run_command(@project.postdeploy_command, "postdeploy") + end + + def run_command(command, type) + @logger.info("Running command: `#{command}`...", color: :yellow) + command_job = K8::Stateless::Command.new(@project, type, command).connect(@connection) + command_job.delete_if_exists! + @kubectl.apply_yaml(command_job.to_yaml) + command_job.wait_for_completion + end + + def complete_deployment! + @deployment.completed! + @project.deployed! + end +end diff --git a/app/services/deployments/deployment_failure.rb b/app/services/deployments/deployment_failure.rb new file mode 100644 index 00000000..1e06b415 --- /dev/null +++ b/app/services/deployments/deployment_failure.rb @@ -0,0 +1 @@ +class Deployments::DeploymentFailure < StandardError; end diff --git a/app/services/deployments/helm_deployment_service.rb b/app/services/deployments/helm_deployment_service.rb new file mode 100644 index 00000000..3650069c --- /dev/null +++ b/app/services/deployments/helm_deployment_service.rb @@ -0,0 +1,71 @@ +class Deployments::HelmDeploymentService < Deployments::BaseDeploymentService + def deploy + setup_connection + setup_chart_builder + + apply_namespace if @project.managed_namespace? + upload_registry_secrets + apply_config_and_secrets + + deploy_volumes + predeploy + deploy_services + @chart_builder.install_chart(@project.name) + kill_one_off_containers + postdeploy + + mark_services_healthy + complete_deployment! + end + + private + + def setup_chart_builder + @chart_builder = K8::Helm::ChartBuilder.new( + @project.name, + @deployment + ).connect(@connection) + + @chart_builder.register_before_install do |yaml_content| + @deployment.add_manifest(yaml_content) + end + end + + def apply_config_and_secrets + @chart_builder << build_resource("ConfigMap", @project) + @chart_builder << build_resource("Secrets", @project) + end + + def build_resource(resource_type, target) + K8::Stateless.const_get(resource_type).new(target) + end + + def deploy_volumes + @project.volumes.each do |volume| + @chart_builder << build_resource("Pv", volume) + @chart_builder << build_resource("Pvc", volume) + volume.deployed! + rescue StandardError => e + volume.failed! + raise e + end + end + + def deploy_service(service) + if service.background_service? + @chart_builder << build_resource("Deployment", service) + elsif service.cron_job? + @chart_builder << build_resource("CronJob", service) + elsif service.web_service? + @chart_builder << build_resource("Deployment", service) + @chart_builder << build_resource("Service", service) + if service.domains.any? && service.allow_public_networking? + @chart_builder << build_resource("Ingress", service) + end + end + end + + def mark_services_healthy + @project.services.each(&:healthy!) + end +end diff --git a/app/services/deployments/legacy_deployment_service.rb b/app/services/deployments/legacy_deployment_service.rb new file mode 100644 index 00000000..9918415f --- /dev/null +++ b/app/services/deployments/legacy_deployment_service.rb @@ -0,0 +1,96 @@ +class Deployments::LegacyDeploymentService < Deployments::BaseDeploymentService + def initialize(deployment, user) + super + @marked_resources = [] + end + + def deploy + setup_connection + + apply_namespace if @project.managed_namespace? + upload_registry_secrets + apply_resource("ConfigMap", @project) + apply_resource("Secrets", @project) + + deploy_volumes + predeploy + deploy_services + sweep_unused_resources + kill_one_off_containers + postdeploy + + complete_deployment! + rescue StandardError => e + @logger.error("Deployment failed: #{e.message}") + puts e.full_message + @deployment.failed! + end + + private + + def setup_connection + @connection = K8::Connection.new(@project, @user, allow_anonymous: true) + runner = Cli::RunAndLog.new(@deployment) + @kubectl = K8::Kubectl.new(@connection, runner) + end + + def apply_resource(resource_type, target) + @logger.info("Creating #{resource_type}: #{target.name}", color: :yellow) + resource = K8::Stateless.const_get(resource_type).new(target) + @kubectl.apply_yaml(resource.to_yaml) + @marked_resources << resource + end + + def deploy_volumes + @project.volumes.each do |volume| + apply_resource("Pv", volume) + apply_resource("Pvc", volume) + volume.deployed! + rescue StandardError => e + @logger.error("Volume deployment failed: #{e.message}") + volume.failed! + raise e + end + end + + def deploy_service(service) + if service.background_service? + apply_resource("Deployment", service) + restart_deployment(service) + elsif service.cron_job? + apply_resource("CronJob", service) + elsif service.web_service? + apply_resource("Deployment", service) + apply_resource("Service", service) + if service.domains.any? && service.allow_public_networking? + apply_resource("Ingress", service) + end + restart_deployment(service) + end + service.healthy! + end + + def restart_deployment(service) + @logger.info("Restarting deployment: #{service.name}", color: :yellow) + @kubectl.call("-n #{service.project.namespace} rollout restart deployment/#{service.name}") + end + + def sweep_unused_resources + resources_to_sweep = DEPLOYABLE_RESOURCES.reject { |r| [ "Pv" ].include?(r) } + 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.namespace}")) + results["items"].each do |resource| + if @marked_resources.select { |r| + r.is_a?(K8::Stateless.const_get(resource_type)) + }.none? { |applied_resource| + applied_resource.name == resource["metadata"]["name"] + } && 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.namespace}") + end + end + end + end +end diff --git a/app/services/k8/stateless/command.rb b/app/services/k8/stateless/command.rb index 51e69477..08ae2d85 100644 --- a/app/services/k8/stateless/command.rb +++ b/app/services/k8/stateless/command.rb @@ -29,7 +29,7 @@ class K8::Stateless::Command < K8::Base sleep(3.0) retries += 1 if retries > 30 - raise Projects::DeploymentJob::DeploymentFailure, "Predeploy command `#{command}` took too long to complete" + raise Deployments::DeploymentFailure, "Predeploy command `#{command}` took too long to complete" end end end @@ -37,7 +37,7 @@ class K8::Stateless::Command < K8::Base def done? _statuses = statuses if _statuses.include?("Failed") || _statuses.include?("ActiveDeadlineExceeded") - raise Projects::DeploymentJob::DeploymentFailure, "Predeploy command `#{command}` failed" + raise Deployments::DeploymentFailure, "Predeploy command `#{command}` failed" end _statuses.include?("Complete") end diff --git a/db/migrate/20251213221841_create_deployment_configurations.rb b/db/migrate/20251213221841_create_deployment_configurations.rb new file mode 100644 index 00000000..38f6e659 --- /dev/null +++ b/db/migrate/20251213221841_create_deployment_configurations.rb @@ -0,0 +1,18 @@ +class CreateDeploymentConfigurations < ActiveRecord::Migration[7.2] + def change + create_table :deployment_configurations do |t| + t.references :project, null: false, foreign_key: true + t.integer :deployment_method, null: false, default: 0 + + t.timestamps + end + + reversible do |dir| + dir.up do + Project.find_each do |project| + DeploymentConfiguration.create!(project: project, deployment_method: :legacy) + end + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 191dc5d2..bfbb10f7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do +ActiveRecord::Schema[7.2].define(version: 2025_12_13_221841) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -168,6 +168,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do t.index ["service_id"], name: "index_cron_schedules_on_service_id" end + create_table "deployment_configurations", force: :cascade do |t| + t.bigint "project_id", null: false + t.integer "deployment_method", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_deployment_configurations_on_project_id" + end + create_table "deployments", force: :cascade do |t| t.bigint "build_id", null: false t.integer "status", default: 0, null: false @@ -393,6 +401,22 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient" 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.string "uid_claim", default: "sub", null: false + t.string "email_claim", default: "email" + t.string "name_claim", default: "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "project_add_ons", force: :cascade do |t| t.bigint "project_id", null: false t.bigint "add_on_id", null: false @@ -464,6 +488,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do t.datetime "last_used_at" t.string "registry_url" t.string "external_id" + t.bigint "sso_provider_id" + t.index ["sso_provider_id", "uid"], name: "index_providers_on_sso_provider_id_and_uid", unique: true, where: "(sso_provider_id IS NOT NULL)" + t.index ["sso_provider_id"], name: "index_providers_on_sso_provider_id" t.index ["user_id"], name: "index_providers_on_user_id" end @@ -597,6 +624,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do add_foreign_key "builds", "projects" add_foreign_key "clusters", "accounts" add_foreign_key "cron_schedules", "services" + add_foreign_key "deployment_configurations", "projects" add_foreign_key "deployments", "builds" add_foreign_key "environment_variables", "projects" add_foreign_key "project_add_ons", "add_ons" @@ -607,6 +635,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_26_014509) do add_foreign_key "project_forks", "projects", column: "parent_project_id" add_foreign_key "projects", "clusters" add_foreign_key "projects", "clusters", column: "project_fork_cluster_id" + add_foreign_key "providers", "sso_providers" add_foreign_key "providers", "users" add_foreign_key "services", "projects" add_foreign_key "sso_providers", "accounts" diff --git a/spec/factories/deployment_configurations.rb b/spec/factories/deployment_configurations.rb new file mode 100644 index 00000000..edb03d81 --- /dev/null +++ b/spec/factories/deployment_configurations.rb @@ -0,0 +1,24 @@ +# == Schema Information +# +# Table name: deployment_configurations +# +# id :bigint not null, primary key +# deployment_method :integer default("legacy"), not null +# created_at :datetime not null +# updated_at :datetime not null +# project_id :bigint not null +# +# Indexes +# +# index_deployment_configurations_on_project_id (project_id) +# +# Foreign Keys +# +# fk_rails_... (project_id => projects.id) +# +FactoryBot.define do + factory :deployment_configuration do + project + deployment_method { :legacy } + end +end diff --git a/spec/factories/providers.rb b/spec/factories/providers.rb index 1d956e83..c3e2c524 100644 --- a/spec/factories/providers.rb +++ b/spec/factories/providers.rb @@ -15,14 +15,18 @@ # created_at :datetime not null # updated_at :datetime not null # external_id :string +# sso_provider_id :bigint # user_id :bigint not null # # Indexes # -# index_providers_on_user_id (user_id) +# index_providers_on_sso_provider_id (sso_provider_id) +# index_providers_on_sso_provider_id_and_uid (sso_provider_id,uid) UNIQUE WHERE (sso_provider_id IS NOT NULL) +# index_providers_on_user_id (user_id) # # Foreign Keys # +# fk_rails_... (sso_provider_id => sso_providers.id) # fk_rails_... (user_id => users.id) # FactoryBot.define do diff --git a/spec/jobs/projects/helm_deployment_job_spec.rb b/spec/services/deployments/helm_deployment_service_spec.rb similarity index 95% rename from spec/jobs/projects/helm_deployment_job_spec.rb rename to spec/services/deployments/helm_deployment_service_spec.rb index 9dec3d18..71688c41 100644 --- a/spec/jobs/projects/helm_deployment_job_spec.rb +++ b/spec/services/deployments/helm_deployment_service_spec.rb @@ -22,12 +22,12 @@ class MockChartBuilder end end -RSpec.describe Projects::HelmDeploymentJob do +RSpec.describe Deployments::HelmDeploymentService do let(:project) { create(:project) } let(:build) { create(:build, project: project) } let(:deployment) { create(:deployment, build: build) } let(:user) { project.account.owner } - let(:job) { described_class.new } + let(:service_instance) { described_class.new(deployment, user) } let(:mock_chart_builder) { MockChartBuilder.new } let!(:web_service) do @@ -61,7 +61,7 @@ RSpec.describe Projects::HelmDeploymentJob do double(failure?: false, docker_config_json: '{}') ) - job.perform(deployment, user) + service_instance.deploy end def find_resource(kind, name = nil) diff --git a/spec/services/deployments/legacy_deployment_service_spec.rb b/spec/services/deployments/legacy_deployment_service_spec.rb new file mode 100644 index 00000000..847a903c --- /dev/null +++ b/spec/services/deployments/legacy_deployment_service_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe Deployments::LegacyDeploymentService do + let(:project) { create(:project) } + let(:build) { create(:build, project: project) } + let(:deployment) { create(:deployment, build: build) } + let(:user) { project.account.owner } + let(:service_instance) { described_class.new(deployment, user) } + let(:applied_yamls) { [] } + + let!(:web_service) do + create(:service, + project: project, + name: 'web', + service_type: :web_service, + allow_public_networking: true + ).tap { |s| create(:domain, service: s, domain_name: 'example.com') } + end + + let!(:worker_service) do + create(:service, :background_service, + project: project, + name: 'worker' + ) + end + + let!(:cron_service) do + create(:service, :cron_job, + project: project, + name: 'scheduler' + ) + end + + before do + allow_any_instance_of(K8::Kubectl).to receive(:apply_yaml) do |_instance, yaml| + applied_yamls << yaml + end + allow_any_instance_of(K8::Kubectl).to receive(:call).and_return("items: []") + allow(Providers::GenerateConfigJson).to receive(:execute).and_return( + double(failure?: false, docker_config_json: '{}') + ) + + service_instance.deploy + end + + def find_applied_resource(kind, name = nil) + applied_yamls.find do |yaml_str| + yaml = YAML.safe_load(yaml_str) + matches_kind = yaml['kind'] == kind + matches_name = name.nil? || yaml.dig('metadata', 'name') == name + matches_kind && matches_name + end + end + + def parse_yaml(yaml_str) + YAML.safe_load(yaml_str) + end + + describe 'deployment status' do + it 'marks deployment as completed' do + expect(deployment.reload.status).to eq('completed') + end + + it 'marks project as deployed' do + expect(project.reload.status).to eq('deployed') + end + + it 'marks services as healthy' do + expect(web_service.reload.status).to eq('healthy') + expect(worker_service.reload.status).to eq('healthy') + expect(cron_service.reload.status).to eq('healthy') + end + end + + describe 'web service resources' do + it 'applies a Deployment' do + yaml_str = find_applied_resource('Deployment', 'web') + expect(yaml_str).to be_present + + yaml = parse_yaml(yaml_str) + expect(yaml.dig('spec', 'selector', 'matchLabels', 'app')).to eq('web') + end + + it 'applies a Service' do + yaml_str = find_applied_resource('Service', 'web-service') + expect(yaml_str).to be_present + + yaml = parse_yaml(yaml_str) + expect(yaml.dig('spec', 'selector', 'app')).to eq('web') + end + + it 'applies an Ingress with domain' do + yaml_str = find_applied_resource('Ingress', 'web-ingress') + expect(yaml_str).to be_present + + yaml = parse_yaml(yaml_str) + expect(yaml.dig('spec', 'rules', 0, 'host')).to eq('example.com') + end + end + + describe 'background worker resources' do + it 'applies a Deployment' do + yaml_str = find_applied_resource('Deployment', 'worker') + expect(yaml_str).to be_present + + yaml = parse_yaml(yaml_str) + expect(yaml.dig('spec', 'selector', 'matchLabels', 'app')).to eq('worker') + end + + it 'does not apply a Service' do + yaml_str = find_applied_resource('Service', 'worker-service') + expect(yaml_str).to be_nil + end + end + + describe 'cron job resources' do + it 'applies a CronJob with schedule' do + yaml_str = find_applied_resource('CronJob', 'scheduler') + expect(yaml_str).to be_present + + yaml = parse_yaml(yaml_str) + expect(yaml.dig('spec', 'schedule')).to be_present + end + end + + describe 'shared resources' do + it 'applies ConfigMap' do + yaml_str = find_applied_resource('ConfigMap') + expect(yaml_str).to be_present + end + + it 'applies Secrets' do + yaml_str = find_applied_resource('Secret') + expect(yaml_str).to be_present + end + + it 'applies Namespace when managed' do + yaml_str = find_applied_resource('Namespace') + expect(yaml_str).to be_present + end + end +end