nvoi 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +19 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +151 -0
- data/Makefile +26 -0
- data/Rakefile +16 -0
- data/doc/config-schema.yaml +357 -0
- data/examples/apex-wildcard/deploy.yml +68 -0
- data/examples/golang/.gitignore +19 -0
- data/examples/golang/Dockerfile +43 -0
- data/examples/golang/README.md +59 -0
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/deploy.yml +54 -0
- data/examples/golang/go.mod +39 -0
- data/examples/golang/go.sum +96 -0
- data/examples/golang/main.go +177 -0
- data/examples/golang/models/user.go +17 -0
- data/examples/golang-postgres-multi/.gitignore +18 -0
- data/examples/golang-postgres-multi/Dockerfile +39 -0
- data/examples/golang-postgres-multi/README.md +211 -0
- data/examples/golang-postgres-multi/deploy.yml +67 -0
- data/examples/golang-postgres-multi/go.mod +45 -0
- data/examples/golang-postgres-multi/go.sum +108 -0
- data/examples/golang-postgres-multi/main.go +197 -0
- data/examples/golang-postgres-multi/models/user.go +17 -0
- data/examples/postgres-multi/.env.production.example +11 -0
- data/examples/postgres-multi/README.md +112 -0
- data/examples/postgres-multi/deploy.yml +74 -0
- data/examples/postgres-single/.env.production.example +11 -0
- data/examples/postgres-single/.gitignore +15 -0
- data/examples/postgres-single/Dockerfile +35 -0
- data/examples/postgres-single/README.md +76 -0
- data/examples/postgres-single/deploy.yml +56 -0
- data/examples/postgres-single/go.mod +45 -0
- data/examples/postgres-single/go.sum +108 -0
- data/examples/postgres-single/main.go +184 -0
- data/examples/rails-single/.dockerignore +51 -0
- data/examples/rails-single/.env.production.example +11 -0
- data/examples/rails-single/.github/dependabot.yml +12 -0
- data/examples/rails-single/.github/workflows/ci.yml +39 -0
- data/examples/rails-single/.gitignore +20 -0
- data/examples/rails-single/.node-version +1 -0
- data/examples/rails-single/.rubocop.yml +8 -0
- data/examples/rails-single/.ruby-version +1 -0
- data/examples/rails-single/Dockerfile +86 -0
- data/examples/rails-single/Gemfile +56 -0
- data/examples/rails-single/Gemfile.lock +350 -0
- data/examples/rails-single/Procfile.dev +3 -0
- data/examples/rails-single/README.md +17 -0
- data/examples/rails-single/Rakefile +6 -0
- data/examples/rails-single/app/assets/builds/.keep +0 -0
- data/examples/rails-single/app/assets/images/.keep +0 -0
- data/examples/rails-single/app/assets/stylesheets/application.tailwind.css +1 -0
- data/examples/rails-single/app/controllers/application_controller.rb +4 -0
- data/examples/rails-single/app/controllers/concerns/.keep +0 -0
- data/examples/rails-single/app/controllers/users_controller.rb +19 -0
- data/examples/rails-single/app/helpers/application_helper.rb +2 -0
- data/examples/rails-single/app/javascript/application.js +3 -0
- data/examples/rails-single/app/javascript/controllers/application.js +9 -0
- data/examples/rails-single/app/javascript/controllers/hello_controller.js +7 -0
- data/examples/rails-single/app/javascript/controllers/index.js +8 -0
- data/examples/rails-single/app/jobs/application_job.rb +7 -0
- data/examples/rails-single/app/mailers/application_mailer.rb +4 -0
- data/examples/rails-single/app/models/application_record.rb +3 -0
- data/examples/rails-single/app/models/concerns/.keep +0 -0
- data/examples/rails-single/app/models/user.rb +2 -0
- data/examples/rails-single/app/views/layouts/application.html.erb +28 -0
- data/examples/rails-single/app/views/layouts/mailer.html.erb +13 -0
- data/examples/rails-single/app/views/layouts/mailer.text.erb +1 -0
- data/examples/rails-single/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails-single/app/views/pwa/service-worker.js +26 -0
- data/examples/rails-single/app/views/users/index.html.erb +38 -0
- data/examples/rails-single/bin/brakeman +7 -0
- data/examples/rails-single/bin/bundle +109 -0
- data/examples/rails-single/bin/dev +11 -0
- data/examples/rails-single/bin/docker-entrypoint +14 -0
- data/examples/rails-single/bin/jobs +6 -0
- data/examples/rails-single/bin/kamal +27 -0
- data/examples/rails-single/bin/rails +4 -0
- data/examples/rails-single/bin/rake +4 -0
- data/examples/rails-single/bin/rubocop +8 -0
- data/examples/rails-single/bin/setup +37 -0
- data/examples/rails-single/bin/thrust +5 -0
- data/examples/rails-single/bun.lock +224 -0
- data/examples/rails-single/config/application.rb +42 -0
- data/examples/rails-single/config/boot.rb +4 -0
- data/examples/rails-single/config/cable.yml +17 -0
- data/examples/rails-single/config/cache.yml +16 -0
- data/examples/rails-single/config/credentials.yml.enc +1 -0
- data/examples/rails-single/config/database.yml +100 -0
- data/examples/rails-single/config/environment.rb +5 -0
- data/examples/rails-single/config/environments/development.rb +69 -0
- data/examples/rails-single/config/environments/production.rb +87 -0
- data/examples/rails-single/config/environments/test.rb +50 -0
- data/examples/rails-single/config/initializers/assets.rb +7 -0
- data/examples/rails-single/config/initializers/content_security_policy.rb +25 -0
- data/examples/rails-single/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails-single/config/initializers/inflections.rb +16 -0
- data/examples/rails-single/config/locales/en.yml +31 -0
- data/examples/rails-single/config/puma.rb +41 -0
- data/examples/rails-single/config/queue.yml +18 -0
- data/examples/rails-single/config/recurring.yml +15 -0
- data/examples/rails-single/config/routes.rb +4 -0
- data/examples/rails-single/config.ru +6 -0
- data/examples/rails-single/db/cable_schema.rb +11 -0
- data/examples/rails-single/db/cache_schema.rb +12 -0
- data/examples/rails-single/db/migrate/20251123095526_create_users.rb +10 -0
- data/examples/rails-single/db/queue_schema.rb +129 -0
- data/examples/rails-single/db/seeds.rb +9 -0
- data/examples/rails-single/deploy.yml +57 -0
- data/examples/rails-single/lib/tasks/.keep +0 -0
- data/examples/rails-single/log/.keep +0 -0
- data/examples/rails-single/package.json +17 -0
- data/examples/rails-single/public/400.html +114 -0
- data/examples/rails-single/public/404.html +114 -0
- data/examples/rails-single/public/406-unsupported-browser.html +114 -0
- data/examples/rails-single/public/422.html +114 -0
- data/examples/rails-single/public/500.html +114 -0
- data/examples/rails-single/public/icon.png +0 -0
- data/examples/rails-single/public/icon.svg +3 -0
- data/examples/rails-single/public/robots.txt +1 -0
- data/examples/rails-single/script/.keep +0 -0
- data/examples/rails-single/vendor/.keep +0 -0
- data/examples/rails-single/yarn.lock +188 -0
- data/exe/nvoi +6 -0
- data/lib/nvoi/cli.rb +190 -0
- data/lib/nvoi/cloudflare/client.rb +287 -0
- data/lib/nvoi/config/config.rb +248 -0
- data/lib/nvoi/config/env_resolver.rb +63 -0
- data/lib/nvoi/config/loader.rb +102 -0
- data/lib/nvoi/config/naming.rb +196 -0
- data/lib/nvoi/config/ssh_keys.rb +82 -0
- data/lib/nvoi/config/types.rb +274 -0
- data/lib/nvoi/constants.rb +59 -0
- data/lib/nvoi/credentials/crypto.rb +88 -0
- data/lib/nvoi/credentials/editor.rb +272 -0
- data/lib/nvoi/credentials/manager.rb +173 -0
- data/lib/nvoi/deployer/cleaner.rb +36 -0
- data/lib/nvoi/deployer/image_builder.rb +23 -0
- data/lib/nvoi/deployer/infrastructure.rb +126 -0
- data/lib/nvoi/deployer/orchestrator.rb +146 -0
- data/lib/nvoi/deployer/retry.rb +67 -0
- data/lib/nvoi/deployer/service_deployer.rb +311 -0
- data/lib/nvoi/deployer/tunnel_manager.rb +57 -0
- data/lib/nvoi/deployer/types.rb +8 -0
- data/lib/nvoi/errors.rb +67 -0
- data/lib/nvoi/k8s/renderer.rb +44 -0
- data/lib/nvoi/k8s/templates.rb +29 -0
- data/lib/nvoi/logger.rb +72 -0
- data/lib/nvoi/providers/aws.rb +403 -0
- data/lib/nvoi/providers/base.rb +111 -0
- data/lib/nvoi/providers/hetzner.rb +288 -0
- data/lib/nvoi/providers/hetzner_client.rb +170 -0
- data/lib/nvoi/remote/docker_manager.rb +203 -0
- data/lib/nvoi/remote/ssh_executor.rb +72 -0
- data/lib/nvoi/remote/volume_manager.rb +103 -0
- data/lib/nvoi/service/delete.rb +234 -0
- data/lib/nvoi/service/deploy.rb +80 -0
- data/lib/nvoi/service/exec.rb +144 -0
- data/lib/nvoi/service/provider.rb +36 -0
- data/lib/nvoi/steps/application_deployer.rb +26 -0
- data/lib/nvoi/steps/database_provisioner.rb +60 -0
- data/lib/nvoi/steps/k3s_cluster_setup.rb +105 -0
- data/lib/nvoi/steps/k3s_provisioner.rb +351 -0
- data/lib/nvoi/steps/server_provisioner.rb +43 -0
- data/lib/nvoi/steps/services_provisioner.rb +29 -0
- data/lib/nvoi/steps/tunnel_configurator.rb +66 -0
- data/lib/nvoi/steps/volume_provisioner.rb +154 -0
- data/lib/nvoi/version.rb +5 -0
- data/lib/nvoi.rb +79 -0
- data/templates/app-deployment.yaml.erb +102 -0
- data/templates/app-ingress.yaml.erb +20 -0
- data/templates/app-secret.yaml.erb +10 -0
- data/templates/app-service.yaml.erb +12 -0
- data/templates/db-statefulset.yaml.erb +76 -0
- data/templates/service-deployment.yaml.erb +91 -0
- data/templates/worker-deployment.yaml.erb +50 -0
- metadata +361 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Nvoi
|
|
6
|
+
module Config
|
|
7
|
+
# ResourceNamer handles resource naming and inference
|
|
8
|
+
class ResourceNamer
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Generate a container name prefix
|
|
14
|
+
def infer_container_prefix
|
|
15
|
+
base = infer_base_prefix
|
|
16
|
+
name = @config.deploy.application.name
|
|
17
|
+
|
|
18
|
+
prefix = name ? "#{base}-#{name}" : base
|
|
19
|
+
|
|
20
|
+
# Truncate to 63 chars (DNS limit) with hash for uniqueness
|
|
21
|
+
if prefix.length > 63
|
|
22
|
+
hash = hash_string(prefix)[0, 8]
|
|
23
|
+
max_len = 63 - hash.length - 1
|
|
24
|
+
prefix = "#{prefix[0, max_len]}-#{hash}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
prefix
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ============================================================================
|
|
31
|
+
# INFRASTRUCTURE RESOURCES
|
|
32
|
+
# ============================================================================
|
|
33
|
+
|
|
34
|
+
# ServerName returns the server name for a given group and index
|
|
35
|
+
def server_name(group, index)
|
|
36
|
+
"#{@config.deploy.application.name}-#{group}-#{index}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def firewall_name
|
|
40
|
+
"#{@config.container_prefix}-firewall"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def network_name
|
|
44
|
+
"#{@config.container_prefix}-network"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def docker_network_name
|
|
48
|
+
"#{@config.container_prefix}-docker-network"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ============================================================================
|
|
52
|
+
# DATABASE RESOURCES
|
|
53
|
+
# ============================================================================
|
|
54
|
+
|
|
55
|
+
def database_service_name
|
|
56
|
+
"db-#{@config.deploy.application.name}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def database_stateful_set_name
|
|
60
|
+
"db-#{@config.deploy.application.name}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def database_pvc_name
|
|
64
|
+
"data-db-#{@config.deploy.application.name}-0"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def database_secret_name
|
|
68
|
+
"db-secret-#{@config.deploy.application.name}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def database_pod_label
|
|
72
|
+
"app=db-#{@config.deploy.application.name}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ============================================================================
|
|
76
|
+
# KUBERNETES APP RESOURCES
|
|
77
|
+
# ============================================================================
|
|
78
|
+
|
|
79
|
+
def app_deployment_name(service_name)
|
|
80
|
+
"#{@config.deploy.application.name}-#{service_name}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def app_service_name(service_name)
|
|
84
|
+
"#{@config.deploy.application.name}-#{service_name}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def app_secret_name
|
|
88
|
+
"app-secret-#{@config.deploy.application.name}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def app_pvc_name(volume_name)
|
|
92
|
+
"#{@config.deploy.application.name}-#{volume_name}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def app_ingress_name(service_name)
|
|
96
|
+
"#{@config.deploy.application.name}-#{service_name}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def app_pod_label(service_name)
|
|
100
|
+
deployment_name = app_deployment_name(service_name)
|
|
101
|
+
"app=#{deployment_name}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def service_container_prefix(service_name)
|
|
105
|
+
"#{@config.container_prefix}-#{service_name}-"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ============================================================================
|
|
109
|
+
# CLOUDFLARE RESOURCES
|
|
110
|
+
# ============================================================================
|
|
111
|
+
|
|
112
|
+
def tunnel_name(service_name)
|
|
113
|
+
"#{@config.container_prefix}-#{service_name}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def cloudflared_deployment_name(service_name)
|
|
117
|
+
"cloudflared-#{service_name}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ============================================================================
|
|
121
|
+
# REGISTRY RESOURCES
|
|
122
|
+
# ============================================================================
|
|
123
|
+
|
|
124
|
+
def registry_deployment_name
|
|
125
|
+
"nvoi-registry"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def registry_service_name
|
|
129
|
+
"nvoi-registry"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ============================================================================
|
|
133
|
+
# DEPLOYMENT RESOURCES
|
|
134
|
+
# ============================================================================
|
|
135
|
+
|
|
136
|
+
def deployment_lock_file_path
|
|
137
|
+
"/tmp/nvoi-deploy-#{@config.container_prefix}.lock"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ============================================================================
|
|
141
|
+
# DOCKER IMAGE RESOURCES
|
|
142
|
+
# ============================================================================
|
|
143
|
+
|
|
144
|
+
def image_tag(timestamp)
|
|
145
|
+
"#{@config.container_prefix}:#{timestamp}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def latest_image_tag
|
|
149
|
+
"#{@config.container_prefix}:latest"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ============================================================================
|
|
153
|
+
# VOLUME RESOURCES
|
|
154
|
+
# ============================================================================
|
|
155
|
+
|
|
156
|
+
def volume_name(service_type, service_name, volume_key)
|
|
157
|
+
"#{@config.deploy.application.name}-#{service_type}-#{service_name}-#{volume_key}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def database_volume_name
|
|
161
|
+
"#{@config.deploy.application.name}-db-data"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def service_volume_name(service_name, volume_key)
|
|
165
|
+
"#{@config.deploy.application.name}-svc-#{service_name}-#{volume_key}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def app_volume_name(service_name, volume_key)
|
|
169
|
+
"#{@config.deploy.application.name}-app-#{service_name}-#{volume_key}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def hash_string(str)
|
|
175
|
+
Digest::SHA256.hexdigest(str)[0, 16]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def infer_base_prefix
|
|
179
|
+
output = `git config --get remote.origin.url 2>/dev/null`.strip
|
|
180
|
+
return "app" if output.empty?
|
|
181
|
+
|
|
182
|
+
# Extract username/repo from: git@github.com:user/repo.git or https://github.com/user/repo.git
|
|
183
|
+
repo_url = output.sub(/\.git$/, "")
|
|
184
|
+
parts = repo_url.split(%r{[/:]+})
|
|
185
|
+
|
|
186
|
+
if parts.length >= 2
|
|
187
|
+
username = parts[-2]
|
|
188
|
+
repo = parts[-1]
|
|
189
|
+
"#{username}-#{repo}"
|
|
190
|
+
else
|
|
191
|
+
"app"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Nvoi
|
|
7
|
+
module Config
|
|
8
|
+
# SSHKeyLoader handles SSH key loading from config content
|
|
9
|
+
# Keys are stored as content in deploy.enc, written to temp files for SSH usage
|
|
10
|
+
class SSHKeyLoader
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@temp_dir = nil
|
|
14
|
+
@private_key_path = nil
|
|
15
|
+
@public_key_path = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Load SSH keys from config content and write to temp files
|
|
19
|
+
def load_keys
|
|
20
|
+
ssh_keys = @config.deploy.application.ssh_keys
|
|
21
|
+
|
|
22
|
+
unless ssh_keys
|
|
23
|
+
raise ConfigError, "ssh_keys section is required in config. Run 'nvoi credentials edit' to generate keys."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless ssh_keys.private_key && !ssh_keys.private_key.empty?
|
|
27
|
+
raise ConfigError, "ssh_keys.private_key is required"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless ssh_keys.public_key && !ssh_keys.public_key.empty?
|
|
31
|
+
raise ConfigError, "ssh_keys.public_key is required"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create temp directory for keys
|
|
35
|
+
@temp_dir = Dir.mktmpdir("nvoi-ssh-")
|
|
36
|
+
|
|
37
|
+
# Write private key
|
|
38
|
+
@private_key_path = File.join(@temp_dir, "id_nvoi")
|
|
39
|
+
File.write(@private_key_path, ssh_keys.private_key)
|
|
40
|
+
File.chmod(0o600, @private_key_path)
|
|
41
|
+
|
|
42
|
+
# Write public key
|
|
43
|
+
@public_key_path = File.join(@temp_dir, "id_nvoi.pub")
|
|
44
|
+
File.write(@public_key_path, ssh_keys.public_key)
|
|
45
|
+
File.chmod(0o644, @public_key_path)
|
|
46
|
+
|
|
47
|
+
# Set config values
|
|
48
|
+
@config.ssh_key_path = @private_key_path
|
|
49
|
+
@config.ssh_public_key = ssh_keys.public_key.strip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Cleanup temp files
|
|
53
|
+
def cleanup
|
|
54
|
+
FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# Generate a new Ed25519 keypair using ssh-keygen
|
|
59
|
+
def generate_keypair
|
|
60
|
+
temp_dir = Dir.mktmpdir("nvoi-keygen-")
|
|
61
|
+
key_path = File.join(temp_dir, "id_nvoi")
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
result = system(
|
|
65
|
+
"ssh-keygen", "-t", "ed25519", "-N", "", "-C", "nvoi-deploy", "-f", key_path,
|
|
66
|
+
out: File::NULL, err: File::NULL
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
raise ConfigError, "Failed to generate SSH keypair (ssh-keygen not available?)" unless result
|
|
70
|
+
|
|
71
|
+
private_key = File.read(key_path)
|
|
72
|
+
public_key = File.read("#{key_path}.pub").strip
|
|
73
|
+
|
|
74
|
+
[private_key, public_key]
|
|
75
|
+
ensure
|
|
76
|
+
FileUtils.rm_rf(temp_dir)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Config
|
|
5
|
+
# DeployConfig represents the root deployment configuration
|
|
6
|
+
class DeployConfig
|
|
7
|
+
attr_accessor :application
|
|
8
|
+
|
|
9
|
+
def initialize(data = {})
|
|
10
|
+
@application = Application.new(data["application"] || {})
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Application contains application-level configuration
|
|
15
|
+
class Application
|
|
16
|
+
attr_accessor :name, :environment, :domain_provider, :compute_provider,
|
|
17
|
+
:keep_count, :servers, :app, :database, :services, :env,
|
|
18
|
+
:secrets, :ssh_keys
|
|
19
|
+
|
|
20
|
+
def initialize(data = {})
|
|
21
|
+
@name = data["name"]
|
|
22
|
+
@environment = data["environment"] || "production"
|
|
23
|
+
@domain_provider = DomainProviderConfig.new(data["domain_provider"] || {})
|
|
24
|
+
@compute_provider = ComputeProviderConfig.new(data["compute_provider"] || {})
|
|
25
|
+
@keep_count = data["keep_count"]&.to_i
|
|
26
|
+
@servers = parse_servers(data["servers"] || {})
|
|
27
|
+
@app = parse_app_config(data["app"] || {})
|
|
28
|
+
@database = data["database"] ? DatabaseConfig.new(data["database"]) : nil
|
|
29
|
+
@services = parse_services(data["services"] || {})
|
|
30
|
+
@env = data["env"] || {}
|
|
31
|
+
@secrets = data["secrets"] || {}
|
|
32
|
+
@ssh_keys = data["ssh_keys"] ? SSHKeyConfig.new(data["ssh_keys"]) : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse_servers(data)
|
|
38
|
+
data.transform_values { |v| ServerConfig.new(v || {}) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_app_config(data)
|
|
42
|
+
data.transform_values { |v| AppServiceConfig.new(v || {}) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse_services(data)
|
|
46
|
+
data.transform_values { |v| ServiceConfig.new(v || {}) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# DomainProviderConfig contains domain provider configuration
|
|
51
|
+
class DomainProviderConfig
|
|
52
|
+
attr_accessor :cloudflare
|
|
53
|
+
|
|
54
|
+
def initialize(data = {})
|
|
55
|
+
@cloudflare = data["cloudflare"] ? CloudflareConfig.new(data["cloudflare"]) : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ComputeProviderConfig contains compute provider configuration
|
|
60
|
+
class ComputeProviderConfig
|
|
61
|
+
attr_accessor :hetzner, :aws
|
|
62
|
+
|
|
63
|
+
def initialize(data = {})
|
|
64
|
+
@hetzner = data["hetzner"] ? HetznerConfig.new(data["hetzner"]) : nil
|
|
65
|
+
@aws = data["aws"] ? AWSConfig.new(data["aws"]) : nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# CloudflareConfig contains Cloudflare-specific configuration
|
|
70
|
+
class CloudflareConfig
|
|
71
|
+
attr_accessor :api_token, :account_id
|
|
72
|
+
|
|
73
|
+
def initialize(data = {})
|
|
74
|
+
@api_token = data["api_token"]
|
|
75
|
+
@account_id = data["account_id"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# HetznerConfig contains Hetzner-specific configuration
|
|
80
|
+
class HetznerConfig
|
|
81
|
+
attr_accessor :api_token, :server_type, :server_location
|
|
82
|
+
|
|
83
|
+
def initialize(data = {})
|
|
84
|
+
@api_token = data["api_token"]
|
|
85
|
+
@server_type = data["server_type"]
|
|
86
|
+
@server_location = data["server_location"]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# AWSConfig contains AWS-specific configuration
|
|
91
|
+
class AWSConfig
|
|
92
|
+
attr_accessor :access_key_id, :secret_access_key, :region, :instance_type
|
|
93
|
+
|
|
94
|
+
def initialize(data = {})
|
|
95
|
+
@access_key_id = data["access_key_id"]
|
|
96
|
+
@secret_access_key = data["secret_access_key"]
|
|
97
|
+
@region = data["region"]
|
|
98
|
+
@instance_type = data["instance_type"]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ServerConfig contains server instance configuration
|
|
103
|
+
class ServerConfig
|
|
104
|
+
attr_accessor :master, :type, :location, :count
|
|
105
|
+
|
|
106
|
+
def initialize(data = {})
|
|
107
|
+
@master = data["master"] || false
|
|
108
|
+
@type = data["type"]
|
|
109
|
+
@location = data["location"]
|
|
110
|
+
@count = data["count"]&.to_i || 1
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# AppServiceConfig defines a service in the app section
|
|
115
|
+
class AppServiceConfig
|
|
116
|
+
attr_accessor :servers, :domain, :subdomain, :port, :healthcheck,
|
|
117
|
+
:command, :pre_run_command, :env, :volumes
|
|
118
|
+
|
|
119
|
+
def initialize(data = {})
|
|
120
|
+
@servers = data["servers"] || []
|
|
121
|
+
@domain = data["domain"]
|
|
122
|
+
@subdomain = data["subdomain"]
|
|
123
|
+
@port = data["port"]&.to_i
|
|
124
|
+
@healthcheck = data["healthcheck"] ? HealthCheckConfig.new(data["healthcheck"]) : nil
|
|
125
|
+
@command = data["command"]
|
|
126
|
+
@pre_run_command = data["pre_run_command"]
|
|
127
|
+
@env = data["env"] || {}
|
|
128
|
+
@volumes = data["volumes"] || {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Convert to ServiceSpec
|
|
132
|
+
def to_service_spec(app_name, service_name, image_tag)
|
|
133
|
+
cmd = @command ? @command.split : []
|
|
134
|
+
|
|
135
|
+
spec = ServiceSpec.new(
|
|
136
|
+
name: "#{app_name}-#{service_name}",
|
|
137
|
+
image: image_tag,
|
|
138
|
+
port: @port,
|
|
139
|
+
command: cmd,
|
|
140
|
+
env: @env,
|
|
141
|
+
volumes: @volumes,
|
|
142
|
+
replicas: @port.nil? || @port.zero? ? 1 : 2,
|
|
143
|
+
healthcheck: @healthcheck,
|
|
144
|
+
stateful_set: false,
|
|
145
|
+
servers: @servers
|
|
146
|
+
)
|
|
147
|
+
spec
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# HealthCheckConfig defines health check configuration
|
|
152
|
+
class HealthCheckConfig
|
|
153
|
+
attr_accessor :type, :path, :port, :command, :interval, :timeout, :retries
|
|
154
|
+
|
|
155
|
+
def initialize(data = {})
|
|
156
|
+
@type = data["type"]
|
|
157
|
+
@path = data["path"]
|
|
158
|
+
@port = data["port"]&.to_i
|
|
159
|
+
@command = data["command"]
|
|
160
|
+
@interval = data["interval"]
|
|
161
|
+
@timeout = data["timeout"]
|
|
162
|
+
@retries = data["retries"]&.to_i
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# DatabaseConfig defines database configuration
|
|
167
|
+
class DatabaseConfig
|
|
168
|
+
attr_accessor :servers, :adapter, :url, :image, :volume, :secrets
|
|
169
|
+
|
|
170
|
+
def initialize(data = {})
|
|
171
|
+
@servers = data["servers"] || []
|
|
172
|
+
@adapter = data["adapter"]
|
|
173
|
+
@url = data["url"]
|
|
174
|
+
@image = data["image"]
|
|
175
|
+
@volume = data["volume"]
|
|
176
|
+
@secrets = data["secrets"] || {}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Convert to ServiceSpec
|
|
180
|
+
def to_service_spec(namer)
|
|
181
|
+
port = case @adapter&.downcase
|
|
182
|
+
when "mysql" then 3306
|
|
183
|
+
else 5432
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
vols = {}
|
|
187
|
+
vols["data"] = @volume if @volume
|
|
188
|
+
|
|
189
|
+
ServiceSpec.new(
|
|
190
|
+
name: namer.database_service_name,
|
|
191
|
+
image: @image,
|
|
192
|
+
port:,
|
|
193
|
+
env: nil,
|
|
194
|
+
volumes: vols,
|
|
195
|
+
replicas: 1,
|
|
196
|
+
stateful_set: true,
|
|
197
|
+
secrets: @secrets,
|
|
198
|
+
servers: @servers
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ServiceConfig defines a generic service
|
|
204
|
+
class ServiceConfig
|
|
205
|
+
attr_accessor :servers, :image, :command, :env, :volume
|
|
206
|
+
|
|
207
|
+
def initialize(data = {})
|
|
208
|
+
@servers = data["servers"] || []
|
|
209
|
+
@image = data["image"]
|
|
210
|
+
@command = data["command"]
|
|
211
|
+
@env = data["env"] || {}
|
|
212
|
+
@volume = data["volume"]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Convert to ServiceSpec
|
|
216
|
+
def to_service_spec(app_name, service_name)
|
|
217
|
+
cmd = @command ? @command.split : []
|
|
218
|
+
vols = {}
|
|
219
|
+
vols["data"] = @volume if @volume
|
|
220
|
+
|
|
221
|
+
# Infer port from image
|
|
222
|
+
port = case @image
|
|
223
|
+
when /redis/ then 6379
|
|
224
|
+
when /postgres/ then 5432
|
|
225
|
+
when /mysql/ then 3306
|
|
226
|
+
else 0
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
ServiceSpec.new(
|
|
230
|
+
name: "#{app_name}-#{service_name}",
|
|
231
|
+
image: @image,
|
|
232
|
+
port:,
|
|
233
|
+
command: cmd,
|
|
234
|
+
env: @env,
|
|
235
|
+
volumes: vols,
|
|
236
|
+
replicas: 1,
|
|
237
|
+
stateful_set: false,
|
|
238
|
+
servers: @servers
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# SSHKeyConfig defines SSH key content (stored in encrypted config)
|
|
244
|
+
class SSHKeyConfig
|
|
245
|
+
attr_accessor :private_key, :public_key
|
|
246
|
+
|
|
247
|
+
def initialize(data = {})
|
|
248
|
+
@private_key = data["private_key"]
|
|
249
|
+
@public_key = data["public_key"]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ServiceSpec is the CORE primitive - pure K8s deployment specification
|
|
254
|
+
class ServiceSpec
|
|
255
|
+
attr_accessor :name, :image, :port, :command, :env, :volumes, :replicas,
|
|
256
|
+
:healthcheck, :stateful_set, :secrets, :servers
|
|
257
|
+
|
|
258
|
+
def initialize(name:, image:, port: 0, command: [], env: nil, volumes: nil,
|
|
259
|
+
replicas: 1, healthcheck: nil, stateful_set: false, secrets: nil, servers: [])
|
|
260
|
+
@name = name
|
|
261
|
+
@image = image
|
|
262
|
+
@port = port
|
|
263
|
+
@command = command || []
|
|
264
|
+
@env = env || {}
|
|
265
|
+
@volumes = volumes || {}
|
|
266
|
+
@replicas = replicas
|
|
267
|
+
@healthcheck = healthcheck
|
|
268
|
+
@stateful_set = stateful_set
|
|
269
|
+
@secrets = secrets || {}
|
|
270
|
+
@servers = servers || []
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Constants
|
|
5
|
+
# Default deployment configuration file
|
|
6
|
+
DEFAULT_CONFIG_FILE = "deploy.enc"
|
|
7
|
+
|
|
8
|
+
# Network configuration
|
|
9
|
+
NETWORK_CIDR = "10.0.0.0/16"
|
|
10
|
+
SUBNET_CIDR = "10.0.1.0/24"
|
|
11
|
+
|
|
12
|
+
# Server configuration
|
|
13
|
+
DEFAULT_IMAGE = "ubuntu-24.04"
|
|
14
|
+
SERVER_READY_INTERVAL = 10 # seconds
|
|
15
|
+
SERVER_READY_MAX_ATTEMPTS = 60
|
|
16
|
+
SSH_READY_INTERVAL = 5 # seconds
|
|
17
|
+
SSH_READY_MAX_ATTEMPTS = 60
|
|
18
|
+
|
|
19
|
+
# Deployment configuration
|
|
20
|
+
MAX_DEPLOYMENT_RETRIES = 3
|
|
21
|
+
STALE_DEPLOYMENT_LOCK_AGE = 3600 # 1 hour in seconds
|
|
22
|
+
KEEP_COUNT_DEFAULT = 3
|
|
23
|
+
|
|
24
|
+
# K3s configuration
|
|
25
|
+
DEFAULT_K3S_VERSION = "v1.28.5+k3s1"
|
|
26
|
+
|
|
27
|
+
# Registry configuration
|
|
28
|
+
REGISTRY_PORT = 30500
|
|
29
|
+
REGISTRY_NAME = "nvoi-registry"
|
|
30
|
+
|
|
31
|
+
# Cloudflare
|
|
32
|
+
CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
|
|
33
|
+
TUNNEL_CONFIG_VERIFY_ATTEMPTS = 10
|
|
34
|
+
|
|
35
|
+
# Traffic verification
|
|
36
|
+
TRAFFIC_VERIFY_ATTEMPTS = 10
|
|
37
|
+
TRAFFIC_VERIFY_CONSECUTIVE = 3
|
|
38
|
+
TRAFFIC_VERIFY_INTERVAL = 5 # seconds
|
|
39
|
+
|
|
40
|
+
# Paths
|
|
41
|
+
DEPLOYMENT_LOCK_FILE = "/tmp/nvoi-deployment.lock"
|
|
42
|
+
APP_BASE_DIR = "/opt/nvoi"
|
|
43
|
+
|
|
44
|
+
# Database defaults
|
|
45
|
+
DATABASE_PORTS = {
|
|
46
|
+
"postgresql" => 5432,
|
|
47
|
+
"postgres" => 5432,
|
|
48
|
+
"mysql" => 3306,
|
|
49
|
+
"redis" => 6379
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Default database images
|
|
53
|
+
DATABASE_IMAGES = {
|
|
54
|
+
"postgresql" => "postgres:15-alpine",
|
|
55
|
+
"postgres" => "postgres:15-alpine",
|
|
56
|
+
"mysql" => "mysql:8.0"
|
|
57
|
+
}.freeze
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
module Credentials
|
|
5
|
+
# Crypto handles AES-256-GCM encryption/decryption
|
|
6
|
+
module Crypto
|
|
7
|
+
KEY_SIZE = 32 # 256 bits
|
|
8
|
+
NONCE_SIZE = 12 # GCM nonce size
|
|
9
|
+
KEY_HEX_LENGTH = KEY_SIZE * 2 # 64 hex characters
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Generate a new random 32-byte key and return it as hex string
|
|
13
|
+
def generate_key
|
|
14
|
+
SecureRandom.hex(KEY_SIZE)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Encrypt plaintext using AES-256-GCM with the provided hex-encoded key
|
|
18
|
+
# Returns: [12-byte nonce][ciphertext][16-byte auth tag]
|
|
19
|
+
def encrypt(plaintext, hex_key)
|
|
20
|
+
key = decode_key(hex_key)
|
|
21
|
+
|
|
22
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
23
|
+
cipher.encrypt
|
|
24
|
+
cipher.key = key
|
|
25
|
+
|
|
26
|
+
# Generate random nonce
|
|
27
|
+
nonce = SecureRandom.random_bytes(NONCE_SIZE)
|
|
28
|
+
cipher.iv = nonce
|
|
29
|
+
|
|
30
|
+
# Encrypt
|
|
31
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
32
|
+
auth_tag = cipher.auth_tag
|
|
33
|
+
|
|
34
|
+
# Return: nonce + ciphertext + auth_tag
|
|
35
|
+
nonce + ciphertext + auth_tag
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Decrypt ciphertext using AES-256-GCM with the provided hex-encoded key
|
|
39
|
+
# Expects format: [12-byte nonce][ciphertext][16-byte auth tag]
|
|
40
|
+
def decrypt(ciphertext, hex_key)
|
|
41
|
+
key = decode_key(hex_key)
|
|
42
|
+
|
|
43
|
+
min_size = NONCE_SIZE + 16 # nonce + auth tag
|
|
44
|
+
if ciphertext.bytesize < min_size
|
|
45
|
+
raise DecryptionError, "ciphertext too short: need at least #{min_size} bytes, got #{ciphertext.bytesize}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Extract nonce and auth tag
|
|
49
|
+
nonce = ciphertext[0, NONCE_SIZE]
|
|
50
|
+
auth_tag = ciphertext[-16, 16]
|
|
51
|
+
encrypted_data = ciphertext[NONCE_SIZE...-16]
|
|
52
|
+
|
|
53
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
54
|
+
cipher.decrypt
|
|
55
|
+
cipher.key = key
|
|
56
|
+
cipher.iv = nonce
|
|
57
|
+
cipher.auth_tag = auth_tag
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
cipher.update(encrypted_data) + cipher.final
|
|
61
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
62
|
+
raise DecryptionError, "decryption failed (wrong key or corrupted data): #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Validate a hex-encoded key
|
|
67
|
+
def validate_key(hex_key)
|
|
68
|
+
unless hex_key.length == KEY_HEX_LENGTH
|
|
69
|
+
raise InvalidKeyError, "invalid key length: expected #{KEY_HEX_LENGTH} hex characters, got #{hex_key.length}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless hex_key.match?(/\A[0-9a-fA-F]+\z/)
|
|
73
|
+
raise InvalidKeyError, "invalid hex key: contains non-hex characters"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def decode_key(hex_key)
|
|
82
|
+
validate_key(hex_key)
|
|
83
|
+
[hex_key].pack("H*")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|