diff --git a/lib/kamal/secrets/adapters/sops.rb b/lib/kamal/secrets/adapters/sops.rb new file mode 100644 index 000000000..48fdc30bf --- /dev/null +++ b/lib/kamal/secrets/adapters/sops.rb @@ -0,0 +1,96 @@ +# Fetches secrets from a sops (https://github.com/getsops/sops) encrypted file. +# +# The `--from` option names the encrypted file to decrypt; the positional secrets are keys +# within it. sops resolves the decryption key itself (age/KMS/PGP/etc.) from its own config +# and environment, so no `--account` is required. The file is decrypted once per call. +# +# Nested structures are flattened into `parent/child` paths, and non-string values are +# coerced to strings. When no keys are given, every (flattened) key in the file is returned. +# +# Given secrets.enc.yaml: +# +# database: +# password: pw # => database/password +# host: db.example # => database/host +# api_key: xyz # => api_key +# +# Examples: +# +# # Fetch specific keys +# kamal secrets fetch --adapter sops --from secrets.enc.yaml database/password api_key +# +# # Fetch a whole subtree by its parent key (returns database/password and database/host) +# kamal secrets fetch --adapter sops --from secrets.enc.yaml database +# +# # Fetch every key in the file +# kamal secrets fetch --adapter sops --from secrets.enc.yaml +class Kamal::Secrets::Adapters::Sops < Kamal::Secrets::Adapters::Base + def requires_account? + false + end + + private + def login(_account) + nil + end + + def fetch_secrets(secrets, from:, account: nil, session:) + raise RuntimeError, "Missing required option '--from'" if from.blank? + + all_secrets = flatten_secrets(decrypt(from)) + + if secrets.blank? + all_secrets + else + select_secrets(all_secrets, secrets, from: from) + end + end + + def decrypt(from) + contents = `sops --decrypt --output-type json -- #{from.shellescape}` + raise RuntimeError, "Could not decrypt #{from} with sops" unless $?.success? + + parsed = JSON.parse(contents) + raise RuntimeError, "Expected #{from} to decrypt to a JSON object" unless parsed.is_a?(Hash) + + parsed + end + + def select_secrets(all_secrets, secrets, from:) + {}.tap do |results| + secrets.each do |secret| + matched = all_secrets.select { |path, _| path == secret || path.start_with?("#{secret}/") } + raise RuntimeError, "Could not find secret #{secret} in #{from}" if matched.empty? + + results.merge!(matched) + end + end + end + + def flatten_secrets(hash, prefix = nil) + {}.tap do |results| + hash.each do |key, value| + path = [ prefix, key ].compact.join("/") + + if value.is_a?(Hash) + results.merge!(flatten_secrets(value, path)) + else + results[path] = stringify_secret_value(value) + end + end + end + end + + def stringify_secret_value(value) + value.is_a?(String) ? value : JSON.dump(value) + end + + def check_dependencies! + raise RuntimeError, "sops is not installed" unless cli_installed? + end + + def cli_installed? + `sops --version 2> /dev/null` + $?.success? + end +end diff --git a/test/secrets/sops_adapter_test.rb b/test/secrets/sops_adapter_test.rb new file mode 100644 index 000000000..f1b1ac93a --- /dev/null +++ b/test/secrets/sops_adapter_test.rb @@ -0,0 +1,171 @@ +require "test_helper" + +class SopsAdapterTest < SecretAdapterTestCase + test "fetch top-level keys" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.json") + .returns(<<~JSON) + { + "DB_PASSWORD": "secret123", + "API_KEY": "key456" + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secrets.enc.json", "DB_PASSWORD", "API_KEY")) + + assert_equal({ "DB_PASSWORD" => "secret123", "API_KEY" => "key456" }, json) + end + + test "fetch nested keys flattened" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.yaml") + .returns(<<~JSON) + { + "database": { + "password": "pw", + "host": "db.example" + }, + "api_key": "xyz" + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secrets.enc.yaml", "database/password", "api_key")) + + assert_equal({ "database/password" => "pw", "api_key" => "xyz" }, json) + end + + test "fetch all when no keys given" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.yaml") + .returns(<<~JSON) + { + "database": { + "password": "pw", + "host": "db.example" + }, + "api_key": "xyz" + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secrets.enc.yaml")) + + assert_equal({ + "database/password" => "pw", + "database/host" => "db.example", + "api_key" => "xyz" + }, json) + end + + test "fetch coerces non-string values to strings" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.json") + .returns(<<~JSON) + { + "port": 5432, + "ssl": true, + "weight": 1.5, + "missing": null, + "tags": [ "prod", "db" ] + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secrets.enc.json")) + + assert_equal({ + "port" => "5432", + "ssl" => "true", + "weight" => "1.5", + "missing" => "null", + "tags" => '["prod","db"]' + }, json) + end + + test "fetch a nested subtree by parent key" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.yaml") + .returns(<<~JSON) + { + "database": { + "password": "pw", + "host": "db.example" + }, + "api_key": "xyz" + } + JSON + + json = JSON.parse(run_command("fetch", "--from", "secrets.enc.yaml", "database")) + + assert_equal({ "database/password" => "pw", "database/host" => "db.example" }, json) + end + + test "fetch without --from" do + stub_ticks.with("sops --version 2> /dev/null") + + error = assert_raises RuntimeError do + run_command("fetch", "DB_PASSWORD") + end + assert_equal "Missing required option '--from'", error.message + end + + test "fetch unknown key" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.json") + .returns(<<~JSON) + { "DB_PASSWORD": "secret123" } + JSON + + error = assert_raises RuntimeError do + run_command("fetch", "--from", "secrets.enc.json", "NOPE") + end + assert_equal "Could not find secret NOPE in secrets.enc.json", error.message + end + + test "fetch when the file does not decrypt to an object" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks + .with("sops --decrypt --output-type json -- secrets.enc.json") + .returns(<<~JSON) + [ "not", "an", "object" ] + JSON + + error = assert_raises RuntimeError do + run_command("fetch", "--from", "secrets.enc.json", "DB_PASSWORD") + end + assert_equal "Expected secrets.enc.json to decrypt to a JSON object", error.message + end + + test "fetch with decryption failure" do + stub_ticks.with("sops --version 2> /dev/null") + stub_ticks_with("sops --decrypt --output-type json -- secrets.enc.json", succeed: false) + + error = assert_raises RuntimeError do + run_command("fetch", "--from", "secrets.enc.json", "DB_PASSWORD") + end + assert_equal "Could not decrypt secrets.enc.json with sops", error.message + end + + test "fetch without CLI installed" do + stub_ticks_with("sops --version 2> /dev/null", succeed: false) + + error = assert_raises RuntimeError do + run_command("fetch", "--from", "secrets.enc.json", "DB_PASSWORD") + end + assert_equal "sops 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", "sops" ] + end + end +end