- TypeScript 93.1%
- JavaScript 6.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The control-plane Kustomization has been running with prune off since the
handoff, because with it on a build that silently lost an app would have Flux
delete that app's Kustomization — which cascades into pruning its PVCs and
Postgres. This moves that protection to the PR boundary so the flag can go back
to doing its job.
clusters/home now asserts, against what is actually on disk:
- every app under apps/ has a Kustomization building ./apps/<name>/k8s —
the one that catches a silent deletion
- no Kustomization points at an app that does not exist
- names are unique, so one cannot quietly shadow another
- every app Kustomization prunes (prune off belongs to the control-plane one
in home-cloud, not to the apps)
Derived from the filesystem rather than a hardcoded list of six, so adding an
app does not require remembering to update the check.
Verified against the failure modes it exists for, not just the happy path.
Un-exporting mealie's declaration produces:
app "mealie" exists on disk but no Kustomization builds ./apps/mealie/k8s —
with prune on, deploying this would DELETE mealie and its storage
and setting prune: false on one app is caught too. CI runs it after the build
step for clusters/home only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
| .github/workflows | ||
| apps | ||
| clusters/home | ||
| packages | ||
| README.md | ||
home-chant
chant-based Kubernetes apps deployed to the home-cloud k3s cluster. Each app is TypeScript-defined infrastructure compiled to plain Kubernetes YAML — the TypeScript in apps/<name>/src/ is the source of truth; the generated apps/<name>/k8s/manifests.yaml is a build artifact and should never be hand-edited.
Layout
packages/
traefik-app/ # shared composite — the boilerplate every public app repeats
apps/
<app-name>/
src/
infra.ts # chant TypeScript source (Deployment, Service, ...)
chant.config.ts
package.json
k8s/
manifests.yaml # `chant build` output — committed, not hand-edited
kustomization.yaml # references manifests.yaml
The TraefikApp composite
packages/traefik-app holds the resources every public app in this cluster
repeats verbatim: the Namespace, the Infisical ServiceAccount + InfisicalSecret
pair, the Service, and the Certificate + IngressRoute + redirect-IngressRoute
trio. Apps depend on it through a file: reference and call it like this:
const app = TraefikApp({
name, host: hostname, port,
infisical: { identityId, projectSlug, secretName },
});
export const { namespace, serviceAccount, secret, service,
certificate, ingressRoute, ingressRouteHttp } = app;
TraefikEndpoint is the same thing minus the Namespace and Infisical pair — for
a second web surface in an existing namespace (mem0's dashboard is the only one).
It deliberately does not cover the Deployment. The five converted apps share
almost nothing there: calcom needs a startupProbe with a 45-failure runway, mem0
replaces the image's CMD with alembic && uvicorn, radicale runs as uid 2999
with an fsGroup, ntfy and mealie mount different volumes, and only calcom keeps
the default RollingUpdate. A prop per variation is how you arrive at the k8s
lexicon's own WebApp — thirty-odd optional props that still can't express
command. Workloads stay hand-written.
hello-chant doesn't use the composite either: it lives in default with no
Namespace, no secrets, and Tailscale annotations on its Service. Forcing it
through would mean props that exist for one caller.
Because the dependency is a file: link and Node resolves links to their real
path, packages/traefik-app needs its own npm ci before an app can build —
CI does this as a separate step.
home-cloud's cluster uses Traefik IngressRoute CRDs rather than standard Kubernetes Ingress. The k8s lexicon types those natively (IngressRoute, IngressRouteTCP, IngressRouteUDP, Middleware, TraefikService, ...), alongside the other operator CRDs this cluster runs — cert-manager's Certificate, Infisical's InfisicalSecret, CNPG's Cluster, and Flux's GitRepository/Kustomization/HelmRelease. They're all just new X({...}) imported from @intentius/chant-lexicon-k8s; no CRD in home-cloud currently needs an escape hatch.
Adding a new app
mkdir -p apps/<name>/src && cd apps/<name>
npx chant init --lexicon k8s
# edit src/infra.ts — see apps/hello-chant/src/infra.ts for the shape.
# For an existing app, import it instead of hand-writing:
# npx chant import --kustomize ../../../home-cloud/k8s/<name> --output src
npx chant lint src
npx chant build src --lexicon k8s --format yaml --output k8s/manifests.yaml
Then onboard it to Flux by adding a Kustomization (pointing at apps/<name>/k8s) to clusters/home/apps/home-chant.yaml in the home-cloud repo — see home-cloud's deploying-apps.md. Pick an app name that doesn't collide with anything already running in the default namespace — Deployment selectors are immutable, so a name collision blocks Flux from ever applying it.
Build parameters
Each app declares domain and issuer under buildParams in its
chant.config.ts, and src/infra.ts reads them as params.domain /
params.issuer. The defaults reproduce what's deployed, so a plain
chant build is unchanged; overriding gives a staging-cert path without a
second source tree:
npx chant build src --lexicon k8s --format yaml --param issuer=letsencrypt-staging
npx chant build src --lexicon k8s --format yaml --param domain=lab.example.com
domain is the base domain the public hostname hangs off — dav.${domain} for
radicale, mem0-ui.${domain} for mem0's dashboard. Its default is per app:
everything is on inevitable.fyi except calcom, which is on jakegaylor.com.
issuer declares an enum, so a typo is a build error naming the parameter
rather than a bad Certificate reaching the cluster.
These are parameters rather than process.env reads on purpose: a parameter is
resolved before any source file is read, so it folds to a literal. An ambient
env read can't fold, and chant rejects a bare process reference in project
source.
Note the EMAIL_FROM / SMTP_FROM_EMAIL addresses on mealie and calcom are
not parameterized — those are on the mail domain (updates.inevitable.fyi),
which is independent of where the app is hosted. calcom is the case that proves
the point: its public host is on jakegaylor.com while its mail stays on
inevitable.fyi.
CI
.github/workflows/validate.yml discovers every app under apps/*/ and, for each, runs chant lint and rebuilds k8s/manifests.yaml, failing if the committed output has drifted from the TypeScript source.
Namespace
Resources intentionally don't hardcode a namespace (chant's linter flags this as WK8001 — hardcoded namespaces should come from deploy-time config, not source). The namespace is set once, at the Flux Kustomization level (spec.targetNamespace), in home-cloud.