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,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Objects
|
|
5
|
+
# Server-related structs
|
|
6
|
+
module Server
|
|
7
|
+
# Record represents a compute server/instance
|
|
8
|
+
Record = Struct.new(:id, :name, :status, :public_ipv4, :private_ipv4, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# CreateOptions contains options for creating a server
|
|
11
|
+
CreateOptions = Struct.new(:name, :type, :image, :location, :user_data, :network_id, :firewall_id, :ssh_keys, keyword_init: true)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Objects
|
|
5
|
+
# ServiceSpec is the CORE primitive - pure K8s deployment specification
|
|
6
|
+
class ServiceSpec
|
|
7
|
+
attr_accessor :name, :image, :port, :command, :env, :mounts, :replicas,
|
|
8
|
+
:healthcheck, :stateful_set, :secrets, :servers
|
|
9
|
+
|
|
10
|
+
def initialize(name:, image:, port: 0, command: [], env: nil, mounts: nil,
|
|
11
|
+
replicas: 1, healthcheck: nil, stateful_set: false, secrets: nil, servers: [])
|
|
12
|
+
@name = name
|
|
13
|
+
@image = image
|
|
14
|
+
@port = port
|
|
15
|
+
@command = command || []
|
|
16
|
+
@env = env || {}
|
|
17
|
+
@mounts = mounts || {}
|
|
18
|
+
@replicas = replicas
|
|
19
|
+
@healthcheck = healthcheck
|
|
20
|
+
@stateful_set = stateful_set
|
|
21
|
+
@secrets = secrets || {}
|
|
22
|
+
@servers = servers || []
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Objects
|
|
5
|
+
# Tunnel-related structs
|
|
6
|
+
module Tunnel
|
|
7
|
+
# Record represents a Cloudflare tunnel
|
|
8
|
+
Record = Struct.new(:id, :name, :token, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Info holds information about a configured tunnel
|
|
11
|
+
Info = Struct.new(:service_name, :hostname, :tunnel_id, :tunnel_token, :port, keyword_init: true)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Objects
|
|
5
|
+
# Volume-related structs
|
|
6
|
+
module Volume
|
|
7
|
+
# Volume represents a block storage volume
|
|
8
|
+
Record = Struct.new(:id, :name, :size, :location, :status, :server_id, :device_path, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# CreateOptions contains options for creating a volume
|
|
11
|
+
CreateOptions = Struct.new(:name, :size, :server_id, :location, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# MountOptions contains options for mounting a volume
|
|
14
|
+
MountOptions = Struct.new(:device_path, :mount_path, :fs_type, keyword_init: true)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Nvoi
|
|
7
|
+
module Utils
|
|
8
|
+
# ConfigLoader handles loading and initializing configuration from encrypted files
|
|
9
|
+
module ConfigLoader
|
|
10
|
+
class << self
|
|
11
|
+
# Load reads and parses the deployment configuration from encrypted file
|
|
12
|
+
def load(config_path, credentials_path: nil, master_key_path: nil)
|
|
13
|
+
working_dir = config_path && !config_path.empty? ? File.dirname(config_path) : "."
|
|
14
|
+
enc_path = credentials_path.nil? || credentials_path.empty? ? config_path : credentials_path
|
|
15
|
+
|
|
16
|
+
manager = CredentialStore.new(working_dir, enc_path, master_key_path)
|
|
17
|
+
plaintext = manager.read
|
|
18
|
+
raise Errors::ConfigError, "Failed to decrypt credentials" unless plaintext
|
|
19
|
+
|
|
20
|
+
data = YAML.safe_load(plaintext, permitted_classes: [Symbol])
|
|
21
|
+
raise Errors::ConfigError, "Invalid config format" unless data.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
deploy_config = Objects::Configuration::Deploy.new(data)
|
|
24
|
+
cfg = Objects::Configuration::Root.new(deploy_config)
|
|
25
|
+
|
|
26
|
+
load_ssh_keys(cfg)
|
|
27
|
+
cfg.validate_config
|
|
28
|
+
generate_resource_names(cfg)
|
|
29
|
+
|
|
30
|
+
cfg
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get database credentials from config
|
|
34
|
+
def get_database_credentials(db_config, namer = nil)
|
|
35
|
+
return nil unless db_config
|
|
36
|
+
|
|
37
|
+
adapter = db_config.adapter&.downcase
|
|
38
|
+
return nil unless adapter
|
|
39
|
+
|
|
40
|
+
provider = External::Database.provider_for(adapter)
|
|
41
|
+
|
|
42
|
+
if db_config.url && !db_config.url.empty?
|
|
43
|
+
creds = provider.parse_url(db_config.url)
|
|
44
|
+
host_path = nil
|
|
45
|
+
|
|
46
|
+
if provider.is_a?(External::Database::Sqlite) && namer && db_config.servers&.any?
|
|
47
|
+
host_path = resolve_sqlite_host_path(db_config, namer, creds.database || "app.db")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return Objects::Database::Credentials.new(
|
|
51
|
+
user: creds.user,
|
|
52
|
+
password: creds.password,
|
|
53
|
+
host: creds.host,
|
|
54
|
+
port: creds.port,
|
|
55
|
+
database: creds.database,
|
|
56
|
+
path: creds.path,
|
|
57
|
+
host_path:
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Fall back to secrets-based credentials
|
|
62
|
+
case adapter
|
|
63
|
+
when "postgres", "postgresql"
|
|
64
|
+
Objects::Database::Credentials.new(
|
|
65
|
+
port: provider.default_port,
|
|
66
|
+
user: db_config.secrets["POSTGRES_USER"],
|
|
67
|
+
password: db_config.secrets["POSTGRES_PASSWORD"],
|
|
68
|
+
database: db_config.secrets["POSTGRES_DB"]
|
|
69
|
+
)
|
|
70
|
+
when "mysql", "mysql2"
|
|
71
|
+
Objects::Database::Credentials.new(
|
|
72
|
+
port: provider.default_port,
|
|
73
|
+
user: db_config.secrets["MYSQL_USER"],
|
|
74
|
+
password: db_config.secrets["MYSQL_PASSWORD"],
|
|
75
|
+
database: db_config.secrets["MYSQL_DATABASE"]
|
|
76
|
+
)
|
|
77
|
+
when "sqlite", "sqlite3"
|
|
78
|
+
Objects::Database::Credentials.new(
|
|
79
|
+
database: "app.db",
|
|
80
|
+
host_path: resolve_sqlite_host_path(db_config, namer, "app.db")
|
|
81
|
+
)
|
|
82
|
+
else
|
|
83
|
+
raise Errors::ConfigError, "Unsupported database adapter: #{adapter}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def load_ssh_keys(cfg)
|
|
90
|
+
ssh_keys = cfg.deploy.application.ssh_keys
|
|
91
|
+
|
|
92
|
+
unless ssh_keys
|
|
93
|
+
raise Errors::ConfigError, "ssh_keys section is required in config. Run 'nvoi credentials edit' to generate keys."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
raise Errors::ConfigError, "ssh_keys.private_key is required" unless ssh_keys.private_key && !ssh_keys.private_key.empty?
|
|
97
|
+
raise Errors::ConfigError, "ssh_keys.public_key is required" unless ssh_keys.public_key && !ssh_keys.public_key.empty?
|
|
98
|
+
|
|
99
|
+
temp_dir = Dir.mktmpdir("nvoi-ssh-")
|
|
100
|
+
|
|
101
|
+
private_key_path = File.join(temp_dir, "id_nvoi")
|
|
102
|
+
File.write(private_key_path, ssh_keys.private_key)
|
|
103
|
+
File.chmod(0o600, private_key_path)
|
|
104
|
+
|
|
105
|
+
public_key_path = File.join(temp_dir, "id_nvoi.pub")
|
|
106
|
+
File.write(public_key_path, ssh_keys.public_key)
|
|
107
|
+
File.chmod(0o644, public_key_path)
|
|
108
|
+
|
|
109
|
+
cfg.ssh_key_path = private_key_path
|
|
110
|
+
cfg.ssh_public_key = ssh_keys.public_key.strip
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def generate_resource_names(cfg)
|
|
114
|
+
namer = cfg.namer
|
|
115
|
+
cfg.container_prefix = namer.infer_container_prefix
|
|
116
|
+
master_group = find_master_server_group(cfg)
|
|
117
|
+
cfg.server_name = namer.server_name(master_group, 1)
|
|
118
|
+
cfg.firewall_name = namer.firewall_name
|
|
119
|
+
cfg.network_name = namer.network_name
|
|
120
|
+
cfg.docker_network_name = namer.docker_network_name
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def find_master_server_group(cfg)
|
|
124
|
+
servers = cfg.deploy.application.servers
|
|
125
|
+
return "master" if servers.empty?
|
|
126
|
+
|
|
127
|
+
servers.each { |name, srv_cfg| return name if srv_cfg&.master }
|
|
128
|
+
return servers.keys.first if servers.size == 1
|
|
129
|
+
|
|
130
|
+
"master"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def resolve_sqlite_host_path(db_config, namer, filename = "app.db")
|
|
134
|
+
return nil unless namer && db_config.servers&.any?
|
|
135
|
+
|
|
136
|
+
server_name = db_config.servers.first
|
|
137
|
+
mount = db_config.mount
|
|
138
|
+
|
|
139
|
+
if mount && !mount.empty?
|
|
140
|
+
vol_name = mount.keys.first
|
|
141
|
+
base_path = namer.server_volume_host_path(server_name, vol_name)
|
|
142
|
+
return "#{base_path}/#{filename}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Generate a new Ed25519 keypair using ssh-keygen
|
|
150
|
+
def self.generate_keypair
|
|
151
|
+
temp_dir = Dir.mktmpdir("nvoi-keygen-")
|
|
152
|
+
key_path = File.join(temp_dir, "id_nvoi")
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
result = system(
|
|
156
|
+
"ssh-keygen", "-t", "ed25519", "-N", "", "-C", "nvoi-deploy", "-f", key_path,
|
|
157
|
+
out: File::NULL, err: File::NULL
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
raise Errors::ConfigError, "Failed to generate SSH keypair (ssh-keygen not available?)" unless result
|
|
161
|
+
|
|
162
|
+
private_key = File.read(key_path)
|
|
163
|
+
public_key = File.read("#{key_path}.pub").strip
|
|
164
|
+
|
|
165
|
+
[private_key, public_key]
|
|
166
|
+
ensure
|
|
167
|
+
FileUtils.rm_rf(temp_dir)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Utils
|
|
5
|
+
module Constants
|
|
6
|
+
# Default deployment configuration file
|
|
7
|
+
DEFAULT_CONFIG_FILE = "deploy.enc"
|
|
8
|
+
|
|
9
|
+
# Network configuration
|
|
10
|
+
NETWORK_CIDR = "10.0.0.0/16"
|
|
11
|
+
SUBNET_CIDR = "10.0.1.0/24"
|
|
12
|
+
|
|
13
|
+
# Server configuration
|
|
14
|
+
DEFAULT_IMAGE = "ubuntu-24.04"
|
|
15
|
+
SERVER_READY_INTERVAL = 10 # seconds
|
|
16
|
+
SERVER_READY_MAX_ATTEMPTS = 60
|
|
17
|
+
SSH_READY_INTERVAL = 5 # seconds
|
|
18
|
+
SSH_READY_MAX_ATTEMPTS = 60
|
|
19
|
+
|
|
20
|
+
# Deployment configuration
|
|
21
|
+
MAX_DEPLOYMENT_RETRIES = 3
|
|
22
|
+
STALE_DEPLOYMENT_LOCK_AGE = 3600 # 1 hour in seconds
|
|
23
|
+
KEEP_COUNT_DEFAULT = 3
|
|
24
|
+
|
|
25
|
+
# K3s configuration
|
|
26
|
+
DEFAULT_K3S_VERSION = "v1.28.5+k3s1"
|
|
27
|
+
|
|
28
|
+
# Registry configuration
|
|
29
|
+
REGISTRY_PORT = 30500
|
|
30
|
+
REGISTRY_NAME = "nvoi-registry"
|
|
31
|
+
|
|
32
|
+
# Cloudflare
|
|
33
|
+
CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
|
|
34
|
+
TUNNEL_CONFIG_VERIFY_ATTEMPTS = 10
|
|
35
|
+
|
|
36
|
+
# Traffic verification
|
|
37
|
+
TRAFFIC_VERIFY_ATTEMPTS = 10
|
|
38
|
+
TRAFFIC_VERIFY_CONSECUTIVE = 3
|
|
39
|
+
TRAFFIC_VERIFY_INTERVAL = 5 # seconds
|
|
40
|
+
|
|
41
|
+
# Paths
|
|
42
|
+
DEPLOYMENT_LOCK_FILE = "/tmp/nvoi-deployment.lock"
|
|
43
|
+
APP_BASE_DIR = "/opt/nvoi"
|
|
44
|
+
|
|
45
|
+
# Database defaults
|
|
46
|
+
DATABASE_PORTS = {
|
|
47
|
+
"postgresql" => 5432,
|
|
48
|
+
"postgres" => 5432,
|
|
49
|
+
"mysql" => 3306,
|
|
50
|
+
"redis" => 6379
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Default database images
|
|
54
|
+
DATABASE_IMAGES = {
|
|
55
|
+
"postgresql" => "postgres:15-alpine",
|
|
56
|
+
"postgres" => "postgres:15-alpine",
|
|
57
|
+
"mysql" => "mysql:8.0"
|
|
58
|
+
}.freeze
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Nvoi
|
|
4
|
-
module
|
|
4
|
+
module Utils
|
|
5
5
|
# Default filenames
|
|
6
6
|
DEFAULT_ENCRYPTED_FILE = "deploy.enc"
|
|
7
7
|
DEFAULT_KEY_FILE = "deploy.key"
|
|
8
8
|
MASTER_KEY_ENV_VAR = "NVOI_MASTER_KEY"
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
class
|
|
10
|
+
# CredentialStore handles encrypted credentials file operations
|
|
11
|
+
class CredentialStore
|
|
12
12
|
attr_reader :encrypted_path, :key_path
|
|
13
13
|
|
|
14
|
-
# Create a new credentials
|
|
14
|
+
# Create a new credentials store
|
|
15
15
|
# working_dir: base directory to search for files
|
|
16
16
|
# encrypted_path: explicit path to encrypted file (optional, nil = auto-discover)
|
|
17
17
|
# key_path: explicit path to key file (optional, nil = auto-discover)
|
|
@@ -24,14 +24,14 @@ module Nvoi
|
|
|
24
24
|
resolve_key(key_path)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# Create a
|
|
27
|
+
# Create a store for initial setup (no existing files required)
|
|
28
28
|
def self.for_init(working_dir)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
store = allocate
|
|
30
|
+
store.instance_variable_set(:@working_dir, working_dir)
|
|
31
|
+
store.instance_variable_set(:@encrypted_path, File.join(working_dir, DEFAULT_ENCRYPTED_FILE))
|
|
32
|
+
store.instance_variable_set(:@key_path, nil)
|
|
33
|
+
store.instance_variable_set(:@master_key, nil)
|
|
34
|
+
store
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Check if the encrypted credentials file exists
|
|
@@ -39,14 +39,14 @@ module Nvoi
|
|
|
39
39
|
File.exist?(@encrypted_path)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# Check if the
|
|
42
|
+
# Check if the store has a master key loaded
|
|
43
43
|
def has_key?
|
|
44
44
|
!@master_key.nil? && !@master_key.empty?
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# Decrypt and return the credentials content
|
|
48
48
|
def read
|
|
49
|
-
raise CredentialError, "master key not loaded" unless has_key?
|
|
49
|
+
raise Errors::CredentialError, "master key not loaded" unless has_key?
|
|
50
50
|
|
|
51
51
|
ciphertext = File.binread(@encrypted_path)
|
|
52
52
|
Crypto.decrypt(ciphertext, @master_key)
|
|
@@ -54,7 +54,7 @@ module Nvoi
|
|
|
54
54
|
|
|
55
55
|
# Encrypt and save the credentials content
|
|
56
56
|
def write(plaintext)
|
|
57
|
-
raise CredentialError, "master key not loaded" unless has_key?
|
|
57
|
+
raise Errors::CredentialError, "master key not loaded" unless has_key?
|
|
58
58
|
|
|
59
59
|
ciphertext = Crypto.encrypt(plaintext, @master_key)
|
|
60
60
|
|
|
@@ -66,7 +66,7 @@ module Nvoi
|
|
|
66
66
|
File.rename(tmp_path, @encrypted_path)
|
|
67
67
|
rescue StandardError => e
|
|
68
68
|
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
69
|
-
raise CredentialError, "failed to rename temp file: #{e.message}"
|
|
69
|
+
raise Errors::CredentialError, "failed to rename temp file: #{e.message}"
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
@@ -160,7 +160,7 @@ module Nvoi
|
|
|
160
160
|
return
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
-
raise CredentialError, "master key not found: set #{MASTER_KEY_ENV_VAR} or create #{DEFAULT_KEY_FILE}"
|
|
163
|
+
raise Errors::CredentialError, "master key not found: set #{MASTER_KEY_ENV_VAR} or create #{DEFAULT_KEY_FILE}"
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
def load_key_from_file(path)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
3
6
|
module Nvoi
|
|
4
|
-
module
|
|
7
|
+
module Utils
|
|
5
8
|
# Crypto handles AES-256-GCM encryption/decryption
|
|
6
9
|
module Crypto
|
|
7
10
|
KEY_SIZE = 32 # 256 bits
|
|
@@ -42,7 +45,7 @@ module Nvoi
|
|
|
42
45
|
|
|
43
46
|
min_size = NONCE_SIZE + 16 # nonce + auth tag
|
|
44
47
|
if ciphertext.bytesize < min_size
|
|
45
|
-
raise DecryptionError, "ciphertext too short: need at least #{min_size} bytes, got #{ciphertext.bytesize}"
|
|
48
|
+
raise Errors::DecryptionError, "ciphertext too short: need at least #{min_size} bytes, got #{ciphertext.bytesize}"
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Extract nonce and auth tag
|
|
@@ -59,18 +62,18 @@ module Nvoi
|
|
|
59
62
|
begin
|
|
60
63
|
cipher.update(encrypted_data) + cipher.final
|
|
61
64
|
rescue OpenSSL::Cipher::CipherError => e
|
|
62
|
-
raise DecryptionError, "decryption failed (wrong key or corrupted data): #{e.message}"
|
|
65
|
+
raise Errors::DecryptionError, "decryption failed (wrong key or corrupted data): #{e.message}"
|
|
63
66
|
end
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
# Validate a hex-encoded key
|
|
67
70
|
def validate_key(hex_key)
|
|
68
71
|
unless hex_key.length == KEY_HEX_LENGTH
|
|
69
|
-
raise InvalidKeyError, "invalid key length: expected #{KEY_HEX_LENGTH} hex characters, got #{hex_key.length}"
|
|
72
|
+
raise Errors::InvalidKeyError, "invalid key length: expected #{KEY_HEX_LENGTH} hex characters, got #{hex_key.length}"
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
unless hex_key.match?(/\A[0-9a-fA-F]+\z/)
|
|
73
|
-
raise InvalidKeyError, "invalid hex key: contains non-hex characters"
|
|
76
|
+
raise Errors::InvalidKeyError, "invalid hex key: contains non-hex characters"
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
true
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Nvoi
|
|
4
|
-
module
|
|
4
|
+
module Utils
|
|
5
5
|
# EnvResolver handles environment variable resolution and injection
|
|
6
6
|
class EnvResolver
|
|
7
7
|
def initialize(config)
|
|
@@ -48,7 +48,7 @@ module Nvoi
|
|
|
48
48
|
|
|
49
49
|
# Handle database URL
|
|
50
50
|
if db.adapter == "sqlite3"
|
|
51
|
-
env["DATABASE_URL"] =
|
|
51
|
+
env["DATABASE_URL"] = sqlite_database_url(db)
|
|
52
52
|
elsif db.url && !db.url.empty?
|
|
53
53
|
env["DATABASE_URL"] = db.url
|
|
54
54
|
end
|
|
@@ -58,6 +58,14 @@ module Nvoi
|
|
|
58
58
|
env[key] = value
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
|
+
|
|
62
|
+
def sqlite_database_url(db)
|
|
63
|
+
raise Errors::ConfigError, "sqlite3 requires database.mount to be configured" if db.mount.nil? || db.mount.empty?
|
|
64
|
+
|
|
65
|
+
mount_path = db.mount.values.first
|
|
66
|
+
app_name = @config.deploy.application.name
|
|
67
|
+
"sqlite://#{mount_path}/#{app_name}-database.sqlite3"
|
|
68
|
+
end
|
|
61
69
|
end
|
|
62
70
|
end
|
|
63
71
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Utils
|
|
5
|
+
class Logger
|
|
6
|
+
COLORS = {
|
|
7
|
+
reset: "\e[0m",
|
|
8
|
+
red: "\e[31m",
|
|
9
|
+
green: "\e[32m",
|
|
10
|
+
yellow: "\e[33m",
|
|
11
|
+
blue: "\e[34m",
|
|
12
|
+
magenta: "\e[35m",
|
|
13
|
+
cyan: "\e[36m",
|
|
14
|
+
white: "\e[37m",
|
|
15
|
+
bold: "\e[1m"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(output: $stdout, color: true)
|
|
19
|
+
@output = output
|
|
20
|
+
@color = color && output.tty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def info(message, *args)
|
|
24
|
+
log(:blue, "INFO", format_message(message, args))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def success(message, *args)
|
|
28
|
+
log(:green, "SUCCESS", format_message(message, args))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def warning(message, *args)
|
|
32
|
+
log(:yellow, "WARNING", format_message(message, args))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def error(message, *args)
|
|
36
|
+
log(:red, "ERROR", format_message(message, args))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def debug(message, *args)
|
|
40
|
+
return unless ENV["NVOI_DEBUG"]
|
|
41
|
+
|
|
42
|
+
log(:magenta, "DEBUG", format_message(message, args))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Step indicator for multi-step operations
|
|
46
|
+
def step(message, *args)
|
|
47
|
+
log(:cyan, "STEP", format_message(message, args))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# OK indicator for step completion
|
|
51
|
+
def ok(message, *args)
|
|
52
|
+
log(:green, "OK", format_message(message, args))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def separator
|
|
56
|
+
@output.puts colorize(:cyan, "-" * 60)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def blank
|
|
60
|
+
@output.puts
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def format_message(message, args)
|
|
66
|
+
return message if args.empty?
|
|
67
|
+
|
|
68
|
+
format(message, *args)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def log(color, level, message)
|
|
72
|
+
timestamp = Time.now.strftime("%H:%M:%S")
|
|
73
|
+
prefix = colorize(color, "[#{timestamp}] [#{level}]")
|
|
74
|
+
@output.puts "#{prefix} #{message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def colorize(color, text)
|
|
78
|
+
return text unless @color
|
|
79
|
+
|
|
80
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
5
|
module Nvoi
|
|
6
|
-
module
|
|
7
|
-
#
|
|
8
|
-
class
|
|
6
|
+
module Utils
|
|
7
|
+
# Namer handles resource naming and inference
|
|
8
|
+
class Namer
|
|
9
9
|
def initialize(config)
|
|
10
10
|
@config = config
|
|
11
11
|
end
|
|
@@ -72,6 +72,10 @@ module Nvoi
|
|
|
72
72
|
"app=db-#{@config.deploy.application.name}"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
def database_pod_name
|
|
76
|
+
"db-#{@config.deploy.application.name}-0"
|
|
77
|
+
end
|
|
78
|
+
|
|
75
79
|
# ============================================================================
|
|
76
80
|
# KUBERNETES APP RESOURCES
|
|
77
81
|
# ============================================================================
|
|
@@ -153,20 +157,32 @@ module Nvoi
|
|
|
153
157
|
# VOLUME RESOURCES
|
|
154
158
|
# ============================================================================
|
|
155
159
|
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
# Server-level volume naming: {app}-{server}-{volume}
|
|
161
|
+
def server_volume_name(server_name, volume_name)
|
|
162
|
+
"#{@config.deploy.application.name}-#{server_name}-#{volume_name}"
|
|
158
163
|
end
|
|
159
164
|
|
|
160
|
-
|
|
161
|
-
|
|
165
|
+
# Host mount path for a server volume
|
|
166
|
+
def server_volume_host_path(server_name, volume_name)
|
|
167
|
+
"/opt/nvoi/volumes/#{server_volume_name(server_name, volume_name)}"
|
|
162
168
|
end
|
|
163
169
|
|
|
164
|
-
|
|
165
|
-
|
|
170
|
+
# ============================================================================
|
|
171
|
+
# HOSTNAME HELPER
|
|
172
|
+
# ============================================================================
|
|
173
|
+
|
|
174
|
+
# Build full hostname from subdomain and domain
|
|
175
|
+
def hostname(subdomain, domain)
|
|
176
|
+
self.class.build_hostname(subdomain, domain)
|
|
166
177
|
end
|
|
167
178
|
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
# Class method for building hostname without instance
|
|
180
|
+
def self.build_hostname(subdomain, domain)
|
|
181
|
+
if subdomain.nil? || subdomain.empty? || subdomain == "@"
|
|
182
|
+
domain
|
|
183
|
+
else
|
|
184
|
+
"#{subdomain}.#{domain}"
|
|
185
|
+
end
|
|
170
186
|
end
|
|
171
187
|
|
|
172
188
|
private
|
|
@@ -176,20 +192,7 @@ module Nvoi
|
|
|
176
192
|
end
|
|
177
193
|
|
|
178
194
|
def infer_base_prefix
|
|
179
|
-
|
|
180
|
-
return "app" if output.empty?
|
|
181
|
-
|
|
182
|
-
# Extract username/repo from: git@github.com:user/repo.git or https://github.com/user/repo.git
|
|
183
|
-
repo_url = output.sub(/\.git$/, "")
|
|
184
|
-
parts = repo_url.split(%r{[/:]+})
|
|
185
|
-
|
|
186
|
-
if parts.length >= 2
|
|
187
|
-
username = parts[-2]
|
|
188
|
-
repo = parts[-1]
|
|
189
|
-
"#{username}-#{repo}"
|
|
190
|
-
else
|
|
191
|
-
"app"
|
|
192
|
-
end
|
|
195
|
+
"nvoi"
|
|
193
196
|
end
|
|
194
197
|
end
|
|
195
198
|
end
|