nvoi 0.1.5
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 +7 -0
- data/.rubocop.yml +19 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +151 -0
- data/Makefile +26 -0
- data/Rakefile +16 -0
- data/doc/config-schema.yaml +357 -0
- data/examples/apex-wildcard/deploy.yml +68 -0
- data/examples/golang/.gitignore +19 -0
- data/examples/golang/Dockerfile +43 -0
- data/examples/golang/README.md +59 -0
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/deploy.yml +54 -0
- data/examples/golang/go.mod +39 -0
- data/examples/golang/go.sum +96 -0
- data/examples/golang/main.go +177 -0
- data/examples/golang/models/user.go +17 -0
- data/examples/golang-postgres-multi/.gitignore +18 -0
- data/examples/golang-postgres-multi/Dockerfile +39 -0
- data/examples/golang-postgres-multi/README.md +211 -0
- data/examples/golang-postgres-multi/deploy.yml +67 -0
- data/examples/golang-postgres-multi/go.mod +45 -0
- data/examples/golang-postgres-multi/go.sum +108 -0
- data/examples/golang-postgres-multi/main.go +197 -0
- data/examples/golang-postgres-multi/models/user.go +17 -0
- data/examples/postgres-multi/.env.production.example +11 -0
- data/examples/postgres-multi/README.md +112 -0
- data/examples/postgres-multi/deploy.yml +74 -0
- data/examples/postgres-single/.env.production.example +11 -0
- data/examples/postgres-single/.gitignore +15 -0
- data/examples/postgres-single/Dockerfile +35 -0
- data/examples/postgres-single/README.md +76 -0
- data/examples/postgres-single/deploy.yml +56 -0
- data/examples/postgres-single/go.mod +45 -0
- data/examples/postgres-single/go.sum +108 -0
- data/examples/postgres-single/main.go +184 -0
- data/examples/rails-single/.dockerignore +51 -0
- data/examples/rails-single/.env.production.example +11 -0
- data/examples/rails-single/.github/dependabot.yml +12 -0
- data/examples/rails-single/.github/workflows/ci.yml +39 -0
- data/examples/rails-single/.gitignore +20 -0
- data/examples/rails-single/.node-version +1 -0
- data/examples/rails-single/.rubocop.yml +8 -0
- data/examples/rails-single/.ruby-version +1 -0
- data/examples/rails-single/Dockerfile +86 -0
- data/examples/rails-single/Gemfile +56 -0
- data/examples/rails-single/Gemfile.lock +350 -0
- data/examples/rails-single/Procfile.dev +3 -0
- data/examples/rails-single/README.md +17 -0
- data/examples/rails-single/Rakefile +6 -0
- data/examples/rails-single/app/assets/builds/.keep +0 -0
- data/examples/rails-single/app/assets/images/.keep +0 -0
- data/examples/rails-single/app/assets/stylesheets/application.tailwind.css +1 -0
- data/examples/rails-single/app/controllers/application_controller.rb +4 -0
- data/examples/rails-single/app/controllers/concerns/.keep +0 -0
- data/examples/rails-single/app/controllers/users_controller.rb +19 -0
- data/examples/rails-single/app/helpers/application_helper.rb +2 -0
- data/examples/rails-single/app/javascript/application.js +3 -0
- data/examples/rails-single/app/javascript/controllers/application.js +9 -0
- data/examples/rails-single/app/javascript/controllers/hello_controller.js +7 -0
- data/examples/rails-single/app/javascript/controllers/index.js +8 -0
- data/examples/rails-single/app/jobs/application_job.rb +7 -0
- data/examples/rails-single/app/mailers/application_mailer.rb +4 -0
- data/examples/rails-single/app/models/application_record.rb +3 -0
- data/examples/rails-single/app/models/concerns/.keep +0 -0
- data/examples/rails-single/app/models/user.rb +2 -0
- data/examples/rails-single/app/views/layouts/application.html.erb +28 -0
- data/examples/rails-single/app/views/layouts/mailer.html.erb +13 -0
- data/examples/rails-single/app/views/layouts/mailer.text.erb +1 -0
- data/examples/rails-single/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails-single/app/views/pwa/service-worker.js +26 -0
- data/examples/rails-single/app/views/users/index.html.erb +38 -0
- data/examples/rails-single/bin/brakeman +7 -0
- data/examples/rails-single/bin/bundle +109 -0
- data/examples/rails-single/bin/dev +11 -0
- data/examples/rails-single/bin/docker-entrypoint +14 -0
- data/examples/rails-single/bin/jobs +6 -0
- data/examples/rails-single/bin/kamal +27 -0
- data/examples/rails-single/bin/rails +4 -0
- data/examples/rails-single/bin/rake +4 -0
- data/examples/rails-single/bin/rubocop +8 -0
- data/examples/rails-single/bin/setup +37 -0
- data/examples/rails-single/bin/thrust +5 -0
- data/examples/rails-single/bun.lock +224 -0
- data/examples/rails-single/config/application.rb +42 -0
- data/examples/rails-single/config/boot.rb +4 -0
- data/examples/rails-single/config/cable.yml +17 -0
- data/examples/rails-single/config/cache.yml +16 -0
- data/examples/rails-single/config/credentials.yml.enc +1 -0
- data/examples/rails-single/config/database.yml +100 -0
- data/examples/rails-single/config/environment.rb +5 -0
- data/examples/rails-single/config/environments/development.rb +69 -0
- data/examples/rails-single/config/environments/production.rb +87 -0
- data/examples/rails-single/config/environments/test.rb +50 -0
- data/examples/rails-single/config/initializers/assets.rb +7 -0
- data/examples/rails-single/config/initializers/content_security_policy.rb +25 -0
- data/examples/rails-single/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails-single/config/initializers/inflections.rb +16 -0
- data/examples/rails-single/config/locales/en.yml +31 -0
- data/examples/rails-single/config/puma.rb +41 -0
- data/examples/rails-single/config/queue.yml +18 -0
- data/examples/rails-single/config/recurring.yml +15 -0
- data/examples/rails-single/config/routes.rb +4 -0
- data/examples/rails-single/config.ru +6 -0
- data/examples/rails-single/db/cable_schema.rb +11 -0
- data/examples/rails-single/db/cache_schema.rb +12 -0
- data/examples/rails-single/db/migrate/20251123095526_create_users.rb +10 -0
- data/examples/rails-single/db/queue_schema.rb +129 -0
- data/examples/rails-single/db/seeds.rb +9 -0
- data/examples/rails-single/deploy.yml +57 -0
- data/examples/rails-single/lib/tasks/.keep +0 -0
- data/examples/rails-single/log/.keep +0 -0
- data/examples/rails-single/package.json +17 -0
- data/examples/rails-single/public/400.html +114 -0
- data/examples/rails-single/public/404.html +114 -0
- data/examples/rails-single/public/406-unsupported-browser.html +114 -0
- data/examples/rails-single/public/422.html +114 -0
- data/examples/rails-single/public/500.html +114 -0
- data/examples/rails-single/public/icon.png +0 -0
- data/examples/rails-single/public/icon.svg +3 -0
- data/examples/rails-single/public/robots.txt +1 -0
- data/examples/rails-single/script/.keep +0 -0
- data/examples/rails-single/vendor/.keep +0 -0
- data/examples/rails-single/yarn.lock +188 -0
- data/exe/nvoi +6 -0
- data/lib/nvoi/cli.rb +190 -0
- data/lib/nvoi/cloudflare/client.rb +287 -0
- data/lib/nvoi/config/config.rb +248 -0
- data/lib/nvoi/config/env_resolver.rb +63 -0
- data/lib/nvoi/config/loader.rb +102 -0
- data/lib/nvoi/config/naming.rb +196 -0
- data/lib/nvoi/config/ssh_keys.rb +82 -0
- data/lib/nvoi/config/types.rb +274 -0
- data/lib/nvoi/constants.rb +59 -0
- data/lib/nvoi/credentials/crypto.rb +88 -0
- data/lib/nvoi/credentials/editor.rb +272 -0
- data/lib/nvoi/credentials/manager.rb +173 -0
- data/lib/nvoi/deployer/cleaner.rb +36 -0
- data/lib/nvoi/deployer/image_builder.rb +23 -0
- data/lib/nvoi/deployer/infrastructure.rb +126 -0
- data/lib/nvoi/deployer/orchestrator.rb +146 -0
- data/lib/nvoi/deployer/retry.rb +67 -0
- data/lib/nvoi/deployer/service_deployer.rb +311 -0
- data/lib/nvoi/deployer/tunnel_manager.rb +57 -0
- data/lib/nvoi/deployer/types.rb +8 -0
- data/lib/nvoi/errors.rb +67 -0
- data/lib/nvoi/k8s/renderer.rb +44 -0
- data/lib/nvoi/k8s/templates.rb +29 -0
- data/lib/nvoi/logger.rb +72 -0
- data/lib/nvoi/providers/aws.rb +403 -0
- data/lib/nvoi/providers/base.rb +111 -0
- data/lib/nvoi/providers/hetzner.rb +288 -0
- data/lib/nvoi/providers/hetzner_client.rb +170 -0
- data/lib/nvoi/remote/docker_manager.rb +203 -0
- data/lib/nvoi/remote/ssh_executor.rb +72 -0
- data/lib/nvoi/remote/volume_manager.rb +103 -0
- data/lib/nvoi/service/delete.rb +234 -0
- data/lib/nvoi/service/deploy.rb +80 -0
- data/lib/nvoi/service/exec.rb +144 -0
- data/lib/nvoi/service/provider.rb +36 -0
- data/lib/nvoi/steps/application_deployer.rb +26 -0
- data/lib/nvoi/steps/database_provisioner.rb +60 -0
- data/lib/nvoi/steps/k3s_cluster_setup.rb +105 -0
- data/lib/nvoi/steps/k3s_provisioner.rb +351 -0
- data/lib/nvoi/steps/server_provisioner.rb +43 -0
- data/lib/nvoi/steps/services_provisioner.rb +29 -0
- data/lib/nvoi/steps/tunnel_configurator.rb +66 -0
- data/lib/nvoi/steps/volume_provisioner.rb +154 -0
- data/lib/nvoi/version.rb +5 -0
- data/lib/nvoi.rb +79 -0
- data/templates/app-deployment.yaml.erb +102 -0
- data/templates/app-ingress.yaml.erb +20 -0
- data/templates/app-secret.yaml.erb +10 -0
- data/templates/app-service.yaml.erb +12 -0
- data/templates/db-statefulset.yaml.erb +76 -0
- data/templates/service-deployment.yaml.erb +91 -0
- data/templates/worker-deployment.yaml.erb +50 -0
- metadata +361 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Deployer
|
|
5
|
+
# Orchestrator coordinates the deployment pipeline
|
|
6
|
+
class Orchestrator
|
|
7
|
+
def initialize(config, provider, log)
|
|
8
|
+
@config = config
|
|
9
|
+
@provider = provider
|
|
10
|
+
@log = log
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(server_ip, tunnels, working_dir)
|
|
14
|
+
@log.info "Starting deployment orchestration"
|
|
15
|
+
|
|
16
|
+
# Create SSH connection to main server
|
|
17
|
+
@ssh = Remote::SSHExecutor.new(server_ip, @config.ssh_key_path)
|
|
18
|
+
|
|
19
|
+
# Acquire deployment lock
|
|
20
|
+
acquire_lock
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
run_deployment(tunnels, working_dir)
|
|
24
|
+
ensure
|
|
25
|
+
release_lock
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def run_deployment(tunnels, working_dir)
|
|
32
|
+
ssh = @ssh
|
|
33
|
+
docker = Remote::DockerManager.new(ssh)
|
|
34
|
+
|
|
35
|
+
# Generate image tag
|
|
36
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
37
|
+
image_tag = @config.namer.image_tag(timestamp)
|
|
38
|
+
|
|
39
|
+
# Build and push image
|
|
40
|
+
image_builder = ImageBuilder.new(@config, docker, @log)
|
|
41
|
+
image_builder.build_and_push(working_dir, image_tag)
|
|
42
|
+
|
|
43
|
+
# Tag as latest locally for K8s to use
|
|
44
|
+
# The image is already in containerd from build_image
|
|
45
|
+
registry_tag = "localhost:#{Constants::REGISTRY_PORT}/#{@config.container_prefix}:#{timestamp}"
|
|
46
|
+
push_to_registry(ssh, image_tag, registry_tag)
|
|
47
|
+
|
|
48
|
+
# Deploy services
|
|
49
|
+
service_deployer = ServiceDeployer.new(@config, ssh, @log)
|
|
50
|
+
|
|
51
|
+
# Gather all env vars using EnvResolver (single source of truth)
|
|
52
|
+
# Use first app service to get full env (includes database vars, deploy_env, etc.)
|
|
53
|
+
first_service = @config.deploy.application.app.keys.first
|
|
54
|
+
all_env = @config.env_for_service(first_service)
|
|
55
|
+
|
|
56
|
+
# Deploy app secret
|
|
57
|
+
service_deployer.deploy_app_secret(all_env)
|
|
58
|
+
|
|
59
|
+
# Deploy database if configured (skip SQLite - handled by app volumes)
|
|
60
|
+
db_config = @config.deploy.application.database
|
|
61
|
+
if db_config && db_config.adapter != "sqlite3"
|
|
62
|
+
db_spec = db_config.to_service_spec(@config.namer)
|
|
63
|
+
service_deployer.deploy_database(db_spec)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deploy additional services
|
|
67
|
+
@config.deploy.application.services.each do |service_name, service_config|
|
|
68
|
+
service_spec = service_config.to_service_spec(@config.deploy.application.name, service_name)
|
|
69
|
+
service_deployer.deploy_service(service_name, service_spec)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Deploy app services
|
|
73
|
+
@config.deploy.application.app.each do |service_name, service_config|
|
|
74
|
+
service_env = @config.env_for_service(service_name)
|
|
75
|
+
service_deployer.deploy_app_service(service_name, service_config, registry_tag, service_env)
|
|
76
|
+
|
|
77
|
+
# Deploy cloudflared for services with tunnels
|
|
78
|
+
tunnel = tunnels.find { |t| t.service_name == service_name }
|
|
79
|
+
if tunnel
|
|
80
|
+
service_deployer.deploy_cloudflared(service_name, tunnel.tunnel_token)
|
|
81
|
+
|
|
82
|
+
# Verify traffic is routing correctly
|
|
83
|
+
service_deployer.verify_traffic_switchover(service_config)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Cleanup old images
|
|
88
|
+
cleaner = Cleaner.new(@config, docker, @log)
|
|
89
|
+
cleaner.cleanup_old_images(timestamp)
|
|
90
|
+
|
|
91
|
+
@log.success "Deployment orchestration complete"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def acquire_lock
|
|
95
|
+
lock_file = @config.namer.deployment_lock_file_path
|
|
96
|
+
|
|
97
|
+
# Check if lock file exists
|
|
98
|
+
output = @ssh.execute("test -f #{lock_file} && cat #{lock_file} || echo ''")
|
|
99
|
+
output = output.strip
|
|
100
|
+
|
|
101
|
+
unless output.empty?
|
|
102
|
+
# Lock exists, check timestamp
|
|
103
|
+
timestamp = output.to_i
|
|
104
|
+
if timestamp > 0
|
|
105
|
+
lock_time = Time.at(timestamp)
|
|
106
|
+
age = Time.now - lock_time
|
|
107
|
+
|
|
108
|
+
if age < Constants::STALE_DEPLOYMENT_LOCK_AGE
|
|
109
|
+
raise DeploymentError.new(
|
|
110
|
+
"lock",
|
|
111
|
+
"deployment already in progress (started #{age.round}s ago). Wait or remove lock file: #{lock_file}"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Lock is stale, will overwrite
|
|
116
|
+
@log.warning "Removing stale deployment lock (age: #{age.round}s)"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Create lock file with current timestamp
|
|
121
|
+
@ssh.execute("echo #{Time.now.to_i} > #{lock_file}")
|
|
122
|
+
@log.info "Deployment lock acquired: %s", lock_file
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def release_lock
|
|
126
|
+
lock_file = @config.namer.deployment_lock_file_path
|
|
127
|
+
@log.info "Releasing deployment lock"
|
|
128
|
+
@ssh.execute("rm -f #{lock_file}")
|
|
129
|
+
rescue StandardError
|
|
130
|
+
# Ignore errors during lock release
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def push_to_registry(ssh, local_tag, registry_tag)
|
|
134
|
+
@log.info "Pushing to in-cluster registry: %s", registry_tag
|
|
135
|
+
|
|
136
|
+
# Tag for registry
|
|
137
|
+
ssh.execute("sudo ctr -n k8s.io images tag #{local_tag} #{registry_tag}")
|
|
138
|
+
|
|
139
|
+
# Push to local registry
|
|
140
|
+
ssh.execute("sudo ctr -n k8s.io images push --plain-http #{registry_tag}")
|
|
141
|
+
|
|
142
|
+
@log.success "Image pushed to registry"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Deployer
|
|
5
|
+
# StepExecutor executes deployment steps with automatic retry on transient failures
|
|
6
|
+
class StepExecutor
|
|
7
|
+
def initialize(max_retries, log)
|
|
8
|
+
@max_retries = max_retries
|
|
9
|
+
@log = log
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Execute runs a step with automatic retry on retryable errors
|
|
13
|
+
def execute(step_name)
|
|
14
|
+
last_error = nil
|
|
15
|
+
|
|
16
|
+
@max_retries.times do |attempt|
|
|
17
|
+
@log.info "Retry attempt %d/%d for: %s", attempt, @max_retries - 1, step_name if attempt > 0
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
yield
|
|
21
|
+
return # Success
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
last_error = e
|
|
24
|
+
|
|
25
|
+
# Check if error is retryable
|
|
26
|
+
unless retryable?(e)
|
|
27
|
+
@log.error "Non-retryable error in %s: %s", step_name, e.message
|
|
28
|
+
raise e
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Log retry with backoff
|
|
32
|
+
if attempt < @max_retries - 1
|
|
33
|
+
backoff_duration = exponential_backoff(attempt)
|
|
34
|
+
@log.warning "Retryable error in %s: %s (backing off %ds)", step_name, e.message, backoff_duration
|
|
35
|
+
sleep(backoff_duration)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise DeploymentError.new(step_name, "max retries (#{@max_retries}) exceeded: #{last_error&.message}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Exponential backoff: attempt 0: 1s, attempt 1: 2s, attempt 2: 4s, etc.
|
|
46
|
+
def exponential_backoff(attempt)
|
|
47
|
+
2**attempt
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if an error is retryable
|
|
51
|
+
def retryable?(error)
|
|
52
|
+
return error.retryable? if error.respond_to?(:retryable?)
|
|
53
|
+
|
|
54
|
+
# Default: network/SSH errors are retryable
|
|
55
|
+
error.is_a?(SSHError) || error.is_a?(NetworkError)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Retry helper module for simple retry scenarios (backwards compatibility)
|
|
60
|
+
module Retry
|
|
61
|
+
def self.with_retry(max_attempts: 3, log: nil)
|
|
62
|
+
executor = StepExecutor.new(max_attempts, log)
|
|
63
|
+
executor.execute("operation") { yield }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Deployer
|
|
5
|
+
# ServiceDeployer handles K8s deployment of application services
|
|
6
|
+
class ServiceDeployer
|
|
7
|
+
DEFAULT_RESOURCES = {
|
|
8
|
+
request_memory: "128Mi",
|
|
9
|
+
request_cpu: "100m",
|
|
10
|
+
limit_memory: "512Mi",
|
|
11
|
+
limit_cpu: "500m"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(config, ssh, log)
|
|
15
|
+
@config = config
|
|
16
|
+
@ssh = ssh
|
|
17
|
+
@log = log
|
|
18
|
+
@namer = config.namer
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Deploy app secret with environment variables
|
|
22
|
+
def deploy_app_secret(env_vars)
|
|
23
|
+
secret_name = @namer.app_secret_name
|
|
24
|
+
|
|
25
|
+
@log.info "Deploying app secret: %s", secret_name
|
|
26
|
+
|
|
27
|
+
K8s::Renderer.apply_manifest(@ssh, "app-secret.yaml", {
|
|
28
|
+
name: secret_name,
|
|
29
|
+
env_vars:
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
@log.success "App secret deployed"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Deploy an app service (web, worker, etc.)
|
|
36
|
+
def deploy_app_service(service_name, service_config, image_tag, env)
|
|
37
|
+
deployment_name = @namer.app_deployment_name(service_name)
|
|
38
|
+
@log.info "Deploying app service: %s", deployment_name
|
|
39
|
+
|
|
40
|
+
# Determine template based on port
|
|
41
|
+
has_port = service_config.port && service_config.port.positive?
|
|
42
|
+
template = has_port ? "app-deployment.yaml" : "worker-deployment.yaml"
|
|
43
|
+
|
|
44
|
+
# Build readiness probe if healthcheck configured
|
|
45
|
+
readiness_probe = nil
|
|
46
|
+
liveness_probe = nil
|
|
47
|
+
|
|
48
|
+
if service_config.healthcheck && has_port
|
|
49
|
+
hc = service_config.healthcheck
|
|
50
|
+
readiness_probe = {
|
|
51
|
+
path: hc.path || "/health",
|
|
52
|
+
port: hc.port || service_config.port,
|
|
53
|
+
initial_delay: 10,
|
|
54
|
+
period: 10,
|
|
55
|
+
timeout: 5,
|
|
56
|
+
failure_threshold: 3
|
|
57
|
+
}
|
|
58
|
+
liveness_probe = readiness_probe.merge(initial_delay: 30)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
data = {
|
|
62
|
+
name: deployment_name,
|
|
63
|
+
image: image_tag,
|
|
64
|
+
replicas: has_port ? 2 : 1,
|
|
65
|
+
port: service_config.port,
|
|
66
|
+
command: service_config.command&.split || [],
|
|
67
|
+
secret_name: @namer.app_secret_name,
|
|
68
|
+
env_keys: env.keys.sort,
|
|
69
|
+
affinity_server_names: service_config.servers,
|
|
70
|
+
resources: DEFAULT_RESOURCES,
|
|
71
|
+
readiness_probe:,
|
|
72
|
+
liveness_probe:,
|
|
73
|
+
volume_mounts: [],
|
|
74
|
+
host_path_volumes: [],
|
|
75
|
+
volumes: []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Add volumes if configured
|
|
79
|
+
service_config.volumes&.each do |vol_key, mount_path|
|
|
80
|
+
host_path = "/opt/nvoi/volumes/#{@namer.app_volume_name(service_name, vol_key)}"
|
|
81
|
+
data[:volume_mounts] << { name: vol_key, mount_path: }
|
|
82
|
+
data[:host_path_volumes] << { name: vol_key, host_path: }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
K8s::Renderer.apply_manifest(@ssh, template, data)
|
|
86
|
+
|
|
87
|
+
# Deploy service if it has a port
|
|
88
|
+
if has_port
|
|
89
|
+
K8s::Renderer.apply_manifest(@ssh, "app-service.yaml", {
|
|
90
|
+
name: deployment_name,
|
|
91
|
+
port: service_config.port
|
|
92
|
+
})
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Deploy ingress if domain is specified
|
|
96
|
+
if service_config.domain && !service_config.domain.empty?
|
|
97
|
+
hostname = if service_config.subdomain && !service_config.subdomain.empty? && service_config.subdomain != "@"
|
|
98
|
+
"#{service_config.subdomain}.#{service_config.domain}"
|
|
99
|
+
else
|
|
100
|
+
service_config.domain
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
K8s::Renderer.apply_manifest(@ssh, "app-ingress.yaml", {
|
|
104
|
+
name: deployment_name,
|
|
105
|
+
domain: hostname,
|
|
106
|
+
port: service_config.port
|
|
107
|
+
})
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Wait for deployment to be ready
|
|
111
|
+
@log.info "Waiting for deployment to be ready..."
|
|
112
|
+
K8s::Renderer.wait_for_deployment(@ssh, deployment_name)
|
|
113
|
+
|
|
114
|
+
# Run pre-run command if specified (e.g., rails db:migrate)
|
|
115
|
+
if service_config.pre_run_command && !service_config.pre_run_command.empty?
|
|
116
|
+
run_pre_run_command(service_name, service_config.pre_run_command)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@log.success "App service deployed: %s", deployment_name
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Deploy database as StatefulSet
|
|
123
|
+
def deploy_database(db_spec)
|
|
124
|
+
@log.info "Deploying database: %s", db_spec.name
|
|
125
|
+
|
|
126
|
+
data = {
|
|
127
|
+
service_name: db_spec.name,
|
|
128
|
+
adapter: @config.deploy.application.database.adapter,
|
|
129
|
+
image: db_spec.image,
|
|
130
|
+
port: db_spec.port,
|
|
131
|
+
secret_name: @namer.database_secret_name,
|
|
132
|
+
secret_keys: db_spec.secrets.keys.sort,
|
|
133
|
+
data_path: "/var/lib/postgresql/data",
|
|
134
|
+
storage_size: "10Gi",
|
|
135
|
+
affinity_server_names: db_spec.servers,
|
|
136
|
+
host_path: nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Use hostPath for database volume if configured
|
|
140
|
+
if @config.deploy.application.database.volume
|
|
141
|
+
data[:host_path] = "/opt/nvoi/volumes/#{@namer.database_volume_name}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Create database secret first
|
|
145
|
+
K8s::Renderer.apply_manifest(@ssh, "app-secret.yaml", {
|
|
146
|
+
name: @namer.database_secret_name,
|
|
147
|
+
env_vars: db_spec.secrets
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
# Deploy StatefulSet
|
|
151
|
+
K8s::Renderer.apply_manifest(@ssh, "db-statefulset.yaml", data)
|
|
152
|
+
|
|
153
|
+
# Wait for database to be ready
|
|
154
|
+
@log.info "Waiting for database to be ready..."
|
|
155
|
+
wait_for_statefulset(db_spec.name)
|
|
156
|
+
|
|
157
|
+
@log.success "Database deployed: %s", db_spec.name
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Deploy additional service (redis, etc.)
|
|
161
|
+
def deploy_service(service_name, service_spec)
|
|
162
|
+
@log.info "Deploying service: %s", service_spec.name
|
|
163
|
+
|
|
164
|
+
host_path = nil
|
|
165
|
+
if service_spec.volumes["data"]
|
|
166
|
+
host_path = "/opt/nvoi/volumes/#{@namer.service_volume_name(service_name, 'data')}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
data = {
|
|
170
|
+
name: service_spec.name,
|
|
171
|
+
image: service_spec.image,
|
|
172
|
+
port: service_spec.port,
|
|
173
|
+
command: service_spec.command,
|
|
174
|
+
env_vars: service_spec.env,
|
|
175
|
+
env_keys: service_spec.env.keys.sort,
|
|
176
|
+
volume_path: service_spec.volumes["data"],
|
|
177
|
+
host_path:,
|
|
178
|
+
affinity_server_names: service_spec.servers
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
K8s::Renderer.apply_manifest(@ssh, "service-deployment.yaml", data)
|
|
182
|
+
|
|
183
|
+
@log.success "Service deployed: %s", service_spec.name
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Deploy cloudflared sidecar
|
|
187
|
+
def deploy_cloudflared(service_name, tunnel_token)
|
|
188
|
+
deployment_name = @namer.cloudflared_deployment_name(service_name)
|
|
189
|
+
@log.info "Deploying cloudflared: %s", deployment_name
|
|
190
|
+
|
|
191
|
+
# Simple cloudflared deployment
|
|
192
|
+
manifest = <<~YAML
|
|
193
|
+
apiVersion: apps/v1
|
|
194
|
+
kind: Deployment
|
|
195
|
+
metadata:
|
|
196
|
+
name: #{deployment_name}
|
|
197
|
+
namespace: default
|
|
198
|
+
spec:
|
|
199
|
+
replicas: 1
|
|
200
|
+
selector:
|
|
201
|
+
matchLabels:
|
|
202
|
+
app: #{deployment_name}
|
|
203
|
+
template:
|
|
204
|
+
metadata:
|
|
205
|
+
labels:
|
|
206
|
+
app: #{deployment_name}
|
|
207
|
+
spec:
|
|
208
|
+
containers:
|
|
209
|
+
- name: cloudflared
|
|
210
|
+
image: cloudflare/cloudflared:latest
|
|
211
|
+
args:
|
|
212
|
+
- tunnel
|
|
213
|
+
- run
|
|
214
|
+
- --token
|
|
215
|
+
- #{tunnel_token}
|
|
216
|
+
YAML
|
|
217
|
+
|
|
218
|
+
@ssh.execute("cat <<'EOF' | kubectl apply -f -\n#{manifest}\nEOF")
|
|
219
|
+
|
|
220
|
+
@log.success "Cloudflared deployed: %s", deployment_name
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Verify traffic is routing to the new deployment via public URL
|
|
224
|
+
def verify_traffic_switchover(service_config)
|
|
225
|
+
return unless service_config.domain && !service_config.domain.empty?
|
|
226
|
+
|
|
227
|
+
hostname = if service_config.subdomain && !service_config.subdomain.empty? && service_config.subdomain != "@"
|
|
228
|
+
"#{service_config.subdomain}.#{service_config.domain}"
|
|
229
|
+
else
|
|
230
|
+
service_config.domain
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
health_path = service_config.healthcheck&.path || "/"
|
|
234
|
+
public_url = "https://#{hostname}#{health_path}"
|
|
235
|
+
|
|
236
|
+
@log.info "Verifying public traffic routing"
|
|
237
|
+
@log.info "Testing: %s", public_url
|
|
238
|
+
|
|
239
|
+
consecutive_success = 0
|
|
240
|
+
required_consecutive = Constants::TRAFFIC_VERIFY_CONSECUTIVE
|
|
241
|
+
max_attempts = Constants::TRAFFIC_VERIFY_ATTEMPTS
|
|
242
|
+
|
|
243
|
+
max_attempts.times do |attempt|
|
|
244
|
+
curl_cmd = "curl -s -o /dev/null -w '%{http_code}' -m 10 '#{public_url}' 2>/dev/null"
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
http_code = @ssh.execute(curl_cmd).strip
|
|
248
|
+
|
|
249
|
+
if http_code == "200"
|
|
250
|
+
consecutive_success += 1
|
|
251
|
+
@log.success "[%d/%d] Public URL responding: %s", consecutive_success, required_consecutive, http_code
|
|
252
|
+
|
|
253
|
+
if consecutive_success >= required_consecutive
|
|
254
|
+
@log.success "Traffic switchover verified: public URL accessible"
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
else
|
|
258
|
+
if consecutive_success > 0
|
|
259
|
+
@log.warning "Success streak broken at %d, restarting count", consecutive_success
|
|
260
|
+
end
|
|
261
|
+
consecutive_success = 0
|
|
262
|
+
@log.info "[%d/%d] Public URL check: %s (expected: 200)", attempt + 1, max_attempts, http_code
|
|
263
|
+
end
|
|
264
|
+
rescue SSHCommandError
|
|
265
|
+
consecutive_success = 0
|
|
266
|
+
@log.info "[%d/%d] Public URL check failed", attempt + 1, max_attempts
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
sleep(Constants::TRAFFIC_VERIFY_INTERVAL)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
raise DeploymentError.new(
|
|
273
|
+
"traffic_verification",
|
|
274
|
+
"public URL verification failed after #{max_attempts} attempts. Cloudflare tunnel may not be routing correctly."
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
def run_pre_run_command(service_name, command)
|
|
281
|
+
@log.info "Running pre-run command: %s", command
|
|
282
|
+
|
|
283
|
+
# Get pod name
|
|
284
|
+
pod_label = @namer.app_pod_label(service_name)
|
|
285
|
+
pod_name = @ssh.execute("kubectl get pod -l #{pod_label} -o jsonpath='{.items[0].metadata.name}'")
|
|
286
|
+
pod_name = pod_name.strip.delete("'")
|
|
287
|
+
|
|
288
|
+
# Execute command in pod
|
|
289
|
+
escaped_command = command.gsub("'", "'\"'\"'")
|
|
290
|
+
exec_cmd = "kubectl exec #{pod_name} -- sh -c '#{escaped_command}'"
|
|
291
|
+
|
|
292
|
+
begin
|
|
293
|
+
output = @ssh.execute(exec_cmd)
|
|
294
|
+
@log.info "Pre-run command output:\n%s", output unless output.empty?
|
|
295
|
+
rescue SSHCommandError => e
|
|
296
|
+
@log.error "Pre-run command failed: %s", e.message
|
|
297
|
+
|
|
298
|
+
# Get pod logs for debugging
|
|
299
|
+
logs = @ssh.execute("kubectl logs #{pod_name} --tail=50")
|
|
300
|
+
@log.error "Pod logs:\n%s", logs
|
|
301
|
+
|
|
302
|
+
raise DeploymentError.new("pre_run_command", "deployment aborted: pre-run command failed: #{e.message}")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def wait_for_statefulset(name, namespace: "default", timeout: 300)
|
|
307
|
+
@ssh.execute("kubectl rollout status statefulset/#{name} -n #{namespace} --timeout=#{timeout}s")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Deployer
|
|
5
|
+
# TunnelManager handles Cloudflare tunnel operations
|
|
6
|
+
class TunnelManager
|
|
7
|
+
def initialize(cf_client, log)
|
|
8
|
+
@cf_client = cf_client
|
|
9
|
+
@log = log
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Create or get existing tunnel, configure it, and create DNS record
|
|
13
|
+
def setup_tunnel(tunnel_name, hostname, service_url, domain)
|
|
14
|
+
@log.info "Setting up tunnel: %s -> %s", tunnel_name, hostname
|
|
15
|
+
|
|
16
|
+
# Find or create tunnel
|
|
17
|
+
tunnel = @cf_client.find_tunnel(tunnel_name)
|
|
18
|
+
|
|
19
|
+
if tunnel
|
|
20
|
+
@log.info "Using existing tunnel: %s", tunnel_name
|
|
21
|
+
else
|
|
22
|
+
@log.info "Creating new tunnel: %s", tunnel_name
|
|
23
|
+
tunnel = @cf_client.create_tunnel(tunnel_name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get tunnel token
|
|
27
|
+
token = tunnel.token
|
|
28
|
+
if token.nil? || token.empty?
|
|
29
|
+
token = @cf_client.get_tunnel_token(tunnel.id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Configure tunnel ingress
|
|
33
|
+
@log.info "Configuring tunnel ingress: %s -> %s", hostname, service_url
|
|
34
|
+
@cf_client.update_tunnel_configuration(tunnel.id, hostname, service_url)
|
|
35
|
+
|
|
36
|
+
# Verify configuration propagated
|
|
37
|
+
@log.info "Verifying tunnel configuration..."
|
|
38
|
+
@cf_client.verify_tunnel_configuration(tunnel.id, hostname, service_url, Constants::TUNNEL_CONFIG_VERIFY_ATTEMPTS)
|
|
39
|
+
|
|
40
|
+
# Create DNS record
|
|
41
|
+
@log.info "Creating DNS CNAME record: %s", hostname
|
|
42
|
+
zone = @cf_client.find_zone(domain)
|
|
43
|
+
raise CloudflareError, "zone not found: #{domain}" unless zone
|
|
44
|
+
|
|
45
|
+
tunnel_cname = "#{tunnel.id}.cfargotunnel.com"
|
|
46
|
+
@cf_client.create_or_update_dns_record(zone.id, hostname, "CNAME", tunnel_cname, proxied: true)
|
|
47
|
+
|
|
48
|
+
@log.success "Tunnel configured: %s", tunnel_name
|
|
49
|
+
|
|
50
|
+
TunnelInfo.new(
|
|
51
|
+
tunnel_id: tunnel.id,
|
|
52
|
+
tunnel_token: token
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/nvoi/errors.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
# Base error class for all Nvoi errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :details
|
|
7
|
+
|
|
8
|
+
def initialize(message, details: nil)
|
|
9
|
+
@details = details
|
|
10
|
+
super(message)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Configuration errors
|
|
15
|
+
class ConfigError < Error; end
|
|
16
|
+
class ConfigNotFoundError < ConfigError; end
|
|
17
|
+
class ConfigValidationError < ConfigError; end
|
|
18
|
+
|
|
19
|
+
# Credential errors
|
|
20
|
+
class CredentialError < Error; end
|
|
21
|
+
class DecryptionError < CredentialError; end
|
|
22
|
+
class EncryptionError < CredentialError; end
|
|
23
|
+
class InvalidKeyError < CredentialError; end
|
|
24
|
+
|
|
25
|
+
# Provider errors
|
|
26
|
+
class ProviderError < Error; end
|
|
27
|
+
class ServerCreationError < ProviderError; end
|
|
28
|
+
class NetworkError < ProviderError; end
|
|
29
|
+
class FirewallError < ProviderError; end
|
|
30
|
+
class VolumeError < ProviderError; end
|
|
31
|
+
class ValidationError < ProviderError; end
|
|
32
|
+
class APIError < ProviderError; end
|
|
33
|
+
class AuthenticationError < ProviderError; end
|
|
34
|
+
class NotFoundError < ProviderError; end
|
|
35
|
+
|
|
36
|
+
# Cloudflare errors
|
|
37
|
+
class CloudflareError < Error; end
|
|
38
|
+
class TunnelError < CloudflareError; end
|
|
39
|
+
class DNSError < CloudflareError; end
|
|
40
|
+
|
|
41
|
+
# SSH errors
|
|
42
|
+
class SSHError < Error; end
|
|
43
|
+
class SSHConnectionError < SSHError; end
|
|
44
|
+
class SSHCommandError < SSHError; end
|
|
45
|
+
|
|
46
|
+
# Deployment errors
|
|
47
|
+
class DeploymentError < Error
|
|
48
|
+
attr_reader :step, :retryable
|
|
49
|
+
|
|
50
|
+
def initialize(step, message, retryable: false, details: nil)
|
|
51
|
+
@step = step
|
|
52
|
+
@retryable = retryable
|
|
53
|
+
super("#{step}: #{message}", details:)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def retryable?
|
|
57
|
+
@retryable
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# K8s errors
|
|
62
|
+
class K8sError < Error; end
|
|
63
|
+
class TemplateError < K8sError; end
|
|
64
|
+
|
|
65
|
+
# Service errors
|
|
66
|
+
class ServiceError < Error; end
|
|
67
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module K8s
|
|
5
|
+
# TemplateBinding provides a clean binding for ERB templates
|
|
6
|
+
class TemplateBinding
|
|
7
|
+
def initialize(data)
|
|
8
|
+
data.each do |key, value|
|
|
9
|
+
instance_variable_set("@#{key}", value)
|
|
10
|
+
define_singleton_method(key) { instance_variable_get("@#{key}") }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get_binding
|
|
15
|
+
binding
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Renderer handles K8s manifest rendering and application
|
|
20
|
+
module Renderer
|
|
21
|
+
class << self
|
|
22
|
+
# Render a template with the provided data
|
|
23
|
+
def render_template(name, data)
|
|
24
|
+
template = Templates.load_template(name)
|
|
25
|
+
binding_obj = TemplateBinding.new(data)
|
|
26
|
+
template.result(binding_obj.get_binding)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Render a template and apply it via kubectl
|
|
30
|
+
def apply_manifest(ssh, template_name, data)
|
|
31
|
+
manifest = render_template(template_name, data)
|
|
32
|
+
|
|
33
|
+
cmd = "cat <<'EOF' | kubectl apply -f -\n#{manifest}\nEOF"
|
|
34
|
+
ssh.execute(cmd)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Wait for a deployment to be ready
|
|
38
|
+
def wait_for_deployment(ssh, name, namespace: "default", timeout: 300)
|
|
39
|
+
ssh.execute("kubectl rollout status deployment/#{name} -n #{namespace} --timeout=#{timeout}s")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|