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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ module Database
6
+ # MySQL provider using mysqldump/mysql via kubectl exec
7
+ class Mysql < Provider
8
+ def default_port
9
+ "3306"
10
+ end
11
+
12
+ def parse_url(url)
13
+ parse_standard_url(url, default_port)
14
+ end
15
+
16
+ def build_url(creds, host: nil)
17
+ h = host || creds.host
18
+ "mysql://#{creds.user}:#{creds.password}@#{h}:#{creds.port}/#{creds.database}"
19
+ end
20
+
21
+ def container_env(creds)
22
+ {
23
+ "MYSQL_USER" => creds.user,
24
+ "MYSQL_PASSWORD" => creds.password,
25
+ "MYSQL_DATABASE" => creds.database,
26
+ "MYSQL_ROOT_PASSWORD" => creds.password
27
+ }
28
+ end
29
+
30
+ def app_env(creds, host:)
31
+ {
32
+ "DATABASE_URL" => build_url(creds, host:),
33
+ "MYSQL_HOST" => host,
34
+ "MYSQL_PORT" => creds.port,
35
+ "MYSQL_USER" => creds.user,
36
+ "MYSQL_PASSWORD" => creds.password,
37
+ "MYSQL_DATABASE" => creds.database
38
+ }
39
+ end
40
+
41
+ def dump(ssh, opts)
42
+ cmd = "kubectl exec -n default #{opts.pod_name} -- " \
43
+ "mysqldump -u #{opts.user} -p#{opts.password} #{opts.database} " \
44
+ "--single-transaction --routines --triggers"
45
+ ssh.execute(cmd)
46
+ rescue Errors::SshCommandError => e
47
+ raise Errors::DatabaseError.new("dump", "mysqldump failed: #{e.message}")
48
+ end
49
+
50
+ def restore(ssh, data, opts)
51
+ create_database(ssh, Objects::Database::CreateOptions.new(
52
+ pod_name: opts.pod_name,
53
+ database: opts.database,
54
+ user: opts.user,
55
+ password: opts.password
56
+ ))
57
+
58
+ temp_file = "/tmp/restore_#{opts.database}.sql"
59
+ write_cmd = "cat > #{temp_file} << 'SQLDUMP'\n#{data}\nSQLDUMP"
60
+ ssh.execute(write_cmd)
61
+
62
+ ssh.execute("kubectl cp #{temp_file} default/#{opts.pod_name}:#{temp_file}")
63
+
64
+ restore_cmd = "kubectl exec -n default #{opts.pod_name} -- " \
65
+ "sh -c 'mysql -u #{opts.user} -p#{opts.password} #{opts.database} < #{temp_file}'"
66
+ ssh.execute(restore_cmd)
67
+
68
+ ssh.execute_ignore_errors("rm -f #{temp_file}")
69
+ ssh.execute_ignore_errors("kubectl exec -n default #{opts.pod_name} -- rm -f #{temp_file}")
70
+ rescue Errors::SshCommandError => e
71
+ raise Errors::DatabaseError.new("restore", "mysql restore failed: #{e.message}")
72
+ end
73
+
74
+ def create_database(ssh, opts)
75
+ cmd = "kubectl exec -n default #{opts.pod_name} -- " \
76
+ "mysql -u #{opts.user} -p#{opts.password} -e \"CREATE DATABASE #{opts.database}\""
77
+ ssh.execute(cmd)
78
+ rescue Errors::SshCommandError => e
79
+ raise Errors::DatabaseError.new("create_database", "failed to create database: #{e.message}")
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ module Database
6
+ # PostgreSQL provider using pg_dump/psql via kubectl exec
7
+ class Postgres < Provider
8
+ def default_port
9
+ "5432"
10
+ end
11
+
12
+ def parse_url(url)
13
+ parse_standard_url(url, default_port)
14
+ end
15
+
16
+ def build_url(creds, host: nil)
17
+ h = host || creds.host
18
+ "postgresql://#{creds.user}:#{creds.password}@#{h}:#{creds.port}/#{creds.database}"
19
+ end
20
+
21
+ def container_env(creds)
22
+ {
23
+ "POSTGRES_USER" => creds.user,
24
+ "POSTGRES_PASSWORD" => creds.password,
25
+ "POSTGRES_DB" => creds.database
26
+ }
27
+ end
28
+
29
+ def app_env(creds, host:)
30
+ {
31
+ "DATABASE_URL" => build_url(creds, host:),
32
+ "POSTGRES_HOST" => host,
33
+ "POSTGRES_PORT" => creds.port,
34
+ "POSTGRES_USER" => creds.user,
35
+ "POSTGRES_PASSWORD" => creds.password,
36
+ "POSTGRES_DB" => creds.database
37
+ }
38
+ end
39
+
40
+ def dump(ssh, opts)
41
+ cmd = "kubectl exec -n default #{opts.pod_name} -- " \
42
+ "pg_dump -U #{opts.user} -d #{opts.database} --no-owner --no-acl"
43
+ ssh.execute(cmd)
44
+ rescue Errors::SshCommandError => e
45
+ raise Errors::DatabaseError.new("dump", "pg_dump failed: #{e.message}")
46
+ end
47
+
48
+ def restore(ssh, data, opts)
49
+ create_database(ssh, Objects::Database::CreateOptions.new(
50
+ pod_name: opts.pod_name,
51
+ database: opts.database,
52
+ user: opts.user,
53
+ password: opts.password
54
+ ))
55
+
56
+ temp_file = "/tmp/restore_#{opts.database}.sql"
57
+ write_cmd = "cat > #{temp_file} << 'SQLDUMP'\n#{data}\nSQLDUMP"
58
+ ssh.execute(write_cmd)
59
+
60
+ ssh.execute("kubectl cp #{temp_file} default/#{opts.pod_name}:#{temp_file}")
61
+
62
+ restore_cmd = "kubectl exec -n default #{opts.pod_name} -- " \
63
+ "psql -U #{opts.user} -d #{opts.database} -f #{temp_file}"
64
+ ssh.execute(restore_cmd)
65
+
66
+ ssh.execute_ignore_errors("rm -f #{temp_file}")
67
+ ssh.execute_ignore_errors("kubectl exec -n default #{opts.pod_name} -- rm -f #{temp_file}")
68
+ rescue Errors::SshCommandError => e
69
+ raise Errors::DatabaseError.new("restore", "psql restore failed: #{e.message}")
70
+ end
71
+
72
+ def create_database(ssh, opts)
73
+ cmd = "kubectl exec -n default #{opts.pod_name} -- " \
74
+ "psql -U #{opts.user} -c \"CREATE DATABASE #{opts.database}\""
75
+ ssh.execute(cmd)
76
+ rescue Errors::SshCommandError => e
77
+ raise Errors::DatabaseError.new("create_database", "failed to create database: #{e.message}")
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ module Database
6
+ # Base provider interface for database backup/restore operations
7
+ class Provider
8
+ def parse_url(url)
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def build_url(creds, host: nil)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def container_env(creds)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def app_env(creds, host:)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def dump(ssh, opts)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def restore(ssh, data, opts)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def create_database(ssh, opts)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def extension
37
+ "sql"
38
+ end
39
+
40
+ def needs_container?
41
+ true
42
+ end
43
+
44
+ def default_port
45
+ raise NotImplementedError
46
+ end
47
+
48
+ protected
49
+
50
+ def parse_standard_url(url, default_port)
51
+ uri = URI.parse(url)
52
+ Objects::Database::Credentials.new(
53
+ user: uri.user,
54
+ password: uri.password,
55
+ host: uri.host,
56
+ port: (uri.port || default_port).to_s,
57
+ database: uri.path&.sub(%r{^/}, "")
58
+ )
59
+ rescue URI::InvalidURIError => e
60
+ raise Errors::DatabaseError.new("parse_url", "invalid URL format: #{e.message}")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ module Database
6
+ # SQLite provider using direct SSH file access on hostPath volume
7
+ class Sqlite < Provider
8
+ def default_port
9
+ nil
10
+ end
11
+
12
+ def needs_container?
13
+ false
14
+ end
15
+
16
+ def parse_url(url)
17
+ path = url.sub(%r{^sqlite3?:///?}, "")
18
+ Objects::Database::Credentials.new(
19
+ path:,
20
+ database: File.basename(path)
21
+ )
22
+ end
23
+
24
+ def build_url(creds, host: nil)
25
+ "sqlite://#{creds.path}"
26
+ end
27
+
28
+ def container_env(_creds)
29
+ {}
30
+ end
31
+
32
+ def app_env(creds, host: nil)
33
+ {
34
+ "DATABASE_URL" => build_url(creds)
35
+ }
36
+ end
37
+
38
+ def dump(ssh, opts)
39
+ db_path = opts.host_path
40
+ raise Errors::DatabaseError.new("dump", "host_path required for SQLite dump") unless db_path
41
+
42
+ ssh.execute("sqlite3 #{db_path} .dump")
43
+ rescue Errors::SshCommandError => e
44
+ raise Errors::DatabaseError.new("dump", "sqlite3 dump failed: #{e.message}")
45
+ end
46
+
47
+ def restore(ssh, data, opts)
48
+ db_path = opts.host_path
49
+ raise Errors::DatabaseError.new("restore", "host_path required for SQLite restore") unless db_path
50
+
51
+ dir = File.dirname(db_path)
52
+ new_db_path = "#{dir}/#{opts.database}.sqlite3"
53
+
54
+ temp_file = "/tmp/restore_#{opts.database}.sql"
55
+ write_cmd = "cat > #{temp_file} << 'SQLDUMP'\n#{data}\nSQLDUMP"
56
+ ssh.execute(write_cmd)
57
+
58
+ ssh.execute("sqlite3 #{new_db_path} < #{temp_file}")
59
+ ssh.execute_ignore_errors("rm -f #{temp_file}")
60
+
61
+ new_db_path
62
+ rescue Errors::SshCommandError => e
63
+ raise Errors::DatabaseError.new("restore", "sqlite3 restore failed: #{e.message}")
64
+ end
65
+
66
+ def create_database(_ssh, _opts)
67
+ # No-op for SQLite
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ # Database module provides database backup/restore operations
6
+ module Database
7
+ # Factory method to create provider by adapter name
8
+ def self.provider_for(adapter)
9
+ case adapter&.downcase
10
+ when "postgres", "postgresql"
11
+ Postgres.new
12
+ when "mysql", "mysql2"
13
+ Mysql.new
14
+ when "sqlite", "sqlite3"
15
+ Sqlite.new
16
+ else
17
+ raise ArgumentError, "Unsupported database adapter: #{adapter}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "base64"
5
+ require "securerandom"
6
+
7
+ module Nvoi
8
+ module External
9
+ module Dns
10
+ # Cloudflare handles Cloudflare API operations for DNS and tunnels
11
+ class Cloudflare
12
+ BASE_URL = "https://api.cloudflare.com/client/v4"
13
+
14
+ attr_reader :account_id
15
+
16
+ def initialize(token, account_id)
17
+ @token = token
18
+ @account_id = account_id
19
+ @conn = Faraday.new(url: BASE_URL) do |f|
20
+ f.request :json
21
+ f.response :json
22
+ f.adapter Faraday.default_adapter
23
+ end
24
+ end
25
+
26
+ # Tunnel operations
27
+
28
+ def create_tunnel(name)
29
+ url = "accounts/#{@account_id}/cfd_tunnel"
30
+ tunnel_secret = generate_tunnel_secret
31
+
32
+ response = post(url, {
33
+ name:,
34
+ tunnel_secret:,
35
+ config_src: "cloudflare"
36
+ })
37
+
38
+ result = response["result"]
39
+ Objects::Tunnel::Record.new(
40
+ id: result["id"],
41
+ name: result["name"],
42
+ token: result["token"]
43
+ )
44
+ end
45
+
46
+ def find_tunnel(name)
47
+ url = "accounts/#{@account_id}/cfd_tunnel"
48
+ response = get(url, { name:, is_deleted: false })
49
+
50
+ results = response["result"]
51
+ return nil if results.nil? || results.empty?
52
+
53
+ result = results[0]
54
+ Objects::Tunnel::Record.new(
55
+ id: result["id"],
56
+ name: result["name"],
57
+ token: result["token"]
58
+ )
59
+ end
60
+
61
+ def get_tunnel_token(tunnel_id)
62
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/token"
63
+ response = get(url)
64
+ response["result"]
65
+ end
66
+
67
+ def update_tunnel_configuration(tunnel_id, hostnames, service_url)
68
+ hostnames = Array(hostnames)
69
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
70
+
71
+ ingress_rules = hostnames.map do |hostname|
72
+ {
73
+ hostname:,
74
+ service: service_url,
75
+ originRequest: { httpHostHeader: hostname.sub(/^\*\./, "") } # Use apex for wildcard
76
+ }
77
+ end
78
+ ingress_rules << { service: "http_status:404" }
79
+
80
+ config = { ingress: ingress_rules }
81
+ put(url, { config: })
82
+ end
83
+
84
+ def verify_tunnel_configuration(tunnel_id, expected_hostnames, expected_service, max_attempts)
85
+ expected_hostnames = Array(expected_hostnames)
86
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
87
+
88
+ max_attempts.times do
89
+ begin
90
+ response = get(url)
91
+
92
+ if response["success"]
93
+ config = response.dig("result", "config")
94
+ ingress = config&.dig("ingress") || []
95
+ configured_hostnames = ingress.map { |r| r["hostname"] }.compact
96
+
97
+ if expected_hostnames.all? { |h| configured_hostnames.include?(h) }
98
+ return true
99
+ end
100
+ end
101
+ rescue StandardError
102
+ # Continue retrying
103
+ end
104
+
105
+ sleep(2)
106
+ end
107
+
108
+ raise Errors::TunnelError, "tunnel configuration not propagated after #{max_attempts} attempts"
109
+ end
110
+
111
+ def delete_tunnel(tunnel_id)
112
+ # Clean up all connections first
113
+ connections_url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/connections"
114
+ begin
115
+ delete(connections_url)
116
+ rescue StandardError
117
+ # Ignore connection cleanup errors
118
+ end
119
+
120
+ # Now delete the tunnel
121
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}"
122
+ delete(url)
123
+ end
124
+
125
+ # DNS operations
126
+
127
+ def list_zones
128
+ url = "zones"
129
+ response = get(url)
130
+
131
+ results = response["result"] || []
132
+ results.map do |z|
133
+ { id: z["id"], name: z["name"], status: z["status"] }
134
+ end
135
+ end
136
+
137
+ def find_zone(domain)
138
+ url = "zones"
139
+ response = get(url)
140
+
141
+ results = response["result"]
142
+ return nil unless results
143
+
144
+ zone_data = results.find { |z| z["name"] == domain }
145
+ return nil unless zone_data
146
+
147
+ Objects::Dns::Zone.new(id: zone_data["id"], name: zone_data["name"])
148
+ end
149
+
150
+ def subdomain_available?(zone_id, subdomain, domain)
151
+ fqdn = subdomain.empty? ? domain : "#{subdomain}.#{domain}"
152
+ # Check for CNAME or A record
153
+ !find_dns_record(zone_id, fqdn, "CNAME") && !find_dns_record(zone_id, fqdn, "A")
154
+ end
155
+
156
+ def find_dns_record(zone_id, name, record_type)
157
+ url = "zones/#{zone_id}/dns_records"
158
+ response = get(url)
159
+
160
+ results = response["result"]
161
+ return nil unless results
162
+
163
+ record_data = results.find { |r| r["name"] == name && r["type"] == record_type }
164
+ return nil unless record_data
165
+
166
+ Objects::Dns::Record.new(
167
+ id: record_data["id"],
168
+ type: record_data["type"],
169
+ name: record_data["name"],
170
+ content: record_data["content"],
171
+ proxied: record_data["proxied"],
172
+ ttl: record_data["ttl"]
173
+ )
174
+ end
175
+
176
+ def create_dns_record(zone_id, name, record_type, content, proxied: true)
177
+ url = "zones/#{zone_id}/dns_records"
178
+
179
+ response = post(url, {
180
+ type: record_type,
181
+ name:,
182
+ content:,
183
+ proxied:,
184
+ ttl: 1
185
+ })
186
+
187
+ result = response["result"]
188
+ Objects::Dns::Record.new(
189
+ id: result["id"],
190
+ type: result["type"],
191
+ name: result["name"],
192
+ content: result["content"],
193
+ proxied: result["proxied"],
194
+ ttl: result["ttl"]
195
+ )
196
+ end
197
+
198
+ def update_dns_record(zone_id, record_id, name, record_type, content, proxied: true)
199
+ url = "zones/#{zone_id}/dns_records/#{record_id}"
200
+
201
+ response = patch(url, {
202
+ type: record_type,
203
+ name:,
204
+ content:,
205
+ proxied:,
206
+ ttl: 1
207
+ })
208
+
209
+ result = response["result"]
210
+ Objects::Dns::Record.new(
211
+ id: result["id"],
212
+ type: result["type"],
213
+ name: result["name"],
214
+ content: result["content"],
215
+ proxied: result["proxied"],
216
+ ttl: result["ttl"]
217
+ )
218
+ end
219
+
220
+ def create_or_update_dns_record(zone_id, name, record_type, content, proxied: true)
221
+ existing = find_dns_record(zone_id, name, record_type)
222
+
223
+ if existing
224
+ update_dns_record(zone_id, existing.id, name, record_type, content, proxied:)
225
+ else
226
+ create_dns_record(zone_id, name, record_type, content, proxied:)
227
+ end
228
+ end
229
+
230
+ def delete_dns_record(zone_id, record_id)
231
+ url = "zones/#{zone_id}/dns_records/#{record_id}"
232
+ delete(url)
233
+ end
234
+
235
+ # Validation
236
+
237
+ def validate_credentials
238
+ get("user/tokens/verify")
239
+ true
240
+ rescue Errors::CloudflareError => e
241
+ raise Errors::ValidationError, "cloudflare credentials invalid: #{e.message}"
242
+ end
243
+
244
+ private
245
+
246
+ def get(url, params = {})
247
+ response = @conn.get(url) do |req|
248
+ req.headers["Authorization"] = "Bearer #{@token}"
249
+ req.params = params unless params.empty?
250
+ end
251
+ handle_response(response)
252
+ end
253
+
254
+ def post(url, body)
255
+ response = @conn.post(url) do |req|
256
+ req.headers["Authorization"] = "Bearer #{@token}"
257
+ req.body = body
258
+ end
259
+ handle_response(response)
260
+ end
261
+
262
+ def put(url, body)
263
+ response = @conn.put(url) do |req|
264
+ req.headers["Authorization"] = "Bearer #{@token}"
265
+ req.body = body
266
+ end
267
+ handle_response(response)
268
+ end
269
+
270
+ def patch(url, body)
271
+ response = @conn.patch(url) do |req|
272
+ req.headers["Authorization"] = "Bearer #{@token}"
273
+ req.body = body
274
+ end
275
+ handle_response(response)
276
+ end
277
+
278
+ def delete(url)
279
+ response = @conn.delete(url) do |req|
280
+ req.headers["Authorization"] = "Bearer #{@token}"
281
+ end
282
+
283
+ # 404 is ok for idempotent delete
284
+ return { "success" => true } if response.status == 404
285
+
286
+ handle_response(response)
287
+ end
288
+
289
+ def handle_response(response)
290
+ body = response.body
291
+
292
+ unless body.is_a?(Hash)
293
+ raise Errors::CloudflareError, "unexpected response format"
294
+ end
295
+
296
+ unless body["success"]
297
+ errors = body["errors"]&.map { |e| e["message"] }&.join(", ") || "unknown error"
298
+ raise Errors::CloudflareError, "API error: #{errors}"
299
+ end
300
+
301
+ body
302
+ end
303
+
304
+ def generate_tunnel_secret
305
+ Base64.strict_encode64(SecureRandom.random_bytes(32))
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ # Kubectl handles kubernetes operations via kubectl on remote servers
6
+ class Kubectl
7
+ attr_reader :ssh
8
+
9
+ def initialize(ssh)
10
+ @ssh = ssh
11
+ end
12
+
13
+ def apply(manifest)
14
+ cmd = "cat <<'EOF' | kubectl apply -f -\n#{manifest}\nEOF"
15
+ @ssh.execute(cmd)
16
+ end
17
+
18
+ def delete(resource_type, name, namespace: "default")
19
+ @ssh.execute("kubectl delete #{resource_type} #{name} -n #{namespace} --ignore-not-found")
20
+ end
21
+
22
+ def get(resource_type, name, namespace: "default", jsonpath: nil)
23
+ cmd = "kubectl get #{resource_type} #{name} -n #{namespace}"
24
+ cmd += " -o jsonpath='#{jsonpath}'" if jsonpath
25
+ @ssh.execute(cmd)
26
+ end
27
+
28
+ def exec(pod_name, command, namespace: "default")
29
+ @ssh.execute("kubectl exec -n #{namespace} #{pod_name} -- #{command}")
30
+ end
31
+
32
+ def logs(pod_name, namespace: "default", tail: nil)
33
+ cmd = "kubectl logs #{pod_name} -n #{namespace}"
34
+ cmd += " --tail=#{tail}" if tail
35
+ @ssh.execute(cmd)
36
+ end
37
+
38
+ def rollout_status(resource_type, name, namespace: "default", timeout: 300)
39
+ @ssh.execute("kubectl rollout status #{resource_type}/#{name} -n #{namespace} --timeout=#{timeout}s")
40
+ end
41
+
42
+ def wait_for_deployment(name, namespace: "default", timeout: 300)
43
+ rollout_status("deployment", name, namespace:, timeout:)
44
+ end
45
+
46
+ def wait_for_statefulset(name, namespace: "default", timeout: 300)
47
+ rollout_status("statefulset", name, namespace:, timeout:)
48
+ end
49
+
50
+ def label_node(node_name, labels)
51
+ labels.each do |key, value|
52
+ @ssh.execute("kubectl label node #{node_name} #{key}=#{value} --overwrite")
53
+ end
54
+ end
55
+
56
+ def get_nodes
57
+ @ssh.execute("kubectl get nodes -o name")
58
+ end
59
+
60
+ def cp(local_path, pod_path, namespace: "default")
61
+ @ssh.execute("kubectl cp #{local_path} #{namespace}/#{pod_path}")
62
+ end
63
+ end
64
+ end
65
+ end