nvoi 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/_target.md +79 -0
  15. data/.claude/todo/scaleway.impl.md +644 -0
  16. data/.claude/todo/scaleway.reference.md +520 -0
  17. data/Gemfile +1 -0
  18. data/Gemfile.lock +12 -2
  19. data/doc/config-schema.yaml +44 -11
  20. data/examples/golang/deploy.enc +0 -0
  21. data/examples/golang/main.go +18 -0
  22. data/exe/nvoi +3 -1
  23. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  24. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  25. data/lib/nvoi/cli/db/command.rb +308 -0
  26. data/lib/nvoi/cli/delete/command.rb +75 -0
  27. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  28. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
  29. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  30. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  31. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  32. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  33. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  34. data/lib/nvoi/cli/deploy/command.rb +184 -0
  35. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  36. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  37. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
  38. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
  39. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  40. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  41. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  42. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
  43. data/lib/nvoi/cli/exec/command.rb +173 -0
  44. data/lib/nvoi/cli.rb +83 -142
  45. data/lib/nvoi/config_api/actions/app.rb +53 -0
  46. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  47. data/lib/nvoi/config_api/actions/database.rb +70 -0
  48. data/lib/nvoi/config_api/actions/env.rb +32 -0
  49. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  50. data/lib/nvoi/config_api/actions/server.rb +66 -0
  51. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  52. data/lib/nvoi/config_api/base.rb +44 -0
  53. data/lib/nvoi/config_api/result.rb +26 -0
  54. data/lib/nvoi/config_api.rb +70 -0
  55. data/lib/nvoi/errors.rb +68 -50
  56. data/lib/nvoi/external/cloud/aws.rb +425 -0
  57. data/lib/nvoi/external/cloud/base.rb +99 -0
  58. data/lib/nvoi/external/cloud/factory.rb +48 -0
  59. data/lib/nvoi/external/cloud/hetzner.rb +376 -0
  60. data/lib/nvoi/external/cloud/scaleway.rb +533 -0
  61. data/lib/nvoi/external/cloud.rb +15 -0
  62. data/lib/nvoi/external/containerd.rb +82 -0
  63. data/lib/nvoi/external/database/mysql.rb +84 -0
  64. data/lib/nvoi/external/database/postgres.rb +82 -0
  65. data/lib/nvoi/external/database/provider.rb +65 -0
  66. data/lib/nvoi/external/database/sqlite.rb +72 -0
  67. data/lib/nvoi/external/database.rb +22 -0
  68. data/lib/nvoi/external/dns/cloudflare.rb +292 -0
  69. data/lib/nvoi/external/kubectl.rb +65 -0
  70. data/lib/nvoi/external/ssh.rb +106 -0
  71. data/lib/nvoi/objects/config_override.rb +60 -0
  72. data/lib/nvoi/objects/configuration.rb +463 -0
  73. data/lib/nvoi/objects/database.rb +56 -0
  74. data/lib/nvoi/objects/dns.rb +14 -0
  75. data/lib/nvoi/objects/firewall.rb +11 -0
  76. data/lib/nvoi/objects/network.rb +11 -0
  77. data/lib/nvoi/objects/server.rb +14 -0
  78. data/lib/nvoi/objects/service_spec.rb +26 -0
  79. data/lib/nvoi/objects/tunnel.rb +14 -0
  80. data/lib/nvoi/objects/volume.rb +17 -0
  81. data/lib/nvoi/utils/config_loader.rb +172 -0
  82. data/lib/nvoi/utils/constants.rb +61 -0
  83. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  84. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  85. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  86. data/lib/nvoi/utils/logger.rb +84 -0
  87. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
  88. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  89. data/lib/nvoi/utils/templates.rb +62 -0
  90. data/lib/nvoi/version.rb +1 -1
  91. data/lib/nvoi.rb +10 -54
  92. data/templates/error-backend.yaml.erb +134 -0
  93. metadata +97 -44
  94. data/examples/golang/deploy.yml +0 -54
  95. data/lib/nvoi/cloudflare/client.rb +0 -287
  96. data/lib/nvoi/config/config.rb +0 -248
  97. data/lib/nvoi/config/loader.rb +0 -102
  98. data/lib/nvoi/config/ssh_keys.rb +0 -82
  99. data/lib/nvoi/config/types.rb +0 -274
  100. data/lib/nvoi/constants.rb +0 -59
  101. data/lib/nvoi/credentials/editor.rb +0 -272
  102. data/lib/nvoi/deployer/cleaner.rb +0 -36
  103. data/lib/nvoi/deployer/image_builder.rb +0 -23
  104. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  105. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  106. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  107. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  108. data/lib/nvoi/deployer/types.rb +0 -8
  109. data/lib/nvoi/k8s/renderer.rb +0 -44
  110. data/lib/nvoi/k8s/templates.rb +0 -29
  111. data/lib/nvoi/logger.rb +0 -72
  112. data/lib/nvoi/providers/aws.rb +0 -403
  113. data/lib/nvoi/providers/base.rb +0 -111
  114. data/lib/nvoi/providers/hetzner.rb +0 -288
  115. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  116. data/lib/nvoi/remote/docker_manager.rb +0 -203
  117. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  118. data/lib/nvoi/remote/volume_manager.rb +0 -103
  119. data/lib/nvoi/service/delete.rb +0 -234
  120. data/lib/nvoi/service/deploy.rb +0 -80
  121. data/lib/nvoi/service/exec.rb +0 -144
  122. data/lib/nvoi/service/provider.rb +0 -36
  123. data/lib/nvoi/steps/application_deployer.rb +0 -26
  124. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  125. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  126. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  127. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  128. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  129. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  130. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ # Ssh handles command execution on remote servers
6
+ class Ssh
7
+ attr_reader :ip, :ssh_key, :user
8
+
9
+ def initialize(ip, ssh_key, user: "deploy")
10
+ @ip = ip
11
+ @ssh_key = ssh_key
12
+ @user = user
13
+ @strict_mode = ENV["SSH_STRICT_HOST_KEY_CHECKING"] == "true"
14
+ end
15
+
16
+ def execute(command, stream: false)
17
+ ssh_args = build_ssh_args
18
+ ssh_args += ["#{@user}@#{@ip}", command]
19
+
20
+ if stream
21
+ success = system("ssh", *ssh_args)
22
+ raise Errors::SshCommandError, "SSH command failed" unless success
23
+
24
+ ""
25
+ else
26
+ output, status = Open3.capture2e("ssh", *ssh_args)
27
+
28
+ unless status.success?
29
+ raise Errors::SshCommandError, "SSH command failed (exit code: #{status.exitstatus}): #{output}"
30
+ end
31
+
32
+ output.strip
33
+ end
34
+ end
35
+
36
+ def execute_ignore_errors(command)
37
+ execute(command)
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ def open_shell
43
+ ssh_args = build_ssh_args
44
+ ssh_args += ["-t", "#{@user}@#{@ip}"]
45
+
46
+ exec("ssh", *ssh_args)
47
+ end
48
+
49
+ def upload(local_path, remote_path)
50
+ scp_args = build_scp_args
51
+ scp_args += [local_path, "#{@user}@#{@ip}:#{remote_path}"]
52
+
53
+ output, status = Open3.capture2e("scp", *scp_args)
54
+ raise Errors::SshCommandError, "SCP upload failed: #{output}" unless status.success?
55
+ end
56
+
57
+ def download(remote_path, local_path)
58
+ scp_args = build_scp_args
59
+ scp_args += ["#{@user}@#{@ip}:#{remote_path}", local_path]
60
+
61
+ output, status = Open3.capture2e("scp", *scp_args)
62
+ raise Errors::SshCommandError, "SCP download failed: #{output}" unless status.success?
63
+ end
64
+
65
+ def rsync(local_path, remote_path)
66
+ rsync_args = [
67
+ "-avz",
68
+ "-e", "ssh #{build_ssh_args.join(' ')}",
69
+ local_path,
70
+ "#{@user}@#{@ip}:#{remote_path}"
71
+ ]
72
+
73
+ output, status = Open3.capture2e("rsync", *rsync_args)
74
+ raise Errors::SshCommandError, "rsync failed: #{output}" unless status.success?
75
+ end
76
+
77
+ private
78
+
79
+ def build_ssh_args
80
+ args = ["-o", "LogLevel=ERROR", "-i", @ssh_key]
81
+
82
+ if @strict_mode
83
+ known_hosts_path = File.join(Dir.home, ".ssh", "known_hosts")
84
+ args += ["-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=#{known_hosts_path}"]
85
+ else
86
+ args += ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
87
+ end
88
+
89
+ args
90
+ end
91
+
92
+ def build_scp_args
93
+ args = ["-o", "LogLevel=ERROR", "-i", @ssh_key]
94
+
95
+ if @strict_mode
96
+ known_hosts_path = File.join(Dir.home, ".ssh", "known_hosts")
97
+ args += ["-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=#{known_hosts_path}"]
98
+ else
99
+ args += ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
100
+ end
101
+
102
+ args
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Objects
5
+ # ConfigOverride allows CLI to override app name and subdomain for branch deployments
6
+ class ConfigOverride
7
+ BRANCH_PATTERN = /\A[a-z0-9-]+\z/
8
+
9
+ attr_reader :branch
10
+
11
+ def initialize(branch:)
12
+ validate!(branch)
13
+ @branch = branch
14
+ end
15
+
16
+ # Apply overrides to config
17
+ def apply(config)
18
+ # Prefix branch to application name
19
+ config.deploy.application.name = "#{config.deploy.application.name}-#{@branch}"
20
+
21
+ # Prefix branch to all service subdomains
22
+ config.deploy.application.app.each_value do |svc|
23
+ svc.subdomain = "#{@branch}-#{svc.subdomain}"
24
+ end
25
+
26
+ # Regenerate resource names with new app name
27
+ regenerate_resource_names(config)
28
+
29
+ config
30
+ end
31
+
32
+ private
33
+
34
+ def validate!(branch)
35
+ raise ArgumentError, "--branch value required" unless branch && !branch.empty?
36
+ raise ArgumentError, "invalid branch format (lowercase alphanumeric and hyphens only)" unless branch.match?(BRANCH_PATTERN)
37
+ end
38
+
39
+ def regenerate_resource_names(config)
40
+ namer = Utils::Namer.new(config)
41
+ config.namer = namer
42
+ config.container_prefix = namer.infer_container_prefix
43
+ config.server_name = namer.server_name(find_master_group(config), 1)
44
+ config.firewall_name = namer.firewall_name
45
+ config.network_name = namer.network_name
46
+ config.docker_network_name = namer.docker_network_name
47
+ end
48
+
49
+ def find_master_group(config)
50
+ servers = config.deploy.application.servers
51
+ return "master" if servers.empty?
52
+
53
+ servers.each { |name, srv_cfg| return name if srv_cfg&.master }
54
+ return servers.keys.first if servers.size == 1
55
+
56
+ "master"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,463 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Objects
5
+ # Configuration module contains all configuration-related classes
6
+ module Configuration
7
+ # Root holds the complete configuration including deployment config and runtime settings
8
+ class Root
9
+ attr_accessor :deploy, :ssh_key_path, :ssh_public_key, :server_name,
10
+ :firewall_name, :network_name, :docker_network_name, :container_prefix, :namer
11
+
12
+ def initialize(deploy_config)
13
+ @deploy = deploy_config
14
+ @namer = nil
15
+ end
16
+
17
+ def namer
18
+ @namer ||= Utils::Namer.new(self)
19
+ end
20
+
21
+ def env_for_service(service_name)
22
+ Utils::EnvResolver.new(self).env_for_service(service_name)
23
+ end
24
+
25
+ def validate_config
26
+ app = @deploy.application
27
+ validate_providers_config
28
+ validate_database_secrets(app.database) if app.database
29
+ inject_database_env_vars
30
+ validate_service_server_bindings
31
+ end
32
+
33
+ def provider_name
34
+ return "hetzner" if @deploy.application.compute_provider.hetzner
35
+ return "aws" if @deploy.application.compute_provider.aws
36
+ return "scaleway" if @deploy.application.compute_provider.scaleway
37
+
38
+ ""
39
+ end
40
+
41
+ def hetzner
42
+ @deploy.application.compute_provider.hetzner
43
+ end
44
+
45
+ def aws
46
+ @deploy.application.compute_provider.aws
47
+ end
48
+
49
+ def scaleway
50
+ @deploy.application.compute_provider.scaleway
51
+ end
52
+
53
+ def cloudflare
54
+ @deploy.application.domain_provider.cloudflare
55
+ end
56
+
57
+ def keep_count_value
58
+ count = @deploy.application.keep_count
59
+ count && count.positive? ? count : 2
60
+ end
61
+
62
+ private
63
+
64
+ def validate_service_server_bindings
65
+ app = @deploy.application
66
+ defined_servers = {}
67
+ master_count = 0
68
+
69
+ app.servers.each do |server_name, server_config|
70
+ defined_servers[server_name] = true
71
+ master_count += 1 if server_config&.master
72
+ end
73
+
74
+ if app.servers.empty?
75
+ has_services = !app.app.empty? || app.database || !app.services.empty?
76
+ raise Errors::ConfigValidationError, "servers must be defined when deploying services" if has_services
77
+ end
78
+
79
+ if app.servers.size > 1
80
+ raise Errors::ConfigValidationError, "when multiple servers are defined, exactly one must have master: true" if master_count.zero?
81
+ raise Errors::ConfigValidationError, "only one server can have master: true, found #{master_count}" if master_count > 1
82
+ elsif app.servers.size == 1 && master_count > 1
83
+ raise Errors::ConfigValidationError, "only one server can have master: true, found #{master_count}"
84
+ end
85
+
86
+ app.app.each do |svc_name, svc_config|
87
+ raise Errors::ConfigValidationError, "app.#{svc_name}: servers field is required" if svc_config.servers.empty?
88
+
89
+ svc_config.servers.each do |server_ref|
90
+ raise Errors::ConfigValidationError, "app.#{svc_name}: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
91
+ end
92
+ end
93
+
94
+ if app.database
95
+ raise Errors::ConfigValidationError, "database: servers field is required" if app.database.servers.empty?
96
+
97
+ app.database.servers.each do |server_ref|
98
+ raise Errors::ConfigValidationError, "database: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
99
+ end
100
+ end
101
+
102
+ app.services.each do |svc_name, svc_config|
103
+ raise Errors::ConfigValidationError, "services.#{svc_name}: servers field is required" if svc_config.servers.empty?
104
+
105
+ svc_config.servers.each do |server_ref|
106
+ raise Errors::ConfigValidationError, "services.#{svc_name}: references undefined server '#{server_ref}'" unless defined_servers[server_ref]
107
+ end
108
+ end
109
+ end
110
+
111
+ def validate_database_secrets(db)
112
+ adapter = db.adapter&.downcase
113
+ return if db.url && !db.url.empty?
114
+
115
+ case adapter
116
+ when "postgres", "postgresql"
117
+ %w[POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB].each do |key|
118
+ raise Errors::ConfigValidationError, "postgres database requires #{key} in secrets (or provide database.url)" unless db.secrets[key]
119
+ end
120
+ when "mysql"
121
+ %w[MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE].each do |key|
122
+ raise Errors::ConfigValidationError, "mysql database requires #{key} in secrets (or provide database.url)" unless db.secrets[key]
123
+ end
124
+ when "sqlite", "sqlite3"
125
+ # SQLite doesn't require secrets
126
+ end
127
+ end
128
+
129
+ def validate_providers_config
130
+ app = @deploy.application
131
+
132
+ unless app.domain_provider.cloudflare
133
+ raise Errors::ConfigValidationError, "domain provider required: currently only cloudflare is supported"
134
+ end
135
+
136
+ cf = app.domain_provider.cloudflare
137
+ raise Errors::ConfigValidationError, "cloudflare api_token is required" if cf.api_token.nil? || cf.api_token.empty?
138
+ raise Errors::ConfigValidationError, "cloudflare account_id is required" if cf.account_id.nil? || cf.account_id.empty?
139
+
140
+ has_provider = false
141
+
142
+ if app.compute_provider.hetzner
143
+ has_provider = true
144
+ h = app.compute_provider.hetzner
145
+ raise Errors::ConfigValidationError, "hetzner api_token is required" if h.api_token.nil? || h.api_token.empty?
146
+ raise Errors::ConfigValidationError, "hetzner server_type is required" if h.server_type.nil? || h.server_type.empty?
147
+ raise Errors::ConfigValidationError, "hetzner server_location is required" if h.server_location.nil? || h.server_location.empty?
148
+ end
149
+
150
+ if app.compute_provider.aws
151
+ has_provider = true
152
+ a = app.compute_provider.aws
153
+ raise Errors::ConfigValidationError, "aws access_key_id is required" if a.access_key_id.nil? || a.access_key_id.empty?
154
+ raise Errors::ConfigValidationError, "aws secret_access_key is required" if a.secret_access_key.nil? || a.secret_access_key.empty?
155
+ raise Errors::ConfigValidationError, "aws region is required" if a.region.nil? || a.region.empty?
156
+ raise Errors::ConfigValidationError, "aws instance_type is required" if a.instance_type.nil? || a.instance_type.empty?
157
+ end
158
+
159
+ if app.compute_provider.scaleway
160
+ has_provider = true
161
+ s = app.compute_provider.scaleway
162
+ raise Errors::ConfigValidationError, "scaleway secret_key is required" if s.secret_key.nil? || s.secret_key.empty?
163
+ raise Errors::ConfigValidationError, "scaleway project_id is required" if s.project_id.nil? || s.project_id.empty?
164
+ raise Errors::ConfigValidationError, "scaleway server_type is required" if s.server_type.nil? || s.server_type.empty?
165
+ end
166
+
167
+ raise Errors::ConfigValidationError, "compute provider required: hetzner, aws, or scaleway must be configured" unless has_provider
168
+ end
169
+
170
+ def inject_database_env_vars
171
+ app = @deploy.application
172
+ return unless app.database
173
+
174
+ db = app.database
175
+ adapter = db.adapter&.downcase
176
+ return unless adapter
177
+
178
+ provider = External::Database.provider_for(adapter)
179
+ return unless provider.needs_container?
180
+
181
+ creds = parse_database_credentials(db, provider)
182
+ return unless creds
183
+
184
+ db_host = namer.database_service_name
185
+ env_vars = provider.app_env(creds, host: db_host)
186
+
187
+ app.app.each_value do |svc_config|
188
+ svc_config.env ||= {}
189
+ env_vars.each { |key, value| svc_config.env[key] ||= value }
190
+ end
191
+ end
192
+
193
+ def parse_database_credentials(db, provider)
194
+ return provider.parse_url(db.url) if db.url && !db.url.empty?
195
+
196
+ adapter = db.adapter&.downcase
197
+ case adapter
198
+ when "postgres", "postgresql"
199
+ Database::Credentials.new(
200
+ user: db.secrets["POSTGRES_USER"],
201
+ password: db.secrets["POSTGRES_PASSWORD"],
202
+ database: db.secrets["POSTGRES_DB"],
203
+ port: provider.default_port
204
+ )
205
+ when "mysql"
206
+ Database::Credentials.new(
207
+ user: db.secrets["MYSQL_USER"],
208
+ password: db.secrets["MYSQL_PASSWORD"],
209
+ database: db.secrets["MYSQL_DATABASE"],
210
+ port: provider.default_port
211
+ )
212
+ end
213
+ end
214
+ end
215
+
216
+ # Deploy represents the root deployment configuration
217
+ class Deploy
218
+ attr_accessor :application
219
+
220
+ def initialize(data = {})
221
+ @application = Application.new(data["application"] || {})
222
+ end
223
+ end
224
+
225
+ # Application contains application-level configuration
226
+ class Application
227
+ attr_accessor :name, :environment, :domain_provider, :compute_provider,
228
+ :keep_count, :servers, :app, :database, :services, :env,
229
+ :secrets, :ssh_keys
230
+
231
+ def initialize(data = {})
232
+ @name = data["name"]
233
+ @environment = data["environment"] || "production"
234
+ @domain_provider = DomainProvider.new(data["domain_provider"] || {})
235
+ @compute_provider = ComputeProvider.new(data["compute_provider"] || {})
236
+ @keep_count = data["keep_count"]&.to_i
237
+ @servers = (data["servers"] || {}).transform_values { |v| Server.new(v || {}) }
238
+ @app = (data["app"] || {}).transform_values { |v| AppService.new(v || {}) }
239
+ @database = data["database"] ? DatabaseCfg.new(data["database"]) : nil
240
+ @services = (data["services"] || {}).transform_values { |v| Service.new(v || {}) }
241
+ @env = data["env"] || {}
242
+ @secrets = data["secrets"] || {}
243
+ @ssh_keys = data["ssh_keys"] ? SshKey.new(data["ssh_keys"]) : nil
244
+ end
245
+ end
246
+
247
+ # DomainProvider contains domain provider configuration
248
+ class DomainProvider
249
+ attr_accessor :cloudflare
250
+
251
+ def initialize(data = {})
252
+ @cloudflare = data["cloudflare"] ? Cloudflare.new(data["cloudflare"]) : nil
253
+ end
254
+ end
255
+
256
+ # ComputeProvider contains compute provider configuration
257
+ class ComputeProvider
258
+ attr_accessor :hetzner, :aws, :scaleway
259
+
260
+ def initialize(data = {})
261
+ @hetzner = data["hetzner"] ? Hetzner.new(data["hetzner"]) : nil
262
+ @aws = data["aws"] ? AwsCfg.new(data["aws"]) : nil
263
+ @scaleway = data["scaleway"] ? Scaleway.new(data["scaleway"]) : nil
264
+ end
265
+ end
266
+
267
+ # Cloudflare contains Cloudflare-specific configuration
268
+ class Cloudflare
269
+ attr_accessor :api_token, :account_id
270
+
271
+ def initialize(data = {})
272
+ @api_token = data["api_token"]
273
+ @account_id = data["account_id"]
274
+ end
275
+ end
276
+
277
+ # Hetzner contains Hetzner-specific configuration
278
+ class Hetzner
279
+ attr_accessor :api_token, :server_type, :server_location
280
+
281
+ def initialize(data = {})
282
+ @api_token = data["api_token"]
283
+ @server_type = data["server_type"]
284
+ @server_location = data["server_location"]
285
+ end
286
+ end
287
+
288
+ # AwsCfg contains AWS-specific configuration
289
+ class AwsCfg
290
+ attr_accessor :access_key_id, :secret_access_key, :region, :instance_type
291
+
292
+ def initialize(data = {})
293
+ @access_key_id = data["access_key_id"]
294
+ @secret_access_key = data["secret_access_key"]
295
+ @region = data["region"]
296
+ @instance_type = data["instance_type"]
297
+ end
298
+ end
299
+
300
+ # Scaleway contains Scaleway-specific configuration
301
+ class Scaleway
302
+ attr_accessor :secret_key, :project_id, :zone, :server_type
303
+
304
+ def initialize(data = {})
305
+ @secret_key = data["secret_key"]
306
+ @project_id = data["project_id"]
307
+ @zone = data["zone"] || "fr-par-1"
308
+ @server_type = data["server_type"]
309
+ end
310
+ end
311
+
312
+ # ServerVolume defines a volume attached to a server
313
+ class ServerVolume
314
+ attr_accessor :size
315
+
316
+ def initialize(data = {})
317
+ raise ArgumentError, "volume config must be a hash with 'size' key" unless data.is_a?(Hash)
318
+
319
+ @size = data["size"]&.to_i || 10
320
+ end
321
+ end
322
+
323
+ # Server contains server instance configuration
324
+ class Server
325
+ attr_accessor :master, :type, :location, :count, :volumes
326
+
327
+ def initialize(data = {})
328
+ @master = data["master"] || false
329
+ @type = data["type"]
330
+ @location = data["location"]
331
+ @count = data["count"]&.to_i || 1
332
+ @volumes = (data["volumes"] || {}).transform_values { |v| ServerVolume.new(v || {}) }
333
+ end
334
+ end
335
+
336
+ # AppService defines a service in the app section
337
+ class AppService
338
+ attr_accessor :servers, :domain, :subdomain, :port, :healthcheck,
339
+ :command, :pre_run_command, :env, :mounts
340
+
341
+ def initialize(data = {})
342
+ @servers = data["servers"] || []
343
+ @domain = data["domain"]
344
+ @subdomain = data["subdomain"]
345
+ @port = data["port"]&.to_i
346
+ @healthcheck = data["healthcheck"] ? HealthCheck.new(data["healthcheck"]) : nil
347
+ @command = data["command"]
348
+ @pre_run_command = data["pre_run_command"]
349
+ @env = data["env"] || {}
350
+ @mounts = data["mounts"] || {}
351
+ end
352
+ end
353
+
354
+ # HealthCheck defines health check configuration
355
+ class HealthCheck
356
+ attr_accessor :type, :path, :port, :command, :interval, :timeout, :retries
357
+
358
+ def initialize(data = {})
359
+ @type = data["type"]
360
+ @path = data["path"]
361
+ @port = data["port"]&.to_i
362
+ @command = data["command"]
363
+ @interval = data["interval"]
364
+ @timeout = data["timeout"]
365
+ @retries = data["retries"]&.to_i
366
+ end
367
+ end
368
+
369
+ # DatabaseCfg defines database configuration
370
+ class DatabaseCfg
371
+ attr_accessor :servers, :adapter, :url, :image, :mount, :secrets, :path
372
+
373
+ def initialize(data = {})
374
+ @servers = data["servers"] || []
375
+ @adapter = data["adapter"]
376
+ @url = data["url"]
377
+ @image = data["image"]
378
+ @mount = data["mount"] || {}
379
+ @secrets = data["secrets"] || {}
380
+ @path = data["path"]
381
+ end
382
+
383
+ def to_service_spec(namer)
384
+ return nil if @adapter&.downcase&.start_with?("sqlite")
385
+
386
+ port = case @adapter&.downcase
387
+ when "mysql" then 3306
388
+ else 5432
389
+ end
390
+
391
+ image = @image || Utils::Constants::DATABASE_IMAGES[@adapter&.downcase]
392
+
393
+ ServiceSpec.new(
394
+ name: namer.database_service_name,
395
+ image:,
396
+ port:,
397
+ env: nil,
398
+ mounts: @mount,
399
+ replicas: 1,
400
+ stateful_set: true,
401
+ secrets: @secrets,
402
+ servers: @servers
403
+ )
404
+ end
405
+ end
406
+
407
+ # Service defines a generic service
408
+ class Service
409
+ attr_accessor :servers, :image, :port, :command, :env, :mount
410
+
411
+ def initialize(data = {})
412
+ @servers = data["servers"] || []
413
+ @image = data["image"]
414
+ @port = data["port"]&.to_i
415
+ @command = data["command"]
416
+ @env = data["env"] || {}
417
+ @mount = data["mount"] || {}
418
+ end
419
+
420
+ def to_service_spec(app_name, service_name)
421
+ cmd = @command ? @command.split : []
422
+ port = @port && @port.positive? ? @port : infer_port_from_image
423
+
424
+ ServiceSpec.new(
425
+ name: "#{app_name}-#{service_name}",
426
+ image: @image,
427
+ port:,
428
+ command: cmd,
429
+ env: @env,
430
+ mounts: @mount,
431
+ replicas: 1,
432
+ stateful_set: false,
433
+ servers: @servers
434
+ )
435
+ end
436
+
437
+ private
438
+
439
+ def infer_port_from_image
440
+ case @image
441
+ when /redis/ then 6379
442
+ when /postgres/ then 5432
443
+ when /mysql/ then 3306
444
+ when /memcache/ then 11211
445
+ when /mongo/ then 27017
446
+ when /elastic/ then 9200
447
+ else 0
448
+ end
449
+ end
450
+ end
451
+
452
+ # SshKey defines SSH key content (stored in encrypted config)
453
+ class SshKey
454
+ attr_accessor :private_key, :public_key
455
+
456
+ def initialize(data = {})
457
+ @private_key = data["private_key"]
458
+ @public_key = data["public_key"]
459
+ end
460
+ end
461
+ end
462
+ end
463
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Nvoi
6
+ module Objects
7
+ # Database-related structs and classes
8
+ module Database
9
+ # Parsed credentials from database URL
10
+ Credentials = Struct.new(:user, :password, :host, :port, :database, :path, :host_path, keyword_init: true)
11
+
12
+ # Options for dumping a database
13
+ DumpOptions = Struct.new(:pod_name, :database, :user, :password, :host_path, keyword_init: true)
14
+
15
+ # Options for restoring a database
16
+ RestoreOptions = Struct.new(:pod_name, :database, :user, :password, :source_db, :host_path, keyword_init: true)
17
+
18
+ # Options for creating a database
19
+ CreateOptions = Struct.new(:pod_name, :database, :user, :password, keyword_init: true)
20
+
21
+ # Branch represents a database branch (snapshot)
22
+ Branch = Struct.new(:id, :created_at, :size, :adapter, :database, keyword_init: true) do
23
+ def to_h
24
+ { id:, created_at:, size:, adapter:, database: }
25
+ end
26
+ end
27
+
28
+ # BranchMetadata holds all branches for an app
29
+ class BranchMetadata
30
+ attr_accessor :branches
31
+
32
+ def initialize(branches = [])
33
+ @branches = branches
34
+ end
35
+
36
+ def to_json(*_args)
37
+ JSON.pretty_generate({ branches: @branches.map(&:to_h) })
38
+ end
39
+
40
+ def self.from_json(json_str)
41
+ data = JSON.parse(json_str)
42
+ branches = (data["branches"] || []).map do |b|
43
+ Branch.new(
44
+ id: b["id"],
45
+ created_at: b["created_at"],
46
+ size: b["size"],
47
+ adapter: b["adapter"],
48
+ database: b["database"]
49
+ )
50
+ end
51
+ new(branches)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Objects
5
+ # DNS-related structs
6
+ module Dns
7
+ # Zone represents a Cloudflare DNS zone
8
+ Zone = Struct.new(:id, :name, keyword_init: true)
9
+
10
+ # Record represents a Cloudflare DNS record
11
+ Record = Struct.new(:id, :type, :name, :content, :proxied, :ttl, keyword_init: true)
12
+ end
13
+ end
14
+ end