init rig
This commit is contained in:
50
rig/ctrl/.env.example
Normal file
50
rig/ctrl/.env.example
Normal file
@@ -0,0 +1,50 @@
|
||||
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
|
||||
# Cluster SHAPE lives in ctrl/env.d/<profile>.env — not here.
|
||||
# The architecture MODEL lives in arch/<name>.json — not here either.
|
||||
|
||||
# Which profile in ctrl/env.d/ to build. minimal | client | offline
|
||||
PROFILE=minimal
|
||||
|
||||
# Cluster name; the kubectl context becomes kind-<CLUSTER>.
|
||||
# LEAVE THIS UNSET unless you need a name that differs from the directory —
|
||||
# it defaults to this folder's name, which is what makes the folder copyable:
|
||||
# copy it, rename it, and you get a separate environment with no edits.
|
||||
# CLUSTER=
|
||||
|
||||
# Host ports. LEAVE UNSET — they derive from the directory name so several
|
||||
# environments coexist without negotiating (see ctrl/ports.sh). `make ports`
|
||||
# shows this environment's block; `make ports persist` writes it here so it stops
|
||||
# being derived and becomes fixed. Set a value only to override.
|
||||
# HTTP_PORT=
|
||||
# HTTPS_PORT=
|
||||
# TILT_PORT=
|
||||
# REGISTRY_PORT=
|
||||
|
||||
# Where the application manifests live. The real ones are expected to be
|
||||
# versioned separately from this installer — they change on a different cadence,
|
||||
# by different people. Repoint this at their repo and rig stops owning them:
|
||||
# MANIFESTS_DIR=../platform-manifests/overlays/dev
|
||||
MANIFESTS_DIR=ctrl/k8s/overlays/dev
|
||||
|
||||
# Where the wizard fetches the pinned binaries from.
|
||||
# upstream GitHub releases / dl.k8s.io (needs internet)
|
||||
# artifactory a generic repo — what a locked-down client usually allows
|
||||
# baked already inside the wizard image; no network at all
|
||||
DEPS_SOURCE=upstream
|
||||
DEPS_ARTIFACTORY_URL=
|
||||
|
||||
# --- Registry -------------------------------------------------------------
|
||||
# Mode comes from the profile (REGISTRY_MODE). These are the secrets it needs.
|
||||
# Required for mirror/remote:
|
||||
REGISTRY_REMOTE_URL=
|
||||
REGISTRY_USER=
|
||||
REGISTRY_PASSWORD=
|
||||
|
||||
# Corporate root CA, if Artifactory is fronted by an internal CA (it usually is).
|
||||
# Trust has to reach THREE places and nothing does it for you: the host docker
|
||||
# daemon, every kind node's containerd, and any in-cluster client. registry.sh
|
||||
# handles the first two; station.sh reports when it's configured but not trusted.
|
||||
# Symptom when missing: x509: certificate signed by unknown authority
|
||||
REGISTRY_CA_FILE=
|
||||
|
||||
# (The local registry's host port is part of the derived block above.)
|
||||
46
rig/ctrl/Dockerfile.wizard
Normal file
46
rig/ctrl/Dockerfile.wizard
Normal file
@@ -0,0 +1,46 @@
|
||||
# The installation wizard. It does NOT run the cluster — it installs a toolchain
|
||||
# onto the host and gets out of the way.
|
||||
#
|
||||
# This exists to kill a bootstrap paradox: a plain bash installer needs curl, jq
|
||||
# and sha256sum to already be present, and a minimal Debian has none of them.
|
||||
# The wizard carries its own toolchain, so the only host prerequisite is Docker.
|
||||
#
|
||||
# Two variants from one file:
|
||||
# docker build -f ctrl/Dockerfile.wizard --target wizard -t <slug>-wizard .
|
||||
# docker build -f ctrl/Dockerfile.wizard --target wizard-full -t <slug>-wizard:full .
|
||||
#
|
||||
# wizard-full bakes every pinned binary in at build time. `docker save` it and
|
||||
# you have the whole installer as one file to carry into an air-gapped network.
|
||||
|
||||
FROM debian:trixie-slim AS wizard
|
||||
|
||||
# ca-certificates + curl: fetch and verify. graphviz + python3: render diagrams
|
||||
# and validate the arch model, so the host never needs an apt package.
|
||||
#
|
||||
# docker-cli, NOT docker.io: we only ever talk to the host's daemon through the
|
||||
# mounted socket, and under --no-install-recommends the docker.io package ships
|
||||
# docker-init without the actual `docker` binary.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl jq graphviz python3 docker-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /work
|
||||
COPY ctrl/versions.env /work/ctrl/versions.env
|
||||
COPY ctrl/wizard.sh /work/ctrl/wizard.sh
|
||||
RUN chmod +x /work/ctrl/wizard.sh
|
||||
|
||||
# Defaults; every one is overridable with -e at run time.
|
||||
ENV DEPS_SOURCE=upstream \
|
||||
OUT_BIN=/out/bin \
|
||||
HOST_ROOT=/host
|
||||
|
||||
ENTRYPOINT ["/work/ctrl/wizard.sh"]
|
||||
CMD ["install"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wizard-full — same wizard, binaries baked in, works with no network at all.
|
||||
FROM wizard AS wizard-full
|
||||
RUN /work/ctrl/wizard.sh fetch --to /opt/rig/bin
|
||||
ENV DEPS_SOURCE=baked \
|
||||
BAKED_BIN=/opt/rig/bin
|
||||
39
rig/ctrl/addons.sh
Executable file
39
rig/ctrl/addons.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the addons the active profile asked for, in the order listed.
|
||||
# Each addon is its own idempotent script in ctrl/addons/ — adding one is adding
|
||||
# a file, not editing a dispatcher.
|
||||
#
|
||||
# Usage: addons.sh install | list
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
install() {
|
||||
if [ -z "${ADDONS// /}" ]; then
|
||||
echo "no addons in profile '$PROFILE_NAME'"
|
||||
return
|
||||
fi
|
||||
local a
|
||||
for a in $ADDONS; do
|
||||
if [ ! -f "addons/${a}.sh" ]; then
|
||||
echo "no such addon: addons/${a}.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "addon: $a"
|
||||
bash "addons/${a}.sh"
|
||||
done
|
||||
}
|
||||
|
||||
list() {
|
||||
echo "profile '$PROFILE_NAME' wants: ${ADDONS:-none}"
|
||||
echo "available:"
|
||||
ls addons/*.sh 2>/dev/null | xargs -n1 basename | sed 's/\.sh$//' | sed 's/^/ /'
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
install) install ;;
|
||||
list) list ;;
|
||||
*) echo "usage: $0 [install|list]" >&2; exit 1 ;;
|
||||
esac
|
||||
115
rig/ctrl/addons/airflow.sh
Executable file
115
rig/ctrl/addons/airflow.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Apache Airflow — the cluster half of soleprint's airflow cabinet.
|
||||
#
|
||||
# Airflow needs a metadata database before it will start at all, so this refuses
|
||||
# rather than rolls a pod that will CrashLoopBackOff while the real problem
|
||||
# (postgres missing from ADDONS) stays invisible in the logs.
|
||||
#
|
||||
# One pod on `standalone`, matching the compose cabinet: migration, admin user,
|
||||
# scheduler and webserver in a single container. The official chart's five
|
||||
# deployments model an installation; a room switching this on wants pipelines.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
NS="${DATA_NAMESPACE:-data}"
|
||||
|
||||
if ! $K get deployment -n "$NS" postgres >/dev/null 2>&1; then
|
||||
echo " ! airflow needs the postgres addon, and it is not installed" >&2
|
||||
echo " add it before airflow in the profile's ADDONS:" >&2
|
||||
echo " ADDONS=\"... postgres airflow\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reuse the credential postgres generated rather than storing a second copy.
|
||||
db_user=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_USER}' | base64 -d)
|
||||
db_pass=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)
|
||||
db_name=$($K get secret -n "$NS" postgres -o jsonpath='{.data.POSTGRES_DB}' | base64 -d)
|
||||
|
||||
if $K get secret -n "$NS" airflow >/dev/null 2>&1; then
|
||||
echo " secret exists, keeping the current admin password and fernet key"
|
||||
else
|
||||
admin_password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
# Airflow requires a 32-byte urlsafe-base64 key; without a fixed one every
|
||||
# restart invalidates every stored connection.
|
||||
fernet_key=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_')
|
||||
$K create secret generic airflow -n "$NS" \
|
||||
--from-literal=ADMIN_USER="${AIRFLOW_ADMIN_USER:-admin}" \
|
||||
--from-literal=ADMIN_PASSWORD="$admin_password" \
|
||||
--from-literal=FERNET_KEY="$fernet_key" \
|
||||
--from-literal=SQL_ALCHEMY_CONN="postgresql+psycopg2://${db_user}:${db_pass}@postgres:5432/${db_name}" \
|
||||
>/dev/null
|
||||
echo " generated an admin password (read it back with the command below)"
|
||||
fi
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
selector:
|
||||
app: airflow
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: airflow
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: airflow
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: airflow
|
||||
spec:
|
||||
containers:
|
||||
- name: airflow
|
||||
image: ${AIRFLOW_IMAGE}
|
||||
args: ["standalone"]
|
||||
env:
|
||||
- name: AIRFLOW__CORE__EXECUTOR
|
||||
value: LocalExecutor
|
||||
- name: AIRFLOW__CORE__LOAD_EXAMPLES
|
||||
value: "false"
|
||||
- name: AIRFLOW__DATABASE__SQL_ALCHEMY_CONN
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: SQL_ALCHEMY_CONN}
|
||||
- name: AIRFLOW__CORE__FERNET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: FERNET_KEY}
|
||||
- name: _AIRFLOW_WWW_USER_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_USER}
|
||||
- name: _AIRFLOW_WWW_USER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: {name: airflow, key: ADMIN_PASSWORD}
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
# First boot runs the whole migration before it serves anything.
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 15
|
||||
failureThreshold: 20
|
||||
YAML
|
||||
|
||||
echo " waiting for airflow (the first boot migrates the database, so this is slow)..."
|
||||
$K rollout status deployment/airflow -n "$NS" --timeout=600s
|
||||
|
||||
echo " in-cluster: http://airflow.${NS}.svc.cluster.local:8080"
|
||||
echo " reach it: kubectl --context ${KUBECONTEXT} -n ${NS} port-forward svc/airflow 8080:8080"
|
||||
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret airflow -o jsonpath='{.data.ADMIN_PASSWORD}' | base64 -d"
|
||||
65
rig/ctrl/addons/cert-manager.sh
Executable file
65
rig/ctrl/addons/cert-manager.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# cert-manager plus a self-signed cluster issuer.
|
||||
#
|
||||
# In a regulated estate almost everything is TLS, so the interesting question
|
||||
# during onboarding is "does this service present a cert my client trusts" — not
|
||||
# "can I reach a public ACME server". A local CA answers that offline, which is
|
||||
# also what makes the air-gapped profile usable.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
|
||||
if $K get deployment -n cert-manager cert-manager >/dev/null 2>&1; then
|
||||
echo " already installed"
|
||||
else
|
||||
$K apply -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
|
||||
fi
|
||||
|
||||
echo " waiting for cert-manager..."
|
||||
$K wait --namespace cert-manager \
|
||||
--for=condition=ready pod --selector=app.kubernetes.io/instance=cert-manager \
|
||||
--timeout=240s
|
||||
|
||||
# A self-signed root, then a CA issuer chained off it. Workloads reference
|
||||
# ClusterIssuer/local-ca and get a cert from a CA you can actually distribute.
|
||||
echo " creating local CA issuer"
|
||||
$K apply -f - <<'YAML' >/dev/null
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: selfsigned-root
|
||||
spec:
|
||||
selfSigned: {}
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: local-ca
|
||||
namespace: cert-manager
|
||||
spec:
|
||||
isCA: true
|
||||
commonName: rig-local-ca
|
||||
secretName: local-ca-key-pair
|
||||
duration: 87600h
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
issuerRef:
|
||||
name: selfsigned-root
|
||||
kind: ClusterIssuer
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: local-ca
|
||||
spec:
|
||||
ca:
|
||||
secretName: local-ca-key-pair
|
||||
YAML
|
||||
|
||||
echo " export the CA for your browser/client with:"
|
||||
echo " kubectl --context ${KUBECONTEXT} -n cert-manager get secret local-ca-key-pair -o jsonpath='{.data.tls\\.crt}' | base64 -d"
|
||||
103
rig/ctrl/addons/metallb.sh
Executable file
103
rig/ctrl/addons/metallb.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# MetalLB — makes `Service type: LoadBalancer` actually get an address.
|
||||
#
|
||||
# Why it matters here: real manifests use LoadBalancer, because a real cluster
|
||||
# has one. On a bare kind cluster those Services sit at EXTERNAL-IP <pending>
|
||||
# forever with no error anywhere — the deployment looks fine and simply is not
|
||||
# reachable. Without this, every such Service has to be edited to NodePort,
|
||||
# which means the local manifests stop matching the ones being modelled.
|
||||
#
|
||||
# The address pool is derived from the kind Docker network at install time, not
|
||||
# hardcoded: Docker picks that subnet, it differs between machines, and a pool
|
||||
# outside it is silently unroutable.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
|
||||
# ── work out an address range ──────────────────────────────────────────────
|
||||
# kind hands node addresses out from the bottom of the subnet, so the top is
|
||||
# free. Taking a slice there avoids collisions with current and future nodes.
|
||||
subnet=$(docker network inspect kind \
|
||||
-f '{{range .IPAM.Config}}{{.Subnet}} {{end}}' 2>/dev/null \
|
||||
| tr ' ' '\n' | grep -E '^[0-9]+\.' | head -1)
|
||||
|
||||
if [ -z "$subnet" ]; then
|
||||
echo " ! could not read the kind Docker network subnet" >&2
|
||||
echo " (is the cluster up? MetalLB needs the network to exist first)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base="${subnet%/*}"; prefix="${subnet#*/}"
|
||||
o1=$(echo "$base" | cut -d. -f1); o2=$(echo "$base" | cut -d. -f2)
|
||||
o3=$(echo "$base" | cut -d. -f3)
|
||||
|
||||
case "$prefix" in
|
||||
16) pool_start="${o1}.${o2}.255.200"; pool_end="${o1}.${o2}.255.250" ;;
|
||||
24) pool_start="${o1}.${o2}.${o3}.200"; pool_end="${o1}.${o2}.${o3}.250" ;;
|
||||
*)
|
||||
# Guessing a range inside an unexpected prefix risks handing out
|
||||
# addresses that belong to something else. Say so instead.
|
||||
echo " ! kind network is $subnet — only /16 and /24 are handled" >&2
|
||||
echo " set the pool by hand in ctrl/addons/metallb.sh" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo " kind network $subnet → pool ${pool_start}-${pool_end}"
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────
|
||||
|
||||
if $K get deployment -n metallb-system controller >/dev/null 2>&1; then
|
||||
echo " already installed"
|
||||
else
|
||||
$K apply -f "https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml"
|
||||
fi
|
||||
|
||||
# `kubectl wait` on a selector errors out immediately when nothing matches yet,
|
||||
# and right after apply the ReplicaSet has not created the pod — so it loses a
|
||||
# race it looks like it should win. `rollout status` waits for the Deployment
|
||||
# itself and handles the not-yet-created case.
|
||||
echo " waiting for the controller..."
|
||||
$K rollout status deployment/controller -n metallb-system --timeout=240s
|
||||
$K rollout status daemonset/speaker -n metallb-system --timeout=240s
|
||||
|
||||
# The webhook rejects IPAddressPools until it is actually serving, and it comes
|
||||
# up a moment after the pod is Ready — so retry rather than fail the whole run
|
||||
# on a race that resolves itself in seconds.
|
||||
echo " configuring the address pool"
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if $K apply -f - >/dev/null 2>&1 <<YAML
|
||||
apiVersion: metallb.io/v1beta1
|
||||
kind: IPAddressPool
|
||||
metadata:
|
||||
name: default
|
||||
namespace: metallb-system
|
||||
spec:
|
||||
addresses:
|
||||
- ${pool_start}-${pool_end}
|
||||
---
|
||||
# Layer 2 mode: one node answers ARP for each address. No BGP peer needed, which
|
||||
# is what makes this work on a laptop.
|
||||
apiVersion: metallb.io/v1beta1
|
||||
kind: L2Advertisement
|
||||
metadata:
|
||||
name: default
|
||||
namespace: metallb-system
|
||||
spec:
|
||||
ipAddressPools:
|
||||
- default
|
||||
YAML
|
||||
then
|
||||
echo " pool ready: ${pool_start}-${pool_end}"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo " ! the pool was rejected after 10 attempts — is the webhook up?" >&2
|
||||
$K get pods -n metallb-system >&2
|
||||
exit 1
|
||||
25
rig/ctrl/addons/metrics-server.sh
Executable file
25
rig/ctrl/addons/metrics-server.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# metrics-server — makes `kubectl top` work.
|
||||
#
|
||||
# kind nodes serve kubelet metrics over a self-signed cert, so the standard
|
||||
# manifest never becomes ready without --kubelet-insecure-tls. That is fine here
|
||||
# (it is a local cluster) and is the single most common reason metrics-server
|
||||
# sits at 0/1 on kind.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
|
||||
if ! $K get deployment -n kube-system metrics-server >/dev/null 2>&1; then
|
||||
$K apply -f "https://github.com/kubernetes-sigs/metrics-server/releases/download/${METRICS_SERVER_VERSION}/components.yaml"
|
||||
fi
|
||||
|
||||
$K patch deployment metrics-server -n kube-system --type=json \
|
||||
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
echo " waiting for metrics-server..."
|
||||
$K rollout status deployment/metrics-server -n kube-system --timeout=180s
|
||||
118
rig/ctrl/addons/postgres.sh
Executable file
118
rig/ctrl/addons/postgres.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostgreSQL — the cluster half of soleprint's postgres cabinet.
|
||||
#
|
||||
# A room declares the dependency once, in cfg/<room>/data/cabinets.json. On a
|
||||
# laptop `build.py` composes it into docker-compose.yml; here it becomes a pod,
|
||||
# so the same declaration works either way and nothing has to be remembered
|
||||
# twice.
|
||||
#
|
||||
# Plain manifests rather than a helm chart, matching the other addons: a chart
|
||||
# repo is a network dependency, and the offline profile exists precisely so
|
||||
# there is a path with none. The image is pinned in ctrl/versions.env and can be
|
||||
# preloaded into a local registry like every other image here.
|
||||
#
|
||||
# One replica on a PVC. This models a dependency for local work, not a
|
||||
# highly-available database, and pretending otherwise on a kind node would be a
|
||||
# more elaborate lie rather than a more useful one.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
NS="${DATA_NAMESPACE:-data}"
|
||||
|
||||
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
|
||||
|
||||
# The password is generated once and then left alone, so re-running this does
|
||||
# not rotate the credential out from under whatever is already connected.
|
||||
if $K get secret -n "$NS" postgres >/dev/null 2>&1; then
|
||||
echo " secret exists, keeping the current password"
|
||||
else
|
||||
password=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24)
|
||||
$K create secret generic postgres -n "$NS" \
|
||||
--from-literal=POSTGRES_DB="${POSTGRES_DB:-soleprint}" \
|
||||
--from-literal=POSTGRES_USER="${POSTGRES_USER:-soleprint}" \
|
||||
--from-literal=POSTGRES_PASSWORD="$password" >/dev/null
|
||||
echo " generated a password (read it back with the command printed below)"
|
||||
fi
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
resources:
|
||||
requests:
|
||||
storage: ${POSTGRES_STORAGE:-2Gi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
# One volume, one writer. Rolling would start a second pod against the same
|
||||
# PVC before the first exits, and Postgres refuses to share a data directory.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: ${POSTGRES_IMAGE}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: postgres
|
||||
env:
|
||||
# The image initialises into the volume root otherwise, and a
|
||||
# lost+found from the PVC makes it refuse to initdb.
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "pg_isready -U \$POSTGRES_USER -d \$POSTGRES_DB"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-data
|
||||
YAML
|
||||
|
||||
echo " waiting for postgres..."
|
||||
$K rollout status deployment/postgres -n "$NS" --timeout=240s
|
||||
|
||||
echo " in-cluster: postgres.${NS}.svc.cluster.local:5432"
|
||||
echo " password: kubectl --context ${KUBECONTEXT} -n ${NS} get secret postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d"
|
||||
60
rig/ctrl/addons/redis.sh
Executable file
60
rig/ctrl/addons/redis.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Redis — the cluster half of soleprint's redis cabinet.
|
||||
#
|
||||
# Cache, and the broker anything queue-shaped runs on. No persistence: a broker
|
||||
# that loses its queue on restart is the honest local model, and a PVC here buys
|
||||
# nothing but a volume to clean up.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
NS="${DATA_NAMESPACE:-data}"
|
||||
|
||||
$K get namespace "$NS" >/dev/null 2>&1 || $K create namespace "$NS"
|
||||
|
||||
echo " applying manifests"
|
||||
$K apply -n "$NS" -f - >/dev/null <<YAML
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: ${REDIS_IMAGE}
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
YAML
|
||||
|
||||
echo " waiting for redis..."
|
||||
$K rollout status deployment/redis -n "$NS" --timeout=180s
|
||||
|
||||
echo " in-cluster: redis://redis.${NS}.svc.cluster.local:6379/0"
|
||||
149
rig/ctrl/cluster.sh
Executable file
149
rig/ctrl/cluster.sh
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cluster lifecycle, plus what else is running on this machine.
|
||||
#
|
||||
# `list` and `free` live here rather than in a separate script because a
|
||||
# near-identical second name (cluster / clusters) is a trap — you reach for one
|
||||
# and get the other. One target, one file, unambiguous subcommands.
|
||||
#
|
||||
# "Idempotent" here means CONVERGENT, not "exits early if the cluster exists".
|
||||
# That distinction matters: an interrupted first run can leave a cluster created
|
||||
# but not finished, and returning early on the re-run would strand it there.
|
||||
# The create step is conditional; every step after it always runs, and each one
|
||||
# is individually idempotent.
|
||||
#
|
||||
# Usage: cluster.sh up | down | reset | list | free
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
up() {
|
||||
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
|
||||
echo "cluster '$CLUSTER' exists — converging"
|
||||
else
|
||||
# Say what this profile locks in BEFORE spending minutes building it:
|
||||
# the audit policy is an apiserver flag and cannot be changed later.
|
||||
echo "creating cluster '$CLUSTER' from profile '$PROFILE_NAME'"
|
||||
echo " shape ctrl/k8s/$KIND_CONFIG"
|
||||
echo " nodes $NODES"
|
||||
echo " image $NODE_IMAGE"
|
||||
echo " audit $AUDIT"
|
||||
echo " ingress $INGRESS_MODE"
|
||||
echo " registry $REGISTRY_MODE"
|
||||
echo " (audit is fixed at creation — 'make cluster reset' to change it)"
|
||||
echo
|
||||
|
||||
render_kind_config | kind create cluster --config -
|
||||
fi
|
||||
|
||||
# The cluster can exist while its context does not — a reset or a switched
|
||||
# KUBECONFIG loses it, and then nothing works despite a healthy cluster.
|
||||
if ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "$KUBECONTEXT"; then
|
||||
echo "context '$KUBECONTEXT' missing from kubeconfig — re-exporting"
|
||||
kind export kubeconfig --name "$CLUSTER"
|
||||
fi
|
||||
kubectl config use-context "$KUBECONTEXT" >/dev/null
|
||||
|
||||
bash registry.sh up
|
||||
|
||||
if [ -n "${ADDONS// /}" ]; then
|
||||
bash addons.sh install
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "cluster '$CLUSTER' ready (context $KUBECONTEXT)"
|
||||
}
|
||||
|
||||
down() {
|
||||
# The registry is a standalone container outside the cluster; take it down
|
||||
# first so a reset doesn't leave it orphaned and holding a port.
|
||||
bash registry.sh down || true
|
||||
|
||||
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
|
||||
echo "deleting cluster '$CLUSTER'..."
|
||||
kind delete cluster --name "$CLUSTER"
|
||||
else
|
||||
echo "no cluster '$CLUSTER' to delete"
|
||||
fi
|
||||
}
|
||||
|
||||
# The escape hatch for a wedged cluster, and the only way to change a
|
||||
# creation-time setting such as the audit policy.
|
||||
reset() {
|
||||
down
|
||||
echo
|
||||
up
|
||||
}
|
||||
|
||||
# ── the whole machine ──────────────────────────────────────────────────────
|
||||
# Every cluster is a running container tree whether or not you are using it, and
|
||||
# an idle one is the usual reason a new one will not fit.
|
||||
|
||||
list() {
|
||||
local total avail
|
||||
total=$(awk '/^MemTotal:/{printf "%.1f", $2/1024/1024}' /proc/meminfo)
|
||||
avail=$(awk '/^MemAvailable:/{printf "%.1f", $2/1024/1024}' /proc/meminfo)
|
||||
echo "memory: ${avail} GB available of ${total} GB"
|
||||
echo
|
||||
|
||||
local names; names=$(kind get clusters 2>/dev/null || true)
|
||||
if [ -z "$names" ]; then
|
||||
echo "no clusters"
|
||||
return
|
||||
fi
|
||||
|
||||
printf "%-16s %-10s %8s %6s %-13s %s\n" CLUSTER STATE MEM NODES PORTS ""
|
||||
local c nodes state mem base
|
||||
for c in $names; do
|
||||
nodes=$(docker ps -a --filter "label=io.x-k8s.kind.cluster=$c" --format '{{.Names}}' | wc -l)
|
||||
state=$(docker inspect -f '{{.State.Status}}' "${c}-control-plane" 2>/dev/null || echo unknown)
|
||||
if [ "$state" = "running" ]; then
|
||||
mem=$(docker stats --no-stream --format '{{.MemUsage}}' \
|
||||
$(docker ps --filter "label=io.x-k8s.kind.cluster=$c" -q) 2>/dev/null \
|
||||
| awk '{gsub(/GiB/,"");gsub(/MiB/,"e-3");s+=$1} END {printf "%.1fG", s}')
|
||||
else
|
||||
mem="-"
|
||||
fi
|
||||
# A cluster's name is its directory slug, so its port block is derivable
|
||||
# here without reading that directory's config.
|
||||
base=$(derive_port_base "$c")
|
||||
printf "%-16s %-10s %8s %6s %-13s %s\n" "$c" "$state" "$mem" "$nodes" \
|
||||
"${base}-$((base + 3))" \
|
||||
"$([ "$c" = "$CLUSTER" ] && echo "<- this one")"
|
||||
done
|
||||
}
|
||||
|
||||
# Stop the OTHER clusters to free memory. Stops, never deletes — a stopped
|
||||
# cluster restarts with `docker start`, so nothing is lost.
|
||||
free() {
|
||||
local targets=("$@")
|
||||
if [ ${#targets[@]} -eq 0 ]; then
|
||||
mapfile -t targets < <(kind get clusters 2>/dev/null | grep -vx "$CLUSTER" || true)
|
||||
fi
|
||||
if [ ${#targets[@]} -eq 0 ]; then
|
||||
echo "nothing to stop"
|
||||
return
|
||||
fi
|
||||
|
||||
local c ids
|
||||
for c in "${targets[@]}"; do
|
||||
ids=$(docker ps --filter "label=io.x-k8s.kind.cluster=$c" -q)
|
||||
if [ -z "$ids" ]; then
|
||||
echo "cluster '$c' is not running"
|
||||
continue
|
||||
fi
|
||||
echo "stopping '$c' (restart with: docker start \$(docker ps -aq -f label=io.x-k8s.kind.cluster=$c))"
|
||||
# shellcheck disable=SC2086
|
||||
docker stop $ids >/dev/null
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-up}" in
|
||||
up) up ;;
|
||||
down) down ;;
|
||||
reset) reset ;;
|
||||
list) list ;;
|
||||
free) shift; free "$@" ;;
|
||||
*) echo "usage: $0 [up|down|reset|list|free]" >&2; exit 1 ;;
|
||||
esac
|
||||
272
rig/ctrl/dockerhost.sh
Executable file
272
rig/ctrl/dockerhost.sh
Executable file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env bash
|
||||
# Share ONE Docker daemon across WSL distros, instead of running one per distro.
|
||||
#
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# WSL2 distros share a kernel and a network stack. Two dockerd instances then
|
||||
# contend over docker0 and iptables, which can disturb the daemon you actually
|
||||
# depend on. Docker Desktop avoids this by running a single daemon in a
|
||||
# dedicated distro and sharing its socket — this is the same idea, without
|
||||
# Docker Desktop.
|
||||
#
|
||||
# So a throwaway rig box does NOT install Docker. It borrows the daemon from
|
||||
# whichever distro is the designated host. That also makes the test more honest:
|
||||
# rig never installs Docker anyway — Docker is its documented prerequisite.
|
||||
#
|
||||
# How
|
||||
# ---
|
||||
# /mnt/wsl is a tmpfs with `shared` mount propagation, visible to every distro
|
||||
# in the WSL VM. The owning distro exposes its socket there; guests point
|
||||
# DOCKER_HOST at it. Two ways, with different costs:
|
||||
#
|
||||
# share bind-mount the existing socket onto the shared tmpfs.
|
||||
# Instant, and dockerd is NEVER restarted. Lasts until the
|
||||
# next WSL shutdown.
|
||||
# share --persist additionally install a systemd drop-in so dockerd listens
|
||||
# there itself. Survives restarts, but requires one Docker
|
||||
# restart now — which stops every container that has no
|
||||
# restart policy, since live-restore is off by default.
|
||||
#
|
||||
# The bind mount is the default precisely because the persistent version's cost
|
||||
# is paid on a machine that is already working.
|
||||
#
|
||||
# Reversibility is the whole design
|
||||
# ---------------------------------
|
||||
# `unshare` removes the bind mount (no restart) and, if present, the drop-in.
|
||||
# The original systemd unit is never edited — only an additive drop-in file is
|
||||
# ever created — so undoing is deletion, not repair. `status` always states
|
||||
# which of the three roles a distro is in, in those words.
|
||||
#
|
||||
# Nothing here runs automatically. It does nothing until invoked.
|
||||
#
|
||||
# Usage:
|
||||
# dockerhost.sh status # which distro owns Docker; what this one uses
|
||||
# dockerhost.sh share # share it (bind mount, no daemon restart)
|
||||
# dockerhost.sh share --persist # ...and survive WSL restarts (restarts Docker)
|
||||
# dockerhost.sh unshare # undo it; this distro owns its Docker again
|
||||
# dockerhost.sh use [--persist] # point THIS distro at the shared socket
|
||||
set -euo pipefail
|
||||
|
||||
SHARED_DIR=/mnt/wsl/shared-docker
|
||||
SHARED_SOCK="$SHARED_DIR/docker.sock"
|
||||
OWNER_FILE="$SHARED_DIR/OWNER"
|
||||
DROPIN=/etc/systemd/system/docker.service.d/10-rig-shared-socket.conf
|
||||
PROFILE_D=/etc/profile.d/rig-docker-host.sh
|
||||
|
||||
distro_name() { echo "${WSL_DISTRO_NAME:-$(hostname)}"; }
|
||||
|
||||
require_wsl() {
|
||||
grep -qi microsoft /proc/version 2>/dev/null && return 0
|
||||
echo "dockerhost is WSL-only: it relies on /mnt/wsl being shared between distros." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
status() {
|
||||
require_wsl
|
||||
echo "distro $(distro_name)"
|
||||
|
||||
if [ -f "$DROPIN" ] || mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
|
||||
echo "role SHARING — this distro's Docker is offered to other distros"
|
||||
elif [ -n "${DOCKER_HOST:-}" ] && [ "${DOCKER_HOST}" = "unix://$SHARED_SOCK" ]; then
|
||||
echo "role BORROWING — using another distro's Docker"
|
||||
else
|
||||
echo "role standalone — this WSL installation has the main host Docker"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ -S "$SHARED_SOCK" ]; then
|
||||
echo "shared sock $SHARED_SOCK (present)"
|
||||
[ -f "$OWNER_FILE" ] && sed 's/^/ /' "$OWNER_FILE"
|
||||
else
|
||||
echo "shared sock none — no distro is sharing right now"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "DOCKER_HOST ${DOCKER_HOST:-(unset — using /var/run/docker.sock)}"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo unreachable)"
|
||||
else
|
||||
echo "docker cli not installed"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── share / unshare (run on the host distro) ───────────────────────────────
|
||||
|
||||
# Default: expose the EXISTING socket by bind-mounting it onto the shared tmpfs.
|
||||
# /mnt/wsl has `shared` propagation, so the mount is visible in other distros.
|
||||
#
|
||||
# The point of doing it this way is that dockerd is never restarted. Restarting
|
||||
# it stops every container that has no restart policy (live-restore is off by
|
||||
# default), which on a working machine means quietly killing whatever you had
|
||||
# running. Not a trade worth making just to expose a socket.
|
||||
#
|
||||
# Cost: a bind mount does not survive a WSL VM shutdown. `--persist` adds the
|
||||
# systemd drop-in as well, which does survive but needs that one restart.
|
||||
share_bind() {
|
||||
mkdir -p "$SHARED_DIR"
|
||||
chmod 0755 "$SHARED_DIR"
|
||||
|
||||
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
|
||||
echo "already bind-mounted at $SHARED_SOCK"
|
||||
else
|
||||
[ -S /var/run/docker.sock ] || { echo "no /var/run/docker.sock here" >&2; exit 1; }
|
||||
# The target must exist as a file for a bind mount onto it.
|
||||
[ -e "$SHARED_SOCK" ] || : > "$SHARED_SOCK"
|
||||
mount --bind /var/run/docker.sock "$SHARED_SOCK"
|
||||
echo "bind-mounted /var/run/docker.sock -> $SHARED_SOCK (no daemon restart)"
|
||||
fi
|
||||
|
||||
cat > "$OWNER_FILE" <<EOF
|
||||
owner distro: $(distro_name)
|
||||
docker gid: $(getent group docker | cut -d: -f3)
|
||||
socket: $SHARED_SOCK
|
||||
method: bind-mount (until the next WSL shutdown)
|
||||
EOF
|
||||
}
|
||||
|
||||
share() {
|
||||
require_wsl
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh share" >&2; exit 1; }
|
||||
|
||||
share_bind
|
||||
|
||||
if [ "${1:-}" != "--persist" ]; then
|
||||
echo
|
||||
echo "This lasts until the next WSL shutdown. To make it survive, re-run with"
|
||||
echo "--persist — but note that adds a systemd drop-in and RESTARTS Docker,"
|
||||
echo "which stops any container that has no restart policy."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$DROPIN" ]; then
|
||||
echo "drop-in already present — sharing persists across restarts."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "--persist: installing a systemd drop-in and restarting Docker."
|
||||
echo "Containers without a restart policy will stop and will NOT come back."
|
||||
docker ps --format ' {{.Names}} restart={{.HostConfig.RestartPolicy.Name}}' 2>/dev/null \
|
||||
|| docker ps --format ' {{.Names}}' 2>/dev/null || true
|
||||
echo
|
||||
|
||||
local exec_line
|
||||
exec_line=$(systemctl cat docker.service | grep -m1 '^ExecStart=')
|
||||
if [ -z "$exec_line" ]; then
|
||||
echo "could not read docker.service ExecStart — refusing to guess" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$DROPIN")" "$SHARED_DIR"
|
||||
# Additive only: blank the inherited ExecStart, then restate it verbatim
|
||||
# with one extra -H. Nothing about the original unit is edited.
|
||||
cat > "$DROPIN" <<EOF
|
||||
# Added by rig (ctrl/dockerhost.sh share).
|
||||
#
|
||||
# Adds a SECOND listening socket on the WSL-shared tmpfs so other distros can
|
||||
# use this daemon instead of running their own. The original socket is
|
||||
# untouched, so this distro behaves exactly as before.
|
||||
#
|
||||
# To undo: sudo bash ctrl/dockerhost.sh unshare
|
||||
[Service]
|
||||
ExecStartPre=-/bin/mkdir -p $SHARED_DIR
|
||||
ExecStartPre=-/bin/chmod 0755 $SHARED_DIR
|
||||
ExecStart=
|
||||
${exec_line} -H unix://$SHARED_SOCK
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
|
||||
# Guests need a group with a MATCHING GID to use the socket; GIDs are not
|
||||
# consistent across distros, so record ours rather than assume.
|
||||
cat > "$OWNER_FILE" <<EOF
|
||||
owner distro: $(distro_name)
|
||||
docker gid: $(getent group docker | cut -d: -f3)
|
||||
socket: $SHARED_SOCK
|
||||
EOF
|
||||
|
||||
echo "sharing from '$(distro_name)'"
|
||||
echo " guests: export DOCKER_HOST=unix://$SHARED_SOCK"
|
||||
echo " undo: sudo bash ctrl/dockerhost.sh unshare"
|
||||
echo
|
||||
echo "NOTE: /mnt/wsl is tmpfs and is cleared when the WSL VM shuts down."
|
||||
echo " The drop-in recreates the directory on the next Docker start."
|
||||
}
|
||||
|
||||
unshare_() {
|
||||
require_wsl
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run with sudo: sudo bash ctrl/dockerhost.sh unshare" >&2; exit 1; }
|
||||
|
||||
local did=0
|
||||
|
||||
# The bind mount first: undoing it needs no restart, so a plain `share`
|
||||
# is fully reversible without disturbing anything.
|
||||
if mountpoint -q "$SHARED_SOCK" 2>/dev/null; then
|
||||
umount "$SHARED_SOCK"
|
||||
rm -f "$SHARED_SOCK"
|
||||
echo " removed the bind mount (no restart needed)"
|
||||
did=1
|
||||
fi
|
||||
rm -f "$OWNER_FILE"
|
||||
rmdir "$SHARED_DIR" 2>/dev/null || true
|
||||
|
||||
if [ -f "$DROPIN" ]; then
|
||||
rm -f "$DROPIN"
|
||||
rmdir "$(dirname "$DROPIN")" 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
echo " removed the systemd drop-in and restarted Docker"
|
||||
did=1
|
||||
fi
|
||||
|
||||
if [ "$did" -eq 0 ]; then
|
||||
echo "not sharing — this WSL installation already has the main host Docker."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "restored: this WSL installation has the main host Docker again."
|
||||
echo " (nothing else was changed; the original unit was never edited)"
|
||||
}
|
||||
|
||||
# ── use (run on a guest distro) ────────────────────────────────────────────
|
||||
|
||||
use() {
|
||||
require_wsl
|
||||
if [ ! -S "$SHARED_SOCK" ]; then
|
||||
echo "no shared socket at $SHARED_SOCK" >&2
|
||||
echo "Run 'sudo bash ctrl/dockerhost.sh share' in the distro that owns Docker." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Align the local docker group GID with the owner's, or the socket is
|
||||
# unreadable here even though it is visible.
|
||||
if [ -f "$OWNER_FILE" ] && [ "$(id -u)" -eq 0 ]; then
|
||||
local gid; gid=$(awk '/docker gid:/ {print $3}' "$OWNER_FILE")
|
||||
if [ -n "$gid" ]; then
|
||||
if getent group docker >/dev/null; then
|
||||
[ "$(getent group docker | cut -d: -f3)" = "$gid" ] || groupmod -g "$gid" docker
|
||||
else
|
||||
groupadd -g "$gid" docker
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--persist" ]; then
|
||||
[ "$(id -u)" -eq 0 ] || { echo "--persist needs root" >&2; exit 1; }
|
||||
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > "$PROFILE_D"
|
||||
echo "persisted in $PROFILE_D"
|
||||
fi
|
||||
|
||||
echo "export DOCKER_HOST=unix://$SHARED_SOCK"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
status) status ;;
|
||||
share) shift; share "${1:-}" ;;
|
||||
unshare) unshare_ ;;
|
||||
use) shift; use "${1:-}" ;;
|
||||
*) echo "usage: $0 [status|share|unshare|use [--persist]]" >&2; exit 1 ;;
|
||||
esac
|
||||
58
rig/ctrl/docs.sh
Executable file
58
rig/ctrl/docs.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Documentation: render the diagrams, and serve the pages.
|
||||
#
|
||||
# The docs are the instructions for building the cluster, so they must work
|
||||
# BEFORE anything else exists. That rules out serving them from the cluster, and
|
||||
# it rules out python -m http.server too — a minimal Debian has no python3. What
|
||||
# it does have, by definition, is Docker: the single prerequisite rig already
|
||||
# demands. So a throwaway nginx container serves a read-only bind mount.
|
||||
#
|
||||
# Rendered SVGs are committed alongside their .dot sources for the same reason:
|
||||
# the pages have to read on a machine with no Graphviz installed.
|
||||
#
|
||||
# Usage: docs.sh serve | graphs
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
REPO="$(cd .. && pwd)"
|
||||
DOCS_PORT="${DOCS_PORT:-$((HTTP_PORT + 4))}" # +4 sits inside this env's block
|
||||
|
||||
serve() {
|
||||
if [ ! -f "$REPO/docs/index.html" ]; then
|
||||
echo "no docs/index.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "docs for '$CLUSTER' on http://localhost:${DOCS_PORT}"
|
||||
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
|
||||
docker run --rm \
|
||||
--name "${CLUSTER}-docs" \
|
||||
-p "${DOCS_PORT}:80" \
|
||||
-v "$REPO/docs:/usr/share/nginx/html:ro" \
|
||||
nginx:alpine
|
||||
}
|
||||
|
||||
graphs() {
|
||||
if ! command -v dot >/dev/null 2>&1; then
|
||||
echo "graphviz not found — install with: sudo apt install graphviz" >&2
|
||||
echo "(only needed to re-render; the committed .svg files already work)" >&2
|
||||
exit 1
|
||||
fi
|
||||
shopt -s nullglob
|
||||
local found=0 f out
|
||||
for f in "$REPO"/docs/graphs/*.dot; do
|
||||
out="${f%.dot}.svg"
|
||||
echo " graphviz $(basename "$f") → $(basename "$out")"
|
||||
dot -Tsvg "$f" -o "$out"
|
||||
found=1
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo " no .dot files in docs/graphs/"
|
||||
}
|
||||
|
||||
case "${1:-serve}" in
|
||||
serve) serve ;;
|
||||
graphs) graphs ;;
|
||||
*) echo "usage: $0 [serve|graphs]" >&2; exit 1 ;;
|
||||
esac
|
||||
30
rig/ctrl/env.d/client.env
Normal file
30
rig/ctrl/env.d/client.env
Normal file
@@ -0,0 +1,30 @@
|
||||
# client — the regulated-estate shape. Multi-node so taints, affinity and
|
||||
# topology are real; apiserver audit on; images through a pull-through cache of
|
||||
# the corporate registry.
|
||||
#
|
||||
# Costs roughly 4-6 GB. Check `make cluster list` before starting this alongside
|
||||
# other work — see the memory note in the README.
|
||||
|
||||
PROFILE_NAME=client
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.client.yaml.tpl
|
||||
ADDONS="metallb cert-manager metrics-server"
|
||||
REGISTRY_MODE=mirror
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Ports derive from the directory name by default (see ctrl/ports.sh), so
|
||||
# several environments run side by side.
|
||||
#
|
||||
# Opt in to the real ports below only when this is the ONLY environment and
|
||||
# nothing else owns :80. They fail to bind otherwise, and docker reports it as an
|
||||
# opaque "failed to bind host port 0.0.0.0:80/tcp: address already in use"
|
||||
# halfway through cluster creation. `make station` checks before you spend the
|
||||
# time. Uncommenting also means only one environment can exist at a time.
|
||||
# HTTP_PORT=80
|
||||
# HTTPS_PORT=443
|
||||
|
||||
# Set these in ctrl/.env (gitignored), not here:
|
||||
# REGISTRY_REMOTE_URL=https://artifactory.corp.example/artifactory/api/docker/docker-virtual
|
||||
# REGISTRY_USER / REGISTRY_PASSWORD
|
||||
# REGISTRY_CA_FILE=/path/to/corp-root-ca.crt
|
||||
42
rig/ctrl/env.d/data.env
Normal file
42
rig/ctrl/env.d/data.env
Normal file
@@ -0,0 +1,42 @@
|
||||
# data — a cluster with the dependency containers a soleprint room asks for.
|
||||
#
|
||||
# The point of this profile is that a room declares what it needs once, in
|
||||
# cfg/<room>/data/cabinets.json, and gets it on either target: `build.py`
|
||||
# composes those services into docker-compose.yml for a laptop, and the addons
|
||||
# below install the same ones here. The names match deliberately —
|
||||
# soleprint/station/cabinets/<name>/cabinet.json carries a `rig_addon` field
|
||||
# pointing at ctrl/addons/<name>.sh.
|
||||
#
|
||||
# Everything lands in the `data` namespace (DATA_NAMESPACE to move it), so
|
||||
# `make cluster reset` on the app namespace leaves the databases alone.
|
||||
#
|
||||
# Costs roughly 2-3 GB with airflow, under 1 without. Airflow's first boot runs
|
||||
# the whole metadata migration, so expect a few minutes before it is ready.
|
||||
|
||||
PROFILE_NAME=data
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.yaml.tpl
|
||||
# Order matters: addons.sh installs in the order listed, and airflow refuses to
|
||||
# start without a metadata database, so postgres comes first.
|
||||
ADDONS="metallb postgres redis airflow"
|
||||
# local, not none — see minimal.env: `none` has no outward-push guard.
|
||||
REGISTRY_MODE=local
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Namespace for the dependency containers.
|
||||
DATA_NAMESPACE=data
|
||||
|
||||
# Postgres identity. The password is not here: postgres.sh generates one on
|
||||
# first install and keeps it across re-runs, so re-running the addon never
|
||||
# rotates the credential out from under whatever is already connected.
|
||||
POSTGRES_DB=soleprint
|
||||
POSTGRES_USER=soleprint
|
||||
POSTGRES_STORAGE=2Gi
|
||||
|
||||
AIRFLOW_ADMIN_USER=admin
|
||||
|
||||
# Ports derive from the directory name by default — see ctrl/ports.sh. Reach
|
||||
# the databases with port-forward rather than binding more host ports:
|
||||
# kubectl -n data port-forward svc/postgres 5432:5432
|
||||
# kubectl -n data port-forward svc/airflow 8080:8080
|
||||
21
rig/ctrl/env.d/minimal.env
Normal file
21
rig/ctrl/env.d/minimal.env
Normal file
@@ -0,0 +1,21 @@
|
||||
# minimal — the default. One node, no addons, no registry.
|
||||
# Assumes nothing and boots fast. Start here; move to client.env when you need
|
||||
# the regulated behaviours.
|
||||
#
|
||||
|
||||
PROFILE_NAME=minimal
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.yaml.tpl
|
||||
ADDONS=""
|
||||
# local, not none: `none` leaves the cluster with no registry to push to, and an
|
||||
# unqualified image name then means docker.io/library/<name>. In a regulated
|
||||
# estate that is a disclosure risk, not a convenience trade — so the default
|
||||
# carries the guard even though it costs one container.
|
||||
REGISTRY_MODE=local
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Ports are deliberately NOT set here. They derive from the directory name so
|
||||
# several environments coexist — see ctrl/ports.sh, and `make ports` to see the
|
||||
# block this one gets. A fixed default here would collide with whatever else the
|
||||
# machine happens to be running; 8080 in particular is rarely free.
|
||||
18
rig/ctrl/env.d/offline.env
Normal file
18
rig/ctrl/env.d/offline.env
Normal file
@@ -0,0 +1,18 @@
|
||||
# offline — air-gapped. Everything comes from a local registry that was loaded
|
||||
# ahead of time; nothing reaches the internet. Pair with the wizard-full image
|
||||
# (DEPS_SOURCE=baked) so the toolchain install is offline too.
|
||||
#
|
||||
# The heavier addons are left out to keep first boot viable.
|
||||
|
||||
PROFILE_NAME=offline
|
||||
K8S_VERSION=v1_36
|
||||
KIND_CONFIG=kind-config.audit.yaml.tpl
|
||||
ADDONS="metallb"
|
||||
REGISTRY_MODE=local
|
||||
INGRESS_MODE=hostport
|
||||
DNS_MODE=hosts
|
||||
|
||||
# Derived from the directory name by default — see ctrl/ports.sh.
|
||||
# Uncomment for the real ports, but only if this is the only environment.
|
||||
# HTTP_PORT=80
|
||||
# HTTPS_PORT=443
|
||||
15
rig/ctrl/hosts.tmpl
Normal file
15
rig/ctrl/hosts.tmpl
Normal file
@@ -0,0 +1,15 @@
|
||||
# /etc/hosts block for this environment. Rendered by newbox.sh; ${CLUSTER} and
|
||||
# ${HTTP_PORT} are substituted.
|
||||
#
|
||||
# Hostnames are a convenience, not a requirement — every service is reachable at
|
||||
# localhost:<port> without any of this, which is why DNS is not touched by
|
||||
# default. Add entries here as the model grows.
|
||||
#
|
||||
# On Windows the same block has to go in
|
||||
# C:\Windows\System32\drivers\etc\hosts for a browser to resolve these. That
|
||||
# file does NOT support wildcards, so every name must be listed explicitly.
|
||||
# newbox.sh prints the block for you to paste rather than editing it.
|
||||
|
||||
127.0.0.1 ${CLUSTER}.local
|
||||
127.0.0.1 api.${CLUSTER}.local
|
||||
127.0.0.1 docs.${CLUSTER}.local
|
||||
71
rig/ctrl/k8s/README.md
Normal file
71
rig/ctrl/k8s/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# `ctrl/k8s` — cluster shape, and what runs on it
|
||||
|
||||
Same layout as every other project here (`unt`, `nvi`, `eth`, `mpr`, and
|
||||
soleprint's generated rooms): a kind config, a kustomize `base/`, and an
|
||||
`overlays/dev/` that patches it. See ALL `projects/templates/conventions.md`.
|
||||
|
||||
```
|
||||
kind-config*.yaml.tpl the cluster itself — nodes, ports, audit
|
||||
base/ the components, as plain manifests
|
||||
overlays/dev/ how this rig differs from the base
|
||||
audit-policy.yaml mounted into the apiserver by the audit shapes
|
||||
```
|
||||
|
||||
## Why the cluster config is a template
|
||||
|
||||
Every other project checks in a literal `kind-config.yaml`, because there is
|
||||
exactly one `unt` and one `nvi`. A rig is copied and renamed to make a second
|
||||
environment, and both the cluster name and the host port block follow the
|
||||
directory name — so a literal would make every copy collide on both.
|
||||
|
||||
`ctrl/cluster.sh` renders it with `sed`, substituting `${CLUSTER}`,
|
||||
`${NODE_IMAGE}`, `${HTTP_PORT}` and `${HOST_WORKDIR}`. Not `envsubst`: that is
|
||||
`gettext-base`, which a minimal Debian does not have, and Docker being the only
|
||||
prerequisite is the one promise rig makes.
|
||||
|
||||
**The chosen file is the source of truth for node count and audit.**
|
||||
`lib/config.sh` reads both back out of it, so a profile names a shape and does
|
||||
not restate what the YAML already says.
|
||||
|
||||
| file | nodes | audit | profiles |
|
||||
| --- | --- | --- | --- |
|
||||
| `kind-config.yaml.tpl` | 1 | off | `minimal`, `data` |
|
||||
| `kind-config.audit.yaml.tpl` | 1 | on | `offline` |
|
||||
| `kind-config.client.yaml.tpl` | 3 | on | `client` |
|
||||
|
||||
A profile picks one with `KIND_CONFIG` in `ctrl/env.d/<profile>.env`. Adding a
|
||||
shape is adding a file — there is no dispatcher to edit.
|
||||
|
||||
Audit is an apiserver flag and therefore fixed at creation: changing it is
|
||||
`make cluster reset`, not a re-apply.
|
||||
|
||||
## `base/` — replace these
|
||||
|
||||
**The two components in `base/` are examples, not the system.** They exist so
|
||||
the real manifests have a shape to be written against.
|
||||
|
||||
The real ones are expected to be versioned **separately from the installer** —
|
||||
they change on a different cadence, by different people, under different review.
|
||||
Point `MANIFESTS_DIR` in `ctrl/.env` at their overlay and rig stops owning them:
|
||||
|
||||
```
|
||||
MANIFESTS_DIR=../platform-manifests/overlays/dev
|
||||
```
|
||||
|
||||
Until then it defaults to `ctrl/k8s/overlays/dev`.
|
||||
|
||||
### The three states a component can be in
|
||||
|
||||
Switching between them should be a one-line change, never a rewrite. The DNS
|
||||
name stays the same in every case, so callers never know the difference:
|
||||
|
||||
| state | what exists | when |
|
||||
| --- | --- | --- |
|
||||
| **real** | an image built from source, hot-reloaded | the one thing you are working on |
|
||||
| **mock** | a stub returning canned responses (`example-mock.yaml`) | everything else — most of the estate |
|
||||
| **remote** | no pod at all, just a Service (`example-remote.yaml`) | when the real system is reachable and you want it |
|
||||
|
||||
Most components should be **mock**. What has to be faithful is the topology —
|
||||
names, ports, dependency order, who can reach whom, how it fails. The workloads
|
||||
are noise, and mocking them is what makes several copies of a large estate fit
|
||||
on one laptop.
|
||||
44
rig/ctrl/k8s/audit-policy.yaml
Normal file
44
rig/ctrl/k8s/audit-policy.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
# Apiserver audit policy. Mounted into the control plane at creation when a
|
||||
# profile sets AUDIT=on — an apiserver flag, so it cannot be added to a running
|
||||
# cluster without recreating it.
|
||||
#
|
||||
# Deliberately modest: enough to make "who changed what, and when" answerable
|
||||
# during onboarding without filling the disk. Read the log with:
|
||||
# docker exec <cluster>-control-plane cat /var/log/kubernetes/audit.log
|
||||
apiVersion: audit.k8s.io/v1
|
||||
kind: Policy
|
||||
|
||||
# Never log the request body for these — they contain credentials.
|
||||
omitStages:
|
||||
- RequestReceived
|
||||
|
||||
rules:
|
||||
# Secrets/configmaps: record that access happened, never the contents.
|
||||
- level: Metadata
|
||||
resources:
|
||||
- group: ""
|
||||
resources: ["secrets", "configmaps"]
|
||||
|
||||
# Authn/authz decisions — the part an auditor actually asks about.
|
||||
- level: Metadata
|
||||
nonResourceURLs:
|
||||
- /apis*
|
||||
- /api*
|
||||
|
||||
# Mutations to workloads and policy: full request, so a diff is reconstructable.
|
||||
- level: Request
|
||||
verbs: ["create", "update", "patch", "delete"]
|
||||
resources:
|
||||
- group: ""
|
||||
resources: ["pods", "services", "serviceaccounts", "namespaces"]
|
||||
- group: "apps"
|
||||
- group: "networking.k8s.io"
|
||||
- group: "rbac.authorization.k8s.io"
|
||||
|
||||
# Everything else that changes state: metadata only.
|
||||
- level: Metadata
|
||||
verbs: ["create", "update", "patch", "delete"]
|
||||
|
||||
# Reads are dropped entirely — otherwise controller polling drowns the log.
|
||||
- level: None
|
||||
verbs: ["get", "list", "watch"]
|
||||
104
rig/ctrl/k8s/base/example-mock.yaml
Normal file
104
rig/ctrl/k8s/base/example-mock.yaml
Normal file
@@ -0,0 +1,104 @@
|
||||
# EXAMPLE — a mocked component. Copy, rename, replace.
|
||||
#
|
||||
# A stub that answers on the right name and port with canned responses. No image
|
||||
# to build: the script is mounted from the ConfigMap, so changing the behaviour
|
||||
# is a kubectl apply, not a rebuild.
|
||||
#
|
||||
# Deliberately boring and readable. This is onboarding material — someone should
|
||||
# be able to read the generated object and recognise what it is.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: example-service-stub
|
||||
data:
|
||||
# Canned responses by path. Add entries as the contract becomes clear;
|
||||
# anything unmatched returns 404 so a missing route is visible, not silent.
|
||||
routes.json: |
|
||||
{
|
||||
"/health": {"status": 200, "body": {"status": "ok"}},
|
||||
"/v1/example": {"status": 200, "body": {"items": [], "mocked": true}}
|
||||
}
|
||||
serve.py: |
|
||||
import json, os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
ROUTES = json.load(open("/etc/stub/routes.json"))
|
||||
NAME = os.environ.get("STUB_NAME", "stub")
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
r = ROUTES.get(self.path)
|
||||
if r is None:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
# Say which stub rejected it — with everything mocked, "404"
|
||||
# alone tells you nothing about where the call actually landed.
|
||||
self.wfile.write(json.dumps(
|
||||
{"error": "no canned route", "stub": NAME, "path": self.path}
|
||||
).encode())
|
||||
return
|
||||
body = json.dumps(r["body"]).encode()
|
||||
self.send_response(r["status"])
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("X-Mocked-By", NAME)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print("%s %s" % (NAME, fmt % args), flush=True)
|
||||
|
||||
HTTPServer(("0.0.0.0", 8080), H).serve_forever()
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: example-service
|
||||
labels:
|
||||
app: example-service
|
||||
rig.component/impl: mock # so `kubectl get deploy -L rig.component/impl`
|
||||
# shows at a glance what is real and what is not
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: example-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: example-service
|
||||
spec:
|
||||
containers:
|
||||
- name: stub
|
||||
image: python:3.12-slim
|
||||
command: ["python3", "/etc/stub/serve.py"]
|
||||
env:
|
||||
- name: STUB_NAME
|
||||
value: example-service
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
- name: stub
|
||||
mountPath: /etc/stub
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 8080 }
|
||||
initialDelaySeconds: 2
|
||||
# Small enough that a whole estate of these fits alongside the real
|
||||
# thing you are working on.
|
||||
resources:
|
||||
requests: { memory: 32Mi, cpu: 10m }
|
||||
limits: { memory: 64Mi }
|
||||
volumes:
|
||||
- name: stub
|
||||
configMap:
|
||||
name: example-service-stub
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: example-service
|
||||
spec:
|
||||
selector:
|
||||
app: example-service
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
46
rig/ctrl/k8s/base/example-remote.yaml
Normal file
46
rig/ctrl/k8s/base/example-remote.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# EXAMPLE — a component that is NOT simulated, pointed at the real system.
|
||||
#
|
||||
# This is the payoff of keeping the topology honest: there is no pod here at
|
||||
# all, yet `example-remote.<namespace>.svc.cluster.local` resolves exactly as it
|
||||
# does when the same component is mocked. Callers are identical in both cases,
|
||||
# so moving a dependency from mocked to real is a one-line change and nothing
|
||||
# downstream is touched.
|
||||
#
|
||||
# Use this when the real system is reachable and you want it in the loop.
|
||||
# Note that reachability depends on where you are running: systems restricted to
|
||||
# a managed workspace will not resolve from a laptop at all, which is the whole
|
||||
# reason most components should stay mocked.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: example-remote
|
||||
labels:
|
||||
rig.component/impl: remote
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: real-system.internal.example.com
|
||||
---
|
||||
# If the real system has no DNS name — only an IP, which is common for legacy
|
||||
# hosts — ExternalName cannot express it. Use a bare Service plus manual
|
||||
# Endpoints instead, and delete the block above.
|
||||
#
|
||||
# apiVersion: v1
|
||||
# kind: Service
|
||||
# metadata:
|
||||
# name: example-remote
|
||||
# labels:
|
||||
# rig.component/impl: remote
|
||||
# spec:
|
||||
# ports:
|
||||
# - port: 80
|
||||
# targetPort: 8080
|
||||
# ---
|
||||
# apiVersion: v1
|
||||
# kind: Endpoints
|
||||
# metadata:
|
||||
# name: example-remote # must match the Service name exactly
|
||||
# subsets:
|
||||
# - addresses:
|
||||
# - ip: 10.0.0.42
|
||||
# ports:
|
||||
# - port: 8080
|
||||
11
rig/ctrl/k8s/base/kustomization.yaml
Normal file
11
rig/ctrl/k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
# The namespace every component lands in. The overlay overrides it, so a rig
|
||||
# modelling two estates can apply the same base twice under different names.
|
||||
namespace: rig
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- example-mock.yaml
|
||||
- example-remote.yaml
|
||||
4
rig/ctrl/k8s/base/namespace.yaml
Normal file
4
rig/ctrl/k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: rig
|
||||
57
rig/ctrl/k8s/kind-config.audit.yaml.tpl
Normal file
57
rig/ctrl/k8s/kind-config.audit.yaml.tpl
Normal file
@@ -0,0 +1,57 @@
|
||||
# Cluster shape: one node, apiserver audit ON. Used by the `offline` profile.
|
||||
#
|
||||
# Audit is an apiserver flag, so it is fixed when the cluster is created —
|
||||
# changing it means `make cluster reset`, not a re-apply. That is why it is a
|
||||
# property of the cluster file rather than something switched at runtime.
|
||||
#
|
||||
# k8s >= 1.31 uses kubeadm v1beta4, where extraArgs is a LIST of name/value
|
||||
# pairs. The older map form is silently ignored — it does not error, audit
|
||||
# simply never turns on.
|
||||
#
|
||||
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
|
||||
# (named without the ${...} braces so this line survives the substitution)
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: ${CLUSTER}
|
||||
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: ClusterConfiguration
|
||||
apiServer:
|
||||
extraArgs:
|
||||
- name: audit-policy-file
|
||||
value: /etc/kubernetes/audit/policy.yaml
|
||||
- name: audit-log-path
|
||||
value: /var/log/kubernetes/audit.log
|
||||
- name: audit-log-maxage
|
||||
value: "7"
|
||||
extraVolumes:
|
||||
- name: audit-policy
|
||||
hostPath: /etc/kubernetes/audit
|
||||
mountPath: /etc/kubernetes/audit
|
||||
readOnly: true
|
||||
- name: audit-log
|
||||
hostPath: /var/log/kubernetes
|
||||
mountPath: /var/log/kubernetes
|
||||
readOnly: false
|
||||
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
# hostPath is resolved by the HOST dockerd, so this must be a host path even
|
||||
# when cluster.sh runs inside the wizard container. HOST_WORKDIR says where
|
||||
# this rig lives on the host; bare on a host it is just the repo root.
|
||||
extraMounts:
|
||||
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
|
||||
containerPath: /etc/kubernetes/audit/policy.yaml
|
||||
readOnly: true
|
||||
extraPortMappings:
|
||||
- containerPort: 30080
|
||||
hostPort: ${HTTP_PORT}
|
||||
listenAddress: "0.0.0.0"
|
||||
protocol: TCP
|
||||
55
rig/ctrl/k8s/kind-config.client.yaml.tpl
Normal file
55
rig/ctrl/k8s/kind-config.client.yaml.tpl
Normal file
@@ -0,0 +1,55 @@
|
||||
# Cluster shape: three nodes, apiserver audit ON. Used by the `client` profile —
|
||||
# the regulated-estate shape.
|
||||
#
|
||||
# Multi-node so taints, affinity and topology spread are real rather than
|
||||
# vacuously satisfied by a single node. It costs roughly 4-6 GB; run
|
||||
# `make cluster list` before starting this alongside other work.
|
||||
#
|
||||
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
|
||||
# (named without the ${...} braces so this line survives the substitution)
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: ${CLUSTER}
|
||||
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: ClusterConfiguration
|
||||
apiServer:
|
||||
extraArgs:
|
||||
- name: audit-policy-file
|
||||
value: /etc/kubernetes/audit/policy.yaml
|
||||
- name: audit-log-path
|
||||
value: /var/log/kubernetes/audit.log
|
||||
- name: audit-log-maxage
|
||||
value: "7"
|
||||
extraVolumes:
|
||||
- name: audit-policy
|
||||
hostPath: /etc/kubernetes/audit
|
||||
mountPath: /etc/kubernetes/audit
|
||||
readOnly: true
|
||||
- name: audit-log
|
||||
hostPath: /var/log/kubernetes
|
||||
mountPath: /var/log/kubernetes
|
||||
readOnly: false
|
||||
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
extraMounts:
|
||||
- hostPath: ${HOST_WORKDIR}/ctrl/k8s/audit-policy.yaml
|
||||
containerPath: /etc/kubernetes/audit/policy.yaml
|
||||
readOnly: true
|
||||
extraPortMappings:
|
||||
- containerPort: 30080
|
||||
hostPort: ${HTTP_PORT}
|
||||
listenAddress: "0.0.0.0"
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: ${NODE_IMAGE}
|
||||
- role: worker
|
||||
image: ${NODE_IMAGE}
|
||||
36
rig/ctrl/k8s/kind-config.yaml.tpl
Normal file
36
rig/ctrl/k8s/kind-config.yaml.tpl
Normal file
@@ -0,0 +1,36 @@
|
||||
# Cluster shape: one node, no audit. Used by the `minimal` and `data` profiles.
|
||||
#
|
||||
# A TEMPLATE rather than a plain kind-config.yaml because a rig is copied and
|
||||
# renamed to make a second environment, and both the cluster name and the host
|
||||
# port follow the directory. A checked-in literal would make every copy collide
|
||||
# on both. ctrl/cluster.sh renders it with sed — not envsubst, which is
|
||||
# gettext-base and absent from a minimal Debian, and rig's whole premise is that
|
||||
# Docker is the only prerequisite.
|
||||
#
|
||||
# Substituted by ctrl/cluster.sh: CLUSTER, NODE_IMAGE, HTTP_PORT, HOST_WORKDIR
|
||||
# (named without the ${...} braces so this line survives the substitution)
|
||||
# Node count and audit are READ BACK from this file by lib/config.sh, so this
|
||||
# YAML is the source of truth for both — there is no second place to update.
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
name: ${CLUSTER}
|
||||
|
||||
# Point containerd at a certs.d directory. registry.sh drops per-host hosts.toml
|
||||
# files in there afterwards, so switching registry mode never requires
|
||||
# recreating the cluster.
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: ${NODE_IMAGE}
|
||||
# One NodePort bridged to the host; an in-cluster gateway owns it. There is
|
||||
# deliberately no ingress controller — they pin a narrow window of k8s
|
||||
# versions, and running a trailing-edge control plane is the point.
|
||||
extraPortMappings:
|
||||
- containerPort: 30080
|
||||
hostPort: ${HTTP_PORT}
|
||||
listenAddress: "0.0.0.0"
|
||||
protocol: TCP
|
||||
22
rig/ctrl/k8s/overlays/dev/kustomization.yaml
Normal file
22
rig/ctrl/k8s/overlays/dev/kustomization.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
# The dev overlay is where a rig says how its estate differs from the base —
|
||||
# which components are real, which are mocked, which point at a live system.
|
||||
# Kept empty on purpose: the base already boots, and an overlay full of examples
|
||||
# is harder to read than one that starts blank.
|
||||
#
|
||||
# The shape a patch takes, for when the first one is needed:
|
||||
#
|
||||
# patches:
|
||||
# - target: {kind: Service, name: example-service}
|
||||
# patch: |
|
||||
# - op: replace
|
||||
# path: /spec/type
|
||||
# value: NodePort
|
||||
# - op: add
|
||||
# path: /spec/ports/0/nodePort
|
||||
# value: 30080
|
||||
148
rig/ctrl/lib/config.sh
Normal file
148
rig/ctrl/lib/config.sh
Normal file
@@ -0,0 +1,148 @@
|
||||
# Shared config loading. Sourced, never executed.
|
||||
#
|
||||
# The ecosystem convention is that scripts are standalone with no shared log
|
||||
# library — that still holds. This file is not a logging lib; it is the single
|
||||
# definition of how the config layers compose, which every script has to agree
|
||||
# on exactly. Precedence, weakest first:
|
||||
#
|
||||
# ctrl/versions.env pinned toolchain + image digests (committed)
|
||||
# ctrl/env.d/<profile> cluster shape (committed)
|
||||
# ctrl/.env machine-local values and secrets (gitignored)
|
||||
# the caller's env `make cluster up PROFILE=client` (always wins)
|
||||
#
|
||||
# That last rule is why this is more than a few `source` lines: .env sets
|
||||
# PROFILE, so without snapshotting it would silently override the PROFILE the
|
||||
# user just typed on the command line.
|
||||
#
|
||||
# Run from ctrl/.
|
||||
|
||||
# Values a user can reasonably override per-invocation. Anything set in the
|
||||
# environment when load_config runs is restored after the files are read.
|
||||
# NODES and AUDIT are deliberately NOT here: they are properties of the chosen
|
||||
# ctrl/k8s/kind-config*.yaml.tpl and are read back out of it below, so there is
|
||||
# one place that decides the shape of the cluster rather than two that can drift.
|
||||
CONFIG_OVERRIDABLE="PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS
|
||||
REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT
|
||||
SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT"
|
||||
|
||||
# The containing folder's name, reduced to something kind accepts as a cluster
|
||||
# name (a DNS label: lowercase alphanumerics and dashes). Run from ctrl/, so the
|
||||
# repo root is the parent.
|
||||
default_cluster_name() {
|
||||
local n
|
||||
n=$(basename "$(cd .. && pwd)")
|
||||
n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')
|
||||
n=$(echo "$n" | sed 's/^-*//; s/-*$//')
|
||||
echo "${n:-rig}"
|
||||
}
|
||||
|
||||
# Base of this environment's 10-port block. cksum is used rather than $RANDOM or
|
||||
# bash hashing because it is POSIX and returns the same value on every machine,
|
||||
# which is what makes the block reproducible instead of merely unique.
|
||||
derive_port_base() {
|
||||
local h; h=$(printf '%s' "$1" | cksum | awk '{print $1}')
|
||||
echo $((20000 + (h % 200) * 10))
|
||||
}
|
||||
|
||||
load_config() {
|
||||
local k saved=""
|
||||
for k in $CONFIG_OVERRIDABLE; do
|
||||
# ${!k+x} distinguishes "set but empty" from "unset" — an explicit
|
||||
# FOO= on the command line is a real choice and must survive.
|
||||
if [ -n "${!k+x}" ]; then
|
||||
saved+="$k=$(printf '%q' "${!k}")"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
set -a
|
||||
source ./versions.env
|
||||
[ -f ./.env ] && source ./.env
|
||||
set +a
|
||||
|
||||
# Re-apply overrides now so PROFILE is the caller's before we pick the file.
|
||||
_config_restore "$saved"
|
||||
|
||||
local profile="${PROFILE:-minimal}"
|
||||
if [ ! -f "./env.d/${profile}.env" ]; then
|
||||
echo "no such profile: env.d/${profile}.env" >&2
|
||||
echo "available: $(ls env.d/*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | tr '\n' ' ')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
source "./env.d/${profile}.env"
|
||||
[ -f ./.env ] && source ./.env
|
||||
set +a
|
||||
|
||||
_config_restore "$saved"
|
||||
|
||||
# Identity follows the FOLDER, so copying this directory somewhere else and
|
||||
# renaming it yields a distinct environment with no further edits. Without
|
||||
# this, two copies would share one cluster and `make cluster down` in either
|
||||
# would destroy the other's.
|
||||
CLUSTER="${CLUSTER:-$(default_cluster_name)}"
|
||||
KUBECONTEXT="kind-${CLUSTER}"
|
||||
|
||||
# Host ports are a single shared namespace, so unlike the cluster name they
|
||||
# cannot just follow the directory — they have to be spread out. Anything
|
||||
# already set (ctrl/.env, a profile, the command line) wins; only the gaps
|
||||
# are filled. See ports.sh for the reasoning.
|
||||
local base; base=$(derive_port_base "$CLUSTER")
|
||||
HTTP_PORT="${HTTP_PORT:-$base}"
|
||||
HTTPS_PORT="${HTTPS_PORT:-$((base + 1))}"
|
||||
TILT_PORT="${TILT_PORT:-$((base + 2))}"
|
||||
REGISTRY_PORT="${REGISTRY_PORT:-$((base + 3))}"
|
||||
|
||||
# Profiles name a k8s minor (v1_36); versions.env holds the pinned digest.
|
||||
local var="NODE_IMAGE_${K8S_VERSION}"
|
||||
NODE_IMAGE="${!var:-}"
|
||||
if [ -z "$NODE_IMAGE" ]; then
|
||||
echo "K8S_VERSION='${K8S_VERSION}' has no NODE_IMAGE_${K8S_VERSION} in versions.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The cluster's shape is a file in ctrl/k8s/, named by the profile. Adding a
|
||||
# shape is adding a file; there is no dispatcher to edit.
|
||||
KIND_CONFIG="${KIND_CONFIG:-kind-config.yaml.tpl}"
|
||||
KIND_CONFIG_PATH="./k8s/${KIND_CONFIG}"
|
||||
if [ ! -f "$KIND_CONFIG_PATH" ]; then
|
||||
echo "no such cluster shape: ctrl/k8s/${KIND_CONFIG}" >&2
|
||||
echo "available: $(ls k8s/kind-config*.yaml.tpl 2>/dev/null | xargs -n1 basename | tr '\n' ' ')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read the shape back out of the YAML rather than trusting a profile to
|
||||
# restate it. station.sh sizes the memory warning on NODES, and cluster.sh
|
||||
# prints AUDIT before spending minutes building something that cannot be
|
||||
# changed afterwards — both would mislead if the numbers drifted.
|
||||
NODES=$(grep -c '^ - role:' "$KIND_CONFIG_PATH")
|
||||
if grep -q 'audit-policy-file' "$KIND_CONFIG_PATH"; then AUDIT=on; else AUDIT=off; fi
|
||||
}
|
||||
|
||||
# Render a cluster shape to stdout. sed rather than envsubst: envsubst is
|
||||
# gettext-base, absent from a minimal Debian, and Docker is meant to be the only
|
||||
# prerequisite. The variable list is explicit so a template cannot quietly start
|
||||
# depending on something the caller does not set.
|
||||
#
|
||||
# hostPath entries are resolved by the HOST dockerd, so HOST_WORKDIR must stay a
|
||||
# host path even when this runs inside the wizard container.
|
||||
render_kind_config() {
|
||||
local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"
|
||||
sed -e "s|\${CLUSTER}|${CLUSTER}|g" \
|
||||
-e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" \
|
||||
-e "s|\${HTTP_PORT}|${HTTP_PORT}|g" \
|
||||
-e "s|\${HOST_WORKDIR}|${host_workdir}|g" \
|
||||
"$KIND_CONFIG_PATH"
|
||||
}
|
||||
|
||||
_config_restore() {
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
if [ -n "$line" ]; then
|
||||
eval "export $line"
|
||||
fi
|
||||
done <<< "$1"
|
||||
# A while loop returns its last body command's status; the trailing empty
|
||||
# line would otherwise make this return 1 and trip `set -e` in the caller.
|
||||
return 0
|
||||
}
|
||||
313
rig/ctrl/newbox.sh
Executable file
313
rig/ctrl/newbox.sh
Executable file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a disposable Linux environment to validate the installer from a
|
||||
# genuinely clean slate — one that can be thrown away without touching the
|
||||
# environment you actually work in.
|
||||
#
|
||||
# This is the ONLY host-aware file in the tree. Everything else needs just a
|
||||
# Linux with Docker, which is what keeps other host types a later addition
|
||||
# rather than a rewrite.
|
||||
#
|
||||
# On WSL it creates a second distro. There is no .bat and no PowerShell script:
|
||||
# wsl.exe is callable from inside WSL, and wslpath converts the paths it wants.
|
||||
# A machine with no WSL at all needs `wsl --install` run once by hand first —
|
||||
# scripting a reboot-requiring Windows feature install is not worth it.
|
||||
#
|
||||
# Docker: borrowed by default, never installed twice
|
||||
# --------------------------------------------------
|
||||
# WSL2 distros share one kernel and one network stack, so two dockerd instances
|
||||
# contend over docker0 and iptables and can disturb the daemon you depend on.
|
||||
# (That is why Docker Desktop runs one daemon in a dedicated distro and shares
|
||||
# its socket rather than installing one per distro.)
|
||||
#
|
||||
# REUSE_DOCKER=1 (default) borrow the host distro's daemon over /mnt/wsl.
|
||||
# Nothing is installed; nothing can conflict.
|
||||
# Requires `ctrl/dockerhost.sh share` once on the
|
||||
# distro that owns Docker.
|
||||
# REUSE_DOCKER=0 install a second daemon in the new distro. Only
|
||||
# if you specifically want to test a from-scratch
|
||||
# Docker install, and not on a machine you need.
|
||||
#
|
||||
# Borrowing is also the more honest test: rig never installs Docker anyway — it
|
||||
# is the documented prerequisite — so a clean box does not need its own to
|
||||
# exercise everything rig actually does.
|
||||
#
|
||||
# Usage: newbox.sh create | destroy [--purge] | status | shell
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
REPO="$(cd .. && pwd)"
|
||||
|
||||
# The distro is named after this environment, and that derived name is the ONLY
|
||||
# thing this script will ever destroy. See guard_name().
|
||||
BOX="${BOX:-${CLUSTER}box}"
|
||||
BOX_USER="${BOX_USER:-dev}"
|
||||
|
||||
# Borrow the host distro's Docker rather than installing a second daemon.
|
||||
REUSE_DOCKER="${REUSE_DOCKER:-1}"
|
||||
SHARED_SOCK=/mnt/wsl/shared-docker/docker.sock
|
||||
|
||||
WSL_EXE=/mnt/c/Windows/System32/wsl.exe
|
||||
|
||||
# ── host detection ─────────────────────────────────────────────────────────
|
||||
|
||||
require_wsl() {
|
||||
if ! grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
cat >&2 <<'EOF'
|
||||
newbox is WSL-only for now.
|
||||
|
||||
On native Linux you do not need it: rig already isolates environments by
|
||||
directory (own cluster, context, images and port block), so a second copy in a
|
||||
second directory is the clean slate. To validate the installer itself against a
|
||||
bare system, run the wizard against a stock Debian container instead.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$WSL_EXE" ]; then
|
||||
echo "wsl.exe not found at $WSL_EXE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wsl_list() { "$WSL_EXE" -l -q 2>/dev/null | tr -d '\0\r'; }
|
||||
box_exists() { wsl_list | grep -qx "$BOX"; }
|
||||
|
||||
# `wsl --unregister` permanently deletes a distro's filesystem. The whole safety
|
||||
# story is this function: only the name derived from this directory can ever be
|
||||
# a target, so a typo or a stray argument cannot destroy the distro you work in.
|
||||
guard_name() {
|
||||
local derived="${CLUSTER}box"
|
||||
if [ "$BOX" != "$derived" ]; then
|
||||
echo "refusing: BOX='$BOX' is not the name derived from this directory ('$derived')." >&2
|
||||
echo "That guard exists because --unregister is irreversible." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CLUSTER" ] || [ "$BOX" = "box" ]; then
|
||||
echo "refusing: empty environment name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── create ─────────────────────────────────────────────────────────────────
|
||||
|
||||
rootfs_path() {
|
||||
local win_home; win_home=$(wslpath "$("$WSL_EXE" -d "$(wsl_list | head -1)" -e printf '%s' "$USERPROFILE" 2>/dev/null || true)" 2>/dev/null || true)
|
||||
# Simpler and reliable: use the current user's Windows home via /mnt/c.
|
||||
ls -d /mnt/c/Users/*/ 2>/dev/null | grep -viE '/(All Users|Default|Default User|Public)/$' | head -1
|
||||
}
|
||||
|
||||
build_rootfs() {
|
||||
local tar="$1"
|
||||
if [ -f "$tar" ]; then
|
||||
echo " rootfs cached: $(basename "$tar")"
|
||||
return
|
||||
fi
|
||||
echo " exporting a stock Debian rootfs (cached for next time)"
|
||||
local cid; cid=$(docker create debian:trixie-slim)
|
||||
docker export "$cid" > "$tar"
|
||||
docker rm -f "$cid" >/dev/null
|
||||
}
|
||||
|
||||
provision() {
|
||||
echo " provisioning (root)"
|
||||
local hosts_block
|
||||
hosts_block=$(CLUSTER="$CLUSTER" HTTP_PORT="$HTTP_PORT" \
|
||||
envsubst < ./hosts.tmpl 2>/dev/null || sed "s/\${CLUSTER}/$CLUSTER/g" ./hosts.tmpl)
|
||||
|
||||
# Piped as stdin rather than a second script file, the same shape as any
|
||||
# remote provisioning heredoc. Everything here is idempotent so a failed run
|
||||
# can simply be repeated.
|
||||
"$WSL_EXE" -d "$BOX" -u root -- bash -s <<PROVISION
|
||||
set -euo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl gnupg sudo >/dev/null
|
||||
|
||||
if [ "$REUSE_DOCKER" = "1" ]; then
|
||||
# Borrow the host distro's daemon: CLI only, no dockerd, nothing to
|
||||
# conflict with. The GID must match the owner's or the shared socket is
|
||||
# unreadable here even though it is visible.
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker-ce-cli >/dev/null
|
||||
|
||||
echo "export DOCKER_HOST=unix://$SHARED_SOCK" > /etc/profile.d/rig-docker-host.sh
|
||||
|
||||
if [ -f /mnt/wsl/shared-docker/OWNER ]; then
|
||||
gid=\$(awk '/docker gid:/ {print \$3}' /mnt/wsl/shared-docker/OWNER)
|
||||
if [ -n "\$gid" ]; then
|
||||
getent group docker >/dev/null && groupmod -g "\$gid" docker || groupadd -g "\$gid" docker
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# A second daemon. Only when deliberately testing a from-scratch install.
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \$(. /etc/os-release && echo \$VERSION_CODENAME) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker-ce docker-ce-cli containerd.io >/dev/null
|
||||
fi
|
||||
|
||||
id -u "$BOX_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$BOX_USER"
|
||||
usermod -aG sudo,docker "$BOX_USER"
|
||||
echo "$BOX_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-$BOX_USER
|
||||
chmod 0440 /etc/sudoers.d/90-$BOX_USER
|
||||
|
||||
# systemd is off by default in WSL, and Docker needs it. Takes effect on the
|
||||
# next start of this distro, which is why create() terminates it below.
|
||||
cat > /etc/wsl.conf <<WSLCONF
|
||||
[boot]
|
||||
systemd=true
|
||||
|
||||
[user]
|
||||
default=$BOX_USER
|
||||
WSLCONF
|
||||
|
||||
# The default inotify limits are low enough that file watching silently stops
|
||||
# working — no error, changes just stop being noticed. Fix it before it bites.
|
||||
cat > /etc/sysctl.d/99-rig.conf <<SYSCTL
|
||||
fs.inotify.max_user_watches=524288
|
||||
fs.inotify.max_user_instances=512
|
||||
SYSCTL
|
||||
|
||||
if ! grep -q 'rig environment' /etc/hosts 2>/dev/null; then
|
||||
{ echo ""; echo "# rig environment"; cat <<'HOSTS'
|
||||
$hosts_block
|
||||
HOSTS
|
||||
} >> /etc/hosts
|
||||
fi
|
||||
|
||||
touch /etc/rig-provisioned
|
||||
PROVISION
|
||||
}
|
||||
|
||||
create() {
|
||||
require_wsl
|
||||
guard_name
|
||||
|
||||
local winhome; winhome=$(rootfs_path)
|
||||
[ -n "$winhome" ] || { echo "could not locate the Windows user directory" >&2; exit 1; }
|
||||
local tar="${winhome}rig-rootfs.tar"
|
||||
local installdir="${winhome}WSL/${BOX}"
|
||||
|
||||
echo "creating '$BOX'"
|
||||
if [ "$REUSE_DOCKER" = "1" ]; then
|
||||
echo " docker: borrowing the host distro's daemon (nothing installed)"
|
||||
if [ ! -S "$SHARED_SOCK" ]; then
|
||||
echo
|
||||
echo " No shared socket yet. In the distro that owns Docker, run once:"
|
||||
echo " sudo bash ctrl/dockerhost.sh share"
|
||||
echo " That adds one systemd drop-in and nothing else; undo with 'unshare'."
|
||||
echo " Continuing — the box will be created, but Docker won't work in it"
|
||||
echo " until you do that."
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo " REUSE_DOCKER=0: installing a SECOND Docker daemon."
|
||||
echo " WSL distros share a network stack, so this can disturb Docker in"
|
||||
echo " the distro you work in. Ctrl-C now if that is a bad trade today."
|
||||
echo
|
||||
sleep 4
|
||||
fi
|
||||
echo
|
||||
|
||||
if box_exists; then
|
||||
echo " distro already registered"
|
||||
else
|
||||
build_rootfs "$tar"
|
||||
mkdir -p "$installdir"
|
||||
"$WSL_EXE" --import "$BOX" "$(wslpath -w "$installdir")" "$(wslpath -w "$tar")" --version 2
|
||||
fi
|
||||
|
||||
# Resumable: a partially-created box is finished rather than restarted.
|
||||
if "$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null; then
|
||||
echo " already provisioned"
|
||||
else
|
||||
provision
|
||||
echo " restarting the distro so systemd and group membership apply"
|
||||
"$WSL_EXE" --terminate "$BOX" # ONLY this distro; never --shutdown
|
||||
fi
|
||||
|
||||
echo " copying rig in"
|
||||
tar c -C "$REPO" --exclude=def --exclude=.git --exclude=ctrl/.env . \
|
||||
| "$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc "mkdir -p ~/rig && tar x -C ~/rig"
|
||||
|
||||
echo
|
||||
echo " docker: $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'systemctl is-active docker 2>/dev/null || echo inactive')"
|
||||
echo
|
||||
echo "next:"
|
||||
echo " make newbox shell # a shell inside it"
|
||||
echo " then: cd ~/rig && make station && make deps && make cluster up"
|
||||
echo
|
||||
echo "For a browser on Windows to resolve the hostnames, paste this into"
|
||||
echo "C:\\Windows\\System32\\drivers\\etc\\hosts (it has no wildcard support):"
|
||||
CLUSTER="$CLUSTER" envsubst < ./hosts.tmpl 2>/dev/null | grep -v '^#' | grep -v '^$' | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ── the rest ───────────────────────────────────────────────────────────────
|
||||
|
||||
destroy() {
|
||||
require_wsl
|
||||
guard_name
|
||||
|
||||
if ! box_exists; then
|
||||
echo "no distro '$BOX' to remove"
|
||||
else
|
||||
echo "about to PERMANENTLY delete the distro '$BOX' and its filesystem."
|
||||
"$WSL_EXE" --terminate "$BOX" 2>/dev/null || true
|
||||
"$WSL_EXE" --unregister "$BOX"
|
||||
echo " unregistered"
|
||||
fi
|
||||
|
||||
local winhome; winhome=$(rootfs_path)
|
||||
rm -rf "${winhome}WSL/${BOX}" 2>/dev/null || true
|
||||
|
||||
if [ "${1:-}" = "--purge" ]; then
|
||||
rm -f "${winhome}rig-rootfs.tar"
|
||||
echo " cached rootfs removed"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
require_wsl
|
||||
echo "environment $CLUSTER"
|
||||
echo "distro $BOX"
|
||||
if box_exists; then
|
||||
echo "registered yes"
|
||||
echo "provisioned $("$WSL_EXE" -d "$BOX" -u root -- test -f /etc/rig-provisioned 2>/dev/null && echo yes || echo no)"
|
||||
echo "docker $("$WSL_EXE" -d "$BOX" -u root -- bash -lc 'systemctl is-active docker 2>/dev/null' || echo unknown)"
|
||||
echo "rig copied $("$WSL_EXE" -d "$BOX" -u "$BOX_USER" -- bash -lc 'test -f ~/rig/Makefile && echo yes || echo no' 2>/dev/null)"
|
||||
else
|
||||
echo "registered no"
|
||||
fi
|
||||
echo
|
||||
echo "all distros (this one is never touched unless it is '$BOX'):"
|
||||
wsl_list | sed 's/^/ /'
|
||||
}
|
||||
|
||||
shell() {
|
||||
require_wsl
|
||||
box_exists || { echo "no distro '$BOX' — run 'make newbox' first" >&2; exit 1; }
|
||||
"$WSL_EXE" -d "$BOX" -u "$BOX_USER" --cd '~'
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
create) create ;;
|
||||
destroy) shift; destroy "${1:-}" ;;
|
||||
status) status ;;
|
||||
shell) shell ;;
|
||||
*) echo "usage: $0 [create|destroy [--purge]|status|shell]" >&2; exit 1 ;;
|
||||
esac
|
||||
103
rig/ctrl/ports.sh
Executable file
103
rig/ctrl/ports.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# Give each environment its own block of host ports.
|
||||
#
|
||||
# New versions of a system mean new clusters on ONE machine, not new machines.
|
||||
# Cluster name, kubectl context, registry container and image tag already derive
|
||||
# from the directory name, so two copies never collide there — but host ports are
|
||||
# a single shared namespace and would.
|
||||
#
|
||||
# The block is derived from the directory name: stateless, stable, and requiring
|
||||
# no coordination between copies that know nothing about each other.
|
||||
#
|
||||
# base = 20000 + (hash(slug) % 200) * 10
|
||||
# +0 HTTP +1 HTTPS +2 TILT +3 REGISTRY (+4..9 reserved)
|
||||
#
|
||||
# 20000+ deliberately avoids the ports something is already likely to hold: 80,
|
||||
# 443, 3000, 5432, 8000, 8080.
|
||||
#
|
||||
# Derivation is a default, not a decision. On first use the resolved block is
|
||||
# written into ctrl/.env, so it becomes pinned, visible and editable rather than
|
||||
# a number that appears from nowhere. Anything already in ctrl/.env wins.
|
||||
#
|
||||
# Usage: ports.sh show | derive | persist
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
|
||||
# derive_port_base lives in lib/config.sh so every script resolves the same block
|
||||
# without going through this one.
|
||||
derive_base() { derive_port_base "$1"; }
|
||||
|
||||
derive() {
|
||||
load_config
|
||||
local base; base=$(derive_base "$CLUSTER")
|
||||
DERIVED_HTTP=$base
|
||||
DERIVED_HTTPS=$((base + 1))
|
||||
DERIVED_TILT=$((base + 2))
|
||||
DERIVED_REGISTRY=$((base + 3))
|
||||
}
|
||||
|
||||
show() {
|
||||
derive
|
||||
echo "environment $CLUSTER"
|
||||
echo "derived base $(derive_base "$CLUSTER")"
|
||||
echo
|
||||
printf " %-14s %-8s %-8s %s\n" KEY DERIVED ACTIVE SOURCE
|
||||
_row HTTP_PORT "$DERIVED_HTTP"
|
||||
_row HTTPS_PORT "$DERIVED_HTTPS"
|
||||
_row TILT_PORT "$DERIVED_TILT"
|
||||
_row REGISTRY_PORT "$DERIVED_REGISTRY"
|
||||
}
|
||||
|
||||
_row() {
|
||||
local key="$1" derived="$2" active="${!1:-}" src="derived"
|
||||
if [ -n "$active" ] && [ "$active" != "$derived" ]; then
|
||||
src="override"
|
||||
elif [ -z "$active" ]; then
|
||||
active="$derived"
|
||||
fi
|
||||
printf " %-14s %-8s %-8s %s\n" "$key" "$derived" "$active" "$src"
|
||||
}
|
||||
|
||||
# Write the derived block into ctrl/.env, once. Existing keys are never
|
||||
# rewritten — an override stays an override.
|
||||
persist() {
|
||||
derive
|
||||
[ -f ./.env ] || cp ./.env.example ./.env
|
||||
|
||||
local wrote=0 key val
|
||||
for key in HTTP_PORT:$DERIVED_HTTP \
|
||||
HTTPS_PORT:$DERIVED_HTTPS \
|
||||
TILT_PORT:$DERIVED_TILT \
|
||||
REGISTRY_PORT:$DERIVED_REGISTRY; do
|
||||
val="${key#*:}"; key="${key%%:*}"
|
||||
if grep -qE "^${key}=[0-9]" ./.env 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
if [ "$wrote" -eq 0 ]; then
|
||||
{
|
||||
echo ""
|
||||
echo "# Port block for this environment, derived from the directory name"
|
||||
echo "# so copies never collide. Pinned here on first use — edit freely."
|
||||
} >> ./.env
|
||||
wrote=1
|
||||
fi
|
||||
# Replace a commented/empty placeholder if present, else append.
|
||||
if grep -qE "^#?\s*${key}=" ./.env 2>/dev/null; then
|
||||
sed -i "s|^#\?\s*${key}=.*|${key}=${val}|" ./.env
|
||||
else
|
||||
echo "${key}=${val}" >> ./.env
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$wrote" -eq 1 ] && echo "pinned port block into ctrl/.env" || echo "ports already set in ctrl/.env"
|
||||
return 0
|
||||
}
|
||||
|
||||
case "${1:-show}" in
|
||||
show) show ;;
|
||||
derive) derive; echo "$DERIVED_HTTP $DERIVED_HTTPS $DERIVED_TILT $DERIVED_REGISTRY" ;;
|
||||
persist) persist ;;
|
||||
*) echo "usage: $0 [show|derive|persist]" >&2; exit 1 ;;
|
||||
esac
|
||||
216
rig/ctrl/registry.sh
Executable file
216
rig/ctrl/registry.sh
Executable file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# Registry plumbing. THIS is the seam — not a tool.
|
||||
#
|
||||
# Four modes, selected by REGISTRY_MODE in the active profile:
|
||||
#
|
||||
# none Tilt builds straight into the node. No registry at all — and so no
|
||||
# guard against an outward push: an unqualified image name means
|
||||
# docker.io/library/<name>, and only Tilt's kind detection stands
|
||||
# between that and a real push. Throwaway use only; every profile
|
||||
# here now defaults to `local` instead.
|
||||
# local a registry:2 container wired into the cluster.
|
||||
# mirror the same container, but configured as a pull-through CACHE of the
|
||||
# corporate registry. What a locked-down client actually looks like:
|
||||
# images originate from corp, you don't hammer it, and you keep
|
||||
# working when the VPN drops.
|
||||
# remote no local container; pull straight from the corporate registry using
|
||||
# an imagePullSecret.
|
||||
#
|
||||
# Deliberately a script rather than a tool. ctlptl collapses the `local` wiring
|
||||
# into one line, but its Registry spec only accepts name/port/image/listenAddress
|
||||
# — there is no way to set REGISTRY_PROXY_REMOTEURL, so it cannot express
|
||||
# `mirror` at all. Keeping the seam here is what keeps the corporate registry
|
||||
# swappable.
|
||||
#
|
||||
# Usage: registry.sh up | down | status
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
REG_NAME="${CLUSTER}-registry"
|
||||
REG_PORT="${REGISTRY_PORT:-5005}"
|
||||
K="kubectl --context ${KUBECONTEXT}"
|
||||
|
||||
# ── CA trust ───────────────────────────────────────────────────────────────
|
||||
# A corporate registry is almost always fronted by an internal CA, and trust has
|
||||
# to reach three separate places. Nothing does this for you, and the symptom when
|
||||
# it's missing is an opaque:
|
||||
# x509: certificate signed by unknown authority
|
||||
#
|
||||
# 1. the host docker daemon — /etc/docker/certs.d/<host>/ca.crt (needs root)
|
||||
# 2. every kind node's containerd — nodes do NOT inherit host trust
|
||||
# 3. anything doing HTTPS from inside the cluster, in its own trust store
|
||||
#
|
||||
# We handle (2) here because it's ours to handle. (1) is reported by station.sh
|
||||
# since it needs root. (3) belongs to the workload.
|
||||
install_ca_into_nodes() {
|
||||
[ -n "${REGISTRY_CA_FILE:-}" ] || return 0
|
||||
|
||||
if [ ! -r "$REGISTRY_CA_FILE" ]; then
|
||||
echo "REGISTRY_CA_FILE is set but not readable: $REGISTRY_CA_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " distributing CA to kind nodes"
|
||||
local node
|
||||
for node in $(kind get nodes --name "$CLUSTER"); do
|
||||
docker cp "$REGISTRY_CA_FILE" "$node:/usr/local/share/ca-certificates/corp-registry.crt"
|
||||
docker exec "$node" update-ca-certificates >/dev/null 2>&1
|
||||
docker exec "$node" systemctl restart containerd
|
||||
done
|
||||
}
|
||||
|
||||
# Point containerd at a registry host. The cluster config already set
|
||||
# config_path=/etc/containerd/certs.d, so this is a per-node drop-in and needs no
|
||||
# cluster recreate — which is what lets registry mode change on a live cluster.
|
||||
write_hosts_toml() {
|
||||
local host="$1" upstream="$2" skip_verify="${3:-false}"
|
||||
local node
|
||||
for node in $(kind get nodes --name "$CLUSTER"); do
|
||||
docker exec "$node" mkdir -p "/etc/containerd/certs.d/${host}"
|
||||
docker exec -i "$node" cp /dev/stdin "/etc/containerd/certs.d/${host}/hosts.toml" <<TOML
|
||||
server = "${upstream}"
|
||||
|
||||
[host."${upstream}"]
|
||||
capabilities = ["pull", "resolve"]
|
||||
skip_verify = ${skip_verify}
|
||||
TOML
|
||||
done
|
||||
}
|
||||
|
||||
# ── the local container (local + mirror) ───────────────────────────────────
|
||||
|
||||
start_registry_container() {
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$REG_NAME" 2>/dev/null || true)" = "true" ]; then
|
||||
echo " registry container '$REG_NAME' already running"
|
||||
return
|
||||
fi
|
||||
docker rm -f "$REG_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
local args=(-d --restart=always --name "$REG_NAME"
|
||||
-p "127.0.0.1:${REG_PORT}:5000")
|
||||
|
||||
if [ "$REGISTRY_MODE" = "mirror" ]; then
|
||||
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
|
||||
echo "REGISTRY_MODE=mirror needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " starting pull-through cache of ${REGISTRY_REMOTE_URL}"
|
||||
args+=(-e "REGISTRY_PROXY_REMOTEURL=${REGISTRY_REMOTE_URL}")
|
||||
[ -n "${REGISTRY_USER:-}" ] && args+=(-e "REGISTRY_PROXY_USERNAME=${REGISTRY_USER}")
|
||||
[ -n "${REGISTRY_PASSWORD:-}" ] && args+=(-e "REGISTRY_PROXY_PASSWORD=${REGISTRY_PASSWORD}")
|
||||
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
|
||||
args+=(-v "$(readlink -f "$REGISTRY_CA_FILE"):/etc/ssl/certs/corp-ca.crt:ro")
|
||||
fi
|
||||
else
|
||||
echo " starting local registry"
|
||||
fi
|
||||
|
||||
docker run "${args[@]}" "$REGISTRY_IMAGE" >/dev/null
|
||||
}
|
||||
|
||||
# The registry must share a network with the nodes so they can resolve it by
|
||||
# container name; localhost inside a node is the node, not the host.
|
||||
join_kind_network() {
|
||||
if docker inspect -f '{{json .NetworkSettings.Networks}}' "$REG_NAME" | grep -q '"kind"'; then
|
||||
return
|
||||
fi
|
||||
docker network connect kind "$REG_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# The documented contract that tells tooling (Tilt, skaffold) where the local
|
||||
# registry is, so they don't have to be configured separately.
|
||||
apply_hosting_configmap() {
|
||||
$K apply -f - <<YAML >/dev/null
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${REG_PORT}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
YAML
|
||||
}
|
||||
|
||||
# ── modes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
up() {
|
||||
echo "registry: ${REGISTRY_MODE}"
|
||||
case "$REGISTRY_MODE" in
|
||||
none)
|
||||
echo " no registry — images are built straight into the node"
|
||||
;;
|
||||
|
||||
local|mirror)
|
||||
start_registry_container
|
||||
join_kind_network
|
||||
install_ca_into_nodes
|
||||
# Nodes reach the registry by container name on the shared network;
|
||||
# the host reaches it on localhost:PORT. Both names must resolve.
|
||||
write_hosts_toml "localhost:${REG_PORT}" "http://${REG_NAME}:5000"
|
||||
if [ "$REGISTRY_MODE" = "mirror" ]; then
|
||||
# Anything asking for docker.io transparently goes to the cache.
|
||||
write_hosts_toml "docker.io" "http://${REG_NAME}:5000"
|
||||
fi
|
||||
apply_hosting_configmap
|
||||
echo " ready at localhost:${REG_PORT}"
|
||||
;;
|
||||
|
||||
remote)
|
||||
if [ -z "${REGISTRY_REMOTE_URL:-}" ]; then
|
||||
echo "REGISTRY_MODE=remote needs REGISTRY_REMOTE_URL in ctrl/.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
install_ca_into_nodes
|
||||
local host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
|
||||
if [ -n "${REGISTRY_USER:-}" ]; then
|
||||
echo " creating imagePullSecret for ${host}"
|
||||
$K create secret docker-registry regcred \
|
||||
--docker-server="$host" \
|
||||
--docker-username="$REGISTRY_USER" \
|
||||
--docker-password="$REGISTRY_PASSWORD" \
|
||||
--dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
# Attach to the default ServiceAccount so plain pods inherit it.
|
||||
$K patch serviceaccount default \
|
||||
-p '{"imagePullSecrets":[{"name":"regcred"}]}' >/dev/null
|
||||
fi
|
||||
echo " pulling directly from ${host}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "unknown REGISTRY_MODE '$REGISTRY_MODE' (expected none|local|mirror|remote)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
down() {
|
||||
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
|
||||
echo "removing registry container '$REG_NAME'"
|
||||
docker rm -f "$REG_NAME" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
echo "mode ${REGISTRY_MODE}"
|
||||
if docker inspect "$REG_NAME" >/dev/null 2>&1; then
|
||||
echo "container ${REG_NAME} $(docker inspect -f '{{.State.Status}}' "$REG_NAME")"
|
||||
echo "endpoint localhost:${REG_PORT}"
|
||||
else
|
||||
echo "container none"
|
||||
fi
|
||||
[ -n "${REGISTRY_REMOTE_URL:-}" ] && echo "upstream ${REGISTRY_REMOTE_URL}"
|
||||
[ -n "${REGISTRY_CA_FILE:-}" ] && echo "ca ${REGISTRY_CA_FILE}"
|
||||
return 0
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
up) up ;;
|
||||
down) down ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 [up|down|status]" >&2; exit 1 ;;
|
||||
esac
|
||||
249
rig/ctrl/setup.sh
Executable file
249
rig/ctrl/setup.sh
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepare a machine to run rig, and say plainly what worked, what was already
|
||||
# done, and what is left for a human.
|
||||
#
|
||||
# This is the grouped entry point: `make setup`. Every step is idempotent and
|
||||
# independently checked, so running it twice is safe and running it on a
|
||||
# half-configured machine finishes the job rather than starting over.
|
||||
#
|
||||
# It deliberately does NOT abort on the first failure. A setup script that dies
|
||||
# at step 2 hides the fact that steps 4 and 5 were also going to fail — and on
|
||||
# an unfamiliar machine, the full picture is the whole point. Failures are
|
||||
# collected and reported together, and the exit code reflects the worst outcome.
|
||||
#
|
||||
# The same script runs inside a fresh throwaway distro (newbox), so the
|
||||
# provisioning path and the everyday path cannot drift apart.
|
||||
#
|
||||
# Usage:
|
||||
# setup.sh # host checks + the dev toolchain
|
||||
# setup.sh core # kubectl and jq only — no cluster tooling
|
||||
# setup.sh --share-docker # ...and offer this distro's Docker to others
|
||||
# setup.sh --cluster # ...and bring the cluster up
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
WITH_SHARE=0
|
||||
WITH_CLUSTER=0
|
||||
# Cluster tooling is not wanted everywhere: a managed or corporate-issued
|
||||
# machine may legitimately want kubectl and nothing that builds clusters.
|
||||
TIER=dev
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
core|dev) TIER="$a" ;;
|
||||
--share-docker) WITH_SHARE=1 ;;
|
||||
--cluster) WITH_CLUSTER=1 ;;
|
||||
*) echo "unknown option: $a" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$TIER" = "core" ] && [ "$WITH_CLUSTER" -eq 1 ]; then
|
||||
echo "core tier installs no cluster tooling, so --cluster cannot work" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── step framework ─────────────────────────────────────────────────────────
|
||||
# Statuses are deliberately distinct: "already" and "done" both mean success but
|
||||
# tell you very different things about the machine you are on.
|
||||
STEP_NAMES=()
|
||||
STEP_STATUS=()
|
||||
STEP_NOTE=()
|
||||
WORST=0
|
||||
|
||||
record() {
|
||||
STEP_NAMES+=("$1"); STEP_STATUS+=("$2"); STEP_NOTE+=("${3:-}")
|
||||
# Only a genuine failure is a non-zero exit. "manual" means the machine is
|
||||
# fine and you have something to do — reporting that as an error makes the
|
||||
# whole run look broken and trains people to ignore the output.
|
||||
[ "$2" = "fail" ] && WORST=1 || true
|
||||
local mark
|
||||
case "$2" in
|
||||
already) mark=" ok " ;;
|
||||
done) mark=" done " ;;
|
||||
skip) mark=" skip " ;;
|
||||
manual) mark="MANUAL" ;;
|
||||
fail) mark=" FAIL " ;;
|
||||
esac
|
||||
printf "[%s] %-22s %s\n" "$mark" "$1" "${3:-}"
|
||||
}
|
||||
|
||||
# ── steps ──────────────────────────────────────────────────────────────────
|
||||
|
||||
step_host() {
|
||||
local out
|
||||
if ! out=$(bash ./wizard.sh detect 2>&1); then
|
||||
record host fail "detection failed"
|
||||
return
|
||||
fi
|
||||
# Anything the wizard flagged with '!' needs a human; surface the count here
|
||||
# and the detail below rather than burying it.
|
||||
local warns; warns=$(echo "$out" | grep -c '^\s*!' || true)
|
||||
HOST_DETAIL="$out"
|
||||
if [ "$warns" -gt 0 ]; then
|
||||
record host manual "$warns item(s) need attention — see below"
|
||||
else
|
||||
record host already "no problems detected"
|
||||
fi
|
||||
}
|
||||
|
||||
step_toolchain() {
|
||||
local want="kubectl jq"
|
||||
[ "$TIER" = "dev" ] && want="$want kind tilt"
|
||||
|
||||
local missing=""
|
||||
for b in $want; do
|
||||
command -v "$b" >/dev/null 2>&1 || missing="$missing $b"
|
||||
done
|
||||
|
||||
if [ -z "$missing" ]; then
|
||||
record toolchain already "$TIER: $want"
|
||||
return
|
||||
fi
|
||||
|
||||
if bash ./wizard.sh install "$TIER" >/tmp/rig-deps.$$ 2>&1; then
|
||||
local still=""
|
||||
for b in $want; do
|
||||
[ -x "${OUT_BIN:-$HOME/.local/bin}/$b" ] || still="$still $b"
|
||||
done
|
||||
if [ -n "$still" ]; then
|
||||
record toolchain fail "still missing:$still (see /tmp/rig-deps.$$)"
|
||||
else
|
||||
record toolchain done "$TIER, installed:$missing"
|
||||
rm -f "/tmp/rig-deps.$$"
|
||||
fi
|
||||
else
|
||||
record toolchain fail "install failed — see /tmp/rig-deps.$$"
|
||||
fi
|
||||
}
|
||||
|
||||
step_path() {
|
||||
local bin="${OUT_BIN:-$HOME/.local/bin}"
|
||||
case ":$PATH:" in
|
||||
*":$bin:"*) ;;
|
||||
*) record path manual "add to ~/.bashrc: export PATH=\"$bin:\$PATH\""; return ;;
|
||||
esac
|
||||
if grep -qs "$bin" "$HOME/.bashrc" "$HOME/.profile" 2>/dev/null; then
|
||||
record path already "$bin on PATH and persisted"
|
||||
else
|
||||
record path manual "on PATH now, but not persisted in ~/.bashrc"
|
||||
fi
|
||||
}
|
||||
|
||||
step_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
record docker fail "no docker cli — this is the one prerequisite rig cannot install"
|
||||
return
|
||||
fi
|
||||
if docker info >/dev/null 2>&1; then
|
||||
record docker already "$(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
||||
else
|
||||
record docker fail "daemon unreachable (in the docker group? logged out and back in?)"
|
||||
fi
|
||||
}
|
||||
|
||||
step_share_docker() {
|
||||
if [ "$WITH_SHARE" -ne 1 ]; then
|
||||
record docker-share skip "not requested (--share-docker)"
|
||||
return
|
||||
fi
|
||||
if ! grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
record docker-share skip "not WSL — sharing only applies between WSL distros"
|
||||
return
|
||||
fi
|
||||
if [ -f /etc/systemd/system/docker.service.d/10-rig-shared-socket.conf ]; then
|
||||
record docker-share already "this distro is offering its Docker to others"
|
||||
return
|
||||
fi
|
||||
# Needs root, and asking mid-script is worse than telling the user the
|
||||
# single command to run.
|
||||
if [ "$(id -u)" -ne 0 ] && ! sudo -n true 2>/dev/null; then
|
||||
record docker-share manual "run: sudo bash ctrl/dockerhost.sh share"
|
||||
return
|
||||
fi
|
||||
if sudo bash ./dockerhost.sh share >/tmp/rig-share.$$ 2>&1; then
|
||||
record docker-share done "this distro now owns the shared Docker"
|
||||
rm -f "/tmp/rig-share.$$"
|
||||
else
|
||||
record docker-share fail "see /tmp/rig-share.$$"
|
||||
fi
|
||||
}
|
||||
|
||||
step_ports() {
|
||||
local busy=""
|
||||
for entry in "HTTP:$HTTP_PORT" "HTTPS:$HTTPS_PORT" "TILT:$TILT_PORT" "REGISTRY:$REGISTRY_PORT"; do
|
||||
local p="${entry#*:}"
|
||||
if command -v ss >/dev/null 2>&1 && ss -ltn "sport = :$p" 2>/dev/null | grep -q LISTEN; then
|
||||
busy="$busy ${entry%%:*}($p)"
|
||||
fi
|
||||
done
|
||||
if [ -n "$busy" ]; then
|
||||
record ports fail "in use:$busy — override in ctrl/.env or rename the directory"
|
||||
else
|
||||
record ports already "$HTTP_PORT-$REGISTRY_PORT free"
|
||||
fi
|
||||
}
|
||||
|
||||
step_cluster() {
|
||||
if [ "$TIER" = "core" ]; then
|
||||
record cluster skip "core tier — no cluster tooling on this machine"
|
||||
return
|
||||
fi
|
||||
if [ "$WITH_CLUSTER" -ne 1 ]; then
|
||||
record cluster skip "not requested (--cluster)"
|
||||
return
|
||||
fi
|
||||
if kind get clusters 2>/dev/null | grep -qx "$CLUSTER"; then
|
||||
record cluster already "'$CLUSTER' exists"
|
||||
return
|
||||
fi
|
||||
if bash ./cluster.sh up >/tmp/rig-cluster.$$ 2>&1; then
|
||||
record cluster done "'$CLUSTER' created"
|
||||
rm -f "/tmp/rig-cluster.$$"
|
||||
else
|
||||
record cluster fail "see /tmp/rig-cluster.$$"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── run ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "setting up '$CLUSTER'"
|
||||
echo
|
||||
HOST_DETAIL=""
|
||||
step_host
|
||||
step_toolchain
|
||||
step_path
|
||||
step_docker
|
||||
step_share_docker
|
||||
step_ports
|
||||
step_cluster
|
||||
|
||||
echo
|
||||
if [ -n "$HOST_DETAIL" ]; then
|
||||
echo "host detail"
|
||||
echo "$HOST_DETAIL" | sed 's/^/ /'
|
||||
echo
|
||||
fi
|
||||
|
||||
# Repeat only what still needs action, so the tail of the output is a to-do list
|
||||
# rather than a transcript.
|
||||
outstanding=0
|
||||
for i in "${!STEP_NAMES[@]}"; do
|
||||
case "${STEP_STATUS[$i]}" in
|
||||
fail|manual)
|
||||
[ "$outstanding" -eq 0 ] && echo "outstanding:"
|
||||
outstanding=1
|
||||
printf " %-8s %-16s %s\n" "${STEP_STATUS[$i]}" "${STEP_NAMES[$i]}" "${STEP_NOTE[$i]}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$outstanding" -eq 0 ]; then
|
||||
echo "ready. next: make cluster up && make docs"
|
||||
else
|
||||
echo
|
||||
echo "(nothing was aborted — every step ran so the list above is complete)"
|
||||
fi
|
||||
|
||||
exit "$WORST"
|
||||
106
rig/ctrl/station.sh
Executable file
106
rig/ctrl/station.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# Station check: is this workstation ready to run rig?
|
||||
#
|
||||
# Reports and instructs; never silently fixes anything. Everything it finds is
|
||||
# either already fine, or something a human has to decide on.
|
||||
#
|
||||
# Runs the wizard's host detection in a container when Docker is the only thing
|
||||
# installed, or directly when the toolchain is already present. Then adds the
|
||||
# checks that need this repo's config: profile sanity, CA trust, port clashes.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
WIZARD_IMAGE="${WIZARD_IMAGE:-$(basename "$(cd .. && pwd)")-wizard}"
|
||||
|
||||
# Host detection. Prefer running it bare — it needs no dependencies beyond
|
||||
# coreutils — and fall back to the container only if this shell can't.
|
||||
bash ./wizard.sh detect
|
||||
|
||||
# ── repo-level checks ──────────────────────────────────────────────────────
|
||||
|
||||
source ./lib/config.sh
|
||||
load_config
|
||||
|
||||
echo
|
||||
echo "config"
|
||||
echo " profile ${PROFILE_NAME} (nodes=${NODES} audit=${AUDIT})"
|
||||
echo " cluster ${CLUSTER} (context ${KUBECONTEXT})"
|
||||
echo " registry ${REGISTRY_MODE}"
|
||||
echo " ingress ${INGRESS_MODE}"
|
||||
|
||||
if [ ! -f ./.env ]; then
|
||||
echo " ! ctrl/.env missing — copy it: cp ctrl/.env.example ctrl/.env"
|
||||
fi
|
||||
|
||||
# A 3-node profile on a box that's already full is the most common first
|
||||
# failure, and it presents as pods stuck Pending rather than anything obvious.
|
||||
avail=$(awk '/^MemAvailable:/{printf "%d", $2/1024/1024}' /proc/meminfo)
|
||||
need=$((NODES * 2))
|
||||
if [ "$avail" -lt "$need" ]; then
|
||||
echo " ! profile '${PROFILE_NAME}' wants ~${need} GB, ${avail} GB available"
|
||||
echo " 'make cluster list' shows what else is running; 'make cluster free' stops it"
|
||||
fi
|
||||
|
||||
# The CA reaches three places and only one of them is ours. Report the other two.
|
||||
if [ -n "${REGISTRY_CA_FILE:-}" ]; then
|
||||
echo
|
||||
echo "registry CA"
|
||||
if [ ! -r "$REGISTRY_CA_FILE" ]; then
|
||||
echo " ! REGISTRY_CA_FILE not readable: $REGISTRY_CA_FILE"
|
||||
else
|
||||
echo " file $REGISTRY_CA_FILE"
|
||||
host="${REGISTRY_REMOTE_URL#*://}"; host="${host%%/*}"
|
||||
if [ -n "$host" ] && [ ! -f "/etc/docker/certs.d/${host}/ca.crt" ]; then
|
||||
echo " ! the HOST docker daemon does not trust it yet:"
|
||||
echo " sudo mkdir -p /etc/docker/certs.d/${host}"
|
||||
echo " sudo cp ${REGISTRY_CA_FILE} /etc/docker/certs.d/${host}/ca.crt"
|
||||
echo " (kind nodes are handled by registry.sh; in-cluster clients are the workload's job)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Host ports this environment will try to bind. Checked before cluster creation
|
||||
# because docker reports a clash halfway through, as an opaque
|
||||
# "failed to bind host port ...: address already in use".
|
||||
echo
|
||||
echo "ports (block derived from the directory name — see 'make ports')"
|
||||
|
||||
port_busy() {
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn "sport = :$1" 2>/dev/null | grep -q LISTEN && return 0 || return 1
|
||||
fi
|
||||
# iproute2 is absent from a minimal Debian, so fall back to procfs rather
|
||||
# than silently reporting everything as free.
|
||||
local hex; hex=$(printf ':%04X' "$1")
|
||||
grep -qi "^ *[0-9]*: [0-9A-F]*$hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null
|
||||
}
|
||||
|
||||
# A port held by THIS environment's own cluster is not a clash — it is the thing
|
||||
# working. Reporting it as a problem every time the cluster is up would train
|
||||
# people to ignore this section, which is the opposite of the point.
|
||||
# Extract with a second grep rather than `tr -d ':->'`: in tr, ':->' is the
|
||||
# character RANGE ':' to '>', which does not contain '-', so the trailing dash
|
||||
# survives and nothing ever matches.
|
||||
ours=$(docker ps --filter "label=io.x-k8s.kind.cluster=${CLUSTER}" \
|
||||
--format '{{.Ports}}' 2>/dev/null | tr ',' '\n' \
|
||||
| grep -oE ':[0-9]+->' | grep -oE '[0-9]+' || true)
|
||||
|
||||
clash=0
|
||||
for entry in "HTTP:${HTTP_PORT}" "HTTPS:${HTTPS_PORT}" \
|
||||
"TILT:${TILT_PORT}" "REGISTRY:${REGISTRY_PORT}"; do
|
||||
name="${entry%%:*}"; p="${entry#*:}"
|
||||
[ -n "$p" ] || continue
|
||||
if ! port_busy "$p"; then
|
||||
printf " %-9s %-6s free\n" "$name" "$p"
|
||||
elif echo "$ours" | grep -qx "$p"; then
|
||||
printf " %-9s %-6s in use by this environment's cluster\n" "$name" "$p"
|
||||
else
|
||||
printf " ! %-9s %-6s IN USE by something else\n" "$name" "$p"
|
||||
clash=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$clash" -eq 1 ]; then
|
||||
echo " override the clashing one in ctrl/.env, e.g. HTTP_PORT=21080"
|
||||
echo " (or rename this directory — the whole block follows the name)"
|
||||
fi
|
||||
54
rig/ctrl/versions.env
Normal file
54
rig/ctrl/versions.env
Normal file
@@ -0,0 +1,54 @@
|
||||
# Pinned toolchain — the single manifest the wizard installs from.
|
||||
# Every entry is a single binary; none of them needs an apt repo.
|
||||
# kubectl fully static
|
||||
# kind libc only
|
||||
# tilt libc + libstdc++ + libgcc (present in base Debian)
|
||||
# jq upstream static build (Debian's is linked against libjq/libonig)
|
||||
#
|
||||
# Checksums are the upstream-published SHA256 of the linux/amd64 artifact.
|
||||
# To bump: change the version, then re-run `bash ctrl/versions-refresh.sh` and
|
||||
# commit the result — never hand-edit a checksum.
|
||||
|
||||
KIND_VERSION=v0.32.0
|
||||
KIND_SHA256=50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54
|
||||
KIND_URL=https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64
|
||||
|
||||
KUBECTL_VERSION=v1.36.3
|
||||
KUBECTL_SHA256=ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336
|
||||
KUBECTL_URL=https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl
|
||||
|
||||
TILT_VERSION=0.37.6
|
||||
TILT_SHA256=e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6
|
||||
TILT_URL=https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/tilt.${TILT_VERSION}.linux.x86_64.tar.gz
|
||||
|
||||
JQ_VERSION=1.8.2
|
||||
JQ_SHA256=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f
|
||||
JQ_URL=https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64
|
||||
|
||||
# Node images shipped with KIND_VERSION above, pinned by digest so a kind upgrade
|
||||
# can never silently move the k8s version. Profiles select one via K8S_VERSION.
|
||||
# Older entries are kept deliberately: running a trailing-edge control plane is
|
||||
# part of simulating a legacy estate.
|
||||
NODE_IMAGE_v1_36=kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
|
||||
NODE_IMAGE_v1_35=kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95
|
||||
NODE_IMAGE_v1_34=kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256
|
||||
NODE_IMAGE_v1_33=kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4
|
||||
|
||||
# Images pulled at runtime (registry, mocks). Pinned by tag; the registry mode
|
||||
# decides where they are pulled FROM.
|
||||
REGISTRY_IMAGE=registry:2
|
||||
STUB_IMAGE=python:3.12-slim
|
||||
|
||||
# Addons, installed by ctrl/addons/<name>.sh when listed in a profile's ADDONS.
|
||||
CERT_MANAGER_VERSION=v1.21.1
|
||||
METRICS_SERVER_VERSION=v0.9.0
|
||||
METALLB_VERSION=v0.16.0
|
||||
|
||||
# Dependency containers. These mirror soleprint's cabinets
|
||||
# (soleprint/station/cabinets/), so a room that declares postgres gets the same
|
||||
# thing whether it runs on compose or in the cluster. Pinned by tag rather than
|
||||
# digest because they are ordinary upstream images with no supply chain claim
|
||||
# attached — bump freely, and preload them for the offline profile.
|
||||
POSTGRES_IMAGE=postgres:16-alpine
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
AIRFLOW_IMAGE=apache/airflow:2.10.4
|
||||
381
rig/ctrl/wizard.sh
Executable file
381
rig/ctrl/wizard.sh
Executable file
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env bash
|
||||
# The installation wizard: detect the host, install a pinned toolchain onto it,
|
||||
# then report what it could not do. It never runs the cluster and never mutates
|
||||
# the host outside the directories mounted into it.
|
||||
#
|
||||
# Usage (normally via `make station` / `make deps`, or directly):
|
||||
# wizard.sh detect # report host facts only, change nothing
|
||||
# wizard.sh fetch [core|dev] [--to DIR] # download + verify into DIR
|
||||
# wizard.sh install [core|dev] # detect, fetch, install, report
|
||||
#
|
||||
# Tiers: 'core' is kubectl + jq (talk to a cluster); 'dev' adds kind and tilt
|
||||
# Default is dev.
|
||||
#
|
||||
# Runs both inside the wizard container and bare on a host. Inside the
|
||||
# container, host files are read through $HOST_ROOT (mount / as :ro); bare, it
|
||||
# falls back to /.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Keep the caller's cwd so a relative --to resolves where the user expects,
|
||||
# not against ctrl/ once we've moved.
|
||||
INVOKED_FROM="$PWD"
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ./versions.env
|
||||
|
||||
# Resolve a possibly-relative path against the caller's original directory.
|
||||
abspath() {
|
||||
case "$1" in
|
||||
/*) echo "$1" ;;
|
||||
*) echo "$INVOKED_FROM/$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
OUT_BIN="${OUT_BIN:-$HOME/.local/bin}"
|
||||
HOST_ROOT="${HOST_ROOT:-/}"
|
||||
DEPS_SOURCE="${DEPS_SOURCE:-upstream}"
|
||||
DEPS_ARTIFACTORY_URL="${DEPS_ARTIFACTORY_URL:-}"
|
||||
BAKED_BIN="${BAKED_BIN:-/opt/rig/bin}"
|
||||
|
||||
# Collected by detect(), printed by report_manual() at the very end.
|
||||
MANUAL=()
|
||||
|
||||
# Host FILES (/etc/..., /mnt/c/...) must be read through the mount. Kernel-level
|
||||
# facts (kernel version, meminfo, inotify) are shared with the container, so the
|
||||
# container's own view is already the host's.
|
||||
host_file() {
|
||||
local p="${1#/}"
|
||||
if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then
|
||||
echo "$HOST_ROOT/$p"
|
||||
else
|
||||
echo "/$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── detect ─────────────────────────────────────────────────────────────────
|
||||
|
||||
is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; }
|
||||
|
||||
detect() {
|
||||
echo "host"
|
||||
echo " kernel $(uname -r)"
|
||||
|
||||
local osr; osr=$(host_file /etc/os-release)
|
||||
[ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")"
|
||||
|
||||
local total_kb avail_kb
|
||||
total_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)
|
||||
avail_kb=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo)
|
||||
printf " memory %d GB total, %d GB available\n" \
|
||||
$((total_kb / 1024 / 1024)) $((avail_kb / 1024 / 1024))
|
||||
|
||||
if [ $((avail_kb / 1024 / 1024)) -lt 4 ]; then
|
||||
echo " ! under 4 GB available — a multi-node profile will struggle."
|
||||
echo " 'make cluster list' shows the others; 'make cluster free' stops them."
|
||||
fi
|
||||
|
||||
detect_wsl
|
||||
detect_docker
|
||||
detect_inotify
|
||||
}
|
||||
|
||||
detect_wsl() {
|
||||
if ! is_wsl; then
|
||||
echo " platform native linux"
|
||||
return
|
||||
fi
|
||||
|
||||
echo " platform WSL"
|
||||
|
||||
# systemd is off by default in WSL, and the ingress/DNS paths that use a
|
||||
# host service need it. Enabling it requires a Windows-side restart, which
|
||||
# cannot be issued from inside the distro.
|
||||
local wc; wc=$(host_file /etc/wsl.conf)
|
||||
if [ -r "$wc" ] && grep -qE '^\s*systemd\s*=\s*true' "$wc"; then
|
||||
echo " systemd enabled in wsl.conf"
|
||||
else
|
||||
echo " ! systemd not enabled in /etc/wsl.conf"
|
||||
MANUAL+=("Enable systemd — add to /etc/wsl.conf:
|
||||
[boot]
|
||||
systemd=true
|
||||
then from a WINDOWS terminal (not this shell): wsl --shutdown")
|
||||
fi
|
||||
|
||||
# WSL regenerates /etc/resolv.conf on every boot, which silently reverts any
|
||||
# local DNS setup.
|
||||
if [ -r "$wc" ] && grep -qE '^\s*generateResolvConf\s*=\s*false' "$wc"; then
|
||||
echo " resolv.conf pinned (generateResolvConf=false)"
|
||||
else
|
||||
echo " - resolv.conf is WSL-generated; DNS_MODE=dnsmasq would be reverted on reboot"
|
||||
fi
|
||||
|
||||
local wcfg
|
||||
wcfg=$(ls "$HOST_ROOT"/mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true)
|
||||
if [ -n "$wcfg" ] && grep -qE '^\s*memory\s*=' "$wcfg"; then
|
||||
echo " wslconfig memory set: $(grep -E '^\s*memory\s*=' "$wcfg" | tr -d ' ')"
|
||||
else
|
||||
MANUAL+=("Cap/raise the WSL VM memory — in %USERPROFILE%\\.wslconfig on Windows:
|
||||
[wsl2]
|
||||
memory=8GB
|
||||
then from a WINDOWS terminal: wsl --shutdown")
|
||||
fi
|
||||
}
|
||||
|
||||
detect_docker() {
|
||||
# Reachability of the daemon is the real question, and the CLI is only how
|
||||
# we ask it. Note that when this runs inside the wizard container, Docker
|
||||
# necessarily exists on the host — otherwise nothing would be executing —
|
||||
# so a missing CLI in here is a wizard packaging bug, not a host problem.
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
echo " docker socket present (no cli in this context)"
|
||||
else
|
||||
echo " ! docker not found and no socket at /var/run/docker.sock"
|
||||
MANUAL+=("Install Docker — the one true prerequisite:
|
||||
sudo apt-get install -y docker.io && sudo usermod -aG docker \"\$USER\"
|
||||
then log out and back in.")
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null)"
|
||||
local n
|
||||
n=$(docker ps --filter "label=io.x-k8s.kind.cluster" --format '{{.Names}}' 2>/dev/null | wc -l)
|
||||
# Must be an `if`, not `[ ] && echo`: as the last statement in this
|
||||
# function the latter returns 1 when the count is zero, and `set -e`
|
||||
# then kills the caller. That is the fresh-machine case — no clusters
|
||||
# yet — so the bug only ever shows up where it does most harm.
|
||||
if [ "$n" -gt 0 ]; then
|
||||
echo " - $n kind node container(s) already running; see 'make cluster list'"
|
||||
fi
|
||||
else
|
||||
echo " ! docker cli present but the daemon is unreachable"
|
||||
MANUAL+=("Start Docker, or add yourself to the docker group:
|
||||
sudo usermod -aG docker \"\$USER\" # then log out and back in")
|
||||
fi
|
||||
}
|
||||
|
||||
# kind and Tilt both watch large trees. WSL ships defaults (8192/128) far too low,
|
||||
# and the failure mode is silent: Tilt simply stops noticing file changes.
|
||||
detect_inotify() {
|
||||
local w i
|
||||
w=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null || echo 0)
|
||||
i=$(cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null || echo 0)
|
||||
echo " inotify watches=$w instances=$i"
|
||||
|
||||
if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then
|
||||
echo " ! inotify limits are low — Tilt will silently stop noticing file changes"
|
||||
MANUAL+=("Raise inotify limits (needs root on the host):
|
||||
echo -e 'fs.inotify.max_user_watches=524288\\nfs.inotify.max_user_instances=512' \\
|
||||
| sudo tee /etc/sysctl.d/99-rig.conf
|
||||
sudo sysctl --system")
|
||||
fi
|
||||
}
|
||||
|
||||
# ── fetch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Resolve where a given artifact comes from, honouring DEPS_SOURCE.
|
||||
resolve_url() {
|
||||
local upstream="$1"
|
||||
case "$DEPS_SOURCE" in
|
||||
upstream) echo "$upstream" ;;
|
||||
artifactory)
|
||||
if [ -z "$DEPS_ARTIFACTORY_URL" ]; then
|
||||
echo "DEPS_SOURCE=artifactory but DEPS_ARTIFACTORY_URL is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${DEPS_ARTIFACTORY_URL%/}/$(basename "$upstream")"
|
||||
;;
|
||||
*) echo "unsupported DEPS_SOURCE '$DEPS_SOURCE' for a download" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify() {
|
||||
local file="$1" want="$2" name="$3" got
|
||||
got=$(sha256sum "$file" | awk '{print $1}')
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "checksum mismatch for $name" >&2
|
||||
echo " expected $want" >&2
|
||||
echo " got $got" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# fetch_bin <name> <url> <sha256> <dest-dir> — a bare binary
|
||||
fetch_bin() {
|
||||
local name="$1" url="$2" sha="$3" dest="$4"
|
||||
local tmp="$dest/.$name.tmp"
|
||||
echo " fetching $name"
|
||||
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
|
||||
verify "$tmp" "$sha" "$name"
|
||||
mv "$tmp" "$dest/$name"
|
||||
chmod +x "$dest/$name"
|
||||
}
|
||||
|
||||
# fetch_tgz <name> <url> <sha256> <dest-dir> <path-inside-archive> <strip>
|
||||
# Archive layouts differ — tilt's is flat (the binary at the root, strip=0),
|
||||
# others nest it a directory down — so the caller says which.
|
||||
fetch_tgz() {
|
||||
local name="$1" url="$2" sha="$3" dest="$4" inner="$5" strip="$6"
|
||||
local tmp="$dest/.$name.tgz"
|
||||
echo " fetching $name"
|
||||
curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")"
|
||||
verify "$tmp" "$sha" "$name"
|
||||
# --no-same-owner: extracting as root would otherwise restore the uid/gid
|
||||
# baked into the archive (some ship as uid 1001), leaving a binary the host
|
||||
# user does not own.
|
||||
tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner"
|
||||
rm -f "$tmp"
|
||||
chmod +x "$dest/$name"
|
||||
}
|
||||
|
||||
# The wizard runs as root so it can reach the docker socket, which means
|
||||
# everything it writes into a mounted volume lands root-owned and unusable from
|
||||
# the host. Hand it back to whoever owns the mount point (the host user created
|
||||
# that directory before mounting it).
|
||||
fix_ownership() {
|
||||
local dir="$1"
|
||||
[ -d "$dir" ] || return 0
|
||||
local owner="${HOST_UID:-}:${HOST_GID:-}"
|
||||
if [ "$owner" = ":" ]; then
|
||||
owner=$(stat -c '%u:%g' "$dir")
|
||||
fi
|
||||
[ "$owner" = "0:0" ] && return 0
|
||||
chown -R "$owner" "$dir" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Two tiers, because not every machine should get cluster tooling.
|
||||
#
|
||||
# core kubectl, jq — talk to a cluster someone else runs. Nothing that
|
||||
# creates one. Appropriate on a managed or corporate-issued machine
|
||||
# where development tools are not wanted by default.
|
||||
# dev core plus kind and tilt — build clusters and hot-reload into them.
|
||||
#
|
||||
# The split exists because "install the toolchain" is not one decision: on a
|
||||
# managed workspace the right answer is kubectl and nothing else.
|
||||
CORE_TOOLS="kubectl jq"
|
||||
# No helm: every addon installs with `kubectl apply -f <url>`, so nothing here
|
||||
# has ever invoked it. Add it back the day something actually needs a chart.
|
||||
DEV_TOOLS="kind tilt"
|
||||
|
||||
fetch() {
|
||||
local dest="$OUT_BIN" tier="${TIER:-dev}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--to) dest="$2"; shift 2 ;;
|
||||
core|dev) tier="$1"; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
dest="$(abspath "$dest")"
|
||||
mkdir -p "$dest"
|
||||
TIER="$tier"
|
||||
|
||||
if [ "$DEPS_SOURCE" = "baked" ]; then
|
||||
echo "installing baked binaries from $BAKED_BIN"
|
||||
cp -a "$BAKED_BIN"/. "$dest"/
|
||||
fix_ownership "$dest"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)"
|
||||
fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"
|
||||
fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"
|
||||
if [ "$tier" = "dev" ]; then
|
||||
fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"
|
||||
fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0
|
||||
fi
|
||||
|
||||
fix_ownership "$dest"
|
||||
# kind writes the kubeconfig as root too; hand that back as well when it's
|
||||
# a mounted host directory rather than container-local state.
|
||||
fix_ownership "${KUBE_DIR:-/out/kube}"
|
||||
}
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────
|
||||
|
||||
report_manual() {
|
||||
echo
|
||||
if [ ${#MANUAL[@]} -eq 0 ]; then
|
||||
echo "nothing left to do by hand."
|
||||
return
|
||||
fi
|
||||
echo "host actions the wizard cannot perform (${#MANUAL[@]}):"
|
||||
echo
|
||||
local n=1
|
||||
for m in "${MANUAL[@]}"; do
|
||||
echo " $n. $m"
|
||||
echo
|
||||
n=$((n + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# Installing into a directory that sits early in PATH silently replaces whatever
|
||||
# the machine was already using — which on a shared or client machine can break
|
||||
# unrelated work (kubectl more than one minor away from a cluster is the common
|
||||
# one). Say so; never decide it for them.
|
||||
tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; }
|
||||
|
||||
warn_shadowing() {
|
||||
local b existing shadowed="" tier="${1:-dev}"
|
||||
for b in $(tier_tools "$tier"); do
|
||||
[ -x "$OUT_BIN/$b" ] || continue
|
||||
# Where would this resolve if OUT_BIN weren't in the way?
|
||||
existing=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -vx "$OUT_BIN" | paste -sd:) \
|
||||
command -v "$b" 2>/dev/null || true)
|
||||
[ -n "$existing" ] || continue
|
||||
[ "$existing" = "$OUT_BIN/$b" ] && continue
|
||||
shadowed+=" $b $existing"$'\n'
|
||||
done
|
||||
|
||||
[ -n "$shadowed" ] || return 0
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":$OUT_BIN:"*) ;;
|
||||
*) return 0 ;; # not on PATH yet, so nothing is being shadowed
|
||||
esac
|
||||
|
||||
echo
|
||||
echo " ! these were already installed elsewhere and are now shadowed by $OUT_BIN:"
|
||||
printf '%s' "$shadowed"
|
||||
echo " Other projects on this machine will pick up the new versions."
|
||||
MANUAL+=("Decide which toolchain wins. To keep the previous one, remove what
|
||||
was just installed:
|
||||
rm -f $(for b in $(tier_tools "$tier"); do printf '%s ' "$OUT_BIN/$b"; done)
|
||||
Or install somewhere private instead:
|
||||
OUT_BIN=\$PWD/def/bin make deps # then put that dir first in PATH")
|
||||
}
|
||||
|
||||
install() {
|
||||
local tier="${1:-dev}"
|
||||
detect
|
||||
echo
|
||||
fetch "$tier"
|
||||
echo
|
||||
echo "installed to $OUT_BIN ($tier):"
|
||||
for b in $(tier_tools "$tier"); do
|
||||
[ -x "$OUT_BIN/$b" ] && echo " $b"
|
||||
done
|
||||
if [ "$tier" = "core" ]; then
|
||||
echo " (no kind/tilt — 'make deps dev' adds them)"
|
||||
fi
|
||||
warn_shadowing "$tier"
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":$OUT_BIN:"*) ;;
|
||||
*) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc:
|
||||
export PATH=\"${OUT_BIN}:\$PATH\"") ;;
|
||||
esac
|
||||
|
||||
report_manual
|
||||
}
|
||||
|
||||
# ── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-install}" in
|
||||
detect) detect; report_manual ;;
|
||||
fetch) shift; fetch "$@" ;;
|
||||
install) shift; install "${1:-dev}" ;;
|
||||
*) echo "usage: $0 [detect|fetch|install]" >&2; exit 1 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user