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
@@ -1,288 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "hetzner_client"
4
-
5
- module Nvoi
6
- module Providers
7
- # Hetzner provider implements the compute provider interface for Hetzner Cloud
8
- class Hetzner < Base
9
- def initialize(token)
10
- @client = HetznerClient.new(token)
11
- end
12
-
13
- # Network operations
14
-
15
- def find_or_create_network(name)
16
- network = find_network_by_name(name)
17
- return to_network(network) if network
18
-
19
- network = @client.create_network(
20
- name:,
21
- ip_range: Constants::NETWORK_CIDR,
22
- subnets: [{
23
- type: "cloud",
24
- ip_range: Constants::SUBNET_CIDR,
25
- network_zone: "eu-central"
26
- }]
27
- )
28
-
29
- to_network(network)
30
- end
31
-
32
- def get_network_by_name(name)
33
- network = find_network_by_name(name)
34
- raise NetworkError, "network not found: #{name}" unless network
35
-
36
- to_network(network)
37
- end
38
-
39
- def delete_network(id)
40
- @client.delete_network(id.to_i)
41
- end
42
-
43
- # Firewall operations
44
-
45
- def find_or_create_firewall(name)
46
- firewall = find_firewall_by_name(name)
47
- return to_firewall(firewall) if firewall
48
-
49
- firewall = @client.create_firewall(
50
- name:,
51
- rules: [{
52
- direction: "in",
53
- protocol: "tcp",
54
- port: "22",
55
- source_ips: ["0.0.0.0/0", "::/0"]
56
- }]
57
- )
58
-
59
- to_firewall(firewall)
60
- end
61
-
62
- def get_firewall_by_name(name)
63
- firewall = find_firewall_by_name(name)
64
- raise FirewallError, "firewall not found: #{name}" unless firewall
65
-
66
- to_firewall(firewall)
67
- end
68
-
69
- def delete_firewall(id)
70
- @client.delete_firewall(id.to_i)
71
- end
72
-
73
- # Server operations
74
-
75
- def find_server(name)
76
- server = find_server_by_name(name)
77
- return nil unless server
78
-
79
- to_server(server)
80
- end
81
-
82
- def list_servers
83
- @client.list_servers.map { |s| to_server(s) }
84
- end
85
-
86
- def create_server(opts)
87
- # Resolve IDs
88
- server_type = find_server_type(opts.type)
89
- raise ValidationError, "invalid server type: #{opts.type}" unless server_type
90
-
91
- image = find_image(opts.image)
92
- raise ValidationError, "invalid image: #{opts.image}" unless image
93
-
94
- location = find_location(opts.location)
95
- raise ValidationError, "invalid location: #{opts.location}" unless location
96
-
97
- create_opts = {
98
- name: opts.name,
99
- server_type: server_type["name"],
100
- image: image["name"],
101
- location: location["name"],
102
- user_data: opts.user_data,
103
- start_after_create: true
104
- }
105
-
106
- # Add network if provided
107
- if opts.network_id && !opts.network_id.empty?
108
- create_opts[:networks] = [opts.network_id.to_i]
109
- end
110
-
111
- # Add firewall if provided
112
- if opts.firewall_id && !opts.firewall_id.empty?
113
- create_opts[:firewalls] = [{ firewall: opts.firewall_id.to_i }]
114
- end
115
-
116
- server = @client.create_server(create_opts)
117
- to_server(server)
118
- end
119
-
120
- def wait_for_server(server_id, max_attempts)
121
- max_attempts.times do
122
- server = @client.get_server(server_id.to_i)
123
-
124
- if server["status"] == "running"
125
- return to_server(server)
126
- end
127
-
128
- sleep(Constants::SERVER_READY_INTERVAL)
129
- end
130
-
131
- raise ServerCreationError, "server did not become running after #{max_attempts} attempts"
132
- end
133
-
134
- def delete_server(id)
135
- server = @client.get_server(id.to_i)
136
-
137
- # Remove from firewalls
138
- @client.list_firewalls.each do |fw|
139
- fw["applied_to"]&.each do |applied|
140
- next unless applied["type"] == "server" && applied.dig("server", "id") == id.to_i
141
-
142
- @client.remove_firewall_from_server(fw["id"], id.to_i)
143
- rescue StandardError
144
- # Ignore cleanup errors
145
- end
146
- end
147
-
148
- # Detach from networks
149
- server["private_net"]&.each do |pn|
150
- @client.detach_server_from_network(id.to_i, pn["network"])
151
- rescue StandardError
152
- # Ignore cleanup errors
153
- end
154
-
155
- @client.delete_server(id.to_i)
156
- end
157
-
158
- # Volume operations
159
-
160
- def create_volume(opts)
161
- server = @client.get_server(opts.server_id.to_i)
162
- raise VolumeError, "server not found: #{opts.server_id}" unless server
163
-
164
- volume = @client.create_volume(
165
- name: opts.name,
166
- size: opts.size,
167
- location: server.dig("datacenter", "location", "name"),
168
- format: "xfs"
169
- )
170
-
171
- to_volume(volume)
172
- end
173
-
174
- def get_volume(id)
175
- volume = @client.get_volume(id.to_i)
176
- return nil unless volume
177
-
178
- to_volume(volume)
179
- end
180
-
181
- def get_volume_by_name(name)
182
- volume = @client.list_volumes.find { |v| v["name"] == name }
183
- return nil unless volume
184
-
185
- to_volume(volume)
186
- end
187
-
188
- def delete_volume(id)
189
- @client.delete_volume(id.to_i)
190
- end
191
-
192
- def attach_volume(volume_id, server_id)
193
- @client.attach_volume(volume_id.to_i, server_id.to_i)
194
- end
195
-
196
- def detach_volume(volume_id)
197
- @client.detach_volume(volume_id.to_i)
198
- end
199
-
200
- # Validation operations
201
-
202
- def validate_instance_type(instance_type)
203
- server_type = find_server_type(instance_type)
204
- raise ValidationError, "invalid hetzner server type: #{instance_type}" unless server_type
205
-
206
- true
207
- end
208
-
209
- def validate_region(region)
210
- location = find_location(region)
211
- raise ValidationError, "invalid hetzner location: #{region}" unless location
212
-
213
- true
214
- end
215
-
216
- def validate_credentials
217
- @client.list_server_types
218
- true
219
- rescue AuthenticationError => e
220
- raise ValidationError, "hetzner credentials invalid: #{e.message}"
221
- end
222
-
223
- private
224
-
225
- def find_network_by_name(name)
226
- @client.list_networks.find { |n| n["name"] == name }
227
- end
228
-
229
- def find_firewall_by_name(name)
230
- @client.list_firewalls.find { |f| f["name"] == name }
231
- end
232
-
233
- def find_server_by_name(name)
234
- @client.list_servers.find { |s| s["name"] == name }
235
- end
236
-
237
- def find_server_type(name)
238
- @client.list_server_types.find { |t| t["name"] == name }
239
- end
240
-
241
- def find_image(name)
242
- # Images endpoint requires filtering
243
- response = @client.get("/images?name=#{name}")
244
- response["images"]&.first
245
- end
246
-
247
- def find_location(name)
248
- @client.list_locations.find { |l| l["name"] == name }
249
- end
250
-
251
- def to_network(data)
252
- Network.new(
253
- id: data["id"].to_s,
254
- name: data["name"],
255
- ip_range: data["ip_range"]
256
- )
257
- end
258
-
259
- def to_firewall(data)
260
- Firewall.new(
261
- id: data["id"].to_s,
262
- name: data["name"]
263
- )
264
- end
265
-
266
- def to_server(data)
267
- Server.new(
268
- id: data["id"].to_s,
269
- name: data["name"],
270
- status: data["status"],
271
- public_ipv4: data.dig("public_net", "ipv4", "ip")
272
- )
273
- end
274
-
275
- def to_volume(data)
276
- Volume.new(
277
- id: data["id"].to_s,
278
- name: data["name"],
279
- size: data["size"],
280
- location: data.dig("location", "name"),
281
- status: data["status"],
282
- server_id: data["server"]&.to_s,
283
- device_path: data["linux_device"]
284
- )
285
- end
286
- end
287
- end
288
- end
@@ -1,170 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "faraday"
4
- require "json"
5
-
6
- module Nvoi
7
- module Providers
8
- # Raw HTTP client for Hetzner Cloud API
9
- class HetznerClient
10
- BASE_URL = "https://api.hetzner.cloud/v1"
11
-
12
- def initialize(token)
13
- @token = token
14
- @conn = Faraday.new do |f|
15
- f.request :json
16
- f.response :json
17
- f.headers["Authorization"] = "Bearer #{token}"
18
- end
19
- end
20
-
21
- def get(path)
22
- response = @conn.get("#{BASE_URL}#{path}")
23
- handle_response(response)
24
- end
25
-
26
- def post(path, payload = {})
27
- response = @conn.post("#{BASE_URL}#{path}", payload)
28
- handle_response(response)
29
- end
30
-
31
- def delete(path)
32
- response = @conn.delete("#{BASE_URL}#{path}")
33
- return nil if response.status == 204
34
- handle_response(response)
35
- end
36
-
37
- # Server types
38
- def list_server_types
39
- get("/server_types")["server_types"]
40
- end
41
-
42
- # Locations
43
- def list_locations
44
- get("/locations")["locations"]
45
- end
46
-
47
- # Servers
48
- def list_servers
49
- get("/servers")["servers"]
50
- end
51
-
52
- def get_server(id)
53
- get("/servers/#{id}")["server"]
54
- end
55
-
56
- def create_server(payload)
57
- post("/servers", payload)["server"]
58
- end
59
-
60
- def delete_server(id)
61
- delete("/servers/#{id}")
62
- end
63
-
64
- # Networks
65
- def list_networks
66
- get("/networks")["networks"]
67
- end
68
-
69
- def create_network(payload)
70
- post("/networks", payload)["network"]
71
- end
72
-
73
- def delete_network(id)
74
- delete("/networks/#{id}")
75
- end
76
-
77
- # Firewalls
78
- def list_firewalls
79
- get("/firewalls")["firewalls"]
80
- end
81
-
82
- def create_firewall(payload)
83
- post("/firewalls", payload)["firewall"]
84
- end
85
-
86
- def delete_firewall(id)
87
- delete("/firewalls/#{id}")
88
- end
89
-
90
- def apply_firewall_to_server(firewall_id, server_id)
91
- payload = {
92
- apply_to: [{
93
- type: "server",
94
- server: { id: server_id }
95
- }]
96
- }
97
- post("/firewalls/#{firewall_id}/actions/apply_to_resources", payload)
98
- end
99
-
100
- def remove_firewall_from_server(firewall_id, server_id)
101
- payload = {
102
- remove_from: [{
103
- type: "server",
104
- server: { id: server_id }
105
- }]
106
- }
107
- post("/firewalls/#{firewall_id}/actions/remove_from_resources", payload)
108
- end
109
-
110
- # Volumes
111
- def list_volumes
112
- get("/volumes")["volumes"]
113
- end
114
-
115
- def get_volume(id)
116
- get("/volumes/#{id}")["volume"]
117
- end
118
-
119
- def create_volume(payload)
120
- post("/volumes", payload)["volume"]
121
- end
122
-
123
- def delete_volume(id)
124
- delete("/volumes/#{id}")
125
- end
126
-
127
- def attach_volume(volume_id, server_id)
128
- post("/volumes/#{volume_id}/actions/attach", { server: server_id })
129
- end
130
-
131
- def detach_volume(volume_id)
132
- post("/volumes/#{volume_id}/actions/detach", {})
133
- end
134
-
135
- # Server network attachment
136
- def attach_server_to_network(server_id, network_id)
137
- post("/servers/#{server_id}/actions/attach_to_network", { network: network_id })
138
- end
139
-
140
- def detach_server_from_network(server_id, network_id)
141
- post("/servers/#{server_id}/actions/detach_from_network", { network: network_id })
142
- end
143
-
144
- private
145
-
146
- def handle_response(response)
147
- case response.status
148
- when 200..299
149
- response.body
150
- when 401
151
- raise AuthenticationError, "Invalid Hetzner API token"
152
- when 404
153
- raise NotFoundError, parse_error(response)
154
- when 422
155
- raise ValidationError, parse_error(response)
156
- else
157
- raise APIError, parse_error(response)
158
- end
159
- end
160
-
161
- def parse_error(response)
162
- if response.body.is_a?(Hash) && response.body["error"]
163
- response.body["error"]["message"]
164
- else
165
- "HTTP #{response.status}: #{response.body}"
166
- end
167
- end
168
- end
169
- end
170
- end
@@ -1,203 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Nvoi
4
- module Remote
5
- # ContainerRunOptions contains options for running a container
6
- ContainerRunOptions = Struct.new(:name, :image, :network, :volumes, :environment, :command, keyword_init: true)
7
-
8
- # WaitForHealthOptions contains options for health checking
9
- WaitForHealthOptions = Struct.new(:container, :utility_container, :health_check_path, :port,
10
- :max_attempts, :interval, :logger, keyword_init: true)
11
-
12
- # DockerManager manages Docker operations on remote servers
13
- class DockerManager
14
- attr_reader :ssh
15
-
16
- def initialize(ssh)
17
- @ssh = ssh
18
- end
19
-
20
- # Create a Docker network
21
- def create_network(name)
22
- @ssh.execute("docker network create #{name} 2>/dev/null || true")
23
- end
24
-
25
- # Build image locally, save to tar, rsync to remote, load on remote
26
- def build_image(path, tag, cache_from = nil)
27
- # 1. Build locally
28
- cache_args = cache_from ? "--cache-from #{cache_from}" : ""
29
- local_build_cmd = "cd #{path} && DOCKER_BUILDKIT=1 docker build --platform linux/amd64 #{cache_args} --build-arg BUILDKIT_INLINE_CACHE=1 -t #{tag} ."
30
-
31
- unless system("bash", "-c", local_build_cmd)
32
- raise SSHError, "local build failed"
33
- end
34
-
35
- # 2. Save to tar
36
- tar_file = "/tmp/#{tag.tr(':', '_')}.tar"
37
- unless system("docker", "save", tag, "-o", tar_file)
38
- raise SSHError, "docker save failed"
39
- end
40
-
41
- begin
42
- # 3. Rsync to remote
43
- remote_tar_path = "/tmp/#{tag.tr(':', '_')}.tar"
44
- rsync_cmd = ["rsync", "-avz",
45
- "-e", "ssh -i #{@ssh.ssh_key} -o StrictHostKeyChecking=no",
46
- tar_file,
47
- "#{@ssh.user}@#{@ssh.ip}:#{remote_tar_path}"]
48
-
49
- unless system(*rsync_cmd)
50
- raise SSHError, "rsync failed"
51
- end
52
-
53
- # 4. Load on remote with ctr (containerd)
54
- output = @ssh.execute("sudo ctr -n k8s.io images import #{remote_tar_path}")
55
-
56
- # Docker saves images with docker.io/library/ prefix
57
- full_image_ref = "docker.io/library/#{tag}"
58
-
59
- # Tag the imported image with the simple name
60
- begin
61
- @ssh.execute("sudo ctr -n k8s.io images tag #{full_image_ref} #{tag}")
62
- rescue SSHCommandError => e
63
- list_output = @ssh.execute("sudo ctr -n k8s.io images ls") rescue ""
64
- raise SSHError, "failed to tag imported image: #{e.message}\nAvailable images:\n#{list_output}"
65
- end
66
-
67
- # Cleanup tar file
68
- @ssh.execute_quiet("rm #{remote_tar_path}")
69
- ensure
70
- File.delete(tar_file) if File.exist?(tar_file)
71
- end
72
- end
73
-
74
- # Run a container
75
- def run_container(opts)
76
- cmd = "docker run -d --name #{opts.name} --network #{opts.network}"
77
-
78
- opts.volumes&.each do |vol|
79
- cmd += " -v #{vol}"
80
- end
81
-
82
- opts.environment&.each do |k, v|
83
- # Escape single quotes in value
84
- escaped_v = v.to_s.gsub("'", "'\\''")
85
- cmd += " -e #{k}='#{escaped_v}'"
86
- end
87
-
88
- cmd += " --restart unless-stopped #{opts.image}"
89
- cmd += " #{opts.command}" if opts.command && !opts.command.empty?
90
-
91
- @ssh.execute(cmd)
92
- end
93
-
94
- # Execute a command inside a running container
95
- def exec(container, command)
96
- @ssh.execute("docker exec #{container} #{command}")
97
- end
98
-
99
- # Wait for a container to be healthy
100
- def wait_for_health(opts)
101
- interval = opts.interval || 3
102
-
103
- opts.max_attempts.times do |i|
104
- # Check container status
105
- begin
106
- status = @ssh.execute(
107
- "docker inspect --format='{{.State.Status}}' #{opts.container} 2>/dev/null || echo 'none'"
108
- )
109
- rescue SSHCommandError
110
- opts.logger&.info("[%d/%d] Failed to get container status", i + 1, opts.max_attempts)
111
- sleep(interval)
112
- next
113
- end
114
-
115
- # Get last log line
116
- log_line = @ssh.execute("docker logs --tail 1 #{opts.container} 2>&1") rescue ""
117
-
118
- opts.logger&.info("[%d/%d] Status: %s | %s", i + 1, opts.max_attempts, status, log_line.strip)
119
-
120
- if status == "running"
121
- # Check if app responds using utility container
122
- health_cmd = "docker exec #{opts.utility_container} curl -s -o /dev/null -w '%{http_code}' -m 2 http://#{opts.container}:#{opts.port}#{opts.health_check_path} 2>&1 || echo '000'"
123
-
124
- http_code = @ssh.execute(health_cmd) rescue "000"
125
- if http_code.include?("200")
126
- return true
127
- else
128
- code = http_code.length >= 3 ? http_code[-3..] : "000"
129
- opts.logger&.info(" App not ready yet (HTTP %s)", code)
130
- end
131
- end
132
-
133
- sleep(interval)
134
- end
135
-
136
- false
137
- end
138
-
139
- # Get container status
140
- def container_status(name)
141
- @ssh.execute("docker inspect --format='{{.State.Status}}' #{name} 2>/dev/null || echo 'none'")
142
- end
143
-
144
- # Get container logs
145
- def container_logs(name, lines)
146
- @ssh.execute("docker logs --tail #{lines} #{name} 2>&1")
147
- end
148
-
149
- # Stop a container
150
- def stop_container(name)
151
- @ssh.execute("docker stop #{name} 2>/dev/null || true")
152
- end
153
-
154
- # Remove a container
155
- def remove_container(name)
156
- @ssh.execute("docker rm #{name} 2>/dev/null || true")
157
- end
158
-
159
- # List containers matching a filter
160
- def list_containers(filter)
161
- output = @ssh.execute("docker ps -a --filter '#{filter}' --format '{{.Names}}' --no-trunc | sort -r")
162
- return [] if output.empty?
163
-
164
- output.split("\n")
165
- end
166
-
167
- # List images matching a filter
168
- def list_images(filter)
169
- output = @ssh.execute("docker images --filter '#{filter}' --format '{{.Tag}}' | sort -r")
170
- return [] if output.empty?
171
-
172
- output.split("\n")
173
- end
174
-
175
- # Cleanup old Docker images
176
- def cleanup_old_images(prefix, keep_tags)
177
- all_tags = list_images("reference=#{prefix}:*")
178
-
179
- remove_tags = all_tags.reject { |tag| keep_tags.include?(tag) }
180
- return if remove_tags.empty?
181
-
182
- images = remove_tags.map { |tag| "#{prefix}:#{tag}" }
183
- @ssh.execute("docker rmi #{images.join(' ')} 2>/dev/null || true")
184
- end
185
-
186
- # Check if a container is running
187
- def container_running?(name)
188
- output = @ssh.execute("docker ps -q -f name=^#{name}$ -f status=running 2>/dev/null")
189
- !output.empty?
190
- end
191
-
192
- # Setup a Cloudflare tunnel sidecar
193
- def setup_cloudflared(network, token, name)
194
- create_network(network)
195
-
196
- cmd = "docker run -d --name #{name} --network #{network} --restart always " \
197
- "cloudflare/cloudflared:latest tunnel run --token #{token}"
198
-
199
- @ssh.execute(cmd)
200
- end
201
- end
202
- end
203
- end