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