|
| 1 | +# Add a git or svn secrets respository for ndr_dev_support:deploy_secrets |
| 2 | +def add_secrets_repo(name:, url:, scm:, branch: nil) |
| 3 | + raise "Invalid repo name #{name}" unless /\A[A-Z0-9_-]+\z/i.match?(name) |
| 4 | + raise "Unknown scm #{scm}" unless %w[svn git].include?(scm) |
| 5 | + raise "Expected branch for repo #{name}" if scm == 'git' && branch.to_s.empty? |
| 6 | + |
| 7 | + secrets_repositories = fetch(:secrets_repositories, {}) |
| 8 | + secrets_repositories[name] = { url: url, scm: scm, branch: branch } |
| 9 | + set :secrets_repositories, secrets_repositories |
| 10 | +end |
| 11 | + |
| 12 | +# Add a secret to be deployed by ndr_dev_support:deploy_secrets |
| 13 | +def add_secret(repo:, repo_path:, shared_dest:) |
| 14 | + secrets = fetch(:secrets, []) |
| 15 | + raise "Unknown repo #{repo}" unless fetch(:secrets_repositories, {}).key?(repo) |
| 16 | + |
| 17 | + secrets << { repo: repo, repo_path: repo_path, shared_dest: shared_dest } |
| 18 | + set :secrets, secrets |
| 19 | +end |
| 20 | + |
| 21 | +Capistrano::Configuration.instance(:must_exist).load do |
| 22 | + namespace :ndr_dev_support do |
| 23 | + desc <<~DESC |
| 24 | + Deploy updated application secrets to shared folders on application servers |
| 25 | +
|
| 26 | + To use this in a project, add something like the code below to your |
| 27 | + Capistrano file config/deploy.rb, then run: |
| 28 | + $ cap target app:update_secrets |
| 29 | +
|
| 30 | + namespace :app do |
| 31 | + desc 'Update application secrets' |
| 32 | + task :update_secrets do |
| 33 | + add_secrets_repo(name: 'userlists', |
| 34 | + url: 'https://github.com/example/users.git', |
| 35 | + branch: 'main', |
| 36 | + scm: 'git') |
| 37 | + add_secrets_repo(name: 'encrypted_credentials_store', |
| 38 | + url: 'https://svn-server.example.org/svn/creds', scm: 'svn') |
| 39 | +
|
| 40 | + add_secret(repo: 'encrypted_credentials_store', |
| 41 | + repo_path: 'path/to/credentials.yml.enc', |
| 42 | + shared_dest: 'config/credentials.yml.enc') |
| 43 | + add_secret(repo: 'userlists', |
| 44 | + repo_path: 'config/userlist.yml', |
| 45 | + shared_dest: 'config/userlist.yml') |
| 46 | + end |
| 47 | + end |
| 48 | + after 'app:update_secrets', 'ndr_dev_support:deploy_secrets' |
| 49 | + DESC |
| 50 | + task :deploy_secrets do |
| 51 | + # List of repositories used for secrets |
| 52 | + secrets_repositories = fetch(:secrets_repositories, {}) |
| 53 | + secrets = fetch(:secrets, []) |
| 54 | + secrets_repo_base = Pathname.new('tmp/deployment-secrets') |
| 55 | + |
| 56 | + if secrets.empty? |
| 57 | + Capistrano::CLI.ui.say 'Warning: No secret files configured to upload' |
| 58 | + next |
| 59 | + end |
| 60 | + |
| 61 | + # Allow quick indexing by filename |
| 62 | + secrets_map = secrets.to_h { |secret| [secret[:shared_dest], secret] } # rubocop:disable Rails/IndexBy |
| 63 | + changed = [] # List of changed files updated |
| 64 | + Dir.mktmpdir do |secret_dir| |
| 65 | + # Clone git secrets repositories if required |
| 66 | + used_repos = secrets.collect { |secret| secret[:repo] }.uniq |
| 67 | + repo_dirs = {} |
| 68 | + used_repos.each do |repo| |
| 69 | + repository = secrets_repositories[repo] |
| 70 | + next unless repository[:scm] == 'git' |
| 71 | + |
| 72 | + repo_dir = Pathname.new(secrets_repo_base).join(".git-#{repo}").to_s |
| 73 | + if File.directory?(repo_dir) |
| 74 | + ok = system("cd #{Shellwords.escape(repo_dir)} && git fetch") |
| 75 | + raise "Error: cannot fetch secrets repository #{repo}: aborting" unless ok |
| 76 | + else |
| 77 | + ok = system('git', 'clone', '--mirror', '--filter=blob:none', repository[:url], repo_dir) |
| 78 | + raise "Error: cannot clone secrets repository #{repo}: aborting" unless ok |
| 79 | + end |
| 80 | + repo_dirs[repo] = repo_dir |
| 81 | + end |
| 82 | + |
| 83 | + # Set up a temporary secrets directory of exported secrets, |
| 84 | + # creating nested structure if necessary |
| 85 | + secrets_map.each_value do |secret| |
| 86 | + repo = secret[:repo] |
| 87 | + repository = secrets_repositories[repo] |
| 88 | + raise "Unknown repository #{secret[:repo]}" unless repository |
| 89 | + |
| 90 | + repo_root = repository[:url] |
| 91 | + raise 'Unknown / unsupported repository' unless repo_root&.start_with?('https://') |
| 92 | + |
| 93 | + dest_fname = File.join(secret_dir, secret[:shared_dest]) |
| 94 | + dest_dir = File.dirname(dest_fname) |
| 95 | + FileUtils.mkdir_p(dest_dir) |
| 96 | + case repository[:scm] |
| 97 | + when 'git' |
| 98 | + repo_dir = Pathname.new(secrets_repo_base).join(".git-#{repo}").to_s |
| 99 | + ok = system("GIT_DIR=#{Shellwords.escape(repo_dir)} git archive --format=tar " \ |
| 100 | + "#{Shellwords.escape(repository[:branch])} " \ |
| 101 | + "#{Shellwords.escape(secret[:repo_path])} | " \ |
| 102 | + "tar x -Ps %#{Shellwords.escape(secret[:repo_path])}%" \ |
| 103 | + "#{Shellwords.escape(File.join(secret_dir, secret[:shared_dest]))}% " \ |
| 104 | + "#{Shellwords.escape(secret[:repo_path])}") |
| 105 | + when 'svn' |
| 106 | + ok = system('svn', 'export', '--quiet', "#{repo_root}/#{secret[:repo_path]}", |
| 107 | + File.join(secret_dir, secret[:shared_dest])) |
| 108 | + # TODO: use --non-interactive, and then run again interactively if there's an eror |
| 109 | + else |
| 110 | + raise "Error: unsupported scm #{repository[:scm]}" |
| 111 | + end |
| 112 | + |
| 113 | + raise 'Error: cannot export secrets files: aborting' unless ok |
| 114 | + |
| 115 | + secret[:digest] = Digest::SHA256.file(dest_fname).hexdigest |
| 116 | + end |
| 117 | + |
| 118 | + # Retrieve digests of secrets from application server |
| 119 | + escaped_fnames = secrets_map.keys.collect { |fname| Shellwords.escape(fname) } |
| 120 | + capture("cd #{shared_path.shellescape}; " \ |
| 121 | + "sha256sum #{escaped_fnames.join(' ')} || true").split("\n").each do |digest_line| |
| 122 | + match = digest_line.match(/([0-9a-f]{64}) [ *](.*)/) |
| 123 | + raise "Invalid digest returned: #{digest_line}" unless match && secrets_map.key?(match[2]) |
| 124 | + |
| 125 | + secrets_map[match[2]][:server_digest] = match[1] |
| 126 | + end |
| 127 | + |
| 128 | + # Upload replacements for all changed files |
| 129 | + secrets_map.each_value do |secret| |
| 130 | + if secret[:digest] == secret[:server_digest] |
| 131 | + # Capistrano::CLI.ui.say "Unchanged secret: #{secret[:shared_dest]}" |
| 132 | + next |
| 133 | + end |
| 134 | + |
| 135 | + Capistrano::CLI.ui.say "Uploading changed secret file: #{secret[:shared_dest]}" |
| 136 | + changed << secret[:shared_dest] |
| 137 | + # Capistrano does an in-place overwrite of the file, so use a temporary name, |
| 138 | + # then move it into place |
| 139 | + temp_dest = capture("mktemp -p #{shared_path.shellescape}").chomp |
| 140 | + dest_fname = File.join(secret_dir, secret[:shared_dest]) |
| 141 | + put File.read(dest_fname), temp_dest |
| 142 | + escape_shared_dest = Shellwords.escape(secret[:shared_dest]) |
| 143 | + escape_temp_dest = Shellwords.escape(temp_dest) |
| 144 | + capture("cd #{shared_path.shellescape}; " \ |
| 145 | + "chmod 664 #{escape_temp_dest}; " \ |
| 146 | + "if [ -e #{escape_shared_dest} ]; then cp -p #{escape_shared_dest}{,.orig}; fi; " \ |
| 147 | + "mv #{escape_temp_dest} #{escape_shared_dest}") |
| 148 | + end |
| 149 | + end |
| 150 | + |
| 151 | + if changed.empty? |
| 152 | + Capistrano::CLI.ui.say 'No changed secret files to upload' |
| 153 | + else |
| 154 | + Capistrano::CLI.ui.say "Uploaded #{changed.size} changed secret files: #{changed.join(', ')}" |
| 155 | + end |
| 156 | + # TODO: Support logging of changes, so that a calling script can report changes |
| 157 | + |
| 158 | + # TODO: maintain a per-target local cache of latest revisions uploaded / file checksums |
| 159 | + # then we don't need to re-connect to the remote servers, if nothing changed, |
| 160 | + # We could also then only need to do "svn ls" instead of "svn export" |
| 161 | + |
| 162 | + # TODO: Warn if some repos are inaccessible? |
| 163 | + # TODO: Add notes for passwordless SSH deployment, using ssh-agent |
| 164 | + end |
| 165 | + end |
| 166 | +end |
0 commit comments