add isolated live development deployments

This commit is contained in:
2026-07-21 20:58:12 +00:00
parent 12711bc3fa
commit 872529b3d9
16 changed files with 322 additions and 8 deletions

View File

@@ -1,3 +1,4 @@
.git
chart
deploy
dev
README.md

View File

@@ -1,5 +1,5 @@
FROM nginx:1.29-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY src/index.html /usr/share/nginx/html/index.html
EXPOSE 80

View File

@@ -1,7 +1,7 @@
# Minikube hello
A minimal NGINX image deployed to Minikube with Helm at
`http://hello.k.mancave.link`.
A minimal NGINX application with separate production and live-edit
development deployments.
## Build and push
@@ -10,12 +10,35 @@ docker build -t git.fisoft.eu/ficik/minikube/hello:latest .
docker push git.fisoft.eu/ficik/minikube/hello:latest
```
## Deploy
## Production deployment
Create a `kubernetes.io/dockerconfigjson` pull secret named
`git-fisoft-registry` in the `hello` namespace with credentials for
`git.fisoft.eu`, then install the chart:
```bash
helm upgrade --install hello ./chart --namespace hello --create-namespace
helm upgrade --install hello ./deploy/helm --namespace hello --create-namespace
```
## Live development
Each development instance gets its own URL, Kubernetes label, and shared source
directory. The scripts default to the current repository directory name, or an
instance can be supplied explicitly:
```bash
./dev/start.sh hello
./dev/start.sh feature-one feature-one.control.k.mancave.link
```
Edits anywhere under `src/` are mirrored into the running NGINX pod. Stop an
instance and permanently remove its Kubernetes resources, sync process, and
shared files with:
```bash
./dev/stop.sh hello
```
The scripts use namespace `vibes`, PVC `control-vibes-control-data`, and domain
`control.k.mancave.link` by default. Override them with `DEV_NAMESPACE`,
`DEV_PVC`, `DEV_SHARED_ROOT`, `DEV_PVC_SUBPATH_ROOT`, or `DEV_DOMAIN`.

6
dev/helm/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: hello-dev
description: Live-edit development deployment for the hello application
type: application
version: 0.1.0
appVersion: "1.29"

View File

@@ -0,0 +1,46 @@
{{- $instance := required "instance is required" .Values.instance -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name | quote }}
labels:
app.kubernetes.io/name: {{ .Chart.Name | quote }}
app.kubernetes.io/instance: {{ $instance | quote }}
dev.mancave.link/instance: {{ $instance | quote }}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name | quote }}
dev.mancave.link/instance: {{ $instance | quote }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name | quote }}
app.kubernetes.io/instance: {{ $instance | quote }}
dev.mancave.link/instance: {{ $instance | quote }}
spec:
containers:
- name: nginx
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
livenessProbe:
httpGet:
path: /
port: http
volumeMounts:
- name: source
mountPath: /usr/share/nginx/html
subPath: {{ required "sharedVolume.subPath is required" .Values.sharedVolume.subPath | quote }}
volumes:
- name: source
persistentVolumeClaim:
claimName: {{ .Values.sharedVolume.claimName | quote }}

View File

@@ -0,0 +1,23 @@
{{- $instance := required "instance is required" .Values.instance -}}
{{- $host := required "ingress.host is required" .Values.ingress.host -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name | quote }}
labels:
app.kubernetes.io/name: {{ .Chart.Name | quote }}
app.kubernetes.io/instance: {{ $instance | quote }}
dev.mancave.link/instance: {{ $instance | quote }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ $host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name | quote }}
port:
number: {{ .Values.service.port }}

View File

@@ -0,0 +1,19 @@
{{- $instance := required "instance is required" .Values.instance -}}
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name | quote }}
labels:
app.kubernetes.io/name: {{ .Chart.Name | quote }}
app.kubernetes.io/instance: {{ $instance | quote }}
dev.mancave.link/instance: {{ $instance | quote }}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ .Chart.Name | quote }}
dev.mancave.link/instance: {{ $instance | quote }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP

17
dev/helm/values.yaml Normal file
View File

@@ -0,0 +1,17 @@
instance: ""
image:
repository: nginx
tag: 1.29-alpine
pullPolicy: IfNotPresent
sharedVolume:
claimName: control-vibes-control-data
subPath: ""
service:
port: 80
ingress:
className: nginx
host: ""

124
dev/start.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
validate_instance() {
local instance=$1
if [[ ${#instance} -gt 42 || ! $instance =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
echo "instance must be 1-42 lowercase letters, digits, or internal hyphens: $instance" >&2
exit 2
fi
}
sync_tree() {
local source_dir=$1
local destination=$2
mkdir -p -- "$destination"
find "$destination" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
cp -a -- "$source_dir/." "$destination/"
}
source_signature() {
local source_dir=$1
find "$source_dir" -printf '%P\0%y\0%s\0%T@\0%l\0' | sort -z | sha256sum
}
is_sync_process() {
local pid=$1
local instance=$2
local command
[[ $pid =~ ^[0-9]+$ && -r /proc/$pid/cmdline ]] || return 1
command=$(tr '\0' ' ' <"/proc/$pid/cmdline")
[[ $command == *"start.sh __sync $instance "* ]]
}
if [[ ${1:-} == "__sync" ]]; then
instance=${2:?missing instance}
source_dir=${3:?missing source directory}
destination=${4:?missing destination}
validate_instance "$instance"
previous_signature=$(source_signature "$source_dir")
while true; do
sleep 1
current_signature=$(source_signature "$source_dir")
if [[ $current_signature != "$previous_signature" ]]; then
sync_tree "$source_dir" "$destination"
previous_signature=$current_signature
fi
done
fi
default_instance=$(basename -- "$REPO_ROOT" | tr '[:upper:]_' '[:lower:]-')
instance=${1:-$default_instance}
validate_instance "$instance"
namespace=${DEV_NAMESPACE:-vibes}
pvc=${DEV_PVC:-control-vibes-control-data}
shared_root=${DEV_SHARED_ROOT:-/data/live}
pvc_subpath_root=${DEV_PVC_SUBPATH_ROOT:-live}
domain=${DEV_DOMAIN:-control.k.mancave.link}
host=${2:-$instance.$domain}
release="hello-dev-$instance"
source_dir="$REPO_ROOT/src"
destination="$shared_root/$instance"
state_dir="$shared_root/.hello-dev-state"
pid_file="$state_dir/$instance.pid"
log_file="$state_dir/$instance.log"
if [[ ! -d $source_dir ]]; then
echo "source directory does not exist: $source_dir" >&2
exit 1
fi
if [[ $shared_root != /* || $shared_root == / ]]; then
echo "DEV_SHARED_ROOT must be an absolute directory below /: $shared_root" >&2
exit 2
fi
if [[ ! $host =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]]; then
echo "invalid hostname: $host" >&2
exit 2
fi
mkdir -p -- "$state_dir"
if [[ -f $pid_file ]]; then
old_pid=$(<"$pid_file")
if is_sync_process "$old_pid" "$instance"; then
kill "$old_pid"
wait "$old_pid" 2>/dev/null || true
fi
rm -f -- "$pid_file"
fi
sync_tree "$source_dir" "$destination"
nohup "$SCRIPT_DIR/start.sh" __sync "$instance" "$source_dir" "$destination" \
>"$log_file" 2>&1 &
sync_pid=$!
printf '%s\n' "$sync_pid" >"$pid_file"
cleanup_failed_start() {
kill "$sync_pid" 2>/dev/null || true
rm -f -- "$pid_file"
}
trap cleanup_failed_start ERR
helm template "$release" "$SCRIPT_DIR/helm" \
--namespace "$namespace" \
--set-string "instance=$instance" \
--set-string "ingress.host=$host" \
--set-string "sharedVolume.claimName=$pvc" \
--set-string "sharedVolume.subPath=$pvc_subpath_root/$instance" \
| kubectl apply --namespace "$namespace" -f -
kubectl rollout status "deployment/$release" --namespace "$namespace" --timeout=120s
trap - ERR
echo "Development instance '$instance' is live at http://$host"

55
dev/stop.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
default_instance=$(basename -- "$REPO_ROOT" | tr '[:upper:]_' '[:lower:]-')
instance=${1:-$default_instance}
if [[ ${#instance} -gt 42 || ! $instance =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then
echo "instance must be 1-42 lowercase letters, digits, or internal hyphens: $instance" >&2
exit 2
fi
namespace=${DEV_NAMESPACE:-vibes}
shared_root=${DEV_SHARED_ROOT:-/data/live}
destination="$shared_root/$instance"
state_dir="$shared_root/.hello-dev-state"
pid_file="$state_dir/$instance.pid"
log_file="$state_dir/$instance.log"
label="dev.mancave.link/instance=$instance"
if [[ $shared_root != /* || $shared_root == / ]]; then
echo "DEV_SHARED_ROOT must be an absolute directory below /: $shared_root" >&2
exit 2
fi
if [[ -f $pid_file ]]; then
sync_pid=$(<"$pid_file")
process_command=""
if [[ $sync_pid =~ ^[0-9]+$ && -r /proc/$sync_pid/cmdline ]]; then
process_command=$(tr '\0' ' ' <"/proc/$sync_pid/cmdline")
fi
if [[ $process_command == *"start.sh __sync $instance "* ]]; then
kill "$sync_pid"
for _ in {1..20}; do
kill -0 "$sync_pid" 2>/dev/null || break
sleep 0.1
done
kill -9 "$sync_pid" 2>/dev/null || true
fi
fi
kubectl delete all,ingress,configmap,secret,pvc \
--namespace "$namespace" \
--selector "$label" \
--ignore-not-found=true \
--wait=true
if [[ -d $destination ]]; then
find "$destination" -depth -delete
fi
rm -f -- "$pid_file" "$log_file"
rmdir -- "$state_dir" 2>/dev/null || true
echo "Development instance '$instance' was permanently removed"

View File

@@ -6,7 +6,7 @@
<title>Hello from Minikube</title>
</head>
<body>
<h1>Hello, world!</h1>
<p>Served by NGINX on Minikube.</p>
<h1>Hello, world!!</h1>
<p>Served by NGINX on Minikube from the shared src mount.</p>
</body>
</html>