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,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,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
|