nvoi 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/12-cli-db-command.md +128 -0
  15. data/.claude/todo/refactor/_target.md +79 -0
  16. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  17. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  18. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  19. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  20. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  21. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  22. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  23. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  24. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  25. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  26. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  27. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  28. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  29. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  30. data/.claude/todo/scaleway.impl.md +644 -0
  31. data/.claude/todo/scaleway.reference.md +520 -0
  32. data/.claude/todos.md +550 -0
  33. data/Gemfile +6 -0
  34. data/Gemfile.lock +46 -5
  35. data/Rakefile +1 -1
  36. data/doc/config-schema.yaml +44 -11
  37. data/examples/golang/deploy.enc +0 -0
  38. data/examples/golang/main.go +18 -0
  39. data/exe/nvoi +3 -1
  40. data/ingest +0 -0
  41. data/lib/nvoi/cli/config/command.rb +219 -0
  42. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  43. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  44. data/lib/nvoi/cli/db/command.rb +308 -0
  45. data/lib/nvoi/cli/delete/command.rb +75 -0
  46. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  47. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
  48. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  49. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  50. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  51. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  52. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  53. data/lib/nvoi/cli/deploy/command.rb +184 -0
  54. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  55. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  56. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
  57. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
  58. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  59. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  60. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  61. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
  62. data/lib/nvoi/cli/exec/command.rb +173 -0
  63. data/lib/nvoi/cli/logs/command.rb +66 -0
  64. data/lib/nvoi/cli/onboard/command.rb +761 -0
  65. data/lib/nvoi/cli/unlock/command.rb +72 -0
  66. data/lib/nvoi/cli.rb +339 -141
  67. data/lib/nvoi/config_api/actions/app.rb +53 -0
  68. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  69. data/lib/nvoi/config_api/actions/database.rb +70 -0
  70. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  71. data/lib/nvoi/config_api/actions/env.rb +32 -0
  72. data/lib/nvoi/config_api/actions/init.rb +67 -0
  73. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  74. data/lib/nvoi/config_api/actions/server.rb +66 -0
  75. data/lib/nvoi/config_api/actions/service.rb +52 -0
  76. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  77. data/lib/nvoi/config_api/base.rb +38 -0
  78. data/lib/nvoi/config_api/result.rb +26 -0
  79. data/lib/nvoi/config_api.rb +93 -0
  80. data/lib/nvoi/errors.rb +68 -50
  81. data/lib/nvoi/external/cloud/aws.rb +450 -0
  82. data/lib/nvoi/external/cloud/base.rb +99 -0
  83. data/lib/nvoi/external/cloud/factory.rb +48 -0
  84. data/lib/nvoi/external/cloud/hetzner.rb +402 -0
  85. data/lib/nvoi/external/cloud/scaleway.rb +559 -0
  86. data/lib/nvoi/external/cloud.rb +15 -0
  87. data/lib/nvoi/external/containerd.rb +86 -0
  88. data/lib/nvoi/external/database/mysql.rb +84 -0
  89. data/lib/nvoi/external/database/postgres.rb +82 -0
  90. data/lib/nvoi/external/database/provider.rb +65 -0
  91. data/lib/nvoi/external/database/sqlite.rb +72 -0
  92. data/lib/nvoi/external/database.rb +22 -0
  93. data/lib/nvoi/external/dns/cloudflare.rb +310 -0
  94. data/lib/nvoi/external/kubectl.rb +65 -0
  95. data/lib/nvoi/external/ssh.rb +106 -0
  96. data/lib/nvoi/objects/config_override.rb +60 -0
  97. data/lib/nvoi/objects/configuration.rb +483 -0
  98. data/lib/nvoi/objects/database.rb +56 -0
  99. data/lib/nvoi/objects/dns.rb +14 -0
  100. data/lib/nvoi/objects/firewall.rb +11 -0
  101. data/lib/nvoi/objects/network.rb +11 -0
  102. data/lib/nvoi/objects/server.rb +14 -0
  103. data/lib/nvoi/objects/service_spec.rb +26 -0
  104. data/lib/nvoi/objects/tunnel.rb +14 -0
  105. data/lib/nvoi/objects/volume.rb +17 -0
  106. data/lib/nvoi/utils/config_loader.rb +172 -0
  107. data/lib/nvoi/utils/constants.rb +61 -0
  108. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  109. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  110. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  111. data/lib/nvoi/utils/logger.rb +84 -0
  112. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
  113. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  114. data/lib/nvoi/utils/templates.rb +62 -0
  115. data/lib/nvoi/version.rb +1 -1
  116. data/lib/nvoi.rb +27 -55
  117. data/templates/app-ingress.yaml.erb +3 -1
  118. data/templates/error-backend.yaml.erb +134 -0
  119. metadata +121 -44
  120. data/examples/golang/deploy.yml +0 -54
  121. data/lib/nvoi/cloudflare/client.rb +0 -287
  122. data/lib/nvoi/config/config.rb +0 -248
  123. data/lib/nvoi/config/loader.rb +0 -102
  124. data/lib/nvoi/config/ssh_keys.rb +0 -82
  125. data/lib/nvoi/config/types.rb +0 -274
  126. data/lib/nvoi/constants.rb +0 -59
  127. data/lib/nvoi/credentials/editor.rb +0 -272
  128. data/lib/nvoi/deployer/cleaner.rb +0 -36
  129. data/lib/nvoi/deployer/image_builder.rb +0 -23
  130. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  131. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  132. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  133. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  134. data/lib/nvoi/deployer/types.rb +0 -8
  135. data/lib/nvoi/k8s/renderer.rb +0 -44
  136. data/lib/nvoi/k8s/templates.rb +0 -29
  137. data/lib/nvoi/logger.rb +0 -72
  138. data/lib/nvoi/providers/aws.rb +0 -403
  139. data/lib/nvoi/providers/base.rb +0 -111
  140. data/lib/nvoi/providers/hetzner.rb +0 -288
  141. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  142. data/lib/nvoi/remote/docker_manager.rb +0 -203
  143. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  144. data/lib/nvoi/remote/volume_manager.rb +0 -103
  145. data/lib/nvoi/service/delete.rb +0 -234
  146. data/lib/nvoi/service/deploy.rb +0 -80
  147. data/lib/nvoi/service/exec.rb +0 -144
  148. data/lib/nvoi/service/provider.rb +0 -36
  149. data/lib/nvoi/steps/application_deployer.rb +0 -26
  150. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  151. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  152. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  153. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  154. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  155. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  156. 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,50 @@
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
+
19
+ delete_dns_records(service.domain, service.subdomain)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def delete_dns_records(domain, subdomain)
26
+ hostnames = Utils::Namer.build_hostnames(subdomain, domain)
27
+
28
+ zone = @cf_client.find_zone(domain)
29
+ unless zone
30
+ @log.warning "Zone not found: %s", domain
31
+ return
32
+ end
33
+
34
+ hostnames.each do |hostname|
35
+ @log.info "Deleting DNS record: %s", hostname
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
+ end
43
+ rescue StandardError => e
44
+ @log.warning "Failed to delete DNS records: %s", e.message
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ 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