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.
- checksums.yaml +7 -0
- data/.github/workflows/auto-bump.yml +11 -0
- data/.github/workflows/ci.yml +7 -0
- data/.github/workflows/release.yml +22 -0
- data/.gitignore +6 -0
- data/.rspec +3 -0
- data/AGENTS.md +3 -0
- data/CLAUDE.md +370 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +128 -0
- data/README.md +42 -0
- data/Rakefile +8 -0
- data/flake.lock +2144 -0
- data/flake.nix +30 -0
- data/gemset.nix +312 -0
- data/lib/pangea/kubernetes/architecture.rb +383 -0
- data/lib/pangea/kubernetes/backend_registry.rb +117 -0
- data/lib/pangea/kubernetes/backends/aws_eks.rb +203 -0
- data/lib/pangea/kubernetes/backends/aws_nixos.rb +1347 -0
- data/lib/pangea/kubernetes/backends/azure_aks.rb +145 -0
- data/lib/pangea/kubernetes/backends/azure_nixos.rb +275 -0
- data/lib/pangea/kubernetes/backends/base.rb +116 -0
- data/lib/pangea/kubernetes/backends/gcp_gke.rb +176 -0
- data/lib/pangea/kubernetes/backends/gcp_nixos.rb +240 -0
- data/lib/pangea/kubernetes/backends/hcloud_k3s.rb +181 -0
- data/lib/pangea/kubernetes/backends/nixos_base.rb +235 -0
- data/lib/pangea/kubernetes/bare_metal/cloud_init.rb +196 -0
- data/lib/pangea/kubernetes/bare_metal/cluster_reference.rb +72 -0
- data/lib/pangea/kubernetes/load_balancer.rb +157 -0
- data/lib/pangea/kubernetes/network_backend_registry.rb +54 -0
- data/lib/pangea/kubernetes/network_backends/base.rb +78 -0
- data/lib/pangea/kubernetes/network_backends/cilium.rb +105 -0
- data/lib/pangea/kubernetes/network_backends/vpc_cni.rb +36 -0
- data/lib/pangea/kubernetes/types/argocd_config.rb +55 -0
- data/lib/pangea/kubernetes/types/control_plane_config.rb +65 -0
- data/lib/pangea/kubernetes/types/etcd_config.rb +64 -0
- data/lib/pangea/kubernetes/types/firewall_config.rb +39 -0
- data/lib/pangea/kubernetes/types/k3s_config.rb +112 -0
- data/lib/pangea/kubernetes/types/kernel_config.rb +31 -0
- data/lib/pangea/kubernetes/types/kubernetes_config.rb +129 -0
- data/lib/pangea/kubernetes/types/persistent_state_config.rb +100 -0
- data/lib/pangea/kubernetes/types/pki_config.rb +48 -0
- data/lib/pangea/kubernetes/types/secrets_config.rb +41 -0
- data/lib/pangea/kubernetes/types/vpn_config.rb +188 -0
- data/lib/pangea/kubernetes/types/wait_for_dns_config.rb +35 -0
- data/lib/pangea/kubernetes/types.rb +521 -0
- data/lib/pangea-kubernetes/version.rb +5 -0
- data/lib/pangea-kubernetes.rb +43 -0
- data/pangea-kubernetes.gemspec +33 -0
- 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
|