Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions lib/kamal/secrets/adapters/sops.rb
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions test/secrets/sops_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -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