Merge branch 'main' into celina__portainer_url

This commit is contained in:
Celina Lopez
2025-08-25 15:50:08 -07:00
69 changed files with 1628 additions and 121 deletions

View File

@@ -136,6 +136,7 @@ rails console
3. **Background Jobs**: Test async operations with `perform_now` in specs
4. **Kubernetes Resources**: Update templates in `resources/k8/` for deployment changes
5. **Migrations**: Use strong migrations practices for zero-downtime deployments
6. **Linting**: Always run `rubocop -A` at the end of every development cycle
## Important Patterns

View File

@@ -55,7 +55,7 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
gem "database_cleaner-active_record", '~> 2.2.0'
gem "database_cleaner-active_record", '~> 2.2.2'
gem 'faker', '~> 3.5.2'
gem 'shoulda-matchers', '~> 6.0'
end
@@ -104,7 +104,7 @@ gem "cron2english", "~> 0.1.7"
gem "avo", "~> 3.23"
gem "sentry-ruby", "~> 5.23"
gem "sentry-rails", "~> 5.23"
gem "sentry-rails", "~> 5.26"
gem "sys-proctable", "~> 1.3"

View File

@@ -100,7 +100,7 @@ GEM
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
ast (2.4.3)
avo (3.23.0)
actionview (>= 6.1)
active_link_to
@@ -123,7 +123,7 @@ GEM
benchmark (0.4.1)
bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.18.4)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.0.2)
racc
@@ -149,9 +149,9 @@ GEM
cssbundling-rails (1.4.1)
railties (>= 6.0.0)
csv (3.3.0)
database_cleaner-active_record (2.2.0)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (~> 2.0)
database_cleaner-core (2.0.1)
date (3.4.1)
debug (1.10.0)
@@ -302,9 +302,10 @@ GEM
jsonpath (~> 1.0)
recursive-open-struct (~> 1.1, >= 1.1.1)
rest-client (~> 2.0)
language_server-protocol (3.17.0.3)
language_server-protocol (3.17.0.5)
light-service (0.20.0)
activesupport (>= 5.0, < 9.0)
lint_roller (1.1.0)
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
@@ -329,7 +330,7 @@ GEM
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.7.2)
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
@@ -374,7 +375,7 @@ GEM
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
omniauth (2.1.3)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
@@ -397,8 +398,8 @@ GEM
orm_adapter (0.5.0)
ostruct (0.6.2)
pagy (9.3.4)
parallel (1.26.3)
parser (3.3.5.0)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
pg (1.5.8)
@@ -407,6 +408,7 @@ GEM
pretender (0.3.4)
actionpack (>= 4.2)
prettyprint (0.2.0)
prism (1.4.0)
prop_initializer (0.2.0)
zeitwerk (>= 2.6.18)
pry (0.15.2)
@@ -416,7 +418,7 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (6.6.0)
puma (6.6.1)
nio4r (~> 2.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
@@ -469,7 +471,7 @@ GEM
psych (>= 4.0.0)
recursive-open-struct (1.1.3)
redcarpet (3.6.1)
regexp_parser (2.9.2)
regexp_parser (2.11.2)
reline (0.6.2)
io-console (~> 0.5)
rest-client (2.1.0)
@@ -499,34 +501,34 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.4)
rubocop (1.66.1)
rubocop (1.79.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.32.2, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.3)
parser (>= 3.3.1.0)
rubocop-minitest (0.36.0)
rubocop (>= 1.61, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.22.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.26.2)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.33.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails-omakase (1.0.0)
rubocop
rubocop-minitest
rubocop-performance
rubocop-rails
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -553,10 +555,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.23.0)
sentry-rails (5.26.0)
railties (>= 5.0)
sentry-ruby (~> 5.23.0)
sentry-ruby (5.23.0)
sentry-ruby (~> 5.26.0)
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda-matchers (6.4.0)
@@ -606,7 +608,9 @@ GEM
turbo-rails (>= 1.3.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.6.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
version_gem (1.1.4)
@@ -653,7 +657,7 @@ DEPENDENCIES
capybara
cron2english (~> 0.1.7)
cssbundling-rails
database_cleaner-active_record (~> 2.2.0)
database_cleaner-active_record (~> 2.2.2)
debug
devise (~> 4.9)
dotenv (~> 3.1)
@@ -697,7 +701,7 @@ DEPENDENCIES
rubyzip (~> 2.4)
sassc-rails (~> 2.1)
selenium-webdriver
sentry-rails (~> 5.23)
sentry-rails (~> 5.26)
sentry-ruby (~> 5.23)
shoulda-matchers (~> 6.0)
simplecov

View File

@@ -15,4 +15,5 @@
- [ ] Deployments API
- [ ] Pull request preview apps
- [ ] Update vocabulary on landing page
- [ ] Clear our historical logs
- [ ] Clear our historical logs
- [ ] log drain from application

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Clusters
class InstallBuildCloud
extend LightService::Action
expects :build_cloud
promises :build_cloud
executed do |context|
build_cloud = context.build_cloud
# Check if build cloud is already installed
# Create BuildCloud record (namespace will use default from migration)
build_cloud = K8::BuildCloudManager.install(build_cloud)
context.build_cloud = build_cloud
end
private
end
end

View File

@@ -35,6 +35,11 @@ class ProjectForks::ForkProject
if file.nil?
file = client.get_file('.canine.yml.erb', pull_request.branch)
end
if parent_project.build_configuration.present?
new_build_configuration = parent_project.build_configuration.dup
new_build_configuration.project = child_project
new_build_configuration.save!
end
if file.present?
# Parse and store the config

View File

@@ -26,11 +26,13 @@ module Projects
project = Project.new(create_params(params))
provider = find_provider(user, params)
project_credential_provider = create_project_credential_provider(project, provider)
build_configuration = create_build_configuration(project, params)
steps = create_steps(provider)
with(
project:,
project_credential_provider:,
build_configuration:,
params:,
user:
).reduce(*steps)
@@ -43,12 +45,25 @@ module Projects
)
end
def self.create_build_configuration(project, params)
build_config_params = params[:project][:build_configuration]
return nil unless build_config_params
BuildConfiguration.new(
project:,
driver: build_config_params[:driver],
build_cloud_id: build_config_params[:build_cloud_id],
provider_id: build_config_params[:provider_id] || project.project_credential_provider.provider_id
)
end
def self.create_steps(provider)
steps = []
if provider.git?
steps << Projects::ValidateGitRepository
end
steps << Projects::ValidateNamespaceAvailability
steps << Projects::Save
# Only register webhook in non-local mode

View File

@@ -2,6 +2,7 @@ class Projects::Save
extend LightService::Action
expects :project, :project_credential_provider
expects :build_configuration, default: nil
promises :project
executed do |context|
@@ -9,6 +10,7 @@ class Projects::Save
context.project.repository_url = context.project.repository_url.strip.downcase
context.project.save!
context.project_credential_provider.save!
context.build_configuration&.save!
end
rescue => e
context.fail_and_return!(e.message)

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Projects
class Update
extend LightService::Organizer
def self.call(project, params)
build_configuration = handle_build_configuration(project, params)
with(
project:,
build_configuration:,
params:
).reduce(
Projects::UpdateSave
)
end
def self.handle_build_configuration(project, params)
build_config_params = params[:project][:build_configuration]
return nil unless build_config_params.present?
build_config = project.build_configuration || project.build_build_configuration
build_config.assign_attributes(
driver: build_config_params[:driver],
build_cloud_id: build_config_params[:build_cloud_id]
)
build_config
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
module Projects
class UpdateSave
extend LightService::Action
expects :project, :params, :build_configuration
promises :project
executed do |context|
ActiveRecord::Base.transaction do
# Update project with permitted params
context.project.assign_attributes(Projects::Create.create_params(context.params))
context.project.repository_url = context.project.repository_url.strip.downcase if context.project.repository_url_changed?
context.project.save!
# Save build configuration if present
context.build_configuration&.save!
end
rescue => e
context.fail_and_return!(e.message)
end
end
end

View File

@@ -0,0 +1,31 @@
module Projects
class ValidateNamespaceAvailability
extend LightService::Action
expects :project
executed do |context|
project = context.project
cluster = project.cluster
begin
client = K8::Client.from_cluster(cluster)
existing_namespaces = client.get_namespaces
# Check if namespace already exists in Kubernetes
namespace_exists = existing_namespaces.any? do |ns|
ns.metadata.name == project.name
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

View File

@@ -0,0 +1,19 @@
class Avo::Resources::BuildCloud < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :cluster, as: :belongs_to
field :namespace, as: :text
field :status, as: :number
field :driver_version, as: :text
field :webhook_url, as: :text
field :installation_metadata, as: :code
field :installed_at, as: :date_time
field :error_message, as: :textarea
end
end

View File

@@ -0,0 +1,14 @@
class Avo::Resources::BuildConfiguration < Avo::BaseResource
# self.includes = []
# self.attachments = []
# self.search = {
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
# }
def fields
field :id, as: :id
field :project, as: :belongs_to
field :driver, as: :number
field :cluster, as: :belongs_to
end
end

View File

@@ -0,0 +1,8 @@
<div class="relative flex flex-1 items-center justify-center rounded-xl bg-base-200 px-4 py-3 font-medium">
<input class="peer hidden" type="radio" name="<%= @name %>" id="<%= @value %>" data-radio-selector-target="radio" data-action="change->radio-selector#toggle" value="<%= @value %>" <%= "checked" if @checked %> />
<label class="peer-checked:ring peer-checked:ring-primary absolute top-0 h-full w-full cursor-pointer rounded-xl" for="<%= @value %>"> </label>
<div class="peer-checked:border-transparent peer-checked:bg-primary peer-checked:ring-2 absolute left-4 h-5 w-5 rounded-full border-2 border-gray-500 bg-gray-600 ring-primary ring-offset-2 ring-offset-base-100"></div>
<span class="pointer-events-none z-10">
<%= content %>
</span>
</div>

View File

@@ -0,0 +1,7 @@
class RadioSelectCardComponent < ViewComponent::Base
def initialize(name:, value:, checked:)
@name = name
@value = value
@checked = checked
end
end

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::BuildCloudsController < Avo::ResourcesController
end

View File

@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::BuildConfigurationsController < Avo::ResourcesController
end

View File

@@ -0,0 +1,75 @@
class Clusters::BuildCloudsController < Clusters::BaseController
include StorageHelper
def show
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
render partial: "clusters/build_clouds/show", locals: { cluster: @cluster }
end
def edit
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
render partial: "clusters/build_clouds/edit", locals: { cluster: @cluster, build_cloud: @build_cloud }
end
def update
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
if @build_cloud.update(build_cloud_params)
Clusters::InstallBuildCloudJob.perform_later(@build_cloud)
render partial: "clusters/build_clouds/show", locals: { cluster: @cluster }
else
render partial: "clusters/build_clouds/edit", locals: { cluster: @cluster, build_cloud: @build_cloud }
end
end
def create
if @cluster.build_cloud.present? && !@cluster.build_cloud.uninstalled?
redirect_to edit_cluster_path(@cluster), alert: "Build cloud is already installed on this cluster"
return
end
build_cloud = @cluster.create_build_cloud!
Clusters::InstallBuildCloudJob.perform_later(build_cloud)
redirect_to edit_cluster_path(@cluster), notice: "Build cloud installation started. This may take a few minutes..."
end
def destroy
@build_cloud = @cluster.build_cloud
if @build_cloud.blank?
redirect_to edit_cluster_path(@cluster), alert: "No build cloud found for this cluster"
return
end
if @build_cloud.uninstalling?
redirect_to edit_cluster_path(@cluster), alert: "Build cloud is already being uninstalled"
return
end
Clusters::DestroyBuildCloudJob.perform_later(@cluster)
redirect_to edit_cluster_path(@cluster), notice: "Build cloud removal started. This may take a few minutes..."
end
private
def build_cloud_params
params.require(:build_cloud).permit(:replicas, :cpu_requests, :cpu_limits, :memory_requests, :memory_limits)
end
end

View File

@@ -4,7 +4,6 @@ class Clusters::MetricsController < Clusters::BaseController
before_action :set_cluster
def show
@nodes = K8::Metrics::Api::Node.ls(@cluster)
@time_range = params[:time_range] || "2h"
start_time = parse_time_range(@time_range)
end_time = Time.now

View File

@@ -60,8 +60,10 @@ class ProjectsController < ApplicationController
# PATCH/PUT /projects/1 or /projects/1.json
def update
result = Projects::Update.call(@project, params)
respond_to do |format|
if @project.update(project_params)
if result.success?
format.html { redirect_to @project, notice: "Project is successfully updated." }
format.json { render :show, status: :ok, location: @project }
else

View File

@@ -2,7 +2,6 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
debugger
const progress = this.element;
let value = 0;
@@ -11,6 +10,4 @@ export default class extends Controller {
progress.value = value;
}, 15);
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["radio", "partial"]
connect() {
this.toggle()
}
toggle() {
const selectedRadio = this.radioTargets.find(radio => radio.checked)
if (selectedRadio) {
const selectedValue = selectedRadio.value
this.partialTargets.forEach(partial => {
if (partial.dataset.value === selectedValue) {
partial.classList.remove('hidden')
} else {
partial.classList.add('hidden')
}
})
}
}
}

View File

@@ -0,0 +1,44 @@
module Clusters
class DestroyBuildCloudJob < ApplicationJob
queue_as :default
def perform(cluster)
Rails.logger.info("Starting build cloud removal for cluster #{cluster.name}")
build_cloud = cluster.build_cloud
build_cloud.update(error_message: nil)
return unless build_cloud
begin
# Update status to indicate removal is in progress
build_cloud.update!(status: :uninstalling)
# Initialize the build cloud manager
build_cloud_manager = K8::BuildCloudManager.new(cluster, build_cloud)
# Teardown the builder
build_cloud_manager.teardown!
# Mark the build cloud as uninstalled (keep the record for logs)
build_cloud.update!(
status: :uninstalled,
installation_metadata: build_cloud.installation_metadata.merge(
uninstalled_at: Time.current
)
)
Rails.logger.info("Successfully removed build cloud from cluster #{cluster.name}")
rescue StandardError => e
Rails.logger.error("Failed to remove build cloud from cluster #{cluster.name}: #{e.message}")
# Update the build cloud status to failed
build_cloud.update!(
status: :failed,
error_message: "Failed to remove: #{e.message}"
)
raise e
end
end
end
end

View File

@@ -0,0 +1,11 @@
module Clusters
class InstallBuildCloudJob < ApplicationJob
queue_as :default
def perform(build_cloud)
Clusters::InstallBuildCloud.execute(build_cloud:)
rescue StandardError => e
build_cloud.update(error_message: e.message, status: :failed)
end
end
end

View File

@@ -15,11 +15,20 @@ class Projects::BuildJob < ApplicationJob
project_credential_provider = project.project_credential_provider
project_credential_provider.used!
clone_repository_and_build_docker(project, build)
# Initialize the Docker builder
image_builder = if project.build_configuration&.k8s?
build.info("Driver: Kubernetes (#{project.build_configuration.build_cloud.friendly_name})", color: :green)
Builders::BuildCloud.new(build)
else
build.info("Driver: Docker", color: :green)
Builders::Docker.new(build)
end
login_to_docker(project_credential_provider, build)
# Login to registry
image_builder.login_to_registry(project_credential_provider)
push_to_github_container_registry(project, build)
# Clone repository and build
clone_repository_and_build_image(project, build, image_builder)
end
complete_build!(build, user)
@@ -53,63 +62,6 @@ class Projects::BuildJob < ApplicationJob
raise BuildFailure, "Failed to clone repository: #{e.message}"
end
def build_docker_build_command(project, repository_path)
docker_build_command = [
"docker", "build",
"--progress=plain",
"--platform", "linux/amd64",
"-t", project.container_registry_url,
"-f", File.join(repository_path, project.dockerfile_path)
]
# Add environment variables to the build command
project.environment_variables.each do |envar|
docker_build_command.push("--build-arg", "#{envar.name}=\"#{envar.value}\"")
end
# Add the build context directory at the end
docker_build_command.push(File.join(repository_path, project.docker_build_context_directory))
docker_build_command
end
def execute_docker_build(project, build, repository_path)
docker_build_command = build_docker_build_command(project, repository_path)
# Create a new instance of RunAndLog with the build object as the loggable and killable
runner = Cli::RunAndLog.new(build, killable: build)
# Call the runner with the command (joined as a string since RunAndLog expects a string)
exit_status = runner.call(docker_build_command.join(" "))
rescue Cli::CommandFailedError => e
raise BuildFailure, e.message
end
def login_to_docker(project_credential_provider, build)
base_url = project_credential_provider.provider.github? ? "ghcr.io" : "registry.gitlab.com"
docker_login_command = [ "docker", "login", base_url, "--username" ] +
[ project_credential_provider.username, "--password", project_credential_provider.access_token ]
build.info("Logging into #{base_url} as #{project_credential_provider.username}", color: :yellow)
_stdout, stderr, status = Open3.capture3(*docker_login_command)
if status.success?
build.success("Logged in to #{base_url} successfully.")
else
build.error("#{base_url} login failed with error:\n#{stderr}")
end
end
def push_to_github_container_registry(project, build)
docker_push_command = [ "docker", "push", project.container_registry_url ]
build.info("Pushing Docker image to #{docker_push_command.last}", color: :yellow)
# Execute docker push with killable support
runner = Cli::RunAndLog.new(build, killable: build)
runner.call(docker_push_command.join(" "))
rescue Cli::CommandFailedError => e
raise BuildFailure, "Docker push failed for project #{project.name}: #{e.message}"
end
def complete_build!(build, user)
build.completed!
@@ -117,13 +69,14 @@ class Projects::BuildJob < ApplicationJob
Projects::DeploymentJob.perform_later(deployment, user)
end
def clone_repository_and_build_docker(project, build)
def clone_repository_and_build_image(project, build, image_builder)
Dir.mktmpdir do |repository_path|
build.info("Cloning repository: #{project.repository_url} to #{repository_path}", color: :yellow)
git_clone(project, build, repository_path)
execute_docker_build(project, build, repository_path)
# Use the Docker builder to build the image
image_builder.build_image(repository_path)
end
end
end

View File

@@ -23,6 +23,7 @@ class Account < ApplicationRecord
has_one :stack_manager, dependent: :destroy
has_many :clusters, dependent: :destroy
has_many :build_clouds, through: :clusters
has_many :projects, through: :clusters
has_many :add_ons, through: :clusters
has_many :services, through: :projects

75
app/models/build_cloud.rb Normal file
View File

@@ -0,0 +1,75 @@
# == Schema Information
#
# Table name: build_clouds
#
# id :bigint not null, primary key
# cpu_limits :bigint default(2000)
# cpu_requests :bigint default(500)
# driver_version :string
# error_message :text
# installation_metadata :jsonb
# installed_at :datetime
# memory_limits :bigint default(4294967296)
# memory_requests :bigint default(536870912)
# namespace :string default("canine-k8s-builder"), not null
# replicas :integer default(2)
# status :integer default("pending"), not null
# webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
# index_build_clouds_on_cluster_id (cluster_id)
#
# Foreign Keys
#
# fk_rails_... (cluster_id => clusters.id)
#
class BuildCloud < ApplicationRecord
include Loggable
belongs_to :cluster
validates :cluster_id, uniqueness: true
validates :namespace, presence: true
validates :status, presence: true
enum :status, {
pending: 0,
installing: 1,
active: 2,
failed: 3,
uninstalling: 4,
uninstalled: 5,
updating: 6
}
# Broadcast updates when the build cloud changes
after_commit :broadcast_update
def friendly_name
"#{cluster.name} - #{namespace}"
end
def installation_details
{
namespace: namespace,
driver_version: driver_version,
webhook_url: webhook_url,
installed_at: installed_at
}
end
private
def broadcast_update
broadcast_replace_later_to(
[ cluster, :build_cloud ],
target: ActionView::RecordIdentifier.dom_id(cluster, "build_cloud"),
partial: "clusters/build_clouds/show",
locals: { cluster: cluster }
)
end
end

View File

@@ -0,0 +1,35 @@
# == Schema Information
#
# Table name: build_configurations
#
# id :bigint not null, primary key
# driver :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# build_cloud_id :bigint
# project_id :bigint not null
# provider_id :bigint not null
#
# Indexes
#
# index_build_configurations_on_build_cloud_id (build_cloud_id)
# index_build_configurations_on_project_id (project_id)
# index_build_configurations_on_provider_id (provider_id)
#
# Foreign Keys
#
# fk_rails_... (build_cloud_id => build_clouds.id)
# fk_rails_... (project_id => projects.id)
# fk_rails_... (provider_id => providers.id)
#
class BuildConfiguration < ApplicationRecord
belongs_to :project
belongs_to :build_cloud, optional: true
belongs_to :provider
enum :driver, {
docker: 0,
k8s: 1
}
validates_presence_of :build_cloud, if: -> { driver == 'k8s' }
end

View File

@@ -30,6 +30,7 @@ class Cluster < ApplicationRecord
has_many :domains, through: :projects
has_many :metrics, dependent: :destroy
has_many :users, through: :account
has_one :build_cloud, dependent: :destroy
validates :name, presence: true,
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" },
@@ -57,4 +58,17 @@ class Cluster < ApplicationRecord
def namespaces
RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name)
end
def create_build_cloud!(attributes = {})
build_cloud&.destroy if build_cloud.present?
create_build_cloud_record!(attributes)
end
private
def create_build_cloud_record!(attributes)
build_cloud = BuildCloud.new(attributes.merge(cluster: self))
build_cloud.save!
build_cloud
end
end

View File

@@ -47,6 +47,7 @@ class Project < ApplicationRecord
has_many :volumes, dependent: :destroy
has_one :project_credential_provider, dependent: :destroy
has_one :build_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

@@ -112,4 +112,16 @@ class Provider < ApplicationRecord
"#{provider.titleize} (#{username})"
end
end
def registry_base_url
if github?
"ghcr.io"
elsif gitlab?
"registry.gitlab.com"
elsif container_registry?
registry_url
else
raise "Unknown registry url"
end
end
end

View File

@@ -0,0 +1,28 @@
class Builders::Base
attr_reader :build
def initialize(build)
@build = build
end
def project
build.project
end
# Login to the Docker registry
def login_to_registry(project_credential_provider)
base_url = project_credential_provider.provider.registry_base_url
docker_login_command = [ "docker", "login", base_url, "--username" ] +
[ project_credential_provider.username, "--password", project_credential_provider.access_token ]
build.info("Logging into #{base_url} as #{project_credential_provider.username}", color: :yellow)
_stdout, stderr, status = Open3.capture3(*docker_login_command)
if status.success?
build.success("Logged in to #{base_url} successfully.")
else
build.error("#{base_url} login failed with error:\n#{stderr}")
raise "Docker login failed: #{stderr}"
end
end
end

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'tempfile'
require 'ostruct'
module Builders
class BuildCloud < Base
# @param connection [Object] An object that responds to #kubeconfig
# @param build_cloud [BuildCloud] Optional BuildCloud model to use for namespace
def initialize(build)
super(build)
end
def build_image(repository_path)
command = construct_buildx_command(project, repository_path)
runner = Cli::RunAndLog.new(build, killable: build)
runner.call(command.join(" "))
end
def construct_buildx_command(project, repository_path)
command = [ "docker", "buildx", "build" ]
command += [ "--builder", K8::BuildCloudManager::BUILDKIT_BUILDER_NAME ]
command += [ "--platform", "linux/amd64,linux/arm64" ]
command += [ "--push" ] # Push directly to registry
command += [ "--progress", "plain" ]
command += [ "-t", project.container_registry_url ]
command += [ "-f", File.join(repository_path, project.dockerfile_path) ]
# Add build arguments
project.environment_variables.each do |envar|
command += [ "--build-arg", "#{envar.name}=#{envar.value}" ]
end
# # Add cache options for better performance
# cache_tag = "#{project.container_registry_url}:buildcache"
# command += [ "--cache-from", "type=registry,ref=#{cache_tag}" ]
# command += [ "--cache-to", "type=registry,ref=#{cache_tag},mode=max" ]
command += [ "--push" ]
# Add build context
command << File.join(repository_path, project.docker_build_context_directory)
command
end
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'open3'
module Builders
class Docker < Base
# Build and push the Docker image
def build_image(repository_path)
execute_docker_build(repository_path)
end
private
def execute_docker_build(repository_path)
docker_build_command = construct_buildx_command(repository_path)
# Create a new instance of RunAndLog with the build object as the loggable and killable
runner = Cli::RunAndLog.new(build, killable: build)
# Call the runner with the command (joined as a string since RunAndLog expects a string)
runner.call(docker_build_command.join(" "))
rescue Cli::CommandFailedError => e
raise "Docker build failed: #{e.message}"
end
def construct_buildx_command(repository_path)
docker_build_command = [
"docker",
"--context", "default",
"buildx",
"build",
"--progress=plain",
"--platform", "linux/amd64",
"-t", project.container_registry_url,
"-f", File.join(repository_path, project.dockerfile_path)
]
# Add environment variables to the build command
project.environment_variables.each do |envar|
docker_build_command.push("--build-arg", "#{envar.name}=\"#{envar.value}\"")
end
docker_build_command.push("--push")
# Add the build context directory at the end
docker_build_command.push(File.join(repository_path, project.docker_build_context_directory))
docker_build_command
end
end
end

View File

@@ -10,14 +10,19 @@ module Cli
end
class RunAndLog
attr_reader :output
def initialize(loggable, killable: nil)
@loggable = loggable
@killable = killable
@process = nil
@monitor_thread = nil
@output = ""
end
def call(command, envs: {})
def call(command, envs: {}, clear_output: true)
if clear_output
@output = ""
end
command = envs.map { |k, v| "#{k}=#{v}" }.join(" ") + " #{command}"
# Start monitoring thread if killable is provided
@@ -29,11 +34,17 @@ module Cli
# Create threads to read stdout and stderr
stdout_thread = Thread.new do
stdout.each_line { |line| @loggable.info(line.chomp) }
stdout.each_line do |line|
@loggable.info(line.chomp)
@output += line
end
end
stderr_thread = Thread.new do
stderr.each_line { |line| @loggable.info(line.chomp) }
stderr.each_line do |line|
@loggable.info(line.chomp)
@output += line
end
end
# Wait for process to complete or be killed

View File

@@ -0,0 +1,218 @@
class K8::BuildCloudManager
include K8::Kubeconfig
include StorageHelper
BUILDKIT_BUILDER_NAME = 'canine-k8s-builder'
attr_reader :connection, :build_cloud
def self.install(build_cloud)
if build_cloud.pending? || build_cloud.failed?
build_cloud.update(error_message: nil, status: :installing)
else
build_cloud.update(error_message: nil, status: :updating)
end
params = {
installation_metadata: {
started_at: Time.current,
builder_name: K8::BuildCloudManager::BUILDKIT_BUILDER_NAME
}
}
begin
# Initialize the K8::BuildCloud service with the build_cloud model
build_cloud_manager = K8::BuildCloudManager.new(build_cloud.cluster, build_cloud)
# Run the setup
build_cloud_manager.create_or_update_builder!
# Check if builder is ready
if build_cloud_manager.builder_ready?
# Update build cloud record with success
build_cloud.update!(
status: :active,
installed_at: Time.current,
driver_version: build_cloud_manager.get_buildkit_version,
installation_metadata: build_cloud.installation_metadata.merge(
completed_at: Time.current,
builder_ready: true
)
)
Rails.logger.info("Successfully installed build cloud on cluster #{build_cloud.cluster.name}")
else
raise "Builder was created but is not ready"
end
rescue StandardError => e
# Update build cloud record with failure
build_cloud.update!(
status: :failed,
error_message: e.message,
installation_metadata: build_cloud.installation_metadata.merge(
failed_at: Time.current,
error_details: {
message: e.message,
backtrace: e.backtrace&.first(5)
}
)
)
Rails.logger.error("Failed to install build cloud on cluster #{build_cloud.cluster.name}: #{e.message}")
end
end
def initialize(connection, build_cloud)
@connection = connection
@build_cloud = build_cloud
end
def get_buildkit_version
local_runner = Cli::RunAndReturnOutput.new
output = local_runner.call("docker buildx inspect #{K8::BuildCloudManager::BUILDKIT_BUILDER_NAME}")
if output
result = parse_inspect_output(output)
result[:version]
else
"unknown"
end
rescue StandardError
"unknown"
end
def namespace
build_cloud.namespace
end
# Remove the BuildKit builder
def teardown!
remove_builder! if builder_ready?
end
# Check if the builder is ready and running
def builder_ready?
status = runner.call("docker buildx ls --format json")
if status.success?
builder_names = runner.output.split("\n").map do |x| JSON.parse(x) end.map { |x| x["Name"] }
builder_names.include?(BUILDKIT_BUILDER_NAME)
else
false
end
rescue StandardError
false
end
# Build and push image using BuildKit in Kubernetes
# @param build [Build] The build object for logging
# @param repository_path [String] Path to the cloned repository
# @param project [Project] The project being built
def build_image(build, repository_path, project)
ensure_builder_active!
build_command = construct_buildx_command(project, repository_path)
execute_build(build_command, build)
end
def create_or_update_builder!
if builder_ready?
build_cloud.info("Existing builder found, removing...")
remove_builder!
create_builder!
else
create_builder!
end
end
def create_builder!
ensure_namespace!
# Write kubeconfig to temp file for docker buildx
# Create the buildx builder with kubernetes driver
# The --bootstrap flag will start the builder immediately
with_kube_config do |kubeconfig_file|
command = "docker buildx create "
command += "--bootstrap "
command += "--name #{BUILDKIT_BUILDER_NAME} "
command += "--driver kubernetes "
command += "--driver-opt namespace=#{namespace} "
command += "--driver-opt replicas=#{build_cloud.replicas} "
command += "--driver-opt requests.cpu=#{integer_to_compute(build_cloud.cpu_requests)} "
command += "--driver-opt requests.memory=#{integer_to_memory(build_cloud.memory_requests)} "
command += "--driver-opt limits.cpu=#{integer_to_compute(build_cloud.cpu_limits)} "
command += "--driver-opt limits.memory=#{integer_to_memory(build_cloud.memory_limits)} "
command += "--use"
runner.call(command, envs: { "KUBECONFIG" => kubeconfig_file.path })
end
# Wait for builder to be ready
wait_for_builder_ready!
end
def wait_for_builder_ready!
max_attempts = 120
attempts = 0
while attempts < max_attempts
if builder_ready?
return true
end
sleep 5
attempts += 1
end
raise "BuildKit builder did not become ready in time"
end
def ensure_builder_active!
unless builder_ready?
raise "BuildKit builder is not ready. Run setup! first."
end
# Set the builder as active
runner.call("docker buildx use #{BUILDKIT_BUILDER_NAME}")
end
def ensure_namespace!
# Create namespace if it doesn't exist
with_kube_config do |kubeconfig_file|
command = "kubectl create namespace #{namespace}"
runner.call(command, envs: { "KUBECONFIG" => kubeconfig_file.path })
end
rescue StandardError => e
# Namespace might already exist, which is fine
build_cloud.info("Namespace #{namespace} might already exist: #{e.message}")
end
def remove_builder!
# Delete locally, this also removes the builder from kubernetes
runner.call("docker buildx rm #{BUILDKIT_BUILDER_NAME}")
# Also remove from kubernetes if possible
K8::Kubectl.new(connection.kubeconfig).call("delete namespace #{namespace} --ignore-not-found=true")
rescue StandardError => e
Rails.logger.warn("Error removing builder: #{e.message}")
end
def runner
@runner ||= Cli::RunAndLog.new(build_cloud)
end
def kubeconfig
# This is necessary for the include K8::Kubeconfig module
connection.kubeconfig
end
def parse_inspect_output(text)
version = nil
text.each_line do |line|
if line.start_with?("BuildKit version:")
version = line.split(":", 2)[1].strip
break
end
end
{ "version" => version }.with_indifferent_access
end
end

View File

@@ -0,0 +1,21 @@
class Async::Clusters::Metrics::ShowViewModel < Async::BaseViewModel
include MetricsHelper
include StorageHelper
expects :cluster_id
def cluster
@cluster ||= current_user.clusters.find(params[:cluster_id])
end
def initial_render
render "shared/components/table_skeleton", locals: { columns: 5 }
end
def async_render
nodes = K8::Metrics::Api::Node.ls(cluster)
render "clusters/metrics/live_metrics", locals: {
nodes: nodes
}
end
end

View File

@@ -1,5 +1,5 @@
<%= add_on_layout(@add_on) do %>
<%= turbo_frame_tag "metrics", data: { controller: "refresh-turbo-frame", "refresh-turbo-frame-frequency-value": 5000 } do %>
<%= turbo_frame_tag "metrics", data: { controller: "refresh-turbo-frame", "refresh-turbo-frame-frequency-value": 30000 } do %>
<%= render(
"shared/partials/async_renderer",
view_model: Async::AddOns::Metrics::ShowViewModel.new(

View File

@@ -6,6 +6,7 @@
<span class="label-text-alt">* Required</span>
</label>
<% end %>
<div class="form-footer">
<%= form.submit "Save", class: "btn btn-primary" %>
</div>

View File

@@ -0,0 +1,90 @@
<%= turbo_frame_tag dom_id(cluster, "build_cloud") do %>
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title">Edit Build Cloud Configuration</h3>
<%= form_with model: build_cloud, url: cluster_build_cloud_path(cluster), method: :patch do |form| %>
<div class="space-y-4">
<div class="form-control">
<%= form.label :replicas, class: "label" do %>
<span class="label-text font-medium">Replicas</span>
<% end %>
<%= form.number_field :replicas,
class: "input input-bordered w-full",
min: 1, max: 10,
placeholder: "2" %>
<div class="label">
<span class="label-text-alt text-gray-500">Number of builder replicas (1-10)</span>
</div>
</div>
<div class="form-control">
<%= form.label :cpu_requests, class: "label" do %>
<span class="label-text font-medium">CPU Requests (millicores)</span>
<% end %>
<%= form.number_field :cpu_requests,
class: "input input-bordered w-full",
min: 100, step: 100,
placeholder: "500" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_compute(build_cloud.cpu_requests) %></span>
</div>
</div>
<div class="form-control">
<%= form.label :cpu_limits, class: "label" do %>
<span class="label-text font-medium">CPU Limits (millicores)</span>
<% end %>
<%= form.number_field :cpu_limits,
class: "input input-bordered w-full",
min: 100, step: 100,
placeholder: "2000" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_compute(build_cloud.cpu_limits) %></span>
</div>
</div>
<div class="form-control">
<%= form.label :memory_requests, class: "label" do %>
<span class="label-text font-medium">Memory Requests (bytes)</span>
<% end %>
<%= form.number_field :memory_requests,
class: "input input-bordered w-full",
min: 134217728, step: 134217728,
placeholder: "536870912" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_memory(build_cloud.memory_requests) %> (536870912 = 512Mi)</span>
</div>
</div>
<div class="form-control">
<%= form.label :memory_limits, class: "label" do %>
<span class="label-text font-medium">Memory Limits (bytes)</span>
<% end %>
<%= form.number_field :memory_limits,
class: "input input-bordered w-full",
min: 134217728, step: 134217728,
placeholder: "4294967296" %>
<div class="label">
<span class="label-text-alt text-gray-500">Current: <%= integer_to_memory(build_cloud.memory_limits) %> (4294967296 = 4Gi)</span>
</div>
</div>
</div>
<div class="card-actions justify-end mt-6">
<%= link_to cluster_build_cloud_path(cluster),
class: "btn btn-ghost",
data: { turbo_frame: dom_id(cluster, "build_cloud") } do %>
Cancel
<% end %>
<%= form.submit "Save Configuration", class: "btn btn-primary" %>
</div>
<% end %>
<div class="alert alert-warning mt-4">
<iconify-icon icon="lucide:alert-triangle" height="20"></iconify-icon>
<span>Changes will require reinstalling the build cloud to take effect.</span>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,18 @@
<%= turbo_frame_tag dom_id(cluster, "build_cloud") do %>
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title">Enable Build Cloud</h3>
<p class="text-sm text-gray-500 mb-4">
Build Cloud allows you to build Docker images directly on your Kubernetes cluster using BuildKit.
This enables multi-platform builds and better caching performance.
</p>
<%= button_to cluster_build_cloud_path(cluster),
method: :post,
class: "btn btn-primary",
data: { turbo_confirm: "This will install BuildKit on your cluster. Continue?" } do %>
<iconify-icon icon="lucide:cloud" height="20"></iconify-icon>
Install Build Cloud
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,6 @@
<div class="flex justify-center items-center h-full">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gray-900"></div>
<p class="mt-4 text-gray-600">Installing build cloud...</p>
</div>
</div>

View File

@@ -0,0 +1,24 @@
<%= turbo_stream_from [cluster, :build_cloud] %>
<div>
<h2 class="text-2xl font-bold">Build Cloud</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div class="mt-6">
<% if cluster.build_cloud.present? %>
<%= turbo_frame_tag dom_id(cluster, "build_cloud"), src: cluster_build_cloud_path(cluster), loading: "lazy" do %>
<div class="card bg-base-200">
<div class="card-body">
<div class="flex justify-center items-center py-8">
<div class="flex flex-col items-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-gray-600">Loading build cloud status...</p>
</div>
</div>
</div>
</div>
<% end %>
<% else %>
<%= render "clusters/build_clouds/install", cluster: cluster %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,129 @@
<%= turbo_frame_tag dom_id(cluster, "build_cloud") do %>
<% build_cloud = cluster.build_cloud %>
<div class="card bg-base-200 border border-base-300 shadow-sm">
<div class="card-body">
<!-- Header with status badge -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<iconify-icon icon="lucide:cloud" height="24" class="text-primary"></iconify-icon>
<h3 class="card-title text-lg">Build Cloud</h3>
</div>
<%= render "clusters/build_clouds/status", build_cloud: build_cloud %>
</div>
<% if build_cloud.installing? %>
<div class="flex justify-center items-center py-6">
<div class="flex flex-col items-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-3 text-sm text-base-content/70">Installing build cloud...</p>
</div>
</div>
<% else %>
<!-- Main info grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<!-- Left column - Basic info -->
<div class="space-y-2">
<% if build_cloud.namespace.present? %>
<div class="flex items-center gap-2">
<iconify-icon icon="lucide:box" height="16" class="text-base-content/60"></iconify-icon>
<span class="text-base-content/70">Namespace:</span>
<code class="text-sm bg-base-100 px-2 py-1 rounded"><%= build_cloud.namespace %></code>
</div>
<% end %>
<% if build_cloud.driver_version.present? %>
<div class="flex items-center gap-2">
<iconify-icon icon="lucide:git-branch" height="16" class="text-base-content/60"></iconify-icon>
<span class="text-base-content/70">Version:</span>
<span class="font-mono text-sm"><%= build_cloud.driver_version %></span>
</div>
<% end %>
<% if build_cloud.installed_at.present? %>
<div class="flex items-center gap-2">
<iconify-icon icon="lucide:calendar" height="16" class="text-base-content/60"></iconify-icon>
<span class="text-base-content/70">Installed:</span>
<span class="text-sm"><%= time_ago_in_words(build_cloud.installed_at) %> ago</span>
</div>
<% end %>
</div>
<!-- Right column - Resource configuration -->
<div class="bg-base-100/50 rounded-lg p-3">
<div class="flex items-center gap-2 mb-3">
<iconify-icon icon="lucide:settings" height="16" class="text-base-content/60"></iconify-icon>
<span class="font-medium">Configuration</span>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="flex justify-between">
<span class="text-base-content/70">Replicas:</span>
<span class="font-mono"><%= build_cloud.replicas %></span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">CPU:</span>
<span class="font-mono"><%= integer_to_compute(build_cloud.cpu_requests) %>/<%= integer_to_compute(build_cloud.cpu_limits) %></span>
</div>
<div class="flex justify-between col-span-2">
<span class="text-base-content/70">Memory:</span>
<span class="font-mono"><%= integer_to_memory(build_cloud.memory_requests) %>/<%= integer_to_memory(build_cloud.memory_limits) %></span>
</div>
</div>
</div>
</div>
<!-- Error message (if any) -->
<% if build_cloud.failed? && build_cloud.error_message.present? %>
<div class="alert alert-error mb-4">
<iconify-icon icon="lucide:alert-circle" height="16"></iconify-icon>
<span><%= build_cloud.error_message %></span>
</div>
<% end %>
<!-- Actions -->
<div class="flex items-center justify-between">
<!-- Action buttons -->
<div class="flex gap-2">
<% if build_cloud.active? %>
<%= link_to edit_cluster_build_cloud_path(cluster),
class: "btn btn-outline btn-sm",
data: { turbo_frame: dom_id(cluster, "build_cloud") } do %>
<iconify-icon icon="lucide:settings" height="16"></iconify-icon>
Edit
<% end %>
<%= button_to cluster_build_cloud_path(cluster),
method: :delete,
class: "btn btn-error btn-sm",
data: { turbo_confirm: "Remove BuildKit from your cluster?" } do %>
<iconify-icon icon="lucide:trash-2" height="16"></iconify-icon>
Remove
<% end %>
<% elsif build_cloud.uninstalling? %>
<button class="btn btn-error btn-sm" disabled>
<span class="loading loading-spinner loading-xs"></span>
Removing...
</button>
<% elsif build_cloud.uninstalled? || build_cloud.failed? %>
<%= button_to cluster_build_cloud_path(cluster),
method: :post,
class: "btn btn-primary btn-sm",
data: { turbo_confirm: "Reinstall BuildKit on your cluster?" } do %>
<iconify-icon icon="lucide:refresh-cw" height="16"></iconify-icon>
<%= build_cloud.failed? ? "Retry" : "Reinstall" %>
<% end %>
<% end %>
</div>
</div>
<!-- Logs section -->
<div class="collapse mt-4">
<input aria-label="Accordion radio" type="checkbox" name="accordion" class="w-full">
<div class="collapse-title p-0 m-0 font-medium py-2 px-0">
<iconify-icon icon="lucide:file-text" height="16"></iconify-icon>
<span class="ml-2">View Logs</span>
</div>
<div class="collapse-content p-0 mt-2">
<%= render "log_outputs/logs", loggable: build_cloud %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,26 @@
<%
badge_color = case build_cloud.status
when "pending"
"warning"
when "installing"
"warning"
when "active"
"success"
when "uninstalling"
"warning"
when "uninstalled"
"secondary"
when "failed"
"error"
when "updating"
"warning"
end
%>
<div class="text-right">
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
<%= build_cloud.status.humanize %>
<% if build_cloud.updating? || build_cloud.installing? %>
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
<% end %>
</div>
</div>

View File

@@ -10,7 +10,7 @@
<div class="mt-6" data-controller="kubeconfig-editor">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Credentials</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<button type="button"
class="btn btn-sm btn-outline"
data-action="click->kubeconfig-editor#toggleEdit"
@@ -19,6 +19,7 @@
Edit
</button>
</div>
<hr class="mt-3 mb-4 border-t border-base-300" />
<div data-kubeconfig-editor-target="placeholder" class="p-4 bg-base-200 rounded-lg">
<p class="text-sm text-gray-500">Kubeconfig is hidden for security. Click edit to view and modify.</p>
@@ -53,6 +54,10 @@
</div>
</div>
<% if Flipper.enabled?(:build_configuration, current_account) %>
<%= render "clusters/build_clouds/section", cluster: @cluster %>
<% end %>
<div>
<h2 class="text-2xl font-bold">Danger zone</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />

View File

@@ -33,7 +33,7 @@
</tr>
</thead>
<tbody>
<% @nodes.each do |node| %>
<% nodes.each do |node| %>
<tr class="cursor-pointer hover:bg-base-200/40">
<td>
<div class="flex items-center space-x-3 truncate">
@@ -86,7 +86,7 @@
</tr>
</thead>
<tbody>
<% @nodes.each do |node| %>
<% nodes.each do |node| %>
<% node.namespaces.each do |namespace, pods| %>
<% pods.each do |pod| %>
<tr class="cursor-pointer hover:bg-base-200/40">

View File

@@ -1,6 +1,12 @@
<%= cluster_layout(@cluster) do %>
<%= turbo_frame_tag "metrics", data: { controller: "refresh-turbo-frame", "refresh-turbo-frame-frequency-value": 5000 } do %>
<%= render "clusters/metrics/live_metrics" %>
<%= turbo_frame_tag "metrics", data: { controller: "refresh-turbo-frame", "refresh-turbo-frame-frequency-value": 30000 } do %>
<%= render(
"shared/partials/async_renderer",
view_model: Async::Clusters::Metrics::ShowViewModel.new(
current_user,
cluster_id: @cluster.id
)
) %>
<% end %>
<%= turbo_frame_tag "charts" do %>

View File

@@ -0,0 +1,28 @@
<div>
<div>
<% build_configuration = project.build_configuration || BuildConfiguration.new(driver: 'docker') %>
<%= form.fields_for :build_configuration, build_configuration do |bc_form| %>
<div class="form-control mt-4">
<%= render "shared/partials/radio_selector", selected: build_configuration.driver, options: [
{
icon: "skill-icons:docker",
name: "project[build_configuration][driver]",
label: "Local Docker",
value: "docker",
description: "Build images using Docker on the deployment server. Simple setup, suitable for smaller projects."
},
{
icon: "skill-icons:kubernetes",
name: "project[build_configuration][driver]",
label: "Kubernetes Build Cloud",
description: "Build images in your Kubernetes cluster. Scalable, distributed builds with better resource utilization.",
value: "k8s",
partial: "projects/build_configurations/k8s_build_cloud",
locals: { current_account:, bc_form:, build_configuration: }
}
] %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<div>
<label class="label">
<span class="label-text">Build Cluster</span>
</label>
<% build_clouds = current_account.build_clouds.active %>
<% if build_clouds.any? %>
<%= bc_form.collection_select :build_cloud_id,
build_clouds,
:id,
:friendly_name,
{ include_blank: "Select a build cloud...", selected: build_configuration.build_cloud_id },
{
class: "select select-bordered w-full",
data: { "build-configuration-target": "clusterInput" }
}
%>
<label class="label">
<span class="label-text-alt">Select a Kubernetes cluster configured for container builds</span>
</label>
<% else %>
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>No clusters are configured for container builds. Please enable container builds on at least one cluster in the cluster settings.</span>
</div>
<%= bc_form.hidden_field :cluster_id, value: nil %>
<% end %>
</div>

View File

@@ -135,6 +135,15 @@
)) do %>
<%= form.text_field :predeploy_command, class: "input input-bordered w-full focus:outline-offset-0" %>
<% end %>
<% if Flipper.enabled?(:build_configuration, current_account) %>
<%= render(FormFieldComponent.new(
label: "Build settings",
description: "Configure where your container images will be built. You can use Kubernetes-based build clouds for faster, scalable builds, or use local Docker for simpler setups."
)) do %>
<%= render "projects/build_configurations/form", form: form, project: project %>
<% end %>
<% end %>
</div>
<div class="form-footer">

View File

@@ -1,7 +1,7 @@
<%= content_for :title, t("scaffold.edit.title", model: "Project") %>
<%= project_layout(@project) do %>
<%= turbo_frame_tag "metrics", data: { controller: "refresh-turbo-frame", "refresh-turbo-frame-frequency-value": 5000 } do %>
<%= turbo_frame_tag "metrics", data: { controller: "refresh-turbo-frame", "refresh-turbo-frame-frequency-value": 30000 } do %>
<%= render(
"shared/partials/async_renderer",
view_model: Async::Projects::Metrics::IndexViewModel.new(

View File

@@ -51,6 +51,12 @@
<span class="label-text-alt">If this is left blank, <%= project.github? ? "Github" : "Gitlab" %> Container Registry will be used</span>
</label>
<% end %>
<% if Flipper.enabled?(:build_configuration, current_account) %>
<%= render(FormFieldComponent.new(label: "Build configuration")) do %>
<%= render "projects/build_configurations/form", form: form, project: project %>
<% end %>
<% end %>
</div>
<div class="form-footer">

View File

@@ -0,0 +1,30 @@
<div data-controller="radio-selector">
<div class="flex gap-x-4">
<% options.each do |option| %>
<%= render(RadioSelectCardComponent.new(
name: option[:name],
value: option[:value],
checked: selected == option[:value]
)) do %>
<div class="shadow-sm cursor-pointer transition-all hover:shadow-md flex-1">
<div class="flex items-start gap-3">
<div class="text-3xl"><iconify-icon icon="<%= option[:icon] %>" height="48"></iconify-icon></div>
<div class="flex-1">
<h4 class="font-semibold"><%= option[:label] %></h4>
<p class="text-sm text-gray-400 mt-1"><%= option[:description] %></p>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
<div class="mt-4">
<% options.each do |option| %>
<% if option[:partial].present? %>
<div data-radio-selector-target="partial" data-value="<%= option[:value] %>" class="hidden">
<%= render option[:partial], **option[:locals] %>
</div>
<% end %>
<% end %>
</div>
</div>

View File

@@ -5,11 +5,21 @@ require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
BOOT_MODES = %w[local cloud cluster]
module Canine
class Application < Rails::Application
config.boot_mode = ENV.fetch("BOOT_MODE", "cloud")
if !BOOT_MODES.include?(config.boot_mode)
raise "Invalid boot mode: #{config.boot_mode}"
end
config.local_mode = config.boot_mode == "local"
config.cloud_mode = config.boot_mode == "cloud"
config.cluster_mode = config.boot_mode == "cluster"
config.assets.css_compressor = nil
config.local_mode = ENV["LOCAL_MODE"] == "true"
config.active_job.queue_adapter = :good_job
config.application_name = Rails.application.class.module_parent_name
# Initialize configuration defaults for originally generated Rails version.

View File

@@ -49,7 +49,7 @@ Rails.application.configure do
# config.assume_ssl = true
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
if ENV['LOCAL_MODE'] == 'true'
if Rails.application.config.local_mode
config.ssl_options = { hsts: false }
config.force_ssl = false
else

View File

@@ -82,6 +82,7 @@ Rails.application.routes.draw do
get :sync
end
resource :metrics, only: [ :show ], module: :clusters
resource :build_cloud, only: [ :show, :edit, :update, :create, :destroy ], module: :clusters
member do
post :test_connection
post :retry_install

View File

@@ -18,6 +18,7 @@ module.exports = {
content: [
'./app/javascript/**/*.js',
'./app/views/**/*.erb',
'./app/components/**/*.{erb,rb}',
'./public/*.html',
],
theme: {

View File

@@ -0,0 +1,21 @@
class CreateBuildClouds < ActiveRecord::Migration[7.2]
def change
create_table :build_clouds do |t|
t.references :cluster, null: false, foreign_key: true
t.string :namespace, null: false, default: K8::BuildCloudManager::BUILDKIT_BUILDER_NAME
t.integer :status, null: false, default: 0
t.string :driver_version
t.string :webhook_url
t.jsonb :installation_metadata, default: {}
t.datetime :installed_at
t.text :error_message
t.integer :replicas, default: 2
t.bigint :cpu_requests, default: 500
t.bigint :cpu_limits, default: 2000
t.bigint :memory_requests, default: 536870912 # 512Mi in bytes
t.bigint :memory_limits, default: 4294967296 # 4Gi in bytes
t.timestamps
end
end
end

View File

@@ -0,0 +1,11 @@
class CreateBuildConfigurations < ActiveRecord::Migration[7.2]
def change
create_table :build_configurations do |t|
t.references :project, null: false, foreign_key: true
t.integer :driver, null: false
t.references :build_cloud, null: true, foreign_key: true
t.timestamps
end
end
end

View File

@@ -0,0 +1,5 @@
class AddProviderIdToBuildConfigurations < ActiveRecord::Migration[7.2]
def change
add_reference :build_configurations, :provider, null: false, foreign_key: true
end
end

35
db/schema.rb generated
View File

@@ -82,6 +82,37 @@ ActiveRecord::Schema[7.2].define(version: 2025_08_25_192538) do
t.datetime "updated_at", null: false
end
create_table "build_clouds", force: :cascade do |t|
t.bigint "cluster_id", null: false
t.string "namespace", default: "canine-k8s-builder", null: false
t.integer "status", default: 0, null: false
t.string "driver_version"
t.string "webhook_url"
t.jsonb "installation_metadata", default: {}
t.datetime "installed_at"
t.text "error_message"
t.integer "replicas", default: 2
t.bigint "cpu_requests", default: 500
t.bigint "cpu_limits", default: 2000
t.bigint "memory_requests", default: 536870912
t.bigint "memory_limits", default: 4294967296
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["cluster_id"], name: "index_build_clouds_on_cluster_id"
end
create_table "build_configurations", force: :cascade do |t|
t.bigint "project_id", null: false
t.integer "driver", null: false
t.bigint "build_cloud_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "provider_id", null: false
t.index ["build_cloud_id"], name: "index_build_configurations_on_build_cloud_id"
t.index ["project_id"], name: "index_build_configurations_on_project_id"
t.index ["provider_id"], name: "index_build_configurations_on_provider_id"
end
create_table "builds", force: :cascade do |t|
t.bigint "project_id", null: false
t.string "repository_url"
@@ -453,6 +484,10 @@ ActiveRecord::Schema[7.2].define(version: 2025_08_25_192538) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "add_ons", "clusters"
add_foreign_key "build_clouds", "clusters"
add_foreign_key "build_configurations", "build_clouds"
add_foreign_key "build_configurations", "projects"
add_foreign_key "build_configurations", "providers"
add_foreign_key "builds", "projects"
add_foreign_key "clusters", "accounts"
add_foreign_key "cron_schedules", "services"

View File

@@ -69,6 +69,37 @@ RSpec.describe ProjectForks::ForkProject do
end
end
context 'with build configuration' do
let!(:build_configuration) do
create(:build_configuration,
project: parent_project,
driver: :docker)
end
it 'duplicates the build configuration for the child project' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_success
child_project = result.project_fork.child_project
child_build_config = child_project.build_configuration
expect(child_build_config).to be_present
expect(child_build_config.driver).to eq(build_configuration.driver)
expect(child_build_config.project).to eq(child_project)
expect(child_build_config.id).not_to eq(build_configuration.id)
end
end
context 'without build configuration' do
it 'does not create a build configuration for the child project' do
result = described_class.execute(parent_project:, pull_request:)
expect(result).to be_success
child_project = result.project_fork.child_project
expect(child_project.build_configuration).to be_nil
end
end
context 'with canine config file' do
let(:canine_config_content) do
Git::Common::File.new(

View File

@@ -25,6 +25,7 @@ RSpec.describe Projects::Create do
before do
allow(Projects::ValidateGitRepository).to receive(:execute)
allow(Projects::ValidateNamespaceAvailability).to receive(:execute)
allow(Projects::RegisterGitWebhook).to receive(:execute)
end
@@ -57,6 +58,7 @@ RSpec.describe Projects::Create do
it 'validates with github and registers webhooks' do
expect(subject).to eq([
Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability,
Projects::Save,
Projects::RegisterGitWebhook
])
@@ -68,9 +70,10 @@ RSpec.describe Projects::Create do
allow(Rails.application.config).to receive(:local_mode).and_return(true)
end
it 'validates with github and registers webhooks' do
it 'validates with github and does not register webhooks' do
expect(subject).to eq([
Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability,
Projects::Save
])
end

View File

@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe Projects::ValidateNamespaceAvailability do
let(:cluster) { create(:cluster) }
let(:project) { build(:project, name: 'test-app', cluster: cluster) }
let(:context) { LightService::Context.make(project: project) }
let(:k8_client) { instance_double(K8::Client) }
before do
allow(K8::Client).to receive(:from_cluster).with(cluster).and_return(k8_client)
end
describe '.execute' do
context 'when namespace does not exist' do
before do
allow(k8_client).to receive(:get_namespaces).and_return([])
end
it 'succeeds' do
described_class.execute(context)
expect(context).to be_success
end
end
context 'when namespace already exists' do
let(:existing_namespace) do
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app'))
end
before do
allow(k8_client).to receive(:get_namespaces).and_return([ existing_namespace ])
end
it 'fails with error message' do
described_class.execute(context)
expect(context).to be_failure
expect(context.message).to include("already exists")
end
end
end
end

View File

@@ -0,0 +1,41 @@
# == Schema Information
#
# Table name: build_clouds
#
# id :bigint not null, primary key
# cpu_limits :bigint default(2000)
# cpu_requests :bigint default(500)
# driver_version :string
# error_message :text
# installation_metadata :jsonb
# installed_at :datetime
# memory_limits :bigint default(4294967296)
# memory_requests :bigint default(536870912)
# namespace :string default("canine-k8s-builder"), not null
# replicas :integer default(2)
# status :integer default("pending"), not null
# webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
# index_build_clouds_on_cluster_id (cluster_id)
#
# Foreign Keys
#
# fk_rails_... (cluster_id => clusters.id)
#
FactoryBot.define do
factory :build_cloud do
cluster { nil }
namespace { "MyString" }
status { 1 }
driver_version { "MyString" }
webhook_url { "MyString" }
installation_metadata { "" }
installed_at { "2025-08-15 16:40:46" }
error_message { "MyText" }
end
end

View File

@@ -0,0 +1,31 @@
# == Schema Information
#
# Table name: build_configurations
#
# id :bigint not null, primary key
# driver :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# build_cloud_id :bigint
# project_id :bigint not null
# provider_id :bigint not null
#
# Indexes
#
# index_build_configurations_on_build_cloud_id (build_cloud_id)
# index_build_configurations_on_project_id (project_id)
# index_build_configurations_on_provider_id (provider_id)
#
# Foreign Keys
#
# fk_rails_... (build_cloud_id => build_clouds.id)
# fk_rails_... (project_id => projects.id)
# fk_rails_... (provider_id => providers.id)
#
FactoryBot.define do
factory :build_configuration do
provider
project
driver { :docker }
end
end

View File

@@ -0,0 +1,34 @@
# == Schema Information
#
# Table name: build_clouds
#
# id :bigint not null, primary key
# cpu_limits :bigint default(2000)
# cpu_requests :bigint default(500)
# driver_version :string
# error_message :text
# installation_metadata :jsonb
# installed_at :datetime
# memory_limits :bigint default(4294967296)
# memory_requests :bigint default(536870912)
# namespace :string default("canine-k8s-builder"), not null
# replicas :integer default(2)
# status :integer default("pending"), not null
# webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
# index_build_clouds_on_cluster_id (cluster_id)
#
# Foreign Keys
#
# fk_rails_... (cluster_id => clusters.id)
#
require 'rails_helper'
RSpec.describe BuildCloud, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: build_configurations
#
# id :bigint not null, primary key
# driver :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# build_cloud_id :bigint
# project_id :bigint not null
# provider_id :bigint not null
#
# Indexes
#
# index_build_configurations_on_build_cloud_id (build_cloud_id)
# index_build_configurations_on_project_id (project_id)
# index_build_configurations_on_provider_id (provider_id)
#
# Foreign Keys
#
# fk_rails_... (build_cloud_id => build_clouds.id)
# fk_rails_... (project_id => projects.id)
# fk_rails_... (provider_id => providers.id)
#
require 'rails_helper'
RSpec.describe BuildConfiguration, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end