This commit is contained in:
Chris
2025-11-28 12:47:47 -08:00
75 changed files with 918 additions and 414 deletions

View File

@@ -0,0 +1,25 @@
class AddOns::ApplyTemplateToValues
extend LightService::Action
expects :add_on
executed do |context|
add_on = context.add_on
add_on.values.extend(DotSettable)
variables = add_on.metadata['template'] || {}
variables.keys.each do |key|
variable = variables[key]
if variable.is_a?(Hash) && variable['type'] == 'size'
add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}")
else
variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key }
if variable_definition['type'] == 'integer'
add_on.values.dotset(key, variable.to_i)
else
add_on.values.dotset(key, variable)
end
end
end
end
end

View File

@@ -1,47 +1,40 @@
class AddOns::Create class AddOns::Create
extend LightService::Action def self.parse_params(params)
expects :add_on if params[:add_on][:values_yaml].present?
promises :add_on params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml])
end
if params[:add_on][:metadata].present?
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
end
params.require(:add_on).permit(
:cluster_id,
:chart_type,
:chart_url,
:name,
metadata: {},
values: {}
)
end
executed do |context| class ToNamespaced
add_on = context.add_on extend LightService::Action
apply_template_to_values(add_on) expects :add_on
fetch_package_details(context, add_on) promises :namespaced
unless add_on.save executed do |context|
context.fail_and_return!("Failed to create add on") context.namespaced = context.add_on
end end
end end
def self.fetch_package_details(context, add_on) extend LightService::Organizer
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
if result.failure? def self.call(add_on, user)
add_on.errors.add(:base, "Failed to fetch package details") with(add_on:, user:).reduce(
context.fail_and_return!("Failed to fetch package details") AddOns::Create::ToNamespaced,
end Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
result.response.delete('readme') AddOns::ApplyTemplateToValues,
add_on.metadata['package_details'] = result.response AddOns::SetPackageDetails,
end AddOns::Save
)
def self.apply_template_to_values(add_on)
# Merge the values from the form with the values.yaml object and create a new values.yaml file
add_on.values.extend(DotSettable)
variables = add_on.metadata['template'] || {}
variables.keys.each do |key|
variable = variables[key]
if variable.is_a?(Hash) && variable['type'] == 'size'
add_on.values.dotset(key, "#{variable['value']}#{variable['unit']}")
else
variable_definition = add_on.chart_definition['template'].find { |t| t['key'] == key }
if variable_definition['type'] == 'integer'
add_on.values.dotset(key, variable.to_i)
else
add_on.values.dotset(key, variable)
end
end
end
end end
end end

View File

@@ -0,0 +1,10 @@
class AddOns::Save
extend LightService::Action
expects :add_on
executed do |context|
unless context.add_on.save
context.fail_and_return!("Failed to create add on")
end
end
end

View File

@@ -0,0 +1,18 @@
class AddOns::SetPackageDetails
extend LightService::Action
expects :add_on
executed do |context|
add_on = context.add_on
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
if result.failure?
add_on.errors.add(:base, "Failed to fetch package details")
context.fail_and_return!("Failed to fetch package details")
end
# Readme is too large
result.response.delete('readme')
add_on.metadata['package_details'] = result.response
end
end

View File

@@ -12,7 +12,7 @@ class AddOns::UninstallHelmChart
end end
client = K8::Client.new(connection) client = K8::Client.new(connection)
if (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.name }).present? if add_on.managed_namespace? && (namespace = client.get_namespaces.find { |n| n.metadata.name == add_on.namespace }).present?
client.delete_namespace(namespace.metadata.name) client.delete_namespace(namespace.metadata.name)
end end

View File

@@ -0,0 +1,16 @@
class Namespaced::SetUpNamespace
extend LightService::Action
expects :namespaced
promises :namespaced
executed do |context|
namespaced = context.namespaced
if namespaced.namespace.blank? && namespaced.managed_namespace
# autoset the namespace to the namespaced name
namespaced.namespace = namespaced.name
elsif namespaced.namespace.blank? && !namespaced.managed_namespace
namespaced.errors.add(:base, "A namespace must be provided if it is not managed by Canine")
context.fail_and_return!("Failed to set up namespace")
end
end
end

View File

@@ -0,0 +1,58 @@
module Namespaced
class ValidateNamespace
extend LightService::Action
expects :namespaced, :user
def self.validate_namespace_does_not_exist_or_is_managed(
context,
namespaced,
client,
existing_namespaces
)
namespace_exists = existing_namespaces.any? do |ns|
ns.metadata.name == namespaced.namespace && ns.metadata&.labels&.caninemanaged != "true"
end
if namespace_exists
error_message = "Namespace `#{namespaced.name}` already exists in the Kubernetes cluster. Please delete the existing namespace, or try a different name."
namespaced.errors.add(:name, error_message)
context.fail_and_return!(error_message)
end
end
def self.validate_namespace_exists(
context,
namespaced,
client,
existing_namespaces
)
existing_namespace = existing_namespaces.any? do |ns|
ns.metadata.name == namespaced.namespace
end
unless existing_namespace
error_message = "`#{namespaced.name}` does not exist in the cluster. If you want Canine to automaticaly create it, enable <b>auto create namespace</b>"
namespaced.errors.add(:base, error_message)
context.fail_and_return!(error_message)
end
end
executed do |context|
namespaced = context.namespaced
cluster = namespaced.cluster
begin
client = K8::Client.new(K8::Connection.new(cluster, context.user))
existing_namespaces = client.get_namespaces
if namespaced.managed_namespace
validate_namespace_does_not_exist_or_is_managed(context, namespaced, client, existing_namespaces)
else
validate_namespace_exists(context, namespaced, client, existing_namespaces)
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

@@ -10,6 +10,8 @@ class ProjectForks::ForkProject
child_project = parent_project.dup child_project = parent_project.dup
child_project.branch = pull_request.branch child_project.branch = pull_request.branch
child_project.name = "#{parent_project.name}-#{pull_request.number}" child_project.name = "#{parent_project.name}-#{pull_request.number}"
child_project.namespace = child_project.name
child_project.managed_namespace = parent_project.managed_namespace
child_project.cluster_id = parent_project.project_fork_cluster_id child_project.cluster_id = parent_project.project_fork_cluster_id
# Duplicate the project_credential_provider # Duplicate the project_credential_provider
child_project_credential_provider = parent_project.project_credential_provider.dup child_project_credential_provider = parent_project.project_credential_provider.dup

View File

@@ -1,90 +1,101 @@
# frozen_string_literal: true # frozen_string_literal: true
module Projects class Projects::Create
class Create class ToNamespaced
extend LightService::Organizer extend LightService::Action
def self.create_params(params) expects :project
params.require(:project).permit( promises :namespaced
:name, executed do |context|
:repository_url, context.namespaced = context.project
:branch,
:cluster_id,
:container_registry_url,
:predeploy_command,
:project_fork_status,
:project_fork_cluster_id
)
end
def self.call(
params,
user
)
project = Project.new(create_params(params))
provider = find_provider(user, params)
project_credential_provider = build_project_credential_provider(project, provider)
build_configuration = build_build_configuration(project, params)
steps = create_steps(provider)
with(
project:,
project_credential_provider:,
build_configuration:,
params:,
user:
).reduce(*steps)
end
def self.build_project_credential_provider(project, provider)
ProjectCredentialProvider.new(
project:,
provider:,
)
end
def self.build_build_configuration(project, params)
return unless project.git?
build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new
default_params = build_default_build_configuration(project)
merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank)
build_configuration = project.build_build_configuration(merged_params)
build_configuration
end
def self.build_default_build_configuration(project)
{
provider: project.project_credential_provider.provider,
driver: BuildConfiguration::DEFAULT_BUILDER,
build_type: :dockerfile,
image_repository: project.repository_url,
context_directory: ".",
dockerfile_path: "./Dockerfile"
}
end
def self.create_steps(provider)
steps = []
if provider.git?
steps << Projects::ValidateGitRepository
end
steps << Projects::ValidateNamespaceAvailability
steps << Projects::InitializeBuildPacks
steps << Projects::Save
# Only register webhook in cloud mode
if Rails.application.config.cloud_mode && provider.git?
steps << Projects::RegisterGitWebhook
end
steps
end
def self.find_provider(user, params)
provider_id = params[:project][:project_credential_provider][:provider_id]
user.providers.find(provider_id)
rescue ActiveRecord::RecordNotFound
raise "Provider #{provider_id} not found"
end end
end end
extend LightService::Organizer
def self.create_params(params)
params.require(:project).permit(
:name,
:namespace,
:managed_namespace,
:repository_url,
:branch,
:cluster_id,
:container_registry_url,
:predeploy_command,
:project_fork_status,
:project_fork_cluster_id
)
end
def self.call(
params,
user
)
project = Project.new(create_params(params))
provider = find_provider(user, params)
project_credential_provider = build_project_credential_provider(project, provider)
build_configuration = build_build_configuration(project, params)
steps = create_steps(provider)
with(
project:,
project_credential_provider:,
build_configuration:,
params:,
user:
).reduce(*steps)
end
def self.build_project_credential_provider(project, provider)
ProjectCredentialProvider.new(
project:,
provider:,
)
end
def self.build_build_configuration(project, params)
return unless project.git?
build_config_params = params[:project][:build_configuration] || ActionController::Parameters.new
default_params = build_default_build_configuration(project)
merged_params = default_params.merge(BuildConfiguration.permit_params(build_config_params).compact_blank)
build_configuration = project.build_build_configuration(merged_params)
build_configuration
end
def self.build_default_build_configuration(project)
{
provider: project.project_credential_provider.provider,
driver: BuildConfiguration::DEFAULT_BUILDER,
build_type: :dockerfile,
image_repository: project.repository_url,
context_directory: ".",
dockerfile_path: "./Dockerfile"
}
end
def self.create_steps(provider)
steps = []
if provider.git?
steps << Projects::ValidateGitRepository
end
steps << Projects::Create::ToNamespaced
steps << Namespaced::SetUpNamespace
steps << Namespaced::ValidateNamespace
steps << Projects::InitializeBuildPacks
steps << Projects::Save
# Only register webhook in cloud mode
if Rails.application.config.cloud_mode && provider.git?
steps << Projects::RegisterGitWebhook
end
steps
end
def self.find_provider(user, params)
provider_id = params[:project][:project_credential_provider][:provider_id]
user.providers.find(provider_id)
rescue ActiveRecord::RecordNotFound
raise "Provider #{provider_id} not found"
end
end end

View File

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

@@ -6,8 +6,9 @@ class Services::Update
executed do |context| executed do |context|
context.service.update(Service.permitted_params(context.params)) context.service.update(Service.permitted_params(context.params))
if context.service.cron_job? if context.service.cron_job? && context.params[:service][:cron_schedule].present?
context.service.cron_schedule.update(context.params[:service][:cron_schedule].permit(:schedule)) context.service.cron_schedule.update(
context.params[:service][:cron_schedule].permit(:schedule))
end end
context.service.updated! context.service.updated!
end end

View File

@@ -40,7 +40,8 @@ class AddOnsController < ApplicationController
# POST /add_ons or /add_ons.json # POST /add_ons or /add_ons.json
def create def create
result = AddOns::Create.execute(add_on: AddOn.new(add_on_params)) add_on_params = AddOns::Create.parse_params(params)
result = AddOns::Create.call(AddOn.new(add_on_params), current_user)
@add_on = result.add_on @add_on = result.add_on
# Uncomment to authorize with Pundit # Uncomment to authorize with Pundit
# authorize @add_on # authorize @add_on
@@ -59,7 +60,7 @@ class AddOnsController < ApplicationController
# PATCH/PUT /add_ons/1 or /add_ons/1.json # PATCH/PUT /add_ons/1 or /add_ons/1.json
def update def update
@add_on.assign_attributes(add_on_params) @add_on.assign_attributes(AddOns::Create.parse_params(params))
result = AddOns::Update.execute(add_on: @add_on) result = AddOns::Update.execute(add_on: @add_on)
respond_to do |format| respond_to do |format|
@@ -125,25 +126,4 @@ class AddOnsController < ApplicationController
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to add_ons_path redirect_to add_ons_path
end end
# Only allow a list of trusted parameters through.
def add_on_params
if params[:add_on][:values_yaml].present?
params[:add_on][:values] = YAML.safe_load(params[:add_on][:values_yaml])
end
if params[:add_on][:metadata].present?
params[:add_on][:metadata] = params[:add_on][:metadata][params[:add_on][:chart_type]]
end
params.require(:add_on).permit(
:cluster_id,
:chart_type,
:chart_url,
:name,
metadata: {},
values: {}
)
# Uncomment to use Pundit permitted attributes
# params.require(:add_on).permit(policy(@add_on).permitted_attributes)
end
end end

View File

@@ -90,8 +90,8 @@ class ClustersController < ApplicationController
%w[services deployments ingress cronjobs].each do |resource| %w[services deployments ingress cronjobs].each do |resource|
yaml_content = K8::Kubectl.new( yaml_content = K8::Kubectl.new(
K8::Connection.new(@cluster, current_user) K8::Connection.new(@cluster, current_user)
).call("get #{resource} -n #{project.name} -o yaml") ).call("get #{resource} -n #{project.namespace} -o yaml")
export(@cluster.name, project.name, yaml_content, zio) export(@cluster.name, project.namespace, yaml_content, zio)
end end
end end
end end

View File

@@ -13,8 +13,8 @@ class Projects::ProcessesController < Projects::BaseController
def show def show
client = K8::Client.new(active_connection) client = K8::Client.new(active_connection)
@logs = client.get_pod_log(params[:id], @project.name) @logs = client.get_pod_log(params[:id], @project.namespace)
@pod_events = client.get_pod_events(params[:id], @project.name) @pod_events = client.get_pod_events(params[:id], @project.namespace)
respond_to do |format| respond_to do |format|
format.html format.html
@@ -30,7 +30,7 @@ class Projects::ProcessesController < Projects::BaseController
def destroy def destroy
client = K8::Client.new(active_connection) client = K8::Client.new(active_connection)
client.delete_pod(params[:id], @project.name) client.delete_pod(params[:id], @project.namespace)
redirect_to project_processes_path(@project), notice: "Pod #{params[:id]} terminating..." redirect_to project_processes_path(@project), notice: "Pod #{params[:id]} terminating..."
end end

View File

@@ -7,7 +7,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
job_name = "#{@service.name}-manual-#{timestamp}" job_name = "#{@service.name}-manual-#{timestamp}"
kubectl = K8::Kubectl.new(active_connection) kubectl = K8::Kubectl.new(active_connection)
kubectl.call( kubectl.call(
"-n #{@project.name} create job #{job_name} --from=cronjob/#{@service.name}" "-n #{@project.namespace} create job #{job_name} --from=cronjob/#{@service.name}"
) )
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false
end end
@@ -16,7 +16,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
job_name = params[:id] job_name = params[:id]
kubectl = K8::Kubectl.new(active_connection) kubectl = K8::Kubectl.new(active_connection)
kubectl.call( kubectl.call(
"-n #{@project.name} delete job #{job_name}" "-n #{@project.namespace} delete job #{job_name}"
) )
render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false render partial: "projects/services/show", locals: { service: @service, tab: "cron-jobs" }, layout: false

View File

@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container"]
connect() {
// Initial setup: hidden + transparent + transition classes
this.containerTarget.classList.add(
"hidden", // prevent layout
"opacity-0", // starting transparency
"transition-all",
"duration-500" // control speed
)
}
show(e) {
e.preventDefault()
// Hide the button
e.target.classList.add("hidden")
// Step 1: unhide but keep opacity-0
this.containerTarget.classList.remove("hidden")
// Step 2: allow browser to register this initial state
requestAnimationFrame(() => {
// Step 3: now fade to opacity-100 → animation occurs
this.containerTarget.classList.add("opacity-100")
this.containerTarget.classList.remove("opacity-0")
})
}
}

View File

@@ -0,0 +1,11 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("NamespaceInputGroupController connected")
}
toggleManagedNamespace() {
}
}

View File

@@ -11,12 +11,14 @@ class Projects::DeploymentJob < ApplicationJob
project = deployment.project project = deployment.project
connection = K8::Connection.new(project, user, allow_anonymous: true) connection = K8::Connection.new(project, user, allow_anonymous: true)
kubectl = create_kubectl(deployment, connection) kubectl = create_kubectl(deployment, connection)
kubectl.register_after_apply do |yaml_content| kubectl.register_before_apply do |yaml_content|
deployment.add_manifest(yaml_content) deployment.add_manifest(yaml_content)
end end
# Create namespace # Create namespace
apply_namespace(project, kubectl) if project.managed_namespace?
apply_namespace(project, kubectl)
end
# Upload container registry secrets # Upload container registry secrets
upload_registry_secrets(kubectl, deployment) upload_registry_secrets(kubectl, deployment)
@@ -108,11 +110,11 @@ class Projects::DeploymentJob < ApplicationJob
end end
def kill_one_off_containers(project, kubectl) def kill_one_off_containers(project, kubectl)
kubectl.call("-n #{project.name} delete pods -l oneoff=true") kubectl.call("-n #{project.namespace} delete pods -l oneoff=true")
end end
def apply_namespace(project, kubectl) def apply_namespace(project, kubectl)
@logger.info("Creating namespace: #{project.name}", color: :yellow) @logger.info("Creating namespace: #{project.namespace}", color: :yellow)
namespace_yaml = K8::Namespace.new(project).to_yaml namespace_yaml = K8::Namespace.new(project).to_yaml
kubectl.apply_yaml(namespace_yaml) kubectl.apply_yaml(namespace_yaml)
end end
@@ -124,7 +126,7 @@ class Projects::DeploymentJob < ApplicationJob
kubectl = K8::Kubectl.new(connection) kubectl = K8::Kubectl.new(connection)
resources_to_sweep.each do |resource_type| resources_to_sweep.each do |resource_type|
results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.name}")) results = YAML.safe_load(kubectl.call("get #{resource_type.downcase} -o yaml -n #{project.namespace}"))
results['items'].each do |resource| results['items'].each do |resource|
if @marked_resources.select do |r| if @marked_resources.select do |r|
r.is_a?(K8::Stateless.const_get(resource_type)) r.is_a?(K8::Stateless.const_get(resource_type))
@@ -132,7 +134,7 @@ class Projects::DeploymentJob < ApplicationJob
applied_resource.name == resource['metadata']['name'] applied_resource.name == resource['metadata']['name']
end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true' end && resource.dig('metadata', 'labels', 'caninemanaged') == 'true'
@logger.info("Deleting #{resource_type}: #{resource['metadata']['name']}", color: :yellow) @logger.info("Deleting #{resource_type}: #{resource['metadata']['name']}", color: :yellow)
kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.name}") kubectl.call("delete #{resource_type.downcase} #{resource['metadata']['name']} -n #{project.namespace}")
end end
end end
end end
@@ -150,7 +152,7 @@ class Projects::DeploymentJob < ApplicationJob
def restart_deployment(service, kubectl) def restart_deployment(service, kubectl)
@logger.info("Restarting deployment: #{service.name}", color: :yellow) @logger.info("Restarting deployment: #{service.name}", color: :yellow)
kubectl.call("-n #{service.project.name} rollout restart deployment/#{service.name}") kubectl.call("-n #{service.project.namespace} rollout restart deployment/#{service.name}")
end end
def upload_registry_secrets(kubectl, deployment) def upload_registry_secrets(kubectl, deployment)

View File

@@ -1,7 +1,9 @@
class Projects::DestroyJob < ApplicationJob class Projects::DestroyJob < ApplicationJob
def perform(project, user) def perform(project, user)
project.destroying! project.destroying!
delete_namespace(project, user) if project.managed_namespace
delete_namespace(project, user)
end
# Delete the github webhook for the project IF there are no more projects that refer to that repository # Delete the github webhook for the project IF there are no more projects that refer to that repository
# TODO: This might have overlapping repository urls across different providers. # TODO: This might have overlapping repository urls across different providers.
@@ -15,7 +17,7 @@ class Projects::DestroyJob < ApplicationJob
def delete_namespace(project, user) def delete_namespace(project, user)
client = K8::Client.new(K8::Connection.new(project.cluster, user)) client = K8::Client.new(K8::Connection.new(project.cluster, user))
if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.name }).present? if (namespace = client.get_namespaces.find { |n| n.metadata.name == project.namespace }).present?
client.delete_namespace(namespace.metadata.name) client.delete_namespace(namespace.metadata.name)
end end
end end

View File

@@ -3,7 +3,7 @@ class Scheduled::CheckHealthJob < ApplicationJob
def perform def perform
Service.web_service.where('healthcheck_url IS NOT NULL').each do |service| Service.web_service.where('healthcheck_url IS NOT NULL').each do |service|
# url = File.join("http://#{service.name}-service.#{service.project.name}.svc.cluster.local", service.healthcheck_url) # url = File.join("http://#{service.name}-service.#{service.project.namespace}.svc.cluster.local", service.healthcheck_url)
# K8::Client.from_project(service.project).run_command("curl -s -o /dev/null -w '%{http_code}' #{url}") # K8::Client.from_project(service.project).run_command("curl -s -o /dev/null -w '%{http_code}' #{url}")
if service.domains.any? if service.domains.any?
url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url) url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url)

View File

@@ -2,16 +2,18 @@
# #
# Table name: add_ons # Table name: add_ons
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# chart_type :string not null # chart_type :string not null
# chart_url :string # chart_url :string
# metadata :jsonb # managed_namespace :boolean default(TRUE)
# name :string not null # metadata :jsonb
# status :integer default("installing"), not null # name :string not null
# values :jsonb # namespace :string not null
# created_at :datetime not null # status :integer default("installing"), not null
# updated_at :datetime not null # values :jsonb
# cluster_id :bigint not null # created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
# #
# Indexes # Indexes
# #
@@ -25,6 +27,7 @@
class AddOn < ApplicationRecord class AddOn < ApplicationRecord
include Loggable include Loggable
include TeamAccessible include TeamAccessible
include Namespaced
belongs_to :cluster belongs_to :cluster
has_one :account, through: :cluster has_one :account, through: :cluster
@@ -40,9 +43,9 @@ class AddOn < ApplicationRecord
validates :chart_type, presence: true validates :chart_type, presence: true
validate :chart_type_exists validate :chart_type_exists
validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" } validates :name, presence: true, format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
validate :name_is_unique_to_cluster, on: :create
validates_presence_of :chart_url validates_presence_of :chart_url
validate :has_package_details, if: :helm_chart? validate :has_package_details, if: :helm_chart?
validates_uniqueness_of :name, scope: :cluster_id
after_update_commit do after_update_commit do
broadcast_replace_later_to [ self, :install_stage ], target: dom_id(self, :install_stage), partial: "add_ons/install_stage", locals: { add_on: self } broadcast_replace_later_to [ self, :install_stage ], target: dom_id(self, :install_stage), partial: "add_ons/install_stage", locals: { add_on: self }
@@ -57,12 +60,6 @@ class AddOn < ApplicationRecord
metadata['install_stage'] || 0 metadata['install_stage'] || 0
end end
def name_is_unique_to_cluster
if cluster.namespaces.include?(name)
errors.add(:name, "must be unique to this cluster")
end
end
def has_package_details def has_package_details
if metadata['package_details'].blank? if metadata['package_details'].blank?
errors.add(:metadata, "is missing required keys: package_details") errors.add(:metadata, "is missing required keys: package_details")

View File

@@ -59,7 +59,7 @@ class Cluster < ApplicationRecord
] ]
def namespaces def namespaces
RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name) RESERVED_NAMESPACES + projects.pluck(:namespace) + add_ons.pluck(:namespace)
end end
def create_build_cloud!(attributes = {}) def create_build_cloud!(attributes = {})

View File

@@ -0,0 +1,15 @@
module Namespaced
def name_is_unique_to_cluster
if cluster.namespaces.include?(namespace)
errors.add(:name, "must be unique to this cluster")
end
end
def self.included(base)
base.class_eval do
validates_presence_of :namespace
validate :name_is_unique_to_cluster, on: :create
end
end
end

View File

@@ -9,7 +9,9 @@
# container_registry_url :string # container_registry_url :string
# docker_build_context_directory :string default("."), not null # docker_build_context_directory :string default("."), not null
# dockerfile_path :string default("./Dockerfile"), not null # dockerfile_path :string default("./Dockerfile"), not null
# managed_namespace :boolean default(TRUE)
# name :string not null # name :string not null
# namespace :string not null
# postdeploy_command :text # postdeploy_command :text
# postdestroy_command :text # postdestroy_command :text
# predeploy_command :text # predeploy_command :text
@@ -33,6 +35,7 @@
# #
class Project < ApplicationRecord class Project < ApplicationRecord
include TeamAccessible include TeamAccessible
include Namespaced
broadcasts_refreshes broadcasts_refreshes
belongs_to :cluster belongs_to :cluster
has_one :account, through: :cluster has_one :account, through: :cluster
@@ -55,6 +58,8 @@ class Project < ApplicationRecord
validates :name, presence: true, validates :name, presence: true,
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" } format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
validates :namespace, presence: true,
format: { with: /\A[a-z0-9-]+\z/, message: "must be lowercase, numbers, and hyphens only" }
validates :branch, presence: true validates :branch, presence: true
validates :repository_url, presence: true, validates :repository_url, presence: true,
format: { format: {
@@ -65,8 +70,8 @@ class Project < ApplicationRecord
validates_presence_of :project_fork_cluster_id, unless: :forks_disabled? validates_presence_of :project_fork_cluster_id, unless: :forks_disabled?
validate :project_fork_cluster_id_is_owned_by_account validate :project_fork_cluster_id_is_owned_by_account
validates_presence_of :build_configuration, if: :git? validates_presence_of :build_configuration, if: :git?
validates_uniqueness_of :name, scope: :cluster_id
validate :name_is_unique_to_cluster, on: :create
after_save_commit do after_save_commit do
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self } broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
end end
@@ -93,12 +98,6 @@ class Project < ApplicationRecord
end end
end end
def name_is_unique_to_cluster
if cluster.namespaces.include?(name)
errors.add(:name, "must be unique to this cluster")
end
end
def current_deployment def current_deployment
deployments.order(created_at: :desc).where(status: :completed).first deployments.order(created_at: :desc).where(status: :completed).first
end end

View File

@@ -11,14 +11,23 @@ class K8::Kubectl
raise "Kubeconfig is required" raise "Kubeconfig is required"
end end
@runner = runner @runner = runner
@before_apply_blocks = []
@after_apply_blocks = [] @after_apply_blocks = []
end end
def register_before_apply(&block)
@before_apply_blocks << block
end
def register_after_apply(&block) def register_after_apply(&block)
@after_apply_blocks << block @after_apply_blocks << block
end end
def apply_yaml(yaml_content) def apply_yaml(yaml_content)
@before_apply_blocks.each do |block|
block.call(yaml_content)
end
with_kube_config do |kubeconfig_file| with_kube_config do |kubeconfig_file|
# Create a temporary file for the YAML content # Create a temporary file for the YAML content
Tempfile.open([ "k8s", ".yaml" ]) do |yaml_file| Tempfile.open([ "k8s", ".yaml" ]) do |yaml_file|

View File

@@ -18,7 +18,7 @@ class K8::Stateless::CronJob < K8::Base
private private
def fetch_jobs_for_cronjob def fetch_jobs_for_cronjob
result = kubectl.call("get jobs -n #{project.name} -o json") result = kubectl.call("get jobs -n #{project.namespace} -o json")
all_jobs = JSON.parse(result, object_class: OpenStruct).items all_jobs = JSON.parse(result, object_class: OpenStruct).items
# Filter jobs owned by this CronJob # Filter jobs owned by this CronJob

View File

@@ -9,6 +9,6 @@ class K8::Stateless::Deployment < K8::Base
end end
def restart def restart
kubectl.call("rollout restart deployment/#{service.name} -n #{project.name}") kubectl.call("rollout restart deployment/#{service.name} -n #{project.namespace}")
end end
end end

View File

@@ -15,7 +15,7 @@ class K8::Stateless::Ingress < K8::Base
return nil unless @service.domains.any? return nil unless @service.domains.any?
return nil unless @service.allow_public_networking? return nil unless @service.allow_public_networking?
kubectl.call("get certificate #{certificate_name} -n #{@project.name} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True" kubectl.call("get certificate #{certificate_name} -n #{@project.namespace} -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}'") == "True"
end end
def certificate_name def certificate_name
@@ -25,7 +25,7 @@ class K8::Stateless::Ingress < K8::Base
def get_ingress def get_ingress
result = kubectl.call('get ingresses -o yaml') result = kubectl.call('get ingresses -o yaml')
results = YAML.safe_load(result) results = YAML.safe_load(result)
results['items'].find { |r| r['metadata']['name'] == "#{@service.project.name}-ingress" } results['items'].find { |r| r['metadata']['name'] == "#{@service.project.namespace}-ingress" }
end end
def self.ip_address(client) def self.ip_address(client)

View File

@@ -7,7 +7,7 @@ class K8::Stateless::Service < K8::Base
end end
def internal_url def internal_url
"#{name}.#{project.name}.svc.cluster.local:80" "#{name}.#{project.namespace}.svc.cluster.local:80"
end end
def name def name

View File

@@ -19,6 +19,6 @@ class Async::Projects::Processes::IndexViewModel < Async::BaseViewModel
def get_pods_for_project(project) def get_pods_for_project(project)
# Get all pods for a given namespace # Get all pods for a given namespace
client = K8::Client.new(K8::Connection.new(project.cluster, current_user)) client = K8::Client.new(K8::Connection.new(project.cluster, current_user))
client.get_pods(namespace: project.name) client.get_pods(namespace: project.namespace)
end end
end end

View File

@@ -12,25 +12,29 @@
<div class="card card-bordered bg-base-100"> <div class="card card-bordered bg-base-100">
<div class="card-body"> <div class="card-body">
<%= form_with(model: @add_on) do |form| %> <%= form_with(model: @add_on) do |form| %>
<%= render(FormFieldComponent.new( <div class="space-y-8">
label: "Name", <%= render(FormFieldComponent.new(
description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed." label: "Name",
)) do %> description: "A unique name for your add on, only lowercase letters, numbers, and hyphens are allowed."
<%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %> )) do %>
<label class="label"> <%= form.text_field :name, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true, required: true %>
<span class="label-text-alt">* Required</span> <label class="label">
</label> <span class="label-text-alt">* Required</span>
<% end %> </label>
<% end %>
<%= render(FormFieldComponent.new( <%= render(FormFieldComponent.new(
label: "Cluster", label: "Cluster",
description: "The cluster to deploy your add on to." description: "The cluster to deploy your add on to."
)) do %> )) do %>
<%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %> <%= form.collection_select :cluster_id, current_account.clusters, :id, :name, {}, { class: "select select-bordered w-full" } %>
<label class="label"> <label class="label">
<span class="label-text-alt">* Required</span> <span class="label-text-alt">* Required</span>
</label> </label>
<% end %> <% end %>
<%= render "shared/partials/namespace_input_group", form: %>
</div>
<div data-controller="card-select"> <div data-controller="card-select">
<div class="form-group"> <div class="form-group">

View File

@@ -22,23 +22,39 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<div class="text-sm"> <div class="text-sm flex flex-row">
<%= link_to project.link_to_view, target: "_blank" do %> <%= link_to project.link_to_view, target: "_blank" do %>
<% if project.git? %> <% if project.git? %>
<% if project.github? %> <div class="flex flex-row items-center gap-1">
<iconify-icon icon="lucide:github"></iconify-icon> <% if project.github? %>
<% elsif project.gitlab? %> <iconify-icon icon="lucide:github"></iconify-icon>
<iconify-icon icon="lucide:gitlab"></iconify-icon> <% elsif project.gitlab? %>
<% end %> <iconify-icon icon="lucide:gitlab"></iconify-icon>
<span class="underline mr-2"><%= project.repository_url %></span> <% end %>
<iconify-icon icon="lucide:git-branch"></iconify-icon> <span class="underline mr-2"><%= project.repository_url %></span>
<span class="underline"><%= project.branch %></span> <iconify-icon icon="lucide:git-branch"></iconify-icon>
<span class="underline"><%= project.branch %></span>
</div>
<% else %> <% else %>
<iconify-icon icon="logos:docker-icon"></iconify-icon> <div class="flex flex-row items-center gap-1">
<span class="underline"><%= project.repository_url %></span> <iconify-icon icon="logos:docker-icon"></iconify-icon>
<span class="underline"><%= project.repository_url %></span>
<div class="flex flex-row items-center gap-1">
<% end %> <% end %>
<% end %> <% end %>
<span class="ml-6"><iconify-icon icon="devicon:kubernetes"></iconify-icon> <%= link_to project.cluster.name, project.cluster, target: "_blank", class: "underline" %></span>
<%= link_to(
project.cluster,
target: "_blank",
class: "underline",
) do %>
<div class="ml-6 flex flex-row items-center gap-1">
<iconify-icon icon="devicon:kubernetes"></iconify-icon>
<div><%= project.cluster.name %></div>
<div><iconify-icon icon="lucide:slash"></iconify-icon></div>
<div><%= project.namespace %></div>
</div>
<% end %>
</div> </div>
</div> </div>
<div class="flex flex-col self-stretch mt-4 lg:mt-0"> <div class="flex flex-col self-stretch mt-4 lg:mt-0">

View File

@@ -67,22 +67,34 @@
<span class="label-text-alt">Select a credential that gives access to a container registry</span> <span class="label-text-alt">Select a credential that gives access to a container registry</span>
</label> </label>
</div> </div>
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label"> <div data-controller="expandable-optional-input">
<span class="label-text">Image repository</span> <div>
</label> <a data-action="expandable-optional-input#show" class="btn btn-ghost">
<%= bc_form.text_field( + Choose custom image registry
:image_repository, </a>
class: "input input-bordered w-full focus:outline-offset-0", </div>
value: build_configuration.image_repository,
placeholder: "namespace/repo", <div data-expandable-optional-input-target="container">
pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+", <div class="form-control mt-1 mb-2 w-full max-w-md">
title: "Must be in the format 'namespace/repo'" <label class="label">
) %> <span class="label-text">Image repository</span>
<label class="label"> </label>
<span class="label-text-alt">If this is left blank, a container registry will be automatically created for you.</span> <%= bc_form.text_field(
</label> :image_repository,
class: "input input-bordered w-full focus:outline-offset-0",
value: build_configuration.image_repository,
placeholder: "namespace/repo",
pattern: "[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]/[a-zA-Z0-9._-]+",
title: "Must be in the format 'namespace/repo'"
) %>
<label class="label">
<span class="label-text-alt">If this is left blank, a container registry will be automatically created for you.</span>
</label>
</div>
</div>
</div> </div>
<div class="form-control mt-4"> <div class="form-control mt-4">
<label class="label mb-2"> <label class="label mb-2">
<span class="label-text">Build method</span> <span class="label-text">Build method</span>

View File

@@ -1,7 +1,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<div class="p-10"> <div class="p-10">
<h1 class="text-2xl font-bold">Missing Credentials for Docker Hub</h1> <h1 class="text-2xl font-bold">Missing credentials for container registry</h1>
<p class="mt-2 text-gray-400">Please provide your Docker Hub credentials to continue.</p> <p class="mt-2 text-gray-400">Please provide your container registry credentials to continue.</p>
<%= link_to "Add Credentials", providers_path, class: "mt-6 btn btn-primary", data: { turbo: false } %> <%= link_to "Add Credentials", providers_path, class: "mt-6 btn btn-primary", data: { turbo: false } %>
<div class="mt-6"> <div class="mt-6">
<%= link_to( <%= link_to(

View File

@@ -1,6 +1,6 @@
<div class="flex flex-col"> <div class="flex flex-col">
<div class="p-10"> <div class="p-10">
<h1 class="text-2xl font-bold">Missing Credentials for Git</h1> <h1 class="text-2xl font-bold">Missing credentials for Git repository</h1>
<p class="mt-2 text-gray-400">Please provide your Github or Gitlab credentials to continue.</p> <p class="mt-2 text-gray-400">Please provide your Github or Gitlab credentials to continue.</p>
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<div role="tooltip" data-tip="This feature is coming soon!" class="tooltip"> <div role="tooltip" data-tip="This feature is coming soon!" class="tooltip">

View File

@@ -55,6 +55,8 @@
)) do %> )) do %>
<%= form.text_field :predeploy_command, class: "input input-bordered w-full focus:outline-offset-0" %> <%= form.text_field :predeploy_command, class: "input input-bordered w-full focus:outline-offset-0" %>
<% end %> <% end %>
<%= render "shared/partials/namespace_input_group", form: %>
</div> </div>
<div class="form-footer"> <div class="form-footer">

View File

@@ -105,6 +105,8 @@
<%= render "projects/build_configurations/form", form: form, project: project %> <%= render "projects/build_configurations/form", form: form, project: project %>
<% end %> <% end %>
<% end %> <% end %>
<%= render "shared/partials/namespace_input_group", form: %>
</div> </div>
<div class="form-footer"> <div class="form-footer">

View File

@@ -10,11 +10,12 @@
}, },
{ {
class: "select select-bordered w-full", class: "select select-bordered w-full",
required: true,
data: { data: {
"new-project-target": "provider", "new-project-target": "provider",
action: "change->new-project#selectProvider", action: "change->new-project#selectProvider",
} }
} },
) %> ) %>
<% end %> <% end %>
<div class="label"> <div class="label">

View File

@@ -4,6 +4,9 @@
<div> <div>
<h2 class="text-2xl font-bold">General</h2> <h2 class="text-2xl font-bold">General</h2>
<hr class="mt-3 mb-4 border-t border-base-300" /> <hr class="mt-3 mb-4 border-t border-base-300" />
<% unless @project.managed_namespace %>
<%= render "shared/partials/namespace_show", namespaced: @project %>
<% end %>
<%= render "edit_form", project: @project %> <%= render "edit_form", project: @project %>
</div> </div>

View File

@@ -40,7 +40,7 @@
placeholder: "# Example:\ncontainers:\n - name: sidecar\n image: nginx:latest\nvolumes:\n - name: cache\n emptyDir: {}", placeholder: "# Example:\ncontainers:\n - name: sidecar\n image: nginx:latest\nvolumes:\n - name: cache\n emptyDir: {}",
class: "textarea textarea-bordered font-mono text-sm w-full", class: "textarea textarea-bordered font-mono text-sm w-full",
data: { yaml_editor_target: "textarea" }, data: { yaml_editor_target: "textarea" },
value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml : "" %> value: @service.pod_yaml.present? ? @service.pod_yaml.to_yaml_raw : "" %>
<div data-yaml-editor-target="editor"></div> <div data-yaml-editor-target="editor"></div>
<label class="label"> <label class="label">
<span class="label-text-alt">Enter valid YAML for pod template spec fields</span> <span class="label-text-alt">Enter valid YAML for pod template spec fields</span>

View File

@@ -29,7 +29,7 @@
</td> </td>
<td> <td>
<% if provider.access_token %> <% if provider.access_token %>
<%= provider.access_token.first(6) %><%= "*" * [10, provider.access_token.length - 12].min %><%= provider.access_token.last(6) %> <%= provider.access_token.first(4) %><%= "*" * [0, [10, provider.access_token.length - 6].min].max %><%= provider.access_token.last(2) %>
<% else %> <% else %>
<span class="text-gray-500"> <span class="text-gray-500">
N/A N/A

View File

@@ -0,0 +1,30 @@
<div data-controller="expandable-optional-input">
<div>
<a data-action="expandable-optional-input#show" class="btn btn-ghost">
+ Add namespace configuration
</a>
</div>
<div data-expandable-optional-input-target="container" data-controller="namespace-input-group">
<%= render(FormFieldComponent.new(
label: "Namespace",
description: "The namespace your application is deployed to."
)) do %>
<%= form.text_field :namespace, class: "input input-bordered w-full focus:outline-offset-0", autofocus: true %>
<label class="label">
<span class="label-text-alt">If this is left blank, a namespace will be created automatically.</span>
</label>
<div class="mt-3 form-control rounded-lg bg-base-200 p-2 px-4">
<label class="label mt-1">
<div class="label-text cursor-pointer">
Automatically create namespace
</div>
<%= form.check_box :managed_namespace, class: "checkbox", data: { action: "namespace-input-group#toggleManagedNamespace" } %>
</label>
</div>
<label class="label">
<span class="label-text-alt">Canine will automatically create a namespace when the project is deployed.</span>
</label>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,24 @@
<div>
<div class="flex flex-row items-end">
<div>
<div><label>Namespace</label></div>
<div class="flex flex-row items-center">
<input value="<%= namespaced.namespace %>" class="input input-bordered w-full focus:outline-offset-0" readonly />
<div class="mx-2 font-medium text-lg">/</div>
</div>
</div>
<div>
<div><label>Name</label></div>
<input value="<%= namespaced.name %>" class="input input-bordered w-full focus:outline-offset-0" readonly />
</div>
</div>
<label class="label">
<span class="label-text-alt">
<% if namespaced.managed_namespace %>
This namespace is managed by Canine. If the <%= namespaced.class.to_s.titleize %> is deleted, the namespace will be deleted.
<% else %>
This namespace is managed by Canine. If the <%= namespaced.class.to_s.titleize %> is deleted, the namespace will not be deleted.
<% end %>
</span>
</label>
</div>

View File

@@ -22,7 +22,7 @@
</div> </div>
<%= render "static/landing_page/announcement" %> <%= render "static/landing_page/announcement" %>
<img class="h-[70px]" src="/images/logo-full.webp" alt="Canine"> <img class="h-[70px]" src="/images/logo-full.webp" alt="Canine">
<h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-6xl">A modern, open source alternative to Heroku</h1> <h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-6xl">A Developer-friendly PaaS for your Kubernetes</h1>
<p class="mt-6 text-lg leading-8 text-gray-300">Canine is an open source deployment platform that makes it easy to deploy and manage your applications.</p> <p class="mt-6 text-lg leading-8 text-gray-300">Canine is an open source deployment platform that makes it easy to deploy and manage your applications.</p>
<div class="mt-10 flex items-center gap-x-6"> <div class="mt-10 flex items-center gap-x-6">
<%= link_to "Get started", new_user_registration_path, class: "rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400" %> <%= link_to "Get started", new_user_registration_path, class: "rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400" %>

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class Hash
# Converts hash to YAML without the document start marker (---)
def to_yaml_raw
to_yaml.sub(/\A---\n?/, "")
end
end

View File

@@ -0,0 +1,20 @@
class AddExistingNamespaceToProjects < ActiveRecord::Migration[7.2]
def change
add_column :projects, :namespace, :string
add_column :projects, :managed_namespace, :boolean, default: true
Project.all.each do |project|
project.namespace = project.name
project.save!
end
change_column_null :projects, :namespace, false
add_column :add_ons, :namespace, :string
add_column :add_ons, :managed_namespace, :boolean, default: true
AddOn.all.each do |add_on|
add_on.namespace = add_on.name
add_on.save!
end
change_column_null :add_ons, :namespace, false
end
end

47
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do ActiveRecord::Schema[7.2].define(version: 2025_11_26_014503) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -71,6 +71,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "values", default: {} t.jsonb "values", default: {}
t.string "chart_url" t.string "chart_url"
t.string "namespace", null: false
t.boolean "managed_namespace", default: true
t.index ["cluster_id", "name"], name: "index_add_ons_on_cluster_id_and_name", unique: true t.index ["cluster_id", "name"], name: "index_add_ons_on_cluster_id_and_name", unique: true
t.index ["cluster_id"], name: "index_add_ons_on_cluster_id" t.index ["cluster_id"], name: "index_add_ons_on_cluster_id"
end end
@@ -333,6 +335,21 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end
create_table "ldap_configurations", force: :cascade do |t|
t.string "host", null: false
t.integer "port", default: 389, null: false
t.integer "encryption", null: false
t.string "base_dn", null: false
t.string "bind_dn"
t.string "bind_password"
t.string "uid_attribute", default: "uid", null: false
t.string "email_attribute", default: "mail"
t.string "name_attribute", default: "cn"
t.string "filter"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "log_outputs", force: :cascade do |t| create_table "log_outputs", force: :cascade do |t|
t.bigint "loggable_id", null: false t.bigint "loggable_id", null: false
t.string "loggable_type", null: false t.string "loggable_type", null: false
@@ -375,6 +392,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient" t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
end end
create_table "oidc_configurations", force: :cascade do |t|
t.string "issuer", null: false
t.string "client_id", null: false
t.string "client_secret", null: false
t.string "authorization_endpoint"
t.string "token_endpoint"
t.string "userinfo_endpoint"
t.string "jwks_uri"
t.string "scopes", default: "openid email profile"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "project_add_ons", force: :cascade do |t| create_table "project_add_ons", force: :cascade do |t|
t.bigint "project_id", null: false t.bigint "project_id", null: false
t.bigint "add_on_id", null: false t.bigint "add_on_id", null: false
@@ -427,6 +457,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
t.text "postdestroy_command" t.text "postdestroy_command"
t.bigint "project_fork_cluster_id" t.bigint "project_fork_cluster_id"
t.integer "project_fork_status", default: 0 t.integer "project_fork_status", default: 0
t.string "namespace", null: false
t.boolean "managed_namespace", default: true
t.index ["cluster_id"], name: "index_projects_on_cluster_id" t.index ["cluster_id"], name: "index_projects_on_cluster_id"
end end
@@ -477,6 +509,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
t.index ["project_id"], name: "index_services_on_project_id" t.index ["project_id"], name: "index_services_on_project_id"
end end
create_table "sso_providers", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "configuration_type", null: false
t.bigint "configuration_id", null: false
t.string "name", null: false
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_sso_providers_on_account_id", unique: true
t.index ["configuration_type", "configuration_id"], name: "index_sso_providers_on_configuration"
end
create_table "stack_managers", force: :cascade do |t| create_table "stack_managers", force: :cascade do |t|
t.string "provider_url", null: false t.string "provider_url", null: false
t.integer "stack_manager_type", default: 0, null: false t.integer "stack_manager_type", default: 0, null: false
@@ -576,6 +620,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
add_foreign_key "projects", "clusters", column: "project_fork_cluster_id" add_foreign_key "projects", "clusters", column: "project_fork_cluster_id"
add_foreign_key "providers", "users" add_foreign_key "providers", "users"
add_foreign_key "services", "projects" add_foreign_key "services", "projects"
add_foreign_key "sso_providers", "accounts"
add_foreign_key "stack_managers", "accounts" add_foreign_key "stack_managers", "accounts"
add_foreign_key "team_memberships", "teams" add_foreign_key "team_memberships", "teams"
add_foreign_key "team_memberships", "users" add_foreign_key "team_memberships", "users"

View File

@@ -60,7 +60,7 @@ class Portainer::Stack
def logs_url(service, pod_name) def logs_url(service, pod_name)
service = service.name service = service.name
container = service.project.name container = service.project.name
namespace = service.project.name namespace = service.project.namespace
cluster = service.project.cluster cluster = service.project.cluster
"/#{cluster.external_id}/kubernetes/applications/#{namespace}/#{service}/#{pod_name}/#{container}/logs" "/#{cluster.external_id}/kubernetes/applications/#{namespace}/#{service}/#{pod_name}/#{container}/logs"

View File

@@ -1,6 +1,6 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: <%= nameable.name %> name: <%= nameable.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'

View File

@@ -3,6 +3,6 @@ type: kubernetes.io/dockerconfigjson
apiVersion: v1 apiVersion: v1
metadata: metadata:
name: dockerconfigjson-github-com name: dockerconfigjson-github-com
namespace: <%= project.name %> namespace: <%= project.namespace %>
data: data:
.dockerconfigjson: <%= docker_config_json %> .dockerconfigjson: <%= docker_config_json %>

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: <%= name %> name: <%= name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
app: <%= project.name %> app: <%= project.name %>

View File

@@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
namespace: <%= @project.name %> namespace: <%= @project.namespace %>
name: <%= @project.name %> name: <%= @project.name %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'

View File

@@ -2,7 +2,7 @@ apiVersion: batch/v1
kind: CronJob kind: CronJob
metadata: metadata:
name: <%= service.name %> name: <%= service.name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
app: <%= service.name %> app: <%= service.name %>
@@ -15,6 +15,9 @@ spec:
spec: spec:
template: template:
spec: spec:
<% if service.pod_yaml.present? %>
<%= service.pod_yaml.to_yaml_raw.to_s.lines.map { |line| " #{line}" }.join %>
<% end %>
containers: containers:
- name: <%= project.name %> - name: <%= project.name %>
image: <%= project.container_image_reference %> image: <%= project.container_image_reference %>
@@ -74,6 +77,3 @@ spec:
# Add your volume specifications here (e.g., persistentVolumeClaim, configMap, etc.) # Add your volume specifications here (e.g., persistentVolumeClaim, configMap, etc.)
<% end %> <% end %>
<% end %> <% end %>
<% if service.pod_yaml.present? %>
<%= service.pod_yaml %>
<% end %>

View File

@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: <%= service.name %> name: <%= service.name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
app: <%= service.name %> app: <%= service.name %>
@@ -21,6 +21,9 @@ spec:
labels: labels:
app: <%= service.name %> app: <%= service.name %>
spec: spec:
<% if service.pod_yaml.present? %>
<%= service.pod_yaml.to_yaml_raw.to_s.lines.map { |line| " #{line}" }.join %>
<% end %>
containers: containers:
- name: <%= project.name %> - name: <%= project.name %>
image: <%= project.container_image_reference %> image: <%= project.container_image_reference %>
@@ -94,6 +97,3 @@ spec:
claimName: <%= volume.name %> claimName: <%= volume.name %>
<% end %> <% end %>
<% end %> <% end %>
<% if service.pod_yaml.present? %>
<%= service.pod_yaml %>
<% end %>

View File

@@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: <%= name %> name: <%= name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
annotations: annotations:

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: Pod kind: Pod
metadata: metadata:
name: <%= name %> name: <%= name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
oneoff: 'true' oneoff: 'true'

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: PersistentVolume kind: PersistentVolume
metadata: metadata:
name: <%= name %> name: <%= name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
spec: spec:

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: <%= name %> name: <%= name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
spec: spec:

View File

@@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
namespace: <%= @project.name %> namespace: <%= @project.namespace %>
name: <%= @project.name %> name: <%= @project.name %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: <%= name %> name: <%= name %>
namespace: <%= project.name %> namespace: <%= project.namespace %>
labels: labels:
caninemanaged: 'true' caninemanaged: 'true'
app: <%= service.name %> app: <%= service.name %>

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe AddOns::ApplyTemplateToValues do
let(:add_on) { build(:add_on) }
let(:template) do
{
'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' },
'replica.replicaCount' => '5'
}
end
before do
add_on.metadata['template'] = template
add_on.chart_type = "redis"
end
it 'applies template values correctly' do
described_class.execute(add_on:)
expect(add_on.values['master']['persistence']['size']).to eq('10Gi')
expect(add_on.values['replica']['replicaCount']).to eq(5)
end
end

View File

@@ -1,10 +1,12 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe AddOns::Create do RSpec.describe AddOns::Create do
let(:cluster) { create(:cluster) }
let(:add_on) { build(:add_on) } let(:add_on) { build(:add_on) }
let(:chart_details) { { 'name' => 'test-chart', 'version' => '1.0.0' } } let(:chart_details) { { 'name' => 'redis', 'version' => '1.0.0' } }
before do before do
allow(Namespaced::ValidateNamespace).to receive(:execute)
allow(AddOns::HelmChartDetails).to receive(:execute).and_return( allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
double(success?: true, failure?: false, response: chart_details) double(success?: true, failure?: false, response: chart_details)
) )
@@ -12,53 +14,42 @@ RSpec.describe AddOns::Create do
describe 'errors' do describe 'errors' do
context 'there is a project with the same name in the same cluster' do context 'there is a project with the same name in the same cluster' do
let!(:project) { create(:project, name: add_on.name, cluster: add_on.cluster) } let!(:project) { create(:project, name: add_on.name, cluster: add_on.cluster, namespace: 'taken') }
let(:add_on) { build(:add_on, namespace: 'taken') }
it 'raises an error' do it 'raises an error' do
result = described_class.execute(add_on:) result = described_class.call(add_on, cluster.account.owner)
expect(result.failure?).to be_truthy expect(result.failure?).to be_truthy
end end
end end
end end
describe '#execute' do let(:params) do
it 'applies template and fetches package details' do ActionController::Parameters.new({
expect(add_on).to receive(:save) add_on: {
result = described_class.execute(add_on:) name: 'redis-main',
expect(result.add_on.metadata['package_details']).to eq(chart_details) chart_type: 'redis',
end chart_url: 'bitnami/redis',
cluster_id: cluster.id,
context 'when package details fetch fails' do metadata: {
before do redis: {
allow(AddOns::HelmChartDetails).to receive(:execute).and_return( template: {
double(success?: false, failure?: true) 'replica.replicaCount' => 3,
) 'master.persistence.size' => {
end 'type' => 'size',
'value' => '2',
it 'adds error and returns' do 'unit' => 'Gi'
result = described_class.execute(add_on:) }
expect(result.failure?).to be_truthy }
expect(result.add_on.errors[:base]).to include('Failed to fetch package details') }
end }
end
end
describe '.apply_template_to_values' do
let(:template) do
{
'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' },
'replica.replicaCount' => '5'
} }
end })
end
before do it 'can create an add on successfully' do
add_on.metadata['template'] = template add_on = AddOn.new(AddOns::Create.parse_params(params))
add_on.chart_type = "redis" result = described_class.call(add_on, cluster.account.owner)
end expect(result.success?).to be_truthy
it 'applies template values correctly' do
described_class.apply_template_to_values(add_on)
expect(add_on.values['master']['persistence']['size']).to eq('10Gi')
expect(add_on.values['replica']['replicaCount']).to eq(5)
end
end end
end end

View File

@@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe AddOns::SetPackageDetails do
let(:add_on) { build(:add_on) }
let(:chart_details) { { 'name' => 'test-chart', 'version' => '1.0.0' } }
before do
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
double(success?: true, failure?: false, response: chart_details)
)
end
it 'fetches package details and saves to add on' do
result = described_class.execute(add_on:)
expect(result.add_on.metadata['package_details']).to eq(chart_details)
end
context 'when package details fetch fails' do
before do
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
double(success?: false, failure?: true)
)
end
it 'adds error and returns' do
result = described_class.execute(add_on:)
expect(result.failure?).to be_truthy
expect(result.add_on.errors[:base]).to include('Failed to fetch package details')
end
end
end

View File

@@ -18,6 +18,23 @@ RSpec.describe AddOns::UninstallHelmChart do
end end
describe '#execute' do describe '#execute' do
context 'with an unmanaged namespace' do
let(:add_on) { create(:add_on, managed_namespace: false) }
it 'does not delete the namespace' do
expect(client).not_to receive(:delete_namespace)
described_class.execute(connection:)
end
end
context 'with a managed namespace' do
it 'deletes the namespace' do
allow(client).to receive(:get_namespaces).and_return([ OpenStruct.new(metadata: OpenStruct.new(name: add_on.namespace)) ])
expect(client).to receive(:delete_namespace)
described_class.execute(connection:)
end
end
it 'uninstalls the helm chart successfully' do it 'uninstalls the helm chart successfully' do
expect(add_on).to receive(:uninstalled!) expect(add_on).to receive(:uninstalled!)
expect(add_on).to receive(:destroy!) expect(add_on).to receive(:destroy!)

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Namespaced::SetUpNamespace do
let(:subject) { described_class.execute(namespaced: project) }
context "canine managed namespace" do
let(:project) { build(:project, managed_namespace: true, namespace: "") }
it "autosets the name" do
result = subject
expect(result.namespaced.namespace).to eq(project.name)
expect(result.namespaced.errors).to be_empty
end
end
context "self managed" do
let(:project) { build(:project, managed_namespace: false, namespace: "") }
it "raises error" do
result = subject
expect(result.namespaced.errors).to be_present
expect(result.failure?).to be_truthy
end
end
end

View File

@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe Namespaced::ValidateNamespace do
let(:cluster) { create(:cluster) }
let(:user) { create(:user) }
let(:k8_client) { instance_double(K8::Client) }
before do
allow(K8::Client).to receive(:new).and_return(k8_client)
end
describe '.execute' do
context 'with unmanaged namespace' do
let(:project) do
build(:project, namespace: 'my-custom-namespace', managed_namespace: false)
end
context 'that doesnt exist' do
before do
allow(k8_client).to receive(
:get_namespaces
).and_return([ OpenStruct.new(metadata: OpenStruct.new(name: "test-app")) ])
end
it 'fails' do
expect(described_class.execute(namespaced: project, user:)).to be_failure
end
end
context 'that does exist' do
before do
allow(k8_client).to receive(
:get_namespaces,
).and_return([ OpenStruct.new(metadata: OpenStruct.new(name: "my-custom-namespace")) ])
end
it 'succeeds' do
expect(described_class.execute(namespaced: project, user:)).to be_success
end
end
end
context 'with managed namespace' do
let(:project) do
build(
:project,
name: 'test-app',
cluster: cluster,
managed_namespace: true,
namespace: 'test-app'
)
end
context 'when namespace does not exist' do
before do
allow(k8_client).to receive(:get_namespaces).and_return([])
end
it 'succeeds' do
expect(described_class.execute(namespaced: project, user: user)).to be_success
end
end
context 'when namespace already exists' do
before do
allow(k8_client).to receive(:get_namespaces).and_return([ existing_namespace ])
end
context 'when namespace is not managed by Canine' do
let(:existing_namespace) do
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app'))
end
it 'fails with error message' do
result = described_class.execute(namespaced: project, user: user)
expect(result).to be_failure
expect(result.message).to include("already exists")
end
end
context 'when namespace is managed by Canine' do
let(:existing_namespace) do
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app', labels: OpenStruct.new(caninemanaged: "true")))
end
it 'succeeds' do
result = described_class.execute(namespaced: project, user: user)
expect(result).to be_success
end
end
end
end
end
end

View File

@@ -24,7 +24,7 @@ RSpec.describe Projects::Create do
before do before do
allow(Projects::ValidateGitRepository).to receive(:execute) allow(Projects::ValidateGitRepository).to receive(:execute)
allow(Projects::ValidateNamespaceAvailability).to receive(:execute) allow(Namespaced::ValidateNamespace).to receive(:execute)
allow(Projects::RegisterGitWebhook).to receive(:execute) allow(Projects::RegisterGitWebhook).to receive(:execute)
end end
@@ -156,7 +156,9 @@ RSpec.describe Projects::Create do
it 'validates with github and registers webhooks' do it 'validates with github and registers webhooks' do
expect(subject).to eq([ expect(subject).to eq([
Projects::ValidateGitRepository, Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability, Projects::Create::ToNamespaced,
Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
Projects::InitializeBuildPacks, Projects::InitializeBuildPacks,
Projects::Save, Projects::Save,
Projects::RegisterGitWebhook Projects::RegisterGitWebhook
@@ -172,7 +174,9 @@ RSpec.describe Projects::Create do
it 'validates with github and does not register webhooks' do it 'validates with github and does not register webhooks' do
expect(subject).to eq([ expect(subject).to eq([
Projects::ValidateGitRepository, Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability, Projects::Create::ToNamespaced,
Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
Projects::InitializeBuildPacks, Projects::InitializeBuildPacks,
Projects::Save Projects::Save
]) ])

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Namespaced::SetUpNamespace do
let(:subject) { described_class.execute(namespaced: project) }
context "canine managed namespace" do
let(:project) { build(:project, managed_namespace: true, namespace: "") }
it "autosets the name" do
result = subject
expect(result.namespaced.namespace).to eq(project.name)
expect(result.namespaced.errors).to be_empty
end
end
context "self managed" do
let(:project) { build(:project, managed_namespace: false, namespace: "") }
it "raises error" do
result = subject
expect(result.namespaced.errors).to be_present
expect(result.failure?).to be_truthy
end
end
end

View File

@@ -1,53 +0,0 @@
require 'rails_helper'
RSpec.describe Projects::ValidateNamespaceAvailability do
let(:cluster) { create(:cluster) }
let(:project) { build(:project, name: 'test-app', cluster: cluster) }
let(:user) { create(:user) }
let(:k8_client) { instance_double(K8::Client) }
before do
allow(K8::Client).to receive(:new).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
expect(described_class.execute(project: project, user: user)).to be_success
end
end
context 'when namespace already exists' do
before do
allow(k8_client).to receive(:get_namespaces).and_return([ existing_namespace ])
end
context 'when namespace is not managed by Canine' do
let(:existing_namespace) do
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app'))
end
it 'fails with error message' do
result = described_class.execute(project: project, user: user)
expect(result).to be_failure
expect(result.message).to include("already exists")
end
end
context 'when namespace is managed by Canine' do
let(:existing_namespace) do
OpenStruct.new(metadata: OpenStruct.new(name: 'test-app', labels: OpenStruct.new(caninemanaged: "true")))
end
it 'succeeds' do
result = described_class.execute(project: project, user: user)
expect(result).to be_success
end
end
end
end
end

View File

@@ -2,16 +2,18 @@
# #
# Table name: add_ons # Table name: add_ons
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# chart_type :string not null # chart_type :string not null
# chart_url :string # chart_url :string
# metadata :jsonb # managed_namespace :boolean default(TRUE)
# name :string not null # metadata :jsonb
# status :integer default("installing"), not null # name :string not null
# values :jsonb # namespace :string not null
# created_at :datetime not null # status :integer default("installing"), not null
# updated_at :datetime not null # values :jsonb
# cluster_id :bigint not null # created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
# #
# Indexes # Indexes
# #
@@ -28,6 +30,8 @@ FactoryBot.define do
chart_url { 'bitnami/redis' } chart_url { 'bitnami/redis' }
chart_type { "helm_chart" } chart_type { "helm_chart" }
sequence(:name) { |n| "example-addon-#{n}" } sequence(:name) { |n| "example-addon-#{n}" }
sequence(:namespace) { |n| "example-addon-#{n}" }
managed_namespace { true }
status { :installing } status { :installing }
values { {} } values { {} }
metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } } metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } }

View File

@@ -9,7 +9,9 @@
# container_registry_url :string # container_registry_url :string
# docker_build_context_directory :string default("."), not null # docker_build_context_directory :string default("."), not null
# dockerfile_path :string default("./Dockerfile"), not null # dockerfile_path :string default("./Dockerfile"), not null
# managed_namespace :boolean default(TRUE)
# name :string not null # name :string not null
# namespace :string not null
# postdeploy_command :text # postdeploy_command :text
# postdestroy_command :text # postdestroy_command :text
# predeploy_command :text # predeploy_command :text
@@ -36,6 +38,8 @@ FactoryBot.define do
cluster cluster
account account
sequence(:name) { |n| "example-project-#{n}" } sequence(:name) { |n| "example-project-#{n}" }
sequence(:namespace) { |n| "example-project-#{n}" }
managed_namespace { true }
repository_url { "owner/repository" } repository_url { "owner/repository" }
status { :creating } status { :creating }
autodeploy { true } autodeploy { true }

View File

@@ -0,0 +1,20 @@
require "rails_helper"
RSpec.describe "Hash#to_yaml_raw" do
it "returns YAML without the document start marker" do
hash = { "nodeSelector" => { "gpu" => "true" } }
result = hash.to_yaml_raw
expect(result).to eq("nodeSelector:\n gpu: 'true'\n")
end
it "produces valid YAML that can be parsed back" do
hash = { name: "test", items: [ 1, 2, 3 ] }
result = hash.to_yaml_raw
parsed = YAML.safe_load(result, permitted_classes: [ Symbol ], permitted_symbols: [ :name, :items ])
expect(parsed).to eq(hash)
end
end

View File

@@ -29,8 +29,8 @@ RSpec.describe Cluster, type: :model do
let!(:add_on) { create(:add_on, cluster: cluster) } let!(:add_on) { create(:add_on, cluster: cluster) }
it 'returns the reserved namespaces and project/add_on names' do it 'returns the reserved namespaces and project/add_on names' do
expect(cluster.namespaces).to include(project.name) expect(cluster.namespaces).to include(project.namespace)
expect(cluster.namespaces).to include(add_on.name) expect(cluster.namespaces).to include(add_on.namespace)
end end
end end
end end

View File

@@ -9,7 +9,9 @@
# container_registry_url :string # container_registry_url :string
# docker_build_context_directory :string default("."), not null # docker_build_context_directory :string default("."), not null
# dockerfile_path :string default("./Dockerfile"), not null # dockerfile_path :string default("./Dockerfile"), not null
# managed_namespace :boolean default(TRUE)
# name :string not null # name :string not null
# namespace :string not null
# postdeploy_command :text # postdeploy_command :text
# postdestroy_command :text # postdestroy_command :text
# predeploy_command :text # predeploy_command :text
@@ -35,12 +37,12 @@ require 'rails_helper'
RSpec.describe Project, type: :model do RSpec.describe Project, type: :model do
let(:cluster) { create(:cluster) } let(:cluster) { create(:cluster) }
let(:project) { build(:project, cluster: cluster, account: cluster.account) } let(:project) { build(:project, cluster:, account: cluster.account, namespace: "taken") }
describe 'validations' do describe 'validations' do
context 'when name is not unique to the cluster' do context 'when name is not unique to the cluster' do
before do before do
create(:project, name: project.name, cluster: cluster) create(:project, name: project.name, cluster:, namespace: "taken")
end end
it 'is not valid' do it 'is not valid' do

View File

@@ -60,7 +60,7 @@ RSpec.describe K8::Stateless::CronJob do
before do before do
kubectl = instance_double(K8::Kubectl) kubectl = instance_double(K8::Kubectl)
allow(K8::Kubectl).to receive(:new).and_return(kubectl) allow(K8::Kubectl).to receive(:new).and_return(kubectl)
allow(kubectl).to receive(:call).with("get jobs -n #{project.name} -o json").and_return(job_response) allow(kubectl).to receive(:call).with("get jobs -n #{project.namespace} -o json").and_return(job_response)
end end
it 'returns job runs for the service' do it 'returns job runs for the service' do