nvoi 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/todo/refactor/00-overview.md +171 -0
- data/.claude/todo/refactor/01-objects.md +96 -0
- data/.claude/todo/refactor/02-utils.md +143 -0
- data/.claude/todo/refactor/03-external-cloud.md +164 -0
- data/.claude/todo/refactor/04-external-dns.md +104 -0
- data/.claude/todo/refactor/05-external.md +133 -0
- data/.claude/todo/refactor/06-cli.md +123 -0
- data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
- data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
- data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
- data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
- data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +12 -2
- data/doc/config-schema.yaml +44 -11
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/main.go +18 -0
- data/exe/nvoi +3 -1
- data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
- data/lib/nvoi/cli/credentials/show/command.rb +35 -0
- data/lib/nvoi/cli/db/command.rb +308 -0
- data/lib/nvoi/cli/delete/command.rb +75 -0
- data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
- data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
- data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
- data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
- data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
- data/lib/nvoi/cli/deploy/command.rb +184 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
- data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
- data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli.rb +83 -142
- data/lib/nvoi/config_api/actions/app.rb +53 -0
- data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
- data/lib/nvoi/config_api/actions/database.rb +70 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/secret.rb +32 -0
- data/lib/nvoi/config_api/actions/server.rb +66 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +44 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +70 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +425 -0
- data/lib/nvoi/external/cloud/base.rb +99 -0
- data/lib/nvoi/external/cloud/factory.rb +48 -0
- data/lib/nvoi/external/cloud/hetzner.rb +376 -0
- data/lib/nvoi/external/cloud/scaleway.rb +533 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +82 -0
- data/lib/nvoi/external/database/mysql.rb +84 -0
- data/lib/nvoi/external/database/postgres.rb +82 -0
- data/lib/nvoi/external/database/provider.rb +65 -0
- data/lib/nvoi/external/database/sqlite.rb +72 -0
- data/lib/nvoi/external/database.rb +22 -0
- data/lib/nvoi/external/dns/cloudflare.rb +292 -0
- data/lib/nvoi/external/kubectl.rb +65 -0
- data/lib/nvoi/external/ssh.rb +106 -0
- data/lib/nvoi/objects/config_override.rb +60 -0
- data/lib/nvoi/objects/configuration.rb +463 -0
- data/lib/nvoi/objects/database.rb +56 -0
- data/lib/nvoi/objects/dns.rb +14 -0
- data/lib/nvoi/objects/firewall.rb +11 -0
- data/lib/nvoi/objects/network.rb +11 -0
- data/lib/nvoi/objects/server.rb +14 -0
- data/lib/nvoi/objects/service_spec.rb +26 -0
- data/lib/nvoi/objects/tunnel.rb +14 -0
- data/lib/nvoi/objects/volume.rb +17 -0
- data/lib/nvoi/utils/config_loader.rb +172 -0
- data/lib/nvoi/utils/constants.rb +61 -0
- data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
- data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
- data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
- data/lib/nvoi/utils/logger.rb +84 -0
- data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
- data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
- data/lib/nvoi/utils/templates.rb +62 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +10 -54
- data/templates/error-backend.yaml.erb +134 -0
- metadata +97 -44
- data/examples/golang/deploy.yml +0 -54
- data/lib/nvoi/cloudflare/client.rb +0 -287
- data/lib/nvoi/config/config.rb +0 -248
- data/lib/nvoi/config/loader.rb +0 -102
- data/lib/nvoi/config/ssh_keys.rb +0 -82
- data/lib/nvoi/config/types.rb +0 -274
- data/lib/nvoi/constants.rb +0 -59
- data/lib/nvoi/credentials/editor.rb +0 -272
- data/lib/nvoi/deployer/cleaner.rb +0 -36
- data/lib/nvoi/deployer/image_builder.rb +0 -23
- data/lib/nvoi/deployer/infrastructure.rb +0 -126
- data/lib/nvoi/deployer/orchestrator.rb +0 -146
- data/lib/nvoi/deployer/service_deployer.rb +0 -311
- data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
- data/lib/nvoi/deployer/types.rb +0 -8
- data/lib/nvoi/k8s/renderer.rb +0 -44
- data/lib/nvoi/k8s/templates.rb +0 -29
- data/lib/nvoi/logger.rb +0 -72
- data/lib/nvoi/providers/aws.rb +0 -403
- data/lib/nvoi/providers/base.rb +0 -111
- data/lib/nvoi/providers/hetzner.rb +0 -288
- data/lib/nvoi/providers/hetzner_client.rb +0 -170
- data/lib/nvoi/remote/docker_manager.rb +0 -203
- data/lib/nvoi/remote/ssh_executor.rb +0 -72
- data/lib/nvoi/remote/volume_manager.rb +0 -103
- data/lib/nvoi/service/delete.rb +0 -234
- data/lib/nvoi/service/deploy.rb +0 -80
- data/lib/nvoi/service/exec.rb +0 -144
- data/lib/nvoi/service/provider.rb +0 -36
- data/lib/nvoi/steps/application_deployer.rb +0 -26
- data/lib/nvoi/steps/database_provisioner.rb +0 -60
- data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
- data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
- data/lib/nvoi/steps/server_provisioner.rb +0 -43
- data/lib/nvoi/steps/services_provisioner.rb +0 -29
- data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
- data/lib/nvoi/steps/volume_provisioner.rb +0 -154
|
@@ -0,0 +1,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
|