Initial public ModelForge release
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Chaos testing
|
||||
|
||||
## Fault matrix
|
||||
|
||||
Every subsystem, the failure modes it can present, how far the damage reaches, and the invariant
|
||||
that must survive.
|
||||
|
||||
| Subsystem | Failure mode | Class | Blast radius | Recovery | Alert | Invariant |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Control Plane | process crash / restart | TRANSIENT | in-flight API requests | startup reconciliation | none by design | no authoritative drift |
|
||||
| Control Plane | restart during backup | TRANSIENT | that backup only | interrupted backup fails closed | `BACKUP_FAILED` | `restore_requires_verified_backup` |
|
||||
| PostgreSQL | unavailable | DEPENDENCY | all persistence | pool reconnects, no restart needed | `PLATFORM_API` SLO | `audit_chain_intact` |
|
||||
| PostgreSQL | deadlock / serialisation failure | CONCURRENCY | one transaction | bounded retry | none | no lost update |
|
||||
| PostgreSQL | latency | CAPACITY | request queueing | timeouts, typed errors | SLO burn | no connection explosion |
|
||||
| Redis | unavailable | DEPENDENCY | queues, transient payloads | queues start empty, leases reconciled | dependency health | no duplicate serving job |
|
||||
| Node Agent | network loss | DEPENDENCY | that node's capacity | heartbeat stale → offline → reconnect | `NODE_OFFLINE` | `single_node_identity` |
|
||||
| Node Agent | credential revoked | AUTH | that node | re-enrol, identity preserved | `NODE_OFFLINE` | `revoked_credentials_stay_revoked` |
|
||||
| Node Agent | restart storm | TRANSIENT | that node | same identity, no duplicate inventory | none | `single_active_node_credential` |
|
||||
| Runtime Worker | crash during load/invoke/unload | TRANSIENT | one request | typed failure, generation fencing | `RUNTIME_CRASH_LOOP` | `no_orphan_serving_work` |
|
||||
| Gateway | invalid credential burst | AUTH | none | identical refusal | none | no enumeration signal |
|
||||
| Gateway | malformed or hostile input | SECURITY | none | typed 4xx | none | no execution, no 5xx |
|
||||
| Scheduler | GPU pressure | CAPACITY | admission | reject or queue | `GPU_PRESSURE` | `no_stale_gpu_lease` |
|
||||
| Scheduler | external VRAM spike | CAPACITY | admission | schedulable headroom shrinks | `CAPABILITY_CAPACITY` | external workloads untouched |
|
||||
| GPU | telemetry stale | DEPENDENCY | placement decisions | admission blocked, never guessed | `GPU_PRESSURE` | no stale snapshot as truth |
|
||||
| Artifact storage | location missing | CORRUPTION | one artifact set | exact-revision rehydration | `ARTIFACT_INTEGRITY` | `no_unsafe_artifact_promoted` |
|
||||
| Artifact storage | disk low / full | CAPACITY | acquisition | capacity guard refuses first | `STORAGE_LOW` | no partial promotion |
|
||||
| Backup storage | unwritable / absent | CAPACITY | that backup | typed `DESTINATION_UNAVAILABLE` | `BACKUP_FAILED` | serving unaffected |
|
||||
| Backup storage | payload corrupted | CORRUPTION | that backup | verification refuses | `BACKUP_VERIFICATION_FAILED` | `restore_requires_verified_backup` |
|
||||
| Lifecycle | operation interrupted | TRANSIENT | one LAB subject | rollback from immutable snapshot, exactly once | `ROLLBACK_FAILURE` | `lifecycle_commit_has_evidence` |
|
||||
| Migration | cutover interrupted | CORRUPTION | one plan | never auto-resolved; operator reconciles | `MIGRATION_FAILURE` | `cutover_commit_has_validation` |
|
||||
| Migration | backfill worker failure | TRANSIENT | one plan | checkpoint preserves completed work | `MIGRATION_FAILURE` | no mixed embedding space |
|
||||
| Observability | persistence unavailable | DEPENDENCY | monitoring only | degraded gauge, serving continues | `modelforge_observability_degraded` | safety never depends on the alert store |
|
||||
| Recovery | restore interrupted | TRANSIENT | that restore | manual intervention, resumable | `RESTORE_FAILED` | `restore_requires_verified_backup` |
|
||||
| Credentials | rotation under load | AUTH | one client | old refused, new accepted | none | one usable secret |
|
||||
| External | Hugging Face unavailable | DEPENDENCY | acquisition only | `ARTIFACT_REHYDRATION_BLOCKED` | none | no fallback source |
|
||||
| External | ExampleRAG / ExampleVision down | EXTERNAL | none | not owned | none | never written to |
|
||||
|
||||
## Failure classes
|
||||
|
||||
```text
|
||||
TRANSIENT recoverable by retry or restart; state converges
|
||||
PERSISTENT requires operator action; state is safe but stuck
|
||||
CORRUPTION integrity is in question; the platform must refuse rather than proceed
|
||||
CAPACITY the safe answer is to reject work, not to risk exhaustion
|
||||
DEPENDENCY something ModelForge does not own is unavailable
|
||||
AUTH a credential is missing, wrong, revoked or out of scope
|
||||
SECURITY hostile input or an attempt to cross a boundary
|
||||
CONCURRENCY two operations race; exactly one must win
|
||||
OPERATOR a human action that must be gated, audited and reversible
|
||||
```
|
||||
|
||||
A capacity rejection is not a failure. The scheduler refusing unsafe work is the system behaving
|
||||
correctly, and the soak counts those separately from internal errors for exactly that reason.
|
||||
|
||||
## Invariants
|
||||
|
||||
Fifteen properties, each a query the platform runs against its own authoritative state. They are
|
||||
read-only, never call an external system, and never depend on the observability database — so they
|
||||
stay usable exactly when monitoring is the thing that failed.
|
||||
|
||||
```sh
|
||||
python scripts/m16_invariants.py --label "before change"
|
||||
```
|
||||
|
||||
See `docs/security/CHAOS_SECURITY_GATE.md` for the full list and what each one protects.
|
||||
|
||||
## Running the suite
|
||||
|
||||
```sh
|
||||
python scripts/m16_chaos.py --database-url "$MODELFORGE_RUNTIME_DATABASE_URL" --all --report chaos.json
|
||||
python scripts/m16_soak.py --database-url "$MODELFORGE_RUNTIME_DATABASE_URL" --minutes 45 --secret <secret>
|
||||
python scripts/m16_invariants.py --database-url "$MODELFORGE_RUNTIME_DATABASE_URL"
|
||||
```
|
||||
|
||||
Chaos scenarios and the soak may be run together deliberately — a backup taken while the soak is
|
||||
generating real traffic is the M16 "backup under load" evidence — but a soak whose numbers are
|
||||
meant to characterise steady state should not overlap an injected fault. When they do overlap, say
|
||||
so in the report rather than presenting the mixed result as a clean baseline.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Failure and Health Model
|
||||
|
||||
## Health layers
|
||||
|
||||
### Process health
|
||||
|
||||
Is the worker/control-plane process running?
|
||||
|
||||
### Runtime health
|
||||
|
||||
Can the adapter communicate with the worker and does its runtime/version match the deployment profile?
|
||||
|
||||
### Hardware health
|
||||
|
||||
Was the node inventory collected, and are previously observed accelerators still reconcilable?
|
||||
|
||||
Remote nodes additionally report `online`, `stale`, `offline` or `disabled`. These states derive
|
||||
from central receive time, not the node clock. Stale/offline nodes are retained with last-good
|
||||
inventory; they are visibly unavailable for future scheduling rather than silently deleted.
|
||||
|
||||
### Accelerator health
|
||||
|
||||
Is the accelerator present and observable? Unsupported optional metrics are not health failures.
|
||||
|
||||
### Model health
|
||||
|
||||
Can the deployed model load and execute a known smoke inference?
|
||||
|
||||
### Capability health
|
||||
|
||||
Can the current routing/policy for a capability satisfy its contract?
|
||||
|
||||
### Project health
|
||||
|
||||
Are all required project capabilities available within acceptable SLO/policy?
|
||||
|
||||
## Degradation examples
|
||||
|
||||
`rag.reranking` may allow a project-specific policy to continue retrieval without second-stage reranking.
|
||||
|
||||
`rag.embedding` may be configured as a hard dependency and fail requests rather than generate inconsistent vectors via an unapproved fallback.
|
||||
|
||||
Fallback semantics are part of capability/project policy, not implicit behavior in runtime code.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Fault injection
|
||||
|
||||
## Where faults come from
|
||||
|
||||
ModelForge has no chaos endpoint and no way to run a command. A control plane that can be told to
|
||||
break itself is a control plane an attacker can tell to break itself, so every fault is injected
|
||||
from outside the product:
|
||||
|
||||
```text
|
||||
container runtime stop, start, restart, network disconnect, filesystem permissions
|
||||
rehearsal seams the M12 lifecycle pause, the M15 restore interruption
|
||||
real credentials revoked, rotated, malformed, wrongly scoped
|
||||
real load the soak harness, running the actual capability path
|
||||
```
|
||||
|
||||
A static test asserts the product exposes no `/chaos`, `/shell`, `/exec`, `/command` or `/debug`
|
||||
route segment, and that subprocess use stays allowlisted to the recovery plane.
|
||||
|
||||
## The shape every scenario has
|
||||
|
||||
```text
|
||||
preflight → baseline → inject → observe → assert → recover → verify invariants → cleanup
|
||||
```
|
||||
|
||||
`preflight` refuses to start if an invariant is already violated: injecting a fault into a broken
|
||||
platform measures nothing. `verify invariants` re-runs the full set afterwards, so a scenario that
|
||||
survives its fault but corrupts state is reported as `INVARIANT_VIOLATED` rather than passing.
|
||||
A scenario that cannot restore the platform reports `RECOVERY_FAILED` rather than leaving the
|
||||
environment dirty and calling itself done.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
python scripts/m16_chaos.py --list
|
||||
python scripts/m16_chaos.py --database-url "$MODELFORGE_RUNTIME_DATABASE_URL" --scenario redis-outage --seed 20260827
|
||||
python scripts/m16_chaos.py --database-url "$MODELFORGE_RUNTIME_DATABASE_URL" --all --report chaos.json
|
||||
```
|
||||
|
||||
The database URL is required for executable scenarios and must name the non-owner runtime role;
|
||||
the harness has no embedded or owner-credential fallback. `--list` does not connect to PostgreSQL.
|
||||
|
||||
Every run records its scenario id, seed, target, expected fault, observed result, recovery, cleanup
|
||||
outcome and the invariant results before and after. The seed is logged even where behaviour is
|
||||
deterministic, so a surprising result can be reproduced exactly.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| Key | Class | Subsystem | Expected |
|
||||
| --- | --- | --- | --- |
|
||||
| `redis-outage` | DEPENDENCY | Redis | control plane stays up, replays nothing |
|
||||
| `postgres-restart` | DEPENDENCY | PostgreSQL | fails safely, recovers its pool, no duplicate work |
|
||||
| `node-agent-disconnect` | DEPENDENCY | Node Agent | heartbeat stops, liveness degrades, one identity survives |
|
||||
| `control-plane-restart-storm` | TRANSIENT | Control Plane | idempotent reconciliation, no authoritative drift |
|
||||
| `invalid-auth-burst` | AUTH | Control Plane | identical refusal, no amplification, no enumeration signal |
|
||||
| `backup-destination-unavailable` | CAPACITY | Backup storage | typed failure, serving unaffected |
|
||||
|
||||
## Safety rules
|
||||
|
||||
Disposable targets only. The node-disconnect scenario enrols a throwaway agent rather than
|
||||
isolating GPU Node: GPU Node reaches the control plane over the LAN rather than the Compose network, so
|
||||
disconnecting a container would not interrupt it anyway — and isolating the production node to
|
||||
prove a point is exactly what a release gate must not do.
|
||||
|
||||
Nothing in the harness touches ExampleRAG, ExampleVision, Ollama, Plex or Tdarr. Their VRAM counts as
|
||||
external pressure and is observed; their processes are never stopped, paused, throttled or
|
||||
reconfigured.
|
||||
|
||||
The harness deletes its own fixtures. `backup-destination-unavailable` removes the `chaos-*` backup
|
||||
sets it created; `node-agent-disconnect` tears down its agent, revokes its credential and disables
|
||||
the node it enrolled.
|
||||
|
||||
## When a scenario finds something
|
||||
|
||||
Both defects the harness found in M16 were real product defects rather than harness noise: an
|
||||
unwritable backup root escaping as an unhandled error, and a Node Agent logging a failure with no
|
||||
diagnostic. Each was fixed with a regression test before the scenario was re-run.
|
||||
|
||||
One scenario was itself wrong first: the node-disconnect measured GPU Node while disconnecting an
|
||||
unrelated container, and reported that the heartbeat kept advancing during the "outage". That
|
||||
contradiction is why the harness records what it observed rather than only whether it passed.
|
||||
|
||||
See `docs/operations/CHAOS_TESTING.md` for the fault matrix and
|
||||
`docs/security/CHAOS_SECURITY_GATE.md` for the invariants.
|
||||
@@ -0,0 +1,17 @@
|
||||
# GPU Leases
|
||||
|
||||
`serving_gpu_leases` is the database coordination boundary for GPU capacity. A lease binds the
|
||||
request, deployment, node and accelerator to reserved bytes, priority, owner, server-created
|
||||
timestamps and an expiry. States are pending, granted, active, releasing, released, expired and
|
||||
failed.
|
||||
|
||||
The scheduler locks the accelerator row before accounting and granting. This prevents two control
|
||||
plane processes from independently spending the same capacity. Production and interactive are
|
||||
serving priorities; background and benchmark remain defined but cannot pre-empt production in M5.
|
||||
Expired leases are reaped and their unfinished request is failed with `LEASE_TIMEOUT`.
|
||||
|
||||
M10 distinguishes the lease purpose (`RESIDENCY_RESERVATION`, `REQUEST_EXECUTION`, or
|
||||
`LOAD_TRANSITION`), records priority, expiry and a 64-bit worker generation, and subtracts only the
|
||||
unmaterialized portion from physical headroom. Startup reconciliation expires orphan reservations,
|
||||
preserves exact worker-reported residency and prevents duplicate adoption. The accelerator row lock
|
||||
remains authoritative when two heavy admissions race.
|
||||
@@ -0,0 +1,116 @@
|
||||
# GPU Scheduler Design
|
||||
|
||||
## M6 reindex QoS
|
||||
|
||||
The database claim order remains production, interactive, background, benchmark. ExampleRAG shadow
|
||||
jobs are stamped `background` from their scoped identity. External Ollama/Tdarr VRAM remains
|
||||
observed capacity pressure and is never managed by ModelForge.
|
||||
|
||||
## M10 operational objective
|
||||
|
||||
Admit capability requests on one finite GPU without letting ModelForge or experimental work disrupt
|
||||
production or unmanaged workloads.
|
||||
|
||||
## Priority classes
|
||||
|
||||
1. production
|
||||
2. interactive
|
||||
3. background
|
||||
4. benchmark
|
||||
5. maintenance
|
||||
|
||||
Priority is not identical to preemption support. Some runtimes/jobs cannot be safely interrupted and must be drained instead.
|
||||
|
||||
## Residency policies
|
||||
|
||||
- `always_warm`
|
||||
- `keep_warm` with TTL
|
||||
- `load_on_demand`
|
||||
- `exclusive` and `lab_only` remain future policy values, not M5 serving choices.
|
||||
|
||||
## Resource leases
|
||||
|
||||
Inference begins only when the scheduler issues a lease for required accelerator resources.
|
||||
|
||||
Lease evaluation considers:
|
||||
|
||||
- target accelerator compatibility;
|
||||
- latest total NVML usage, including unmanaged processes;
|
||||
- reserved safety margin;
|
||||
- deployment resource envelope;
|
||||
- current leases;
|
||||
- queue priority;
|
||||
- model load/sleep/unload cost;
|
||||
- production eligibility and exact measured compatibility.
|
||||
|
||||
M5 never silently falls back to CPU, another model, a workstation or cloud inference.
|
||||
|
||||
The pre-M10 budget was:
|
||||
|
||||
```text
|
||||
total VRAM - external usage - ModelForge residency - active leases - 1 GiB reserve
|
||||
```
|
||||
|
||||
Rows are aggregated once per accelerator. M10's authoritative invariant, dynamic reserve,
|
||||
multi-resident placement, hysteresis and typed failure behavior are specified in
|
||||
`docs/architecture/ADVANCED_GPU_SCHEDULER.md`. GPU Node is eligible; the DESKTOP workstation is explicitly
|
||||
rejected because `production_eligible=false`. The accelerator row is locked while a lease is granted.
|
||||
|
||||
## Resource envelope
|
||||
|
||||
Memory is not a single static number. Record measured profiles across:
|
||||
|
||||
- context length;
|
||||
- concurrency;
|
||||
- batch size;
|
||||
- idle vs peak VRAM;
|
||||
- runtime version/configuration.
|
||||
|
||||
## M5 embedding decision
|
||||
|
||||
```text
|
||||
Request: rag.embedding@1
|
||||
Deployment: stable and cold
|
||||
Envelope: 1,216,348,160 process-reserved bytes
|
||||
Policy: KEEP_WARM 901s, concurrency 1, queue 16
|
||||
|
||||
Decision: lock GPU Node GPU, account unmanaged VRAM and reserve, grant a lease, load once, run a
|
||||
functional health check, invoke, release the request lease and retain residency.
|
||||
```
|
||||
|
||||
## Failure classes
|
||||
|
||||
- MODEL_LOAD_FAILED
|
||||
- GPU_OOM
|
||||
- RUNTIME_CRASH
|
||||
- TIMEOUT
|
||||
- HEALTHCHECK_FAILED
|
||||
- INVALID_OUTPUT
|
||||
- ARTIFACT_CORRUPT
|
||||
- CAPABILITY_UNAVAILABLE
|
||||
- DRIVER_ERROR
|
||||
- SCHEDULER_STATE_STALE
|
||||
- INSUFFICIENT_SCHEDULABLE_VRAM
|
||||
- EXTERNAL_GPU_PRESSURE
|
||||
- RESIDENCY_CONFLICT
|
||||
- EVICTION_NOT_ALLOWED
|
||||
- DEADLINE_CANNOT_BE_MET
|
||||
- LEASE_CONFLICT
|
||||
- GPU_MEMORY_NOT_RECLAIMED
|
||||
- QUEUE_FULL
|
||||
- LEASE_TIMEOUT
|
||||
|
||||
Failures must produce structured events. No silent retry loop may hide prolonged capability degradation.
|
||||
|
||||
## M14 operational telemetry
|
||||
|
||||
The Operations plane samples the existing NVML/scheduler truth once per minute. A retained snapshot
|
||||
separates total, NVML-observed, ModelForge resident, active leased, reserve, external and schedulable
|
||||
bytes and carries observed/received freshness. `GPU_PRESSURE` uses the existing hysteresis state;
|
||||
`CAPABILITY_CAPACITY` compares a measured production ResourceEnvelope with known headroom. Neither
|
||||
alert authorizes control of Ollama, Plex, Tdarr or another external GPU process. See
|
||||
`docs/architecture/CAPACITY_TRENDS.md` and `docs/operations/RUNBOOK_GPU_PRESSURE.md`.
|
||||
|
||||
Runtime adapters own model load, crash and invalid-output detection. The scheduler owns VRAM
|
||||
reservation, OOM recovery and lease release. The gateway owns bounded retries and only contract-
|
||||
approved fallback/degradation. The control plane blocks corrupt artifacts and records state changes.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Model Storage
|
||||
|
||||
## Configuration model
|
||||
|
||||
Registry storage is explicit and node-scoped. `StorageRoot` references a `ComputeNode`, has a host
|
||||
path and purpose, and stores write/capacity observations plus reserve policy. It is intentionally
|
||||
separate from `StorageVolumeState`: the latter is agent telemetry, while a storage root is an
|
||||
operator-approved placement target.
|
||||
|
||||
Every placement check is fail-closed:
|
||||
|
||||
- unknown capacity blocks placement;
|
||||
- non-writable roots block placement;
|
||||
- usable bytes are `free - max(reserve_bytes, capacity * reserve_percent)`;
|
||||
- a request crossing that reserve becomes `capacity_blocked`.
|
||||
|
||||
No default silently assumes infinite storage.
|
||||
|
||||
## GPU Node validation — 2026-08-25
|
||||
|
||||
The Unraid array reported 54,983,962,927,104 bytes total, 53,206,636,449,792 used and
|
||||
1,777,326,477,312 available (97% used). It is explicitly unsuitable for model placement.
|
||||
|
||||
The cache-backed `appdata` filesystem reported approximately 1.438 TB total and 535 GB free (63%
|
||||
used). ModelForge created this dedicated root:
|
||||
|
||||
`/mnt/cache/appdata/modelforge-node-agent/artifacts/model-registry`
|
||||
|
||||
Ownership is UID/GID 999 with mode 0750. A write/create/remove probe from the existing non-root,
|
||||
read-only, capability-dropped node-agent container passed. The registered root is
|
||||
`5774fef2-8cd4-44bd-a244-5b5b20c02811` on GPU Node node
|
||||
`c3fab19c-d7f7-4dde-80ac-2edf565ad61d`.
|
||||
|
||||
The stored observation is 1,438,282,285,056 bytes capacity and 534,938,255,360 bytes free, with a
|
||||
50 GiB absolute reserve and 10% percentage reserve. Effective zero-request usable capacity was
|
||||
391,110,026,855 bytes. The array-unsuitable decision, backing filesystem, write-probe outcome and
|
||||
`download_performed=false` are retained in validation details.
|
||||
|
||||
## M12 cleanup boundary
|
||||
|
||||
CleanupPlan is a dry-run over node-local ArtifactLocations and all known control-plane dependencies.
|
||||
Execution rechecks the dependency digest, requires operator confirmation and records physical removal
|
||||
only after the typed storage action succeeds. Removing a GPU Node location cannot mark the artifact
|
||||
globally missing while another verified node location exists. Metadata archive and byte removal are
|
||||
separate operations; rollback-retained artifacts are not eligible.
|
||||
|
||||
M2 did not download or move any model artifact. M3 maps the host root to
|
||||
`/data/artifacts/model-registry` inside the GPU Node agent. Planning and control-plane execution checks
|
||||
use the registered observation; the agent rechecks the actual filesystem immediately before bytes.
|
||||
There is no fallback to `/mnt/user` or another storage root.
|
||||
|
||||
After the M3 acceptance artifact was promoted, GPU Node reported 1,440,503,169,024 bytes capacity and
|
||||
536,169,676,800 bytes free. The effective reserve is 144,050,316,902 bytes (10%, stricter than
|
||||
50 GiB), leaving 392,119,359,898 bytes usable. Status remains `ready`. The accepted artifact set
|
||||
uses 1,207,470,234 bytes plus a 4,256-byte reconciliation manifest.
|
||||
|
||||
## M15 artifact recovery
|
||||
|
||||
Artifacts are classified for recovery rather than treated uniformly. A set with an exact upstream
|
||||
repository and commit is `REHYDRATABLE` and is protected by a manifest recording every file's
|
||||
SHA-256 and size; recovery redownloads that exact revision through quarantine. A set without both is
|
||||
`NON_REHYDRATABLE` and needs a payload backup — the platform will not pretend it can be redownloaded.
|
||||
|
||||
Recovery targets a disposable storage root, never the only usable copy of a production artifact. A
|
||||
recovered artifact re-enters the supply chain unapproved. See
|
||||
`docs/architecture/ARTIFACT_RECOVERY.md` and `docs/operations/RUNBOOK_ARTIFACT_LOSS.md`.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Isolated public-candidate acceptance
|
||||
|
||||
The Gitea workflow `.gitea/workflows/public-candidate-acceptance.yml` moves the expensive release
|
||||
proof off a developer workstation and onto a Docker-capable server runner. It is manual-only and
|
||||
accepts an exact canonical commit SHA. It does not invoke `unraid-deploy.yml`, the deploy controller
|
||||
or the production Compose project.
|
||||
|
||||
The job creates a curated public export, validates its content manifest, scans it with Gitleaks,
|
||||
then creates a temporary parentless Git commit so release provenance describes the exact exported
|
||||
tree. From that tree it:
|
||||
|
||||
1. builds API, Console, Node Agent and Runtime Worker images with OCI identity;
|
||||
2. produces the release manifest, checksums, CycloneDX inventory and image provenance;
|
||||
3. scans every exact image ID with checksum-pinned Trivy and rejects ambiguous reports or any
|
||||
HIGH/CRITICAL finding;
|
||||
4. starts API, Console, PostgreSQL and Redis with the production overlay under a unique
|
||||
`modelforge-rc-*` Compose project;
|
||||
5. uses newly generated secrets, loopback-only ephemeral host ports and project-scoped volumes;
|
||||
6. proves liveness, readiness, version identity, Console serving and both sides of the operator
|
||||
authentication boundary; and
|
||||
7. always removes the temporary containers, networks, volumes and four candidate image tags. An
|
||||
independent `if: always()` workflow cleanup repeats the exact project-label cleanup after a
|
||||
failure, cancellation or timeout; it never runs a host-wide Docker prune.
|
||||
|
||||
No Node Agent or Runtime Worker is started by this clean-install gate. Consequently it creates no
|
||||
compute identity and cannot interact with the production GPU node. A later GPU qualification must
|
||||
enrol a disposable `rc-canary` identity with `production_eligible=false` and retain that separate
|
||||
evidence.
|
||||
|
||||
After the workflow exists on the default branch, dispatch it directly and enter the exact pushed
|
||||
commit. Before merge, dispatch the existing **Managed validation** workflow on the candidate branch
|
||||
with profile `build`; that profile calls the same acceptance workflow with `gitea.sha`. Use the
|
||||
actual intended Console API origin for a publishable artifact; the managed-branch route uses
|
||||
`https://modelforge.example.test` and therefore produces non-publishable infrastructure rehearsal
|
||||
artifacts. Evidence is retained for 90 days. A failed scan or install is evidence of a blocker, not
|
||||
permission to bypass the gate.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Residency Manager
|
||||
|
||||
Residency is distinct from a request lease. M10's full multi-resident identity and recovery contract
|
||||
is specified in `docs/architecture/MULTI_CAPABILITY_RESIDENCY.md`. The state machine is cold → loading → warm/busy →
|
||||
draining/unloading → cold, with explicit failed states. A cold request creates exactly one serialized
|
||||
load job; later requests reuse the resident adapter at its measured concurrency of one.
|
||||
|
||||
Policies are `always_warm`, `keep_warm`, `load_on_demand` and `lab_only`. The production embedding deployment
|
||||
uses `keep_warm` with a 901-second TTL. Unload is allowed only without active leases. Model-owned
|
||||
VRAM reclaim is measured with the PyTorch CUDA allocator; NVML total memory remains the independent
|
||||
external-pressure signal. Workers report the complete deployment inventory and generation.
|
||||
Exact immutable identities can be adopted after a control-plane restart; absent identities are
|
||||
reconciled cold and unknown GPU processes remain external.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Restart and crash recovery
|
||||
|
||||
## What each restart reconciles
|
||||
|
||||
The control plane reconciles on every start. Reconciliation is idempotent by design, so a restart
|
||||
storm converges rather than accumulating.
|
||||
|
||||
```text
|
||||
lifecycle incomplete operations roll back from their immutable snapshot, exactly once
|
||||
migration interrupted cutovers are reported, never auto-resolved
|
||||
recovery interrupted backups fail closed; interrupted restores need operator review
|
||||
serving stale leases and residency are reconciled against live node state
|
||||
observability current alert state is re-derived; history is retained
|
||||
```
|
||||
|
||||
Migration is deliberately the exception. External alias truth cannot be inferred after a crash, so
|
||||
an interrupted cutover stays in its intermediate stage and is reported as requiring reconciliation
|
||||
through the typed adapter. Guessing here is how a platform ends up serving two embedding spaces.
|
||||
|
||||
## Restart storms
|
||||
|
||||
Four consecutive control-plane restarts under load produced no authoritative drift: models,
|
||||
revisions, artifacts, artifact sets, projects, bindings, capability deployments, lifecycle
|
||||
operations, migration plans and cutovers, compute nodes, node credentials, service clients and
|
||||
backup sets were all identical before and after, and the registry answered on every start.
|
||||
|
||||
Audit events are expected to grow — reconciliation is itself an audited action — so the drift check
|
||||
excludes them and the `audit_chain_intact` invariant covers their integrity instead.
|
||||
|
||||
## Node Agent restarts
|
||||
|
||||
A restarting agent keeps its persisted identity file, so it re-attaches to the same node with the
|
||||
same accelerators, storage roots and history. It does not re-enrol unless it has lost its
|
||||
credential, and enrolment revokes the previous credential rather than adding a second.
|
||||
|
||||
The publication and artifact loops share an enrolment lock. Without it they could enrol
|
||||
concurrently from separate threads and burn a single-use token on two identities for the same
|
||||
hardware.
|
||||
|
||||
## Database restarts
|
||||
|
||||
The control plane fails safely while PostgreSQL is unavailable: liveness stays up because it does
|
||||
not touch the database, and any route that needs persistence returns an error rather than inventing
|
||||
state. The connection pool recovers on its own — no control-plane restart is required — and no
|
||||
duplicate audit sequence appears.
|
||||
|
||||
An error raised while the database is down carries no DSN, driver name, SQL or traceback. That is
|
||||
verified directly, because a dependency failure is when a framework is most likely to leak
|
||||
internals.
|
||||
|
||||
## Redis restarts
|
||||
|
||||
Redis holds queues and transient payloads, never authoritative state. An outage leaves the control
|
||||
plane serving, and the return produces no duplicate serving job: idempotency keys are unique in the
|
||||
database, not in the queue.
|
||||
|
||||
## Runtime Worker crashes
|
||||
|
||||
A worker crash during load, inference or unload produces a typed failure for the request, fences the
|
||||
worker generation so a late result from a dead generation cannot be committed, and reconciles the
|
||||
lease and residency. A crash loop raises `RUNTIME_CRASH_LOOP`.
|
||||
|
||||
## Interrupted backups and restores
|
||||
|
||||
A backup interrupted mid-write is moved to `FAILED` with `MANIFEST_INCOMPLETE` on the next start and
|
||||
can never become restore eligible. A restore interrupted mid-phase becomes
|
||||
`MANUAL_INTERVENTION_REQUIRED` rather than resuming silently; its journal records the phase it
|
||||
reached.
|
||||
|
||||
A backup also cannot contain a record of itself as complete, since the dump is taken while its own
|
||||
`BackupSet` row is still `CREATING`. A restored control plane therefore consistently reports the
|
||||
backup it came from as not restore eligible.
|
||||
|
||||
## Checking after a restart
|
||||
|
||||
```sh
|
||||
python scripts/m16_invariants.py --label "after restart"
|
||||
curl -s localhost:8000/api/v1/health/ready
|
||||
```
|
||||
|
||||
Fifteen invariants holding, readiness healthy, and no orphaned lease or queued job is what "the
|
||||
restart reconciled" means. See `docs/operations/CHAOS_TESTING.md` for the fault matrix.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Runbook: artifact loss
|
||||
|
||||
## Trigger
|
||||
|
||||
An artifact location is missing, corrupt, or unavailable after a storage failure or a control-plane
|
||||
restore.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Establish the recovery class first, because it decides whether recovery is possible at all:
|
||||
|
||||
| Class | Recovery |
|
||||
| --- | --- |
|
||||
| `REHYDRATABLE` | exact upstream repository and commit exist; redownload |
|
||||
| `NON_REHYDRATABLE` | no upstream identity; only a payload backup can recover it |
|
||||
| `DERIVED` | rebuild from exact source artifacts, preserving lineage |
|
||||
| `LOCAL_ONLY` | payload backup only |
|
||||
|
||||
`artifacts.json` inside the backup lists every artifact set with its class, upstream repository,
|
||||
exact commit SHA and per-file SHA-256.
|
||||
|
||||
## Act and recover
|
||||
|
||||
1. Register a disposable storage root for the recovery. Never rehydrate over the only usable copy of
|
||||
a production artifact.
|
||||
2. Create the recovery operation (`POST /api/v1/admin/recovery/artifact-recoveries`) and confirm it
|
||||
reports `REHYDRATABLE` with the exact commit you expect. If it reports `NON_REHYDRATABLE`, stop:
|
||||
this artifact needs a payload restore, not a download.
|
||||
3. Create a download plan for that artifact set against the disposable root, approve it and execute
|
||||
it. The Node Agent downloads into quarantine, hashes every file and promotes atomically.
|
||||
4. Record the observed evidence against the recovery operation.
|
||||
|
||||
Rehydration is never done from a floating revision. If the upstream is unreachable, record the
|
||||
operation as `BLOCKED` with `ARTIFACT_REHYDRATION_BLOCKED` and say so — do not report the platform
|
||||
as recovered while an artifact it depends on is still missing.
|
||||
|
||||
## Security
|
||||
|
||||
A rehydrated artifact is new material. It re-enters quarantine, is re-hashed against provenance and
|
||||
re-checked against the security policy, and it carries `static_checks_passed_unapproved` rather than
|
||||
inheriting its predecessor's approval. `trust_remote_code` stays `false`. A digest mismatch fails
|
||||
the recovery with `ARTIFACT_HASH_MISMATCH` even if the download reported success.
|
||||
|
||||
## Confirm
|
||||
|
||||
Every expected file present with a matching digest, byte counts equal to provenance, the artifact
|
||||
locations `verified`, lineage intact for derived artifacts, and a runtime probe or capability smoke
|
||||
against the recovered set before it is used for anything.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Runbook: backup failure or stale backup
|
||||
|
||||
## Trigger
|
||||
|
||||
`BACKUP_FAILED`, `BACKUP_VERIFICATION_FAILED`, `BACKUP_STALE` or `RECOVERY_READINESS_DEGRADED`.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Read the backup set's `failure_code` and the `verification` block, which records exactly how far
|
||||
verification reached before it stopped.
|
||||
|
||||
| Failure code | Meaning |
|
||||
| --- | --- |
|
||||
| `MANIFEST_HASH_MISMATCH` | the manifest no longer matches its recorded identity |
|
||||
| `MANIFEST_INCOMPLETE` | objects and journal disagree, no database payload, or the backup was interrupted |
|
||||
| `HASH_MISMATCH` | a payload object no longer matches the manifest |
|
||||
| `PAYLOAD_MISSING` | an object is absent from the allowlisted root |
|
||||
| `DECRYPTION_FAILED` | the configured key cannot authenticate the payload |
|
||||
| `ENCRYPTION_KEY_UNAVAILABLE` | the policy requires encryption and no key is configured |
|
||||
| `INSUFFICIENT_CAPACITY` | the destination lacked headroom; nothing was written |
|
||||
| `BACKUP_TOOL_UNAVAILABLE` | `pg_dump` is missing from the control-plane image |
|
||||
| `CONCURRENT_OPERATION` | another backup or restore is in flight |
|
||||
|
||||
`BACKUP_STALE` means no *verified* backup exists inside the policy window. A `CREATED` backup that
|
||||
was never verified does not count and never will.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Do not delete the failed set: it is evidence, and retention protects the last verified backups
|
||||
independently of it. Fix the cause — restore storage headroom, supply the encryption key, replace
|
||||
damaged media — then take a new backup and verify it. Verification is idempotent and safe to repeat.
|
||||
|
||||
If verification fails on a backup that previously verified, treat the destination as suspect. Take a
|
||||
fresh backup immediately, verify it, and only then investigate the older one; a platform with one
|
||||
damaged backup and no newer verified backup is unprotected.
|
||||
|
||||
Never mark a backup restore eligible by editing the database. Only successful verification does that.
|
||||
|
||||
## Confirm
|
||||
|
||||
`GET /api/v1/admin/recovery/dashboard` shows a `latest_verified_backup_id`, an age inside
|
||||
`backup_staleness_threshold_seconds`, `stale_backup: false` and no unprotected assets. The
|
||||
`BACKUP_STALE` and `RECOVERY_READINESS_DEGRADED` alerts resolve on the next evaluation.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Runbook: control-plane loss
|
||||
|
||||
## Trigger
|
||||
|
||||
The ModelForge control plane is unrecoverable: host lost, image lost, configuration lost, or all
|
||||
three.
|
||||
|
||||
## Rebuild
|
||||
|
||||
1. Read the DR bundle for the chosen backup:
|
||||
`python -m modelforge_api.cli.dr bundle --backup-id <id>`. It names the ModelForge commit,
|
||||
repository and reference, the backup identity and manifest hash, the encryption key requirement,
|
||||
the host paths, the artifact recovery plan, the node enrolment requirement and the external
|
||||
dependencies.
|
||||
2. Clone the canonical repository and check out the exact commit the backup records. ModelForge does
|
||||
not back up its own Git host; that is platform operations.
|
||||
3. Provision PostgreSQL of a compatible major version and an empty database.
|
||||
4. Supply configuration and secrets. There is no default administrator password anywhere in
|
||||
ModelForge. `MODELFORGE_OPERATOR_API_KEY` and `MODELFORGE_BACKUP_ENCRYPTION_KEY` are operator
|
||||
inputs; the encryption key is never written into a backup and a restore cannot proceed without it.
|
||||
5. Restore the database — see `RUNBOOK_DATABASE_RESTORE.md`.
|
||||
6. Start the control plane against the restored database. Do not let it run migrations it has not
|
||||
restored; the restore already brought the schema to head.
|
||||
7. Re-enrol or reconnect the node — see `RUNBOOK_NODE_LOSS.md`.
|
||||
8. Rehydrate missing artifacts — see `RUNBOOK_ARTIFACT_LOSS.md`.
|
||||
|
||||
## Break-glass credentials
|
||||
|
||||
If the operator API key is lost, the recovered deployment starts with none configured and every
|
||||
admin route returns 503 rather than falling open. Set a new key in deployment configuration and
|
||||
restart. Project and node credentials restore as hashes only: consumers that lost their plaintext
|
||||
need a new credential issued, not a recovered one.
|
||||
|
||||
## What the rebuilt plane will and will not know
|
||||
|
||||
It knows all restored history: provenance, lifecycle, migrations, approvals, audit, observability
|
||||
history, project bindings and node identities. It does not know current truth — telemetry, leases,
|
||||
residency and inventory are empty until a live agent reports. Restored firing alerts are suppressed
|
||||
so the rebuilt plane re-derives which alerts are actually firing now.
|
||||
|
||||
## Confirm
|
||||
|
||||
Liveness and readiness are healthy, the OpenAPI document publishes, an unauthenticated admin call
|
||||
returns 401 and an authenticated one returns 200, the registry and project views serve restored
|
||||
truth, and the recovery dashboard honestly reports that the *new* deployment has no verified backup
|
||||
of its own yet. Take one immediately.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Runbook: control-plane database restore
|
||||
|
||||
## Trigger
|
||||
|
||||
Loss, corruption or suspected divergence of the ModelForge PostgreSQL database, or a scheduled
|
||||
disaster-recovery rehearsal.
|
||||
|
||||
## Preconditions
|
||||
|
||||
A `VERIFIED` backup set, the backup encryption key, a PostgreSQL server of a compatible major
|
||||
version, and an empty destination database. A restore may never target the database the running
|
||||
control plane is using; that is refused with `DESTINATION_NOT_ISOLATED`.
|
||||
|
||||
## Act
|
||||
|
||||
1. Verify the backup again — `POST /api/v1/admin/recovery/backups/{id}/verify`. Never restore from
|
||||
a set you have not just verified.
|
||||
2. Create a restore plan with the destination, artifact strategy, secret strategy and node
|
||||
strategy. Use `VALIDATION` into an isolated environment unless this is a real disaster.
|
||||
3. Run the preflight and read all eleven checks. Do not proceed on a failure; each failure code
|
||||
names a real precondition.
|
||||
4. Start the restore and advance it. The journal records every phase with its evidence and
|
||||
duration.
|
||||
5. Read the validation result: table, constraint and index counts, the fingerprint diff, the
|
||||
residual current-truth rows, the measured RPO and RTO, and the `READY` gate.
|
||||
|
||||
For a destructive replacement, use the operator CLI rather than the API:
|
||||
|
||||
```sh
|
||||
python -m modelforge_api.cli.dr verify-backup --backup-id <id>
|
||||
python -m modelforge_api.cli.dr plan-restore --backup-id <id> --target-url <dsn> \
|
||||
--target-label <label> --mode DISASTER_RECOVERY --target-environment PRODUCTION --reason "..."
|
||||
python -m modelforge_api.cli.dr validate-restore --plan-id <uuid>
|
||||
python -m modelforge_api.cli.dr run-restore --plan-id <uuid> --reason "..."
|
||||
```
|
||||
|
||||
## If it fails
|
||||
|
||||
`DESTINATION_NOT_EMPTY` on a retry is correct behaviour, not an obstacle: the previous attempt left
|
||||
data. Drop and recreate the destination database, then retry. Do not merge into a partially
|
||||
restored database.
|
||||
|
||||
`MANUAL_INTERVENTION_REQUIRED` means the restore reached a state that must not be resolved
|
||||
automatically. Read the journal, decide, and record the decision.
|
||||
|
||||
## Confirm
|
||||
|
||||
The operation reaches `READY` with every gate true, including `current_truth_reconciled`. Compare
|
||||
the fingerprint diff against the expected differences: the recovery reconciliation audit event, and
|
||||
whatever the live source wrote after the backup point. Anything else must be explained before the
|
||||
restore is accepted.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Runbook: full disaster recovery
|
||||
|
||||
The complete procedure when ModelForge must be rebuilt from a backup. Each step has its own runbook;
|
||||
this one gives the order and the gates between them.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need a `VERIFIED` backup set, the backup encryption key, access to the canonical Git repository,
|
||||
a PostgreSQL server of a compatible major version, and the operator API key you intend to use
|
||||
afterwards. Without the encryption key the restore cannot proceed, and no part of ModelForge can
|
||||
supply it — it is deliberately never written into a backup.
|
||||
|
||||
Run `python -m modelforge_api.cli.dr bundle --backup-id <id>` first. Everything below is listed
|
||||
there: the ModelForge commit and repository, the backup identity and manifest hash, the encryption
|
||||
key requirement, the host paths, the artifact recovery plan, the node enrolment requirement and the
|
||||
external dependencies.
|
||||
|
||||
## Order
|
||||
|
||||
1. **Verify the backup.** See `RUNBOOK_BACKUP_FAILURE.md` if it does not verify. Do not continue
|
||||
with an unverified backup; nothing downstream can compensate.
|
||||
2. **Rebuild the control plane.** `RUNBOOK_CONTROL_PLANE_LOSS.md`. Exact commit, provisioned
|
||||
database, supplied secrets.
|
||||
3. **Restore the database.** `RUNBOOK_DATABASE_RESTORE.md`. Empty destination, eleven preflight
|
||||
checks, journalled phases, `READY` gate.
|
||||
4. **Reconcile.** The restore clears current truth, suppresses restored firing alerts and reports
|
||||
in-flight lifecycle operations and migration cutovers by id and stage. Read that list. Lifecycle
|
||||
operations are rolled back conservatively from their immutable snapshots on the first start.
|
||||
Migration cutovers are *not* auto-resolved — external alias truth cannot be inferred after a
|
||||
crash — and must be reconciled through the typed adapter.
|
||||
5. **Recover the node.** `RUNBOOK_NODE_LOSS.md`. Until a live agent reports, the platform has node
|
||||
identities but no telemetry, and that is correct.
|
||||
6. **Recover artifacts.** `RUNBOOK_ARTIFACT_LOSS.md`. Rehydratable sets first; non-rehydratable sets
|
||||
need their payload backup.
|
||||
7. **Validate.** Health, OpenAPI, operator auth, registry, lifecycle, migration, observability,
|
||||
scheduler reconciliation, capability smoke.
|
||||
8. **Take a new backup immediately** and verify it. A recovered deployment has no recovery point of
|
||||
its own until it does, and the dashboard will say so.
|
||||
|
||||
## Recovery is complete only when
|
||||
|
||||
```text
|
||||
database validated schema at head
|
||||
API healthy scheduler reconciled against re-measured truth
|
||||
node status understood critical artifact coverage known
|
||||
lifecycle and migration state valid audit history available
|
||||
no unknown corruption a new verified backup exists
|
||||
```
|
||||
|
||||
## What recovery must not touch
|
||||
|
||||
ExampleRAG's Qdrant collections and production alias, ExampleVision's recognizer and
|
||||
`VISION_AUTO_ACCEPT_ENABLED`, external Ollama, Plex and Tdarr workloads, and any GPU work ModelForge
|
||||
does not own. ModelForge records these as external dependencies and writes to none of them. If a
|
||||
recovery procedure appears to require changing one of them, stop and escalate — it is the wrong
|
||||
procedure.
|
||||
|
||||
## Declaring completion
|
||||
|
||||
Record the measured RPO and RTO with the phase breakdown, the fingerprint comparison and its
|
||||
explained differences, and the artifact and node recovery evidence. An operator declares recovery
|
||||
complete; the platform reports measurements and deliberately refuses to declare it for you.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Runbook: gateway errors or capability unavailable
|
||||
|
||||
## Trigger
|
||||
|
||||
`GATEWAY_ERROR_RATE`, `CAPABILITY_UNAVAILABLE`, a breached gateway SLO or fast burn.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Check valid population, sample minimum, short/long burn, capability, priority and bounded failure
|
||||
class. Separate authentication/rate-limit rejection from runtime, queue, scheduler and node failure.
|
||||
Use correlation IDs in structured logs; metrics contain no request content.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Acknowledge without marking the SLO healthy. Stop unsafe LAB traffic if it is the source; do not
|
||||
change production routing, model identity or project binding without M12 authority. Confirm a later
|
||||
rolling-window evaluation, normal request outcomes and explicit alert resolution. For
|
||||
`rag.embedding@1`, verify the ExampleRAG Ollama baseline remains unchanged.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Runbook: GPU pressure
|
||||
|
||||
## Trigger
|
||||
|
||||
`GPU_PRESSURE`, `CAPABILITY_CAPACITY`, stale capacity, or sustained `HIGH`/`CRITICAL` scheduler
|
||||
pressure.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Compare NVML observed, ModelForge resident, leases, reserve, external and schedulable bytes. Confirm
|
||||
freshness and inspect active placement/queue decisions. Ollama, Plex and Tdarr are external capacity;
|
||||
ModelForge must not kill, pause or reconfigure them.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Pause optional LAB/benchmark submissions through existing policy where justified. Let bounded QoS,
|
||||
queueing and managed-idle eviction operate. Never manufacture an OOM for diagnosis. Resolve only
|
||||
after fresh telemetry shows recovered headroom/pressure and queues drain without orphan leases.
|
||||
Escalate unexplained VRAM drift, repeated crash/OOM or managed residency that does not reclaim.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Runbook: migration or rollback failure
|
||||
|
||||
## Trigger
|
||||
|
||||
`MIGRATION_FAILURE`, `ROLLBACK_FAILURE`, `FAILED` or `MANUAL_INTERVENTION_REQUIRED`.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Review immutable plan/generation, adapter fingerprint, source/target identities, latest checkpoint,
|
||||
validation snapshot, cutover journal, external-state observation and rollback retention. Never guess
|
||||
external alias truth after a crash.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Pause further plan actions and acknowledge the alert. Resume only idempotent backfill after source
|
||||
and generation validation. Reconcile ambiguous cutover through the typed adapter. A rollback failure
|
||||
is CRITICAL and requires explicit operator intervention; do not mutate production aliases manually
|
||||
without preserving evidence. Recovery must retain the alert/timeline and prove exact final identity.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Runbook: permanently decommission a compute node
|
||||
|
||||
Use this only when hardware is permanently leaving ModelForge. For a temporary outage, lost
|
||||
credential or rebuild, use `RUNBOOK_NODE_LOSS.md`. Never use GPU Node or another production node for a
|
||||
rehearsal; enroll a disposable node identity.
|
||||
|
||||
## Prepare
|
||||
|
||||
1. Drain and retire every deployment, job, request, probe, lease and residency on the node.
|
||||
2. Move any sole artifact copy and finish recovery, lifecycle and migration operations.
|
||||
3. Stop the Node Agent and wait until liveness is `offline` or `stale`.
|
||||
4. Record the change owner and a durable reason. Obtain the operator API key; project credentials
|
||||
and node credentials are intentionally insufficient.
|
||||
|
||||
## Preview
|
||||
|
||||
In Console, open Compute Nodes, select the node and use **Preview decommission** in the danger zone.
|
||||
Alternatively:
|
||||
|
||||
```text
|
||||
POST /api/v1/admin/hardware/nodes/{node_id}/decommission/preview
|
||||
X-ModelForge-Admin-Token: <operator key>
|
||||
```
|
||||
|
||||
Resolve every returned blocker. Do not attempt to bypass a blocker in the database. Confirm the
|
||||
preview names the intended persisted identity and lists both cleanup and retained history.
|
||||
|
||||
## Execute
|
||||
|
||||
Type the displayed persisted identity, hostname or display name exactly. Supply the preview's
|
||||
`node_generation` and `dependency_digest`, a fresh idempotency key, operator and reason to:
|
||||
|
||||
```text
|
||||
POST /api/v1/admin/hardware/nodes/{node_id}/decommission
|
||||
```
|
||||
|
||||
A 409 means state changed or a blocker remains: generate a fresh preview. Repeating a completed
|
||||
request is safe and returns the existing operation; it does not create another audit event.
|
||||
|
||||
## Confirm
|
||||
|
||||
- node status and liveness are `decommissioned`, all eligibility is false and current inventory is
|
||||
empty;
|
||||
- all old node credentials return 401 and rotation/ordinary re-enrollment are refused;
|
||||
- scheduler/current telemetry rows are absent, accelerators are decommissioned and storage roots
|
||||
are unavailable/read-only;
|
||||
- historical inventory, jobs, probes, deployments and audit evidence remain queryable;
|
||||
- exactly one `NODE_DECOMMISSIONED` audit event and one operation record exist;
|
||||
- the `decommissioned_nodes_are_terminal` invariant holds.
|
||||
|
||||
There is no undo button. A later physical host must enroll with a new persisted identity unless a
|
||||
future explicit, audited recovery lifecycle is implemented.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Runbook: node loss or credential loss
|
||||
|
||||
## Trigger
|
||||
|
||||
A compute node stops publishing, its credential is lost or believed stolen, or a node must be
|
||||
rebuilt after a control-plane restore.
|
||||
|
||||
This runbook is for recoverable loss. If hardware is permanently leaving service, use
|
||||
`RUNBOOK_NODE_DECOMMISSION.md`; do not disable or delete the row by hand.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Distinguish the three failures, because they have different recoveries:
|
||||
|
||||
| Symptom | Meaning |
|
||||
| --- | --- |
|
||||
| heartbeats 401 | the credential is revoked or does not match a stored hash |
|
||||
| heartbeats 403 | the credential authenticates but is not authorised for that node |
|
||||
| no requests at all | the agent is down, or cannot reach the control plane |
|
||||
|
||||
Check `node_credentials` for the node: how many are active, how many revoked, and when the active
|
||||
one was last used. Only hashes are stored; the plaintext cannot be read back from anywhere.
|
||||
|
||||
## Act and recover
|
||||
|
||||
**The node still has its credential.** Nothing to do. After a database restore the restored hash
|
||||
matches and the node reconnects on its own.
|
||||
|
||||
**The credential is lost.** Issue a fresh enrolment token
|
||||
(`POST /api/v1/admin/node-enrollments`) and restart the agent with it. The agent keeps its persisted
|
||||
identity file, so it re-enrols onto the same node id with its accelerators, storage roots and
|
||||
history intact. Enrolment revokes the previous credential automatically.
|
||||
|
||||
**The credential is believed stolen.** Revoke it first
|
||||
(`DELETE /api/v1/admin/hardware/nodes/{node_id}/credential`), then re-enrol. Revocation is
|
||||
permanent: the old credential stays refused after re-enrolment.
|
||||
|
||||
**The identity file is also lost.** The agent enrols as a new node, because ModelForge genuinely
|
||||
cannot tell it apart from different hardware. If the old identity is permanently superseded, use
|
||||
the audited decommission preview/execute flow rather than deleting or hand-editing it, and confirm
|
||||
only one enabled node claims that hostname.
|
||||
|
||||
One enrolment token mints exactly one node identity. If two nodes appear from one token, the control
|
||||
plane predates the M15 atomic-claim fix.
|
||||
|
||||
## Confirm
|
||||
|
||||
Exactly one enabled node for that hardware, one active credential with the old ones revoked,
|
||||
heartbeats returning 200, inventory and telemetry re-collected, the scheduler reconciled against
|
||||
freshly measured NVML state rather than a restored snapshot, and a capability smoke against the
|
||||
recovered node.
|
||||
|
||||
Never revoke a production node credential merely to rehearse this. Use a disposable agent identity;
|
||||
`docker-compose.node-recovery.yml` exists for exactly that.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Runbook: production node offline or stale
|
||||
|
||||
## Trigger
|
||||
|
||||
`NODE_OFFLINE` is pending/firing, or GPU Node freshness is `STALE`/over 90 seconds.
|
||||
|
||||
## Diagnose
|
||||
|
||||
1. Confirm API and Operations view timestamps; do not infer current health from an old snapshot.
|
||||
2. Inspect Node Agent liveness, last server-received heartbeat/telemetry and credential state.
|
||||
3. Check network/TLS reachability from GPU Node outward and the Node Agent service logs.
|
||||
4. Review the incident for capability alerts correlated as `DOWNSTREAM`.
|
||||
5. Verify ExampleRAG's external Ollama production baseline independently.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Acknowledge with an operator reason. Restart the Node Agent only through the established host
|
||||
runbook and only after checking unrelated workloads; never stop the GPU Node host. Do not promote a
|
||||
fallback model. Recovery requires new server-received telemetry, online liveness, resolved alerts
|
||||
and a retained timeline. Escalate if the credential is rejected, inventory identity changed or
|
||||
freshness oscillates.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Runbook: runtime failure or crash loop
|
||||
|
||||
## Trigger
|
||||
|
||||
`RUNTIME_CRASH_LOOP`, capability-unavailable alerts, failed load/invoke/unload/health jobs or
|
||||
runtime-success SLO degradation.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Inspect bounded failure code, exact deployment/artifact/runtime identities, worker health, queue,
|
||||
leases, residency and NVML freshness. Confirm offline execution and `trust_remote_code=false` remain
|
||||
in effect. Distinguish model failure from node/GPU pressure.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Do not send shell commands through ModelForge or expose the worker. Use the established isolated
|
||||
runtime probe/LAB path for reproduction. Production rollback requires an approved M12 plan; M14 does
|
||||
not perform it. Recovery requires successful typed health/invoke evidence, no orphan lease and an
|
||||
explicit resolved alert.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Runbook: model storage low
|
||||
|
||||
## Trigger
|
||||
|
||||
`STORAGE_LOW` fires because usable free bytes after absolute/percentage reserve are below policy or
|
||||
unknown.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Confirm the registered root, observed time, writable status, total/free bytes and reserve. Inspect
|
||||
active artifact locations, acquisition quarantine, rollback retention and migration dependencies.
|
||||
Never treat an unavailable capacity reading as healthy.
|
||||
|
||||
## Act and recover
|
||||
|
||||
Use dependency-aware M12 cleanup planning and explicit confirmation. Never issue ad-hoc recursive
|
||||
deletion or remove retained rollback artifacts. Reclaim only reviewed candidates, then collect a
|
||||
fresh storage snapshot and confirm the alert resolves. Escalate filesystem errors, divergent agent
|
||||
paths or repeated unexplained growth.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Runtime Probes
|
||||
|
||||
## Operator flow
|
||||
|
||||
1. Review the exact ArtifactSet and static assessment.
|
||||
2. Record exact-set LAB execution approval.
|
||||
3. Queue the typed fixed-input probe for a selected node.
|
||||
4. Follow preparing/load/health/ready/unload progress.
|
||||
5. Inspect technical output and measured resource evidence.
|
||||
|
||||
The sole M4 embedding input is `ModelForge runtime compatibility probe`. It is retained only as a
|
||||
SHA-256 in runtime results. This is a technical smoke probe, not a benchmark or quality evaluation.
|
||||
|
||||
## Measurements
|
||||
|
||||
NVML samples are captured before load, after load, around inference and after child-process exit.
|
||||
Stored evidence includes absolute used/free VRAM, delta above baseline, peak, GPU utilization,
|
||||
temperature, power, load time, inference latency, batch and maximum sequence length. Reclamation is
|
||||
checked against the measured baseline plus a bounded tolerance.
|
||||
|
||||
## Failure and cancellation
|
||||
|
||||
Failures are typed (`MODEL_LOAD_FAILED`, `GPU_OOM`, `RUNTIME_CRASH`, `TIMEOUT`, `INVALID_OUTPUT`,
|
||||
`HEALTHCHECK_FAILED`, `OFFLINE_LOAD_VIOLATION`, `GPU_MEMORY_NOT_RECLAIMED`, and related gate
|
||||
codes). Unexpected exception text is normalized at the child boundary. Cancellation terminates the
|
||||
child, runs reclamation checks, reports `cancelled`, and never creates a candidate.
|
||||
|
||||
## Offline guarantee
|
||||
|
||||
The image sets Hugging Face/Transformers offline variables. All loads use the exact local path,
|
||||
`local_files_only=true` and `trust_remote_code=false`; an in-process socket guard rejects IP network
|
||||
connections while model loading and inference run. No duplicate model download is permitted.
|
||||
|
||||
## Logs and worker identity
|
||||
|
||||
Every claimed attempt receives a bounded `runtime-worker://<node>/<probe>/attempt/<n>` log
|
||||
reference. The probe, node, lease attempt and child-process lifecycle together identify the worker
|
||||
without exposing a general process-control API. Verbose runtime output is not stored in the probe
|
||||
row; technical input is represented by its hash, credentials are redacted, and audit events remain
|
||||
separate from diagnostic logs.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Remote Server Agent Deployment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The remote server needs Docker Engine with Compose v2, an NVIDIA driver, NVIDIA Container Toolkit,
|
||||
and outbound HTTPS access to the central ModelForge API. It needs no inbound SSH path from
|
||||
ModelForge, no public agent port, no Docker socket mount and no privileged container.
|
||||
|
||||
The container deliberately uses a persisted UUID because host `/etc/machine-id` is not mounted.
|
||||
Hostname/OS/CPU/RAM describe the agent's Linux execution environment; without container resource
|
||||
limits this normally represents the server, while configured CPU/memory limits represent the
|
||||
capacity actually available to future workloads. Storage facts cover only the named ModelForge data
|
||||
volumes. This avoids unsafe host-root mounts and keeps the semantics operationally honest.
|
||||
|
||||
The Compose service drops all Linux capabilities, enables `no-new-privileges`, uses a read-only root
|
||||
filesystem and opens only a bounded in-memory `/tmp`. It publishes no port. Set
|
||||
`MODELFORGE_AGENT_HOSTNAME` to the real compute-node hostname. On Unraid or another appliance, set
|
||||
the four `MODELFORGE_AGENT_*_VOLUME` variables to narrow, dedicated absolute bind paths when state
|
||||
must survive Docker image/volume recreation; never point one at the host root.
|
||||
|
||||
Expose the control-plane API through a trusted HTTPS reverse proxy. Use an internal CA by installing
|
||||
that CA in the agent image/host trust store; do not disable TLS verification outside an isolated
|
||||
local smoke test.
|
||||
|
||||
For a private CA that should be trusted only by ModelForge, combine the base file with
|
||||
`docker-compose.node-agent.private-ca.yml`. Set `MODELFORGE_AGENT_CA_CERT_PATH` to the public CA
|
||||
certificate, plus `MODELFORGE_AGENT_CONTROL_PLANE_HOST_ALIAS` and
|
||||
`MODELFORGE_AGENT_CONTROL_PLANE_HOST_ADDRESS` when private DNS is unavailable. The overlay mounts
|
||||
only that public certificate read-only and scopes the host mapping to the agent container.
|
||||
|
||||
## 1. Configure the control plane
|
||||
|
||||
Set a high-entropy `MODELFORGE_OPERATOR_API_KEY`, keep `MODELFORGE_HARDWARE_REFRESH_ON_STARTUP=false`,
|
||||
and start the base stack. Create an enrollment token from **Compute nodes → Enroll node**, or:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://modelforge.example/api/v1/admin/node-enrollments \
|
||||
-H "X-ModelForge-Admin-Token: $MODELFORGE_OPERATOR_API_KEY" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"expires_in_seconds":900,"display_name":"server01","role":"primary-inference","labels":{"site":"office"},"production_eligible":false,"lab_eligible":true,"benchmark_eligible":true}'
|
||||
```
|
||||
|
||||
The response shows the enrollment secret once. Leave `production_eligible` false until the actual
|
||||
node has passed hardware, identity, recovery and security acceptance. Eligibility is scheduler
|
||||
metadata; setting it true does not launch a runtime or bypass later promotion evidence.
|
||||
|
||||
## 2. Start the agent on the GPU server
|
||||
|
||||
Copy/clone this repository to the server. Create `.env` without committing it:
|
||||
|
||||
```dotenv
|
||||
MODELFORGE_AGENT_CONTROL_PLANE_URL=https://modelforge.example
|
||||
MODELFORGE_AGENT_ENROLLMENT_TOKEN=<one-time-token>
|
||||
MODELFORGE_AGENT_DISPLAY_NAME=Server 01
|
||||
MODELFORGE_AGENT_HOSTNAME=server01
|
||||
MODELFORGE_AGENT_TLS_VERIFY=true
|
||||
MODELFORGE_AGENT_STATE_VOLUME=/mnt/user/appdata/modelforge-node-agent/state
|
||||
MODELFORGE_AGENT_HF_CACHE_VOLUME=/mnt/cache/appdata/modelforge-node-agent/hf-cache
|
||||
MODELFORGE_AGENT_ARTIFACT_VOLUME=/mnt/cache/appdata/modelforge-node-agent/artifacts
|
||||
MODELFORGE_AGENT_QUARANTINE_VOLUME=/mnt/cache/appdata/modelforge-node-agent/quarantine
|
||||
MODELFORGE_HF_TOKEN=<optional-secret-for-gated-repositories>
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.node-agent.yml up -d --build
|
||||
docker compose -f docker-compose.node-agent.yml logs --tail=100 node-agent
|
||||
```
|
||||
|
||||
With the private-CA overlay, use both files for every lifecycle command:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.node-agent.yml \
|
||||
-f docker-compose.node-agent.private-ca.yml up -d --build
|
||||
```
|
||||
|
||||
After the node appears online, remove `MODELFORGE_AGENT_ENROLLMENT_TOKEN` from `.env` and recreate
|
||||
the service. The node-scoped credential persists in `node-agent-state`:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.node-agent.yml up -d --force-recreate node-agent
|
||||
```
|
||||
|
||||
Do not delete the state volume during ordinary upgrades; doing so discards identity and credential.
|
||||
Use the UI/API to revoke the old credential before deliberately re-enrolling.
|
||||
|
||||
For M3, register the host cache path as a storage root and map it to
|
||||
`/data/artifacts/model-registry`. The host path and container path are deliberately separate facts.
|
||||
Never substitute `/mnt/user` when the cache capacity guard fails. The agent advertises
|
||||
`artifact.acquire.v1`, polls one typed job at a time, and retains job-scoped partials below the
|
||||
approved root for bounded retry. Keep the optional Hub token only in secret environment/config;
|
||||
do not place it in Compose files or logs.
|
||||
|
||||
## 3. Verify and operate
|
||||
|
||||
Confirm the UI shows the expected hostname, agent/protocol version, heartbeat age, CPU/RAM/storage,
|
||||
GPU UUID/model/VRAM/driver and measured telemetry. Restart the agent and confirm node and accelerator
|
||||
UUIDs remain unchanged. Stop it long enough to observe `stale` then `offline`, restart it and confirm
|
||||
`online` plus a return audit event.
|
||||
|
||||
For temporary outages the agent retains its identity/credential and retries with bounded exponential
|
||||
backoff. Rotate with the authenticated admin rotation endpoint, securely replace
|
||||
`/data/state/node-credential` with its one-time response, and recreate the agent. Revoke without a
|
||||
replacement when a credential may be compromised. Disable a node to make it ineligible and immediately
|
||||
reject further publications. Upgrades are ordinary image
|
||||
rebuild/recreate operations; protocol-incompatible agents are visibly rejected instead of silently
|
||||
downgraded.
|
||||
|
||||
## RTX 4080 acceptance checklist
|
||||
|
||||
On the actual target server, capture: `nvidia-smi` model/UUID/driver output, node-agent container
|
||||
status, `/api/v1/hardware/nodes/{id}` output with secrets removed, a restart identity comparison,
|
||||
stale/offline/recovery evidence, and UI screenshots. The release verdict may not claim RTX 4080
|
||||
validation until these observations exist.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Soak testing
|
||||
|
||||
## What a soak is for
|
||||
|
||||
A short run proves a path works. A soak proves it keeps working: that memory does not climb, that
|
||||
leases are released, that queues drain, that retention keeps row growth bounded, and that latency
|
||||
does not degrade as caches warm and evict.
|
||||
|
||||
```sh
|
||||
python scripts/m16_soak.py --database-url "$MODELFORGE_RUNTIME_DATABASE_URL" --minutes 45 --secret <gateway-secret> --report soak.json
|
||||
```
|
||||
|
||||
The database URL is mandatory and must use the non-owner runtime role. The harness contains no
|
||||
embedded credential or owner-role fallback.
|
||||
|
||||
## Workload shape
|
||||
|
||||
Three gateway workers drive the real capability path — `rag.embedding@1` through the authenticated
|
||||
gateway to GPU Node — mixing three phases:
|
||||
|
||||
```text
|
||||
burst 3-6 requests back to back, 50-400 ms apart
|
||||
steady single requests, 1.5-5 s apart
|
||||
idle 8-25 s of nothing
|
||||
```
|
||||
|
||||
The mix matters. A soak that only hammers never exercises keep-warm and unload, so it never
|
||||
observes a cold start. A soak that only idles never exercises admission control, so it never
|
||||
observes a capacity rejection. Batch sizes vary from one to four inputs so payload handling varies
|
||||
too.
|
||||
|
||||
Two read workers poll registry, projects, hardware, health, operations, alerts, lifecycle,
|
||||
migrations and recovery. One background worker drives the operational work the platform already
|
||||
performs for itself — capacity collection, SLO evaluation, alert evaluation — at a bounded rate.
|
||||
|
||||
## What is measured
|
||||
|
||||
```text
|
||||
per kind requests, succeeded, capacity rejected, internal errors, status distribution
|
||||
latency p50, p95, p99, mean, max
|
||||
gateway cold starts, warm invocations, queue p95, inference p50 and p95, failure codes
|
||||
resources container memory, CPU, PIDs and restart counts, before and after
|
||||
database row growth per table, connection count
|
||||
runtime active leases, serving jobs in flight, residency allocations
|
||||
GPU used, free and utilisation per accelerator, plus scheduler pressure
|
||||
invariants the full set, at every progress interval
|
||||
```
|
||||
|
||||
Capacity rejections are counted separately from internal errors. A 429 or a `QUEUE_FULL` is the
|
||||
scheduler refusing unsafe work, which is the platform behaving correctly; counting it as a failure
|
||||
would reward a system that over-admits.
|
||||
|
||||
## Stopping early
|
||||
|
||||
The soak aborts and reports partial evidence if an invariant is violated at any progress interval.
|
||||
Partial evidence honestly labelled is worth more than a full run whose result is already known to
|
||||
be invalid.
|
||||
|
||||
It should also be aborted manually on uncontrolled host pressure, degradation of an external
|
||||
workload, repeated OOM, data corruption or any unsafe production mutation.
|
||||
|
||||
## Reading the result
|
||||
|
||||
```text
|
||||
queue drained queued, running and active leases all zero at the end,
|
||||
unless a resident is intentionally kept warm
|
||||
memory a flat or sawtooth profile is healthy; monotonic growth is investigated
|
||||
row growth bounded by retention and aggregation, not linear in request count
|
||||
restart counts unchanged, unless a restart was deliberately injected
|
||||
```
|
||||
|
||||
## Honesty rules
|
||||
|
||||
Report the duration actually achieved. A 45-minute soak is a 45-minute soak; it is not evidence
|
||||
about a week.
|
||||
|
||||
If a chaos scenario or an infrastructure change overlapped the run, say so and say which numbers it
|
||||
affected, rather than presenting the mixed result as a clean baseline. The M16 evidence includes one
|
||||
soak that was discarded for exactly this reason after container hardening restarted its datastores
|
||||
mid-run.
|
||||
|
||||
The disposable gateway client the soak uses is revoked afterwards, and the fixtures it created are
|
||||
removed.
|
||||
|
||||
## Measuring latency honestly
|
||||
|
||||
The soak reports percentiles, so the harness must first prove that it is not the thing being
|
||||
measured. Before any worker starts it times a plain TCP connect to the target and refuses to run if
|
||||
the median exceeds `MAX_CONNECT_FLOOR_MS` (100 ms), printing the floor it measured and recording it
|
||||
in the report as `client_connect_floor_ms`.
|
||||
|
||||
This exists because the first M16 soak reported a p50 near two seconds on every route including
|
||||
`/api/v1/health/live`, with only a few milliseconds of spread. A constant with no tail is never queueing.
|
||||
The cause was the harness addressing the API as `localhost`, which resolves to `::1` first on this
|
||||
host; each request waited out the IPv6 connect timeout before falling back to IPv4, adding a fixed
|
||||
~2,045 ms of client cost to every sample. Direct measurement, both returning 200: 2,043.3 ms median
|
||||
to `http://localhost:8000/api/v1/health/live` against 4.7 ms to
|
||||
`http://127.0.0.1:8000/api/v1/health/live`.
|
||||
|
||||
Address the platform by an address that resolves directly. Both M16 harnesses default to
|
||||
`http://127.0.0.1:8000` for this reason, not to `localhost`.
|
||||
Reference in New Issue
Block a user