diff --git a/.rubocop.yml b/.rubocop.yml index b2acd6f..88270c1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,12 +15,15 @@ Style/Documentation: Metrics/AbcSize: CountRepeatedAttributes: false +Metrics/BlockLength: + Max: 30 + Metrics/MethodLength: CountAsOne: - 'array' - 'hash' - 'heredoc' - Max: 25 + Max: 30 RSpec/ExampleLength: CountAsOne: diff --git a/.yamllint b/.yamllint index 4b54359..3baee75 100644 --- a/.yamllint +++ b/.yamllint @@ -3,5 +3,7 @@ extends: default rules: indentation: indent-sequences: consistent + line-length: + max: 120 # vim:ft=yaml diff --git a/README.md b/README.md index 56ce1bc..9230b51 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,14 @@ actions. ### Configuration -Before using the CLI, configure the Supervisor base URL and API key by +Before using the CLI, configure the Supervisor base URI and API token by creating a configuration file at `~/.supervisor`: ```yaml --- -base_url: https://supervisor.example.com -api_key: 8db7fde4-6a11-462e-ba27-6897b7c9281b +api: + uri: https://supervisor.example.com + token: 8db7fde4-6a11-462e-ba27-6897b7c9281b ``` ### Command Reference @@ -96,6 +97,161 @@ supervisor is-healthy Checks the health of the Supervisor service. +### Deployment Management + +The command `deploy` installs and sets up a containerized Supervisor service +on a vanilla Linux machine by provisioning the docker service and +deploying the application proxy [Traefik](https://traefik.io/). + +#### Default Traefik docker command + +```bash +docker run \ + --detach --restart always --name traefik \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume /var/lib/traefik:/etc/traefik \ + --network supervisor \ + --publish 80:80 --publish 443:443 \ + traefik:v3.2.1 \ + --providers.docker.exposedbydefault="false" \ + --entrypoints.web.address=":80" \ + --entrypoints.websecure.address=":443" \ + --certificatesresolvers.letsencrypt.acme.email="acme@supervisor.example" \ + --certificatesresolvers.letsencrypt.acme.storage="/etc/traefik/certs.d/acme.json" \ + --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint="web" +``` + +#### Default Supervisor docker command + +```bash +docker run \ + --detach --restart always --name supervisor \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume /var/lib/supervisor:/rails/storage \ + --network supervisor \ + --label traefik.enable="true" \ + --label traefik.http.routers.supervisor.tls="true" \ + --label traefik.http.routers.supervisor.tls.certresolver="letsencrypt" \ + --label traefik.http.routers.supervisor.rule="Host(\"supervisor.example.com\")" \ + --label traefik.http.routers.supervisor.entrypoints="websecure" \ + --env SECRET_KEY_BASE="601f72235d8ea11db69e678f9...1a" \ + --env SUPERVISOR_API_KEY="8db7fde4-6a11-462e-ba27-6897b7c9281b" \ + ghcr.io/tschaefer/supervisor:main +``` + +#### Default docker network command + +```bash +docker network create \ + --attachable true \ + --ipv6=true \ + --driver=bridge \ + --opt com.docker.network.container_iface_prefix=supervisor + supervisor +``` + +Prerequisites are super-user privileges, a valid DNS record for the +Supervisor service and the above mentioned configuration file. + +While setup the necessary certificate is requested from +[Let's Encrypt](https://letsencrypt.org/) via HTTP-challenge. + + +```bash +supervisor deploy --host root@machine.example.com +``` + +The provisioning of docker can be skipped wit the option `--skip-docker` as +well as the installation of Traefik with the option `--skip-traefik`. For a +more informative output use `--verbose` - beware, sensible information will be +exposed. + +The deployment is customizable by configuration in the root under `deploy`. + +```yaml +deploy: + + # Network settings + network: + + # The name of the network to create, defaults to supervisor + name: supervisor + # Additional options to pass to the network create command + options: + ipv6: false + opt: com.docker.network.driver.mtu=1500 + + # Traefik settings + traefik: + + # The Traefik image to use, defaults to traefik:v3.2.1 + image: traefik:v3.2.0 + + # Additional arguments to pass to the Traefik container + args: + configfile: /etc/traefik/traefik.yml + + # Additional environment variables to pass to the Traefik container + env: + CF_API_EMAIL: cloudflare@example.com + CF_DNS_API_TOKEN: YSsfAH-d1q57j2D7T41ptAfM + + # Supervisor settings + supervisor: + + # The Supervisor image to use, defaults to ghcr.io/tschaefer/supervisor:main + image: ghcr.io/tschaefer/supervisor:latest + + # Additional labels to apply to the Supervisor container + labels: + traefik.http.routers.supervisor.tls.certresolver: cloudflare + + # Additional environment variables to pass to the Supervisor container + env: {} +``` + +Custom `hooks` scripts can be run before and after certain deployment steps. + +* `post-docker-setup` +* `pre-traefik-deploy` +* `post-traefik-deploy` +* `pre-supervisor-deploy` +* `post-supervisor-deploy` + +**Example**: + +```bash +#!/usr/bin/env sh + +# pre-traefik-deploy hook script + +cat < /var/lib/traefik/traefik.yml +--- +certificatesresolvers: + cloudflare: + acme: + email: acme@example.com + storage: /etc/traefik/certs.d/cloudflare.json + dnschallenge: + provider: cloudflare +EOF +``` + +The hook filename must be the hook name without any extension. The path to the +hooks directory can be configured in the root under `hooks`. + +```yaml +hooks: /path/to/hooks +``` + +The Supervisor service can be redeployed with the command `redeploy`. + +```bash +supervisor redeploy --host machine.example.com +``` + +Optionally, Traefik can be redeployed with the option `--with-traefik`. + ### Stack Management The `stacks` commands provide a variety of operations for managing stacks. diff --git a/etc/bash/completion b/etc/bash/completion index f5f926b..f29504c 100644 --- a/etc/bash/completion +++ b/etc/bash/completion @@ -19,7 +19,7 @@ __supervisor_stacks_completion() { done local cmd - cmd="supervisor ${configuration_file} list --json" + cmd="supervisor ${configuration_file} stacks list --json" local stacks stacks=$(eval ${cmd} | jq -r '.[].uuid') @@ -165,7 +165,7 @@ _supervisor_stack_control() { _get_comp_words_by_ref -n : cur prev words local options='--help --command' - local actions='start stop restart' + local actions='start stop restart redeploy' case "$prev" in --command) @@ -223,23 +223,91 @@ _supervisor_health() { fi } -_supervisor() { +_supervisor_deploy() { + local cur prev + _get_comp_words_by_ref -n : cur prev + + local options='--help' + local options_deploy='--host --skip-docker --skip-traefik --verbose' + + options="${options} ${options_deploy}" + + case "$prev" in + --help) + return + ;; + --host) + _known_hosts + return + ;; + esac + + if [[ "$cur" == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "$options" -- "$cur") + return + fi +} + +_supervisor_redeploy() { + local cur prev + _get_comp_words_by_ref -n : cur prev + + local options='--help' + local options_deploy='--host --verbose --with-traefik' + + options="${options} ${options_deploy}" + + case "$prev" in + --help) + return + ;; + --host) + _known_hosts + return + ;; + esac + + if [[ "$cur" == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "$options" -- "$cur") + return + fi +} + +_supervisor_dashboard() { + local cur prev + _get_comp_words_by_ref -n : cur prev + + local options='--help' + local options_dashboard='--open' + + options="${options} ${options_dashboard}" + + case "$prev" in + --help) + return + ;; + --open) + return + ;; + esac + + if [[ "$cur" == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "$options" -- "$cur") + return + fi +} + + +_supervisor_stacks() { local cur prev words _get_comp_words_by_ref -n : cur prev words - local actions="is-healthy create delete list show stats update control log" + local actions="create delete list show stats update control log" local options='--help --man --version' - local options_config='--configuration-file' - - options="${options} ${options_config}" local word for word in "${words[@]}"; do case $word in - is-healthy) - _supervisor_health - return - ;; delete) _supervisor_stack_delete return @@ -275,6 +343,56 @@ _supervisor() { esac done + case "$prev" in + --help|--man|--version) + return + ;; + esac + + if [[ "$cur" == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "${options}" -- "$cur") + return + fi + + mapfile -t COMPREPLY < <(compgen -W "${actions}" -- "$cur") +} + +_supervisor() { + local cur prev words + _get_comp_words_by_ref -n : cur prev words + + local actions="dashboard redeploy deploy is-healthy stacks" + local options='--help --man --version' + local options_config='--configuration-file' + + options="${options} ${options_config}" + + local word + for word in "${words[@]}"; do + case $word in + dashboard) + _supervisor_dashboard + return + ;; + deploy) + _supervisor_deploy + return + ;; + redeploy) + _supervisor_redeploy + return + ;; + is-healthy) + _supervisor_health + return + ;; + stacks) + _supervisor_stacks + return + ;; + esac + done + case "$prev" in --help|--man|--version) return diff --git a/lib/supervisor.rb b/lib/supervisor.rb index 2999378..090ef2a 100644 --- a/lib/supervisor.rb +++ b/lib/supervisor.rb @@ -1,8 +1,17 @@ # frozen_string_literal: true +require 'active_support' +require 'active_support/core_ext' + require 'zeitwerk' loader = Zeitwerk::Loader.for_gem +loader.inflector.inflect 'prepares_sshkit' => 'PreparesSSHKit' +Dir.glob(File.join(__dir__, '/**/*/')).each do |dir| + next unless dir.ends_with?('/concerns/') + + loader.collapse(dir) +end loader.setup module Supervisor @@ -17,7 +26,7 @@ def configure end def configured? - @client ? true : false + defined?(@client) end def configured! diff --git a/lib/supervisor/app.rb b/lib/supervisor/app.rb index 8cfd227..81dc5e5 100644 --- a/lib/supervisor/app.rb +++ b/lib/supervisor/app.rb @@ -5,15 +5,20 @@ module App class Command < Supervisor::App::Base option ['-c', '--configuration-file'], 'FILE', 'configuration file', attribute_name: :cfgfile + subcommand 'deploy', 'Deploy the Supervisor service with stack', Supervisor::App::Deploy + subcommand 'redeploy', 'Redeploy the Supervisor service', Supervisor::App::Redeploy subcommand 'is-healthy', 'Check the health of the Supervisor service', Supervisor::App::Health - subcommand 'list', 'List all stacks', Supervisor::App::Stacks::List - subcommand 'show', 'Show a stack', Supervisor::App::Stacks::Show - subcommand 'stats', 'Show stats of a stack', Supervisor::App::Stacks::Stats - subcommand 'create', 'Create a stack', Supervisor::App::Stacks::Create - subcommand 'update', 'Update a stack', Supervisor::App::Stacks::Update - subcommand 'delete', 'Delete a stack', Supervisor::App::Stacks::Delete - subcommand 'control', 'Control a stack', Supervisor::App::Stacks::Control - subcommand 'log', 'Show the log of a stack', Supervisor::App::Stacks::Log + subcommand 'dashboard', 'Show Supervisor service dashboard URL', Supervisor::App::Dashboard + subcommand 'stacks', 'Manage stacks' do + subcommand 'list', 'List all stacks', Supervisor::App::Stacks::List + subcommand 'show', 'Show a stack', Supervisor::App::Stacks::Show + subcommand 'stats', 'Show stats of a stack', Supervisor::App::Stacks::Stats + subcommand 'create', 'Create a stack', Supervisor::App::Stacks::Create + subcommand 'update', 'Update a stack', Supervisor::App::Stacks::Update + subcommand 'delete', 'Delete a stack', Supervisor::App::Stacks::Delete + subcommand 'control', 'Control a stack', Supervisor::App::Stacks::Control + subcommand 'log', 'Show the log of a stack', Supervisor::App::Stacks::Log + end end end end diff --git a/lib/supervisor/app/base.rb b/lib/supervisor/app/base.rb index 3d8ef2d..1708c12 100644 --- a/lib/supervisor/app/base.rb +++ b/lib/supervisor/app/base.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true require 'clamp' +require 'hashie' require 'pastel' + require 'tty-screen' require 'tty-table' require 'tty-pager' @@ -55,7 +57,7 @@ def call(method, *) result = filter_secrets(result) if defined?(unfiltered?) && !unfiltered? if defined?(json?) && json? - puts result.to_json + puts JSON.pretty_generate(result) exit 0 end @@ -64,13 +66,19 @@ def call(method, *) bailout(e.message) end - def configure + def settings + return @settings if defined?(@settings) + cfgfile = @cfgfile.presence || File.join(Dir.home, '.supervisor') - settings = File.readable?(cfgfile) ? YAML.load_file(cfgfile) : {} + settings = File.readable?(cfgfile) ? YAML.load_file(cfgfile) : bailout('No configuration file found') + @settings = Hashie::Mash.new(settings) + end + + def configure Supervisor.configure do |config| - config.base_uri = ENV.fetch('SUPERVISOR_BASE_URI', settings['base_uri']) || bailout('No base URI configured') - config.api_key = ENV.fetch('SUPERVISOR_API_KEY', settings['api_key']) || bailout('No API key configured') + config.base_uri = settings.api.uri || bailout('No base URI configured') + config.api_key = settings.api.token || bailout('No API key configured') end end diff --git a/lib/supervisor/app/concerns/prepares_sshkit.rb b/lib/supervisor/app/concerns/prepares_sshkit.rb new file mode 100644 index 0000000..23add72 --- /dev/null +++ b/lib/supervisor/app/concerns/prepares_sshkit.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Supervisor + module App + module PreparesSSHKit + extend ActiveSupport::Concern + + included do + private + + def setup_sshkit + SSHKit.config.tap do |cfg| + cfg.output_verbosity = Logger::DEBUG if %w[true yes 1].include?(ENV['SUPERVISOR_CLIENT_DEBUG']) + cfg.use_format verbose? ? :pretty : :dot + end + + effective_host = %w[localhost 127.0.0.1 ::1].include?(host) ? :local : host + @host = SSHKit::Host.new(effective_host) + end + end + end + end +end diff --git a/lib/supervisor/app/dashboard.rb b/lib/supervisor/app/dashboard.rb new file mode 100644 index 0000000..8c8c068 --- /dev/null +++ b/lib/supervisor/app/dashboard.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Supervisor + module App + class Dashboard < Supervisor::App::Base + option ['--open'], :flag, 'open in browser' + + def execute + dashboard = URI.parse(settings.api.uri).tap { |u| u.path = '/dashboard' } + return system('open', dashboard) if open? + + puts dashboard + end + end + end +end diff --git a/lib/supervisor/app/deploy.rb b/lib/supervisor/app/deploy.rb new file mode 100644 index 0000000..c73db2e --- /dev/null +++ b/lib/supervisor/app/deploy.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'sshkit' +require 'sshkit/dsl' + +module Supervisor + module App + class Deploy < Supervisor::App::Base + include SSHKit::DSL + include Supervisor::App::PreparesSSHKit + + option ['--host'], 'HOST', 'the host to deploy to', required: true + option ['--skip-docker'], :flag, 'skip Docker installation' + option ['--skip-traefik'], :flag, 'skip Traefik deployment' + option ['--verbose'], :flag, 'show SSHKit output' + + def execute + setup_sshkit + check_prerequisites + setup_docker + deploy_traefik + deploy_supervisor + + puts unless verbose? + rescue SSHKit::Runner::ExecuteError => e + bailout(e.message) + end + + private + + def check_prerequisites + Supervisor::App::Services::Prerequisites.new(host, settings).run + end + + def setup_docker + return if skip_docker? + + Supervisor::App::Services::Docker.new(host, settings).run + end + + def deploy_traefik + return if skip_traefik? + + Supervisor::App::Services::Traefik.new(host, settings).run + end + + def deploy_supervisor + Supervisor::App::Services::Supervisor.new(host, settings).run + end + end + end +end diff --git a/lib/supervisor/app/redeploy.rb b/lib/supervisor/app/redeploy.rb new file mode 100644 index 0000000..0d5f9f0 --- /dev/null +++ b/lib/supervisor/app/redeploy.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'sshkit' +require 'sshkit/dsl' + +module Supervisor + module App + class Redeploy < Supervisor::App::Base + include SSHKit::DSL + include Supervisor::App::PreparesSSHKit + + option ['--host'], 'HOST', 'the host to redeploy to', required: true + option ['--verbose'], :flag, 'show SSHKit output' + option ['--with-traefik'], :flag, 'redeploy Traefik' + + def execute + setup_sshkit + check_prerequisites + redeploy_traefik + redeploy_supervisor + + puts unless verbose? + rescue SSHKit::Runner::ExecuteError => e + bailout(e.message) + end + + private + + def check_prerequisites + Supervisor::App::Services::Prerequisites.new(host, settings).run + end + + def redeploy_traefik + return unless with_traefik? + + on @host do + as :root do + execute :docker, 'rm', '--force', 'traefik' + end + end + Supervisor::App::Services::Traefik.new(host, settings).run + end + + def redeploy_supervisor + image = settings&.dig(:deploy, :supervisor, :image) || 'ghcr.io/tschaefer/supervisor:main' + on @host do + as :root do + execute :docker, 'pull', image + execute :docker, 'rm', '--force', 'supervisor' + end + end + Supervisor::App::Services::Supervisor.new(host, settings).run + end + end + end +end diff --git a/lib/supervisor/app/services/concerns/ensures_network.rb b/lib/supervisor/app/services/concerns/ensures_network.rb new file mode 100644 index 0000000..fb07b0a --- /dev/null +++ b/lib/supervisor/app/services/concerns/ensures_network.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + module EnsuresNetwork + extend ActiveSupport::Concern + include SSHKit::DSL + + included do + private + + def ensure_network + command = %w[ + network create + --attachable --ipv6 + --driver bridge --opt com.docker.network.container_iface_prefix=supervisor + ] + command += build_network_options + command << network_name + + test_command = %w[ + network inspect + --format {{.Name}} + ] + test_command << network_name + + on @host do + as :root do + execute :docker, *command unless test :docker, *test_command + end + end + end + + def build_network_options + default = {} + default.merge!(@settings.deploy&.network&.options || {}) + + argumentize(default) + end + + def network_name + @settings.deploy&.network&.name || 'supervisor' + end + end + end + end + end +end diff --git a/lib/supervisor/app/services/docker.rb b/lib/supervisor/app/services/docker.rb new file mode 100644 index 0000000..bc50123 --- /dev/null +++ b/lib/supervisor/app/services/docker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + class Docker + include SSHKit::DSL + + def initialize(host, settings) + @host = host + @settings = settings + end + + def run + on @host do + as :root do + if execute :docker, '-v', raise_on_non_zero_exit: false + unless execute :docker, 'version', raise_on_non_zero_exit: false + error 'Docker is not running' + exit 1 + end + else + tmdir = capture :mktemp, '--directory' + within tmdir do + execute :curl, '-fsSL', 'https://get.docker.com', '-o', 'get-docker.sh' + execute :sh, 'get-docker.sh' + execute :rm, 'get-docker.sh' + end + execute :rm, '-rf', tmdir + end + end + end + run_post_hook + end + + def run_post_hook + ::Supervisor::App::Services::Hook.new(@host, @settings, 'post-docker-setup').run + end + end + end + end +end diff --git a/lib/supervisor/app/services/hook.rb b/lib/supervisor/app/services/hook.rb new file mode 100644 index 0000000..104dad1 --- /dev/null +++ b/lib/supervisor/app/services/hook.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + class Hook + include SSHKit::DSL + + def initialize(host, settings, hook) + @host = host + @settings = settings + @hook = hook + end + + def run + return unless @settings.deploy&.hooks&.presence + return unless hook_exist? + + execute_hook + end + + private + + def execute_hook + file = hook_file + hook = @hook + + on @host do + tmpdir = capture :mktemp, '--directory' + script = "#{tmpdir}/#{SecureRandom.uuid}" + upload! file, script + + succeeded = false + as :root do + succeeded = execute script, raise_on_non_zero_exit: false + end + + execute :rm, '-rf', tmpdir + + unless succeeded + error "Hook #{hook} failed" + exit 1 + end + end + end + + def hook_exist? + Pathname.new(hook_file).exist? + end + + def hook_file + File.join(@settings.deploy.hooks, @hook) + end + end + end + end +end diff --git a/lib/supervisor/app/services/prerequisites.rb b/lib/supervisor/app/services/prerequisites.rb new file mode 100644 index 0000000..0824b13 --- /dev/null +++ b/lib/supervisor/app/services/prerequisites.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + class Prerequisites + include SSHKit::DSL + + def initialize(host, settings) + @host = host + @settings = settings + end + + def run + on @host do + unless test :sh, '-c', '\'[ "${EUID:-$(id -u)}" -eq 0 ] || command -v sudo || command -v su\'' + error "You need to be root or have sudo installed on #{@host}" + exit 1 + end + + unless test :sh, '-c', 'command -v curl' + error "You need to have curl installed on #{@host}" + exit 1 + end + end + end + end + end + end +end diff --git a/lib/supervisor/app/services/supervisor.rb b/lib/supervisor/app/services/supervisor.rb new file mode 100644 index 0000000..4de0808 --- /dev/null +++ b/lib/supervisor/app/services/supervisor.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + class Supervisor + include SSHKit::DSL + include ::Supervisor::App::Services::EnsuresNetwork + + delegate :argumentize, to: ::Supervisor::App::Services::Utils + + def initialize(host, settings) + @host = host + @settings = settings + end + + def run + run_pre_hook + ensure_network + run_command + run_post_hook + end + + private + + def run_command + command = build_command + on @host do + as :root do + execute :mkdir, '-p', '/var/lib/supervisor' + execute :chown, '-R', '1001:1001', '/var/lib/supervisor' + execute :docker, *command + end + end + end + + def run_pre_hook + ::Supervisor::App::Services::Hook.new(@host, @settings, 'pre-supervisor-deploy').run + end + + def run_post_hook + ::Supervisor::App::Services::Hook.new(@host, @settings, 'post-supervisor-deploy').run + end + + def build_command + command = %w[ + run --detach --restart always + --name supervisor + --volume /var/run/docker.sock:/var/run/docker.sock + --volume /var/lib/supervisor:/rails/storage + ] + command += ['--network', network_name] + command += build_labels + command += build_env + command += [set_image] + + command + end + + def set_image + @settings.deploy&.supervisor&.image || 'ghcr.io/tschaefer/supervisor:main' + end + + def build_labels + rule = "Host(\\\"#{URI.parse(@settings.api.uri).host}\\\")" + + labels = { + 'traefik.enable' => 'true', + 'traefik.http.routers.supervisor.tls' => 'true', + 'traefik.http.routers.supervisor.tls.certresolver' => 'letsencrypt', + 'traefik.http.routers.supervisor.rule' => rule, + 'traefik.http.routers.supervisor.entrypoints' => 'websecure' + } + labels.merge!(@settings.deploy&.supervisor&.labels || {}) + + argumentize(labels, prefix: '--label ') + end + + def build_env + env = { + 'SUPERVISOR_API_KEY' => @settings.api.token + } + env.merge!(@settings.deploy&.supervisor&.env || {}) + + argumentize(env, prefix: '--env ') + end + end + end + end +end diff --git a/lib/supervisor/app/services/traefik.rb b/lib/supervisor/app/services/traefik.rb new file mode 100644 index 0000000..a5601c4 --- /dev/null +++ b/lib/supervisor/app/services/traefik.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + class Traefik + include SSHKit::DSL + include ::Supervisor::App::Services::EnsuresNetwork + + delegate :argumentize, to: ::Supervisor::App::Services::Utils + + def initialize(host, settings) + @host = host + @settings = settings + end + + def run + run_pre_hook + ensure_network + run_command + run_post_hook + end + + private + + def run_command + command = build_command + on @host do + as :root do + execute :mkdir, '-p', '/var/lib/traefik/certs.d' + execute :docker, *command + end + end + end + + def run_pre_hook + ::Supervisor::App::Services::Hook.new(@host, @settings, 'pre-traefik-deploy').run + end + + def run_post_hook + ::Supervisor::App::Services::Hook.new(@host, @settings, 'post-traefik-deploy').run + end + + def build_command + command = %w[ + run --detach --restart always + --name traefik + --volume /var/run/docker.sock:/var/run/docker.sock + --volume /var/lib/traefik:/etc/traefik + --publish 80:80 --publish 443:443 + ] + command += ['--network', network_name] + command += build_env + command += [set_image] + command += build_args + + command + end + + def set_image + @settings.deploy&.traefik&.image || 'traefik:v3.2.1' + end + + def build_args + email = "acme@#{URI.parse(@settings.api.uri).host}" + + args = { + 'providers.docker.exposedbydefault' => 'false', + 'entrypoints.web.address' => ':80', + 'entrypoints.websecure.address' => ':443', + 'certificatesresolvers.letsencrypt.acme.email' => email, + 'certificatesresolvers.letsencrypt.acme.storage' => '/etc/traefik/certs.d/acme.json', + 'certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint' => 'web' + } + args.merge!(@settings.deploy&.traefik&.args || {}) + + argumentize(args) + end + + def build_env + env = {} + env.merge!(@settings.deploy&.traefik&.env || {}) + + argumentize(env, prefix: '--env ') + end + end + end + end +end diff --git a/lib/supervisor/app/services/utils.rb b/lib/supervisor/app/services/utils.rb new file mode 100644 index 0000000..4d987c4 --- /dev/null +++ b/lib/supervisor/app/services/utils.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Services + module Utils + class << self + def argumentize(hash, prefix: '--') + hash.map { |key, value| "#{prefix}#{key}=\"#{value}\"" } + end + end + end + end + end +end diff --git a/lib/supervisor/app/stacks/concerns/handles_manifest.rb b/lib/supervisor/app/stacks/concerns/handles_manifest.rb new file mode 100644 index 0000000..e99c8cb --- /dev/null +++ b/lib/supervisor/app/stacks/concerns/handles_manifest.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Supervisor + module App + module Stacks + module HandlesManifest + extend ActiveSupport::Concern + + included do + private + + def load_manifest_file(file) + yaml = decrypt_manifest_values(file) if decrypt? + + begin + yaml.present? ? YAML.load(yaml) : YAML.safe_load_file(file) + rescue StandardError => e + bailout(e.message) + end + end + + def decrypt_manifest_values(_file) + begin + yaml_encrypted = File.read(file) + rescue StandardError => e + bailout(e.message) + end + + cmd = 'sops decrypt --input-type yaml --output-type yaml --output /dev/stdout /dev/stdin' + stdout, stderr, status = Open3.capture3(*cmd.split, stdin_data: yaml_encrypted) + bailout(stderr.strip) if !status.success? + + stdout.strip + end + end + end + end + end +end diff --git a/lib/supervisor/app/stacks/concerns/manifest.rb b/lib/supervisor/app/stacks/concerns/manifest.rb deleted file mode 100644 index 6757d14..0000000 --- a/lib/supervisor/app/stacks/concerns/manifest.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Supervisor - module App - module Stacks - module Concerns - module Manifest - extend ActiveSupport::Concern - - included do - private - - def load_manifest_file(file) - yaml = decrypt_manifest_values(file) if decrypt? - - begin - yaml.present? ? YAML.load(yaml) : YAML.safe_load_file(file) - rescue StandardError => e - bailout(e.message) - end - end - - def decrypt_manifest_values(_file) - begin - yaml_encrypted = File.read(file) - rescue StandardError => e - bailout(e.message) - end - - cmd = 'sops decrypt --input-type yaml --output-type yaml --output /dev/stdout /dev/stdin' - stdout, stderr, status = Open3.capture3(*cmd.split, stdin_data: yaml_encrypted) - bailout(stderr.strip) if !status.success? - - stdout.strip - end - end - end - end - end - end -end diff --git a/lib/supervisor/app/stacks/create.rb b/lib/supervisor/app/stacks/create.rb index 64e1fac..130917b 100644 --- a/lib/supervisor/app/stacks/create.rb +++ b/lib/supervisor/app/stacks/create.rb @@ -4,7 +4,7 @@ module Supervisor module App module Stacks class Create < Supervisor::App::Base - include Supervisor::App::Stacks::Concerns::Manifest + include Supervisor::App::Stacks::HandlesManifest option ['--manifest-file'], 'FILE', 'manifest file', required: true, attribute_name: :file option ['--decrypt'], :flag, 'decrypt manifest values using sops' diff --git a/lib/supervisor/app/stacks/update.rb b/lib/supervisor/app/stacks/update.rb index 1838d8c..53fa436 100644 --- a/lib/supervisor/app/stacks/update.rb +++ b/lib/supervisor/app/stacks/update.rb @@ -4,7 +4,7 @@ module Supervisor module App module Stacks class Update < Supervisor::App::Base - include Supervisor::App::Stacks::Concerns::Manifest + include Supervisor::App::Stacks::HandlesManifest parameter 'STACK_UUID', 'the UUID of the stack to update' diff --git a/supervisor.gemspec b/supervisor.gemspec index 73ac746..b28b56e 100644 --- a/supervisor.gemspec +++ b/supervisor.gemspec @@ -30,9 +30,11 @@ Gem::Specification.new do |spec| spec.add_dependency 'activesupport', '~> 8.0.0' spec.add_dependency 'clamp', '~> 1.3.2' + spec.add_dependency 'erb', '~> 4.0.3' spec.add_dependency 'hashie', '~> 5.0' spec.add_dependency 'httparty', '~> 0.22' spec.add_dependency 'pastel', '~> 0.8.0' + spec.add_dependency 'sshkit', '~> 1.23.2' spec.add_dependency 'tty-pager', '~> 0.14.0' spec.add_dependency 'tty-screen', '~> 0.8.2' spec.add_dependency 'tty-table', '~> 0.12.0'