From 5910249d02fdbbb8dead8631bcf5404c360ab0fa Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 7 Aug 2024 14:00:59 +0100 Subject: [PATCH] Add secrets command + 1password integration --- lib/kamal/cli/main.rb | 3 ++ lib/kamal/cli/secrets.rb | 36 ++++++++++++++ lib/kamal/secrets/adapters.rb | 12 +++++ lib/kamal/secrets/adapters/one_password.rb | 56 ++++++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 lib/kamal/cli/secrets.rb create mode 100644 lib/kamal/secrets/adapters.rb create mode 100644 lib/kamal/secrets/adapters/one_password.rb diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 65222eb7..39d431ad 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -211,6 +211,9 @@ class Kamal::Cli::Main < Kamal::Cli::Base desc "registry", "Login and -out of the image registry" subcommand "registry", Kamal::Cli::Registry + desc "secrets", "Helpers for extracting secrets", hide: true + subcommand "secrets", Kamal::Cli::Secrets + desc "server", "Bootstrap servers with curl and Docker" subcommand "server", Kamal::Cli::Server diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb new file mode 100644 index 00000000..1b192b0a --- /dev/null +++ b/lib/kamal/cli/secrets.rb @@ -0,0 +1,36 @@ +class Kamal::Cli::Secrets < Kamal::Cli::Base + desc "login", "Login to a secrets vault" + option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" + option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + def login + puts adapter(options).login(**adapter_options(options)) + end + + desc "fetch", "Fetch a secret from a vault" + option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" + option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + def fetch(name) + puts adapter(options).fetch(name, **adapter_options(options)) + end + + desc "fetch_all", "Fetch multiple secrets from a vault" + option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" + option :adapter_options, type: :hash, aliases: "-O", required: true, desc: "Options to pass to the vault adapter" + def fetch_all(*names) + puts JSON.dump(adapter(options).fetch_all(*names, **adapter_options(options))).shellescape + end + + desc "extract", "Extract a single secret from the results of a fetch_all call" + def extract(name, secrets) + puts JSON.parse(secrets).fetch(name) + end + + private + def adapter(options) + Kamal::Secrets::Adapters.lookup(options[:adapter]) + end + + def adapter_options(options) + options[:adapter_options].transform_keys(&:to_sym) + end +end diff --git a/lib/kamal/secrets/adapters.rb b/lib/kamal/secrets/adapters.rb new file mode 100644 index 00000000..2ad1dcdf --- /dev/null +++ b/lib/kamal/secrets/adapters.rb @@ -0,0 +1,12 @@ +module Kamal::Secrets::Adapters + def self.lookup(name) + case name + when "1password" + Kamal::Secrets::Adapters::OnePassword.new + else + Object.const_get("Kamal::Secrets::Adapters::#{name.camelize}").new + end + rescue NameError + raise RuntimeError, "Unknown secrets adapter: #{name}" + end +end diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb new file mode 100644 index 00000000..36a439c2 --- /dev/null +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -0,0 +1,56 @@ +class Kamal::Secrets::Adapters::OnePassword + delegate :optionize, to: Kamal::Utils + + def login(account:) + `op signin #{to_options(account: account, force: true, raw: true)}`.tap do + raise RuntimeError, "Failed to login to 1Password: #{output}" unless $?.success? + end + end + + def fetch(name, account:, session: nil) + `op read #{name} #{to_options(account: account, session: session)}`.tap do + raise RuntimeError, "Could not read #{name} from 1Password" unless $?.success? + end + end + + def fetch_all(*names, account:, session: nil) + secrets = {} + + vaults_items_fields(names).each do |vault, items| + items.each do |item, fields| + labels = fields.map { |field| "label=#{field}" }.join(",") + secrets_json = `op item get #{item} #{to_options(vault: vault, fields: labels, format: "json", account: account, session: session.presence)}`.tap do + raise RuntimeError, "Could not read #{labels} from #{item} in the #{vault} 1Password vault" unless $?.success? + end + + JSON.parse(secrets_json).each do |secret_json| + secrets[secret_json["reference"]] = secret_json["value"] + end + end + end + + secrets + end + + private + def vaults_items_fields(names) + {}.tap do |vaults| + names.each do |name| + vault, item, field = vault_item_field(name) + vaults[vault] ||= {} + vaults[vault][item] ||= [] + vaults[vault][item] << field + end + end + end + + def vault_item_field(name) + parts = name.delete_prefix("op://").split("/") + + [ parts[0], parts[1], parts[2..-1].join(".") ] + end + + def to_options(**options) + optionize(options.compact).join(" ") + end +end