added deployment split

This commit is contained in:
Chris
2025-12-13 14:48:42 -08:00
parent 0bed7cd7e1
commit f701cb09f4
15 changed files with 511 additions and 122 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
class Deployments::DeploymentFailure < StandardError; end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

31
db/schema.rb generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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