Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0809aac20 | ||
|
|
0653361bc4 | ||
|
|
7b2900e594 | ||
|
|
56bb47e483 | ||
|
|
c1a6a2c6ca | ||
|
|
8173cf5517 | ||
|
|
d50be81ed1 | ||
|
|
357f8d79c2 | ||
|
|
0d8f436466 | ||
|
|
e33b0f584a | ||
|
|
f84df3b188 | ||
|
|
e11ba99991 | ||
|
|
43143847e9 | ||
|
|
1febebc55d | ||
|
|
aade7b5d29 | ||
|
|
02817ef45e | ||
|
|
d376f318c2 | ||
|
|
bd5bc920d8 | ||
|
|
b33c482ad3 | ||
|
|
0f68a1b344 | ||
|
|
bd6e101bfb | ||
|
|
be0f891c73 | ||
|
|
1306d2ad89 | ||
|
|
1f15062b80 | ||
|
|
8e97253d6c | ||
|
|
44149d6363 | ||
|
|
78355b58de | ||
|
|
46129f2d4a | ||
|
|
a4885926e3 | ||
|
|
ca324825a9 |
@@ -128,42 +128,6 @@ steps:
|
||||
- printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
|
||||
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
|
||||
- |
|
||||
# Ensure Talos image exists on all Proxmox nodes via API.
|
||||
#
|
||||
# Only nodes still running Proxmox belong in this list. jupiter and
|
||||
# saturn run Talos on bare metal now, so the storage API returns
|
||||
# nothing for them and the download-wait loop below would burn its full
|
||||
# 60x10s per node before giving up. Bare-metal nodes boot the
|
||||
# metal-amd64 image from their own installed disk instead, and the
|
||||
# whole step goes away once neptune is converted too.
|
||||
TALOS_VERSION="v1.12.6"
|
||||
TALOS_SCHEMATIC="88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
|
||||
TALOS_URL="https://factory.talos.dev/image/$TALOS_SCHEMATIC/$TALOS_VERSION/nocloud-amd64.raw"
|
||||
TALOS_FILENAME="talos-$TALOS_VERSION-$(echo "$TALOS_SCHEMATIC" | cut -c1-12)-nocloud-amd64.raw"
|
||||
API_TOKEN="$TF_VAR_proxmox_user!$TF_VAR_proxmox_token_id=$TF_VAR_proxmox_token_secret"
|
||||
for node in neptune; do
|
||||
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
|
||||
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
|
||||
if [ "$EXISTS" -gt 0 ]; then
|
||||
echo "Talos image already exists on $node"
|
||||
continue
|
||||
fi
|
||||
echo "Downloading Talos image to $node..."
|
||||
curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/download-url" \
|
||||
-H "Authorization: PVEAPIToken=$API_TOKEN" \
|
||||
--data-urlencode "url=$TALOS_URL" \
|
||||
--data-urlencode "content=import" \
|
||||
--data-urlencode "filename=$TALOS_FILENAME" \
|
||||
--max-time 600
|
||||
echo "Waiting for download on $node..."
|
||||
for i in $(seq 1 60); do
|
||||
sleep 10
|
||||
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
|
||||
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
|
||||
if [ "$EXISTS" -gt 0 ]; then echo "Download complete on $node"; break; fi
|
||||
done
|
||||
done
|
||||
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
|
||||
- |
|
||||
if [ -n "$$TOFU_STATE_RM" ]; then
|
||||
|
||||
@@ -253,22 +253,6 @@ endpoints:
|
||||
{% endif %}
|
||||
|
||||
# Infrastructure
|
||||
- name: Proxmox DNS (VIP)
|
||||
group: infrastructure
|
||||
url: 10.2.40.53
|
||||
interval: 30s
|
||||
dns:
|
||||
query-name: k8s-1.srv.renner.uno
|
||||
query-type: A
|
||||
conditions:
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
- "[RESPONSE_TIME] < 500"
|
||||
alerts:
|
||||
- type: email
|
||||
{% if gatus_webhook_url %}
|
||||
- type: custom
|
||||
{% endif %}
|
||||
|
||||
- name: PostgreSQL HA (VIP)
|
||||
group: infrastructure
|
||||
url: tcp://10.2.40.135:5432
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Proxmox DNS Server Configuration
|
||||
|
||||
# Proxmox API connection
|
||||
proxmox:
|
||||
host: "pve.renner.uno"
|
||||
user: "root@pam"
|
||||
|
||||
# Token-based authentication (recommended)
|
||||
token_name: "dns"
|
||||
token_value: "your-token-secret-here"
|
||||
|
||||
# OR password-based authentication
|
||||
# password: "your-password-here"
|
||||
|
||||
verify_ssl: false
|
||||
|
||||
# DNS server settings
|
||||
dns_host: "0.0.0.0" # Listen on all interfaces
|
||||
dns_port: 53 # Standard DNS port (requires root or CAP_NET_BIND_SERVICE)
|
||||
|
||||
# DNS suffix for VM/LXC hostnames
|
||||
dns_suffix: ".renner.uno"
|
||||
|
||||
# TTL for DNS responses (in seconds)
|
||||
dns_ttl: 300
|
||||
|
||||
# Cache settings
|
||||
cache_ttl: 60 # How often to refresh the cache (in seconds)
|
||||
|
||||
# Upstream DNS server for non-Proxmox queries
|
||||
upstream_dns: "10.2.40.1"
|
||||
@@ -1,15 +0,0 @@
|
||||
module proxmox-dns
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/miekg/dns v1.1.58
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/tools v0.17.0 // indirect
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
|
||||
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
|
||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,565 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Proxmox struct {
|
||||
Host string `yaml:"host"`
|
||||
User string `yaml:"user"`
|
||||
TokenName string `yaml:"token_name"`
|
||||
TokenValue string `yaml:"token_value"`
|
||||
VerifySSL bool `yaml:"verify_ssl"`
|
||||
} `yaml:"proxmox"`
|
||||
DNSHost string `yaml:"dns_host"`
|
||||
DNSPort int `yaml:"dns_port"`
|
||||
DNSSuffix string `yaml:"dns_suffix"`
|
||||
DNSTTL int `yaml:"dns_ttl"`
|
||||
CacheTTL int `yaml:"cache_ttl"`
|
||||
UpstreamDNS string `yaml:"upstream_dns"`
|
||||
}
|
||||
|
||||
type DNSRecord struct {
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type ProxmoxDNS struct {
|
||||
config Config
|
||||
cache map[string]*DNSRecord
|
||||
cacheMux sync.RWMutex
|
||||
client *http.Client
|
||||
stopChan chan struct{}
|
||||
nodes []string
|
||||
nodesMux sync.RWMutex
|
||||
}
|
||||
|
||||
func NewProxmoxDNS(configPath string) (*ProxmoxDNS, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if config.DNSPort == 0 {
|
||||
config.DNSPort = 53
|
||||
}
|
||||
if config.CacheTTL == 0 {
|
||||
config.CacheTTL = 60
|
||||
}
|
||||
if config.DNSTTL == 0 {
|
||||
config.DNSTTL = 300
|
||||
}
|
||||
if config.UpstreamDNS == "" {
|
||||
config.UpstreamDNS = "10.2.40.1:53"
|
||||
}
|
||||
if !strings.Contains(config.UpstreamDNS, ":") {
|
||||
config.UpstreamDNS += ":53"
|
||||
}
|
||||
|
||||
return &ProxmoxDNS{
|
||||
config: config,
|
||||
cache: make(map[string]*DNSRecord),
|
||||
nodes: []string{},
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
},
|
||||
stopChan: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isValidIPv6ForDNS checks if an IPv6 address is suitable for DNS records
|
||||
func isValidIPv6ForDNS(ip net.IP) bool {
|
||||
if ip == nil || ip.To16() == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject loopback
|
||||
if ip.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject link-local (fe80::/10) - THIS IS THE KEY FIX
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject multicast
|
||||
if ip.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject unspecified
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Accept global unicast and ULA (fc00::/7)
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) queryProxmoxAPI(endpoint string) ([]byte, error) {
|
||||
// Remove port from host if present
|
||||
host := p.config.Proxmox.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += ":8006"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/api2/json/%s", host, endpoint)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("PVEAPIToken=%s!%s=%s",
|
||||
p.config.Proxmox.User,
|
||||
p.config.Proxmox.TokenName,
|
||||
p.config.Proxmox.TokenValue)
|
||||
req.Header.Set("Authorization", auth)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) discoverNodes() error {
|
||||
data, err := p.queryProxmoxAPI("nodes")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to discover nodes: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return fmt.Errorf("failed to parse nodes: %w", err)
|
||||
}
|
||||
|
||||
p.nodesMux.Lock()
|
||||
defer p.nodesMux.Unlock()
|
||||
|
||||
p.nodes = []string{}
|
||||
for _, node := range response.Data {
|
||||
if node.Status == "online" {
|
||||
p.nodes = append(p.nodes, node.Node)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Discovered %d online nodes: %v", len(p.nodes), p.nodes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) refreshCache() {
|
||||
log.Println("Refreshing DNS cache...")
|
||||
|
||||
// Discover nodes first
|
||||
if err := p.discoverNodes(); err != nil {
|
||||
log.Printf("Failed to discover nodes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newCache := make(map[string]*DNSRecord)
|
||||
|
||||
p.nodesMux.RLock()
|
||||
nodes := append([]string{}, p.nodes...)
|
||||
p.nodesMux.RUnlock()
|
||||
|
||||
for _, node := range nodes {
|
||||
// Query VMs
|
||||
p.refreshVMs(node, newCache)
|
||||
// Query LXC containers
|
||||
p.refreshLXC(node, newCache)
|
||||
}
|
||||
|
||||
p.cacheMux.Lock()
|
||||
p.cache = newCache
|
||||
p.cacheMux.Unlock()
|
||||
|
||||
log.Printf("Cache refreshed: %d entries", len(newCache))
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) refreshVMs(node string, cache map[string]*DNSRecord) {
|
||||
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu", node))
|
||||
if err != nil {
|
||||
log.Printf("Failed to query VMs on %s: %v", node, err)
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
log.Printf("Failed to parse VM data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, vm := range response.Data {
|
||||
if vm.Status != "running" || vm.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Query agent network info
|
||||
agentData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu/%d/agent/network-get-interfaces", node, vm.VMID))
|
||||
if err != nil {
|
||||
log.Printf("VM %s (ID %d): no guest agent data", vm.Name, vm.VMID)
|
||||
continue
|
||||
}
|
||||
|
||||
var agentResp struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Name string `json:"name"`
|
||||
IPAddresses []struct {
|
||||
IPAddress string `json:"ip-address"`
|
||||
IPAddressType string `json:"ip-address-type"`
|
||||
} `json:"ip-addresses"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(agentData, &agentResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4, ipv6 string
|
||||
for _, iface := range agentResp.Data.Result {
|
||||
if iface.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ipAddr := range iface.IPAddresses {
|
||||
if ipAddr.IPAddressType == "ipv4" && ipv4 == "" {
|
||||
if !strings.HasPrefix(ipAddr.IPAddress, "127.") {
|
||||
ipv4 = ipAddr.IPAddress
|
||||
}
|
||||
} else if ipAddr.IPAddressType == "ipv6" && ipv6 == "" {
|
||||
ip := net.ParseIP(ipAddr.IPAddress)
|
||||
if isValidIPv6ForDNS(ip) {
|
||||
ipv6 = ipAddr.IPAddress
|
||||
log.Printf(" VM %s: accepting IPv6 %s", vm.Name, ipv6)
|
||||
} else if ip != nil {
|
||||
log.Printf(" VM %s: rejecting IPv6 %s (link-local/loopback/multicast)", vm.Name, ipAddr.IPAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop after finding both on first non-lo interface
|
||||
if ipv4 != "" && ipv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ipv4 != "" || ipv6 != "" {
|
||||
hostname := strings.ToLower(vm.Name)
|
||||
// Strip DNS suffix from hostname if present
|
||||
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
|
||||
cache[hostname] = &DNSRecord{
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
log.Printf("VM: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) refreshLXC(node string, cache map[string]*DNSRecord) {
|
||||
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc", node))
|
||||
if err != nil {
|
||||
log.Printf("Failed to query LXC on %s: %v", node, err)
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
log.Printf("Failed to parse LXC data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, lxc := range response.Data {
|
||||
if lxc.Status != "running" || lxc.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Query LXC interfaces
|
||||
ifaceData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc/%d/interfaces", node, lxc.VMID))
|
||||
if err != nil {
|
||||
log.Printf("LXC %s (ID %d): failed to get interfaces", lxc.Name, lxc.VMID)
|
||||
continue
|
||||
}
|
||||
|
||||
var ifaceResp struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
Inet string `json:"inet"`
|
||||
Inet6 string `json:"inet6"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(ifaceData, &ifaceResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4, ipv6 string
|
||||
for _, iface := range ifaceResp.Data {
|
||||
if iface.Name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
if iface.Inet != "" && ipv4 == "" {
|
||||
// Format is "IP/CIDR"
|
||||
parts := strings.Split(iface.Inet, "/")
|
||||
if len(parts) > 0 && !strings.HasPrefix(parts[0], "127.") {
|
||||
ipv4 = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
if iface.Inet6 != "" && ipv6 == "" {
|
||||
parts := strings.Split(iface.Inet6, "/")
|
||||
if len(parts) > 0 {
|
||||
ip := net.ParseIP(parts[0])
|
||||
if isValidIPv6ForDNS(ip) {
|
||||
ipv6 = parts[0]
|
||||
log.Printf(" LXC %s: accepting IPv6 %s", lxc.Name, ipv6)
|
||||
} else if ip != nil {
|
||||
log.Printf(" LXC %s: rejecting IPv6 %s (link-local/loopback/multicast)", lxc.Name, parts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop after finding both on first non-lo interface
|
||||
if ipv4 != "" && ipv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ipv4 != "" || ipv6 != "" {
|
||||
hostname := strings.ToLower(lxc.Name)
|
||||
// Strip DNS suffix from hostname if present
|
||||
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
|
||||
cache[hostname] = &DNSRecord{
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
log.Printf("LXC: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Authoritative = true
|
||||
|
||||
for _, q := range r.Question {
|
||||
name := strings.ToLower(strings.TrimSuffix(q.Name, "."))
|
||||
qtype := dns.TypeToString[q.Qtype]
|
||||
|
||||
// Check if query is for our domain suffix
|
||||
if !strings.HasSuffix(name, strings.TrimPrefix(p.config.DNSSuffix, ".")) {
|
||||
// Forward to upstream
|
||||
log.Printf("Query %s (%s) - forwarding to upstream", name, qtype)
|
||||
p.forwardToUpstream(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract hostname (remove suffix)
|
||||
hostname := strings.TrimSuffix(name, p.config.DNSSuffix)
|
||||
|
||||
p.cacheMux.RLock()
|
||||
record, found := p.cache[hostname]
|
||||
p.cacheMux.RUnlock()
|
||||
|
||||
if !found {
|
||||
// NXDOMAIN
|
||||
m.SetRcode(r, dns.RcodeNameError)
|
||||
log.Printf("Query %s (%s) - NXDOMAIN", name, qtype)
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA:
|
||||
if record.IPv4 != "" {
|
||||
rr := &dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: q.Name,
|
||||
Rrtype: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: uint32(p.config.DNSTTL),
|
||||
},
|
||||
A: net.ParseIP(record.IPv4),
|
||||
}
|
||||
m.Answer = append(m.Answer, rr)
|
||||
log.Printf("Query %s (A) -> %s", name, record.IPv4)
|
||||
} else {
|
||||
// NODATA (host exists but no A record)
|
||||
m.Ns = append(m.Ns, p.getSOARecord())
|
||||
log.Printf("Query %s (A) - NODATA", name)
|
||||
}
|
||||
|
||||
case dns.TypeAAAA:
|
||||
if record.IPv6 != "" {
|
||||
// Parse and validate IPv6 again before returning
|
||||
ip := net.ParseIP(record.IPv6)
|
||||
if ip != nil && isValidIPv6ForDNS(ip) {
|
||||
rr := &dns.AAAA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: q.Name,
|
||||
Rrtype: dns.TypeAAAA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: uint32(p.config.DNSTTL),
|
||||
},
|
||||
AAAA: ip,
|
||||
}
|
||||
m.Answer = append(m.Answer, rr)
|
||||
log.Printf("Query %s (AAAA) -> %s", name, record.IPv6)
|
||||
} else {
|
||||
// IPv6 is invalid (link-local etc)
|
||||
m.Ns = append(m.Ns, p.getSOARecord())
|
||||
log.Printf("Query %s (AAAA) - NODATA (invalid IPv6 in cache)", name)
|
||||
}
|
||||
} else {
|
||||
// NODATA (host exists but no AAAA record)
|
||||
m.Ns = append(m.Ns, p.getSOARecord())
|
||||
log.Printf("Query %s (AAAA) - NODATA", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) getSOARecord() dns.RR {
|
||||
domain := strings.TrimPrefix(p.config.DNSSuffix, ".")
|
||||
soa := &dns.SOA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: domain + ".",
|
||||
Rrtype: dns.TypeSOA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: 300,
|
||||
},
|
||||
Ns: "ns." + domain + ".",
|
||||
Mbox: "admin." + domain + ".",
|
||||
Serial: uint32(time.Now().Unix()),
|
||||
Refresh: 3600,
|
||||
Retry: 600,
|
||||
Expire: 86400,
|
||||
Minttl: 300,
|
||||
}
|
||||
return soa
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) forwardToUpstream(w dns.ResponseWriter, r *dns.Msg) {
|
||||
c := new(dns.Client)
|
||||
c.Timeout = 2 * time.Second
|
||||
|
||||
resp, _, err := c.Exchange(r, p.config.UpstreamDNS)
|
||||
if err != nil {
|
||||
log.Printf("Failed to forward to upstream: %v", err)
|
||||
m := new(dns.Msg)
|
||||
m.SetRcode(r, dns.RcodeServerFailure)
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteMsg(resp)
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) startCacheRefresher() {
|
||||
ticker := time.NewTicker(time.Duration(p.config.CacheTTL) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial refresh
|
||||
p.refreshCache()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
p.refreshCache()
|
||||
case <-p.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProxmoxDNS) Start() error {
|
||||
// Start cache refresher
|
||||
go p.startCacheRefresher()
|
||||
|
||||
// Start DNS server
|
||||
dns.HandleFunc(".", p.handleDNSRequest)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", p.config.DNSHost, p.config.DNSPort)
|
||||
log.Printf("Starting Proxmox DNS server on %s", addr)
|
||||
log.Printf("Base domain: %s", p.config.DNSSuffix)
|
||||
log.Printf("Upstream DNS: %s", p.config.UpstreamDNS)
|
||||
|
||||
server := &dns.Server{Addr: addr, Net: "udp"}
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("c", "/etc/proxmox-dns/config.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
server, err := NewProxmoxDNS(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create DNS server: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Proxmox DNS Server starting...")
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("Failed to start DNS server: %v", err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
[Unit]
|
||||
Description=Proxmox DNS Server
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/proxmox-dns
|
||||
ExecStart=/usr/bin/python3 /opt/proxmox-dns/proxmox_dns.py -c /etc/proxmox-dns/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/log
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -172,12 +172,22 @@ use the secureboot installer image too. That is a separate project.
|
||||
## 5. Install
|
||||
|
||||
Boot the stick. Maintenance mode DHCPs untagged on every link; on these hosts
|
||||
that lands on `192.168.8.x`, which is routable from the workstation.
|
||||
that lands on `192.168.8.x`, which is routable from the workstation. It DHCPs on
|
||||
*every* link, so one machine answers on several addresses at once — they are the
|
||||
same host, not several hosts. `get disks` on each confirms it.
|
||||
|
||||
Maintenance mode is effectively write-only — `talosctl get` has no `--insecure`
|
||||
and `version` returns "API is not implemented in maintenance mode". You cannot
|
||||
inspect disks or links before applying. Verify serials and MACs from the host's
|
||||
PVE beforehand, in step 3.
|
||||
Maintenance mode does serve `talosctl get`. `--insecure` is a flag on the
|
||||
subcommand rather than a global, so it must come *after* `get` — placed before
|
||||
it, cobra resolves to the legacy `talosctl disks` command and reports
|
||||
`unknown flag: --insecure`:
|
||||
|
||||
```sh
|
||||
talosctl -n <maintenance-ip> get disks --insecure
|
||||
talosctl -n <maintenance-ip> get links --insecure
|
||||
```
|
||||
|
||||
Verify serials and MACs here, on the machine itself, against the config about to
|
||||
be applied.
|
||||
|
||||
Build the config from the **live cluster's secrets**, not a fresh
|
||||
`talosctl gen config`, or the node forms a new cluster instead of joining:
|
||||
@@ -202,13 +212,58 @@ Strip the generated `HostnameConfig` document — it collides with
|
||||
against a base with no `inlineManifests` (issue #581). Use only the additive
|
||||
second document.
|
||||
|
||||
Validate, then apply:
|
||||
Validate:
|
||||
|
||||
```sh
|
||||
talosctl validate --config install.yaml --mode metal
|
||||
talosctl apply-config --insecure -n <maintenance-ip> --file install.yaml
|
||||
```
|
||||
|
||||
### Install minimal, complete on the running node
|
||||
|
||||
Do **not** apply the full per-node config in maintenance mode. A document that
|
||||
references an interface which does not exist yet hangs the install: the node
|
||||
sits at `STAGE: Installing` indefinitely with `KUBELET n/a`, `APISERVER n/a`,
|
||||
and the console scrolling
|
||||
|
||||
```
|
||||
k8s.NodeApplyController: error getting node: nodes "k8s-N" not found
|
||||
```
|
||||
|
||||
That message is a symptom of an install that never finished, not a Kubernetes or
|
||||
networking fault — the node holds a DHCP address and is reachable throughout. On
|
||||
saturn the offenders were the `ens19` VLAN document and the sysctl naming it,
|
||||
`net.ipv6.conf.ens19.disable_ipv6`. Three cold installs hung on it; the identical
|
||||
config applied cleanly once the node was up. Wiping disks makes no difference —
|
||||
it is not leftover state.
|
||||
|
||||
**Stage 1** — the smallest config that installs and joins, plus `EPHEMERAL`,
|
||||
which is one-shot (section 7) and so cannot be deferred to stage 2:
|
||||
|
||||
```
|
||||
machine.network.hostname, machine.install.diskSelector, certSANs, nodeLabels
|
||||
LinkConfig <uplink>
|
||||
VLANConfig mgmt0
|
||||
DHCPv4Config mgmt0
|
||||
VolumeConfig EPHEMERAL
|
||||
```
|
||||
|
||||
```sh
|
||||
talosctl apply-config --insecure -n <maintenance-ip> --file install-stage1.yaml
|
||||
```
|
||||
|
||||
**Stage 2** — once the node is `Ready` and `talosctl etcd members` lists it,
|
||||
apply the full config to the running node. `--mode=try` reverts on its own if the
|
||||
network config is wrong, so it cannot strand the node; re-apply with
|
||||
`--mode=auto` to persist.
|
||||
|
||||
```sh
|
||||
talosctl -e <node-ip> -n <node-ip> apply-config --mode=try --timeout=3m --file install.yaml
|
||||
talosctl -e <node-ip> -n <node-ip> apply-config --mode=auto --file install.yaml
|
||||
```
|
||||
|
||||
Both applied without a reboot on saturn. Confirm `ens19`, `mgmt0` and the Ceph
|
||||
link are up afterwards with `talosctl get links` and `get addresses`.
|
||||
|
||||
## 6. The network config will strand the node if it is wrong
|
||||
|
||||
Talos stops running default DHCP operators **for the entire machine** as soon as
|
||||
@@ -334,7 +389,58 @@ The retired Crucial leaves a stale entry in `ceph device ls`. Remove it with
|
||||
cold rebuild is broken independently of this work.
|
||||
- Manual Woodpecker pipelines are broken (issue #582), so anything gated on
|
||||
`PIPELINE == "..."` must be run another way.
|
||||
- Cilium's eBPF datapath silently drops VLAN-tagged traffic unless
|
||||
`bpf.vlanBypass` is set. Both `mgmt0` and `ens19` are VLAN interfaces on a
|
||||
converted node. Not observed on jupiter, but it is the first place to look if
|
||||
the v30iot macvlan workloads misbehave there.
|
||||
- Cilium's eBPF datapath drops VLAN-tagged traffic unless `bpf.vlanBypass` is
|
||||
set. **This happened** — every v30iot macvlan workload on a converted node
|
||||
stayed in sandbox creation until `vlanBypass: [30]` was added (issue #592).
|
||||
|
||||
Only `ens19` is affected, and the reason is worth knowing: Cilium attaches to
|
||||
devices that carry an address. `cilium-dbg status --verbose` lists `mgmt0`
|
||||
among its devices but not `ens19`, because `ens19` is deliberately
|
||||
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
|
||||
untagged by the 8021q layer, while VLAN 30 frames only ever meet the filter
|
||||
on the shared parent `enp2s0f0np0`, still tagged. Any future address-less
|
||||
VLAN carrying pod traffic will need the same treatment.
|
||||
|
||||
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
|
||||
AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports `no buffer space
|
||||
available` and `network is down` rather than a DHCP timeout, and the pod sits
|
||||
in `ContainerCreating` behind `plugin type="macvlan" failed (add): error
|
||||
calling DHCP.Allocate: no more tries`. Confirm it with
|
||||
`cilium_drop_count_total{reason=~"VLAN.*"}` per node rather than by reading
|
||||
the CNI logs, and check `node_network_receive_packets_total{device="ens19"}`
|
||||
— it reads 0 on an affected node.
|
||||
|
||||
- The conversion drops the node's global IPv6 on `mgmt0`, and nothing in the
|
||||
config says so. **This happened** (issue #603). Pods get only a ULA
|
||||
(`fd10:244::/48`) and reach global IPv6 by being masqueraded to the node's
|
||||
global address, so a node without one silently loses outbound IPv6 for every
|
||||
pod scheduled onto it. The only in-cluster symptom is one line at Cilium
|
||||
agent startup — `k8s-1` and `k8s-2` log it, `k8s-3` does not:
|
||||
|
||||
```
|
||||
level=warn msg="IPv6 is enabled, but Cilium cannot find the IPv6 address
|
||||
for this node" module=agent.controlplane.local-node-sync
|
||||
```
|
||||
|
||||
It surfaced as `NetbirdRelayMemberDown` plus both v6 `blackbox-edge-wan`
|
||||
probes going red the moment the blackbox exporter was rescheduled onto a
|
||||
converted node. The relays themselves were healthy throughout.
|
||||
|
||||
Settle where the address comes from before converting the last VM. It
|
||||
decides the fix and is not visible through kubectl — nodes publish only a
|
||||
v4 `InternalIP`:
|
||||
|
||||
```sh
|
||||
talosctl -n <unconverted> get addresses # while a VM is still up
|
||||
talosctl -n <converted> get addresses
|
||||
```
|
||||
|
||||
DHCPv6 means the converted nodes need a `DHCPv6Config` document for `mgmt0`
|
||||
beside `DHCPv4Config`; SLAAC means they need RA acceptance instead and
|
||||
`DHCPv6Config` alone would not help. Legacy `dhcp: true` requests DHCPv4
|
||||
only, so SLAAC is the likelier source. Validate either with
|
||||
`talosctl apply-config --mode=try`, per section 6.
|
||||
|
||||
Converting the last VM before this is settled leaves no IPv6 path at all:
|
||||
`proxy-1` is the v6-only edge/relay member and goes from unmonitorable to
|
||||
genuinely unreachable.
|
||||
|
||||
@@ -19,7 +19,7 @@ metadata:
|
||||
# Opaque-label map (maintainer reference only — NOT served publicly):
|
||||
# services group infrastructure group
|
||||
# Web Service 1 = Authentik Infrastructure 1 = PBS 2 (pbs2.renner.uno)
|
||||
# Web Service 2 = Gitea Infrastructure 2 = Proxmox VE (pve.renner.uno)
|
||||
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
|
||||
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
|
||||
# Web Service 5 = Grafana
|
||||
@@ -206,21 +206,6 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- name: Infrastructure 2
|
||||
group: infrastructure
|
||||
url: https://pve.renner.uno
|
||||
interval: 60s
|
||||
client:
|
||||
insecure-skip-verify: true
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- name: Infrastructure 3
|
||||
group: infrastructure
|
||||
url: 10.2.40.1
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# backend serving HTTPS with a self-signed / hostname-mismatched cert:
|
||||
#
|
||||
# apps/file — NAS web UI on a self-signed cert
|
||||
# apps/pve — PVE on :8006 with per-node self-signed cert
|
||||
#
|
||||
# Replaces the nginx `backend-protocol: HTTPS` + `proxy-ssl-verify: off`
|
||||
# annotation pair. Each Ingress that wants this behaviour sets
|
||||
|
||||
@@ -7,7 +7,6 @@ resources:
|
||||
- file/
|
||||
- gitea/
|
||||
- ical-proxy/
|
||||
- pve/
|
||||
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
|
||||
# (clusters/homelab/woodpecker.yaml) so its wp-pg CNPG cluster's barman
|
||||
# dependency + Cluster health-gating don't contaminate this shared apps KS.
|
||||
|
||||
@@ -83,9 +83,6 @@ configMapGenerator:
|
||||
- name: dashboard-power-flow
|
||||
files:
|
||||
- power-flow.json
|
||||
- name: dashboard-proxmox-cluster
|
||||
files:
|
||||
- proxmox-cluster.json
|
||||
- name: dashboard-pv-system-roi
|
||||
files:
|
||||
- pv-system-roi.json
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
{
|
||||
"annotations": {"list": []},
|
||||
"description": "Proxmox VE Cluster Overview",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 3}]}},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_up{id=~\"node/.*\"} == 1))", "refId": "A"}],
|
||||
"title": "Online Nodes",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]}},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
|
||||
"id": 2,
|
||||
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_guest_info{type=\"qemu\", template=\"0\"}))", "refId": "A"}],
|
||||
"title": "Total VMs",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "purple", "value": null}]}},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
|
||||
"id": 3,
|
||||
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_guest_info{type=\"lxc\"}))", "refId": "A"}],
|
||||
"title": "Total LXC Containers",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 5, "x": 15, "y": 0},
|
||||
"id": 4,
|
||||
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_up{id=~\"(qemu|lxc)/.*\"} == 1))", "refId": "A"}],
|
||||
"title": "Running Guests",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [{"options": {"0": {"color": "green", "index": 0, "text": "All Backed Up"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 1}, {"color": "red", "value": 5}]}},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 4, "x": 20, "y": 0},
|
||||
"id": 12,
|
||||
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max(pve_not_backed_up_total)", "refId": "A"}],
|
||||
"title": "Not Backed Up",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 60}, {"color": "red", "value": 80}]},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 6, "w": 8, "x": 0, "y": 4},
|
||||
"id": 5,
|
||||
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
|
||||
"title": "Node CPU Usage",
|
||||
"type": "bargauge"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 70}, {"color": "red", "value": 85}]},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 6, "w": 8, "x": 8, "y": 4},
|
||||
"id": 6,
|
||||
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
|
||||
"title": "Node Memory Usage",
|
||||
"type": "bargauge"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 70}, {"color": "red", "value": 85}]},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 6, "w": 8, "x": 16, "y": 4},
|
||||
"id": 7,
|
||||
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_disk_usage_bytes{id=~\"storage/.*\"}) / max by (id) (pve_disk_size_bytes{id=~\"storage/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
|
||||
"title": "Storage Usage",
|
||||
"type": "bargauge"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 10},
|
||||
"id": 8,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
|
||||
"title": "Node CPU Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 10},
|
||||
"id": 9,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"})", "legendFormat": "{{id}}", "refId": "A"}],
|
||||
"title": "Node Memory Usage Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "thresholds"},
|
||||
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}
|
||||
},
|
||||
"overrides": [
|
||||
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}, {"id": "mappings", "value": [{"options": {"0": {"color": "red", "index": 1, "text": "Stopped"}, "1": {"color": "green", "index": 0, "text": "Running"}}, "type": "value"}]}]},
|
||||
{"matcher": {"id": "byName", "options": "CPU %"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"mode": "gradient", "type": "gauge"}}, {"id": "min", "value": 0}, {"id": "max", "value": 100}]},
|
||||
{"matcher": {"id": "byName", "options": "Memory %"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"mode": "gradient", "type": "gauge"}}, {"id": "min", "value": 0}, {"id": "max", "value": 100}]}
|
||||
]
|
||||
},
|
||||
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 18},
|
||||
"id": 10,
|
||||
"options": {"footer": {"fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": false, "displayName": "Name"}]},
|
||||
"targets": [
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id, name, node, type) (pve_guest_info)", "format": "table", "instant": true, "refId": "A"},
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"(qemu|lxc)/.*\"}) * 100", "format": "table", "instant": true, "refId": "B"},
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"(qemu|lxc)/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"(qemu|lxc)/.*\"}) * 100", "format": "table", "instant": true, "refId": "C"},
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_up{id=~\"(qemu|lxc)/.*\"})", "format": "table", "instant": true, "refId": "D"}
|
||||
],
|
||||
"title": "Guest Status",
|
||||
"transformations": [
|
||||
{"id": "seriesToColumns", "options": {"byField": "id"}},
|
||||
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "Time 4": true, "Value #A": true}, "indexByName": {"id": 0, "name": 1, "node": 2, "type": 3, "Value #D": 4, "Value #B": 5, "Value #C": 6}, "renameByName": {"Value #B": "CPU %", "Value #C": "Memory %", "Value #D": "Status", "id": "ID", "name": "Name", "node": "Node", "type": "Type"}}}
|
||||
],
|
||||
"type": "table"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {"mode": "palette-classic"},
|
||||
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
|
||||
"mappings": [],
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||
"unit": "Bps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 28},
|
||||
"id": 11,
|
||||
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (rate(pve_disk_read_bytes_total{id=~\"(qemu|lxc)/.*\"}[5m]))", "legendFormat": "{{id}} read", "refId": "A"},
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (rate(pve_disk_written_bytes_total{id=~\"(qemu|lxc)/.*\"}[5m]))", "legendFormat": "{{id}} write", "refId": "B"}
|
||||
],
|
||||
"title": "Guest Disk I/O",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["homelab", "proxmox", "virtualization"],
|
||||
"templating": {"list": []},
|
||||
"time": {"from": "now-1h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Proxmox VE Cluster",
|
||||
"uid": "proxmox-cluster",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -93,68 +93,8 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
# Scrape existing Proxmox infrastructure targets
|
||||
# Additional scrape targets not covered by ServiceMonitors.
|
||||
additionalScrapeConfigs:
|
||||
# pve-exporter is a blackbox-style exporter: Prometheus passes
|
||||
# ?target=<host> via relabel, and one in-cluster exporter
|
||||
# instance queries each PVE node's API on the user's behalf.
|
||||
# The targets list is the PVE node addresses; the actual
|
||||
# scrape goes to pve-exporter.monitoring.svc:9221.
|
||||
#
|
||||
# Cluster collectors (pve_up, pve_guest_info, …) return the SAME
|
||||
# cluster-wide resource set no matter which node answers, so they
|
||||
# are scraped from a single node only. Scraping them from all three
|
||||
# (the pre-2026-07 config) ingested every node/guest series 3x —
|
||||
# identical `id`, differing `instance` — a real TSDB cardinality
|
||||
# cost that dashboards had to paper over with max by (id).
|
||||
#
|
||||
# Target the pve Service (apps ns) rather than a hardcoded node IP:
|
||||
# its EndpointSlice load-balances the PVE API across all three nodes,
|
||||
# so cluster metrics keep flowing as long as ANY node is up, instead
|
||||
# of pausing whenever .201 (jupiter) specifically is down. The
|
||||
# exporter reaches the API on 8006 (verify_ssl:false, so the LB'd
|
||||
# cert hostname mismatch is a non-issue).
|
||||
- job_name: pve
|
||||
static_configs:
|
||||
- targets:
|
||||
- "pve.apps.svc.cluster.local"
|
||||
metrics_path: /pve
|
||||
params:
|
||||
cluster: ["1"]
|
||||
node: ["0"]
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: __param_target
|
||||
- source_labels: [__param_target]
|
||||
target_label: instance
|
||||
- target_label: __address__
|
||||
replacement: pve-exporter.monitoring.svc.cluster.local:9221
|
||||
|
||||
# Node collectors (pve_version_info, replication status) are
|
||||
# node-local and genuinely distinct per target, so every node is
|
||||
# scraped — this is the split recommended by prometheus-pve-exporter
|
||||
# upstream to avoid the cluster-collector duplication above.
|
||||
# Targets are the node FQDNs, not raw IPs: the instance label then
|
||||
# reads jupiter/neptune/saturn.renner.uno, and a renumbered node is
|
||||
# picked up from DNS without editing this file.
|
||||
- job_name: pve-node
|
||||
static_configs:
|
||||
- targets:
|
||||
- "jupiter.renner.uno"
|
||||
- "neptune.renner.uno"
|
||||
- "saturn.renner.uno"
|
||||
metrics_path: /pve
|
||||
params:
|
||||
cluster: ["0"]
|
||||
node: ["1"]
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: __param_target
|
||||
- source_labels: [__param_target]
|
||||
target_label: instance
|
||||
- target_label: __address__
|
||||
replacement: pve-exporter.monitoring.svc.cluster.local:9221
|
||||
|
||||
# Gitea exposes /metrics anonymously on its main HTTP port. The
|
||||
# `gitea_*` metrics power the gitea-overview dashboard. Gitea now runs
|
||||
# in-cluster (StatefulSet in apps ns; LXC 114 retired), so scrape the
|
||||
|
||||
@@ -3,8 +3,6 @@ kind: Kustomization
|
||||
resources:
|
||||
- grafana-oauth-externalsecret.yaml
|
||||
- alertmanager-smtp-externalsecret.yaml
|
||||
- pve-exporter-config-externalsecret.yaml
|
||||
- pve-exporter-deployment.yaml
|
||||
- helmrelease-prometheus.yaml
|
||||
- helmrelease-loki.yaml
|
||||
- helmrelease-alloy-syslog.yaml
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: pve-exporter-config
|
||||
namespace: monitoring
|
||||
spec:
|
||||
# 10m (was 1h) so an automated token rotation propagates within ~10m; Reloader
|
||||
# then restarts pve-exporter. See kubernetes/infrastructure/openbao/rotation/pve-exporter.yaml.
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: pve-exporter-config # keeps the old Secret's name — consumers unchanged
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/monitoring/pve-exporter-config
|
||||
@@ -1,84 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: pve-exporter
|
||||
namespace: monitoring
|
||||
annotations:
|
||||
# Reloader restarts pve-exporter when pve-exporter-config changes (token
|
||||
# rotation) so it reloads pve.yml. See
|
||||
# kubernetes/infrastructure/openbao/rotation/pve-exporter.yaml.
|
||||
secret.reloader.stakater.com/reload: pve-exporter-config
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: pve-exporter
|
||||
image: prompve/prometheus-pve-exporter:3.10.0 # {"$imagepolicy": "flux-system:pve-exporter"}
|
||||
# Image ENTRYPOINT is already `pve_exporter`; pass flags only.
|
||||
args:
|
||||
- --config.file=/etc/pve-exporter/pve.yml
|
||||
- --web.listen-address=0.0.0.0:9221
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 9221
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/pve-exporter
|
||||
readOnly: true
|
||||
# Python needs a writable temp dir; the rootfs is read-only.
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: config
|
||||
secret:
|
||||
secretName: pve-exporter-config
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 16Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: pve-exporter
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
ports:
|
||||
- name: http
|
||||
port: 9221
|
||||
targetPort: http
|
||||
@@ -25,8 +25,11 @@ spec:
|
||||
# replaces the former hostNetwork attachment (see
|
||||
# network-attachment.yaml) so the NODE no longer holds an address
|
||||
# on the compromised IoT VLAN; MA gets its own leased IoT IP on
|
||||
# net1 instead. master: ens19 is the v30iot NIC on every k8s-{1,2,3}/edge
|
||||
# worker.
|
||||
# net1 instead. master: ens19 resolves on every k8s-{1,2,3}/edge
|
||||
# worker, though it is no longer the same kind of device everywhere:
|
||||
# on the bare-metal control-plane nodes it is a VLAN 30 sub-interface
|
||||
# of enp2s0f0np0 rather than a NIC, which is why the datapath needs
|
||||
# bpf.vlanBypass to carry it (issue #592).
|
||||
k8s.v1.cni.cncf.io/networks: music-assistant-iot
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# PVE node IPs are hardcoded here despite the general "IPs aren't static"
|
||||
# rule for the homelab: corosync requires stable cluster member identity,
|
||||
# so jupiter/neptune/saturn keep .201/.202/.203 across reboots and PVE
|
||||
# upgrades. If a node is permanently renumbered, edit this file.
|
||||
apiVersion: discovery.k8s.io/v1
|
||||
kind: EndpointSlice
|
||||
metadata:
|
||||
name: pve-nodes
|
||||
namespace: apps
|
||||
labels:
|
||||
kubernetes.io/service-name: pve
|
||||
app.kubernetes.io/name: pve
|
||||
addressType: IPv4
|
||||
ports:
|
||||
- name: https
|
||||
port: 8006
|
||||
protocol: TCP
|
||||
endpoints:
|
||||
- addresses:
|
||||
- 10.2.40.201
|
||||
hostname: jupiter
|
||||
conditions:
|
||||
ready: true
|
||||
- addresses:
|
||||
- 10.2.40.202
|
||||
hostname: neptune
|
||||
conditions:
|
||||
ready: true
|
||||
- addresses:
|
||||
- 10.2.40.203
|
||||
hostname: saturn
|
||||
conditions:
|
||||
ready: true
|
||||
@@ -1,48 +0,0 @@
|
||||
# PVE (Proxmox cluster UI) on Traefik. Replaced the standard Ingress
|
||||
# pattern because PVE's backend is per-node HTTPS on :8006 with a
|
||||
# self-signed cert that has no IP SAN — Traefik's kubernetesIngress
|
||||
# provider's service.serverstransport annotation doesn't resolve the
|
||||
# apps/insecure-backend ServersTransport CRD, so TLS verification ran
|
||||
# and rejected. IngressRoute's native services[].serversTransport field
|
||||
# binds directly with no provider-qualifier guessing.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: pve-tls
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: pve
|
||||
spec:
|
||||
secretName: pve-tls
|
||||
issuerRef:
|
||||
name: letsencrypt-cluster
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- pve.renner.uno
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: pve
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: pve
|
||||
annotations:
|
||||
# Traefik does not populate IngressRoute.status.loadBalancer, so
|
||||
# external-dns-unifi (traefik-proxy source) has no implicit target.
|
||||
# Pin to the traefik Service VIP (kubernetes/infrastructure/traefik
|
||||
# service.annotations io.cilium/lb-ipam-ips).
|
||||
external-dns.alpha.kubernetes.io/target: "10.2.99.86,fd99::86"
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`pve.renner.uno`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: pve
|
||||
port: 8006
|
||||
scheme: https
|
||||
serversTransport: insecure-backend
|
||||
tls:
|
||||
secretName: pve-tls
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- service.yaml
|
||||
- endpointslice.yaml
|
||||
- ingressroute.yaml
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: pve
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: pve
|
||||
spec:
|
||||
# No selector: endpoints managed manually via the sibling EndpointSlice
|
||||
# so ingress-nginx fans out across the three Proxmox cluster members.
|
||||
ports:
|
||||
- name: https
|
||||
port: 8006
|
||||
targetPort: 8006
|
||||
protocol: TCP
|
||||
@@ -33,6 +33,24 @@ spec:
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# Roll the agent DaemonSet when cilium-config changes. Cilium reads
|
||||
# datapath options once, at agent start, and the chart puts no config
|
||||
# checksum on the pod template — so a changed ConfigMap leaves every agent
|
||||
# running the previous datapath until something restarts it. Nothing did.
|
||||
#
|
||||
# This is not hypothetical. vlan-bpf-bypass reached cilium-config and every
|
||||
# v30iot macvlan workload stayed broken until the agents were deleted by
|
||||
# hand, one node at a time (issue #592). policyMapMax has the same
|
||||
# exposure, and its comment below says "takes effect on agent restart" —
|
||||
# documentation standing in for automation.
|
||||
#
|
||||
# The trade: any cilium-config change now rolls the CNI cluster-wide,
|
||||
# node by node. That is disruptive-ish, but it is what a datapath change
|
||||
# already required — the difference is that it now happens on its own
|
||||
# instead of being forgotten. Reloader is already deployed for exactly
|
||||
# this pattern; named per-ConfigMap rather than blanket, as elsewhere.
|
||||
annotations:
|
||||
configmap.reloader.stakater.com/reload: cilium-config
|
||||
# Which pod labels feed security-identity computation. Patterns are
|
||||
# start-anchored regexes; a leading "!" excludes while keeping the default
|
||||
# set. Woodpecker stamps every step pod with woodpecker-ci.org/task-uuid
|
||||
@@ -73,6 +91,36 @@ spec:
|
||||
# kubernetes/talos/cilium.yaml carries a third copy of the same key.
|
||||
# Nothing enforces any of this; grep bpf-policy-map-max before changing it.
|
||||
policyMapMax: 65536
|
||||
# VLAN ids the eBPF datapath is allowed to pass. Without this it drops
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
# filter" in cilium_drop_count_total.
|
||||
#
|
||||
# 30 is v30iot, the macvlan parent for the shelly-manager, matter-server,
|
||||
# otbr and music-assistant attachments. On bare metal it is a VLAN
|
||||
# sub-interface of enp2s0f0np0; inside the former Proxmox guests it was
|
||||
# an untagged virtio NIC, which is why this was not needed before.
|
||||
#
|
||||
# VLAN 40 (mgmt0) is deliberately NOT listed, and the asymmetry is the
|
||||
# interesting part. Cilium attaches to devices that carry an address —
|
||||
# `cilium-dbg status --verbose` lists enp87s0, enp2s0f0np0, enp2s0f1np1
|
||||
# and mgmt0, but NOT ens19. mgmt0 holds the node IP, so Cilium owns it as
|
||||
# a device and sees frames the 8021q layer has already untagged; nothing
|
||||
# for a VLAN filter to match. ens19 is deliberately address-less (see
|
||||
# talos/patches/k8s-1.yaml — host addresses on that L2 make node replies
|
||||
# bypass the UDM), so Cilium never attaches to it and VLAN 30 frames only
|
||||
# meet the filter on the shared parent, still tagged.
|
||||
#
|
||||
# So the fix belongs on 30 alone. Listing 40 would change nothing, and
|
||||
# would touch the datapath carrying etcd and the API for no reason.
|
||||
#
|
||||
# A tc-layer drop surfaces to an AF_PACKET sender as ENOBUFS, so the
|
||||
# symptom is the CNI dhcp plugin reporting "no buffer space available"
|
||||
# rather than a DHCP timeout. See issue #592.
|
||||
#
|
||||
# Same three-copy problem as policyMapMax above: vlan-bpf-bypass in
|
||||
# kubernetes/talos/cilium.yaml and kubernetes/talos/patches/cilium-cni.yaml
|
||||
# must move with this list. Takes effect on agent restart.
|
||||
vlanBypass: [30]
|
||||
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
|
||||
# redirects the socket directly to the backend pod instead of sending
|
||||
# traffic out to the VIP and back (which fails with L2 announcements).
|
||||
|
||||
@@ -150,16 +150,3 @@ spec:
|
||||
env:
|
||||
- name: COLLECTIONS
|
||||
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
|
||||
|
||||
config:
|
||||
parsers:
|
||||
s02-enrich:
|
||||
pve-ui-polling-whitelist.yaml: |
|
||||
name: krenner/pve-ui-polling-whitelist
|
||||
description: "Suppress CrowdSec scenarios for the Proxmox UI's legitimate API polling on pve.renner.uno"
|
||||
filter: "evt.Meta.log_type == 'http_access-log' && evt.Meta.target_fqdn == 'pve.renner.uno'"
|
||||
whitelist:
|
||||
reason: "Proxmox web UI polls /api2/json/cluster/* and /api2/json/nodes/<n>/{lxc,qemu}/<id>/status/current every ~2s; http-probing flagged it as scanning and banned the residential IPv6."
|
||||
expression:
|
||||
- "evt.Meta.http_path startsWith '/api2/json/cluster/'"
|
||||
- "evt.Meta.http_path matches '^/api2/json/nodes/[^/]+/(lxc|qemu)/[0-9]+/status/current$'"
|
||||
|
||||
@@ -15,7 +15,6 @@ resources:
|
||||
- netbird-ingress.yaml
|
||||
- paperless-ngx.yaml
|
||||
- protonmail-bridge.yaml
|
||||
- pve-exporter.yaml
|
||||
- mosquitto.yaml
|
||||
- redis.yaml
|
||||
- shelly-manager.yaml
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: pve-exporter
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: prompve/prometheus-pve-exporter
|
||||
interval: 1h
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: pve-exporter
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: pve-exporter
|
||||
filterTags:
|
||||
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: "3.x"
|
||||
@@ -7,7 +7,7 @@ spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# Intra-namespace — Prometheus ↔ Alertmanager ↔ Grafana ↔ Loki ↔
|
||||
# alloy ↔ kube-state-metrics ↔ blackbox-exporter ↔ pve-exporter.
|
||||
# alloy ↔ kube-state-metrics ↔ blackbox-exporter.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# OpenBao policy: rotator-proxmox
|
||||
#
|
||||
# Class-B rotation for the pve-exporter Proxmox API token. Grants the
|
||||
# proxmox-secret-rotator CronJob READ on its bootstrap credential (a PRIVILEGED
|
||||
# Proxmox token allowed to delete/create tokens) and PATCH on the target
|
||||
# pve-exporter-config path. Per-secret isolation (#213/#202).
|
||||
#
|
||||
# NOT applied by Flux. Apply ONCE with an admin token:
|
||||
#
|
||||
# bao policy write rotator-proxmox kubernetes/infrastructure/openbao/policies/rotator-proxmox.hcl
|
||||
# bao write auth/kubernetes/role/rotator-proxmox \
|
||||
# bound_service_account_names=proxmox-secret-rotator \
|
||||
# bound_service_account_namespaces=openbao \
|
||||
# policies=rotator-proxmox ttl=15m
|
||||
#
|
||||
# BOOTSTRAP CREDENTIAL (PROVISIONED — recorded here for rebuild/DR).
|
||||
#
|
||||
# A SEPARATE least-privilege Proxmox identity, NOT root@pam. PVE has no
|
||||
# per-user ACL path (/access/users/<id> is rejected — see AccessControl.pm
|
||||
# check_path), and the token endpoints authorise via a `userid-group` check.
|
||||
# So the tightest possible scope is a group containing only the target user:
|
||||
#
|
||||
# pveum role add SecretRotator -privs "User.Modify"
|
||||
# pveum user add secret-rotator@pve
|
||||
# pveum group add secret-rotated
|
||||
# pveum user modify pve-exporter@pve --groups secret-rotated
|
||||
# pveum acl modify /access/groups/secret-rotated \
|
||||
# --users secret-rotator@pve --roles SecretRotator
|
||||
# pveum user token add secret-rotator@pve rotate --privsep 0
|
||||
#
|
||||
# This grants exactly one thing: managing API tokens of users in the
|
||||
# secret-rotated group. Verified: no VM/storage visibility, and minting a
|
||||
# root@pam token returns 403.
|
||||
#
|
||||
# bao kv put homelab/infrastructure/secret-rotators-bootstrap/proxmox \
|
||||
# api-url=https://pve.apps.svc.cluster.local:8006 \
|
||||
# auth='secret-rotator@pve!rotate=<secret>'
|
||||
#
|
||||
# api-url MUST be the in-cluster Service (backed by all three PVE nodes).
|
||||
# The external name pve.renner.uno does NOT work from a pod — pods cannot
|
||||
# hairpin to their own cluster's ingress LB and the Job hangs.
|
||||
#
|
||||
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
|
||||
|
||||
# Bootstrap: the privileged Proxmox token + API URL.
|
||||
path "homelab/data/infrastructure/secret-rotators-bootstrap/proxmox" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
# Target: the pve-exporter config (holds the pve.yml with the token_value).
|
||||
path "homelab/data/apps/monitoring/pve-exporter-config" {
|
||||
capabilities = ["read", "patch"]
|
||||
}
|
||||
@@ -52,7 +52,6 @@ resources:
|
||||
- crowdsec-bouncer-key.yaml
|
||||
- netbird-ingress.yaml
|
||||
- cloudflare.yaml
|
||||
- pve-exporter.yaml
|
||||
- external-dns.yaml
|
||||
- flux-git.yaml
|
||||
- home-assistant.yaml
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
# Class-B rotation of the pve-exporter Proxmox API token via the secret-rotator
|
||||
# binary (proxmox-token mode). The RISKIEST rotator: Proxmox has no in-place
|
||||
# token roll, so the binary DELETEs and recreates pve-exporter@pve!monitoring
|
||||
# (same id, privsep=0) and patches OpenBao with a rendered pve.yml. That leaves
|
||||
# a brief window with NO valid token — pve-exporter metrics gap until ESO(10m)+
|
||||
# Reloader restart. Quarterly, off-hours: acceptable.
|
||||
#
|
||||
# privsep=0 makes the recreated token inherit pve-exporter@pve's own perms
|
||||
# (PVEAuditor ACL is on the USER, not the token — verified), so no ACL re-grant.
|
||||
# The bootstrap 'auth' is the least-privilege secret-rotator@pve!rotate identity
|
||||
# (User.Modify scoped to the secret-rotated group), NOT root.
|
||||
#
|
||||
# api-url is the in-cluster Service https://pve.apps.svc.cluster.local:8006 (all
|
||||
# three PVE nodes back it → node-failover). TLS verification is disabled for the
|
||||
# PVE calls: the nodes present valid Let's Encrypt certs that fail only on
|
||||
# hostname mismatch (Service DNS vs node FQDN); ureq has no skip-hostname-only
|
||||
# option, and this matches the previous python's CERT_NONE. Do NOT use
|
||||
# pve.renner.uno (pod hairpin off the ingress LB) or a single node FQDN (SPOF).
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: proxmox-secret-rotator
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: proxmox-secret-rotate
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
spec:
|
||||
suspend: false
|
||||
schedule: "0 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 5
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
activeDeadlineSeconds: 300
|
||||
ttlSecondsAfterFinished: 259200 # 3 d
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: pve-exporter
|
||||
app.kubernetes.io/component: secret-rotator
|
||||
spec:
|
||||
serviceAccountName: proxmox-secret-rotator
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: rotate
|
||||
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
|
||||
args: ["proxmox-token"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: BAO_ADDR
|
||||
value: http://openbao.openbao.svc.cluster.local:8200
|
||||
- name: BAO_ROLE
|
||||
value: rotator-proxmox
|
||||
- name: BOOTSTRAP_PATH
|
||||
value: homelab/infrastructure/secret-rotators-bootstrap/proxmox
|
||||
- name: BAO_PATH
|
||||
value: homelab/apps/monitoring/pve-exporter-config
|
||||
- name: PVE_USER
|
||||
value: pve-exporter@pve
|
||||
- name: PVE_TOKENID
|
||||
value: monitoring
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 48Mi
|
||||
@@ -167,34 +167,44 @@ spec:
|
||||
# impossible: the OSD is recreated within a reconcile and starts backfilling
|
||||
# onto the drive being replaced.
|
||||
#
|
||||
# nvme0n1 on k8s-2/k8s-3 is the NVMe drive PCI-passed-through from saturn and
|
||||
# neptune, and is the only block device those guests can see, so a bare device
|
||||
# name is unambiguous there.
|
||||
#
|
||||
# k8s-1 is bare metal and sees three NVMe drives, so it names its OSD by
|
||||
# /dev/disk/by-id path instead. A bare name would be actively wrong: Talos
|
||||
# enumerates this machine's drives in a different order than Proxmox did, and
|
||||
# nvme0n1 here is the Kingston SYSTEM disk. Rook would probably decline it for
|
||||
# All three nodes are bare metal and see three NVMe drives each, so they name
|
||||
# their OSDs by /dev/disk/by-id path. A bare name would be actively wrong:
|
||||
# Talos enumerates these machines' drives in a different order than Proxmox
|
||||
# did, and the kernel name lands on the Kingston SYSTEM disk or on EPHEMERAL
|
||||
# depending on the boot. Rook would probably decline the system disk for
|
||||
# carrying partitions, but that is luck rather than intent. The by-id path is
|
||||
# derived from model and serial and does not depend on enumeration at all.
|
||||
#
|
||||
# nvme0n1 50026B7382B48B86 Kingston — Talos system disk
|
||||
# nvme1n1 2332E86B749A Crucial — Talos EPHEMERAL (/var)
|
||||
# nvme2n1 S437NX0M400426 PM983 — this OSD
|
||||
# k8s-3 demonstrated why: it enumerated the Kingston as nvme0n1 on one boot
|
||||
# and the Crucial as nvme0n1 on the very next one.
|
||||
#
|
||||
# k8s-1 (jupiter)
|
||||
# 50026B7382B48B86 Kingston — Talos system disk
|
||||
# 2332E86B749A Crucial — Talos EPHEMERAL (/var)
|
||||
# S437NX0M400426 PM983 — this OSD
|
||||
#
|
||||
# k8s-2 (saturn)
|
||||
# 50026B7382DA5F6E Kingston — Talos system disk
|
||||
# 2332E86B74BC Crucial — Talos EPHEMERAL (/var)
|
||||
# S437NX0M403161 PM983 — this OSD
|
||||
#
|
||||
# k8s-3 (neptune)
|
||||
# 50026B7382B48B90 Kingston — Talos system disk
|
||||
# 2326E6E8E67D Crucial — Talos EPHEMERAL (/var)
|
||||
# S437NF0M501176 PM983 — this OSD
|
||||
storage:
|
||||
useAllNodes: false
|
||||
useAllDevices: false
|
||||
nodes:
|
||||
# k8s-2 is absent while saturn's OSD drive is swapped and the node is
|
||||
# converted to bare metal. RE-ADD IT once the PM983 is in and the node has
|
||||
# rejoined, naming the device by /dev/disk/by-id as k8s-1 does — bare
|
||||
# metal sees three NVMe drives and the bare name is not the OSD.
|
||||
- name: k8s-3
|
||||
devices:
|
||||
- name: nvme0n1
|
||||
- name: k8s-1
|
||||
devices:
|
||||
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NX0M400426
|
||||
- name: k8s-2
|
||||
devices:
|
||||
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NX0M403161
|
||||
- name: k8s-3
|
||||
devices:
|
||||
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NF0M501176
|
||||
config:
|
||||
osdsPerDevice: "1"
|
||||
# Storage layer must never be evicted by kubelet under node pressure
|
||||
|
||||
@@ -5,31 +5,24 @@ metadata:
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
failureDomain: host
|
||||
# size 2, NOT 3. With 3 OSDs on 3 hosts, size=3 meant every write landed on
|
||||
# every drive in the cluster: per-drive write volume equalled total cluster
|
||||
# write volume, and all three drives wore at an identical rate and would have
|
||||
# reached end-of-rated-life together (measured 2026-08-19: 80/82/87% endurance
|
||||
# used, ~0.13 points/day, all three exhausted between Nov 2026 and Jan 2027 —
|
||||
# see #434). Replication does not protect against that failure mode, because
|
||||
# wear-out here is correlated by construction rather than independent.
|
||||
# size 3. This pool sat at 2 while the OSDs were Crucial P3 QLC drives: with
|
||||
# 3 OSDs on 3 hosts, size=3 put every write on every drive, so per-drive write
|
||||
# volume equalled total cluster write volume and all three wore at an
|
||||
# identical rate (measured 2026-08-19: 80/82/87% endurance used, ~0.13
|
||||
# points/day, all three projected to exhaust between Nov 2026 and Jan 2027 —
|
||||
# see #434). Replication does not protect against that, because the wear was
|
||||
# correlated by construction rather than independent.
|
||||
#
|
||||
# size 2 cuts per-drive write volume by a third and desynchronises the wear.
|
||||
# The OSDs are Samsung PM983 enterprise drives now, at 2-3% of rated
|
||||
# endurance with a far higher write budget, so wear-out is no longer the
|
||||
# binding constraint and the correlation argument no longer applies.
|
||||
#
|
||||
# min_size 1 is REQUIRED and deliberate. Ceph does not lower min_size when
|
||||
# size is reduced — it was 2 here, and leaving it would make the pool 2/2,
|
||||
# where any single OSD down (every rolling node reboot, every OSD restart)
|
||||
# min_size is left at the size-3 default of 2. The explicit min_size 1
|
||||
# override existed only because Ceph does not lower min_size when size is
|
||||
# reduced, which would have left the pool 2/2 — where any single OSD down
|
||||
# blocks IO for its PGs and hangs every RBD volume in the cluster.
|
||||
#
|
||||
# The trade this accepts: while the pool is degraded (one OSD down), a second
|
||||
# drive failing loses data, and with all three drives at 80%+ endurance that
|
||||
# window is not hypothetical. Mitigations that already exist: CNPG barman and
|
||||
# Velero both ship offsite to Hetzner (opentofu/object_storage.tf), so the
|
||||
# exposure is the RPO of those backups, not total loss. Keep node reboots
|
||||
# serialised and wait for HEALTH_OK between them.
|
||||
replicated:
|
||||
size: 2
|
||||
parameters:
|
||||
min_size: "1"
|
||||
size: 3
|
||||
---
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
@@ -87,39 +80,26 @@ metadata:
|
||||
name: cephfs
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
# TEMPORARY — RESTORE TO size 3 / no min_size OVERRIDE ONCE SATURN AND
|
||||
# NEPTUNE HAVE BOTH BEEN CONVERTED TO BARE METAL. It stays at 2/1 across all
|
||||
# three conversions rather than being toggled per node: each change
|
||||
# re-replicates the pool, and the window reopens with the next node anyway.
|
||||
# size 3 with no min_size override. This pool holds ~253 MiB against the data
|
||||
# pool's 280 GiB, and losing it would destroy the whole filesystem rather
|
||||
# than just the objects on the lost OSD, so it is the last pool that should
|
||||
# ever run thin.
|
||||
#
|
||||
# The standing trade for this pool is size 3: it holds ~253 MiB against the
|
||||
# data pool's 280 GiB, so it contributes nothing measurable to drive wear —
|
||||
# the entire reason data0 dropped to 2 — while losing it would destroy the
|
||||
# whole filesystem, not just the objects on the lost OSD. Paying 3x on a
|
||||
# quarter of a gigabyte to keep that intact is the right trade in steady
|
||||
# state.
|
||||
#
|
||||
# It does not hold while one of three hosts is out. size 3 on two remaining
|
||||
# hosts leaves every PG undersized at two copies with min_size 2, so a single
|
||||
# further OSD loss drops the pool below min_size and blocks IO on every
|
||||
# CephFS mount cluster-wide. At 2/1 the same two copies are active+clean
|
||||
# instead, and a further loss degrades rather than blocks — availability
|
||||
# bought at the cost of accepting writes onto a single copy while degraded.
|
||||
#
|
||||
# See RUNBOOK-baremetal-conversion.md.
|
||||
# It drops to 2/1 whenever one of the three hosts is out for longer than a
|
||||
# reboot. size 3 on two remaining hosts leaves every PG undersized at two
|
||||
# copies with min_size 2, so a single further OSD loss falls below min_size
|
||||
# and blocks IO on every CephFS mount cluster-wide. At 2/1 the same two
|
||||
# copies are active+clean instead, and a further loss degrades rather than
|
||||
# blocks. See RUNBOOK-baremetal-conversion.md.
|
||||
metadataPool:
|
||||
replicated:
|
||||
size: 2
|
||||
parameters:
|
||||
min_size: "1"
|
||||
size: 3
|
||||
dataPools:
|
||||
- name: data0
|
||||
# size 2 / min_size 1 for the same reasons as replicapool above — see
|
||||
# the comment there for the full rationale and the accepted trade.
|
||||
# size 3 for the same reasons as replicapool above — see the comment
|
||||
# there for why this pool sat at 2 while the Crucials backed the OSDs.
|
||||
replicated:
|
||||
size: 2
|
||||
parameters:
|
||||
min_size: "1"
|
||||
size: 3
|
||||
metadataServer:
|
||||
activeCount: 1
|
||||
activeStandby: true
|
||||
|
||||
@@ -310,6 +310,13 @@ data:
|
||||
# policy map (per endpoint). Keep in lockstep with patches/cilium-cni.yaml and
|
||||
# values.bpf.policyMapMax in kubernetes/infrastructure/cilium/helmrelease.yaml.
|
||||
bpf-policy-map-max: "65536"
|
||||
# VLAN ids the eBPF datapath may pass; without this it drops VLAN-tagged
|
||||
# frames. 30 is v30iot, the macvlan parent for the IoT workloads, and a VLAN
|
||||
# sub-interface of enp2s0f0np0 on bare metal. VLAN 40 (mgmt0) rides the same
|
||||
# parent but passes without an entry, so it is deliberately not listed.
|
||||
# Keep in lockstep with patches/cilium-cni.yaml and values.bpf.vlanBypass in
|
||||
# kubernetes/infrastructure/cilium/helmrelease.yaml. See issue #592.
|
||||
vlan-bpf-bypass: "30"
|
||||
# bpf-policy-stats-map-max specifies the maximum number of entries in global
|
||||
# policy stats map
|
||||
bpf-policy-stats-map-max: "65536"
|
||||
|
||||
@@ -331,6 +331,16 @@ cluster:
|
||||
# inline manifest, so a lower value here means a cold rebuild comes up with
|
||||
# a map that overflows until Flux reconciles.
|
||||
bpf-policy-map-max: "65536"
|
||||
# VLAN ids the eBPF datapath may pass; without this it drops
|
||||
# VLAN-tagged frames. 30 is v30iot, the macvlan parent for the IoT
|
||||
# workloads, and a VLAN sub-interface of enp2s0f0np0 on bare metal.
|
||||
# VLAN 40 (mgmt0) rides the same parent but passes without an entry,
|
||||
# so it is deliberately not listed. Keep in lockstep with
|
||||
# values.bpf.vlanBypass in kubernetes/infrastructure/cilium/helmrelease.yaml
|
||||
# and the third copy in kubernetes/talos/cilium.yaml — bootstrap installs
|
||||
# from this inline manifest, so omitting it here breaks the IoT macvlan
|
||||
# workloads until Flux reconciles. See issue #592.
|
||||
vlan-bpf-bypass: "30"
|
||||
# bpf-policy-stats-map-max specifies the maximum number of entries in global
|
||||
# policy stats map
|
||||
bpf-policy-stats-map-max: "65536"
|
||||
|
||||
@@ -5,6 +5,31 @@ machine:
|
||||
- k8s-3.srv.renner.uno
|
||||
- k8s.renner.uno
|
||||
- 10.2.40.60
|
||||
# Install from the Image Factory metal installer, not the stock
|
||||
# ghcr.io/siderolabs/installer that `talosctl gen config` defaults to.
|
||||
# Extensions live in the installer image, so with the stock one the schematic
|
||||
# is only ever a property of the boot media and nothing lands on disk —
|
||||
# `talosctl get extensions` returns empty on a node installed that way.
|
||||
#
|
||||
# What the schematic carries, and why each matters here:
|
||||
#
|
||||
# siderolabs/intel-ucode CPU microcode. On a VM the hypervisor supplied
|
||||
# it; on bare metal nothing does except the BIOS.
|
||||
# siderolabs/thunderbolt Inert until machine.kernel.modules asks for
|
||||
# thunderbolt/thunderbolt_net. Present so enabling
|
||||
# the MS-01 mesh later needs neither a new image
|
||||
# nor a reinstall — which is exactly the property
|
||||
# lost by installing without it.
|
||||
#
|
||||
# a21cec8f… is the bare-metal schematic described in RUNBOOK-cold-rebuild.md:
|
||||
# same net.ifnames=1 as the Proxmox one (the predictable interface names the
|
||||
# per-node VLAN parents match against), minus qemu-guest-agent.
|
||||
#
|
||||
# Only read at install and upgrade time. Changing it here does nothing to a
|
||||
# running node until that node is upgraded. Bump the tag with the Talos
|
||||
# version — it must match the running version, or the upgrade is a downgrade.
|
||||
install:
|
||||
image: factory.talos.dev/metal-installer/a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64:v1.12.6
|
||||
features:
|
||||
rbac: true
|
||||
stableHostname: true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# k8s-2 runs Talos directly on saturn's MS-01 hardware. Structurally identical
|
||||
# to k8s-1.yaml — see that file for the reasoning behind each choice; only the
|
||||
# serials, MACs and addresses differ. k8s-3 is still a Proxmox VM and keeps the
|
||||
# VM-era layout until neptune is converted.
|
||||
# serials, MACs and addresses differ.
|
||||
machine:
|
||||
network:
|
||||
hostname: k8s-2
|
||||
|
||||
@@ -1,31 +1,82 @@
|
||||
# k8s-3 runs Talos directly on neptune's MS-01 hardware. Structurally identical
|
||||
# to k8s-1.yaml — see that file for the reasoning behind each choice; only the
|
||||
# serials and addresses differ. neptune was the last node converted, so all
|
||||
# three control-plane nodes now share this layout and no Proxmox remains.
|
||||
machine:
|
||||
network:
|
||||
hostname: k8s-3
|
||||
# Shared control-plane VIP — see k8s-1.yaml for the busPath rationale.
|
||||
interfaces:
|
||||
- deviceSelector:
|
||||
busPath: "0000:06:12.0"
|
||||
dhcp: true
|
||||
vip:
|
||||
ip: 10.2.40.60
|
||||
# v30iot: link only, no host addressing — see k8s-1.yaml.
|
||||
- deviceSelector:
|
||||
busPath: "0000:06:13.0"
|
||||
dhcp: false
|
||||
# See k8s-1.yaml for context on the dedicated Ceph cluster network.
|
||||
- deviceSelector:
|
||||
driver: i40e
|
||||
addresses:
|
||||
- 10.99.0.3/29
|
||||
mtu: 9000
|
||||
sysctls:
|
||||
# v30iot NIC carries no host addressing — see k8s-1.yaml.
|
||||
net.ipv6.conf.ens19.disable_ipv6: "1"
|
||||
install:
|
||||
disk: /dev/vda
|
||||
# Three NVMe drives are visible on bare metal. Select the installer target
|
||||
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
|
||||
# order the kernel enumerates them in — Talos and Proxmox do not agree on
|
||||
# that order.
|
||||
diskSelector:
|
||||
serial: 50026B7382B48B90
|
||||
certSANs:
|
||||
- k8s-3.srv.renner.uno
|
||||
nodeLabels:
|
||||
topology.kubernetes.io/zone: neptune
|
||||
cluster:
|
||||
allowSchedulingOnControlPlanes: true
|
||||
---
|
||||
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk. That
|
||||
# drive is at 83% of its rated endurance and is finished as an OSD, but
|
||||
# everything EPHEMERAL holds is reconstructible, so it is consumable in this
|
||||
# role. Available spare is still 100% and it reports no media errors.
|
||||
#
|
||||
# ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
|
||||
# solely when the volume does not yet exist; applied later it is silently
|
||||
# ignored and relocating needs a disk wipe and a boot from install media.
|
||||
#
|
||||
# Matched by serial deliberately: a broad selector such as
|
||||
# `disk.transport == 'nvme' && !system_disk` would match the PM983 as well.
|
||||
apiVersion: v1alpha1
|
||||
kind: VolumeConfig
|
||||
name: EPHEMERAL
|
||||
provisioning:
|
||||
diskSelector:
|
||||
match: disk.serial == '2326E6E8E67D'
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: mgmt0
|
||||
vlanID: 40
|
||||
parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: mgmt0
|
||||
---
|
||||
# Shared control-plane VIP, held by whichever node wins the etcd campaign.
|
||||
apiVersion: v1alpha1
|
||||
kind: Layer2VIPConfig
|
||||
name: 10.2.40.60
|
||||
link: mgmt0
|
||||
---
|
||||
# v30iot: link only, no host addressing — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: VLANConfig
|
||||
name: ens19
|
||||
vlanID: 30
|
||||
parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
# Dedicated Ceph cluster network — see k8s-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp2s0f1np1
|
||||
up: true
|
||||
mtu: 9000
|
||||
addresses:
|
||||
- address: 10.99.0.3/29
|
||||
|
||||
@@ -47,8 +47,13 @@ variable "tags" {
|
||||
}
|
||||
|
||||
variable "template_name" {
|
||||
type = string
|
||||
default = "ubuntu-24-04-template"
|
||||
type = string
|
||||
# No default clone source. The ubuntu-24.04-template it used to name was
|
||||
# deleted with saturn's conversion to bare metal, and Proxmox is being
|
||||
# retired, so a default pointing at a template that does not exist would
|
||||
# only fail at apply time. Set it explicitly if a clone source is ever
|
||||
# wanted again.
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "full_clone" {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# k8s-3 was the last Proxmox VM. neptune now runs Talos on bare metal, and its
|
||||
# disks were wiped during the conversion, so the VM this module described does
|
||||
# not exist in any form.
|
||||
#
|
||||
# `removed` rather than simply deleting vm_152_k8s_3.tf: dropping the resource
|
||||
# from configuration alone makes OpenTofu plan a DESTROY, and planning a destroy
|
||||
# refreshes the resource through the bpg/proxmox provider — which now has no
|
||||
# endpoint to reach. That is exactly what has been failing every infrastructure
|
||||
# plan since the conversion:
|
||||
#
|
||||
# Error: failed to get resources list of type ("vm") for cluster:
|
||||
# dial tcp 10.2.40.201:8006: connect: no route to host
|
||||
# with module.k8s_3.proxmox_virtual_environment_vm.this
|
||||
#
|
||||
# `lifecycle { destroy = false }` forgets the state entry without attempting to
|
||||
# contact Proxmox. Nothing is orphaned by doing so: the guest, its disks and the
|
||||
# hypervisor underneath it are all already gone.
|
||||
#
|
||||
# This block can be deleted once one plan has run cleanly with it — it is a
|
||||
# one-shot instruction, not standing configuration.
|
||||
removed {
|
||||
from = module.k8s_3
|
||||
|
||||
lifecycle {
|
||||
destroy = false
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to help with UniFi client migration
|
||||
#
|
||||
# NOTE: This script is NO LONGER RECOMMENDED
|
||||
# Instead, follow the migration guide in MIGRATION_QUICKSTART.md
|
||||
#
|
||||
# This script removes unifi_client resources from state, but the recommended
|
||||
# approach is to let 'tofu apply' handle the removal automatically.
|
||||
|
||||
echo "⚠️ WARNING: This script is deprecated"
|
||||
echo ""
|
||||
echo "Please use the recommended migration path instead:"
|
||||
echo " 1. Deploy DNS server: tofu apply -target=module.proxmox_dns"
|
||||
echo " 2. Configure UniFi DNS to use 10.2.40.53"
|
||||
echo " 3. Run full apply: tofu apply"
|
||||
echo ""
|
||||
echo "See MIGRATION_QUICKSTART.md for details."
|
||||
echo ""
|
||||
read -p "Do you still want to remove resources from state? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cancelled. See MIGRATION_QUICKSTART.md for recommended approach."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
echo "Removing unifi_client resources from OpenTofu state..."
|
||||
echo "Note: VMs will keep their current network configuration"
|
||||
echo ""
|
||||
|
||||
# List of all unifi_client resources to remove
|
||||
RESOURCES=(
|
||||
"module.cdns_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.fortress_renner_uno.unifi_client.this[0]"
|
||||
"module.grafana_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.influxdb_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.kasm_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.mosquitto_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.netbox_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.ntp_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.paperless_srv_renner_uno.unifi_client.this[0]"
|
||||
"module.uptime_kuma_srv_renner_uno.unifi_client.this[0]"
|
||||
)
|
||||
|
||||
for resource in "${RESOURCES[@]}"; do
|
||||
echo "Removing: $resource"
|
||||
tofu state rm "$resource" || echo " (already removed or not found)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done! Run 'tofu plan' to verify."
|
||||
@@ -1,13 +0,0 @@
|
||||
locals {
|
||||
talos_version = "v1.12.6"
|
||||
# Image Factory schematic (predictable naming: net.ifnames=1). Keep in
|
||||
# sync with TALOS_SCHEMATIC in .woodpecker/deploy-infrastructure.yml —
|
||||
# that pipeline downloads the image, this local names it for VM import,
|
||||
# and both MUST agree on the filename. The schematic hash is embedded in
|
||||
# the filename so a schematic bump changes the name, forcing a fresh
|
||||
# download instead of silently reusing a stale cached image (which would
|
||||
# reintroduce net.ifnames=0 on the next VM (re)provision).
|
||||
talos_schematic = "88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
|
||||
talos_filename = "talos-${local.talos_version}-${substr(local.talos_schematic, 0, 12)}-nocloud-amd64.raw"
|
||||
talos_image = "local:import/${local.talos_filename}"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
module "k8s_3" {
|
||||
source = "./modules/proxmox_vm"
|
||||
|
||||
name = "k8s-3.srv.renner.uno"
|
||||
target_node = "neptune"
|
||||
vmid = 152
|
||||
memory = 49152
|
||||
cores = 8
|
||||
|
||||
template_name = null
|
||||
cloud_init = false
|
||||
full_clone = false
|
||||
bios = "ovmf"
|
||||
machine = "q35"
|
||||
boot = "order=virtio0"
|
||||
agent_enabled = true
|
||||
|
||||
# Second NIC on v30iot so Cilium L2 announce can win ARP on the IoT
|
||||
# VLAN from this node (previously only k8s-edge had v30iot, so any
|
||||
# IoT-pool LB lease landing here was unreachable from devices).
|
||||
extra_network_bridges = ["v30iot"]
|
||||
|
||||
disks = [
|
||||
{
|
||||
storage = "local-zfs"
|
||||
size = "64G"
|
||||
format = "raw"
|
||||
slot = "virtio0"
|
||||
file_id = local.talos_image
|
||||
}
|
||||
]
|
||||
|
||||
hostpci = [
|
||||
{
|
||||
mapping = "osd-ssd"
|
||||
},
|
||||
{
|
||||
mapping = "x710-port1"
|
||||
}
|
||||
]
|
||||
|
||||
tags = "kubernetes;talos;controlplane"
|
||||
ssh_public_keys = local.common_ssh_keys
|
||||
|
||||
vm_state = "running"
|
||||
|
||||
# Rebuild pipeline issues `-replace`; skip graceful shutdown so a
|
||||
# non-responsive Talos guest can't hang the destroy for 30 minutes.
|
||||
stop_on_destroy = true
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
resource "proxmox_virtual_environment_vm" "ubuntu_24_04_template" {
|
||||
name = "ubuntu-24.04-template"
|
||||
node_name = "saturn"
|
||||
vm_id = 605
|
||||
|
||||
started = false
|
||||
template = true
|
||||
on_boot = false
|
||||
|
||||
keyboard_layout = "en-us"
|
||||
|
||||
bios = "seabios"
|
||||
|
||||
agent {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
cpu {
|
||||
cores = 2
|
||||
type = "x86-64-v3"
|
||||
}
|
||||
|
||||
memory {
|
||||
dedicated = 2048
|
||||
}
|
||||
|
||||
initialization {
|
||||
datastore_id = "local-zfs"
|
||||
|
||||
ip_config {
|
||||
ipv4 {
|
||||
address = "dhcp"
|
||||
}
|
||||
}
|
||||
|
||||
user_account {
|
||||
username = "ladmin"
|
||||
keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
disk {
|
||||
datastore_id = "local-zfs"
|
||||
interface = "scsi0"
|
||||
size = 32
|
||||
file_format = "raw"
|
||||
iothread = false
|
||||
}
|
||||
|
||||
network_device {
|
||||
bridge = "v40srv"
|
||||
model = "virtio"
|
||||
}
|
||||
|
||||
serial_device {}
|
||||
|
||||
scsi_hardware = "virtio-scsi-pci"
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [
|
||||
node_name,
|
||||
started,
|
||||
initialization,
|
||||
disk,
|
||||
cpu,
|
||||
memory
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user