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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Nvoi
6
+ module Config
7
+ # ResourceNamer handles resource naming and inference
8
+ class ResourceNamer
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Generate a container name prefix
14
+ def infer_container_prefix
15
+ base = infer_base_prefix
16
+ name = @config.deploy.application.name
17
+
18
+ prefix = name ? "#{base}-#{name}" : base
19
+
20
+ # Truncate to 63 chars (DNS limit) with hash for uniqueness
21
+ if prefix.length > 63
22
+ hash = hash_string(prefix)[0, 8]
23
+ max_len = 63 - hash.length - 1
24
+ prefix = "#{prefix[0, max_len]}-#{hash}"
25
+ end
26
+
27
+ prefix
28
+ end
29
+
30
+ # ============================================================================
31
+ # INFRASTRUCTURE RESOURCES
32
+ # ============================================================================
33
+
34
+ # ServerName returns the server name for a given group and index
35
+ def server_name(group, index)
36
+ "#{@config.deploy.application.name}-#{group}-#{index}"
37
+ end
38
+
39
+ def firewall_name
40
+ "#{@config.container_prefix}-firewall"
41
+ end
42
+
43
+ def network_name
44
+ "#{@config.container_prefix}-network"
45
+ end
46
+
47
+ def docker_network_name
48
+ "#{@config.container_prefix}-docker-network"
49
+ end
50
+
51
+ # ============================================================================
52
+ # DATABASE RESOURCES
53
+ # ============================================================================
54
+
55
+ def database_service_name
56
+ "db-#{@config.deploy.application.name}"
57
+ end
58
+
59
+ def database_stateful_set_name
60
+ "db-#{@config.deploy.application.name}"
61
+ end
62
+
63
+ def database_pvc_name
64
+ "data-db-#{@config.deploy.application.name}-0"
65
+ end
66
+
67
+ def database_secret_name
68
+ "db-secret-#{@config.deploy.application.name}"
69
+ end
70
+
71
+ def database_pod_label
72
+ "app=db-#{@config.deploy.application.name}"
73
+ end
74
+
75
+ # ============================================================================
76
+ # KUBERNETES APP RESOURCES
77
+ # ============================================================================
78
+
79
+ def app_deployment_name(service_name)
80
+ "#{@config.deploy.application.name}-#{service_name}"
81
+ end
82
+
83
+ def app_service_name(service_name)
84
+ "#{@config.deploy.application.name}-#{service_name}"
85
+ end
86
+
87
+ def app_secret_name
88
+ "app-secret-#{@config.deploy.application.name}"
89
+ end
90
+
91
+ def app_pvc_name(volume_name)
92
+ "#{@config.deploy.application.name}-#{volume_name}"
93
+ end
94
+
95
+ def app_ingress_name(service_name)
96
+ "#{@config.deploy.application.name}-#{service_name}"
97
+ end
98
+
99
+ def app_pod_label(service_name)
100
+ deployment_name = app_deployment_name(service_name)
101
+ "app=#{deployment_name}"
102
+ end
103
+
104
+ def service_container_prefix(service_name)
105
+ "#{@config.container_prefix}-#{service_name}-"
106
+ end
107
+
108
+ # ============================================================================
109
+ # CLOUDFLARE RESOURCES
110
+ # ============================================================================
111
+
112
+ def tunnel_name(service_name)
113
+ "#{@config.container_prefix}-#{service_name}"
114
+ end
115
+
116
+ def cloudflared_deployment_name(service_name)
117
+ "cloudflared-#{service_name}"
118
+ end
119
+
120
+ # ============================================================================
121
+ # REGISTRY RESOURCES
122
+ # ============================================================================
123
+
124
+ def registry_deployment_name
125
+ "nvoi-registry"
126
+ end
127
+
128
+ def registry_service_name
129
+ "nvoi-registry"
130
+ end
131
+
132
+ # ============================================================================
133
+ # DEPLOYMENT RESOURCES
134
+ # ============================================================================
135
+
136
+ def deployment_lock_file_path
137
+ "/tmp/nvoi-deploy-#{@config.container_prefix}.lock"
138
+ end
139
+
140
+ # ============================================================================
141
+ # DOCKER IMAGE RESOURCES
142
+ # ============================================================================
143
+
144
+ def image_tag(timestamp)
145
+ "#{@config.container_prefix}:#{timestamp}"
146
+ end
147
+
148
+ def latest_image_tag
149
+ "#{@config.container_prefix}:latest"
150
+ end
151
+
152
+ # ============================================================================
153
+ # VOLUME RESOURCES
154
+ # ============================================================================
155
+
156
+ def volume_name(service_type, service_name, volume_key)
157
+ "#{@config.deploy.application.name}-#{service_type}-#{service_name}-#{volume_key}"
158
+ end
159
+
160
+ def database_volume_name
161
+ "#{@config.deploy.application.name}-db-data"
162
+ end
163
+
164
+ def service_volume_name(service_name, volume_key)
165
+ "#{@config.deploy.application.name}-svc-#{service_name}-#{volume_key}"
166
+ end
167
+
168
+ def app_volume_name(service_name, volume_key)
169
+ "#{@config.deploy.application.name}-app-#{service_name}-#{volume_key}"
170
+ end
171
+
172
+ private
173
+
174
+ def hash_string(str)
175
+ Digest::SHA256.hexdigest(str)[0, 16]
176
+ end
177
+
178
+ def infer_base_prefix
179
+ output = `git config --get remote.origin.url 2>/dev/null`.strip
180
+ return "app" if output.empty?
181
+
182
+ # Extract username/repo from: git@github.com:user/repo.git or https://github.com/user/repo.git
183
+ repo_url = output.sub(/\.git$/, "")
184
+ parts = repo_url.split(%r{[/:]+})
185
+
186
+ if parts.length >= 2
187
+ username = parts[-2]
188
+ repo = parts[-1]
189
+ "#{username}-#{repo}"
190
+ else
191
+ "app"
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "fileutils"
5
+
6
+ module Nvoi
7
+ module Config
8
+ # SSHKeyLoader handles SSH key loading from config content
9
+ # Keys are stored as content in deploy.enc, written to temp files for SSH usage
10
+ class SSHKeyLoader
11
+ def initialize(config)
12
+ @config = config
13
+ @temp_dir = nil
14
+ @private_key_path = nil
15
+ @public_key_path = nil
16
+ end
17
+
18
+ # Load SSH keys from config content and write to temp files
19
+ def load_keys
20
+ ssh_keys = @config.deploy.application.ssh_keys
21
+
22
+ unless ssh_keys
23
+ raise ConfigError, "ssh_keys section is required in config. Run 'nvoi credentials edit' to generate keys."
24
+ end
25
+
26
+ unless ssh_keys.private_key && !ssh_keys.private_key.empty?
27
+ raise ConfigError, "ssh_keys.private_key is required"
28
+ end
29
+
30
+ unless ssh_keys.public_key && !ssh_keys.public_key.empty?
31
+ raise ConfigError, "ssh_keys.public_key is required"
32
+ end
33
+
34
+ # Create temp directory for keys
35
+ @temp_dir = Dir.mktmpdir("nvoi-ssh-")
36
+
37
+ # Write private key
38
+ @private_key_path = File.join(@temp_dir, "id_nvoi")
39
+ File.write(@private_key_path, ssh_keys.private_key)
40
+ File.chmod(0o600, @private_key_path)
41
+
42
+ # Write public key
43
+ @public_key_path = File.join(@temp_dir, "id_nvoi.pub")
44
+ File.write(@public_key_path, ssh_keys.public_key)
45
+ File.chmod(0o644, @public_key_path)
46
+
47
+ # Set config values
48
+ @config.ssh_key_path = @private_key_path
49
+ @config.ssh_public_key = ssh_keys.public_key.strip
50
+ end
51
+
52
+ # Cleanup temp files
53
+ def cleanup
54
+ FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
55
+ end
56
+
57
+ class << self
58
+ # Generate a new Ed25519 keypair using ssh-keygen
59
+ def generate_keypair
60
+ temp_dir = Dir.mktmpdir("nvoi-keygen-")
61
+ key_path = File.join(temp_dir, "id_nvoi")
62
+
63
+ begin
64
+ result = system(
65
+ "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "nvoi-deploy", "-f", key_path,
66
+ out: File::NULL, err: File::NULL
67
+ )
68
+
69
+ raise ConfigError, "Failed to generate SSH keypair (ssh-keygen not available?)" unless result
70
+
71
+ private_key = File.read(key_path)
72
+ public_key = File.read("#{key_path}.pub").strip
73
+
74
+ [private_key, public_key]
75
+ ensure
76
+ FileUtils.rm_rf(temp_dir)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Config
5
+ # DeployConfig represents the root deployment configuration
6
+ class DeployConfig
7
+ attr_accessor :application
8
+
9
+ def initialize(data = {})
10
+ @application = Application.new(data["application"] || {})
11
+ end
12
+ end
13
+
14
+ # Application contains application-level configuration
15
+ class Application
16
+ attr_accessor :name, :environment, :domain_provider, :compute_provider,
17
+ :keep_count, :servers, :app, :database, :services, :env,
18
+ :secrets, :ssh_keys
19
+
20
+ def initialize(data = {})
21
+ @name = data["name"]
22
+ @environment = data["environment"] || "production"
23
+ @domain_provider = DomainProviderConfig.new(data["domain_provider"] || {})
24
+ @compute_provider = ComputeProviderConfig.new(data["compute_provider"] || {})
25
+ @keep_count = data["keep_count"]&.to_i
26
+ @servers = parse_servers(data["servers"] || {})
27
+ @app = parse_app_config(data["app"] || {})
28
+ @database = data["database"] ? DatabaseConfig.new(data["database"]) : nil
29
+ @services = parse_services(data["services"] || {})
30
+ @env = data["env"] || {}
31
+ @secrets = data["secrets"] || {}
32
+ @ssh_keys = data["ssh_keys"] ? SSHKeyConfig.new(data["ssh_keys"]) : nil
33
+ end
34
+
35
+ private
36
+
37
+ def parse_servers(data)
38
+ data.transform_values { |v| ServerConfig.new(v || {}) }
39
+ end
40
+
41
+ def parse_app_config(data)
42
+ data.transform_values { |v| AppServiceConfig.new(v || {}) }
43
+ end
44
+
45
+ def parse_services(data)
46
+ data.transform_values { |v| ServiceConfig.new(v || {}) }
47
+ end
48
+ end
49
+
50
+ # DomainProviderConfig contains domain provider configuration
51
+ class DomainProviderConfig
52
+ attr_accessor :cloudflare
53
+
54
+ def initialize(data = {})
55
+ @cloudflare = data["cloudflare"] ? CloudflareConfig.new(data["cloudflare"]) : nil
56
+ end
57
+ end
58
+
59
+ # ComputeProviderConfig contains compute provider configuration
60
+ class ComputeProviderConfig
61
+ attr_accessor :hetzner, :aws
62
+
63
+ def initialize(data = {})
64
+ @hetzner = data["hetzner"] ? HetznerConfig.new(data["hetzner"]) : nil
65
+ @aws = data["aws"] ? AWSConfig.new(data["aws"]) : nil
66
+ end
67
+ end
68
+
69
+ # CloudflareConfig contains Cloudflare-specific configuration
70
+ class CloudflareConfig
71
+ attr_accessor :api_token, :account_id
72
+
73
+ def initialize(data = {})
74
+ @api_token = data["api_token"]
75
+ @account_id = data["account_id"]
76
+ end
77
+ end
78
+
79
+ # HetznerConfig contains Hetzner-specific configuration
80
+ class HetznerConfig
81
+ attr_accessor :api_token, :server_type, :server_location
82
+
83
+ def initialize(data = {})
84
+ @api_token = data["api_token"]
85
+ @server_type = data["server_type"]
86
+ @server_location = data["server_location"]
87
+ end
88
+ end
89
+
90
+ # AWSConfig contains AWS-specific configuration
91
+ class AWSConfig
92
+ attr_accessor :access_key_id, :secret_access_key, :region, :instance_type
93
+
94
+ def initialize(data = {})
95
+ @access_key_id = data["access_key_id"]
96
+ @secret_access_key = data["secret_access_key"]
97
+ @region = data["region"]
98
+ @instance_type = data["instance_type"]
99
+ end
100
+ end
101
+
102
+ # ServerConfig contains server instance configuration
103
+ class ServerConfig
104
+ attr_accessor :master, :type, :location, :count
105
+
106
+ def initialize(data = {})
107
+ @master = data["master"] || false
108
+ @type = data["type"]
109
+ @location = data["location"]
110
+ @count = data["count"]&.to_i || 1
111
+ end
112
+ end
113
+
114
+ # AppServiceConfig defines a service in the app section
115
+ class AppServiceConfig
116
+ attr_accessor :servers, :domain, :subdomain, :port, :healthcheck,
117
+ :command, :pre_run_command, :env, :volumes
118
+
119
+ def initialize(data = {})
120
+ @servers = data["servers"] || []
121
+ @domain = data["domain"]
122
+ @subdomain = data["subdomain"]
123
+ @port = data["port"]&.to_i
124
+ @healthcheck = data["healthcheck"] ? HealthCheckConfig.new(data["healthcheck"]) : nil
125
+ @command = data["command"]
126
+ @pre_run_command = data["pre_run_command"]
127
+ @env = data["env"] || {}
128
+ @volumes = data["volumes"] || {}
129
+ end
130
+
131
+ # Convert to ServiceSpec
132
+ def to_service_spec(app_name, service_name, image_tag)
133
+ cmd = @command ? @command.split : []
134
+
135
+ spec = ServiceSpec.new(
136
+ name: "#{app_name}-#{service_name}",
137
+ image: image_tag,
138
+ port: @port,
139
+ command: cmd,
140
+ env: @env,
141
+ volumes: @volumes,
142
+ replicas: @port.nil? || @port.zero? ? 1 : 2,
143
+ healthcheck: @healthcheck,
144
+ stateful_set: false,
145
+ servers: @servers
146
+ )
147
+ spec
148
+ end
149
+ end
150
+
151
+ # HealthCheckConfig defines health check configuration
152
+ class HealthCheckConfig
153
+ attr_accessor :type, :path, :port, :command, :interval, :timeout, :retries
154
+
155
+ def initialize(data = {})
156
+ @type = data["type"]
157
+ @path = data["path"]
158
+ @port = data["port"]&.to_i
159
+ @command = data["command"]
160
+ @interval = data["interval"]
161
+ @timeout = data["timeout"]
162
+ @retries = data["retries"]&.to_i
163
+ end
164
+ end
165
+
166
+ # DatabaseConfig defines database configuration
167
+ class DatabaseConfig
168
+ attr_accessor :servers, :adapter, :url, :image, :volume, :secrets
169
+
170
+ def initialize(data = {})
171
+ @servers = data["servers"] || []
172
+ @adapter = data["adapter"]
173
+ @url = data["url"]
174
+ @image = data["image"]
175
+ @volume = data["volume"]
176
+ @secrets = data["secrets"] || {}
177
+ end
178
+
179
+ # Convert to ServiceSpec
180
+ def to_service_spec(namer)
181
+ port = case @adapter&.downcase
182
+ when "mysql" then 3306
183
+ else 5432
184
+ end
185
+
186
+ vols = {}
187
+ vols["data"] = @volume if @volume
188
+
189
+ ServiceSpec.new(
190
+ name: namer.database_service_name,
191
+ image: @image,
192
+ port:,
193
+ env: nil,
194
+ volumes: vols,
195
+ replicas: 1,
196
+ stateful_set: true,
197
+ secrets: @secrets,
198
+ servers: @servers
199
+ )
200
+ end
201
+ end
202
+
203
+ # ServiceConfig defines a generic service
204
+ class ServiceConfig
205
+ attr_accessor :servers, :image, :command, :env, :volume
206
+
207
+ def initialize(data = {})
208
+ @servers = data["servers"] || []
209
+ @image = data["image"]
210
+ @command = data["command"]
211
+ @env = data["env"] || {}
212
+ @volume = data["volume"]
213
+ end
214
+
215
+ # Convert to ServiceSpec
216
+ def to_service_spec(app_name, service_name)
217
+ cmd = @command ? @command.split : []
218
+ vols = {}
219
+ vols["data"] = @volume if @volume
220
+
221
+ # Infer port from image
222
+ port = case @image
223
+ when /redis/ then 6379
224
+ when /postgres/ then 5432
225
+ when /mysql/ then 3306
226
+ else 0
227
+ end
228
+
229
+ ServiceSpec.new(
230
+ name: "#{app_name}-#{service_name}",
231
+ image: @image,
232
+ port:,
233
+ command: cmd,
234
+ env: @env,
235
+ volumes: vols,
236
+ replicas: 1,
237
+ stateful_set: false,
238
+ servers: @servers
239
+ )
240
+ end
241
+ end
242
+
243
+ # SSHKeyConfig defines SSH key content (stored in encrypted config)
244
+ class SSHKeyConfig
245
+ attr_accessor :private_key, :public_key
246
+
247
+ def initialize(data = {})
248
+ @private_key = data["private_key"]
249
+ @public_key = data["public_key"]
250
+ end
251
+ end
252
+
253
+ # ServiceSpec is the CORE primitive - pure K8s deployment specification
254
+ class ServiceSpec
255
+ attr_accessor :name, :image, :port, :command, :env, :volumes, :replicas,
256
+ :healthcheck, :stateful_set, :secrets, :servers
257
+
258
+ def initialize(name:, image:, port: 0, command: [], env: nil, volumes: nil,
259
+ replicas: 1, healthcheck: nil, stateful_set: false, secrets: nil, servers: [])
260
+ @name = name
261
+ @image = image
262
+ @port = port
263
+ @command = command || []
264
+ @env = env || {}
265
+ @volumes = volumes || {}
266
+ @replicas = replicas
267
+ @healthcheck = healthcheck
268
+ @stateful_set = stateful_set
269
+ @secrets = secrets || {}
270
+ @servers = servers || []
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Constants
5
+ # Default deployment configuration file
6
+ DEFAULT_CONFIG_FILE = "deploy.enc"
7
+
8
+ # Network configuration
9
+ NETWORK_CIDR = "10.0.0.0/16"
10
+ SUBNET_CIDR = "10.0.1.0/24"
11
+
12
+ # Server configuration
13
+ DEFAULT_IMAGE = "ubuntu-24.04"
14
+ SERVER_READY_INTERVAL = 10 # seconds
15
+ SERVER_READY_MAX_ATTEMPTS = 60
16
+ SSH_READY_INTERVAL = 5 # seconds
17
+ SSH_READY_MAX_ATTEMPTS = 60
18
+
19
+ # Deployment configuration
20
+ MAX_DEPLOYMENT_RETRIES = 3
21
+ STALE_DEPLOYMENT_LOCK_AGE = 3600 # 1 hour in seconds
22
+ KEEP_COUNT_DEFAULT = 3
23
+
24
+ # K3s configuration
25
+ DEFAULT_K3S_VERSION = "v1.28.5+k3s1"
26
+
27
+ # Registry configuration
28
+ REGISTRY_PORT = 30500
29
+ REGISTRY_NAME = "nvoi-registry"
30
+
31
+ # Cloudflare
32
+ CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
33
+ TUNNEL_CONFIG_VERIFY_ATTEMPTS = 10
34
+
35
+ # Traffic verification
36
+ TRAFFIC_VERIFY_ATTEMPTS = 10
37
+ TRAFFIC_VERIFY_CONSECUTIVE = 3
38
+ TRAFFIC_VERIFY_INTERVAL = 5 # seconds
39
+
40
+ # Paths
41
+ DEPLOYMENT_LOCK_FILE = "/tmp/nvoi-deployment.lock"
42
+ APP_BASE_DIR = "/opt/nvoi"
43
+
44
+ # Database defaults
45
+ DATABASE_PORTS = {
46
+ "postgresql" => 5432,
47
+ "postgres" => 5432,
48
+ "mysql" => 3306,
49
+ "redis" => 6379
50
+ }.freeze
51
+
52
+ # Default database images
53
+ DATABASE_IMAGES = {
54
+ "postgresql" => "postgres:15-alpine",
55
+ "postgres" => "postgres:15-alpine",
56
+ "mysql" => "mysql:8.0"
57
+ }.freeze
58
+ end
59
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Credentials
5
+ # Crypto handles AES-256-GCM encryption/decryption
6
+ module Crypto
7
+ KEY_SIZE = 32 # 256 bits
8
+ NONCE_SIZE = 12 # GCM nonce size
9
+ KEY_HEX_LENGTH = KEY_SIZE * 2 # 64 hex characters
10
+
11
+ class << self
12
+ # Generate a new random 32-byte key and return it as hex string
13
+ def generate_key
14
+ SecureRandom.hex(KEY_SIZE)
15
+ end
16
+
17
+ # Encrypt plaintext using AES-256-GCM with the provided hex-encoded key
18
+ # Returns: [12-byte nonce][ciphertext][16-byte auth tag]
19
+ def encrypt(plaintext, hex_key)
20
+ key = decode_key(hex_key)
21
+
22
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
23
+ cipher.encrypt
24
+ cipher.key = key
25
+
26
+ # Generate random nonce
27
+ nonce = SecureRandom.random_bytes(NONCE_SIZE)
28
+ cipher.iv = nonce
29
+
30
+ # Encrypt
31
+ ciphertext = cipher.update(plaintext) + cipher.final
32
+ auth_tag = cipher.auth_tag
33
+
34
+ # Return: nonce + ciphertext + auth_tag
35
+ nonce + ciphertext + auth_tag
36
+ end
37
+
38
+ # Decrypt ciphertext using AES-256-GCM with the provided hex-encoded key
39
+ # Expects format: [12-byte nonce][ciphertext][16-byte auth tag]
40
+ def decrypt(ciphertext, hex_key)
41
+ key = decode_key(hex_key)
42
+
43
+ min_size = NONCE_SIZE + 16 # nonce + auth tag
44
+ if ciphertext.bytesize < min_size
45
+ raise DecryptionError, "ciphertext too short: need at least #{min_size} bytes, got #{ciphertext.bytesize}"
46
+ end
47
+
48
+ # Extract nonce and auth tag
49
+ nonce = ciphertext[0, NONCE_SIZE]
50
+ auth_tag = ciphertext[-16, 16]
51
+ encrypted_data = ciphertext[NONCE_SIZE...-16]
52
+
53
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
54
+ cipher.decrypt
55
+ cipher.key = key
56
+ cipher.iv = nonce
57
+ cipher.auth_tag = auth_tag
58
+
59
+ begin
60
+ cipher.update(encrypted_data) + cipher.final
61
+ rescue OpenSSL::Cipher::CipherError => e
62
+ raise DecryptionError, "decryption failed (wrong key or corrupted data): #{e.message}"
63
+ end
64
+ end
65
+
66
+ # Validate a hex-encoded key
67
+ def validate_key(hex_key)
68
+ unless hex_key.length == KEY_HEX_LENGTH
69
+ raise InvalidKeyError, "invalid key length: expected #{KEY_HEX_LENGTH} hex characters, got #{hex_key.length}"
70
+ end
71
+
72
+ unless hex_key.match?(/\A[0-9a-fA-F]+\z/)
73
+ raise InvalidKeyError, "invalid hex key: contains non-hex characters"
74
+ end
75
+
76
+ true
77
+ end
78
+
79
+ private
80
+
81
+ def decode_key(hex_key)
82
+ validate_key(hex_key)
83
+ [hex_key].pack("H*")
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end