kitsune-kit 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +112 -135
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/decisions/0007-compose-customization.md +30 -0
  14. data/docs/architecture/provider-adapters.md +49 -0
  15. data/docs/architecture.md +93 -0
  16. data/docs/commands.md +224 -0
  17. data/docs/configuration.md +226 -0
  18. data/docs/getting-started.md +133 -0
  19. data/docs/providers/digitalocean.md +52 -0
  20. data/docs/releasing.md +43 -0
  21. data/docs/roadmap.md +46 -0
  22. data/docs/security-audit.md +130 -0
  23. data/docs/security.md +116 -0
  24. data/docs/services/compose.md +164 -0
  25. data/docs/services/postgres.md +78 -0
  26. data/docs/services/redis.md +53 -0
  27. data/docs/testing.md +200 -0
  28. data/docs/troubleshooting.md +123 -0
  29. data/docs/tui.md +57 -0
  30. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  31. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  32. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  34. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  35. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  36. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  37. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  38. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  39. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  40. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  41. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  42. data/lib/kitsune/kit/application.rb +170 -0
  43. data/lib/kitsune/kit/cancellation.rb +21 -0
  44. data/lib/kitsune/kit/cli.rb +879 -64
  45. data/lib/kitsune/kit/clock.rb +11 -0
  46. data/lib/kitsune/kit/configuration.rb +577 -0
  47. data/lib/kitsune/kit/errors.rb +98 -0
  48. data/lib/kitsune/kit/events.rb +55 -0
  49. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  50. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  51. data/lib/kitsune/kit/operations/ensure_service.rb +302 -0
  52. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  53. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  54. data/lib/kitsune/kit/operations/service_files.rb +131 -0
  55. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  56. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  57. data/lib/kitsune/kit/plan.rb +72 -0
  58. data/lib/kitsune/kit/reporters/human.rb +89 -0
  59. data/lib/kitsune/kit/reporters/json.rb +65 -0
  60. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  61. data/lib/kitsune/kit/result.rb +28 -0
  62. data/lib/kitsune/kit/run_journal.rb +102 -0
  63. data/lib/kitsune/kit/run_logger.rb +37 -0
  64. data/lib/kitsune/kit/scripts/docker.sh +142 -0
  65. data/lib/kitsune/kit/scripts/firewall.sh +210 -0
  66. data/lib/kitsune/kit/scripts/metrics.sh +59 -0
  67. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  68. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  69. data/lib/kitsune/kit/scripts/unattended.sh +86 -0
  70. data/lib/kitsune/kit/scripts/user.sh +114 -0
  71. data/lib/kitsune/kit/secret_filter.rb +54 -0
  72. data/lib/kitsune/kit/secret_store.rb +32 -0
  73. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  74. data/lib/kitsune/kit/service_compose.rb +272 -0
  75. data/lib/kitsune/kit/state_store.rb +158 -0
  76. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  77. data/lib/kitsune/kit/tui/actions.rb +72 -0
  78. data/lib/kitsune/kit/tui/application.rb +35 -0
  79. data/lib/kitsune/kit/tui/controller.rb +162 -0
  80. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  81. data/lib/kitsune/kit/tui/state.rb +18 -0
  82. data/lib/kitsune/kit/tui/store.rb +88 -0
  83. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  84. data/lib/kitsune/kit/version.rb +1 -1
  85. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  86. data/lib/kitsune/kit/workflows/base.rb +31 -0
  87. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  88. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  89. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  90. data/lib/kitsune/kit/workflows/eject_compose.rb +82 -0
  91. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  92. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  93. data/lib/kitsune/kit/workflows/initialize_project.rb +129 -0
  94. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  95. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  96. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  97. data/lib/kitsune/kit.rb +42 -2
  98. metadata +125 -79
  99. data/.rspec +0 -3
  100. data/Rakefile +0 -8
  101. data/kitsune-kit-logo.jpg +0 -0
  102. data/lib/kitsune/blueprints/.env.template +0 -31
  103. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  104. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  105. data/lib/kitsune/blueprints/kit.env.template +0 -1
  106. data/lib/kitsune/kit/ansi_color.rb +0 -78
  107. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  108. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  109. data/lib/kitsune/kit/commands/dns.rb +0 -112
  110. data/lib/kitsune/kit/commands/init.rb +0 -148
  111. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  112. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  113. data/lib/kitsune/kit/commands/provision.rb +0 -43
  114. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  115. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  116. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  117. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  118. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  119. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  120. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  121. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  122. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  123. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  124. data/lib/kitsune/kit/defaults.rb +0 -91
  125. data/lib/kitsune/kit/env_loader.rb +0 -41
  126. data/lib/kitsune/kit/options_builder.rb +0 -26
  127. data/lib/kitsune/kit/provisioner.rb +0 -107
  128. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kitsune
4
4
  module Kit
5
- VERSION = "0.4.1"
5
+ VERSION = "0.6.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