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,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../errors"
5
+ require_relative "../plan"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ module Operations
10
+ class RemoteScript
11
+ MARKER_DIRECTORY = "/var/lib/kitsune/state"
12
+
13
+ attr_reader :resource
14
+
15
+ def initialize(config:, transport_factory:, state_store:, resource:, script_path:, arguments: [],
16
+ transport_roles: { apply: :deploy }, verification: {})
17
+ @config = config
18
+ @transport_factory = transport_factory
19
+ @state_store = state_store
20
+ @resource = resource
21
+ @script_path = script_path
22
+ @arguments = arguments.map(&:to_s).freeze
23
+ @transport_roles = transport_roles
24
+ @post_verify = verification[:callback]
25
+ @finalize_action = verification[:finalize_action]
26
+ @rollback_on_failure = verification.fetch(:rollback_on_failure, false)
27
+ end
28
+
29
+ def plan
30
+ return pending_change unless transport_factory.server&.public_ip
31
+
32
+ actual = remote_fingerprint
33
+ return unchanged_change if actual == fingerprint
34
+
35
+ Change.new(
36
+ resource: resource,
37
+ action: actual.empty? ? "create" : "update",
38
+ summary: actual.empty? ? "Configure #{resource}" : "Update #{resource}",
39
+ details: { fingerprint: fingerprint, actual_fingerprint: actual.empty? ? nil : actual }
40
+ )
41
+ rescue Errors::ConnectionError
42
+ pending_change
43
+ end
44
+
45
+ def apply(change)
46
+ return true unless change.changed?
47
+
48
+ remote = "/tmp/kitsune-#{safe_resource}-#{fingerprint[0, 12]}.sh"
49
+ applied = false
50
+ current_transport.with_session do
51
+ current_transport.upload(content: script, remote_path: remote, mode: "0700")
52
+ execute_script(remote, "apply")
53
+ applied = true
54
+ execute_script(remote, "verify")
55
+ @post_verify&.call
56
+ finalize(remote)
57
+ write_marker
58
+ record_state(change)
59
+ true
60
+ rescue StandardError => e
61
+ recover_verified_transition(remote, e) if applied && @rollback_on_failure
62
+ raise e
63
+ ensure
64
+ cleanup(remote)
65
+ end
66
+ end
67
+
68
+ def rollback
69
+ state = state_store.read(config.environment).dig("resources", resource)
70
+ return false unless state && state["managed"]
71
+
72
+ remote = "/tmp/kitsune-#{safe_resource}-rollback.sh"
73
+ rollback_transport.upload(content: script, remote_path: remote, mode: "0700")
74
+ execute_script(remote, "rollback", transport: rollback_transport)
75
+ rollback_transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
76
+ state_store.update(config.environment) do |current|
77
+ current["resources"].delete(resource)
78
+ current["operations"] << { "resource" => resource, "action" => "rollback", "status" => "applied" }
79
+ current
80
+ end
81
+ true
82
+ ensure
83
+ cleanup(remote, transport: rollback_transport) if defined?(remote) && remote
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :config, :transport_factory, :state_store
89
+
90
+ def script = @script ||= File.read(@script_path)
91
+ def fingerprint = @fingerprint ||= Digest::SHA256.hexdigest([script, *@arguments].join("\0"))
92
+ def safe_resource = resource.tr(".", "-")
93
+ def marker_path = "#{MARKER_DIRECTORY}/#{safe_resource}.sha256"
94
+
95
+ def current_transport
96
+ @current_transport ||= transport_factory.public_send(@transport_roles.fetch(:apply, :deploy))
97
+ end
98
+
99
+ def rollback_transport
100
+ role = @transport_roles.fetch(:rollback) { @transport_roles.fetch(:apply, :deploy) }
101
+ @rollback_transport ||= transport_factory.public_send(role)
102
+ end
103
+
104
+ def remote_fingerprint
105
+ result = current_transport.execute("sudo", arguments: ["cat", marker_path], timeout: 10)
106
+ result.success? ? result.stdout.strip : ""
107
+ end
108
+
109
+ def execute_script(remote, action, transport: current_transport)
110
+ result = transport.execute(
111
+ "sudo",
112
+ arguments: ["bash", remote, action, *@arguments],
113
+ timeout: 300
114
+ )
115
+ return result if result.success?
116
+
117
+ raise Errors::RemoteCommandError.new(
118
+ "#{resource} #{action} failed with status #{result.exit_status}",
119
+ hint: "Review the remote command error and resume apply after correcting it.",
120
+ context: { stderr: result.stderr, stdout: result.stdout }
121
+ )
122
+ end
123
+
124
+ def finalize(remote)
125
+ return unless @finalize_action
126
+
127
+ execute_script(remote, @finalize_action)
128
+ execute_script(remote, "verify_final")
129
+ @post_verify&.call
130
+ end
131
+
132
+ def write_marker
133
+ directory = current_transport.execute(
134
+ "sudo", arguments: ["install", "-d", "-m", "0755", MARKER_DIRECTORY], timeout: 10
135
+ )
136
+ raise_remote_result!(directory, "create marker directory")
137
+ marker = current_transport.execute(
138
+ "sudo", arguments: ["tee", marker_path], stdin: "#{fingerprint}\n", timeout: 10
139
+ )
140
+ raise_remote_result!(marker, "write marker")
141
+ mode = current_transport.execute("sudo", arguments: ["chmod", "0644", marker_path], timeout: 10)
142
+ raise_remote_result!(mode, "secure marker")
143
+ end
144
+
145
+ def raise_remote_result!(result, action)
146
+ return if result.success?
147
+
148
+ raise Errors::RemoteCommandError.new("unable to #{action} for #{resource}",
149
+ context: { stderr: result.stderr })
150
+ end
151
+
152
+ def record_state(change)
153
+ state_store.update(config.environment) do |state|
154
+ state["resources"][resource] = {
155
+ "managed" => true,
156
+ "fingerprint" => fingerprint,
157
+ "action" => change.action
158
+ }
159
+ state["operations"] << { "resource" => resource, "action" => change.action, "status" => "applied" }
160
+ state
161
+ end
162
+ end
163
+
164
+ def pending_change
165
+ Change.new(
166
+ resource: resource,
167
+ action: "create",
168
+ summary: "Configure #{resource} after the server is ready",
169
+ details: { fingerprint: fingerprint, depends_on: "server" }
170
+ )
171
+ end
172
+
173
+ def unchanged_change
174
+ Change.new(
175
+ resource: resource,
176
+ action: "no_change",
177
+ summary: "#{resource} is configured",
178
+ details: { fingerprint: fingerprint }
179
+ )
180
+ end
181
+
182
+ def cleanup(remote, transport: current_transport)
183
+ transport.execute("rm", arguments: ["-f", remote], timeout: 10)
184
+ rescue StandardError
185
+ nil
186
+ end
187
+
188
+ def recover_verified_transition(remote, original_error)
189
+ execute_script(remote, "rollback")
190
+ marker = current_transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
191
+ raise_remote_result!(marker, "remove marker during recovery")
192
+ state_store.update(config.environment) do |state|
193
+ if state["resources"].delete(resource)
194
+ state["operations"] << {
195
+ "resource" => resource, "action" => "recover", "status" => "applied"
196
+ }
197
+ end
198
+ state
199
+ end
200
+ rescue StandardError => e
201
+ raise Errors::VerificationError.new(
202
+ "#{resource} verification failed and the preserved SSH session could not restore it",
203
+ hint: "Keep the current terminal open and use provider-console access before retrying.",
204
+ context: {
205
+ original_error: error_name(original_error), recovery_error: error_name(e)
206
+ }
207
+ )
208
+ end
209
+
210
+ def error_name(error) = error.respond_to?(:code) ? error.code : error.class.name
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Operations
8
+ class ServiceBackup
9
+ BACKUP_IMAGE = "alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"
10
+
11
+ def initialize(config:, type:, transport:, compose:, clock: -> { Time.now.utc })
12
+ @config = config
13
+ @type = type
14
+ @transport = transport
15
+ @compose = compose
16
+ @clock = clock
17
+ end
18
+
19
+ def call(state)
20
+ unless state&.fetch("installed", true)
21
+ raise Errors::UnsafeOperationError.new(
22
+ "#{type} is not installed",
23
+ hint: "Install and verify the service before creating a data backup."
24
+ )
25
+ end
26
+
27
+ directory = "/home/#{config.ssh.user}/.local/share/kitsune/backups/#{type}"
28
+ path = "#{directory}/#{type}-#{clock.call.strftime('%Y%m%dT%H%M%S')}.tar.gz"
29
+ create_directory(directory)
30
+ create_archive(directory, path, state.fetch("volume"))
31
+ path
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :config, :type, :transport, :compose, :clock
37
+
38
+ def create_directory(directory)
39
+ result = transport.execute("mkdir", arguments: ["-p", directory], timeout: 10)
40
+ raise_remote!(result, "create backup directory")
41
+ end
42
+
43
+ def create_archive(directory, path, volume)
44
+ paused = false
45
+ compose.call("pause")
46
+ paused = true
47
+ result = transport.execute(
48
+ "docker",
49
+ arguments: ["run", "--rm", "--volume", "#{volume}:/data:ro", "--volume", "#{directory}:/backup",
50
+ BACKUP_IMAGE, "tar", "-czf", "/backup/#{File.basename(path)}", "-C", "/data", "."],
51
+ timeout: 600
52
+ )
53
+ raise_remote!(result, "back up service data")
54
+ result = transport.execute("chmod", arguments: ["0600", path], timeout: 10)
55
+ raise_remote!(result, "secure service backup")
56
+ ensure
57
+ compose.call("unpause") if paused
58
+ end
59
+
60
+ def raise_remote!(result, action)
61
+ return if result.success?
62
+
63
+ raise Errors::RemoteCommandError.new(
64
+ "unable to #{action} for #{type}",
65
+ context: { stderr: result.stderr, exit_status: result.exit_status }
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Operations
8
+ class ServiceFiles
9
+ def initialize(config:, type:, transport:, state_store:, resource:, fingerprint:)
10
+ @config = config
11
+ @type = type
12
+ @transport = transport
13
+ @state_store = state_store
14
+ @resource = resource
15
+ @fingerprint = fingerprint
16
+ end
17
+
18
+ def directory = "/home/#{config.ssh.user}/.local/share/kitsune/services/#{type}"
19
+ def compose_path = "#{directory}/compose.yml"
20
+ def env_path = "#{directory}/.env"
21
+ def backup_directory = "/var/lib/kitsune/backups/service-#{type}-#{fingerprint.call[0, 12]}"
22
+
23
+ def ensure_managed_or_absent!(actual_fingerprint)
24
+ return if managed?
25
+ return if actual_fingerprint.empty? && !exists?(compose_path) && !exists?(env_path)
26
+
27
+ raise Errors::UnsafeOperationError.new(
28
+ "refusing to replace unmanaged #{type} service files",
29
+ hint: "Restore the local state backup or move the existing files before applying again.",
30
+ context: { directory: directory }
31
+ )
32
+ end
33
+
34
+ def prepare_backup
35
+ result = transport.execute(
36
+ "sudo",
37
+ arguments: ["install", "-d", "-m", "0700", "-o", config.ssh.user, "-g", config.ssh.user,
38
+ backup_directory],
39
+ timeout: 20
40
+ )
41
+ raise_remote!(result, "create service backup directory")
42
+ [compose_path, env_path].select { |path| exists?(path) }.tap do |paths|
43
+ paths.each { |path| copy(path, backup_directory) }
44
+ end
45
+ end
46
+
47
+ def restore_after_failure(backed_up_files)
48
+ if backed_up_files.empty?
49
+ transport.execute("rm", arguments: ["-f", compose_path, env_path], timeout: 20)
50
+ else
51
+ backed_up_files.each { |path| copy("#{backup_directory}/#{File.basename(path)}", File.dirname(path)) }
52
+ end
53
+ end
54
+
55
+ def restore_previous(previous, marker_path:)
56
+ if previous["backup_directory"]
57
+ restore_files(previous["backup_directory"], previous.fetch("backed_up_files", []))
58
+ restore_marker(marker_path, previous["previous_fingerprint"])
59
+ remove_backup(previous["backup_directory"])
60
+ true
61
+ else
62
+ transport.execute("rm", arguments: ["-rf", directory], timeout: 30)
63
+ transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
64
+ false
65
+ end
66
+ end
67
+
68
+ def destroy(entry, marker_path:)
69
+ result = transport.execute("rm", arguments: ["-rf", directory], timeout: 30)
70
+ raise_remote!(result, "remove service files")
71
+ result = transport.execute("sudo", arguments: ["rm", "-f", marker_path], timeout: 10)
72
+ raise_remote!(result, "remove service marker")
73
+ backup_directories(entry).each { |backup| remove_backup(backup) }
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :config, :type, :transport, :state_store, :resource, :fingerprint
79
+
80
+ def managed? = !!state_store.read(config.environment).dig("resources", resource, "managed")
81
+ def exists?(path) = transport.execute("test", arguments: ["-e", path], timeout: 10).success?
82
+
83
+ def restore_files(directory, files)
84
+ files.each { |path| copy("#{directory}/#{File.basename(path)}", File.dirname(path)) }
85
+ end
86
+
87
+ def copy(source, destination)
88
+ result = transport.execute(
89
+ "cp", arguments: ["--preserve=mode,ownership", source, destination], timeout: 20
90
+ )
91
+ raise_remote!(result, "copy service files")
92
+ end
93
+
94
+ def restore_marker(path, value)
95
+ return transport.execute("sudo", arguments: ["rm", "-f", path], timeout: 10) unless value
96
+
97
+ result = transport.execute("sudo", arguments: ["tee", path], stdin: "#{value}\n", timeout: 10)
98
+ raise_remote!(result, "restore service marker")
99
+ end
100
+
101
+ def remove_backup(directory)
102
+ result = transport.execute("sudo", arguments: ["rm", "-rf", directory], timeout: 30)
103
+ raise_remote!(result, "remove restored service backup")
104
+ end
105
+
106
+ def backup_directories(entry)
107
+ current = entry
108
+ [].tap do |directories|
109
+ while current
110
+ directories << current["backup_directory"] if current["backup_directory"]
111
+ current = current["previous_state"]
112
+ end
113
+ end.uniq
114
+ end
115
+
116
+ def raise_remote!(result, action)
117
+ return if result.success?
118
+
119
+ raise Errors::RemoteCommandError.new(
120
+ "unable to #{action} for #{type}",
121
+ context: { stderr: result.stderr, exit_status: result.exit_status }
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Operations
8
+ class ServiceFirewall
9
+ attr_reader :owned_rules, :drop_owned
10
+
11
+ def initialize(config:, type:, service:, transport:, state_store:, resource:)
12
+ @config = config
13
+ @type = type
14
+ @service = service
15
+ @transport = transport
16
+ @state_store = state_store
17
+ @resource = resource
18
+ @owned_rules = []
19
+ @drop_owned = false
20
+ end
21
+
22
+ def reconcile(previous)
23
+ unless service.publish
24
+ remove_state(previous)
25
+ return
26
+ end
27
+
28
+ @transaction_added = []
29
+ @transaction_deleted = []
30
+ ensure_drop_rule(previous, tracked: true)
31
+ added = service.allowed_cidrs.reject { |cidr| rule_exists?(allow_rule(cidr)) }.each do |cidr|
32
+ add_tracked_rule(allow_rule(cidr))
33
+ end
34
+ retained = previous_rules_for_current_port(previous) & service.allowed_cidrs
35
+ @owned_rules = (retained + added).uniq
36
+ remove_stale(previous, tracked: true)
37
+ rescue StandardError => e
38
+ begin
39
+ rollback_transaction
40
+ rescue StandardError => recovery_error
41
+ raise Errors::VerificationError.new(
42
+ "Docker firewall reconciliation and recovery both failed",
43
+ hint: "Inspect DOCKER-USER rules marked by Kitsune Kit before retrying.",
44
+ context: {
45
+ original_error: error_name(e), recovery_error: error_name(recovery_error)
46
+ }
47
+ )
48
+ end
49
+ raise e
50
+ ensure
51
+ @transaction_added = nil
52
+ @transaction_deleted = nil
53
+ end
54
+
55
+ def remove
56
+ remove_state(managed_state)
57
+ end
58
+
59
+ def restore(previous)
60
+ return unless previous["published"]
61
+
62
+ port = previous.fetch("port")
63
+ ensure_rule(drop_rule(port: port))
64
+ previous.fetch("firewall_rules_added", []).each do |cidr|
65
+ ensure_rule(allow_rule(cidr, port: port))
66
+ end
67
+ end
68
+
69
+ def restore_after_failure(previous)
70
+ remove_state(
71
+ "published" => service.publish,
72
+ "port" => service.port,
73
+ "firewall_rules_added" => @owned_rules,
74
+ "firewall_drop_added" => @drop_owned
75
+ )
76
+ restore(previous || {})
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :config, :type, :service, :transport, :state_store, :resource
82
+
83
+ def ensure_drop_rule(previous, tracked: false)
84
+ existed = rule_exists?(drop_rule)
85
+ unless existed
86
+ tracked ? add_tracked_rule(drop_rule) : add_rule(drop_rule, position: 1)
87
+ end
88
+ previously_owned = previous&.fetch("published", false) && previous.fetch("port") == service.port &&
89
+ previous["firewall_drop_added"]
90
+ @drop_owned = previously_owned || !existed
91
+ end
92
+
93
+ def remove_state(previous)
94
+ return unless previous&.fetch("published", false)
95
+
96
+ port = previous.fetch("port")
97
+ previous.fetch("firewall_rules_added", []).each { |cidr| delete_rule(allow_rule(cidr, port: port)) }
98
+ delete_rule(drop_rule(port: port)) if previous["firewall_drop_added"]
99
+ end
100
+
101
+ def remove_stale(previous, tracked: false)
102
+ return unless previous&.fetch("published", false)
103
+
104
+ previous_port = previous.fetch("port")
105
+ previous.fetch("firewall_rules_added", []).each do |cidr|
106
+ next if previous_port == service.port && service.allowed_cidrs.include?(cidr)
107
+
108
+ rule = allow_rule(cidr, port: previous_port)
109
+ tracked ? delete_tracked_rule(rule) : delete_rule(rule)
110
+ end
111
+ return unless previous["firewall_drop_added"] && previous_port != service.port
112
+
113
+ rule = drop_rule(port: previous_port)
114
+ tracked ? delete_tracked_rule(rule) : delete_rule(rule)
115
+ end
116
+
117
+ def previous_rules_for_current_port(previous)
118
+ return [] unless previous&.fetch("published", false) && previous.fetch("port") == service.port
119
+
120
+ previous.fetch("firewall_rules_added", [])
121
+ end
122
+
123
+ def allow_rule(cidr, port: service.port)
124
+ common_rule(port) + ["-s", cidr, "-m", "comment", "--comment", "kitsune:#{type}:allow", "-j", "ACCEPT"]
125
+ end
126
+
127
+ def drop_rule(port: service.port)
128
+ common_rule(port) + ["-m", "comment", "--comment", "kitsune:#{type}:drop", "-j", "DROP"]
129
+ end
130
+
131
+ def common_rule(port)
132
+ ["DOCKER-USER", "-p", "tcp", "-m", "conntrack", "--ctorigdstport", port.to_s,
133
+ "--ctdir", "ORIGINAL"]
134
+ end
135
+
136
+ def ensure_rule(rule)
137
+ add_rule(rule, position: 1) unless rule_exists?(rule)
138
+ end
139
+
140
+ def rule_exists?(rule)
141
+ transport.execute("sudo", arguments: ["iptables", "-C", *rule], timeout: 20).success?
142
+ end
143
+
144
+ def add_rule(rule, position:)
145
+ result = transport.execute(
146
+ "sudo",
147
+ arguments: ["iptables", "-I", rule.first, position.to_s, *rule.drop(1)],
148
+ timeout: 20
149
+ )
150
+ raise_remote!(result, "add Docker firewall rule")
151
+ end
152
+
153
+ def add_tracked_rule(rule)
154
+ add_rule(rule, position: 1)
155
+ @transaction_added << rule
156
+ end
157
+
158
+ def delete_rule(rule)
159
+ result = transport.execute("sudo", arguments: ["iptables", "-D", *rule], timeout: 20)
160
+ return false if result.exit_status == 1
161
+
162
+ raise_remote!(result, "remove Docker firewall rule") unless result.success?
163
+ true
164
+ end
165
+
166
+ def delete_tracked_rule(rule)
167
+ @transaction_deleted << rule if delete_rule(rule)
168
+ end
169
+
170
+ def rollback_transaction
171
+ Array(@transaction_added).reverse_each { |rule| delete_rule(rule) }
172
+ Array(@transaction_deleted).reverse_each { |rule| ensure_rule(rule) }
173
+ end
174
+
175
+ def error_name(error) = error.respond_to?(:code) ? error.code : error.class.name
176
+
177
+ def managed_state = state_store.read(config.environment).dig("resources", resource)
178
+
179
+ def raise_remote!(result, action)
180
+ return if result.success?
181
+
182
+ raise Errors::RemoteCommandError.new(
183
+ "unable to #{action} for #{type}",
184
+ context: { stderr: result.stderr, exit_status: result.exit_status }
185
+ )
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Operations
6
+ class ServiceState
7
+ def initialize(state_store:, environment:, resource:)
8
+ @state_store = state_store
9
+ @environment = environment
10
+ @resource = resource
11
+ end
12
+
13
+ def current = state_store.read(environment).dig("resources", resource)
14
+ def managed? = !!current&.fetch("managed", false)
15
+
16
+ def record_apply(action, attributes)
17
+ update do |state|
18
+ previous = state["resources"][resource]
19
+ state["resources"][resource] = attributes.merge("previous_state" => previous)
20
+ operation(state, action)
21
+ end
22
+ end
23
+
24
+ def record_remove
25
+ update do |state|
26
+ state["resources"][resource]["installed"] = false
27
+ operation(state, "remove")
28
+ end
29
+ end
30
+
31
+ def record_rollback(previous)
32
+ update do |state|
33
+ prior = previous["previous_state"]
34
+ prior ? state["resources"][resource] = prior : state["resources"].delete(resource)
35
+ operation(state, "rollback")
36
+ end
37
+ end
38
+
39
+ def delete(action)
40
+ update do |state|
41
+ state["resources"].delete(resource)
42
+ operation(state, action)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :state_store, :environment, :resource
49
+
50
+ def update(&) = state_store.update(environment, &)
51
+
52
+ def operation(state, action)
53
+ state["operations"] << { "resource" => resource, "action" => action, "status" => "applied" }
54
+ state
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end