Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(secrets): add Doppler adapter #1099

Merged
merged 5 commits into from
Nov 21, 2024
Merged
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
12 changes: 9 additions & 3 deletions lib/kamal/cli/secrets.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,7 +35,7 @@ def print
end

private
def adapter(adapter)
def initialize_adapter(adapter)
Kamal::Secrets::Adapters.lookup(adapter)
end

Expand Down
9 changes: 8 additions & 1 deletion lib/kamal/secrets/adapters/base.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 53 additions & 0 deletions lib/kamal/secrets/adapters/doppler.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions lib/kamal/secrets/adapters/test_optional_account.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Kamal::Secrets::Adapters::TestOptionalAccount < Kamal::Secrets::Adapters::Test
def requires_account?
false
end
end
12 changes: 12 additions & 0 deletions test/cli/secrets_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
186 changes: 186 additions & 0 deletions test/secrets/doppler_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -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