These rules prevent immediate cluster failures. Violations cause crashes, data corruption, or GitOps drift.
Every cluster change MUST flow through git. Imperative commands are diagnostic only.
- NEVER run git commit/push without explicit user request - GitOps requires user commits for accountability. This includes using the commit subagent. Always wait for explicit "commit" request.
- NEVER delete resources as a fix - Deleting jobs, pods, or PVCs treats symptoms, not causes. Find the manifest issue and fix it. Exception: cleanup after root cause is fixed.
- NEVER adjust health probes to fix failures - Probes detect problems, they don't cause them. Investigate WHY the probe fails (resource exhaustion, slow startup, missing deps).
- Query: Gather symptoms via subagent (alerts, logs, events, pod status)
- History:
git log -p --follow --invert-grep --author="renovate" -- path/to/file.yamlfor recent changes - Analyze: Read manifests, check CRD specs, verify dependencies
- Research: Subagent for reference repos, Context7, upstream docs
- Fix: Modify manifests to address root cause
- Validate:
pre-commit run --files <changed-files>
Recurring issues indicate incomplete root cause analysis.
All cluster queries MUST use hops commands (./scripts/hops.sh). Direct use of kubectl,
talosctl, helm, flux, and other cluster CLIs is prohibited except when hops lacks the needed
functionality (see escape hatch below). hops produces LLM-optimized, token-compact output by
design; raw CLI output wastes context on noise the LLM has to parse and discard.
hops escape hatch: hops is not feature-complete. When a command you need does not exist,
produces too much or too little output, or has a bug: (1) load the hops skill, (2) update or add
the command, (3) test the updated command, (4) use it to continue your original task. Do not work
around gaps by falling back to raw CLIs; fix the tool instead. If the gap is too complex to fix
inline, document it as a TODO in the relevant hops source file and fall back to the raw CLI for that
specific operation only.
hops stewardship obligation: The escape hatch handles missing capability. The no-passthrough
rule (hops commands MUST embody investigative workflows, not reformat single CLI calls) requires
active stewardship. While running existing hops commands during diagnosis, if any of the following
signals appear, MUST load the hops skill and act on them within the current session. MUST NOT
defer the fix to a follow-up session.
- A command's output is barely more useful than the raw kubectl/talosctl/flux call it wraps
- You instinctively reach for a second command to fetch context the first should have included
- A resolver rejects an edge case (orphan pod, terminated workload, fuzzy name) the caller would reasonably expect to work
- You catch yourself repeating a sequence of
hopscalls that should be a single command
These signals are fix triggers, not curiosities. Acting on them while unrelated work is fresh in context is cheaper than a future audit.
- RWO volumes MUST use strategy: Recreate - RollingUpdate causes Multi-Attach errors during pod transitions (ceph-block is RWO)
- RWO volumes REQUIRE advancedMounts - Single-pod exclusive access requires explicit controller/container specification
- Jobs/CronJobs with RWO PVCs MUST use native sidecar pattern - initContainers with restartPolicy: Always prevents Multi-Attach errors on subsequent runs
- NEVER specify metadata.namespace in app resources - Breaks namespace inheritance from parent kustomization.yaml
- App ks.yaml (Flux Kustomization) uses spec.targetNamespace - Exception to inheritance rule, NOT metadata.namespace
- NEVER use chart.spec.sourceRef for app-template - Use chartRef (references OCIRepository). Exception: External HelmRepository charts may use chart.spec.sourceRef.
- chartRef REQUIRES namespace for cross-namespace OCIRepository references - App-template OCIRepository is in flux-system namespace; all HelmReleases MUST specify namespace: flux-system
- NEVER use secret.sops.yaml files - Obsolete pattern replaced by ExternalSecret with Infisical ClusterSecretStore
- NEVER use postBuild.substituteFrom for app secrets - Timing race condition with ExternalSecret creation causes failures
- ONLY use postBuild.substituteFrom for: cluster-secrets, email-secrets (pre-existing SOPS secrets managed centrally)
- NEVER use raw ConfigMap resources - ALWAYS use configMapGenerator in kustomization.yaml with files from config/ subdirectory
- NEVER inline VRL source in vector.yaml - Separate VRL file required for testing and validation
- ALWAYS include test data for VRL validation - Use ./scripts/test-vrl.py for validation
- NFS-dependent apps use native HPAs with external metrics (probe_success from prometheus-adapter) to scale 0-1 based on NFS availability
- The nfs-scaler kustomize component creates HPAs with minReplicas: 0, maxReplicas: 1
- Requires HPAScaleToZero feature gate enabled in kube-apiserver
Consistency patterns for maintainability and clarity.
- ALWAYS use chartRef (see Tier 1: Storage, Volumes, and Resource Patterns)
- ALWAYS include appropriate
# yaml-language-server:directive at top of YAML files - ALWAYS use reloader.stakater.com/auto: "true" for ALL apps (NEVER use targeted annotations)
- ALWAYS use rootless containers (security requirement)
- ALWAYS check existing applications before making changes
- When removing, renaming, or restructuring an app, MUST run
rg "name|alias|keyword"at repo root to find all references (docs, AGENTS.md, Homepage, backup lists, S3 buckets, etc.) before committing - PREFER YAML defaults by omission over explicit configuration (minimal config improves maintainability)
- Add comments explaining WHY special approaches were needed (e.g., chart limitations, upstream issues)
- NEVER use cluster-apps- prefix in service/app names
- NEVER invent new patterns or adopt conventions from other repositories
- NEVER reference real homelab domain names in documentation or config examples (use
${SECRET_DOMAIN}in YAML manifests) - Internal cluster hostnames: ONLY use
service.namespace, without ending withsvc.cluster.local - Service naming: Single
service:entry uses HelmRelease name only; multiple entries append the service key (e.g.,plexvsplex-main,plex-api). Usingappas a single service key is also acceptable - Controller naming: Primary controller MUST match HelmRelease name (e.g.,
controllers: plex:for releaseplex). This produces deploymentplexinstead ofplex-main. App-template avoids{release}-{release}duplication when controller matches release. - Primary:
ghcr.io/home-operations/*containers (semantically versioned, rootless, multi-arch) - Secondary:
ghcr.io/onedr0p/*containers (if home-operations unavailable) - Avoid:
ghcr.io/hotio/*and containers using s6-overlay, gosu - Prefer semantic versions for all production containers. Rolling tags (like
latest) are acceptable for trivial init containers (e.g., alpine for file copying) where renovate upgrades add no value - SHA256 digests: Automatically added by renovatebot
- Container command/args: Use bracket notation
command: ["cmd", "arg"]instead of multi-line dash format for consistency - ALWAYS use America/Chicago (or equivalent representation) for timezone if needed.
- SMTP:
smtp-relay.network:587(no auth) - never configure app-specific SMTP credentials
The Talos scheduler is configured with a cluster-wide PodTopologySpread default: ScheduleAnyway,
maxSkew: 1, topologyKey: kubernetes.io/hostname. All pods prefer spreading across nodes
automatically. NEVER add explicit topologySpreadConstraints unless DoNotSchedule is required;
NEVER use podAntiAffinity for topology spreading. ScheduleAnyway (the scheduler default) is
sufficient for application-tier workloads. DoNotSchedule is for critical infrastructure where
co-location defeats the purpose of HA (e.g., DNS servers, database operators).
- Probes default to off in app-template; only specify probes you need
- Liveness: ALWAYS enable with simple httpGet returning 200 (e.g.,
/status,/health,/ping) - Readiness: OMIT for single-replica services (readiness controls traffic routing, irrelevant without HA). Only add readiness for multi-replica deployments where it should perform a more comprehensive check than liveness.
- Startup: OMIT unless slow initialization requires extended startup time
- Omit default values (initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, failureThreshold: 3)
- When liveness and readiness end up identical (no heavier endpoint exists): use a YAML anchor
(
&probe/*probe) to share the spec, and add a comment abovereadiness:explaining why no distinct endpoint is available
- OIDC client IDs: Hardcode as app name in env vars (not secret); only client secret needs Infisical
- HTTPRoute ONLY for all routing (never Ingress)
- NEVER configure External-DNS on HTTPRoutes (Gateways only)
- DNSEndpoint CRDs MUST carry
external-dns/provider: <provider>label indicating which external-dns instance should manage them - NEVER create LoadBalancer without explicit user discussion
- MCP sidecar routing:
mcp-{service-subdomain}.${SECRET_DOMAIN}where{service-subdomain}is the app's existing hostname prefix - Route backendRefs: Use full service name (e.g., radarr-app), not identifier (e.g., app)
- NEVER use wildcards for SecurityPolicy headers (always explicit headers)
- NEVER specify explicit timeouts/intervals without justification (use Flux defaults)
- Pod securityContext: runAsUser/runAsGroup 1000, runAsNonRoot true, fsGroup 1000, fsGroupChangePolicy OnRootMismatch (identity + volume ownership for all containers)
- Container securityContext: allowPrivilegeEscalation false, readOnlyRootFilesystem true, capabilities drop ALL (hardening only; these fields have no pod-level equivalent)
- NEVER duplicate identity fields (runAsUser, runAsGroup, runAsNonRoot) in container securityContext unless containers in the same pod require different UIDs
- NEVER share databases between apps (dedicated instances per app)
- Use CloudNativePG for PostgreSQL, MariaDB Operator for MariaDB
- CNPG clusters MUST use
instances: 2minimum. Single-instance clusters create a PDB withdisruptionsAllowed: 0, blocking node drains during Talos upgrades. - CNPG S3 backups are decided case by case (not every cluster warrants them). When backups are
warranted, use the
cnpg-backupkustomize component: add tokustomization.yamlcomponents and adddependsOn: garage-instance(namespace: storage) toks.yaml. The component provides the GarageS3AccessKey, GarageS3Bucket, ScheduledBackup,garage-s3-regionsecret, and patches the Cluster CR with backup config. The Garage operator creates the S3 credential secret ({accesskey}-gs3ak) automatically; no Infisical/ExternalSecret needed. - NEVER create custom equivalents to standard Vector fields (message, timestamp, level, severity, host, source_type)
- VRL regex: Prefer non-greedy
.*?over greedy.* - Sidecar pattern: Regular containers for Deployments, initContainers with restartPolicy: Always for Jobs/CronJobs
- Vector log collection uses two paths: (1) daemonset collection via pod label
observability.home-ops/logs=true, or (2) a Vector sidecar container for apps that need custom log parsing (e.g., file-based logs). Both paths deliver to VictoriaLogs. - Multi-container pods: use
vector.dev/exclude-containerspod annotation (comma-separated container names) to skip noisy sidecar logs. The daemonset collects all containers by default; exclude VPN tunnels, port-forward helpers, and other infrastructure sidecars. - Vector sidecar containers MUST be named
vector(notvector-sidecaror other variants)
- Validate markdown changes with
markdownlint-cli2before committing - Links: reference-style
[text][anchor]with definitions at section end (not inline) - NEVER use bold text as heading replacement - use actual
##headings - MUST hard-wrap at column 100
- Blank line required between headings, lists, code blocks, and other elements
Outline vs docs/ boundary: Outline (wiki.${SECRET_DOMAIN}) is the household knowledge base for
home maintenance, pool care, media room, homelab reference, and hobby documentation. Content lives
in Outline when it changes independently of code, benefits from rich editing and search, or may be
referenced by non-technical users. The docs/ directory is for content tightly coupled to the
codebase: ADRs, investigation journals, runbooks, and AGENTS.md directives that evolve through PRs
alongside manifests. Litmus test: if the content needs PR review with code changes, it belongs in
docs/; otherwise it belongs in Outline.
Outline access: Manage Outline content through the ol CLI (@doist/outline-cli, installed via
mise). See the outline-cli skill for commands. SHOULD proactively use ol when the user asks to
create, update, search, or organize documentation that belongs in Outline per the boundary above.
When documenting a debugging session:
- MUST read
docs/investigations/TEMPLATE.mdfor structure - Create
docs/investigations/{component}-{brief-description}-{YYYY-MM-DD}.md - Extract any durable design decisions into separate ADRs in
docs/decisions/ - Cross-reference between investigation and ADR(s)
Investigation docs are historical snapshots, not living documents. If conclusions become stale due to infrastructure changes, the ADR supersession chain captures that; do not update old investigation docs.
Repo-scoped skills live under .opencode/skills/. Per-skill triggers (RFC 2119):
dns-debug: MUST load when diagnosing DNS resolution failures, investigating Blocky-blocked domains, debugging "site X is broken" reports, or editingkubernetes/apps/dns-private/blocky/.home-assistant: MUST load when querying Home Assistant state, firing automations/scripts, editing HA automation or script YAML, or working with./scripts/hass.shandscripts/hass/.hops: MUST load when adding, modifying, or debugging commands inscripts/hops/(per the Tier 1hopsescape hatch) OR when the Tier 1 stewardship signals appear during diagnosis. MUST NOT load to merely run existinghopscommands when no signals are present.outline-cli: MUST load when creating, updating, searching, moving, or deleting Outline wiki content via theolCLI.paperless-classify: MUST load when classifying or triaging Paperless documents; reviewing the paperless inbox; discussing taxonomy conventions; or running./scripts/paperless.sh classify.
Implementation patterns, operational workflows, and environment details.
This repository is at rcdailey/home-ops in github.
Popular repositories to use as reliable reference implementations. You MUST reference these repositories often as documentation.
- onedr0p/home-ops
- bjw-s-labs/home-ops
- buroa/k8s-gitops
- m00nwtchr/homelab-cluster
- dsluo/homelab
- aclerici38/home-ops
- ExternalSecret:
external-secrets.io/v1
Pattern: kubernetes/apps/namespace/app/
-
kubernetes/apps/{namespace}/{app}/-- app manifestsks.yaml-- Flux Kustomization (spec.targetNamespace)kustomization.yaml-- Kustomize resources list, componentshelmrelease.yaml-- HelmRelease (chartRef or chart.spec.sourceRef)pvc.yaml-- PersistentVolumeClaimsexternalsecret.yaml-- Infisical secretsconfig/-- files for configMapGenerator
-
containers/-- bespoke container images (one subdir per image) -
docs/architecture/-- system design, rationale for technology choicesdecisions/-- ADRs; read TEMPLATE.md before creating/editinginvestigations/-- historical debugging journals (not prescriptive guides)memory-bank/-- ephemeral context, temporary workarounds (remove when stale)reference/-- standalone reference material (OIDC patterns, protocol guides)runbooks/-- step-by-step operational procedures
-
Namespace directories MUST match actual namespace names exactly
-
Use flat directory structure for YAML files
-
Use subdirectory for files used in configMapGenerator
-
Use straightforward naming matching directory structure (e.g., mariadb-operator NOT cluster-apps-mariadb-operator)
-
App
ks.yamlfile (Flux Kustomization) must be listed in parent kustomization.yaml resources -
App
kustomization.yamllists resources:helmrelease.yaml,pvc.yaml,externalsecret.yaml, etc -
ALWAYS include ./pvc.yaml in kustomization.yaml resources
-
PVC naming: Primary PVC matches app name, additional PVCs use {app}-{purpose}
Copy patterns from exemplary apps rather than using synthetic templates. These apps follow all Tier 1 and Tier 2 conventions:
Canonical example (start here): kubernetes/apps/default/donetick/
- Complete app-template implementation with all best practices
- Files: ks.yaml, kustomization.yaml, helmrelease.yaml, pvc.yaml, externalsecret.yaml
- Patterns: chartRef, strategy: Recreate, advancedMounts, security context, liveness-only probe
ConfigMapGenerator pattern: kubernetes/apps/media/plex/
- Uses config/ subdirectory with configMapGenerator and disableNameSuffixHash: true
External chart pattern: kubernetes/apps/default/headlamp/
- Uses chart.spec.sourceRef with local HelmRepository (not app-template)
ks.yaml (Flux Kustomization):
targetNamespacesets namespace (NOT metadata.namespace)dependsOn: global-configrequired if using cluster-secrets substitutiondependsOn: rook-ceph-clusterrequired if using ceph storagedependsOn: garage-instancerequired if using Garage S3 backups (see Database and Logging)postBuild.substitute.APPrequired if using volsync component
kustomization.yaml (Kustomize):
- NO namespace field (inherited from parent ks.yaml)
- List all resources explicitly
- Components (volsync, nfs-scaler) are optional based on app needs
helmrelease.yaml (App-Template):
chartRef.namespace: flux-systemREQUIRED (OCIRepository location)- Controller name MUST match HelmRelease metadata.name
strategy: RecreateREQUIRED for RWO volumes- ALWAYS use advancedMounts (format:
{controller}: {container}: - path: /path)
helmrelease.yaml (External Chart):
- Use
chart.spec.sourceRefwith local HelmRepository defined in same directory - See headlamp for example with helmrepository.yaml alongside helmrelease.yaml
pvc.yaml:
- Primary PVC name matches app name; additional PVCs use {app}-{purpose}
- Storage types: ceph-block (RWO, Recreate), ceph-filesystem (RWX, RollingUpdate), NFS (RWX, RollingUpdate, media/large files)
externalsecret.yaml:
- Path format:
/namespace/app-name/secret-name - Add secrets:
just infisical add-secret /namespace/app/key "value"
Source repositories:
- GitRepository (Kustomization sources): ALWAYS in flux/meta/repos - ks.yaml can't deploy its own source
- OCIRepository/HelmRepository (chart sources): Shared (2+ apps) in flux/meta/repos with
namespace: flux-system; single-use local to app, omit namespace (inherits from ks.yaml)
Kustomize files:
- Parent kustomization.yaml: Sets namespace, lists app ks.yaml files, includes components (common, drift-detection)
- App kustomization.yaml: Lists resources, may include components (volsync), may define configMapGenerator
- Stable naming (disableNameSuffixHash: true): ONLY for cross-resource dependencies (Helm valuesFrom, persistence.name)
Volsync component:
Add to app kustomization.yaml components; in ks.yaml add postBuild.substituteFrom: cluster-secrets. Variables: APP (required), VOLSYNC_PVC (default: APP),
VOLSYNC_STORAGECLASS/VOLSYNC_SNAPSHOTCLASS (default: ceph-block/csi-ceph-blockpool). For
ceph-filesystem PVCs: set both to ceph-filesystem/csi-ceph-filesystem
Secrets priority: envFrom > env.valueFrom > HelmRelease valuesFrom
For apps with multiple processes (main + worker + redis pattern):
Reference implementation: kubernetes/apps/default/immich/
- Define separate controllers for each process (immich, machine-learning, redis)
- Each controller gets its own service with
controller:reference - Use advancedMounts to map persistence per-controller:
{controller}: {container}: - path: - Each controller can have independent strategy, replicas, and security context
For apps requiring Intel GPU acceleration:
Reference implementation: kubernetes/apps/default/immich/ (machine-learning controller)
- ks.yaml: Add
dependsOn: intel-gpu-resource-driver(namespace: kube-system) - Pod: nodeSelector
feature.node.kubernetes.io/custom-intel-gpu: "true" - Pod: resourceClaims with resourceClaimTemplateName referencing app-specific ResourceClaimTemplate
- Container: resources.claims to request the GPU
- Values: resourceClaimTemplates with deviceClassName: gpu.intel.com
- OpenVINO: Set OPENVINO_DEVICE: GPU for hardware acceleration
GitOps flow: Modify manifests -> User commits/pushes -> Flux auto-applies -> Optional just reconcile
User-only commands (agents MUST NOT run these): just reconcile, flux reconcile, helm template, just talos diff-config/just talos apply-node. Use hops flux values and hops flux defaults instead of helm show values/helm get values. Standalone scripts:
./scripts/test-vrl.py (VRL validation), ./scripts/icon-search.py (dashboard icons),
./scripts/hass.sh (Home Assistant API), ./scripts/qui.py (QUI/qBittorrent management API),
./scripts/arrapi.py (Sonarr/Radarr API: get/put/post/delete verbs, --all/--type
instance targeting, indexer list/fix-seeds subcommands; env: SECRET_DOMAIN,
{SONARR,SONARR_ANIME,RADARR,RADARR_4K,RADARR_ANIME}_API_KEY)
paperless CLI: ./scripts/paperless.sh (subcommands: doc, bulk, tag, type, correspondent,
field, workflow, config). Run --help on the relevant subcommand before use.
talosctl access: Read-only diagnostic subcommands (get, version, services, dmesg,
logs, health, inspect, time, etcd members, etcd status, etcd snapshot, netstat) are
permitted for agents. Mutating subcommands are denied via permissions.
Conventional commits:
Type is determined by intent (what the change accomplishes), not by filename. Path patterns below identify unambiguous cases where a single type always applies; everything else requires reading the diff.
The primary product of this repository is the cluster. feat and fix describe changes to the
cluster's behavior (new apps, bug fixes, alert resolutions). Changes that only affect developer
tooling, CI, or documentation are never feat or fix unless they accompany a cluster change in
the same commit.
Path-determined types (path alone decides):
- ci:
.github/workflows/**,.justfiles/**, .justfile - build: renovate.json5,
.renovate/** - chore: .editorconfig, .gitignore, .yamllint.yaml, .markdownlint-cli2.yaml, .pre-commit-config.yaml
- docs:
docs/**, LICENSE, SECURITY.md, CODEOWNERS
Intent-determined types (diff content decides):
kubernetes/**: feat (new app/service directory), fix (bug fixes, crash loops, probe failures, resource issues, alert resolutions), refactor (reorganization, no behavior change)scripts/**: chore (new script or capability), fix (bug fix that fixes cluster behavior).opencode/**: chore (new skill, agent, or command), fix (bug fix that fixes cluster behavior)- Root
*.md(AGENTS.md, README.md, etc.): docs when prose-only; use the type matching the co-changed domain when the markdown change accompanies code (e.g., adding a skill + its AGENTS.md routing entry is chore, not docs) - Breaking (append
!after scope): API/CRD upgrades, incompatible Helm upgrades, storage migrations
Scope format by domain:
kubernetes/**: app name from directory (e.g.,plex,bookstack)scripts/**: script name without extension (e.g.,hass,hops).opencode/**: component name (e.g., skill namehome-assistant, agent namecommit)flux/**,talos/**: component or subsystem name- Omit scope for repo-wide changes that span multiple domains
Mixed-path commits: when a single commit spans multiple domains (e.g., a script + its AGENTS.md entry), use the type and scope of the primary change. The primary change is the one that provides the new behavior; supporting changes (docs updates, routing entries) are secondary.
Examples: fix(plex): resolve crash loop, feat(bookstack): add wiki documentation app,
chore(home-assistant): add API integration skill, docs: update investigation template
Stack components:
- OS: Talos Linux
- Orchestration: Kubernetes with Flux v2 GitOps
- Secrets: SOPS with Age encryption, External Secrets Operator with Infisical
- Storage: Rook Ceph (distributed), NFS from Nezuko, Garage S3
- Automation: just, mise, talhelper
Alerting:
- Pushover: VMAlertmanager sends alerts to Pushover app for mobile notifications
- Healthchecks.io: Dead man's switch; Watchdog alert pings external endpoint every 5 minutes. If cluster or monitoring stack goes down, Healthchecks.io detects missing pings and alerts via Pushover.
Network topology:
- Main subnet: 192.168.1.0/24
- BGP subnet (Cilium LoadBalancers): 192.168.50.0/24
- AT&T Fiber Gateway: BGW320-505 (IP passthrough to UDMP)
- Gateway (Unifi UDMP): 192.168.1.1
- Kubernetes API: 192.168.1.70
- LoadBalancer IPs (Cilium IPAM): 192.168.50.71-.99 (infrastructure), 192.168.50.100+ (applications)
Cluster nodes:
- Control plane: hanekawa (192.168.1.63), marin (192.168.1.59), sakura (192.168.1.62)
- Workers: lucy (192.168.1.54), nami (192.168.1.50)
Node storage devices:
Every node has two physical drives: sda (SATA, Talos system disk) and nvme0n1 (NVMe, Ceph OSD).
When interpreting disk metrics, sda is always the OS disk and nvme0n1 is always the Ceph data
disk. The rbd* devices are Ceph RBD block devices mapped by CSI (not physical disks).
| Node | sda (Talos) | nvme0n1 (Ceph OSD) |
|---|---|---|
| hanekawa | Intel S3700 400GB | Samsung 970 EVO Plus 1TB (OSD.5) |
| marin | Intel S3700 400GB | Samsung 970 EVO Plus 1TB (OSD.2) |
| sakura | Intel S3700 400GB | Samsung 970 EVO Plus 1TB (OSD.4) |
| lucy | Intel S3700 400GB | Crucial P3 2TB (OSD.3) |
| nami | Intel S3700 400GB | Samsung 990 PRO 2TB (OSD.0) |
Storage backends:
- Rook Ceph: Distributed block/filesystem storage across cluster nodes
- NFS (Nezuko 192.168.1.58): Media (100Ti), Photos (10Ti)
- Garage S3 (192.168.1.58:3900): Region garage, per-app buckets (e.g.,
immich-postgres-backups,home-assistant-postgres-backups,outline-postgres-backups) - Volsync: Kopia repository on NFS (Nezuko /mnt/user/volsync), shared single repository with per-app isolation via snapshot identity
- CloudNativePG: Barman WAL archiving to per-app S3 buckets (e.g.,
s3://immich-postgres-backups). Each CNPG cluster MUST have its own GarageS3Bucket CR to avoid permission races when multiple CRs target the same underlying bucket. - Ceph toolbox:
./scripts/hops.sh storage ceph status(orosd,io)
hops CLI: Run ./scripts/hops.sh --help for domains, ./scripts/hops.sh <domain> --help for
commands. Key entry points for debugging: ./scripts/hops.sh app diagnose APP [-n NS] (workload or
gateway-only scope, flux status + pods + events + recent logs), ./scripts/hops.sh app pod APP [-n NS] (per-pod drill-down with container state machine, previous-termination table, auto-fetched
crash logs for restarted containers and failure logs for non-zero exit containers, and pod-scoped
event timeline), ./scripts/hops.sh app unhealthy [NS] (cluster-wide pod health check; shows all
non-Running/non-Succeeded pods with status reason, or a one-liner when everything is healthy; use
during and after infrastructure changes to monitor recovery), ./scripts/hops.sh db status (all
CNPG clusters: instances, phase, PDB status, backup config, pod placement with role/node, memory
request/actual/limit, PVC sizes, last backup time), ./scripts/hops.sh backup status (unified
backup health: Volsync last sync times for all ReplicationSources plus CNPG scheduled backup results
with schedule and last completion), and ./scripts/hops.sh debug route APP [-n NS] (gateway request
path trace: HTTPRoute, Gateway, ClientTrafficPolicy, BackendTrafficPolicy, SecurityPolicy,
EnvoyProxy config, and recent envoy access log errors for the route's hostnames). Use app pod
whenever a specific pod misbehaves (crashloops, startup races, image pull failures, terminated
Jobs). Use app unhealthy as a quick cluster health scan before or after any change. Use db status for CNPG health checks, replica audits, PDB readiness, and resource usage across all
database clusters. Use backup status for backup health audits. Use debug route when requests to
an app fail at the gateway layer (503s, upload failures, timeouts, TLS issues). app diagnose and
app pod accept workload names, app labels, pod-name prefixes, or full pod names, including orphan
pods whose parent workload has been deleted (TTL'd Jobs, manually removed controllers) and
operator-managed pods without parent workloads (CNPG Clusters, etc.). debug route accepts app
names, HTTPRoute names, or hostname substrings. app diagnose --explain prints the resolver trace,
showing which resolvers were tried and what matched (useful for debugging resolution failures). app types lists every resolvable resource category with a sample name from the cluster.
hops test suite: The test suite at scripts/hops/tests/ MUST be run after any hops change:
uv run --project scripts/hops pytest scripts/hops/tests/ -v. Fix failures caused by code changes
(update assertions, not delete tests). Add tests for new commands and resolver strategies.
- Create directory
kubernetes/apps/{namespace}/{app}/ - Create ks.yaml with correct path, targetNamespace, dependencies
- Create kustomization.yaml listing all resources
- Create helmrelease.yaml with correct chartRef pattern
- Create pvc.yaml if stateful (match storage type to strategy)
- Create externalsecret.yaml if secrets needed
- Add ks.yaml to parent
kubernetes/apps/{namespace}/kustomization.yaml - Add secrets to Infisical:
just infisical add-secret /namespace/app/key "value"