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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -2
- data/CONTRIBUTING.md +83 -0
- data/README.md +112 -135
- 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/decisions/0007-compose-customization.md +30 -0
- data/docs/architecture/provider-adapters.md +49 -0
- data/docs/architecture.md +93 -0
- data/docs/commands.md +224 -0
- data/docs/configuration.md +226 -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/compose.md +164 -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 +879 -64
- data/lib/kitsune/kit/clock.rb +11 -0
- data/lib/kitsune/kit/configuration.rb +577 -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 +302 -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 +131 -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 +142 -0
- data/lib/kitsune/kit/scripts/firewall.sh +210 -0
- data/lib/kitsune/kit/scripts/metrics.sh +59 -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 +86 -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 +272 -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/eject_compose.rb +82 -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 +129 -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 +42 -2
- metadata +125 -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,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kitsune
|
|
4
|
+
module Kit
|
|
5
|
+
module Tui
|
|
6
|
+
class Controller
|
|
7
|
+
CONFIRMATIONS = {
|
|
8
|
+
apply: "Apply the visible plan?",
|
|
9
|
+
resume: "Resume the latest incomplete run?"
|
|
10
|
+
}.freeze
|
|
11
|
+
SCREEN_KEYS = { "p" => %i[plan plan], "d" => %i[doctor doctor] }.freeze
|
|
12
|
+
SIMPLE_KEYS = { "l" => :logs, "?" => :help, "a" => :apply, "r" => :resume }.freeze
|
|
13
|
+
NAVIGATION_KEYS = { "j" => 1, down: 1, "k" => -1, up: -1 }.freeze
|
|
14
|
+
SCROLL_KEYS = { page_up: 5, page_down: -5 }.freeze
|
|
15
|
+
SCREENS = %i[dashboard plan doctor logs help].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :exit_requested
|
|
18
|
+
|
|
19
|
+
def initialize(store:, actions:)
|
|
20
|
+
@store = store
|
|
21
|
+
@actions = actions
|
|
22
|
+
@exit_requested = false
|
|
23
|
+
@worker = nil
|
|
24
|
+
@pending_action = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def handle(key)
|
|
28
|
+
return if key.nil?
|
|
29
|
+
return handle_modal(key) if store.snapshot.modal
|
|
30
|
+
return run_screen_action(key) if SCREEN_KEYS.key?(key)
|
|
31
|
+
return handle_simple(SIMPLE_KEYS.fetch(key)) if SIMPLE_KEYS.key?(key)
|
|
32
|
+
return navigate(NAVIGATION_KEYS.fetch(key)) if NAVIGATION_KEYS.key?(key)
|
|
33
|
+
return store.scroll(SCROLL_KEYS.fetch(key)) if SCROLL_KEYS.key?(key)
|
|
34
|
+
|
|
35
|
+
case key
|
|
36
|
+
when "q" then request_exit
|
|
37
|
+
when "\u0003" then cancel_or_exit
|
|
38
|
+
when "\t" then cycle_screen
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def busy? = @worker&.alive? || false
|
|
43
|
+
|
|
44
|
+
def start = run(:status, screen: :dashboard)
|
|
45
|
+
|
|
46
|
+
def wait
|
|
47
|
+
@worker&.join
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :store, :actions
|
|
53
|
+
|
|
54
|
+
def request_exit
|
|
55
|
+
if busy?
|
|
56
|
+
store.modal(message: "An operation is active. Quit after it finishes?", action: :quit)
|
|
57
|
+
else
|
|
58
|
+
@exit_requested = true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cancel_or_exit
|
|
63
|
+
return @exit_requested = true unless busy?
|
|
64
|
+
|
|
65
|
+
actions.cancel
|
|
66
|
+
store.handle(
|
|
67
|
+
Events::Event.build("warning_emitted", run_id: "tui", message: "Cancellation requested")
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move(delta)
|
|
72
|
+
state = store.snapshot
|
|
73
|
+
return if state.resources.empty?
|
|
74
|
+
|
|
75
|
+
store.select((state.selected_resource + delta) % state.resources.length)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def navigate(delta)
|
|
79
|
+
return store.scroll(-delta) if store.snapshot.screen == :logs
|
|
80
|
+
|
|
81
|
+
move(delta)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cycle_screen
|
|
85
|
+
current = SCREENS.index(store.snapshot.screen) || 0
|
|
86
|
+
store.show(SCREENS[(current + 1) % SCREENS.length])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def toggle_help
|
|
90
|
+
state = store.snapshot
|
|
91
|
+
store.show(state.screen == :help ? :dashboard : :help)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_screen_action(key)
|
|
95
|
+
action, screen = SCREEN_KEYS.fetch(key)
|
|
96
|
+
run(action, screen: screen)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_simple(action)
|
|
100
|
+
case action
|
|
101
|
+
when :logs then store.show(:logs)
|
|
102
|
+
when :help then toggle_help
|
|
103
|
+
else confirm(action)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def confirm(action)
|
|
108
|
+
return busy_notification if busy?
|
|
109
|
+
|
|
110
|
+
@pending_action = action
|
|
111
|
+
store.modal(message: CONFIRMATIONS.fetch(action), action: action)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_modal(key)
|
|
115
|
+
modal = store.snapshot.modal
|
|
116
|
+
if key == "y"
|
|
117
|
+
store.modal(nil)
|
|
118
|
+
return @exit_requested = true if modal[:action] == :quit
|
|
119
|
+
|
|
120
|
+
run(modal[:action], screen: modal[:action] == :apply ? :plan : :logs)
|
|
121
|
+
elsif ["n", :escape, "q"].include?(key)
|
|
122
|
+
store.modal(nil)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run(action, screen:)
|
|
127
|
+
return busy_notification if busy?
|
|
128
|
+
|
|
129
|
+
store.show(screen)
|
|
130
|
+
store.running(true)
|
|
131
|
+
@worker = Thread.new do
|
|
132
|
+
result = actions.public_send(action)
|
|
133
|
+
update_resources(result.value) if action == :status
|
|
134
|
+
store.show(screen, result: result.value)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
store.modal(nil)
|
|
137
|
+
store.show(:logs)
|
|
138
|
+
store.handle(
|
|
139
|
+
Events::Event.build("operation_failed", run_id: "tui", resource: action.to_s,
|
|
140
|
+
summary: action.to_s, message: e.message)
|
|
141
|
+
)
|
|
142
|
+
ensure
|
|
143
|
+
store.running(false)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def update_resources(status)
|
|
148
|
+
resources = status.managed_resources.map do |name, value|
|
|
149
|
+
state = value.is_a?(Hash) && value["managed"] == false ? "drift" : "managed"
|
|
150
|
+
{ name: name, status: state }
|
|
151
|
+
end
|
|
152
|
+
resources.unshift(name: "server", status: status.server ? status.server.status : "missing")
|
|
153
|
+
store.resources = resources
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def busy_notification
|
|
157
|
+
store.handle(Events::Event.build("warning_emitted", run_id: "tui", message: "Another operation is active"))
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kitsune
|
|
4
|
+
module Kit
|
|
5
|
+
module Tui
|
|
6
|
+
class Renderer
|
|
7
|
+
MIN_WIDTH = 70
|
|
8
|
+
MIN_HEIGHT = 18
|
|
9
|
+
|
|
10
|
+
def render(state)
|
|
11
|
+
width, height = state.terminal_size
|
|
12
|
+
return small_terminal(width, height) if width < MIN_WIDTH || height < MIN_HEIGHT
|
|
13
|
+
|
|
14
|
+
body = screen_lines(state, width, height - 4)
|
|
15
|
+
lines = [top_border(state, width), *body, footer(state, width), bottom_border(width)]
|
|
16
|
+
fit(lines, width, height)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def screen_lines(state, width, height)
|
|
22
|
+
return modal_lines(state, width, height) if state.modal
|
|
23
|
+
|
|
24
|
+
case state.screen
|
|
25
|
+
when :dashboard then dashboard(state, width, height)
|
|
26
|
+
when :plan then plan(state, width, height)
|
|
27
|
+
when :doctor then doctor(state, width, height)
|
|
28
|
+
when :logs then logs(state, width, height)
|
|
29
|
+
when :help then help_lines
|
|
30
|
+
else [" Unknown screen "]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dashboard(state, width, height)
|
|
35
|
+
left_width = [(width * 0.34).floor, 24].max
|
|
36
|
+
resources = state.resources.empty? ? [{ name: "No managed resources", status: "pending" }] : state.resources
|
|
37
|
+
left = resources.each_with_index.map do |resource, index|
|
|
38
|
+
marker = index == state.selected_resource ? ">" : " "
|
|
39
|
+
"#{marker} #{resource[:name].to_s.ljust(left_width - 13)} #{resource[:status]}"
|
|
40
|
+
end
|
|
41
|
+
right = ["Details", "", "Environment: #{state.environment}"]
|
|
42
|
+
right.concat(state.operations.last([height - 6, 0].max).map { |operation| operation_line(operation) })
|
|
43
|
+
columns(left, right, left_width, width, height)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def plan(state, width, _height)
|
|
47
|
+
plan = state.result
|
|
48
|
+
return [" Plan has not been built. Press p. "] unless plan
|
|
49
|
+
|
|
50
|
+
[" Plan for #{plan.environment}", ""] + plan.changes.map do |change|
|
|
51
|
+
marker = { "create" => "+", "update" => "~", "delete" => "-", "no_change" => "=" }.fetch(change.action)
|
|
52
|
+
truncate(" #{marker} #{change.summary}", width - 2)
|
|
53
|
+
end + ["", " #{plan.changed_count} change(s)"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def doctor(state, width, _height)
|
|
57
|
+
checks = state.result
|
|
58
|
+
return [" Doctor has not run. Press d. "] unless checks
|
|
59
|
+
|
|
60
|
+
checks.flat_map do |check|
|
|
61
|
+
lines = [truncate(" #{check.status.upcase.ljust(5)} #{check.name}: #{check.message}", width - 2)]
|
|
62
|
+
lines << truncate(" Fix: #{check.hint}", width - 2) if check.hint
|
|
63
|
+
lines
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def logs(state, width, height)
|
|
68
|
+
available = [height - 2, 0].max
|
|
69
|
+
end_index = [state.logs.length - state.scroll_offset, 0].max
|
|
70
|
+
start_index = [end_index - available, 0].max
|
|
71
|
+
visible = state.logs[start_index...end_index] || []
|
|
72
|
+
[" Logs", ""] + visible.map { |line| truncate(" #{line}", width - 2) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def help_lines
|
|
76
|
+
[
|
|
77
|
+
" Keyboard shortcuts", "", " j/k or arrows Select resource", " p Build plan",
|
|
78
|
+
" a Apply the visible plan", " d Run doctor", " r Resume failed run",
|
|
79
|
+
" l Show logs", " Tab Next screen", " PgUp/PgDn Scroll logs",
|
|
80
|
+
" ? Toggle help", " q Quit",
|
|
81
|
+
" Ctrl+C Cancel/quit"
|
|
82
|
+
]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def modal_lines(state, width, height)
|
|
86
|
+
message = state.modal[:message]
|
|
87
|
+
padding = [((height - 5) / 2), 0].max
|
|
88
|
+
([""] * padding) + [center("Confirmation", width - 2), "", center(message, width - 2),
|
|
89
|
+
center("Press y to confirm or n/Esc to cancel", width - 2)]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def columns(left, right, left_width, width, height)
|
|
93
|
+
right_width = width - left_width - 4
|
|
94
|
+
Array.new(height) do |index|
|
|
95
|
+
" #{truncate(left[index].to_s, left_width).ljust(left_width)}│ #{truncate(right[index].to_s, right_width)}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def operation_line(operation)
|
|
100
|
+
duration = operation[:duration_ms] ? " #{operation[:duration_ms]}ms" : ""
|
|
101
|
+
progress = operation[:percent] ? " #{operation[:percent]}%" : ""
|
|
102
|
+
"#{operation[:status].to_s.ljust(9)} #{operation[:summary]}#{progress}#{duration}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def top_border(state, width)
|
|
106
|
+
title = " Kitsune Kit · #{state.environment} · #{state.screen} "
|
|
107
|
+
"┌#{title}#{'─' * [width - title.length - 2, 0].max}┐"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def footer(state, width)
|
|
111
|
+
status = state.running ? "running" : (state.notification || "ready")
|
|
112
|
+
content = truncate("├─ p plan a apply d doctor r resume l logs ? help q quit · #{status} ", width - 1)
|
|
113
|
+
"#{content.ljust(width - 1, '─')}┤"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def bottom_border(width) = "└#{'─' * (width - 2)}┘"
|
|
117
|
+
|
|
118
|
+
def small_terminal(width, height)
|
|
119
|
+
fit(["Kitsune Kit", "", "Terminal too small (#{width}x#{height}).", "Minimum: #{MIN_WIDTH}x#{MIN_HEIGHT}.",
|
|
120
|
+
"Resize or press q to quit."], width, height)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fit(lines, width, height)
|
|
124
|
+
lines.first(height).map { |line| truncate(line, width).ljust(width) }.then do |visible|
|
|
125
|
+
(visible + Array.new([height - visible.length, 0].max, " " * width)).join("\n")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def center(value, width) = value.to_s.center(width)
|
|
130
|
+
def truncate(value, width) = value.length > width ? "#{value[0, [width - 1, 0].max]}…" : value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -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
|