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
data/lib/nvoi/errors.rb CHANGED
@@ -1,67 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nvoi
4
- # Base error class for all Nvoi errors
5
- class Error < StandardError
6
- attr_reader :details
4
+ # Errors module
5
+ module Errors
6
+ # Base error class for all Nvoi errors
7
+ class Error < StandardError
8
+ attr_reader :details
7
9
 
8
- def initialize(message, details: nil)
9
- @details = details
10
- super(message)
10
+ def initialize(message, details: nil)
11
+ @details = details
12
+ super(message)
13
+ end
11
14
  end
12
- end
13
15
 
14
- # Configuration errors
15
- class ConfigError < Error; end
16
- class ConfigNotFoundError < ConfigError; end
17
- class ConfigValidationError < ConfigError; end
16
+ # Configuration errors
17
+ class ConfigError < Error; end
18
+ class ConfigNotFoundError < ConfigError; end
19
+ class ConfigValidationError < ConfigError; end
18
20
 
19
- # Credential errors
20
- class CredentialError < Error; end
21
- class DecryptionError < CredentialError; end
22
- class EncryptionError < CredentialError; end
23
- class InvalidKeyError < CredentialError; end
21
+ # Credential errors
22
+ class CredentialError < Error; end
23
+ class DecryptionError < CredentialError; end
24
+ class EncryptionError < CredentialError; end
25
+ class InvalidKeyError < CredentialError; end
24
26
 
25
- # Provider errors
26
- class ProviderError < Error; end
27
- class ServerCreationError < ProviderError; end
28
- class NetworkError < ProviderError; end
29
- class FirewallError < ProviderError; end
30
- class VolumeError < ProviderError; end
31
- class ValidationError < ProviderError; end
32
- class APIError < ProviderError; end
33
- class AuthenticationError < ProviderError; end
34
- class NotFoundError < ProviderError; end
27
+ # Provider errors
28
+ class ProviderError < Error; end
29
+ class ServerCreationError < ProviderError; end
30
+ class NetworkError < ProviderError; end
31
+ class FirewallError < ProviderError; end
32
+ class VolumeError < ProviderError; end
33
+ class ValidationError < ProviderError; end
34
+ class ApiError < ProviderError; end
35
+ class AuthenticationError < ProviderError; end
36
+ class NotFoundError < ProviderError; end
37
+ class ConflictError < ProviderError; end
38
+ class RateLimitError < ProviderError; end
35
39
 
36
- # Cloudflare errors
37
- class CloudflareError < Error; end
38
- class TunnelError < CloudflareError; end
39
- class DNSError < CloudflareError; end
40
+ # Cloudflare errors
41
+ class CloudflareError < Error; end
42
+ class TunnelError < CloudflareError; end
43
+ class DnsError < CloudflareError; end
40
44
 
41
- # SSH errors
42
- class SSHError < Error; end
43
- class SSHConnectionError < SSHError; end
44
- class SSHCommandError < SSHError; end
45
+ # Ssh errors
46
+ class SshError < Error; end
47
+ class SshConnectionError < SshError; end
48
+ class SshCommandError < SshError; end
45
49
 
46
- # Deployment errors
47
- class DeploymentError < Error
48
- attr_reader :step, :retryable
50
+ # Timeout errors
51
+ class TimeoutError < Error; end
49
52
 
50
- def initialize(step, message, retryable: false, details: nil)
51
- @step = step
52
- @retryable = retryable
53
- super("#{step}: #{message}", details:)
54
- end
53
+ # Deployment errors
54
+ class DeploymentError < Error
55
+ attr_reader :step, :retryable
55
56
 
56
- def retryable?
57
- @retryable
57
+ def initialize(step, message, retryable: false, details: nil)
58
+ @step = step
59
+ @retryable = retryable
60
+ super("#{step}: #{message}", details:)
61
+ end
62
+
63
+ def retryable?
64
+ @retryable
65
+ end
58
66
  end
59
- end
60
67
 
61
- # K8s errors
62
- class K8sError < Error; end
63
- class TemplateError < K8sError; end
68
+ # K8s errors
69
+ class K8sError < Error; end
70
+ class TemplateError < K8sError; end
64
71
 
65
- # Service errors
66
- class ServiceError < Error; end
72
+ # Service errors
73
+ class ServiceError < Error; end
74
+
75
+ # Database errors
76
+ class DatabaseError < Error
77
+ attr_reader :operation
78
+
79
+ def initialize(operation, message, details: nil)
80
+ @operation = operation
81
+ super("database #{operation}: #{message}", details:)
82
+ end
83
+ end
84
+ end
67
85
  end
@@ -0,0 +1,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-ec2"
4
+ require "base64"
5
+
6
+ module Nvoi
7
+ module External
8
+ module Cloud
9
+ # AWS provider implements the compute provider interface for AWS EC2
10
+ class Aws < Base
11
+ def initialize(access_key_id, secret_access_key, region)
12
+ @region = region || "us-east-1"
13
+ @client = ::Aws::EC2::Client.new(
14
+ region: @region,
15
+ credentials: ::Aws::Credentials.new(access_key_id, secret_access_key)
16
+ )
17
+ end
18
+
19
+ # Network operations
20
+
21
+ def find_or_create_network(name)
22
+ vpc = find_vpc_by_name(name)
23
+ if vpc
24
+ return Objects::Network::Record.new(
25
+ id: vpc.vpc_id,
26
+ name:,
27
+ ip_range: vpc.cidr_block
28
+ )
29
+ end
30
+
31
+ # Create new VPC
32
+ create_resp = @client.create_vpc(
33
+ cidr_block: "10.0.0.0/16",
34
+ tag_specifications: [{
35
+ resource_type: "vpc",
36
+ tags: [{ key: "Name", value: name }]
37
+ }]
38
+ )
39
+ vpc_id = create_resp.vpc.vpc_id
40
+
41
+ # Enable DNS hostnames
42
+ @client.modify_vpc_attribute(
43
+ vpc_id:,
44
+ enable_dns_hostnames: { value: true }
45
+ )
46
+
47
+ # Create subnet
48
+ subnet_resp = @client.create_subnet(
49
+ vpc_id:,
50
+ cidr_block: "10.0.1.0/24",
51
+ tag_specifications: [{
52
+ resource_type: "subnet",
53
+ tags: [{ key: "Name", value: "#{name}-subnet" }]
54
+ }]
55
+ )
56
+
57
+ # Create internet gateway
58
+ igw_resp = @client.create_internet_gateway(
59
+ tag_specifications: [{
60
+ resource_type: "internet-gateway",
61
+ tags: [{ key: "Name", value: "#{name}-igw" }]
62
+ }]
63
+ )
64
+ igw_id = igw_resp.internet_gateway.internet_gateway_id
65
+
66
+ # Attach internet gateway to VPC
67
+ @client.attach_internet_gateway(vpc_id:, internet_gateway_id: igw_id)
68
+
69
+ # Create route table
70
+ rtb_resp = @client.create_route_table(
71
+ vpc_id:,
72
+ tag_specifications: [{
73
+ resource_type: "route-table",
74
+ tags: [{ key: "Name", value: "#{name}-rtb" }]
75
+ }]
76
+ )
77
+ rtb_id = rtb_resp.route_table.route_table_id
78
+
79
+ # Add route to internet gateway
80
+ @client.create_route(
81
+ route_table_id: rtb_id,
82
+ destination_cidr_block: "0.0.0.0/0",
83
+ gateway_id: igw_id
84
+ )
85
+
86
+ # Associate route table with subnet
87
+ @client.associate_route_table(
88
+ route_table_id: rtb_id,
89
+ subnet_id: subnet_resp.subnet.subnet_id
90
+ )
91
+
92
+ Objects::Network::Record.new(
93
+ id: vpc_id,
94
+ name:,
95
+ ip_range: create_resp.vpc.cidr_block
96
+ )
97
+ end
98
+
99
+ def get_network_by_name(name)
100
+ vpc = find_vpc_by_name(name)
101
+ raise Errors::NetworkError, "network not found: #{name}" unless vpc
102
+
103
+ Objects::Network::Record.new(
104
+ id: vpc.vpc_id,
105
+ name:,
106
+ ip_range: vpc.cidr_block
107
+ )
108
+ end
109
+
110
+ def delete_network(id)
111
+ @client.delete_vpc(vpc_id: id)
112
+ end
113
+
114
+ # Firewall operations
115
+
116
+ def find_or_create_firewall(name)
117
+ sg = find_security_group_by_name(name)
118
+ if sg
119
+ return Objects::Firewall::Record.new(id: sg.group_id, name:)
120
+ end
121
+
122
+ # Get default VPC
123
+ vpcs = @client.describe_vpcs(filters: [{ name: "isDefault", values: ["true"] }])
124
+ raise Errors::NetworkError, "no default VPC found" if vpcs.vpcs.empty?
125
+
126
+ # Create security group
127
+ create_resp = @client.create_security_group(
128
+ group_name: name,
129
+ description: "Managed by nvoi",
130
+ vpc_id: vpcs.vpcs[0].vpc_id,
131
+ tag_specifications: [{
132
+ resource_type: "security-group",
133
+ tags: [{ key: "Name", value: name }]
134
+ }]
135
+ )
136
+
137
+ # Add SSH ingress rule
138
+ @client.authorize_security_group_ingress(
139
+ group_id: create_resp.group_id,
140
+ ip_permissions: [{
141
+ ip_protocol: "tcp",
142
+ from_port: 22,
143
+ to_port: 22,
144
+ ip_ranges: [{ cidr_ip: "0.0.0.0/0" }]
145
+ }]
146
+ )
147
+
148
+ Objects::Firewall::Record.new(id: create_resp.group_id, name:)
149
+ end
150
+
151
+ def get_firewall_by_name(name)
152
+ sg = find_security_group_by_name(name)
153
+ raise Errors::FirewallError, "firewall not found: #{name}" unless sg
154
+
155
+ Objects::Firewall::Record.new(id: sg.group_id, name:)
156
+ end
157
+
158
+ def delete_firewall(id)
159
+ @client.delete_security_group(group_id: id)
160
+ end
161
+
162
+ # Server operations
163
+
164
+ def find_server(name)
165
+ instance = find_instance_by_name(name)
166
+ return nil unless instance
167
+
168
+ instance_to_server(instance)
169
+ end
170
+
171
+ def find_server_by_id(id)
172
+ result = @client.describe_instances(instance_ids: [id])
173
+ return nil if result.reservations.empty? || result.reservations[0].instances.empty?
174
+
175
+ instance_to_server(result.reservations[0].instances[0])
176
+ rescue ::Aws::EC2::Errors::InvalidInstanceIDNotFound
177
+ nil
178
+ end
179
+
180
+ def list_servers
181
+ result = @client.describe_instances(
182
+ filters: [{
183
+ name: "instance-state-name",
184
+ values: %w[pending running stopping stopped]
185
+ }]
186
+ )
187
+
188
+ servers = []
189
+ result.reservations.each do |reservation|
190
+ reservation.instances.each do |instance|
191
+ servers << instance_to_server(instance)
192
+ end
193
+ end
194
+ servers
195
+ end
196
+
197
+ def create_server(opts)
198
+ ami_id = get_ubuntu_ami
199
+
200
+ input = {
201
+ image_id: ami_id,
202
+ instance_type: opts.type,
203
+ min_count: 1,
204
+ max_count: 1,
205
+ user_data: opts.user_data ? Base64.encode64(opts.user_data) : nil,
206
+ tag_specifications: [{
207
+ resource_type: "instance",
208
+ tags: [{ key: "Name", value: opts.name }]
209
+ }]
210
+ }
211
+
212
+ # Add network configuration if provided
213
+ if opts.network_id && !opts.network_id.empty?
214
+ subnets = @client.describe_subnets(
215
+ filters: [{ name: "vpc-id", values: [opts.network_id] }]
216
+ )
217
+ input[:subnet_id] = subnets.subnets[0].subnet_id unless subnets.subnets.empty?
218
+ end
219
+
220
+ # Add security group if provided
221
+ if opts.firewall_id && !opts.firewall_id.empty?
222
+ input[:security_group_ids] = [opts.firewall_id]
223
+ end
224
+
225
+ result = @client.run_instances(input)
226
+ raise Errors::ServerCreationError, "no instance created" if result.instances.empty?
227
+
228
+ instance_to_server(result.instances[0])
229
+ end
230
+
231
+ def wait_for_server(server_id, max_attempts)
232
+ server = Utils::Retry.poll(max_attempts:, interval: 5) do
233
+ resp = @client.describe_instances(instance_ids: [server_id])
234
+
235
+ if resp.reservations.any? && resp.reservations[0].instances.any?
236
+ instance = resp.reservations[0].instances[0]
237
+ instance_to_server(instance) if instance.state.name == "running"
238
+ end
239
+ end
240
+
241
+ raise Errors::ServerCreationError, "instance did not become running after #{max_attempts} attempts" unless server
242
+
243
+ server
244
+ end
245
+
246
+ def delete_server(id)
247
+ @client.terminate_instances(instance_ids: [id])
248
+ end
249
+
250
+ # Volume operations
251
+
252
+ def create_volume(opts)
253
+ resp = @client.describe_instances(instance_ids: [opts.server_id])
254
+ raise Errors::VolumeError, "instance not found: #{opts.server_id}" if resp.reservations.empty?
255
+
256
+ instance = resp.reservations[0].instances[0]
257
+ az = instance.placement.availability_zone
258
+
259
+ create_resp = @client.create_volume(
260
+ availability_zone: az,
261
+ size: opts.size,
262
+ volume_type: "gp3",
263
+ tag_specifications: [{
264
+ resource_type: "volume",
265
+ tags: [{ key: "Name", value: opts.name }]
266
+ }]
267
+ )
268
+
269
+ Objects::Volume::Record.new(
270
+ id: create_resp.volume_id,
271
+ name: opts.name,
272
+ size: create_resp.size,
273
+ location: create_resp.availability_zone,
274
+ status: create_resp.state
275
+ )
276
+ end
277
+
278
+ def get_volume(id)
279
+ resp = @client.describe_volumes(volume_ids: [id])
280
+ return nil if resp.volumes.empty?
281
+
282
+ volume_to_object(resp.volumes[0])
283
+ end
284
+
285
+ def get_volume_by_name(name)
286
+ resp = @client.describe_volumes(
287
+ filters: [{ name: "tag:Name", values: [name] }]
288
+ )
289
+ return nil if resp.volumes.empty?
290
+
291
+ volume_to_object(resp.volumes[0])
292
+ end
293
+
294
+ def delete_volume(id)
295
+ @client.delete_volume(volume_id: id)
296
+ end
297
+
298
+ def attach_volume(volume_id, server_id)
299
+ @client.attach_volume(
300
+ volume_id:,
301
+ instance_id: server_id,
302
+ device: "/dev/xvdf"
303
+ )
304
+ end
305
+
306
+ def detach_volume(volume_id)
307
+ @client.detach_volume(volume_id:)
308
+ end
309
+
310
+ def wait_for_device_path(volume_id, _ssh)
311
+ # AWS provides device path in attachment info
312
+ Utils::Retry.poll(max_attempts: 30, interval: 2) do
313
+ resp = @client.describe_volumes(volume_ids: [volume_id])
314
+ next nil if resp.volumes.empty?
315
+
316
+ vol = resp.volumes[0]
317
+ next nil if vol.attachments.empty?
318
+
319
+ device = vol.attachments[0].device
320
+ device if device && !device.empty?
321
+ end
322
+ end
323
+
324
+ # Validation operations
325
+
326
+ def validate_instance_type(instance_type)
327
+ resp = @client.describe_instance_types(instance_types: [instance_type])
328
+ raise Errors::ValidationError, "invalid AWS instance type: #{instance_type}" if resp.instance_types.empty?
329
+
330
+ true
331
+ end
332
+
333
+ def validate_region(region)
334
+ resp = @client.describe_regions(region_names: [region])
335
+ raise Errors::ValidationError, "invalid AWS region: #{region}" if resp.regions.empty?
336
+
337
+ true
338
+ end
339
+
340
+ def validate_credentials
341
+ @client.describe_regions
342
+ true
343
+ rescue StandardError => e
344
+ raise Errors::ValidationError, "aws credentials invalid: #{e.message}"
345
+ end
346
+
347
+ # List available instance types for onboarding
348
+ def list_instance_types
349
+ # Common instance types (full list is huge)
350
+ common_types = %w[t3.micro t3.small t3.medium t3.large t3.xlarge m5.large m5.xlarge c5.large c5.xlarge]
351
+ resp = @client.describe_instance_types(instance_types: common_types)
352
+ resp.instance_types.map do |t|
353
+ {
354
+ name: t.instance_type,
355
+ vcpus: t.v_cpu_info.default_v_cpus,
356
+ memory: t.memory_info.size_in_mi_b
357
+ }
358
+ end
359
+ rescue StandardError
360
+ # Fallback to static list if API fails
361
+ common_types.map { |t| { name: t, vcpus: nil, memory: nil } }
362
+ end
363
+
364
+ # List available regions for onboarding
365
+ def list_regions
366
+ resp = @client.describe_regions
367
+ resp.regions.map do |r|
368
+ { name: r.region_name, endpoint: r.endpoint }
369
+ end
370
+ end
371
+
372
+ private
373
+
374
+ def find_vpc_by_name(name)
375
+ resp = @client.describe_vpcs(
376
+ filters: [{ name: "tag:Name", values: [name] }]
377
+ )
378
+ resp.vpcs.first
379
+ end
380
+
381
+ def find_security_group_by_name(name)
382
+ resp = @client.describe_security_groups(
383
+ filters: [{ name: "group-name", values: [name] }]
384
+ )
385
+ resp.security_groups.first
386
+ end
387
+
388
+ def find_instance_by_name(name)
389
+ resp = @client.describe_instances(
390
+ filters: [
391
+ { name: "tag:Name", values: [name] },
392
+ { name: "instance-state-name", values: %w[pending running stopping stopped] }
393
+ ]
394
+ )
395
+
396
+ resp.reservations.each do |reservation|
397
+ return reservation.instances.first unless reservation.instances.empty?
398
+ end
399
+ nil
400
+ end
401
+
402
+ def get_ubuntu_ami
403
+ resp = @client.describe_images(
404
+ owners: ["099720109477"], # Canonical's AWS account ID
405
+ filters: [
406
+ { name: "name", values: ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] },
407
+ { name: "state", values: ["available"] }
408
+ ]
409
+ )
410
+
411
+ raise Errors::ProviderError, "no Ubuntu 22.04 AMI found" if resp.images.empty?
412
+
413
+ latest = resp.images.max_by(&:creation_date)
414
+ latest.image_id
415
+ end
416
+
417
+ def instance_to_server(instance)
418
+ name = instance.tags&.find { |t| t.key == "Name" }&.value || ""
419
+
420
+ Objects::Server::Record.new(
421
+ id: instance.instance_id,
422
+ name:,
423
+ status: instance.state.name,
424
+ public_ipv4: instance.public_ip_address,
425
+ private_ipv4: instance.private_ip_address
426
+ )
427
+ end
428
+
429
+ def volume_to_object(vol)
430
+ name = vol.tags&.find { |t| t.key == "Name" }&.value || ""
431
+
432
+ v = Objects::Volume::Record.new(
433
+ id: vol.volume_id,
434
+ name:,
435
+ size: vol.size,
436
+ location: vol.availability_zone,
437
+ status: vol.state
438
+ )
439
+
440
+ if vol.attachments.any?
441
+ v.server_id = vol.attachments[0].instance_id
442
+ v.device_path = vol.attachments[0].device
443
+ end
444
+
445
+ v
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ module Cloud
6
+ # Base provider interface - all providers must implement these methods
7
+ class Base
8
+ # Network operations
9
+ def find_or_create_network(name)
10
+ raise NotImplementedError
11
+ end
12
+
13
+ def get_network_by_name(name)
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def delete_network(id)
18
+ raise NotImplementedError
19
+ end
20
+
21
+ # Firewall operations
22
+ def find_or_create_firewall(name)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def get_firewall_by_name(name)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def delete_firewall(id)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ # Server operations
35
+ def find_server(name)
36
+ raise NotImplementedError
37
+ end
38
+
39
+ def find_server_by_id(id)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def list_servers
44
+ raise NotImplementedError
45
+ end
46
+
47
+ def create_server(opts)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ def wait_for_server(server_id, max_attempts)
52
+ raise NotImplementedError
53
+ end
54
+
55
+ def delete_server(id)
56
+ raise NotImplementedError
57
+ end
58
+
59
+ # Volume operations
60
+ def create_volume(opts)
61
+ raise NotImplementedError
62
+ end
63
+
64
+ def get_volume(id)
65
+ raise NotImplementedError
66
+ end
67
+
68
+ def get_volume_by_name(name)
69
+ raise NotImplementedError
70
+ end
71
+
72
+ def delete_volume(id)
73
+ raise NotImplementedError
74
+ end
75
+
76
+ def attach_volume(volume_id, server_id)
77
+ raise NotImplementedError
78
+ end
79
+
80
+ def detach_volume(volume_id)
81
+ raise NotImplementedError
82
+ end
83
+
84
+ # Validation operations
85
+ def validate_instance_type(instance_type)
86
+ raise NotImplementedError
87
+ end
88
+
89
+ def validate_region(region)
90
+ raise NotImplementedError
91
+ end
92
+
93
+ def validate_credentials
94
+ raise NotImplementedError
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end