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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -2
- data/CONTRIBUTING.md +83 -0
- data/README.md +108 -136
- data/SECURITY.md +25 -0
- data/bin/kit +4 -1
- data/docs/architecture/decisions/0001-product-boundary.md +23 -0
- data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
- data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
- data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
- data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
- data/docs/architecture/decisions/0006-tui-backend.md +21 -0
- data/docs/architecture/provider-adapters.md +49 -0
- data/docs/architecture.md +89 -0
- data/docs/commands.md +210 -0
- data/docs/configuration.md +210 -0
- data/docs/getting-started.md +133 -0
- data/docs/providers/digitalocean.md +52 -0
- data/docs/releasing.md +43 -0
- data/docs/roadmap.md +46 -0
- data/docs/security-audit.md +130 -0
- data/docs/security.md +116 -0
- data/docs/services/postgres.md +78 -0
- data/docs/services/redis.md +53 -0
- data/docs/testing.md +200 -0
- data/docs/troubleshooting.md +123 -0
- data/docs/tui.md +57 -0
- data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
- data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
- data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
- data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
- data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
- data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
- data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
- data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
- data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
- data/lib/kitsune/kit/adapters/provider.rb +34 -0
- data/lib/kitsune/kit/adapters/transport.rb +22 -0
- data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
- data/lib/kitsune/kit/application.rb +170 -0
- data/lib/kitsune/kit/cancellation.rb +21 -0
- data/lib/kitsune/kit/cli.rb +802 -64
- data/lib/kitsune/kit/clock.rb +11 -0
- data/lib/kitsune/kit/configuration.rb +493 -0
- data/lib/kitsune/kit/errors.rb +98 -0
- data/lib/kitsune/kit/events.rb +55 -0
- data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
- data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
- data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
- data/lib/kitsune/kit/operations/remote_script.rb +214 -0
- data/lib/kitsune/kit/operations/service_backup.rb +71 -0
- data/lib/kitsune/kit/operations/service_files.rb +127 -0
- data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
- data/lib/kitsune/kit/operations/service_state.rb +59 -0
- data/lib/kitsune/kit/plan.rb +72 -0
- data/lib/kitsune/kit/reporters/human.rb +89 -0
- data/lib/kitsune/kit/reporters/json.rb +65 -0
- data/lib/kitsune/kit/reporters/reporter.rb +11 -0
- data/lib/kitsune/kit/result.rb +28 -0
- data/lib/kitsune/kit/run_journal.rb +102 -0
- data/lib/kitsune/kit/run_logger.rb +37 -0
- data/lib/kitsune/kit/scripts/docker.sh +137 -0
- data/lib/kitsune/kit/scripts/firewall.sh +205 -0
- data/lib/kitsune/kit/scripts/metrics.sh +54 -0
- data/lib/kitsune/kit/scripts/ssh.sh +95 -0
- data/lib/kitsune/kit/scripts/swap.sh +86 -0
- data/lib/kitsune/kit/scripts/unattended.sh +81 -0
- data/lib/kitsune/kit/scripts/user.sh +114 -0
- data/lib/kitsune/kit/secret_filter.rb +54 -0
- data/lib/kitsune/kit/secret_store.rb +32 -0
- data/lib/kitsune/kit/secret_stores/store.rb +12 -0
- data/lib/kitsune/kit/service_compose.rb +72 -0
- data/lib/kitsune/kit/state_store.rb +158 -0
- data/lib/kitsune/kit/state_stores/store.rb +15 -0
- data/lib/kitsune/kit/tui/actions.rb +72 -0
- data/lib/kitsune/kit/tui/application.rb +35 -0
- data/lib/kitsune/kit/tui/controller.rb +162 -0
- data/lib/kitsune/kit/tui/renderer.rb +134 -0
- data/lib/kitsune/kit/tui/state.rb +18 -0
- data/lib/kitsune/kit/tui/store.rb +88 -0
- data/lib/kitsune/kit/tui/terminal.rb +95 -0
- data/lib/kitsune/kit/version.rb +1 -1
- data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
- data/lib/kitsune/kit/workflows/base.rb +31 -0
- data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
- data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
- data/lib/kitsune/kit/workflows/doctor.rb +225 -0
- data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
- data/lib/kitsune/kit/workflows/import_server.rb +100 -0
- data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
- data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
- data/lib/kitsune/kit/workflows/rollback.rb +54 -0
- data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
- data/lib/kitsune/kit.rb +41 -2
- metadata +122 -79
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/kitsune-kit-logo.jpg +0 -0
- data/lib/kitsune/blueprints/.env.template +0 -31
- data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
- data/lib/kitsune/blueprints/docker/redis.yml +0 -23
- data/lib/kitsune/blueprints/kit.env.template +0 -1
- data/lib/kitsune/kit/ansi_color.rb +0 -78
- data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
- data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
- data/lib/kitsune/kit/commands/dns.rb +0 -112
- data/lib/kitsune/kit/commands/init.rb +0 -148
- data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
- data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
- data/lib/kitsune/kit/commands/provision.rb +0 -43
- data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
- data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
- data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
- data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
- data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
- data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
- data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
- data/lib/kitsune/kit/commands/setup_user.rb +0 -189
- data/lib/kitsune/kit/commands/ssh.rb +0 -46
- data/lib/kitsune/kit/commands/switch_env.rb +0 -42
- data/lib/kitsune/kit/defaults.rb +0 -91
- data/lib/kitsune/kit/env_loader.rb +0 -41
- data/lib/kitsune/kit/options_builder.rb +0 -26
- data/lib/kitsune/kit/provisioner.rb +0 -107
- 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
|