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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plan"
4
+ require_relative "../result"
5
+ require_relative "base"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ module Workflows
10
+ class BuildPlan < Base
11
+ def initialize(config:, operations:, event_bus: Events::NullBus.new, clock: Clock.new)
12
+ super(config: config, event_bus: event_bus, clock: clock)
13
+ @operations = operations
14
+ end
15
+
16
+ def call
17
+ started_at = monotonic_time
18
+ emit("run_started", command: "plan", environment: config.environment)
19
+ changes = @operations.map(&:plan)
20
+ plan = Plan.new(environment: config.environment, changes: changes)
21
+ emit(
22
+ "plan_built",
23
+ changed_count: plan.changed_count,
24
+ unchanged_count: plan.changes.length - plan.changed_count,
25
+ destructive: plan.destructive?
26
+ )
27
+ emit("run_finished", status: "success", duration_ms: elapsed_ms(started_at))
28
+ Result.success(plan, metadata: { run_id: run_id })
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,84 @@
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 DestroyServer < Base
11
+ def initialize(config:, provider:, state_store:, dns_operation: nil, event_bus: Events::NullBus.new,
12
+ clock: Clock.new)
13
+ super(config: config, event_bus: event_bus, clock: clock)
14
+ @provider = provider
15
+ @state_store = state_store
16
+ @dns_operation = dns_operation
17
+ end
18
+
19
+ def call(confirmation:)
20
+ @state_store.with_execution_lock(config.environment) { call_unlocked(confirmation: confirmation) }
21
+ end
22
+
23
+ private
24
+
25
+ def call_unlocked(confirmation:)
26
+ state = @state_store.read(config.environment)
27
+ managed = state.dig("resources", "server")
28
+ unless managed&.fetch("id", nil)
29
+ raise Errors::UnsafeOperationError.new(
30
+ "no managed server ID exists in state",
31
+ hint: "Import and verify the server before attempting destruction."
32
+ )
33
+ end
34
+ validate_confirmation!(confirmation, managed)
35
+ current = verify_exact_server!(managed)
36
+ started_at = monotonic_time
37
+ emit("run_started", command: "server.destroy", environment: config.environment)
38
+ @provider.delete_server(id: managed.fetch("id")) if current
39
+ @dns_operation&.rollback
40
+ record_destruction(managed.fetch("id"))
41
+ emit("run_finished", status: "success", duration_ms: elapsed_ms(started_at))
42
+ Result.success(managed.fetch("id"), metadata: { run_id: run_id })
43
+ rescue StandardError
44
+ emit("run_finished", status: "failure", duration_ms: 0) if defined?(started_at) && started_at
45
+ raise
46
+ end
47
+
48
+ def validate_confirmation!(confirmation, managed)
49
+ return if confirmation == config.server.name
50
+
51
+ raise Errors::UnsafeOperationError.new(
52
+ "server destruction was not confirmed",
53
+ hint: "Pass --confirm-destroy #{config.server.name} after reviewing the environment.",
54
+ context: {
55
+ provider: config.provider.name, environment: config.environment,
56
+ server: config.server.name, provider_id: managed["id"], recoverable: false
57
+ }
58
+ )
59
+ end
60
+
61
+ def verify_exact_server!(managed)
62
+ current = @provider.find_server_by_id(id: managed.fetch("id"))
63
+ return unless current
64
+
65
+ expected_tags = config.server.tags
66
+ return current if current.name == config.server.name && (expected_tags - current.tags).empty?
67
+
68
+ raise Errors::UnsafeOperationError.new(
69
+ "managed provider ID points to an unexpected server",
70
+ hint: "Do not delete by name. Reconcile or import state first."
71
+ )
72
+ end
73
+
74
+ def record_destruction(id)
75
+ @state_store.update(config.environment) do |state|
76
+ state["resources"].clear
77
+ state["operations"] << { "resource" => "server", "action" => "destroy", "status" => "applied", "id" => id }
78
+ state
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "tempfile"
6
+ require "yaml"
7
+ require_relative "../errors"
8
+ require_relative "../service_compose"
9
+
10
+ module Kitsune
11
+ module Kit
12
+ module Workflows
13
+ class EjectCompose
14
+ TYPES = %w[postgres redis].freeze
15
+
16
+ def initialize(root:, config:, type:, config_path: nil)
17
+ @root = Pathname(root).expand_path
18
+ @config = config
19
+ @type = type.to_s
20
+ @config_path = config_path ? Pathname(config_path).expand_path(@root) : @root.join(".kitsune/config.yml")
21
+ end
22
+
23
+ def call(force: false)
24
+ @backup_created = false
25
+ validate_target!(force)
26
+ content = ServiceCompose.new(config: config, type: type, service: service).generated_content
27
+ FileUtils.mkdir_p(output_path.dirname, mode: 0o700)
28
+ FileUtils.cp(config_path, backup_path)
29
+ @backup_created = true
30
+ write_atomic(output_path, content, 0o644)
31
+ update_configuration
32
+ { compose_file: output_path.to_s, config_file: config_path.to_s, backup_file: backup_path.to_s }
33
+ rescue StandardError
34
+ FileUtils.cp(backup_path, config_path) if @backup_created && backup_path.file?
35
+ raise
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :root, :config, :type, :config_path
41
+
42
+ def service = config.services.public_send(type)
43
+ def output_path = root.join(".kitsune/compose/#{type}.yml")
44
+ def backup_path = Pathname("#{config_path}.backup")
45
+
46
+ def validate_target!(force)
47
+ raise Errors::ConfigurationError, "unknown service type: #{type}" unless TYPES.include?(type)
48
+ raise Errors::ConfigurationError, "configuration file does not exist: #{config_path}" unless config_path.file?
49
+ return if force || (!output_path.exist? && !backup_path.exist? && service.compose.mode == "generated")
50
+
51
+ raise Errors::UnsafeOperationError.new(
52
+ "refusing to replace an existing Compose customization or configuration backup",
53
+ hint: "Review #{output_path} and #{backup_path}, then repeat with --force if replacement is intentional."
54
+ )
55
+ end
56
+
57
+ def update_configuration
58
+ document = YAML.safe_load(config_path.read, permitted_classes: [], permitted_symbols: [], aliases: false)
59
+ service_config = document.fetch("services").fetch(type)
60
+ service_config["compose"] = {
61
+ "mode" => "custom",
62
+ "file" => ".kitsune/compose/#{type}.yml",
63
+ "allow_unsafe" => false
64
+ }
65
+ write_atomic(config_path, YAML.dump(document), 0o600)
66
+ rescue KeyError, Psych::Exception => e
67
+ raise Errors::ConfigurationError, "unable to update configuration for Compose ejection: #{e.message}"
68
+ end
69
+
70
+ def write_atomic(path, content, mode)
71
+ Tempfile.create([path.basename.to_s, ".tmp"], path.dirname) do |file|
72
+ file.write(content)
73
+ file.flush
74
+ file.fsync
75
+ File.chmod(mode, file.path)
76
+ File.rename(file.path, path)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ 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