nvoi 0.1.5 → 0.1.7
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/12-cli-db-command.md +128 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
- data/.claude/todo/refactor-execution/01-objects.md +42 -0
- data/.claude/todo/refactor-execution/02-utils.md +41 -0
- data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
- data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
- data/.claude/todo/refactor-execution/05-external-other.md +46 -0
- data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
- data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
- data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
- data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
- data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
- data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
- data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
- data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/.claude/todos.md +550 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +46 -5
- data/Rakefile +1 -1
- 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/ingest +0 -0
- data/lib/nvoi/cli/config/command.rb +219 -0
- 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 +50 -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 +102 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -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 +490 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli/logs/command.rb +66 -0
- data/lib/nvoi/cli/onboard/command.rb +761 -0
- data/lib/nvoi/cli/unlock/command.rb +72 -0
- data/lib/nvoi/cli.rb +339 -141
- 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/domain_provider.rb +40 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/init.rb +67 -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/service.rb +52 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +38 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +93 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +450 -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 +402 -0
- data/lib/nvoi/external/cloud/scaleway.rb +559 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +86 -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 +310 -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 +483 -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} +37 -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 +27 -55
- data/templates/app-ingress.yaml.erb +3 -1
- data/templates/error-backend.yaml.erb +134 -0
- metadata +121 -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
data/lib/nvoi/config/ssh_keys.rb
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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
|
data/lib/nvoi/config/types.rb
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
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
|
data/lib/nvoi/constants.rb
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
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
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Nvoi
|
|
4
|
-
module Credentials
|
|
5
|
-
DEFAULT_EDITOR = "vim"
|
|
6
|
-
TEMP_FILE_PATTERN = "nvoi-credentials-"
|
|
7
|
-
|
|
8
|
-
# Editor handles the edit workflow
|
|
9
|
-
class Editor
|
|
10
|
-
def initialize(manager)
|
|
11
|
-
@manager = manager
|
|
12
|
-
@editor = ENV["EDITOR"] || DEFAULT_EDITOR
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# Perform the full edit cycle: decrypt -> edit -> validate -> encrypt
|
|
16
|
-
def edit
|
|
17
|
-
is_first_time = !@manager.exists?
|
|
18
|
-
|
|
19
|
-
content = if is_first_time
|
|
20
|
-
default_template
|
|
21
|
-
else
|
|
22
|
-
@manager.read
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Create temp file
|
|
26
|
-
tmp_file = Tempfile.new([TEMP_FILE_PATTERN, ".yaml"])
|
|
27
|
-
tmp_path = tmp_file.path
|
|
28
|
-
|
|
29
|
-
begin
|
|
30
|
-
tmp_file.write(content)
|
|
31
|
-
tmp_file.close
|
|
32
|
-
|
|
33
|
-
# Edit loop: keep opening editor until valid or user quits
|
|
34
|
-
loop do
|
|
35
|
-
# Get file mtime before edit
|
|
36
|
-
before_mtime = File.mtime(tmp_path)
|
|
37
|
-
|
|
38
|
-
# Open editor
|
|
39
|
-
unless system(@editor, tmp_path)
|
|
40
|
-
raise CredentialError, "editor failed"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Check if file was modified
|
|
44
|
-
after_mtime = File.mtime(tmp_path)
|
|
45
|
-
if after_mtime == before_mtime
|
|
46
|
-
puts "No changes made, aborting."
|
|
47
|
-
return
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Read edited content
|
|
51
|
-
edited_content = File.read(tmp_path)
|
|
52
|
-
|
|
53
|
-
# Validate
|
|
54
|
-
validation_error = validate(edited_content)
|
|
55
|
-
if validation_error
|
|
56
|
-
puts "\n\e[31mValidation failed:\e[0m #{validation_error}"
|
|
57
|
-
puts "\nPress Enter to re-edit, or Ctrl+C to abort..."
|
|
58
|
-
$stdin.gets
|
|
59
|
-
next
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Valid: save
|
|
63
|
-
if is_first_time
|
|
64
|
-
@manager.initialize_credentials(edited_content)
|
|
65
|
-
else
|
|
66
|
-
@manager.write(edited_content)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
puts "\e[32mCredentials saved:\e[0m #{@manager.encrypted_path}"
|
|
70
|
-
return
|
|
71
|
-
end
|
|
72
|
-
ensure
|
|
73
|
-
tmp_file.close
|
|
74
|
-
tmp_file.unlink
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Print the decrypted credentials to stdout
|
|
79
|
-
def show
|
|
80
|
-
unless @manager.exists?
|
|
81
|
-
raise CredentialError, "credentials file not found: #{@manager.encrypted_path}\nRun 'nvoi credentials edit' to create one"
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
content = @manager.read
|
|
85
|
-
print content
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
private
|
|
89
|
-
|
|
90
|
-
def validate(content)
|
|
91
|
-
# First: basic YAML parse
|
|
92
|
-
begin
|
|
93
|
-
data = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
94
|
-
rescue Psych::SyntaxError => e
|
|
95
|
-
return "invalid YAML syntax: #{e.message}"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
return "config must be a hash" unless data.is_a?(Hash)
|
|
99
|
-
|
|
100
|
-
# Second: validate required fields
|
|
101
|
-
validate_required_fields(data)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def validate_required_fields(cfg)
|
|
105
|
-
app = cfg["application"]
|
|
106
|
-
return "application section is required" unless app.is_a?(Hash)
|
|
107
|
-
|
|
108
|
-
# Application name
|
|
109
|
-
return "application.name is required" if app["name"].nil? || app["name"].to_s.empty?
|
|
110
|
-
|
|
111
|
-
# Environment
|
|
112
|
-
return "application.environment is required" if app["environment"].nil? || app["environment"].to_s.empty?
|
|
113
|
-
|
|
114
|
-
# Domain provider
|
|
115
|
-
domain_provider = app["domain_provider"]
|
|
116
|
-
return "application.domain_provider.cloudflare is required" unless domain_provider&.dig("cloudflare")
|
|
117
|
-
|
|
118
|
-
cf = domain_provider["cloudflare"]
|
|
119
|
-
return "application.domain_provider.cloudflare.api_token is required" if cf["api_token"].nil? || cf["api_token"].to_s.empty?
|
|
120
|
-
return "application.domain_provider.cloudflare.account_id is required" if cf["account_id"].nil? || cf["account_id"].to_s.empty?
|
|
121
|
-
|
|
122
|
-
# Compute provider
|
|
123
|
-
compute_provider = app["compute_provider"]
|
|
124
|
-
has_compute = compute_provider&.dig("hetzner") || compute_provider&.dig("aws")
|
|
125
|
-
return "compute_provider (hetzner or aws) is required" unless has_compute
|
|
126
|
-
|
|
127
|
-
if (h = compute_provider&.dig("hetzner"))
|
|
128
|
-
return "application.compute_provider.hetzner.api_token is required" if h["api_token"].nil? || h["api_token"].to_s.empty?
|
|
129
|
-
return "application.compute_provider.hetzner.server_type is required" if h["server_type"].nil? || h["server_type"].to_s.empty?
|
|
130
|
-
return "application.compute_provider.hetzner.server_location is required" if h["server_location"].nil? || h["server_location"].to_s.empty?
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
if (a = compute_provider&.dig("aws"))
|
|
134
|
-
return "application.compute_provider.aws.access_key_id is required" if a["access_key_id"].nil? || a["access_key_id"].to_s.empty?
|
|
135
|
-
return "application.compute_provider.aws.secret_access_key is required" if a["secret_access_key"].nil? || a["secret_access_key"].to_s.empty?
|
|
136
|
-
return "application.compute_provider.aws.region is required" if a["region"].nil? || a["region"].to_s.empty?
|
|
137
|
-
return "application.compute_provider.aws.instance_type is required" if a["instance_type"].nil? || a["instance_type"].to_s.empty?
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Servers (if any services defined)
|
|
141
|
-
servers = app["servers"] || {}
|
|
142
|
-
app_services = app["app"] || {}
|
|
143
|
-
database = app["database"]
|
|
144
|
-
services = app["services"] || {}
|
|
145
|
-
|
|
146
|
-
has_services = !app_services.empty? || database || !services.empty?
|
|
147
|
-
return "servers must be defined when deploying services" if has_services && servers.empty?
|
|
148
|
-
|
|
149
|
-
defined_servers = servers.keys.to_set
|
|
150
|
-
|
|
151
|
-
# Validate app services
|
|
152
|
-
app_services.each do |service_name, svc|
|
|
153
|
-
next unless svc
|
|
154
|
-
|
|
155
|
-
return "app.#{service_name}.servers is required" if svc["servers"].nil? || svc["servers"].empty?
|
|
156
|
-
|
|
157
|
-
svc["servers"].each do |ref|
|
|
158
|
-
return "app.#{service_name} references undefined server: #{ref}" unless defined_servers.include?(ref)
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Validate database
|
|
163
|
-
if database
|
|
164
|
-
return "database.servers is required" if database["servers"].nil? || database["servers"].empty?
|
|
165
|
-
|
|
166
|
-
database["servers"].each do |ref|
|
|
167
|
-
return "database references undefined server: #{ref}" unless defined_servers.include?(ref)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
db_error = validate_database_secrets(database)
|
|
171
|
-
return db_error if db_error
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Validate SSH keys
|
|
175
|
-
ssh_keys = app["ssh_keys"]
|
|
176
|
-
return "application.ssh_keys is required" unless ssh_keys.is_a?(Hash)
|
|
177
|
-
return "application.ssh_keys.private_key is required" if ssh_keys["private_key"].nil? || ssh_keys["private_key"].to_s.strip.empty?
|
|
178
|
-
return "application.ssh_keys.public_key is required" if ssh_keys["public_key"].nil? || ssh_keys["public_key"].to_s.strip.empty?
|
|
179
|
-
|
|
180
|
-
nil
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def validate_database_secrets(db)
|
|
184
|
-
adapter = db["adapter"]&.downcase
|
|
185
|
-
|
|
186
|
-
case adapter
|
|
187
|
-
when "postgres", "postgresql"
|
|
188
|
-
%w[POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB].each do |key|
|
|
189
|
-
return "database.secrets.#{key} is required for postgres" unless db.dig("secrets", key)
|
|
190
|
-
end
|
|
191
|
-
when "mysql"
|
|
192
|
-
%w[MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE].each do |key|
|
|
193
|
-
return "database.secrets.#{key} is required for mysql" unless db.dig("secrets", key)
|
|
194
|
-
end
|
|
195
|
-
when "sqlite3"
|
|
196
|
-
# SQLite doesn't require secrets
|
|
197
|
-
when nil, ""
|
|
198
|
-
return "database.adapter is required"
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
nil
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def default_template
|
|
205
|
-
# Generate SSH keypair for first-time setup
|
|
206
|
-
private_key, public_key = Config::SSHKeyLoader.generate_keypair
|
|
207
|
-
|
|
208
|
-
<<~YAML
|
|
209
|
-
# NVOI Deployment Configuration
|
|
210
|
-
# This file is encrypted - never commit deploy.key!
|
|
211
|
-
|
|
212
|
-
application:
|
|
213
|
-
name: myapp
|
|
214
|
-
environment: production
|
|
215
|
-
|
|
216
|
-
domain_provider:
|
|
217
|
-
cloudflare:
|
|
218
|
-
api_token: YOUR_CLOUDFLARE_API_TOKEN
|
|
219
|
-
account_id: YOUR_CLOUDFLARE_ACCOUNT_ID
|
|
220
|
-
|
|
221
|
-
compute_provider:
|
|
222
|
-
hetzner:
|
|
223
|
-
api_token: YOUR_HETZNER_API_TOKEN
|
|
224
|
-
server_type: cx22
|
|
225
|
-
server_location: fsn1
|
|
226
|
-
|
|
227
|
-
servers:
|
|
228
|
-
master:
|
|
229
|
-
type: cx22
|
|
230
|
-
location: fsn1
|
|
231
|
-
|
|
232
|
-
keep_count: 2
|
|
233
|
-
|
|
234
|
-
app:
|
|
235
|
-
web:
|
|
236
|
-
servers: [master]
|
|
237
|
-
domain: example.com
|
|
238
|
-
subdomain: app
|
|
239
|
-
port: 3000
|
|
240
|
-
healthcheck:
|
|
241
|
-
type: http
|
|
242
|
-
path: /health
|
|
243
|
-
port: 3000
|
|
244
|
-
|
|
245
|
-
# database:
|
|
246
|
-
# servers: [master]
|
|
247
|
-
# adapter: postgres
|
|
248
|
-
# image: postgres:16-alpine
|
|
249
|
-
# volume: postgres_data
|
|
250
|
-
# secrets:
|
|
251
|
-
# POSTGRES_DB: myapp_production
|
|
252
|
-
# POSTGRES_USER: myapp
|
|
253
|
-
# POSTGRES_PASSWORD: YOUR_DB_PASSWORD
|
|
254
|
-
|
|
255
|
-
env:
|
|
256
|
-
# Add environment variables here
|
|
257
|
-
# RAILS_ENV: production
|
|
258
|
-
|
|
259
|
-
secrets:
|
|
260
|
-
# Add secrets here (will be injected as env vars)
|
|
261
|
-
# SECRET_KEY_BASE: YOUR_SECRET_KEY_BASE
|
|
262
|
-
|
|
263
|
-
# SSH keys (auto-generated, do not modify)
|
|
264
|
-
ssh_keys:
|
|
265
|
-
private_key: |
|
|
266
|
-
#{private_key.lines.map { |l| " #{l}" }.join}
|
|
267
|
-
public_key: #{public_key}
|
|
268
|
-
YAML
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|