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,176 @@
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
+ # GCP GKE backend — creates managed GKE clusters with VPC-native networking.
23
+ module GcpGke
24
+ include Base
25
+
26
+ class << self
27
+ def backend_name = :gcp
28
+ def managed_kubernetes? = true
29
+ def required_gem = 'pangea-gcp'
30
+
31
+ def load_provider!
32
+ require required_gem
33
+ rescue LoadError => e
34
+ raise LoadError,
35
+ "Backend :gcp requires gem 'pangea-gcp'. " \
36
+ "Add it to your Gemfile: gem 'pangea-gcp'\n" \
37
+ "Original error: #{e.message}"
38
+ end
39
+
40
+ # Create GCP VPC network + subnet
41
+ def create_network(ctx, name, config, tags)
42
+ network = Architecture::NetworkResult.new
43
+
44
+ network.vpc = ctx.google_compute_network(
45
+ :"#{name}_network",
46
+ name: "#{name}-network",
47
+ auto_create_subnetworks: false,
48
+ project: config.project
49
+ )
50
+
51
+ subnet = ctx.google_compute_subnetwork(
52
+ :"#{name}_subnet",
53
+ name: "#{name}-subnet",
54
+ ip_cidr_range: config.network&.vpc_cidr || '10.0.0.0/20',
55
+ region: config.region,
56
+ network: network.vpc.id,
57
+ project: config.project,
58
+ secondary_ip_range: [
59
+ { range_name: "#{name}-pods", ip_cidr_range: config.network&.pod_cidr || '10.1.0.0/16' },
60
+ { range_name: "#{name}-services", ip_cidr_range: config.network&.service_cidr || '10.2.0.0/20' }
61
+ ]
62
+ )
63
+ network.add_subnet(:subnet, subnet)
64
+
65
+ network
66
+ end
67
+
68
+ # GKE uses Workload Identity — no standalone IAM resources needed
69
+ def create_iam(ctx, name, config, tags)
70
+ iam = Architecture::GcpIamResult.new
71
+
72
+ # Service account for GKE nodes
73
+ iam.node_sa = ctx.google_service_account(
74
+ :"#{name}_node_sa",
75
+ account_id: "#{name}-gke-nodes",
76
+ display_name: "#{name} GKE Node Service Account",
77
+ project: config.project
78
+ )
79
+
80
+ # Bind minimum required roles
81
+ %w[logging.logWriter monitoring.metricWriter monitoring.viewer].each do |role|
82
+ ctx.google_project_iam_member(
83
+ :"#{name}_node_#{role.gsub('.', '_')}",
84
+ project: config.project,
85
+ role: "roles/#{role}",
86
+ member: "serviceAccount:#{iam.node_sa.email}"
87
+ )
88
+ end
89
+
90
+ iam
91
+ end
92
+
93
+ # Create the GKE cluster
94
+ def create_cluster(ctx, name, config, result, tags)
95
+ cluster_attrs = {
96
+ name: "#{name}-cluster",
97
+ location: config.region,
98
+ project: config.project,
99
+ initial_node_count: 1,
100
+ remove_default_node_pool: true,
101
+ min_master_version: config.kubernetes_version,
102
+ deletion_protection: false,
103
+ networking_mode: 'VPC_NATIVE',
104
+ resource_labels: gke_labels(tags)
105
+ }
106
+
107
+ # VPC-native networking
108
+ if result.network
109
+ cluster_attrs[:network] = result.network[:vpc]&.id
110
+ cluster_attrs[:subnetwork] = result.network[:subnet]&.id
111
+ cluster_attrs[:ip_allocation_policy] = {
112
+ cluster_secondary_range_name: "#{name}-pods",
113
+ services_secondary_range_name: "#{name}-services"
114
+ }
115
+ end
116
+
117
+ # Private cluster
118
+ if config.network&.private_endpoint
119
+ cluster_attrs[:private_cluster_config] = {
120
+ enable_private_nodes: true,
121
+ enable_private_endpoint: !config.network.public_endpoint,
122
+ master_ipv4_cidr_block: '172.16.0.0/28'
123
+ }
124
+ end
125
+
126
+ # Workload Identity
127
+ if config.project
128
+ cluster_attrs[:workload_identity_config] = {
129
+ workload_pool: "#{config.project}.svc.id.goog"
130
+ }
131
+ end
132
+
133
+ # Release channel
134
+ cluster_attrs[:release_channel] = { channel: 'REGULAR' }
135
+
136
+ ctx.google_container_cluster(:"#{name}_cluster", cluster_attrs)
137
+ end
138
+
139
+ # Create a GKE node pool
140
+ def create_node_pool(ctx, name, cluster_ref, pool_config, tags)
141
+ pool_name = :"#{name}_#{pool_config.name}"
142
+
143
+ node_pool_attrs = {
144
+ name: "#{name}-#{pool_config.name}",
145
+ cluster: cluster_ref.id,
146
+ location: tags[:Region] || 'us-central1',
147
+ initial_node_count: pool_config.effective_desired_size,
148
+ node_config: {
149
+ machine_type: pool_config.instance_types.first,
150
+ disk_size_gb: pool_config.disk_size_gb,
151
+ oauth_scopes: ['https://www.googleapis.com/auth/cloud-platform'],
152
+ labels: pool_config.labels.merge(
153
+ 'node-pool' => pool_config.name.to_s
154
+ )
155
+ },
156
+ autoscaling: {
157
+ min_node_count: pool_config.min_size,
158
+ max_node_count: pool_config.max_size
159
+ }
160
+ }
161
+
162
+ ctx.google_container_node_pool(pool_name, node_pool_attrs)
163
+ end
164
+
165
+ private
166
+
167
+ # Convert tags to GKE-compatible labels (lowercase, hyphens)
168
+ def gke_labels(tags)
169
+ tags.transform_keys { |k| k.to_s.downcase.gsub(/[^a-z0-9-]/, '-') }
170
+ .transform_values { |v| v.to_s.downcase.gsub(/[^a-z0-9-]/, '-') }
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,240 @@
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
+ require 'pangea/kubernetes/backends/nixos_base'
19
+
20
+ module Pangea
21
+ module Kubernetes
22
+ module Backends
23
+ # GCP NixOS backend — GCE instances running NixOS with k3s/k8s
24
+ # via blackmatter-kubernetes modules.
25
+ #
26
+ # Uses:
27
+ # - GCE instances for control plane (static)
28
+ # - Managed Instance Groups (MIGs) for worker node pools
29
+ # - VPC + Firewall rules for networking
30
+ # - Instance Templates for NixOS image + cloud-init
31
+ #
32
+ # No managed K8s services (GKE) — all k3s/k8s managed by NixOS.
33
+ module GcpNixos
34
+ include Base
35
+ extend NixosBase
36
+
37
+ class << self
38
+ def backend_name = :gcp_nixos
39
+ def managed_kubernetes? = false
40
+ def required_gem = 'pangea-gcp'
41
+
42
+ def load_provider!
43
+ require required_gem
44
+ rescue LoadError => e
45
+ raise LoadError,
46
+ "Backend :gcp_nixos requires gem 'pangea-gcp'. " \
47
+ "Add it to your Gemfile: gem 'pangea-gcp'\n" \
48
+ "Original error: #{e.message}"
49
+ end
50
+
51
+ # Create VPC network + subnet + firewall rules
52
+ def create_network(ctx, name, config, tags)
53
+ network = Architecture::GcpNetworkResult.new
54
+
55
+ network.vpc = ctx.google_compute_network(
56
+ :"#{name}_network",
57
+ name: "#{name}-network",
58
+ auto_create_subnetworks: false,
59
+ project: config.project
60
+ )
61
+
62
+ subnet = ctx.google_compute_subnetwork(
63
+ :"#{name}_subnet",
64
+ name: "#{name}-subnet",
65
+ ip_cidr_range: config.network&.vpc_cidr || '10.0.0.0/20',
66
+ region: config.region,
67
+ network: network.vpc.id,
68
+ project: config.project
69
+ )
70
+ network.add_subnet(:subnet, subnet)
71
+
72
+ # Firewall rules for k3s/k8s
73
+ network.firewall_internal = ctx.google_compute_firewall(
74
+ :"#{name}_fw_internal",
75
+ name: "#{name}-allow-internal",
76
+ network: network.vpc.id,
77
+ project: config.project,
78
+ allow: [
79
+ { protocol: 'tcp', ports: %w[0-65535] },
80
+ { protocol: 'udp', ports: %w[0-65535] },
81
+ { protocol: 'icmp' }
82
+ ],
83
+ source_ranges: [config.network&.vpc_cidr || '10.0.0.0/20']
84
+ )
85
+
86
+ network.firewall_external = ctx.google_compute_firewall(
87
+ :"#{name}_fw_external",
88
+ name: "#{name}-allow-external",
89
+ network: network.vpc.id,
90
+ project: config.project,
91
+ allow: [
92
+ { protocol: 'tcp', ports: %w[22 80 443 6443] }
93
+ ],
94
+ source_ranges: ['0.0.0.0/0']
95
+ )
96
+
97
+ network
98
+ end
99
+
100
+ # Service account for GCE instances (minimal permissions)
101
+ def create_iam(ctx, name, config, tags)
102
+ iam = Architecture::GcpIamResult.new
103
+
104
+ iam.node_sa = ctx.google_service_account(
105
+ :"#{name}_node_sa",
106
+ account_id: "#{name}-nixos-nodes",
107
+ display_name: "#{name} NixOS K8s Node Service Account",
108
+ project: config.project
109
+ )
110
+
111
+ %w[logging.logWriter monitoring.metricWriter].each do |role|
112
+ ctx.google_project_iam_member(
113
+ :"#{name}_node_#{role.gsub('.', '_')}",
114
+ project: config.project,
115
+ role: "roles/#{role}",
116
+ member: "serviceAccount:#{iam.node_sa.email}"
117
+ )
118
+ end
119
+
120
+ iam
121
+ end
122
+
123
+ # Create control plane GCE instances (static, no MIG)
124
+ def create_cluster(ctx, name, config, result, tags)
125
+ nixos_create_cluster(ctx, name, config, result, tags)
126
+ end
127
+
128
+ # Create worker node pool via Instance Template + MIG + Autoscaler
129
+ def create_node_pool(ctx, name, cluster_ref, pool_config, tags)
130
+ nixos_create_node_pool(ctx, name, cluster_ref, pool_config, tags)
131
+ end
132
+
133
+ # --- NixosBase template hooks ---
134
+
135
+ def create_compute_instance(ctx, name, config, result, cloud_init, index, tags)
136
+ system_pool = config.system_node_pool
137
+ machine_type = system_pool.instance_types.first
138
+ image = config.gce_image || config.nixos&.image_id || 'nixos-24-05'
139
+
140
+ ctx.google_compute_instance(
141
+ :"#{name}_cp_#{index}",
142
+ name: "#{name}-cp-#{index}",
143
+ machine_type: machine_type,
144
+ zone: "#{config.region}-a",
145
+ project: config.project,
146
+ boot_disk: {
147
+ initialize_params: {
148
+ image: image,
149
+ size: system_pool.disk_size_gb
150
+ }
151
+ },
152
+ network_interface: {
153
+ network: result.network&.dig(:vpc)&.id,
154
+ subnetwork: result.network&.dig(:subnet)&.id,
155
+ access_config: {}
156
+ },
157
+ metadata: {
158
+ 'user-data' => cloud_init
159
+ },
160
+ service_account: {
161
+ email: result.iam&.dig(:node_sa)&.email,
162
+ scopes: ['cloud-platform']
163
+ },
164
+ labels: gce_labels(tags.merge(
165
+ role: 'control-plane',
166
+ node_index: index.to_s,
167
+ distribution: config.distribution.to_s
168
+ ))
169
+ )
170
+ end
171
+
172
+ def create_worker_pool(ctx, name, _cluster_ref, pool_config, cloud_init, tags)
173
+ pool_name = :"#{name}_#{pool_config.name}"
174
+ machine_type = pool_config.instance_types.first
175
+
176
+ # Instance Template
177
+ template = ctx.google_compute_instance_template(
178
+ :"#{pool_name}_template",
179
+ name: "#{name}-#{pool_config.name}-template",
180
+ machine_type: machine_type,
181
+ project: tags[:Project],
182
+ disk: [{
183
+ source_image: tags[:Image] || 'nixos-24-05',
184
+ disk_size_gb: pool_config.disk_size_gb,
185
+ auto_delete: true,
186
+ boot: true
187
+ }],
188
+ network_interface: {
189
+ network: tags[:NetworkId],
190
+ subnetwork: tags[:SubnetId],
191
+ access_config: {}
192
+ },
193
+ metadata: { 'user-data' => cloud_init },
194
+ labels: gce_labels(tags.merge(
195
+ role: 'worker',
196
+ node_pool: pool_config.name.to_s
197
+ ))
198
+ )
199
+
200
+ # Managed Instance Group
201
+ mig = ctx.google_compute_instance_group_manager(
202
+ :"#{pool_name}_mig",
203
+ name: "#{name}-#{pool_config.name}-mig",
204
+ base_instance_name: "#{name}-#{pool_config.name}",
205
+ zone: "#{tags[:Region] || 'us-central1'}-a",
206
+ project: tags[:Project],
207
+ target_size: pool_config.effective_desired_size,
208
+ version: [{
209
+ instance_template: template.id
210
+ }]
211
+ )
212
+
213
+ # Autoscaler
214
+ ctx.google_compute_autoscaler(
215
+ :"#{pool_name}_autoscaler",
216
+ name: "#{name}-#{pool_config.name}-autoscaler",
217
+ zone: "#{tags[:Region] || 'us-central1'}-a",
218
+ project: tags[:Project],
219
+ target: mig.id,
220
+ autoscaling_policy: {
221
+ min_replicas: pool_config.min_size,
222
+ max_replicas: pool_config.max_size,
223
+ cpu_utilization: { target: 0.7 }
224
+ }
225
+ )
226
+
227
+ mig
228
+ end
229
+
230
+ private
231
+
232
+ def gce_labels(tags)
233
+ tags.transform_keys { |k| k.to_s.downcase.gsub(/[^a-z0-9-]/, '-') }
234
+ .transform_values { |v| v.to_s.downcase.gsub(/[^a-z0-9-]/, '-') }
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,181 @@
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
+ require 'pangea/kubernetes/backends/nixos_base'
19
+
20
+ module Pangea
21
+ module Kubernetes
22
+ module Backends
23
+ # Hetzner Cloud NixOS backend.
24
+ # Provisions NixOS VMs with cloud-init carrying blackmatter-kubernetes config.
25
+ # Supports both k3s and vanilla Kubernetes distributions.
26
+ module HcloudK3s
27
+ include Base
28
+ extend NixosBase
29
+
30
+ class << self
31
+ def backend_name = :hcloud
32
+ def managed_kubernetes? = false
33
+ def required_gem = 'pangea-hcloud'
34
+
35
+ def load_provider!
36
+ require required_gem
37
+ rescue LoadError => e
38
+ raise LoadError,
39
+ "Backend :hcloud requires gem 'pangea-hcloud'. " \
40
+ "Add it to your Gemfile: gem 'pangea-hcloud'\n" \
41
+ "Original error: #{e.message}"
42
+ end
43
+
44
+ # Create Hetzner Cloud network + subnet
45
+ def create_network(ctx, name, config, tags)
46
+ network = Architecture::HcloudNetworkResult.new
47
+
48
+ ip_range = config.network&.vpc_cidr || '10.0.0.0/16'
49
+ network.network = ctx.hcloud_network(
50
+ :"#{name}_network",
51
+ name: "#{name}-network",
52
+ ip_range: ip_range,
53
+ labels: hcloud_labels(tags)
54
+ )
55
+ network.vpc = network.network
56
+
57
+ subnet = ctx.hcloud_network_subnet(
58
+ :"#{name}_subnet",
59
+ network_id: network.network.id,
60
+ type: 'cloud',
61
+ network_zone: config.region,
62
+ ip_range: config.network&.pod_cidr || '10.0.1.0/24'
63
+ )
64
+ network.add_subnet(:subnet, subnet)
65
+
66
+ network
67
+ end
68
+
69
+ # NixOS doesn't use cloud IAM — return empty
70
+ def create_iam(_ctx, _name, _config, _tags)
71
+ Architecture::IamResult.new
72
+ end
73
+
74
+ # Create control plane server(s) as hcloud_server resources
75
+ def create_cluster(ctx, name, config, result, tags)
76
+ # Create firewall first
77
+ ctx.hcloud_firewall(
78
+ :"#{name}_firewall",
79
+ name: "#{name}-firewall",
80
+ rules: hcloud_firewall_rules(config.distribution),
81
+ labels: hcloud_labels(tags)
82
+ )
83
+
84
+ nixos_create_cluster(ctx, name, config, result, tags)
85
+ end
86
+
87
+ # Create worker nodes as hcloud_server resources
88
+ def create_node_pool(ctx, name, cluster_ref, pool_config, tags)
89
+ # Hetzner doesn't have ASG — create individual servers
90
+ server_type = pool_config.instance_types.first
91
+ count = pool_config.effective_desired_size
92
+
93
+ servers = []
94
+ count.times do |idx|
95
+ # Generate per-worker cloud-init with unique node_index (not shared)
96
+ cloud_init = build_agent_cloud_init(name, tags, cluster_ref, node_index: idx)
97
+
98
+ server = ctx.hcloud_server(
99
+ :"#{name}_#{pool_config.name}_#{idx}",
100
+ name: "#{name}-#{pool_config.name}-#{idx}",
101
+ server_type: server_type,
102
+ image: 'ubuntu-24.04',
103
+ location: tags[:Region] || 'nbg1',
104
+ user_data: cloud_init,
105
+ ssh_keys: pool_config.ssh_keys,
106
+ labels: hcloud_labels(tags.merge(
107
+ Role: 'worker',
108
+ NodePool: pool_config.name.to_s,
109
+ NodeIndex: idx.to_s
110
+ ))
111
+ )
112
+
113
+ servers << server
114
+ end
115
+
116
+ servers.first
117
+ end
118
+
119
+ # --- NixosBase template hooks ---
120
+
121
+ def create_compute_instance(ctx, name, config, result, cloud_init, index, tags)
122
+ system_pool = config.system_node_pool
123
+ server_type = system_pool.instance_types.first
124
+ firewall = result.network ? ctx.created_resources&.find { |r| r[:type] == 'hcloud_firewall' } : nil
125
+
126
+ ctx.hcloud_server(
127
+ :"#{name}_cp_#{index}",
128
+ name: "#{name}-cp-#{index}",
129
+ server_type: server_type,
130
+ image: nixos_image(config),
131
+ location: config.region,
132
+ user_data: cloud_init,
133
+ ssh_keys: system_pool.ssh_keys,
134
+ firewall_ids: firewall ? [firewall[:ref].id] : [],
135
+ labels: hcloud_labels(tags.merge(
136
+ Role: 'control-plane',
137
+ NodeIndex: index.to_s,
138
+ Distribution: config.distribution.to_s
139
+ ))
140
+ )
141
+ end
142
+
143
+ # Override post_create_instance for Hetzner network attachment
144
+ def post_create_instance(ctx, name, server, result, index, _tags)
145
+ return unless result.network&.dig(:network)
146
+
147
+ ctx.hcloud_server_network(
148
+ :"#{name}_cp_#{index}_network",
149
+ server_id: server.id,
150
+ network_id: result.network[:network].id
151
+ )
152
+ end
153
+
154
+ private
155
+
156
+ def nixos_image(config)
157
+ config.nixos&.image_id || 'ubuntu-24.04'
158
+ end
159
+
160
+ # Convert standard tags to Hetzner labels (lowercase, underscored)
161
+ def hcloud_labels(tags)
162
+ tags.transform_keys { |k| k.to_s.downcase.gsub(/[^a-z0-9_]/, '_') }
163
+ end
164
+
165
+ # Firewall rules for k3s or vanilla k8s
166
+ def hcloud_firewall_rules(distribution)
167
+ base_firewall_ports(distribution).map do |_name, port_def|
168
+ source_ips = port_def[:public] ? ['0.0.0.0/0', '::/0'] : ['10.0.0.0/8']
169
+ {
170
+ direction: 'in',
171
+ protocol: port_def[:protocol].to_s,
172
+ port: port_def[:port].to_s,
173
+ source_ips: source_ips
174
+ }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end