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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +485 -1
  3. data/LICENSE.txt +25 -7
  4. data/README.md +12 -3
  5. data/lib/odysseus/builder/client.rb +15 -15
  6. data/lib/odysseus/caddy/client.rb +119 -42
  7. data/lib/odysseus/command_redaction.rb +38 -0
  8. data/lib/odysseus/config/parser.rb +43 -18
  9. data/lib/odysseus/core/deploy_versioning.rb +42 -0
  10. data/lib/odysseus/core/environment.rb +63 -0
  11. data/lib/odysseus/core/version.rb +1 -1
  12. data/lib/odysseus/core/volume_namespacer.rb +0 -0
  13. data/lib/odysseus/core.rb +2 -2
  14. data/lib/odysseus/deploy_log.rb +120 -0
  15. data/lib/odysseus/deploy_version.rb +9 -0
  16. data/lib/odysseus/deployer/dependency_manager.rb +121 -0
  17. data/lib/odysseus/deployer/executor.rb +213 -161
  18. data/lib/odysseus/deployer/retention_sweeper.rb +94 -0
  19. data/lib/odysseus/deployer/ssh.rb +92 -18
  20. data/lib/odysseus/docker/client.rb +240 -48
  21. data/lib/odysseus/docker/labels.rb +43 -0
  22. data/lib/odysseus/errors.rb +10 -0
  23. data/lib/odysseus/git.rb +70 -0
  24. data/lib/odysseus/host_paths.rb +79 -0
  25. data/lib/odysseus/host_providers/base.rb +1 -1
  26. data/lib/odysseus/host_providers/static.rb +1 -1
  27. data/lib/odysseus/host_providers.rb +3 -4
  28. data/lib/odysseus/host_verifier.rb +156 -0
  29. data/lib/odysseus/host_versions.rb +59 -0
  30. data/lib/odysseus/orchestrator/{accessory_deploy.rb → dependency_deploy.rb} +84 -88
  31. data/lib/odysseus/orchestrator/job_deploy.rb +31 -43
  32. data/lib/odysseus/orchestrator/web_deploy.rb +61 -55
  33. data/lib/odysseus/plugins.rb +72 -0
  34. data/lib/odysseus/retention_plan.rb +14 -0
  35. data/lib/odysseus/retention_planner.rb +58 -0
  36. data/lib/odysseus/rollback_plan.rb +20 -0
  37. data/lib/odysseus/rollback_planner.rb +107 -0
  38. data/lib/odysseus/sails.rb +0 -0
  39. data/lib/odysseus/secrets/encrypted_file.rb +5 -7
  40. data/lib/odysseus/secrets/loader.rb +1 -3
  41. data/lib/odysseus/setup/docker_apt.rb +178 -0
  42. data/lib/odysseus/setup/escalation.rb +73 -0
  43. data/lib/odysseus/setup/preparer.rb +372 -0
  44. data/lib/odysseus/setup/public_key.rb +127 -0
  45. data/lib/odysseus/validators/config.rb +38 -25
  46. data/lib/odysseus/version_resolver.rb +80 -0
  47. data/lib/odysseus.rb +0 -0
  48. data/sig/odysseus/core.rbs +0 -0
  49. metadata +48 -27
  50. data/Rakefile +0 -12
  51. 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