#!/usr/bin/env bash # tools/create_railiance_overlay_repo.sh # Create a local Railiance overlay repo skeleton for a third-party upstream app. set -euo pipefail APP_ID="" APP_NAME="" OWNER="platform" CRITICALITY="medium" UPSTREAM_URL="" UPSTREAM_REVISION="main" UPSTREAM_TRACKING="branch" OUT_DIR="" INIT_GIT=false usage() { cat <<'EOF' Usage: tools/create_railiance_overlay_repo.sh --app-id --upstream-url [options] Required: --app-id Stable lowercase app id, e.g. forgejo --upstream-url Upstream source repository or release URL Options: --name Human-readable app name (default: app id) --owner Owning team/domain (default: platform) --criticality low|medium|high|critical (default: medium) --upstream-revision Upstream branch/tag/commit/release (default: main) --upstream-tracking branch|tag|commit|release|digest (default: branch) --out-dir Output directory (default: -railiance-overlay) --init-git Initialize a local Git repo, without committing -h|--help Show this help The script writes local files only. It does not clone upstream code, call Gitea, fetch secrets, or push a remote. EOF } while [[ $# -gt 0 ]]; do case "$1" in --app-id) APP_ID="${2:?}"; shift 2 ;; --name) APP_NAME="${2:?}"; shift 2 ;; --owner) OWNER="${2:?}"; shift 2 ;; --criticality) CRITICALITY="${2:?}"; shift 2 ;; --upstream-url) UPSTREAM_URL="${2:?}"; shift 2 ;; --upstream-revision) UPSTREAM_REVISION="${2:?}"; shift 2 ;; --upstream-tracking) UPSTREAM_TRACKING="${2:?}"; shift 2 ;; --out-dir) OUT_DIR="${2:?}"; shift 2 ;; --init-git) INIT_GIT=true; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown arg: $1" >&2; usage >&2; exit 2 ;; esac done if [[ -z "${APP_ID}" || -z "${UPSTREAM_URL}" ]]; then echo "ERROR: --app-id and --upstream-url are required" >&2 usage >&2 exit 2 fi if [[ ! "${APP_ID}" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then echo "ERROR: --app-id must match ^[a-z0-9][a-z0-9-]*$" >&2 exit 2 fi case "${CRITICALITY}" in low|medium|high|critical) ;; *) echo "ERROR: --criticality must be low, medium, high, or critical" >&2; exit 2 ;; esac case "${UPSTREAM_TRACKING}" in branch|tag|commit|release|digest) ;; *) echo "ERROR: --upstream-tracking must be branch, tag, commit, release, or digest" >&2; exit 2 ;; esac if [[ -z "${APP_NAME}" ]]; then APP_NAME="${APP_ID}" fi if [[ -z "${OUT_DIR}" ]]; then OUT_DIR="${APP_ID}-railiance-overlay" fi if [[ -e "${OUT_DIR}" ]]; then if [[ -n "$(ls -A "${OUT_DIR}")" ]]; then echo "ERROR: output directory exists and is not empty: ${OUT_DIR}" >&2 exit 1 fi fi mkdir -p \ "${OUT_DIR}/railiance" \ "${OUT_DIR}/charts/${APP_ID}/templates" \ "${OUT_DIR}/values" \ "${OUT_DIR}/patches/upstream" \ "${OUT_DIR}/tests" \ "${OUT_DIR}/runbooks" \ "${OUT_DIR}/docs" touch "${OUT_DIR}/patches/upstream/.gitkeep" cat > "${OUT_DIR}/README.md" < "${OUT_DIR}/railiance/upstream.toml" < "${OUT_DIR}/railiance/app.toml" < "${OUT_DIR}/charts/${APP_ID}/Chart.yaml" < "${OUT_DIR}/charts/${APP_ID}/values.yaml" < "${OUT_DIR}/charts/${APP_ID}/templates/_helpers.tpl" <<'EOF' {{- define "railiance.stage" -}} {{- default "stable" .Values.railiance.stage -}} {{- end -}} {{- define "railiance.releaseName" -}} {{- if eq (include "railiance.stage" .) "canary" -}} {{- default (printf "%s-canary" .Chart.Name) .Values.railiance.canaryRelease | trunc 63 | trimSuffix "-" -}} {{- else -}} {{- default .Release.Name .Values.railiance.stableRelease | trunc 63 | trimSuffix "-" -}} {{- end -}} {{- end -}} {{- define "railiance.image" -}} {{- if .Values.image.digest -}} {{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} {{- else -}} {{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} {{- end -}} {{- end -}} {{- define "railiance.selectorLabels" -}} app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ include "railiance.releaseName" . }} railiance.coulomb.social/stage: {{ include "railiance.stage" . }} {{- end -}} {{- define "railiance.labels" -}} helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} app.kubernetes.io/managed-by: {{ .Release.Service }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{ include "railiance.selectorLabels" . }} {{- end -}} {{- define "railiance.prometheusAnnotations" -}} {{- if .Values.prometheus.enabled }} prometheus.io/scrape: {{ .Values.prometheus.scrape | quote }} prometheus.io/path: {{ .Values.prometheus.path | quote }} prometheus.io/port: {{ .Values.prometheus.port | quote }} {{- end }} {{- end -}} EOF cat > "${OUT_DIR}/charts/${APP_ID}/templates/deployment.yaml" <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "railiance.releaseName" . }} labels: {{ include "railiance.labels" . | nindent 4 }} annotations: railiance.coulomb.social/stable-release: {{ .Values.railiance.stableRelease | quote }} railiance.coulomb.social/canary-release: {{ .Values.railiance.canaryRelease | quote }} railiance.coulomb.social/previous-stable: {{ .Values.railiance.previousStable.release | quote }} spec: replicas: {{ .Values.replicaCount }} revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }} strategy: {{ toYaml .Values.deployment.strategy | nindent 4 }} selector: matchLabels: {{ include "railiance.selectorLabels" . | nindent 6 }} template: metadata: labels: {{ include "railiance.labels" . | nindent 8 }} annotations: {{ include "railiance.prometheusAnnotations" . | nindent 8 }} {{- with .Values.podAnnotations }} {{ toYaml . | nindent 8 }} {{- end }} spec: containers: - name: {{ .Chart.Name }} image: "{{ include "railiance.image" . }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http containerPort: {{ .Values.service.port }} readinessProbe: httpGet: path: {{ .Values.health.path | quote }} port: http initialDelaySeconds: {{ .Values.health.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.health.readiness.periodSeconds }} livenessProbe: httpGet: path: {{ .Values.health.path | quote }} port: http initialDelaySeconds: {{ .Values.health.liveness.initialDelaySeconds }} periodSeconds: {{ .Values.health.liveness.periodSeconds }} {{- with .Values.env }} env: {{ toYaml . | nindent 12 }} {{- end }} {{- if .Values.secretRefs }} envFrom: {{- range .Values.secretRefs }} - secretRef: name: {{ . | quote }} {{- end }} {{- end }} resources: {{ toYaml .Values.resources | nindent 12 }} EOF cat > "${OUT_DIR}/charts/${APP_ID}/templates/service.yaml" <<'EOF' apiVersion: v1 kind: Service metadata: name: {{ include "railiance.releaseName" . }} labels: {{ include "railiance.labels" . | nindent 4 }} annotations: {{ include "railiance.prometheusAnnotations" . | nindent 4 }} spec: selector: {{ include "railiance.selectorLabels" . | nindent 4 }} ports: - name: http port: {{ .Values.service.port }} targetPort: http EOF cat > "${OUT_DIR}/charts/${APP_ID}/templates/ingress.yaml" <<'EOF' {{- if and .Values.ingress.enabled (ne .Values.railiance.traffic.mode "weighted") }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "railiance.releaseName" . }} labels: {{ include "railiance.labels" . | nindent 4 }} annotations: {{- with .Values.ingress.annotations }} {{ toYaml . | nindent 4 }} {{- else }} railiance.coulomb.social/traffic-mode: {{ .Values.railiance.traffic.mode | quote }} {{- end }} spec: {{- if .Values.ingress.className }} ingressClassName: {{ .Values.ingress.className | quote }} {{- end }} rules: - host: {{ .Values.ingress.host | quote }} http: paths: - path: {{ .Values.ingress.path | quote }} pathType: {{ .Values.ingress.pathType }} backend: service: name: {{ include "railiance.releaseName" . }} port: name: http {{- with .Values.ingress.tls }} tls: {{ toYaml . | nindent 4 }} {{- end }} {{- end }} EOF cat > "${OUT_DIR}/charts/${APP_ID}/templates/traefik-weighted.yaml" <<'EOF' {{- if and .Values.ingress.enabled (eq .Values.railiance.traffic.mode "weighted") (eq .Values.railiance.traffic.provider "traefik") }} {{- $routeName := default (printf "%s-weighted" .Chart.Name) .Values.railiance.traffic.routeName }} apiVersion: traefik.io/v1alpha1 kind: TraefikService metadata: name: {{ $routeName }} labels: {{ include "railiance.labels" . | nindent 4 }} spec: weighted: services: - name: {{ .Values.railiance.stableRelease }} port: {{ .Values.service.port }} weight: {{ .Values.railiance.traffic.stableWeight }} - name: {{ .Values.railiance.canaryRelease }} port: {{ .Values.service.port }} weight: {{ .Values.railiance.traffic.canaryWeight }} --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: {{ $routeName }} labels: {{ include "railiance.labels" . | nindent 4 }} spec: entryPoints: {{ toYaml .Values.railiance.traffic.entryPoints | nindent 4 }} routes: - kind: Rule match: "Host(`{{ .Values.ingress.host }}`) && PathPrefix(`{{ .Values.ingress.path }}`)" services: - name: {{ $routeName }} kind: TraefikService port: {{ .Values.service.port }} {{- end }} EOF cat > "${OUT_DIR}/values/stage1.yaml" < "${OUT_DIR}/values/stage2-canary.yaml" < "${OUT_DIR}/values/stage3-production.yaml" < "${OUT_DIR}/tests/stage2-template.sh" </dev/null 2>&1; then helm template ${APP_ID}-canary charts/${APP_ID} -f values/stage2-canary.yaml >/tmp/${APP_ID}-stage2-canary-render.yaml grep -q 'kind: Deployment' /tmp/${APP_ID}-stage2-canary-render.yaml grep -q 'kind: Service' /tmp/${APP_ID}-stage2-canary-render.yaml grep -q 'kind: Ingress' /tmp/${APP_ID}-stage2-canary-render.yaml echo 'stage2 helm template ok' else echo 'helm unavailable; verified stage2 canary scaffold files only' fi EOF chmod +x "${OUT_DIR}/tests/stage2-template.sh" cat > "${OUT_DIR}/tests/stage1.sh" </dev/null 2>&1; then helm template ${APP_ID}-local charts/${APP_ID} -f values/stage1.yaml >/tmp/${APP_ID}-stage1-render.yaml echo 'helm template ok' else echo 'helm unavailable; skipped helm template check' fi EOF chmod +x "${OUT_DIR}/tests/stage1.sh" cat > "${OUT_DIR}/runbooks/rollback.md" < "${OUT_DIR}/docs/promotion.md" < "${OUT_DIR}/.gitignore" <<'EOF' .DS_Store __pycache__/ *.pyc *.log *.tmp *.bak .secrets/ secrets/ *.kubeconfig .railiance_gitea.conf EOF if [[ "${INIT_GIT}" == true ]]; then git -C "${OUT_DIR}" init fi echo "Created Railiance overlay repo skeleton: ${OUT_DIR}" echo "Next: edit railiance/app.toml, run tests/stage1.sh, then commit the overlay repo."