pangea-kubernetes 0.1.0

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/auto-bump.yml +11 -0
  3. data/.github/workflows/ci.yml +7 -0
  4. data/.github/workflows/release.yml +22 -0
  5. data/.gitignore +6 -0
  6. data/.rspec +3 -0
  7. data/AGENTS.md +3 -0
  8. data/CLAUDE.md +370 -0
  9. data/Gemfile +9 -0
  10. data/Gemfile.lock +128 -0
  11. data/README.md +42 -0
  12. data/Rakefile +8 -0
  13. data/flake.lock +2144 -0
  14. data/flake.nix +30 -0
  15. data/gemset.nix +312 -0
  16. data/lib/pangea/kubernetes/architecture.rb +383 -0
  17. data/lib/pangea/kubernetes/backend_registry.rb +117 -0
  18. data/lib/pangea/kubernetes/backends/aws_eks.rb +203 -0
  19. data/lib/pangea/kubernetes/backends/aws_nixos.rb +1347 -0
  20. data/lib/pangea/kubernetes/backends/azure_aks.rb +145 -0
  21. data/lib/pangea/kubernetes/backends/azure_nixos.rb +275 -0
  22. data/lib/pangea/kubernetes/backends/base.rb +116 -0
  23. data/lib/pangea/kubernetes/backends/gcp_gke.rb +176 -0
  24. data/lib/pangea/kubernetes/backends/gcp_nixos.rb +240 -0
  25. data/lib/pangea/kubernetes/backends/hcloud_k3s.rb +181 -0
  26. data/lib/pangea/kubernetes/backends/nixos_base.rb +235 -0
  27. data/lib/pangea/kubernetes/bare_metal/cloud_init.rb +196 -0
  28. data/lib/pangea/kubernetes/bare_metal/cluster_reference.rb +72 -0
  29. data/lib/pangea/kubernetes/load_balancer.rb +157 -0
  30. data/lib/pangea/kubernetes/network_backend_registry.rb +54 -0
  31. data/lib/pangea/kubernetes/network_backends/base.rb +78 -0
  32. data/lib/pangea/kubernetes/network_backends/cilium.rb +105 -0
  33. data/lib/pangea/kubernetes/network_backends/vpc_cni.rb +36 -0
  34. data/lib/pangea/kubernetes/types/argocd_config.rb +55 -0
  35. data/lib/pangea/kubernetes/types/control_plane_config.rb +65 -0
  36. data/lib/pangea/kubernetes/types/etcd_config.rb +64 -0
  37. data/lib/pangea/kubernetes/types/firewall_config.rb +39 -0
  38. data/lib/pangea/kubernetes/types/k3s_config.rb +112 -0
  39. data/lib/pangea/kubernetes/types/kernel_config.rb +31 -0
  40. data/lib/pangea/kubernetes/types/kubernetes_config.rb +129 -0
  41. data/lib/pangea/kubernetes/types/persistent_state_config.rb +100 -0
  42. data/lib/pangea/kubernetes/types/pki_config.rb +48 -0
  43. data/lib/pangea/kubernetes/types/secrets_config.rb +41 -0
  44. data/lib/pangea/kubernetes/types/vpn_config.rb +188 -0
  45. data/lib/pangea/kubernetes/types/wait_for_dns_config.rb +35 -0
  46. data/lib/pangea/kubernetes/types.rb +521 -0
  47. data/lib/pangea-kubernetes/version.rb +5 -0
  48. data/lib/pangea-kubernetes.rb +43 -0
  49. data/pangea-kubernetes.gemspec +33 -0
  50. metadata +192 -0
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 The Pangea Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'pangea/contracts'
18
+ require 'pangea/kubernetes/types'
19
+ require 'pangea/kubernetes/backend_registry'
20
+ require 'pangea/kubernetes/backends/base'
21
+
22
+ module Pangea
23
+ module Kubernetes
24
+ # Cloud-agnostic Kubernetes architecture module.
25
+ # Provides kubernetes_cluster() and kubernetes_node_pool() functions
26
+ # that delegate to provider-specific backends.
27
+ #
28
+ # This module is designed to be included in a synthesizer context
29
+ # (AbstractSynthesizer or TerraformSynthesizer).
30
+ #
31
+ # @example
32
+ # class MyInfra < TerraformSynthesizer
33
+ # include Pangea::Kubernetes::Architecture
34
+ #
35
+ # def build
36
+ # kubernetes_cluster(:production, {
37
+ # backend: :aws,
38
+ # kubernetes_version: '1.29',
39
+ # region: 'us-east-1',
40
+ # node_pools: [
41
+ # { name: :system, instance_types: ['t3.large'], min_size: 2, max_size: 5 }
42
+ # ]
43
+ # })
44
+ # end
45
+ # end
46
+ module Architecture
47
+ # Create a complete Kubernetes cluster with all supporting infrastructure.
48
+ #
49
+ # Phase pipeline: Network -> IAM -> Cluster -> Node Pools -> Addons
50
+ #
51
+ # @param name [Symbol] Architecture name
52
+ # @param attributes [Hash] Cluster configuration (see Types::ClusterConfig)
53
+ # @return [ArchitectureResult] with cluster, node_pools, network, iam references
54
+ def kubernetes_cluster(name, attributes = {})
55
+ # VPN config comes in two forms:
56
+ # 1. Typed (VpnConfig with :links) — validated by dry-struct, used by NixOS backends
57
+ # 2. Cloud-init passthrough (flat hash with :interface, :port, etc.) — passed
58
+ # as-is to cloud-init for AWS/cloud backends where the node configures WireGuard
59
+ # Both are legitimate. Only validate if the hash has :links (typed form).
60
+
61
+ config = Types::ClusterConfig.new(attributes)
62
+ backend_module = BackendRegistry.resolve(config.backend)
63
+ backend_module.load_provider!
64
+
65
+ base_tags = {
66
+ KubernetesCluster: name.to_s,
67
+ Backend: config.backend.to_s,
68
+ ManagedBy: 'Pangea'
69
+ }.merge(config.tags)
70
+
71
+ result = ArchitectureResult.new(name, config)
72
+
73
+ # Phase 1: Network
74
+ # Pre-built network takes priority (e.g., SecureVpc from pangea-architectures).
75
+ # When external_network is set, skip backend's create_network entirely.
76
+ if config.external_network
77
+ result.network = config.external_network
78
+ elsif config.network
79
+ result.network = backend_module.create_network(self, name, config, base_tags)
80
+ end
81
+
82
+ # Phase 2: IAM
83
+ result.iam = backend_module.create_iam(self, name, config, base_tags)
84
+
85
+ # Phase 3: Cluster
86
+ result.cluster = backend_module.create_cluster(self, name, config, result, base_tags)
87
+
88
+ # Phase 4: Node Pools
89
+ config.node_pools.each do |pool_config|
90
+ pool_ref = backend_module.create_node_pool(self, name, result.cluster, pool_config, base_tags)
91
+ result.add_node_pool(pool_config.name, pool_ref)
92
+ end
93
+
94
+ result
95
+ end
96
+
97
+ # Create a standalone node pool for an existing cluster.
98
+ #
99
+ # @param cluster_name [Symbol] Parent cluster name
100
+ # @param pool_name [Symbol] Node pool name
101
+ # @param attributes [Hash] Node pool configuration
102
+ # @param cluster_ref [ResourceReference] Reference to existing cluster
103
+ # @param backend [Symbol] Backend name (:aws, :gcp, :azure, :hcloud)
104
+ # @return [ResourceReference] Node pool reference
105
+ def kubernetes_node_pool(cluster_name, pool_name, attributes = {}, cluster_ref:, backend:, tags: {})
106
+ pool_config = Types::NodePoolConfig.new(attributes.merge(name: pool_name))
107
+ backend_module = BackendRegistry.resolve(backend)
108
+ backend_module.load_provider!
109
+
110
+ base_tags = {
111
+ KubernetesCluster: cluster_name.to_s,
112
+ Backend: backend.to_s,
113
+ ManagedBy: 'Pangea'
114
+ }.merge(tags)
115
+
116
+ backend_module.create_node_pool(self, cluster_name, cluster_ref, pool_config, base_tags)
117
+ end
118
+
119
+ # AWS-specific NetworkResult — extends the base contract with AWS fields.
120
+ # is_a?(Pangea::Contracts::NetworkResult) returns true.
121
+ class NetworkResult < Pangea::Contracts::NetworkResult
122
+ attr_accessor :igw, :route_table, :etcd_bucket,
123
+ :flow_log, :flow_log_role,
124
+ :ssm_logs_bucket,
125
+ :kms_key,
126
+ :persistent_state_volume
127
+
128
+ def initialize
129
+ super
130
+ @igw = nil
131
+ @route_table = nil
132
+ @etcd_bucket = nil
133
+ @flow_log = nil
134
+ @flow_log_role = nil
135
+ @ssm_logs_bucket = nil
136
+ @kms_key = nil
137
+ @persistent_state_volume = nil
138
+ end
139
+
140
+ def [](key)
141
+ case key.to_sym
142
+ when :igw then igw
143
+ when :route_table then route_table
144
+ when :etcd_bucket then etcd_bucket
145
+ when :flow_log then flow_log
146
+ when :flow_log_role then flow_log_role
147
+ when :ssm_logs_bucket then ssm_logs_bucket
148
+ when :kms_key then kms_key
149
+ when :persistent_state_volume then persistent_state_volume
150
+ else super
151
+ end
152
+ end
153
+
154
+ def to_h
155
+ hash = super
156
+ hash[:igw] = igw if igw
157
+ hash[:route_table] = route_table if route_table
158
+ hash[:etcd_bucket] = etcd_bucket if etcd_bucket
159
+ hash[:flow_log] = flow_log if flow_log
160
+ hash[:flow_log_role] = flow_log_role if flow_log_role
161
+ hash[:ssm_logs_bucket] = ssm_logs_bucket if ssm_logs_bucket
162
+ hash[:kms_key] = kms_key if kms_key
163
+ hash[:persistent_state_volume] = persistent_state_volume if persistent_state_volume
164
+ hash
165
+ end
166
+ end
167
+
168
+ # GCP-specific NetworkResult with firewall rules
169
+ class GcpNetworkResult < Pangea::Contracts::NetworkResult
170
+ attr_accessor :firewall_internal, :firewall_external
171
+
172
+ def [](key)
173
+ case key.to_sym
174
+ when :firewall_internal then firewall_internal
175
+ when :firewall_external then firewall_external
176
+ else super
177
+ end
178
+ end
179
+
180
+ def to_h
181
+ hash = super
182
+ hash[:firewall_internal] = firewall_internal if firewall_internal
183
+ hash[:firewall_external] = firewall_external if firewall_external
184
+ hash
185
+ end
186
+ end
187
+
188
+ # Azure-specific NetworkResult with resource group, vnet, and NSG
189
+ class AzureNetworkResult < Pangea::Contracts::NetworkResult
190
+ attr_accessor :resource_group, :vnet, :nsg
191
+
192
+ def [](key)
193
+ case key.to_sym
194
+ when :resource_group then resource_group
195
+ when :vnet then vnet
196
+ when :nsg then nsg
197
+ else super
198
+ end
199
+ end
200
+
201
+ def to_h
202
+ hash = super
203
+ hash[:resource_group] = resource_group if resource_group
204
+ hash[:vnet] = vnet if vnet
205
+ hash[:nsg] = nsg if nsg
206
+ hash
207
+ end
208
+ end
209
+
210
+ # Hetzner Cloud-specific NetworkResult with network (not VPC)
211
+ class HcloudNetworkResult < Pangea::Contracts::NetworkResult
212
+ attr_accessor :network
213
+
214
+ def [](key)
215
+ case key.to_sym
216
+ when :network then network
217
+ else super
218
+ end
219
+ end
220
+
221
+ def to_h
222
+ hash = super
223
+ hash[:network] = network if network
224
+ hash
225
+ end
226
+ end
227
+
228
+ # AWS-specific IamResult — extends the base contract with AWS fields.
229
+ # is_a?(Pangea::Contracts::IamResult) returns true.
230
+ class IamResult < Pangea::Contracts::IamResult
231
+ attr_accessor :log_group,
232
+ :ecr_policy, :etcd_policy, :logs_policy,
233
+ :ec2_policy, :ssm_policy,
234
+ :karpenter_role, :karpenter_profile,
235
+ :persistent_state_policy
236
+
237
+ def initialize
238
+ super
239
+ @log_group = nil
240
+ @ecr_policy = nil
241
+ @etcd_policy = nil
242
+ @logs_policy = nil
243
+ @ec2_policy = nil
244
+ @ssm_policy = nil
245
+ @karpenter_role = nil
246
+ @karpenter_profile = nil
247
+ @persistent_state_policy = nil
248
+ end
249
+
250
+ # Hash-style access for backward compatibility
251
+ def [](key)
252
+ case key.to_sym
253
+ when :log_group then log_group
254
+ when :ecr_policy then ecr_policy
255
+ when :etcd_policy then etcd_policy
256
+ when :logs_policy then logs_policy
257
+ when :ec2_policy then ec2_policy
258
+ when :ssm_policy then ssm_policy
259
+ when :karpenter_role then karpenter_role
260
+ when :karpenter_profile then karpenter_profile
261
+ when :persistent_state_policy then persistent_state_policy
262
+ else super
263
+ end
264
+ end
265
+
266
+ def to_h
267
+ hash = super
268
+ hash[:log_group] = log_group if log_group
269
+ hash[:ecr_policy] = ecr_policy if ecr_policy
270
+ hash[:etcd_policy] = etcd_policy if etcd_policy
271
+ hash[:logs_policy] = logs_policy if logs_policy
272
+ hash[:ec2_policy] = ec2_policy if ec2_policy
273
+ hash[:ssm_policy] = ssm_policy if ssm_policy
274
+ hash[:karpenter_role] = karpenter_role if karpenter_role
275
+ hash[:karpenter_profile] = karpenter_profile if karpenter_profile
276
+ hash[:persistent_state_policy] = persistent_state_policy if persistent_state_policy
277
+ hash
278
+ end
279
+ end
280
+
281
+ # AWS EKS-specific IamResult with cluster_role, cluster_policy_attachment, and node_role
282
+ class AwsEksIamResult < IamResult
283
+ attr_accessor :cluster_role, :cluster_policy_attachment, :node_role
284
+
285
+ def [](key)
286
+ case key.to_sym
287
+ when :cluster_role then cluster_role
288
+ when :cluster_policy_attachment then cluster_policy_attachment
289
+ when :node_role then node_role
290
+ else super
291
+ end
292
+ end
293
+
294
+ def to_h
295
+ hash = super
296
+ hash[:cluster_role] = cluster_role if cluster_role
297
+ hash[:cluster_policy_attachment] = cluster_policy_attachment if cluster_policy_attachment
298
+ hash[:node_role] = node_role if node_role
299
+ hash
300
+ end
301
+ end
302
+
303
+ # GCP-specific IamResult with service account for nodes
304
+ class GcpIamResult < IamResult
305
+ attr_accessor :node_sa
306
+
307
+ def [](key)
308
+ case key.to_sym
309
+ when :node_sa then node_sa
310
+ else super
311
+ end
312
+ end
313
+
314
+ def to_h
315
+ hash = super
316
+ hash[:node_sa] = node_sa if node_sa
317
+ hash
318
+ end
319
+ end
320
+
321
+ # AWS-specific ClusterResult — extends the base contract with AWS fields.
322
+ # is_a?(Pangea::Contracts::ClusterResult) returns true.
323
+ class ClusterResult < Pangea::Contracts::ClusterResult
324
+ def asg_tg
325
+ control_plane_ref.asg_tg
326
+ end
327
+
328
+ def subnet_ids
329
+ control_plane_ref.subnet_ids
330
+ end
331
+
332
+ def instance_profile_name
333
+ control_plane_ref.instance_profile_name
334
+ end
335
+
336
+ def ami_id
337
+ control_plane_ref.ami_id
338
+ end
339
+
340
+ def key_name
341
+ control_plane_ref.key_name
342
+ end
343
+
344
+ def ipv4_address
345
+ control_plane_ref.ipv4_address
346
+ end
347
+ end
348
+
349
+ # Minimal accessor so templates can write result.cluster.security_group.id
350
+ # Inherits from the base contract for is_a? compatibility.
351
+ SecurityGroupAccessor = Pangea::Contracts::SecurityGroupAccessor
352
+
353
+ # Result object from kubernetes_cluster() — holds all created references.
354
+ # Inherits from base contract; provider-specific to_h calls config methods
355
+ # that the typed ClusterConfig provides.
356
+ class ArchitectureResult < Pangea::Contracts::ArchitectureResult
357
+ # Override cluster= to wrap in the local ClusterResult subclass
358
+ # (not the base Pangea::Contracts::ClusterResult)
359
+ def cluster=(value)
360
+ @cluster = if value.is_a?(Pangea::Contracts::ClusterResult)
361
+ value
362
+ elsif value
363
+ ClusterResult.new(value)
364
+ end
365
+ end
366
+
367
+ def to_h
368
+ {
369
+ name: name,
370
+ backend: config.backend,
371
+ kubernetes_version: config.kubernetes_version,
372
+ region: config.region,
373
+ managed_kubernetes: config.managed_kubernetes?,
374
+ cluster: cluster&.to_h,
375
+ network: network_to_h,
376
+ iam: iam_to_h,
377
+ node_pools: node_pools.transform_values { |np| np.respond_to?(:to_h) ? np.to_h : np }
378
+ }
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 The Pangea Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Pangea
18
+ module Kubernetes
19
+ # Lazy-loading backend registry. Provider gems are loaded on first use,
20
+ # so users only need the gems for backends they actually reference.
21
+ module BackendRegistry
22
+ @backends = {}
23
+
24
+ # Backend matrix: cloud x tech
25
+ #
26
+ # Each cloud provider can run multiple Kubernetes technologies:
27
+ # - Managed K8s (EKS, GKE, AKS) — cloud-native control plane
28
+ # - NixOS K3s — lightweight K3s on NixOS VMs via blackmatter-kubernetes
29
+ # - NixOS K8s — vanilla Kubernetes on NixOS VMs (future)
30
+ #
31
+ # Naming: {cloud}_{tech} (e.g., aws_eks, aws_nixos_k3s, gcp_gke)
32
+ # Legacy aliases: :aws → :aws_eks, :aws_nixos → :aws_nixos_k3s
33
+ BACKEND_MAP = {
34
+ # ── AWS ───────────────────────────────────────────────────────
35
+ aws_eks: { module_path: 'pangea/kubernetes/backends/aws_eks', class_name: 'AwsEks' },
36
+ aws_nixos_k3s: { module_path: 'pangea/kubernetes/backends/aws_nixos', class_name: 'AwsNixos' },
37
+ # aws_nixos_k8s: { module_path: 'pangea/kubernetes/backends/aws_nixos_k8s', class_name: 'AwsNixosK8s' },
38
+
39
+ # ── GCP ───────────────────────────────────────────────────────
40
+ gcp_gke: { module_path: 'pangea/kubernetes/backends/gcp_gke', class_name: 'GcpGke' },
41
+ gcp_nixos_k3s: { module_path: 'pangea/kubernetes/backends/gcp_nixos', class_name: 'GcpNixos' },
42
+
43
+ # ── Azure ─────────────────────────────────────────────────────
44
+ azure_aks: { module_path: 'pangea/kubernetes/backends/azure_aks', class_name: 'AzureAks' },
45
+ azure_nixos_k3s: { module_path: 'pangea/kubernetes/backends/azure_nixos', class_name: 'AzureNixos' },
46
+
47
+ # ── Hetzner ──────────────────────────────────────────────────
48
+ hcloud_k3s: { module_path: 'pangea/kubernetes/backends/hcloud_k3s', class_name: 'HcloudK3s' },
49
+ }.freeze
50
+
51
+ # Legacy aliases — backward compat with existing templates
52
+ ALIASES = {
53
+ aws: :aws_eks,
54
+ aws_nixos: :aws_nixos_k3s,
55
+ gcp: :gcp_gke,
56
+ gcp_nixos: :gcp_nixos_k3s,
57
+ azure: :azure_aks,
58
+ azure_nixos: :azure_nixos_k3s,
59
+ hcloud: :hcloud_k3s,
60
+ }.freeze
61
+
62
+ class << self
63
+ # Register a backend module for a given backend name
64
+ def register(name, backend_module)
65
+ @backends[name.to_sym] = backend_module
66
+ end
67
+
68
+ # Resolve a backend by name, lazy-loading if needed.
69
+ # Supports both canonical names (aws_eks) and legacy aliases (aws).
70
+ def resolve(name)
71
+ name = name.to_sym
72
+ # Resolve alias to canonical name
73
+ name = ALIASES[name] if ALIASES.key?(name)
74
+
75
+ return @backends[name] if @backends.key?(name)
76
+
77
+ entry = BACKEND_MAP[name]
78
+ raise ArgumentError, "Unknown backend: #{name}. Available: #{available_backends.join(', ')}" unless entry
79
+
80
+ load_backend(name, entry)
81
+ end
82
+
83
+ # List all canonical backend names
84
+ def available_backends
85
+ BACKEND_MAP.keys
86
+ end
87
+
88
+ # List all names including aliases
89
+ def all_names
90
+ (BACKEND_MAP.keys + ALIASES.keys).uniq
91
+ end
92
+
93
+ # Check if a backend's provider gem is available
94
+ def backend_available?(name)
95
+ resolve(name)
96
+ true
97
+ rescue LoadError
98
+ false
99
+ end
100
+
101
+ # Reset registry (for testing)
102
+ def reset!
103
+ @backends = {}
104
+ end
105
+
106
+ private
107
+
108
+ def load_backend(name, entry)
109
+ require entry[:module_path]
110
+ backend_module = Pangea::Kubernetes::Backends.const_get(entry[:class_name])
111
+ @backends[name] = backend_module
112
+ backend_module
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2025 The Pangea Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'pangea/kubernetes/backends/base'
18
+
19
+ module Pangea
20
+ module Kubernetes
21
+ module Backends
22
+ # AWS EKS backend — creates managed EKS clusters with VPC, IAM, and node groups.
23
+ module AwsEks
24
+ include Base
25
+
26
+ class << self
27
+ def backend_name = :aws
28
+ def managed_kubernetes? = true
29
+ def required_gem = 'pangea-aws'
30
+
31
+ def load_provider!
32
+ require required_gem
33
+ rescue LoadError => e
34
+ raise LoadError,
35
+ "Backend :aws requires gem 'pangea-aws'. " \
36
+ "Add it to your Gemfile: gem 'pangea-aws'\n" \
37
+ "Original error: #{e.message}"
38
+ end
39
+
40
+ # Create VPC + subnets for the EKS cluster
41
+ def create_network(ctx, name, config, tags)
42
+ network = Architecture::NetworkResult.new
43
+
44
+ vpc_cidr = config.network&.vpc_cidr || '10.0.0.0/16'
45
+ network.vpc = ctx.aws_vpc(
46
+ :"#{name}_vpc",
47
+ cidr_block: vpc_cidr,
48
+ enable_dns_hostnames: true,
49
+ enable_dns_support: true,
50
+ tags: tags.merge(Name: "#{name}-vpc")
51
+ )
52
+
53
+ # Create 2 subnets in different AZs (EKS requirement)
54
+ %w[a b].each_with_index do |az_suffix, idx|
55
+ subnet = ctx.aws_subnet(
56
+ :"#{name}_subnet_#{az_suffix}",
57
+ vpc_id: network.vpc.id,
58
+ cidr_block: "10.0.#{idx}.0/24",
59
+ availability_zone: "#{config.region}#{az_suffix}",
60
+ map_public_ip_on_launch: true,
61
+ tags: tags.merge(Name: "#{name}-subnet-#{az_suffix}")
62
+ )
63
+ network.add_subnet(:"subnet_#{az_suffix}", subnet)
64
+ end
65
+
66
+ network
67
+ end
68
+
69
+ # Create IAM role for the EKS cluster and node groups
70
+ def create_iam(ctx, name, config, tags)
71
+ iam = Architecture::AwsEksIamResult.new
72
+
73
+ # Cluster role — use provided role_arn or create one
74
+ unless config.role_arn
75
+ assume_role_policy = {
76
+ Version: '2012-10-17',
77
+ Statement: [{
78
+ Effect: 'Allow',
79
+ Principal: { Service: 'eks.amazonaws.com' },
80
+ Action: 'sts:AssumeRole'
81
+ }]
82
+ }
83
+
84
+ iam.cluster_role = ctx.aws_iam_role(
85
+ :"#{name}_cluster_role",
86
+ name: "#{name}-eks-cluster-role",
87
+ assume_role_policy: assume_role_policy,
88
+ tags: tags.merge(Name: "#{name}-cluster-role")
89
+ )
90
+
91
+ iam.cluster_policy_attachment = ctx.aws_iam_role_policy_attachment(
92
+ :"#{name}_cluster_policy",
93
+ role: iam.cluster_role.ref(:name),
94
+ policy_arn: 'arn:aws:iam::aws:policy/AmazonEKSClusterPolicy'
95
+ )
96
+ end
97
+
98
+ # Node role
99
+ node_assume_role_policy = {
100
+ Version: '2012-10-17',
101
+ Statement: [{
102
+ Effect: 'Allow',
103
+ Principal: { Service: 'ec2.amazonaws.com' },
104
+ Action: 'sts:AssumeRole'
105
+ }]
106
+ }
107
+
108
+ iam.node_role = ctx.aws_iam_role(
109
+ :"#{name}_node_role",
110
+ name: "#{name}-eks-node-role",
111
+ assume_role_policy: node_assume_role_policy,
112
+ tags: tags.merge(Name: "#{name}-node-role")
113
+ )
114
+
115
+ %w[AmazonEKSWorkerNodePolicy AmazonEKS_CNI_Policy AmazonEC2ContainerRegistryReadOnly].each do |policy|
116
+ ctx.aws_iam_role_policy_attachment(
117
+ :"#{name}_node_#{policy.downcase.gsub(/[^a-z0-9]/, '_')}",
118
+ role: iam.node_role.ref(:name),
119
+ policy_arn: "arn:aws:iam::aws:policy/#{policy}"
120
+ )
121
+ end
122
+
123
+ iam
124
+ end
125
+
126
+ # Create the EKS cluster
127
+ def create_cluster(ctx, name, config, result, tags)
128
+ # Determine subnet IDs
129
+ subnet_ids = if config.network&.subnet_ids&.any?
130
+ config.network.subnet_ids
131
+ elsif result.network
132
+ if result.network.respond_to?(:subnet_ids)
133
+ result.network.subnet_ids
134
+ else
135
+ result.network.select { |k, _| k.to_s.start_with?('subnet_') }.values.map(&:id)
136
+ end
137
+ else
138
+ []
139
+ end
140
+
141
+ # Determine role ARN
142
+ role_arn = config.role_arn || result.iam&.dig(:cluster_role)&.arn
143
+
144
+ cluster_attrs = {
145
+ name: "#{name}-cluster",
146
+ role_arn: role_arn,
147
+ version: config.kubernetes_version,
148
+ vpc_config: {
149
+ subnet_ids: subnet_ids,
150
+ endpoint_private_access: config.network&.private_endpoint || true,
151
+ endpoint_public_access: config.network&.public_endpoint || false,
152
+ security_group_ids: config.network&.security_group_ids || []
153
+ },
154
+ tags: tags.merge(Name: "#{name}-cluster")
155
+ }
156
+
157
+ cluster_attrs[:enabled_cluster_log_types] = config.logging if config.logging.any?
158
+
159
+ if config.encryption_at_rest
160
+ cluster_attrs[:encryption_config] = {
161
+ resources: ['secrets']
162
+ }
163
+ end
164
+
165
+ ctx.aws_eks_cluster(:"#{name}_cluster", cluster_attrs)
166
+ end
167
+
168
+ # Create an EKS managed node group
169
+ def create_node_pool(ctx, name, cluster_ref, pool_config, tags)
170
+ pool_name = :"#{name}_#{pool_config.name}"
171
+
172
+ node_group_attrs = {
173
+ cluster_name: cluster_ref.ref(:name),
174
+ node_group_name: "#{name}-#{pool_config.name}",
175
+ node_role_arn: "${aws_iam_role.#{name}_node_role.arn}",
176
+ instance_types: pool_config.instance_types,
177
+ scaling_config: {
178
+ desired_size: pool_config.effective_desired_size,
179
+ min_size: pool_config.min_size,
180
+ max_size: pool_config.max_size
181
+ },
182
+ disk_size: pool_config.disk_size_gb,
183
+ tags: tags.merge(
184
+ Name: "#{name}-#{pool_config.name}",
185
+ NodePool: pool_config.name.to_s
186
+ )
187
+ }
188
+
189
+ node_group_attrs[:labels] = pool_config.labels if pool_config.labels.any?
190
+
191
+ if pool_config.taints.any?
192
+ node_group_attrs[:taint] = pool_config.taints.map do |t|
193
+ { key: t[:key], value: t[:value], effect: t[:effect] }
194
+ end
195
+ end
196
+
197
+ ctx.aws_eks_node_group(pool_name, node_group_attrs)
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end