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,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,292 @@
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, hostname, service_url)
68
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
69
+
70
+ config = {
71
+ ingress: [
72
+ {
73
+ hostname:,
74
+ service: service_url,
75
+ originRequest: { httpHostHeader: hostname }
76
+ },
77
+ { service: "http_status:404" }
78
+ ]
79
+ }
80
+
81
+ put(url, { config: })
82
+ end
83
+
84
+ def verify_tunnel_configuration(tunnel_id, expected_hostname, expected_service, max_attempts)
85
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/configurations"
86
+
87
+ max_attempts.times do
88
+ begin
89
+ response = get(url)
90
+
91
+ if response["success"]
92
+ config = response.dig("result", "config")
93
+ config&.dig("ingress")&.each do |rule|
94
+ if rule["hostname"] == expected_hostname && rule["service"] == expected_service
95
+ return true
96
+ end
97
+ end
98
+ end
99
+ rescue StandardError
100
+ # Continue retrying
101
+ end
102
+
103
+ sleep(2)
104
+ end
105
+
106
+ raise Errors::TunnelError, "tunnel configuration not propagated after #{max_attempts} attempts"
107
+ end
108
+
109
+ def delete_tunnel(tunnel_id)
110
+ # Clean up all connections first
111
+ connections_url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}/connections"
112
+ begin
113
+ delete(connections_url)
114
+ rescue StandardError
115
+ # Ignore connection cleanup errors
116
+ end
117
+
118
+ # Now delete the tunnel
119
+ url = "accounts/#{@account_id}/cfd_tunnel/#{tunnel_id}"
120
+ delete(url)
121
+ end
122
+
123
+ # DNS operations
124
+
125
+ def find_zone(domain)
126
+ url = "zones"
127
+ response = get(url)
128
+
129
+ results = response["result"]
130
+ return nil unless results
131
+
132
+ zone_data = results.find { |z| z["name"] == domain }
133
+ return nil unless zone_data
134
+
135
+ Objects::Dns::Zone.new(id: zone_data["id"], name: zone_data["name"])
136
+ end
137
+
138
+ def find_dns_record(zone_id, name, record_type)
139
+ url = "zones/#{zone_id}/dns_records"
140
+ response = get(url)
141
+
142
+ results = response["result"]
143
+ return nil unless results
144
+
145
+ record_data = results.find { |r| r["name"] == name && r["type"] == record_type }
146
+ return nil unless record_data
147
+
148
+ Objects::Dns::Record.new(
149
+ id: record_data["id"],
150
+ type: record_data["type"],
151
+ name: record_data["name"],
152
+ content: record_data["content"],
153
+ proxied: record_data["proxied"],
154
+ ttl: record_data["ttl"]
155
+ )
156
+ end
157
+
158
+ def create_dns_record(zone_id, name, record_type, content, proxied: true)
159
+ url = "zones/#{zone_id}/dns_records"
160
+
161
+ response = post(url, {
162
+ type: record_type,
163
+ name:,
164
+ content:,
165
+ proxied:,
166
+ ttl: 1
167
+ })
168
+
169
+ result = response["result"]
170
+ Objects::Dns::Record.new(
171
+ id: result["id"],
172
+ type: result["type"],
173
+ name: result["name"],
174
+ content: result["content"],
175
+ proxied: result["proxied"],
176
+ ttl: result["ttl"]
177
+ )
178
+ end
179
+
180
+ def update_dns_record(zone_id, record_id, name, record_type, content, proxied: true)
181
+ url = "zones/#{zone_id}/dns_records/#{record_id}"
182
+
183
+ response = patch(url, {
184
+ type: record_type,
185
+ name:,
186
+ content:,
187
+ proxied:,
188
+ ttl: 1
189
+ })
190
+
191
+ result = response["result"]
192
+ Objects::Dns::Record.new(
193
+ id: result["id"],
194
+ type: result["type"],
195
+ name: result["name"],
196
+ content: result["content"],
197
+ proxied: result["proxied"],
198
+ ttl: result["ttl"]
199
+ )
200
+ end
201
+
202
+ def create_or_update_dns_record(zone_id, name, record_type, content, proxied: true)
203
+ existing = find_dns_record(zone_id, name, record_type)
204
+
205
+ if existing
206
+ update_dns_record(zone_id, existing.id, name, record_type, content, proxied:)
207
+ else
208
+ create_dns_record(zone_id, name, record_type, content, proxied:)
209
+ end
210
+ end
211
+
212
+ def delete_dns_record(zone_id, record_id)
213
+ url = "zones/#{zone_id}/dns_records/#{record_id}"
214
+ delete(url)
215
+ end
216
+
217
+ # Validation
218
+
219
+ def validate_credentials
220
+ get("user/tokens/verify")
221
+ true
222
+ rescue Errors::CloudflareError => e
223
+ raise Errors::ValidationError, "cloudflare credentials invalid: #{e.message}"
224
+ end
225
+
226
+ private
227
+
228
+ def get(url, params = {})
229
+ response = @conn.get(url) do |req|
230
+ req.headers["Authorization"] = "Bearer #{@token}"
231
+ req.params = params unless params.empty?
232
+ end
233
+ handle_response(response)
234
+ end
235
+
236
+ def post(url, body)
237
+ response = @conn.post(url) do |req|
238
+ req.headers["Authorization"] = "Bearer #{@token}"
239
+ req.body = body
240
+ end
241
+ handle_response(response)
242
+ end
243
+
244
+ def put(url, body)
245
+ response = @conn.put(url) do |req|
246
+ req.headers["Authorization"] = "Bearer #{@token}"
247
+ req.body = body
248
+ end
249
+ handle_response(response)
250
+ end
251
+
252
+ def patch(url, body)
253
+ response = @conn.patch(url) do |req|
254
+ req.headers["Authorization"] = "Bearer #{@token}"
255
+ req.body = body
256
+ end
257
+ handle_response(response)
258
+ end
259
+
260
+ def delete(url)
261
+ response = @conn.delete(url) do |req|
262
+ req.headers["Authorization"] = "Bearer #{@token}"
263
+ end
264
+
265
+ # 404 is ok for idempotent delete
266
+ return { "success" => true } if response.status == 404
267
+
268
+ handle_response(response)
269
+ end
270
+
271
+ def handle_response(response)
272
+ body = response.body
273
+
274
+ unless body.is_a?(Hash)
275
+ raise Errors::CloudflareError, "unexpected response format"
276
+ end
277
+
278
+ unless body["success"]
279
+ errors = body["errors"]&.map { |e| e["message"] }&.join(", ") || "unknown error"
280
+ raise Errors::CloudflareError, "API error: #{errors}"
281
+ end
282
+
283
+ body
284
+ end
285
+
286
+ def generate_tunnel_secret
287
+ Base64.strict_encode64(SecureRandom.random_bytes(32))
288
+ end
289
+ end
290
+ end
291
+ end
292
+ 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