odysseus-core 0.3.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +485 -1
- data/LICENSE.txt +25 -7
- data/README.md +12 -3
- data/lib/odysseus/builder/client.rb +15 -15
- data/lib/odysseus/caddy/client.rb +119 -42
- data/lib/odysseus/command_redaction.rb +38 -0
- data/lib/odysseus/config/parser.rb +43 -18
- data/lib/odysseus/core/deploy_versioning.rb +42 -0
- data/lib/odysseus/core/environment.rb +63 -0
- data/lib/odysseus/core/version.rb +1 -1
- data/lib/odysseus/core/volume_namespacer.rb +0 -0
- data/lib/odysseus/core.rb +2 -2
- data/lib/odysseus/deploy_log.rb +120 -0
- data/lib/odysseus/deploy_version.rb +9 -0
- data/lib/odysseus/deployer/dependency_manager.rb +121 -0
- data/lib/odysseus/deployer/executor.rb +213 -161
- data/lib/odysseus/deployer/retention_sweeper.rb +94 -0
- data/lib/odysseus/deployer/ssh.rb +92 -18
- data/lib/odysseus/docker/client.rb +240 -48
- data/lib/odysseus/docker/labels.rb +43 -0
- data/lib/odysseus/errors.rb +10 -0
- data/lib/odysseus/git.rb +70 -0
- data/lib/odysseus/host_paths.rb +79 -0
- data/lib/odysseus/host_providers/base.rb +1 -1
- data/lib/odysseus/host_providers/static.rb +1 -1
- data/lib/odysseus/host_providers.rb +3 -4
- data/lib/odysseus/host_verifier.rb +156 -0
- data/lib/odysseus/host_versions.rb +59 -0
- data/lib/odysseus/orchestrator/{accessory_deploy.rb → dependency_deploy.rb} +84 -88
- data/lib/odysseus/orchestrator/job_deploy.rb +31 -43
- data/lib/odysseus/orchestrator/web_deploy.rb +61 -55
- data/lib/odysseus/plugins.rb +72 -0
- data/lib/odysseus/retention_plan.rb +14 -0
- data/lib/odysseus/retention_planner.rb +58 -0
- data/lib/odysseus/rollback_plan.rb +20 -0
- data/lib/odysseus/rollback_planner.rb +107 -0
- data/lib/odysseus/sails.rb +0 -0
- data/lib/odysseus/secrets/encrypted_file.rb +5 -7
- data/lib/odysseus/secrets/loader.rb +1 -3
- data/lib/odysseus/setup/docker_apt.rb +178 -0
- data/lib/odysseus/setup/escalation.rb +73 -0
- data/lib/odysseus/setup/preparer.rb +372 -0
- data/lib/odysseus/setup/public_key.rb +127 -0
- data/lib/odysseus/validators/config.rb +38 -25
- data/lib/odysseus/version_resolver.rb +80 -0
- data/lib/odysseus.rb +0 -0
- data/sig/odysseus/core.rbs +0 -0
- metadata +48 -27
- data/Rakefile +0 -12
- data/lib/odysseus/version.rb +0 -5
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lib/odysseus/deploy_log.rb
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Odysseus
|
|
6
|
+
# Append-only record of what has been deployed to one host, keyed by service.
|
|
7
|
+
#
|
|
8
|
+
# This is the host's own account of its history: it orders versions reliably,
|
|
9
|
+
# which image timestamps cannot, and it survives container removal and image
|
|
10
|
+
# pruning. It is also the host-side audit trail.
|
|
11
|
+
class DeployLog
|
|
12
|
+
FILENAME = 'deploys.log'.freeze
|
|
13
|
+
TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'.freeze
|
|
14
|
+
|
|
15
|
+
Entry = Data.define(:at, :version, :role, :ref, :deployer, :kind, :from)
|
|
16
|
+
|
|
17
|
+
# @param ssh [Odysseus::Deployer::SSH] connection to the host
|
|
18
|
+
# @param service [String] the service: value from deploy.yml, never role-suffixed
|
|
19
|
+
def initialize(ssh:, service:)
|
|
20
|
+
@ssh = ssh
|
|
21
|
+
@service = service
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] absolute path of this service's log on the host
|
|
25
|
+
def path
|
|
26
|
+
File.join(host_paths.service_dir(@service), FILENAME)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Where a root install wrote this log. A host that has since moved to a
|
|
30
|
+
# deploy user still has its history here, and nothing ever deletes it.
|
|
31
|
+
# @return [String]
|
|
32
|
+
def legacy_path
|
|
33
|
+
File.join(host_paths.legacy_base, @service, FILENAME)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Record one deploy of one role.
|
|
37
|
+
#
|
|
38
|
+
# @param kind [String] 'deployed' or 'rolled-back'
|
|
39
|
+
# @param from [String, nil] the version replaced, for a rollback
|
|
40
|
+
def append(version:, role:, ref:, deployer:, kind: 'deployed', from: nil)
|
|
41
|
+
fields = [
|
|
42
|
+
Time.now.utc.strftime(TIME_FORMAT), sanitize(version), sanitize(role.to_s),
|
|
43
|
+
sanitize(ref || '-'), sanitize(deployer || '-'), sanitize(kind)
|
|
44
|
+
]
|
|
45
|
+
fields << "from=#{sanitize(from)}" if from
|
|
46
|
+
|
|
47
|
+
# Escape the assembled line as ONE argument. printf reuses its format for
|
|
48
|
+
# every remaining argument, so passing the fields separately would write
|
|
49
|
+
# one line per field instead of one line per deploy.
|
|
50
|
+
line = Shellwords.escape(fields.map(&:to_s).join(' '))
|
|
51
|
+
|
|
52
|
+
@ssh.execute("mkdir -p #{Shellwords.escape(File.dirname(path))}")
|
|
53
|
+
@ssh.execute("printf '%s\\n' #{line} >> #{Shellwords.escape(path)}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Array<Entry>] parsed entries, oldest first; empty when absent
|
|
57
|
+
def entries
|
|
58
|
+
# Prefer the current location, then the one a root install used. No
|
|
59
|
+
# merging: a host deploying as two different users is not a supported
|
|
60
|
+
# shape, and merging two histories would invent an ordering.
|
|
61
|
+
#
|
|
62
|
+
# For a root connection path and legacy_path are the same file, so the
|
|
63
|
+
# legacy `cat` is only added when they differ — otherwise the command
|
|
64
|
+
# would read one file twice for no reason, and no longer match what a
|
|
65
|
+
# root install has always run.
|
|
66
|
+
command = "cat #{Shellwords.escape(path)} 2>/dev/null"
|
|
67
|
+
command += " || cat #{Shellwords.escape(legacy_path)} 2>/dev/null" if legacy_path != path
|
|
68
|
+
command += ' || true'
|
|
69
|
+
|
|
70
|
+
raw = @ssh.execute(command)
|
|
71
|
+
|
|
72
|
+
raw.to_s.lines.filter_map { |line| parse_line(line) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def host_paths
|
|
78
|
+
@host_paths ||= Odysseus::HostPaths.new(@ssh)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The log's shape -- one line per record, fields separated by single
|
|
82
|
+
# spaces -- is load-bearing: phase 3's rollback ordering reads this file
|
|
83
|
+
# and relies on exactly one record per line and stable field positions.
|
|
84
|
+
# A field value is not guaranteed to be whitespace-free (git config
|
|
85
|
+
# user.email, for instance, can contain a literal newline), and
|
|
86
|
+
# `Shellwords.escape` treats a newline as safe to embed rather than
|
|
87
|
+
# stripping it, so an unsanitised value could split one record across
|
|
88
|
+
# multiple lines or shift every field after it. Collapse any run of
|
|
89
|
+
# whitespace in a field to a single underscore so the shape can never be
|
|
90
|
+
# broken, instead of raising and risking the record being dropped
|
|
91
|
+
# entirely (callers log after a deploy has already succeeded). A blank
|
|
92
|
+
# field maps to '-' rather than the empty string: split(/\s+/, 7) on read
|
|
93
|
+
# collapses an empty field into the whitespace around it, silently
|
|
94
|
+
# shifting every field after it into the wrong position with no line-count
|
|
95
|
+
# anomaly to notice.
|
|
96
|
+
def sanitize(value)
|
|
97
|
+
collapsed = value.to_s.gsub(/\s+/, '_')
|
|
98
|
+
collapsed.empty? ? '-' : collapsed
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_line(line)
|
|
102
|
+
stripped = line.strip
|
|
103
|
+
|
|
104
|
+
# Checked against the full split, not the limit-7 one below: a genuinely
|
|
105
|
+
# shifted or garbage-laden record can still land inside 6..7 once the
|
|
106
|
+
# trailing words are merged into the last field, which is exactly the
|
|
107
|
+
# "plausible-looking wrong record" this guards against.
|
|
108
|
+
return nil unless (6..7).cover?(stripped.split(/\s+/).length)
|
|
109
|
+
|
|
110
|
+
at, version, role, ref, deployer, kind, extra = stripped.split(/\s+/, 7)
|
|
111
|
+
return nil if at.nil? || version.nil? || kind.nil?
|
|
112
|
+
return nil unless at.match?(/\A\d{4}-\d{2}-\d{2}T/)
|
|
113
|
+
|
|
114
|
+
Entry.new(
|
|
115
|
+
at: at, version: version, role: role, ref: ref, deployer: deployer,
|
|
116
|
+
kind: kind, from: extra&.slice(/\Afrom=(\S+)/, 1)
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# lib/odysseus/deploy_version.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# The identity of one deploy: the image tag, plus where it came from.
|
|
5
|
+
# ref is nil when the version was given explicitly, because an arbitrary tag
|
|
6
|
+
# says nothing about a commit. deployer is still known in that case — it
|
|
7
|
+
# never depended on the tag identifying a commit.
|
|
8
|
+
DeployVersion = Data.define(:version, :ref, :deployer)
|
|
9
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# lib/odysseus/deployer/dependency_manager.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module Deployer
|
|
5
|
+
# Dependency verbs (deploy/remove/restart/upgrade/status/boot), split out of
|
|
6
|
+
# Executor because they are a distinct concern from deploying and rolling
|
|
7
|
+
# back the service itself, sharing only config and a way to open a
|
|
8
|
+
# connection.
|
|
9
|
+
class DependencyManager
|
|
10
|
+
# @param config [Hash] parsed deploy.yml
|
|
11
|
+
# @param secrets_loader [Odysseus::Secrets::Loader]
|
|
12
|
+
# @param connector [#call] returns an open SSH connection for a host
|
|
13
|
+
def initialize(config:, secrets_loader:, connector:)
|
|
14
|
+
@config = config
|
|
15
|
+
@secrets_loader = secrets_loader
|
|
16
|
+
@connector = connector
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param name [Symbol] dependency name
|
|
20
|
+
# @return [Hash] results keyed by host
|
|
21
|
+
def deploy(name:)
|
|
22
|
+
run_action(name, 'Deploying', 'to') { |orchestrator| orchestrator.deploy(name: name.to_sym) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param name [Symbol] dependency name
|
|
26
|
+
# @return [Hash] results keyed by host
|
|
27
|
+
def remove(name:)
|
|
28
|
+
run_action(name, 'Removing', 'from') { |orchestrator| orchestrator.remove(name: name.to_sym) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param name [Symbol] dependency name
|
|
32
|
+
# @return [Hash] results keyed by host
|
|
33
|
+
def restart(name:)
|
|
34
|
+
run_action(name, 'Restarting', 'on') { |orchestrator| orchestrator.restart(name: name.to_sym) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param name [Symbol] dependency name
|
|
38
|
+
# @return [Hash] results keyed by host
|
|
39
|
+
def upgrade(name:)
|
|
40
|
+
run_action(name, 'Upgrading', 'on') { |orchestrator| orchestrator.upgrade(name: name.to_sym) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<Hash>] status for every dependency on every configured host
|
|
44
|
+
def status
|
|
45
|
+
return [] unless @config[:dependencies]&.any?
|
|
46
|
+
|
|
47
|
+
all_statuses = []
|
|
48
|
+
@config[:dependencies].each do |name, dep_config|
|
|
49
|
+
(dep_config[:hosts] || []).each do |host|
|
|
50
|
+
dep_status = status_on(host, name)
|
|
51
|
+
all_statuses << dep_status if dep_status
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
all_statuses
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash] boot results keyed by dependency name
|
|
58
|
+
def boot_all
|
|
59
|
+
return {} unless @config[:dependencies]&.any?
|
|
60
|
+
|
|
61
|
+
results = {}
|
|
62
|
+
@config[:dependencies].each_key do |name|
|
|
63
|
+
puts "\n=== Booting dependency: #{name} ==="
|
|
64
|
+
results[name] = deploy(name: name)
|
|
65
|
+
end
|
|
66
|
+
results
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# DependencyDeploy exposes #list_status (no args, every dependency on the
|
|
72
|
+
# host) rather than a per-dependency lookup, so pick out the one entry
|
|
73
|
+
# this host/dependency pair needs; nil when it is somehow absent. One
|
|
74
|
+
# #list_status call per dependency/host pair where one per host would
|
|
75
|
+
# do; accepted for now rather than restructuring the dependency-then-host
|
|
76
|
+
# result order.
|
|
77
|
+
def status_on(host, name)
|
|
78
|
+
ssh = @connector.call(host)
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
dep_status = build_orchestrator(ssh).list_status.find { |entry| entry[:name] == name }
|
|
82
|
+
dep_status&.merge(host: host)
|
|
83
|
+
ensure
|
|
84
|
+
ssh.close
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def dependency_config(name)
|
|
89
|
+
config = @config[:dependencies]&.[](name.to_sym)
|
|
90
|
+
raise Odysseus::ConfigError, "Dependency '#{name}' not found in config" unless config
|
|
91
|
+
|
|
92
|
+
config
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Shared plumbing for the verbs above: resolve hosts, connect, build the
|
|
96
|
+
# orchestrator, run the block, and always close the connection.
|
|
97
|
+
def run_action(name, verb, preposition)
|
|
98
|
+
hosts = dependency_config(name)[:hosts] || []
|
|
99
|
+
|
|
100
|
+
raise Odysseus::ConfigError, "No hosts configured for dependency #{name}" if hosts.empty?
|
|
101
|
+
|
|
102
|
+
results = {}
|
|
103
|
+
hosts.each do |host|
|
|
104
|
+
puts "#{verb} dependency #{name} #{preposition} #{host}..."
|
|
105
|
+
ssh = @connector.call(host)
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
results[host] = yield(build_orchestrator(ssh))
|
|
109
|
+
ensure
|
|
110
|
+
ssh.close
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
results
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_orchestrator(ssh)
|
|
117
|
+
Odysseus::Orchestrator::DependencyDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|