diff --git a/docs/guide/skill.md b/docs/guide/skill.md new file mode 100644 index 0000000..cd5d97a --- /dev/null +++ b/docs/guide/skill.md @@ -0,0 +1,75 @@ +# Use with Claude (Skill) + +`@tsops/skill` is a [Claude Skill](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) that teaches Claude how to operate tsops correctly. Once installed, Claude Code, the Agent SDK, and any other tool implementing the [Agent Skills standard](https://agentskills.io/specification) load it automatically when relevant. + +## Why + +The docs explain what tsops _is_. The Skill tells Claude how to _use_ it — specifically, the hard rules that an agent will violate by default if it has only ever seen YAML deploys before: + +- Don't put internal service URLs in env vars — use `config.url('api', 'service')`. +- Always run `tsops plan` before `tsops deploy`. +- After editing `tsops.config.ts`, run `tsc --noEmit` so renames propagate. +- Never `--no-verify` past secret validation. + +Without the Skill, an agent reaches for `BACKEND_URL=http://api:3000` because that pattern is everywhere else on the public internet. With the Skill, it reaches for `config.url`. + +## Install + +::: code-group + +```bash [user-scope] +# Install once for your account → ~/.claude/skills/tsops +npx @tsops/skill install +``` + +```bash [project-scope] +# Commit the Skill to your repo → ./.claude/skills/tsops +# Every contributor's agent picks it up automatically. +npx @tsops/skill install --project +``` + +::: + +After install, restart Claude Code (or your Agent SDK session). Verify with: + +```bash +claude /skills +# "tsops" should appear in the list +``` + +## What's inside + +``` +~/.claude/skills/tsops/ +├── SKILL.md # entry point — frontmatter + hard rules +├── reference/ +│ ├── commands.md # CLI commands and flags +│ ├── runtime-helpers.md # config.url, config.env, config.dns +│ ├── secrets.md # secret validation +│ └── preview-overlays.md # overlay namespace lifecycle +└── examples/ + ├── add-app.md + ├── rename-app.md + └── add-secret.md +``` + +The Skill is small on purpose. The entry-point `SKILL.md` covers the mental model and the hard rules. References load on demand — Claude pulls in `reference/secrets.md` only when secret work is in scope. + +## Updating + +```bash +npx @tsops/skill@latest install --force +``` + +The Skill is versioned independently of tsops core. When the CLI surface changes in a way that affects how an agent should operate, the Skill gets a release with updated instructions. + +## Uninstall + +```bash +npx @tsops/skill uninstall # remove from ~/.claude/skills/tsops +npx @tsops/skill uninstall --project # remove from ./.claude/skills/tsops +``` + +## Source + +The Skill content lives at [`skills/tsops/`](https://github.com/Pom4H/tsops/tree/main/skills/tsops) in the main tsops repo. Edits go through PR review, the same as any other code change. Changes to the Skill should explain — in the PR description — what failure mode the change prevents. diff --git a/packages/skill/README.md b/packages/skill/README.md new file mode 100644 index 0000000..af4d0bd --- /dev/null +++ b/packages/skill/README.md @@ -0,0 +1,69 @@ +# @tsops/skill + +A [Claude Skill](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) that teaches Claude how to use [tsops](https://github.com/Pom4H/tsops) correctly. + +When installed, Claude Code, the Agent SDK, or any other tool that implements the [Agent Skills standard](https://agentskills.io/specification) will load this skill automatically when: + +- the project contains a `tsops.config.ts` file, or +- the user mentions tsops, `tsops plan`, `tsops deploy`, `tsops up`, preview namespaces, or asks to add/rename/remove apps, secrets, namespaces, or routes. + +## Install + +```bash +# Run once — copies the skill into ~/.claude/skills/tsops +npx @tsops/skill install +``` + +Or commit the skill to your repo so every contributor's agent picks it up: + +```bash +npx @tsops/skill install --project +``` + +After install, restart Claude Code (or your Agent SDK session). Verify: + +```bash +claude /skills +# tsops should be listed +``` + +## What's inside + +``` +~/.claude/skills/tsops/ +├── SKILL.md # entry point — frontmatter + tactical rules +├── reference/ +│ ├── commands.md # tsops plan / build / deploy / up / down +│ ├── runtime-helpers.md # config.url, config.env, config.dns +│ ├── secrets.md # secret validation, cluster fallback +│ └── preview-overlays.md # PR-style preview namespaces +└── examples/ + ├── add-app.md # recipe: add a new app + ├── rename-app.md # recipe: rename safely (compiler-driven) + └── add-secret.md # recipe: add a secret +``` + +The skill is small on purpose — references load on demand, only the file relevant to the current task. + +## Why a skill, not just docs + +The tsops docs explain what tsops is. This skill teaches Claude **how to operate it correctly** — the hard rules (no internal URLs in env vars, never bypass `tsops plan`, always run `tsc --noEmit` after a rename), the canonical workflow, and the specific failure modes that cost the most time. + +Without the skill, an LLM agent will reach for `BACKEND_URL=http://api:3000` because that's the pattern it has seen everywhere else. With the skill, it reaches for `config.url('api', 'service')`. + +## Uninstall + +```bash +npx @tsops/skill uninstall +npx @tsops/skill uninstall --project +``` + +## Versioning + +This package is versioned independently of the tsops core. The skill text is content-addressable — pinning a version pins the wording. + +When tsops's CLI surface changes in a way that affects how an agent should use it, this package gets a release with the updated instructions. + +## License + +MIT diff --git a/packages/skill/bin/install.mjs b/packages/skill/bin/install.mjs new file mode 100755 index 0000000..8b33579 --- /dev/null +++ b/packages/skill/bin/install.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * `tsops-skill install` — copy the bundled tsops Claude Skill into + * either `~/.claude/skills/tsops` (user scope, default) or + * `/.claude/skills/tsops` (project scope, with `--project`). + * + * Idempotent: re-running overwrites existing files. Refuses to delete + * unrelated content under the target directory. + */ +import { argv, exit, cwd } from 'node:process' +import { homedir } from 'node:os' +import { mkdir, cp, stat, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PKG_DIR = resolve(__dirname, '..') +const SKILL_SRC = join(PKG_DIR, 'skill') +const SKILL_NAME = 'tsops' + +function parseArgs(args) { + const out = { command: 'install', scope: 'user', force: false, help: false } + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === 'install' || arg === 'uninstall' || arg === 'where') { + out.command = arg + } else if (arg === '--project' || arg === '-p') { + out.scope = 'project' + } else if (arg === '--user' || arg === '-u') { + out.scope = 'user' + } else if (arg === '--force' || arg === '-f') { + out.force = true + } else if (arg === '--help' || arg === '-h') { + out.help = true + } else { + console.error(`Unknown argument: ${arg}`) + out.help = true + } + } + return out +} + +function targetDir(scope) { + const base = scope === 'project' ? cwd() : homedir() + return join(base, '.claude', 'skills', SKILL_NAME) +} + +function help() { + console.log(`tsops-skill — install the tsops Claude Skill + +Usage: + tsops-skill install [--user | --project] [--force] + tsops-skill uninstall [--user | --project] + tsops-skill where [--user | --project] + +Options: + --user, -u Install into ~/.claude/skills/tsops (default) + --project, -p Install into ./.claude/skills/tsops (commit to repo) + --force, -f Overwrite without prompting + --help, -h Show this help + +After install, restart Claude Code (or any Agent SDK session) so the skill +is picked up. Verify with: claude /skills +`) +} + +async function exists(p) { + try { await stat(p); return true } catch { return false } +} + +async function readSkillVersion() { + const pkgJson = JSON.parse(await readFile(join(PKG_DIR, 'package.json'), 'utf8')) + return pkgJson.version +} + +async function install({ scope, force }) { + const dst = targetDir(scope) + const version = await readSkillVersion() + + if (!(await exists(SKILL_SRC))) { + console.error(`Bundled skill source missing: ${SKILL_SRC}`) + console.error(`This package is broken — please file an issue.`) + exit(2) + } + + if (await exists(dst) && !force) { + console.log(`Skill already present at ${dst}`) + console.log(`Re-run with --force to overwrite.`) + exit(0) + } + + await mkdir(dst, { recursive: true }) + await cp(SKILL_SRC, dst, { recursive: true, force: true }) + + console.log(`✅ Installed @tsops/skill@${version} → ${dst}`) + console.log(``) + console.log(`Next: restart Claude Code so the skill is picked up.`) + console.log(`Verify: run "claude /skills" and confirm "tsops" appears.`) +} + +async function uninstall({ scope }) { + const dst = targetDir(scope) + if (!(await exists(dst))) { + console.log(`Nothing to uninstall — ${dst} does not exist.`) + exit(0) + } + // Conservative: only remove files we'd write. Use rm with force. + const { rm } = await import('node:fs/promises') + await rm(dst, { recursive: true, force: true }) + console.log(`Removed ${dst}`) +} + +async function where({ scope }) { + const dst = targetDir(scope) + console.log(dst) + console.log((await exists(dst)) ? '(installed)' : '(not installed)') +} + +const args = parseArgs(argv.slice(2)) +if (args.help) { help(); exit(0) } + +try { + if (args.command === 'install') await install(args) + if (args.command === 'uninstall') await uninstall(args) + if (args.command === 'where') await where(args) +} catch (err) { + console.error(err.message ?? err) + exit(1) +} diff --git a/packages/skill/package.json b/packages/skill/package.json new file mode 100644 index 0000000..659d626 --- /dev/null +++ b/packages/skill/package.json @@ -0,0 +1,40 @@ +{ + "name": "@tsops/skill", + "version": "0.1.0", + "description": "Claude Skill for tsops — installs into ~/.claude/skills or .claude/skills", + "type": "module", + "bin": { + "tsops-skill": "./bin/install.mjs" + }, + "files": [ + "bin", + "skill", + "README.md" + ], + "scripts": { + "build": "node ./scripts/sync-skill.mjs", + "lint": "pnpm -w exec eslint ." + }, + "keywords": [ + "tsops", + "claude", + "claude-code", + "agent-skill", + "skill", + "anthropic" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Pom4H/tsops.git", + "directory": "packages/skill" + }, + "license": "MIT", + "author": "Roman Popov", + "engines": { + "node": ">=20.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/packages/skill/scripts/sync-skill.mjs b/packages/skill/scripts/sync-skill.mjs new file mode 100755 index 0000000..7babfb4 --- /dev/null +++ b/packages/skill/scripts/sync-skill.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Build step for @tsops/skill. + * + * The canonical source for the Skill lives at the repo root in + * `skills/tsops/`. This script syncs it into `packages/skill/skill/` + * so that the npm tarball ships the files. We don't symlink because + * npm's tarball does not preserve symlinks reliably across all + * package managers. + */ +import { cp, rm, stat } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PKG_DIR = resolve(__dirname, '..') +const REPO_ROOT = resolve(PKG_DIR, '..', '..') + +const SRC = join(REPO_ROOT, 'skills', 'tsops') +const DST = join(PKG_DIR, 'skill') + +async function exists(p) { try { await stat(p); return true } catch { return false } } + +if (!(await exists(SRC))) { + console.error(`Source skill missing: ${SRC}`) + process.exit(1) +} + +if (await exists(DST)) { + await rm(DST, { recursive: true, force: true }) +} + +await cp(SRC, DST, { recursive: true }) +console.log(`Synced ${SRC} → ${DST}`) diff --git a/packages/skill/skill/SKILL.md b/packages/skill/skill/SKILL.md new file mode 100644 index 0000000..0fb496a --- /dev/null +++ b/packages/skill/skill/SKILL.md @@ -0,0 +1,91 @@ +--- +name: tsops +description: Use when the project contains a `tsops.config.ts` file or the user mentions tsops, deploying with tsops, `tsops plan`, `tsops deploy`, `tsops up`, `tsops down`, preview namespaces, or asks how to add/rename apps, namespaces, secrets, or routes in a TypeScript-defined deployment. Covers the typed operational model, runtime helpers (`config.url`, `config.env`), preview overlays, and the diff-first plan/deploy workflow. +license: MIT +--- + +# tsops + +`tsops` is a typed operational model for containerized apps. **One `tsops.config.ts` is the source of truth for three things**: (1) what images get built, (2) what manifests get applied to Kubernetes, and (3) what runtime config the application code imports at startup. + +The third point is the one that matters most. Renaming an app or changing a port is caught by the TypeScript compiler in every caller — including the application code, not just the deploy pipeline. + +## When to use this skill + +Trigger on any of: + +- A file named `tsops.config.ts` exists at the repo root or inside a workspace package. +- The user runs or asks about `tsops plan`, `tsops deploy`, `tsops build`, `tsops up`, `tsops down`. +- The user asks to "add an app", "rename a service", "add a secret", "set up a preview environment", "deploy the frontend separately" inside a project that already uses tsops. +- The user is debugging a failing deploy and the failure mentions tsops, kubectl manifests generated by tsops, secret validation, or orphaned resources. + +If the project does **not** use tsops, do not propose introducing it unless the user explicitly asks — this skill is for working _within_ an existing tsops setup, not migrating to it. + +## Mental model (read first, applies always) + +``` +tsops.config.ts + │ + ├── input to manifest builder → kubectl apply + └── input to runtime helpers → imported by application code +``` + +Two consequences that govern every change you make: + +1. **Renaming or removing an app, secret key, or namespace breaks every caller.** The compiler will tell you. After you change anything in `tsops.config.ts`, run `pnpm tsc --noEmit` (or `bun tsc --noEmit`) at the repo root and fix every reported error before claiming the task is done. + +2. **Internal service URLs are not env vars.** Use `config.url('api', 'service')` from app code. Do **not** add `BACKEND_URL=http://api:3000` to `env`. The `env` field is for secrets, external APIs, feature flags, and build-time values only. + +## Canonical workflow + +For any tsops change, the steps are always the same: + +1. **Read the existing `tsops.config.ts` end-to-end** before editing. It is the typed graph; partial reads cause partial fixes. +2. **Edit `tsops.config.ts`** with the requested change. +3. **Run `tsops plan --namespace `** (or `pnpm tsops plan`). Read the diff. Confirm the planned changes match intent. Errors here are not warnings — fix them before deploy. +4. **Run `tsc --noEmit`** in any package that imports the config (frontend, backend) to catch type breakage downstream. +5. **Only when plan is green and types compile, run `tsops deploy --namespace `.** + +`tsops deploy` is atomic per app and prunes orphaned resources tagged `tsops/managed=true`. It is safe to re-run. + +For preview environments use `tsops up preview --var pr=` and `tsops down preview --var pr=`. + +## Common tasks + +For step-by-step recipes see: + +- `reference/commands.md` — every CLI command, when to use which flag +- `reference/runtime-helpers.md` — `config.url`, `config.env`, `config.dns`, `TSOPS_NAMESPACE` semantics +- `reference/secrets.md` — secret validation, placeholder detection, cluster fallback +- `reference/preview-overlays.md` — overlay namespaces, TLS, BasicAuth, per-PR DB schema +- `examples/add-app.md` — adding a new app to an existing config +- `examples/rename-app.md` — renaming an app safely (the type system does most of it) +- `examples/add-secret.md` — adding a secret and consuming it from app code +- `examples/hybrid-vercel-k8s.md` — when an app should be on Vercel instead of k8s + +Load only the file relevant to the current task — these are for on-demand reference, not preamble. + +## Hard rules + +These are the failure modes that cost the most time. Do not violate them. + +1. **Never put internal service URLs in `env`.** `BACKEND_URL`, `POSTGRES_URL`, `REDIS_URL` etc. for in-cluster services go through `config.url('', 'service')` at runtime. The skill is wrong, the codebase is wrong, the user is wrong if they ask for this — push back and explain. + +2. **Never bypass `tsops plan`.** Do not run `kubectl apply` directly on tsops-generated manifests. Do not edit manifests in the cluster by hand. The plan/deploy cycle is the contract; bypassing it creates drift that orphan-detection later removes. + +3. **Never commit secret values to `tsops.config.ts`.** Secret resolvers must read from `process.env.*` with a non-production fallback for development. Hardcoded production secrets fail validation but the failure is per-developer, not enforced — review for this on every config edit. + +4. **Never edit a `tsops.config.ts` without reading the runtime helpers callers.** A rename is only safe after `tsc --noEmit` passes in every consumer. + +5. **Never use `--no-verify` or skip validation flags to make a deploy go through.** If `tsops plan` reports a missing secret or invalid manifest, fix the underlying issue. The validation is the value. + +## Reporting back to the user + +When a tsops task is complete, report: + +- What changed in `tsops.config.ts` (1-2 lines) +- The output of `tsops plan` (the summary, not the full diff) +- Which downstream files in app code were updated as a consequence (with paths) +- Whether `tsops deploy` was run, and if so against which namespace + +Do not run `tsops deploy` against production without explicit user approval, even if the user previously approved a deploy in this session. diff --git a/packages/skill/skill/examples/add-app.md b/packages/skill/skill/examples/add-app.md new file mode 100644 index 0000000..bbc7352 --- /dev/null +++ b/packages/skill/skill/examples/add-app.md @@ -0,0 +1,45 @@ +# Recipe: add a new app + +Goal: add a new service `worker` to an existing tsops project. + +## Steps + +1. **Read `tsops.config.ts`** to find the existing `apps` block and copy the shape of a similar app. +2. **Add the new app** with build, ports, env, and ingress (if public): + + ```ts + apps: { + // ...existing apps + worker: { + build: { + type: 'dockerfile', + context: './apps/worker', + dockerfile: './apps/worker/Dockerfile' + }, + ports: [{ name: 'http', port: 80, targetPort: 8080 }], + env: ({ secret }) => ({ + JWT_SECRET: secret('api-secrets', 'JWT_SECRET') + }) + // No ingress — internal-only worker + } + } + ``` + +3. **Create the Dockerfile** at the declared path if it doesn't exist. +4. **Run `tsc --noEmit`** — adding an app is non-breaking; this should pass. +5. **Run `tsops plan --namespace `** — confirm the planned changes show only `Will create: Deployment/-worker, Service/-worker`. +6. **Run `tsops build --app worker`** to build the image. +7. **Run `tsops deploy --namespace --app worker`**. + +## Common mistakes + +- **Adding a hardcoded `BACKEND_URL` to the new app's env.** Don't. Use `config.url('api', 'service')` in the worker's source code. +- **Forgetting `ports`.** Without `ports`, no Service is created and other apps can't dial it via `config.url(..., 'service')`. +- **Naming the app with underscores or capitals.** App names become DNS labels — lowercase, hyphens only. + +## What to report + +After deploy, tell the user: +- The new app's name and namespace +- The internal DNS (`` for same-namespace, `..svc.cluster.local` cross-namespace) +- Whether any orphaned resources were pruned diff --git a/packages/skill/skill/examples/add-secret.md b/packages/skill/skill/examples/add-secret.md new file mode 100644 index 0000000..4204155 --- /dev/null +++ b/packages/skill/skill/examples/add-secret.md @@ -0,0 +1,59 @@ +# Recipe: add a secret + +Goal: add `SENTRY_DSN` to the `api` app, populated from CI in production and a static dev value locally. + +## Steps + +1. **Pick the secret name.** Group related keys under one secret (`api-secrets`, `db-secrets`, `payment-secrets`). Don't create a one-key secret per value. + +2. **Add the key to the secret resolver** at config root: + + ```ts + secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production ? process.env.JWT_SECRET ?? '' : 'dev-jwt', + SENTRY_DSN: production ? process.env.SENTRY_DSN ?? '' : '' // new + }) + } + ``` + +3. **Reference it from the app** via the `secret()` helper: + + ```ts + apps: { + api: { + env: ({ secret }) => ({ + JWT_SECRET: secret('api-secrets', 'JWT_SECRET'), + SENTRY_DSN: secret('api-secrets', 'SENTRY_DSN') // new + }) + } + } + ``` + +4. **Provide the value to CI.** Add `SENTRY_DSN` as a CI secret. Locally, the empty fallback means `process.env.SENTRY_DSN` is empty — Sentry will no-op, which is the desired dev behavior. + +5. **Run `tsops plan --namespace prod`** with the env var set: + + ```bash + SENTRY_DSN=https://... tsops plan --namespace prod + ``` + + Expect: `Update: Secret/api-secrets` with the new key. If validation fails, read the error — usually a placeholder slipped in. + +6. **Deploy**: `tsops deploy --namespace prod`. + +## Reading the secret in app code + +```ts +// API uses the env var directly — Kubernetes injects it at pod start +const sentryDsn = process.env.SENTRY_DSN +``` + +This is the **one** case where reading from `process.env` in app code is correct: tsops injected it via `valueFrom.secretKeyRef`, so the value is the cluster's, not the developer's shell. + +## Common mistakes + +- **Hardcoding the production value.** `SENTRY_DSN: 'https://abc@sentry.io/123'` in the resolver bakes the value into the TypeScript file. Don't. +- **Adding a value-only key without a fallback.** `SENTRY_DSN: process.env.SENTRY_DSN ?? ''` — never `process.env.SENTRY_DSN!`. The `!` non-null assertion bypasses tsops's missing-value validation. +- **Adding the secret name to `env` without declaring it in `secrets`.** Compile error: secret name is type-checked against the declared map. +- **Re-using a secret name across unrelated apps.** Tolerated but error-prone — when the secret schema changes, every consumer breaks at once. diff --git a/packages/skill/skill/examples/rename-app.md b/packages/skill/skill/examples/rename-app.md new file mode 100644 index 0000000..5c2a6e2 --- /dev/null +++ b/packages/skill/skill/examples/rename-app.md @@ -0,0 +1,41 @@ +# Recipe: rename an app + +Goal: rename `api` → `core-api` across the entire project. + +This is the canonical demonstration of why tsops exists. The TypeScript compiler does most of the work. + +## Steps + +1. **Edit `tsops.config.ts`** — change the key in `apps`: + + ```ts + apps: { + 'core-api': { // was: api + build: { ... }, + ports: [...], + env: ({ secret }) => ({ ... }) + } + } + ``` + +2. **Run `tsc --noEmit`** at the repo root. Every caller of `config.url('api', ...)`, `config.dns('api', ...)`, `config.env('api', ...)` will be a compile error. There may be dozens. **This is the point.** + +3. **For each compile error**, change the string literal `'api'` to `'core-api'`. Do not use a regex find/replace blindly — verify each call site is the renamed app, not a different `'api'` string. + +4. **Run `tsops plan --namespace `** — expect: + - `Delete: Deployment/-api`, `Service/-api`, etc. + - `Create: Deployment/-core-api`, `Service/-core-api`, etc. + +5. **The plan diff is the audit.** If it shows extra or missing changes, stop and read the config again. + +6. **Run `tsops deploy --namespace `**. Old resources are pruned, new resources are created. Brief downtime is expected — the rename is treated as delete + create, not as a rolling update. + +## What about cross-namespace callers? + +If another tsops project consumes `config.url('api', ...)` from this config (rare; usually each project has its own config), that project's compile will also break and its config must be updated independently. + +## Common mistakes + +- **Forgetting to update CI scripts that hard-code the app name.** `kubectl logs deploy/myproject-api` will silently break. Search the repo for the old name across all file types, not just `.ts`. +- **Renaming the app key but not updating its `build.context`.** The Dockerfile path is independent of the app name; if you rename the directory too, update both. +- **Trying to do this without running `tsc --noEmit`.** The compiler is the rename audit. Skipping it means relying on runtime errors, which defeats the purpose of using tsops. diff --git a/packages/skill/skill/reference/commands.md b/packages/skill/skill/reference/commands.md new file mode 100644 index 0000000..6ce1dc9 --- /dev/null +++ b/packages/skill/skill/reference/commands.md @@ -0,0 +1,70 @@ +# tsops CLI commands + +All commands accept `-c, --config ` (defaults to `tsops.config`) and `--dry-run`. + +## `tsops plan` + +Validate manifests, diff against cluster state, list orphaned resources. **Run before every deploy.** + +```bash +tsops plan # all namespaces, all apps +tsops plan --namespace prod # one namespace +tsops plan --namespace prod --app api # one app in one namespace +tsops plan --dry-run # skip Docker/kubectl, log only +``` + +Output groups: +1. **Global resources** — namespaces, secrets, configMaps validated once across all apps. +2. **Per-app changes** — Deployment, Service, Ingress, etc. with diffs. +3. **Orphans** — resources tagged `tsops/managed=true` in the cluster but not declared in config. These will be deleted by `deploy`. +4. **Summary** — fails the command if any validation errors are present. + +If plan output shows errors, **fix the config**, do not deploy. + +## `tsops build` + +Resolve image refs and invoke Docker. + +```bash +tsops build # all apps with build definitions +tsops build --app api # one app +tsops build --force # rebuild even if image exists in registry +tsops build --changed-files ... # incremental: only apps affected by changed files +``` + +Use `--changed-files` in CI to skip builds for unchanged services. Pair with `git diff --name-only HEAD^1`. + +## `tsops deploy` + +Apply manifests atomically, prune orphans. + +```bash +tsops deploy --namespace prod +tsops deploy --namespace prod --app api +tsops deploy --namespace prod --dry-run +``` + +Deploy refuses to run if `plan` would have errors. Always atomic per app — partial failures roll back to the previous manifest version. + +## `tsops up preview` / `tsops down preview` + +Overlay namespace lifecycle. Used for PR-style preview environments. + +```bash +tsops up preview --var pr=857 # bring up overlay for PR #857 +tsops up preview --var pr=857 --skip-cert # operator debugging only +tsops up preview --var pr=857 --skip-database # operator debugging only + +tsops down preview --var pr=857 # tear down + drop schema +tsops down preview --var pr=857 --keep-database # tear down, keep DB +``` + +`--skip-cert` and `--skip-database` are operator-only flags. **Do not use them in CI or product orchestration** — they bypass the lifecycle hooks that make previews safe. + +## Exit codes + +- `0` — success or no changes +- `1` — validation error (config invalid, missing secret, etc.) +- `2` — runtime failure (Docker/kubectl error, network, ...) + +In CI, treat `1` as "config bug, fix and retry"; treat `2` as "infrastructure issue, may be transient". diff --git a/packages/skill/skill/reference/preview-overlays.md b/packages/skill/skill/reference/preview-overlays.md new file mode 100644 index 0000000..94ee954 --- /dev/null +++ b/packages/skill/skill/reference/preview-overlays.md @@ -0,0 +1,76 @@ +# Preview overlay namespaces + +Overlay namespaces are PR-style ephemeral environments. They inherit from a base static namespace and are materialized at runtime via `--var key=value`. + +## Lifecycle + +```bash +tsops up preview --var pr=857 # create namespace pr-857 +tsops down preview --var pr=857 # delete namespace + drop schema +``` + +`tsops up` applies resources in this order: + +1. Namespace +2. ResourceQuota / LimitRange (`namespacePolicy`) +3. TLS hook (`cert`) — copies wildcard cert from source namespace +4. Access hook (`access`) — Traefik BasicAuth middleware attached to all public routes +5. Database pre-deploy hook (`database.preDeploy`) — runs schema migration job, awaited before app rollout +6. App secrets, configMaps, workloads, services, public routes + +`tsops down` runs `database.postDestroy` (default: drop schema) before namespace deletion, unless `--keep-database` is set. + +## Common operations + +### Bring up a preview for PR #857 + +```bash +tsops up preview --var pr=857 +``` + +This creates namespace `pr-857` with all apps. Vars (like `pr`) are passed via `--var`; the overlay's `naming` and `domain` templates produce the final namespace name and ingress hostname. + +### Deploy only changed apps to the overlay + +```bash +tsops up preview --var pr=857 --include web,api +``` + +Apps **not** in `--include` become `Service: ExternalName` stubs that proxy to the same app in the overlay's `fallback` namespace (typically staging). This lets a single PR show changes to only the services it touches. + +### Tear down + +```bash +tsops down preview --var pr=857 +``` + +Always teardown when the PR closes. Overlay namespaces are guarded against accidental teardown of static namespaces — `tsops down` refuses to run on a non-overlay namespace. Static namespaces must be deleted via `kubectl` after human review. + +## When to add a new var + +If the user says "I need to parameterize X per preview", that's a new `OverlayVars` field: + +```ts +namespaces: { + preview: { + extends: 'staging', + naming: ({ pr }) => `pr-${pr}`, + domain: ({ pr }) => `pr-${pr}.staging.example.com`, + fallback: 'staging', + // new var: deploy a different image tag for testing + imageTag: ({ tag }) => tag + } +} +``` + +Then deploy with `tsops up preview --var pr=857 --var tag=feat-foo-abc123`. + +## Hard rules for previews + +1. **Never disable BasicAuth** (`failClosed: false`) in production preview configs unless the access secret is actually optional. The default — `failClosed: true` — is what keeps preview environments from leaking to the public internet. + +2. **Never reuse runtime DB credentials across overlays.** Use `runtimeSecret.mode: 'generated-per-overlay'` so each PR gets its own role/password. Reuse causes one PR's tests to corrupt another's data. + +3. **Never run `--skip-cert` or `--skip-database` in CI.** Those flags are operator debugging aids; they bypass safety hooks. + +4. **`tsops down` is destructive.** It drops the database schema by default. If the user wants to inspect post-mortem, run `--keep-database` and remember to clean up later. diff --git a/packages/skill/skill/reference/runtime-helpers.md b/packages/skill/skill/reference/runtime-helpers.md new file mode 100644 index 0000000..7b27b28 --- /dev/null +++ b/packages/skill/skill/reference/runtime-helpers.md @@ -0,0 +1,74 @@ +# Runtime helpers + +Importing `tsops.config.ts` in application code unlocks three helpers. These are the **type-safe replacement for environment variables** for any value tsops already knows about. + +```ts +import config from '../tsops.config' +``` + +The active namespace is selected by `process.env.TSOPS_NAMESPACE` at runtime. If unset, the first namespace declared in the config wins (use this only for local development). + +## `config.url(app, scope, options?)` + +Returns a complete URL with protocol resolved from context. + +```ts +config.url('api', 'service') // → http://api +config.url('api', 'cluster') // → http://api.prod.svc.cluster.local +config.url('api', 'ingress') // → https://api.example.com (prod) + // http://api.dev.localtest.me (dev) + +config.url('api', 'service', { port: 'metrics' }) // → http://api:9090 +``` + +| Scope | Use when | +|-----------|-----------------------------------------------------------| +| `service` | Same-namespace calls. Default for backend-to-backend. | +| `cluster` | Cross-namespace calls. Required when caller and callee live in different namespaces. | +| `ingress` | External calls (browser, Vercel-hosted frontend). Public URL with TLS. | + +Protocol selection: +- `service` / `cluster` always `http` (in-cluster traffic) +- `ingress` reads from the app's `ingress` config; auto-detects `http` for `*.localtest.me` / `localhost` / `*.local`, `https` otherwise + +## `config.dns(app, scope)` + +Like `url` but returns just the hostname. Use when you need the bare DNS name (e.g. for `Host:` headers, gRPC channels, custom protocol prefixes). + +## `config.env(app, key)` + +Resolved environment variable for one app in the active namespace. + +```ts +const nodeEnv = config.env('api', 'NODE_ENV') // typed as string, key autocompleted +``` + +Use this only to read values that tsops already defined for the app's `env` block. For ad-hoc env reads, use `process.env` directly. + +## Namespace switching at runtime + +```bash +TSOPS_NAMESPACE=prod node server.js +TSOPS_NAMESPACE=pr-857 node server.js # in a preview overlay namespace +``` + +This is the **only** way to switch namespace at runtime. Do not parse the namespace from a custom env var — `TSOPS_NAMESPACE` is the contract. + +## When to use `config.url` vs an env var + +```ts +// ✅ Internal service — always config.url +const apiUrl = config.url('api', 'service') + +// ✅ External service tsops doesn't know about — env var is correct +const stripeKey = process.env.STRIPE_API_KEY + +// ❌ Wrong: hardcoding internal URL in env +// In tsops.config.ts: env: () => ({ BACKEND_URL: 'http://api:3000' }) +// In app code: fetch(process.env.BACKEND_URL + '/foo') + +// ✅ Right: same intent, type-safe +// In app code: fetch(config.url('api', 'service') + '/foo') +``` + +If the user (or another agent) tries to add an internal URL to the `env` block, refuse and explain. This is non-negotiable — see the "Hard rules" section in `SKILL.md`. diff --git a/packages/skill/skill/reference/secrets.md b/packages/skill/skill/reference/secrets.md new file mode 100644 index 0000000..c3f0037 --- /dev/null +++ b/packages/skill/skill/reference/secrets.md @@ -0,0 +1,105 @@ +# Secrets and ConfigMaps + +Secrets are declared at config root and referenced by apps via the `secret()` helper. + +## Declaring a secret + +```ts +secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production + ? process.env.JWT_SECRET ?? '' + : 'dev-secret-not-for-prod', + STRIPE_KEY: production + ? process.env.STRIPE_KEY ?? '' + : 'sk_test_xxx' + }) +} +``` + +The resolver function runs at plan/deploy time in Node — `process.env` is the developer's shell, **not** the cluster. Production values come from CI env vars. + +## Referencing a secret from an app + +```ts +apps: { + api: { + env: ({ secret }) => ({ + JWT_SECRET: secret('api-secrets', 'JWT_SECRET'), // valueFrom.secretKeyRef + STRIPE_KEY: secret('api-secrets', 'STRIPE_KEY') + }) + } +} +``` + +Or pull every key as `envFrom`: + +```ts +env: ({ secret }) => secret('api-secrets') // entire secret as envFrom +``` + +The secret name and key are type-checked. Typos are compile errors. + +## Validation + +`tsops plan` validates secrets before any cluster changes. It rejects: + +- Undefined values (`process.env.MISSING ?? ''`) +- Placeholder values (`change-me`, `replace-me`, `todo`, `fixme`) +- Missing required keys + +If validation fails, tsops checks if the secret already exists in the cluster. If yes, it reuses the cluster value. If no, the deploy is blocked with an actionable error. + +This means: **secret rotation can happen via `kubectl edit secret` without a tsops deploy.** The config declares the schema; the cluster owns the value. + +## Common patterns + +```ts +// ✅ Different secrets per environment +secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production ? process.env.JWT_SECRET ?? '' : 'dev-jwt', + DB_URL: production ? process.env.DB_URL ?? '' : 'postgres://localhost/dev' + }) +} + +// ✅ External database URL — template helper +secrets: { + 'db-secrets': ({ template, env, production }) => ({ + DATABASE_URL: template('postgresql://{user}:{pwd}@{host}:5432/{db}', { + user: env('DB_USER', 'admin'), + pwd: env('DB_PASSWORD'), + host: production ? 'prod-db.internal' : 'dev-db.internal', + db: 'myapp' + }) + }) +} + +// ❌ Hardcoded — fails validation, but only because the placeholder list catches it. +// Do not rely on validation to catch all hardcoded secrets. +secrets: { + 'api-secrets': () => ({ + JWT_SECRET: 'super-secret-prod-value' + }) +} +``` + +## ConfigMaps + +Same shape, different intent: ConfigMaps are for non-sensitive config (log level, feature flags, public endpoints). + +```ts +configMaps: { + 'api-config': { + LOG_LEVEL: 'info', + FEATURE_FLAGS: 'auth,payments' + } +} + +// In app: +env: ({ configMap }) => ({ + LOG_LEVEL: configMap('api-config', 'LOG_LEVEL') +}) +``` + +ConfigMaps are not validated for placeholders — they're allowed to contain "TODO" and similar strings. diff --git a/skills/tsops/SKILL.md b/skills/tsops/SKILL.md new file mode 100644 index 0000000..0fb496a --- /dev/null +++ b/skills/tsops/SKILL.md @@ -0,0 +1,91 @@ +--- +name: tsops +description: Use when the project contains a `tsops.config.ts` file or the user mentions tsops, deploying with tsops, `tsops plan`, `tsops deploy`, `tsops up`, `tsops down`, preview namespaces, or asks how to add/rename apps, namespaces, secrets, or routes in a TypeScript-defined deployment. Covers the typed operational model, runtime helpers (`config.url`, `config.env`), preview overlays, and the diff-first plan/deploy workflow. +license: MIT +--- + +# tsops + +`tsops` is a typed operational model for containerized apps. **One `tsops.config.ts` is the source of truth for three things**: (1) what images get built, (2) what manifests get applied to Kubernetes, and (3) what runtime config the application code imports at startup. + +The third point is the one that matters most. Renaming an app or changing a port is caught by the TypeScript compiler in every caller — including the application code, not just the deploy pipeline. + +## When to use this skill + +Trigger on any of: + +- A file named `tsops.config.ts` exists at the repo root or inside a workspace package. +- The user runs or asks about `tsops plan`, `tsops deploy`, `tsops build`, `tsops up`, `tsops down`. +- The user asks to "add an app", "rename a service", "add a secret", "set up a preview environment", "deploy the frontend separately" inside a project that already uses tsops. +- The user is debugging a failing deploy and the failure mentions tsops, kubectl manifests generated by tsops, secret validation, or orphaned resources. + +If the project does **not** use tsops, do not propose introducing it unless the user explicitly asks — this skill is for working _within_ an existing tsops setup, not migrating to it. + +## Mental model (read first, applies always) + +``` +tsops.config.ts + │ + ├── input to manifest builder → kubectl apply + └── input to runtime helpers → imported by application code +``` + +Two consequences that govern every change you make: + +1. **Renaming or removing an app, secret key, or namespace breaks every caller.** The compiler will tell you. After you change anything in `tsops.config.ts`, run `pnpm tsc --noEmit` (or `bun tsc --noEmit`) at the repo root and fix every reported error before claiming the task is done. + +2. **Internal service URLs are not env vars.** Use `config.url('api', 'service')` from app code. Do **not** add `BACKEND_URL=http://api:3000` to `env`. The `env` field is for secrets, external APIs, feature flags, and build-time values only. + +## Canonical workflow + +For any tsops change, the steps are always the same: + +1. **Read the existing `tsops.config.ts` end-to-end** before editing. It is the typed graph; partial reads cause partial fixes. +2. **Edit `tsops.config.ts`** with the requested change. +3. **Run `tsops plan --namespace `** (or `pnpm tsops plan`). Read the diff. Confirm the planned changes match intent. Errors here are not warnings — fix them before deploy. +4. **Run `tsc --noEmit`** in any package that imports the config (frontend, backend) to catch type breakage downstream. +5. **Only when plan is green and types compile, run `tsops deploy --namespace `.** + +`tsops deploy` is atomic per app and prunes orphaned resources tagged `tsops/managed=true`. It is safe to re-run. + +For preview environments use `tsops up preview --var pr=` and `tsops down preview --var pr=`. + +## Common tasks + +For step-by-step recipes see: + +- `reference/commands.md` — every CLI command, when to use which flag +- `reference/runtime-helpers.md` — `config.url`, `config.env`, `config.dns`, `TSOPS_NAMESPACE` semantics +- `reference/secrets.md` — secret validation, placeholder detection, cluster fallback +- `reference/preview-overlays.md` — overlay namespaces, TLS, BasicAuth, per-PR DB schema +- `examples/add-app.md` — adding a new app to an existing config +- `examples/rename-app.md` — renaming an app safely (the type system does most of it) +- `examples/add-secret.md` — adding a secret and consuming it from app code +- `examples/hybrid-vercel-k8s.md` — when an app should be on Vercel instead of k8s + +Load only the file relevant to the current task — these are for on-demand reference, not preamble. + +## Hard rules + +These are the failure modes that cost the most time. Do not violate them. + +1. **Never put internal service URLs in `env`.** `BACKEND_URL`, `POSTGRES_URL`, `REDIS_URL` etc. for in-cluster services go through `config.url('', 'service')` at runtime. The skill is wrong, the codebase is wrong, the user is wrong if they ask for this — push back and explain. + +2. **Never bypass `tsops plan`.** Do not run `kubectl apply` directly on tsops-generated manifests. Do not edit manifests in the cluster by hand. The plan/deploy cycle is the contract; bypassing it creates drift that orphan-detection later removes. + +3. **Never commit secret values to `tsops.config.ts`.** Secret resolvers must read from `process.env.*` with a non-production fallback for development. Hardcoded production secrets fail validation but the failure is per-developer, not enforced — review for this on every config edit. + +4. **Never edit a `tsops.config.ts` without reading the runtime helpers callers.** A rename is only safe after `tsc --noEmit` passes in every consumer. + +5. **Never use `--no-verify` or skip validation flags to make a deploy go through.** If `tsops plan` reports a missing secret or invalid manifest, fix the underlying issue. The validation is the value. + +## Reporting back to the user + +When a tsops task is complete, report: + +- What changed in `tsops.config.ts` (1-2 lines) +- The output of `tsops plan` (the summary, not the full diff) +- Which downstream files in app code were updated as a consequence (with paths) +- Whether `tsops deploy` was run, and if so against which namespace + +Do not run `tsops deploy` against production without explicit user approval, even if the user previously approved a deploy in this session. diff --git a/skills/tsops/examples/add-app.md b/skills/tsops/examples/add-app.md new file mode 100644 index 0000000..bbc7352 --- /dev/null +++ b/skills/tsops/examples/add-app.md @@ -0,0 +1,45 @@ +# Recipe: add a new app + +Goal: add a new service `worker` to an existing tsops project. + +## Steps + +1. **Read `tsops.config.ts`** to find the existing `apps` block and copy the shape of a similar app. +2. **Add the new app** with build, ports, env, and ingress (if public): + + ```ts + apps: { + // ...existing apps + worker: { + build: { + type: 'dockerfile', + context: './apps/worker', + dockerfile: './apps/worker/Dockerfile' + }, + ports: [{ name: 'http', port: 80, targetPort: 8080 }], + env: ({ secret }) => ({ + JWT_SECRET: secret('api-secrets', 'JWT_SECRET') + }) + // No ingress — internal-only worker + } + } + ``` + +3. **Create the Dockerfile** at the declared path if it doesn't exist. +4. **Run `tsc --noEmit`** — adding an app is non-breaking; this should pass. +5. **Run `tsops plan --namespace `** — confirm the planned changes show only `Will create: Deployment/-worker, Service/-worker`. +6. **Run `tsops build --app worker`** to build the image. +7. **Run `tsops deploy --namespace --app worker`**. + +## Common mistakes + +- **Adding a hardcoded `BACKEND_URL` to the new app's env.** Don't. Use `config.url('api', 'service')` in the worker's source code. +- **Forgetting `ports`.** Without `ports`, no Service is created and other apps can't dial it via `config.url(..., 'service')`. +- **Naming the app with underscores or capitals.** App names become DNS labels — lowercase, hyphens only. + +## What to report + +After deploy, tell the user: +- The new app's name and namespace +- The internal DNS (`` for same-namespace, `..svc.cluster.local` cross-namespace) +- Whether any orphaned resources were pruned diff --git a/skills/tsops/examples/add-secret.md b/skills/tsops/examples/add-secret.md new file mode 100644 index 0000000..4204155 --- /dev/null +++ b/skills/tsops/examples/add-secret.md @@ -0,0 +1,59 @@ +# Recipe: add a secret + +Goal: add `SENTRY_DSN` to the `api` app, populated from CI in production and a static dev value locally. + +## Steps + +1. **Pick the secret name.** Group related keys under one secret (`api-secrets`, `db-secrets`, `payment-secrets`). Don't create a one-key secret per value. + +2. **Add the key to the secret resolver** at config root: + + ```ts + secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production ? process.env.JWT_SECRET ?? '' : 'dev-jwt', + SENTRY_DSN: production ? process.env.SENTRY_DSN ?? '' : '' // new + }) + } + ``` + +3. **Reference it from the app** via the `secret()` helper: + + ```ts + apps: { + api: { + env: ({ secret }) => ({ + JWT_SECRET: secret('api-secrets', 'JWT_SECRET'), + SENTRY_DSN: secret('api-secrets', 'SENTRY_DSN') // new + }) + } + } + ``` + +4. **Provide the value to CI.** Add `SENTRY_DSN` as a CI secret. Locally, the empty fallback means `process.env.SENTRY_DSN` is empty — Sentry will no-op, which is the desired dev behavior. + +5. **Run `tsops plan --namespace prod`** with the env var set: + + ```bash + SENTRY_DSN=https://... tsops plan --namespace prod + ``` + + Expect: `Update: Secret/api-secrets` with the new key. If validation fails, read the error — usually a placeholder slipped in. + +6. **Deploy**: `tsops deploy --namespace prod`. + +## Reading the secret in app code + +```ts +// API uses the env var directly — Kubernetes injects it at pod start +const sentryDsn = process.env.SENTRY_DSN +``` + +This is the **one** case where reading from `process.env` in app code is correct: tsops injected it via `valueFrom.secretKeyRef`, so the value is the cluster's, not the developer's shell. + +## Common mistakes + +- **Hardcoding the production value.** `SENTRY_DSN: 'https://abc@sentry.io/123'` in the resolver bakes the value into the TypeScript file. Don't. +- **Adding a value-only key without a fallback.** `SENTRY_DSN: process.env.SENTRY_DSN ?? ''` — never `process.env.SENTRY_DSN!`. The `!` non-null assertion bypasses tsops's missing-value validation. +- **Adding the secret name to `env` without declaring it in `secrets`.** Compile error: secret name is type-checked against the declared map. +- **Re-using a secret name across unrelated apps.** Tolerated but error-prone — when the secret schema changes, every consumer breaks at once. diff --git a/skills/tsops/examples/rename-app.md b/skills/tsops/examples/rename-app.md new file mode 100644 index 0000000..5c2a6e2 --- /dev/null +++ b/skills/tsops/examples/rename-app.md @@ -0,0 +1,41 @@ +# Recipe: rename an app + +Goal: rename `api` → `core-api` across the entire project. + +This is the canonical demonstration of why tsops exists. The TypeScript compiler does most of the work. + +## Steps + +1. **Edit `tsops.config.ts`** — change the key in `apps`: + + ```ts + apps: { + 'core-api': { // was: api + build: { ... }, + ports: [...], + env: ({ secret }) => ({ ... }) + } + } + ``` + +2. **Run `tsc --noEmit`** at the repo root. Every caller of `config.url('api', ...)`, `config.dns('api', ...)`, `config.env('api', ...)` will be a compile error. There may be dozens. **This is the point.** + +3. **For each compile error**, change the string literal `'api'` to `'core-api'`. Do not use a regex find/replace blindly — verify each call site is the renamed app, not a different `'api'` string. + +4. **Run `tsops plan --namespace `** — expect: + - `Delete: Deployment/-api`, `Service/-api`, etc. + - `Create: Deployment/-core-api`, `Service/-core-api`, etc. + +5. **The plan diff is the audit.** If it shows extra or missing changes, stop and read the config again. + +6. **Run `tsops deploy --namespace `**. Old resources are pruned, new resources are created. Brief downtime is expected — the rename is treated as delete + create, not as a rolling update. + +## What about cross-namespace callers? + +If another tsops project consumes `config.url('api', ...)` from this config (rare; usually each project has its own config), that project's compile will also break and its config must be updated independently. + +## Common mistakes + +- **Forgetting to update CI scripts that hard-code the app name.** `kubectl logs deploy/myproject-api` will silently break. Search the repo for the old name across all file types, not just `.ts`. +- **Renaming the app key but not updating its `build.context`.** The Dockerfile path is independent of the app name; if you rename the directory too, update both. +- **Trying to do this without running `tsc --noEmit`.** The compiler is the rename audit. Skipping it means relying on runtime errors, which defeats the purpose of using tsops. diff --git a/skills/tsops/reference/commands.md b/skills/tsops/reference/commands.md new file mode 100644 index 0000000..6ce1dc9 --- /dev/null +++ b/skills/tsops/reference/commands.md @@ -0,0 +1,70 @@ +# tsops CLI commands + +All commands accept `-c, --config ` (defaults to `tsops.config`) and `--dry-run`. + +## `tsops plan` + +Validate manifests, diff against cluster state, list orphaned resources. **Run before every deploy.** + +```bash +tsops plan # all namespaces, all apps +tsops plan --namespace prod # one namespace +tsops plan --namespace prod --app api # one app in one namespace +tsops plan --dry-run # skip Docker/kubectl, log only +``` + +Output groups: +1. **Global resources** — namespaces, secrets, configMaps validated once across all apps. +2. **Per-app changes** — Deployment, Service, Ingress, etc. with diffs. +3. **Orphans** — resources tagged `tsops/managed=true` in the cluster but not declared in config. These will be deleted by `deploy`. +4. **Summary** — fails the command if any validation errors are present. + +If plan output shows errors, **fix the config**, do not deploy. + +## `tsops build` + +Resolve image refs and invoke Docker. + +```bash +tsops build # all apps with build definitions +tsops build --app api # one app +tsops build --force # rebuild even if image exists in registry +tsops build --changed-files ... # incremental: only apps affected by changed files +``` + +Use `--changed-files` in CI to skip builds for unchanged services. Pair with `git diff --name-only HEAD^1`. + +## `tsops deploy` + +Apply manifests atomically, prune orphans. + +```bash +tsops deploy --namespace prod +tsops deploy --namespace prod --app api +tsops deploy --namespace prod --dry-run +``` + +Deploy refuses to run if `plan` would have errors. Always atomic per app — partial failures roll back to the previous manifest version. + +## `tsops up preview` / `tsops down preview` + +Overlay namespace lifecycle. Used for PR-style preview environments. + +```bash +tsops up preview --var pr=857 # bring up overlay for PR #857 +tsops up preview --var pr=857 --skip-cert # operator debugging only +tsops up preview --var pr=857 --skip-database # operator debugging only + +tsops down preview --var pr=857 # tear down + drop schema +tsops down preview --var pr=857 --keep-database # tear down, keep DB +``` + +`--skip-cert` and `--skip-database` are operator-only flags. **Do not use them in CI or product orchestration** — they bypass the lifecycle hooks that make previews safe. + +## Exit codes + +- `0` — success or no changes +- `1` — validation error (config invalid, missing secret, etc.) +- `2` — runtime failure (Docker/kubectl error, network, ...) + +In CI, treat `1` as "config bug, fix and retry"; treat `2` as "infrastructure issue, may be transient". diff --git a/skills/tsops/reference/preview-overlays.md b/skills/tsops/reference/preview-overlays.md new file mode 100644 index 0000000..94ee954 --- /dev/null +++ b/skills/tsops/reference/preview-overlays.md @@ -0,0 +1,76 @@ +# Preview overlay namespaces + +Overlay namespaces are PR-style ephemeral environments. They inherit from a base static namespace and are materialized at runtime via `--var key=value`. + +## Lifecycle + +```bash +tsops up preview --var pr=857 # create namespace pr-857 +tsops down preview --var pr=857 # delete namespace + drop schema +``` + +`tsops up` applies resources in this order: + +1. Namespace +2. ResourceQuota / LimitRange (`namespacePolicy`) +3. TLS hook (`cert`) — copies wildcard cert from source namespace +4. Access hook (`access`) — Traefik BasicAuth middleware attached to all public routes +5. Database pre-deploy hook (`database.preDeploy`) — runs schema migration job, awaited before app rollout +6. App secrets, configMaps, workloads, services, public routes + +`tsops down` runs `database.postDestroy` (default: drop schema) before namespace deletion, unless `--keep-database` is set. + +## Common operations + +### Bring up a preview for PR #857 + +```bash +tsops up preview --var pr=857 +``` + +This creates namespace `pr-857` with all apps. Vars (like `pr`) are passed via `--var`; the overlay's `naming` and `domain` templates produce the final namespace name and ingress hostname. + +### Deploy only changed apps to the overlay + +```bash +tsops up preview --var pr=857 --include web,api +``` + +Apps **not** in `--include` become `Service: ExternalName` stubs that proxy to the same app in the overlay's `fallback` namespace (typically staging). This lets a single PR show changes to only the services it touches. + +### Tear down + +```bash +tsops down preview --var pr=857 +``` + +Always teardown when the PR closes. Overlay namespaces are guarded against accidental teardown of static namespaces — `tsops down` refuses to run on a non-overlay namespace. Static namespaces must be deleted via `kubectl` after human review. + +## When to add a new var + +If the user says "I need to parameterize X per preview", that's a new `OverlayVars` field: + +```ts +namespaces: { + preview: { + extends: 'staging', + naming: ({ pr }) => `pr-${pr}`, + domain: ({ pr }) => `pr-${pr}.staging.example.com`, + fallback: 'staging', + // new var: deploy a different image tag for testing + imageTag: ({ tag }) => tag + } +} +``` + +Then deploy with `tsops up preview --var pr=857 --var tag=feat-foo-abc123`. + +## Hard rules for previews + +1. **Never disable BasicAuth** (`failClosed: false`) in production preview configs unless the access secret is actually optional. The default — `failClosed: true` — is what keeps preview environments from leaking to the public internet. + +2. **Never reuse runtime DB credentials across overlays.** Use `runtimeSecret.mode: 'generated-per-overlay'` so each PR gets its own role/password. Reuse causes one PR's tests to corrupt another's data. + +3. **Never run `--skip-cert` or `--skip-database` in CI.** Those flags are operator debugging aids; they bypass safety hooks. + +4. **`tsops down` is destructive.** It drops the database schema by default. If the user wants to inspect post-mortem, run `--keep-database` and remember to clean up later. diff --git a/skills/tsops/reference/runtime-helpers.md b/skills/tsops/reference/runtime-helpers.md new file mode 100644 index 0000000..7b27b28 --- /dev/null +++ b/skills/tsops/reference/runtime-helpers.md @@ -0,0 +1,74 @@ +# Runtime helpers + +Importing `tsops.config.ts` in application code unlocks three helpers. These are the **type-safe replacement for environment variables** for any value tsops already knows about. + +```ts +import config from '../tsops.config' +``` + +The active namespace is selected by `process.env.TSOPS_NAMESPACE` at runtime. If unset, the first namespace declared in the config wins (use this only for local development). + +## `config.url(app, scope, options?)` + +Returns a complete URL with protocol resolved from context. + +```ts +config.url('api', 'service') // → http://api +config.url('api', 'cluster') // → http://api.prod.svc.cluster.local +config.url('api', 'ingress') // → https://api.example.com (prod) + // http://api.dev.localtest.me (dev) + +config.url('api', 'service', { port: 'metrics' }) // → http://api:9090 +``` + +| Scope | Use when | +|-----------|-----------------------------------------------------------| +| `service` | Same-namespace calls. Default for backend-to-backend. | +| `cluster` | Cross-namespace calls. Required when caller and callee live in different namespaces. | +| `ingress` | External calls (browser, Vercel-hosted frontend). Public URL with TLS. | + +Protocol selection: +- `service` / `cluster` always `http` (in-cluster traffic) +- `ingress` reads from the app's `ingress` config; auto-detects `http` for `*.localtest.me` / `localhost` / `*.local`, `https` otherwise + +## `config.dns(app, scope)` + +Like `url` but returns just the hostname. Use when you need the bare DNS name (e.g. for `Host:` headers, gRPC channels, custom protocol prefixes). + +## `config.env(app, key)` + +Resolved environment variable for one app in the active namespace. + +```ts +const nodeEnv = config.env('api', 'NODE_ENV') // typed as string, key autocompleted +``` + +Use this only to read values that tsops already defined for the app's `env` block. For ad-hoc env reads, use `process.env` directly. + +## Namespace switching at runtime + +```bash +TSOPS_NAMESPACE=prod node server.js +TSOPS_NAMESPACE=pr-857 node server.js # in a preview overlay namespace +``` + +This is the **only** way to switch namespace at runtime. Do not parse the namespace from a custom env var — `TSOPS_NAMESPACE` is the contract. + +## When to use `config.url` vs an env var + +```ts +// ✅ Internal service — always config.url +const apiUrl = config.url('api', 'service') + +// ✅ External service tsops doesn't know about — env var is correct +const stripeKey = process.env.STRIPE_API_KEY + +// ❌ Wrong: hardcoding internal URL in env +// In tsops.config.ts: env: () => ({ BACKEND_URL: 'http://api:3000' }) +// In app code: fetch(process.env.BACKEND_URL + '/foo') + +// ✅ Right: same intent, type-safe +// In app code: fetch(config.url('api', 'service') + '/foo') +``` + +If the user (or another agent) tries to add an internal URL to the `env` block, refuse and explain. This is non-negotiable — see the "Hard rules" section in `SKILL.md`. diff --git a/skills/tsops/reference/secrets.md b/skills/tsops/reference/secrets.md new file mode 100644 index 0000000..c3f0037 --- /dev/null +++ b/skills/tsops/reference/secrets.md @@ -0,0 +1,105 @@ +# Secrets and ConfigMaps + +Secrets are declared at config root and referenced by apps via the `secret()` helper. + +## Declaring a secret + +```ts +secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production + ? process.env.JWT_SECRET ?? '' + : 'dev-secret-not-for-prod', + STRIPE_KEY: production + ? process.env.STRIPE_KEY ?? '' + : 'sk_test_xxx' + }) +} +``` + +The resolver function runs at plan/deploy time in Node — `process.env` is the developer's shell, **not** the cluster. Production values come from CI env vars. + +## Referencing a secret from an app + +```ts +apps: { + api: { + env: ({ secret }) => ({ + JWT_SECRET: secret('api-secrets', 'JWT_SECRET'), // valueFrom.secretKeyRef + STRIPE_KEY: secret('api-secrets', 'STRIPE_KEY') + }) + } +} +``` + +Or pull every key as `envFrom`: + +```ts +env: ({ secret }) => secret('api-secrets') // entire secret as envFrom +``` + +The secret name and key are type-checked. Typos are compile errors. + +## Validation + +`tsops plan` validates secrets before any cluster changes. It rejects: + +- Undefined values (`process.env.MISSING ?? ''`) +- Placeholder values (`change-me`, `replace-me`, `todo`, `fixme`) +- Missing required keys + +If validation fails, tsops checks if the secret already exists in the cluster. If yes, it reuses the cluster value. If no, the deploy is blocked with an actionable error. + +This means: **secret rotation can happen via `kubectl edit secret` without a tsops deploy.** The config declares the schema; the cluster owns the value. + +## Common patterns + +```ts +// ✅ Different secrets per environment +secrets: { + 'api-secrets': ({ production }) => ({ + JWT_SECRET: production ? process.env.JWT_SECRET ?? '' : 'dev-jwt', + DB_URL: production ? process.env.DB_URL ?? '' : 'postgres://localhost/dev' + }) +} + +// ✅ External database URL — template helper +secrets: { + 'db-secrets': ({ template, env, production }) => ({ + DATABASE_URL: template('postgresql://{user}:{pwd}@{host}:5432/{db}', { + user: env('DB_USER', 'admin'), + pwd: env('DB_PASSWORD'), + host: production ? 'prod-db.internal' : 'dev-db.internal', + db: 'myapp' + }) + }) +} + +// ❌ Hardcoded — fails validation, but only because the placeholder list catches it. +// Do not rely on validation to catch all hardcoded secrets. +secrets: { + 'api-secrets': () => ({ + JWT_SECRET: 'super-secret-prod-value' + }) +} +``` + +## ConfigMaps + +Same shape, different intent: ConfigMaps are for non-sensitive config (log level, feature flags, public endpoints). + +```ts +configMaps: { + 'api-config': { + LOG_LEVEL: 'info', + FEATURE_FLAGS: 'auth,payments' + } +} + +// In app: +env: ({ configMap }) => ({ + LOG_LEVEL: configMap('api-config', 'LOG_LEVEL') +}) +``` + +ConfigMaps are not validated for placeholders — they're allowed to contain "TODO" and similar strings.