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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pangea/resources/types'
4
+ require 'pangea/kubernetes/types/firewall_config'
5
+ require 'pangea/kubernetes/types/kernel_config'
6
+ require 'pangea/kubernetes/types/wait_for_dns_config'
7
+ require 'pangea/kubernetes/types/etcd_config'
8
+ require 'pangea/kubernetes/types/pki_config'
9
+ require 'pangea/kubernetes/types/control_plane_config'
10
+
11
+ module Pangea
12
+ module Kubernetes
13
+ module Types
14
+ # Vanilla Kubernetes distribution configuration for blackmatter-kubernetes
15
+ # NixOS modules. Maps to `services.blackmatter.kubernetes.*` options.
16
+ #
17
+ # Extends K3sConfig fields with control plane, PKI, and etcd options
18
+ # that are only relevant for vanilla Kubernetes (not k3s).
19
+ class VanillaKubernetesConfig < Pangea::Resources::BaseAttributes
20
+ transform_keys(&:to_sym)
21
+
22
+ # --- Shared fields (same as K3sConfig) ---
23
+
24
+ # Cluster CIDR for pod networking
25
+ attribute :cluster_cidr, T::String.optional.default(nil)
26
+
27
+ # Service CIDR for service networking
28
+ attribute :service_cidr, T::String.optional.default(nil)
29
+
30
+ # Cluster DNS address
31
+ attribute :cluster_dns, T::String.optional.default(nil)
32
+
33
+ # Node name override
34
+ attribute :node_name, T::String.optional.default(nil)
35
+
36
+ # Node labels (key => value)
37
+ attribute :node_labels, T::Hash.default({}.freeze)
38
+
39
+ # Node taints
40
+ attribute :node_taints, T::Array.of(T::String).default([].freeze)
41
+
42
+ # Node IP address override
43
+ attribute :node_ip, T::String.optional.default(nil)
44
+
45
+ # Extra flags passed to kubelet
46
+ attribute :extra_flags, T::Array.of(T::String).default([].freeze)
47
+
48
+ # Data directory
49
+ attribute :data_dir, T::String.optional.default(nil)
50
+
51
+ # Config file path
52
+ attribute :config_path, T::String.optional.default(nil)
53
+
54
+ # Environment file for systemd services
55
+ attribute :environment_file, T::String.optional.default(nil)
56
+
57
+ # Containerd config template path
58
+ attribute :containerd_config_template, T::String.optional.default(nil)
59
+
60
+ # Components to disable
61
+ attribute :disable, T::Array.of(T::String).default([].freeze)
62
+
63
+ # Extra kubelet args (key => value)
64
+ attribute :extra_kubelet_config, T::Hash.default({}.freeze)
65
+
66
+ # Extra kube-proxy args (key => value)
67
+ attribute :extra_kube_proxy_config, T::Hash.default({}.freeze)
68
+
69
+ # Auto-deploying manifests
70
+ attribute :manifests, T::Hash.default({}.freeze)
71
+
72
+ # Firewall configuration
73
+ attribute :firewall, FirewallConfig.optional.default(nil)
74
+
75
+ # Kernel configuration
76
+ attribute :kernel, KernelConfig.optional.default(nil)
77
+
78
+ # DNS wait configuration
79
+ attribute :wait_for_dns, WaitForDNSConfig.optional.default(nil)
80
+
81
+ # Enable NVIDIA GPU support
82
+ attribute :nvidia_enable, T::Bool.default(false)
83
+
84
+ # Enable graceful node shutdown
85
+ attribute :graceful_node_shutdown, T::Bool.default(true)
86
+
87
+ # --- Vanilla Kubernetes-specific fields ---
88
+
89
+ # Control plane configuration
90
+ attribute :control_plane, ControlPlaneConfig.optional.default(nil)
91
+
92
+ # PKI/certificate configuration
93
+ attribute :pki, PKIConfig.optional.default(nil)
94
+
95
+ # Etcd configuration
96
+ attribute :etcd, EtcdConfig.optional.default(nil)
97
+
98
+ def to_h
99
+ hash = {}
100
+ hash[:cluster_cidr] = cluster_cidr if cluster_cidr
101
+ hash[:service_cidr] = service_cidr if service_cidr
102
+ hash[:cluster_dns] = cluster_dns if cluster_dns
103
+ hash[:node_name] = node_name if node_name
104
+ hash[:node_labels] = node_labels if node_labels.any?
105
+ hash[:node_taints] = node_taints if node_taints.any?
106
+ hash[:node_ip] = node_ip if node_ip
107
+ hash[:extra_flags] = extra_flags if extra_flags.any?
108
+ hash[:data_dir] = data_dir if data_dir
109
+ hash[:config_path] = config_path if config_path
110
+ hash[:environment_file] = environment_file if environment_file
111
+ hash[:containerd_config_template] = containerd_config_template if containerd_config_template
112
+ hash[:disable] = disable if disable.any?
113
+ hash[:extra_kubelet_config] = extra_kubelet_config if extra_kubelet_config.any?
114
+ hash[:extra_kube_proxy_config] = extra_kube_proxy_config if extra_kube_proxy_config.any?
115
+ hash[:manifests] = manifests if manifests.any?
116
+ hash[:firewall] = firewall.to_h if firewall
117
+ hash[:kernel] = kernel.to_h if kernel
118
+ hash[:wait_for_dns] = wait_for_dns.to_h if wait_for_dns
119
+ hash[:nvidia_enable] = nvidia_enable if nvidia_enable
120
+ hash[:graceful_node_shutdown] = graceful_node_shutdown
121
+ hash[:control_plane] = control_plane.to_h if control_plane
122
+ hash[:pki] = pki.to_h if pki
123
+ hash[:etcd] = etcd.to_h if etcd
124
+ hash
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pangea/resources/types'
4
+
5
+ module Pangea
6
+ module Kubernetes
7
+ module Types
8
+ # PersistentStateConfig — opt-in persistent state volume for a NixOS
9
+ # cluster node, decoupled from the EC2 instance lifecycle.
10
+ #
11
+ # The aws_nixos backend (and any future backend that opts in) emits a
12
+ # separately-managed cloud volume (e.g., aws_ebs_volume) tagged with
13
+ # `<discovery_tag>=<cluster_name>`, with `lifecycle.prevent_destroy`
14
+ # ON. The volume survives ASG sleep/wake, instance replacement, and
15
+ # cluster recreation; only an explicit operator action can destroy it.
16
+ #
17
+ # At first boot the cluster bootstrap (kindling) discovers the
18
+ # volume by tag, attaches it to the running instance, formats it if
19
+ # blank, and mounts it at `mount_path`. Subsequent boots (after
20
+ # sleep/wake or instance replacement) skip the format step and
21
+ # remount the existing filesystem.
22
+ #
23
+ # AZ binding: an EBS volume is permanently bound to one AZ. When
24
+ # `persistent_state` is set, the system node pool's ASG is
25
+ # constrained to that AZ. Multi-AZ persistent state is a different
26
+ # primitive (regional replication) and outside this type's scope.
27
+ #
28
+ # Mount path default `/var/lib/rancher/k3s` puts the k3s data dir
29
+ # on the persistent volume, so cluster state (etcd, registrations,
30
+ # workload state) survives instance churn.
31
+ class PersistentStateConfig < Pangea::Resources::BaseAttributes
32
+ transform_keys(&:to_sym)
33
+
34
+ SUPPORTED_VOLUME_TYPES = %w[gp3 gp2 io1 io2 st1 sc1].freeze
35
+ SUPPORTED_FILESYSTEMS = %w[ext4 xfs].freeze
36
+
37
+ # Volume size in GiB. EBS minimum 1 GiB for gp3/gp2; default 50.
38
+ attribute :size_gb,
39
+ T::Coercible::Integer.constrained(gteq: 8).default(50)
40
+
41
+ # EBS volume type. gp3 is the modern default.
42
+ attribute :volume_type,
43
+ T::String.constrained(included_in: SUPPORTED_VOLUME_TYPES).default('gp3')
44
+
45
+ # Mount path on the node. Default = k3s data dir, so cluster
46
+ # state lives on the persistent volume.
47
+ attribute :mount_path, T::String.default('/var/lib/rancher/k3s')
48
+
49
+ # Filesystem to format on first boot. Subsequent boots remount
50
+ # the existing fs without reformatting.
51
+ attribute :filesystem,
52
+ T::String.constrained(included_in: SUPPORTED_FILESYSTEMS).default('ext4')
53
+
54
+ # Tag key used to discover this cluster's persistent volume from
55
+ # inside the instance. Tag value is always the cluster name.
56
+ # Tunable so multiple persistent volumes per cluster (e.g. one
57
+ # for k3s data, one for a workload PV) can coexist with
58
+ # distinguishable discovery keys.
59
+ attribute :discovery_tag, T::String.default('PersistentStateFor')
60
+
61
+ # Whether to enable EBS encryption-at-rest. Default true.
62
+ # Set false only for non-sensitive workloads where the marginal
63
+ # cost matters.
64
+ attribute :encrypted, T::Bool.default(true)
65
+
66
+ # KMS key ARN/alias for encryption. nil = AWS-managed default key.
67
+ attribute :kms_key_id, T::String.optional.default(nil)
68
+
69
+ # Provisioned IOPS (gp3: 3000-16000; io1/io2 required). nil =
70
+ # volume-type baseline.
71
+ attribute :iops, T::Coercible::Integer.optional.default(nil)
72
+
73
+ # Provisioned throughput in MiB/s (gp3 only: 125-1000). nil =
74
+ # baseline.
75
+ attribute :throughput, T::Coercible::Integer.optional.default(nil)
76
+
77
+ # Availability zone for the volume. Must match the AZ where the
78
+ # ASG instance launches. When nil, the backend derives it from
79
+ # the first system-pool subnet's AZ.
80
+ attribute :availability_zone, T::String.optional.default(nil)
81
+
82
+ def to_h
83
+ hash = {
84
+ size_gb: size_gb,
85
+ volume_type: volume_type,
86
+ mount_path: mount_path,
87
+ filesystem: filesystem,
88
+ discovery_tag: discovery_tag,
89
+ encrypted: encrypted
90
+ }
91
+ hash[:kms_key_id] = kms_key_id if kms_key_id
92
+ hash[:iops] = iops if iops
93
+ hash[:throughput] = throughput if throughput
94
+ hash[:availability_zone] = availability_zone if availability_zone
95
+ hash
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pangea/resources/types'
4
+
5
+ module Pangea
6
+ module Kubernetes
7
+ module Types
8
+ # PKI configuration for blackmatter-kubernetes NixOS modules.
9
+ # Maps to `pki.*` options in the NixOS module.
10
+ # Controls certificate generation and distribution.
11
+ class PKIConfig < Pangea::Resources::BaseAttributes
12
+ transform_keys(&:to_sym)
13
+
14
+ # PKI mode: 'auto' (generated), 'manual' (user-provided), or 'external' (cert-manager)
15
+ attribute :mode, T::String.constrained(
16
+ included_in: %w[auto manual external]
17
+ ).default('auto')
18
+
19
+ # Certificate validity period in days
20
+ attribute :cert_validity_days, T::Coercible::Integer.constrained(gteq: 1).default(365)
21
+
22
+ # CA certificate path (for manual mode)
23
+ attribute :ca_cert_path, T::String.optional.default(nil)
24
+
25
+ # CA key path (for manual mode)
26
+ attribute :ca_key_path, T::String.optional.default(nil)
27
+
28
+ # Additional SANs for the API server certificate
29
+ attribute :api_server_extra_sans, T::Array.of(T::String).default([].freeze)
30
+
31
+ # Certificate directory
32
+ attribute :cert_dir, T::String.default('/etc/kubernetes/pki')
33
+
34
+ def to_h
35
+ hash = {
36
+ mode: mode,
37
+ cert_validity_days: cert_validity_days,
38
+ cert_dir: cert_dir
39
+ }
40
+ hash[:ca_cert_path] = ca_cert_path if ca_cert_path
41
+ hash[:ca_key_path] = ca_key_path if ca_key_path
42
+ hash[:api_server_extra_sans] = api_server_extra_sans if api_server_extra_sans.any?
43
+ hash
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pangea/resources/types'
4
+
5
+ module Pangea
6
+ module Kubernetes
7
+ module Types
8
+ # Secrets configuration for sops-nix path references.
9
+ # These are file paths that sops-nix decrypts at boot time.
10
+ # NEVER contains actual secret values — only filesystem paths.
11
+ class SecretsConfig < Pangea::Resources::BaseAttributes
12
+ transform_keys(&:to_sym)
13
+
14
+ # Path to the FluxCD SSH deploy key (decrypted by sops-nix)
15
+ attribute :flux_ssh_key_path, T::String.optional.default(nil)
16
+
17
+ # Path to the FluxCD token (decrypted by sops-nix)
18
+ attribute :flux_token_path, T::String.optional.default(nil)
19
+
20
+ # Path to the SOPS age key (decrypted by sops-nix)
21
+ attribute :sops_age_key_path, T::String.optional.default(nil)
22
+
23
+ # Path to the K8s join token (decrypted by sops-nix)
24
+ attribute :join_token_path, T::String.optional.default(nil)
25
+
26
+ # Additional secret paths (name => path)
27
+ attribute :extra_paths, T::Hash.default({}.freeze)
28
+
29
+ def to_h
30
+ hash = {}
31
+ hash[:flux_ssh_key_path] = flux_ssh_key_path if flux_ssh_key_path
32
+ hash[:flux_token_path] = flux_token_path if flux_token_path
33
+ hash[:sops_age_key_path] = sops_age_key_path if sops_age_key_path
34
+ hash[:join_token_path] = join_token_path if join_token_path
35
+ hash[:extra_paths] = extra_paths if extra_paths.any?
36
+ hash
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pangea/resources/types'
4
+
5
+ module Pangea
6
+ module Kubernetes
7
+ module Types
8
+ # Valid VPN profiles — must match kindling's VALID_VPN_PROFILES and
9
+ # blackmatter-vpn's lib/profiles.nix
10
+ VALID_VPN_PROFILES = %w[k8s-control-plane k8s-full site-to-site mesh].freeze
11
+
12
+ # VPN peer configuration for WireGuard links.
13
+ class VpnPeerConfig < Pangea::Resources::BaseAttributes
14
+ transform_keys(&:to_sym)
15
+
16
+ attribute :public_key, T::String
17
+ attribute :endpoint, T::String.optional.default(nil)
18
+ attribute :allowed_ips, T::Array.of(T::String).default([].freeze)
19
+ attribute :persistent_keepalive, T::Coercible::Integer.optional.default(nil)
20
+ attribute :preshared_key_file, T::String.optional.default(nil)
21
+
22
+ def to_h
23
+ hash = { public_key: public_key, allowed_ips: allowed_ips }
24
+ hash[:endpoint] = endpoint if endpoint
25
+ hash[:persistent_keepalive] = persistent_keepalive if persistent_keepalive
26
+ hash[:preshared_key_file] = preshared_key_file if preshared_key_file
27
+ hash
28
+ end
29
+ end
30
+
31
+ # Per-link firewall configuration.
32
+ class VpnFirewallConfig < Pangea::Resources::BaseAttributes
33
+ transform_keys(&:to_sym)
34
+
35
+ attribute :trust_interface, T::Bool.default(false)
36
+ attribute :allowed_tcp_ports, T::Array.of(T::Coercible::Integer).default([].freeze)
37
+ attribute :allowed_udp_ports, T::Array.of(T::Coercible::Integer).default([].freeze)
38
+ attribute :incoming_udp_port, T::Coercible::Integer.optional.default(nil)
39
+
40
+ def to_h
41
+ hash = { trust_interface: trust_interface }
42
+ hash[:allowed_tcp_ports] = allowed_tcp_ports if allowed_tcp_ports.any?
43
+ hash[:allowed_udp_ports] = allowed_udp_ports if allowed_udp_ports.any?
44
+ hash[:incoming_udp_port] = incoming_udp_port if incoming_udp_port
45
+ hash
46
+ end
47
+ end
48
+
49
+ # A single WireGuard VPN link.
50
+ class VpnLinkConfig < Pangea::Resources::BaseAttributes
51
+ transform_keys(&:to_sym)
52
+
53
+ attribute :name, T::String
54
+ attribute :private_key_file, T::String.optional.default(nil)
55
+ attribute :listen_port, T::Coercible::Integer.optional.default(nil)
56
+ attribute :address, T::String.optional.default(nil)
57
+ attribute :profile, T::String.optional.default(nil)
58
+ attribute :persistent_keepalive, T::Coercible::Integer.optional.default(nil)
59
+ attribute :mtu, T::Coercible::Integer.optional.default(nil)
60
+ attribute :peers, T::Array.of(VpnPeerConfig).default([].freeze)
61
+ attribute :firewall, VpnFirewallConfig.optional.default(nil)
62
+
63
+ def to_h
64
+ hash = { name: name }
65
+ hash[:private_key_file] = private_key_file if private_key_file
66
+ hash[:listen_port] = listen_port if listen_port
67
+ hash[:address] = address if address
68
+ hash[:profile] = profile if profile
69
+ hash[:persistent_keepalive] = persistent_keepalive if persistent_keepalive
70
+ hash[:mtu] = mtu if mtu
71
+ hash[:peers] = peers.map(&:to_h) if peers.any?
72
+ hash[:firewall] = firewall.to_h if firewall
73
+ hash
74
+ end
75
+ end
76
+
77
+ # Top-level VPN configuration for a cluster.
78
+ class VpnConfig < Pangea::Resources::BaseAttributes
79
+ transform_keys(&:to_sym)
80
+
81
+ attribute :require_liveness, T::Bool.default(false)
82
+ attribute :links, T::Array.of(VpnLinkConfig).default([].freeze)
83
+
84
+ def to_h
85
+ return {} if links.empty?
86
+
87
+ hash = { links: links.map(&:to_h) }
88
+ hash[:require_liveness] = true if require_liveness
89
+ hash
90
+ end
91
+
92
+ # Validate VPN configuration — mirrors kindling's structural checks.
93
+ # Raises ArgumentError with all violations if any are found.
94
+ def validate!
95
+ return if links.empty?
96
+
97
+ errors = []
98
+ links.each_with_index do |link, i|
99
+ ctx = "vpn.links[#{i}] (#{link.name})"
100
+
101
+ errors << "#{ctx}: address is not a valid CIDR" if link.address && !valid_cidr?(link.address)
102
+ errors << "#{ctx}: profile '#{link.profile}' is not valid" if link.profile && !VALID_VPN_PROFILES.include?(link.profile)
103
+
104
+ if link.listen_port && link.listen_port != 0 && (link.listen_port < 1024 || link.listen_port > 65_535)
105
+ errors << "#{ctx}: listen_port #{link.listen_port} outside valid range (0 or 1024-65535)"
106
+ end
107
+
108
+ if link.mtu && (link.mtu < 1280 || link.mtu > 9000)
109
+ errors << "#{ctx}: mtu #{link.mtu} outside valid range (1280-9000)"
110
+ end
111
+
112
+ link.peers.each_with_index do |peer, j|
113
+ pctx = "#{ctx}.peers[#{j}]"
114
+ errors << "#{pctx}: public_key does not look like a valid WireGuard key" if peer.public_key && !valid_wg_key?(peer.public_key)
115
+
116
+ peer.allowed_ips.each do |ip|
117
+ errors << "#{pctx}: allowed_ips entry '#{ip}' is not a valid CIDR" unless valid_cidr?(ip)
118
+ end
119
+
120
+ if peer.endpoint && !valid_endpoint?(peer.endpoint)
121
+ errors << "#{pctx}: endpoint '#{peer.endpoint}' is not valid (expected host:port)"
122
+ end
123
+ end
124
+ end
125
+
126
+ return if errors.empty?
127
+
128
+ raise ArgumentError,
129
+ "VPN validation failed (#{errors.length} violation(s)):\n - #{errors.join("\n - ")}"
130
+ end
131
+
132
+ private
133
+
134
+ def valid_cidr?(cidr)
135
+ parts = cidr.split('/', 2)
136
+ return false unless parts.length == 2
137
+
138
+ ip_str, prefix_str = parts
139
+ begin
140
+ prefix = Integer(prefix_str, 10)
141
+ rescue ArgumentError
142
+ return false
143
+ end
144
+
145
+ require 'ipaddr'
146
+ begin
147
+ addr = IPAddr.new(ip_str)
148
+ rescue IPAddr::InvalidAddressError
149
+ return false
150
+ end
151
+
152
+ if addr.ipv4?
153
+ prefix >= 0 && prefix <= 32
154
+ else
155
+ prefix >= 0 && prefix <= 128
156
+ end
157
+ end
158
+
159
+ def valid_wg_key?(key)
160
+ # WireGuard keys are 32 bytes base64-encoded = 44 characters ending with =
161
+ key.match?(/\A[A-Za-z0-9+\/]{43}=\z/)
162
+ end
163
+
164
+ def valid_endpoint?(endpoint)
165
+ # Handle IPv6: [host]:port
166
+ if endpoint.start_with?('[')
167
+ match = endpoint.match(/\A\[.+\]:(\d+)\z/)
168
+ return false unless match
169
+
170
+ port = match[1].to_i
171
+ return port >= 1 && port <= 65_535
172
+ end
173
+
174
+ # IPv4/hostname: host:port
175
+ parts = endpoint.rpartition(':')
176
+ return false if parts[0].empty? || parts[2].empty?
177
+
178
+ begin
179
+ port = Integer(parts[2], 10)
180
+ rescue ArgumentError
181
+ return false
182
+ end
183
+ port >= 1 && port <= 65_535
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pangea/resources/types'
4
+
5
+ module Pangea
6
+ module Kubernetes
7
+ module Types
8
+ # DNS wait configuration for blackmatter-kubernetes NixOS modules.
9
+ # Maps to `waitForDNS.*` options in the NixOS module.
10
+ class WaitForDNSConfig < Pangea::Resources::BaseAttributes
11
+ transform_keys(&:to_sym)
12
+
13
+ # Enable waiting for DNS resolution before bootstrap
14
+ attribute :enabled, T::Bool.default(false)
15
+
16
+ # DNS hostname to resolve before proceeding
17
+ attribute :hostname, T::String.optional.default(nil)
18
+
19
+ # Maximum wait time in seconds
20
+ attribute :timeout_seconds, T::Coercible::Integer.constrained(gteq: 1).default(300)
21
+
22
+ # Retry interval in seconds
23
+ attribute :retry_interval, T::Coercible::Integer.constrained(gteq: 1).default(5)
24
+
25
+ def to_h
26
+ hash = { enabled: enabled }
27
+ hash[:hostname] = hostname if hostname
28
+ hash[:timeout_seconds] = timeout_seconds
29
+ hash[:retry_interval] = retry_interval
30
+ hash
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end