added specs for project forks and yaml templating

This commit is contained in:
Chris
2025-07-13 12:14:47 -07:00
parent c988155b5c
commit 7cb07c632e
18 changed files with 630 additions and 96 deletions

View File

@@ -1,44 +1,10 @@
class ProjectForks::Create class ProjectForks::Create
extend LightService::Action extend LightService::Organizer
expects :parent_project, :pull_request def self.call(parent_project:, pull_request:)
promises :project_fork with(parent_project:, pull_request:).reduce(
ProjectForks::ForkProject,
executed do |context| ProjectForks::InitializeFromCanineConfig
parent_project = context.parent_project )
pull_request = context.pull_request
child_project = parent_project.dup
child_project.branch = pull_request.branch
child_project.name = "#{parent_project.name}-#{pull_request.number}"
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
child_project_credential_provider.project = child_project
# Duplicate the services
new_services = parent_project.services.map do |service|
new_service = service.dup
new_service.allow_public_networking = false
new_service.project = child_project
new_service
end
ActiveRecord::Base.transaction do
context.project_fork = ProjectFork.new(
child_project:,
parent_project:,
external_id: pull_request.id,
number: pull_request.number,
title: pull_request.title,
url: pull_request.url,
user: pull_request.user,
)
child_project.save!
child_project_credential_provider.save!
new_services.each(&:save!)
context.project_fork.save!
end
rescue StandardError => e
context.fail_and_return!("Failed to create project fork: #{e.message}")
end end
end end

View File

@@ -0,0 +1,46 @@
class ProjectForks::ForkProject
extend LightService::Action
expects :parent_project, :pull_request
promises :project_fork
executed do |context|
parent_project = context.parent_project
pull_request = context.pull_request
child_project = parent_project.dup
child_project.branch = pull_request.branch
child_project.name = "#{parent_project.name}-#{pull_request.number}"
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
child_project_credential_provider.project = child_project
# Duplicate the services
ActiveRecord::Base.transaction do
context.project_fork = ProjectFork.new(
child_project:,
parent_project:,
external_id: pull_request.id,
number: pull_request.number,
title: pull_request.title,
url: pull_request.url,
user: pull_request.user,
)
child_project.save!
child_project_credential_provider.save!
context.project_fork.save!
# Fetch and store canine config if it exists
client = Git::Client.from_project(child_project)
file = client.get_file('.canine.yml', pull_request.branch)
if file.present?
# Parse and store the config
canine_config = CanineConfig::Definition.parse(file.content, parent_project, pull_request)
context.project_fork.update!(canine_config: canine_config.to_hash)
end
end
rescue StandardError => e
context.fail_and_return!("Failed to create project fork: #{e.message}")
end
end

View File

@@ -1,12 +1,26 @@
class ProjectForks::CreateFromCanineConfig class ProjectForks::InitializeFromCanineConfig
extend LightService::Action extend LightService::Action
expects :canine_config, :project expects :project_fork
executed do |context| executed do |context|
context.canine_config.services.each do |service_config| # Skip this action if no canine_config is stored
next if context.project_fork.canine_config.blank?
config_data = context.project_fork.canine_config
# Create services from the stored config
config_data['services']&.each do |service_config|
params = Service.permitted_params(ActionController::Parameters.new(service: service_config))
service = context.project_fork.child_project.services.build(params)
service.save!
end end
context.canine_config.environment_variables.each do |environment_variable| # Create environment variables from the stored config
config_data['environment_variables']&.each do |env_var|
context.project_fork.child_project.environment_variables.create!(
name: env_var['name'],
value: env_var['value']
)
end end
end end
end end

View File

@@ -3,6 +3,7 @@
# Table name: project_forks # Table name: project_forks
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# canine_config :jsonb
# clean_up_command :text # clean_up_command :text
# number :string not null # number :string not null
# title :string not null # title :string not null

View File

@@ -1,8 +1,25 @@
class CanineConfig::Definition class CanineConfig::Definition
attr_reader :definition attr_reader :definition
def initialize(yaml_path, base_project, pull_request) def self.parse(yaml_content, base_project, pull_request)
context = { context = build_context(base_project, pull_request)
parsed_content = if yaml_content.include?('<%')
erb = ERB.new(yaml_content)
context_binding = binding
context.each do |key, value|
context_binding.local_variable_set(key, value)
end
erb.result(context_binding)
else
yaml_content
end
new(YAML.safe_load(parsed_content))
end
def self.build_context(base_project, pull_request)
{
"cluster_id": base_project.project_fork_cluster_id, "cluster_id": base_project.project_fork_cluster_id,
"cluster_name": base_project.project_fork_cluster.name, "cluster_name": base_project.project_fork_cluster.name,
"project_name": "#{base_project.name}-#{pull_request.number}", "project_name": "#{base_project.name}-#{pull_request.number}",
@@ -11,19 +28,10 @@ class CanineConfig::Definition
"branch_name": pull_request.branch, "branch_name": pull_request.branch,
"username": pull_request.user "username": pull_request.user
} }
end
content = if yaml_path.to_s.end_with?('.erb') def initialize(definition)
erb = ERB.new(File.read(yaml_path)) @definition = definition
context_binding = binding
context.each do |key, value|
context_binding.local_variable_set(key, value)
end
erb.result(context_binding)
else
File.read(yaml_path)
end
@definition = YAML.load(content)
end end
def services def services
@@ -38,4 +46,8 @@ class CanineConfig::Definition
EnvironmentVariable.new(name: env['name'], value: env['value']) EnvironmentVariable.new(name: env['name'], value: env['value'])
end end
end end
def to_hash
@definition
end
end end

View File

@@ -0,0 +1 @@
class Git::Common::File < Struct.new(:path, :content, :branch); end

View File

@@ -81,6 +81,15 @@ class Git::Github::Client < Git::Client
end end
end end
def get_file(file_path, branch)
contents = client.contents(repository_url, path: file_path, ref: branch)
return nil if contents.nil?
Git::Common::File.new(file_path, Base64.decode64(contents.content), branch)
rescue Octokit::NotFound
nil
end
private private
def webhook_secret def webhook_secret

View File

@@ -109,4 +109,12 @@ class Git::Gitlab::Client < Git::Client
) )
end end
end end
def get_file(file_path, branch)
response = HTTParty.get(
"#{GITLAB_API_BASE}/projects/#{encoded_url}/repository/files/#{URI.encode_www_form_component(file_path)}/raw?ref=#{branch}",
headers: { "Authorization" => "Bearer #{access_token}" }
)
response.success? ? response.body : nil
end
end end

View File

@@ -9,6 +9,7 @@ class CreateProjectForks < ActiveRecord::Migration[7.2]
t.string :title, null: false t.string :title, null: false
t.string :url, null: false t.string :url, null: false
t.string :user, null: false t.string :user, null: false
t.jsonb :canine_config, default: {}
t.text :clean_up_command t.text :clean_up_command

1
db/schema.rb generated
View File

@@ -239,6 +239,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_29_164951) do
t.string "title", null: false t.string "title", null: false
t.string "url", null: false t.string "url", null: false
t.string "user", null: false t.string "user", null: false
t.jsonb "canine_config", default: {}
t.text "clean_up_command" t.text "clean_up_command"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false

View File

@@ -1,23 +1,56 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe ProjectForks::Create do RSpec.describe ProjectForks::Create do
class MockPr < Struct.new(:id, :title, :number, :branch); end let(:account) { create(:account) }
let(:fork_cluster) { create(:cluster, account:) }
let(:base_project) { create(:project) } let(:parent_project) { create(:project, project_fork_cluster_id: fork_cluster.id, account:) }
let!(:service_1) { create(:service, project: base_project) } let!(:service_1) { create(:service, project: parent_project) }
let!(:service_2) { create(:service, project: base_project) } let!(:service_2) { create(:service, project: parent_project) }
let(:pull_request) { MockPr.new(id: "1", title: "Fake title", number: "7301", branch: "feature/test") } let(:pull_request) do
Git::Common::PullRequest.new(
it 'can create a project fork from a base project' do id: "1",
result = described_class.execute(base_project:, pull_request:) title: "Fake title",
expect(result).to be_success number: "7301",
base_project.reload branch: "feature/test",
expect(base_project.forks.count).to eq(1) user: "testuser",
url: "https://github.com/test/repo/pull/7301",
)
end end
let(:git_client) { instance_double(Git::Github::Client) }
it 'clones over services, project credential providers' do context 'with a simple yaml file' do
result = described_class.execute(base_project:, pull_request:) before do
result. allow(Git::Client).to receive(:from_project).and_return(git_client)
expect(base_project.forks.first.new_project.services.count).to eq(2) allow(git_client).to receive(:get_file).with('.canine.yml', 'feature/test').and_return(
Git::Common::File.new(
'.canine.yml',
File.read(Rails.root.join('spec', 'resources', 'canine_config', 'example_1.yaml')),
'feature/test'
)
)
end
it 'can create a project fork from a base project' do
result = described_class.call(parent_project:, pull_request:)
expect(result).to be_success
parent_project.reload
expect(parent_project.forks.count).to eq(1)
end
end
context 'with a yaml file with erb' do
before do
allow(Git::Client).to receive(:from_project).and_return(git_client)
allow(git_client).to receive(:get_file).with('.canine.yml', 'feature/test').and_return(
Git::Common::File.new(
'.canine.yml',
File.read(Rails.root.join('spec', 'resources', 'canine_config', 'example_2.yaml.erb')),
'feature/test'
)
)
end
it 'can create a project fork from a base project' do
expect { described_class.call(parent_project:, pull_request:) }.to change { parent_project.forks.count }.by(1)
end
end end
end end

View File

@@ -0,0 +1,122 @@
require 'rails_helper'
RSpec.describe ProjectForks::ForkProject do
let(:account) { create(:account) }
let(:cluster) { create(:cluster, account:) }
let(:parent_project) { create(:project, account:, cluster:, project_fork_cluster_id: cluster.id) }
let(:provider) { create(:provider, :github, user: account.owner) }
let!(:project_credential_provider) { create(:project_credential_provider, project: parent_project, provider:) }
let(:pull_request) do
Git::Common::PullRequest.new(
id: "123",
title: "Test PR",
number: "42",
branch: "feature/test",
user: "testuser",
url: "https://github.com/test/repo/pull/42"
)
end
let(:git_client) { instance_double(Git::Github::Client) }
before do
# Mock Git client for all tests to avoid authentication issues
allow(Git::Client).to receive(:from_project).and_return(git_client)
allow(git_client).to receive(:get_file).with('.canine.yml', 'feature/test').and_return(nil)
end
describe '#execute' do
context 'when successful' do
let(:result) { described_class.execute(parent_project:, pull_request:) }
it 'creates a project fork' do
expect { result }.to change { ProjectFork.count }.by(1)
expect(result).to be_success
end
it 'creates a child project with correct attributes' do
result
project_fork = result.project_fork
child_project = project_fork.child_project
expect(child_project.name).to eq("#{parent_project.name}-42")
expect(child_project.branch).to eq("feature/test")
expect(child_project.cluster_id).to eq(parent_project.project_fork_cluster_id)
end
it 'duplicates the project credential provider' do
expect { result }.to change { ProjectCredentialProvider.count }.by(1)
child_project = result.project_fork.child_project
expect(child_project.project_credential_provider).to be_present
expect(child_project.project_credential_provider.provider).to eq(provider)
end
it 'sets project fork attributes correctly' do
result
project_fork = result.project_fork
expect(project_fork.parent_project).to eq(parent_project)
expect(project_fork.external_id).to eq("123")
expect(project_fork.number).to eq("42")
expect(project_fork.title).to eq("Test PR")
expect(project_fork.url).to eq("https://github.com/test/repo/pull/42")
expect(project_fork.user).to eq("testuser")
end
it 'includes project_fork in the context' do
expect(result.project_fork).to be_a(ProjectFork)
end
end
context 'with canine config file' do
let(:canine_config_content) do
Git::Common::File.new(
'.canine.yml',
File.read(Rails.root.join('spec', 'resources', 'canine_config', 'example_1.yaml')),
'feature/test'
)
end
before do
allow(git_client).to receive(:get_file).with('.canine.yml', 'feature/test').and_return(canine_config_content)
end
it 'fetches and stores the canine config' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_success
project_fork = result.project_fork
expect(project_fork.canine_config).to be_present
expect(project_fork.canine_config['services']).to be_an(Array)
expect(project_fork.canine_config['services'].first['name']).to eq('web')
expect(project_fork.canine_config['environment_variables']).to be_an(Array)
expect(project_fork.canine_config['environment_variables'].first['name']).to eq('DATABASE_URL')
end
end
context 'without canine config file' do
# Git client is already mocked in the parent before block
it 'still creates the project fork successfully' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_success
expect(result.project_fork.canine_config).to eq({})
end
end
context 'when transaction fails' do
before do
allow_any_instance_of(ProjectFork).to receive(:save!).and_raise(ActiveRecord::RecordInvalid)
end
it 'returns failure context' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_failure
expect(result.message).to include("Failed to create project fork")
end
end
end
end

View File

@@ -0,0 +1,107 @@
require 'rails_helper'
RSpec.describe ProjectForks::InitializeFromCanineConfig do
let(:account) { create(:account) }
let(:cluster) { create(:cluster, account:) }
let(:parent_project) { create(:project, account:, cluster:) }
let(:child_project) { create(:project, account:, cluster:) }
let(:project_fork) { create(:project_fork, parent_project:, child_project:) }
describe '#execute' do
context 'when project fork has canine config' do
let(:canine_config) do
{
'services' => [
{
'name' => 'web',
'container_port' => 6379,
'service_type' => 'web_service'
},
{
'name' => 'worker',
'container_port' => 5432,
'service_type' => 'background_service'
}
],
'environment_variables' => [
{
'name' => 'DATABASE_URL',
'value' => 'postgres://localhost/test'
},
{
'name' => 'REDIS_URL',
'value' => 'redis://localhost:6379'
}
]
}
end
before do
project_fork.update!(canine_config:)
end
it 'creates services from the config' do
expect {
described_class.execute(project_fork:)
}.to change { child_project.services.count }.by(2)
services = child_project.services.order(:name)
expect(services[0].name).to eq('web')
expect(services[0].container_port).to eq(6379)
expect(services[0].service_type).to eq('web_service')
expect(services[1].name).to eq('worker')
expect(services[1].container_port).to eq(5432)
expect(services[1].service_type).to eq('background_service')
end
it 'creates environment variables from the config' do
expect {
described_class.execute(project_fork:)
}.to change { child_project.environment_variables.count }.by(2)
env_vars = child_project.environment_variables.order(:name)
expect(env_vars[0].name).to eq('DATABASE_URL')
expect(env_vars[0].value).to eq('postgres://localhost/test')
expect(env_vars[1].name).to eq('REDIS_URL')
expect(env_vars[1].value).to eq('redis://localhost:6379')
end
it 'returns success context' do
result = described_class.execute(project_fork:)
expect(result).to be_success
end
end
context 'when project fork has empty canine config' do
before do
project_fork.update!(canine_config: {})
end
it 'skips and returns success' do
result = described_class.execute(project_fork:)
expect(result).to be_success
expect(child_project.services.count).to eq(0)
expect(child_project.environment_variables.count).to eq(0)
end
end
context 'when project fork has no canine config' do
before do
project_fork.update!(canine_config: nil)
end
it 'skips and returns success' do
result = described_class.execute(project_fork:)
expect(result).to be_success
expect(child_project.services.count).to eq(0)
expect(child_project.environment_variables.count).to eq(0)
end
end
end
end

View File

@@ -3,6 +3,7 @@
# Table name: project_forks # Table name: project_forks
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# canine_config :jsonb
# clean_up_command :text # clean_up_command :text
# number :string not null # number :string not null
# title :string not null # title :string not null

View File

@@ -113,21 +113,21 @@ RSpec.describe Project, type: :model do
end end
describe 'forks' do describe 'forks' do
let(:base_project) { create(:project) } let(:parent_project) { create(:project) }
let!(:project_fork) { create(:project_fork, base_project:, new_project: project) } let!(:project_fork) { create(:project_fork, parent_project:, child_project: project) }
it 'can determine if a project can fork' do it 'can determine if a project can fork' do
expect(base_project.can_fork?).to be_falsey expect(parent_project.can_fork?).to be_falsey
expect(project.can_fork?).to be_falsey expect(project.can_fork?).to be_falsey
base_project.project_fork_status = :manually_create parent_project.project_fork_status = :manually_create
base_project.save! parent_project.save!
expect(base_project.can_fork?).to be_truthy expect(parent_project.can_fork?).to be_truthy
end end
it 'can tell if a project is a preview project' do it 'can tell if a project is a preview project' do
expect(base_project.preview?).to be_falsey expect(parent_project.forked?).to be_falsey
expect(project.preview?).to be_truthy expect(project.forked?).to be_truthy
end end
end end
end end

View File

@@ -0,0 +1,7 @@
services:
- name: web
container_port: 6379
service_type: web_service
environment_variables:
- name: DATABASE_URL
value: postgres://localhost/test

View File

@@ -0,0 +1,11 @@
services:
- name: "<%= project_name %>"
container_port: 3000
service_type: "web_service"
environment_variables:
- name: "DATABASE_URL"
value: "redis://redis.<%= cluster_name %>.svc.local/<%= number %>"
- name: "BRANCH"
value: "<%= branch_name %>"
- name: "USER"
value: "<%= username %>"

View File

@@ -1,10 +1,9 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe CanineConfig::Definition do RSpec.describe CanineConfig::Definition do
let(:yaml_path) { Rails.root.join('resources', 'canine_config', 'example_1.yaml.erb') }
let(:account) { create(:account) } let(:account) { create(:account) }
let(:cluster) { create(:cluster, account:) } let(:cluster) { create(:cluster, account:, name: 'test-cluster') }
let(:base_project) { create(:project, project_fork_cluster_id: cluster.id, account:) } let(:base_project) { create(:project, project_fork_cluster_id: cluster.id, account:, name: 'test-project') }
let(:pull_request) do let(:pull_request) do
Git::Common::PullRequest.new( Git::Common::PullRequest.new(
number: 42, number: 42,
@@ -14,25 +13,220 @@ RSpec.describe CanineConfig::Definition do
) )
end end
subject(:definition) { described_class.new(yaml_path, base_project, pull_request) } describe '.parse' do
context 'with plain YAML content' do
let(:yaml_content) do
<<~YAML
services:
- name: "web"
container_port: 3000
service_type: "web_service"
environment_variables:
- name: "API_KEY"
value: "test-key"
YAML
end
describe '#environment_variables' do it 'parses YAML content and returns a Definition instance' do
it 'returns environment variables from the definition' do definition = described_class.parse(yaml_content, base_project, pull_request)
env_vars = definition.environment_variables
expect(env_vars.count).to eq(1) expect(definition).to be_a(CanineConfig::Definition)
expect(env_vars.first.name).to eq('DATABASE_URL') expect(definition.to_hash).to include(
expect(env_vars.first.value).to eq('redis://redis.cluster.svc.local/42') 'services' => [
{
'name' => 'web',
'container_port' => 3000,
'service_type' => 'web_service'
}
],
'environment_variables' => [
{
'name' => 'API_KEY',
'value' => 'test-key'
}
]
)
end
end
context 'with ERB template content' do
let(:yaml_content) do
<<~YAML
services:
- name: "<%= project_name %>"
container_port: 3000
service_type: "web_service"
environment_variables:
- name: "DATABASE_URL"
value: "redis://redis.<%= cluster_name %>.svc.local/<%= number %>"
- name: "BRANCH"
value: "<%= branch_name %>"
- name: "USER"
value: "<%= username %>"
YAML
end
it 'interpolates ERB variables with context values' do
definition = described_class.parse(yaml_content, base_project, pull_request)
expect(definition.to_hash).to include(
'services' => [
{
'name' => 'test-project-42',
'container_port' => 3000,
'service_type' => 'web_service'
}
],
'environment_variables' => [
{
'name' => 'DATABASE_URL',
'value' => 'redis://redis.test-cluster.svc.local/42'
},
{
'name' => 'BRANCH',
'value' => 'feature/test'
},
{
'name' => 'USER',
'value' => 'testuser'
}
]
)
end
end
context 'with invalid YAML' do
let(:yaml_content) { "invalid: yaml: content:" }
it 'raises an error' do
expect {
described_class.parse(yaml_content, base_project, pull_request)
}.to raise_error(Psych::SyntaxError)
end
end
end
describe '.build_context' do
it 'returns a hash with all required context variables' do
context = described_class.build_context(base_project, pull_request)
expect(context).to eq({
cluster_id: cluster.id,
cluster_name: 'test-cluster',
project_name: 'test-project-42',
number: 42,
title: 'Test PR',
branch_name: 'feature/test',
username: 'testuser'
})
end
end
describe '#initialize' do
let(:definition_hash) do
{
'services' => [
{ 'name' => 'web', 'container_port' => 3000 }
],
'environment_variables' => [
{ 'name' => 'API_KEY', 'value' => 'secret' }
]
}
end
it 'stores the definition hash' do
definition = described_class.new(definition_hash)
expect(definition.definition).to eq(definition_hash)
end end
end end
describe '#services' do describe '#services' do
it 'returns services from the definition' do let(:definition_hash) do
{
'services' => [
{
'name' => 'web',
'container_port' => 3000,
'service_type' => 'web_service',
'extra_field' => 'should_be_filtered'
},
{
'name' => 'worker',
'container_port' => 4000,
'service_type' => 'background_service'
}
]
}
end
let(:definition) { described_class.new(definition_hash) }
before do
allow(Service).to receive(:permitted_params).and_return(
{ 'name' => 'web', 'container_port' => 3000, 'service_type' => 'web_service' },
{ 'name' => 'worker', 'container_port' => 4000, 'service_type' => 'background_service' }
)
end
it 'returns an array of Service objects' do
services = definition.services services = definition.services
expect(services.count).to eq(1) expect(services).to be_an(Array)
expect(services.first.name).to eq('service_1') expect(services.length).to eq(2)
expect(services.first.container_port).to eq(8080) expect(services).to all(be_a(Service))
end
it 'filters service parameters through Service.permitted_params' do
expect(Service).to receive(:permitted_params).exactly(2).times
definition.services
end
end
describe '#environment_variables' do
let(:definition_hash) do
{
'environment_variables' => [
{ 'name' => 'DATABASE_URL', 'value' => 'postgres://localhost/test' },
{ 'name' => 'REDIS_URL', 'value' => 'redis://localhost:6379' },
{ 'name' => 'SECRET_KEY', 'value' => 'abc123' }
]
}
end
let(:definition) { described_class.new(definition_hash) }
it 'returns an array of EnvironmentVariable objects' do
env_vars = definition.environment_variables
expect(env_vars).to be_an(Array)
expect(env_vars.length).to eq(3)
expect(env_vars).to all(be_a(EnvironmentVariable))
end
it 'creates EnvironmentVariable objects with correct attributes' do
env_vars = definition.environment_variables
expect(env_vars[0].name).to eq('DATABASE_URL')
expect(env_vars[0].value).to eq('postgres://localhost/test')
expect(env_vars[1].name).to eq('REDIS_URL')
expect(env_vars[1].value).to eq('redis://localhost:6379')
expect(env_vars[2].name).to eq('SECRET_KEY')
expect(env_vars[2].value).to eq('abc123')
end
end
describe '#to_hash' do
let(:definition_hash) do
{
'services' => [ { 'name' => 'web' } ],
'environment_variables' => [ { 'name' => 'KEY', 'value' => 'value' } ],
'extra_field' => 'extra_value'
}
end
let(:definition) { described_class.new(definition_hash) }
it 'returns the original definition hash' do
expect(definition.to_hash).to eq(definition_hash)
end end
end end
end end