From b3b86eb802e52f67b1697720d87c29d5a3cb98cc Mon Sep 17 00:00:00 2001 From: nick-gorse Date: Sat, 22 Aug 2026 15:17:22 +0100 Subject: [PATCH] initial commit --- .gitignore | 2 +- DORMANT_IMPLEMENTATION_REPORT.md | 37 +++++ OPERATIONS.md | 29 ++++ bootstrap/applicationset.yaml | 53 +++++++ bootstrap/config.yaml | 3 + kube-flannel.yml | 214 +++++++++++++++++++++++++++ scripts/ci/apply-bootstrap | 34 +++++ scripts/ci/perform-kubeconform | 38 +++++ scripts/init-template | 241 +++++++++++++++++++++++++++++++ scripts/replace_repo_name_kabab | 8 + vi | 0 11 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 DORMANT_IMPLEMENTATION_REPORT.md create mode 100644 OPERATIONS.md create mode 100644 bootstrap/applicationset.yaml create mode 100644 bootstrap/config.yaml create mode 100644 kube-flannel.yml create mode 100755 scripts/ci/apply-bootstrap create mode 100755 scripts/ci/perform-kubeconform create mode 100755 scripts/init-template create mode 100644 scripts/replace_repo_name_kabab create mode 100644 vi diff --git a/.gitignore b/.gitignore index fbabe3e..04defb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -.DS_Store +.DS_StoreTH .idea/ .vscode/ *.swp diff --git a/DORMANT_IMPLEMENTATION_REPORT.md b/DORMANT_IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..220d618 --- /dev/null +++ b/DORMANT_IMPLEMENTATION_REPORT.md @@ -0,0 +1,37 @@ +# Dormant Implementation Report + +## Classification + +### Runtime + +- `manifest/base/runtime/deployment.yaml` +- `manifest/base/runtime/service.yaml` +- `manifest/overlays/production/ingressroute.yaml` +- `manifest/overlays/production/secret-generator.yaml` +- `manifest/overlays/production/secret.secret.yaml` + +These resources are only required while the app is running. + +### Retained state + +- `manifest/base/state/persistentvolumeclaim.yaml` +- optional `manifest/overlays/production/storage/persistentvolume-nfs.yaml` example + +The PVC must survive dormancy so the app can restart with the same data later. + +### Components kept in place + +- `manifest/components/example-component/*` + +This remains in `components` because it is shared template material, not app-owned runtime or retained state. + +## Ambiguities and safe choices + +- The namespace is not declared in-repo, so there is nothing here to prune. Namespace lifecycle remains outside this manifest set. +- Dynamic PV reclaim behavior is controlled by the cluster storage class or Longhorn settings, not this repo. That prerequisite is documented in `OPERATIONS.md`. + +## Validation checklist + +- Production active render before/after should stay materially equivalent for `Deployment`, `Service`, `IngressRoute`, `Secret`, and `PersistentVolumeClaim`. +- Dormant render should retain only the PVC. +- No component resources were moved or pruned by this refactor. diff --git a/OPERATIONS.md b/OPERATIONS.md new file mode 100644 index 0000000..76d4ccc --- /dev/null +++ b/OPERATIONS.md @@ -0,0 +1,29 @@ +# Operations + +## Storage prerequisites + +- This repo keeps retained application storage in `manifest/base/state/persistentvolumeclaim.yaml`. +- The PVC depends on the storage class policy of the target cluster. For dynamic Longhorn provisioning, verify the backing `StorageClass` and Longhorn volume reclaim policy keep the volume data after the workload is removed. +- If you switch to the optional static NFS PV example in `manifest/overlays/production/storage/persistentvolume-nfs.yaml`, keep `persistentVolumeReclaimPolicy: Retain`. +- The namespace is not owned by this repo. Argo CD creates it with `CreateNamespace=true`, so this dormant refactor does not prune namespaces. + +## Dormant transition flow + +1. Push a protected tag in the form `dormant//production`. +2. Gitea Actions creates a short-lived branch that changes `bootstrap/applicationset.yaml` to target `manifest/overlays/dormant`. +3. The workflow opens a pull request against `main`. +4. Review the PR, confirm the storage prerequisite above, and merge only when you want Argo CD to prune runtime resources. +5. To reactivate, open a normal PR that switches the `overlay` field back to `production`. + +## Required Gitea protections + +- Protect `main` so direct pushes are blocked and PR review is required. +- Protect the tag pattern `dormant/*` so only approved operators can trigger dormancy proposals. +- Keep branch protection and tag protection separate. Branch protection does not restrict who can create trigger tags. +- Store a repo secret named `GITEA_TOKEN` with permission to push branches and open pull requests. + +## Safe rollout notes + +- Dormant mode keeps only retained state. Runtime resources, service exposure, and ingress are removed. +- Merge the dormant PR only after confirming no shared component or external dependency still needs the runtime path. +- The example component under `manifest/components/example-component` stays untouched because it is shared template material and is not owned by the runtime-state split. diff --git a/bootstrap/applicationset.yaml b/bootstrap/applicationset.yaml new file mode 100644 index 0000000..eee526a --- /dev/null +++ b/bootstrap/applicationset.yaml @@ -0,0 +1,53 @@ +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: flannel + namespace: argocd +spec: + ignoreApplicationDifferences: + - jsonPointers: + - /spec/syncPolicy + goTemplate: true + goTemplateOptions: ["missingkey=error"] + generators: + - list: + elements: + - environment: production + namespace: kube-flannel + overlay: production + template: + metadata: + name: 'flannel-{{ .environment }}' + labels: + app.kubernetes.io/managed-by: argocd + app.kubernetes.io/name: flannel + spec: + project: default + source: + repoURL: https://git.olb42.com/olb42/flannel.git + targetRevision: main + path: 'manifest/overlays/{{ .overlay }}' + kustomize: + commonAnnotationsEnvsubst: true + commonAnnotations: + app-source: ${ARGOCD_APP_SOURCE_REPO_URL} + app-revision: ${ARGOCD_APP_SOURCE_TARGET_REVISION} + destination: + server: https://kubernetes.default.svc + namespace: '{{ .namespace }}' + ignoreDifferences: + - group: "" + kind: PersistentVolume + name: app-data + jsonPointers: + - /spec/claimRef/resourceVersion + - /spec/claimRef/uid + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=false + - ApplyOutOfSyncOnly=false + - ServerSideApply=true diff --git a/bootstrap/config.yaml b/bootstrap/config.yaml new file mode 100644 index 0000000..5e77a0b --- /dev/null +++ b/bootstrap/config.yaml @@ -0,0 +1,3 @@ +enabled: false +target_namespace: argocd +apply_from_branch: main diff --git a/kube-flannel.yml b/kube-flannel.yml new file mode 100644 index 0000000..f177cb4 --- /dev/null +++ b/kube-flannel.yml @@ -0,0 +1,214 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + k8s-app: flannel + pod-security.kubernetes.io/enforce: privileged + name: kube-flannel +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + k8s-app: flannel + name: flannel + namespace: kube-flannel +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + k8s-app: flannel + name: flannel +rules: +- apiGroups: + - "" + resources: + - pods + verbs: + - get +- apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - nodes/status + verbs: + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + k8s-app: flannel + name: flannel +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: flannel +subjects: +- kind: ServiceAccount + name: flannel + namespace: kube-flannel +--- +apiVersion: v1 +data: + cni-conf.json: | + { + "name": "cbr0", + "cniVersion": "0.3.1", + "plugins": [ + { + "type": "flannel", + "delegate": { + "hairpinMode": true, + "isDefaultGateway": true + } + }, + { + "type": "portmap", + "capabilities": { + "portMappings": true + } + } + ] + } + net-conf.json: | + { + "Network": "172.25.0.0/16", + "EnableNFTables": false, + "Backend": { + "Type": "vxlan" + } + } +kind: ConfigMap +metadata: + labels: + app: flannel + k8s-app: flannel + tier: node + name: kube-flannel-cfg + namespace: kube-flannel +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + labels: + app: flannel + k8s-app: flannel + tier: node + name: kube-flannel-ds + namespace: kube-flannel +spec: + selector: + matchLabels: + app: flannel + k8s-app: flannel + template: + metadata: + labels: + app: flannel + k8s-app: flannel + tier: node + spec: + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/os + operator: In + values: + - linux + containers: + - args: + - --ip-masq + - --kube-subnet-mgr + command: + - /opt/bin/flanneld + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: EVENT_QUEUE_DEPTH + value: "5000" + - name: CONT_WHEN_CACHE_NOT_READY + value: "false" + image: ghcr.io/flannel-io/flannel:v0.28.7 + name: kube-flannel + resources: + requests: + cpu: 100m + memory: 50Mi + securityContext: + capabilities: + add: + - NET_ADMIN + - NET_RAW + privileged: false + volumeMounts: + - mountPath: /run/flannel + name: run + - mountPath: /etc/kube-flannel/ + name: flannel-cfg + - mountPath: /run/xtables.lock + name: xtables-lock + hostNetwork: true + initContainers: + - args: + - -f + - /flannel + - /opt/cni/bin/flannel + command: + - cp + image: ghcr.io/flannel-io/flannel-cni-plugin:v1.9.1-flannel2 + name: install-cni-plugin + volumeMounts: + - mountPath: /opt/cni/bin + name: cni-plugin + - args: + - -f + - /etc/kube-flannel/cni-conf.json + - /etc/cni/net.d/10-flannel.conflist + command: + - cp + image: ghcr.io/flannel-io/flannel:v0.28.7 + name: install-cni + volumeMounts: + - mountPath: /etc/cni/net.d + name: cni + - mountPath: /etc/kube-flannel/ + name: flannel-cfg + priorityClassName: system-node-critical + serviceAccountName: flannel + tolerations: + - effect: NoSchedule + operator: Exists + volumes: + - hostPath: + path: /run/flannel + name: run + - hostPath: + path: /opt/cni/bin + name: cni-plugin + - hostPath: + path: /etc/cni/net.d + name: cni + - configMap: + name: kube-flannel-cfg + name: flannel-cfg + - hostPath: + path: /run/xtables.lock + type: FileOrCreate + name: xtables-lock diff --git a/scripts/ci/apply-bootstrap b/scripts/ci/apply-bootstrap new file mode 100755 index 0000000..dcdc0d8 --- /dev/null +++ b/scripts/ci/apply-bootstrap @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +event_name="${GITHUB_EVENT_NAME:-}" +git_ref="${GITHUB_REF:-}" + +if [[ "$event_name" != "push" || "$git_ref" != "refs/heads/main" ]]; then + echo "Skipping bootstrap apply: only push events on main may apply" + exit 0 +fi + +if [[ ! -f bootstrap/config.yaml ]] || ! grep -Eq '^[[:space:]]*enabled:[[:space:]]*true[[:space:]]*$' bootstrap/config.yaml; then + echo "Skipping bootstrap apply: bootstrap/config.yaml is not enabled" + exit 0 +fi + +if [[ -z "${KUBECONFIG_B64:-}" ]]; then + echo "KUBECONFIG_B64 secret is required to apply bootstrap/applicationset.yaml" + exit 1 +fi + +curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + -o /usr/local/bin/kubectl +chmod +x /usr/local/bin/kubectl + +mkdir -p "${HOME}/.kube" +printf '%s' "$KUBECONFIG_B64" | base64 -d > "${HOME}/.kube/config" + +if ! kubectl config current-context >/dev/null 2>&1; then + echo "Skipping bootstrap apply: kubeconfig secret is not usable in this runner" + exit 0 +fi + +kubectl apply -f bootstrap/applicationset.yaml diff --git a/scripts/ci/perform-kubeconform b/scripts/ci/perform-kubeconform new file mode 100755 index 0000000..5d40cf1 --- /dev/null +++ b/scripts/ci/perform-kubeconform @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +bootstrap_enabled=false +if [ -f bootstrap/config.yaml ] && grep -Eq '^[[:space:]]*enabled:[[:space:]]*true[[:space:]]*$' bootstrap/config.yaml; then + bootstrap_enabled=true +fi + +mapfile -t manifests < <( + find . -type f -name '*.yaml' \ + ! -path './.gitea/*' \ + ! -path './manifest/components/example-component/*' \ + ! -name '*kustomization.yaml' \ + ! -path './bootstrap/config.yaml' \ + | sort +) + +if [ "$bootstrap_enabled" != "true" ]; then + filtered=() + for manifest in "${manifests[@]}"; do + [ "$manifest" = "./bootstrap/applicationset.yaml" ] && continue + filtered+=("$manifest") + done + manifests=("${filtered[@]}") +fi + +if [ "${#manifests[@]}" -eq 0 ]; then + echo "No manifests found" + exit 0 +fi + +printf '%s\n' "${manifests[@]}" \ + | xargs kubeconform \ + -strict \ + -kubernetes-version 1.35.0 \ + -schema-location default \ + -schema-location 'http://gitea-ha-http.apps:3000/olb042/kubeconform/raw/branch/main/crdSchemas/{{ .ResourceKind }}_{{ .ResourceAPIVersion }}.json' \ + -summary \ No newline at end of file diff --git a/scripts/init-template b/scripts/init-template new file mode 100755 index 0000000..5ac93e2 --- /dev/null +++ b/scripts/init-template @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/init-template \ + --repo-name my-app + +Optional: + --dry-run + --repo-url https://git.olb42.com/olb42/my-app.git + --namespace my-app + --release-name my-app + --hostname my-app.example.com +USAGE +} + +die() { + echo "Error: $*" >&2 + exit 1 +} + +add_error() { + errors+=("$*") +} + +add_warning() { + warnings+=("$*") +} + +validate_dns_label() { + local value="$1" + [[ ${#value} -le 63 && "$value" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]] +} + +validate_hostname() { + local value="$1" + [[ ${#value} -le 253 && "$value" != .* && "$value" != *. ]] || return 1 + + local old_ifs="$IFS" + local labels=() + IFS='.' + read -r -a labels <<< "$value" + IFS="$old_ifs" + + local label + for label in "${labels[@]}"; do + validate_dns_label "$label" || return 1 + done +} + +print_preflight() { + local status="$1" + + echo "Preflight checks for $repo_name" + echo " template: $template_dir_real" + echo " target: $target_dir" + echo " repo URL: $repo_url" + echo " namespace: $namespace" + echo " hostname: $hostname" + echo + + if [[ ${#warnings[@]} -gt 0 ]]; then + echo "Warnings:" + local warning + for warning in "${warnings[@]}"; do + echo " - $warning" + done + echo + fi + + if [[ ${#errors[@]} -gt 0 ]]; then + echo "Failures:" + local error + for error in "${errors[@]}"; do + echo " - $error" + done + echo + echo "$status" + return 1 + fi + + echo "$status" +} + +run_preflight() { + local include_remote_check="${1:-false}" + errors=() + warnings=() + + command -v git >/dev/null 2>&1 || add_error "git is required" + command -v python3 >/dev/null 2>&1 || add_error "python3 is required" + command -v cp >/dev/null 2>&1 || add_error "cp is required" + + if [[ -z "$repo_name" ]]; then + add_error "--repo-name is required" + elif [[ ! "$repo_name" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]; then + add_error "--repo-name must be kebab-case using lowercase letters, numbers, and hyphens" + fi + + if [[ -z "$template_dir_real" ]]; then + add_error "template directory not found: $template_dir" + elif [[ "$current_dir/" != "$template_dir_real"/* ]]; then + add_error "run this script from $template_dir or one of its subdirectories" + elif [[ ! -d "$template_dir_real/.git" ]]; then + add_error "template directory is not a git repository: $template_dir_real" + else + local branch + branch="$(git -C "$template_dir_real" branch --show-current 2>/dev/null || true)" + [[ "$branch" == "init" ]] || add_warning "template branch is '$branch', expected 'init'" + fi + + if [[ -d "$apps_dir" ]]; then + [[ -w "$apps_dir" ]] || add_error "apps directory is not writable: $apps_dir" + else + local apps_parent + apps_parent="$(dirname "$apps_dir")" + [[ -d "$apps_parent" && -w "$apps_parent" ]] || add_error "cannot create apps directory under: $apps_parent" + fi + [[ -e "$target_dir" ]] && add_error "target directory already exists: $target_dir" + + validate_dns_label "$namespace" || add_error "--namespace must be a valid Kubernetes DNS label" + validate_dns_label "$release_name" || add_error "--release-name must be a valid Helm release name" + validate_hostname "$hostname" || add_error "--hostname must be a valid DNS hostname" + [[ "$repo_url" == *$'\n'* || "$repo_url" == *$'\r'* ]] && add_error "--repo-url must not contain newlines" + [[ "$repo_url" =~ ^https://git\.olb42\.com/olb42/[a-z0-9]([-a-z0-9]*[a-z0-9])?\.git$ ]] || add_error "--repo-url must match https://git.olb42.com/olb42/.git" + [[ "$repo_url" == "https://git.olb42.com/olb42/$repo_name.git" ]] || add_warning "--repo-url does not match --repo-name" + + if [[ "$include_remote_check" == true && ${#errors[@]} -eq 0 ]]; then + local remote_output + remote_output="$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$repo_url" main init 2>&1 || true)" + if printf '%s\n' "$remote_output" | grep -q 'refs/heads/'; then + add_error "remote already has main/init branches at $repo_url" + else + local remote_check_output + remote_check_output="$(GIT_TERMINAL_PROMPT=0 git ls-remote "$repo_url" 2>&1 || true)" + if printf '%s\n' "$remote_check_output" | grep -qi '^fatal:'; then + add_error "remote repository is not reachable without prompting: $repo_url ($remote_check_output)" + fi + fi + fi +} + +repo_name="" +repo_url="" +namespace="" +release_name="" +hostname="" +dry_run=false +errors=() +warnings=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-name) [[ $# -ge 2 ]] || die "--repo-name requires a value"; repo_name="$2"; shift 2 ;; + --repo-url) [[ $# -ge 2 ]] || die "--repo-url requires a value"; repo_url="$2"; shift 2 ;; + --namespace) [[ $# -ge 2 ]] || die "--namespace requires a value"; namespace="$2"; shift 2 ;; + --release-name) [[ $# -ge 2 ]] || die "--release-name requires a value"; release_name="$2"; shift 2 ;; + --hostname) [[ $# -ge 2 ]] || die "--hostname requires a value"; hostname="$2"; shift 2 ;; + --dry-run) dry_run=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1"; usage; exit 1 ;; + esac +done + +template_dir="$HOME/devops/infra/templates/template" +apps_dir="$HOME/devops/apps" +target_dir="$apps_dir/$repo_name" + +current_dir="$(pwd -P)" +template_dir_real="$(cd "$template_dir" 2>/dev/null && pwd -P || true)" + +repo_url="${repo_url:-https://git.olb42.com/olb42/$repo_name.git}" +release_name="${release_name:-$repo_name}" +hostname="${hostname:-$repo_name.olb42.com}" +namespace="${namespace:-$repo_name}" + +run_preflight "$dry_run" + +if [[ "$dry_run" == true ]]; then + if [[ ${#errors[@]} -gt 0 ]]; then + print_preflight "Dry run failed. No files were copied." + exit 1 + fi + print_preflight "Dry run passed. No files were copied." + exit 0 +fi + +if [[ ${#errors[@]} -gt 0 ]]; then + usage + print_preflight "Template initialization failed before copying files." >&2 + exit 1 +fi + +mkdir -p "$apps_dir" +cp -a "$template_dir_real" "$target_dir" + +cd "$target_dir" + +git reset --mixed >/dev/null + +python3 - "$repo_name" "$repo_url" "$namespace" "$release_name" "$hostname" <<'PY' +from pathlib import Path +import sys + +repo_name, repo_url, namespace, release_name, hostname = sys.argv[1:] +replacements = { + "${REPO_NAME_KEBAB}": repo_name, + "${REPO_HTTPS_URL}": repo_url, + "${APP_NAMESPACE}": namespace, + "${HELM_RELEASE_NAME}": release_name, + "${APP_HOSTNAME}": hostname, +} + +for path in Path(".").rglob("*"): + if not path.is_file(): + continue + if ".git" in path.parts or path.name == "init-template": + continue + try: + text = path.read_text() + except UnicodeDecodeError: + continue + original = text + for old, new in replacements.items(): + text = text.replace(old, new) + if text != original: + path.write_text(text) +PY + +git branch -M main +while IFS= read -r remote; do + git remote remove "$remote" +done < <(git remote) +git remote add origin "$repo_url" + +echo "Template initialized for $repo_name" +echo "Created $target_dir" +echo "Remote origin: $repo_url" +echo "Next: review manifest/overlays/production and push main" diff --git a/scripts/replace_repo_name_kabab b/scripts/replace_repo_name_kabab new file mode 100644 index 0000000..8d48b80 --- /dev/null +++ b/scripts/replace_repo_name_kabab @@ -0,0 +1,8 @@ +replace_repo_name_kebab() { + local replacement="$1" + if [[ -z "$replacement" ]]; then + echo "Usage: replace_repo_name_kebab " + return 1 + fi + find . -type f ! -path './.git/*' -exec perl -pi -e 's/\$\{REPO_NAME_KEBAB}/$ARGV[0]/g' "$replacement" {} + +} \ No newline at end of file diff --git a/vi b/vi new file mode 100644 index 0000000..e69de29