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,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Deploy
|
|
6
|
+
module Steps
|
|
7
|
+
# DeployService handles K8s deployment of all services
|
|
8
|
+
class DeployService
|
|
9
|
+
DEFAULT_RESOURCES = {
|
|
10
|
+
request_memory: "128Mi",
|
|
11
|
+
request_cpu: "100m",
|
|
12
|
+
limit_memory: "512Mi",
|
|
13
|
+
limit_cpu: "500m"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(config, ssh, tunnels, log)
|
|
17
|
+
@config = config
|
|
18
|
+
@ssh = ssh
|
|
19
|
+
@tunnels = tunnels
|
|
20
|
+
@log = log
|
|
21
|
+
@namer = config.namer
|
|
22
|
+
@kubectl = External::Kubectl.new(ssh)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(image_tag, timestamp)
|
|
26
|
+
# Push to in-cluster registry
|
|
27
|
+
registry_tag = "localhost:#{Utils::Constants::REGISTRY_PORT}/#{@config.container_prefix}:#{timestamp}"
|
|
28
|
+
push_to_registry(image_tag, registry_tag)
|
|
29
|
+
|
|
30
|
+
# Gather env vars
|
|
31
|
+
first_service = @config.deploy.application.app.keys.first
|
|
32
|
+
all_env = @config.env_for_service(first_service)
|
|
33
|
+
|
|
34
|
+
# Deploy app secret
|
|
35
|
+
deploy_app_secret(all_env)
|
|
36
|
+
|
|
37
|
+
# Deploy database if configured
|
|
38
|
+
db_config = @config.deploy.application.database
|
|
39
|
+
if db_config
|
|
40
|
+
db_spec = db_config.to_service_spec(@namer)
|
|
41
|
+
deploy_database(db_spec) if db_spec
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Deploy additional services
|
|
45
|
+
@config.deploy.application.services.each do |service_name, service_config|
|
|
46
|
+
service_spec = service_config.to_service_spec(@config.deploy.application.name, service_name)
|
|
47
|
+
deploy_service(service_name, service_spec)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Deploy app services
|
|
51
|
+
@config.deploy.application.app.each do |service_name, service_config|
|
|
52
|
+
service_env = @config.env_for_service(service_name)
|
|
53
|
+
deploy_app_service(service_name, service_config, registry_tag, service_env)
|
|
54
|
+
|
|
55
|
+
# Deploy cloudflared for services with tunnels
|
|
56
|
+
tunnel = @tunnels.find { |t| t.service_name == service_name }
|
|
57
|
+
if tunnel
|
|
58
|
+
deploy_cloudflared(service_name, tunnel.tunnel_token)
|
|
59
|
+
verify_traffic_switchover(service_config)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@log.success "All services deployed"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def push_to_registry(local_tag, registry_tag)
|
|
69
|
+
@log.info "Pushing to in-cluster registry: %s", registry_tag
|
|
70
|
+
|
|
71
|
+
@ssh.execute("sudo ctr -n k8s.io images tag #{local_tag} #{registry_tag}")
|
|
72
|
+
@ssh.execute("sudo ctr -n k8s.io images push --plain-http #{registry_tag}")
|
|
73
|
+
|
|
74
|
+
@log.success "Image pushed to registry"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def deploy_app_secret(env_vars)
|
|
78
|
+
secret_name = @namer.app_secret_name
|
|
79
|
+
|
|
80
|
+
@log.info "Deploying app secret: %s", secret_name
|
|
81
|
+
|
|
82
|
+
Utils::Templates.apply_manifest(@ssh, "app-secret.yaml", {
|
|
83
|
+
name: secret_name,
|
|
84
|
+
env_vars:
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
@log.success "App secret deployed"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deploy_database(db_spec)
|
|
91
|
+
@log.info "Deploying database: %s", db_spec.name
|
|
92
|
+
|
|
93
|
+
data = {
|
|
94
|
+
service_name: db_spec.name,
|
|
95
|
+
adapter: @config.deploy.application.database.adapter,
|
|
96
|
+
image: db_spec.image,
|
|
97
|
+
port: db_spec.port,
|
|
98
|
+
secret_name: @namer.database_secret_name,
|
|
99
|
+
secret_keys: db_spec.secrets.keys.sort,
|
|
100
|
+
data_path: "/var/lib/postgresql/data",
|
|
101
|
+
storage_size: "10Gi",
|
|
102
|
+
affinity_server_names: db_spec.servers,
|
|
103
|
+
host_path: nil
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Use hostPath for database volume if configured
|
|
107
|
+
db_mount = @config.deploy.application.database.mount
|
|
108
|
+
if db_mount && !db_mount.empty?
|
|
109
|
+
server_name = db_spec.servers.first
|
|
110
|
+
vol_name = db_mount.keys.first
|
|
111
|
+
data[:host_path] = @namer.server_volume_host_path(server_name, vol_name)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Create database secret
|
|
115
|
+
Utils::Templates.apply_manifest(@ssh, "app-secret.yaml", {
|
|
116
|
+
name: @namer.database_secret_name,
|
|
117
|
+
env_vars: db_spec.secrets
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
# Deploy StatefulSet
|
|
121
|
+
Utils::Templates.apply_manifest(@ssh, "db-statefulset.yaml", data)
|
|
122
|
+
|
|
123
|
+
# Wait for database to be ready
|
|
124
|
+
@log.info "Waiting for database to be ready..."
|
|
125
|
+
@kubectl.wait_for_statefulset(db_spec.name)
|
|
126
|
+
|
|
127
|
+
@log.success "Database deployed: %s", db_spec.name
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def deploy_service(service_name, service_spec)
|
|
131
|
+
@log.info "Deploying service: %s", service_spec.name
|
|
132
|
+
|
|
133
|
+
host_path = nil
|
|
134
|
+
volume_path = nil
|
|
135
|
+
if service_spec.mounts && !service_spec.mounts.empty?
|
|
136
|
+
server_name = service_spec.servers.first
|
|
137
|
+
vol_name, mount_path = service_spec.mounts.first
|
|
138
|
+
host_path = @namer.server_volume_host_path(server_name, vol_name)
|
|
139
|
+
volume_path = mount_path
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
data = {
|
|
143
|
+
name: service_spec.name,
|
|
144
|
+
image: service_spec.image,
|
|
145
|
+
port: service_spec.port,
|
|
146
|
+
command: service_spec.command,
|
|
147
|
+
env_vars: service_spec.env,
|
|
148
|
+
env_keys: service_spec.env.keys.sort,
|
|
149
|
+
volume_path:,
|
|
150
|
+
host_path:,
|
|
151
|
+
affinity_server_names: service_spec.servers
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Utils::Templates.apply_manifest(@ssh, "service-deployment.yaml", data)
|
|
155
|
+
|
|
156
|
+
@log.success "Service deployed: %s", service_spec.name
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def deploy_app_service(service_name, service_config, image_tag, env)
|
|
160
|
+
deployment_name = @namer.app_deployment_name(service_name)
|
|
161
|
+
@log.info "Deploying app service: %s", deployment_name
|
|
162
|
+
|
|
163
|
+
has_port = service_config.port && service_config.port.positive?
|
|
164
|
+
template = has_port ? "app-deployment.yaml" : "worker-deployment.yaml"
|
|
165
|
+
|
|
166
|
+
# Build probes
|
|
167
|
+
readiness_probe = nil
|
|
168
|
+
liveness_probe = nil
|
|
169
|
+
|
|
170
|
+
if service_config.healthcheck && has_port
|
|
171
|
+
hc = service_config.healthcheck
|
|
172
|
+
readiness_probe = {
|
|
173
|
+
path: hc.path || "/health",
|
|
174
|
+
port: hc.port || service_config.port,
|
|
175
|
+
initial_delay: 10,
|
|
176
|
+
period: 10,
|
|
177
|
+
timeout: 5,
|
|
178
|
+
failure_threshold: 3
|
|
179
|
+
}
|
|
180
|
+
liveness_probe = readiness_probe.merge(initial_delay: 30)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
data = {
|
|
184
|
+
name: deployment_name,
|
|
185
|
+
image: image_tag,
|
|
186
|
+
replicas: has_port ? 2 : 1,
|
|
187
|
+
port: service_config.port,
|
|
188
|
+
command: service_config.command&.split || [],
|
|
189
|
+
secret_name: @namer.app_secret_name,
|
|
190
|
+
env_keys: env.keys.sort,
|
|
191
|
+
affinity_server_names: service_config.servers,
|
|
192
|
+
resources: DEFAULT_RESOURCES,
|
|
193
|
+
readiness_probe:,
|
|
194
|
+
liveness_probe:,
|
|
195
|
+
volume_mounts: [],
|
|
196
|
+
host_path_volumes: [],
|
|
197
|
+
volumes: []
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Collect all mounts: explicit app mounts + sqlite3 database mount
|
|
201
|
+
all_mounts = (service_config.mounts || {}).dup
|
|
202
|
+
|
|
203
|
+
# For sqlite3, add database.mount to app services
|
|
204
|
+
db = @config.deploy.application.database
|
|
205
|
+
if db&.adapter&.downcase&.start_with?("sqlite") && db.mount && !db.mount.empty?
|
|
206
|
+
db.mount.each { |k, v| all_mounts[k] ||= v }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Add mounts if configured
|
|
210
|
+
if !all_mounts.empty?
|
|
211
|
+
if service_config.servers.length > 1
|
|
212
|
+
raise Errors::DeploymentError.new(
|
|
213
|
+
"validation",
|
|
214
|
+
"app '#{service_name}' runs on multiple servers #{service_config.servers} " \
|
|
215
|
+
"and cannot have mounts. Volumes are server-local and would cause data inconsistency."
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
server_name = service_config.servers.first
|
|
220
|
+
server_config = @config.deploy.application.servers[server_name]
|
|
221
|
+
|
|
222
|
+
all_mounts.each do |vol_name, mount_path|
|
|
223
|
+
unless server_config&.volumes&.key?(vol_name)
|
|
224
|
+
available = server_config&.volumes&.keys&.join(", ") || "none"
|
|
225
|
+
raise Errors::DeploymentError.new(
|
|
226
|
+
"validation",
|
|
227
|
+
"app '#{service_name}' mounts '#{vol_name}' but server '#{server_name}' " \
|
|
228
|
+
"has no volume named '#{vol_name}'. Available: #{available}"
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
host_path = @namer.server_volume_host_path(server_name, vol_name)
|
|
233
|
+
data[:volume_mounts] << { name: vol_name, mount_path: }
|
|
234
|
+
data[:host_path_volumes] << { name: vol_name, host_path: }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
Utils::Templates.apply_manifest(@ssh, template, data)
|
|
239
|
+
|
|
240
|
+
# Deploy service if it has a port
|
|
241
|
+
if has_port
|
|
242
|
+
Utils::Templates.apply_manifest(@ssh, "app-service.yaml", {
|
|
243
|
+
name: deployment_name,
|
|
244
|
+
port: service_config.port
|
|
245
|
+
})
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Deploy ingress if domain is specified
|
|
249
|
+
if service_config.domain && !service_config.domain.empty?
|
|
250
|
+
hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
|
|
251
|
+
|
|
252
|
+
Utils::Templates.apply_manifest(@ssh, "app-ingress.yaml", {
|
|
253
|
+
name: deployment_name,
|
|
254
|
+
domain: hostname,
|
|
255
|
+
port: service_config.port
|
|
256
|
+
})
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Wait for deployment
|
|
260
|
+
@log.info "Waiting for deployment to be ready..."
|
|
261
|
+
@kubectl.wait_for_deployment(deployment_name)
|
|
262
|
+
|
|
263
|
+
# Run pre-run command if specified
|
|
264
|
+
if service_config.pre_run_command && !service_config.pre_run_command.empty?
|
|
265
|
+
run_pre_run_command(service_name, service_config.pre_run_command)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
@log.success "App service deployed: %s", deployment_name
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def deploy_cloudflared(service_name, tunnel_token)
|
|
272
|
+
deployment_name = @namer.cloudflared_deployment_name(service_name)
|
|
273
|
+
@log.info "Deploying cloudflared: %s", deployment_name
|
|
274
|
+
|
|
275
|
+
manifest = <<~YAML
|
|
276
|
+
apiVersion: apps/v1
|
|
277
|
+
kind: Deployment
|
|
278
|
+
metadata:
|
|
279
|
+
name: #{deployment_name}
|
|
280
|
+
namespace: default
|
|
281
|
+
spec:
|
|
282
|
+
replicas: 1
|
|
283
|
+
selector:
|
|
284
|
+
matchLabels:
|
|
285
|
+
app: #{deployment_name}
|
|
286
|
+
template:
|
|
287
|
+
metadata:
|
|
288
|
+
labels:
|
|
289
|
+
app: #{deployment_name}
|
|
290
|
+
spec:
|
|
291
|
+
containers:
|
|
292
|
+
- name: cloudflared
|
|
293
|
+
image: cloudflare/cloudflared:latest
|
|
294
|
+
args:
|
|
295
|
+
- tunnel
|
|
296
|
+
- run
|
|
297
|
+
- --token
|
|
298
|
+
- #{tunnel_token}
|
|
299
|
+
YAML
|
|
300
|
+
|
|
301
|
+
@ssh.execute("cat <<'EOF' | kubectl apply -f -\n#{manifest}\nEOF")
|
|
302
|
+
|
|
303
|
+
@log.success "Cloudflared deployed: %s", deployment_name
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def verify_traffic_switchover(service_config)
|
|
307
|
+
return unless service_config.domain && !service_config.domain.empty?
|
|
308
|
+
|
|
309
|
+
hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
|
|
310
|
+
|
|
311
|
+
health_path = service_config.healthcheck&.path || "/"
|
|
312
|
+
public_url = "https://#{hostname}#{health_path}"
|
|
313
|
+
|
|
314
|
+
@log.info "Verifying public traffic routing"
|
|
315
|
+
@log.info "Testing: %s", public_url
|
|
316
|
+
|
|
317
|
+
consecutive_success = 0
|
|
318
|
+
required_consecutive = Utils::Constants::TRAFFIC_VERIFY_CONSECUTIVE
|
|
319
|
+
max_attempts = Utils::Constants::TRAFFIC_VERIFY_ATTEMPTS
|
|
320
|
+
|
|
321
|
+
max_attempts.times do |attempt|
|
|
322
|
+
begin
|
|
323
|
+
result = check_public_url(public_url)
|
|
324
|
+
|
|
325
|
+
if result[:success]
|
|
326
|
+
consecutive_success += 1
|
|
327
|
+
@log.success "[%d/%d] Public URL responding: %s", consecutive_success, required_consecutive, result[:http_code]
|
|
328
|
+
|
|
329
|
+
if consecutive_success >= required_consecutive
|
|
330
|
+
@log.success "Traffic switchover verified: public URL accessible"
|
|
331
|
+
return
|
|
332
|
+
end
|
|
333
|
+
else
|
|
334
|
+
if consecutive_success > 0
|
|
335
|
+
@log.warning "Success streak broken at %d, restarting count", consecutive_success
|
|
336
|
+
end
|
|
337
|
+
consecutive_success = 0
|
|
338
|
+
@log.info "[%d/%d] %s", attempt + 1, max_attempts, result[:message]
|
|
339
|
+
end
|
|
340
|
+
rescue Errors::SshCommandError
|
|
341
|
+
consecutive_success = 0
|
|
342
|
+
@log.info "[%d/%d] Public URL check failed", attempt + 1, max_attempts
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
sleep(Utils::Constants::TRAFFIC_VERIFY_INTERVAL)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
raise Errors::DeploymentError.new(
|
|
349
|
+
"traffic_verification",
|
|
350
|
+
"public URL verification failed after #{max_attempts} attempts. Cloudflare tunnel may not be routing correctly."
|
|
351
|
+
)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def check_public_url(url)
|
|
355
|
+
curl_cmd = "curl -si -m 10 '#{url}' 2>/dev/null"
|
|
356
|
+
output = @ssh.execute(curl_cmd).strip
|
|
357
|
+
|
|
358
|
+
http_code = output.lines.first&.match(/HTTP\/[\d.]+ (\d+)/)&.captures&.first || "000"
|
|
359
|
+
has_error_header = output.lines.any? { |line| line.downcase.start_with?("x-nvoi-error:") }
|
|
360
|
+
|
|
361
|
+
if http_code == "200" && !has_error_header
|
|
362
|
+
{ success: true, http_code:, message: "OK" }
|
|
363
|
+
elsif has_error_header
|
|
364
|
+
{ success: false, http_code:, message: "Error backend responding (X-Nvoi-Error header present) - app is down" }
|
|
365
|
+
else
|
|
366
|
+
{ success: false, http_code:, message: "HTTP #{http_code} (expected: 200)" }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def run_pre_run_command(service_name, command)
|
|
371
|
+
@log.info "Running pre-run command: %s", command
|
|
372
|
+
|
|
373
|
+
pod_label = @namer.app_pod_label(service_name)
|
|
374
|
+
pod_name = @ssh.execute("kubectl get pod -l #{pod_label} -o jsonpath='{.items[0].metadata.name}'")
|
|
375
|
+
pod_name = pod_name.strip.delete("'")
|
|
376
|
+
|
|
377
|
+
escaped_command = command.gsub("'", "'\"'\"'")
|
|
378
|
+
exec_cmd = "kubectl exec #{pod_name} -- sh -c '#{escaped_command}'"
|
|
379
|
+
|
|
380
|
+
begin
|
|
381
|
+
output = @ssh.execute(exec_cmd)
|
|
382
|
+
@log.info "Pre-run command output:\n%s", output unless output.empty?
|
|
383
|
+
rescue Errors::SshCommandError => e
|
|
384
|
+
@log.error "Pre-run command failed: %s", e.message
|
|
385
|
+
|
|
386
|
+
logs = @ssh.execute("kubectl logs #{pod_name} --tail=50")
|
|
387
|
+
@log.error "Pod logs:\n%s", logs
|
|
388
|
+
|
|
389
|
+
raise Errors::DeploymentError.new("pre_run_command", "deployment aborted: pre-run command failed: #{e.message}")
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Deploy
|
|
6
|
+
module Steps
|
|
7
|
+
# ProvisionNetwork handles network and firewall provisioning
|
|
8
|
+
class ProvisionNetwork
|
|
9
|
+
def initialize(config, provider, log)
|
|
10
|
+
@config = config
|
|
11
|
+
@provider = provider
|
|
12
|
+
@log = log
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
@log.info "Provisioning network infrastructure"
|
|
17
|
+
|
|
18
|
+
network = provision_network
|
|
19
|
+
firewall = provision_firewall
|
|
20
|
+
|
|
21
|
+
@log.success "Network infrastructure ready"
|
|
22
|
+
[network, firewall]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def provision_network
|
|
28
|
+
@log.info "Provisioning network: %s", @config.network_name
|
|
29
|
+
network = @provider.find_or_create_network(@config.network_name)
|
|
30
|
+
@log.success "Network ready: %s", network.id
|
|
31
|
+
network
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def provision_firewall
|
|
35
|
+
@log.info "Provisioning firewall: %s", @config.firewall_name
|
|
36
|
+
firewall = @provider.find_or_create_firewall(@config.firewall_name)
|
|
37
|
+
@log.success "Firewall ready: %s", firewall.id
|
|
38
|
+
firewall
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Deploy
|
|
6
|
+
module Steps
|
|
7
|
+
# ProvisionServer handles compute server provisioning
|
|
8
|
+
class ProvisionServer
|
|
9
|
+
def initialize(config, provider, log, network, firewall)
|
|
10
|
+
@config = config
|
|
11
|
+
@provider = provider
|
|
12
|
+
@log = log
|
|
13
|
+
@network = network
|
|
14
|
+
@firewall = firewall
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
@log.info "Provisioning servers"
|
|
19
|
+
|
|
20
|
+
servers = @config.deploy.application.servers
|
|
21
|
+
main_server_ip = nil
|
|
22
|
+
|
|
23
|
+
servers.each do |group_name, group_config|
|
|
24
|
+
count = group_config&.count&.positive? ? group_config.count : 1
|
|
25
|
+
|
|
26
|
+
(1..count).each do |i|
|
|
27
|
+
server_name = @config.namer.server_name(group_name, i)
|
|
28
|
+
server = provision_server(server_name, group_config)
|
|
29
|
+
|
|
30
|
+
# Track main server IP (first master, or just first server)
|
|
31
|
+
main_server_ip ||= server.public_ipv4 if group_config&.master || i == 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@log.success "All servers provisioned"
|
|
36
|
+
main_server_ip
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def provision_server(name, server_config)
|
|
42
|
+
@log.info "Provisioning server: %s", name
|
|
43
|
+
|
|
44
|
+
# Check if server already exists
|
|
45
|
+
existing = @provider.find_server(name)
|
|
46
|
+
if existing
|
|
47
|
+
@log.info "Server already exists: %s (%s)", name, existing.public_ipv4
|
|
48
|
+
return existing
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Determine server type and location
|
|
52
|
+
server_type = server_config&.type
|
|
53
|
+
location = server_config&.location
|
|
54
|
+
image = "ubuntu-22.04"
|
|
55
|
+
|
|
56
|
+
case @config.provider_name
|
|
57
|
+
when "hetzner"
|
|
58
|
+
h = @config.hetzner
|
|
59
|
+
server_type ||= h.server_type
|
|
60
|
+
location ||= h.server_location
|
|
61
|
+
when "aws"
|
|
62
|
+
a = @config.aws
|
|
63
|
+
server_type ||= a.instance_type
|
|
64
|
+
location ||= a.region
|
|
65
|
+
when "scaleway"
|
|
66
|
+
s = @config.scaleway
|
|
67
|
+
server_type ||= s.server_type
|
|
68
|
+
location ||= s.zone
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Create cloud-init user data
|
|
72
|
+
user_data = generate_user_data
|
|
73
|
+
|
|
74
|
+
opts = Objects::Server::CreateOptions.new(
|
|
75
|
+
name:,
|
|
76
|
+
type: server_type,
|
|
77
|
+
image:,
|
|
78
|
+
location:,
|
|
79
|
+
user_data:,
|
|
80
|
+
network_id: @network.id,
|
|
81
|
+
firewall_id: @firewall.id
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
server = @provider.create_server(opts)
|
|
85
|
+
@log.info "Server created: %s (waiting for ready...)", server.id
|
|
86
|
+
|
|
87
|
+
# Wait for server to be running
|
|
88
|
+
server = @provider.wait_for_server(server.id, Utils::Constants::SERVER_READY_MAX_ATTEMPTS)
|
|
89
|
+
@log.success "Server ready: %s (%s)", name, server.public_ipv4
|
|
90
|
+
|
|
91
|
+
# Wait for SSH to be available
|
|
92
|
+
wait_for_ssh(server.public_ipv4)
|
|
93
|
+
|
|
94
|
+
server
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def generate_user_data
|
|
98
|
+
ssh_key = @config.ssh_public_key
|
|
99
|
+
|
|
100
|
+
<<~CLOUD_INIT
|
|
101
|
+
#cloud-config
|
|
102
|
+
users:
|
|
103
|
+
- name: deploy
|
|
104
|
+
groups: sudo, docker
|
|
105
|
+
shell: /bin/bash
|
|
106
|
+
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
107
|
+
ssh_authorized_keys:
|
|
108
|
+
- #{ssh_key}
|
|
109
|
+
package_update: true
|
|
110
|
+
package_upgrade: true
|
|
111
|
+
packages:
|
|
112
|
+
- curl
|
|
113
|
+
- git
|
|
114
|
+
- jq
|
|
115
|
+
- rsync
|
|
116
|
+
CLOUD_INIT
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def wait_for_ssh(ip)
|
|
120
|
+
@log.info "Waiting for SSH on %s...", ip
|
|
121
|
+
ssh = External::Ssh.new(ip, @config.ssh_key_path)
|
|
122
|
+
|
|
123
|
+
Utils::Constants::SSH_READY_MAX_ATTEMPTS.times do |_i|
|
|
124
|
+
begin
|
|
125
|
+
output = ssh.execute("echo 'ready'")
|
|
126
|
+
if output.strip == "ready"
|
|
127
|
+
@log.success "SSH ready"
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
rescue Errors::SshCommandError
|
|
131
|
+
# SSH not ready yet
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
sleep(Utils::Constants::SSH_READY_INTERVAL)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
raise Errors::SshConnectionError, "SSH connection failed after #{Utils::Constants::SSH_READY_MAX_ATTEMPTS} attempts"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|