kitsune-kit 0.4.1 → 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,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "../result"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
Check = Data.define(:name, :status, :message, :hint) do
|
|
11
|
+
def to_h = { name: name, status: status, message: message, hint: hint }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Doctor < Base
|
|
15
|
+
SUPPORTED_UBUNTU = %w[22.04 24.04].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(config:, provider:, state_store:, transport_factory: nil, operations: [],
|
|
18
|
+
event_bus: Events::NullBus.new, clock: Clock.new)
|
|
19
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
20
|
+
@provider = provider
|
|
21
|
+
@state_store = state_store
|
|
22
|
+
@transport_factory = transport_factory
|
|
23
|
+
@operations = operations
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
started_at = monotonic_time
|
|
28
|
+
emit("run_started", command: "doctor", environment: config.environment)
|
|
29
|
+
checks = local_checks + infrastructure_checks
|
|
30
|
+
emit_findings(checks)
|
|
31
|
+
status = checks.any? { |check| check.status == "fail" } ? "failure" : "success"
|
|
32
|
+
emit("run_finished", status: status, duration_ms: elapsed_ms(started_at))
|
|
33
|
+
Result.new(status: status.to_sym, value: checks, metadata: { run_id: run_id })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def local_checks
|
|
39
|
+
[runtime_check, configuration_check, ssh_key_check, security_check, provider_check, state_check]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def infrastructure_checks
|
|
43
|
+
@server = find_server
|
|
44
|
+
values = [server_check]
|
|
45
|
+
return values unless @server
|
|
46
|
+
|
|
47
|
+
values << ssh_connection_check
|
|
48
|
+
return values unless @transport
|
|
49
|
+
|
|
50
|
+
values + [ubuntu_check, sudo_check, docker_check, compose_check, ports_check, drift_check]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emit_findings(checks)
|
|
54
|
+
checks.reject { |check| check.status == "pass" }.each do |check|
|
|
55
|
+
emit("warning_emitted", message: "#{check.name}: #{check.message}", hint: check.hint,
|
|
56
|
+
status: check.status)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def runtime_check
|
|
61
|
+
supported = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2.0")
|
|
62
|
+
check("Runtime", supported ? "pass" : "fail", "Ruby #{RUBY_VERSION} on #{RbConfig::CONFIG['host_os']}",
|
|
63
|
+
supported ? nil : "Install Ruby 3.2 or newer.")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def configuration_check = check("Configuration", "pass", "Configuration is valid")
|
|
67
|
+
|
|
68
|
+
def ssh_key_check
|
|
69
|
+
path = config.ssh.key_path
|
|
70
|
+
unless File.file?(path)
|
|
71
|
+
return check("SSH key", "fail", "Private key does not exist: #{path}",
|
|
72
|
+
"Set ssh.key_path to an existing private key.")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
mode = File.stat(path).mode & 0o777
|
|
76
|
+
if mode.anybits?(0o077)
|
|
77
|
+
return check("SSH key", "warn", format("Private key mode is %<mode>04o", mode: mode),
|
|
78
|
+
"Run `chmod 600 #{path}`.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
check("SSH key", "pass", "Private key permissions are restricted")
|
|
82
|
+
rescue SystemCallError => e
|
|
83
|
+
check("SSH key", "fail", e.message, "Check the configured path and permissions.")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def security_check
|
|
87
|
+
published = %w[postgres redis].select do |type|
|
|
88
|
+
service = config.services.public_send(type)
|
|
89
|
+
service.enabled && service.mode == "managed" && service.publish
|
|
90
|
+
end
|
|
91
|
+
return check("Security defaults", "pass", "Data services are private") if published.empty?
|
|
92
|
+
|
|
93
|
+
check("Security defaults", "warn", "Published services: #{published.join(', ')}",
|
|
94
|
+
"Confirm every allowed CIDR and prefer private Docker networking.")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def provider_check
|
|
98
|
+
@provider.validate_credentials!
|
|
99
|
+
check("Provider API", "pass", "DigitalOcean credentials and API access are valid")
|
|
100
|
+
rescue Errors::Error => e
|
|
101
|
+
check("Provider API", "fail", e.message, e.hint)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def state_check
|
|
105
|
+
@state = @state_store.read(config.environment)
|
|
106
|
+
check("State", "pass", "State is readable and schema-compatible")
|
|
107
|
+
rescue Errors::Error => e
|
|
108
|
+
check("State", "fail", e.message, e.hint)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def find_server
|
|
112
|
+
@provider.find_server(name: config.server.name, tags: config.server.tags)
|
|
113
|
+
rescue Errors::Error => e
|
|
114
|
+
@server_error = e
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def server_check
|
|
119
|
+
return check("Server", "fail", @server_error.message, @server_error.hint) if @server_error
|
|
120
|
+
return check("Server", "warn", "Server does not exist", "Run `kit plan`, then `kit apply`.") unless @server
|
|
121
|
+
|
|
122
|
+
managed_id = @state&.dig("resources", "server", "id")
|
|
123
|
+
unless managed_id
|
|
124
|
+
return check("Server", "warn", "Server exists but is not tracked in local state",
|
|
125
|
+
"Restore the state backup before changing or destroying this server.")
|
|
126
|
+
end
|
|
127
|
+
if managed_id.to_s != @server.id.to_s
|
|
128
|
+
return check("Server", "fail", "Provider server does not match the managed ID",
|
|
129
|
+
"Reconcile state without deleting either server by name.")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
status = @server.status == "active" ? "pass" : "warn"
|
|
133
|
+
check("Server", status, "#{@server.name} is #{@server.status}",
|
|
134
|
+
status == "pass" ? nil : "Wait for the server to become active.")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ssh_connection_check
|
|
138
|
+
unless @transport_factory
|
|
139
|
+
return check("SSH connection", "warn", "SSH adapter is unavailable", "Run doctor through the CLI.")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@transport = @transport_factory.bootstrap
|
|
143
|
+
check("SSH connection", "pass", "Host key and public-key login are verified")
|
|
144
|
+
rescue Errors::Error => e
|
|
145
|
+
check("SSH connection", "fail", e.message, e.hint)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ubuntu_check
|
|
149
|
+
result = @transport.execute("cat", arguments: ["/etc/os-release"], timeout: 15)
|
|
150
|
+
version = result.stdout[/^VERSION_ID="?([^"\n]+)"?$/, 1]
|
|
151
|
+
ubuntu = result.stdout.match?(/^ID=ubuntu$/)
|
|
152
|
+
supported = result.success? && ubuntu && SUPPORTED_UBUNTU.include?(version)
|
|
153
|
+
check("Remote OS", supported ? "pass" : "fail", "Ubuntu #{version || 'unknown'}",
|
|
154
|
+
supported ? nil : "Use Ubuntu #{SUPPORTED_UBUNTU.join(' or ')} LTS.")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def sudo_check = command_check("Sudo", "sudo", ["-n", "true"], "Passwordless sudo is available")
|
|
158
|
+
|
|
159
|
+
def docker_check
|
|
160
|
+
command_check("Docker", "docker", ["version", "--format", "{{.Server.Version}}"],
|
|
161
|
+
"Docker Engine is available", status: "warn", hint: "Run `kit docker install`.")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def compose_check
|
|
165
|
+
command_check("Docker Compose", "docker", ["compose", "version", "--short"],
|
|
166
|
+
"Docker Compose v2 is available", status: "warn", hint: "Run `kit docker install`.")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def ports_check
|
|
170
|
+
result = @transport.execute("sudo", arguments: ["ss", "-lntH"], timeout: 15)
|
|
171
|
+
return check("Ports", "fail", "Unable to inspect listening ports") unless result.success?
|
|
172
|
+
|
|
173
|
+
ssh_listening = result.stdout.match?(/:#{Regexp.escape(config.ssh.port.to_s)}\s/)
|
|
174
|
+
exposed = private_service_ports.select { |port| result.stdout.match?(/(?:0\.0\.0\.0|\[::\]):#{port}\s/) }
|
|
175
|
+
return check("Ports", "fail", "SSH port #{config.ssh.port} is not listening") unless ssh_listening
|
|
176
|
+
if exposed.any?
|
|
177
|
+
return check("Ports", "fail", "Private data ports are publicly listening: #{exposed.join(', ')}",
|
|
178
|
+
"Stop the service and inspect its Compose port bindings.")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
check("Ports", "pass", "SSH is listening and private data ports are not public")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def drift_check
|
|
185
|
+
state = @state || @state_store.read(config.environment)
|
|
186
|
+
planned = @operations.map { |operation| [operation, operation.plan] }
|
|
187
|
+
desired_resources = planned.map { |operation, change| state_key_for(operation, change) }
|
|
188
|
+
drifted = planned.filter_map do |operation, change|
|
|
189
|
+
change.resource if state.dig("resources", state_key_for(operation, change)) && change.changed?
|
|
190
|
+
end
|
|
191
|
+
orphaned = state["resources"].keys - desired_resources
|
|
192
|
+
findings = drifted + orphaned
|
|
193
|
+
return check("Drift", "pass", "Managed resources match the desired configuration") if findings.empty?
|
|
194
|
+
|
|
195
|
+
check("Drift", "warn", "Review managed-resource drift: #{findings.uniq.join(', ')}",
|
|
196
|
+
"Run `kit plan`; restore state before adopting or removing existing resources.")
|
|
197
|
+
rescue Errors::Error => e
|
|
198
|
+
check("Drift", "fail", e.message, e.hint)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def state_key_for(operation, change)
|
|
202
|
+
operation.respond_to?(:state_key) ? operation.state_key : change.resource
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def command_check(name, command, arguments, success_message, status: "fail", hint: nil)
|
|
206
|
+
result = @transport.execute(command, arguments: arguments, timeout: 15)
|
|
207
|
+
return check(name, "pass", success_message) if result.success?
|
|
208
|
+
|
|
209
|
+
check(name, status, "Command failed with status #{result.exit_status}", hint)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def private_service_ports
|
|
213
|
+
%w[postgres redis].filter_map do |type|
|
|
214
|
+
service = config.services.public_send(type)
|
|
215
|
+
service.port if service.enabled && service.mode == "managed" && !service.publish
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def check(name, status, message, hint = nil)
|
|
220
|
+
Check.new(name: name, status: status, message: message, hint: hint)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Kitsune
|
|
9
|
+
module Kit
|
|
10
|
+
module Workflows
|
|
11
|
+
class EnvironmentSelection
|
|
12
|
+
NAME = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
13
|
+
|
|
14
|
+
def initialize(root: Dir.pwd)
|
|
15
|
+
@root = Pathname(root).expand_path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def list
|
|
19
|
+
environments_directory.glob("*.yml").map { |path| path.basename(".yml").to_s }.sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current(env: ENV)
|
|
23
|
+
env["KITSUNE_ENV"] || selected_from_file || "development"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use(name)
|
|
27
|
+
validate_name!(name)
|
|
28
|
+
unless list.include?(name)
|
|
29
|
+
raise Errors::ConfigurationError.new(
|
|
30
|
+
"environment does not exist: #{name}",
|
|
31
|
+
hint: "Create .kitsune/environments/#{name}.yml, then retry."
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
FileUtils.mkdir_p(base_directory, mode: 0o700)
|
|
36
|
+
Tempfile.create(["environment", ".tmp"], base_directory.to_s, mode: 0o600) do |file|
|
|
37
|
+
file.write("#{name}\n")
|
|
38
|
+
file.flush
|
|
39
|
+
file.fsync
|
|
40
|
+
File.rename(file.path, selection_path)
|
|
41
|
+
end
|
|
42
|
+
File.chmod(0o600, selection_path)
|
|
43
|
+
name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :root
|
|
49
|
+
|
|
50
|
+
def base_directory = root.join(".kitsune")
|
|
51
|
+
def environments_directory = base_directory.join("environments")
|
|
52
|
+
def selection_path = base_directory.join("environment")
|
|
53
|
+
|
|
54
|
+
def selected_from_file
|
|
55
|
+
return unless selection_path.file?
|
|
56
|
+
|
|
57
|
+
value = selection_path.read.strip
|
|
58
|
+
validate_name!(value)
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_name!(name)
|
|
63
|
+
return if name.to_s.match?(NAME)
|
|
64
|
+
|
|
65
|
+
raise Errors::ConfigurationError, "environment name has an invalid format"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../result"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
class ImportServer < Base
|
|
11
|
+
def initialize(config:, provider:, state_store:, event_bus: Events::NullBus.new, clock: Clock.new)
|
|
12
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
13
|
+
@provider = provider
|
|
14
|
+
@state_store = state_store
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(provider_id:, confirmation:)
|
|
18
|
+
validate_inputs!(provider_id, confirmation)
|
|
19
|
+
@state_store.with_execution_lock(config.environment) do
|
|
20
|
+
import_locked(provider_id.to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_inputs!(provider_id, confirmation)
|
|
27
|
+
unless provider_id.to_s.match?(/\A[0-9]+\z/)
|
|
28
|
+
raise Errors::ConfigurationError.new(
|
|
29
|
+
"a numeric provider ID is required for server import",
|
|
30
|
+
hint: "Pass --provider-id after copying the exact Droplet ID from the DigitalOcean control panel."
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
return if confirmation == config.server.name
|
|
34
|
+
|
|
35
|
+
raise Errors::UnsafeOperationError.new(
|
|
36
|
+
"server import was not confirmed",
|
|
37
|
+
hint: "Pass --confirm-import #{config.server.name} only after checking the exact provider ID."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def import_locked(provider_id)
|
|
42
|
+
state = @state_store.read(config.environment)
|
|
43
|
+
existing_id = state.dig("resources", "server", "id")
|
|
44
|
+
return Result.success(existing_id, metadata: { unchanged: true }) if existing_id.to_s == provider_id
|
|
45
|
+
if existing_id
|
|
46
|
+
raise Errors::UnsafeOperationError.new(
|
|
47
|
+
"state already tracks a different server",
|
|
48
|
+
hint: "Restore/reconcile the existing state instead of replacing its provider ID."
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
server = @provider.find_server_by_id(id: provider_id)
|
|
53
|
+
unless server
|
|
54
|
+
raise Errors::ProviderError.new(
|
|
55
|
+
"server does not exist for provider ID #{provider_id}",
|
|
56
|
+
hint: "Copy the exact Droplet ID from DigitalOcean and retry."
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
verify_identity!(server)
|
|
60
|
+
record(server)
|
|
61
|
+
Result.success(server, metadata: { imported: true })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def verify_identity!(server)
|
|
65
|
+
differences = {
|
|
66
|
+
name: [server.name, config.server.name],
|
|
67
|
+
region: [server.region, config.server.region],
|
|
68
|
+
size: [server.size, config.server.size],
|
|
69
|
+
image: [server.image, config.server.image],
|
|
70
|
+
tags: [server.tags, config.server.tags],
|
|
71
|
+
status: [server.status, "active"]
|
|
72
|
+
}
|
|
73
|
+
differences.delete_if do |field, (actual, desired)|
|
|
74
|
+
field == :tags ? (desired - Array(actual)).empty? : actual == desired
|
|
75
|
+
end
|
|
76
|
+
return if differences.empty? && server.public_ip
|
|
77
|
+
|
|
78
|
+
differences[:public_ip] = [server.public_ip, "required"] unless server.public_ip
|
|
79
|
+
raise Errors::UnsafeOperationError.new(
|
|
80
|
+
"provider server does not match the configured environment",
|
|
81
|
+
hint: "Do not import it. Correct configuration or recover the original state backup.",
|
|
82
|
+
context: { provider_id: server.id, differences: differences }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record(server)
|
|
87
|
+
@state_store.update(config.environment) do |state|
|
|
88
|
+
state["resources"]["server"] = server.to_h.transform_keys(&:to_s).merge(
|
|
89
|
+
"provider" => config.provider.name
|
|
90
|
+
)
|
|
91
|
+
state["operations"] << {
|
|
92
|
+
"resource" => "server.#{config.server.name}", "action" => "import", "status" => "applied"
|
|
93
|
+
}
|
|
94
|
+
state
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Kitsune
|
|
8
|
+
module Kit
|
|
9
|
+
module Workflows
|
|
10
|
+
class InitializeProject
|
|
11
|
+
CONFIG = <<~YAML
|
|
12
|
+
version: 1
|
|
13
|
+
provider:
|
|
14
|
+
name: digitalocean
|
|
15
|
+
token_env: DO_API_TOKEN
|
|
16
|
+
|
|
17
|
+
server:
|
|
18
|
+
name: myapp-development
|
|
19
|
+
region: sfo3
|
|
20
|
+
size: s-1vcpu-1gb
|
|
21
|
+
image: ubuntu-24-04-x64
|
|
22
|
+
ssh_key_id: replace-with-digitalocean-key-id
|
|
23
|
+
tags:
|
|
24
|
+
- kitsune-managed
|
|
25
|
+
|
|
26
|
+
ssh:
|
|
27
|
+
user: deploy
|
|
28
|
+
port: 22
|
|
29
|
+
key_path: ~/.ssh/id_ed25519
|
|
30
|
+
allowed_cidrs: []
|
|
31
|
+
|
|
32
|
+
services:
|
|
33
|
+
postgres:
|
|
34
|
+
enabled: false
|
|
35
|
+
mode: managed
|
|
36
|
+
host:
|
|
37
|
+
image: postgres:17
|
|
38
|
+
publish: false
|
|
39
|
+
bind: 127.0.0.1
|
|
40
|
+
allowed_cidrs: []
|
|
41
|
+
port: 5432
|
|
42
|
+
password_env: POSTGRES_PASSWORD
|
|
43
|
+
redis:
|
|
44
|
+
enabled: false
|
|
45
|
+
mode: managed
|
|
46
|
+
host:
|
|
47
|
+
image: redis:7.2
|
|
48
|
+
publish: false
|
|
49
|
+
bind: 127.0.0.1
|
|
50
|
+
allowed_cidrs: []
|
|
51
|
+
port: 6379
|
|
52
|
+
password_env: REDIS_PASSWORD
|
|
53
|
+
|
|
54
|
+
system:
|
|
55
|
+
swap_size_gb: 2
|
|
56
|
+
swap_swappiness: 10
|
|
57
|
+
unattended_upgrades: true
|
|
58
|
+
metrics: false
|
|
59
|
+
metrics_installer_sha256:
|
|
60
|
+
|
|
61
|
+
dns:
|
|
62
|
+
domains: []
|
|
63
|
+
ttl: 3600
|
|
64
|
+
YAML
|
|
65
|
+
|
|
66
|
+
ENVIRONMENT = <<~YAML
|
|
67
|
+
version: 1
|
|
68
|
+
server:
|
|
69
|
+
name: myapp-development
|
|
70
|
+
YAML
|
|
71
|
+
|
|
72
|
+
def initialize(root: Dir.pwd)
|
|
73
|
+
@root = Pathname(root).expand_path
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call(force: false)
|
|
77
|
+
base = @root.join(".kitsune")
|
|
78
|
+
files = {
|
|
79
|
+
base.join("config.yml") => CONFIG,
|
|
80
|
+
base.join("environments/development.yml") => ENVIRONMENT,
|
|
81
|
+
base.join("environment") => "development\n"
|
|
82
|
+
}
|
|
83
|
+
existing = files.keys.select(&:exist?)
|
|
84
|
+
if existing.any? && !force
|
|
85
|
+
raise Errors::UnsafeOperationError.new(
|
|
86
|
+
"Kitsune Kit configuration already exists",
|
|
87
|
+
hint: "Use `kit init --force` only if replacing these files is intentional.",
|
|
88
|
+
context: { files: existing.map(&:to_s) }
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
files.each do |path, content|
|
|
93
|
+
FileUtils.mkdir_p(path.dirname, mode: 0o700)
|
|
94
|
+
path.write(content)
|
|
95
|
+
File.chmod(0o600, path)
|
|
96
|
+
end
|
|
97
|
+
ensure_gitignore
|
|
98
|
+
files.keys.map(&:to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def ensure_gitignore
|
|
104
|
+
path = @root.join(".gitignore")
|
|
105
|
+
lines = path.file? ? path.readlines(chomp: true) : []
|
|
106
|
+
additions = [
|
|
107
|
+
"/.kitsune/state/",
|
|
108
|
+
"/.kitsune/logs/",
|
|
109
|
+
"/.kitsune/support/",
|
|
110
|
+
"/.kitsune/environment",
|
|
111
|
+
"/.kitsune/known_hosts"
|
|
112
|
+
] - lines
|
|
113
|
+
return if additions.empty?
|
|
114
|
+
|
|
115
|
+
separator = lines.empty? || lines.last.empty? ? "" : "\n"
|
|
116
|
+
File.open(path, "a", 0o644) { |file| file.write("#{separator}#{additions.join("\n")}\n") }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../result"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Kitsune
|
|
7
|
+
module Kit
|
|
8
|
+
module Workflows
|
|
9
|
+
EnvironmentStatus = Data.define(:environment, :server, :managed_resources, :last_operations) do
|
|
10
|
+
def to_h
|
|
11
|
+
{
|
|
12
|
+
environment: environment,
|
|
13
|
+
server: server&.to_h,
|
|
14
|
+
managed_resources: managed_resources,
|
|
15
|
+
last_operations: last_operations
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class InspectEnvironment < Base
|
|
21
|
+
def initialize(config:, provider:, state_store:, event_bus: Events::NullBus.new, clock: Clock.new)
|
|
22
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
23
|
+
@provider = provider
|
|
24
|
+
@state_store = state_store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
started_at = monotonic_time
|
|
29
|
+
emit("run_started", command: "inspect", environment: config.environment)
|
|
30
|
+
state = @state_store.read(config.environment)
|
|
31
|
+
server = @provider.find_server(name: config.server.name, tags: config.server.tags)
|
|
32
|
+
value = EnvironmentStatus.new(
|
|
33
|
+
environment: config.environment,
|
|
34
|
+
server: server,
|
|
35
|
+
managed_resources: state["resources"],
|
|
36
|
+
last_operations: state["operations"].last(20)
|
|
37
|
+
)
|
|
38
|
+
emit("run_finished", status: "success", duration_ms: elapsed_ms(started_at))
|
|
39
|
+
Result.success(value, metadata: { run_id: run_id })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../result"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Kitsune
|
|
7
|
+
module Kit
|
|
8
|
+
module Workflows
|
|
9
|
+
class Rollback < Base
|
|
10
|
+
def initialize(config:, operations:, state_store: nil, event_bus: Events::NullBus.new, clock: Clock.new)
|
|
11
|
+
super(config: config, event_bus: event_bus, clock: clock)
|
|
12
|
+
@operations = operations
|
|
13
|
+
@state_store = state_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return call_unlocked unless @state_store
|
|
18
|
+
|
|
19
|
+
@state_store.with_execution_lock(config.environment) { call_unlocked }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def call_unlocked
|
|
25
|
+
started_at = monotonic_time
|
|
26
|
+
emit("run_started", command: "rollback", environment: config.environment)
|
|
27
|
+
rolled_back = @operations.reverse.filter_map { |operation| rollback_operation(operation) }
|
|
28
|
+
emit("run_finished", status: "success", duration_ms: elapsed_ms(started_at))
|
|
29
|
+
Result.success(rolled_back, metadata: { run_id: run_id })
|
|
30
|
+
rescue StandardError
|
|
31
|
+
emit("run_finished", status: "failure", duration_ms: elapsed_ms(started_at))
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rollback_operation(operation)
|
|
36
|
+
return unless operation.respond_to?(:rollback)
|
|
37
|
+
|
|
38
|
+
started_at = monotonic_time
|
|
39
|
+
emit("operation_started", resource: operation.resource, summary: "Rollback #{operation.resource}",
|
|
40
|
+
action: "rollback")
|
|
41
|
+
changed = operation.rollback
|
|
42
|
+
emit(
|
|
43
|
+
"operation_succeeded",
|
|
44
|
+
resource: operation.resource,
|
|
45
|
+
summary: changed ? "Rolled back #{operation.resource}" : "#{operation.resource} was not managed",
|
|
46
|
+
action: "rollback",
|
|
47
|
+
duration_ms: elapsed_ms(started_at)
|
|
48
|
+
)
|
|
49
|
+
operation.resource if changed
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|