diff --git a/lib/kamal/cli/accessory.rb b/lib/kamal/cli/accessory.rb index 6c51c774..4de8832b 100644 --- a/lib/kamal/cli/accessory.rb +++ b/lib/kamal/cli/accessory.rb @@ -18,6 +18,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base execute *accessory.ensure_env_directory upload! accessory.secrets_io, accessory.secrets_path, mode: "0600" execute *accessory.run + + if accessory.running_proxy? + target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip + execute *accessory.deploy(target: target) + end end end end @@ -75,6 +80,10 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base on(hosts) do execute *KAMAL.auditor.record("Started #{name} accessory"), verbosity: :debug execute *accessory.start + if accessory.running_proxy? + target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip + execute *accessory.deploy(target: target) + end end end end @@ -87,6 +96,11 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base on(hosts) do execute *KAMAL.auditor.record("Stopped #{name} accessory"), verbosity: :debug execute *accessory.stop, raise_on_non_zero_exit: false + + if accessory.running_proxy? + target = capture_with_info(*accessory.container_id_for(container_name: accessory.service_name, only_running: true)).strip + execute *accessory.remove if target + end end end end @@ -112,14 +126,15 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base end end - desc "exec [NAME] [CMD]", "Execute a custom command on servers (use --help to show options)" + desc "exec [NAME] [CMD...]", "Execute a custom command on servers within the accessory container (use --help to show options)" option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)" option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one" - def exec(name, cmd) + def exec(name, *cmd) + cmd = Kamal::Utils.join_commands(cmd) with_accessory(name) do |accessory, hosts| case when options[:interactive] && options[:reuse] - say "Launching interactive command with via SSH from existing container...", :magenta + say "Launching interactive command via SSH from existing container...", :magenta run_locally { exec accessory.execute_in_existing_container_over_ssh(cmd) } when options[:interactive] @@ -128,16 +143,16 @@ class Kamal::Cli::Accessory < Kamal::Cli::Base when options[:reuse] say "Launching command from existing container...", :magenta - on(hosts) do + on(hosts) do |host| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug - capture_with_info(*accessory.execute_in_existing_container(cmd)) + puts_by_host host, capture_with_info(*accessory.execute_in_existing_container(cmd)) end else say "Launching command from new container...", :magenta - on(hosts) do + on(hosts) do |host| execute *KAMAL.auditor.record("Executed cmd '#{cmd}' on #{name} accessory"), verbosity: :debug - capture_with_info(*accessory.execute_in_new_container(cmd)) + puts_by_host host, capture_with_info(*accessory.execute_in_new_container(cmd)) end end end diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index b094be46..0cfcf628 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -1,11 +1,17 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base desc "fetch [SECRETS...]", "Fetch secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" - option :account, type: :string, required: true, desc: "The account identifier or username" + option :account, type: :string, required: false, desc: "The account identifier or username" option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" option :inline, type: :boolean, required: false, hidden: true def fetch(*secrets) - results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) + adapter = initialize_adapter(options[:adapter]) + + if adapter.requires_account? && options[:account].blank? + return puts "No value provided for required options '--account'" + end + + results = adapter.fetch(secrets, **options.slice(:account, :from).symbolize_keys) return_or_puts JSON.dump(results).shellescape, inline: options[:inline] end @@ -29,7 +35,7 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base end private - def adapter(adapter) + def initialize_adapter(adapter) Kamal::Secrets::Adapters.lookup(adapter) end diff --git a/lib/kamal/cli/templates/deploy.yml b/lib/kamal/cli/templates/deploy.yml index fc44b6b9..89104ad3 100644 --- a/lib/kamal/cli/templates/deploy.yml +++ b/lib/kamal/cli/templates/deploy.yml @@ -16,8 +16,8 @@ servers: # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. # -# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. -proxy: +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: ssl: true host: app.example.com # Proxy connects to your container on port 80 by default. @@ -36,6 +36,9 @@ registry: # Configure builder setup. builder: arch: amd64 + # Pass in additional build args needed for your Dockerfile. + # args: + # RUBY_VERSION: <%= File.read('.ruby-version').strip %> # Inject ENV variables into containers (secrets come from .kamal/secrets). # diff --git a/lib/kamal/commands/accessory.rb b/lib/kamal/commands/accessory.rb index 9abb6dfc..281b8713 100644 --- a/lib/kamal/commands/accessory.rb +++ b/lib/kamal/commands/accessory.rb @@ -1,9 +1,13 @@ class Kamal::Commands::Accessory < Kamal::Commands::Base + include Proxy + attr_reader :accessory_config delegate :service_name, :image, :hosts, :port, :files, :directories, :cmd, :network_args, :publish_args, :env_args, :volume_args, :label_args, :option_args, - :secrets_io, :secrets_path, :env_directory, + :secrets_io, :secrets_path, :env_directory, :proxy, :running_proxy?, to: :accessory_config + delegate :proxy_container_name, to: :config + def initialize(config, name:) super(config) diff --git a/lib/kamal/commands/accessory/proxy.rb b/lib/kamal/commands/accessory/proxy.rb new file mode 100644 index 00000000..195a321b --- /dev/null +++ b/lib/kamal/commands/accessory/proxy.rb @@ -0,0 +1,16 @@ +module Kamal::Commands::Accessory::Proxy + delegate :proxy_container_name, to: :config + + def deploy(target:) + proxy_exec :deploy, service_name, *proxy.deploy_command_args(target: target) + end + + def remove + proxy_exec :remove, service_name + end + + private + def proxy_exec(*command) + docker :exec, proxy_container_name, "kamal-proxy", *command + end +end diff --git a/lib/kamal/commands/builder/base.rb b/lib/kamal/commands/builder/base.rb index d551520b..dea04a3a 100644 --- a/lib/kamal/commands/builder/base.rb +++ b/lib/kamal/commands/builder/base.rb @@ -6,7 +6,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base delegate :argumentize, to: Kamal::Utils delegate \ :args, :secrets, :dockerfile, :target, :arches, :local_arches, :remote_arches, :remote, - :cache_from, :cache_to, :ssh, :provenance, :driver, :docker_driver?, + :cache_from, :cache_to, :ssh, :provenance, :sbom, :driver, :docker_driver?, to: :builder_config def clean @@ -37,7 +37,7 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base end def build_options - [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance ] + [ *build_tags, *build_cache, *build_labels, *build_args, *build_secrets, *build_dockerfile, *build_target, *build_ssh, *builder_provenance, *builder_sbom ] end def build_context @@ -101,6 +101,10 @@ class Kamal::Commands::Builder::Base < Kamal::Commands::Base argumentize "--provenance", provenance unless provenance.nil? end + def builder_sbom + argumentize "--sbom", sbom unless sbom.nil? + end + def builder_config config.builder end diff --git a/lib/kamal/configuration/accessory.rb b/lib/kamal/configuration/accessory.rb index 3258c9d0..2728607d 100644 --- a/lib/kamal/configuration/accessory.rb +++ b/lib/kamal/configuration/accessory.rb @@ -5,7 +5,7 @@ class Kamal::Configuration::Accessory delegate :argumentize, :optionize, to: Kamal::Utils - attr_reader :name, :accessory_config, :env + attr_reader :name, :accessory_config, :env, :proxy def initialize(name, config:) @name, @config, @accessory_config = name.inquiry, config, config.raw_config["accessories"][name] @@ -20,6 +20,8 @@ class Kamal::Configuration::Accessory config: accessory_config.fetch("env", {}), secrets: config.secrets, context: "accessories/#{name}/env" + + initialize_proxy if running_proxy? end def service_name @@ -106,6 +108,17 @@ class Kamal::Configuration::Accessory accessory_config["cmd"] end + def running_proxy? + @accessory_config["proxy"].present? + end + + def initialize_proxy + @proxy = Kamal::Configuration::Proxy.new \ + config: config, + proxy_config: accessory_config["proxy"], + context: "accessories/#{name}/proxy" + end + private attr_accessor :config @@ -176,7 +189,9 @@ class Kamal::Configuration::Accessory def hosts_from_roles if accessory_config.key?("roles") - accessory_config["roles"].flat_map { |role| config.role(role).hosts } + accessory_config["roles"].flat_map do |role| + config.role(role)&.hosts || raise(Kamal::ConfigurationError, "Unknown role in accessories config: '#{role}'") + end end end diff --git a/lib/kamal/configuration/builder.rb b/lib/kamal/configuration/builder.rb index 4c0dc603..970c47d1 100644 --- a/lib/kamal/configuration/builder.rb +++ b/lib/kamal/configuration/builder.rb @@ -115,6 +115,10 @@ class Kamal::Configuration::Builder builder_config["provenance"] end + def sbom + builder_config["sbom"] + end + def git_clone? Kamal::Git.used? && builder_config["context"].nil? end diff --git a/lib/kamal/configuration/docs/accessory.yml b/lib/kamal/configuration/docs/accessory.yml index e77bf754..fab2989f 100644 --- a/lib/kamal/configuration/docs/accessory.yml +++ b/lib/kamal/configuration/docs/accessory.yml @@ -98,3 +98,7 @@ accessories: # Defaults to kamal: network: custom + # Proxy + # + proxy: + ... \ No newline at end of file diff --git a/lib/kamal/configuration/docs/builder.yml b/lib/kamal/configuration/docs/builder.yml index b6e639f3..230b39ee 100644 --- a/lib/kamal/configuration/docs/builder.yml +++ b/lib/kamal/configuration/docs/builder.yml @@ -108,3 +108,9 @@ builder: # It is used to configure provenance attestations for the build result. # The value can also be a boolean to enable or disable provenance attestations. provenance: mode=max + + # SBOM (Software Bill of Materials) + # + # It is used to configure SBOM generation for the build result. + # The value can also be a boolean to enable or disable SBOM generation. + sbom: true diff --git a/lib/kamal/secrets/adapters/aws_secrets_manager.rb b/lib/kamal/secrets/adapters/aws_secrets_manager.rb new file mode 100644 index 00000000..e23ea1f1 --- /dev/null +++ b/lib/kamal/secrets/adapters/aws_secrets_manager.rb @@ -0,0 +1,34 @@ +class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base + private + def login(_account) + nil + end + + def fetch_secrets(secrets, account:, session:) + {}.tap do |results| + JSON.parse(get_from_secrets_manager(secrets, account: account))["SecretValues"].each do |secret| + secret_name = secret["Name"] + secret_string = JSON.parse(secret["SecretString"]) + + secret_string.each do |key, value| + results["#{secret_name}/#{key}"] = value + end + end + end + end + + def get_from_secrets_manager(secrets, account:) + `aws secretsmanager batch-get-secret-value --secret-id-list #{secrets.map(&:shellescape).join(" ")} --profile #{account.shellescape}`.tap do + raise RuntimeError, "Could not read #{secret} from AWS Secrets Manager" unless $?.success? + end + end + + def check_dependencies! + raise RuntimeError, "AWS CLI is not installed" unless cli_installed? + end + + def cli_installed? + `aws --version 2> /dev/null` + $?.success? + end +end diff --git a/lib/kamal/secrets/adapters/base.rb b/lib/kamal/secrets/adapters/base.rb index 579414af..fc66bb34 100644 --- a/lib/kamal/secrets/adapters/base.rb +++ b/lib/kamal/secrets/adapters/base.rb @@ -1,13 +1,20 @@ class Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils - def fetch(secrets, account:, from: nil) + def fetch(secrets, account: nil, from: nil) + raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank? + check_dependencies! + session = login(account) full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } fetch_secrets(full_secrets, account: account, session: session) end + def requires_account? + true + end + private def login(...) raise NotImplementedError diff --git a/lib/kamal/secrets/adapters/doppler.rb b/lib/kamal/secrets/adapters/doppler.rb new file mode 100644 index 00000000..64d644f7 --- /dev/null +++ b/lib/kamal/secrets/adapters/doppler.rb @@ -0,0 +1,53 @@ +class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base + def requires_account? + false + end + + private + def login(*) + unless loggedin? + `doppler login -y` + raise RuntimeError, "Failed to login to Doppler" unless $?.success? + end + end + + def loggedin? + `doppler me --json 2> /dev/null` + $?.success? + end + + def fetch_secrets(secrets, **) + project_and_config_flags = "" + unless service_token_set? + project, config, _ = secrets.first.split("/") + + unless project && config + raise RuntimeError, "Missing project or config from '--from=project/config' option" + end + + project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}" + end + + secret_names = secrets.collect { |s| s.split("/").last } + + items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{project_and_config_flags}` + raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success? + + items = JSON.parse(items) + + items.transform_values { |value| value["computed"] } + end + + def service_token_set? + ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st" + end + + def check_dependencies! + raise RuntimeError, "Doppler CLI is not installed" unless cli_installed? + end + + def cli_installed? + `doppler --version 2> /dev/null` + $?.success? + end +end diff --git a/lib/kamal/secrets/adapters/test_optional_account.rb b/lib/kamal/secrets/adapters/test_optional_account.rb new file mode 100644 index 00000000..3a252e68 --- /dev/null +++ b/lib/kamal/secrets/adapters/test_optional_account.rb @@ -0,0 +1,5 @@ +class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test + def requires_account? + false + end +end diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb index 6014a7e7..bd412862 100644 --- a/test/cli/secrets_test.rb +++ b/test/cli/secrets_test.rb @@ -7,6 +7,18 @@ class CliSecretsTest < CliTestCase run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test") end + test "fetch missing --acount" do + assert_equal \ + "No value provided for required options '--account'", + run_command("fetch", "foo", "bar", "baz", "--adapter", "test") + end + + test "fetch without required --account" do + assert_equal \ + "\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", + run_command("fetch", "foo", "bar", "baz", "--adapter", "test_optional_account") + end + test "extract" do assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") end diff --git a/test/commands/accessory_test.rb b/test/commands/accessory_test.rb index 1befd9e6..b9bcca7e 100644 --- a/test/commands/accessory_test.rb +++ b/test/commands/accessory_test.rb @@ -39,7 +39,10 @@ class CommandsAccessoryTest < ActiveSupport::TestCase "busybox" => { "service" => "custom-busybox", "image" => "busybox:latest", - "host" => "1.1.1.7" + "host" => "1.1.1.7", + "proxy" => { + "host" => "busybox.example.com" + } } } } @@ -166,6 +169,18 @@ class CommandsAccessoryTest < ActiveSupport::TestCase new_command(:mysql).remove_image.join(" ") end + test "deploy" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy deploy custom-busybox --target=\"172.1.0.2:80\" --host=\"busybox.example.com\" --deploy-timeout=\"30s\" --drain-timeout=\"30s\" --buffer-requests --buffer-responses --log-request-header=\"Cache-Control\" --log-request-header=\"Last-Modified\" --log-request-header=\"User-Agent\"", + new_command(:busybox).deploy(target: "172.1.0.2").join(" ") + end + + test "remove" do + assert_equal \ + "docker exec kamal-proxy kamal-proxy remove custom-busybox", + new_command(:busybox).remove.join(" ") + end + private def new_command(accessory) Kamal::Commands::Accessory.new(Kamal::Configuration.new(@config), name: accessory) diff --git a/test/commands/builder_test.rb b/test/commands/builder_test.rb index 86b2f573..85703f54 100644 --- a/test/commands/builder_test.rb +++ b/test/commands/builder_test.rb @@ -158,6 +158,20 @@ class CommandsBuilderTest < ActiveSupport::TestCase builder.push.join(" ") end + test "push with sbom" do + builder = new_builder_command(builder: { "sbom" => true }) + assert_equal \ + "docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom true .", + builder.push.join(" ") + end + + test "push with sbom false" do + builder = new_builder_command(builder: { "sbom" => false }) + assert_equal \ + "docker buildx build --push --platform linux/amd64 --builder kamal-local-docker-container -t dhh/app:123 -t dhh/app:latest --label service=\"app\" --file Dockerfile --sbom false .", + builder.push.join(" ") + end + test "mirror count" do command = new_builder_command assert_equal "docker info --format '{{index .RegistryConfig.Mirrors 0}}'", command.first_mirror.join(" ") diff --git a/test/configuration/accessory_test.rb b/test/configuration/accessory_test.rb index f5220902..d15a48ad 100644 --- a/test/configuration/accessory_test.rb +++ b/test/configuration/accessory_test.rb @@ -63,6 +63,9 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase "options" => { "cpus" => "4", "memory" => "2GB" + }, + "proxy" => { + "host" => "monitoring.example.com" } } } @@ -161,4 +164,9 @@ class ConfigurationAccessoryTest < ActiveSupport::TestCase @deploy[:accessories]["mysql"]["network"] = "database" assert_equal [ "--network", "database" ], @config.accessory(:mysql).network_args end + + test "proxy" do + assert @config.accessory(:monitoring).running_proxy? + assert_equal [ "monitoring.example.com" ], @config.accessory(:monitoring).proxy.hosts + end end diff --git a/test/configuration/builder_test.rb b/test/configuration/builder_test.rb index 5fef465a..12387800 100644 --- a/test/configuration/builder_test.rb +++ b/test/configuration/builder_test.rb @@ -144,6 +144,16 @@ class ConfigurationBuilderTest < ActiveSupport::TestCase assert_equal "mode=max", config.builder.provenance end + test "sbom" do + assert_nil config.builder.sbom + end + + test "setting sbom" do + @deploy[:builder]["sbom"] = true + + assert_equal true, config.builder.sbom + end + test "local disabled but no remote set" do @deploy[:builder]["local"] = false diff --git a/test/integration/docker/deployer/Dockerfile b/test/integration/docker/deployer/Dockerfile index c7132861..c25747a2 100644 --- a/test/integration/docker/deployer/Dockerfile +++ b/test/integration/docker/deployer/Dockerfile @@ -20,6 +20,7 @@ COPY *.sh . COPY app/ app/ COPY app_with_roles/ app_with_roles/ COPY app_with_traefik/ app_with_traefik/ +COPY app_with_proxied_accessory/ app_with_proxied_accessory/ RUN rm -rf /root/.ssh RUN ln -s /shared/ssh /root/.ssh @@ -30,6 +31,7 @@ RUN git config --global user.name "Deployer" RUN cd app && git init && git add . && git commit -am "Initial version" RUN cd app_with_roles && git init && git add . && git commit -am "Initial version" RUN cd app_with_traefik && git init && git add . && git commit -am "Initial version" +RUN cd app_with_proxied_accessory && git init && git add . && git commit -am "Initial version" HEALTHCHECK --interval=1s CMD pgrep sleep diff --git a/test/integration/docker/deployer/app_with_proxied_accessory/Dockerfile b/test/integration/docker/deployer/app_with_proxied_accessory/Dockerfile new file mode 100644 index 00000000..0e6237df --- /dev/null +++ b/test/integration/docker/deployer/app_with_proxied_accessory/Dockerfile @@ -0,0 +1,9 @@ +FROM registry:4443/nginx:1-alpine-slim + +COPY default.conf /etc/nginx/conf.d/default.conf + +ARG COMMIT_SHA +RUN echo $COMMIT_SHA > /usr/share/nginx/html/version +RUN mkdir -p /usr/share/nginx/html/versions && echo "version" > /usr/share/nginx/html/versions/$COMMIT_SHA +RUN mkdir -p /usr/share/nginx/html/versions && echo "hidden" > /usr/share/nginx/html/versions/.hidden +RUN echo "Up!" > /usr/share/nginx/html/up diff --git a/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml b/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml new file mode 100644 index 00000000..bdb547ae --- /dev/null +++ b/test/integration/docker/deployer/app_with_proxied_accessory/config/deploy.yml @@ -0,0 +1,44 @@ +service: app_with_proxied_accessory +image: app_with_proxied_accessory +servers: + - vm1 +env: + clear: + CLEAR_TOKEN: 4321 + CLEAR_TAG: "" + HOST_TOKEN: "${HOST_TOKEN}" +asset_path: /usr/share/nginx/html/versions +proxy: + host: 127.0.0.1 +registry: + server: registry:4443 + username: root + password: root +builder: + driver: docker + arch: <%= Kamal::Utils.docker_arch %> + args: + COMMIT_SHA: <%= `git rev-parse HEAD` %> +accessories: + busybox: + service: custom-busybox + image: registry:4443/busybox:1.36.0 + cmd: sh -c 'echo "Starting busybox..."; trap exit term; while true; do sleep 1; done' + roles: + - web + netcat: + service: netcat + image: registry:4443/busybox:1.36.0 + cmd: > + sh -c 'echo "Starting netcat..."; while true; do echo -e "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nHello Ruby" | nc -l -p 80; done' + roles: + - web + port: 12345:80 + proxy: + host: netcat + ssl: false + healthcheck: + interval: 1 + timeout: 1 + path: "/" + diff --git a/test/integration/docker/deployer/app_with_proxied_accessory/default.conf b/test/integration/docker/deployer/app_with_proxied_accessory/default.conf new file mode 100644 index 00000000..e37a9bc1 --- /dev/null +++ b/test/integration/docker/deployer/app_with_proxied_accessory/default.conf @@ -0,0 +1,17 @@ +server { + listen 80; + listen [::]:80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + } + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/test/integration/proxied_accessory_test.rb b/test/integration/proxied_accessory_test.rb new file mode 100644 index 00000000..10f3cff8 --- /dev/null +++ b/test/integration/proxied_accessory_test.rb @@ -0,0 +1,63 @@ +require_relative "integration_test" + +class ProxiedAccessoryTest < IntegrationTest + test "boot, stop, start, restart, logs, remove" do + @app = "app_with_proxied_accessory" + + kamal :deploy + + kamal :accessory, :boot, :netcat + assert_accessory_running :netcat + assert_netcat_is_up + + kamal :accessory, :stop, :netcat + assert_accessory_not_running :netcat + assert_netcat_not_found + + kamal :accessory, :start, :netcat + assert_accessory_running :netcat + assert_netcat_is_up + + kamal :accessory, :restart, :netcat + assert_accessory_running :netcat + assert_netcat_is_up + + kamal :accessory, :remove, :netcat, "-y" + assert_accessory_not_running :netcat + assert_netcat_not_found + end + + private + def assert_accessory_running(name) + assert_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) + end + + def assert_accessory_not_running(name) + assert_no_match /registry:4443\/busybox:1.36.0 "sh -c 'echo \\"Start/, accessory_details(name) + end + + def accessory_details(name) + kamal :accessory, :details, name, capture: true + end + + def assert_netcat_is_up + response = netcat_response + debug_response_code(response, "200") + assert_equal "200", response.code + end + + def assert_netcat_not_found + response = netcat_response + debug_response_code(response, "404") + assert_equal "404", response.code + end + + def netcat_response + uri = URI.parse("http://127.0.0.1:12345/up") + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Get.new(uri) + request["Host"] = "netcat" + + http.request(request) + end +end diff --git a/test/secrets/aws_secrets_manager_adapter_test.rb b/test/secrets/aws_secrets_manager_adapter_test.rb new file mode 100644 index 00000000..42a0f48a --- /dev/null +++ b/test/secrets/aws_secrets_manager_adapter_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +class AwsSecretsManagerAdapterTest < SecretAdapterTestCase + test "fetch" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 secret2/KEY3 --profile default") + .returns(<<~JSON) + { + "SecretValues": [ + { + "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret", + "Name": "secret", + "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", + "SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}", + "VersionStages": [ + "AWSCURRENT" + ], + "CreatedDate": "2024-01-01T00:00:00.000000" + }, + { + "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret2", + "Name": "secret2", + "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", + "SecretString": "{\\"KEY3\\":\\"VALUE3\\"}", + "VersionStages": [ + "AWSCURRENT" + ], + "CreatedDate": "2024-01-01T00:00:00.000000" + } + ], + "Errors": [] + } + JSON + + json = JSON.parse(shellunescape(run_command("fetch", "secret/KEY1", "secret/KEY2", "secret2/KEY3"))) + + expected_json = { + "secret/KEY1"=>"VALUE1", + "secret/KEY2"=>"VALUE2", + "secret2/KEY3"=>"VALUE3" + } + + assert_equal expected_json, json + end + + test "fetch with secret names" do + stub_ticks.with("aws --version 2> /dev/null") + stub_ticks + .with("aws secretsmanager batch-get-secret-value --secret-id-list secret/KEY1 secret/KEY2 --profile default") + .returns(<<~JSON) + { + "SecretValues": [ + { + "ARN": "arn:aws:secretsmanager:us-east-1:aaaaaaaaaaaa:secret:secret", + "Name": "secret", + "VersionId": "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", + "SecretString": "{\\"KEY1\\":\\"VALUE1\\", \\"KEY2\\":\\"VALUE2\\"}", + "VersionStages": [ + "AWSCURRENT" + ], + "CreatedDate": "2024-01-01T00:00:00.000000" + } + ], + "Errors": [] + } + JSON + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "secret", "KEY1", "KEY2"))) + + expected_json = { + "secret/KEY1"=>"VALUE1", + "secret/KEY2"=>"VALUE2" + } + + assert_equal expected_json, json + end + + test "fetch without CLI installed" do + stub_ticks_with("aws --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "SECRET1"))) + end + assert_equal "AWS CLI is not installed", error.message + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "aws_secrets_manager", + "--account", "default" ] + end + end +end diff --git a/test/secrets/doppler_adapter_test.rb b/test/secrets/doppler_adapter_test.rb new file mode 100644 index 00000000..c2b16468 --- /dev/null +++ b/test/secrets/doppler_adapter_test.rb @@ -0,0 +1,186 @@ +require "test_helper" + +class DopplerAdapterTest < SecretAdapterTestCase + setup do + `true` # Ensure $? is 0 + end + + test "fetch" do + stub_ticks_with("doppler --version 2> /dev/null", succeed: true) + stub_ticks.with("doppler me --json 2> /dev/null") + + stub_ticks + .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd") + .returns(<<~JSON) + { + "SECRET1": { + "computed":"secret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET1": { + "computed":"fsecret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET2": { + "computed":"fsecret2", + "computedVisibility":"unmasked", + "note":"" + } + } + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "--from", "my-project/prd", "SECRET1", "FSECRET1", "FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch having DOPPLER_TOKEN" do + ENV["DOPPLER_TOKEN"] = "dp.st.xxxxxxxxxxxxxxxxxxxxxx" + + stub_ticks_with("doppler --version 2> /dev/null", succeed: true) + stub_ticks.with("doppler me --json 2> /dev/null") + + stub_ticks + .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json ") + .returns(<<~JSON) + { + "SECRET1": { + "computed":"secret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET1": { + "computed":"fsecret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET2": { + "computed":"fsecret2", + "computedVisibility":"unmasked", + "note":"" + } + } + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "SECRET1", "FSECRET1", "FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + + ENV.delete("DOPPLER_TOKEN") + end + + test "fetch with folder in secret" do + stub_ticks_with("doppler --version 2> /dev/null", succeed: true) + stub_ticks.with("doppler me --json 2> /dev/null") + + stub_ticks + .with("doppler secrets get SECRET1 FSECRET1 FSECRET2 --json -p my-project -c prd") + .returns(<<~JSON) + { + "SECRET1": { + "computed":"secret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET1": { + "computed":"fsecret1", + "computedVisibility":"unmasked", + "note":"" + }, + "FSECRET2": { + "computed":"fsecret2", + "computedVisibility":"unmasked", + "note":"" + } + } + JSON + + json = JSON.parse( + shellunescape run_command("fetch", "my-project/prd/SECRET1", "my-project/prd/FSECRET1", "my-project/prd/FSECRET2") + ) + + expected_json = { + "SECRET1"=>"secret1", + "FSECRET1"=>"fsecret1", + "FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch without --from" do + stub_ticks_with("doppler --version 2> /dev/null", succeed: true) + stub_ticks.with("doppler me --json 2> /dev/null") + + error = assert_raises RuntimeError do + run_command("fetch", "FSECRET1", "FSECRET2") + end + + assert_equal "Missing project or config from '--from=project/config' option", error.message + end + + test "fetch with signin" do + stub_ticks_with("doppler --version 2> /dev/null", succeed: true) + stub_ticks_with("doppler me --json 2> /dev/null", succeed: false) + stub_ticks_with("doppler login -y", succeed: true).returns("") + stub_ticks.with("doppler secrets get SECRET1 --json -p my-project -c prd").returns(single_item_json) + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "my-project/prd", "SECRET1"))) + + expected_json = { + "SECRET1"=>"secret1" + } + + assert_equal expected_json, json + end + + test "fetch without CLI installed" do + stub_ticks_with("doppler --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + JSON.parse(shellunescape(run_command("fetch", "HOST", "PORT"))) + end + + assert_equal "Doppler CLI is not installed", error.message + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "doppler" ] + end + end + + def single_item_json + <<~JSON + { + "SECRET1": { + "computed":"secret1", + "computedVisibility":"unmasked", + "note":"" + } + } + JSON + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1749ee32..f5811872 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,7 @@ require "bundler/setup" require "active_support/test_case" require "active_support/testing/autorun" require "active_support/testing/stream" +require "rails/test_unit/line_filtering" require "debug" require "mocha/minitest" # using #stubs that can alter returns require "minitest/autorun" # using #stub that take args @@ -32,6 +33,7 @@ end class ActiveSupport::TestCase include ActiveSupport::Testing::Stream + extend Rails::LineFiltering private def stdouted