diff --git a/.dockerignore b/.dockerignore index dc05a13..5091194 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ .git -chart +deploy +dev README.md diff --git a/Dockerfile b/Dockerfile index e98abb4..0a3f5a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 0abe0fd..05cf61b 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/chart/Chart.yaml b/deploy/helm/Chart.yaml similarity index 100% rename from chart/Chart.yaml rename to deploy/helm/Chart.yaml diff --git a/chart/templates/deployment.yaml b/deploy/helm/templates/deployment.yaml similarity index 100% rename from chart/templates/deployment.yaml rename to deploy/helm/templates/deployment.yaml diff --git a/chart/templates/ingress.yaml b/deploy/helm/templates/ingress.yaml similarity index 100% rename from chart/templates/ingress.yaml rename to deploy/helm/templates/ingress.yaml diff --git a/chart/templates/service.yaml b/deploy/helm/templates/service.yaml similarity index 100% rename from chart/templates/service.yaml rename to deploy/helm/templates/service.yaml diff --git a/chart/values.yaml b/deploy/helm/values.yaml similarity index 100% rename from chart/values.yaml rename to deploy/helm/values.yaml diff --git a/dev/helm/Chart.yaml b/dev/helm/Chart.yaml new file mode 100644 index 0000000..347891a --- /dev/null +++ b/dev/helm/Chart.yaml @@ -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" diff --git a/dev/helm/templates/deployment.yaml b/dev/helm/templates/deployment.yaml new file mode 100644 index 0000000..f350013 --- /dev/null +++ b/dev/helm/templates/deployment.yaml @@ -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 }} diff --git a/dev/helm/templates/ingress.yaml b/dev/helm/templates/ingress.yaml new file mode 100644 index 0000000..946751b --- /dev/null +++ b/dev/helm/templates/ingress.yaml @@ -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 }} diff --git a/dev/helm/templates/service.yaml b/dev/helm/templates/service.yaml new file mode 100644 index 0000000..04ae9e0 --- /dev/null +++ b/dev/helm/templates/service.yaml @@ -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 diff --git a/dev/helm/values.yaml b/dev/helm/values.yaml new file mode 100644 index 0000000..e481269 --- /dev/null +++ b/dev/helm/values.yaml @@ -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: "" diff --git a/dev/start.sh b/dev/start.sh new file mode 100755 index 0000000..d6c08f0 --- /dev/null +++ b/dev/start.sh @@ -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" diff --git a/dev/stop.sh b/dev/stop.sh new file mode 100755 index 0000000..d90aae5 --- /dev/null +++ b/dev/stop.sh @@ -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" diff --git a/index.html b/src/index.html similarity index 69% rename from index.html rename to src/index.html index 6468f18..6c4511f 100644 --- a/index.html +++ b/src/index.html @@ -6,7 +6,7 @@
Served by NGINX on Minikube.
+Served by NGINX on Minikube from the shared src mount.