Skip to content

Latest commit

 

History

History
70 lines (56 loc) · 21.7 KB

CLAUDE.md

File metadata and controls

70 lines (56 loc) · 21.7 KB

CLAUDE.md

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

Repository state

Bootstrapped on 2026-10-09; the Cargo workspace and toolchain pins landed on 2026-10-10. tools/repo-check enforces repository invariants as part of cargo test. Pin every new runtime or dev tool in .prototools and add its commands to this file.

Architecture decisions, rebuild scope (items A1–M8), service split, repository layout, and milestones (MS0–MS6) live in docs/; start at docs/README.md. docs/ is the source of truth: when a decision changes, update docs/ in the same change. In docs/, a bare #N refers to the legacy repo Laysi/trustiot; always write legacy references as Laysi/trustiot#N in issues and PRs here.

The repository has been public since 2026-10-12: commits, PR and issue text, test reports, and CI logs are readable by anyone. Never put secrets, internal hostnames or LAN addresses, customer names, or personal data in them (cluster pod and service addresses are fine); the rule is in docs/architecture/secrets.md, section 「公開 repo 的資訊規則」.

The gateway hardware is the TrustIoT gateway: it is developed on the Raspberry Pi platform and runs Raspberry Pi OS, but it is not a Raspberry Pi. Never call it a Pi in code, docs, commits, issues or PRs (no "Pi", "lab Pi", <pi> or PI_HOST): write "TrustIoT gateway" or "gateway" (zh-TW 閘道器), and "lab gateway" (lab 的閘道器) for the unit used in tests. Upstream software names stay as they are, such as Raspberry Pi OS and vcgencmd.

Build, lint, and test

.prototools pins Rust, Bun, Go, actionlint, cargo-deny, gitleaks, Helm, k3d, kubeconform, kubectl, the GitOps tools (flux, sops), and the release tools (zig, cargo-zigbuild, cargo-auditable, syft, cosign); tools without a built-in proto plugin get a schema plugin in .proto/plugins/ that verifies the release checksum. proto installs Rust through rustup and does not shim cargo, so rustup picks the toolchain from rust-toolchain.toml: change rust in .prototools and channel in rust-toolchain.toml together (cargo test -p repo-check fails when they differ). Run every command from the repository root.

  • Install the pinned tools: proto install
  • Build: cargo build --workspace
  • Format: cargo fmt --all (check only: cargo fmt --all --check)
  • Lint: cargo clippy --workspace --all-targets -- -D warnings
  • Test: cargo test --workspace
  • Single test: cargo test -p <crate> <test-name-filter>, for example cargo test -p repo-check toolchain_pins_agree
  • Local dev environment (podman, no Kubernetes; details in scripts/README.md): bun scripts/dev.ts up | seed | status | env | down | purge. Its Postgres serves TLS from a CA that up generates (kept under $XDG_STATE_HOME/trustiot-v2/dev/<instance>/tls/, never in the repo), so services connect with verify-full; env prints TRUSTIOT_POSTGRES_{HOST,PORT,DATABASE,SSLMODE,CA_FILE} and PGSSLMODE/PGSSLROOTCERT, so set -a; eval "$(bun scripts/dev.ts env)"; set +a is all a service or psql on the host needs. up also runs the migration Job (tools/migrate, built with cargo and run unchanged against a loopback stand-in for the Kubernetes Secret API that fills identity_app's password) and generates the identity service's Ed25519 signing key and writes the signing keys and public keys files from it (kept by down, removed by purge, mode 0600 under the same state directory); seed adds a dev user, a tenant membership and a dev session and prints its cookie; env also prints TRUSTIOT_INTERNAL_IDENTITY_KEYS_FILE and TRUSTIOT_IDENTITY_SIGNING_KEYS_FILE (paths), TRUSTIOT_INGRESS_ROUTES, TRUSTIOT_DEV_IDENTITY_DATABASE_{USERNAME,PASSWORD} and TRUSTIOT_DEV_IDENTITY_PLATFORM_DATABASE_{USERNAME,PASSWORD}, so the identity service starts on the host with cargo run -p identity (steps and which variable is for which service: scripts/README.md, section 「身分服務」). Script tests: bun test scripts, after bun install --cwd scripts --frozen-lockfile (add TRUSTIOT_DEV_E2E=1 and the file scripts/tests/e2e.test.ts to run the podman end-to-end test, which builds migrate, identity and example-service; use CARGO_BUILD_JOBS=2 on a small machine).
  • Helm chart (deploy/chart): bun test deploy/chart runs helm lint, kubeconform on both values files, the chart's own rules, and the README field check; a single check is bun test deploy/chart -t "<test-name-filter>". The individual helm lint/helm template commands are in deploy/chart/README.md.
  • GitOps (deploy/flux, Flux on the SaaS cluster and the demo cluster; details in deploy/flux/README.md): bun test deploy/flux runs kustomize build, kubeconform against the pinned Flux CRDs, and the checks on the chart source, values and .sops.yaml (needs proto install kubectl kubeconform helm flux). After changing the flux pin, regenerate Flux's manifests with bun scripts/flux/components.ts. age has no checksum-verified release binary, so it is built from source with the pinned Go: bun scripts/flux/age.ts age-keygen -o <file> (or ... age <args>). The SOPS-encrypted GHCR pull Secret is written by <token on stdout> | bun scripts/flux/ghcr-pull-secret.ts --username <account> [--cluster demo], any other SOPS-encrypted Secret by <value on stdout> | bun scripts/flux/sops-secret.ts --cluster <saas|demo> --name <secret> --key <key>, the demo cluster's private values by <four key=value lines on stdout> | bun scripts/flux/demo-values.ts, and one cluster's chart pin by bun scripts/flux/pin-chart.ts --cluster <saas|demo> --version <version> --digest <chart digest>. The lab end-to-end test (Flux install, deploy, revert, signature refusal; needs the lab lock with the owner line Laysi/trustiot-v2#<issue> <time> for the issue of E2E_NAMESPACE, which it checks, and refuses to run on a cluster that already has Flux, such as the demo cluster): E2E_SSH_HOST=trustiot-mac-m1 E2E_NAMESPACE=e2e-<issue> E2E_OUT=<dir> bun deploy/flux/tests/e2e/lab.e2e.ts.
  • SaaS host installer (deploy/host): bun install --cwd deploy/host --frozen-lockfile, then bun run --cwd deploy/host typecheck, bun run --cwd deploy/host test (simulated host; a single test is -t "<test-name-filter>") and bun run --cwd deploy/host build (the x86_64 and aarch64 executables in deploy/host/dist/). Running it on a host is in deploy/host/README.md.
  • Frontend (frontend/, Vite + React single-page app; details in frontend/README.md): bun install --cwd frontend --frozen-lockfile, then bun run --cwd frontend api:check (the committed API client matches api/openapi.json; api:generate rewrites it after the spec changes), typecheck, lint (Biome; format fixes formatting), test (a single test is bun run --cwd frontend test -t "<test-name-filter>"), build (into frontend/dist/) and check:dist (the build output's layout and CSP rules). bun run --cwd frontend dev serves it with hot reload; TRUSTIOT_DEV_API_ORIGIN=<origin> proxies /api and /auth to a running backend. A new message key in Rust (MessageKey::new or message_key! under crates/contract/src or services/*/src) needs its zh-TW and en translations in frontend/src/i18n/locales/ in the same change, or bun run --cwd frontend test fails.
  • Nightly workflow (.github/workflows/nightly.yml, scripts/nightly/): CI deploys the chart with both values files to a k3d cluster and smoke-tests it; it runs only in CI, because workstations do not run Kubernetes. A scheduled run skips the deployment when main's tree equals the tree of the newest completed scheduled or manual run and that run passed (a failed or missing run, an API error and every manual run deploy); bun scripts/nightly/unchanged.ts --dry-run (with GITHUB_TOKEN and GITHUB_REPOSITORY) prints the decision read-only. On a pull request it deploys only while the PR carries the smoke label, which the person who merges the pull request adds before merging. Its unit tests need no cluster: bun test scripts/nightly. Renovate (renovate.json5) updates dependencies; a tool pinned in .prototools with a schema plugin needs a # renovate: comment above its pin (bun test scripts/nightly checks it). Details: docs/architecture/ci-release.md.
  • Ingress e2e (needs a cluster with Istio ambient; never run Kubernetes locally): E2E_NAMESPACE=<ns> E2E_IDENTITY_IMAGE=<image> E2E_MIGRATE_IMAGE=<image> bun test --timeout 600000 ./deploy/chart/tests/e2e/ingress.e2e.ts; prerequisites and the other variables are in deploy/chart/tests/e2e/README.md. Its tunnel recovery (raw-client.ts, tunnel-recovery.ts) has cluster-free unit tests that bun test deploy/chart runs.
  • Log pipeline e2e (OpenTelemetry collector to ClickHouse and a stand-in SIEM; needs a cluster, run on the lab k3s): E2E_NAMESPACE=<ns> E2E_SERVICE_IMAGE=<example-service image> bun test --timeout 900000 ./deploy/chart/tests/e2e/otel.e2e.ts; building the arm64 example-service image is in deploy/chart/tests/e2e/README.md.
  • Bundled Postgres e2e (CloudNativePG Cluster with AGE; needs a cluster with the CloudNativePG operator installed as deploy/cloudnative-pg/README.md describes, run on the lab k3s): E2E_NAMESPACE=<ns> E2E_SHAPE=<saas|onprem> E2E_POSTGRES_IMAGE=<image> E2E_SERVICE_IMAGE=<example-service image> bun test --timeout 900000 ./deploy/chart/tests/e2e/postgres.e2e.ts; building both arm64 images and the other variables are in deploy/chart/tests/e2e/README.md.
  • Container-backed tests: crates/service-kit starts throwaway Postgres and PID 1 containers with podman or docker (or uses TEST_DATABASE_URL for Postgres), crates/testkit starts a TLS Postgres for the cross-tenant suite's self-test, and edge/agent's broker test starts Mosquitto (testkit::mqtt). Without them those tests skip locally; with CI set they fail instead. A test container must not leave volumes behind: the images declare VOLUMEs, so remove containers with rm --force --volumes (cargo test -p repo-check rejects a Rust rm --force without it); leaked anonymous volumes use up podman's locks and then every container test fails.
  • Cross-tenant negative tests (decision 29): every service declares its tables and endpoints with crates/testkit in tests/; how to hook a service in is in crates/testkit/README.md. cargo test -p testkit -- --nocapture prints every probe.
  • Test time limits: workstations and CI runners are often loaded (load average 25–35 on 8 cores was seen), so no test may depend on a fixed short time. Wait for the condition with a generous bound that fails with a message naming what was awaited. Do not widen a number without asking what it waits for; assert ordering rather than elapsed time, and bound a duration only against what the test configured (a drain delay, a 60 s timeout it must not reach). Chart tests (deploy/chart/tests): call useProcessTestTimeouts() from deploy/chart/tests/timing.ts at the top of the file (bun applies setDefaultTimeout per file and ignores a bunfig test timeout), pass SLOW_TEST_MS to a test that runs many helm or kubeconform commands, and run commands through a helper with timeout: COMMAND_TIMEOUT_MS and throwIfTimedOut. Rust tests: use the named constants and helpers in the test file or crates/service-kit/tests/support, for example wait_until_ready and refusal_through_pool in tenant_db.rs.
  • crates/service-kit without its default contract feature, which the --workspace commands never build: cargo clippy -p service-kit --no-default-features --all-targets -- -D warnings and cargo test -p service-kit --no-default-features
  • API spec (api/openapi.json, generated from the code, never edited by hand): regenerate with cargo run -p openapi after changing any API type and commit the result; cargo run -p openapi -- --check only verifies it. cargo test --workspace and CI fail when the committed file differs from what the code generates. Each service exposes pub fn openapi() and registers it in tools/openapi (tools/openapi/README.md).
  • Dependency licenses, advisories, and sources (rules in deny.toml): cargo deny --locked check
  • Secret scan, as CI runs it: gitleaks git --redact --log-opts="--full-history --diff-filter=tuxdb --diff-merges=first-parent HEAD" .. Before every push (the first and each later one), also scan the branch and the tree it leaves behind: gitleaks git --redact --log-opts="origin/main..HEAD" ., then git archive HEAD | tar -x -C <dir> and gitleaks dir --redact <dir>; both must find nothing (reasons: docs/architecture/secrets.md, section 「公開 repo 的資訊規則」)
  • Workflow policy (job permissions, SHA-pinned actions): bun scripts/ci/check-workflows.ts
  • Which ci.yml jobs a change runs: scripts/ci/changes.ts decides from the changed paths whether rust, cargo-deny, helm, host, postgres-image, and frontend run; gitleaks and workflow-policy always run. When a Rust test, a chart test, the host installer, or the frontend starts reading a file outside its own directory, add the path to the rules there (bun test scripts/ci/changes.test.ts fails until you do). The if and concurrency conditions of ci.yml and nightly.yml are tested by bun test scripts/ci/workflow-conditions.test.ts. On a push to main, a job that already ran and passed on the identical tree in the merged pull request's CI is skipped too (scripts/ci/tested-tree.ts); GITHUB_TOKEN=<token> bun scripts/ci/changes.ts --explain <main commit> prints that decision and its evidence without writing anything (run git fetch origin main first). Details: docs/architecture/ci-release.md.
  • Secret handling (every Dockerfile/Containerfile, container build commands, build args, where workflows read secrets, SOPS-encrypted Secret manifests; rules in docs/architecture/secrets.md): bun scripts/ci/check-secret-handling.ts
  • Workflow syntax and expression check with the pinned actionlint, which rejects what GitHub cannot parse (for example runner.temp in job-level env): bun scripts/ci/lint-workflows.ts (run proto install actionlint first; the shellcheck integration is deliberately off, see docs/architecture/ci-release.md)
  • Bun scripts in scripts/ (the dev environment and the CI checks): bun install --cwd scripts --frozen-lockfile installs the type checker and the test dependencies (bun test scripts needs them too), then bun run --cwd scripts typecheck checks every .ts file there and under deploy/chart/tests/, deploy/flux/tests/ and deploy/images/ (the ingress e2e included, which needs a cluster to run but not to type-check); bun run --cwd scripts test runs the same test files as bun test scripts, and a single test is bun run --cwd scripts test -t "<test-name-filter>"
  • Production workstation tool (factory/workstation, binary trustiot-workstation; operator steps in its README): cargo build --release --locked -p workstation; its tests (cargo test -p workstation) use loopback stand-ins only.
  • Lab datasets (datasets/lab/, checklist M7; the index format, artificial anomalies, storage, recording, the synthetic fixture and how a Rust test loads a dataset are in datasets/lab/README.md): cargo run -p lab-dataset -- check datasets/lab/<name>/index.toml checks an index and its segment files; cargo run -p lab-dataset -- load <index> --tenant <uuid> --gateway <uuid> [--end-before now] writes a dataset into the ClickHouse named by CLICKHOUSE_URL, CLICKHOUSE_USER and CLICKHOUSE_PASSWORD (plus CLICKHOUSE_CA_FILE for https); cargo run --release -p lab-dataset -- record <config> --out <dir> [--duration-s <seconds>] records devices with the agent's Modbus module (the config names converter addresses, so it and <dir> stay outside the repository). cargo test -p lab-dataset checks the fixture's content, the anomaly insertion, records from testkit's fake RS485 converter, and loads into a throwaway ClickHouse (same container rules as the ingest tests). The fixture is fixed files: there is no generator to rerun; change the files and bump the index's version.
  • Gateway crates (crates/protocol and every member under edge/) must build for aarch64: rustup target add aarch64-unknown-linux-gnu once, then cargo build --locked --target aarch64-unknown-linux-gnu -p <crate> for a pure-Rust library such as protocol. edge/agent (the trustiot-agent executable, with ring's C code) needs Zig as the target's C compiler and linker: proto install zig && proto install cargo-zigbuild, then CARGO_ZIGBUILD_ZIG_PATH="$(proto bin zig --reporter text | tail -n 1)" PATH="$(dirname "$(proto bin cargo-zigbuild --reporter text | tail -n 1)"):$PATH" cargo zigbuild --locked --release --target aarch64-unknown-linux-gnu -p agent (glibc, not musl; details in edge/agent/README.md). Agent tests: cargo test -p agent (single files with --test link|transport|broker|binary|systemd_unit|dependencies; systemd_unit also runs systemd-analyze when present)
  • Release scripts (scripts/release, run by .github/workflows/release.yml): bun test scripts/release; a single test is bun test scripts/release -t "<test-name-filter>". Building the arm64 images locally and running the whole publish and verify flow against a throwaway registry are described in scripts/README.md.
  • Images: each directory under deploy/images/ is a published arm64 image (image.toml for a Rust binary, or its own Containerfile); see deploy/images/README.md. An image.toml with a [static] table also packs a Bun project's checked build output at /srv/www, for services/web (scripts/release/static-build.ts). Base images must be pinned by digest.
  • Postgres image with Apache AGE (deploy/images/postgres): bun test deploy/images/postgres builds it for arm64 with podman and checks it; the tests that start Postgres need an arm64 host or a binfmt arm64 emulator and skip without one (fail with CI set). POSTGRES_IMAGE=<image> tests an existing image instead. Upgrade steps are in its README.

.github/workflows/ci.yml runs these checks on every pull request and every push to main; its jobs and the required-checks list are in docs/architecture/ci-release.md. Every job must list its permissions, every action must be pinned to a full commit SHA with the tag in a trailing comment, and local actions belong under .github/actions/.

Every directory under crates/, services/, edge/, factory/, and tools/ is a workspace member, so it needs a Cargo.toml that sets edition.workspace = true and [lints] workspace = true; list non-Rust directories in the root exclude. Declare third-party dependencies once in the root [workspace.dependencies]. Clippy fails on every lint in [workspace.lints]; suppress one only with #[allow(lint, reason = "...")]. Save test evidence under docs/test-reports/ as its README describes.

Work tracking: GitHub Issues + Project board

Work is tracked as real issues in Laysi/trustiot-v2 on the org Project TrustIoT v2 (Laysi, project #12). Follow the github-loop-engineering skill for every issue and board transition. Resolve field and option IDs at runtime; never hard-code them.

  • Status: Triage, Todo, In Progress, Blocked, Done. Agents triage by best judgment, without waiting for a human: accepted work moves to Todo, and rejected work is closed as not planned. Todo includes accepted work that still waits on other issues in this repo. Those waits are native blocked-by dependencies, and the board's Ready view (filter status:Todo -is:blocked) lists the work that can start now.
  • Priority (board field): P0 incident or blocker, P1 current focus (the milestone in progress, or the demo slice in docs/roadmap/milestones.md), P2 backlog. Set it when the issue is created, by best judgment; agents set it too and do not wait for human triage. The board field is the source of truth, and the issue carries exactly one matching priority:P0|P1|P2 label.
  • Labels: every issue carries at least one area:* label (or documentation for docs-only work). Add an area:<component> label, whose description names the component path, when a new top-level component is created. Other workflow labels: blocked, tech-debt, security, and demo for the issues in the demo slice (docs/roadmap/milestones.md, section 「展示切片」).
  • Issue types: Feature for new capabilities, Bug for incorrect behavior or vulnerabilities, Task for everything else.
  • Issue bodies are written in Traditional Chinese and must be cold-start readable. Use the headings from .github/ISSUE_TEMPLATE/: 背景 (Why), 範圍 (What), 現狀 (State), 驗收條件 (Acceptance Criteria), 指標 (Pointers); bugs add 重現 (Reproduction). Use absolute dates only. Don't copy board fields such as Status or Priority into the body. The templates come from the github-loop-engineering skill (zh-TW); update them through the skill, not by hand.
  • Parallel work: each milestone is a root issue whose sub-issues are epics and then leaves, and blocked-by dependencies connect leaves only. To work through a parent issue, follow the skill's fan-out: the board filter parent-issue:Laysi/trustiot-v2#N status:Todo -is:blocked lists its children that can start now.
  • Blocked work: a blocker outside the work graph, such as missing hardware, a pending decision, or a customer, gets the blocked label, a Blocked by section in the body, and Status Blocked. A wait on another issue in this repo is a native blocked-by dependency instead.
  • CLI fallback: use the GitHub MCP server for every GitHub read and write. The user has authorized the skill's gh api fallback for two things the server can't do yet, and nothing else: native blocked-by dependencies (list, add, remove) and creating milestones.
  • Assignment is decided when work starts, not at creation.
  • Closing: if the Project's auto-close workflow is enabled, setting Status to Done closes the issue, so close the issue through the completion ritual instead of moving the card first.