mirror of
https://github.com/czhu12/canine.git
synced 2026-04-27 06:49:58 -05:00
Merge branch 'main' into chriszhu__comment_on_github
This commit is contained in:
@@ -2,4 +2,5 @@ APP_HOST=canine.example.com
|
||||
BOOT_MODE=cloud
|
||||
OMNIAUTH_GITHUB_WEBHOOK_SECRET=1234567890
|
||||
OMNIAUTH_GITHUB_PUBLIC_KEY=1234567890
|
||||
OMNIAUTH_GITHUB_PRIVATE_KEY=1234567890
|
||||
OMNIAUTH_GITHUB_PRIVATE_KEY=1234567890
|
||||
ENABLE_AUTOMATIC_DNS_MAPPING=false
|
||||
|
||||
@@ -68,6 +68,7 @@ gem "ransack", "~> 4.2"
|
||||
gem "cron2english", "~> 0.1.7"
|
||||
gem "cssbundling-rails"
|
||||
gem "devise", "~> 4.9"
|
||||
gem "devise_invitable", "~> 2.0"
|
||||
gem "dotenv", "~> 3.1"
|
||||
gem "friendly_id", "~> 5.4"
|
||||
gem "good_job", "~> 4.12"
|
||||
|
||||
@@ -168,6 +168,9 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise_invitable (2.0.11)
|
||||
actionmailer (>= 5.0)
|
||||
devise (>= 4.6)
|
||||
diff-lcs (1.6.2)
|
||||
docile (1.4.1)
|
||||
domain_name (0.6.20240107)
|
||||
@@ -741,6 +744,7 @@ DEPENDENCIES
|
||||
database_cleaner-active_record (~> 2.2.2)
|
||||
debug
|
||||
devise (~> 4.9)
|
||||
devise_invitable (~> 2.0)
|
||||
doorkeeper (~> 5.8)
|
||||
dotenv (~> 3.1)
|
||||
factory_bot_rails
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class Domains::AttachAutoManagedDomain
|
||||
extend LightService::Action
|
||||
|
||||
expects :service
|
||||
|
||||
executed do |context|
|
||||
next unless Dns::AutoSetupService.enabled?
|
||||
|
||||
service = context.service
|
||||
next unless service.allow_public_networking?
|
||||
next unless service.web_service?
|
||||
next if service.domains.exists?(auto_managed: true)
|
||||
|
||||
domain_name = "#{service.name}-#{service.project.slug}.oncanine.run"
|
||||
|
||||
service.domains.create!(
|
||||
domain_name: domain_name,
|
||||
auto_managed: true
|
||||
)
|
||||
rescue StandardError => e
|
||||
context.fail_and_return!(e.message)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Domains::Destroy
|
||||
extend LightService::Action
|
||||
|
||||
expects :domain
|
||||
|
||||
executed do |context|
|
||||
domain = context.domain
|
||||
|
||||
if domain.auto_managed? && Dns::AutoSetupService.enabled?
|
||||
cleanup_dns_record(domain)
|
||||
end
|
||||
|
||||
domain.destroy!
|
||||
end
|
||||
|
||||
def self.cleanup_dns_record(domain)
|
||||
dns_client = Dns::Client.default
|
||||
subdomain = domain.domain_name.chomp(".#{dns_client.domain}")
|
||||
dns_client.delete_record(subdomain: subdomain)
|
||||
rescue Dns::Client::Error => e
|
||||
Rails.logger.error("[DNS Cleanup] Failed to delete record for #{domain.domain_name}: #{e.message}")
|
||||
end
|
||||
end
|
||||
@@ -2,50 +2,8 @@ class Networks::CheckDns
|
||||
extend LightService::Action
|
||||
expects :ingress, :connection
|
||||
|
||||
class << self
|
||||
def infer_expected_dns(ingress, connection)
|
||||
ingress.connect(connection)
|
||||
dns_record = ingress.hostname
|
||||
|
||||
if dns_record[:type] == :ip_address && is_private_ip?(dns_record[:value])
|
||||
cluster = ingress.service.project.cluster
|
||||
# This only works if it is a single node cluster like k3s
|
||||
public_ip = infer_public_ip_from_cluster(connection)
|
||||
dns_record = {
|
||||
value: public_ip,
|
||||
type: :ip_address
|
||||
}
|
||||
end
|
||||
dns_record
|
||||
end
|
||||
|
||||
def is_private_ip?(ip)
|
||||
ip.starts_with?("10.") || ip.starts_with?("172.") || ip.starts_with?("192.")
|
||||
end
|
||||
|
||||
def infer_public_ip_from_cluster(connection)
|
||||
# The ingress is reporting a private IP address, so we need to guess the public IP address
|
||||
# based on the cluster's domain name
|
||||
server_name = K8::Client.new(connection).server
|
||||
# Parse the hostname from the server, with ruby's URI.parse
|
||||
hostname = URI.parse(server_name).hostname
|
||||
# If hostname is just an ip address, then we can return it
|
||||
if ip?(hostname)
|
||||
hostname
|
||||
else
|
||||
# Otherwise, we need to use Resolv to get the public IP address
|
||||
Resolv.getaddress(hostname)
|
||||
end
|
||||
end
|
||||
|
||||
def ip?(ip)
|
||||
ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
||||
end
|
||||
end
|
||||
|
||||
executed do |context|
|
||||
# TODO
|
||||
expected_dns = infer_expected_dns(context.ingress, context.connection)
|
||||
expected_dns = Dns::Utils.infer_expected_hostname(context.ingress, context.connection)
|
||||
context.ingress.service.domains.each do |domain|
|
||||
if expected_dns[:type] == :ip_address
|
||||
ip_addresses = Resolv::DNS.open do |dns|
|
||||
|
||||
@@ -5,6 +5,7 @@ class Services::Create
|
||||
with(service:, params:).reduce(
|
||||
Services::CreateAssociations,
|
||||
Services::Save,
|
||||
Domains::AttachAutoManagedDomain
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,11 +5,18 @@ class Services::Update
|
||||
promises :service
|
||||
|
||||
executed do |context|
|
||||
was_public = context.service.allow_public_networking?
|
||||
|
||||
context.service.update(Service.permitted_params(context.params))
|
||||
if context.service.cron_job? && context.params[:service][:cron_schedule].present?
|
||||
context.service.cron_schedule.update(
|
||||
context.params[:service][:cron_schedule].permit(:schedule))
|
||||
end
|
||||
|
||||
if !was_public && context.service.allow_public_networking?
|
||||
Domains::AttachAutoManagedDomain.execute(service: context.service)
|
||||
end
|
||||
|
||||
context.service.updated!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,4 +84,58 @@
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
@apply w-4 h-4 rounded-full bg-primary cursor-pointer border-0;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: oklch(var(--su) / 0.1) !important;
|
||||
color: oklch(var(--su)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: oklch(var(--er) / 0.1) !important;
|
||||
color: oklch(var(--er)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: oklch(var(--wa) / 0.1) !important;
|
||||
color: oklch(var(--wa)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: oklch(var(--in) / 0.1) !important;
|
||||
color: oklch(var(--in)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: oklch(var(--su) / 0.1) !important;
|
||||
color: oklch(var(--su)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: oklch(var(--er) / 0.1) !important;
|
||||
color: oklch(var(--er)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: oklch(var(--wa) / 0.1) !important;
|
||||
color: oklch(var(--wa)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: oklch(var(--in) / 0.1) !important;
|
||||
color: oklch(var(--in)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: oklch(var(--s) / 0.1) !important;
|
||||
color: oklch(var(--s)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
class Avo::Resources::Notifier < Avo::BaseResource
|
||||
# self.includes = []
|
||||
# self.attachments = []
|
||||
# self.search = {
|
||||
# query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) }
|
||||
# }
|
||||
|
||||
def fields
|
||||
field :id, as: :id
|
||||
field :project, as: :belongs_to
|
||||
field :name, as: :text
|
||||
field :provider_type, as: :number
|
||||
field :webhook_url, as: :text
|
||||
field :enabled, as: :boolean
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Avo::Resources::User < Avo::BaseResource
|
||||
query: -> { query.ransack(email_cont: params[:q], first_name_cont: params[:q], last_name_cont: params[:q], m: "or").result(distinct: false) },
|
||||
item: -> {
|
||||
{
|
||||
title: record.name.presence || record.email
|
||||
title: record.name.present? ? "#{record.name} (#{record.email})" : record.email
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ class Avo::Resources::User < Avo::BaseResource
|
||||
field :email, as: :text, link_to_record: true
|
||||
field :first_name, as: :text
|
||||
field :last_name, as: :text
|
||||
field :admin, as: :boolean
|
||||
field :created_at, as: :date_time, sortable: true
|
||||
field :avatar, as: :file, only_on: [ :show, :edit ]
|
||||
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
class Accounts::AccountUsersController < ApplicationController
|
||||
include SettingsHelper
|
||||
def create
|
||||
user = User.find_or_initialize_by(email: user_params[:email]) do |user|
|
||||
user.first_name = user_params[:email].split("@").first
|
||||
user.password = Devise.friendly_token[0, 20]
|
||||
user.save!
|
||||
end
|
||||
AccountUser.create!(account: current_account, user: user)
|
||||
email = user_params[:email].downcase
|
||||
existing_member = current_account.users.find_by(email: email)
|
||||
|
||||
redirect_to account_users_path, notice: "User was successfully added."
|
||||
if existing_member
|
||||
redirect_to account_users_path, alert: "This user is already a member of this account."
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email: email)
|
||||
|
||||
if user
|
||||
AccountUser.create!(account: current_account, user: user)
|
||||
redirect_to account_users_path, notice: "User was successfully added."
|
||||
else
|
||||
temp_password = generate_temp_password
|
||||
user = User.new(
|
||||
email: user_params[:email],
|
||||
password: temp_password,
|
||||
password_confirmation: temp_password,
|
||||
first_name: user_params[:email].split("@").first,
|
||||
password_change_required: true
|
||||
)
|
||||
user.skip_invitation = true
|
||||
user.save!
|
||||
AccountUser.create!(account: current_account, user: user)
|
||||
|
||||
@invite_credentials = {
|
||||
email: user.email,
|
||||
password: temp_password,
|
||||
login_url: new_user_session_url
|
||||
}
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to account_users_path, notice: "User was successfully invited." }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -41,4 +70,8 @@ class Accounts::AccountUsersController < ApplicationController
|
||||
def account_user_params
|
||||
params.require(:account_user).permit(:role)
|
||||
end
|
||||
|
||||
def generate_temp_password
|
||||
"#{SecureRandom.alphanumeric(8)}!#{SecureRandom.alphanumeric(4)}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
module Accounts
|
||||
class StackManagersController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :authorize_account, except: [ :verify_url, :check_reachable ]
|
||||
before_action :authorize_account, except: [ :verify_url, :check_reachable, :verify_connectivity ]
|
||||
before_action :set_stack_manager, only: [ :show, :edit, :update, :destroy, :sync_clusters, :sync_registries ]
|
||||
before_action :set_stack, only: [ :sync_clusters, :sync_registries ]
|
||||
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable ]
|
||||
skip_before_action :authenticate_user!, only: [ :verify_url, :check_reachable, :verify_connectivity ]
|
||||
|
||||
def check_reachable
|
||||
url = params[:stack_manager][:url]
|
||||
|
||||
@@ -8,6 +8,7 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
before_action :authenticate_user!
|
||||
before_action :check_password_change_required
|
||||
|
||||
layout :determine_layout
|
||||
|
||||
@@ -71,4 +72,13 @@ class ApplicationController < ActionController::Base
|
||||
flash[:alert] = "You are not authorized to perform this action."
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
def check_password_change_required
|
||||
return if devise_controller?
|
||||
return unless user_signed_in?
|
||||
return if true_user != current_user # Skip when impersonating
|
||||
return unless current_user.password_change_required?
|
||||
|
||||
redirect_to password_change_path, alert: "Please change your password to continue."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@ class Avo::ImpersonationsController < Avo::ApplicationController
|
||||
def create
|
||||
user = User.find(params[:user_id])
|
||||
impersonate_user(user)
|
||||
# Set account to the impersonated user's first account
|
||||
session[:account_id] = user.accounts.first&.id
|
||||
redirect_to main_app.root_path, notice: "Now impersonating #{user.name}"
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# This controller has been generated to enable Rails' resource routes.
|
||||
# More information on https://docs.avohq.io/3.0/controllers.html
|
||||
class Avo::NotifiersController < Avo::ResourcesController
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
class Avo::PasswordResetsController < Avo::ApplicationController
|
||||
def create
|
||||
user = User.find(params[:user_id])
|
||||
temp_password = generate_temp_password
|
||||
|
||||
user.update!(
|
||||
password: temp_password,
|
||||
password_confirmation: temp_password,
|
||||
password_change_required: true
|
||||
)
|
||||
|
||||
@credentials = {
|
||||
email: user.email,
|
||||
password: temp_password,
|
||||
login_url: main_app.new_user_session_url
|
||||
}
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to avo.resources_user_path(user), notice: "Password reset. Temp password: #{temp_password}" }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_temp_password
|
||||
"#{SecureRandom.alphanumeric(8)}!#{SecureRandom.alphanumeric(4)}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class Avo::PromoteToAdminsController < Avo::ApplicationController
|
||||
def create
|
||||
user = User.find(params[:user_id])
|
||||
User.where(id: user.id).update_all(admin: true)
|
||||
|
||||
redirect_to avo.resources_user_path(user), notice: "#{user.name} has been promoted to site admin"
|
||||
end
|
||||
|
||||
def destroy
|
||||
user = User.find(params[:user_id])
|
||||
User.where(id: user.id).update_all(admin: false)
|
||||
|
||||
redirect_to avo.resources_user_path(user), notice: "#{user.name} has been demoted from site admin"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
class PasswordChangeController < ApplicationController
|
||||
layout "homepage"
|
||||
skip_before_action :check_password_change_required
|
||||
|
||||
def show
|
||||
unless current_user.password_change_required?
|
||||
redirect_to root_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if current_user.update_with_password(password_params.merge(password_change_required: false))
|
||||
bypass_sign_in(current_user)
|
||||
redirect_to root_path, notice: "Password changed successfully."
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:current_password, :password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Projects::NotifiersController < Projects::BaseController
|
||||
before_action :set_notifier, only: [ :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
render partial: "index", locals: { project: @project }
|
||||
end
|
||||
|
||||
def new
|
||||
@notifier = @project.notifiers.new
|
||||
end
|
||||
|
||||
def create
|
||||
@notifier = @project.notifiers.build(notifier_params)
|
||||
if @notifier.save
|
||||
render partial: "index", locals: { project: @project }
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @notifier.update(notifier_params)
|
||||
render partial: "index", locals: { project: @project }
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notifier.destroy
|
||||
render partial: "index", locals: { project: @project }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_notifier
|
||||
@notifier = @project.notifiers.find(params[:id])
|
||||
end
|
||||
|
||||
def notifier_params
|
||||
params.require(:notifier).permit(:name, :provider_type, :webhook_url, :enabled)
|
||||
end
|
||||
end
|
||||
@@ -21,7 +21,7 @@ class Projects::Services::DomainsController < Projects::Services::BaseController
|
||||
|
||||
def destroy
|
||||
@domain = @project.domains.find(params[:id])
|
||||
@domain.destroy
|
||||
Domains::Destroy.execute(domain: @domain)
|
||||
|
||||
respond_to(&:turbo_stream)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
class Projects::VolumesController < Projects::BaseController
|
||||
def index
|
||||
render partial: "index", locals: { project: @project }
|
||||
end
|
||||
|
||||
def new
|
||||
@volume = @project.volumes.new
|
||||
end
|
||||
@@ -7,19 +11,16 @@ class Projects::VolumesController < Projects::BaseController
|
||||
@volume = @project.volumes.build(volume_params)
|
||||
@project.updated!
|
||||
if @volume.save
|
||||
redirect_to edit_project_path(@project), notice: "Volume saved and will be created on the next deployment"
|
||||
render partial: "index", locals: { project: @project }
|
||||
else
|
||||
redirect_to edit_project_path(@project), alert: "Failed to create volume"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@volume = @project.volumes.find(params[:id])
|
||||
if @volume.destroy
|
||||
redirect_to edit_project_path(@project), notice: "Volume deleted and will be removed on the next deployment"
|
||||
else
|
||||
redirect_to edit_project_path(@project), alert: "Failed to delete volume"
|
||||
end
|
||||
@volume.destroy
|
||||
render partial: "index", locals: { project: @project }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["trigger"];
|
||||
|
||||
connect() {
|
||||
this.closeOnClickOutside = this.closeOnClickOutside.bind(this);
|
||||
document.addEventListener("click", this.closeOnClickOutside);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("click", this.closeOnClickOutside);
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
event.preventDefault();
|
||||
this.element.classList.toggle("dropdown-open");
|
||||
}
|
||||
|
||||
closeOnClickOutside(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.element.classList.remove("dropdown-open");
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.classList.remove("dropdown-open");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class Projects::BuildJob < ApplicationJob
|
||||
def perform(build, user)
|
||||
project = build.project
|
||||
build.in_progress!
|
||||
notify_build(project, build)
|
||||
# If its a container registry deploy, we don't need to build the docker image
|
||||
if project.container_registry?
|
||||
build.info("Skipping build for #{project.name} because it's a deploying from a container registry")
|
||||
@@ -50,6 +51,7 @@ class Projects::BuildJob < ApplicationJob
|
||||
unless build.killed?
|
||||
build.error(e.message)
|
||||
build.failed!
|
||||
notify_build(project, build)
|
||||
end
|
||||
raise e
|
||||
end
|
||||
@@ -77,6 +79,7 @@ class Projects::BuildJob < ApplicationJob
|
||||
|
||||
def complete_build!(build, user)
|
||||
build.completed!
|
||||
notify_build(build.project, build)
|
||||
deployment = Deployment.create!(build:)
|
||||
Projects::DeploymentJob.perform_later(deployment, user)
|
||||
end
|
||||
@@ -91,4 +94,10 @@ class Projects::BuildJob < ApplicationJob
|
||||
image_builder.build_image(repository_path)
|
||||
end
|
||||
end
|
||||
|
||||
def notify_build(project, build)
|
||||
return unless project.notifiers.enabled.any?
|
||||
|
||||
BuildNotifier.with(project: project, build: build).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class Projects::DestroyJob < ApplicationJob
|
||||
|
||||
uninstall_service_class(project).new(project, user).call
|
||||
|
||||
cleanup_auto_managed_domains(project)
|
||||
remove_github_webhook(project) if should_remove_webhook?(project)
|
||||
project.destroy!
|
||||
end
|
||||
@@ -31,4 +32,10 @@ class Projects::DestroyJob < ApplicationJob
|
||||
rescue Octokit::NotFound
|
||||
# If the hook is not found, do nothing
|
||||
end
|
||||
|
||||
def cleanup_auto_managed_domains(project)
|
||||
project.domains.where(auto_managed: true).find_each do |domain|
|
||||
Domains::Destroy.cleanup_dns_record(domain)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class Scheduled::SweepDnsRecordsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
return unless Dns::AutoSetupService.enabled?
|
||||
|
||||
dns_client = Dns::Client.default
|
||||
valid_domains = Set.new
|
||||
Domain.where(auto_managed: true).find_each { |d| valid_domains << d.domain_name }
|
||||
|
||||
dns_client.list_all_records.each do |record|
|
||||
next unless stale_record?(record, valid_domains, dns_client.domain)
|
||||
|
||||
Rails.logger.info("[DNS Sweep] Deleting stale record: #{record['name']}")
|
||||
delete_record(dns_client, record)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stale_record?(record, valid_domains, base_domain)
|
||||
return false unless record["name"].end_with?(".#{base_domain}")
|
||||
return false if valid_domains.include?(record["name"])
|
||||
|
||||
# Only sweep A and CNAME records that match the auto-managed pattern
|
||||
return false unless %w[A CNAME].include?(record["type"])
|
||||
|
||||
subdomain = record["name"].chomp(".#{base_domain}")
|
||||
subdomain.match?(/\A[a-z0-9-]+-[a-z0-9-]+\z/)
|
||||
end
|
||||
|
||||
def delete_record(dns_client, record)
|
||||
subdomain = record["name"].chomp(".#{dns_client.domain}")
|
||||
dns_client.delete_record(subdomain: subdomain)
|
||||
rescue Dns::Client::Error => e
|
||||
Rails.logger.error("[DNS Sweep] Failed to delete #{record['name']}: #{e.message}")
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@
|
||||
# Table name: domains
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# auto_managed :boolean default(FALSE)
|
||||
# domain_name :string not null
|
||||
# status :integer default("checking_dns")
|
||||
# status_reason :string
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: notifiers
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# name :string not null
|
||||
# provider_type :integer default("slack"), not null
|
||||
# webhook_url :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# project_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_notifiers_on_project_id (project_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (project_id => projects.id)
|
||||
#
|
||||
class Notifier < ApplicationRecord
|
||||
belongs_to :project
|
||||
|
||||
enum :provider_type, { slack: 0, discord: 1, microsoft_teams: 2, google_chat: 3 }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :webhook_url, presence: true,
|
||||
format: { with: URI::DEFAULT_PARSER.make_regexp(%w[https]), message: "must be a valid HTTPS URL" }
|
||||
validate :webhook_url_matches_provider
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
private
|
||||
|
||||
def webhook_url_matches_provider
|
||||
return if webhook_url.blank?
|
||||
|
||||
case provider_type
|
||||
when "slack"
|
||||
unless webhook_url.include?("hooks.slack.com")
|
||||
errors.add(:webhook_url, "must be a valid Slack webhook URL")
|
||||
end
|
||||
when "discord"
|
||||
unless webhook_url.include?("discord.com/api/webhooks") || webhook_url.include?("discordapp.com/api/webhooks")
|
||||
errors.add(:webhook_url, "must be a valid Discord webhook URL")
|
||||
end
|
||||
when "microsoft_teams"
|
||||
unless webhook_url.include?(".webhook.office.com") || webhook_url.include?("outlook.office.com/webhook")
|
||||
errors.add(:webhook_url, "must be a valid Microsoft Teams webhook URL")
|
||||
end
|
||||
when "google_chat"
|
||||
unless webhook_url.include?("chat.googleapis.com")
|
||||
errors.add(:webhook_url, "must be a valid Google Chat webhook URL")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,7 @@
|
||||
# predestroy_command :text
|
||||
# project_fork_status :integer default("disabled")
|
||||
# repository_url :string not null
|
||||
# slug :string not null
|
||||
# status :integer default("creating"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
@@ -28,6 +29,7 @@
|
||||
#
|
||||
# index_projects_on_cluster_id (cluster_id)
|
||||
# index_projects_on_name (name)
|
||||
# index_projects_on_slug (slug) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
@@ -55,6 +57,7 @@ class Project < ApplicationRecord
|
||||
has_many :domains, through: :services
|
||||
has_many :events, dependent: :destroy
|
||||
has_many :volumes, dependent: :destroy
|
||||
has_many :notifiers, dependent: :destroy
|
||||
|
||||
has_one :project_credential_provider, dependent: :destroy
|
||||
has_one :build_configuration, dependent: :destroy
|
||||
@@ -79,6 +82,7 @@ class Project < ApplicationRecord
|
||||
validate :project_fork_cluster_id_is_owned_by_account
|
||||
validates_presence_of :build_configuration, if: :git?
|
||||
validates_presence_of :deployment_configuration
|
||||
before_create :generate_slug
|
||||
|
||||
after_save_commit do
|
||||
broadcast_replace_to [ self, :status ], target: dom_id(self, :status), partial: "projects/status", locals: { project: self }
|
||||
@@ -100,6 +104,13 @@ class Project < ApplicationRecord
|
||||
delegate :git?, :github?, :gitlab?, to: :project_credential_provider
|
||||
delegate :container_registry?, to: :project_credential_provider
|
||||
|
||||
def generate_slug
|
||||
self.slug = self.name
|
||||
while Project.exists?(slug: self.slug)
|
||||
self.slug = "#{self.name}-#{SecureRandom.uuid[0..7]}"
|
||||
end
|
||||
end
|
||||
|
||||
def project_fork_cluster_id_is_owned_by_account
|
||||
if project_fork_cluster_id.present? && !account.clusters.exists?(id: project_fork_cluster_id)
|
||||
errors.add(:project_fork_cluster_id, "must be owned by the account")
|
||||
|
||||
+11
-1
@@ -20,7 +20,7 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_services_on_project_id (project_id)
|
||||
# index_services_on_project_id_and_name (project_id,name) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
@@ -59,6 +59,16 @@ class Service < ApplicationRecord
|
||||
K8::Stateless::Service.new(self).internal_url
|
||||
end
|
||||
|
||||
def auto_subdomain
|
||||
"#{name}-#{project.namespace}"
|
||||
end
|
||||
|
||||
def auto_domain
|
||||
return nil unless allow_public_networking?
|
||||
|
||||
"#{auto_subdomain}.#{Dns::Client.default.domain}"
|
||||
end
|
||||
|
||||
def friendly_status
|
||||
if !web_service? && healthy?
|
||||
"deployed"
|
||||
|
||||
+13
-1
@@ -8,22 +8,34 @@
|
||||
# email :string default(""), not null
|
||||
# encrypted_password :string default(""), not null
|
||||
# first_name :string
|
||||
# invitation_accepted_at :datetime
|
||||
# invitation_created_at :datetime
|
||||
# invitation_limit :integer
|
||||
# invitation_sent_at :datetime
|
||||
# invitation_token :string
|
||||
# invitations_count :integer default(0)
|
||||
# invited_by_type :string
|
||||
# last_name :string
|
||||
# password_change_required :boolean default(FALSE)
|
||||
# remember_created_at :datetime
|
||||
# reset_password_sent_at :datetime
|
||||
# reset_password_token :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# invited_by_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_users_on_email (email) UNIQUE
|
||||
# index_users_on_invitation_token (invitation_token) UNIQUE
|
||||
# index_users_on_invited_by (invited_by_type,invited_by_id)
|
||||
# index_users_on_invited_by_id (invited_by_id)
|
||||
# index_users_on_reset_password_token (reset_password_token) UNIQUE
|
||||
#
|
||||
class User < ApplicationRecord
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :recoverable
|
||||
devise :database_authenticatable, :registerable, :rememberable, :validatable, :omniauthable
|
||||
devise :invitable, :database_authenticatable, :registerable, :rememberable, :validatable, :omniauthable
|
||||
|
||||
has_one_attached :avatar
|
||||
has_person_name
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
class ApplicationBulkDeliveryMethod < Noticed::BulkDeliveryMethod
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
class ApplicationDeliveryMethod < Noticed::DeliveryMethod
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class ApplicationNotifier < Noticed::Event
|
||||
bulk_deliver_by :webhook, class: "BulkDeliveryMethods::ProjectWebhook"
|
||||
|
||||
def project
|
||||
params[:project]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
class BuildNotifier < ApplicationNotifier
|
||||
required_params :project, :build
|
||||
|
||||
def message
|
||||
build = params[:build]
|
||||
commit_info = build.commit_message.present? ? "\"#{build.commit_message.truncate(50)}\"" : build.commit_sha[0..7]
|
||||
|
||||
case build.status
|
||||
when "in_progress"
|
||||
"Building #{commit_info}"
|
||||
when "completed"
|
||||
"Build successful for #{commit_info}"
|
||||
when "failed"
|
||||
"Build failed for #{commit_info}"
|
||||
when "killed"
|
||||
"Build cancelled for #{commit_info}"
|
||||
else
|
||||
"Build #{build.status}"
|
||||
end
|
||||
end
|
||||
|
||||
def url
|
||||
project_deployment_url(params[:project], params[:build])
|
||||
end
|
||||
|
||||
def success?
|
||||
params[:build].status == "completed"
|
||||
end
|
||||
|
||||
def in_progress?
|
||||
params[:build].status == "in_progress"
|
||||
end
|
||||
|
||||
def build_payload(provider_type)
|
||||
build = params[:build]
|
||||
project = params[:project]
|
||||
|
||||
builder = WebhookBuilder.new
|
||||
.title(project.name)
|
||||
.description(message)
|
||||
.url(url, label: "View Build")
|
||||
.status(emoji: status_emoji, text: status_text, state: status_state)
|
||||
|
||||
builder.widget(label: "Status", value: "#{status_emoji} #{status_text}")
|
||||
|
||||
if build.commit_sha.present?
|
||||
commit_url = "https://github.com/#{project.repository_url}/commit/#{build.commit_sha}"
|
||||
builder.widget(label: "SHA", value: build.commit_sha[0..7], link: commit_url)
|
||||
end
|
||||
|
||||
builder.widget(label: "Commit", value: build.commit_message.truncate(100)) if build.commit_message.present?
|
||||
|
||||
builder.build(provider_type)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_state
|
||||
return :success if success?
|
||||
return :in_progress if in_progress?
|
||||
:failed
|
||||
end
|
||||
|
||||
def status_text
|
||||
case status_state
|
||||
when :success then "Success"
|
||||
when :in_progress then "Building"
|
||||
else "Failed"
|
||||
end
|
||||
end
|
||||
|
||||
def status_emoji
|
||||
case status_state
|
||||
when :success then "✅"
|
||||
when :in_progress then "🔨"
|
||||
else "❌"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class BulkDeliveryMethods::ProjectWebhook < ApplicationBulkDeliveryMethod
|
||||
def deliver
|
||||
project = event.params[:project]
|
||||
return unless project
|
||||
|
||||
project.notifiers.enabled.find_each do |notifier|
|
||||
payload = event.build_payload(notifier.provider_type)
|
||||
send_webhook(notifier.webhook_url, payload)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_webhook(url, payload)
|
||||
HTTParty.post(
|
||||
url,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: payload.to_json
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send webhook notification: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
class DeploymentNotifier < Noticed::Event
|
||||
required_params :project, :deployment
|
||||
bulk_deliver_by :project_webhook, class: "BulkDeliveryMethods::ProjectWebhook"
|
||||
|
||||
def project
|
||||
params[:project]
|
||||
end
|
||||
|
||||
def message
|
||||
deployment = params[:deployment]
|
||||
version = "v#{deployment.version}"
|
||||
commit_info = deployment.build.commit_message.present? ? "\"#{deployment.build.commit_message.truncate(50)}\"" : nil
|
||||
|
||||
case deployment.status
|
||||
when "in_progress"
|
||||
commit_info ? "Deploying #{version}: #{commit_info}" : "Deploying #{version}"
|
||||
when "completed"
|
||||
commit_info ? "Deployed #{version}: #{commit_info}" : "Successfully deployed #{version}"
|
||||
when "failed"
|
||||
commit_info ? "Deploy failed for #{version}: #{commit_info}" : "Deploy failed for #{version}"
|
||||
else
|
||||
"Deployment #{deployment.status}"
|
||||
end
|
||||
end
|
||||
|
||||
def url
|
||||
project_deployment_url(params[:project], params[:deployment].build)
|
||||
end
|
||||
|
||||
def success?
|
||||
params[:deployment].status == "completed"
|
||||
end
|
||||
|
||||
def in_progress?
|
||||
params[:deployment].status == "in_progress"
|
||||
end
|
||||
|
||||
def build_payload(provider_type)
|
||||
deployment = params[:deployment]
|
||||
project = params[:project]
|
||||
build = deployment.build
|
||||
cluster = project.cluster
|
||||
|
||||
builder = WebhookBuilder.new
|
||||
.title(project.name)
|
||||
.description(message)
|
||||
.url(url, label: "View Deployment")
|
||||
.status(emoji: status_emoji, text: status_text, state: status_state)
|
||||
|
||||
builder.widget(label: "Status", value: "#{status_emoji} #{status_text}")
|
||||
builder.widget(label: "Version", value: deployment.version)
|
||||
builder.widget(label: "Cluster", value: cluster.name, link: cluster_url(cluster))
|
||||
builder.widget(label: "Commit", value: build.commit_message.truncate(100)) if build.commit_message.present?
|
||||
|
||||
if build.commit_sha.present?
|
||||
commit_url = "https://github.com/#{project.repository_url}/commit/#{build.commit_sha}"
|
||||
builder.widget(label: "SHA", value: build.commit_sha[0..7], link: commit_url)
|
||||
end
|
||||
|
||||
builder.build(provider_type)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_state
|
||||
return :success if success?
|
||||
return :in_progress if in_progress?
|
||||
:failed
|
||||
end
|
||||
|
||||
def status_text
|
||||
case status_state
|
||||
when :success then "Deployed"
|
||||
when :in_progress then "Deploying"
|
||||
else "Failed"
|
||||
end
|
||||
end
|
||||
|
||||
def status_emoji
|
||||
case status_state
|
||||
when :success then "✅"
|
||||
when :in_progress then "🚀"
|
||||
else "❌"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,199 @@
|
||||
class WebhookBuilder
|
||||
def initialize
|
||||
@title = nil
|
||||
@description = nil
|
||||
@url = nil
|
||||
@color = nil
|
||||
@widgets = []
|
||||
end
|
||||
|
||||
def title(value)
|
||||
@title = value
|
||||
self
|
||||
end
|
||||
|
||||
def description(value)
|
||||
@description = value
|
||||
self
|
||||
end
|
||||
|
||||
def url(value, label: "View Details")
|
||||
@url = value
|
||||
@url_label = label
|
||||
self
|
||||
end
|
||||
|
||||
def color(success: nil, in_progress: nil, failed: nil)
|
||||
@color = { success: success, in_progress: in_progress, failed: failed }
|
||||
self
|
||||
end
|
||||
|
||||
def status(emoji:, text:, state:)
|
||||
@status_emoji = emoji
|
||||
@status_text = text
|
||||
@status_state = state
|
||||
self
|
||||
end
|
||||
|
||||
def widget(label:, value:, link: nil)
|
||||
@widgets << { label: label, value: value, link: link }
|
||||
self
|
||||
end
|
||||
|
||||
def build(provider)
|
||||
case provider.to_sym
|
||||
when :slack
|
||||
slack_payload
|
||||
when :discord
|
||||
discord_payload
|
||||
when :microsoft_teams
|
||||
microsoft_teams_payload
|
||||
when :google_chat
|
||||
google_chat_payload
|
||||
else
|
||||
raise ArgumentError, "Unknown provider: #{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def slack_payload
|
||||
fields = @widgets.map do |w|
|
||||
value = w[:link] ? "<#{w[:link]}|#{w[:value]}>" : w[:value]
|
||||
{ title: w[:label], value: value, short: w[:value].to_s.length < 30 }
|
||||
end
|
||||
|
||||
attachment = {
|
||||
color: slack_color,
|
||||
text: @description,
|
||||
fields: fields,
|
||||
footer: "Canine",
|
||||
ts: Time.current.to_i
|
||||
}
|
||||
|
||||
attachment[:actions] = [ { type: "button", text: @url_label, url: @url } ] if @url
|
||||
|
||||
{
|
||||
text: "#{@status_emoji} *#{@title}*",
|
||||
attachments: [ attachment ]
|
||||
}
|
||||
end
|
||||
|
||||
def discord_payload
|
||||
fields = @widgets.map do |w|
|
||||
value = w[:link] ? "[#{w[:value]}](#{w[:link]})" : w[:value]
|
||||
{ name: w[:label], value: value, inline: w[:value].to_s.length < 30 }
|
||||
end
|
||||
|
||||
embed = {
|
||||
title: "#{@status_emoji} #{@title}",
|
||||
description: @description,
|
||||
color: discord_color,
|
||||
fields: fields,
|
||||
timestamp: Time.current.iso8601
|
||||
}
|
||||
|
||||
embed[:url] = @url if @url
|
||||
|
||||
{ embeds: [ embed ] }
|
||||
end
|
||||
|
||||
def microsoft_teams_payload
|
||||
facts = @widgets.map do |w|
|
||||
value = w[:link] ? "[#{w[:value]}](#{w[:link]})" : w[:value]
|
||||
{ name: w[:label], value: value }
|
||||
end
|
||||
|
||||
payload = {
|
||||
"@type": "MessageCard",
|
||||
"@context": "http://schema.org/extensions",
|
||||
themeColor: teams_color,
|
||||
summary: "#{@title} - #{@status_text}",
|
||||
sections: [
|
||||
{
|
||||
activityTitle: "#{@status_emoji} #{@title}",
|
||||
activitySubtitle: @description,
|
||||
facts: facts,
|
||||
markdown: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if @url
|
||||
payload[:potentialAction] = [
|
||||
{
|
||||
"@type": "OpenUri",
|
||||
name: @url_label,
|
||||
targets: [ { os: "default", uri: @url } ]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
payload
|
||||
end
|
||||
|
||||
def google_chat_payload
|
||||
widgets = @widgets.map do |w|
|
||||
widget = {
|
||||
decoratedText: {
|
||||
topLabel: w[:label],
|
||||
text: w[:value]
|
||||
}
|
||||
}
|
||||
widget[:decoratedText][:onClick] = { openLink: { url: w[:link] } } if w[:link]
|
||||
widget
|
||||
end
|
||||
|
||||
if @url
|
||||
widgets << {
|
||||
buttonList: {
|
||||
buttons: [
|
||||
{
|
||||
text: @url_label,
|
||||
onClick: { openLink: { url: @url } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
cardsV2: [
|
||||
{
|
||||
cardId: "notification-#{Time.current.to_i}",
|
||||
card: {
|
||||
header: {
|
||||
title: @title,
|
||||
subtitle: @description
|
||||
},
|
||||
sections: [ { widgets: widgets } ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def slack_color
|
||||
case @status_state
|
||||
when :success then "good"
|
||||
when :in_progress then "#3B82F6"
|
||||
else "danger"
|
||||
end
|
||||
end
|
||||
|
||||
def discord_color
|
||||
case @status_state
|
||||
when :success then 0x22C55E
|
||||
when :in_progress then 0x3B82F6
|
||||
else 0xEF4444
|
||||
end
|
||||
end
|
||||
|
||||
def teams_color
|
||||
case @status_state
|
||||
when :success then "22C55E"
|
||||
when :in_progress then "3B82F6"
|
||||
else "EF4444"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -74,5 +74,18 @@ class Deployments::BaseDeploymentService
|
||||
def complete_deployment!
|
||||
@deployment.completed!
|
||||
@project.deployed!
|
||||
notify_deployment
|
||||
end
|
||||
|
||||
def notify_deployment
|
||||
return unless @project.notifiers.enabled.any?
|
||||
|
||||
DeploymentNotifier.with(project: @project, deployment: @deployment).deliver_later
|
||||
end
|
||||
|
||||
def setup_automatic_dns(service)
|
||||
service.domains.where(auto_managed: true).find_each do |domain|
|
||||
Dns::AutoSetupService.new(domain, connection: @connection, logger: @logger).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ class Deployments::HelmDeploymentService < Deployments::BaseDeploymentService
|
||||
predeploy
|
||||
deploy_services
|
||||
@chart_builder.install_chart(@project.name)
|
||||
setup_dns_for_services
|
||||
kill_one_off_containers
|
||||
postdeploy
|
||||
|
||||
@@ -69,4 +70,10 @@ class Deployments::HelmDeploymentService < Deployments::BaseDeploymentService
|
||||
def mark_services_healthy
|
||||
@project.services.each(&:healthy!)
|
||||
end
|
||||
|
||||
def setup_dns_for_services
|
||||
@project.services.each do |service|
|
||||
setup_automatic_dns(service)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,6 +66,7 @@ class Deployments::LegacyDeploymentService < Deployments::BaseDeploymentService
|
||||
apply_resource("Ingress", service)
|
||||
end
|
||||
restart_deployment(service)
|
||||
setup_automatic_dns(service)
|
||||
end
|
||||
service.healthy!
|
||||
end
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
class Dns::AutoSetupService
|
||||
attr_reader :domain, :connection, :logger
|
||||
|
||||
def initialize(domain, connection:, logger: Rails.logger)
|
||||
@domain = domain
|
||||
@connection = connection
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def self.enabled?
|
||||
ENV["ENABLE_AUTOMATIC_DNS_MAPPING"] == "true" &&
|
||||
Dns::Cloudflare::API_TOKEN.present? &&
|
||||
Dns::Cloudflare::ZONE_ID.present?
|
||||
end
|
||||
|
||||
def call
|
||||
return unless self.class.enabled?
|
||||
|
||||
raise ArgumentError, "Domain must be auto_managed" unless domain.auto_managed?
|
||||
|
||||
validate_domain_matches_zone!
|
||||
|
||||
logger.info("Setting up automatic DNS for #{domain.domain_name}")
|
||||
|
||||
hostname = fetch_expected_hostname
|
||||
return unless hostname
|
||||
|
||||
create_dns_record(hostname)
|
||||
rescue Dns::Client::Error => e
|
||||
logger.error("Failed to create DNS record for #{domain.domain_name}: #{e.message}")
|
||||
rescue StandardError => e
|
||||
logger.error("DNS setup failed for #{domain.domain_name}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_domain_matches_zone!
|
||||
dns_client = Dns::Client.default
|
||||
return if domain.domain_name.end_with?(".#{dns_client.domain}")
|
||||
|
||||
raise ArgumentError, "Domain #{domain.domain_name} does not match configured zone #{dns_client.domain}"
|
||||
end
|
||||
|
||||
def service
|
||||
domain.service
|
||||
end
|
||||
|
||||
def subdomain
|
||||
dns_client = Dns::Client.default
|
||||
domain.domain_name.chomp(".#{dns_client.domain}")
|
||||
end
|
||||
|
||||
def fetch_expected_hostname
|
||||
ingress = K8::Stateless::Ingress.new(service)
|
||||
Dns::Utils.infer_expected_hostname(ingress, connection)
|
||||
end
|
||||
|
||||
def create_dns_record(hostname)
|
||||
dns_client = Dns::Client.default
|
||||
|
||||
if hostname[:type] == :ip_address
|
||||
create_a_record(dns_client, hostname[:value])
|
||||
else
|
||||
create_cname_record(dns_client, hostname[:value])
|
||||
end
|
||||
end
|
||||
|
||||
def create_a_record(dns_client, ip_address)
|
||||
dns_client.create_a_record(subdomain: subdomain, ip_address: ip_address)
|
||||
logger.success("Created A record: #{domain.domain_name} -> #{ip_address}")
|
||||
end
|
||||
|
||||
def create_cname_record(dns_client, target)
|
||||
dns_client.create_cname_record(subdomain: subdomain, target: target)
|
||||
logger.success("Created CNAME record: #{domain.domain_name} -> #{target}")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Dns::Client
|
||||
class Error < StandardError; end
|
||||
|
||||
def self.for_provider(provider)
|
||||
case provider.to_sym
|
||||
when :cloudflare
|
||||
Dns::Cloudflare.new
|
||||
else
|
||||
raise Error, "Unsupported DNS provider: #{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.default
|
||||
Dns::Cloudflare.new
|
||||
end
|
||||
|
||||
# Interface methods - subclasses must implement these
|
||||
|
||||
def create_a_record(subdomain:, ip_address:, proxied: false, ttl: 300)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def create_cname_record(subdomain:, target:, proxied: false, ttl: 300)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def delete_record(subdomain:)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def record_exists?(subdomain:)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def find_record(subdomain:)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def domain
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
class Dns::Cloudflare < Dns::Client
|
||||
BASE_URL = "https://api.cloudflare.com/client/v4"
|
||||
API_TOKEN = ENV["CLOUDFLARE_API_TOKEN"]
|
||||
ZONE_ID = ENV["CLOUDFLARE_ZONE_ID"]
|
||||
DOMAIN = ENV["CLOUDFLARE_DOMAIN"] || "oncanine.run"
|
||||
|
||||
attr_reader :api_token, :zone_id, :domain
|
||||
|
||||
def initialize(api_token: nil, zone_id: nil, domain: nil)
|
||||
@api_token = api_token || API_TOKEN
|
||||
@zone_id = zone_id || ZONE_ID
|
||||
@domain = domain || DOMAIN
|
||||
end
|
||||
|
||||
def create_a_record(subdomain:, ip_address:, proxied: false, ttl: 1)
|
||||
name = build_fqdn(subdomain)
|
||||
|
||||
existing = find_record_by_name(name: name, type: "A")
|
||||
if existing
|
||||
update_record(record_id: existing["id"], type: "A", content: ip_address, proxied: proxied, ttl: ttl)
|
||||
else
|
||||
create_record(name: name, type: "A", content: ip_address, proxied: proxied, ttl: ttl)
|
||||
end
|
||||
end
|
||||
|
||||
def create_cname_record(subdomain:, target:, proxied: false, ttl: 1)
|
||||
name = build_fqdn(subdomain)
|
||||
|
||||
existing = find_record_by_name(name: name, type: "CNAME")
|
||||
if existing
|
||||
update_record(record_id: existing["id"], type: "CNAME", content: target, proxied: proxied, ttl: ttl)
|
||||
else
|
||||
create_record(name: name, type: "CNAME", content: target, proxied: proxied, ttl: ttl)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_record(subdomain:)
|
||||
name = build_fqdn(subdomain)
|
||||
record = find_record_by_name(name: name)
|
||||
return false unless record
|
||||
|
||||
response = connection.delete("zones/#{zone_id}/dns_records/#{record['id']}")
|
||||
handle_response(response)
|
||||
true
|
||||
end
|
||||
|
||||
def record_exists?(subdomain:)
|
||||
find_record(subdomain: subdomain).present?
|
||||
end
|
||||
|
||||
def find_record(subdomain:)
|
||||
name = build_fqdn(subdomain)
|
||||
find_record_by_name(name: name)
|
||||
end
|
||||
|
||||
def list_records(type: nil, name: nil)
|
||||
params = {}
|
||||
params[:type] = type if type
|
||||
params[:name] = name if name
|
||||
|
||||
response = connection.get("zones/#{zone_id}/dns_records", params)
|
||||
data = handle_response(response)
|
||||
data["result"]
|
||||
end
|
||||
|
||||
def list_all_records(type: nil)
|
||||
params = { per_page: 100, page: 1 }
|
||||
params[:type] = type if type
|
||||
|
||||
all_records = []
|
||||
loop do
|
||||
response = connection.get("zones/#{zone_id}/dns_records", params)
|
||||
data = handle_response(response)
|
||||
all_records.concat(data["result"])
|
||||
|
||||
result_info = data["result_info"]
|
||||
break if params[:page] >= result_info["total_pages"]
|
||||
|
||||
params[:page] += 1
|
||||
end
|
||||
|
||||
all_records
|
||||
end
|
||||
|
||||
def verify_connection
|
||||
response = connection.get("user/tokens/verify")
|
||||
data = handle_response(response)
|
||||
data["success"]
|
||||
rescue Dns::Client::Error
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_fqdn(subdomain)
|
||||
"#{subdomain}.#{domain}"
|
||||
end
|
||||
|
||||
def find_record_by_name(name:, type: nil)
|
||||
records = list_records(name: name, type: type)
|
||||
records&.first
|
||||
end
|
||||
|
||||
def create_record(name:, type:, content:, proxied:, ttl:)
|
||||
payload = {
|
||||
type: type,
|
||||
name: name,
|
||||
content: content,
|
||||
ttl: ttl,
|
||||
proxied: proxied
|
||||
}
|
||||
|
||||
response = connection.post("zones/#{zone_id}/dns_records", payload.to_json)
|
||||
data = handle_response(response)
|
||||
data["result"]
|
||||
end
|
||||
|
||||
def update_record(record_id:, type:, content:, proxied:, ttl:)
|
||||
payload = {
|
||||
type: type,
|
||||
content: content,
|
||||
ttl: ttl,
|
||||
proxied: proxied
|
||||
}
|
||||
|
||||
response = connection.patch("zones/#{zone_id}/dns_records/#{record_id}", payload.to_json)
|
||||
data = handle_response(response)
|
||||
data["result"]
|
||||
end
|
||||
|
||||
def connection
|
||||
@connection ||= Faraday.new(url: BASE_URL) do |faraday|
|
||||
faraday.headers["Authorization"] = "Bearer #{api_token}"
|
||||
faraday.headers["Content-Type"] = "application/json"
|
||||
faraday.adapter Faraday.default_adapter
|
||||
end
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
unless body["success"]
|
||||
errors = body["errors"]&.map { |e| e["message"] }&.join(", ") || "Unknown error"
|
||||
raise Dns::Client::Error, errors
|
||||
end
|
||||
|
||||
body
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
class Dns::Utils
|
||||
class << self
|
||||
def private_ip?(ip)
|
||||
ip.start_with?("10.") || ip.start_with?("172.") || ip.start_with?("192.168.")
|
||||
end
|
||||
|
||||
def ip_address?(str)
|
||||
str.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
||||
end
|
||||
|
||||
def infer_public_ip(connection)
|
||||
server_name = K8::Client.new(connection).server
|
||||
hostname = URI.parse(server_name).hostname
|
||||
|
||||
if ip_address?(hostname)
|
||||
hostname
|
||||
else
|
||||
Resolv.getaddress(hostname)
|
||||
end
|
||||
end
|
||||
|
||||
def infer_expected_hostname(ingress, connection)
|
||||
ingress.connect(connection)
|
||||
hostname = ingress.hostname
|
||||
|
||||
# Only try to infer a public IP address if the cluster is a single node cluster (k3s, local_k3s)
|
||||
if !connection.cluster.k8s? && hostname[:type] == :ip_address && private_ip?(hostname[:value])
|
||||
public_ip = infer_public_ip(connection)
|
||||
{ type: :ip_address, value: public_ip }
|
||||
elsif hostname[:type] == :ip_address && private_ip?(hostname[:value])
|
||||
raise "Private IP address detected for cluster type: #{connection.cluster.cluster_type}"
|
||||
else
|
||||
hostname
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,7 +12,7 @@ class Async::K8::ClusterIpViewModel < Async::BaseViewModel
|
||||
def async_render
|
||||
connection = K8::Connection.new(service.project, current_user)
|
||||
ingress = K8::Stateless::Ingress.new(service)
|
||||
record = Networks::CheckDns.infer_expected_dns(ingress, connection)
|
||||
record = Dns::Utils.infer_expected_hostname(ingress, connection)
|
||||
if record[:type] == :ip_address
|
||||
ip = record[:value]
|
||||
<<~HTML
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class Async::K8::InfoViewModel < Async::BaseViewModel
|
||||
expects :cluster_id
|
||||
|
||||
def server
|
||||
cluster = current_user.clusters.find(params[:cluster_id])
|
||||
K8::Client.new(K8::Connection.new(cluster, current_user)).server
|
||||
end
|
||||
|
||||
def initial_render
|
||||
"<div class='loading loading-spinner loading-sm'></div>"
|
||||
end
|
||||
|
||||
def async_render
|
||||
"<div class='text-sm text-gray-500'>#{server}</div>"
|
||||
end
|
||||
end
|
||||
@@ -43,7 +43,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<% if current_account_user.admin_or_owner? && !account_user.owner? %>
|
||||
<%= button_to account_user_path(account_user), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-ghost btn-sm btn-square" do %>
|
||||
<%= button_to account_user_path(account_user), method: :delete, data: { confirm: "Remove #{account_user.user.name.presence || account_user.user.email} from #{current_account.name}? They will lose access to all projects and resources in this account." }, class: "btn btn-ghost btn-sm btn-square" do %>
|
||||
<iconify-icon icon="lucide:trash" height="18" class="text-error"></iconify-icon>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<%= turbo_stream.replace "team_member_modal" do %>
|
||||
<dialog aria-label="Modal" class="modal modal-open" id="team_member_modal">
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button aria-label="Close modal" class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" onclick="window.location.reload()">
|
||||
<iconify-icon icon="lucide:x" height="16"></iconify-icon>
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-4 w-full text-xl font-bold flex items-center gap-2">
|
||||
<iconify-icon icon="lucide:check-circle" class="text-success" height="24"></iconify-icon>
|
||||
Invitation Created
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
Share these credentials with the new team member. They will be prompted to change their password on first login.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Login URL</span>
|
||||
</label>
|
||||
<div class="input input-bordered flex items-center gap-2 bg-base-200">
|
||||
<span class="truncate flex-1 text-sm"><%= @invite_credentials[:login_url] %></span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="navigator.clipboard.writeText('<%= @invite_credentials[:login_url] %>')">
|
||||
<iconify-icon icon="lucide:copy" height="14"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Email</span>
|
||||
</label>
|
||||
<div class="input input-bordered flex items-center gap-2 bg-base-200">
|
||||
<span class="truncate flex-1 text-sm"><%= @invite_credentials[:email] %></span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="navigator.clipboard.writeText('<%= @invite_credentials[:email] %>')">
|
||||
<iconify-icon icon="lucide:copy" height="14"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Temporary Password</span>
|
||||
</label>
|
||||
<div class="input input-bordered flex items-center gap-2 bg-base-200">
|
||||
<code class="truncate flex-1 text-sm"><%= @invite_credentials[:password] %></code>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="navigator.clipboard.writeText('<%= @invite_credentials[:password] %>')">
|
||||
<iconify-icon icon="lucide:copy" height="14"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-block mt-4" onclick="navigator.clipboard.writeText(`Login URL: <%= @invite_credentials[:login_url] %>\nEmail: <%= @invite_credentials[:email] %>\nTemporary Password: <%= @invite_credentials[:password] %>`); this.innerHTML = '<iconify-icon icon="lucide:check" height="16"></iconify-icon> Copied!'; setTimeout(() => this.innerHTML = '<iconify-icon icon="lucide:copy" height="16"></iconify-icon> Copy All', 2000)">
|
||||
<iconify-icon icon="lucide:copy" height="16"></iconify-icon>
|
||||
Copy All
|
||||
</button>
|
||||
<div class="alert alert-warning mt-4">
|
||||
<iconify-icon icon="lucide:alert-triangle" height="16"></iconify-icon>
|
||||
<span class="text-sm">This password will not be shown again. Make sure to copy it now.</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary" onclick="window.location.reload()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button onclick="window.location.reload()">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<% end %>
|
||||
@@ -68,7 +68,7 @@
|
||||
</label>
|
||||
<%= form.text_field :email, type: :email, class: "input input-bordered w-full focus:outline-offset-0" %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Use the email associated with their Canine account.</span>
|
||||
<span class="label-text-alt">If the user doesn't have an account, you'll get a temporary password to send them.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"warning"
|
||||
end
|
||||
%>
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= add_on.status.humanize %>
|
||||
<% if add_on.updating? || add_on.installing? %>
|
||||
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<%= turbo_stream.replace "user_tool_panel" do %>
|
||||
<div id="user_tool_panel">
|
||||
<%= render Avo::PanelComponent.new(name: "Password Reset Complete") do |c| %>
|
||||
<% c.with_body do %>
|
||||
<div class="p-4">
|
||||
<!-- Success Header -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-green-100">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<p class="text-sm text-gray-600">Share these credentials with the user.</p>
|
||||
</div>
|
||||
|
||||
<!-- Credentials -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Login URL</label>
|
||||
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
<span class="flex-1 text-sm text-gray-900 truncate font-mono"><%= @credentials[:login_url] %></span>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('<%= @credentials[:login_url] %>'); this.innerHTML = '<svg class=\'w-4 h-4 text-green-600\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\'></path></svg>'; setTimeout(() => this.innerHTML = '<svg class=\'w-4 h-4\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\'></path></svg>', 1500)" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Email</label>
|
||||
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
<span class="flex-1 text-sm text-gray-900 truncate"><%= @credentials[:email] %></span>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('<%= @credentials[:email] %>'); this.innerHTML = '<svg class=\'w-4 h-4 text-green-600\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\'></path></svg>'; setTimeout(() => this.innerHTML = '<svg class=\'w-4 h-4\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\'></path></svg>', 1500)" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Temporary Password</label>
|
||||
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
<code class="flex-1 text-sm text-gray-900 font-mono"><%= @credentials[:password] %></code>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('<%= @credentials[:password] %>'); this.innerHTML = '<svg class=\'w-4 h-4 text-green-600\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\'></path></svg>'; setTimeout(() => this.innerHTML = '<svg class=\'w-4 h-4\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\'></path></svg>', 1500)" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy All Button -->
|
||||
<button type="button"
|
||||
id="copy-all-btn"
|
||||
onclick="navigator.clipboard.writeText(`Login URL: <%= @credentials[:login_url] %>\nEmail: <%= @credentials[:email] %>\nTemporary Password: <%= @credentials[:password] %>`); this.querySelector('span').innerText = 'Copied!'; setTimeout(() => this.querySelector('span').innerText = 'Copy All', 1500)"
|
||||
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span>Copy All</span>
|
||||
</button>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-md">
|
||||
<svg class="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<span class="text-xs text-amber-800">This password won't be shown again. Copy it now.</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,31 +1,64 @@
|
||||
<div class="flex flex-col">
|
||||
<%= render Avo::PanelComponent.new(name: "User") do |c| %>
|
||||
<% c.with_tools do %>
|
||||
<%= a_link('/avo', icon: 'heroicons/solid/academic-cap', color: :primary, style: :primary) do %>
|
||||
Login As
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div id="user_tool_panel">
|
||||
<%= render Avo::PanelComponent.new(name: "Manage User") do |c| %>
|
||||
<% c.with_body do %>
|
||||
<div class="flex flex-col p-4 min-h-24">
|
||||
<div class="space-y-4">
|
||||
<h3>Manage User</h3>
|
||||
<%= form_with url: avo.impersonation_path(user_id: @resource.record.id), method: :post, data: { turbo: false } do |f| %>
|
||||
<%= f.submit "Login As", class: Avo::Config.button_css %>
|
||||
<% end %>
|
||||
<%
|
||||
# In this partial you have access to the following variables:
|
||||
# tool
|
||||
# @resource
|
||||
# @resource.record
|
||||
# params
|
||||
# Avo::Current.context
|
||||
# current_user
|
||||
%>
|
||||
<div style="border-top: 1px solid #f3f4f6;">
|
||||
<!-- Login As -->
|
||||
<div style="padding: 1rem; border-bottom: 1px solid #f3f4f6;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<h4 style="font-size: 0.875rem; font-weight: 500; color: #111827;">Login as this user</h4>
|
||||
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Impersonate to see what they see</p>
|
||||
</div>
|
||||
<%= form_with url: avo.impersonation_path(user_id: @resource.record.id), method: :post, data: { turbo: false } do |f| %>
|
||||
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: white; background-color: #2563eb; border: none; cursor: pointer;">
|
||||
Login As
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password -->
|
||||
<div style="padding: 1rem; border-bottom: 1px solid #f3f4f6;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<h4 style="font-size: 0.875rem; font-weight: 500; color: #111827;">Reset password</h4>
|
||||
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Generate a new temporary password</p>
|
||||
</div>
|
||||
<%= form_with url: avo.password_reset_path(user_id: @resource.record.id), method: :post do |f| %>
|
||||
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: #374151; background-color: #f3f4f6; border: none; cursor: pointer;" data-confirm="Generate a new temporary password for <%= @resource.record.email %>?">
|
||||
Reset
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Promote/Demote Site Admin -->
|
||||
<div style="padding: 1rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div>
|
||||
<h4 style="font-size: 0.875rem; font-weight: 500; color: #111827;">Site Admin</h4>
|
||||
<% if @resource.record.admin? %>
|
||||
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Remove site admin privileges</p>
|
||||
<% else %>
|
||||
<p style="font-size: 0.75rem; color: #6b7280; margin-top: 0.125rem;">Promote this user to site admin</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @resource.record.admin? %>
|
||||
<%= form_with url: avo.promote_to_admin_path(user_id: @resource.record.id), method: :delete do |f| %>
|
||||
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: #374151; background-color: #f3f4f6; border: none; cursor: pointer;" data-confirm="Demote <%= @resource.record.email %> from site admin?">
|
||||
Demote
|
||||
</button>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= form_with url: avo.promote_to_admin_path(user_id: @resource.record.id), method: :post do |f| %>
|
||||
<button type="submit" style="display: inline-flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 0.375rem; color: white; background-color: #dc2626; border: none; cursor: pointer;" data-confirm="Promote <%= @resource.record.email %> to site admin?">
|
||||
Promote
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<div class="text-sm text-gray-500">
|
||||
<%= K8::Client.new(K8::Connection.new(cluster, current_user)).server %>
|
||||
</div>
|
||||
<%= render(
|
||||
"shared/partials/async_renderer",
|
||||
view_model: Async::K8::InfoViewModel.new(
|
||||
current_user,
|
||||
cluster_id: cluster.id
|
||||
)
|
||||
) %>
|
||||
@@ -15,7 +15,7 @@
|
||||
end
|
||||
%>
|
||||
<div class="flex items-center gap-2">
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= cluster.status %>
|
||||
</div>
|
||||
<% if cluster.status == "failed" %>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
end
|
||||
%>
|
||||
<div class="text-right">
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= build_cloud.status.humanize %>
|
||||
<% if build_cloud.updating? || build_cloud.installing? %>
|
||||
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="flex justify-center items-center min-h-screen bg-base-200">
|
||||
<div class="card w-96 bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="text-2xl font-bold text-center">Change Your Password</h1>
|
||||
<p class="text-center text-sm text-base-content/70">
|
||||
You must change your temporary password before continuing.
|
||||
</p>
|
||||
|
||||
<%= form_with(model: current_user, url: password_change_path, method: :patch, html: { class: "space-y-4 mt-4" }) do |f| %>
|
||||
<% if current_user.errors.any? %>
|
||||
<div class="alert alert-error">
|
||||
<iconify-icon icon="lucide:alert-triangle" class="mr-2"></iconify-icon>
|
||||
<span><%= current_user.errors.full_messages.to_sentence %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :current_password, class: "label" do %>
|
||||
<span class="label-text">Current Password</span>
|
||||
<% end %>
|
||||
<%= f.password_field :current_password, autocomplete: "current-password", class: "input input-bordered w-full", required: true %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Enter the temporary password you were given</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :password, "New Password", class: "label" do %>
|
||||
<span class="label-text">New Password</span>
|
||||
<% end %>
|
||||
<%= f.password_field :password, autocomplete: "new-password", class: "input input-bordered w-full", required: true %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= f.label :password_confirmation, class: "label" do %>
|
||||
<span class="label-text">Confirm New Password</span>
|
||||
<% end %>
|
||||
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input input-bordered w-full", required: true %>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<%= f.submit "Change Password", class: "btn btn-primary w-full" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,11 @@
|
||||
<%= turbo_stream_from project, :status %>
|
||||
<div id="<%= dom_id(project, :status) %>">
|
||||
<% if project.last_build&.in_progress? %>
|
||||
<div aria-label="Badge" class="badge border-0 bg-warning/10 font-medium capitalize text-warning">
|
||||
<div aria-label="Badge" class="badge badge-warning font-medium capitalize">
|
||||
Building <iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
|
||||
</div>
|
||||
<% elsif project.last_deployment&.in_progress? %>
|
||||
<div aria-label="Badge" class="badge border-0 bg-warning/10 font-medium capitalize text-warning">
|
||||
<div aria-label="Badge" class="badge badge-warning font-medium capitalize">
|
||||
Deploying <iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
|
||||
</div>
|
||||
<% else %>
|
||||
@@ -19,7 +19,7 @@
|
||||
"error"
|
||||
end
|
||||
%>
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= project.status %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"error"
|
||||
end
|
||||
%>
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= status %>
|
||||
<% if status == "pending" %>
|
||||
<iconify-icon class="ml-1 animate-spin" icon="lucide:loader-circle"></iconify-icon>
|
||||
|
||||
@@ -32,6 +32,13 @@
|
||||
<%= render "build_packs/search" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">Notifications</h2>
|
||||
<hr class="mt-3 mb-4 border-t border-base-300" />
|
||||
|
||||
<%= render "projects/notifiers/index", project: @project %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">Danger zone</h2>
|
||||
<hr class="mt-3 mb-4 border-t border-base-300" />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="text-sm text-base-content/60">
|
||||
Get your webhook URL from Discord: <strong>Server Settings → Integrations → Webhooks → New Webhook</strong>
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
<%= form_with model: notifier, url: notifier.persisted? ? project_notifier_path(@project, notifier) : project_notifiers_path(@project) do |f| %>
|
||||
<% if notifier.errors.any? %>
|
||||
<div class="alert alert-error mb-4">
|
||||
<ul>
|
||||
<% notifier.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="form-control w-full max-w-sm">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<%= f.text_field :name, class: "input input-bordered w-full", placeholder: "e.g. Deploy Alerts", required: true %>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Provider</span>
|
||||
</label>
|
||||
<%= render "shared/partials/radio_selector",
|
||||
selected: notifier.provider_type || "slack",
|
||||
options: [
|
||||
{
|
||||
name: "notifier[provider_type]",
|
||||
value: "slack",
|
||||
href: nil,
|
||||
icon: "logos:slack-icon",
|
||||
label: "Slack",
|
||||
description: "Send notifications to a Slack channel",
|
||||
partial: "projects/notifiers/slack_instructions",
|
||||
locals: {}
|
||||
},
|
||||
{
|
||||
name: "notifier[provider_type]",
|
||||
value: "discord",
|
||||
href: nil,
|
||||
icon: "logos:discord-icon",
|
||||
label: "Discord",
|
||||
description: "Send notifications to a Discord channel",
|
||||
partial: "projects/notifiers/discord_instructions",
|
||||
locals: {}
|
||||
},
|
||||
{
|
||||
name: "notifier[provider_type]",
|
||||
value: "microsoft_teams",
|
||||
href: nil,
|
||||
icon: "logos:microsoft-teams",
|
||||
label: "Microsoft Teams",
|
||||
description: "Send notifications to a Teams channel",
|
||||
partial: "projects/notifiers/microsoft_teams_instructions",
|
||||
locals: {}
|
||||
},
|
||||
{
|
||||
name: "notifier[provider_type]",
|
||||
value: "google_chat",
|
||||
href: nil,
|
||||
icon: "/images/logos/google-chat.webp",
|
||||
label: "Google Chat",
|
||||
description: "Send notifications to a Google Chat space",
|
||||
partial: "projects/notifiers/google_chat_instructions",
|
||||
locals: {}
|
||||
}
|
||||
] %>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Webhook URL</span>
|
||||
</label>
|
||||
<%= f.text_field :webhook_url, class: "input input-bordered w-full font-mono text-sm", placeholder: "https://hooks.slack.com/services/..." %>
|
||||
</div>
|
||||
|
||||
<div class="form-control rounded-lg bg-base-200 p-2 px-4 max-w-sm mt-4">
|
||||
<label class="label mt-1">
|
||||
<span class="label-text cursor-pointer">Enabled</span>
|
||||
<%= f.check_box :enabled, class: "checkbox" %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<%= f.submit notifier.persisted? ? "Update Notifier" : "Create Notifier", class: "btn btn-primary" %>
|
||||
<%= link_to "Cancel", project_notifiers_path(@project), class: "btn btn-ghost", data: { turbo_frame: "notifiers" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="text-sm text-base-content/60">
|
||||
Get your webhook URL from Google Chat: <strong>Space → Apps & integrations → Webhooks → Add webhook</strong>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<%= turbo_frame_tag "notifiers" do %>
|
||||
<div class="space-y-4">
|
||||
<div class="text-base-content/50">
|
||||
Set up webhook notifications to receive real-time alerts about builds and deployments. Supports Slack, Discord, Microsoft Teams, and Google Chat.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= link_to "Add Notifier", new_project_notifier_path(project), class: "btn btn-primary btn-outline", data: { turbo_frame: "notifiers" } %>
|
||||
</div>
|
||||
|
||||
<% if project.notifiers.any? %>
|
||||
<div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Provider</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= render partial: "projects/notifiers/notifier", collection: project.notifiers.order(:created_at), as: :notifier %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="text-sm text-base-content/60">
|
||||
Get your webhook URL from Teams: <strong>Channel → Connectors → Incoming Webhook → Configure</strong>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<tr id="<%= dom_id(notifier) %>">
|
||||
<td><%= notifier.name %></td>
|
||||
<td>
|
||||
<span class="badge badge-outline">
|
||||
<%= notifier.provider_type.titleize %>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<% if notifier.enabled? %>
|
||||
<span class="badge badge-success">Enabled</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">Disabled</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="flex gap-2 justify-end">
|
||||
<%= link_to "Edit", edit_project_notifier_path(notifier.project, notifier), class: "btn btn-sm btn-ghost", data: { turbo_frame: "notifiers" } %>
|
||||
<%= button_to(
|
||||
"Delete",
|
||||
project_notifier_path(notifier.project, notifier),
|
||||
method: :delete,
|
||||
data: { turbo_confirm: "Are you sure you want to delete this #{notifier.provider_type} notifier?" },
|
||||
class: "btn btn-sm btn-ghost text-error"
|
||||
) %>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="text-sm text-base-content/60">
|
||||
Get your webhook URL from Slack: <strong>Apps → Incoming Webhooks → Add New Webhook</strong>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<%= turbo_frame_tag "notifiers" do %>
|
||||
<h3 class="text-lg font-bold mb-4">Edit Notifier</h3>
|
||||
<%= render "form", notifier: @notifier %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,4 @@
|
||||
<%= turbo_frame_tag "notifiers" do %>
|
||||
<h3 class="text-lg font-bold mb-4">New Notifier</h3>
|
||||
<%= render "form", notifier: @notifier %>
|
||||
<% end %>
|
||||
@@ -11,7 +11,7 @@
|
||||
end
|
||||
%>
|
||||
<div class="text-right">
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= service.friendly_status %>
|
||||
</div>
|
||||
<% if service.web_service? %>
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
<tbody>
|
||||
<% service.domains.each do |domain| %>
|
||||
<tr id="<%= dom_id(domain) %>">
|
||||
<td><%= link_to domain.domain_name, "https://#{domain.domain_name}", target: "_blank" %></td>
|
||||
<td>
|
||||
<%= link_to domain.domain_name, "https://#{domain.domain_name}", target: "_blank" %>
|
||||
<% if domain.auto_managed? %>
|
||||
<div class="tooltip tooltip-right inline-block ml-2" data-tip="Automatically managed by Canine">
|
||||
<span class="badge badge-sm badge-info">Auto</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<%= render(
|
||||
"shared/partials/async_renderer",
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
"error"
|
||||
end
|
||||
%>
|
||||
<div
|
||||
aria-label="Badge"
|
||||
class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>"
|
||||
>
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= domain.status.to_s.titleize %>
|
||||
</div>
|
||||
<% if domain.status_reason.present? && domain.dns_incorrect? %>
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"warning"
|
||||
end
|
||||
%>
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= status.to_s.humanize %>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with model: volume, url: project_volumes_path(volume.project), method: :post, data: { turbo_frame: "new_volume" } do |f| %>
|
||||
<%= form_with model: volume, url: project_volumes_path(volume.project), method: :post do |f| %>
|
||||
<div class="form-control mt-1 w-full max-w-xs">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
@@ -45,6 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<%= f.button "Submit", class: "btn btn-primary", data: { turbo: false } %>
|
||||
<%= f.submit "Create Volume", class: "btn btn-primary" %>
|
||||
<%= link_to "Cancel", project_volumes_path(volume.project), class: "btn btn-ghost", data: { turbo_frame: "volumes" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<%= turbo_frame_tag "volumes" do %>
|
||||
<div class="space-y-4">
|
||||
<div class="text-base-content/50">
|
||||
Volumes are a way to persist data across container restarts. In general, you should use a database for this.
|
||||
@@ -5,12 +6,10 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= turbo_frame_tag "new_volume" do %>
|
||||
<%= link_to "Create Volume", new_project_volume_path(project), class: "btn btn-primary btn-outline" %>
|
||||
<% end %>
|
||||
<%= link_to "Create Volume", new_project_volume_path(project), class: "btn btn-primary btn-outline", data: { turbo_frame: "volumes" } %>
|
||||
</div>
|
||||
|
||||
<% if @project.volumes.any? %>
|
||||
<% if project.volumes.any? %>
|
||||
<div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -24,9 +23,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= render partial: "projects/volumes/volume", collection: @project.volumes, as: :volume %>
|
||||
<%= render partial: "projects/volumes/volume", collection: project.volumes, as: :volume %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"error"
|
||||
end
|
||||
%>
|
||||
<div aria-label="Badge" class="badge border-0 bg-<%= badge_color %>/10 font-medium capitalize text-<%= badge_color %>">
|
||||
<div aria-label="Badge" class="badge badge-<%= badge_color %> font-medium capitalize">
|
||||
<%= volume.status %>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= turbo_frame_tag "new_volume" do %>
|
||||
<%= turbo_frame_tag "volumes" do %>
|
||||
<h3 class="text-lg font-bold">New Volume</h3>
|
||||
<%= render "projects/volumes/form", volume: @volume %>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<nav role="navigation" aria-label="Navbar" class="topbar-wrapper navbar z-10 border-b border-base-200 px-3" data-controller="toggle">
|
||||
<nav role="navigation" aria-label="Navbar" class="topbar-wrapper navbar z-10 border-b border-base-200 px-3">
|
||||
<%= render "shared/left_nav" %>
|
||||
<%= render "shared/right_nav" %>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="ml-auto">
|
||||
<div class="dropdown ml-auto dropdown-end dropdown-bottom">
|
||||
<label tabindex="0" class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20">
|
||||
<div class="dropdown ml-auto dropdown-end dropdown-bottom" data-controller="dropdown">
|
||||
<label class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20" data-action="click->dropdown#toggle">
|
||||
<div aria-label="Avatar photo" class="avatar">
|
||||
<div class="mask mask-squircle" style="width: 30px; height: 30px">
|
||||
<%= image_tag avatar_path(current_user, size: 40) %>
|
||||
@@ -14,7 +14,7 @@
|
||||
<%#= link_to t(".api_tokens"), api_tokens_path, class: 'text-gray-800 bg-white hover:bg-primary-50 dark:text-gray-50 dark:bg-gray-800 dark:hover:bg-primary-800 transition ease-in-out duration-200 whitespace-nowrap no-underline block px-6 py-3' if Jumpstart.config.payments_enabled? %>
|
||||
<%= link_to edit_user_registration_path do %>
|
||||
<iconify-icon icon="lucide:user" height="16"></iconify-icon>
|
||||
Account
|
||||
Profile
|
||||
<% end %>
|
||||
<%= link_to account_users_path do %>
|
||||
<iconify-icon icon="lucide:users" height="16"></iconify-icon>
|
||||
|
||||
@@ -11,7 +11,7 @@ Avo.configure do |config|
|
||||
# config.mount_avo_engines = true
|
||||
|
||||
# Where should the user be redirected when visiting the `/avo` url
|
||||
# config.home_path = nil
|
||||
config.home_path = '/admin/dashboard'
|
||||
|
||||
## == Licensing ==
|
||||
# config.license_key = ENV['AVO_LICENSE_KEY']
|
||||
@@ -23,9 +23,9 @@ Avo.configure do |config|
|
||||
|
||||
## == Authentication ==
|
||||
config.current_user_method = :current_user
|
||||
# config.authenticate_with do
|
||||
# current_user.present? && current_user.admin?
|
||||
# end
|
||||
config.authenticate_with do
|
||||
current_user.present? && current_user.admin?
|
||||
end
|
||||
|
||||
## == Authorization ==
|
||||
# config.is_admin_method = :is_admin
|
||||
|
||||
@@ -134,6 +134,55 @@ Devise.setup do |config|
|
||||
# Send a notification email when the user's password is changed.
|
||||
# config.send_password_change_notification = false
|
||||
|
||||
# ==> Configuration for :invitable
|
||||
# The period the generated invitation token is valid.
|
||||
# After this period, the invited resource won't be able to accept the invitation.
|
||||
# When invite_for is 0 (the default), the invitation won't expire.
|
||||
# config.invite_for = 2.weeks
|
||||
|
||||
# Number of invitations users can send.
|
||||
# - If invitation_limit is nil, there is no limit for invitations, users can
|
||||
# send unlimited invitations, invitation_limit column is not used.
|
||||
# - If invitation_limit is 0, users can't send invitations by default.
|
||||
# - If invitation_limit n > 0, users can send n invitations.
|
||||
# You can change invitation_limit column for some users so they can send more
|
||||
# or less invitations, even with global invitation_limit = 0
|
||||
# Default: nil
|
||||
# config.invitation_limit = 5
|
||||
|
||||
# The key to be used to check existing users when sending an invitation
|
||||
# and the regexp used to test it when validate_on_invite is not set.
|
||||
# config.invite_key = { email: /\A[^@]+@[^@]+\z/ }
|
||||
# config.invite_key = { email: /\A[^@]+@[^@]+\z/, username: nil }
|
||||
|
||||
# Ensure that invited record is valid.
|
||||
# The invitation won't be sent if this check fails.
|
||||
# Default: false
|
||||
# config.validate_on_invite = true
|
||||
|
||||
# Resend invitation if user with invited status is invited again
|
||||
# Default: true
|
||||
# config.resend_invitation = false
|
||||
|
||||
# The class name of the inviting model. If this is nil,
|
||||
# the #invited_by association is declared to be polymorphic.
|
||||
# Default: nil
|
||||
# config.invited_by_class_name = 'User'
|
||||
|
||||
# The foreign key to the inviting model (if invited_by_class_name is set)
|
||||
# Default: :invited_by_id
|
||||
# config.invited_by_foreign_key = :invited_by_id
|
||||
|
||||
# The column name used for counter_cache column. If this is nil,
|
||||
# the #invited_by association is declared without counter_cache.
|
||||
# Default: nil
|
||||
# config.invited_by_counter_cache = :invitations_count
|
||||
|
||||
# Auto-login after the user accepts the invite. If this is false,
|
||||
# the user will need to manually log in after accepting the invite.
|
||||
# Default: true
|
||||
# config.allow_insecure_sign_in_after_accept = false
|
||||
|
||||
# ==> Configuration for :confirmable
|
||||
# A period that the user is allowed to access the website even without
|
||||
# confirming their account. For instance, if set to 2.days, the user will be
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
en:
|
||||
devise:
|
||||
failure:
|
||||
invited: "You have a pending invitation, accept it to finish creating your account."
|
||||
invitations:
|
||||
send_instructions: "An invitation email has been sent to %{email}."
|
||||
invitation_token_invalid: "The invitation token provided is not valid!"
|
||||
updated: "Your password was set successfully. You are now signed in."
|
||||
updated_not_active: "Your password was set successfully."
|
||||
no_invitations_remaining: "No invitations remaining"
|
||||
invitation_removed: "Your invitation was removed."
|
||||
new:
|
||||
header: "Send invitation"
|
||||
submit_button: "Send an invitation"
|
||||
edit:
|
||||
header: "Set your password"
|
||||
submit_button: "Set my password"
|
||||
mailer:
|
||||
invitation_instructions:
|
||||
subject: "Invitation instructions"
|
||||
hello: "Hello %{email}"
|
||||
someone_invited_you: "Someone has invited you to %{url}, you can accept it through the link below."
|
||||
accept: "Accept invitation"
|
||||
accept_until: "This invitation will be due in %{due_date}."
|
||||
ignore: "If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password."
|
||||
time:
|
||||
formats:
|
||||
devise:
|
||||
mailer:
|
||||
invitation_instructions:
|
||||
accept_until_format: "%B %d, %Y %I:%M %p"
|
||||
+10
-1
@@ -64,7 +64,12 @@ Rails.application.routes.draw do
|
||||
Avo::Engine.routes.draw do
|
||||
# This route is not protected, secure it with authentication if needed.
|
||||
get "dashboard", to: "tools#dashboard", as: :dashboard
|
||||
resource :impersonation, only: [ :create, :destroy ]
|
||||
resource :impersonation, only: [ :destroy ] do
|
||||
post :create, on: :collection, action: :create
|
||||
get :create, on: :collection, action: :create, as: :start
|
||||
end
|
||||
resource :password_reset, only: [ :create ]
|
||||
resource :promote_to_admin, only: [ :create, :destroy ]
|
||||
end
|
||||
end
|
||||
resources :accounts, only: [ :create ] do
|
||||
@@ -145,6 +150,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :project_forks, only: %i[index edit create], module: :projects
|
||||
resources :volumes, only: %i[index new create destroy], module: :projects
|
||||
resources :notifiers, only: %i[index new create edit update destroy], module: :projects
|
||||
resources :processes, only: %i[index show create destroy], module: :projects
|
||||
resources :services, only: %i[index new create destroy update show], module: :projects do
|
||||
resource :resource_constraint, only: %i[show new create update destroy], module: :services
|
||||
@@ -201,6 +207,9 @@ Rails.application.routes.draw do
|
||||
resources :notifications, only: [ :index ]
|
||||
resources :announcements, only: [ :index ]
|
||||
devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks", registrations: "users/registrations", sessions: "users/sessions" }
|
||||
|
||||
resource :password_change, only: [ :show, :update ], controller: "password_change"
|
||||
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
|
||||
@@ -109,7 +109,6 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
safelist: [
|
||||
'bg-success/10', 'text-success',
|
||||
{
|
||||
pattern: /text-(gray|red|green|yellow|blue|purple|cyan)-(100|300|400|500)/ // LogColorsHelper uses custom tailwind colors
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class DeviseInvitableAddToUsers < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
change_table :users do |t|
|
||||
t.string :invitation_token
|
||||
t.datetime :invitation_created_at
|
||||
t.datetime :invitation_sent_at
|
||||
t.datetime :invitation_accepted_at
|
||||
t.integer :invitation_limit
|
||||
t.references :invited_by, polymorphic: true
|
||||
t.integer :invitations_count, default: 0
|
||||
t.index :invitation_token, unique: true # for invitable
|
||||
t.index :invited_by_id
|
||||
end
|
||||
add_column :users, :password_change_required, :boolean, default: false
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :users, :password_change_required, :boolean, default: false
|
||||
change_table :users do |t|
|
||||
t.remove_references :invited_by, polymorphic: true
|
||||
t.remove :invitations_count, :invitation_limit, :invitation_sent_at, :invitation_accepted_at, :invitation_token, :invitation_created_at
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateNotifiers < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :notifiers do |t|
|
||||
t.references :project, null: false, foreign_key: true
|
||||
t.string :name, null: false
|
||||
t.integer :provider_type, null: false, default: 0
|
||||
t.string :webhook_url, null: false
|
||||
t.boolean :enabled, null: false, default: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
class AddSlugToProjects < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
add_column :projects, :slug, :string
|
||||
Project.find_each do |project|
|
||||
project.slug = if Project.exists?(slug: project.name)
|
||||
SecureRandom.uuid[0..7]
|
||||
else
|
||||
project.name
|
||||
end
|
||||
project.save!(validate: false)
|
||||
end
|
||||
|
||||
change_column_null :projects, :slug, false
|
||||
add_index :projects, :slug, unique: true
|
||||
remove_index :services, :project_id
|
||||
add_index :services, [ :project_id, :name ], unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :projects, :slug
|
||||
add_index :services, :project_id
|
||||
remove_index :services, [ :project_id, :name ]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddAutoManagedToDomains < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :domains, :auto_managed, :boolean, default: false
|
||||
end
|
||||
end
|
||||
Generated
+29
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_25_233309) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
@@ -210,6 +210,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "status", default: 0
|
||||
t.string "status_reason"
|
||||
t.boolean "auto_managed", default: false
|
||||
t.index ["service_id"], name: "index_domains_on_service_id"
|
||||
end
|
||||
|
||||
@@ -432,6 +433,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient"
|
||||
end
|
||||
|
||||
create_table "notifiers", force: :cascade do |t|
|
||||
t.bigint "project_id", null: false
|
||||
t.string "name", null: false
|
||||
t.integer "provider_type", default: 0, null: false
|
||||
t.string "webhook_url", null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["project_id"], name: "index_notifiers_on_project_id"
|
||||
end
|
||||
|
||||
create_table "oauth_access_grants", force: :cascade do |t|
|
||||
t.bigint "resource_owner_id", null: false
|
||||
t.bigint "application_id", null: false
|
||||
@@ -544,8 +556,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
t.integer "project_fork_status", default: 0
|
||||
t.string "namespace", null: false
|
||||
t.boolean "managed_namespace", default: true
|
||||
t.string "slug", null: false
|
||||
t.index ["cluster_id"], name: "index_projects_on_cluster_id"
|
||||
t.index ["name"], name: "index_projects_on_name"
|
||||
t.index ["slug"], name: "index_projects_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "providers", force: :cascade do |t|
|
||||
@@ -612,7 +626,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "description"
|
||||
t.jsonb "pod_yaml"
|
||||
t.index ["project_id"], name: "index_services_on_project_id"
|
||||
t.index ["project_id", "name"], name: "index_services_on_project_id_and_name", unique: true
|
||||
end
|
||||
|
||||
create_table "sso_providers", force: :cascade do |t|
|
||||
@@ -683,7 +697,19 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
t.boolean "admin", default: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "invitation_token"
|
||||
t.datetime "invitation_created_at"
|
||||
t.datetime "invitation_sent_at"
|
||||
t.datetime "invitation_accepted_at"
|
||||
t.integer "invitation_limit"
|
||||
t.string "invited_by_type"
|
||||
t.bigint "invited_by_id"
|
||||
t.integer "invitations_count", default: 0
|
||||
t.boolean "password_change_required", default: false
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
|
||||
t.index ["invited_by_id"], name: "index_users_on_invited_by_id"
|
||||
t.index ["invited_by_type", "invited_by_id"], name: "index_users_on_invited_by"
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
@@ -721,6 +747,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_173824) do
|
||||
add_foreign_key "environment_variables", "projects"
|
||||
add_foreign_key "favorites", "accounts"
|
||||
add_foreign_key "favorites", "users"
|
||||
add_foreign_key "notifiers", "projects"
|
||||
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
|
||||
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
|
||||
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
|
||||
|
||||
+138
-14
@@ -3,7 +3,6 @@
|
||||
<head>
|
||||
<title>Page Not Found (404)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #167bff;
|
||||
@@ -12,32 +11,157 @@
|
||||
--base-content: #dcebfa;
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.error-page {
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 56rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-section > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #167bff;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: var(--base-content);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: opacity 200ms;
|
||||
background-color: #167bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 200ms;
|
||||
background-color: var(--base-200);
|
||||
color: var(--base-content);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #2d343d;
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-4xl w-full">
|
||||
<div class="grid md:grid-cols-2 gap-12 items-center">
|
||||
<body class="error-page">
|
||||
<div class="container">
|
||||
<div class="content-wrapper">
|
||||
<div class="grid">
|
||||
<!-- Left side - Content -->
|
||||
<div class="space-y-6">
|
||||
<div class="text-8xl font-bold" style="color: #167bff;">404</div>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight" style="color: var(--base-content);">
|
||||
<div class="content-section">
|
||||
<div class="error-code">404</div>
|
||||
<h1 class="error-title">
|
||||
Sorry,<br>
|
||||
we can't find<br>
|
||||
that page.
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<a href="/" class="inline-block hover:opacity-90 text-white font-semibold px-8 py-3 rounded-lg transition-all duration-200" style="background-color: #167bff;">
|
||||
<div class="button-group">
|
||||
<a href="/" class="btn-primary">
|
||||
Take me home
|
||||
</a>
|
||||
<button onclick="history.back()" class="inline-block hover:bg-[#252b32] font-semibold px-8 py-3 rounded-lg transition-colors duration-200" style="background-color: var(--base-200); color: var(--base-content);">
|
||||
<button onclick="history.back()" class="btn-secondary">
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
@@ -45,8 +169,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Right side - Illustration -->
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<img src="/images/illustrations/floating.webp" alt="Error illustration" class="w-full max-w-md">
|
||||
<div class="illustration-section">
|
||||
<img src="/images/illustrations/floating.webp" alt="Error illustration" class="illustration">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Your browser is not supported (406)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
.rails-default-error-page {
|
||||
background-color: #EFEFEF;
|
||||
color: #2E2F30;
|
||||
text-align: center;
|
||||
font-family: arial, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
:root {
|
||||
--primary: #167bff;
|
||||
--base-100: #191e23;
|
||||
--base-200: #252b32;
|
||||
--base-content: #dcebfa;
|
||||
--warning: #f97316;
|
||||
}
|
||||
|
||||
.rails-default-error-page div.dialog {
|
||||
width: 95%;
|
||||
max-width: 33em;
|
||||
margin: 4em auto 0;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rails-default-error-page div.dialog > div {
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #BBB;
|
||||
border-top: #B00100 solid 4px;
|
||||
border-top-left-radius: 9px;
|
||||
border-top-right-radius: 9px;
|
||||
background-color: white;
|
||||
padding: 7px 12% 0;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
body.error-page {
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.rails-default-error-page h1 {
|
||||
font-size: 100%;
|
||||
color: #730E15;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.rails-default-error-page div.dialog > p {
|
||||
margin: 0 0 1em;
|
||||
padding: 1em;
|
||||
background-color: #F7F7F7;
|
||||
border: 1px solid #CCC;
|
||||
border-right-color: #999;
|
||||
border-left-color: #999;
|
||||
border-bottom-color: #999;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top-color: #DADADA;
|
||||
color: #666;
|
||||
box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
|
||||
}
|
||||
.content-wrapper {
|
||||
max-width: 56rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-section > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: var(--base-content);
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--base-content);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: opacity 200ms;
|
||||
background-color: #f97316;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="rails-default-error-page">
|
||||
<!-- This file lives in public/406-unsupported-browser.html -->
|
||||
<div class="dialog">
|
||||
<div>
|
||||
<h1>Your browser is not supported.</h1>
|
||||
<p>Please upgrade your browser to continue.</p>
|
||||
<body class="error-page">
|
||||
<div class="container">
|
||||
<div class="content-wrapper">
|
||||
<div class="grid">
|
||||
<!-- Left side - Content -->
|
||||
<div class="content-section">
|
||||
<div class="error-code">406</div>
|
||||
<h1 class="error-title">
|
||||
Sorry,<br>
|
||||
your browser<br>
|
||||
is not supported.
|
||||
</h1>
|
||||
|
||||
<p class="error-description">
|
||||
Please upgrade your browser to continue.
|
||||
</p>
|
||||
|
||||
<div class="button-group">
|
||||
<a href="/" class="btn-primary">
|
||||
Take me home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right side - Illustration -->
|
||||
<div class="illustration-section">
|
||||
<img src="/images/illustrations/design_2.webp" alt="Error illustration" class="illustration">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+138
-14
@@ -3,7 +3,6 @@
|
||||
<head>
|
||||
<title>The change you wanted was rejected (422)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #167bff;
|
||||
@@ -13,32 +12,157 @@
|
||||
--warning: #fbbf24;
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.error-page {
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 56rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-section > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: var(--base-content);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
color: rgb(17, 24, 39);
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: opacity 200ms;
|
||||
background-color: #fbbf24;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 200ms;
|
||||
background-color: var(--base-200);
|
||||
color: var(--base-content);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #2d343d;
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-4xl w-full">
|
||||
<div class="grid md:grid-cols-2 gap-12 items-center">
|
||||
<body class="error-page">
|
||||
<div class="container">
|
||||
<div class="content-wrapper">
|
||||
<div class="grid">
|
||||
<!-- Left side - Content -->
|
||||
<div class="space-y-6">
|
||||
<div class="text-8xl font-bold" style="color: #fbbf24;">422</div>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight" style="color: var(--base-content);">
|
||||
<div class="content-section">
|
||||
<div class="error-code">422</div>
|
||||
<h1 class="error-title">
|
||||
Sorry,<br>
|
||||
that wasn't<br>
|
||||
accepted.
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<a href="/" class="inline-block hover:opacity-90 text-gray-900 font-semibold px-8 py-3 rounded-lg transition-all duration-200" style="background-color: #fbbf24;">
|
||||
<div class="button-group">
|
||||
<a href="/" class="btn-primary">
|
||||
Take me home
|
||||
</a>
|
||||
<button onclick="history.back()" class="inline-block hover:bg-[#252b32] font-semibold px-8 py-3 rounded-lg transition-colors duration-200" style="background-color: var(--base-200); color: var(--base-content);">
|
||||
<button onclick="history.back()" class="btn-secondary">
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
@@ -46,8 +170,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Right side - Illustration -->
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<img src="/images/illustrations/design_2.webp" alt="Error illustration" class="w-full max-w-md">
|
||||
<div class="illustration-section">
|
||||
<img src="/images/illustrations/design_2.webp" alt="Error illustration" class="illustration">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+138
-14
@@ -3,7 +3,6 @@
|
||||
<head>
|
||||
<title>Something Went Wrong (500)</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #167bff;
|
||||
@@ -13,32 +12,157 @@
|
||||
--error: #f31260;
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.error-page {
|
||||
background-color: var(--base-100);
|
||||
color: var(--base-content);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 56rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-section > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #f31260;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: var(--base-content);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: opacity 200ms;
|
||||
background-color: #f31260;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 200ms;
|
||||
background-color: var(--base-200);
|
||||
color: var(--base-content);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #2d343d;
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.illustration-section {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 3.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-4xl w-full">
|
||||
<div class="grid md:grid-cols-2 gap-12 items-center">
|
||||
<body class="error-page">
|
||||
<div class="container">
|
||||
<div class="content-wrapper">
|
||||
<div class="grid">
|
||||
<!-- Left side - Content -->
|
||||
<div class="space-y-6">
|
||||
<div class="text-8xl font-bold" style="color: #f31260;">500</div>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold leading-tight" style="color: var(--base-content);">
|
||||
<div class="content-section">
|
||||
<div class="error-code">500</div>
|
||||
<h1 class="error-title">
|
||||
Sorry,<br>
|
||||
something<br>
|
||||
went wrong.
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<a href="/" class="inline-block hover:opacity-90 text-white font-semibold px-8 py-3 rounded-lg transition-all duration-200" style="background-color: #f31260;">
|
||||
<div class="button-group">
|
||||
<a href="/" class="btn-primary">
|
||||
Take me home
|
||||
</a>
|
||||
<button onclick="history.back()" class="inline-block hover:bg-[#252b32] font-semibold px-8 py-3 rounded-lg transition-colors duration-200" style="background-color: var(--base-200); color: var(--base-content);">
|
||||
<button onclick="history.back()" class="btn-secondary">
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
@@ -46,8 +170,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Right side - Illustration -->
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<img src="/images/illustrations/rover.webp" alt="Error illustration" class="w-full max-w-md">
|
||||
<div class="illustration-section">
|
||||
<img src="/images/illustrations/rover.webp" alt="Error illustration" class="illustration">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,56 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Domains::AttachAutoManagedDomain do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
describe '.execute' do
|
||||
context 'when auto dns is enabled and service is public web service' do
|
||||
let(:service) { create(:service, project: project, allow_public_networking: true, service_type: :web_service) }
|
||||
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
|
||||
end
|
||||
|
||||
it 'creates an auto-managed domain' do
|
||||
expect { described_class.execute(service: service) }.to change { service.domains.count }.by(1)
|
||||
|
||||
domain = service.domains.last
|
||||
expect(domain.auto_managed).to be true
|
||||
expect(domain.domain_name).to eq("#{service.name}-#{project.slug}.oncanine.run")
|
||||
end
|
||||
|
||||
it 'does not create duplicate auto-managed domains' do
|
||||
described_class.execute(service: service)
|
||||
expect { described_class.execute(service: service) }.not_to change { service.domains.count }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when auto dns is disabled' do
|
||||
let(:service) { create(:service, project: project, allow_public_networking: true, service_type: :web_service) }
|
||||
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not create a domain' do
|
||||
expect { described_class.execute(service: service) }.not_to change { Domain.count }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when service is not public or not a web service' do
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
|
||||
end
|
||||
|
||||
it 'does not create a domain for non-public services' do
|
||||
service = create(:service, project: project, allow_public_networking: false, service_type: :web_service)
|
||||
expect { described_class.execute(service: service) }.not_to change { Domain.count }
|
||||
end
|
||||
|
||||
it 'does not create a domain for background services' do
|
||||
service = create(:service, :background_service, project: project, allow_public_networking: true)
|
||||
expect { described_class.execute(service: service) }.not_to change { Domain.count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Domains::Destroy do
|
||||
let(:project) { create(:project) }
|
||||
let(:service) { create(:service, project: project) }
|
||||
|
||||
describe '.execute' do
|
||||
it 'destroys the domain' do
|
||||
domain = create(:domain, service: service)
|
||||
|
||||
expect { described_class.execute(domain: domain) }.to change { Domain.count }.by(-1)
|
||||
end
|
||||
|
||||
context 'when domain is auto_managed and dns is enabled' do
|
||||
let(:dns_client) { instance_double(Dns::Cloudflare, domain: 'oncanine.run') }
|
||||
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
|
||||
allow(Dns::Client).to receive(:default).and_return(dns_client)
|
||||
end
|
||||
|
||||
it 'deletes the DNS record and destroys the domain' do
|
||||
domain = create(:domain, service: service, domain_name: 'test-app.oncanine.run', auto_managed: true)
|
||||
|
||||
expect(dns_client).to receive(:delete_record).with(subdomain: 'test-app')
|
||||
|
||||
described_class.execute(domain: domain)
|
||||
expect(Domain.exists?(domain.id)).to be false
|
||||
end
|
||||
|
||||
it 'still destroys the domain if DNS deletion fails' do
|
||||
domain = create(:domain, service: service, domain_name: 'test-app.oncanine.run', auto_managed: true)
|
||||
|
||||
allow(dns_client).to receive(:delete_record).and_raise(Dns::Client::Error, 'API error')
|
||||
|
||||
expect { described_class.execute(domain: domain) }.to change { Domain.count }.by(-1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when domain is not auto_managed' do
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
|
||||
end
|
||||
|
||||
it 'does not attempt DNS cleanup' do
|
||||
domain = create(:domain, service: service, auto_managed: false)
|
||||
|
||||
expect(Dns::Client).not_to receive(:default)
|
||||
|
||||
described_class.execute(domain: domain)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dns is disabled' do
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not attempt DNS cleanup' do
|
||||
domain = create(:domain, service: service, auto_managed: true)
|
||||
|
||||
expect(Dns::Client).not_to receive(:default)
|
||||
|
||||
described_class.execute(domain: domain)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,21 +1,21 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Networks::CheckDns do
|
||||
let(:cluster) { create(:cluster) }
|
||||
let(:cluster) { create(:cluster, cluster_type: :k3s) }
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project, cluster: cluster) }
|
||||
let(:service) { create(:service, project: project) }
|
||||
let(:connection) { K8::Connection.new(cluster, user) }
|
||||
let(:ingress) { K8::Stateless::Ingress.new(service).connect(connection) }
|
||||
|
||||
describe '.infer_expected_dns' do
|
||||
describe 'Dns::Utils.infer_expected_hostname' do
|
||||
context 'when ingress returns public IP' do
|
||||
before do
|
||||
allow(ingress).to receive(:hostname).and_return({ value: '8.8.8.8', type: :ip_address })
|
||||
end
|
||||
|
||||
it 'returns the IP' do
|
||||
expect(described_class.infer_expected_dns(ingress, connection)).to eq({ value: "8.8.8.8", type: :ip_address })
|
||||
expect(Dns::Utils.infer_expected_hostname(ingress, connection)).to eq({ value: "8.8.8.8", type: :ip_address })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,8 +25,16 @@ RSpec.describe Networks::CheckDns do
|
||||
allow(Resolv).to receive(:getaddress).with('example.com').and_return('1.2.3.4')
|
||||
end
|
||||
|
||||
it 'resolves and returns public IP' do
|
||||
expect(described_class.infer_expected_dns(ingress, connection)).to eq({ value: "1.2.3.4", type: :ip_address })
|
||||
context 'when cluster is a single node cluster' do
|
||||
it 'resolves and returns public IP' do
|
||||
expect(Dns::Utils.infer_expected_hostname(ingress, connection)).to eq({ value: "1.2.3.4", type: :ip_address })
|
||||
end
|
||||
end
|
||||
context 'when cluster is not a single node cluster' do
|
||||
let(:cluster) { create(:cluster, cluster_type: :k8s) }
|
||||
it 'raises an error' do
|
||||
expect { Dns::Utils.infer_expected_hostname(ingress, connection) }.to raise_error("Private IP address detected for cluster type: k8s")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,7 +57,7 @@ RSpec.describe Networks::CheckDns do
|
||||
end
|
||||
|
||||
it 'returns the hostname IP' do
|
||||
expect(described_class.infer_expected_dns(ingress, connection)).to(eq({ value: "1.2.3.4", type: :ip_address }))
|
||||
expect(Dns::Utils.infer_expected_hostname(ingress, connection)).to(eq({ value: "1.2.3.4", type: :ip_address }))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Services::Create do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
it 'saves the service with associations' do
|
||||
project = create(:project)
|
||||
service = build(:service, :cron_job, project: project)
|
||||
|
||||
result = described_class.call(service, { service: {} })
|
||||
@@ -11,4 +12,29 @@ RSpec.describe Services::Create do
|
||||
expect(service).to be_persisted
|
||||
expect(service.cron_schedule).to be_persisted
|
||||
end
|
||||
|
||||
context 'when auto dns is enabled' do
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
|
||||
end
|
||||
|
||||
it 'creates an auto-managed domain for public web services' do
|
||||
service = build(:service, project: project, allow_public_networking: true, service_type: :web_service)
|
||||
|
||||
result = described_class.call(service, { service: {} })
|
||||
|
||||
expect(result).to be_success
|
||||
expect(service.domains.count).to eq(1)
|
||||
expect(service.domains.first.auto_managed).to be true
|
||||
end
|
||||
|
||||
it 'does not create an auto-managed domain for non-public services' do
|
||||
service = build(:service, project: project, allow_public_networking: false, service_type: :web_service)
|
||||
|
||||
result = described_class.call(service, { service: {} })
|
||||
|
||||
expect(result).to be_success
|
||||
expect(service.domains.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Services::Update do
|
||||
let(:project) { create(:project) }
|
||||
|
||||
describe '.execute' do
|
||||
it 'updates the service and marks it as updated' do
|
||||
service = create(:service, project: project, replicas: 1)
|
||||
params = ActionController::Parameters.new({ service: { replicas: 3 } })
|
||||
|
||||
result = described_class.execute(service: service, params: params)
|
||||
|
||||
expect(result).to be_success
|
||||
expect(service.reload.replicas).to eq(3)
|
||||
expect(service.status).to eq('updated')
|
||||
end
|
||||
|
||||
context 'when auto dns is enabled' do
|
||||
before do
|
||||
allow(Dns::AutoSetupService).to receive(:enabled?).and_return(true)
|
||||
end
|
||||
|
||||
it 'creates an auto-managed domain when public networking is turned on' do
|
||||
service = create(:service, project: project, allow_public_networking: false, service_type: :web_service)
|
||||
params = ActionController::Parameters.new({ service: { allow_public_networking: true } })
|
||||
|
||||
expect { described_class.execute(service: service, params: params) }
|
||||
.to change { service.domains.count }.by(1)
|
||||
|
||||
expect(service.domains.last.auto_managed).to be true
|
||||
end
|
||||
|
||||
it 'does not create a domain when public networking was already on' do
|
||||
service = create(:service, project: project, allow_public_networking: true, service_type: :web_service)
|
||||
create(:domain, service: service, auto_managed: true)
|
||||
params = ActionController::Parameters.new({ service: { replicas: 2 } })
|
||||
|
||||
expect { described_class.execute(service: service, params: params) }
|
||||
.not_to change { service.domains.count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@
|
||||
# Table name: domains
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# auto_managed :boolean default(FALSE)
|
||||
# domain_name :string not null
|
||||
# status :integer default("checking_dns")
|
||||
# status_reason :string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user