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
extend LightService::Action
expects :add_on
promises :add_on
def self.parse_params(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: {}
)
end
executed do |context|
add_on = context.add_on
apply_template_to_values(add_on)
fetch_package_details(context, add_on)
unless add_on.save
context.fail_and_return!("Failed to create add on")
class ToNamespaced
extend LightService::Action
expects :add_on
promises :namespaced
executed do |context|
context.namespaced = context.add_on
end
end
def self.fetch_package_details(context, add_on)
result = AddOns::HelmChartDetails.execute(chart_url: add_on.chart_url)
extend LightService::Organizer
if result.failure?
add_on.errors.add(:base, "Failed to fetch package details")
context.fail_and_return!("Failed to fetch package details")
end
result.response.delete('readme')
add_on.metadata['package_details'] = result.response
end
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
def self.call(add_on, user)
with(add_on:, user:).reduce(
AddOns::Create::ToNamespaced,
Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
AddOns::ApplyTemplateToValues,
AddOns::SetPackageDetails,
AddOns::Save
)
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
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)
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.branch = pull_request.branch
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
# Duplicate the project_credential_provider
child_project_credential_provider = parent_project.project_credential_provider.dup

View File

@@ -1,90 +1,101 @@
# frozen_string_literal: true
module Projects
class Create
extend LightService::Organizer
def self.create_params(params)
params.require(:project).permit(
:name,
: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::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"
class Projects::Create
class ToNamespaced
extend LightService::Action
expects :project
promises :namespaced
executed do |context|
context.namespaced = context.project
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

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|
context.service.update(Service.permitted_params(context.params))
if context.service.cron_job?
context.service.cron_schedule.update(context.params[:service][:cron_schedule].permit(:schedule))
if context.service.cron_job? && context.params[:service][:cron_schedule].present?
context.service.cron_schedule.update(
context.params[:service][:cron_schedule].permit(:schedule))
end
context.service.updated!
end

View File

@@ -40,7 +40,8 @@ class AddOnsController < ApplicationController
# POST /add_ons or /add_ons.json
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
# Uncomment to authorize with Pundit
# authorize @add_on
@@ -59,7 +60,7 @@ class AddOnsController < ApplicationController
# PATCH/PUT /add_ons/1 or /add_ons/1.json
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)
respond_to do |format|
@@ -125,25 +126,4 @@ class AddOnsController < ApplicationController
rescue ActiveRecord::RecordNotFound
redirect_to add_ons_path
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

View File

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

View File

@@ -13,8 +13,8 @@ class Projects::ProcessesController < Projects::BaseController
def show
client = K8::Client.new(active_connection)
@logs = client.get_pod_log(params[:id], @project.name)
@pod_events = client.get_pod_events(params[:id], @project.name)
@logs = client.get_pod_log(params[:id], @project.namespace)
@pod_events = client.get_pod_events(params[:id], @project.namespace)
respond_to do |format|
format.html
@@ -30,7 +30,7 @@ class Projects::ProcessesController < Projects::BaseController
def destroy
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..."
end

View File

@@ -7,7 +7,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
job_name = "#{@service.name}-manual-#{timestamp}"
kubectl = K8::Kubectl.new(active_connection)
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
end
@@ -16,7 +16,7 @@ class Projects::Services::JobsController < Projects::Services::BaseController
job_name = params[:id]
kubectl = K8::Kubectl.new(active_connection)
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

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

View File

@@ -1,7 +1,9 @@
class Projects::DestroyJob < ApplicationJob
def perform(project, user)
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
# TODO: This might have overlapping repository urls across different providers.
@@ -15,7 +17,7 @@ class Projects::DestroyJob < ApplicationJob
def delete_namespace(project, 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)
end
end

View File

@@ -3,7 +3,7 @@ class Scheduled::CheckHealthJob < ApplicationJob
def perform
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}")
if service.domains.any?
url = File.join("https://#{service.domains.first.domain_name}", service.healthcheck_url)

View File

@@ -2,16 +2,18 @@
#
# Table name: add_ons
#
# id :bigint not null, primary key
# chart_type :string not null
# chart_url :string
# metadata :jsonb
# name :string not null
# status :integer default("installing"), not null
# values :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
# id :bigint not null, primary key
# chart_type :string not null
# chart_url :string
# managed_namespace :boolean default(TRUE)
# metadata :jsonb
# name :string not null
# namespace :string not null
# status :integer default("installing"), not null
# values :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
@@ -25,6 +27,7 @@
class AddOn < ApplicationRecord
include Loggable
include TeamAccessible
include Namespaced
belongs_to :cluster
has_one :account, through: :cluster
@@ -40,9 +43,9 @@ class AddOn < ApplicationRecord
validates :chart_type, presence: true
validate :chart_type_exists
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
validate :has_package_details, if: :helm_chart?
validates_uniqueness_of :name, scope: :cluster_id
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 }
@@ -57,12 +60,6 @@ class AddOn < ApplicationRecord
metadata['install_stage'] || 0
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
if metadata['package_details'].blank?
errors.add(:metadata, "is missing required keys: package_details")

View File

@@ -59,7 +59,7 @@ class Cluster < ApplicationRecord
]
def namespaces
RESERVED_NAMESPACES + projects.pluck(:name) + add_ons.pluck(:name)
RESERVED_NAMESPACES + projects.pluck(:namespace) + add_ons.pluck(:namespace)
end
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
# docker_build_context_directory :string default("."), not null
# dockerfile_path :string default("./Dockerfile"), not null
# managed_namespace :boolean default(TRUE)
# name :string not null
# namespace :string not null
# postdeploy_command :text
# postdestroy_command :text
# predeploy_command :text
@@ -33,6 +35,7 @@
#
class Project < ApplicationRecord
include TeamAccessible
include Namespaced
broadcasts_refreshes
belongs_to :cluster
has_one :account, through: :cluster
@@ -55,6 +58,8 @@ class Project < ApplicationRecord
validates :name, presence: true,
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 :repository_url, presence: true,
format: {
@@ -65,8 +70,8 @@ class Project < ApplicationRecord
validates_presence_of :project_fork_cluster_id, unless: :forks_disabled?
validate :project_fork_cluster_id_is_owned_by_account
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
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
end
@@ -93,12 +98,6 @@ class Project < ApplicationRecord
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
deployments.order(created_at: :desc).where(status: :completed).first
end

View File

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

View File

@@ -18,7 +18,7 @@ class K8::Stateless::CronJob < K8::Base
private
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
# Filter jobs owned by this CronJob

View File

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

View File

@@ -15,7 +15,7 @@ class K8::Stateless::Ingress < K8::Base
return nil unless @service.domains.any?
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
def certificate_name
@@ -25,7 +25,7 @@ class K8::Stateless::Ingress < K8::Base
def get_ingress
result = kubectl.call('get ingresses -o yaml')
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
def self.ip_address(client)

View File

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

View File

@@ -19,6 +19,6 @@ class Async::Projects::Processes::IndexViewModel < Async::BaseViewModel
def get_pods_for_project(project)
# Get all pods for a given namespace
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

View File

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

View File

@@ -22,23 +22,39 @@
<% end %>
</div>
<% end %>
<div class="text-sm">
<div class="text-sm flex flex-row">
<%= link_to project.link_to_view, target: "_blank" do %>
<% if project.git? %>
<% if project.github? %>
<iconify-icon icon="lucide:github"></iconify-icon>
<% elsif project.gitlab? %>
<iconify-icon icon="lucide:gitlab"></iconify-icon>
<% end %>
<span class="underline mr-2"><%= project.repository_url %></span>
<iconify-icon icon="lucide:git-branch"></iconify-icon>
<span class="underline"><%= project.branch %></span>
<div class="flex flex-row items-center gap-1">
<% if project.github? %>
<iconify-icon icon="lucide:github"></iconify-icon>
<% elsif project.gitlab? %>
<iconify-icon icon="lucide:gitlab"></iconify-icon>
<% end %>
<span class="underline mr-2"><%= project.repository_url %></span>
<iconify-icon icon="lucide:git-branch"></iconify-icon>
<span class="underline"><%= project.branch %></span>
</div>
<% else %>
<iconify-icon icon="logos:docker-icon"></iconify-icon>
<span class="underline"><%= project.repository_url %></span>
<div class="flex flex-row items-center gap-1">
<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 %>
<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 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>
</label>
</div>
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Image repository</span>
</label>
<%= bc_form.text_field(
: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 data-controller="expandable-optional-input">
<div>
<a data-action="expandable-optional-input#show" class="btn btn-ghost">
+ Choose custom image registry
</a>
</div>
<div data-expandable-optional-input-target="container">
<div class="form-control mt-1 mb-2 w-full max-w-md">
<label class="label">
<span class="label-text">Image repository</span>
</label>
<%= bc_form.text_field(
: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 class="form-control mt-4">
<label class="label mb-2">
<span class="label-text">Build method</span>

View File

@@ -1,7 +1,7 @@
<div class="flex flex-col">
<div class="p-10">
<h1 class="text-2xl font-bold">Missing Credentials for Docker Hub</h1>
<p class="mt-2 text-gray-400">Please provide your Docker Hub credentials to continue.</p>
<h1 class="text-2xl font-bold">Missing credentials for container registry</h1>
<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 } %>
<div class="mt-6">
<%= link_to(

View File

@@ -1,6 +1,6 @@
<div class="flex flex-col">
<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>
<div class="flex flex-row gap-4">
<div role="tooltip" data-tip="This feature is coming soon!" class="tooltip">

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@
<div>
<h2 class="text-2xl font-bold">General</h2>
<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 %>
</div>

View File

@@ -40,7 +40,7 @@
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",
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>
<label class="label">
<span class="label-text-alt">Enter valid YAML for pod template spec fields</span>

View File

@@ -29,7 +29,7 @@
</td>
<td>
<% 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 %>
<span class="text-gray-500">
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>
<%= render "static/landing_page/announcement" %>
<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>
<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" %>

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.
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
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.jsonb "values", default: {}
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: "index_add_ons_on_cluster_id"
end
@@ -333,6 +335,21 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_22_230641) do
t.datetime "updated_at", null: false
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|
t.bigint "loggable_id", 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"
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|
t.bigint "project_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.bigint "project_fork_cluster_id"
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"
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"
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|
t.string "provider_url", 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 "providers", "users"
add_foreign_key "services", "projects"
add_foreign_key "sso_providers", "accounts"
add_foreign_key "stack_managers", "accounts"
add_foreign_key "team_memberships", "teams"
add_foreign_key "team_memberships", "users"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: <%= name %>
namespace: <%= project.name %>
namespace: <%= project.namespace %>
labels:
caninemanaged: 'true'
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'
RSpec.describe AddOns::Create do
let(:cluster) { create(:cluster) }
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
allow(Namespaced::ValidateNamespace).to receive(:execute)
allow(AddOns::HelmChartDetails).to receive(:execute).and_return(
double(success?: true, failure?: false, response: chart_details)
)
@@ -12,53 +14,42 @@ RSpec.describe AddOns::Create do
describe 'errors' 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
result = described_class.execute(add_on:)
result = described_class.call(add_on, cluster.account.owner)
expect(result.failure?).to be_truthy
end
end
end
describe '#execute' do
it 'applies template and fetches package details' do
expect(add_on).to receive(:save)
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
describe '.apply_template_to_values' do
let(:template) do
{
'master.persistence.size' => { 'type' => 'size', 'value' => '10', 'unit' => 'Gi' },
'replica.replicaCount' => '5'
let(:params) do
ActionController::Parameters.new({
add_on: {
name: 'redis-main',
chart_type: 'redis',
chart_url: 'bitnami/redis',
cluster_id: cluster.id,
metadata: {
redis: {
template: {
'replica.replicaCount' => 3,
'master.persistence.size' => {
'type' => 'size',
'value' => '2',
'unit' => 'Gi'
}
}
}
}
}
end
})
end
before do
add_on.metadata['template'] = template
add_on.chart_type = "redis"
end
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
it 'can create an add on successfully' do
add_on = AddOn.new(AddOns::Create.parse_params(params))
result = described_class.call(add_on, cluster.account.owner)
expect(result.success?).to be_truthy
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
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
expect(add_on).to receive(:uninstalled!)
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
allow(Projects::ValidateGitRepository).to receive(:execute)
allow(Projects::ValidateNamespaceAvailability).to receive(:execute)
allow(Namespaced::ValidateNamespace).to receive(:execute)
allow(Projects::RegisterGitWebhook).to receive(:execute)
end
@@ -156,7 +156,9 @@ RSpec.describe Projects::Create do
it 'validates with github and registers webhooks' do
expect(subject).to eq([
Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability,
Projects::Create::ToNamespaced,
Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
Projects::InitializeBuildPacks,
Projects::Save,
Projects::RegisterGitWebhook
@@ -172,7 +174,9 @@ RSpec.describe Projects::Create do
it 'validates with github and does not register webhooks' do
expect(subject).to eq([
Projects::ValidateGitRepository,
Projects::ValidateNamespaceAvailability,
Projects::Create::ToNamespaced,
Namespaced::SetUpNamespace,
Namespaced::ValidateNamespace,
Projects::InitializeBuildPacks,
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
#
# id :bigint not null, primary key
# chart_type :string not null
# chart_url :string
# metadata :jsonb
# name :string not null
# status :integer default("installing"), not null
# values :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
# id :bigint not null, primary key
# chart_type :string not null
# chart_url :string
# managed_namespace :boolean default(TRUE)
# metadata :jsonb
# name :string not null
# namespace :string not null
# status :integer default("installing"), not null
# values :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# cluster_id :bigint not null
#
# Indexes
#
@@ -28,6 +30,8 @@ FactoryBot.define do
chart_url { 'bitnami/redis' }
chart_type { "helm_chart" }
sequence(:name) { |n| "example-addon-#{n}" }
sequence(:namespace) { |n| "example-addon-#{n}" }
managed_namespace { true }
status { :installing }
values { {} }
metadata { { "package_details" => { "repository" => { "name" => "bitnami", "url" => "https://bitnami.com/charts" } } } }

View File

@@ -9,7 +9,9 @@
# container_registry_url :string
# docker_build_context_directory :string default("."), not null
# dockerfile_path :string default("./Dockerfile"), not null
# managed_namespace :boolean default(TRUE)
# name :string not null
# namespace :string not null
# postdeploy_command :text
# postdestroy_command :text
# predeploy_command :text
@@ -36,6 +38,8 @@ FactoryBot.define do
cluster
account
sequence(:name) { |n| "example-project-#{n}" }
sequence(:namespace) { |n| "example-project-#{n}" }
managed_namespace { true }
repository_url { "owner/repository" }
status { :creating }
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) }
it 'returns the reserved namespaces and project/add_on names' do
expect(cluster.namespaces).to include(project.name)
expect(cluster.namespaces).to include(add_on.name)
expect(cluster.namespaces).to include(project.namespace)
expect(cluster.namespaces).to include(add_on.namespace)
end
end
end

View File

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

View File

@@ -60,7 +60,7 @@ RSpec.describe K8::Stateless::CronJob do
before do
kubectl = instance_double(K8::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
it 'returns job runs for the service' do