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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "secret_stores/store"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module SecretStores
9
+ class Environment < Store
10
+ def initialize(env: ENV, filter: nil)
11
+ super()
12
+ @env = env
13
+ @filter = filter
14
+ end
15
+
16
+ def fetch(name, required: true)
17
+ value = @env.fetch(name.to_s, "")
18
+ if required && value.empty?
19
+ raise Errors::ConfigurationError.new(
20
+ "required secret #{name} is not set",
21
+ hint: "Export #{name} before running this command."
22
+ )
23
+ end
24
+ @filter&.register(value)
25
+ value
26
+ end
27
+
28
+ def configured?(name) = !@env.fetch(name.to_s, "").empty?
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module SecretStores
6
+ class Store
7
+ def fetch(name, required: true) = raise NotImplementedError
8
+ def configured?(name) = raise NotImplementedError
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ class ServiceCompose
9
+ def initialize(config:, type:, service:)
10
+ @config = config
11
+ @type = type
12
+ @service = service
13
+ end
14
+
15
+ def content = YAML.dump(document)
16
+ def env_content(password) = "#{@service.password_env}=#{JSON.generate(password)}\n"
17
+
18
+ private
19
+
20
+ def document
21
+ {
22
+ "name" => project_name,
23
+ "services" => { @type => service_definition },
24
+ "volumes" => { "data" => nil },
25
+ "networks" => { "private" => { "external" => true, "name" => "kitsune-private" } }
26
+ }
27
+ end
28
+
29
+ def service_definition
30
+ definition = @type == "postgres" ? postgres : redis
31
+ definition["ports"] = ["#{@service.bind}:#{@service.port}:#{container_port}"] if @service.publish
32
+ definition
33
+ end
34
+
35
+ def postgres
36
+ {
37
+ "image" => @service.image,
38
+ "restart" => "unless-stopped",
39
+ "environment" => {
40
+ "POSTGRES_DB" => database,
41
+ "POSTGRES_USER" => "postgres",
42
+ "POSTGRES_PASSWORD" => interpolation
43
+ },
44
+ "volumes" => ["data:/var/lib/postgresql/data"],
45
+ "networks" => ["private"],
46
+ "healthcheck" => healthcheck("pg_isready -U postgres -d #{database}")
47
+ }
48
+ end
49
+
50
+ def redis
51
+ {
52
+ "image" => @service.image,
53
+ "restart" => "unless-stopped",
54
+ "environment" => { "REDIS_PASSWORD" => interpolation },
55
+ "command" => ["sh", "-ec", 'exec redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"'],
56
+ "volumes" => ["data:/data"],
57
+ "networks" => ["private"],
58
+ "healthcheck" => healthcheck('REDISCLI_AUTH="$${REDIS_PASSWORD}" redis-cli ping | grep -Fxq PONG')
59
+ }
60
+ end
61
+
62
+ def healthcheck(command)
63
+ { "test" => ["CMD-SHELL", command], "interval" => "10s", "timeout" => "5s", "retries" => 12 }
64
+ end
65
+
66
+ def project_name = "kitsune-#{@config.environment}-#{@type}"
67
+ def database = "app_#{@config.environment.tr('-', '_')}"
68
+ def container_port = @type == "postgres" ? 5432 : 6379
69
+ def interpolation = "${#{@service.password_env}}"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+ require "tempfile"
7
+ require "time"
8
+ require_relative "errors"
9
+ require_relative "state_stores/store"
10
+
11
+ module Kitsune
12
+ module Kit
13
+ class StateStore < StateStores::Store
14
+ SCHEMA_VERSION = 1
15
+
16
+ def initialize(root: Dir.pwd)
17
+ super()
18
+ @directory = Pathname(root).expand_path.join(".kitsune/state")
19
+ end
20
+
21
+ def read(environment)
22
+ path = state_path(environment)
23
+ return empty_state(environment) unless path.file?
24
+
25
+ with_lock(environment, shared: true) do
26
+ parsed = JSON.parse(path.read)
27
+ validate!(parsed, environment)
28
+ parsed
29
+ end
30
+ rescue JSON::ParserError => e
31
+ raise Errors::ConfigurationError.new(
32
+ "state file is invalid JSON: #{path}",
33
+ hint: "Restore the state backup or import the environment again.",
34
+ context: { cause: e.message }
35
+ )
36
+ end
37
+
38
+ def update(environment)
39
+ with_lock(environment) do
40
+ current = read_without_lock(environment)
41
+ updated = yield(deep_copy(current))
42
+ updated["updated_at"] = Time.now.utc.iso8601(6)
43
+ validate!(updated, environment)
44
+ write_atomically(state_path(environment), JSON.pretty_generate(updated) << "\n")
45
+ deep_copy(updated)
46
+ end
47
+ end
48
+
49
+ def write(environment, state)
50
+ update(environment) { state }
51
+ end
52
+
53
+ def delete(environment)
54
+ with_lock(environment) do
55
+ next unless state_path(environment).file?
56
+
57
+ state_path(environment).delete
58
+ true
59
+ end
60
+ end
61
+
62
+ def with_execution_lock(environment)
63
+ FileUtils.mkdir_p(@directory, mode: 0o700)
64
+ path = @directory.join("#{safe_environment(environment)}.operation.lock")
65
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
66
+ locked = file.flock(File::LOCK_EX | File::LOCK_NB)
67
+ unless locked
68
+ raise Errors::UnsafeOperationError.new(
69
+ "another mutating operation is active for #{environment}",
70
+ hint: "Wait for it to finish or stop it safely before retrying."
71
+ )
72
+ end
73
+ yield
74
+ ensure
75
+ file.flock(File::LOCK_UN) if locked
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def empty_state(environment)
82
+ {
83
+ "version" => SCHEMA_VERSION,
84
+ "environment" => environment.to_s,
85
+ "updated_at" => nil,
86
+ "resources" => {},
87
+ "operations" => [],
88
+ "runs" => {}
89
+ }
90
+ end
91
+
92
+ def state_path(environment) = @directory.join("#{safe_environment(environment)}.json")
93
+ def lock_path(environment) = @directory.join("#{safe_environment(environment)}.lock")
94
+
95
+ def safe_environment(environment)
96
+ value = environment.to_s
97
+ return value if value.match?(/\A[a-zA-Z0-9_-]+\z/)
98
+
99
+ raise Errors::ConfigurationError, "environment name has an invalid format"
100
+ end
101
+
102
+ def with_lock(environment, shared: false)
103
+ FileUtils.mkdir_p(@directory, mode: 0o700)
104
+ File.open(lock_path(environment), File::RDWR | File::CREAT, 0o600) do |file|
105
+ file.flock(shared ? File::LOCK_SH : File::LOCK_EX)
106
+ yield
107
+ ensure
108
+ file.flock(File::LOCK_UN)
109
+ end
110
+ end
111
+
112
+ def read_without_lock(environment)
113
+ path = state_path(environment)
114
+ return empty_state(environment) unless path.file?
115
+
116
+ parsed = JSON.parse(path.read)
117
+ validate!(parsed, environment)
118
+ parsed
119
+ rescue JSON::ParserError => e
120
+ raise Errors::ConfigurationError.new("state file is invalid JSON: #{path}", context: { cause: e.message })
121
+ end
122
+
123
+ def validate!(state, environment)
124
+ raise Errors::ConfigurationError, "state must be an object" unless state.is_a?(Hash)
125
+ unless state["version"] == SCHEMA_VERSION
126
+ raise Errors::ConfigurationError.new(
127
+ "unsupported state schema version #{state['version'].inspect}; expected #{SCHEMA_VERSION}",
128
+ hint: "Use a compatible Kitsune Kit version; do not edit IDs manually. Preserve this file and its backup."
129
+ )
130
+ end
131
+ raise Errors::ConfigurationError, "state environment mismatch" unless state["environment"] == environment.to_s
132
+ raise Errors::ConfigurationError, "state resources must be an object" unless state["resources"].is_a?(Hash)
133
+ raise Errors::ConfigurationError, "state operations must be an array" unless state["operations"].is_a?(Array)
134
+ raise Errors::ConfigurationError, "state runs must be an object" unless state["runs"].is_a?(Hash)
135
+
136
+ state
137
+ end
138
+
139
+ def write_atomically(path, content)
140
+ FileUtils.mkdir_p(path.dirname, mode: 0o700)
141
+ backup = Pathname("#{path}.backup")
142
+ if path.file?
143
+ FileUtils.cp(path, backup)
144
+ File.chmod(0o600, backup)
145
+ end
146
+ Tempfile.create([path.basename.to_s, ".tmp"], path.dirname.to_s, mode: 0o600) do |temp|
147
+ temp.write(content)
148
+ temp.flush
149
+ temp.fsync
150
+ File.rename(temp.path, path)
151
+ File.chmod(0o600, path)
152
+ end
153
+ end
154
+
155
+ def deep_copy(value) = Marshal.load(Marshal.dump(value))
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module StateStores
6
+ class Store
7
+ def read(environment) = raise NotImplementedError
8
+ def update(environment) = raise NotImplementedError
9
+ def write(environment, state) = raise NotImplementedError
10
+ def delete(environment) = raise NotImplementedError
11
+ def with_execution_lock(environment) = raise NotImplementedError
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Tui
6
+ class Actions
7
+ def initialize(application:, cancellation: Cancellation.new)
8
+ @application = application
9
+ @cancellation = cancellation
10
+ @last_plan = nil
11
+ end
12
+
13
+ def status
14
+ Workflows::InspectEnvironment.new(
15
+ config: app.config, provider: app.provider, state_store: app.state_store, event_bus: app.event_bus
16
+ ).call
17
+ end
18
+
19
+ def plan
20
+ workflow = Workflows::BuildPlan.new(config: app.config, operations: app.operations,
21
+ event_bus: app.event_bus)
22
+ workflow.call.tap do |result|
23
+ @last_plan = result.value
24
+ end
25
+ end
26
+
27
+ def doctor
28
+ Workflows::Doctor.new(
29
+ config: app.config,
30
+ provider: app.provider,
31
+ state_store: app.state_store,
32
+ transport_factory: app.transport_factory,
33
+ operations: app.operations,
34
+ event_bus: app.event_bus
35
+ ).call
36
+ end
37
+
38
+ def apply
39
+ @last_plan ||= plan.value
40
+ raise Errors::UnsafeOperationError, "the plan contains destructive changes" if @last_plan.destructive?
41
+ return Result.success([], metadata: { unchanged: true }) unless @last_plan.changed?
42
+
43
+ Workflows::ApplyPlan.new(
44
+ config: app.config, operations: app.operations, state_store: app.state_store, event_bus: app.event_bus,
45
+ cancellation: @cancellation
46
+ ).call(plan: @last_plan)
47
+ ensure
48
+ @last_plan = nil
49
+ reset_cancellation
50
+ end
51
+
52
+ def resume
53
+ Workflows::ApplyPlan.new(
54
+ config: app.config, operations: app.operations, state_store: app.state_store, event_bus: app.event_bus,
55
+ cancellation: @cancellation
56
+ ).call(resume_from: true)
57
+ ensure
58
+ reset_cancellation
59
+ end
60
+
61
+ def cancel = @cancellation.cancel!
62
+
63
+ private
64
+
65
+ attr_reader :application
66
+ alias app application
67
+
68
+ def reset_cancellation = @cancellation = Cancellation.new
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Tui
6
+ class Application
7
+ def initialize(store:, controller:, renderer: Renderer.new, terminal: Terminal.new)
8
+ @store = store
9
+ @controller = controller
10
+ @renderer = renderer
11
+ @terminal = terminal
12
+ end
13
+
14
+ def call
15
+ terminal.run do
16
+ controller.start
17
+ until controller.exit_requested
18
+ width, height = terminal.size
19
+ store.resize(width, height)
20
+ terminal.draw(renderer.render(store.snapshot))
21
+ controller.handle(terminal.read_key)
22
+ end
23
+ end
24
+ 0
25
+ ensure
26
+ controller.wait
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :store, :controller, :renderer, :terminal
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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