kitsune-kit 0.4.0 → 0.5.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +108 -136
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/provider-adapters.md +49 -0
  14. data/docs/architecture.md +89 -0
  15. data/docs/commands.md +210 -0
  16. data/docs/configuration.md +210 -0
  17. data/docs/getting-started.md +133 -0
  18. data/docs/providers/digitalocean.md +52 -0
  19. data/docs/releasing.md +43 -0
  20. data/docs/roadmap.md +46 -0
  21. data/docs/security-audit.md +130 -0
  22. data/docs/security.md +116 -0
  23. data/docs/services/postgres.md +78 -0
  24. data/docs/services/redis.md +53 -0
  25. data/docs/testing.md +200 -0
  26. data/docs/troubleshooting.md +123 -0
  27. data/docs/tui.md +57 -0
  28. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  29. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  30. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  31. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  32. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  34. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  35. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  36. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  37. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  38. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  39. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  40. data/lib/kitsune/kit/application.rb +170 -0
  41. data/lib/kitsune/kit/cancellation.rb +21 -0
  42. data/lib/kitsune/kit/cli.rb +802 -64
  43. data/lib/kitsune/kit/clock.rb +11 -0
  44. data/lib/kitsune/kit/configuration.rb +493 -0
  45. data/lib/kitsune/kit/errors.rb +98 -0
  46. data/lib/kitsune/kit/events.rb +55 -0
  47. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  48. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  49. data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
  50. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  51. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  52. data/lib/kitsune/kit/operations/service_files.rb +127 -0
  53. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  54. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  55. data/lib/kitsune/kit/plan.rb +72 -0
  56. data/lib/kitsune/kit/reporters/human.rb +89 -0
  57. data/lib/kitsune/kit/reporters/json.rb +65 -0
  58. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  59. data/lib/kitsune/kit/result.rb +28 -0
  60. data/lib/kitsune/kit/run_journal.rb +102 -0
  61. data/lib/kitsune/kit/run_logger.rb +37 -0
  62. data/lib/kitsune/kit/scripts/docker.sh +137 -0
  63. data/lib/kitsune/kit/scripts/firewall.sh +205 -0
  64. data/lib/kitsune/kit/scripts/metrics.sh +54 -0
  65. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  66. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  67. data/lib/kitsune/kit/scripts/unattended.sh +81 -0
  68. data/lib/kitsune/kit/scripts/user.sh +114 -0
  69. data/lib/kitsune/kit/secret_filter.rb +54 -0
  70. data/lib/kitsune/kit/secret_store.rb +32 -0
  71. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  72. data/lib/kitsune/kit/service_compose.rb +72 -0
  73. data/lib/kitsune/kit/state_store.rb +158 -0
  74. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  75. data/lib/kitsune/kit/tui/actions.rb +72 -0
  76. data/lib/kitsune/kit/tui/application.rb +35 -0
  77. data/lib/kitsune/kit/tui/controller.rb +162 -0
  78. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  79. data/lib/kitsune/kit/tui/state.rb +18 -0
  80. data/lib/kitsune/kit/tui/store.rb +88 -0
  81. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  82. data/lib/kitsune/kit/version.rb +1 -1
  83. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  84. data/lib/kitsune/kit/workflows/base.rb +31 -0
  85. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  86. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  87. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  88. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  89. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  90. data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
  91. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  92. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  93. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  94. data/lib/kitsune/kit.rb +41 -2
  95. metadata +122 -79
  96. data/.rspec +0 -3
  97. data/Rakefile +0 -8
  98. data/kitsune-kit-logo.jpg +0 -0
  99. data/lib/kitsune/blueprints/.env.template +0 -31
  100. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  101. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  102. data/lib/kitsune/blueprints/kit.env.template +0 -1
  103. data/lib/kitsune/kit/ansi_color.rb +0 -78
  104. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  105. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  106. data/lib/kitsune/kit/commands/dns.rb +0 -112
  107. data/lib/kitsune/kit/commands/init.rb +0 -148
  108. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  109. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  110. data/lib/kitsune/kit/commands/provision.rb +0 -43
  111. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  112. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  113. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  114. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  115. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  116. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  117. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  118. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  119. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  120. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  121. data/lib/kitsune/kit/defaults.rb +0 -91
  122. data/lib/kitsune/kit/env_loader.rb +0 -41
  123. data/lib/kitsune/kit/options_builder.rb +0 -26
  124. data/lib/kitsune/kit/provisioner.rb +0 -107
  125. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "public_suffix"
4
+ require_relative "../adapters/provider"
5
+ require_relative "../errors"
6
+ require_relative "../plan"
7
+
8
+ module Kitsune
9
+ module Kit
10
+ module Operations
11
+ class EnsureDnsRecords
12
+ def initialize(config:, provider:, state_store:)
13
+ @config = config
14
+ @provider = provider
15
+ @state_store = state_store
16
+ end
17
+
18
+ def plan
19
+ server = find_server
20
+ desired_ip = server&.public_ip
21
+ entries = domains.map { |domain| plan_domain(domain, desired_ip) }
22
+ action = entries.all? { |entry| entry[:action] == "no_change" } ? "no_change" : "update"
23
+ Change.new(
24
+ resource: "dns",
25
+ action: action,
26
+ summary: summary_for(action, entries),
27
+ details: { records: entries, desired_ip: desired_ip, depends_on: desired_ip ? nil : "server" }
28
+ )
29
+ end
30
+
31
+ def apply(change)
32
+ return [] unless change.changed?
33
+
34
+ server = find_server
35
+ unless server&.public_ip
36
+ raise Errors::VerificationError.new(
37
+ "server has no public IP for DNS records",
38
+ hint: "Wait for the server to become ready, then resume apply."
39
+ )
40
+ end
41
+
42
+ domains.map do |domain|
43
+ apply_domain(domain, server.public_ip).tap { |entry| save_record(entry) }
44
+ end
45
+ end
46
+
47
+ def rollback
48
+ managed = state_store.read(config.environment).dig("resources", "dns")
49
+ return false unless managed
50
+
51
+ managed.each_value do |entry|
52
+ record = record_from_state(entry.fetch("record"))
53
+ previous = entry["previous"] && record_from_state(entry["previous"])
54
+ if previous
55
+ provider.upsert_dns_record(record: previous)
56
+ else
57
+ provider.delete_dns_record(id: record.id, zone: record.zone)
58
+ end
59
+ end
60
+ state_store.update(config.environment) do |state|
61
+ state["resources"].delete("dns")
62
+ state["operations"] << { "resource" => resource, "action" => "rollback", "status" => "applied" }
63
+ state
64
+ end
65
+ true
66
+ end
67
+
68
+ def resource = "dns"
69
+
70
+ private
71
+
72
+ attr_reader :config, :provider, :state_store
73
+
74
+ def domains = config.dns.domains
75
+
76
+ def find_server
77
+ provider.find_server(name: config.server.name, tags: config.server.tags)
78
+ end
79
+
80
+ def plan_domain(domain, desired_ip)
81
+ zone, name = zone_and_name(domain)
82
+ existing = provider.find_dns_record(zone: zone, name: name, type: "A")
83
+ action = if existing && desired_ip && existing.data == desired_ip && existing.ttl == config.dns.ttl
84
+ "no_change"
85
+ elsif existing
86
+ "update"
87
+ else
88
+ "create"
89
+ end
90
+ {
91
+ domain: domain,
92
+ zone: zone,
93
+ name: name,
94
+ action: action,
95
+ current: existing&.to_h,
96
+ desired: { data: desired_ip || "pending-server-ip", ttl: config.dns.ttl }
97
+ }
98
+ end
99
+
100
+ def apply_domain(domain, ip)
101
+ zone, name = zone_and_name(domain)
102
+ existing = provider.find_dns_record(zone: zone, name: name, type: "A")
103
+ desired = Adapters::DnsRecord.new(
104
+ id: existing&.id,
105
+ zone: zone,
106
+ name: name,
107
+ type: "A",
108
+ data: ip,
109
+ ttl: config.dns.ttl
110
+ )
111
+ saved = if existing&.data == ip && existing.ttl == config.dns.ttl
112
+ existing
113
+ else
114
+ provider.upsert_dns_record(record: desired)
115
+ end
116
+ previous = prior_previous(domain, existing)
117
+ { domain: domain, record: saved, previous: previous }
118
+ end
119
+
120
+ def zone_and_name(domain)
121
+ parsed = PublicSuffix.parse(domain)
122
+ zone = parsed.domain
123
+ prefix = domain.delete_suffix(zone).delete_suffix(".")
124
+ [zone, prefix.empty? ? "@" : prefix]
125
+ rescue PublicSuffix::Error
126
+ raise Errors::ConfigurationError.new(
127
+ "cannot determine the DNS zone for #{domain}",
128
+ hint: "Use a fully qualified public domain name."
129
+ )
130
+ end
131
+
132
+ def save_record(entry)
133
+ state_store.update(config.environment) do |state|
134
+ state["resources"]["dns"] ||= {}
135
+ state["resources"]["dns"][entry[:domain]] = {
136
+ "record" => stringify(entry[:record].to_h),
137
+ "previous" => entry[:previous] && stringify(entry[:previous].to_h)
138
+ }
139
+ state["operations"] << { "resource" => resource, "action" => "update", "status" => "applied" }
140
+ state
141
+ end
142
+ end
143
+
144
+ def prior_previous(domain, existing)
145
+ prior = state_store.read(config.environment).dig("resources", "dns", domain)
146
+ return existing unless prior
147
+
148
+ prior["previous"] && record_from_state(prior["previous"])
149
+ end
150
+
151
+ def stringify(hash) = hash.transform_keys(&:to_s)
152
+
153
+ def record_from_state(value)
154
+ Adapters::DnsRecord.new(**value.transform_keys(&:to_sym))
155
+ end
156
+
157
+ def summary_for(action, entries)
158
+ return "DNS records are current" if action == "no_change"
159
+
160
+ "Apply #{entries.count { |entry| entry[:action] != 'no_change' }} DNS record change(s)"
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../plan"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Operations
9
+ class EnsureServer
10
+ attr_reader :config
11
+
12
+ def initialize(config:, provider:, state_store:, maximum_timeout: nil)
13
+ if maximum_timeout && maximum_timeout <= 0
14
+ raise Errors::ConfigurationError, "timeout must be greater than zero"
15
+ end
16
+
17
+ @config = config
18
+ @provider = provider
19
+ @state_store = state_store
20
+ @ready_timeout = maximum_timeout || 180
21
+ end
22
+
23
+ def plan
24
+ @provider.validate_server_spec!(spec: desired_spec)
25
+ existing = @provider.find_server(name: config.server.name, tags: config.server.tags)
26
+ return create_change unless existing
27
+
28
+ differences = differences_for(existing)
29
+ return unchanged_change(existing) if differences.empty?
30
+
31
+ Change.new(
32
+ resource: "server.#{config.server.name}",
33
+ action: "update",
34
+ summary: "Server exists with immutable configuration differences",
35
+ details: { id: existing.id, differences: differences, replacement_required: true },
36
+ destructive: true
37
+ )
38
+ end
39
+
40
+ def apply(change)
41
+ return current_server(change) unless change.changed?
42
+ if change.action == "update"
43
+ raise Errors::UnsafeOperationError.new(
44
+ "server replacement is required for #{config.server.name}",
45
+ hint: "Destroy the managed server explicitly, then run apply again.",
46
+ context: change.details
47
+ )
48
+ end
49
+
50
+ created = @provider.find_server(name: config.server.name, tags: config.server.tags) ||
51
+ @provider.create_server(spec: desired_spec)
52
+ record_created(created)
53
+ ready = @provider.wait_until_ready(id: created.id, timeout: @ready_timeout)
54
+ record_ready(ready)
55
+ ready
56
+ rescue Errors::Error
57
+ raise
58
+ rescue StandardError => e
59
+ raise Errors::ProviderError.new(
60
+ "server operation failed for #{config.server.name}",
61
+ context: { cause: e.class.name },
62
+ retryable: true
63
+ )
64
+ end
65
+
66
+ def resource = "server.#{config.server.name}"
67
+ def state_key = "server"
68
+
69
+ private
70
+
71
+ def desired_spec
72
+ {
73
+ name: config.server.name,
74
+ region: config.server.region,
75
+ size: config.server.size,
76
+ image: config.server.image,
77
+ ssh_key_id: config.server.ssh_key_id,
78
+ tags: config.server.tags
79
+ }
80
+ end
81
+
82
+ def create_change
83
+ Change.new(
84
+ resource: resource,
85
+ action: "create",
86
+ summary: "Create server #{config.server.name}",
87
+ details: desired_spec.except(:ssh_key_id)
88
+ )
89
+ end
90
+
91
+ def unchanged_change(existing)
92
+ Change.new(
93
+ resource: resource,
94
+ action: "no_change",
95
+ summary: "Server #{config.server.name} is ready",
96
+ details: { id: existing.id, public_ip: existing.public_ip, status: existing.status }
97
+ )
98
+ end
99
+
100
+ def differences_for(existing)
101
+ {
102
+ region: [existing.region, config.server.region],
103
+ size: [existing.size, config.server.size],
104
+ image: [existing.image, config.server.image]
105
+ }.reject { |_key, (actual, desired)| actual.nil? || actual == desired }
106
+ end
107
+
108
+ def current_server(_change)
109
+ @provider.find_server(name: config.server.name, tags: config.server.tags) ||
110
+ raise(Errors::VerificationError, "server disappeared after planning")
111
+ end
112
+
113
+ def record_created(server)
114
+ @state_store.update(config.environment) do |state|
115
+ state["resources"]["server"] = state_record(server)
116
+ state["operations"] << { "resource" => resource, "action" => "create", "status" => "started" }
117
+ state
118
+ end
119
+ end
120
+
121
+ def record_ready(server)
122
+ @state_store.update(config.environment) do |state|
123
+ state["resources"]["server"] = state_record(server)
124
+ state["operations"] << { "resource" => resource, "action" => "create", "status" => "applied" }
125
+ state
126
+ end
127
+ end
128
+
129
+ def state_record(server)
130
+ server.to_h.transform_keys(&:to_s).merge("provider" => config.provider.name)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../errors"
5
+ require_relative "../plan"
6
+ require_relative "../service_compose"
7
+ require_relative "service_files"
8
+ require_relative "service_firewall"
9
+ require_relative "service_backup"
10
+ require_relative "service_state"
11
+
12
+ module Kitsune
13
+ module Kit
14
+ module Operations
15
+ class EnsureService
16
+ TYPES = %w[postgres redis].freeze
17
+ MARKER_DIRECTORY = "/var/lib/kitsune/state"
18
+
19
+ attr_reader :resource
20
+
21
+ def initialize(config:, type:, transport_factory:, state_store:, secret_store:, clock: -> { Time.now.utc })
22
+ @config = config
23
+ @type = type.to_s
24
+ raise ArgumentError, "unknown service type: #{@type}" unless TYPES.include?(@type)
25
+
26
+ @service = config.services.public_send(@type)
27
+ @transport_factory = transport_factory
28
+ @state_store = state_store
29
+ @secret_store = secret_store
30
+ @resource = "service.#{@type}"
31
+ @managed_state = ServiceState.new(state_store: state_store, environment: config.environment,
32
+ resource: resource)
33
+ @compose = ServiceCompose.new(config: config, type: @type, service: @service)
34
+ @clock = clock
35
+ end
36
+
37
+ def plan
38
+ return pending_change unless transport_factory.server&.public_ip
39
+
40
+ actual = remote_fingerprint
41
+ files.ensure_managed_or_absent!(actual)
42
+ return unchanged_change if actual == fingerprint
43
+
44
+ Change.new(
45
+ resource: resource,
46
+ action: actual.empty? ? "create" : "update",
47
+ summary: "#{actual.empty? ? 'Install' : 'Update'} #{@type} service",
48
+ details: safe_details(actual)
49
+ )
50
+ rescue Errors::ConnectionError
51
+ pending_change
52
+ end
53
+
54
+ def apply(change)
55
+ return true unless change.changed?
56
+
57
+ @firewall_changed = false
58
+ previous_state = managed_state
59
+ password = validated_secret
60
+ files.ensure_managed_or_absent!(remote_fingerprint)
61
+ prepare_backup
62
+ transport.execute("mkdir", arguments: ["-p", service_directory], timeout: 10).then do |result|
63
+ raise_remote!(result, "create service directory")
64
+ end
65
+ transport.upload(content: compose_content, remote_path: compose_path, mode: "0600")
66
+ transport.upload(content: env_content(password), remote_path: env_path, mode: "0600")
67
+ validate_compose
68
+ start_service
69
+ verify_service
70
+ firewall.reconcile(managed_state)
71
+ @firewall_changed = true
72
+ write_marker
73
+ record_state(change)
74
+ true
75
+ rescue StandardError => e
76
+ recover_failed_apply(previous_state, e)
77
+ end
78
+
79
+ def remove
80
+ return false unless managed?
81
+
82
+ compose("down", "--remove-orphans")
83
+ firewall.remove
84
+ remove_marker
85
+ @managed_state.record_remove
86
+ true
87
+ end
88
+
89
+ def backup
90
+ backup_operation.call(managed_state)
91
+ end
92
+
93
+ def rollback
94
+ previous = managed_state
95
+ return false unless previous
96
+
97
+ remove
98
+ if files.restore_previous(previous, marker_path: marker_path)
99
+ firewall.restore(previous["previous_state"] || {})
100
+ start_service if previous.dig("previous_state", "installed") != false
101
+ end
102
+ @managed_state.record_rollback(previous)
103
+ true
104
+ end
105
+
106
+ def destroy_data
107
+ previous = managed_state
108
+ return false unless previous
109
+
110
+ compose("down", "--volumes", "--remove-orphans")
111
+ files.destroy(previous, marker_path: marker_path)
112
+ @managed_state.delete("destroy_data")
113
+ true
114
+ end
115
+
116
+ private
117
+
118
+ attr_reader :config, :service, :transport_factory, :state_store, :secret_store
119
+
120
+ def transport = @transport ||= transport_factory.deploy
121
+ def project_name = "kitsune-#{config.environment}-#{@type}"
122
+ def service_directory = files.directory
123
+ def compose_path = files.compose_path
124
+ def env_path = files.env_path
125
+ def marker_path = "#{MARKER_DIRECTORY}/service-#{@type}.sha256"
126
+
127
+ def fingerprint
128
+ @fingerprint ||= Digest::SHA256.hexdigest(
129
+ [compose_content, service.password_env, secret_digest, service.publish, service.bind,
130
+ *service.allowed_cidrs].join("\0")
131
+ )
132
+ end
133
+
134
+ def secret_digest = Digest::SHA256.hexdigest(validated_secret)
135
+
136
+ def remote_fingerprint
137
+ result = transport.execute("sudo", arguments: ["cat", marker_path], timeout: 10)
138
+ result.success? ? result.stdout.strip : ""
139
+ end
140
+
141
+ def validated_secret
142
+ value = secret_store.fetch(service.password_env)
143
+ if value.match?(/[\n\r\0]/)
144
+ raise Errors::ConfigurationError, "#{service.password_env} contains unsupported control characters"
145
+ end
146
+
147
+ value
148
+ end
149
+
150
+ def compose_content = @compose.content
151
+ def env_content(password) = @compose.env_content(password)
152
+
153
+ def validate_compose = compose("config", "--quiet")
154
+ def start_service = compose("up", "--detach", "--wait", "--wait-timeout", "120")
155
+
156
+ def verify_service
157
+ result = compose("ps", "--status", "running", "--quiet")
158
+ raise Errors::VerificationError, "#{@type} did not reach running state" if result.stdout.strip.empty?
159
+ end
160
+
161
+ def compose(*arguments)
162
+ result = transport.execute(
163
+ "docker",
164
+ arguments: ["compose", "--project-directory", service_directory, "--file", compose_path, *arguments],
165
+ timeout: 180
166
+ )
167
+ raise_remote!(result, "docker compose #{arguments.first}")
168
+ result
169
+ end
170
+
171
+ def prepare_backup = @backed_up_files = managed? ? files.prepare_backup : []
172
+
173
+ def restore_after_failed_apply(previous_state)
174
+ return unless defined?(@backed_up_files)
175
+
176
+ firewall.restore_after_failure(previous_state) if @firewall_changed
177
+ safe_compose_down
178
+ files.restore_after_failure(@backed_up_files)
179
+ start_service unless @backed_up_files.empty?
180
+ end
181
+
182
+ def recover_failed_apply(previous_state, original_error)
183
+ restore_after_failed_apply(previous_state)
184
+ rescue StandardError => e
185
+ raise Errors::VerificationError.new(
186
+ "#{@type} apply failed and automatic recovery also failed",
187
+ hint: "Stop changes and inspect the service files, container, volume and firewall before retrying.",
188
+ context: { original_error: error_name(original_error), recovery_error: error_name(e) }
189
+ )
190
+ else
191
+ raise original_error
192
+ end
193
+
194
+ def safe_compose_down
195
+ transport.execute(
196
+ "docker",
197
+ arguments: ["compose", "--project-directory", service_directory, "--file", compose_path,
198
+ "down", "--remove-orphans"],
199
+ timeout: 180
200
+ )
201
+ end
202
+
203
+ def write_marker
204
+ transport.execute("sudo", arguments: ["install", "-d", "-m", "0755", MARKER_DIRECTORY], timeout: 10)
205
+ result = transport.execute("sudo", arguments: ["tee", marker_path], stdin: "#{fingerprint}\n", timeout: 10)
206
+ raise_remote!(result, "write service marker")
207
+ end
208
+
209
+ def remove_marker
210
+ result = transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
211
+ raise_remote!(result, "remove service marker")
212
+ end
213
+
214
+ def record_state(change)
215
+ @managed_state.record_apply(change.action, state_attributes(change))
216
+ end
217
+
218
+ def state_attributes(change)
219
+ {
220
+ "managed" => true, "installed" => true, "fingerprint" => fingerprint, "project" => project_name,
221
+ "directory" => service_directory, "published" => service.publish, "port" => service.port,
222
+ "firewall_rules_added" => firewall.owned_rules, "firewall_drop_added" => firewall.drop_owned,
223
+ "volume" => "#{project_name}_data",
224
+ "previous_fingerprint" => change.details[:actual_fingerprint] || change.details["actual_fingerprint"],
225
+ "backup_directory" => @backed_up_files ? files.backup_directory : nil,
226
+ "backed_up_files" => @backed_up_files || []
227
+ }
228
+ end
229
+
230
+ def managed? = @managed_state.managed?
231
+ def managed_state = @managed_state.current
232
+
233
+ def files
234
+ @files ||= ServiceFiles.new(
235
+ config: config, type: @type, transport: transport, state_store: state_store,
236
+ resource: resource, fingerprint: -> { fingerprint }
237
+ )
238
+ end
239
+
240
+ def firewall
241
+ @firewall ||= ServiceFirewall.new(
242
+ config: config, type: @type, service: @service, transport: transport,
243
+ state_store: state_store, resource: resource
244
+ )
245
+ end
246
+
247
+ def backup_operation
248
+ @backup_operation ||= ServiceBackup.new(
249
+ config: config, type: @type, transport: transport, compose: method(:compose), clock: @clock
250
+ )
251
+ end
252
+
253
+ def raise_remote!(result, action)
254
+ return if result.success?
255
+
256
+ raise Errors::RemoteCommandError.new(
257
+ "unable to #{action} for #{@type}",
258
+ context: { stderr: result.stderr, exit_status: result.exit_status }
259
+ )
260
+ end
261
+
262
+ def pending_change
263
+ Change.new(resource: resource, action: "create", summary: "Install #{@type} after Docker is ready",
264
+ details: safe_details("").merge(depends_on: "docker"))
265
+ end
266
+
267
+ def unchanged_change
268
+ Change.new(resource: resource, action: "no_change", summary: "#{@type} is configured",
269
+ details: safe_details(fingerprint))
270
+ end
271
+
272
+ def safe_details(actual)
273
+ {
274
+ image: service.image, published: service.publish,
275
+ bind: service.publish ? service.bind : nil, port: service.publish ? service.port : nil,
276
+ fingerprint: fingerprint,
277
+ actual_fingerprint: actual.empty? ? nil : actual
278
+ }
279
+ end
280
+
281
+ def error_name(error) = error.respond_to?(:code) ? error.code : error.class.name
282
+ end
283
+ end
284
+ end
285
+ end