Skip to content

Commit f15b2f9

Browse files
authored
Capistrano: deploy application secrets from a subversion or git repository (#141)
1 parent e1b6dbf commit f15b2f9

File tree

3 files changed

+169
-1
lines changed

3 files changed

+169
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## [Unreleased]
2-
*no unreleased changes*
2+
### Added
3+
* Capistrano: deploy application secrets from a subversion or git repository
34

45
## 7.3.0 / 2024-12-19
56
### Added
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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

lib/ndr_dev_support/capistrano/ndr_model.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# Discrete bits of functionality we use automatically:
44
require_relative 'assets'
5+
require_relative 'deploy_secrets'
56
require_relative 'install_ruby'
67
require_relative 'restart'
78
require_relative 'revision_logger'

0 commit comments

Comments
 (0)