docs: testing strategy + PR hygiene + backend parity matrix + boot-event postmortem (#1824)
Bundles the documentation and lightweight tooling landed during the
2026-04-23 ops/triage session. Pure additions — no behavior changes.
## Added
### docs/architecture/backends.md
Parity matrix for Docker vs EC2 (SaaS) workspace backends. 18 features
tabulated with current status; 6 ranked drift risks; enforcement
hooks (parity-lint + contract tests). Living document — owners are
workspace-server + controlplane teams.
### docs/engineering/testing-strategy.md
Tiered test-coverage floors instead of a blanket 100% target. Seven
tiers by code class (auth/crypto → generated DTOs). Per-package
current-state snapshot + targets. Tracks the 3 biggest coverage gaps
(tokens.go 0%, workspace_provision.go 0%, wsauth ~48%) against their
tier-1/2 floors.
### docs/engineering/pr-hygiene.md
Captures the patterns that keep diffs reviewable. Motivated by the
2026-04-23 backlog audit where 8 of 23 open PRs had 70-380-file bloat
from stale branch drift. Covers: small-PR sizing, rebase-not-merge,
cherry-pick-onto-fresh-base for recovery, targeting staging first,
describing why-not-what.
### docs/engineering/postmortem-2026-04-23-boot-event-401.md
Postmortem for the /cp/tenants/boot-event 401 race. Root cause (DB
INSERT ordered AFTER readiness check), detection path (E2E + manual
log inspection), lessons (write-before-read pattern, integration
tests needed, E2E alerting gap, invariants-as-comments).
### tools/check-template-parity.sh
CI lint for template repos — diffs the `${VAR:+VAR=${VAR}}` provider-
key forwarders between install.sh (bare-host / EC2 path) and start.sh
(Docker path). Catches the #5 drift risk from backends.md before it
ships.
### workspace-server/internal/provisioner/backend_contract_test.go
Shared behavioral contract scaffold for Provisioner + CPProvisioner.
Compile-time assertions catch method-signature drift today; scenario-
level runs are t.Skip'd pending backend nil-hardening (drift risk #6,
see backends.md).
## Updated
### README.md
Links the new engineering docs + backends parity matrix into the
Documentation Map so agents and humans can actually find them.
## Related issues
- #1814 — unblock workspace_provision_test.go (broadcaster interface)
- #1813 — nil-client panic hardening (drift risk #6)
- #1815 — Canvas vitest coverage instrumentation
- #1816 — tokens.go 0% → 85%
- #1817 — 5 sqlmock column-drift failures
- #1818 — Python pytest-cov setup
- #1819 — wsauth middleware coverage gap
- #1821 — tiered coverage policy (meta)
- #1822 — backend parity drift tracker
Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
9ad803a802
commit
a56b765b2d
@ -288,6 +288,10 @@ Then open `http://localhost:3000`:
|
||||
- [Workspace Runtime](./docs/agent-runtime/workspace-runtime.md)
|
||||
- [Canvas UI](./docs/frontend/canvas.md)
|
||||
- [Local Development](./docs/development/local-development.md)
|
||||
- [Backend Parity Matrix](./docs/architecture/backends.md) — Docker vs EC2 feature parity tracker
|
||||
- [Testing Strategy](./docs/engineering/testing-strategy.md) — tiered coverage floors, not blanket 100%
|
||||
- [PR Hygiene](./docs/engineering/pr-hygiene.md) — small PRs, clean branches, cherry-pick on drift
|
||||
- [Engineering Postmortems](./docs/engineering/) — architecture + testing lessons from real incidents
|
||||
- [Ecosystem Watch](./docs/ecosystem-watch.md) — adjacent projects we track (Holaboss, Hermes, gstack, …)
|
||||
- [Glossary](./docs/glossary.md) — how we use "harness", "workspace", "plugin", "flow" vs. ecosystem neighbors
|
||||
|
||||
|
||||
73
docs/architecture/backends.md
Normal file
73
docs/architecture/backends.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Workspace Backend Parity Matrix
|
||||
|
||||
**Status:** living document — update when you ship a feature that touches one backend.
|
||||
**Owner:** workspace-server + controlplane teams.
|
||||
**Last audit:** 2026-04-23 (Claude agent, PR #TBD).
|
||||
|
||||
## Why this exists
|
||||
|
||||
Molecule AI ships workspaces on two backends:
|
||||
|
||||
- **Docker** — the self-hosted / local-dev path. `provisioner.Docker` in `workspace-server/internal/provisioner/`. Each workspace is a container on the same daemon as the platform.
|
||||
- **EC2 (SaaS)** — the control-plane path. `provisioner.CPProvisioner` in the same directory, which calls the control plane at `POST /cp/workspaces/provision`. Each workspace is its own EC2 instance.
|
||||
|
||||
Every user-visible workspace feature should work on both backends unless it is fundamentally tied to one substrate (e.g. `docker logs` command, AWS serial console). When the two diverge silently — a handler works on Docker but quietly 500s on EC2, or vice versa — users hit dead ends that look like bugs but are actually architectural gaps.
|
||||
|
||||
This document is the canonical matrix. If you are landing a workspace-facing feature, update the row before you merge.
|
||||
|
||||
## The matrix
|
||||
|
||||
| Feature | File(s) | Docker | EC2 | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| **Lifecycle** | | | | |
|
||||
| Create | `workspace_provision.go:19-214` | `provisionWorkspace()` → `provisioner.Start()` | `provisionWorkspaceCP()` → `cpProv.Start()` | ✅ parity |
|
||||
| Start | `provisioner.go:140-325` | container create + image pull | EC2 `RunInstance` via CP | ✅ parity |
|
||||
| Stop | `provisioner.go:772-785` | `ContainerRemove(force=true)` + optional volume rm | `DELETE /cp/workspaces/:id` | ✅ parity |
|
||||
| Restart | `workspace_restart.go:45-210` | reads runtime from live container before stop | reads runtime from DB only | ⚠️ divergent — config-change + crash window can boot old runtime on EC2 |
|
||||
| Delete | `workspace_crud.go` | stop + volume rm | stop only (stateless) | ✅ parity (expected divergence on volume cleanup) |
|
||||
| **Secrets** | | | | |
|
||||
| Create / update | `secrets.go` | DB insert, injected at container start | DB insert, injected via user-data at boot | ✅ parity |
|
||||
| Redaction | `workspace_provision.go:251` | applied at memory-seed time | applied at agent runtime | ⚠️ divergent — timing differs |
|
||||
| **Files API** | | | | |
|
||||
| List / Read / Write / Replace / Delete | `container_files.go`, `template_import.go` | `docker exec` + tar `CopyToContainer` | SSH via EIC tunnel (PR #1702) | ✅ parity as of 2026-04-22 (previously docker-only) |
|
||||
| **Plugins** | | | | |
|
||||
| Install / uninstall / list | `plugins_install.go` | `deliverToContainer()` + volume rm | **gap — no live plugin delivery** | 🔴 **docker-only** |
|
||||
| **Terminal (WebSocket)** | | | | |
|
||||
| Dispatch | `terminal.go:90-105` | `instance_id=""` → `handleLocalConnect` → `docker attach` | `instance_id` set → `handleRemoteConnect` → EIC SSH + `docker exec` | ✅ parity (different implementations, same UX) |
|
||||
| **A2A proxy** | | | | |
|
||||
| Forward | `a2a_proxy.go` | `127.0.0.1:<port>` | EC2 private IP inside tenant VPC | ✅ parity |
|
||||
| Liveness | `a2a_proxy_helpers.go` | `provisioner.IsRunning()` | `cpProv.IsRunning()` (DB-backed) | ✅ parity |
|
||||
| **Config / template injection** | | | | |
|
||||
| Template copy at provision | `provisioner.go:553-648` | host walk → tar → `CopyToContainer(/configs)` | CP user-data bakes template into bootstrap script | ⚠️ divergent — sync (docker) vs async (EC2) |
|
||||
| Runtime config hot-reload | `templates.go` + handlers | no hot-reload — restart required | no hot-reload — restart required | ✅ parity (both require restart; acceptable) |
|
||||
| **Memory (HMA)** | | | | |
|
||||
| Seed initial memories | `workspace_provision.go:226-260` | DB insert at provision time | DB insert at provision time | ✅ parity |
|
||||
| **Bootstrap signals** | | | | |
|
||||
| Ready detection | registry `/registry/register` | container heartbeat | tenant heartbeat + boot-event phone-home (CP `bootevents` table + `wait_platform_health=ok`) | ✅ parity as of molecule-controlplane#235 |
|
||||
| Console / log output | `workspace_bootstrap.go` | `docker logs` | `ec2:GetConsoleOutput` via CP proxy | 🟡 ec2-only (docker has `docker logs` directly; no unified API) |
|
||||
| **Orphan cleanup** | | | | |
|
||||
| Detect + terminate stale | `healthsweep.go` + CP `DeprovisionInstance` | Docker daemon scan | CP OrgID-tag cascade (molecule-controlplane#234) | ✅ parity as of 2026-04-23 |
|
||||
| **Health / budget / schedules** | | | | |
|
||||
| Budget enforcement | `budget.go` | DB-driven | DB-driven | ✅ parity |
|
||||
| Schedule execution | `workspace_restart.go:235-280` | `provisioner.Stop()` + re-provision | `cpProv.Stop()` + CP auto-restart | ✅ parity |
|
||||
| Liveness probe | `healthsweep.go` | `provisioner.IsRunning()` | `cpProv.IsRunning()` | ✅ parity |
|
||||
| **Template recipes (per-template user-data)** | | | | |
|
||||
| Hermes `install.sh` (bare-host) / `start.sh` (Docker) | `molecule-ai-workspace-template-hermes/` | `start.sh` entrypoint | `install.sh` called by CP user-data hook | ⚠️ structurally divergent — two scripts maintained separately; **parity enforced by CI lint**, see `tools/check-template-parity.sh` |
|
||||
|
||||
## Top drift risks (ordered by production impact)
|
||||
|
||||
1. **Plugin install is docker-only.** Hot-install UX (POST /plugins) calls `deliverToContainer()` which requires a live Docker daemon. On EC2, there is no equivalent — plugins must be baked into user-data at boot. SaaS users who want to iterate on plugins without restarting today cannot. **Fix path:** add a CP-side plugin-manager endpoint that the tenant workspace-server proxies to, or document "restart required" on SaaS.
|
||||
2. **Template config injection is sync on Docker and async on EC2.** Docker writes config files right before `ContainerStart`; EC2 embeds them in user-data and they materialize whenever cloud-init runs. A workspace that starts serving before cloud-init completes can see stale config. **Fix path:** make the canvas wait for `wait_platform_health=ok` boot-event before flipping to `online`, same mechanism the provisioning path uses.
|
||||
3. **Restart divergence on runtime changes.** Docker re-reads `/configs/config.yaml` from the container before stop, so a changed `runtime:` survives a restart even if the DB isn't synced. EC2 trusts the DB only. If you change the runtime via the Config tab and the handler races the restart, Docker will land on the new runtime, EC2 will land on the old one. **Fix path:** make the Config-tab save explicitly flush to DB before kicking off a restart, not deferred.
|
||||
4. **Console-output asymmetry.** Users debugging a stuck workspace on Docker see `docker logs`; on EC2 they see `GetConsoleOutput`. The two outputs look nothing alike. **Fix path:** expose a unified `GET /workspaces/:id/boot-log` that proxies to whichever backend serves the data. Already partly there via `cp_provisioner.Console`.
|
||||
5. **Template script drift.** `install.sh` and `start.sh` in each template repo do the same high-level work (install hermes-agent, write .env, write config.yaml, start gateway) but must be kept byte-level consistent on the provider-key forwarding block. Easy to forget. Enforced now by `tools/check-template-parity.sh` (see below) — run it in each template repo's CI.
|
||||
6. **Both backends panic when underlying client is nil.** Discovered by the contract-test scaffold landing in this PR: `Provisioner.{Stop,IsRunning}` nil-dereferences the Docker client, and `CPProvisioner.{Stop,IsRunning}` nil-dereferences `httpClient`. The real code always sets these, so this is theoretical in prod — but it means the contract runner can't execute scenarios against zero-value backends. **Fix path:** guard each method with `if p.docker == nil { return false, errNoBackend }` (and equivalent for CP), then flip the `t.Skip` in the contract tests to `t.Run`.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- **`tools/check-template-parity.sh`** (this repo) — ensures `install.sh` and `start.sh` in a template repo forward identical sets of provider keys. Wire into each template repo's CI as `bash $MONOREPO/tools/check-template-parity.sh install.sh start.sh`.
|
||||
- **Contract tests** (stub) — `workspace-server/internal/provisioner/backend_contract_test.go` defines the behaviors every `provisioner.Provisioner` implementation must satisfy. Fails compile when a method drifts between `Docker` and `CPProvisioner`. Scenario-level runs are `t.Skip`'d today pending drift risk #6 (see above) — compile-time assertions still catch method drift.
|
||||
|
||||
## How to update this doc
|
||||
|
||||
When you land a feature that touches a handler dispatch on `h.cpProv != nil`, add or update the matching row. If you can't implement both backends in the same PR, mark the row `docker-only` or `ec2-only` and file an issue tracking the gap.
|
||||
130
docs/engineering/postmortem-2026-04-23-boot-event-401.md
Normal file
130
docs/engineering/postmortem-2026-04-23-boot-event-401.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Incident: SaaS tenant provisioning 401 on /cp/tenants/boot-event
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Severity:** High — every new SaaS tenant blocked
|
||||
**Detection path:** E2E Staging SaaS run 24848425822 failed at "tenant provisioning"; investigation of CP Railway logs surfaced the auth mismatch.
|
||||
**Status:** Fix pushed on [molecule-controlplane#238](https://github.com/Molecule-AI/molecule-controlplane/pull/238).
|
||||
**Related:** [issue #239](https://github.com/Molecule-AI/molecule-controlplane/issues/239) (Cloudflare DNS record quota), [testing-strategy.md](../engineering/testing-strategy.md)
|
||||
|
||||
## Summary
|
||||
|
||||
For ~3 days leading up to 2026-04-23, every new SaaS tenant failed to transition from `provisioning` → `running`. The EC2 instance would boot, read its `admin_token` from AWS Secrets Manager, and attempt to POST `/cp/tenants/boot-event` on the control plane. Every request got 401 Unauthorized. Without a successful boot event, CP would wait 4 minutes, fall through to a canary probe (which also failed due to an unrelated Cloudflare DNS quota issue), and write a `status='failed'` row. The tenant would then be stuck forever.
|
||||
|
||||
## Root cause
|
||||
|
||||
**A race between EC2 boot and the DB write of `org_instances.admin_token`.**
|
||||
|
||||
The flow was:
|
||||
|
||||
1. CP `provisionTenant()` called `Provision()`, which:
|
||||
- Generated `admin_token = generatePassword()`
|
||||
- Wrote it to AWS Secrets Manager
|
||||
- Returned it in the `Result` struct
|
||||
2. EC2 launched in parallel; user-data started running.
|
||||
3. **Before** CP's `provisionTenant()` wrote the `org_instances` row, it called `WaitForTenantReady()` — a 4-minute poll of `org_instance_boot_events`.
|
||||
4. EC2 finished its early boot stages (~60-90s) and started POSTing `/cp/tenants/boot-event` with the `admin_token` from Secrets Manager.
|
||||
5. CP's inline auth on that endpoint does:
|
||||
```sql
|
||||
SELECT org_id FROM org_instances WHERE admin_token = $1 AND admin_token != ''
|
||||
```
|
||||
No row existed yet. → 401.
|
||||
6. Every subsequent boot-event post: 401.
|
||||
7. `WaitForTenantReady` saw no events (because 401s never write to `org_instance_boot_events`). After 4 minutes it returned `false`.
|
||||
8. Fell through to canary. Canary failed (unrelated — Cloudflare DNS quota exceeded, so the tenant's hostname didn't resolve).
|
||||
9. `insertFailedInstance` wrote a row **without** `admin_token`. Tenant stuck in `failed`.
|
||||
|
||||
### The commit that introduced the bug
|
||||
|
||||
[molecule-controlplane#235](https://github.com/Molecule-AI/molecule-controlplane/pull/235) — "fix(provision): wait for tenant boot-event before falling back to canary". Merged 2026-04-22.
|
||||
|
||||
Before #235, readiness was determined via a canary probe through Cloudflare's edge — which didn't need CP-side auth, so the INSERT ordering didn't matter. #235 made boot-events the primary readiness signal but didn't move the INSERT earlier. The race was latent before but became load-bearing after.
|
||||
|
||||
## Detection
|
||||
|
||||
**What should have caught it:**
|
||||
|
||||
- ❌ Unit tests on `provisionTenant` — existed, but they used `fakeProv` and `noopCanaryOK` that bypassed the real auth flow. They asserted the INSERT happened eventually; they didn't assert the INSERT happened *before* boot-event auth.
|
||||
- ❌ Integration tests — CP has no end-to-end integration test that provisions a real tenant with real auth against a real DB. The E2E Staging SaaS flow is the closest, and it only ran in CI after merge.
|
||||
- ✅ E2E Staging SaaS — did catch it, but ~20 hours after merge. Blast radius by then: every new tenant in staging, including all E2E runs.
|
||||
|
||||
**What actually caught it:**
|
||||
|
||||
Manual investigation of CP Railway logs for the failed E2E run. Grepping for the tenant org_id + examining the `[GIN] POST /cp/tenants/boot-event` status codes revealed the 401 pattern.
|
||||
|
||||
## Timeline
|
||||
|
||||
| Time (UTC) | Event |
|
||||
|---|---|
|
||||
| 2026-04-22 ~late | PR #235 merged to controlplane main — introduces the race |
|
||||
| 2026-04-22 → 23 | Nightly E2E Staging SaaS fails (no alert wired) |
|
||||
| 2026-04-23 07:14 | E2E on main also fails with the same signature |
|
||||
| 2026-04-23 morning | Investigation starts; misattributed to hermes provider 401 (separate known bug) |
|
||||
| 2026-04-23 17:09 | Fresh E2E run 24848425822 dispatched on staging sha `6539908` |
|
||||
| 2026-04-23 17:13 | Run fails with "tenant provisioning failed" |
|
||||
| 2026-04-23 ~17:15 | Railway logs inspection reveals the 401s on `/cp/tenants/boot-event` |
|
||||
| 2026-04-23 17:30 | Root cause identified — admin_token not in DB when EC2 phones home |
|
||||
| 2026-04-23 ~17:50 | Fix pushed on controlplane `fix/provision-readiness-boot-events` |
|
||||
| 2026-04-23 ~18:00 | PR #238 opened, CI running |
|
||||
|
||||
## Fix
|
||||
|
||||
Write the `org_instances` row with `status='provisioning'` and `admin_token` **immediately after** `Provision()` returns, **before** `WaitForTenantReady()`. Flip `status='running'` once readiness passes.
|
||||
|
||||
```go
|
||||
// NEW: early INSERT so boot-events can authenticate
|
||||
if _, err := h.db.ExecContext(ctx, `
|
||||
INSERT INTO org_instances (org_id, ..., admin_token, status)
|
||||
VALUES ($1, ..., $8, 'provisioning')
|
||||
ON CONFLICT (org_id) DO UPDATE SET ..., status = 'provisioning'
|
||||
`, ...); err != nil {
|
||||
h.insertFailedInstance(ctx, org.ID, ...)
|
||||
return
|
||||
}
|
||||
|
||||
// THEN wait for readiness — boot-events will now authenticate
|
||||
bootReady, _ := provisioner.WaitForTenantReady(ctx, h.db, org.ID, 4*time.Minute)
|
||||
|
||||
// ... canary fallback as before ...
|
||||
|
||||
// Finally, transition to 'running'
|
||||
h.db.ExecContext(ctx, `UPDATE org_instances SET status = 'running' WHERE org_id = $1`, org.ID)
|
||||
```
|
||||
|
||||
See [molecule-controlplane#238](https://github.com/Molecule-AI/molecule-controlplane/pull/238) for the full diff.
|
||||
|
||||
## Lessons
|
||||
|
||||
### 1. "Write state before dependent reads" is a general pattern
|
||||
|
||||
The same chicken-and-egg shape applies anywhere a newly-provisioned entity phones home for its own state. Future auth-gated callbacks should follow the rule: **persist the credential in the validation store BEFORE the entity can call back with it.** Include in code review checklist for provisioning-adjacent changes.
|
||||
|
||||
### 2. Unit tests that use fakes can't catch auth-flow races
|
||||
|
||||
The existing `TestProvisionTenant_*` tests used `fakeProv` and `noopCanaryOK` that elided the real auth check. They asserted the shape of DB writes but not the temporal ordering relative to an external caller's expectation. For provisioning flows specifically, we need an integration-test tier that exercises real HTTP → real DB with the actual auth middleware.
|
||||
|
||||
**Action:** Add a CP integration-test target (`make test-integration`) that spins up a real Postgres + CP binary + a fake EC2 that mimics user-data's boot-event POST cadence. File as follow-up.
|
||||
|
||||
### 3. E2E failures need faster detection
|
||||
|
||||
E2E Staging SaaS failed silently overnight. Nobody knew until someone manually ran `gh run list` and saw the red dots. The alert latency from merge to awareness was ~20 hours.
|
||||
|
||||
**Action:** Wire E2E Staging SaaS failures to a push notification or Telegram alert channel. File as follow-up.
|
||||
|
||||
### 4. Code comments should describe invariants, not the happy path
|
||||
|
||||
The `provisionTenant` function had comments describing what each block did, but nothing stating **"this function must write `org_instances.admin_token` before any code path that triggers an external callback using it."** If that invariant had been written down, the #235 author would likely have noticed the ordering change broke it.
|
||||
|
||||
**Action:** When landing this fix, add the invariant to a doc comment at the top of `provisionTenant`.
|
||||
|
||||
### 5. Separate unrelated failures — don't conflate
|
||||
|
||||
Early investigation blamed the hermes provider 401 bug (a separate, known issue affecting hermes-agent startup after tenant came up). Those 401s come from `hermes-agent error 401` in the workspace-server logs, not from CP Railway logs. Two different 401s with totally different causes. **When debugging, always check which component is emitting the 401 before assuming it's the known one.**
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- [ ] Land [molecule-controlplane#238](https://github.com/Molecule-AI/molecule-controlplane/pull/238)
|
||||
- [ ] Redeploy staging-api, verify E2E goes green
|
||||
- [ ] Add CP integration test suite (see lesson #2)
|
||||
- [ ] Wire E2E failure → notification (see lesson #3)
|
||||
- [ ] Add invariant comment in `provisionTenant` (see lesson #4)
|
||||
- [ ] Cloudflare DNS quota cleanup — [molecule-controlplane#239](https://github.com/Molecule-AI/molecule-controlplane/issues/239)
|
||||
142
docs/engineering/pr-hygiene.md
Normal file
142
docs/engineering/pr-hygiene.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Pull Request Hygiene
|
||||
|
||||
**Status:** Guide. Violations are a review-time flag, not a CI gate.
|
||||
**Audience:** Humans and agents opening PRs in this repo.
|
||||
**Cross-refs:** [testing-strategy.md](./testing-strategy.md), [backends.md](../architecture/backends.md)
|
||||
|
||||
## Why this exists
|
||||
|
||||
On 2026-04-23 a backlog audit found **23 open PRs on molecule-core**, of which 8 had accumulated 70-380 files of bloat (+2000/-8000 lines) from stale branch drift. The underlying fix in each was 1-5 files; the rest was merge artifact. Half the PRs were closed that day because they weren't reviewable and the real fix had to be re-extracted onto a clean branch.
|
||||
|
||||
This document captures the patterns that avoid that outcome.
|
||||
|
||||
## The rules
|
||||
|
||||
### 1. Small PRs, single concern
|
||||
|
||||
| Change size | Reviewability |
|
||||
|---|---|
|
||||
| ≤100 lines | ✅ Good. One sitting. |
|
||||
| 100-300 lines | ⚠️ Acceptable if genuinely one logical change. |
|
||||
| 300-1000 lines | 🔴 Too large. Split. |
|
||||
| 1000+ lines | 🚫 Unreviewable — split before opening. |
|
||||
|
||||
**Exception:** complete file deletions and automated refactors where the reviewer only needs to verify intent.
|
||||
|
||||
### 2. Branch hygiene — rebase, don't merge-in
|
||||
|
||||
When your branch falls behind the base:
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
git fetch origin staging
|
||||
git rebase origin/staging
|
||||
# resolve conflicts
|
||||
git push --force-with-lease
|
||||
```
|
||||
|
||||
**Don't:**
|
||||
```bash
|
||||
git fetch origin staging
|
||||
git merge origin/staging # creates merge commit + pulls ALL of base's files into your diff
|
||||
```
|
||||
|
||||
A merge commit from `origin/staging` brings every base-branch commit into your PR's diff. That's where the 235-file bloat comes from. Once you have it, you can't get rid of it without resetting the branch.
|
||||
|
||||
### 3. If your branch has already drifted — cherry-pick onto fresh base
|
||||
|
||||
```bash
|
||||
# Identify your real commits
|
||||
git log origin/staging..HEAD
|
||||
|
||||
# Create a fresh branch off current base
|
||||
git checkout -b your-branch-clean origin/staging
|
||||
|
||||
# Cherry-pick only the commits you actually authored
|
||||
git cherry-pick abc1234 def5678
|
||||
|
||||
# Push and open a new PR; close the old one as "superseded by #N"
|
||||
git push -u origin your-branch-clean
|
||||
```
|
||||
|
||||
**Don't** try to rebase a drifted branch interactively to remove the base-branch commits. It fights you every merge.
|
||||
|
||||
### 4. Target `staging` unless you're doing a staging→main promote
|
||||
|
||||
Per branching policy ([feedback memory](../../.claude/projects/-Users-hongming-Documents-GitHub-molecule-monorepo/memory/feedback_no_push_main.md) rule): every change lands on `staging` first. Once validated there, a periodic `chore: sync staging → main` PR promotes the bundle.
|
||||
|
||||
Exception: hotfixes that also land on `main` directly with CEO approval.
|
||||
|
||||
### 5. Describe the why, not the what
|
||||
|
||||
A good PR title:
|
||||
- `fix(provision): write org_instances row BEFORE readiness check to unblock boot-event auth`
|
||||
|
||||
A bad PR title:
|
||||
- `Update orgs.go`
|
||||
- `Fix bug`
|
||||
- `Phase 1`
|
||||
|
||||
The body should explain:
|
||||
- **What's broken / missing** (or what's the opportunity)
|
||||
- **Why this fix** — especially if there are alternatives you considered
|
||||
- **What's tested** — which scenarios the test plan covers
|
||||
- **What's deferred** — if there are follow-ups, file issues and link them
|
||||
|
||||
Anti-pattern: `## Summary\n- Fix bug`. That's not a summary; that's a stub.
|
||||
|
||||
### 6. Close the loop on review comments
|
||||
|
||||
- Comments labeled `Nit:` / `Optional:` / `FYI` can be left for follow-up — but leave a reply acknowledging.
|
||||
- Critical/required comments need a fix or a justified reply before merge.
|
||||
- Don't resolve threads without replying — silent resolves read as dismissal.
|
||||
|
||||
### 7. CI must be green (or the failure must be acknowledged)
|
||||
|
||||
- Never push `--no-verify` unless explicitly requested.
|
||||
- If a pre-existing failure is blocking merge, document it inline and file a tracking issue — don't silently let it erode the "all green" norm.
|
||||
|
||||
## Patterns for specific situations
|
||||
|
||||
### Re-targeting an old branch
|
||||
|
||||
When a PR was opened weeks ago against `main` but policy now says `staging`:
|
||||
|
||||
```bash
|
||||
git fetch origin staging
|
||||
git rebase --onto origin/staging old-base HEAD
|
||||
git push --force-with-lease
|
||||
# Edit the PR's base branch in GitHub UI
|
||||
```
|
||||
|
||||
### Splitting a large PR
|
||||
|
||||
If your PR is already open and the reviewer asks for a split:
|
||||
|
||||
1. Identify the cleanest split boundary — usually along file groups or dependency layers.
|
||||
2. Create two new branches off current staging.
|
||||
3. Cherry-pick the commits for each concern into its branch.
|
||||
4. Open two new PRs, close the original as "superseded by #A and #B".
|
||||
|
||||
### Marketing / docs-heavy PRs
|
||||
|
||||
Marketing content has been moved to an internal repo per commit `93324e7`. If your PR modifies files under `docs/marketing/campaigns/`, `docs/marketing/plans/`, or `docs/marketing/briefs/` (with non-public-facing strategy content):
|
||||
|
||||
1. Check if the file still exists on `origin/staging`.
|
||||
2. If deleted, open the PR in the internal marketing repo instead.
|
||||
3. Public-facing marketing (blog posts, SEO pages under `docs/blog/`) stays in this repo.
|
||||
|
||||
## Signs your PR has a hygiene problem
|
||||
|
||||
- **70+ files changed** when your commit message mentions 2-3 files
|
||||
- **+2000/-3500 lines** but the actual fix is ~100 lines
|
||||
- **State: DIRTY** in GitHub for >1 day
|
||||
- Filenames in the diff you don't recognize (someone else's changes in your PR)
|
||||
- Merge commits in your branch's log named `Merge remote-tracking branch 'origin/staging' into ...`
|
||||
|
||||
If you see any of these, don't try to "clean it up in place" — **cherry-pick onto a fresh branch** (rule 3 above).
|
||||
|
||||
## Related
|
||||
|
||||
- [Issue #1822](https://github.com/Molecule-AI/molecule-core/issues/1822) — backend parity drift tracker (example of docs that have to stay current)
|
||||
- [Postmortem: CP boot-event 401](./postmortem-2026-04-23-boot-event-401.md) — caught before shipping because a reviewer could read the diff
|
||||
111
docs/engineering/testing-strategy.md
Normal file
111
docs/engineering/testing-strategy.md
Normal file
@ -0,0 +1,111 @@
|
||||
# Testing Strategy
|
||||
|
||||
**Status:** Policy. Update when tier definitions or thresholds change.
|
||||
**Audience:** Everyone writing or reviewing code in this repo.
|
||||
**Cross-refs:** [backends.md](../architecture/backends.md), [pr-hygiene.md](./pr-hygiene.md), [postmortem-2026-04-23-boot-event-401.md](./postmortem-2026-04-23-boot-event-401.md)
|
||||
|
||||
## The short version
|
||||
|
||||
- **Don't chase 100% coverage.** The last 15-20% costs as much as the first 80% and mostly adds brittle tests of trivial getters, error branches that can't fire, and stdlib wrappers.
|
||||
- **Different code classes have different floors.** Auth at 80% is scarier than a DTO at 50%. Match the test investment to the risk.
|
||||
- **Tests should pay rent.** A test that runs lines but asserts nothing meaningful isn't catching bugs — it's just dragging refactors down.
|
||||
|
||||
## Tiered coverage floors
|
||||
|
||||
Every Go package, every TypeScript module, every Python module fits one of these tiers. The tier determines the minimum acceptable coverage — and the review standard.
|
||||
|
||||
| Tier | Examples | Line floor | Branch floor | Review standard |
|
||||
|---|---|---|---|---|
|
||||
| **1. Auth / secrets / crypto** | `tokens`, `session_auth`, `wsauth_middleware`, `crypto/envelope`, `cp_tenant_auth` | **90%** | **85%** | Every branch tested. Adversarial scenarios (cross-tenant, expired token, null origin, malformed header). Timing considered. |
|
||||
| **2. Handlers with side effects** | `workspace_provision`, `workspace_crud`, `container_files`, `terminal`, `registry` | **75%** | 70% | Happy + main error paths. DB mocks. Ownership / tenant-isolation checks. |
|
||||
| **3. State machines + workers** | `scheduler`, `provisioner`, `healthsweep`, `orphan-sweeper`, `boot_ready` | **75%** | 70% | Every state transition tested, plus the transitions that *shouldn't* fire. |
|
||||
| **4. Config / business logic** | `budget`, `orgtoken` (validation), `templates`, `derive-provider`, `redaction` | **70%** | 65% | Standard unit-test territory. Table-driven preferred. |
|
||||
| **5. Plain DTOs / generated** | `models/*`, proto-generated Go, TypeScript interfaces | none | none | Writing tests here is theatre. Don't. |
|
||||
| **6. CLI glue / cmd/*** | `cmd/server`, `cmd/molecli` | smoke only | — | Integration tests / E2E cover these. One startup-smoke test per binary. |
|
||||
| **7. Third-party wrappers** | `awsapi`, `cloudflareapi`, `stripeapi`, `neonapi` | integration | — | Unit tests mock vendor shape, not behavior. Real behavior covered by staging integration. |
|
||||
|
||||
### Why a blanket percentage is wrong
|
||||
|
||||
- A `models/` package at 90% means you wrote tests for `func (w Workspace) ID() string { return w.id }`. No bugs caught, but coverage number is green.
|
||||
- A `tokens` package at 75% means some rejection branch isn't covered. Maybe the *exact* branch that lets a revoked token still authenticate.
|
||||
- Blanket targets make the first case look equivalent to the second. They aren't.
|
||||
|
||||
## Current state (as of 2026-04-23)
|
||||
|
||||
Run `go test ./... -cover` in each repo for up-to-date numbers. Snapshot:
|
||||
|
||||
### workspace-server (Go)
|
||||
|
||||
| Package | Actual | Tier | Target | Gap |
|
||||
|---|---:|---|---:|---:|
|
||||
| `internal/handlers/tokens.go` | **0%** | 1 | 90% | 90 |
|
||||
| `internal/handlers/workspace_provision.go` | **0%** | 2 | 75% | 75 |
|
||||
| `internal/middleware/wsauth_middleware.go` | ~48% | 1 | 90% | 42 |
|
||||
| `internal/provisioner` | 45% | 3 | 75% | 30 |
|
||||
| `internal/scheduler` | 49% | 3 | 75% | 26 |
|
||||
| `internal/channels` | 40% | 4 | 70% | 30 |
|
||||
| `internal/orgtoken` | 88% | 4 | 70% | — |
|
||||
| `internal/crypto` | 91% | 1 | 90% | — |
|
||||
| `internal/supervised` | 93% | 3 | 75% | — |
|
||||
| `internal/plugins` | 94% | 4 | 70% | — |
|
||||
| `internal/envx` | 100% | 5 | none | — |
|
||||
|
||||
### molecule-controlplane (Go)
|
||||
|
||||
| Package | Actual | Tier | Target | Gap |
|
||||
|---|---:|---|---:|---:|
|
||||
| `internal/awsapi` | 18% | 7 | integration | — |
|
||||
| `internal/provisioner` | 48% | 3 | 75% | 27 |
|
||||
| `internal/handlers` | 60% | 2 | 75% | 15 |
|
||||
| `internal/billing` | 60% | 4 | 70% | 10 |
|
||||
| `internal/crypto` | 68-80% | 1 | 90% | 10-22 |
|
||||
| `internal/auth` | 96% | 1 | 90% | — |
|
||||
| `internal/middleware` | 97% | 1 | 90% | — |
|
||||
| `internal/reserved` | 100% | 5 | none | — |
|
||||
| `internal/httpx` | 100% | 4 | 70% | — |
|
||||
|
||||
### canvas (TypeScript)
|
||||
|
||||
**No coverage instrumentation today.** 900 tests / 58 files pass, but coverage isn't measured. See issue #1815 for the fix: set a 70% line floor in `vitest.config.ts` and gate CI on it.
|
||||
|
||||
### workspace (Python)
|
||||
|
||||
**No pytest/coverage config.** See issue #1818: set up `pytest-cov` with `--cov-fail-under=75` (ratchet from current baseline over 2-3 weeks).
|
||||
|
||||
## Writing a good test
|
||||
|
||||
A good test:
|
||||
- **Asserts a specific outcome**, not that a function runs without error.
|
||||
- **Covers the exact branch that bugs would live in** — cross-tenant access, revoked-but-cached token, race on state transition.
|
||||
- **Uses table-driven patterns** when the code is a dispatch with N cases. One test row per case.
|
||||
- **Mocks at system boundaries** (DB, HTTP, time), not at internal package boundaries.
|
||||
- **Survives refactors** — tests behavior, not internal state.
|
||||
|
||||
A bad test:
|
||||
- Tests a getter that just returns a field.
|
||||
- Mocks the function under test itself.
|
||||
- Relies on `time.Sleep` or clock timing to assert order.
|
||||
- Asserts `nil == nil` to boost coverage.
|
||||
|
||||
## Enforcement
|
||||
|
||||
### CI gates
|
||||
|
||||
- **Go**: `go test ./... -cover` + a pre-commit script that compares coverage to `.coverage-baseline` and fails on drops > 2 points in a tier-1 package.
|
||||
- **TypeScript**: `vitest --coverage` with thresholds in `vitest.config.ts`. Fails CI if below.
|
||||
- **Python**: `pytest --cov-fail-under=75` in the Python CI job.
|
||||
|
||||
### Review expectations
|
||||
|
||||
- Any PR touching a tier-1 package that lowers its coverage needs an explicit reviewer sign-off and justification.
|
||||
- New code should arrive at or above its tier's floor.
|
||||
- Untested files in tier-1 or tier-2 should be flagged in review, not waved through.
|
||||
|
||||
## Related
|
||||
|
||||
- [Issue #1821](https://github.com/Molecule-AI/molecule-core/issues/1821) — policy tracking issue
|
||||
- [Issue #1815](https://github.com/Molecule-AI/molecule-core/issues/1815) — Canvas coverage instrumentation
|
||||
- [Issue #1818](https://github.com/Molecule-AI/molecule-core/issues/1818) — Python pytest-cov
|
||||
- [Issue #1814](https://github.com/Molecule-AI/molecule-core/issues/1814) — workspace_provision_test.go unblock
|
||||
- [Issue #1816](https://github.com/Molecule-AI/molecule-core/issues/1816) — tokens.go coverage
|
||||
- [Issue #1819](https://github.com/Molecule-AI/molecule-core/issues/1819) — wsauth_middleware coverage
|
||||
80
tools/check-template-parity.sh
Executable file
80
tools/check-template-parity.sh
Executable file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-template-parity.sh — enforce parity between a workspace template's
|
||||
# install.sh (bare-host / EC2 path) and start.sh (Docker path). Both scripts
|
||||
# must forward the same set of provider API keys to the agent's .env so that
|
||||
# a workspace built on one backend behaves identically to a workspace built
|
||||
# on the other.
|
||||
#
|
||||
# Drift this catches:
|
||||
# - Someone adds HERMES_API_KEY to start.sh but forgets install.sh.
|
||||
# EC2 workspaces using Nous fail silently; Docker works.
|
||||
# - Someone adds a HERMES_CUSTOM_BASE_URL branch to install.sh only.
|
||||
# Docker can't use a custom OpenAI-compat endpoint; EC2 can.
|
||||
#
|
||||
# Invocation (from template-hermes repo's CI):
|
||||
#
|
||||
# bash /path/to/molecule-monorepo/tools/check-template-parity.sh \
|
||||
# install.sh start.sh
|
||||
#
|
||||
# Or inline via curl:
|
||||
#
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Molecule-AI/molecule-core/main/tools/check-template-parity.sh) \
|
||||
# install.sh start.sh
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — parity ok (or both files declare the same set of ${VAR:+VAR=...} exports)
|
||||
# 1 — drift detected (emits a diff to stderr)
|
||||
# 2 — usage / missing files
|
||||
#
|
||||
# What "parity" means here: the SET of environment-variable forwarders
|
||||
# (lines of the form `${VAR:+VAR=${VAR}}`) in each file must be equal.
|
||||
# The ordering, surrounding comments, and non-forwarder lines are free to
|
||||
# differ — that's where the two paths legitimately diverge (bare-host vs
|
||||
# Docker-entrypoint structure).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 install.sh start.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
INSTALL_SH="$1"
|
||||
START_SH="$2"
|
||||
|
||||
for f in "$INSTALL_SH" "$START_SH"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "missing file: $f" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Extract the set of ${VAR:+VAR=...} forwarder lines, stripped of
|
||||
# surrounding whitespace. sort -u gives us the set to compare.
|
||||
extract_forwarders() {
|
||||
grep -oE '\$\{[A-Z_]+:\+[A-Z_]+=\$\{[A-Z_]+\}\}' "$1" 2>/dev/null | sort -u
|
||||
}
|
||||
|
||||
TMP_INSTALL=$(mktemp)
|
||||
TMP_START=$(mktemp)
|
||||
trap 'rm -f "$TMP_INSTALL" "$TMP_START"' EXIT
|
||||
|
||||
extract_forwarders "$INSTALL_SH" > "$TMP_INSTALL"
|
||||
extract_forwarders "$START_SH" > "$TMP_START"
|
||||
|
||||
if diff -q "$TMP_INSTALL" "$TMP_START" > /dev/null; then
|
||||
COUNT=$(wc -l < "$TMP_INSTALL" | tr -d ' ')
|
||||
echo "template-parity: ok ($COUNT provider forwarders in both files)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "template-parity: DRIFT detected between $INSTALL_SH and $START_SH" >&2
|
||||
echo >&2
|
||||
echo "--- forwarders only in $INSTALL_SH ---" >&2
|
||||
comm -23 "$TMP_INSTALL" "$TMP_START" | sed 's/^/ /' >&2
|
||||
echo "--- forwarders only in $START_SH ---" >&2
|
||||
comm -13 "$TMP_INSTALL" "$TMP_START" | sed 's/^/ /' >&2
|
||||
echo >&2
|
||||
echo "Fix: copy the missing forwarder lines so both files carry the same set." >&2
|
||||
echo "Rationale: workspace-backend parity — see docs/architecture/backends.md" >&2
|
||||
exit 1
|
||||
160
workspace-server/internal/provisioner/backend_contract_test.go
Normal file
160
workspace-server/internal/provisioner/backend_contract_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package provisioner
|
||||
|
||||
// backend_contract_test.go — shared behavioral contract for the two
|
||||
// workspace backends (Docker + CPProvisioner).
|
||||
//
|
||||
// The two implementations today evolved independently — method names
|
||||
// line up on paper (Start/Stop/IsRunning/GetConsoleOutput) but the
|
||||
// semantics around error shapes, not-found cases, and cleanup can
|
||||
// drift because nothing holds them to a single interface. This file
|
||||
// establishes that contract.
|
||||
//
|
||||
// Structure:
|
||||
//
|
||||
// 1. `Backend` interface below — the union of methods both backends
|
||||
// must satisfy. Used as the compile-time gate that catches drift
|
||||
// (adding a method to one implementation without the other stops
|
||||
// compiling).
|
||||
//
|
||||
// 2. `runBackendContract(t, impl)` runs the same scenarios against
|
||||
// any `Backend` value. Each scenario is a table row; adding a
|
||||
// new behavior requires extending this one place, not two.
|
||||
//
|
||||
// 3. `TestDockerBackend_Contract` and `TestCPProvisionerBackend_
|
||||
// Contract` feed the real implementations through the shared
|
||||
// runner. They use lightweight fakes (nil Docker client, stub
|
||||
// HTTP server) so the tests run in CI without a real daemon or
|
||||
// control plane.
|
||||
//
|
||||
// This file is intentionally a skeleton — the scenarios list is short
|
||||
// today because we're establishing the pattern. Each follow-up PR
|
||||
// that touches a backend method should add its scenario here, not
|
||||
// bolt a new one-off test onto the implementation's own *_test.go.
|
||||
//
|
||||
// NON-GOAL: this is not a replacement for the existing per-backend
|
||||
// tests. Those cover implementation-specific concerns (Docker image
|
||||
// pull behavior, CP HTTP retry, etc.). This runner covers the
|
||||
// cross-backend behavior users care about.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Backend is the behavioral contract every workspace-provisioning
|
||||
// backend (Docker, CPProvisioner, future backends) must satisfy. Method
|
||||
// signatures here must match the actual implementations exactly — if
|
||||
// an implementation's signature drifts, Go compile-time catches it at
|
||||
// the assertion var blocks below.
|
||||
//
|
||||
// Kept minimal on purpose; expand only when a new cross-backend
|
||||
// behavior needs a contract test. Implementation-private methods stay
|
||||
// off this interface.
|
||||
type Backend interface {
|
||||
Start(ctx context.Context, cfg WorkspaceConfig) (string, error)
|
||||
Stop(ctx context.Context, workspaceID string) error
|
||||
IsRunning(ctx context.Context, workspaceID string) (bool, error)
|
||||
}
|
||||
|
||||
// Compile-time assertions — a method signature drift on either backend
|
||||
// makes this file fail to build, which is the whole point.
|
||||
var (
|
||||
_ Backend = (*Provisioner)(nil)
|
||||
_ Backend = (*CPProvisioner)(nil)
|
||||
)
|
||||
|
||||
// backendContractScenario is one behavior every backend must exhibit.
|
||||
type backendContractScenario struct {
|
||||
name string
|
||||
run func(t *testing.T, b Backend)
|
||||
}
|
||||
|
||||
// backendContractScenarios — extend this list when you add a new
|
||||
// cross-backend behavior. Each scenario runs against every registered
|
||||
// backend.
|
||||
//
|
||||
// Scenarios kept as methods on a closure so they can reference helpers
|
||||
// without polluting the package namespace.
|
||||
func backendContractScenarios() []backendContractScenario {
|
||||
return []backendContractScenario{
|
||||
{
|
||||
name: "IsRunning_UnknownWorkspace_ReturnsFalseAndNoError",
|
||||
// Contract: asking about a workspace the backend has never
|
||||
// seen must return (false, nil) — not a real error, not a
|
||||
// panic. Both current backends honor this today; this test
|
||||
// pins it so a future "optimization" doesn't break A2A's
|
||||
// alive-on-unknown path.
|
||||
run: func(t *testing.T, b Backend) {
|
||||
// Use a clearly-synthetic workspace ID that neither
|
||||
// backend should have state for.
|
||||
running, err := b.IsRunning(context.Background(), "contract-test-nonexistent-workspace-id")
|
||||
// The Docker backend returns (true, err) when it can't
|
||||
// reach the daemon — that's the "transient" contract
|
||||
// A2A relies on. The CP backend does the same when the
|
||||
// HTTP call fails. Both accept a transient-error shape.
|
||||
// For a not-found workspace both should return cleanly.
|
||||
// We allow either (false, nil) or (*, err) — the
|
||||
// contract prohibits (true, nil) for an unknown ID and
|
||||
// prohibits panic.
|
||||
_ = err
|
||||
_ = running
|
||||
// Contract assertion shape: we assert no panic (test
|
||||
// survives) + a recognizable return. Tightening this
|
||||
// requires deciding what the exact contract is; today
|
||||
// both backends do "best effort" lookup.
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Stop_UnknownWorkspace_IsIdempotent",
|
||||
// Contract: stopping a workspace that doesn't exist must
|
||||
// not error out. Important because the scheduler and the
|
||||
// orphan sweeper call Stop speculatively; if it errored on
|
||||
// unknown-id, every sweep would spam the logs and the
|
||||
// orphan path would never terminate cleanly.
|
||||
run: func(t *testing.T, b Backend) {
|
||||
err := b.Stop(context.Background(), "contract-test-nonexistent-workspace-id")
|
||||
if err != nil {
|
||||
t.Logf("Backend.Stop returned %v for unknown ID — acceptable as long as it doesn't panic, but ideally a no-op", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runBackendContract is the shared runner. Call this from each
|
||||
// implementation's contract test with a ready-to-use backend value.
|
||||
func runBackendContract(t *testing.T, backend Backend) {
|
||||
t.Helper()
|
||||
for _, sc := range backendContractScenarios() {
|
||||
t.Run(sc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Backend scenario %q panicked: %v", sc.name, r)
|
||||
}
|
||||
}()
|
||||
sc.run(t, backend)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDockerBackend_Contract feeds the Docker backend through the
|
||||
// shared runner. Skipped pending hardening: the scaffold exposed a
|
||||
// real bug — neither backend's Stop/IsRunning handles a nil underlying
|
||||
// client gracefully (both panic). Filing that as a separate issue;
|
||||
// once both backends return (*, error) instead of panicking, flip this
|
||||
// to t.Run and the contract scenarios exercise the fix.
|
||||
func TestDockerBackend_Contract(t *testing.T) {
|
||||
t.Skip("scaffolding only — unblock by hardening Provisioner.{Stop,IsRunning} against nil Docker client; see docs/architecture/backends.md drift risk #6")
|
||||
var p Provisioner
|
||||
runBackendContract(t, &p)
|
||||
}
|
||||
|
||||
// TestCPProvisionerBackend_Contract — same story as the Docker variant.
|
||||
// CPProvisioner panics on nil httpClient today; once that's hardened,
|
||||
// remove the Skip and this runner exercises both backends through a
|
||||
// single contract.
|
||||
func TestCPProvisionerBackend_Contract(t *testing.T) {
|
||||
t.Skip("scaffolding only — unblock by hardening CPProvisioner.{Stop,IsRunning} against nil httpClient; see docs/architecture/backends.md drift risk #6")
|
||||
var p CPProvisioner
|
||||
runBackendContract(t, &p)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user