mirror of
https://github.com/czhu12/canine.git
synced 2025-12-19 09:49:58 -06:00
Merge branch 'main' into celina__portainer_url
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -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"
|
||||
|
||||
|
||||
80
Gemfile.lock
80
Gemfile.lock
@@ -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
|
||||
|
||||
3
TODO.md
3
TODO.md
@@ -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
|
||||
20
app/actions/clusters/install_build_cloud.rb
Normal file
20
app/actions/clusters/install_build_cloud.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
app/actions/projects/update.rb
Normal file
31
app/actions/projects/update.rb
Normal 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
|
||||
24
app/actions/projects/update_save.rb
Normal file
24
app/actions/projects/update_save.rb
Normal 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
|
||||
31
app/actions/projects/validate_namespace_availability.rb
Normal file
31
app/actions/projects/validate_namespace_availability.rb
Normal 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
|
||||
19
app/avo/resources/build_cloud.rb
Normal file
19
app/avo/resources/build_cloud.rb
Normal 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
|
||||
14
app/avo/resources/build_configuration.rb
Normal file
14
app/avo/resources/build_configuration.rb
Normal 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
|
||||
8
app/components/radio_select_card_component.html.erb
Normal file
8
app/components/radio_select_card_component.html.erb
Normal 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>
|
||||
7
app/components/radio_select_card_component.rb
Normal file
7
app/components/radio_select_card_component.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class RadioSelectCardComponent < ViewComponent::Base
|
||||
def initialize(name:, value:, checked:)
|
||||
@name = name
|
||||
@value = value
|
||||
@checked = checked
|
||||
end
|
||||
end
|
||||
4
app/controllers/avo/build_clouds_controller.rb
Normal file
4
app/controllers/avo/build_clouds_controller.rb
Normal 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
|
||||
4
app/controllers/avo/build_configurations_controller.rb
Normal file
4
app/controllers/avo/build_configurations_controller.rb
Normal 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
|
||||
75
app/controllers/clusters/build_clouds_controller.rb
Normal file
75
app/controllers/clusters/build_clouds_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
25
app/javascript/controllers/radio_selector_controller.js
Normal file
25
app/javascript/controllers/radio_selector_controller.js
Normal 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')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/jobs/clusters/destroy_build_cloud_job.rb
Normal file
44
app/jobs/clusters/destroy_build_cloud_job.rb
Normal 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
|
||||
11
app/jobs/clusters/install_build_cloud_job.rb
Normal file
11
app/jobs/clusters/install_build_cloud_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
75
app/models/build_cloud.rb
Normal 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
|
||||
35
app/models/build_configuration.rb
Normal file
35
app/models/build_configuration.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
28
app/services/builders/base.rb
Normal file
28
app/services/builders/base.rb
Normal 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
|
||||
46
app/services/builders/build_cloud.rb
Normal file
46
app/services/builders/build_cloud.rb
Normal 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
|
||||
50
app/services/builders/docker.rb
Normal file
50
app/services/builders/docker.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
218
app/services/k8/build_cloud_manager.rb
Normal file
218
app/services/k8/build_cloud_manager.rb
Normal 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
|
||||
21
app/view_models/async/clusters/metrics/show_view_model.rb
Normal file
21
app/view_models/async/clusters/metrics/show_view_model.rb
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
90
app/views/clusters/build_clouds/_edit.html.erb
Normal file
90
app/views/clusters/build_clouds/_edit.html.erb
Normal 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 %>
|
||||
18
app/views/clusters/build_clouds/_install.html.erb
Normal file
18
app/views/clusters/build_clouds/_install.html.erb
Normal 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 %>
|
||||
6
app/views/clusters/build_clouds/_installing.html.erb
Normal file
6
app/views/clusters/build_clouds/_installing.html.erb
Normal 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>
|
||||
24
app/views/clusters/build_clouds/_section.html.erb
Normal file
24
app/views/clusters/build_clouds/_section.html.erb
Normal 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>
|
||||
129
app/views/clusters/build_clouds/_show.html.erb
Normal file
129
app/views/clusters/build_clouds/_show.html.erb
Normal 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 %>
|
||||
26
app/views/clusters/build_clouds/_status.html.erb
Normal file
26
app/views/clusters/build_clouds/_status.html.erb
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
28
app/views/projects/build_configurations/_form.html.erb
Normal file
28
app/views/projects/build_configurations/_form.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
30
app/views/shared/partials/_radio_selector.html.erb
Normal file
30
app/views/shared/partials/_radio_selector.html.erb
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ module.exports = {
|
||||
content: [
|
||||
'./app/javascript/**/*.js',
|
||||
'./app/views/**/*.erb',
|
||||
'./app/components/**/*.{erb,rb}',
|
||||
'./public/*.html',
|
||||
],
|
||||
theme: {
|
||||
|
||||
21
db/migrate/20250815234046_create_build_clouds.rb
Normal file
21
db/migrate/20250815234046_create_build_clouds.rb
Normal 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
|
||||
11
db/migrate/20250815234047_create_build_configurations.rb
Normal file
11
db/migrate/20250815234047_create_build_configurations.rb
Normal 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
|
||||
@@ -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
35
db/schema.rb
generated
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
41
spec/factories/build_clouds.rb
Normal file
41
spec/factories/build_clouds.rb
Normal 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
|
||||
31
spec/factories/build_configurations.rb
Normal file
31
spec/factories/build_configurations.rb
Normal 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
|
||||
34
spec/models/build_cloud_spec.rb
Normal file
34
spec/models/build_cloud_spec.rb
Normal 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
|
||||
29
spec/models/build_configuration_spec.rb
Normal file
29
spec/models/build_configuration_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user