kitsune-kit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +108 -136
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/provider-adapters.md +49 -0
  14. data/docs/architecture.md +89 -0
  15. data/docs/commands.md +210 -0
  16. data/docs/configuration.md +210 -0
  17. data/docs/getting-started.md +133 -0
  18. data/docs/providers/digitalocean.md +52 -0
  19. data/docs/releasing.md +43 -0
  20. data/docs/roadmap.md +46 -0
  21. data/docs/security-audit.md +130 -0
  22. data/docs/security.md +116 -0
  23. data/docs/services/postgres.md +78 -0
  24. data/docs/services/redis.md +53 -0
  25. data/docs/testing.md +200 -0
  26. data/docs/troubleshooting.md +123 -0
  27. data/docs/tui.md +57 -0
  28. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  29. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  30. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  31. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  32. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  34. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  35. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  36. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  37. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  38. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  39. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  40. data/lib/kitsune/kit/application.rb +170 -0
  41. data/lib/kitsune/kit/cancellation.rb +21 -0
  42. data/lib/kitsune/kit/cli.rb +802 -64
  43. data/lib/kitsune/kit/clock.rb +11 -0
  44. data/lib/kitsune/kit/configuration.rb +493 -0
  45. data/lib/kitsune/kit/errors.rb +98 -0
  46. data/lib/kitsune/kit/events.rb +55 -0
  47. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  48. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  49. data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
  50. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  51. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  52. data/lib/kitsune/kit/operations/service_files.rb +127 -0
  53. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  54. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  55. data/lib/kitsune/kit/plan.rb +72 -0
  56. data/lib/kitsune/kit/reporters/human.rb +89 -0
  57. data/lib/kitsune/kit/reporters/json.rb +65 -0
  58. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  59. data/lib/kitsune/kit/result.rb +28 -0
  60. data/lib/kitsune/kit/run_journal.rb +102 -0
  61. data/lib/kitsune/kit/run_logger.rb +37 -0
  62. data/lib/kitsune/kit/scripts/docker.sh +137 -0
  63. data/lib/kitsune/kit/scripts/firewall.sh +205 -0
  64. data/lib/kitsune/kit/scripts/metrics.sh +54 -0
  65. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  66. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  67. data/lib/kitsune/kit/scripts/unattended.sh +81 -0
  68. data/lib/kitsune/kit/scripts/user.sh +114 -0
  69. data/lib/kitsune/kit/secret_filter.rb +54 -0
  70. data/lib/kitsune/kit/secret_store.rb +32 -0
  71. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  72. data/lib/kitsune/kit/service_compose.rb +72 -0
  73. data/lib/kitsune/kit/state_store.rb +158 -0
  74. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  75. data/lib/kitsune/kit/tui/actions.rb +72 -0
  76. data/lib/kitsune/kit/tui/application.rb +35 -0
  77. data/lib/kitsune/kit/tui/controller.rb +162 -0
  78. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  79. data/lib/kitsune/kit/tui/state.rb +18 -0
  80. data/lib/kitsune/kit/tui/store.rb +88 -0
  81. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  82. data/lib/kitsune/kit/version.rb +1 -1
  83. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  84. data/lib/kitsune/kit/workflows/base.rb +31 -0
  85. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  86. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  87. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  88. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  89. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  90. data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
  91. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  92. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  93. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  94. data/lib/kitsune/kit.rb +41 -2
  95. metadata +122 -79
  96. data/.rspec +0 -3
  97. data/Rakefile +0 -8
  98. data/kitsune-kit-logo.jpg +0 -0
  99. data/lib/kitsune/blueprints/.env.template +0 -31
  100. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  101. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  102. data/lib/kitsune/blueprints/kit.env.template +0 -1
  103. data/lib/kitsune/kit/ansi_color.rb +0 -78
  104. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  105. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  106. data/lib/kitsune/kit/commands/dns.rb +0 -112
  107. data/lib/kitsune/kit/commands/init.rb +0 -148
  108. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  109. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  110. data/lib/kitsune/kit/commands/provision.rb +0 -43
  111. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  112. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  113. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  114. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  115. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  116. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  117. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  118. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  119. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  120. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  121. data/lib/kitsune/kit/defaults.rb +0 -91
  122. data/lib/kitsune/kit/env_loader.rb +0 -41
  123. data/lib/kitsune/kit/options_builder.rb +0 -26
  124. data/lib/kitsune/kit/provisioner.rb +0 -107
  125. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Tui
6
+ State = Data.define(
7
+ :screen, :environment, :resources, :selected_resource, :operations, :logs, :modal, :notification,
8
+ :running, :terminal_size, :result, :scroll_offset
9
+ ) do
10
+ def self.initial(environment:, terminal_size: [100, 30])
11
+ new(screen: :dashboard, environment: environment, resources: [], selected_resource: 0, operations: [],
12
+ logs: [], modal: nil, notification: nil, running: false, terminal_size: terminal_size, result: nil,
13
+ scroll_offset: 0)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "state"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Tui
8
+ class Store
9
+ MAX_LOGS = 200
10
+
11
+ def initialize(environment:, secret_filter: SecretFilter.new, terminal_size: [100, 30])
12
+ @state = State.initial(environment: environment, terminal_size: terminal_size)
13
+ @secret_filter = secret_filter
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def snapshot = @mutex.synchronize { @state }
18
+
19
+ def handle(event)
20
+ data = @secret_filter.filter(event.data)
21
+ mutate do |state|
22
+ operations = update_operations(state.operations, event.type, data)
23
+ logs = append_log(state.logs, event.type, data)
24
+ state.with(operations: operations, logs: logs, running: running_for(event.type, state.running),
25
+ notification: notification_for(event.type, data, state.notification))
26
+ end
27
+ end
28
+
29
+ def show(screen, result: nil)
30
+ mutate { |state| state.with(screen: screen, result: result, modal: nil, scroll_offset: 0) }
31
+ end
32
+
33
+ def resize(width, height) = mutate { |state| state.with(terminal_size: [width, height]) }
34
+ def select(index) = mutate { |state| state.with(selected_resource: index) }
35
+ def modal(value) = mutate { |state| state.with(modal: value) }
36
+ def running(value) = mutate { |state| state.with(running: value) }
37
+
38
+ def scroll(delta)
39
+ mutate do |state|
40
+ maximum = [state.logs.length - 1, 0].max
41
+ state.with(scroll_offset: (state.scroll_offset + delta).clamp(0, maximum))
42
+ end
43
+ end
44
+
45
+ def resources=(values)
46
+ mutate { |state| state.with(resources: values, selected_resource: 0) }
47
+ end
48
+
49
+ private
50
+
51
+ def mutate
52
+ @mutex.synchronize { @state = yield(@state) }
53
+ end
54
+
55
+ def update_operations(operations, type, data)
56
+ return operations unless type.start_with?("operation_")
57
+
58
+ item = {
59
+ resource: data[:resource], summary: data[:summary], status: type.delete_prefix("operation_"),
60
+ duration_ms: data[:duration_ms], message: data[:message], percent: data[:percent]
61
+ }
62
+ operations.reject { |operation| operation[:resource] == item[:resource] } + [item]
63
+ end
64
+
65
+ def append_log(logs, type, data)
66
+ message = data[:message] || data[:summary]
67
+ return logs unless message
68
+
69
+ (logs + ["#{type}: #{message}"]).last(MAX_LOGS)
70
+ end
71
+
72
+ def running_for(type, current)
73
+ return true if type == "run_started"
74
+ return false if type == "run_finished"
75
+
76
+ current
77
+ end
78
+
79
+ def notification_for(type, data, current)
80
+ return data[:message] if %w[operation_failed warning_emitted].include?(type)
81
+ return "Run finished: #{data[:status]}" if type == "run_finished"
82
+
83
+ current
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "io/wait"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Tui
9
+ class Terminal
10
+ ALT_SCREEN = "\e[?1049h"
11
+ MAIN_SCREEN = "\e[?1049l"
12
+ DISABLE_WRAP = "\e[?7l"
13
+ ENABLE_WRAP = "\e[?7h"
14
+
15
+ def initialize(input: $stdin, output: $stdout)
16
+ @input = input
17
+ @output = output
18
+ end
19
+
20
+ def tty? = input.tty? && output.tty?
21
+
22
+ def run
23
+ raise Errors::ConfigurationError, "the TUI requires an interactive terminal" unless tty?
24
+
25
+ enter
26
+ yield self
27
+ ensure
28
+ restore
29
+ end
30
+
31
+ def size
32
+ rows, columns = output.winsize
33
+ [columns, rows]
34
+ rescue SystemCallError, NoMethodError
35
+ [100, 30]
36
+ end
37
+
38
+ def draw(content)
39
+ frame = content.lines(chomp: true).each_with_index.map do |line, index|
40
+ "\e[#{index + 1};1H#{line}"
41
+ end.join
42
+ output.write(frame)
43
+ output.flush
44
+ end
45
+
46
+ def read_key(timeout: 0.1)
47
+ return unless input.wait_readable(timeout)
48
+
49
+ first = input.read_nonblock(1)
50
+ return first unless first == "\e"
51
+
52
+ sequence = first.dup
53
+ sequence << input.read_nonblock(1) while sequence.length < 6 && input.wait_readable(0.005)
54
+ { "\e[A" => :up, "\e[B" => :down, "\e[5~" => :page_up, "\e[6~" => :page_down }
55
+ .fetch(sequence, :escape)
56
+ rescue IO::WaitReadable
57
+ nil
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :input, :output
63
+
64
+ def enter
65
+ @original_console_mode = input.console_mode
66
+ input.raw!
67
+ @active = true
68
+ output.write("#{ALT_SCREEN}#{DISABLE_WRAP}\e[?25l\e[2J\e[H")
69
+ output.flush
70
+ end
71
+
72
+ def restore
73
+ return unless @active
74
+
75
+ restore_console
76
+ restore_screen
77
+ @active = false
78
+ end
79
+
80
+ def restore_console
81
+ input.console_mode = @original_console_mode if @original_console_mode
82
+ rescue SystemCallError, IOError
83
+ nil
84
+ end
85
+
86
+ def restore_screen
87
+ output.write("#{ENABLE_WRAP}\e[?25h#{MAIN_SCREEN}")
88
+ output.flush
89
+ rescue SystemCallError, IOError
90
+ nil
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kitsune
4
4
  module Kit
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../result"
5
+ require_relative "../run_journal"
6
+ require_relative "base"
7
+
8
+ module Kitsune
9
+ module Kit
10
+ module Workflows
11
+ class ApplyPlan < Base
12
+ def initialize(config:, operations:, state_store: nil, cancellation: Cancellation.new,
13
+ event_bus: Events::NullBus.new, clock: Clock.new)
14
+ super(config: config, event_bus: event_bus, clock: clock)
15
+ @operations = operations
16
+ @cancellation = cancellation
17
+ @state_store = state_store
18
+ @journal = RunJournal.new(
19
+ state_store: state_store,
20
+ environment: config.environment,
21
+ run_id: run_id,
22
+ clock: -> { clock.now }
23
+ )
24
+ end
25
+
26
+ def call(plan: nil, resume_from: nil)
27
+ return call_unlocked(plan: plan, resume_from: resume_from) unless @state_store
28
+
29
+ @state_store.with_execution_lock(config.environment) do
30
+ call_unlocked(plan: plan, resume_from: resume_from)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def call_unlocked(plan:, resume_from:)
37
+ started_at = monotonic_time
38
+ journal_started = false
39
+ execution = build_execution(plan, resume_from)
40
+ emit("run_started", command: "apply", environment: config.environment, resumed_from: execution[:source_id])
41
+ @journal.start(changes: execution[:changes].map(&:to_h), resumed_from: execution[:source_id])
42
+ journal_started = true
43
+ @last_confirmed = nil
44
+ results = execute(execution)
45
+ finish("success", started_at)
46
+ Result.success(results, metadata: { run_id: run_id, resumed_from: execution[:source_id] })
47
+ rescue StandardError => e
48
+ if journal_started
49
+ emit_recovery_guidance
50
+ finish(failure_status(e), started_at, error: error_code(e))
51
+ end
52
+ raise
53
+ end
54
+
55
+ def build_execution(plan, resume_from)
56
+ return fresh_execution(plan) unless resume_from
57
+
58
+ source_id, source = @journal.resumable(resume_from == true ? nil : resume_from)
59
+ changes = Plan.from_h(environment: config.environment, changes: source["changes"]).changes
60
+ validate_operation_set!(changes)
61
+ completed = source["steps"].filter_map { |resource, step| resource if step["status"] == "success" }
62
+ { source_id: source_id, changes: changes, completed: completed }
63
+ end
64
+
65
+ def fresh_execution(plan)
66
+ changes = plan ? plan.changes : @operations.map(&:plan)
67
+ validate_operation_set!(changes)
68
+ { source_id: nil, changes: changes, completed: [] }
69
+ end
70
+
71
+ def validate_operation_set!(changes)
72
+ operation_resources = @operations.map(&:resource)
73
+ change_resources = changes.map(&:resource)
74
+ return if operation_resources == change_resources
75
+
76
+ raise Errors::ConfigurationError.new(
77
+ "the saved plan does not match the configured operations",
78
+ hint: "Review the configuration and start a new `kit plan` and `kit apply`."
79
+ )
80
+ end
81
+
82
+ def execute(execution)
83
+ execution[:changes].each_with_index.map do |change, index|
84
+ if execution[:completed].include?(change.resource)
85
+ emit("operation_skipped", resource: change.resource, summary: change.summary,
86
+ reason: "already_completed")
87
+ next
88
+ end
89
+
90
+ execute_change(operation_for(change), change, index: index + 1, total: execution[:changes].length)
91
+ end
92
+ end
93
+
94
+ def execute_change(operation, change, index:, total:)
95
+ @cancellation.check!
96
+ started_at = monotonic_time
97
+ @journal.record_step(change.resource, "running")
98
+ emit("operation_started", resource: change.resource, summary: change.summary, action: change.action,
99
+ index: index, total: total)
100
+ emit("operation_progressed", resource: change.resource, summary: change.summary, action: change.action,
101
+ percent: 0)
102
+ operation.apply(change).tap do
103
+ @journal.record_step(change.resource, "success")
104
+ @last_confirmed = change.resource
105
+ emit("operation_progressed", resource: change.resource, summary: change.summary, action: change.action,
106
+ percent: 100)
107
+ emit_success(change, started_at)
108
+ end
109
+ rescue StandardError => e
110
+ @journal.record_step(change.resource, "failure", error: error_code(e))
111
+ emit_failure(change, e)
112
+ raise
113
+ end
114
+
115
+ def operation_for(change) = @operations.find { |operation| operation.resource == change.resource }
116
+
117
+ def emit_success(change, started_at)
118
+ emit("operation_succeeded", resource: change.resource, summary: change.summary, action: change.action,
119
+ duration_ms: elapsed_ms(started_at))
120
+ end
121
+
122
+ def emit_failure(change, error)
123
+ emit("operation_failed", resource: change.resource, summary: change.summary,
124
+ message: error.message, code: error_code(error))
125
+ end
126
+
127
+ def finish(status, started_at, error: nil)
128
+ @journal.finish(status, error: error)
129
+ emit("run_finished", status: status, duration_ms: elapsed_ms(started_at))
130
+ end
131
+
132
+ def error_code(error) = error.respond_to?(:code) ? error.code : "unexpected_error"
133
+ def failure_status(error) = error.is_a?(Cancellation::Cancelled) ? "cancelled" : "failure"
134
+
135
+ def emit_recovery_guidance
136
+ confirmed = @last_confirmed ? " Last confirmed step: #{@last_confirmed}." : " No step was confirmed."
137
+ emit(
138
+ "warning_emitted",
139
+ message: "Run #{run_id} is incomplete.#{confirmed} Correct the cause, then run `kit resume #{run_id}`."
140
+ )
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../clock"
5
+ require_relative "../events"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ module Workflows
10
+ class Base
11
+ def initialize(config:, event_bus: Events::NullBus.new, clock: Clock.new)
12
+ @config = config
13
+ @event_bus = event_bus
14
+ @clock = clock
15
+ @run_id = SecureRandom.uuid
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :config, :event_bus, :run_id, :clock
21
+
22
+ def emit(type, **data)
23
+ event_bus.publish(Events::Event.build(type, run_id: run_id, time: clock.now, **data))
24
+ end
25
+
26
+ def monotonic_time = clock.monotonic
27
+ def elapsed_ms(started_at) = ((monotonic_time - started_at) * 1000).round
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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