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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/12-cli-db-command.md +128 -0
  15. data/.claude/todo/refactor/_target.md +79 -0
  16. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  17. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  18. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  19. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  20. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  21. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  22. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  23. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  24. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  25. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  26. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  27. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  28. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  29. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  30. data/.claude/todo/scaleway.impl.md +644 -0
  31. data/.claude/todo/scaleway.reference.md +520 -0
  32. data/.claude/todos.md +550 -0
  33. data/Gemfile +6 -0
  34. data/Gemfile.lock +46 -5
  35. data/Rakefile +1 -1
  36. data/doc/config-schema.yaml +44 -11
  37. data/examples/golang/deploy.enc +0 -0
  38. data/examples/golang/main.go +18 -0
  39. data/exe/nvoi +3 -1
  40. data/ingest +0 -0
  41. data/lib/nvoi/cli/config/command.rb +219 -0
  42. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  43. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  44. data/lib/nvoi/cli/db/command.rb +308 -0
  45. data/lib/nvoi/cli/delete/command.rb +75 -0
  46. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  47. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
  48. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  49. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  50. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  51. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  52. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  53. data/lib/nvoi/cli/deploy/command.rb +184 -0
  54. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  55. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  56. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
  57. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
  58. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  59. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  60. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  61. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
  62. data/lib/nvoi/cli/exec/command.rb +173 -0
  63. data/lib/nvoi/cli/logs/command.rb +66 -0
  64. data/lib/nvoi/cli/onboard/command.rb +761 -0
  65. data/lib/nvoi/cli/unlock/command.rb +72 -0
  66. data/lib/nvoi/cli.rb +339 -141
  67. data/lib/nvoi/config_api/actions/app.rb +53 -0
  68. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  69. data/lib/nvoi/config_api/actions/database.rb +70 -0
  70. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  71. data/lib/nvoi/config_api/actions/env.rb +32 -0
  72. data/lib/nvoi/config_api/actions/init.rb +67 -0
  73. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  74. data/lib/nvoi/config_api/actions/server.rb +66 -0
  75. data/lib/nvoi/config_api/actions/service.rb +52 -0
  76. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  77. data/lib/nvoi/config_api/base.rb +38 -0
  78. data/lib/nvoi/config_api/result.rb +26 -0
  79. data/lib/nvoi/config_api.rb +93 -0
  80. data/lib/nvoi/errors.rb +68 -50
  81. data/lib/nvoi/external/cloud/aws.rb +450 -0
  82. data/lib/nvoi/external/cloud/base.rb +99 -0
  83. data/lib/nvoi/external/cloud/factory.rb +48 -0
  84. data/lib/nvoi/external/cloud/hetzner.rb +402 -0
  85. data/lib/nvoi/external/cloud/scaleway.rb +559 -0
  86. data/lib/nvoi/external/cloud.rb +15 -0
  87. data/lib/nvoi/external/containerd.rb +86 -0
  88. data/lib/nvoi/external/database/mysql.rb +84 -0
  89. data/lib/nvoi/external/database/postgres.rb +82 -0
  90. data/lib/nvoi/external/database/provider.rb +65 -0
  91. data/lib/nvoi/external/database/sqlite.rb +72 -0
  92. data/lib/nvoi/external/database.rb +22 -0
  93. data/lib/nvoi/external/dns/cloudflare.rb +310 -0
  94. data/lib/nvoi/external/kubectl.rb +65 -0
  95. data/lib/nvoi/external/ssh.rb +106 -0
  96. data/lib/nvoi/objects/config_override.rb +60 -0
  97. data/lib/nvoi/objects/configuration.rb +483 -0
  98. data/lib/nvoi/objects/database.rb +56 -0
  99. data/lib/nvoi/objects/dns.rb +14 -0
  100. data/lib/nvoi/objects/firewall.rb +11 -0
  101. data/lib/nvoi/objects/network.rb +11 -0
  102. data/lib/nvoi/objects/server.rb +14 -0
  103. data/lib/nvoi/objects/service_spec.rb +26 -0
  104. data/lib/nvoi/objects/tunnel.rb +14 -0
  105. data/lib/nvoi/objects/volume.rb +17 -0
  106. data/lib/nvoi/utils/config_loader.rb +172 -0
  107. data/lib/nvoi/utils/constants.rb +61 -0
  108. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  109. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  110. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  111. data/lib/nvoi/utils/logger.rb +84 -0
  112. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
  113. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  114. data/lib/nvoi/utils/templates.rb +62 -0
  115. data/lib/nvoi/version.rb +1 -1
  116. data/lib/nvoi.rb +27 -55
  117. data/templates/app-ingress.yaml.erb +3 -1
  118. data/templates/error-backend.yaml.erb +134 -0
  119. metadata +121 -44
  120. data/examples/golang/deploy.yml +0 -54
  121. data/lib/nvoi/cloudflare/client.rb +0 -287
  122. data/lib/nvoi/config/config.rb +0 -248
  123. data/lib/nvoi/config/loader.rb +0 -102
  124. data/lib/nvoi/config/ssh_keys.rb +0 -82
  125. data/lib/nvoi/config/types.rb +0 -274
  126. data/lib/nvoi/constants.rb +0 -59
  127. data/lib/nvoi/credentials/editor.rb +0 -272
  128. data/lib/nvoi/deployer/cleaner.rb +0 -36
  129. data/lib/nvoi/deployer/image_builder.rb +0 -23
  130. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  131. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  132. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  133. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  134. data/lib/nvoi/deployer/types.rb +0 -8
  135. data/lib/nvoi/k8s/renderer.rb +0 -44
  136. data/lib/nvoi/k8s/templates.rb +0 -29
  137. data/lib/nvoi/logger.rb +0 -72
  138. data/lib/nvoi/providers/aws.rb +0 -403
  139. data/lib/nvoi/providers/base.rb +0 -111
  140. data/lib/nvoi/providers/hetzner.rb +0 -288
  141. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  142. data/lib/nvoi/remote/docker_manager.rb +0 -203
  143. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  144. data/lib/nvoi/remote/volume_manager.rb +0 -103
  145. data/lib/nvoi/service/delete.rb +0 -234
  146. data/lib/nvoi/service/deploy.rb +0 -80
  147. data/lib/nvoi/service/exec.rb +0 -144
  148. data/lib/nvoi/service/provider.rb +0 -36
  149. data/lib/nvoi/steps/application_deployer.rb +0 -26
  150. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  151. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  152. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  153. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  154. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  155. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  156. 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
+ # DNS-related structs
6
+ module Dns
7
+ # Zone represents a Cloudflare DNS zone
8
+ Zone = Struct.new(:id, :name, keyword_init: true)
9
+
10
+ # Record represents a Cloudflare DNS record
11
+ Record = Struct.new(:id, :type, :name, :content, :proxied, :ttl, keyword_init: true)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Objects
5
+ # Firewall-related structs
6
+ module Firewall
7
+ # Record represents a firewall configuration
8
+ Record = Struct.new(:id, :name, keyword_init: true)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Objects
5
+ # Network-related structs
6
+ module Network
7
+ # Record represents a virtual network
8
+ Record = Struct.new(:id, :name, :ip_range, keyword_init: true)
9
+ end
10
+ end
11
+ end
@@ -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 Credentials
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
- # Manager handles encrypted credentials operations
11
- class Manager
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 manager
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 manager for initial setup (no existing files required)
27
+ # Create a store for initial setup (no existing files required)
28
28
  def self.for_init(working_dir)
29
- manager = allocate
30
- manager.instance_variable_set(:@working_dir, working_dir)
31
- manager.instance_variable_set(:@encrypted_path, File.join(working_dir, DEFAULT_ENCRYPTED_FILE))
32
- manager.instance_variable_set(:@key_path, nil)
33
- manager.instance_variable_set(:@master_key, nil)
34
- manager
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 manager has a master key loaded
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 Credentials
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 Config
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"] = "sqlite://data/db/production.sqlite3"
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