Initial cluster config: NixOS flake + k3s + 5 node definitions
- Multi-host flake.nix (3x N97 x86_64 + 2x RPi4 aarch64) - Shared modules: k3s-cluster, tailscale, meos-gateway - Node configs: n97-1 (control plane), n97-2/n97-3 (Ollama workers), rpi4-1/rpi4-2 (ARM workers) - K8s manifests: Forgejo, Postgres (CNPG), Umami, OpenBrain, MeOS Dashboard, MCP servers - Bootstrap and DB migration scripts - Migration plan doc (7-day phased rollout)
This commit is contained in:
parent
791a9b6721
commit
7b5ffe6b11
21 changed files with 1184 additions and 2 deletions
65
README.md
65
README.md
|
|
@ -1,3 +1,64 @@
|
|||
# devops
|
||||
# sscoble/devops
|
||||
|
||||
Infra as code for Scobleclaw multi-node cluster
|
||||
Infrastructure as code for the Scobleclaw cluster.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Node | Platform | Arch | Role | RAM | Storage |
|
||||
|-----------|-----------|---------|---------------|------|---------|
|
||||
| n97-1 | GMK N97 | x86_64 | k3s server | 12GB | 233GB |
|
||||
| n97-2 | GMK N97 | x86_64 | k3s worker | 12GB | ~233GB |
|
||||
| n97-3 | GMK N97 | x86_64 | k3s worker | 12GB | ~233GB |
|
||||
| rpi4-1 | RPi 4B | aarch64 | k3s worker | 8GB | SD/USB |
|
||||
| rpi4-2 | RPi 4B | aarch64 | k3s worker | 8GB | SD/USB |
|
||||
|
||||
## Network
|
||||
|
||||
- **Mesh**: Tailscale (all nodes)
|
||||
- **DNS**: os.scoble.me → n97-1 (Tailscale IP), SSL via Let's Encrypt + Cloudflare DNS
|
||||
- **K8s API**: Tailscale-only, no public exposure
|
||||
|
||||
## Repo Layout
|
||||
|
||||
```
|
||||
├── README.md
|
||||
├── flake.nix # NixOS flake — all 5 nodes
|
||||
├── hardware-n97.nix # shared N97 perf tuning
|
||||
├── hardware-rpi4.nix # shared RPi4 config
|
||||
├── modules/
|
||||
│ ├── k3s-cluster.nix # shared k3s agent config
|
||||
│ ├── meos-gateway.nix # nginx + acme + homepage
|
||||
│ ├── ollama-worker.nix # Ollama on worker nodes
|
||||
│ └── tailscale.nix # shared Tailscale config
|
||||
├── nodes/
|
||||
│ ├── n97-1.nix # control plane + gateway
|
||||
│ ├── n97-2.nix # Ollama worker x86
|
||||
│ ├── n97-3.nix # Ollama worker x86
|
||||
│ ├── rpi4-1.nix # ARM worker (light services)
|
||||
│ └── rpi4-2.nix # ARM worker (umami)
|
||||
├── apps/ # K8s manifests + Helm values
|
||||
│ ├── forgejo/
|
||||
│ ├── postgres/
|
||||
│ ├── umami/
|
||||
│ ├── openbrain/
|
||||
│ ├── meos-dashboard/
|
||||
│ └── mcp-servers/
|
||||
├── scripts/
|
||||
│ ├── bootstrap-node.sh # initial node setup
|
||||
│ └── migrate-db.sh # Postgres migration from bare to K8s
|
||||
└── docs/
|
||||
└── migration.md # transition plan
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# Build and deploy a node
|
||||
nixos-rebuild switch --flake .#n97-1 --target-host root@100.x.x.x
|
||||
|
||||
# Access cluster
|
||||
kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml get nodes
|
||||
|
||||
# Deploy app
|
||||
kubectl apply -k apps/forgejo/
|
||||
```
|
||||
|
|
|
|||
103
apps/forgejo/deployment.yaml
Normal file
103
apps/forgejo/deployment.yaml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Forgejo on k3s
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: forgejo
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: forgejo-data
|
||||
namespace: forgejo
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: forgejo
|
||||
namespace: forgejo
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: forgejo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: forgejo
|
||||
spec:
|
||||
containers:
|
||||
- name: forgejo
|
||||
image: codeberg.org/forgejo/forgejo:11.0
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
- containerPort: 22
|
||||
name: ssh
|
||||
env:
|
||||
- name: USER_UID
|
||||
value: "1000"
|
||||
- name: USER_GID
|
||||
value: "1000"
|
||||
- name: FORGEJO__database__DB_TYPE
|
||||
value: postgres
|
||||
- name: FORGEJO__database__HOST
|
||||
value: postgres-scobleclaw-rw.database.svc.cluster.local:5432
|
||||
- name: FORGEJO__database__NAME
|
||||
value: forgejo
|
||||
- name: FORGEJO__database__USER
|
||||
value: forgejo
|
||||
- name: FORGEJO__database__PASSWD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-users
|
||||
key: forgejo-password
|
||||
- name: FORGEJO__server__ROOT_URL
|
||||
value: "https://os.scoble.me/forgejo/"
|
||||
- name: FORGEJO__server__HTTP_PORT
|
||||
value: "3000"
|
||||
- name: FORGEJO__server__DOMAIN
|
||||
value: os.scoble.me
|
||||
- name: FORGEJO__server__DISABLE_SSH
|
||||
value: "false"
|
||||
- name: FORGEJO__server__SSH_PORT
|
||||
value: "2222"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "1"
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: forgejo-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: forgejo
|
||||
namespace: forgejo
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
- name: ssh
|
||||
port: 22
|
||||
targetPort: 22
|
||||
selector:
|
||||
app: forgejo
|
||||
83
apps/mcp-servers/deployment.yaml
Normal file
83
apps/mcp-servers/deployment.yaml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# MCP Servers — lightweight Node.js workloads for ARM worker (rpi4-1)
|
||||
# forgejo-mcp, lean-experiment-mcp, wardrobe-ai, homeschool
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: mcp-servers
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: forgejo-mcp
|
||||
namespace: mcp-servers
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: forgejo-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: forgejo-mcp
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
tolerations:
|
||||
- key: "arm64"
|
||||
operator: "Exists"
|
||||
effect: "NoSchedule"
|
||||
containers:
|
||||
- name: forgejo-mcp
|
||||
image: node:22-slim
|
||||
command: ["node", "/app/index.js"]
|
||||
workingDir: /app
|
||||
env:
|
||||
- name: FORGEJO_URL
|
||||
value: "http://forgejo.forgejo.svc.cluster.local:3000"
|
||||
- name: FORGEJO_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forgejo-tokens
|
||||
key: agent-cos
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: lean-experiment-mcp
|
||||
namespace: mcp-servers
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: lean-experiment-mcp
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: lean-experiment-mcp
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
tolerations:
|
||||
- key: "arm64"
|
||||
operator: "Exists"
|
||||
effect: "NoSchedule"
|
||||
containers:
|
||||
- name: lean-mcp
|
||||
image: node:22-slim
|
||||
command: ["node", "/app/dist/index.js"]
|
||||
workingDir: /app
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
46
apps/meos-dashboard/deployment.yaml
Normal file
46
apps/meos-dashboard/deployment.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# MeOS Dashboard — stateless React app, can run on any node
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: meos-dashboard
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: meos-dashboard
|
||||
namespace: meos-dashboard
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: meos-dashboard
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: meos-dashboard
|
||||
spec:
|
||||
containers:
|
||||
- name: dashboard
|
||||
image: ghcr.io/scobleclaw/meos-dashboard:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "250m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: meos-dashboard
|
||||
namespace: meos-dashboard
|
||||
spec:
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: meos-dashboard
|
||||
59
apps/openbrain/deployment.yaml
Normal file
59
apps/openbrain/deployment.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# OpenBrain MCP — deployed to ARM worker (rpi4-1)
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: openbrain
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: openbrain
|
||||
namespace: openbrain
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: openbrain
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: openbrain
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
tolerations:
|
||||
- key: "arm64"
|
||||
operator: "Exists"
|
||||
effect: "NoSchedule"
|
||||
containers:
|
||||
- name: openbrain
|
||||
image: node:22-slim
|
||||
command: ["node", "/app/index.js"]
|
||||
workingDir: /app
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: openbrain
|
||||
namespace: openbrain
|
||||
spec:
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: openbrain
|
||||
59
apps/postgres/cluster.yaml
Normal file
59
apps/postgres/cluster.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# CloudNative PG operator (recommended for Postgres on K8s)
|
||||
# https://cloudnative-pg.io/
|
||||
#
|
||||
# Install: kubectl apply --server-side -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.1.yaml
|
||||
#
|
||||
# Then create the cluster below.
|
||||
---
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: postgres-scobleclaw
|
||||
namespace: database
|
||||
spec:
|
||||
instances: 1
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:17.4
|
||||
|
||||
storage:
|
||||
size: 5Gi
|
||||
storageClass: local-path
|
||||
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
|
||||
postgresql:
|
||||
parameters:
|
||||
max_connections: "200"
|
||||
shared_buffers: "128MB"
|
||||
|
||||
databases:
|
||||
- name: forgejo
|
||||
owner: forgejo
|
||||
- name: umami
|
||||
owner: umami
|
||||
|
||||
users:
|
||||
- name: forgejo
|
||||
password: "<set-via-secret>"
|
||||
- name: umami
|
||||
password: "<set-via-secret>"
|
||||
|
||||
enableSuperuserAccess: false
|
||||
|
||||
monitoring:
|
||||
enablePodMonitor: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: postgres-users
|
||||
namespace: database
|
||||
type: Opaque
|
||||
stringData:
|
||||
forgejo-password: "<change-me>"
|
||||
umami-password: "<change-me>"
|
||||
67
apps/umami/deployment.yaml
Normal file
67
apps/umami/deployment.yaml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Umami analytics — deployed to ARM worker (rpi4-2)
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: umami
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: umami
|
||||
namespace: umami
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: umami
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: umami
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
tolerations:
|
||||
- key: "arm64"
|
||||
operator: "Exists"
|
||||
effect: "NoSchedule"
|
||||
containers:
|
||||
- name: umami
|
||||
image: ghcr.io/umami-software/umami:postgresql-v2
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: DATABASE_TYPE
|
||||
value: postgresql
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: umami-db
|
||||
key: url
|
||||
- name: APP_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: umami-secret
|
||||
key: secret
|
||||
- name: DISABLE_TELEMETRY
|
||||
value: "1"
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "500m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: umami
|
||||
namespace: umami
|
||||
spec:
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
selector:
|
||||
app: umami
|
||||
69
docs/migration.md
Normal file
69
docs/migration.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Migration Plan: Bare Metal → k3s Cluster
|
||||
|
||||
## Overview
|
||||
|
||||
Migrate from single-node N97 (nixos) to 5-node k3s cluster with zero downtime.
|
||||
|
||||
## Phase 1: Foundation (Day 1)
|
||||
|
||||
1. **Create devops repo** ✅ (sscoble/devops)
|
||||
2. **Bootstrap k3s on n97-1** — single-node control plane alongside existing services
|
||||
3. **Install CNPG operator** — `kubectl apply -f cnpg-crds.yaml`
|
||||
4. **Deploy local-path provisioner** — for persistent volumes on each node
|
||||
5. **Install nginx ingress controller** — `helm install nginx-ingress ingress-nginx`
|
||||
|
||||
At this point, k3s is running but all services are still bare metal. No traffic shifts yet.
|
||||
|
||||
## Phase 2: Non-Critical Services (Day 2-3)
|
||||
|
||||
Move services that don't affect the core workflow:
|
||||
|
||||
1. **MeOS Dashboard → k3s** — deploy manifest, update nginx upstream
|
||||
2. **MCP servers → rpi4-1** — forgejo-mcp, lean-experiment, wardrobe-ai
|
||||
3. **Taint ARM nodes** — prevent x86 pods from landing on Pi 4s
|
||||
|
||||
## Phase 3: Data Layer (Day 4-5)
|
||||
|
||||
1. **Deploy CNPG Postgres cluster** — `kubectl apply -f apps/postgres/cluster.yaml`
|
||||
2. **Run migration** — `./scripts/migrate-db.sh`
|
||||
3. **Verify data integrity** — row counts, test queries
|
||||
4. **Update Forgejo to point at CNPG** — change DATABASE_URL env var
|
||||
5. **Disable bare-metal PostgreSQL** — set `services.postgresql.enable = false`
|
||||
|
||||
## Phase 4: Core Services (Day 6-7)
|
||||
|
||||
1. **Forgejo → k3s** — deploy manifest, cut over nginx upstream
|
||||
2. **Umami → rpi4-2** — deploy manifest, verify analytics still collecting
|
||||
3. **Homepage dashboard** — stays on n97-1 (lightweight, already working)
|
||||
|
||||
## Phase 5: Scale Out (Day 8+)
|
||||
|
||||
1. **Add n97-2 as worker** — `./scripts/bootstrap-node.sh n97-2 100.x.x.x agent`
|
||||
2. **Add n97-3 as worker** — same
|
||||
3. **Deploy Ollama on n97-2/n97-3** — systemd services, expose as ClusterIP
|
||||
4. **Add rpi4-1/rpi4-2 as workers** — bootstrap with ARM64 support
|
||||
5. **Configure node taints** — ARM workers for light work, N97s for heavy
|
||||
|
||||
## What Stays Bare Metal
|
||||
|
||||
- **OpenClaw Gateway** — manages processes, not containers. Stay outside k8s.
|
||||
- **Nginx + ACME** — TLS termination on n97-1, routes to k8s Ingress
|
||||
- **Ollama** — GPU/NPU passthrough is painful in k8s. Run as systemd services on workers.
|
||||
- **Storj static hosting** — build + deploy pipeline stays in Nix flake
|
||||
- **X11/LightDM** — only on n97-1 if you use the desktop
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
Each phase can be rolled back independently:
|
||||
- **Phase 2**: Switch nginx upstreams back to bare-metal ports
|
||||
- **Phase 3**: Re-enable `services.postgresql.enable = true`, restore from dump
|
||||
- **Phase 4**: Same as Phase 2 — nginx upstream swap
|
||||
- **Phase 5**: `kubectl drain <node> && kubectl delete node <node>`
|
||||
|
||||
## Post-Migration
|
||||
|
||||
- Add `nodeAffinity` rules for Ollama pods (n97-2, n97-3 only)
|
||||
- Set up `PodDisruptionBudgets` for critical services
|
||||
- Consider HA control plane (3-server etcd) if uptime matters
|
||||
- Add monitoring: Prometheus + Grafana on rpi4-2
|
||||
- Set up Velero for K8s backups
|
||||
48
flake.nix
Normal file
48
flake.nix
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
description = "Scobleclaw Multi-Node Cluster — NixOS + k3s";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
shared-modules = [
|
||||
./modules/tailscale.nix
|
||||
./modules/k3s-cluster.nix
|
||||
];
|
||||
|
||||
mkSystem = system: hostModule: nixpkgs.lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = shared-modules ++ [ hostModule ];
|
||||
specialArgs = { inherit self; };
|
||||
};
|
||||
in {
|
||||
nixosConfigurations = {
|
||||
n97-1 = mkSystem "x86_64-linux" [
|
||||
./hardware-n97.nix
|
||||
./nodes/n97-1.nix
|
||||
];
|
||||
|
||||
n97-2 = mkSystem "x86_64-linux" [
|
||||
./hardware-n97.nix
|
||||
./nodes/n97-2.nix
|
||||
];
|
||||
|
||||
n97-3 = mkSystem "x86_64-linux" [
|
||||
./hardware-n97.nix
|
||||
./nodes/n97-3.nix
|
||||
];
|
||||
|
||||
rpi4-1 = mkSystem "aarch64-linux" [
|
||||
./hardware-rpi4.nix
|
||||
./nodes/rpi4-1.nix
|
||||
];
|
||||
|
||||
rpi4-2 = mkSystem "aarch64-linux" [
|
||||
./hardware-rpi4.nix
|
||||
./nodes/rpi4-2.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
29
hardware-n97.nix
Normal file
29
hardware-n97.nix
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# Intel N97 Mini PC performance tuning
|
||||
boot = {
|
||||
kernelPackages = pkgs.linuxPackages_latest;
|
||||
loader = {
|
||||
systemd-boot.enable = true;
|
||||
efi.canTouchEfiVariables = true;
|
||||
};
|
||||
initrd.availableKernelModules = [ "xhci_pci" "ahci" "nvme" "usbhid" ];
|
||||
};
|
||||
|
||||
hardware = {
|
||||
cpu.intel.updateMicrocode = true;
|
||||
enableRedistributableFirmware = true;
|
||||
opengl.enable = true;
|
||||
};
|
||||
|
||||
services.xserver.videoDrivers = [ "intel" ];
|
||||
powerManagement.enable = true;
|
||||
services.thermald.enable = true;
|
||||
|
||||
# Local storage dir for k3s persistent volumes
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /data/k3s 0755 root root -"
|
||||
"d /data/ollama 0755 root root -"
|
||||
];
|
||||
}
|
||||
23
hardware-rpi4.nix
Normal file
23
hardware-rpi4.nix
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# Raspberry Pi 4 — aarch64 worker node
|
||||
boot = {
|
||||
kernelPackages = pkgs.linuxPackages_rpi4;
|
||||
loader = {
|
||||
grub.enable = false;
|
||||
generic-extlinux-compatible.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# ARM-specific performance
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
# Local storage
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /data/k3s 0755 root root -"
|
||||
];
|
||||
|
||||
# RPi 4 throttling protection
|
||||
powerManagement.cpuFreqGovernor = "ondemand";
|
||||
}
|
||||
76
modules/k3s-cluster.nix
Normal file
76
modules/k3s-cluster.nix
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.services.k3s-cluster;
|
||||
in {
|
||||
options.services.k3s-cluster = {
|
||||
enable = mkEnableOption "k3s cluster node";
|
||||
role = mkOption {
|
||||
type = types.enum [ "server" "agent" ];
|
||||
default = "agent";
|
||||
description = "k3s role: server (control plane) or agent (worker).";
|
||||
};
|
||||
serverAddr = mkOption {
|
||||
type = types.str;
|
||||
default = "https://127.0.0.1:6443";
|
||||
description = "k3s server URL for agents to join.";
|
||||
};
|
||||
tailscaleIP = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "This node's Tailscale IP (for --advertise-address).";
|
||||
};
|
||||
tokenFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = "/var/lib/k3s/cluster-token";
|
||||
description = "File containing the cluster shared secret.";
|
||||
};
|
||||
withOllama = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable Ollama on this node (GPU passthrough).";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
services.k3s = {
|
||||
enable = true;
|
||||
role = cfg.role;
|
||||
|
||||
extraFlags = with cfg; concatStringsSep " " (
|
||||
[ "--flannel-iface=tailscale0" ]
|
||||
++ optionals (tailscaleIP != null) [
|
||||
"--advertise-address=${tailscaleIP}"
|
||||
"--node-ip=${tailscaleIP}"
|
||||
]
|
||||
++ optionals (role == "server") [
|
||||
"--cluster-init"
|
||||
"--disable=traefik"
|
||||
"--disable=servicelb"
|
||||
"--kube-controller-manager-arg=bind-address=0.0.0.0"
|
||||
]
|
||||
);
|
||||
|
||||
inherit tokenFile;
|
||||
|
||||
# Allow pods on control plane (we need the resources)
|
||||
inherit (cfg) serverAddr;
|
||||
};
|
||||
|
||||
# Open k3s API port on control plane
|
||||
networking.firewall.allowedTCPPorts = optionals (cfg.role == "server") [ 6443 ];
|
||||
|
||||
# ── Ollama (optional, on worker nodes with GPU/NPU) ──
|
||||
services.ollama = mkIf cfg.withOllama {
|
||||
enable = true;
|
||||
acceleration = "opencl"; # Intel N97 iGPU
|
||||
environmentVariables = {
|
||||
OLLAMA_KEEP_ALIVE = "24h";
|
||||
OLLAMA_HOST = "0.0.0.0:11434";
|
||||
};
|
||||
openFirewall = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
94
modules/meos-gateway.nix
Normal file
94
modules/meos-gateway.nix
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.services.meos-gateway;
|
||||
certDir = config.security.acme.certs."os.scoble.me".directory;
|
||||
in {
|
||||
options.services.meos-gateway = {
|
||||
enable = mkEnableOption "MeOS Gateway — nginx reverse proxy for os.scoble.me";
|
||||
tailscaleIP = mkOption {
|
||||
type = types.str;
|
||||
description = "n97-1 Tailscale IP for nginx to bind to.";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
# ── Nginx — SSL-terminating reverse proxy ──
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."os.scoble.me" = {
|
||||
addSSL = true;
|
||||
sslCertificate = "${certDir}/fullchain.pem";
|
||||
sslCertificateKey = "${certDir}/key.pem";
|
||||
listen = [
|
||||
{ addr = cfg.tailscaleIP; port = 443; ssl = true; }
|
||||
];
|
||||
|
||||
locations = {
|
||||
"/forgejo/" = {
|
||||
proxyPass = "http://k3s-forgejo.kube-system.svc.cluster.local:3000/";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /forgejo;
|
||||
'';
|
||||
};
|
||||
"/umami" = {
|
||||
proxyPass = "http://k3s-umami.kube-system.svc.cluster.local:8069";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
'';
|
||||
};
|
||||
"/umami/" = {
|
||||
proxyPass = "http://k3s-umami.kube-system.svc.cluster.local:8069";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = ''
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
'';
|
||||
};
|
||||
"/dashboard/" = {
|
||||
proxyPass = "http://k3s-dashboard.kube-system.svc.cluster.local:3000/";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
"/" = {
|
||||
proxyPass = "http://127.0.0.1:8082";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# HTTP -> HTTPS redirect
|
||||
virtualHosts."os.scoble.me-http" = {
|
||||
listen = [ { addr = cfg.tailscaleIP; port = 80; } ];
|
||||
serverName = "os.scoble.me";
|
||||
extraConfig = "return 301 https://$server_name$request_uri;";
|
||||
};
|
||||
};
|
||||
|
||||
# ACME with Cloudflare DNS challenge
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = "scott@scoble.me";
|
||||
certs."os.scoble.me" = {
|
||||
dnsProvider = "cloudflare";
|
||||
environmentFile = "/var/lib/acme/cloudflare.env";
|
||||
group = "nginx";
|
||||
};
|
||||
};
|
||||
|
||||
# Firewall
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
};
|
||||
}
|
||||
34
modules/tailscale.nix
Normal file
34
modules/tailscale.nix
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.services.tailscale-node;
|
||||
in {
|
||||
options.services.tailscale-node = {
|
||||
enable = mkEnableOption "Tailscale mesh node";
|
||||
authKeyFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Path to file containing Tailscale pre-auth key (for unattended setup).";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
interfaceName = "tailscale0";
|
||||
authKeyFile = cfg.authKeyFile;
|
||||
extraUpFlags = "--accept-routes --accept-dns=false";
|
||||
};
|
||||
|
||||
# Persist Tailscale state across reboots
|
||||
systemd.services.tailscale = {
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig.SupplementaryGroups = [ "nginx" ];
|
||||
};
|
||||
|
||||
networking.firewall.trustedInterfaces = [ "tailscale0" ];
|
||||
};
|
||||
}
|
||||
68
nodes/n97-1.nix
Normal file
68
nodes/n97-1.nix
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
# n97-1 Tailscale IP — update after initial Tailscale auth
|
||||
tailscaleIP = "100.x.x.x";
|
||||
in {
|
||||
imports = [
|
||||
../modules/meos-gateway.nix
|
||||
];
|
||||
|
||||
networking.hostName = "n97-1";
|
||||
|
||||
# ── k3s server (single-node control plane) ──
|
||||
services.k3s-cluster = {
|
||||
enable = true;
|
||||
role = "server";
|
||||
tailscaleIP = tailscaleIP;
|
||||
tokenFile = "/var/lib/k3s/cluster-token";
|
||||
};
|
||||
|
||||
# ── Gateway (nginx + ACME) ──
|
||||
services.meos-gateway = {
|
||||
enable = true;
|
||||
tailscaleIP = tailscaleIP;
|
||||
};
|
||||
|
||||
# ── PostgreSQL (for Forgejo + Umami, transition to K8s) ──
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
package = pkgs.postgresql_17;
|
||||
extraPlugins = [ pkgs.postgresql_17.pkgs.pgvector ];
|
||||
dataDir = "/data/postgresql";
|
||||
};
|
||||
|
||||
# ── Forgejo (bare metal during transition, then K8s) ──
|
||||
services.forgejo = {
|
||||
enable = true;
|
||||
database.type = "postgres";
|
||||
database.name = "forgejo";
|
||||
database.user = "forgejo";
|
||||
settings.server.ROOT_URL = "https://os.scoble.me/forgejo/";
|
||||
lfs.enable = true;
|
||||
};
|
||||
|
||||
# ── OpenClaw gateway stays bare metal ──
|
||||
# (managed separately via systemd user service)
|
||||
|
||||
# ── Firewall ──
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
allowedTCPPorts = [ 80 443 6443 ];
|
||||
};
|
||||
|
||||
# ── User ──
|
||||
users.users.sscoble = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" "docker" ];
|
||||
};
|
||||
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "sscoble" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
nix.settings.trusted-users = [ "sscoble" ];
|
||||
}
|
||||
42
nodes/n97-2.nix
Normal file
42
nodes/n97-2.nix
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
tailscaleIP = "100.x.x.x";
|
||||
in {
|
||||
networking.hostName = "n97-2";
|
||||
|
||||
services.k3s-cluster = {
|
||||
enable = true;
|
||||
role = "agent";
|
||||
serverAddr = "https://100.x.x.x:6443"; # n97-1 IP
|
||||
tailscaleIP = tailscaleIP;
|
||||
tokenFile = "/var/lib/k3s/cluster-token";
|
||||
withOllama = true;
|
||||
};
|
||||
|
||||
# Ollama models to serve
|
||||
services.ollama.models = [
|
||||
"deepseek-v4-pro:cloud" # heavy reasoning
|
||||
"kimi-k2.6:cloud" # backup reasoning
|
||||
"glm-5.1:cloud" # fallback
|
||||
];
|
||||
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
allowedTCPPorts = [ 11434 ];
|
||||
};
|
||||
|
||||
users.users.sscoble = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "sscoble" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
nix.settings.trusted-users = [ "sscoble" ];
|
||||
}
|
||||
42
nodes/n97-3.nix
Normal file
42
nodes/n97-3.nix
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
tailscaleIP = "100.x.x.x";
|
||||
in {
|
||||
networking.hostName = "n97-3";
|
||||
|
||||
services.k3s-cluster = {
|
||||
enable = true;
|
||||
role = "agent";
|
||||
serverAddr = "https://100.x.x.x:6443"; # n97-1 IP
|
||||
tailscaleIP = tailscaleIP;
|
||||
tokenFile = "/var/lib/k3s/cluster-token";
|
||||
withOllama = true;
|
||||
};
|
||||
|
||||
services.ollama.models = [
|
||||
"qwen3-coder-next:cloud" # targeted code
|
||||
"deepseek-v4-flash:cloud" # fast default
|
||||
"nomic-embed-text" # embeddings
|
||||
"mxbai-embed-large" # embeddings
|
||||
];
|
||||
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
allowedTCPPorts = [ 11434 ];
|
||||
};
|
||||
|
||||
users.users.sscoble = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "sscoble" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
nix.settings.trusted-users = [ "sscoble" ];
|
||||
}
|
||||
36
nodes/rpi4-1.nix
Normal file
36
nodes/rpi4-1.nix
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
tailscaleIP = "100.x.x.x";
|
||||
in {
|
||||
networking.hostName = "rpi4-1";
|
||||
|
||||
services.k3s-cluster = {
|
||||
enable = true;
|
||||
role = "agent";
|
||||
serverAddr = "https://100.x.x.x:6443"; # n97-1 IP
|
||||
tailscaleIP = tailscaleIP;
|
||||
tokenFile = "/var/lib/k3s/cluster-token";
|
||||
};
|
||||
|
||||
# ARM worker — light services only
|
||||
# Taints/tolerations will be set via kubectl to prevent x86 pods landing here
|
||||
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
};
|
||||
|
||||
users.users.sscoble = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "sscoble" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
nix.settings.trusted-users = [ "sscoble" ];
|
||||
}
|
||||
36
nodes/rpi4-2.nix
Normal file
36
nodes/rpi4-2.nix
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
tailscaleIP = "100.x.x.x";
|
||||
in {
|
||||
networking.hostName = "rpi4-2";
|
||||
|
||||
services.k3s-cluster = {
|
||||
enable = true;
|
||||
role = "agent";
|
||||
serverAddr = "https://100.x.x.x:6443"; # n97-1 IP
|
||||
tailscaleIP = tailscaleIP;
|
||||
tokenFile = "/var/lib/k3s/cluster-token";
|
||||
};
|
||||
|
||||
# ARM worker — Umami and monitoring
|
||||
# Taints/tolerations set via kubectl
|
||||
|
||||
networking.firewall = {
|
||||
enable = true;
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
};
|
||||
|
||||
users.users.sscoble = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
security.sudo.extraRules = [{
|
||||
users = [ "sscoble" ];
|
||||
commands = [{ command = "ALL"; options = [ "NOPASSWD" ]; }];
|
||||
}];
|
||||
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
nix.settings.trusted-users = [ "sscoble" ];
|
||||
}
|
||||
58
scripts/bootstrap-node.sh
Executable file
58
scripts/bootstrap-node.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
# Bootstrap a new node for the Scobleclaw k3s cluster
|
||||
#
|
||||
# Usage: ./bootstrap-node.sh <hostname> <tailscale-ip> <role: server|agent>
|
||||
#
|
||||
# Prerequisites:
|
||||
# - NixOS installed on target node
|
||||
# - SSH access to root@<tailscale-ip>
|
||||
# - This repo cloned locally
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HOSTNAME="${1:?Usage: bootstrap-node.sh <hostname> <tailscale-ip> <role>}"
|
||||
TSIP="${2:?Need tailscale IP}"
|
||||
ROLE="${3:-agent}"
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "==> Bootstrapping $HOSTNAME ($TSIP) as k3s $ROLE"
|
||||
|
||||
# 1. Generate cluster token if it doesn't exist
|
||||
TOKEN_FILE="/var/lib/k3s/cluster-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "==> Generating k3s cluster token"
|
||||
sudo mkdir -p /var/lib/k3s
|
||||
head -c 32 /dev/urandom | base64 | sudo tee "$TOKEN_FILE" > /dev/null
|
||||
sudo chmod 600 "$TOKEN_FILE"
|
||||
fi
|
||||
|
||||
# 2. Copy token to target node
|
||||
echo "==> Copying cluster token to $HOSTNAME"
|
||||
ssh "root@$TSIP" "mkdir -p /var/lib/k3s"
|
||||
scp "$TOKEN_FILE" "root@$TSIP:$TOKEN_FILE"
|
||||
|
||||
# 3. Copy NixOS config
|
||||
echo "==> Copying NixOS configuration"
|
||||
ssh "root@$TSIP" "mkdir -p /etc/nixos/nodes /etc/nixos/modules"
|
||||
scp "$REPO_DIR/flake.nix" "root@$TSIP:/etc/nixos/flake.nix"
|
||||
scp "$REPO_DIR/hardware-n97.nix" "root@$TSIP:/etc/nixos/hardware-n97.nix" 2>/dev/null || true
|
||||
scp "$REPO_DIR/hardware-rpi4.nix" "root@$TSIP:/etc/nixos/hardware-rpi4.nix" 2>/dev/null || true
|
||||
scp "$REPO_DIR/modules/"*.nix "root@$TSIP:/etc/nixos/modules/"
|
||||
scp "$REPO_DIR/nodes/$HOSTNAME.nix" "root@$TSIP:/etc/nixos/nodes/"
|
||||
|
||||
# 4. Build and switch
|
||||
echo "==> Building NixOS configuration for $HOSTNAME"
|
||||
ssh "root@$TSIP" "nixos-rebuild switch --flake /etc/nixos#$HOSTNAME"
|
||||
|
||||
# 5. Wait for k3s to come up
|
||||
echo "==> Waiting for k3s to start..."
|
||||
sleep 10
|
||||
|
||||
if [ "$ROLE" = "server" ]; then
|
||||
echo "==> k3s server is running. Get kubeconfig:"
|
||||
echo " scp root@$TSIP:/etc/rancher/k3s/k3s.yaml ~/.kube/config-scobleclaw"
|
||||
echo " sed -i 's/127.0.0.1/$TSIP/g' ~/.kube/config-scobleclaw"
|
||||
else
|
||||
echo "==> k3s agent joined. Verify with: kubectl get nodes"
|
||||
fi
|
||||
|
||||
echo "==> Done! $HOSTNAME is ready."
|
||||
49
scripts/migrate-db.sh
Executable file
49
scripts/migrate-db.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
# Migrate PostgreSQL from bare metal (n97-1) to CloudNative PG (k3s)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - CNPG operator installed in k3s
|
||||
# - postgres-scobleclaw cluster created (apps/postgres/cluster.yaml)
|
||||
# - Both clusters accessible from n97-1
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_HOST="127.0.0.1"
|
||||
SOURCE_PORT="5432"
|
||||
CNPG_HOST="postgres-scobleclaw-rw.database.svc.cluster.local"
|
||||
CNPG_PORT="5432"
|
||||
|
||||
echo "==> Starting PostgreSQL migration"
|
||||
echo " Source: $SOURCE_HOST:$SOURCE_PORT (bare metal)"
|
||||
echo " Target: $CNPG_HOST:$CNPG_PORT (k3s/CNPG)"
|
||||
|
||||
# 1. Dump all databases
|
||||
echo "==> Dumping databases from bare metal..."
|
||||
sudo -u postgres pg_dumpall --clean --if-exists > /tmp/pg-migration-dump.sql
|
||||
|
||||
# 2. Wait for CNPG cluster to be ready
|
||||
echo "==> Waiting for CNPG cluster to be ready..."
|
||||
kubectl -n database wait --for=condition=Ready cluster/postgres-scobleclaw --timeout=300s
|
||||
|
||||
# 3. Get CNPG superuser password
|
||||
CNPG_PASSWORD=$(kubectl -n database get secret postgres-scobleclaw-superuser -o jsonpath='{.data.password}' | base64 -d)
|
||||
|
||||
# 4. Restore dump into CNPG
|
||||
echo "==> Restoring dump into CNPG..."
|
||||
PGPASSWORD="$CNPG_PASSWORD" psql -h "$CNPG_HOST" -p "$CNPG_PORT" -U postgres < /tmp/pg-migration-dump.sql
|
||||
|
||||
# 5. Verify
|
||||
echo "==> Verifying migration..."
|
||||
DB_COUNT=$(PGPASSWORD="$CNPG_PASSWORD" psql -h "$CNPG_HOST" -p "$CNPG_PORT" -U postgres -t -c "SELECT count(*) FROM pg_database WHERE datname IN ('forgejo','umami');" | tr -d ' ')
|
||||
echo " Databases found: $DB_COUNT (expected: 2)"
|
||||
|
||||
if [ "$DB_COUNT" = "2" ]; then
|
||||
echo "==> Migration successful! You can now disable bare-metal PostgreSQL:"
|
||||
echo " Set services.postgresql.enable = false; in nodes/n97-1.nix"
|
||||
echo " And rebuild: nixos-rebuild switch --flake .#n97-1"
|
||||
else
|
||||
echo "==> WARNING: Not all databases migrated. Check manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/pg-migration-dump.sql
|
||||
Loading…
Add table
Add a link
Reference in a new issue