kitsune-kit 0.4.1 → 0.6.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +112 -135
  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/decisions/0007-compose-customization.md +30 -0
  14. data/docs/architecture/provider-adapters.md +49 -0
  15. data/docs/architecture.md +93 -0
  16. data/docs/commands.md +224 -0
  17. data/docs/configuration.md +226 -0
  18. data/docs/getting-started.md +133 -0
  19. data/docs/providers/digitalocean.md +52 -0
  20. data/docs/releasing.md +43 -0
  21. data/docs/roadmap.md +46 -0
  22. data/docs/security-audit.md +130 -0
  23. data/docs/security.md +116 -0
  24. data/docs/services/compose.md +164 -0
  25. data/docs/services/postgres.md +78 -0
  26. data/docs/services/redis.md +53 -0
  27. data/docs/testing.md +200 -0
  28. data/docs/troubleshooting.md +123 -0
  29. data/docs/tui.md +57 -0
  30. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  31. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  32. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  34. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  35. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  36. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  37. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  38. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  39. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  40. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  41. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  42. data/lib/kitsune/kit/application.rb +170 -0
  43. data/lib/kitsune/kit/cancellation.rb +21 -0
  44. data/lib/kitsune/kit/cli.rb +879 -64
  45. data/lib/kitsune/kit/clock.rb +11 -0
  46. data/lib/kitsune/kit/configuration.rb +577 -0
  47. data/lib/kitsune/kit/errors.rb +98 -0
  48. data/lib/kitsune/kit/events.rb +55 -0
  49. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  50. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  51. data/lib/kitsune/kit/operations/ensure_service.rb +302 -0
  52. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  53. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  54. data/lib/kitsune/kit/operations/service_files.rb +131 -0
  55. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  56. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  57. data/lib/kitsune/kit/plan.rb +72 -0
  58. data/lib/kitsune/kit/reporters/human.rb +89 -0
  59. data/lib/kitsune/kit/reporters/json.rb +65 -0
  60. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  61. data/lib/kitsune/kit/result.rb +28 -0
  62. data/lib/kitsune/kit/run_journal.rb +102 -0
  63. data/lib/kitsune/kit/run_logger.rb +37 -0
  64. data/lib/kitsune/kit/scripts/docker.sh +142 -0
  65. data/lib/kitsune/kit/scripts/firewall.sh +210 -0
  66. data/lib/kitsune/kit/scripts/metrics.sh +59 -0
  67. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  68. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  69. data/lib/kitsune/kit/scripts/unattended.sh +86 -0
  70. data/lib/kitsune/kit/scripts/user.sh +114 -0
  71. data/lib/kitsune/kit/secret_filter.rb +54 -0
  72. data/lib/kitsune/kit/secret_store.rb +32 -0
  73. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  74. data/lib/kitsune/kit/service_compose.rb +272 -0
  75. data/lib/kitsune/kit/state_store.rb +158 -0
  76. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  77. data/lib/kitsune/kit/tui/actions.rb +72 -0
  78. data/lib/kitsune/kit/tui/application.rb +35 -0
  79. data/lib/kitsune/kit/tui/controller.rb +162 -0
  80. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  81. data/lib/kitsune/kit/tui/state.rb +18 -0
  82. data/lib/kitsune/kit/tui/store.rb +88 -0
  83. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  84. data/lib/kitsune/kit/version.rb +1 -1
  85. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  86. data/lib/kitsune/kit/workflows/base.rb +31 -0
  87. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  88. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  89. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  90. data/lib/kitsune/kit/workflows/eject_compose.rb +82 -0
  91. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  92. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  93. data/lib/kitsune/kit/workflows/initialize_project.rb +129 -0
  94. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  95. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  96. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  97. data/lib/kitsune/kit.rb +42 -2
  98. metadata +125 -79
  99. data/.rspec +0 -3
  100. data/Rakefile +0 -8
  101. data/kitsune-kit-logo.jpg +0 -0
  102. data/lib/kitsune/blueprints/.env.template +0 -31
  103. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  104. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  105. data/lib/kitsune/blueprints/kit.env.template +0 -1
  106. data/lib/kitsune/kit/ansi_color.rb +0 -78
  107. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  108. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  109. data/lib/kitsune/kit/commands/dns.rb +0 -112
  110. data/lib/kitsune/kit/commands/init.rb +0 -148
  111. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  112. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  113. data/lib/kitsune/kit/commands/provision.rb +0 -43
  114. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  115. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  116. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  117. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  118. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  119. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  120. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  121. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  122. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  123. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  124. data/lib/kitsune/kit/defaults.rb +0 -91
  125. data/lib/kitsune/kit/env_loader.rb +0 -41
  126. data/lib/kitsune/kit/options_builder.rb +0 -26
  127. data/lib/kitsune/kit/provisioner.rb +0 -107
  128. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Events
9
+ SCHEMA_VERSION = 1
10
+
11
+ class Event < Data.define(:id, :type, :time, :run_id, :data)
12
+ def self.build(type, run_id:, time: Time.now.utc, **data)
13
+ new(
14
+ id: SecureRandom.uuid,
15
+ type: type.to_s,
16
+ time: time.iso8601(6),
17
+ run_id: run_id,
18
+ data: data.freeze
19
+ )
20
+ end
21
+
22
+ def to_h
23
+ { schema_version: SCHEMA_VERSION, id: id, type: type, time: time, run_id: run_id, data: data }
24
+ end
25
+ end
26
+
27
+ class Bus
28
+ def initialize
29
+ @subscribers = []
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ def subscribe(subscriber = nil, &block)
34
+ target = subscriber || block
35
+ raise ArgumentError, "subscriber or block is required" unless target
36
+
37
+ @mutex.synchronize { @subscribers << target }
38
+ target
39
+ end
40
+
41
+ def publish(event)
42
+ @mutex.synchronize { @subscribers.dup }.each do |subscriber|
43
+ subscriber.respond_to?(:call) ? subscriber.call(event) : subscriber.handle(event)
44
+ end
45
+ event
46
+ end
47
+ end
48
+
49
+ class NullBus
50
+ def subscribe(*) = nil
51
+ def publish(event) = event
52
+ end
53
+ end
54
+ end
55
+ end
@@ -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,302 @@
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
+ # Service lifecycle coordination intentionally lives together so apply and rollback share one transaction model.
16
+ class EnsureService # rubocop:disable Metrics/ClassLength
17
+ TYPES = %w[postgres redis].freeze
18
+ MARKER_DIRECTORY = "/var/lib/kitsune/state"
19
+
20
+ attr_reader :resource
21
+
22
+ def initialize(config:, type:, transport_factory:, state_store:, secret_store:, clock: -> { Time.now.utc })
23
+ @config = config
24
+ @type = type.to_s
25
+ raise ArgumentError, "unknown service type: #{@type}" unless TYPES.include?(@type)
26
+
27
+ @service = config.services.public_send(@type)
28
+ @transport_factory = transport_factory
29
+ @state_store = state_store
30
+ @secret_store = secret_store
31
+ @resource = "service.#{@type}"
32
+ @managed_state = ServiceState.new(state_store: state_store, environment: config.environment,
33
+ resource: resource)
34
+ @compose = ServiceCompose.new(config: config, type: @type, service: @service)
35
+ @clock = clock
36
+ end
37
+
38
+ def plan
39
+ return pending_change unless transport_factory.server&.public_ip
40
+
41
+ actual = remote_fingerprint
42
+ files.ensure_managed_or_absent!(actual)
43
+ return unchanged_change if actual == fingerprint
44
+
45
+ Change.new(
46
+ resource: resource,
47
+ action: actual.empty? ? "create" : "update",
48
+ summary: "#{actual.empty? ? 'Install' : 'Update'} #{@type} service",
49
+ details: safe_details(actual)
50
+ )
51
+ rescue Errors::ConnectionError
52
+ pending_change
53
+ end
54
+
55
+ def apply(change)
56
+ return true unless change.changed?
57
+
58
+ @firewall_changed = false
59
+ previous_state = managed_state
60
+ password = validated_secret
61
+ files.ensure_managed_or_absent!(remote_fingerprint)
62
+ prepare_backup
63
+ transport.execute("mkdir", arguments: ["-p", service_directory], timeout: 10).then do |result|
64
+ raise_remote!(result, "create service directory")
65
+ end
66
+ @compose.documents.each do |document|
67
+ transport.upload(
68
+ content: document.content, remote_path: files.compose_path(document.filename), mode: "0600"
69
+ )
70
+ end
71
+ transport.upload(content: env_content(password), remote_path: env_path, mode: "0600")
72
+ validate_compose
73
+ start_service
74
+ verify_service
75
+ firewall.reconcile(managed_state)
76
+ @firewall_changed = true
77
+ write_marker
78
+ record_state(change)
79
+ true
80
+ rescue StandardError => e
81
+ recover_failed_apply(previous_state, e)
82
+ end
83
+
84
+ def remove
85
+ return false unless managed?
86
+
87
+ compose("down", "--remove-orphans", filenames: installed_compose_files)
88
+ firewall.remove
89
+ remove_marker
90
+ @managed_state.record_remove
91
+ true
92
+ end
93
+
94
+ def backup
95
+ backup_operation.call(managed_state)
96
+ end
97
+
98
+ def rollback
99
+ previous = managed_state
100
+ return false unless previous
101
+
102
+ remove
103
+ if files.restore_previous(previous, marker_path: marker_path)
104
+ firewall.restore(previous["previous_state"] || {})
105
+ start_service(filenames: previous.dig("previous_state", "compose_files")) if
106
+ previous.dig("previous_state", "installed") != false
107
+ end
108
+ @managed_state.record_rollback(previous)
109
+ true
110
+ end
111
+
112
+ def destroy_data
113
+ previous = managed_state
114
+ return false unless previous
115
+
116
+ compose("down", "--volumes", "--remove-orphans", filenames: installed_compose_files)
117
+ files.destroy(previous, marker_path: marker_path)
118
+ @managed_state.delete("destroy_data")
119
+ true
120
+ end
121
+
122
+ private
123
+
124
+ attr_reader :config, :service, :transport_factory, :state_store, :secret_store
125
+
126
+ def transport = @transport ||= transport_factory.deploy
127
+ def project_name = "kitsune-#{config.environment}-#{@type}"
128
+ def service_directory = files.directory
129
+ def env_path = files.env_path
130
+ def marker_path = "#{MARKER_DIRECTORY}/service-#{@type}.sha256"
131
+
132
+ def fingerprint
133
+ @fingerprint ||= Digest::SHA256.hexdigest(
134
+ [@compose.fingerprint, service.password_env, secret_digest, service.publish, service.bind,
135
+ *service.allowed_cidrs].join("\0")
136
+ )
137
+ end
138
+
139
+ def secret_digest = Digest::SHA256.hexdigest(validated_secret)
140
+
141
+ def remote_fingerprint
142
+ result = transport.execute("sudo", arguments: ["cat", marker_path], timeout: 10)
143
+ result.success? ? result.stdout.strip : ""
144
+ end
145
+
146
+ def validated_secret
147
+ value = secret_store.fetch(service.password_env)
148
+ if value.match?(/[\n\r\0]/)
149
+ raise Errors::ConfigurationError, "#{service.password_env} contains unsupported control characters"
150
+ end
151
+
152
+ value
153
+ end
154
+
155
+ def env_content(password) = @compose.env_content(password)
156
+
157
+ def validate_compose = compose("config", "--quiet")
158
+
159
+ def start_service(filenames: nil)
160
+ compose("up", "--detach", "--wait", "--wait-timeout", "120", filenames: filenames)
161
+ end
162
+
163
+ def verify_service
164
+ result = compose("ps", "--status", "running", "--quiet")
165
+ raise Errors::VerificationError, "#{@type} did not reach running state" if result.stdout.strip.empty?
166
+ end
167
+
168
+ def compose(*arguments, filenames: nil)
169
+ result = transport.execute(
170
+ "docker",
171
+ arguments: ["compose", "--project-directory", service_directory,
172
+ *compose_file_arguments(filenames), *arguments],
173
+ timeout: 180
174
+ )
175
+ raise_remote!(result, "docker compose #{arguments.first}")
176
+ result
177
+ end
178
+
179
+ def prepare_backup = @backed_up_files = managed? ? files.prepare_backup : []
180
+
181
+ def restore_after_failed_apply(previous_state)
182
+ return unless defined?(@backed_up_files)
183
+
184
+ firewall.restore_after_failure(previous_state) if @firewall_changed
185
+ safe_compose_down
186
+ files.restore_after_failure(@backed_up_files)
187
+ start_service(filenames: previous_state&.fetch("compose_files", nil)) unless @backed_up_files.empty?
188
+ end
189
+
190
+ def recover_failed_apply(previous_state, original_error)
191
+ restore_after_failed_apply(previous_state)
192
+ rescue StandardError => e
193
+ raise Errors::VerificationError.new(
194
+ "#{@type} apply failed and automatic recovery also failed",
195
+ hint: "Stop changes and inspect the service files, container, volume and firewall before retrying.",
196
+ context: { original_error: error_name(original_error), recovery_error: error_name(e) }
197
+ )
198
+ else
199
+ raise original_error
200
+ end
201
+
202
+ def safe_compose_down
203
+ transport.execute(
204
+ "docker",
205
+ arguments: ["compose", "--project-directory", service_directory, *compose_file_arguments,
206
+ "down", "--remove-orphans"],
207
+ timeout: 180
208
+ )
209
+ end
210
+
211
+ def write_marker
212
+ transport.execute("sudo", arguments: ["install", "-d", "-m", "0755", MARKER_DIRECTORY], timeout: 10)
213
+ result = transport.execute("sudo", arguments: ["tee", marker_path], stdin: "#{fingerprint}\n", timeout: 10)
214
+ raise_remote!(result, "write service marker")
215
+ end
216
+
217
+ def remove_marker
218
+ result = transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
219
+ raise_remote!(result, "remove service marker")
220
+ end
221
+
222
+ def record_state(change)
223
+ @managed_state.record_apply(change.action, state_attributes(change))
224
+ end
225
+
226
+ def state_attributes(change)
227
+ {
228
+ "managed" => true, "installed" => true, "fingerprint" => fingerprint, "project" => project_name,
229
+ "directory" => service_directory, "published" => service.publish, "port" => service.port,
230
+ "compose_mode" => @compose.mode, "compose_files" => @compose.filenames,
231
+ "compose_fingerprint" => @compose.fingerprint,
232
+ "firewall_rules_added" => firewall.owned_rules, "firewall_drop_added" => firewall.drop_owned,
233
+ "volume" => "#{project_name}_data",
234
+ "previous_fingerprint" => change.details[:actual_fingerprint] || change.details["actual_fingerprint"],
235
+ "backup_directory" => @backed_up_files ? files.backup_directory : nil,
236
+ "backed_up_files" => @backed_up_files || []
237
+ }
238
+ end
239
+
240
+ def managed? = @managed_state.managed?
241
+ def managed_state = @managed_state.current
242
+ def installed_compose_files = managed_state&.fetch("compose_files", nil)
243
+
244
+ def files
245
+ @files ||= ServiceFiles.new(
246
+ config: config, type: @type, transport: transport, state_store: state_store,
247
+ resource: resource, fingerprint: -> { fingerprint }, compose_filenames: -> { @compose.filenames }
248
+ )
249
+ end
250
+
251
+ def firewall
252
+ @firewall ||= ServiceFirewall.new(
253
+ config: config, type: @type, service: @service, transport: transport,
254
+ state_store: state_store, resource: resource
255
+ )
256
+ end
257
+
258
+ def backup_operation
259
+ @backup_operation ||= ServiceBackup.new(
260
+ config: config, type: @type, transport: transport,
261
+ compose: ->(*arguments) { compose(*arguments, filenames: installed_compose_files) }, clock: @clock
262
+ )
263
+ end
264
+
265
+ def raise_remote!(result, action)
266
+ return if result.success?
267
+
268
+ raise Errors::RemoteCommandError.new(
269
+ "unable to #{action} for #{@type}",
270
+ context: { stderr: result.stderr, exit_status: result.exit_status }
271
+ )
272
+ end
273
+
274
+ def pending_change
275
+ Change.new(resource: resource, action: "create", summary: "Install #{@type} after Docker is ready",
276
+ details: safe_details("").merge(depends_on: "docker"))
277
+ end
278
+
279
+ def unchanged_change
280
+ Change.new(resource: resource, action: "no_change", summary: "#{@type} is configured",
281
+ details: safe_details(fingerprint))
282
+ end
283
+
284
+ def safe_details(actual)
285
+ {
286
+ image: service.image, published: service.publish,
287
+ bind: service.publish ? service.bind : nil, port: service.publish ? service.port : nil,
288
+ fingerprint: fingerprint,
289
+ actual_fingerprint: actual.empty? ? nil : actual,
290
+ compose: @compose.metadata
291
+ }
292
+ end
293
+
294
+ def compose_file_arguments(filenames = nil)
295
+ Array(filenames || @compose.filenames).flat_map { |filename| ["--file", files.compose_path(filename)] }
296
+ end
297
+
298
+ def error_name(error) = error.respond_to?(:code) ? error.code : error.class.name
299
+ end
300
+ end
301
+ end
302
+ end