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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +19 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +151 -0
  5. data/Makefile +26 -0
  6. data/Rakefile +16 -0
  7. data/doc/config-schema.yaml +357 -0
  8. data/examples/apex-wildcard/deploy.yml +68 -0
  9. data/examples/golang/.gitignore +19 -0
  10. data/examples/golang/Dockerfile +43 -0
  11. data/examples/golang/README.md +59 -0
  12. data/examples/golang/deploy.enc +0 -0
  13. data/examples/golang/deploy.yml +54 -0
  14. data/examples/golang/go.mod +39 -0
  15. data/examples/golang/go.sum +96 -0
  16. data/examples/golang/main.go +177 -0
  17. data/examples/golang/models/user.go +17 -0
  18. data/examples/golang-postgres-multi/.gitignore +18 -0
  19. data/examples/golang-postgres-multi/Dockerfile +39 -0
  20. data/examples/golang-postgres-multi/README.md +211 -0
  21. data/examples/golang-postgres-multi/deploy.yml +67 -0
  22. data/examples/golang-postgres-multi/go.mod +45 -0
  23. data/examples/golang-postgres-multi/go.sum +108 -0
  24. data/examples/golang-postgres-multi/main.go +197 -0
  25. data/examples/golang-postgres-multi/models/user.go +17 -0
  26. data/examples/postgres-multi/.env.production.example +11 -0
  27. data/examples/postgres-multi/README.md +112 -0
  28. data/examples/postgres-multi/deploy.yml +74 -0
  29. data/examples/postgres-single/.env.production.example +11 -0
  30. data/examples/postgres-single/.gitignore +15 -0
  31. data/examples/postgres-single/Dockerfile +35 -0
  32. data/examples/postgres-single/README.md +76 -0
  33. data/examples/postgres-single/deploy.yml +56 -0
  34. data/examples/postgres-single/go.mod +45 -0
  35. data/examples/postgres-single/go.sum +108 -0
  36. data/examples/postgres-single/main.go +184 -0
  37. data/examples/rails-single/.dockerignore +51 -0
  38. data/examples/rails-single/.env.production.example +11 -0
  39. data/examples/rails-single/.github/dependabot.yml +12 -0
  40. data/examples/rails-single/.github/workflows/ci.yml +39 -0
  41. data/examples/rails-single/.gitignore +20 -0
  42. data/examples/rails-single/.node-version +1 -0
  43. data/examples/rails-single/.rubocop.yml +8 -0
  44. data/examples/rails-single/.ruby-version +1 -0
  45. data/examples/rails-single/Dockerfile +86 -0
  46. data/examples/rails-single/Gemfile +56 -0
  47. data/examples/rails-single/Gemfile.lock +350 -0
  48. data/examples/rails-single/Procfile.dev +3 -0
  49. data/examples/rails-single/README.md +17 -0
  50. data/examples/rails-single/Rakefile +6 -0
  51. data/examples/rails-single/app/assets/builds/.keep +0 -0
  52. data/examples/rails-single/app/assets/images/.keep +0 -0
  53. data/examples/rails-single/app/assets/stylesheets/application.tailwind.css +1 -0
  54. data/examples/rails-single/app/controllers/application_controller.rb +4 -0
  55. data/examples/rails-single/app/controllers/concerns/.keep +0 -0
  56. data/examples/rails-single/app/controllers/users_controller.rb +19 -0
  57. data/examples/rails-single/app/helpers/application_helper.rb +2 -0
  58. data/examples/rails-single/app/javascript/application.js +3 -0
  59. data/examples/rails-single/app/javascript/controllers/application.js +9 -0
  60. data/examples/rails-single/app/javascript/controllers/hello_controller.js +7 -0
  61. data/examples/rails-single/app/javascript/controllers/index.js +8 -0
  62. data/examples/rails-single/app/jobs/application_job.rb +7 -0
  63. data/examples/rails-single/app/mailers/application_mailer.rb +4 -0
  64. data/examples/rails-single/app/models/application_record.rb +3 -0
  65. data/examples/rails-single/app/models/concerns/.keep +0 -0
  66. data/examples/rails-single/app/models/user.rb +2 -0
  67. data/examples/rails-single/app/views/layouts/application.html.erb +28 -0
  68. data/examples/rails-single/app/views/layouts/mailer.html.erb +13 -0
  69. data/examples/rails-single/app/views/layouts/mailer.text.erb +1 -0
  70. data/examples/rails-single/app/views/pwa/manifest.json.erb +22 -0
  71. data/examples/rails-single/app/views/pwa/service-worker.js +26 -0
  72. data/examples/rails-single/app/views/users/index.html.erb +38 -0
  73. data/examples/rails-single/bin/brakeman +7 -0
  74. data/examples/rails-single/bin/bundle +109 -0
  75. data/examples/rails-single/bin/dev +11 -0
  76. data/examples/rails-single/bin/docker-entrypoint +14 -0
  77. data/examples/rails-single/bin/jobs +6 -0
  78. data/examples/rails-single/bin/kamal +27 -0
  79. data/examples/rails-single/bin/rails +4 -0
  80. data/examples/rails-single/bin/rake +4 -0
  81. data/examples/rails-single/bin/rubocop +8 -0
  82. data/examples/rails-single/bin/setup +37 -0
  83. data/examples/rails-single/bin/thrust +5 -0
  84. data/examples/rails-single/bun.lock +224 -0
  85. data/examples/rails-single/config/application.rb +42 -0
  86. data/examples/rails-single/config/boot.rb +4 -0
  87. data/examples/rails-single/config/cable.yml +17 -0
  88. data/examples/rails-single/config/cache.yml +16 -0
  89. data/examples/rails-single/config/credentials.yml.enc +1 -0
  90. data/examples/rails-single/config/database.yml +100 -0
  91. data/examples/rails-single/config/environment.rb +5 -0
  92. data/examples/rails-single/config/environments/development.rb +69 -0
  93. data/examples/rails-single/config/environments/production.rb +87 -0
  94. data/examples/rails-single/config/environments/test.rb +50 -0
  95. data/examples/rails-single/config/initializers/assets.rb +7 -0
  96. data/examples/rails-single/config/initializers/content_security_policy.rb +25 -0
  97. data/examples/rails-single/config/initializers/filter_parameter_logging.rb +8 -0
  98. data/examples/rails-single/config/initializers/inflections.rb +16 -0
  99. data/examples/rails-single/config/locales/en.yml +31 -0
  100. data/examples/rails-single/config/puma.rb +41 -0
  101. data/examples/rails-single/config/queue.yml +18 -0
  102. data/examples/rails-single/config/recurring.yml +15 -0
  103. data/examples/rails-single/config/routes.rb +4 -0
  104. data/examples/rails-single/config.ru +6 -0
  105. data/examples/rails-single/db/cable_schema.rb +11 -0
  106. data/examples/rails-single/db/cache_schema.rb +12 -0
  107. data/examples/rails-single/db/migrate/20251123095526_create_users.rb +10 -0
  108. data/examples/rails-single/db/queue_schema.rb +129 -0
  109. data/examples/rails-single/db/seeds.rb +9 -0
  110. data/examples/rails-single/deploy.yml +57 -0
  111. data/examples/rails-single/lib/tasks/.keep +0 -0
  112. data/examples/rails-single/log/.keep +0 -0
  113. data/examples/rails-single/package.json +17 -0
  114. data/examples/rails-single/public/400.html +114 -0
  115. data/examples/rails-single/public/404.html +114 -0
  116. data/examples/rails-single/public/406-unsupported-browser.html +114 -0
  117. data/examples/rails-single/public/422.html +114 -0
  118. data/examples/rails-single/public/500.html +114 -0
  119. data/examples/rails-single/public/icon.png +0 -0
  120. data/examples/rails-single/public/icon.svg +3 -0
  121. data/examples/rails-single/public/robots.txt +1 -0
  122. data/examples/rails-single/script/.keep +0 -0
  123. data/examples/rails-single/vendor/.keep +0 -0
  124. data/examples/rails-single/yarn.lock +188 -0
  125. data/exe/nvoi +6 -0
  126. data/lib/nvoi/cli.rb +190 -0
  127. data/lib/nvoi/cloudflare/client.rb +287 -0
  128. data/lib/nvoi/config/config.rb +248 -0
  129. data/lib/nvoi/config/env_resolver.rb +63 -0
  130. data/lib/nvoi/config/loader.rb +102 -0
  131. data/lib/nvoi/config/naming.rb +196 -0
  132. data/lib/nvoi/config/ssh_keys.rb +82 -0
  133. data/lib/nvoi/config/types.rb +274 -0
  134. data/lib/nvoi/constants.rb +59 -0
  135. data/lib/nvoi/credentials/crypto.rb +88 -0
  136. data/lib/nvoi/credentials/editor.rb +272 -0
  137. data/lib/nvoi/credentials/manager.rb +173 -0
  138. data/lib/nvoi/deployer/cleaner.rb +36 -0
  139. data/lib/nvoi/deployer/image_builder.rb +23 -0
  140. data/lib/nvoi/deployer/infrastructure.rb +126 -0
  141. data/lib/nvoi/deployer/orchestrator.rb +146 -0
  142. data/lib/nvoi/deployer/retry.rb +67 -0
  143. data/lib/nvoi/deployer/service_deployer.rb +311 -0
  144. data/lib/nvoi/deployer/tunnel_manager.rb +57 -0
  145. data/lib/nvoi/deployer/types.rb +8 -0
  146. data/lib/nvoi/errors.rb +67 -0
  147. data/lib/nvoi/k8s/renderer.rb +44 -0
  148. data/lib/nvoi/k8s/templates.rb +29 -0
  149. data/lib/nvoi/logger.rb +72 -0
  150. data/lib/nvoi/providers/aws.rb +403 -0
  151. data/lib/nvoi/providers/base.rb +111 -0
  152. data/lib/nvoi/providers/hetzner.rb +288 -0
  153. data/lib/nvoi/providers/hetzner_client.rb +170 -0
  154. data/lib/nvoi/remote/docker_manager.rb +203 -0
  155. data/lib/nvoi/remote/ssh_executor.rb +72 -0
  156. data/lib/nvoi/remote/volume_manager.rb +103 -0
  157. data/lib/nvoi/service/delete.rb +234 -0
  158. data/lib/nvoi/service/deploy.rb +80 -0
  159. data/lib/nvoi/service/exec.rb +144 -0
  160. data/lib/nvoi/service/provider.rb +36 -0
  161. data/lib/nvoi/steps/application_deployer.rb +26 -0
  162. data/lib/nvoi/steps/database_provisioner.rb +60 -0
  163. data/lib/nvoi/steps/k3s_cluster_setup.rb +105 -0
  164. data/lib/nvoi/steps/k3s_provisioner.rb +351 -0
  165. data/lib/nvoi/steps/server_provisioner.rb +43 -0
  166. data/lib/nvoi/steps/services_provisioner.rb +29 -0
  167. data/lib/nvoi/steps/tunnel_configurator.rb +66 -0
  168. data/lib/nvoi/steps/volume_provisioner.rb +154 -0
  169. data/lib/nvoi/version.rb +5 -0
  170. data/lib/nvoi.rb +79 -0
  171. data/templates/app-deployment.yaml.erb +102 -0
  172. data/templates/app-ingress.yaml.erb +20 -0
  173. data/templates/app-secret.yaml.erb +10 -0
  174. data/templates/app-service.yaml.erb +12 -0
  175. data/templates/db-statefulset.yaml.erb +76 -0
  176. data/templates/service-deployment.yaml.erb +91 -0
  177. data/templates/worker-deployment.yaml.erb +50 -0
  178. 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Deployer
5
+ # TunnelInfo holds information about a configured tunnel
6
+ TunnelInfo = Struct.new(:service_name, :hostname, :tunnel_id, :tunnel_token, :port, keyword_init: true)
7
+ end
8
+ end
@@ -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