nvoi 0.1.5 → 0.1.6
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/.claude/todo/refactor/00-overview.md +171 -0
- data/.claude/todo/refactor/01-objects.md +96 -0
- data/.claude/todo/refactor/02-utils.md +143 -0
- data/.claude/todo/refactor/03-external-cloud.md +164 -0
- data/.claude/todo/refactor/04-external-dns.md +104 -0
- data/.claude/todo/refactor/05-external.md +133 -0
- data/.claude/todo/refactor/06-cli.md +123 -0
- data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
- data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
- data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
- data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
- data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +12 -2
- data/doc/config-schema.yaml +44 -11
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/main.go +18 -0
- data/exe/nvoi +3 -1
- data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
- data/lib/nvoi/cli/credentials/show/command.rb +35 -0
- data/lib/nvoi/cli/db/command.rb +308 -0
- data/lib/nvoi/cli/delete/command.rb +75 -0
- data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
- data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
- data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
- data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
- data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
- data/lib/nvoi/cli/deploy/command.rb +184 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
- data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
- data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli.rb +83 -142
- data/lib/nvoi/config_api/actions/app.rb +53 -0
- data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
- data/lib/nvoi/config_api/actions/database.rb +70 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/secret.rb +32 -0
- data/lib/nvoi/config_api/actions/server.rb +66 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +44 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +70 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +425 -0
- data/lib/nvoi/external/cloud/base.rb +99 -0
- data/lib/nvoi/external/cloud/factory.rb +48 -0
- data/lib/nvoi/external/cloud/hetzner.rb +376 -0
- data/lib/nvoi/external/cloud/scaleway.rb +533 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +82 -0
- data/lib/nvoi/external/database/mysql.rb +84 -0
- data/lib/nvoi/external/database/postgres.rb +82 -0
- data/lib/nvoi/external/database/provider.rb +65 -0
- data/lib/nvoi/external/database/sqlite.rb +72 -0
- data/lib/nvoi/external/database.rb +22 -0
- data/lib/nvoi/external/dns/cloudflare.rb +292 -0
- data/lib/nvoi/external/kubectl.rb +65 -0
- data/lib/nvoi/external/ssh.rb +106 -0
- data/lib/nvoi/objects/config_override.rb +60 -0
- data/lib/nvoi/objects/configuration.rb +463 -0
- data/lib/nvoi/objects/database.rb +56 -0
- data/lib/nvoi/objects/dns.rb +14 -0
- data/lib/nvoi/objects/firewall.rb +11 -0
- data/lib/nvoi/objects/network.rb +11 -0
- data/lib/nvoi/objects/server.rb +14 -0
- data/lib/nvoi/objects/service_spec.rb +26 -0
- data/lib/nvoi/objects/tunnel.rb +14 -0
- data/lib/nvoi/objects/volume.rb +17 -0
- data/lib/nvoi/utils/config_loader.rb +172 -0
- data/lib/nvoi/utils/constants.rb +61 -0
- data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
- data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
- data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
- data/lib/nvoi/utils/logger.rb +84 -0
- data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
- data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
- data/lib/nvoi/utils/templates.rb +62 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +10 -54
- data/templates/error-backend.yaml.erb +134 -0
- metadata +97 -44
- data/examples/golang/deploy.yml +0 -54
- data/lib/nvoi/cloudflare/client.rb +0 -287
- data/lib/nvoi/config/config.rb +0 -248
- data/lib/nvoi/config/loader.rb +0 -102
- data/lib/nvoi/config/ssh_keys.rb +0 -82
- data/lib/nvoi/config/types.rb +0 -274
- data/lib/nvoi/constants.rb +0 -59
- data/lib/nvoi/credentials/editor.rb +0 -272
- data/lib/nvoi/deployer/cleaner.rb +0 -36
- data/lib/nvoi/deployer/image_builder.rb +0 -23
- data/lib/nvoi/deployer/infrastructure.rb +0 -126
- data/lib/nvoi/deployer/orchestrator.rb +0 -146
- data/lib/nvoi/deployer/service_deployer.rb +0 -311
- data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
- data/lib/nvoi/deployer/types.rb +0 -8
- data/lib/nvoi/k8s/renderer.rb +0 -44
- data/lib/nvoi/k8s/templates.rb +0 -29
- data/lib/nvoi/logger.rb +0 -72
- data/lib/nvoi/providers/aws.rb +0 -403
- data/lib/nvoi/providers/base.rb +0 -111
- data/lib/nvoi/providers/hetzner.rb +0 -288
- data/lib/nvoi/providers/hetzner_client.rb +0 -170
- data/lib/nvoi/remote/docker_manager.rb +0 -203
- data/lib/nvoi/remote/ssh_executor.rb +0 -72
- data/lib/nvoi/remote/volume_manager.rb +0 -103
- data/lib/nvoi/service/delete.rb +0 -234
- data/lib/nvoi/service/deploy.rb +0 -80
- data/lib/nvoi/service/exec.rb +0 -144
- data/lib/nvoi/service/provider.rb +0 -36
- data/lib/nvoi/steps/application_deployer.rb +0 -26
- data/lib/nvoi/steps/database_provisioner.rb +0 -60
- data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
- data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
- data/lib/nvoi/steps/server_provisioner.rb +0 -43
- data/lib/nvoi/steps/services_provisioner.rb +0 -29
- data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
- data/lib/nvoi/steps/volume_provisioner.rb +0 -154
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Db
|
|
6
|
+
# Command handles database branch operations (backup/restore)
|
|
7
|
+
class Command
|
|
8
|
+
BRANCHES_DIR = "/mnt/db-branches"
|
|
9
|
+
|
|
10
|
+
def initialize(options)
|
|
11
|
+
@options = options
|
|
12
|
+
@log = Nvoi.logger
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def branch_create(name = nil)
|
|
16
|
+
init_db_context
|
|
17
|
+
|
|
18
|
+
name ||= generate_branch_id
|
|
19
|
+
|
|
20
|
+
@log.info "Creating database branch: %s", name
|
|
21
|
+
@log.separator
|
|
22
|
+
|
|
23
|
+
with_ssh do |ssh|
|
|
24
|
+
opts = build_dump_options
|
|
25
|
+
|
|
26
|
+
@log.step "Dumping database"
|
|
27
|
+
dump_data = @db_provider.dump(ssh, opts)
|
|
28
|
+
@log.ok "Dump complete (%d bytes)", dump_data.bytesize
|
|
29
|
+
|
|
30
|
+
@log.step "Saving branch"
|
|
31
|
+
app_name = @config.deploy.application.name
|
|
32
|
+
branches_path = "#{BRANCHES_DIR}/#{app_name}"
|
|
33
|
+
|
|
34
|
+
ssh.execute("mkdir -p #{branches_path}")
|
|
35
|
+
|
|
36
|
+
dump_file = "#{branches_path}/#{name}.#{@db_provider.extension}"
|
|
37
|
+
write_file_via_ssh(ssh, dump_file, dump_data)
|
|
38
|
+
|
|
39
|
+
update_branch_metadata(ssh, branches_path, name, dump_data.bytesize)
|
|
40
|
+
|
|
41
|
+
@log.ok "Branch saved: %s", dump_file
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@log.separator
|
|
45
|
+
@log.success "Branch created: %s", name
|
|
46
|
+
|
|
47
|
+
name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def branch_list
|
|
51
|
+
init_db_context
|
|
52
|
+
|
|
53
|
+
branches = []
|
|
54
|
+
|
|
55
|
+
with_ssh do |ssh|
|
|
56
|
+
app_name = @config.deploy.application.name
|
|
57
|
+
branches_path = "#{BRANCHES_DIR}/#{app_name}"
|
|
58
|
+
metadata_file = "#{branches_path}/branches.json"
|
|
59
|
+
|
|
60
|
+
result = ssh.execute("test -f #{metadata_file} && cat #{metadata_file} || echo '{}'")
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
metadata = Objects::Database::BranchMetadata.from_json(result)
|
|
64
|
+
branches = metadata.branches
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
branches = []
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if branches.empty?
|
|
71
|
+
@log.info "No branches found"
|
|
72
|
+
else
|
|
73
|
+
@log.info "Database branches:"
|
|
74
|
+
@log.info ""
|
|
75
|
+
@log.info "%-20s %-20s %-12s %-10s", "ID", "Created", "Size", "Adapter"
|
|
76
|
+
@log.info "-" * 70
|
|
77
|
+
|
|
78
|
+
branches.each do |b|
|
|
79
|
+
size_str = format_size(b.size)
|
|
80
|
+
created = b.created_at[0, 19].gsub("T", " ") rescue b.created_at
|
|
81
|
+
@log.info "%-20s %-20s %-12s %-10s", b.id, created, size_str, b.adapter
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
branches
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def branch_restore(branch_id, new_db_name = nil)
|
|
89
|
+
init_db_context
|
|
90
|
+
|
|
91
|
+
new_db_name ||= "#{@creds.database}_#{branch_id.gsub('-', '_')}"
|
|
92
|
+
|
|
93
|
+
@log.info "Restoring branch %s to database %s", branch_id, new_db_name
|
|
94
|
+
@log.separator
|
|
95
|
+
|
|
96
|
+
with_ssh do |ssh|
|
|
97
|
+
app_name = @config.deploy.application.name
|
|
98
|
+
branches_path = "#{BRANCHES_DIR}/#{app_name}"
|
|
99
|
+
dump_file = "#{branches_path}/#{branch_id}.#{@db_provider.extension}"
|
|
100
|
+
|
|
101
|
+
unless ssh.execute("test -f #{dump_file} && echo yes || echo no").strip == "yes"
|
|
102
|
+
raise Errors::ServiceError, "Branch not found: #{branch_id}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@log.step "Reading branch data"
|
|
106
|
+
dump_data = ssh.execute("cat #{dump_file}")
|
|
107
|
+
|
|
108
|
+
opts = build_restore_options(new_db_name)
|
|
109
|
+
|
|
110
|
+
@log.step "Restoring to new database"
|
|
111
|
+
result = @db_provider.restore(ssh, dump_data, opts)
|
|
112
|
+
@log.ok "Restore complete"
|
|
113
|
+
|
|
114
|
+
if @db_provider.is_a?(External::Database::Sqlite)
|
|
115
|
+
@log.info "New database path: %s", result
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@log.separator
|
|
120
|
+
@log.success "Branch restored: %s -> %s", branch_id, new_db_name
|
|
121
|
+
|
|
122
|
+
output_credentials_helper(new_db_name)
|
|
123
|
+
|
|
124
|
+
new_db_name
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def branch_download(branch_id)
|
|
128
|
+
init_db_context
|
|
129
|
+
|
|
130
|
+
output_path = @options[:path] || "#{branch_id}.#{@db_provider.extension}"
|
|
131
|
+
|
|
132
|
+
@log.info "Downloading branch: %s", branch_id
|
|
133
|
+
|
|
134
|
+
dump_data = nil
|
|
135
|
+
|
|
136
|
+
with_ssh do |ssh|
|
|
137
|
+
app_name = @config.deploy.application.name
|
|
138
|
+
branches_path = "#{BRANCHES_DIR}/#{app_name}"
|
|
139
|
+
dump_file = "#{branches_path}/#{branch_id}.#{@db_provider.extension}"
|
|
140
|
+
|
|
141
|
+
unless ssh.execute("test -f #{dump_file} && echo yes || echo no").strip == "yes"
|
|
142
|
+
raise Errors::ServiceError, "Branch not found: #{branch_id}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
dump_data = ssh.execute("cat #{dump_file}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
File.write(output_path, dump_data)
|
|
149
|
+
|
|
150
|
+
@log.success "Downloaded to: %s (%d bytes)", output_path, dump_data.bytesize
|
|
151
|
+
|
|
152
|
+
output_path
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def init_db_context
|
|
158
|
+
config_path = resolve_config_path
|
|
159
|
+
@config = Utils::ConfigLoader.load(config_path)
|
|
160
|
+
|
|
161
|
+
apply_branch_override if @options[:branch]
|
|
162
|
+
|
|
163
|
+
@provider = External::Cloud.for(@config)
|
|
164
|
+
|
|
165
|
+
@db_config = @config.deploy.application.database
|
|
166
|
+
raise Errors::ServiceError, "No database configured" unless @db_config
|
|
167
|
+
|
|
168
|
+
adapter = @db_config.adapter&.downcase
|
|
169
|
+
raise Errors::ServiceError, "No database adapter configured" unless adapter
|
|
170
|
+
|
|
171
|
+
@db_provider = External::Database.provider_for(adapter)
|
|
172
|
+
@creds = Utils::ConfigLoader.get_database_credentials(@db_config, @config.namer)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve_config_path
|
|
176
|
+
config_path = @options[:config] || "deploy.enc"
|
|
177
|
+
working_dir = @options[:dir]
|
|
178
|
+
|
|
179
|
+
if config_path == "deploy.enc" && working_dir && working_dir != "."
|
|
180
|
+
File.join(working_dir, "deploy.enc")
|
|
181
|
+
else
|
|
182
|
+
config_path
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def apply_branch_override
|
|
187
|
+
branch = @options[:branch]
|
|
188
|
+
return if branch.nil? || branch.empty?
|
|
189
|
+
|
|
190
|
+
override = Objects::ConfigOverride.new(branch:)
|
|
191
|
+
override.apply(@config)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def with_ssh
|
|
195
|
+
server_name = @db_config.servers.first
|
|
196
|
+
resolved_name = @config.namer.server_name(server_name, 1)
|
|
197
|
+
server = @provider.find_server(resolved_name)
|
|
198
|
+
raise Errors::ServiceError, "Could not find server: #{server_name}" unless server
|
|
199
|
+
|
|
200
|
+
ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
|
|
201
|
+
|
|
202
|
+
yield ssh
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_dump_options
|
|
206
|
+
if @db_provider.is_a?(External::Database::Sqlite)
|
|
207
|
+
Objects::Database::DumpOptions.new(
|
|
208
|
+
host_path: @creds.host_path,
|
|
209
|
+
database: @creds.database
|
|
210
|
+
)
|
|
211
|
+
else
|
|
212
|
+
Objects::Database::DumpOptions.new(
|
|
213
|
+
pod_name: @config.namer.database_pod_name,
|
|
214
|
+
database: @creds.database,
|
|
215
|
+
user: @creds.user,
|
|
216
|
+
password: @creds.password
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def build_restore_options(new_db_name)
|
|
222
|
+
sanitized_name = sanitize_db_name(new_db_name)
|
|
223
|
+
|
|
224
|
+
if @db_provider.is_a?(External::Database::Sqlite)
|
|
225
|
+
Objects::Database::RestoreOptions.new(
|
|
226
|
+
host_path: @creds.host_path,
|
|
227
|
+
database: sanitized_name
|
|
228
|
+
)
|
|
229
|
+
else
|
|
230
|
+
Objects::Database::RestoreOptions.new(
|
|
231
|
+
pod_name: @config.namer.database_pod_name,
|
|
232
|
+
database: sanitized_name,
|
|
233
|
+
user: @creds.user,
|
|
234
|
+
password: @creds.password
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def sanitize_db_name(name)
|
|
240
|
+
name.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def generate_branch_id
|
|
244
|
+
Time.now.strftime("%Y%m%d-%H%M%S")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def write_file_via_ssh(ssh, path, content)
|
|
248
|
+
cmd = "cat > #{path} << 'NVOI_DUMP_EOF'\n#{content}\nNVOI_DUMP_EOF"
|
|
249
|
+
ssh.execute(cmd)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def update_branch_metadata(ssh, branches_path, branch_id, size)
|
|
253
|
+
metadata_file = "#{branches_path}/branches.json"
|
|
254
|
+
|
|
255
|
+
result = ssh.execute("test -f #{metadata_file} && cat #{metadata_file} || echo '{}'")
|
|
256
|
+
|
|
257
|
+
metadata = begin
|
|
258
|
+
Objects::Database::BranchMetadata.from_json(result)
|
|
259
|
+
rescue JSON::ParserError
|
|
260
|
+
Objects::Database::BranchMetadata.new
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
metadata.branches << Objects::Database::Branch.new(
|
|
264
|
+
id: branch_id,
|
|
265
|
+
created_at: Time.now.iso8601,
|
|
266
|
+
size:,
|
|
267
|
+
adapter: @db_config.adapter,
|
|
268
|
+
database: @creds.database
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
ssh.execute("cat > #{metadata_file} << 'NVOI_META_EOF'\n#{metadata.to_json}\nNVOI_META_EOF")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def format_size(bytes)
|
|
275
|
+
return "0 B" unless bytes
|
|
276
|
+
|
|
277
|
+
units = %w[B KB MB GB]
|
|
278
|
+
unit_index = 0
|
|
279
|
+
size = bytes.to_f
|
|
280
|
+
|
|
281
|
+
while size >= 1024 && unit_index < units.length - 1
|
|
282
|
+
size /= 1024
|
|
283
|
+
unit_index += 1
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
format("%.1f %s", size, units[unit_index])
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def output_credentials_helper(new_db_name)
|
|
290
|
+
adapter = @db_config.adapter&.downcase
|
|
291
|
+
|
|
292
|
+
@log.info ""
|
|
293
|
+
@log.info "To persist this change, run:"
|
|
294
|
+
|
|
295
|
+
case adapter
|
|
296
|
+
when "postgres", "postgresql"
|
|
297
|
+
@log.info " nvoi credentials set database.secrets.POSTGRES_DB %s", new_db_name
|
|
298
|
+
when "mysql"
|
|
299
|
+
@log.info " nvoi credentials set database.secrets.MYSQL_DATABASE %s", new_db_name
|
|
300
|
+
when "sqlite", "sqlite3"
|
|
301
|
+
new_url = "sqlite://#{File.dirname(@creds.path || 'data')}/#{new_db_name}.sqlite3"
|
|
302
|
+
@log.info " nvoi credentials set database.url %s", new_url
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
# Command handles cleanup of all cloud resources
|
|
7
|
+
class Command
|
|
8
|
+
def initialize(options)
|
|
9
|
+
@options = options
|
|
10
|
+
@log = Nvoi.logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
@log.info "Delete CLI %s", VERSION
|
|
15
|
+
|
|
16
|
+
# Load configuration
|
|
17
|
+
config_path = resolve_config_path
|
|
18
|
+
@config = Utils::ConfigLoader.load(config_path)
|
|
19
|
+
|
|
20
|
+
# Apply branch override if specified
|
|
21
|
+
apply_branch_override if @options[:branch]
|
|
22
|
+
|
|
23
|
+
# Initialize cloud provider
|
|
24
|
+
@provider = External::Cloud.for(@config)
|
|
25
|
+
|
|
26
|
+
# Initialize Cloudflare client
|
|
27
|
+
cf = @config.cloudflare
|
|
28
|
+
@cf_client = External::Dns::Cloudflare.new(cf.api_token, cf.account_id)
|
|
29
|
+
|
|
30
|
+
@log.info "Using %s Cloud provider", @config.provider_name
|
|
31
|
+
|
|
32
|
+
# Run teardown steps in order
|
|
33
|
+
require_relative "steps/detach_volumes"
|
|
34
|
+
require_relative "steps/teardown_server"
|
|
35
|
+
require_relative "steps/teardown_volume"
|
|
36
|
+
require_relative "steps/teardown_firewall"
|
|
37
|
+
require_relative "steps/teardown_network"
|
|
38
|
+
require_relative "steps/teardown_tunnel"
|
|
39
|
+
require_relative "steps/teardown_dns"
|
|
40
|
+
|
|
41
|
+
Steps::DetachVolumes.new(@config, @provider, @log).run
|
|
42
|
+
Steps::TeardownServer.new(@config, @provider, @log).run
|
|
43
|
+
Steps::TeardownVolume.new(@config, @provider, @log).run
|
|
44
|
+
Steps::TeardownFirewall.new(@config, @provider, @log).run
|
|
45
|
+
Steps::TeardownNetwork.new(@config, @provider, @log).run
|
|
46
|
+
Steps::TeardownTunnel.new(@config, @cf_client, @log).run
|
|
47
|
+
Steps::TeardownDns.new(@config, @cf_client, @log).run
|
|
48
|
+
|
|
49
|
+
@log.success "Cleanup complete"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_config_path
|
|
55
|
+
config_path = @options[:config] || "deploy.enc"
|
|
56
|
+
working_dir = @options[:dir]
|
|
57
|
+
|
|
58
|
+
if config_path == "deploy.enc" && working_dir && working_dir != "."
|
|
59
|
+
File.join(working_dir, "deploy.enc")
|
|
60
|
+
else
|
|
61
|
+
config_path
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def apply_branch_override
|
|
66
|
+
branch = @options[:branch]
|
|
67
|
+
return if branch.nil? || branch.empty?
|
|
68
|
+
|
|
69
|
+
override = Objects::ConfigOverride.new(branch:)
|
|
70
|
+
override.apply(@config)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
module Steps
|
|
7
|
+
# DetachVolumes handles volume detachment before server deletion
|
|
8
|
+
class DetachVolumes
|
|
9
|
+
def initialize(config, provider, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@provider = provider
|
|
12
|
+
@log = log
|
|
13
|
+
@namer = config.namer
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
volume_configs = collect_volume_configs
|
|
18
|
+
return if volume_configs.empty?
|
|
19
|
+
|
|
20
|
+
@log.info "Detaching %d volume(s)", volume_configs.size
|
|
21
|
+
|
|
22
|
+
volume_configs.each do |vol_config|
|
|
23
|
+
detach_volume(vol_config)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def collect_volume_configs
|
|
30
|
+
configs = []
|
|
31
|
+
|
|
32
|
+
@config.deploy.application.servers.each do |server_name, server_config|
|
|
33
|
+
next unless server_config.volumes && !server_config.volumes.empty?
|
|
34
|
+
|
|
35
|
+
resolved_server = @namer.server_name(server_name, 1)
|
|
36
|
+
|
|
37
|
+
server_config.volumes.each_key do |vol_name|
|
|
38
|
+
configs << {
|
|
39
|
+
name: @namer.server_volume_name(server_name, vol_name),
|
|
40
|
+
server_name: resolved_server,
|
|
41
|
+
mount_path: @namer.server_volume_host_path(server_name, vol_name)
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
configs
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detach_volume(vol_config)
|
|
50
|
+
volume = @provider.get_volume_by_name(vol_config[:name])
|
|
51
|
+
return unless volume&.server_id && !volume.server_id.empty?
|
|
52
|
+
|
|
53
|
+
@log.info "Detaching volume: %s", vol_config[:name]
|
|
54
|
+
|
|
55
|
+
# Unmount from server before detaching via provider API
|
|
56
|
+
unmount_volume(volume, vol_config[:mount_path])
|
|
57
|
+
|
|
58
|
+
@provider.detach_volume(volume.id)
|
|
59
|
+
@log.success "Volume detached: %s", vol_config[:name]
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
@log.warning "Failed to detach volume %s: %s", vol_config[:name], e.message
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unmount_volume(volume, mount_path)
|
|
65
|
+
server = @provider.find_server_by_id(volume.server_id)
|
|
66
|
+
return unless server&.public_ipv4
|
|
67
|
+
|
|
68
|
+
ssh = External::Ssh.new(server.public_ipv4, @config.ssh_key_path)
|
|
69
|
+
|
|
70
|
+
# Check if mounted
|
|
71
|
+
return unless mounted?(ssh, mount_path)
|
|
72
|
+
|
|
73
|
+
@log.info "Unmounting volume from %s", mount_path
|
|
74
|
+
|
|
75
|
+
# Unmount the volume
|
|
76
|
+
ssh.execute("sudo umount #{mount_path}")
|
|
77
|
+
|
|
78
|
+
# Remove from fstab to prevent boot issues
|
|
79
|
+
remove_from_fstab(ssh, mount_path)
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
@log.warning "Failed to unmount %s: %s", mount_path, e.message
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def mounted?(ssh, mount_path)
|
|
85
|
+
output = ssh.execute("mountpoint -q #{mount_path} && echo 'mounted' || echo 'not_mounted'")
|
|
86
|
+
output.strip == "mounted"
|
|
87
|
+
rescue StandardError
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def remove_from_fstab(ssh, mount_path)
|
|
92
|
+
ssh.execute("sudo sed -i '\\|#{mount_path}|d' /etc/fstab")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
module Steps
|
|
7
|
+
# TeardownDns handles DNS record deletion
|
|
8
|
+
class TeardownDns
|
|
9
|
+
def initialize(config, cf_client, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@cf_client = cf_client
|
|
12
|
+
@log = log
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
@config.deploy.application.app.each do |_service_name, service|
|
|
17
|
+
next unless service&.domain && !service.domain.empty?
|
|
18
|
+
next if service.subdomain.nil?
|
|
19
|
+
|
|
20
|
+
delete_dns_record(service.domain, service.subdomain)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def delete_dns_record(domain, subdomain)
|
|
27
|
+
hostname = Utils::Namer.build_hostname(subdomain, domain)
|
|
28
|
+
|
|
29
|
+
@log.info "Deleting DNS record: %s", hostname
|
|
30
|
+
|
|
31
|
+
zone = @cf_client.find_zone(domain)
|
|
32
|
+
unless zone
|
|
33
|
+
@log.warning "Zone not found: %s", domain
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
record = @cf_client.find_dns_record(zone.id, hostname, "CNAME")
|
|
38
|
+
if record
|
|
39
|
+
@cf_client.delete_dns_record(zone.id, record.id)
|
|
40
|
+
@log.success "DNS record deleted: %s", hostname
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@log.warning "Failed to delete DNS record: %s", e.message
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
module Steps
|
|
7
|
+
# TeardownFirewall handles firewall deletion
|
|
8
|
+
class TeardownFirewall
|
|
9
|
+
def initialize(config, provider, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@provider = provider
|
|
12
|
+
@log = log
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
@log.info "Deleting firewall: %s", @config.firewall_name
|
|
17
|
+
|
|
18
|
+
firewall = @provider.get_firewall_by_name(@config.firewall_name)
|
|
19
|
+
delete_firewall_with_retry(firewall.id) if firewall
|
|
20
|
+
rescue Errors::FirewallError => e
|
|
21
|
+
@log.warning "Firewall not found: %s", e.message
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def delete_firewall_with_retry(firewall_id, max_retries: 5)
|
|
27
|
+
max_retries.times do |i|
|
|
28
|
+
begin
|
|
29
|
+
@provider.delete_firewall(firewall_id)
|
|
30
|
+
@log.success "Firewall deleted"
|
|
31
|
+
return
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
if i == max_retries - 1
|
|
34
|
+
raise Errors::ServiceError, "failed to delete firewall after #{max_retries} attempts: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@log.info "Firewall still in use, waiting 3s before retry (%d/%d)", i + 1, max_retries
|
|
38
|
+
sleep(3)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
module Steps
|
|
7
|
+
# TeardownNetwork handles network deletion
|
|
8
|
+
class TeardownNetwork
|
|
9
|
+
def initialize(config, provider, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@provider = provider
|
|
12
|
+
@log = log
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
@log.info "Deleting network: %s", @config.network_name
|
|
17
|
+
|
|
18
|
+
network = @provider.get_network_by_name(@config.network_name)
|
|
19
|
+
if network
|
|
20
|
+
@provider.delete_network(network.id)
|
|
21
|
+
@log.success "Network deleted"
|
|
22
|
+
end
|
|
23
|
+
rescue Errors::NetworkError => e
|
|
24
|
+
@log.warning "Network not found: %s", e.message
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
module Steps
|
|
7
|
+
# TeardownServer handles server deletion
|
|
8
|
+
class TeardownServer
|
|
9
|
+
def initialize(config, provider, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@provider = provider
|
|
12
|
+
@log = log
|
|
13
|
+
@namer = config.namer
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
servers = @config.deploy.application.servers
|
|
18
|
+
return if servers.empty?
|
|
19
|
+
|
|
20
|
+
servers.each do |group_name, group_config|
|
|
21
|
+
next unless group_config
|
|
22
|
+
|
|
23
|
+
count = group_config.count.positive? ? group_config.count : 1
|
|
24
|
+
@log.info "Deleting %d server(s) from group '%s'", count, group_name
|
|
25
|
+
|
|
26
|
+
(1..count).each do |i|
|
|
27
|
+
server_name = @namer.server_name(group_name, i)
|
|
28
|
+
delete_server(server_name)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def delete_server(server_name)
|
|
36
|
+
@log.info "Deleting server: %s", server_name
|
|
37
|
+
|
|
38
|
+
server = @provider.find_server(server_name)
|
|
39
|
+
if server
|
|
40
|
+
@provider.delete_server(server.id)
|
|
41
|
+
@log.success "Server deleted: %s", server_name
|
|
42
|
+
end
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
@log.warning "Failed to delete server %s: %s", server_name, e.message
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Delete
|
|
6
|
+
module Steps
|
|
7
|
+
# TeardownTunnel handles Cloudflare tunnel deletion
|
|
8
|
+
class TeardownTunnel
|
|
9
|
+
def initialize(config, cf_client, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@cf_client = cf_client
|
|
12
|
+
@log = log
|
|
13
|
+
@namer = config.namer
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
@config.deploy.application.app.each do |service_name, service|
|
|
18
|
+
next unless service&.domain && !service.domain.empty?
|
|
19
|
+
next if service.subdomain.nil?
|
|
20
|
+
|
|
21
|
+
delete_tunnel(service_name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def delete_tunnel(service_name)
|
|
28
|
+
tunnel_name = @namer.tunnel_name(service_name)
|
|
29
|
+
|
|
30
|
+
@log.info "Deleting Cloudflare tunnel: %s", tunnel_name
|
|
31
|
+
|
|
32
|
+
tunnel = @cf_client.find_tunnel(tunnel_name)
|
|
33
|
+
if tunnel
|
|
34
|
+
@cf_client.delete_tunnel(tunnel.id)
|
|
35
|
+
@log.success "Tunnel deleted: %s", tunnel_name
|
|
36
|
+
end
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
@log.warning "Failed to delete tunnel: %s", e.message
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|