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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +19 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +151 -0
  5. data/Makefile +26 -0
  6. data/Rakefile +16 -0
  7. data/doc/config-schema.yaml +357 -0
  8. data/examples/apex-wildcard/deploy.yml +68 -0
  9. data/examples/golang/.gitignore +19 -0
  10. data/examples/golang/Dockerfile +43 -0
  11. data/examples/golang/README.md +59 -0
  12. data/examples/golang/deploy.enc +0 -0
  13. data/examples/golang/deploy.yml +54 -0
  14. data/examples/golang/go.mod +39 -0
  15. data/examples/golang/go.sum +96 -0
  16. data/examples/golang/main.go +177 -0
  17. data/examples/golang/models/user.go +17 -0
  18. data/examples/golang-postgres-multi/.gitignore +18 -0
  19. data/examples/golang-postgres-multi/Dockerfile +39 -0
  20. data/examples/golang-postgres-multi/README.md +211 -0
  21. data/examples/golang-postgres-multi/deploy.yml +67 -0
  22. data/examples/golang-postgres-multi/go.mod +45 -0
  23. data/examples/golang-postgres-multi/go.sum +108 -0
  24. data/examples/golang-postgres-multi/main.go +197 -0
  25. data/examples/golang-postgres-multi/models/user.go +17 -0
  26. data/examples/postgres-multi/.env.production.example +11 -0
  27. data/examples/postgres-multi/README.md +112 -0
  28. data/examples/postgres-multi/deploy.yml +74 -0
  29. data/examples/postgres-single/.env.production.example +11 -0
  30. data/examples/postgres-single/.gitignore +15 -0
  31. data/examples/postgres-single/Dockerfile +35 -0
  32. data/examples/postgres-single/README.md +76 -0
  33. data/examples/postgres-single/deploy.yml +56 -0
  34. data/examples/postgres-single/go.mod +45 -0
  35. data/examples/postgres-single/go.sum +108 -0
  36. data/examples/postgres-single/main.go +184 -0
  37. data/examples/rails-single/.dockerignore +51 -0
  38. data/examples/rails-single/.env.production.example +11 -0
  39. data/examples/rails-single/.github/dependabot.yml +12 -0
  40. data/examples/rails-single/.github/workflows/ci.yml +39 -0
  41. data/examples/rails-single/.gitignore +20 -0
  42. data/examples/rails-single/.node-version +1 -0
  43. data/examples/rails-single/.rubocop.yml +8 -0
  44. data/examples/rails-single/.ruby-version +1 -0
  45. data/examples/rails-single/Dockerfile +86 -0
  46. data/examples/rails-single/Gemfile +56 -0
  47. data/examples/rails-single/Gemfile.lock +350 -0
  48. data/examples/rails-single/Procfile.dev +3 -0
  49. data/examples/rails-single/README.md +17 -0
  50. data/examples/rails-single/Rakefile +6 -0
  51. data/examples/rails-single/app/assets/builds/.keep +0 -0
  52. data/examples/rails-single/app/assets/images/.keep +0 -0
  53. data/examples/rails-single/app/assets/stylesheets/application.tailwind.css +1 -0
  54. data/examples/rails-single/app/controllers/application_controller.rb +4 -0
  55. data/examples/rails-single/app/controllers/concerns/.keep +0 -0
  56. data/examples/rails-single/app/controllers/users_controller.rb +19 -0
  57. data/examples/rails-single/app/helpers/application_helper.rb +2 -0
  58. data/examples/rails-single/app/javascript/application.js +3 -0
  59. data/examples/rails-single/app/javascript/controllers/application.js +9 -0
  60. data/examples/rails-single/app/javascript/controllers/hello_controller.js +7 -0
  61. data/examples/rails-single/app/javascript/controllers/index.js +8 -0
  62. data/examples/rails-single/app/jobs/application_job.rb +7 -0
  63. data/examples/rails-single/app/mailers/application_mailer.rb +4 -0
  64. data/examples/rails-single/app/models/application_record.rb +3 -0
  65. data/examples/rails-single/app/models/concerns/.keep +0 -0
  66. data/examples/rails-single/app/models/user.rb +2 -0
  67. data/examples/rails-single/app/views/layouts/application.html.erb +28 -0
  68. data/examples/rails-single/app/views/layouts/mailer.html.erb +13 -0
  69. data/examples/rails-single/app/views/layouts/mailer.text.erb +1 -0
  70. data/examples/rails-single/app/views/pwa/manifest.json.erb +22 -0
  71. data/examples/rails-single/app/views/pwa/service-worker.js +26 -0
  72. data/examples/rails-single/app/views/users/index.html.erb +38 -0
  73. data/examples/rails-single/bin/brakeman +7 -0
  74. data/examples/rails-single/bin/bundle +109 -0
  75. data/examples/rails-single/bin/dev +11 -0
  76. data/examples/rails-single/bin/docker-entrypoint +14 -0
  77. data/examples/rails-single/bin/jobs +6 -0
  78. data/examples/rails-single/bin/kamal +27 -0
  79. data/examples/rails-single/bin/rails +4 -0
  80. data/examples/rails-single/bin/rake +4 -0
  81. data/examples/rails-single/bin/rubocop +8 -0
  82. data/examples/rails-single/bin/setup +37 -0
  83. data/examples/rails-single/bin/thrust +5 -0
  84. data/examples/rails-single/bun.lock +224 -0
  85. data/examples/rails-single/config/application.rb +42 -0
  86. data/examples/rails-single/config/boot.rb +4 -0
  87. data/examples/rails-single/config/cable.yml +17 -0
  88. data/examples/rails-single/config/cache.yml +16 -0
  89. data/examples/rails-single/config/credentials.yml.enc +1 -0
  90. data/examples/rails-single/config/database.yml +100 -0
  91. data/examples/rails-single/config/environment.rb +5 -0
  92. data/examples/rails-single/config/environments/development.rb +69 -0
  93. data/examples/rails-single/config/environments/production.rb +87 -0
  94. data/examples/rails-single/config/environments/test.rb +50 -0
  95. data/examples/rails-single/config/initializers/assets.rb +7 -0
  96. data/examples/rails-single/config/initializers/content_security_policy.rb +25 -0
  97. data/examples/rails-single/config/initializers/filter_parameter_logging.rb +8 -0
  98. data/examples/rails-single/config/initializers/inflections.rb +16 -0
  99. data/examples/rails-single/config/locales/en.yml +31 -0
  100. data/examples/rails-single/config/puma.rb +41 -0
  101. data/examples/rails-single/config/queue.yml +18 -0
  102. data/examples/rails-single/config/recurring.yml +15 -0
  103. data/examples/rails-single/config/routes.rb +4 -0
  104. data/examples/rails-single/config.ru +6 -0
  105. data/examples/rails-single/db/cable_schema.rb +11 -0
  106. data/examples/rails-single/db/cache_schema.rb +12 -0
  107. data/examples/rails-single/db/migrate/20251123095526_create_users.rb +10 -0
  108. data/examples/rails-single/db/queue_schema.rb +129 -0
  109. data/examples/rails-single/db/seeds.rb +9 -0
  110. data/examples/rails-single/deploy.yml +57 -0
  111. data/examples/rails-single/lib/tasks/.keep +0 -0
  112. data/examples/rails-single/log/.keep +0 -0
  113. data/examples/rails-single/package.json +17 -0
  114. data/examples/rails-single/public/400.html +114 -0
  115. data/examples/rails-single/public/404.html +114 -0
  116. data/examples/rails-single/public/406-unsupported-browser.html +114 -0
  117. data/examples/rails-single/public/422.html +114 -0
  118. data/examples/rails-single/public/500.html +114 -0
  119. data/examples/rails-single/public/icon.png +0 -0
  120. data/examples/rails-single/public/icon.svg +3 -0
  121. data/examples/rails-single/public/robots.txt +1 -0
  122. data/examples/rails-single/script/.keep +0 -0
  123. data/examples/rails-single/vendor/.keep +0 -0
  124. data/examples/rails-single/yarn.lock +188 -0
  125. data/exe/nvoi +6 -0
  126. data/lib/nvoi/cli.rb +190 -0
  127. data/lib/nvoi/cloudflare/client.rb +287 -0
  128. data/lib/nvoi/config/config.rb +248 -0
  129. data/lib/nvoi/config/env_resolver.rb +63 -0
  130. data/lib/nvoi/config/loader.rb +102 -0
  131. data/lib/nvoi/config/naming.rb +196 -0
  132. data/lib/nvoi/config/ssh_keys.rb +82 -0
  133. data/lib/nvoi/config/types.rb +274 -0
  134. data/lib/nvoi/constants.rb +59 -0
  135. data/lib/nvoi/credentials/crypto.rb +88 -0
  136. data/lib/nvoi/credentials/editor.rb +272 -0
  137. data/lib/nvoi/credentials/manager.rb +173 -0
  138. data/lib/nvoi/deployer/cleaner.rb +36 -0
  139. data/lib/nvoi/deployer/image_builder.rb +23 -0
  140. data/lib/nvoi/deployer/infrastructure.rb +126 -0
  141. data/lib/nvoi/deployer/orchestrator.rb +146 -0
  142. data/lib/nvoi/deployer/retry.rb +67 -0
  143. data/lib/nvoi/deployer/service_deployer.rb +311 -0
  144. data/lib/nvoi/deployer/tunnel_manager.rb +57 -0
  145. data/lib/nvoi/deployer/types.rb +8 -0
  146. data/lib/nvoi/errors.rb +67 -0
  147. data/lib/nvoi/k8s/renderer.rb +44 -0
  148. data/lib/nvoi/k8s/templates.rb +29 -0
  149. data/lib/nvoi/logger.rb +72 -0
  150. data/lib/nvoi/providers/aws.rb +403 -0
  151. data/lib/nvoi/providers/base.rb +111 -0
  152. data/lib/nvoi/providers/hetzner.rb +288 -0
  153. data/lib/nvoi/providers/hetzner_client.rb +170 -0
  154. data/lib/nvoi/remote/docker_manager.rb +203 -0
  155. data/lib/nvoi/remote/ssh_executor.rb +72 -0
  156. data/lib/nvoi/remote/volume_manager.rb +103 -0
  157. data/lib/nvoi/service/delete.rb +234 -0
  158. data/lib/nvoi/service/deploy.rb +80 -0
  159. data/lib/nvoi/service/exec.rb +144 -0
  160. data/lib/nvoi/service/provider.rb +36 -0
  161. data/lib/nvoi/steps/application_deployer.rb +26 -0
  162. data/lib/nvoi/steps/database_provisioner.rb +60 -0
  163. data/lib/nvoi/steps/k3s_cluster_setup.rb +105 -0
  164. data/lib/nvoi/steps/k3s_provisioner.rb +351 -0
  165. data/lib/nvoi/steps/server_provisioner.rb +43 -0
  166. data/lib/nvoi/steps/services_provisioner.rb +29 -0
  167. data/lib/nvoi/steps/tunnel_configurator.rb +66 -0
  168. data/lib/nvoi/steps/volume_provisioner.rb +154 -0
  169. data/lib/nvoi/version.rb +5 -0
  170. data/lib/nvoi.rb +79 -0
  171. data/templates/app-deployment.yaml.erb +102 -0
  172. data/templates/app-ingress.yaml.erb +20 -0
  173. data/templates/app-secret.yaml.erb +10 -0
  174. data/templates/app-service.yaml.erb +12 -0
  175. data/templates/db-statefulset.yaml.erb +76 -0
  176. data/templates/service-deployment.yaml.erb +91 -0
  177. data/templates/worker-deployment.yaml.erb +50 -0
  178. metadata +361 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Remote
5
+ # SSHExecutor executes commands on remote servers via SSH
6
+ class SSHExecutor
7
+ attr_reader :ip, :ssh_key, :user
8
+
9
+ def initialize(ip, ssh_key)
10
+ @ip = ip
11
+ @ssh_key = ssh_key
12
+ @user = "deploy"
13
+ @strict_mode = ENV["SSH_STRICT_HOST_KEY_CHECKING"] == "true"
14
+ end
15
+
16
+ # Execute runs a command on the remote server
17
+ def execute(command, stream: false)
18
+ ssh_args = build_ssh_args
19
+ ssh_args += ["#{@user}@#{@ip}", command]
20
+
21
+ if stream
22
+ # Stream output to stdout/stderr for interactive commands
23
+ success = system("ssh", *ssh_args)
24
+ raise SSHCommandError, "SSH command failed" unless success
25
+
26
+ ""
27
+ else
28
+ # Capture output
29
+ output, status = Open3.capture2e("ssh", *ssh_args)
30
+
31
+ unless status.success?
32
+ raise SSHCommandError, "SSH command failed (exit code: #{status.exitstatus}): #{output}"
33
+ end
34
+
35
+ output.strip
36
+ end
37
+ end
38
+
39
+ # Execute quietly, ignoring errors (useful for optional cleanup)
40
+ def execute_quiet(command)
41
+ execute(command)
42
+ rescue StandardError
43
+ # Ignore errors
44
+ end
45
+
46
+ # Open an interactive SSH shell
47
+ def open_shell
48
+ ssh_args = build_ssh_args
49
+ ssh_args += ["-t", "#{@user}@#{@ip}"]
50
+
51
+ exec("ssh", *ssh_args)
52
+ end
53
+
54
+ private
55
+
56
+ def build_ssh_args
57
+ args = ["-o", "LogLevel=ERROR", "-i", @ssh_key]
58
+
59
+ if @strict_mode
60
+ # Use known_hosts verification
61
+ known_hosts_path = File.join(Dir.home, ".ssh", "known_hosts")
62
+ args += ["-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=#{known_hosts_path}"]
63
+ else
64
+ # Disable host key checking (default for cloud environments)
65
+ args += ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"]
66
+ end
67
+
68
+ args
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Remote
5
+ # MountOptions contains options for mounting a volume
6
+ MountOptions = Struct.new(:device_path, :mount_path, :fs_type, keyword_init: true)
7
+
8
+ # VolumeManager handles volume mount operations via SSH
9
+ class VolumeManager
10
+ def initialize(ssh)
11
+ @ssh = ssh
12
+ end
13
+
14
+ # Mount formats (if needed) and mounts a volume at the specified path
15
+ def mount(opts)
16
+ fs_type = opts.fs_type || "xfs"
17
+
18
+ # Check if already mounted
19
+ return if mounted?(opts.mount_path)
20
+
21
+ # Wait for device to appear (Hetzner attachment is async)
22
+ wait_for_device(opts.device_path)
23
+
24
+ # Check if device has a filesystem
25
+ unless has_filesystem?(opts.device_path)
26
+ format_volume(opts.device_path, fs_type)
27
+ end
28
+
29
+ # Create mount point
30
+ @ssh.execute("sudo mkdir -p #{opts.mount_path}")
31
+
32
+ # Mount the volume
33
+ @ssh.execute("sudo mount #{opts.device_path} #{opts.mount_path}")
34
+
35
+ # Add to fstab for persistence
36
+ add_to_fstab(opts.device_path, opts.mount_path, fs_type)
37
+ end
38
+
39
+ # Unmount a volume
40
+ def unmount(mount_path)
41
+ return unless mounted?(mount_path)
42
+
43
+ @ssh.execute("sudo umount #{mount_path}")
44
+ end
45
+
46
+ # Check if a path is currently mounted
47
+ def mounted?(mount_path)
48
+ output = @ssh.execute("mountpoint -q #{mount_path} && echo 'mounted' || echo 'not_mounted'")
49
+ output.strip == "mounted"
50
+ rescue SSHCommandError
51
+ # mountpoint command might fail if path doesn't exist
52
+ false
53
+ end
54
+
55
+ # Remove a mount entry from /etc/fstab
56
+ def remove_from_fstab(mount_path)
57
+ @ssh.execute("sudo sed -i '\\|#{mount_path}|d' /etc/fstab")
58
+ end
59
+
60
+ private
61
+
62
+ def wait_for_device(device_path, timeout: 60)
63
+ attempts = 0
64
+ max_attempts = timeout / 2
65
+
66
+ loop do
67
+ output = @ssh.execute("test -e #{device_path} && echo 'exists' || echo 'missing'")
68
+ return if output.strip == "exists"
69
+
70
+ attempts += 1
71
+ if attempts >= max_attempts
72
+ raise VolumeError, "Timeout waiting for device #{device_path} to appear"
73
+ end
74
+
75
+ sleep(2)
76
+ end
77
+ end
78
+
79
+ def has_filesystem?(device_path)
80
+ output = @ssh.execute("sudo blkid #{device_path}")
81
+ output.include?("TYPE=")
82
+ rescue SSHCommandError
83
+ # blkid returns error if no filesystem
84
+ false
85
+ end
86
+
87
+ def format_volume(device_path, fs_type)
88
+ @ssh.execute("sudo mkfs.#{fs_type} #{device_path}")
89
+ end
90
+
91
+ def add_to_fstab(device_path, mount_path, fs_type)
92
+ # Check if entry already exists
93
+ output = @ssh.execute("grep -q '#{mount_path}' /etc/fstab && echo 'exists' || echo 'missing'")
94
+ return if output.strip == "exists"
95
+
96
+ # Add fstab entry using UUID for reliability
97
+ cmd = "UUID=$(sudo blkid -s UUID -o value #{device_path}) && " \
98
+ "echo \"UUID=$UUID #{mount_path} #{fs_type} defaults,nofail 0 2\" | sudo tee -a /etc/fstab"
99
+ @ssh.execute(cmd)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Service
5
+ # DeleteService handles cleanup of cloud resources
6
+ class DeleteService
7
+ include ProviderHelper
8
+
9
+ attr_accessor :config_dir
10
+
11
+ def initialize(config_path, log)
12
+ @log = log
13
+
14
+ # Load configuration
15
+ @config = Config.load(config_path)
16
+
17
+ # Initialize provider
18
+ @provider = init_provider(@config)
19
+
20
+ # Initialize Cloudflare client
21
+ cf = @config.cloudflare
22
+ @cf_client = Cloudflare::Client.new(cf.api_token, cf.account_id)
23
+ end
24
+
25
+ def run
26
+ @log.info "Using %s Cloud provider", @config.provider_name
27
+
28
+ # Detach volumes first (must happen before server deletion)
29
+ detach_volumes
30
+
31
+ # Delete all servers from all groups
32
+ delete_all_servers
33
+
34
+ # Delete volumes (already detached)
35
+ delete_volumes
36
+
37
+ # Delete firewall
38
+ @log.info "Deleting firewall: %s", @config.firewall_name
39
+ begin
40
+ firewall = @provider.get_firewall_by_name(@config.firewall_name)
41
+ delete_firewall_with_retry(firewall.id) if firewall
42
+ rescue FirewallError => e
43
+ @log.warning "Firewall not found: %s", e.message
44
+ end
45
+
46
+ # Delete network
47
+ @log.info "Deleting network: %s", @config.network_name
48
+ begin
49
+ network = @provider.get_network_by_name(@config.network_name)
50
+ if network
51
+ @provider.delete_network(network.id)
52
+ @log.success "Network deleted"
53
+ end
54
+ rescue NetworkError => e
55
+ @log.warning "Network not found: %s", e.message
56
+ end
57
+
58
+ # Delete Cloudflare resources
59
+ delete_cloudflare_resources
60
+
61
+ @log.success "Cleanup complete"
62
+ end
63
+
64
+ private
65
+
66
+ def delete_firewall_with_retry(firewall_id, max_retries: 5)
67
+ max_retries.times do |i|
68
+ begin
69
+ @provider.delete_firewall(firewall_id)
70
+ @log.success "Firewall deleted"
71
+ return
72
+ rescue StandardError => e
73
+ if i == max_retries - 1
74
+ raise ServiceError, "failed to delete firewall after #{max_retries} attempts: #{e.message}"
75
+ end
76
+
77
+ @log.info "Firewall still in use, waiting 3s before retry (%d/%d)", i + 1, max_retries
78
+ sleep(3)
79
+ end
80
+ end
81
+ end
82
+
83
+ def detach_volumes
84
+ volume_names = collect_volume_names
85
+ return if volume_names.empty?
86
+
87
+ @log.info "Detaching %d volume(s)", volume_names.size
88
+
89
+ volume_names.each do |vol_name|
90
+ begin
91
+ volume = @provider.get_volume_by_name(vol_name)
92
+ next unless volume&.server_id && !volume.server_id.empty?
93
+
94
+ @log.info "Detaching volume: %s", vol_name
95
+ @provider.detach_volume(volume.id)
96
+ @log.success "Volume detached: %s", vol_name
97
+ rescue StandardError => e
98
+ @log.warning "Failed to detach volume %s: %s", vol_name, e.message
99
+ end
100
+ end
101
+ end
102
+
103
+ def delete_volumes
104
+ volume_names = collect_volume_names
105
+ return if volume_names.empty?
106
+
107
+ @log.info "Deleting %d volume(s)", volume_names.size
108
+
109
+ volume_names.each do |vol_name|
110
+ @log.info "Deleting volume: %s", vol_name
111
+
112
+ begin
113
+ volume = @provider.get_volume_by_name(vol_name)
114
+ unless volume
115
+ @log.info "Volume not found: %s", vol_name
116
+ next
117
+ end
118
+
119
+ @provider.delete_volume(volume.id)
120
+ @log.success "Volume deleted: %s", vol_name
121
+ rescue StandardError => e
122
+ @log.warning "Failed to delete volume %s: %s", vol_name, e.message
123
+ end
124
+ end
125
+ end
126
+
127
+ def collect_volume_names
128
+ namer = @config.namer
129
+ names = []
130
+
131
+ # Database volume
132
+ db = @config.deploy.application.database
133
+ names << namer.database_volume_name if db&.volume && !db.volume.empty?
134
+
135
+ # Service volumes
136
+ @config.deploy.application.services.each do |svc_name, svc|
137
+ names << namer.service_volume_name(svc_name, "data") if svc&.volume && !svc.volume.empty?
138
+ end
139
+
140
+ # App volumes
141
+ @config.deploy.application.app.each do |app_name, app|
142
+ next unless app&.volumes && !app.volumes.empty?
143
+
144
+ app.volumes.keys.each do |vol_key|
145
+ names << namer.app_volume_name(app_name, vol_key)
146
+ end
147
+ end
148
+
149
+ names
150
+ end
151
+
152
+ def delete_all_servers
153
+ servers = @config.deploy.application.servers
154
+ return if servers.empty?
155
+
156
+ servers.each do |group_name, group_config|
157
+ next unless group_config
158
+
159
+ count = group_config.count.positive? ? group_config.count : 1
160
+ @log.info "Deleting %d server(s) from group '%s'", count, group_name
161
+
162
+ (1..count).each do |i|
163
+ server_name = @config.namer.server_name(group_name, i)
164
+ @log.info "Deleting server: %s", server_name
165
+
166
+ begin
167
+ server = @provider.find_server(server_name)
168
+ if server
169
+ @provider.delete_server(server.id)
170
+ @log.success "Server deleted: %s", server_name
171
+ end
172
+ rescue StandardError => e
173
+ @log.warning "Failed to delete server %s: %s", server_name, e.message
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ def delete_cloudflare_resources
180
+ @config.deploy.application.app.each do |service_name, service|
181
+ next unless service&.domain && !service.domain.empty?
182
+ next if service.subdomain.nil?
183
+
184
+ delete_tunnel_and_dns(service_name, service.domain, service.subdomain)
185
+ end
186
+ end
187
+
188
+ def delete_tunnel_and_dns(service_name, domain, subdomain)
189
+ tunnel_name = @config.namer.tunnel_name(service_name)
190
+ hostname = build_hostname(subdomain, domain)
191
+
192
+ # Delete tunnel
193
+ @log.info "Deleting Cloudflare tunnel: %s", tunnel_name
194
+ begin
195
+ tunnel = @cf_client.find_tunnel(tunnel_name)
196
+ if tunnel
197
+ @cf_client.delete_tunnel(tunnel.id)
198
+ @log.success "Tunnel deleted: %s", tunnel_name
199
+ end
200
+ rescue StandardError => e
201
+ @log.warning "Failed to delete tunnel: %s", e.message
202
+ end
203
+
204
+ # Delete DNS record
205
+ @log.info "Deleting DNS record: %s", hostname
206
+ begin
207
+ zone = @cf_client.find_zone(domain)
208
+ unless zone
209
+ @log.warning "Zone not found: %s", domain
210
+ return
211
+ end
212
+
213
+ record = @cf_client.find_dns_record(zone.id, hostname, "CNAME")
214
+ if record
215
+ @cf_client.delete_dns_record(zone.id, record.id)
216
+ @log.success "DNS record deleted: %s", hostname
217
+ end
218
+ rescue StandardError => e
219
+ @log.warning "Failed to delete DNS record: %s", e.message
220
+ end
221
+ end
222
+
223
+ # Build hostname from subdomain and domain
224
+ # Supports: "app" -> "app.example.com", "" or "@" -> "example.com", "*" -> "*.example.com"
225
+ def build_hostname(subdomain, domain)
226
+ if subdomain.nil? || subdomain.empty? || subdomain == "@"
227
+ domain
228
+ else
229
+ "#{subdomain}.#{domain}"
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Service
5
+ # DeployService orchestrates the deployment process
6
+ class DeployService
7
+ include ProviderHelper
8
+
9
+ attr_accessor :config_dir, :dockerfile_path
10
+
11
+ def initialize(config_path, working_dir, log)
12
+ @working_dir = working_dir
13
+ @log = log
14
+
15
+ # Load configuration
16
+ @config = Config.load(config_path)
17
+
18
+ # Initialize provider
19
+ @provider = init_provider(@config)
20
+
21
+ # Validate provider-specific configuration
22
+ validate_provider_config(@config, @provider)
23
+
24
+ @log.info "Using %s Cloud provider", @config.provider_name
25
+ end
26
+
27
+ def run
28
+ @log.info "Starting deployment"
29
+ @log.separator
30
+
31
+ # Step 1: Provision server
32
+ server_ip = provision_server
33
+ raise DeploymentError.new("server provisioning", "failed") unless server_ip
34
+
35
+ # Step 2: Configure tunnels
36
+ tunnels = configure_tunnels
37
+
38
+ # Step 3: Deploy application
39
+ deploy_application(server_ip, tunnels)
40
+
41
+ # Success
42
+ @log.separator
43
+ @log.success "Deployment complete"
44
+
45
+ # Log service URLs
46
+ tunnels.each do |tunnel|
47
+ @log.info "Service %s: https://%s", tunnel.service_name, tunnel.hostname
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def provision_server
54
+ # Step 1: Provision all servers (main + workers)
55
+ provisioner = Steps::ServerProvisioner.new(@config, @provider, @log)
56
+ main_server_ip = provisioner.run
57
+
58
+ # Step 2: Provision volumes (create, attach, mount)
59
+ volume_provisioner = Steps::VolumeProvisioner.new(@config, @provider, @log)
60
+ volume_provisioner.run
61
+
62
+ # Step 3: Setup K3s cluster (main server + join workers)
63
+ cluster_setup = Steps::K3sClusterSetup.new(@config, @provider, @log, main_server_ip)
64
+ cluster_setup.run
65
+
66
+ main_server_ip
67
+ end
68
+
69
+ def configure_tunnels
70
+ configurator = Steps::TunnelConfigurator.new(@config, @log)
71
+ configurator.run
72
+ end
73
+
74
+ def deploy_application(server_ip, tunnels)
75
+ app_deployer = Steps::ApplicationDeployer.new(@config, @provider, @working_dir, server_ip, tunnels, @log)
76
+ app_deployer.run
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Service
5
+ # ExecService handles remote command execution on servers
6
+ class ExecService
7
+ include ProviderHelper
8
+
9
+ def initialize(config_path, log)
10
+ @log = log
11
+
12
+ # Load configuration
13
+ @config = Config.load(config_path)
14
+
15
+ # Initialize provider
16
+ @provider = init_provider(@config)
17
+ end
18
+
19
+ # Run executes a command on a specific server
20
+ def run(command, server_name)
21
+ # Resolve server name (main, worker-1, etc. -> actual server name)
22
+ actual_server_name = resolve_server_name(server_name)
23
+
24
+ # Find server using provider
25
+ server = find_server(actual_server_name)
26
+
27
+ @log.info "Connecting to %s (%s)", actual_server_name, server.public_ipv4
28
+
29
+ # Create SSH executor
30
+ ssh = Remote::SSHExecutor.new(server.public_ipv4, @config.ssh_key_path)
31
+
32
+ # Execute command with streaming output
33
+ @log.info "Executing: %s", command
34
+ output = ssh.execute(command, stream: true)
35
+
36
+ # Output is already streamed, but if there's buffered output, show it
37
+ puts output if !output.empty? && !output.include?("\n")
38
+
39
+ @log.success "Command completed successfully"
40
+ end
41
+
42
+ # RunAll executes a command on all servers (main + workers)
43
+ def run_all(command)
44
+ # Get all server names
45
+ server_names = get_all_server_names
46
+
47
+ raise ServiceError, "no servers found in configuration" if server_names.empty?
48
+
49
+ @log.info "Executing on %d server(s): %s", server_names.size, server_names.join(", ")
50
+ @log.separator
51
+
52
+ # Execute in parallel with threads
53
+ results = {}
54
+ mutex = Mutex.new
55
+ threads = server_names.map do |name|
56
+ Thread.new do
57
+ actual_name = resolve_server_name(name)
58
+ begin
59
+ server = find_server(actual_name)
60
+ ssh = Remote::SSHExecutor.new(server.public_ipv4, @config.ssh_key_path)
61
+
62
+ @log.info "[%s] Executing...", name
63
+ output = ssh.execute(command)
64
+
65
+ # Print output with server prefix
66
+ output.strip.split("\n").each do |line|
67
+ puts "[#{name}] #{line}"
68
+ end
69
+
70
+ mutex.synchronize { results[name] = nil }
71
+ rescue StandardError => e
72
+ @log.error "[%s] Failed: %s", name, e.message
73
+ mutex.synchronize { results[name] = e }
74
+ end
75
+ end
76
+ end
77
+
78
+ threads.each(&:join)
79
+
80
+ @log.separator
81
+
82
+ failures = results.select { |_, err| err }.keys
83
+ if failures.any?
84
+ @log.warning "Command failed on %d server(s): %s", failures.size, failures.join(", ")
85
+ raise ServiceError, "command failed on some servers"
86
+ end
87
+
88
+ @log.success "Command completed successfully on all servers"
89
+ end
90
+
91
+ # OpenShell opens an interactive SSH shell on a specific server
92
+ def open_shell(server_name)
93
+ actual_server_name = resolve_server_name(server_name)
94
+ server = find_server(actual_server_name)
95
+
96
+ @log.info "Opening SSH shell to %s (%s)", actual_server_name, server.public_ipv4
97
+
98
+ ssh = Remote::SSHExecutor.new(server.public_ipv4, @config.ssh_key_path)
99
+ ssh.open_shell
100
+ end
101
+
102
+ private
103
+
104
+ def resolve_server_name(name)
105
+ return @config.server_name if name.nil? || name.empty? || name == "main"
106
+
107
+ # Check if name matches "{group}-{n}" pattern
108
+ parts = name.split("-")
109
+ if parts.length >= 2
110
+ num_str = parts.last
111
+ if num_str.match?(/^\d+$/)
112
+ group_name = parts[0...-1].join("-")
113
+ return @config.namer.server_name(group_name, num_str.to_i)
114
+ end
115
+ end
116
+
117
+ # Assume it's a group name, return first server
118
+ @config.namer.server_name(name, 1)
119
+ end
120
+
121
+ def get_all_server_names
122
+ names = []
123
+
124
+ @config.deploy.application.servers.each do |group_name, group_config|
125
+ next unless group_config
126
+
127
+ count = group_config.count.positive? ? group_config.count : 1
128
+ (1..count).each do |i|
129
+ names << @config.namer.server_name(group_name, i)
130
+ end
131
+ end
132
+
133
+ names
134
+ end
135
+
136
+ def find_server(server_name)
137
+ server = @provider.find_server(server_name)
138
+ raise ServiceError, "server not found: #{server_name}" unless server
139
+
140
+ server
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Service
5
+ # Provider initialization helpers
6
+ module ProviderHelper
7
+ def init_provider(config)
8
+ case config.provider_name
9
+ when "hetzner"
10
+ h = config.hetzner
11
+ Providers::Hetzner.new(h.api_token)
12
+ when "aws"
13
+ a = config.aws
14
+ Providers::AWS.new(a.access_key_id, a.secret_access_key, a.region)
15
+ else
16
+ raise ProviderError, "unknown provider: #{config.provider_name}"
17
+ end
18
+ end
19
+
20
+ def validate_provider_config(config, provider)
21
+ case config.provider_name
22
+ when "hetzner"
23
+ h = config.hetzner
24
+ provider.validate_credentials
25
+ provider.validate_instance_type(h.server_type)
26
+ provider.validate_region(h.server_location)
27
+ when "aws"
28
+ a = config.aws
29
+ provider.validate_credentials
30
+ provider.validate_instance_type(a.instance_type)
31
+ provider.validate_region(a.region)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module Steps
5
+ # ApplicationDeployer orchestrates the full application deployment
6
+ class ApplicationDeployer
7
+ def initialize(config, provider, working_dir, server_ip, tunnels, log)
8
+ @config = config
9
+ @provider = provider
10
+ @working_dir = working_dir
11
+ @server_ip = server_ip
12
+ @tunnels = tunnels
13
+ @log = log
14
+ end
15
+
16
+ def run
17
+ @log.info "Deploying application"
18
+
19
+ orchestrator = Deployer::Orchestrator.new(@config, @provider, @log)
20
+ orchestrator.run(@server_ip, @tunnels, @working_dir)
21
+
22
+ @log.success "Application deployed"
23
+ end
24
+ end
25
+ end
26
+ end