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
data/lib/kitsune/kit/cli.rb
CHANGED
|
@@ -1,95 +1,833 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "thor"
|
|
2
|
-
require_relative "
|
|
3
|
-
require_relative "env_loader"
|
|
4
|
-
require_relative "commands/init"
|
|
5
|
-
require_relative "commands/switch_env"
|
|
6
|
-
require_relative "commands/provision"
|
|
7
|
-
require_relative "commands/dns"
|
|
8
|
-
require_relative "commands/setup_user"
|
|
9
|
-
require_relative "commands/setup_firewall"
|
|
10
|
-
require_relative "commands/setup_unattended"
|
|
11
|
-
require_relative "commands/setup_swap"
|
|
12
|
-
require_relative "commands/setup_do_metrics"
|
|
13
|
-
require_relative "commands/bootstrap"
|
|
14
|
-
require_relative "commands/setup_docker_prereqs"
|
|
15
|
-
require_relative "commands/install_docker_engine"
|
|
16
|
-
require_relative "commands/postinstall_docker"
|
|
17
|
-
require_relative "commands/bootstrap_docker"
|
|
18
|
-
require_relative "commands/setup_postgres_docker"
|
|
19
|
-
require_relative "commands/setup_redis_docker"
|
|
20
|
-
require_relative "commands/ssh"
|
|
4
|
+
require_relative "../kit"
|
|
21
5
|
|
|
22
6
|
module Kitsune
|
|
23
7
|
module Kit
|
|
24
8
|
class CLI < Thor
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
9
|
+
package_name "Kitsune Kit"
|
|
10
|
+
|
|
11
|
+
# Thor may add framework-level commands between minor releases. Keep the
|
|
12
|
+
# Keep the Kitsune Kit public surface intentional instead of inheriting those commands.
|
|
13
|
+
remove_task :tree if all_tasks.key?("tree")
|
|
14
|
+
|
|
15
|
+
class_option :env, type: :string, aliases: "-e", desc: "Environment name"
|
|
16
|
+
class_option :root, type: :string, default: Dir.pwd, desc: "Project root"
|
|
17
|
+
class_option :config, type: :string, desc: "Alternative project configuration file"
|
|
18
|
+
class_option :format, type: :string, default: "human", enum: %w[human json], desc: "Output format"
|
|
19
|
+
class_option :no_color, type: :boolean, default: false, desc: "Disable ANSI colors"
|
|
20
|
+
class_option :no_input, type: :boolean, default: false, desc: "Fail instead of prompting"
|
|
21
|
+
class_option :yes, type: :boolean, default: false, desc: "Approve non-destructive changes"
|
|
22
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Build and display a plan without applying"
|
|
23
|
+
class_option :quiet, type: :boolean, default: false, desc: "Show only warnings, errors and final results"
|
|
24
|
+
class_option :verbose, type: :boolean, default: false, desc: "Show additional operational details"
|
|
25
|
+
class_option :debug, type: :boolean, default: false, desc: "Show technical error details"
|
|
26
|
+
class_option :log, type: :boolean, default: true, desc: "Write redacted local execution logs"
|
|
27
|
+
class_option :timeout, type: :numeric, desc: "Maximum seconds allowed for each remote step"
|
|
28
|
+
class_option :trust_host_key, type: :string, desc: "Trust this exact SHA256 SSH host-key fingerprint"
|
|
29
|
+
|
|
30
|
+
def self.exit_on_failure? = true
|
|
31
|
+
|
|
32
|
+
def self.start(given_args = ARGV, config = {})
|
|
33
|
+
config[:shell] ||= Thor::Base.shell.new
|
|
34
|
+
dispatch(nil, normalized_arguments(given_args), nil, config)
|
|
35
|
+
rescue Thor::Error => e
|
|
36
|
+
config[:debug] || ENV["THOR_DEBUG"] == "1" ? raise(e) : config[:shell].error(e.message)
|
|
37
|
+
|
|
38
|
+
exit(2)
|
|
39
|
+
rescue Errno::EPIPE
|
|
40
|
+
exit(0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.normalized_arguments(arguments)
|
|
44
|
+
values = arguments.dup
|
|
45
|
+
return ["help", values.first] if values.length == 2 && values.last == "help" && tasks.key?(values.first)
|
|
46
|
+
|
|
47
|
+
values
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc "version", "Print the Kitsune Kit version"
|
|
51
|
+
def version
|
|
52
|
+
puts "Kitsune Kit #{VERSION}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
map %w[-v --version] => :version
|
|
56
|
+
|
|
57
|
+
desc "init", "Create a new .kitsune project configuration"
|
|
58
|
+
option :force, type: :boolean, default: false, desc: "Replace generated configuration files"
|
|
59
|
+
def init
|
|
60
|
+
execute do
|
|
61
|
+
files = Workflows::InitializeProject.new(root: options[:root]).call(force: options[:force])
|
|
62
|
+
if json?
|
|
63
|
+
flush_json(build_reporter(build_secret_filter), Result.success(files), "init", "development")
|
|
64
|
+
else
|
|
65
|
+
puts "Created Kitsune Kit configuration:"
|
|
66
|
+
files.each { |file| puts " #{file}" }
|
|
67
|
+
puts "Next: edit .kitsune/config.yml, export DO_API_TOKEN, then run `kit doctor`."
|
|
68
|
+
end
|
|
69
|
+
0
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "doctor", "Check configuration, credentials and managed resources without changing them"
|
|
74
|
+
def doctor
|
|
75
|
+
execute do
|
|
76
|
+
app, reporter = build_application
|
|
77
|
+
result = Workflows::Doctor.new(
|
|
78
|
+
config: app.config,
|
|
79
|
+
provider: app.provider,
|
|
80
|
+
state_store: app.state_store,
|
|
81
|
+
transport_factory: app.transport_factory,
|
|
82
|
+
operations: app.operations,
|
|
83
|
+
event_bus: app.event_bus
|
|
84
|
+
).call
|
|
85
|
+
if json?
|
|
86
|
+
flush_json(reporter, result, "doctor", app.config.environment)
|
|
87
|
+
else
|
|
88
|
+
puts
|
|
89
|
+
print_checks(result.value)
|
|
90
|
+
end
|
|
91
|
+
result.success? ? 0 : 1
|
|
29
92
|
end
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
desc "plan", "Inspect infrastructure changes without applying them"
|
|
96
|
+
def plan
|
|
97
|
+
execute do
|
|
98
|
+
app, reporter = build_application
|
|
99
|
+
result = Workflows::BuildPlan.new(
|
|
100
|
+
config: app.config,
|
|
101
|
+
operations: app.operations,
|
|
102
|
+
event_bus: app.event_bus
|
|
103
|
+
).call
|
|
104
|
+
json? ? flush_json(reporter, result, "plan", app.config.environment) : print_plan(result.value)
|
|
105
|
+
0
|
|
33
106
|
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
desc "apply", "Apply the desired infrastructure configuration"
|
|
110
|
+
def apply
|
|
111
|
+
execute do
|
|
112
|
+
app, reporter = build_application
|
|
113
|
+
plan_result = Workflows::BuildPlan.new(
|
|
114
|
+
config: app.config,
|
|
115
|
+
operations: app.operations,
|
|
116
|
+
event_bus: app.event_bus
|
|
117
|
+
).call
|
|
118
|
+
plan = plan_result.value
|
|
119
|
+
print_plan(plan) unless json?
|
|
120
|
+
|
|
121
|
+
if options[:dry_run]
|
|
122
|
+
render_apply_terminal(reporter, plan_result, app, "Dry run: no changes were applied.")
|
|
123
|
+
next 0
|
|
124
|
+
end
|
|
34
125
|
|
|
35
|
-
|
|
126
|
+
unless plan.changed?
|
|
127
|
+
result = Result.success([], metadata: { unchanged: true })
|
|
128
|
+
render_apply_terminal(reporter, result, app, "No changes to apply.")
|
|
129
|
+
next 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
confirm_apply!(plan)
|
|
133
|
+
result = Workflows::ApplyPlan.new(
|
|
134
|
+
config: app.config,
|
|
135
|
+
operations: app.operations,
|
|
136
|
+
state_store: app.state_store,
|
|
137
|
+
event_bus: app.event_bus
|
|
138
|
+
).call(plan: plan)
|
|
139
|
+
render_apply_success(reporter, result, app)
|
|
140
|
+
0
|
|
141
|
+
end
|
|
36
142
|
end
|
|
37
143
|
|
|
38
|
-
desc "
|
|
39
|
-
def
|
|
40
|
-
|
|
144
|
+
desc "resume [RUN_ID]", "Resume the latest failed or interrupted apply run"
|
|
145
|
+
def resume(run_id = nil)
|
|
146
|
+
execute do
|
|
147
|
+
app, reporter = build_application
|
|
148
|
+
confirm_resume!(run_id)
|
|
149
|
+
result = Workflows::ApplyPlan.new(
|
|
150
|
+
config: app.config,
|
|
151
|
+
operations: app.operations,
|
|
152
|
+
state_store: app.state_store,
|
|
153
|
+
event_bus: app.event_bus
|
|
154
|
+
).call(resume_from: run_id || true)
|
|
155
|
+
flush_json(reporter, result, "resume", app.config.environment) if json?
|
|
156
|
+
0
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
desc "status", "Show the current server and managed-resource state"
|
|
161
|
+
def status
|
|
162
|
+
execute do
|
|
163
|
+
app, reporter = build_application
|
|
164
|
+
result = Workflows::InspectEnvironment.new(
|
|
165
|
+
config: app.config,
|
|
166
|
+
provider: app.provider,
|
|
167
|
+
state_store: app.state_store,
|
|
168
|
+
event_bus: app.event_bus
|
|
169
|
+
).call
|
|
170
|
+
if json?
|
|
171
|
+
flush_json(reporter, result, "status", app.config.environment)
|
|
172
|
+
else
|
|
173
|
+
print_environment_status(result.value)
|
|
174
|
+
end
|
|
175
|
+
0
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
desc "rollback", "Restore managed configuration while preserving the server and service data"
|
|
180
|
+
def rollback
|
|
181
|
+
execute do
|
|
182
|
+
app, reporter = build_application
|
|
183
|
+
confirm_named_action!("rollback", app.config.environment, option: :yes)
|
|
184
|
+
result = Workflows::Rollback.new(
|
|
185
|
+
config: app.config,
|
|
186
|
+
operations: app.operations.drop(1),
|
|
187
|
+
state_store: app.state_store,
|
|
188
|
+
event_bus: app.event_bus
|
|
189
|
+
).call
|
|
190
|
+
flush_json(reporter, result, "rollback", app.config.environment) if json?
|
|
191
|
+
0
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
desc "server ACTION", "Manage the server: show, create, configure, ssh, import, destroy"
|
|
196
|
+
option :confirm_destroy, type: :string, desc: "Exact server name required for destruction"
|
|
197
|
+
option :provider_id, type: :string, desc: "Exact provider ID required for state recovery"
|
|
198
|
+
option :confirm_import, type: :string, desc: "Exact server name required for state import"
|
|
199
|
+
def server(action)
|
|
200
|
+
execute do
|
|
201
|
+
app, reporter = build_application
|
|
202
|
+
result = dispatch_server_action(app, action)
|
|
203
|
+
flush_json(reporter, result, "server.#{action}", app.config.environment) if json? && result.is_a?(Result)
|
|
204
|
+
0
|
|
205
|
+
end
|
|
41
206
|
end
|
|
42
207
|
|
|
43
|
-
desc "
|
|
44
|
-
|
|
208
|
+
desc "service TYPE ACTION", "Manage postgres or redis: status, install, backup, remove, destroy-data"
|
|
209
|
+
option :confirm_destroy, type: :string, desc: "Exact TYPE@ENV value required for data destruction"
|
|
210
|
+
option :backup_before_destroy, type: :boolean, default: false,
|
|
211
|
+
desc: "Create a restricted remote data backup before destroy-data"
|
|
212
|
+
def service(type, action)
|
|
213
|
+
execute do
|
|
214
|
+
app, reporter = build_application
|
|
215
|
+
operation = find_service_operation(app, type, action)
|
|
216
|
+
result = dispatch_service_action(app, operation, type, action)
|
|
217
|
+
if json? && result.is_a?(Result)
|
|
218
|
+
flush_json(reporter, result, "service.#{type}.#{action}", app.config.environment)
|
|
219
|
+
end
|
|
220
|
+
0
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
desc "dns ACTION", "Manage configured DNS records: list, plan, apply, remove"
|
|
225
|
+
def dns(action)
|
|
226
|
+
execute do
|
|
227
|
+
app, reporter = build_application
|
|
228
|
+
operation = app.operations.find { |candidate| candidate.resource == "dns" }
|
|
229
|
+
raise Errors::ConfigurationError, "no DNS domains are configured" unless operation
|
|
230
|
+
|
|
231
|
+
result = dispatch_resource_action(app, operation, action)
|
|
232
|
+
flush_json(reporter, result, "dns.#{action}", app.config.environment) if json? && result.is_a?(Result)
|
|
233
|
+
0
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
desc "env ACTION [NAME]", "Manage environments: list, current, use NAME"
|
|
238
|
+
def env(action, name = nil)
|
|
239
|
+
execute do
|
|
240
|
+
selection = Workflows::EnvironmentSelection.new(root: options[:root])
|
|
241
|
+
value = case action
|
|
242
|
+
when "list" then selection.list
|
|
243
|
+
when "current" then selection.current
|
|
244
|
+
when "use" then selection.use(name)
|
|
245
|
+
else raise Errors::ConfigurationError, "unknown environment action: #{action}"
|
|
246
|
+
end
|
|
247
|
+
if json?
|
|
248
|
+
flush_json(
|
|
249
|
+
build_reporter(build_secret_filter), Result.success(value), "env.#{action}", selection.current
|
|
250
|
+
)
|
|
251
|
+
elsif value.is_a?(Array)
|
|
252
|
+
value.each { |environment| puts environment }
|
|
253
|
+
else
|
|
254
|
+
puts value
|
|
255
|
+
end
|
|
256
|
+
0
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
desc "support ACTION", "Create local diagnostics: bundle"
|
|
261
|
+
def support(action)
|
|
262
|
+
execute do
|
|
263
|
+
raise Errors::ConfigurationError, "unknown support action: #{action}" unless action == "bundle"
|
|
264
|
+
|
|
265
|
+
app, = build_application
|
|
266
|
+
filter = configured_secret_filter(app.config)
|
|
267
|
+
doctor = Workflows::Doctor.new(
|
|
268
|
+
config: app.config,
|
|
269
|
+
provider: app.provider,
|
|
270
|
+
state_store: app.state_store,
|
|
271
|
+
transport_factory: app.transport_factory,
|
|
272
|
+
operations: app.operations
|
|
273
|
+
)
|
|
274
|
+
path = Workflows::SupportBundle.new(
|
|
275
|
+
root: options[:root], config: app.config, state_store: app.state_store, doctor: doctor,
|
|
276
|
+
secret_filter: filter
|
|
277
|
+
).call
|
|
278
|
+
if json?
|
|
279
|
+
flush_json(
|
|
280
|
+
build_reporter(filter), Result.success({ path: path }), "support.bundle", app.config.environment
|
|
281
|
+
)
|
|
282
|
+
else
|
|
283
|
+
puts "Support bundle created: #{path}"
|
|
284
|
+
puts "\nRedacted contents (inspect before sharing):"
|
|
285
|
+
puts File.read(path)
|
|
286
|
+
puts "Kitsune Kit did not upload anything."
|
|
287
|
+
end
|
|
288
|
+
0
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
desc "docker ACTION", "Manage Docker: status, install, uninstall"
|
|
293
|
+
def docker(action)
|
|
294
|
+
execute do
|
|
295
|
+
app, reporter = build_application
|
|
296
|
+
operation = app.operations.find { |candidate| candidate.resource == "docker" }
|
|
297
|
+
result = dispatch_docker_action(app, operation, action)
|
|
298
|
+
flush_json(reporter, result, "docker.#{action}", app.config.environment) if json? && result.is_a?(Result)
|
|
299
|
+
0
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
desc "ui", "Open the interactive terminal interface"
|
|
304
|
+
def ui
|
|
305
|
+
execute do
|
|
306
|
+
build_tui.call
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
default_task :help
|
|
311
|
+
|
|
312
|
+
no_commands do
|
|
313
|
+
def execute
|
|
314
|
+
status = yield
|
|
315
|
+
exit(status) if status.is_a?(Integer) && !status.zero?
|
|
316
|
+
status
|
|
317
|
+
rescue Errors::Error => e
|
|
318
|
+
render_error(e)
|
|
319
|
+
exit(Errors.exit_status(e))
|
|
320
|
+
rescue Interrupt
|
|
321
|
+
warn "Cancelled."
|
|
322
|
+
exit(130)
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
render_unexpected_error(e)
|
|
325
|
+
exit(1)
|
|
326
|
+
ensure
|
|
327
|
+
puts "Log: #{@run_log_path}" if @run_log_path && !json?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def build_application
|
|
331
|
+
filter = @secret_filter = build_secret_filter
|
|
332
|
+
bus = Events::Bus.new
|
|
333
|
+
reporter = build_reporter(filter)
|
|
334
|
+
bus.subscribe(reporter)
|
|
335
|
+
subscribe_logger(bus, filter)
|
|
336
|
+
app = Application.build(
|
|
337
|
+
root: options[:root],
|
|
338
|
+
environment: options[:env],
|
|
339
|
+
config_path: options[:config],
|
|
340
|
+
event_bus: bus,
|
|
341
|
+
host_key_confirmation: method(:confirm_host_key),
|
|
342
|
+
maximum_timeout: options[:timeout]
|
|
343
|
+
)
|
|
344
|
+
register_configured_secrets(filter, app.config)
|
|
345
|
+
[app, reporter]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def build_tui
|
|
349
|
+
unless $stdin.tty? && $stdout.tty?
|
|
350
|
+
raise Errors::ConfigurationError.new(
|
|
351
|
+
"the TUI requires an interactive terminal",
|
|
352
|
+
hint: "Use `kit doctor`, `kit plan`, `kit apply`, or --format json in automation."
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
filter = @secret_filter = build_secret_filter
|
|
357
|
+
bus = Events::Bus.new
|
|
358
|
+
loader = Configuration::Loader.new(root: options[:root], env: ENV, config_path: options[:config])
|
|
359
|
+
config = loader.load(environment: options[:env])
|
|
360
|
+
store = Tui::Store.new(environment: config.environment, secret_filter: filter)
|
|
361
|
+
bus.subscribe(store)
|
|
362
|
+
subscribe_logger(bus, filter)
|
|
363
|
+
app = Application.build(
|
|
364
|
+
root: options[:root], environment: options[:env], config_path: options[:config], event_bus: bus,
|
|
365
|
+
host_key_confirmation: method(:confirm_host_key), maximum_timeout: options[:timeout]
|
|
366
|
+
)
|
|
367
|
+
register_configured_secrets(filter, app.config)
|
|
368
|
+
controller = Tui::Controller.new(store: store, actions: Tui::Actions.new(application: app))
|
|
369
|
+
Tui::Application.new(store: store, controller: controller)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def build_secret_filter
|
|
373
|
+
SecretFilter.new([ENV["DO_API_TOKEN"], ENV["POSTGRES_PASSWORD"], ENV["REDIS_PASSWORD"]])
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def configured_secret_filter(config)
|
|
377
|
+
build_secret_filter.tap { |filter| register_configured_secrets(filter, config) }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def build_reporter(filter)
|
|
381
|
+
return Reporters::Json.new(secret_filter: filter) if json?
|
|
382
|
+
|
|
383
|
+
Reporters::Human.new(color: !options[:no_color] && !ENV.key?("NO_COLOR"), quiet: options[:quiet],
|
|
384
|
+
verbose: options[:verbose], secret_filter: filter)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def flush_json(reporter, result, command, environment)
|
|
388
|
+
reporter.flush(result: result, command: command, environment: environment)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def render_apply_terminal(reporter, result, app, human_message)
|
|
392
|
+
return flush_json(reporter, result, "apply", app.config.environment) if json?
|
|
393
|
+
|
|
394
|
+
puts human_message
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def render_apply_success(reporter, result, app)
|
|
398
|
+
return flush_json(reporter, result, "apply", app.config.environment) if json?
|
|
399
|
+
|
|
400
|
+
puts "Next: run `kit doctor`; a subsequent `kit plan` should report zero changes."
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def subscribe_logger(bus, filter)
|
|
404
|
+
return unless options[:log]
|
|
405
|
+
|
|
406
|
+
logger = RunLogger.new(root: options[:root], run_id: "session-#{SecureRandom.uuid}",
|
|
407
|
+
secret_filter: filter)
|
|
408
|
+
@run_log_path = logger.path
|
|
409
|
+
bus.subscribe(logger)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def register_configured_secrets(filter, config)
|
|
413
|
+
filter.register(ENV[config.provider.token_env])
|
|
414
|
+
%w[postgres redis].each do |type|
|
|
415
|
+
filter.register(ENV[config.services.public_send(type).password_env])
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def confirm_apply!(plan)
|
|
420
|
+
if plan.destructive?
|
|
421
|
+
raise Errors::UnsafeOperationError.new(
|
|
422
|
+
"the plan contains destructive changes",
|
|
423
|
+
hint: "Resolve or explicitly perform the required destructive operation first."
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
return if options[:yes]
|
|
427
|
+
if options[:no_input] || !$stdin.tty?
|
|
428
|
+
raise Errors::UnsafeOperationError.new(
|
|
429
|
+
"apply requires confirmation",
|
|
430
|
+
hint: "Review `kit plan`, then pass --yes for a non-interactive apply."
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
return if yes?("Apply #{plan.changed_count} change(s)? [y/N]")
|
|
434
|
+
|
|
435
|
+
raise Errors::UnsafeOperationError, "apply was not confirmed"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def confirm_resume!(run_id)
|
|
439
|
+
return if options[:yes]
|
|
440
|
+
|
|
441
|
+
label = run_id || "the latest incomplete run"
|
|
442
|
+
if options[:no_input] || !$stdin.tty?
|
|
443
|
+
raise Errors::UnsafeOperationError.new(
|
|
444
|
+
"resume requires confirmation",
|
|
445
|
+
hint: "Inspect `kit status`, then pass --yes to resume #{label}."
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
return if yes?("Resume #{label}? [y/N]")
|
|
449
|
+
|
|
450
|
+
raise Errors::UnsafeOperationError, "resume was not confirmed"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def dispatch_server_action(app, action)
|
|
454
|
+
case action
|
|
455
|
+
when "show", "status" then inspect_environment(app)
|
|
456
|
+
when "create" then apply_operations(app, [app.operations.first])
|
|
457
|
+
when "configure" then apply_operations(app, app.operations.drop(1))
|
|
458
|
+
when "import" then import_server(app)
|
|
459
|
+
when "destroy" then destroy_server(app)
|
|
460
|
+
when "ssh" then open_ssh(app)
|
|
461
|
+
else raise Errors::ConfigurationError, "unknown server action: #{action}"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def dispatch_service_action(app, operation, type, action)
|
|
466
|
+
case action
|
|
467
|
+
when "status" then service_status(app, type)
|
|
468
|
+
when "install" then apply_operations(app, [operation])
|
|
469
|
+
when "backup" then with_mutation_lock(app) { service_backup(operation) }
|
|
470
|
+
when "remove"
|
|
471
|
+
confirm_named_action!("remove #{type}", "#{type}@#{app.config.environment}", option: :yes)
|
|
472
|
+
with_mutation_lock(app) { Result.success(operation.remove) }
|
|
473
|
+
when "destroy-data" then destroy_service_data(app, operation, type)
|
|
474
|
+
else raise Errors::ConfigurationError, "unknown service action: #{action}"
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def destroy_service_data(app, operation, type)
|
|
479
|
+
expected = "#{type}@#{app.config.environment}"
|
|
480
|
+
state = app.state_store.read(app.config.environment).dig("resources", "service.#{type}")
|
|
481
|
+
unless state
|
|
482
|
+
raise Errors::UnsafeOperationError.new(
|
|
483
|
+
"no managed #{type} data exists in #{app.config.environment}",
|
|
484
|
+
hint: "Run `kit service #{type} status`; restore state before attempting data destruction."
|
|
485
|
+
)
|
|
486
|
+
end
|
|
487
|
+
context = { environment: app.config.environment, resource: "service.#{type}",
|
|
488
|
+
volume: state.fetch("volume", nil), recoverable: false }
|
|
489
|
+
print_destructive_summary(
|
|
490
|
+
environment: context[:environment], provider: app.config.provider.name, resource: context[:resource],
|
|
491
|
+
identifier: context[:volume], recoverable: context[:recoverable]
|
|
492
|
+
)
|
|
493
|
+
backup_path = create_pre_destroy_backup?(type) ? operation.backup : nil
|
|
494
|
+
confirmation = options[:confirm_destroy]
|
|
495
|
+
confirmation ||= ask("Type #{expected} to permanently destroy its data:") unless options[:no_input]
|
|
496
|
+
confirm_exact!(confirmation, expected, "service data destruction", context: context)
|
|
497
|
+
destroyed = with_mutation_lock(app) { operation.destroy_data }
|
|
498
|
+
Result.success({ destroyed: destroyed, backup: backup_path })
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def create_pre_destroy_backup?(type)
|
|
502
|
+
return true if options[:backup_before_destroy]
|
|
503
|
+
return false if options[:no_input] || !$stdin.tty?
|
|
504
|
+
|
|
505
|
+
yes?("Create a #{type} data backup on the server before destruction? [y/N]")
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def service_backup(operation)
|
|
509
|
+
path = operation.backup
|
|
510
|
+
puts "Backup created: #{path}" unless json?
|
|
511
|
+
Result.success({ path: path })
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def dispatch_resource_action(app, operation, action)
|
|
515
|
+
case action
|
|
516
|
+
when "list" then dns_list(app)
|
|
517
|
+
when "plan" then build_plan(app, [operation])
|
|
518
|
+
when "apply" then apply_operations(app, [operation])
|
|
519
|
+
when "remove"
|
|
520
|
+
confirm_named_action!("remove DNS", app.config.environment, option: :yes)
|
|
521
|
+
with_mutation_lock(app) { Result.success(operation.rollback) }
|
|
522
|
+
else raise Errors::ConfigurationError, "unknown DNS action: #{action}"
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def dns_list(app)
|
|
527
|
+
values = app.config.dns.domains
|
|
528
|
+
values.each { |domain| puts domain } unless json?
|
|
529
|
+
Result.success(values)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def dispatch_docker_action(app, operation, action)
|
|
533
|
+
case action
|
|
534
|
+
when "status" then resource_status(app, "docker")
|
|
535
|
+
when "install" then apply_operations(app, [operation])
|
|
536
|
+
when "uninstall"
|
|
537
|
+
active_services = app.state_store.read(app.config.environment)["resources"].keys.grep(/\Aservice\./)
|
|
538
|
+
unless active_services.empty?
|
|
539
|
+
raise Errors::UnsafeOperationError.new(
|
|
540
|
+
"Docker cannot be removed while managed services exist",
|
|
541
|
+
hint: "Remove services first: #{active_services.join(', ')}"
|
|
542
|
+
)
|
|
543
|
+
end
|
|
544
|
+
confirm_named_action!("uninstall Docker", app.config.environment, option: :yes)
|
|
545
|
+
with_mutation_lock(app) { Result.success(operation.rollback) }
|
|
546
|
+
else raise Errors::ConfigurationError, "unknown Docker action: #{action}"
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def inspect_environment(app)
|
|
551
|
+
result = Workflows::InspectEnvironment.new(
|
|
552
|
+
config: app.config,
|
|
553
|
+
provider: app.provider,
|
|
554
|
+
state_store: app.state_store,
|
|
555
|
+
event_bus: app.event_bus
|
|
556
|
+
).call
|
|
557
|
+
print_environment_status(result.value) unless json?
|
|
558
|
+
result
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def destroy_server(app)
|
|
562
|
+
expected = app.config.server.name
|
|
563
|
+
managed = app.state_store.read(app.config.environment).dig("resources", "server")
|
|
564
|
+
unless managed&.fetch("id", nil)
|
|
565
|
+
raise Errors::UnsafeOperationError.new(
|
|
566
|
+
"no managed server ID exists in state",
|
|
567
|
+
hint: "Restore state or use the verified `kit server import` recovery flow first."
|
|
568
|
+
)
|
|
569
|
+
end
|
|
570
|
+
print_destructive_summary(
|
|
571
|
+
environment: app.config.environment, provider: app.config.provider.name,
|
|
572
|
+
resource: expected, identifier: managed&.fetch("id", nil), recoverable: false
|
|
573
|
+
)
|
|
574
|
+
confirmation = options[:confirm_destroy]
|
|
575
|
+
confirmation ||= ask("Type #{expected} to permanently destroy the server:") unless options[:no_input]
|
|
576
|
+
dns_operation = app.operations.find { |operation| operation.resource == "dns" }
|
|
577
|
+
Workflows::DestroyServer.new(
|
|
578
|
+
config: app.config,
|
|
579
|
+
provider: app.provider,
|
|
580
|
+
state_store: app.state_store,
|
|
581
|
+
dns_operation: dns_operation,
|
|
582
|
+
event_bus: app.event_bus
|
|
583
|
+
).call(confirmation: confirmation)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def import_server(app)
|
|
587
|
+
result = Workflows::ImportServer.new(
|
|
588
|
+
config: app.config,
|
|
589
|
+
provider: app.provider,
|
|
590
|
+
state_store: app.state_store,
|
|
591
|
+
event_bus: app.event_bus
|
|
592
|
+
).call(provider_id: options[:provider_id], confirmation: options[:confirm_import])
|
|
593
|
+
unless json?
|
|
594
|
+
if result.metadata[:unchanged]
|
|
595
|
+
puts "State already tracks provider ID #{result.value}."
|
|
596
|
+
else
|
|
597
|
+
puts "Imported server #{result.value.name} (provider ID #{result.value.id}) " \
|
|
598
|
+
"into #{app.config.environment}."
|
|
599
|
+
puts "Only the server identity was imported; run `kit doctor` before applying remote resources."
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
result
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def open_ssh(app)
|
|
606
|
+
current = app.provider.find_server(name: app.config.server.name, tags: app.config.server.tags)
|
|
607
|
+
raise Errors::ConnectionError, "server has no public IP" unless current&.public_ip
|
|
608
|
+
|
|
609
|
+
known_hosts = File.expand_path(".kitsune/known_hosts", options[:root])
|
|
610
|
+
unless app.transport_factory.deploy.reachable?
|
|
611
|
+
raise Errors::ConnectionError, "the deploy SSH connection could not be verified"
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
Kernel.exec(
|
|
615
|
+
"ssh", "-i", app.config.ssh.key_path, "-p", app.config.ssh.port.to_s,
|
|
616
|
+
"-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=#{known_hosts}",
|
|
617
|
+
"#{app.config.ssh.user}@#{current.public_ip}"
|
|
618
|
+
)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def confirm_host_key(host:, fingerprint:, key_type:)
|
|
622
|
+
expected = options[:trust_host_key]
|
|
623
|
+
if expected
|
|
624
|
+
return true if expected == fingerprint
|
|
625
|
+
|
|
626
|
+
raise Errors::UnsafeOperationError.new(
|
|
627
|
+
"SSH host-key fingerprint does not match --trust-host-key",
|
|
628
|
+
context: { host: host, expected: expected, actual: fingerprint }
|
|
629
|
+
)
|
|
630
|
+
end
|
|
631
|
+
if options[:no_input] || !$stdin.tty? || json?
|
|
632
|
+
raise Errors::UnsafeOperationError.new(
|
|
633
|
+
"SSH host key is not trusted",
|
|
634
|
+
hint: "Verify the provider-console fingerprint, then pass --trust-host-key #{fingerprint}.",
|
|
635
|
+
context: { host: host, fingerprint: fingerprint, key_type: key_type }
|
|
636
|
+
)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
puts "SSH host key for #{host} (#{key_type}): #{fingerprint}"
|
|
640
|
+
yes?("Trust and save this host key? [y/N]")
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def find_service_operation(app, type, action)
|
|
644
|
+
raise Errors::ConfigurationError, "unknown service type: #{type}" unless %w[postgres redis].include?(type)
|
|
645
|
+
|
|
646
|
+
service = app.config.services.public_send(type)
|
|
647
|
+
if service.enabled && service.mode == "external"
|
|
648
|
+
return nil if action == "status"
|
|
649
|
+
|
|
650
|
+
raise Errors::UnsafeOperationError.new(
|
|
651
|
+
"#{type} is configured as an external service",
|
|
652
|
+
hint: "Manage its lifecycle with its provider; Kitsune Kit only reports the configured endpoint."
|
|
653
|
+
)
|
|
654
|
+
end
|
|
655
|
+
enabled = service.enabled
|
|
656
|
+
return app.service_operation(type) if enabled || action != "install"
|
|
657
|
+
|
|
658
|
+
raise Errors::ConfigurationError.new(
|
|
659
|
+
"#{type} is not enabled in configuration",
|
|
660
|
+
hint: "Set services.#{type}.enabled to true and configure its secret."
|
|
661
|
+
)
|
|
662
|
+
end
|
|
45
663
|
|
|
46
|
-
|
|
47
|
-
|
|
664
|
+
def apply_operations(app, operations)
|
|
665
|
+
plan_result = build_plan(app, operations)
|
|
666
|
+
plan = plan_result.value
|
|
667
|
+
return Result.success([], metadata: { unchanged: true }) unless plan.changed?
|
|
668
|
+
return plan_result if options[:dry_run]
|
|
48
669
|
|
|
49
|
-
|
|
50
|
-
|
|
670
|
+
confirm_apply!(plan)
|
|
671
|
+
Workflows::ApplyPlan.new(
|
|
672
|
+
config: app.config,
|
|
673
|
+
operations: operations,
|
|
674
|
+
state_store: app.state_store,
|
|
675
|
+
event_bus: app.event_bus
|
|
676
|
+
).call(plan: plan)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def build_plan(app, operations)
|
|
680
|
+
result = Workflows::BuildPlan.new(
|
|
681
|
+
config: app.config,
|
|
682
|
+
operations: operations,
|
|
683
|
+
event_bus: app.event_bus
|
|
684
|
+
).call
|
|
685
|
+
print_plan(result.value) unless json?
|
|
686
|
+
result
|
|
687
|
+
end
|
|
51
688
|
|
|
52
|
-
|
|
53
|
-
|
|
689
|
+
def service_status(app, type)
|
|
690
|
+
service = app.config.services.public_send(type)
|
|
691
|
+
return resource_status(app, "service.#{type}") unless service.enabled && service.mode == "external"
|
|
692
|
+
|
|
693
|
+
value = { mode: "external", host: service.host, port: service.port, managed: false }
|
|
694
|
+
puts("service.#{type}: external (#{service.host}:#{service.port})") unless json?
|
|
695
|
+
Result.success(value)
|
|
696
|
+
end
|
|
54
697
|
|
|
55
|
-
|
|
56
|
-
|
|
698
|
+
def resource_status(app, resource)
|
|
699
|
+
value = app.state_store.read(app.config.environment).dig("resources", resource)
|
|
700
|
+
if json?
|
|
701
|
+
Result.success(value)
|
|
702
|
+
else
|
|
703
|
+
status = if !value
|
|
704
|
+
"not managed"
|
|
705
|
+
elsif value["installed"] == false
|
|
706
|
+
"removed (data preserved)"
|
|
707
|
+
else
|
|
708
|
+
"managed"
|
|
709
|
+
end
|
|
710
|
+
puts("#{resource}: #{status}")
|
|
711
|
+
end
|
|
712
|
+
Result.success(value)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def confirm_named_action!(action, expected, option:)
|
|
716
|
+
return if options[option]
|
|
717
|
+
if options[:no_input] || !$stdin.tty?
|
|
718
|
+
raise Errors::UnsafeOperationError.new(
|
|
719
|
+
"#{action} requires confirmation",
|
|
720
|
+
hint: "Pass --yes after reviewing the affected environment (#{expected})."
|
|
721
|
+
)
|
|
722
|
+
end
|
|
723
|
+
return if yes?("#{action.capitalize} in #{expected}? [y/N]")
|
|
724
|
+
|
|
725
|
+
raise Errors::UnsafeOperationError, "#{action} was not confirmed"
|
|
726
|
+
end
|
|
57
727
|
|
|
58
|
-
|
|
59
|
-
|
|
728
|
+
def confirm_exact!(actual, expected, action, context: {})
|
|
729
|
+
return if actual == expected
|
|
60
730
|
|
|
61
|
-
|
|
62
|
-
|
|
731
|
+
raise Errors::UnsafeOperationError.new(
|
|
732
|
+
"#{action} was not confirmed",
|
|
733
|
+
hint: "Pass --confirm-destroy #{expected} after creating a backup.",
|
|
734
|
+
context: context
|
|
735
|
+
)
|
|
736
|
+
end
|
|
63
737
|
|
|
64
|
-
|
|
65
|
-
|
|
738
|
+
def print_destructive_summary(environment:, provider:, resource:, identifier:, recoverable:)
|
|
739
|
+
return if json?
|
|
66
740
|
|
|
67
|
-
|
|
68
|
-
|
|
741
|
+
puts
|
|
742
|
+
puts "Permanent destruction"
|
|
743
|
+
puts " Environment: #{environment}"
|
|
744
|
+
puts " Provider: #{provider}"
|
|
745
|
+
puts " Resource: #{resource}"
|
|
746
|
+
puts " Identifier: #{identifier || 'not recorded'}"
|
|
747
|
+
puts " Recoverable: #{recoverable ? 'yes' : 'no; restore only from an independent backup'}"
|
|
748
|
+
puts
|
|
749
|
+
end
|
|
69
750
|
|
|
70
|
-
|
|
71
|
-
|
|
751
|
+
def with_mutation_lock(app, &)
|
|
752
|
+
app.state_store.with_execution_lock(app.config.environment, &)
|
|
753
|
+
end
|
|
72
754
|
|
|
73
|
-
|
|
74
|
-
|
|
755
|
+
def print_plan(plan)
|
|
756
|
+
puts
|
|
757
|
+
puts "Plan for environment: #{plan.environment}"
|
|
758
|
+
puts
|
|
759
|
+
if options[:quiet]
|
|
760
|
+
puts "#{plan.changed_count} change(s), #{plan.changes.length - plan.changed_count} unchanged"
|
|
761
|
+
return
|
|
762
|
+
end
|
|
75
763
|
|
|
76
|
-
|
|
77
|
-
|
|
764
|
+
plan.changes.each do |change|
|
|
765
|
+
marker = { "create" => "+", "update" => "~", "delete" => "-", "no_change" => "=" }.fetch(change.action)
|
|
766
|
+
puts " #{marker} #{change.summary}"
|
|
767
|
+
end
|
|
768
|
+
puts
|
|
769
|
+
puts "#{plan.changed_count} change(s), #{plan.changes.length - plan.changed_count} unchanged"
|
|
770
|
+
end
|
|
78
771
|
|
|
79
|
-
|
|
80
|
-
|
|
772
|
+
def print_checks(checks)
|
|
773
|
+
checks.each do |check|
|
|
774
|
+
puts format("%<name>-24s %<status>-5s %<message>s",
|
|
775
|
+
name: check.name, status: check.status.upcase, message: check.message)
|
|
776
|
+
puts "#{' ' * 31}#{check.hint}" if check.hint
|
|
777
|
+
end
|
|
778
|
+
end
|
|
81
779
|
|
|
82
|
-
|
|
83
|
-
|
|
780
|
+
def print_environment_status(status)
|
|
781
|
+
puts
|
|
782
|
+
puts "Environment: #{status.environment}"
|
|
783
|
+
if status.server
|
|
784
|
+
puts "Server: #{status.server.name} (#{status.server.status}, #{status.server.public_ip || 'no IP'})"
|
|
785
|
+
else
|
|
786
|
+
puts "Server: not created"
|
|
787
|
+
end
|
|
788
|
+
puts "Managed resources:"
|
|
789
|
+
if status.managed_resources.empty?
|
|
790
|
+
puts " none"
|
|
791
|
+
else
|
|
792
|
+
status.managed_resources.each_key { |resource| puts " - #{resource}" }
|
|
793
|
+
end
|
|
794
|
+
end
|
|
84
795
|
|
|
85
|
-
|
|
86
|
-
|
|
796
|
+
def render_error(error)
|
|
797
|
+
filter = @secret_filter || build_secret_filter
|
|
798
|
+
safe_message = filter.filter(error.message)
|
|
799
|
+
safe_hint = filter.filter(error.hint)
|
|
800
|
+
safe_context = filter.filter(error.context)
|
|
801
|
+
if json?
|
|
802
|
+
warn ::JSON.generate(
|
|
803
|
+
schema_version: 1,
|
|
804
|
+
status: "failure",
|
|
805
|
+
error: { code: error.code, message: safe_message, hint: safe_hint, context: safe_context }
|
|
806
|
+
)
|
|
807
|
+
else
|
|
808
|
+
warn "Error [#{error.code}]: #{safe_message}"
|
|
809
|
+
warn "Hint: #{safe_hint}" if safe_hint
|
|
810
|
+
warn "Context: #{safe_context.inspect}" if options[:debug] && !safe_context.empty?
|
|
811
|
+
end
|
|
812
|
+
end
|
|
87
813
|
|
|
88
|
-
|
|
89
|
-
|
|
814
|
+
def render_unexpected_error(error)
|
|
815
|
+
filter = @secret_filter || build_secret_filter
|
|
816
|
+
safe_message = filter.filter(error.message)
|
|
817
|
+
if json?
|
|
818
|
+
warn ::JSON.generate(
|
|
819
|
+
schema_version: 1,
|
|
820
|
+
status: "failure",
|
|
821
|
+
error: { code: "unexpected_error", message: safe_message }
|
|
822
|
+
)
|
|
823
|
+
else
|
|
824
|
+
warn "Unexpected error: #{safe_message}"
|
|
825
|
+
warn filter.filter(error.full_message) if options[:debug]
|
|
826
|
+
end
|
|
827
|
+
end
|
|
90
828
|
|
|
91
|
-
|
|
92
|
-
|
|
829
|
+
def json? = options[:format] == "json"
|
|
830
|
+
end
|
|
93
831
|
end
|
|
94
832
|
end
|
|
95
833
|
end
|