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.
Files changed (130) 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/_target.md +79 -0
  15. data/.claude/todo/scaleway.impl.md +644 -0
  16. data/.claude/todo/scaleway.reference.md +520 -0
  17. data/Gemfile +1 -0
  18. data/Gemfile.lock +12 -2
  19. data/doc/config-schema.yaml +44 -11
  20. data/examples/golang/deploy.enc +0 -0
  21. data/examples/golang/main.go +18 -0
  22. data/exe/nvoi +3 -1
  23. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  24. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  25. data/lib/nvoi/cli/db/command.rb +308 -0
  26. data/lib/nvoi/cli/delete/command.rb +75 -0
  27. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  28. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
  29. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  30. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  31. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  32. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  33. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  34. data/lib/nvoi/cli/deploy/command.rb +184 -0
  35. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  36. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  37. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
  38. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
  39. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  40. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  41. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  42. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
  43. data/lib/nvoi/cli/exec/command.rb +173 -0
  44. data/lib/nvoi/cli.rb +83 -142
  45. data/lib/nvoi/config_api/actions/app.rb +53 -0
  46. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  47. data/lib/nvoi/config_api/actions/database.rb +70 -0
  48. data/lib/nvoi/config_api/actions/env.rb +32 -0
  49. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  50. data/lib/nvoi/config_api/actions/server.rb +66 -0
  51. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  52. data/lib/nvoi/config_api/base.rb +44 -0
  53. data/lib/nvoi/config_api/result.rb +26 -0
  54. data/lib/nvoi/config_api.rb +70 -0
  55. data/lib/nvoi/errors.rb +68 -50
  56. data/lib/nvoi/external/cloud/aws.rb +425 -0
  57. data/lib/nvoi/external/cloud/base.rb +99 -0
  58. data/lib/nvoi/external/cloud/factory.rb +48 -0
  59. data/lib/nvoi/external/cloud/hetzner.rb +376 -0
  60. data/lib/nvoi/external/cloud/scaleway.rb +533 -0
  61. data/lib/nvoi/external/cloud.rb +15 -0
  62. data/lib/nvoi/external/containerd.rb +82 -0
  63. data/lib/nvoi/external/database/mysql.rb +84 -0
  64. data/lib/nvoi/external/database/postgres.rb +82 -0
  65. data/lib/nvoi/external/database/provider.rb +65 -0
  66. data/lib/nvoi/external/database/sqlite.rb +72 -0
  67. data/lib/nvoi/external/database.rb +22 -0
  68. data/lib/nvoi/external/dns/cloudflare.rb +292 -0
  69. data/lib/nvoi/external/kubectl.rb +65 -0
  70. data/lib/nvoi/external/ssh.rb +106 -0
  71. data/lib/nvoi/objects/config_override.rb +60 -0
  72. data/lib/nvoi/objects/configuration.rb +463 -0
  73. data/lib/nvoi/objects/database.rb +56 -0
  74. data/lib/nvoi/objects/dns.rb +14 -0
  75. data/lib/nvoi/objects/firewall.rb +11 -0
  76. data/lib/nvoi/objects/network.rb +11 -0
  77. data/lib/nvoi/objects/server.rb +14 -0
  78. data/lib/nvoi/objects/service_spec.rb +26 -0
  79. data/lib/nvoi/objects/tunnel.rb +14 -0
  80. data/lib/nvoi/objects/volume.rb +17 -0
  81. data/lib/nvoi/utils/config_loader.rb +172 -0
  82. data/lib/nvoi/utils/constants.rb +61 -0
  83. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  84. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  85. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  86. data/lib/nvoi/utils/logger.rb +84 -0
  87. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
  88. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  89. data/lib/nvoi/utils/templates.rb +62 -0
  90. data/lib/nvoi/version.rb +1 -1
  91. data/lib/nvoi.rb +10 -54
  92. data/templates/error-backend.yaml.erb +134 -0
  93. metadata +97 -44
  94. data/examples/golang/deploy.yml +0 -54
  95. data/lib/nvoi/cloudflare/client.rb +0 -287
  96. data/lib/nvoi/config/config.rb +0 -248
  97. data/lib/nvoi/config/loader.rb +0 -102
  98. data/lib/nvoi/config/ssh_keys.rb +0 -82
  99. data/lib/nvoi/config/types.rb +0 -274
  100. data/lib/nvoi/constants.rb +0 -59
  101. data/lib/nvoi/credentials/editor.rb +0 -272
  102. data/lib/nvoi/deployer/cleaner.rb +0 -36
  103. data/lib/nvoi/deployer/image_builder.rb +0 -23
  104. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  105. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  106. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  107. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  108. data/lib/nvoi/deployer/types.rb +0 -8
  109. data/lib/nvoi/k8s/renderer.rb +0 -44
  110. data/lib/nvoi/k8s/templates.rb +0 -29
  111. data/lib/nvoi/logger.rb +0 -72
  112. data/lib/nvoi/providers/aws.rb +0 -403
  113. data/lib/nvoi/providers/base.rb +0 -111
  114. data/lib/nvoi/providers/hetzner.rb +0 -288
  115. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  116. data/lib/nvoi/remote/docker_manager.rb +0 -203
  117. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  118. data/lib/nvoi/remote/volume_manager.rb +0 -103
  119. data/lib/nvoi/service/delete.rb +0 -234
  120. data/lib/nvoi/service/deploy.rb +0 -80
  121. data/lib/nvoi/service/exec.rb +0 -144
  122. data/lib/nvoi/service/provider.rb +0 -36
  123. data/lib/nvoi/steps/application_deployer.rb +0 -26
  124. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  125. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  126. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  127. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  128. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  129. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  130. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Delete
6
+ module Steps
7
+ # TeardownVolume handles volume deletion after detachment
8
+ class TeardownVolume
9
+ def initialize(config, provider, log)
10
+ @config = config
11
+ @provider = provider
12
+ @log = log
13
+ @namer = config.namer
14
+ end
15
+
16
+ def run
17
+ volume_names = collect_volume_names
18
+ return if volume_names.empty?
19
+
20
+ @log.info "Deleting %d volume(s)", volume_names.size
21
+
22
+ volume_names.each do |vol_name|
23
+ delete_volume(vol_name)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def collect_volume_names
30
+ names = []
31
+
32
+ @config.deploy.application.servers.each do |server_name, server_config|
33
+ next unless server_config.volumes && !server_config.volumes.empty?
34
+
35
+ server_config.volumes.each_key do |vol_name|
36
+ names << @namer.server_volume_name(server_name, vol_name)
37
+ end
38
+ end
39
+
40
+ names
41
+ end
42
+
43
+ def delete_volume(vol_name)
44
+ @log.info "Deleting volume: %s", vol_name
45
+
46
+ volume = @provider.get_volume_by_name(vol_name)
47
+ unless volume
48
+ @log.info "Volume not found: %s", vol_name
49
+ return
50
+ end
51
+
52
+ @provider.delete_volume(volume.id)
53
+ @log.success "Volume deleted: %s", vol_name
54
+ rescue StandardError => e
55
+ @log.warning "Failed to delete volume %s: %s", vol_name, e.message
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ # Command orchestrates the full deployment process
7
+ class Command
8
+ def initialize(options)
9
+ @options = options
10
+ @log = Nvoi.logger
11
+ end
12
+
13
+ def run
14
+ @log.info "Deploy CLI %s", VERSION
15
+
16
+ # Load configuration
17
+ config_path = resolve_config_path
18
+ working_dir = @options[:dir] || "."
19
+ dockerfile_path = @options[:dockerfile_path] || File.join(working_dir, "Dockerfile")
20
+
21
+ @config = Utils::ConfigLoader.load(config_path)
22
+
23
+ # Apply branch override if specified
24
+ apply_branch_override if @options[:branch]
25
+
26
+ # Initialize cloud provider
27
+ @provider = External::Cloud.for(@config)
28
+ validate_provider_config
29
+
30
+ @log.info "Using %s Cloud provider", @config.provider_name
31
+ @log.info "Starting deployment"
32
+ @log.separator
33
+
34
+ # Step 1: Provision infrastructure (network, servers)
35
+ main_server_ip = provision_infrastructure
36
+
37
+ # Step 2: Configure Cloudflare tunnels
38
+ tunnels = configure_tunnels
39
+
40
+ # Step 3: Deploy application
41
+ deploy_application(main_server_ip, tunnels, working_dir)
42
+
43
+ # Success
44
+ @log.separator
45
+ @log.success "Deployment complete"
46
+
47
+ # Log service URLs
48
+ tunnels.each do |tunnel|
49
+ @log.info "Service %s: https://%s", tunnel.service_name, tunnel.hostname
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def resolve_config_path
56
+ config_path = @options[:config] || "deploy.enc"
57
+ working_dir = @options[:dir]
58
+
59
+ if config_path == "deploy.enc" && working_dir && working_dir != "."
60
+ File.join(working_dir, "deploy.enc")
61
+ else
62
+ config_path
63
+ end
64
+ end
65
+
66
+ def apply_branch_override
67
+ branch = @options[:branch]
68
+ return if branch.nil? || branch.empty?
69
+
70
+ override = Objects::ConfigOverride.new(branch:)
71
+ override.apply(@config)
72
+ end
73
+
74
+ def validate_provider_config
75
+ case @config.provider_name
76
+ when "hetzner"
77
+ h = @config.hetzner
78
+ @provider.validate_credentials
79
+ @provider.validate_instance_type(h.server_type)
80
+ @provider.validate_region(h.server_location)
81
+ when "aws"
82
+ a = @config.aws
83
+ @provider.validate_credentials
84
+ @provider.validate_instance_type(a.instance_type)
85
+ @provider.validate_region(a.region)
86
+ when "scaleway"
87
+ s = @config.scaleway
88
+ @provider.validate_credentials
89
+ @provider.validate_instance_type(s.server_type)
90
+ @provider.validate_region(s.zone)
91
+ end
92
+ end
93
+
94
+ def provision_infrastructure
95
+ require_relative "steps/provision_network"
96
+ require_relative "steps/provision_server"
97
+ require_relative "steps/provision_volume"
98
+ require_relative "steps/setup_k3s"
99
+
100
+ # Step 1: Provision network and firewall
101
+ network, firewall = Steps::ProvisionNetwork.new(@config, @provider, @log).run
102
+
103
+ # Step 2: Provision servers
104
+ main_server_ip = Steps::ProvisionServer.new(@config, @provider, @log, network, firewall).run
105
+
106
+ # Step 3: Provision and mount volumes
107
+ Steps::ProvisionVolume.new(@config, @provider, @log).run
108
+
109
+ # Step 4: Setup K3s cluster
110
+ Steps::SetupK3s.new(@config, @provider, @log, main_server_ip).run
111
+
112
+ main_server_ip
113
+ end
114
+
115
+ def configure_tunnels
116
+ require_relative "steps/configure_tunnel"
117
+ Steps::ConfigureTunnel.new(@config, @log).run
118
+ end
119
+
120
+ def deploy_application(server_ip, tunnels, working_dir)
121
+ require_relative "steps/build_image"
122
+ require_relative "steps/deploy_service"
123
+ require_relative "steps/cleanup_images"
124
+
125
+ ssh = External::Ssh.new(server_ip, @config.ssh_key_path)
126
+
127
+ # Acquire deployment lock
128
+ acquire_lock(ssh)
129
+
130
+ begin
131
+ # Build and push image
132
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
133
+ image_tag = @config.namer.image_tag(timestamp)
134
+
135
+ Steps::BuildImage.new(@config, ssh, @log).run(working_dir, image_tag)
136
+
137
+ # Deploy all services
138
+ Steps::DeployService.new(@config, ssh, tunnels, @log).run(image_tag, timestamp)
139
+
140
+ # Cleanup old images
141
+ Steps::CleanupImages.new(@config, ssh, @log).run(timestamp)
142
+ ensure
143
+ release_lock(ssh)
144
+ end
145
+ end
146
+
147
+ def acquire_lock(ssh)
148
+ lock_file = @config.namer.deployment_lock_file_path
149
+
150
+ output = ssh.execute("test -f #{lock_file} && cat #{lock_file} || echo ''")
151
+ output = output.strip
152
+
153
+ unless output.empty?
154
+ timestamp = output.to_i
155
+ if timestamp > 0
156
+ lock_time = Time.at(timestamp)
157
+ age = Time.now - lock_time
158
+
159
+ if age < Utils::Constants::STALE_DEPLOYMENT_LOCK_AGE
160
+ raise Errors::DeploymentError.new(
161
+ "lock",
162
+ "deployment already in progress (started #{age.round}s ago). Wait or remove lock file: #{lock_file}"
163
+ )
164
+ end
165
+
166
+ @log.warning "Removing stale deployment lock (age: #{age.round}s)"
167
+ end
168
+ end
169
+
170
+ ssh.execute("echo #{Time.now.to_i} > #{lock_file}")
171
+ @log.info "Deployment lock acquired: %s", lock_file
172
+ end
173
+
174
+ def release_lock(ssh)
175
+ lock_file = @config.namer.deployment_lock_file_path
176
+ @log.info "Releasing deployment lock"
177
+ ssh.execute("rm -f #{lock_file}")
178
+ rescue StandardError
179
+ # Ignore errors during lock release
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ module Steps
7
+ # BuildImage handles Docker image building and pushing to cluster
8
+ class BuildImage
9
+ def initialize(config, ssh, log)
10
+ @config = config
11
+ @ssh = ssh
12
+ @log = log
13
+ end
14
+
15
+ def run(working_dir, image_tag)
16
+ @log.info "Building Docker image: %s", image_tag
17
+
18
+ containerd = External::Containerd.new(@ssh)
19
+ containerd.build_and_deploy_image(working_dir, image_tag, cache_from: @config.namer.latest_image_tag)
20
+
21
+ @log.success "Image built: %s", image_tag
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ module Steps
7
+ # CleanupImages handles cleanup of old container images
8
+ class CleanupImages
9
+ def initialize(config, ssh, log)
10
+ @config = config
11
+ @ssh = ssh
12
+ @log = log
13
+ end
14
+
15
+ def run(current_tag)
16
+ keep_count = @config.keep_count_value
17
+ prefix = @config.container_prefix
18
+
19
+ @log.info "Cleaning up old images (keeping %d)", keep_count
20
+
21
+ containerd = External::Containerd.new(@ssh)
22
+
23
+ # List all images
24
+ all_tags = containerd.list_images("#{prefix}:*")
25
+
26
+ # Sort by tag (timestamp), keep newest
27
+ sorted_tags = all_tags.sort.reverse
28
+ keep_tags = sorted_tags.take(keep_count)
29
+
30
+ # Make sure current tag is kept
31
+ keep_tags << current_tag unless keep_tags.include?(current_tag)
32
+ keep_tags << "latest"
33
+
34
+ containerd.cleanup_old_images(prefix, keep_tags.uniq)
35
+
36
+ @log.success "Old images cleaned up"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ class Cli
5
+ module Deploy
6
+ module Steps
7
+ # ConfigureTunnel handles Cloudflare tunnel setup for services
8
+ class ConfigureTunnel
9
+ def initialize(config, log)
10
+ @config = config
11
+ @log = log
12
+
13
+ cf = config.cloudflare
14
+ @cf_client = External::Dns::Cloudflare.new(cf.api_token, cf.account_id)
15
+ end
16
+
17
+ def run
18
+ @log.info "Configuring Cloudflare tunnels"
19
+
20
+ tunnels = []
21
+
22
+ @config.deploy.application.app.each do |service_name, service_config|
23
+ next unless service_config.domain && !service_config.domain.empty?
24
+ next unless service_config.port && service_config.port.positive?
25
+ next if service_config.subdomain.nil?
26
+
27
+ tunnel_info = configure_service_tunnel(service_name, service_config)
28
+ tunnels << tunnel_info
29
+ end
30
+
31
+ @log.success "All tunnels configured (%d)", tunnels.size
32
+ tunnels
33
+ end
34
+
35
+ private
36
+
37
+ def configure_service_tunnel(service_name, service_config)
38
+ tunnel_name = @config.namer.tunnel_name(service_name)
39
+ hostname = Utils::Namer.build_hostname(service_config.subdomain, service_config.domain)
40
+
41
+ # Service URL points to the NGINX Ingress Controller
42
+ service_url = "http://ingress-nginx-controller.ingress-nginx.svc.cluster.local:80"
43
+
44
+ tunnel = setup_tunnel(tunnel_name, hostname, service_url, service_config.domain)
45
+
46
+ Objects::Tunnel::Info.new(
47
+ service_name:,
48
+ hostname:,
49
+ tunnel_id: tunnel.tunnel_id,
50
+ tunnel_token: tunnel.tunnel_token
51
+ )
52
+ end
53
+
54
+ def setup_tunnel(tunnel_name, hostname, service_url, domain)
55
+ @log.info "Setting up tunnel: %s -> %s", tunnel_name, hostname
56
+
57
+ # Find or create tunnel
58
+ tunnel = @cf_client.find_tunnel(tunnel_name)
59
+
60
+ if tunnel
61
+ @log.info "Using existing tunnel: %s", tunnel_name
62
+ else
63
+ @log.info "Creating new tunnel: %s", tunnel_name
64
+ tunnel = @cf_client.create_tunnel(tunnel_name)
65
+ end
66
+
67
+ # Get tunnel token
68
+ token = tunnel.token
69
+ if token.nil? || token.empty?
70
+ token = @cf_client.get_tunnel_token(tunnel.id)
71
+ end
72
+
73
+ # Configure tunnel ingress
74
+ @log.info "Configuring tunnel ingress: %s -> %s", hostname, service_url
75
+ @cf_client.update_tunnel_configuration(tunnel.id, hostname, service_url)
76
+
77
+ # Verify configuration propagated
78
+ @log.info "Verifying tunnel configuration..."
79
+ @cf_client.verify_tunnel_configuration(tunnel.id, hostname, service_url, Utils::Constants::TUNNEL_CONFIG_VERIFY_ATTEMPTS)
80
+
81
+ # Create DNS record
82
+ @log.info "Creating DNS CNAME record: %s", hostname
83
+ zone = @cf_client.find_zone(domain)
84
+ raise Errors::CloudflareError, "zone not found: #{domain}" unless zone
85
+
86
+ tunnel_cname = "#{tunnel.id}.cfargotunnel.com"
87
+ @cf_client.create_or_update_dns_record(zone.id, hostname, "CNAME", tunnel_cname, proxied: true)
88
+
89
+ @log.success "Tunnel configured: %s", tunnel_name
90
+
91
+ Objects::Tunnel::Info.new(
92
+ tunnel_id: tunnel.id,
93
+ tunnel_token: token
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end