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.
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,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