Files
yucca/CLAUDE.md
T

12 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

Yucca is a multi-tenant backup service: OIDC-authenticated users get S3-backed restic repositories. The repo is two things at once:

  • Application (packages/) — the NestJS/Go/Svelte services that run the product.
  • Infrastructure (tf/, ansible/, kubernetes/, charts/) — everything that operates Yucca: Ceph object storage, Talos K8s, Flux GitOps, networking. See the table in README.md and the per-directory README.md files; each infra subtree is largely self-contained.

Tooling model

Everything is driven through mise tasks, not raw pnpm/go. Task definitions live in .mise/tasks/ (shell scripts, hierarchical: web/test/_default is mise run web:test) and .mise/config.toml (the [tasks.*] aggregate tasks + tool pins). mise also pins every binary version (node, pnpm, go, kubectl, helm, tilt, opentofu, ansible, etc.) — do not assume a tool is on PATH outside of mise.

Package manager is pnpm workspaces with a catalog (pnpm-workspace.yaml): dependency versions are centralized there and referenced as "catalog:" in each package.json. Add/bump shared deps in the catalog, not in individual packages.

Secrets come from 1Password via op run. Tasks that need secrets wrap commands in op run --env-file=... --; .env files contain op:// references, never literal secrets. OP_ACCOUNT is set in .env (copy .env.example).

Common commands

# Compose-based dev (fastest inner loop): postgres, minio, mock-oidc, victoria-* in Docker
mise dev                  # install deps, build common, start docker infra, run all *:dev
mise <pkg>:dev            # run one service, e.g. mise web:dev, mise yucca-api:dev

# Quality gates (what CI runs)
mise check                # lint + format check + svelte-check + unit tests (= the `checks` CI job)
mise fix                  # autofix lint/format + lingui extract
mise lint / mise format   # individually
mise build                # build all packages

# Tests
mise test                 # all unit tests (jest per NestJS pkg, vitest for web)
mise test:integration     # all integration tests (runs --jobs 1; needs infra up)
mise test:e2e             # e2e (jest suites) — needs the stack running
mise test:e2e:web         # web Playwright e2e
mise <pkg>:test           # one package's unit tests, e.g. mise yucca-api:test
mise yucca-api:test -- -t "name of test"   # single test (args after -- go to jest)
mise yucca-api:test:watch # watch mode

# Database migrations (yucca-api owns the schema)
mise yucca-api:migrations <args>   # wraps @immich/sql-tools against the dev DB

k3d + Tilt (prod-shaped dev)

The alternative dev flow that mirrors prod topology (Helm, CloudNativePG, Rook-Ceph, in-cluster DNS):

mise k3d:up      # create local k3d cluster + registry
mise tilt:up     # build images, render charts from kubernetes/apps/dev/local, port-forward, live_update
mise tilt:down
mise k3d:down

CI uses the same path: mise tilt:ci-infra (infra only, apps run on the runner) for integration tests, mise tilt:ci (full stack) for e2e. See Tiltfile (extensively commented) — Tilt's source of truth is the Flux tree under kubernetes/, not a separate manifest.

Forwarded ports: 5173 web · 3020 yucca-api · 3030 yucca-admin-api · 3010 michael · 8092 mock-oidc · 9000 ceph rgw (S3) · 8428 victoria-metrics · 9428 victoria-logs.

Infrastructure (terragrunt / ansible)

mise tf:plan / tf:apply / tf:init / tf:destroy   # terragrunt for a stack (TF_STACK_DIR=tf/deployment/<partition>/<region>/<stack>)
mise tf:fmt
mise k8s:validate        # helm template + kubeconform + flux-local build of the whole k8s tree (CI gate, no cluster)
mise mgmt:render-inventory / mgmt:ansible        # render + converge management hosts

tf:* wrap terragrunt in tf/op-run.sh (injects the 1P superuser token from tf/.env).

CI owns terraform applies. Do not run mise tf:apply (or infra:apply) by hand — terraform/terragrunt applies are run by CI (.github/workflows/infra.yml) on merge to main. Locally you may tf:plan to preview, but never apply. (Also: the tf:* tasks inherit a stray AWS_CA_BUNDLE=~/.config/homelab/root-ca.crt from the shell that breaks the OVH S3 state backend — unset it if you plan locally.)

NetBird futo-org provider — netbird_group resources bug (fixed in 1.0.2): registry.terraform.io/futo-org/netbird ≤ 1.0.1 could not UPDATE/DELETE a netbird_group that had network resources tagged into it — the TF→API path decoded the resources list of {id,type} objects into []map[string]string ("cannot reflect tftypes.Object … into a map"), so a resource-tag group (e.g. htz-fsn1 resources) couldn't be renamed in place. Fixed in 1.0.2 — we own the provider (../terraform-provider-netbird); stacks pin version = "1.0.2". Renaming a setup key still forces replacement (the NetBird API can't rename keys), regenerating its value.

Application architecture

Backend services are NestJS 11 + TypeScript, sharing patterns: controllers → services → repositories (data access), Zod-validated env.ts, JWT auth guards via an @AuthRoute() decorator, and observability from the shared @common/server package. Each service imports @common/server/otel at bootstrap to init the OpenTelemetry SDK (pino logs, OTLP traces/metrics to victoria-*).

Service Lang Role
yucca-api NestJS User-facing API. Owns auth (OIDC code + device flow, ES256 JWTs), repositories, the database schema + migrations.
yucca-admin-api NestJS Admin API (user/session/repository management). Shares the same DB + JWT validation.
michael Go Production restic REST backend — S3 proxy implementing restic's HTTP protocol, with JWT (ECDSA pubkey) verification, WORM enforcement, multi-backend pool/DNS load-balancing. Deployed in k8s (kubernetes/apps/base/michael).
restic-api NestJS Earlier TypeScript implementation of the same restic backend, kept as a reference (mise restic-api:dev-reference); not in the deployed app set.
yucca-metrics-worker NestJS Cron worker (every 5 min): reads bucket usage from RadosGW, writes meter tables, emits OTel gauges.
mock-oidc-provider Node Dev/test OIDC IdP (code + device flow). Used by compose and k3d when no real issuer is configured.
common (@common/server) TS lib Shared OTel init, pino logger repository, logging interceptor.

Frontend (packages/web) is SvelteKit 5 + Tailwind 4, using @immich/ui, lingui i18n (mise web:lingui:* to extract/compile — compiled locales are generated, not edited), and the generated API client. It also embeds the orchestration UI (@futo-org/backups-orchestrator-ui).

packages/yucca-sdk/ is a separately-versioned product: orchestration-api (NestJS) + orchestration-ui (SvelteKit). Not in the pnpm workspace glob's packages/* alone — note packages/yucca-sdk/* is added explicitly in pnpm-workspace.yaml.

API client generation (don't hand-edit generated files)

yucca-api controllers/DTOs (via @nestjs/swagger) → mise yucca-api:sync-openapi writes openapi-specs.json → mise yucca-api-client:build runs oazapfts to generate packages/yucca-api-client/src/fetch-client.ts (published as @futo-org/backups-api-client, consumed by web). fetch-client.ts is generated (eslint-ignored). When you change an API contract, regenerate rather than editing the client.

Database

PostgreSQL accessed via Kysely. The schema lives in packages/yucca-api/src/schema/ (tables/ definitions, migrations/ SQL run through @immich/sql-tools). yucca-api is the schema owner; other services read the same DB.

Go services

michael and yuctl are Go 1.25, module <name> + internal/<pkg>, aws-sdk-go-v2 for S3, rs/zerolog for logs. yuctl (packages/yuctl, cobra CLI) is the operations CLI: it reads the Terraform discovery contract from S3 state (no terragrunt/checkout) to resolve the partition→region→{k8s, ceph} topology and drive day-2 ops. See packages/yuctl/README.md.

Infrastructure architecture

The whole fleet is modeled as partition → region → { exactly one K8s cluster, one-or-more Ceph clusters } (introduced in #222; replaced the older env/site terms). Partitions: prod, staging, dev. Regions e.g. htz-fsn1, austin, local, plus a global pseudo-region for account-wide stacks. Slug = <partition>-<region>.

  • tf/ — OpenTofu + Terragrunt, authoritative for cluster identity, 1P secret items, and rendered Ansible inventories. Layout: deployment/<partition>/<region>/<stack>/; shared logic in shared/modules/ (ceph-cluster, talos-baremetal, identity, fabric-addressing, netbird-env, fabric/switch config). Every stack emits a non-sensitive discovery output (the contract yuctl/k8s/ansible consume); all secrets in it are op:// refs. render/ renders Ansible inventories.
  • ansible/ — three subtrees: ceph/ (cephadm bare-metal clusters), talos/ (Talos K8s as libvirt VMs on the Ceph hypervisors — VM provisioning only; talosctl gen/bootstrap is owned by the TF siderolabs provider), mgmt/ (Hetzner management hosts + NetBird routing). Roles are gated by *_enabled flags.
  • kubernetes/ — Flux GitOps. clusters/<partition>/<region>/ are entry points; apps/{base,<overlays>,dev/local}/ hold HelmReleases; components/{infra,apps,roles}/ are Kustomize Components selected per role (primary/secondary). Three config layers merged via postBuild: cluster-settings.generated.yaml (TF-rendered), cluster-settings.yaml (human), image-versions (CI). Tilt reads apps/dev/local/.
  • charts/ — in-repo Helm charts. lib/yucca-common is a library chart (shared deployment/ service/secret templates); apps/<svc> are per-service charts depending on it; platform/ for rook/cnpg/objectuser. Service names are pinned via fullnameOverride so in-cluster DNS is identical whether Tilt or Flux renders them.

Deploy flow on merge to main: CI builds images tagged 0.0.<run_number> → Flux auto-promotes the highest tag to staging → production is gated behind a reviewed promote-production job.

Conventions

  • Conventional commits are enforced on PRs (feat(scope):, fix(scope):, chore:). Common scopes: ceph, netbird, michael, yucca-api, ansible, bgp. Releases are automated via release-please (chore(main): release … PRs); the monorepo is single-versioned.
  • ESLint flat config (eslint.config.mjs) is strict on promises: no-floating-promises, no-misused-promises, require-await, await-thenable are all errors. Prettier: single quotes, trailing commas, width 120.
  • Generated files are eslint-ignored: **/fetch-client.ts, packages/web/src/locales, dist, build, .svelte-kit.

Naming

  • Cluster names are themed by workload. Kubernetes clusters → Star Wars (luke = staging, father = the soon-to-be prod). Ceph clusters → Dune (sietch, spice, …). Choose the next themed name when standing up a cluster; it's the Talos/Ceph cluster name + the <clustername> hostname segment.
  • Node hostnames follow <product>-<provider>-<region>-<clustername>-<role>-<nodename> — e.g. a staging Talos node is yucca-int-aus-luke-k8s-<word>:
    • product = yucca; role = the workload segment (k8s for Talos nodes, ceph for Ceph).
    • provider / region = the 3-letter provider_code / region_code from the region's region.hcl (austin = int/aus, htz-fsn1 = htz/fsn).
    • <clustername> = the themed cluster name (above).
    • <nodename> = auto-picked from the shared name inventory (tf/shared/modules/node-names/wordlist.txt) — deterministic per cluster, unique within it; pass an explicit node name to override.