generated from coulomb/repo-seed
feat: TTL enforcement and operational hardening (SAND-WP-0009)
Add TTL parser, expires_at on create, extend_ttl and expire/reap APIs, activity-core integration doc, repo classification, registry refresh, HTTP parity, and 69 tests.
This commit is contained in:
25
.repo-classification.yaml
Normal file
25
.repo-classification.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-24'
|
||||||
|
classified_by: codex
|
||||||
|
category: tooling
|
||||||
|
domain: infotech
|
||||||
|
secondary_domains:
|
||||||
|
- agents
|
||||||
|
capability_tags:
|
||||||
|
- sandbox
|
||||||
|
- isolation
|
||||||
|
- provision
|
||||||
|
- execution
|
||||||
|
- orchestration
|
||||||
|
business_stake:
|
||||||
|
- technology
|
||||||
|
- execution
|
||||||
|
- automation
|
||||||
|
business_mechanics:
|
||||||
|
- operation
|
||||||
|
- coordination
|
||||||
|
notes: >
|
||||||
|
Sandbox establishment meta-framework — profiles, extensions, routing,
|
||||||
|
lifecycle, TTL, and host telemetry for agentic and deterministic workloads.
|
||||||
15
SCOPE.md
15
SCOPE.md
@@ -44,7 +44,7 @@ orchestration from `create` remains deferred.
|
|||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
- **Unified establishment API** — CLI v0 + HTTP stub (`create`, `get`, `list`,
|
- **Unified establishment API** — CLI v0 + HTTP stub (`create`, `get`, `list`,
|
||||||
`destroy`, `recreate`, `snapshot`, `restore`); `extend_ttl` planned
|
`destroy`, `recreate`, `snapshot`, `restore`, `extend-ttl`, `expire`)
|
||||||
- **Profile catalog** — six profiles: compose e2e/checkpoint, sandbox canary,
|
- **Profile catalog** — six profiles: compose e2e/checkpoint, sandbox canary,
|
||||||
vm-haskell-build, saas-stub, burst-sandbox
|
vm-haskell-build, saas-stub, burst-sandbox
|
||||||
- **Extension platform** — `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`;
|
- **Extension platform** — `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`;
|
||||||
@@ -128,12 +128,12 @@ own tunnels or CAs.
|
|||||||
- **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`,
|
- **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`,
|
||||||
`payments`, `snapshots`, `migration-gaps`, `migration-build-machines`
|
`payments`, `snapshots`, `migration-gaps`, `migration-build-machines`
|
||||||
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
||||||
- **Tests:** 54 pytest cases; `make check` green
|
- **Tests:** 69 pytest cases; `make check` green
|
||||||
- **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian
|
- **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian
|
||||||
`make e2e REPO=` shim (SAND-WP-0004)
|
`make e2e REPO=` shim (SAND-WP-0004)
|
||||||
|
|
||||||
Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||||
Next workplan: **SAND-WP-0009** (TTL enforcement and operational hardening).
|
Latest workplan: **SAND-WP-0009** (TTL enforcement — finished).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,6 +149,9 @@ sandboxer get <id> / list / destroy / recreate
|
|||||||
sandboxer snapshot <id> [--name LABEL]
|
sandboxer snapshot <id> [--name LABEL]
|
||||||
sandboxer restore <snapshot_id>
|
sandboxer restore <snapshot_id>
|
||||||
sandboxer snapshots list / snapshots get <id>
|
sandboxer snapshots list / snapshots get <id>
|
||||||
|
sandboxer extend-ttl <id> --duration 2h
|
||||||
|
sandboxer expire [--apply]
|
||||||
|
sandboxer create --ttl 2h ...
|
||||||
sandboxer credits show / credits add <amount>
|
sandboxer credits show / credits add <amount>
|
||||||
sandboxer inspect host / inspect stale / reap-stale [--apply]
|
sandboxer inspect host / inspect stale / reap-stale [--apply]
|
||||||
make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST)
|
make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST)
|
||||||
@@ -168,14 +171,14 @@ cd ~/the-custodian && make e2e REPO=activity-core
|
|||||||
|
|
||||||
## What Is Not Possible Yet
|
## What Is Not Possible Yet
|
||||||
|
|
||||||
- TTL auto-expiry / `extend_ttl` enforcement
|
- ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009)
|
||||||
- Packer build orchestration from `create` (attach-only today)
|
- Packer build orchestration from `create` (attach-only today)
|
||||||
- Real E2B / Modal / Daytona adapters (in-repo stub only)
|
- Real E2B / Modal / Daytona adapters (in-repo stub only)
|
||||||
- Cross-host snapshot transfer
|
- Cross-host snapshot transfer
|
||||||
- Formal ops-bridge tunnel attachment in reachability descriptor
|
- Formal ops-bridge tunnel attachment in reachability descriptor
|
||||||
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
||||||
- `reuse-surface validate` / federation publish workflow
|
- `reuse-surface validate` / federation publish workflow
|
||||||
- `.repo-classification.yaml` (State Hub C-24 hygiene)
|
- ~~`.repo-classification.yaml`~~ — done (SAND-WP-0009)
|
||||||
- fin-hub billing export for metered usage
|
- fin-hub billing export for metered usage
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -239,6 +242,8 @@ see `registry/capabilities/execution.sandbox-provision.md`.
|
|||||||
| `docs/routing.md` | Backend selection strategies |
|
| `docs/routing.md` | Backend selection strategies |
|
||||||
| `docs/payments.md` | Credits and metering |
|
| `docs/payments.md` | Credits and metering |
|
||||||
| `docs/snapshots.md` | Checkpoint snapshot/restore |
|
| `docs/snapshots.md` | Checkpoint snapshot/restore |
|
||||||
|
| `docs/ttl.md` | TTL extend and expire/reap |
|
||||||
|
| `docs/security.md` | Blast-radius vs intent enforcement |
|
||||||
| `docs/migration-gaps.md` | Legacy cutover status |
|
| `docs/migration-gaps.md` | Legacy cutover status |
|
||||||
| `docs/integrations/` | Consumer contracts |
|
| `docs/integrations/` | Consumer contracts |
|
||||||
| `workplans/` | ADR-001 work structure |
|
| `workplans/` | ADR-001 work structure |
|
||||||
|
|||||||
42
docs/integrations/activity-core.md
Normal file
42
docs/integrations/activity-core.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# activity-core integration
|
||||||
|
|
||||||
|
activity-core schedules bounded work on Railiance01. sand-boxer provides
|
||||||
|
**sandbox venues** with TTL enforcement; activity-core owns **when** expire runs.
|
||||||
|
|
||||||
|
## Scheduled TTL reap
|
||||||
|
|
||||||
|
Run periodically (cron, Temporal activity, or CI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandboxer expire --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP equivalent:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/sandboxes/expire?apply=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a list of `ExpireActionResult` entries (`dry-run`, `destroyed`, `failed`).
|
||||||
|
|
||||||
|
## Lifecycle events
|
||||||
|
|
||||||
|
Each expired sandbox emits a State Hub progress event:
|
||||||
|
|
||||||
|
- `state`: `expired` (`event_type`: `milestone`)
|
||||||
|
- Followed by `destroying` → `destroyed`
|
||||||
|
|
||||||
|
Event `detail` includes `ttl`, `expires_at`, and reachability fields.
|
||||||
|
|
||||||
|
## What sand-boxer does not do
|
||||||
|
|
||||||
|
- No Temporal workflows or activity-core code in this repo
|
||||||
|
- No push webhook to activity-core on expiry (poll/schedule only in v0)
|
||||||
|
- TTL parsing and destroy orchestration live in sand-boxer
|
||||||
|
|
||||||
|
## Consumer pattern
|
||||||
|
|
||||||
|
1. activity-core activity provisions via `sandboxer create` (or HTTP)
|
||||||
|
2. Work runs in the sandbox (glas-harness, wise-validator, etc.)
|
||||||
|
3. Scheduled `sandboxer expire --apply` reaps past-TTL sandboxes
|
||||||
|
4. State Hub records full lifecycle for audit
|
||||||
@@ -82,7 +82,7 @@ Extends the `build-agent` self-register pattern: generic sandbox identities carr
|
|||||||
| `create` | Provision from profile + inputs | **Yes** |
|
| `create` | Provision from profile + inputs | **Yes** |
|
||||||
| `get` | Inspect sandbox status | **Yes** |
|
| `get` | Inspect sandbox status | **Yes** |
|
||||||
| `list` | List sandboxes (filter by consumer optional) | **Yes** |
|
| `list` | List sandboxes (filter by consumer optional) | **Yes** |
|
||||||
| `extend_ttl` | Extend time-to-live | Stub |
|
| `extend_ttl` | Extend time-to-live | **Yes** |
|
||||||
| `recreate` | Destroy and reprovision from stored seed | **Yes** |
|
| `recreate` | Destroy and reprovision from stored seed | **Yes** |
|
||||||
| `destroy` | Idempotent teardown | **Yes** |
|
| `destroy` | Idempotent teardown | **Yes** |
|
||||||
| `snapshot` / `restore` | Checkpoint workspace | **Yes** (compose-ssh, saas-stub) |
|
| `snapshot` / `restore` | Checkpoint workspace | **Yes** (compose-ssh, saas-stub) |
|
||||||
@@ -97,6 +97,9 @@ HTTP surface (optional v0; CLI calls core library directly):
|
|||||||
- `POST /v1/sandboxes/{id}/snapshot` — checkpoint
|
- `POST /v1/sandboxes/{id}/snapshot` — checkpoint
|
||||||
- `POST /v1/snapshots/{id}/restore` — restore
|
- `POST /v1/snapshots/{id}/restore` — restore
|
||||||
- `GET /v1/snapshots` — list checkpoints
|
- `GET /v1/snapshots` — list checkpoints
|
||||||
|
- `POST /v1/sandboxes/{id}/recreate` — recreate
|
||||||
|
- `PATCH /v1/sandboxes/{id}/ttl` — extend TTL
|
||||||
|
- `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ Deferred: Packer orchestration from API, `make remote-build` shim.
|
|||||||
| ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits |
|
| ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits |
|
||||||
| E2B / Modal real adapters | Post SAND-WP-0006 |
|
| E2B / Modal real adapters | Post SAND-WP-0006 |
|
||||||
| ~~Snapshot / restore~~ | SAND-WP-0007 — `docs/snapshots.md` |
|
| ~~Snapshot / restore~~ | SAND-WP-0007 — `docs/snapshots.md` |
|
||||||
| TTL enforcement + scheduled reap | **SAND-WP-0009** |
|
| ~~TTL enforcement + scheduled reap~~ | SAND-WP-0009 — `docs/ttl.md` |
|
||||||
23
docs/security.md
Normal file
23
docs/security.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Security posture
|
||||||
|
|
||||||
|
sand-boxer limits **blast radius** — it does not enforce **intent**.
|
||||||
|
|
||||||
|
## What sandboxing provides
|
||||||
|
|
||||||
|
- Isolated compose projects and workspace directories on placement hosts
|
||||||
|
- Profile-declared network default-deny (declarative in v0; enforcement varies by extension)
|
||||||
|
- TTL-bound disposable venues with automated expire/reap
|
||||||
|
- Consumer attribution (`adm` / `agt` / `atm`) on lifecycle events
|
||||||
|
|
||||||
|
## What sandboxing does not provide
|
||||||
|
|
||||||
|
- Protection against a malicious or compromised agent *inside* the sandbox
|
||||||
|
- Guarantee that an agent follows instructions or policy
|
||||||
|
- Replacement for secrets management (use OpenBao / operator paths via `warden route`)
|
||||||
|
- Production isolation on Railiance01 (sandboxes run on sandboxer01 / CoulombCore)
|
||||||
|
|
||||||
|
Per INTENT: *"Honest security — sandboxing limits blast radius; it is not intent
|
||||||
|
enforcement."*
|
||||||
|
|
||||||
|
Operators should combine sand-boxer with flex-auth, credential routing, and
|
||||||
|
harness-level controls for end-to-end safety.
|
||||||
65
docs/ttl.md
Normal file
65
docs/ttl.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Time-to-live (TTL)
|
||||||
|
|
||||||
|
Disposable-by-default sandboxes — SAND-WP-0009.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
Each ready sandbox has:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `ttl` | Active duration string (e.g. `4h`) |
|
||||||
|
| `expires_at` | UTC timestamp when the sandbox should be reaped |
|
||||||
|
|
||||||
|
On `create`, TTL comes from `SandboxCreateRequest.ttl` or the profile
|
||||||
|
`ttl.default`, capped at `ttl.max`. Anchor is `ready_at`.
|
||||||
|
|
||||||
|
Duration format: positive integer + unit — `s`, `m`, `h`, `d` (e.g. `30m`, `4h`).
|
||||||
|
|
||||||
|
## extend_ttl
|
||||||
|
|
||||||
|
Extend a live sandbox (`ready` or `active`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandboxer extend-ttl <sandbox_id> --duration 2h
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP: `PATCH /v1/sandboxes/{id}/ttl` with `{"duration": "2h"}`.
|
||||||
|
|
||||||
|
Extension adds to the current `expires_at` (or now if already past). Total lifetime
|
||||||
|
from `ready_at` cannot exceed profile `ttl.max`.
|
||||||
|
|
||||||
|
## expire / reap
|
||||||
|
|
||||||
|
TTL reap is distinct from host inventory `reap-stale`:
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `sandboxer expire` | Sandboxes past `expires_at` or profile `ttl.idle_reap` |
|
||||||
|
| `sandboxer reap-stale` | Orphan host resources vs store inventory |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandboxer expire # dry-run (default)
|
||||||
|
sandboxer expire --apply # mark expired, destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP: `POST /v1/sandboxes/expire?apply=true`
|
||||||
|
|
||||||
|
Flow on `--apply`:
|
||||||
|
|
||||||
|
1. Transition to `expired` (State Hub milestone)
|
||||||
|
2. `destroy` (idempotent teardown)
|
||||||
|
|
||||||
|
## Profile fields
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ttl:
|
||||||
|
default: 4h
|
||||||
|
max: 24h
|
||||||
|
idle_reap: null # optional; reap when updated_at + idle_reap elapsed
|
||||||
|
```
|
||||||
|
|
||||||
|
## activity-core
|
||||||
|
|
||||||
|
Scheduled jobs should invoke `sandboxer expire --apply` (or HTTP equivalent).
|
||||||
|
See `docs/integrations/activity-core.md`.
|
||||||
@@ -6,7 +6,16 @@ Markdown-first capability index for federation and reuse planning.
|
|||||||
|
|
||||||
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
|
1. Copy a capability entry template (see reuse-surface `templates/capability-entry.template.md`).
|
||||||
2. Add the row to `indexes/capabilities.yaml`.
|
2. Add the row to `indexes/capabilities.yaml`.
|
||||||
3. Run `reuse-surface validate` from a checkout with the CLI installed.
|
3. Run `reuse-surface validate` from a checkout with the CLI installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/reuse-surface
|
||||||
|
reuse-surface validate --repo ~/sand-boxer
|
||||||
|
```
|
||||||
|
|
||||||
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
|
4. Merge to `main` and verify publish with `reuse-surface establish --publish-check`.
|
||||||
|
|
||||||
|
sand-boxer v0 maturity (post SAND-WP-0009): D5/A4/C4 — see
|
||||||
|
`registry/capabilities/execution.sandbox-provision.md`.
|
||||||
|
|
||||||
Federation contract: reuse-surface `docs/RegistryFederation.md`.
|
Federation contract: reuse-surface `docs/RegistryFederation.md`.
|
||||||
|
|||||||
@@ -9,32 +9,35 @@ tags: [sandbox, isolation, provision, e2e, agentic, execution, profile]
|
|||||||
|
|
||||||
maturity:
|
maturity:
|
||||||
discovery:
|
discovery:
|
||||||
current: D4
|
current: D5
|
||||||
target: D6
|
target: D6
|
||||||
confidence: high
|
confidence: high
|
||||||
rationale: >
|
rationale: >
|
||||||
Charter (INTENT.md), meta-framework spec (docs/meta-framework.md), and
|
Charter (INTENT.md), meta-framework spec, extension SDK, integration docs,
|
||||||
research synthesis define scope. First extension (ext.compose-ssh) in progress.
|
and research synthesis. Capability indexed in registry/.
|
||||||
availability:
|
availability:
|
||||||
current: A2
|
current: A4
|
||||||
target: A5
|
target: A5
|
||||||
confidence: medium
|
confidence: high
|
||||||
rationale: >
|
rationale: >
|
||||||
CLI v0 and ext.compose-ssh scaffold land in SAND-WP-0002. SaaS extensions
|
CLI v0 (create/destroy/snapshot/TTL), HTTP API, CoulombCore remote smoke.
|
||||||
and payments deferred.
|
SaaS stub + routing + credits shipped (SAND-WP-0006).
|
||||||
|
|
||||||
external_evidence:
|
external_evidence:
|
||||||
completeness:
|
completeness:
|
||||||
level: C2
|
level: C4
|
||||||
name: Partial
|
name: Substantial
|
||||||
confidence: medium
|
confidence: high
|
||||||
basis: scope_vs_intent_and_consumer_expectations
|
basis: scope_vs_intent_and_consumer_expectations
|
||||||
satisfied_expectations:
|
satisfied_expectations:
|
||||||
- profile-based create/destroy via CLI
|
- profile-based create/destroy/snapshot/restore via CLI
|
||||||
|
- TTL extend and expire/reap (SAND-WP-0009)
|
||||||
- State Hub lifecycle events on transitions
|
- State Hub lifecycle events on transitions
|
||||||
|
- wise-validator and the-custodian migration arc complete
|
||||||
|
- extension SDK with compose-ssh, vm-packer attach, saas-stub
|
||||||
broken_expectations:
|
broken_expectations:
|
||||||
- Real E2B/Modal adapters not yet built (saas-stub + credits v0 done)
|
- Real E2B/Modal adapters not yet built
|
||||||
- wise-validator migration not complete
|
- sandboxer01 dedicated host not live (CoulombCore interim)
|
||||||
out_of_scope_expectations:
|
out_of_scope_expectations:
|
||||||
- agent harness and tool orchestration (glas-harness)
|
- agent harness and tool orchestration (glas-harness)
|
||||||
- e2e test semantics (wise-validator)
|
- e2e test semantics (wise-validator)
|
||||||
@@ -42,4 +45,4 @@ external_evidence:
|
|||||||
consumption_modes:
|
consumption_modes:
|
||||||
- CLI (sandboxer)
|
- CLI (sandboxer)
|
||||||
- core library (Python)
|
- core library (Python)
|
||||||
- HTTP API (planned)
|
- HTTP API (uvicorn sandboxer.api.app:app)
|
||||||
@@ -6,6 +6,8 @@ from fastapi import FastAPI, HTTPException
|
|||||||
|
|
||||||
from sandboxer.core.manager import SandboxManager
|
from sandboxer.core.manager import SandboxManager
|
||||||
from sandboxer.models import (
|
from sandboxer.models import (
|
||||||
|
ExpireActionResult,
|
||||||
|
ExtendTtlRequest,
|
||||||
SandboxCreateRequest,
|
SandboxCreateRequest,
|
||||||
SandboxStatus,
|
SandboxStatus,
|
||||||
SnapshotRecord,
|
SnapshotRecord,
|
||||||
@@ -82,4 +84,29 @@ def get_snapshot(snapshot_id: str) -> SnapshotRecord:
|
|||||||
record = _manager.get_snapshot(snapshot_id)
|
record = _manager.get_snapshot(snapshot_id)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="snapshot not found")
|
raise HTTPException(status_code=404, detail="snapshot not found")
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/sandboxes/{sandbox_id}/recreate", response_model=SandboxStatus)
|
||||||
|
def recreate_sandbox(sandbox_id: str) -> SandboxStatus:
|
||||||
|
try:
|
||||||
|
return _manager.recreate(sandbox_id)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/v1/sandboxes/{sandbox_id}/ttl", response_model=SandboxStatus)
|
||||||
|
def extend_sandbox_ttl(sandbox_id: str, request: ExtendTtlRequest) -> SandboxStatus:
|
||||||
|
try:
|
||||||
|
return _manager.extend_ttl(sandbox_id, request.duration)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/sandboxes/expire", response_model=list[ExpireActionResult])
|
||||||
|
def expire_sandboxes(apply: bool = False) -> list[ExpireActionResult]:
|
||||||
|
return _manager.expire(apply=apply)
|
||||||
@@ -90,6 +90,7 @@ def sandbox_create(
|
|||||||
actor: Annotated[str, typer.Option(help="Consumer actor type")] = "adm",
|
actor: Annotated[str, typer.Option(help="Consumer actor type")] = "adm",
|
||||||
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
|
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
|
||||||
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
|
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
|
||||||
|
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Provision a sandbox. No args → canary self-deploy of sand-boxer."""
|
"""Provision a sandbox. No args → canary self-deploy of sand-boxer."""
|
||||||
parsed = _parse_inputs(input or [])
|
parsed = _parse_inputs(input or [])
|
||||||
@@ -98,6 +99,7 @@ def sandbox_create(
|
|||||||
profile=resolved_profile,
|
profile=resolved_profile,
|
||||||
inputs=resolved_inputs,
|
inputs=resolved_inputs,
|
||||||
consumer=Consumer(actor=ActorType(actor), project=project),
|
consumer=Consumer(actor=ActorType(actor), project=project),
|
||||||
|
ttl=ttl,
|
||||||
)
|
)
|
||||||
manager = SandboxManager()
|
manager = SandboxManager()
|
||||||
try:
|
try:
|
||||||
@@ -196,6 +198,33 @@ def snapshots_get(snapshot_id: str) -> None:
|
|||||||
_print_json(record.model_dump(mode="json"))
|
_print_json(record.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("extend-ttl")
|
||||||
|
def sandbox_extend_ttl(
|
||||||
|
sandbox_id: str,
|
||||||
|
duration: Annotated[str, typer.Option("--duration", help="Extension duration (e.g. 2h)")],
|
||||||
|
) -> None:
|
||||||
|
"""Extend sandbox time-to-live (capped at profile max)."""
|
||||||
|
manager = SandboxManager()
|
||||||
|
try:
|
||||||
|
status = manager.extend_ttl(sandbox_id, duration)
|
||||||
|
except (KeyError, RuntimeError, ValueError) as exc:
|
||||||
|
typer.echo(f"Error: {exc}", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
_print_json(status.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("expire")
|
||||||
|
def sandbox_expire(
|
||||||
|
apply: Annotated[bool, typer.Option("--apply", help="Destroy expired sandboxes")] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Report or destroy sandboxes past TTL or idle-reap threshold."""
|
||||||
|
manager = SandboxManager()
|
||||||
|
results = manager.expire(apply=apply)
|
||||||
|
mode = "apply" if apply else "dry-run"
|
||||||
|
typer.echo(f"expire ({mode}): {len(results)} candidate(s)", err=True)
|
||||||
|
_print_json([r.model_dump(mode="json") for r in results])
|
||||||
|
|
||||||
|
|
||||||
@app.command("recreate")
|
@app.command("recreate")
|
||||||
def sandbox_recreate(sandbox_id: str) -> None:
|
def sandbox_recreate(sandbox_id: str) -> None:
|
||||||
"""Destroy and reprovision from stored inputs."""
|
"""Destroy and reprovision from stored inputs."""
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sandboxer.extensions.registry import load_extension, resolve_backend
|
from sandboxer.extensions.registry import load_extension, resolve_backend
|
||||||
|
from sandboxer.lifecycle.expire import (
|
||||||
|
ExpireCandidate,
|
||||||
|
apply_expired_state,
|
||||||
|
find_expire_candidates,
|
||||||
|
)
|
||||||
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
||||||
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
||||||
|
from sandboxer.lifecycle.ttl import expires_at_from, extend_expires_at, resolve_initial_ttl
|
||||||
from sandboxer.models import (
|
from sandboxer.models import (
|
||||||
Consumer,
|
Consumer,
|
||||||
|
ExpireActionResult,
|
||||||
MeterRecord,
|
MeterRecord,
|
||||||
Reachability,
|
Reachability,
|
||||||
SandboxCreateRequest,
|
SandboxCreateRequest,
|
||||||
@@ -60,6 +67,18 @@ class SandboxManager:
|
|||||||
return extension.config.get("provider", "saas")
|
return extension.config.get("provider", "saas")
|
||||||
return resolve_host(profile, override=host_override)
|
return resolve_host(profile, override=host_override)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assign_ttl(
|
||||||
|
status: SandboxStatus,
|
||||||
|
profile,
|
||||||
|
*,
|
||||||
|
request_ttl: str | None,
|
||||||
|
) -> None:
|
||||||
|
ttl_str = resolve_initial_ttl(profile, request_ttl)
|
||||||
|
anchor = status.ready_at or utcnow()
|
||||||
|
status.ttl = ttl_str
|
||||||
|
status.expires_at = expires_at_from(anchor, ttl_str)
|
||||||
|
|
||||||
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
||||||
profile = load_profile(request.profile)
|
profile = load_profile(request.profile)
|
||||||
extension = resolve_extension(profile, request.inputs, host_override=host)
|
extension = resolve_extension(profile, request.inputs, host_override=host)
|
||||||
@@ -119,6 +138,7 @@ class SandboxManager:
|
|||||||
status.state = SandboxState.READY
|
status.state = SandboxState.READY
|
||||||
status.ready_at = utcnow()
|
status.ready_at = utcnow()
|
||||||
status.updated_at = status.ready_at
|
status.updated_at = status.ready_at
|
||||||
|
self._assign_ttl(status, profile, request_ttl=request.ttl)
|
||||||
|
|
||||||
if wants_telemetry and provision_before:
|
if wants_telemetry and provision_before:
|
||||||
provision_after = collect_host_snapshot(resolved_host)
|
provision_after = collect_host_snapshot(resolved_host)
|
||||||
@@ -224,11 +244,98 @@ class SandboxManager:
|
|||||||
profile=existing.profile_id,
|
profile=existing.profile_id,
|
||||||
inputs=dict(existing.inputs),
|
inputs=dict(existing.inputs),
|
||||||
consumer=existing.consumer,
|
consumer=existing.consumer,
|
||||||
|
ttl=existing.ttl,
|
||||||
)
|
)
|
||||||
if existing.state != SandboxState.DESTROYED:
|
if existing.state != SandboxState.DESTROYED:
|
||||||
self.destroy(sandbox_id)
|
self.destroy(sandbox_id)
|
||||||
return self.create(request, host=existing.host)
|
return self.create(request, host=existing.host)
|
||||||
|
|
||||||
|
def extend_ttl(self, sandbox_id: str, duration: str) -> SandboxStatus:
|
||||||
|
status = self.store.get(sandbox_id)
|
||||||
|
if not status:
|
||||||
|
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||||
|
if status.state not in (SandboxState.READY, SandboxState.ACTIVE):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot extend TTL for sandbox in state {status.state.value}"
|
||||||
|
)
|
||||||
|
if not status.expires_at or not status.ready_at:
|
||||||
|
raise RuntimeError("Sandbox has no expiry metadata")
|
||||||
|
|
||||||
|
profile = load_profile(status.profile_id)
|
||||||
|
new_expires, applied = extend_expires_at(
|
||||||
|
status.expires_at,
|
||||||
|
anchor=status.ready_at,
|
||||||
|
extension=duration,
|
||||||
|
max_duration=profile.ttl.max,
|
||||||
|
)
|
||||||
|
status.expires_at = new_expires
|
||||||
|
status.ttl = applied
|
||||||
|
status.updated_at = utcnow()
|
||||||
|
self.store.save(status)
|
||||||
|
emit_lifecycle_event(
|
||||||
|
status,
|
||||||
|
summary=f"TTL extended by {applied} (expires {new_expires.isoformat()})",
|
||||||
|
event_type="note",
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
def expire(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
apply: bool = False,
|
||||||
|
now=None,
|
||||||
|
) -> list[ExpireActionResult]:
|
||||||
|
candidates = find_expire_candidates(self.store, now=now)
|
||||||
|
results: list[ExpireActionResult] = []
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if not apply:
|
||||||
|
results.append(
|
||||||
|
ExpireActionResult(
|
||||||
|
sandbox_id=candidate.sandbox_id,
|
||||||
|
reason=candidate.reason,
|
||||||
|
action="dry-run",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = self.store.get(candidate.sandbox_id)
|
||||||
|
if not status or status.state not in (
|
||||||
|
SandboxState.READY,
|
||||||
|
SandboxState.ACTIVE,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
status = apply_expired_state(status, now=now)
|
||||||
|
self.store.save(status)
|
||||||
|
emit_lifecycle_event(
|
||||||
|
status,
|
||||||
|
summary=f"Sandbox expired ({candidate.reason})",
|
||||||
|
event_type=event_type_for_state(status.state),
|
||||||
|
)
|
||||||
|
self.destroy(candidate.sandbox_id)
|
||||||
|
results.append(
|
||||||
|
ExpireActionResult(
|
||||||
|
sandbox_id=candidate.sandbox_id,
|
||||||
|
reason=candidate.reason,
|
||||||
|
action="destroyed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(
|
||||||
|
ExpireActionResult(
|
||||||
|
sandbox_id=candidate.sandbox_id,
|
||||||
|
reason=candidate.reason,
|
||||||
|
action="failed",
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def list_expire_candidates(self, *, now=None) -> list[ExpireCandidate]:
|
||||||
|
return find_expire_candidates(self.store, now=now)
|
||||||
|
|
||||||
def snapshot(self, sandbox_id: str, *, name: str | None = None) -> SnapshotRecord:
|
def snapshot(self, sandbox_id: str, *, name: str | None = None) -> SnapshotRecord:
|
||||||
status = self.store.get(sandbox_id)
|
status = self.store.get(sandbox_id)
|
||||||
if not status:
|
if not status:
|
||||||
@@ -345,6 +452,7 @@ class SandboxManager:
|
|||||||
status.state = SandboxState.READY
|
status.state = SandboxState.READY
|
||||||
status.ready_at = utcnow()
|
status.ready_at = utcnow()
|
||||||
status.updated_at = status.ready_at
|
status.updated_at = status.ready_at
|
||||||
|
self._assign_ttl(status, profile, request_ttl=None)
|
||||||
self.store.save(status)
|
self.store.save(status)
|
||||||
emit_lifecycle_event(
|
emit_lifecycle_event(
|
||||||
status,
|
status,
|
||||||
|
|||||||
77
src/sandboxer/lifecycle/expire.py
Normal file
77
src/sandboxer/lifecycle/expire.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""TTL and idle-reap expiry candidate selection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sandboxer.lifecycle.store import SandboxStore
|
||||||
|
from sandboxer.lifecycle.ttl import is_idle_expired, is_past_expiry
|
||||||
|
from sandboxer.models import SandboxState, SandboxStatus
|
||||||
|
from sandboxer.profiles.loader import load_profile
|
||||||
|
|
||||||
|
ExpireReason = Literal["ttl", "idle"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExpireCandidate:
|
||||||
|
sandbox_id: str
|
||||||
|
profile_id: str
|
||||||
|
reason: ExpireReason
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_LIVE_STATES = frozenset({SandboxState.READY, SandboxState.ACTIVE})
|
||||||
|
|
||||||
|
|
||||||
|
def find_expire_candidates(
|
||||||
|
store: SandboxStore,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> list[ExpireCandidate]:
|
||||||
|
ref = now or datetime.now(UTC)
|
||||||
|
candidates: list[ExpireCandidate] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for status in store.list_all():
|
||||||
|
if status.state not in _LIVE_STATES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_past_expiry(status.expires_at, now=ref):
|
||||||
|
candidates.append(
|
||||||
|
ExpireCandidate(
|
||||||
|
sandbox_id=status.sandbox_id,
|
||||||
|
profile_id=status.profile_id,
|
||||||
|
reason="ttl",
|
||||||
|
expires_at=status.expires_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen.add(status.sandbox_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = load_profile(status.profile_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_idle_expired(status.updated_at, profile.ttl.idle_reap, now=ref):
|
||||||
|
candidates.append(
|
||||||
|
ExpireCandidate(
|
||||||
|
sandbox_id=status.sandbox_id,
|
||||||
|
profile_id=status.profile_id,
|
||||||
|
reason="idle",
|
||||||
|
updated_at=status.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen.add(status.sandbox_id)
|
||||||
|
|
||||||
|
return sorted(candidates, key=lambda c: c.sandbox_id)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_expired_state(status: SandboxStatus, *, now: datetime | None = None) -> SandboxStatus:
|
||||||
|
ref = now or datetime.now(UTC)
|
||||||
|
status.state = SandboxState.EXPIRED
|
||||||
|
status.updated_at = ref
|
||||||
|
return status
|
||||||
@@ -38,6 +38,8 @@ def emit_lifecycle_event(
|
|||||||
"consumer": status.consumer.model_dump(),
|
"consumer": status.consumer.model_dump(),
|
||||||
"actor_type": status.consumer.actor.value,
|
"actor_type": status.consumer.actor.value,
|
||||||
"state": status.state.value,
|
"state": status.state.value,
|
||||||
|
"ttl": status.ttl,
|
||||||
|
"expires_at": status.expires_at.isoformat() if status.expires_at else None,
|
||||||
"reachability": status.reachability.model_dump() if status.reachability else None,
|
"reachability": status.reachability.model_dump() if status.reachability else None,
|
||||||
"telemetry": status.telemetry,
|
"telemetry": status.telemetry,
|
||||||
"timestamps": {
|
"timestamps": {
|
||||||
@@ -58,6 +60,6 @@ def emit_lifecycle_event(
|
|||||||
|
|
||||||
|
|
||||||
def event_type_for_state(state: SandboxState) -> str:
|
def event_type_for_state(state: SandboxState) -> str:
|
||||||
if state in (SandboxState.READY, SandboxState.DESTROYED):
|
if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED):
|
||||||
return "milestone"
|
return "milestone"
|
||||||
return "note"
|
return "note"
|
||||||
121
src/sandboxer/lifecycle/ttl.py
Normal file
121
src/sandboxer/lifecycle/ttl.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""TTL duration parsing and expiry calculation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
|
_DURATION_RE = re.compile(r"^(\d+)([smhd])$", re.IGNORECASE)
|
||||||
|
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(value: str) -> timedelta:
|
||||||
|
"""Parse a duration string like ``4h``, ``30m``, ``1d``."""
|
||||||
|
raw = value.strip()
|
||||||
|
match = _DURATION_RE.match(raw)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Invalid duration: {value!r} (expected e.g. 4h, 30m, 1d)")
|
||||||
|
amount = int(match.group(1))
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError(f"Duration must be positive: {value!r}")
|
||||||
|
unit = match.group(2).lower()
|
||||||
|
return timedelta(seconds=amount * _UNIT_SECONDS[unit])
|
||||||
|
|
||||||
|
|
||||||
|
def duration_seconds(value: str) -> int:
|
||||||
|
return int(parse_duration(value).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_initial_ttl(profile: Profile, request_ttl: str | None) -> str:
|
||||||
|
"""Pick create TTL from request override or profile default, capped at profile max."""
|
||||||
|
requested = request_ttl or profile.ttl.default
|
||||||
|
return cap_duration(requested, profile.ttl.max)
|
||||||
|
|
||||||
|
|
||||||
|
def cap_duration(requested: str, maximum: str) -> str:
|
||||||
|
"""Return ``requested`` if within ``maximum``; otherwise return ``maximum``."""
|
||||||
|
req_s = duration_seconds(requested)
|
||||||
|
max_s = duration_seconds(maximum)
|
||||||
|
if req_s > max_s:
|
||||||
|
return maximum
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
|
def expires_at_from(base: datetime, duration: str) -> datetime:
|
||||||
|
if base.tzinfo is None:
|
||||||
|
base = base.replace(tzinfo=UTC)
|
||||||
|
return base + parse_duration(duration)
|
||||||
|
|
||||||
|
|
||||||
|
def cap_expires_at(
|
||||||
|
candidate: datetime,
|
||||||
|
*,
|
||||||
|
anchor: datetime,
|
||||||
|
max_duration: str,
|
||||||
|
) -> datetime:
|
||||||
|
"""Cap ``candidate`` so it does not exceed ``anchor + max_duration``."""
|
||||||
|
ceiling = expires_at_from(anchor, max_duration)
|
||||||
|
if candidate.tzinfo is None:
|
||||||
|
candidate = candidate.replace(tzinfo=UTC)
|
||||||
|
return min(candidate, ceiling)
|
||||||
|
|
||||||
|
|
||||||
|
def extend_expires_at(
|
||||||
|
current: datetime,
|
||||||
|
*,
|
||||||
|
anchor: datetime,
|
||||||
|
extension: str,
|
||||||
|
max_duration: str,
|
||||||
|
) -> tuple[datetime, str]:
|
||||||
|
"""Add ``extension`` to ``current`` and cap at ``anchor + max_duration``."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
base = max(current, now)
|
||||||
|
proposed = expires_at_from(base, extension)
|
||||||
|
capped = cap_expires_at(proposed, anchor=anchor, max_duration=max_duration)
|
||||||
|
applied = extension
|
||||||
|
if capped < proposed:
|
||||||
|
remaining = capped - base
|
||||||
|
if remaining.total_seconds() <= 0:
|
||||||
|
raise ValueError(f"Cannot extend: already at profile max ({max_duration})")
|
||||||
|
applied = format_timedelta(remaining)
|
||||||
|
return capped, applied
|
||||||
|
|
||||||
|
|
||||||
|
def format_timedelta(delta: timedelta) -> str:
|
||||||
|
seconds = int(delta.total_seconds())
|
||||||
|
if seconds <= 0:
|
||||||
|
raise ValueError("Duration must be positive")
|
||||||
|
if seconds >= 86400 and seconds % 86400 == 0:
|
||||||
|
return f"{seconds // 86400}d"
|
||||||
|
if seconds >= 3600 and seconds % 3600 == 0:
|
||||||
|
return f"{seconds // 3600}h"
|
||||||
|
if seconds >= 60 and seconds % 60 == 0:
|
||||||
|
return f"{seconds // 60}m"
|
||||||
|
return f"{seconds}s"
|
||||||
|
|
||||||
|
|
||||||
|
def is_past_expiry(expires_at: datetime | None, *, now: datetime | None = None) -> bool:
|
||||||
|
if expires_at is None:
|
||||||
|
return False
|
||||||
|
ref = now or datetime.now(UTC)
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=UTC)
|
||||||
|
return expires_at <= ref
|
||||||
|
|
||||||
|
|
||||||
|
def is_idle_expired(
|
||||||
|
updated_at: datetime,
|
||||||
|
idle_reap: str | None,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if not idle_reap:
|
||||||
|
return False
|
||||||
|
ref = now or datetime.now(UTC)
|
||||||
|
if updated_at.tzinfo is None:
|
||||||
|
updated_at = updated_at.replace(tzinfo=UTC)
|
||||||
|
return updated_at + parse_duration(idle_reap) <= ref
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +164,8 @@ class SandboxStatus(BaseModel):
|
|||||||
host: str | None = None
|
host: str | None = None
|
||||||
reachability: Reachability | None = None
|
reachability: Reachability | None = None
|
||||||
inputs: dict[str, str] = Field(default_factory=dict)
|
inputs: dict[str, str] = Field(default_factory=dict)
|
||||||
|
ttl: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
meter: MeterRecord | None = None
|
meter: MeterRecord | None = None
|
||||||
telemetry: dict | None = None # IntrospectionReport JSON when canary
|
telemetry: dict | None = None # IntrospectionReport JSON when canary
|
||||||
@@ -173,6 +175,17 @@ class SandboxStatus(BaseModel):
|
|||||||
destroyed_at: datetime | None = None
|
destroyed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExtendTtlRequest(BaseModel):
|
||||||
|
duration: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExpireActionResult(BaseModel):
|
||||||
|
sandbox_id: str
|
||||||
|
reason: Literal["ttl", "idle"]
|
||||||
|
action: Literal["dry-run", "expired", "destroyed", "failed"]
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SnapshotRestoreRequest(BaseModel):
|
class SnapshotRestoreRequest(BaseModel):
|
||||||
host: str | None = None
|
host: str | None = None
|
||||||
consumer: Consumer | None = None
|
consumer: Consumer | None = None
|
||||||
|
|||||||
@@ -88,4 +88,64 @@ def test_restore_snapshot() -> None:
|
|||||||
json={"consumer": {"actor": "adm", "project": "sand-boxer"}},
|
json={"consumer": {"actor": "adm", "project": "sand-boxer"}},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["sandbox_id"] == "restored1"
|
assert resp.json()["sandbox_id"] == "restored1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recreate_sandbox() -> None:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
status = SandboxStatus(
|
||||||
|
sandbox_id="new12345",
|
||||||
|
profile_id="profile.compose-e2e",
|
||||||
|
extension_id="ext.compose-ssh",
|
||||||
|
state=SandboxState.READY,
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
with patch("sandboxer.api.app._manager") as mgr:
|
||||||
|
mgr.recreate.return_value = status
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/v1/sandboxes/abc12345/recreate")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["sandbox_id"] == "new12345"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extend_ttl() -> None:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
status = SandboxStatus(
|
||||||
|
sandbox_id="abc12345",
|
||||||
|
profile_id="profile.compose-e2e",
|
||||||
|
extension_id="ext.compose-ssh",
|
||||||
|
state=SandboxState.READY,
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
ttl="2h",
|
||||||
|
expires_at=now,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
ready_at=now,
|
||||||
|
)
|
||||||
|
with patch("sandboxer.api.app._manager") as mgr:
|
||||||
|
mgr.extend_ttl.return_value = status
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.patch(
|
||||||
|
"/v1/sandboxes/abc12345/ttl",
|
||||||
|
json={"duration": "2h"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ttl"] == "2h"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expire_sandboxes() -> None:
|
||||||
|
from sandboxer.models import ExpireActionResult
|
||||||
|
|
||||||
|
with patch("sandboxer.api.app._manager") as mgr:
|
||||||
|
mgr.expire.return_value = [
|
||||||
|
ExpireActionResult(sandbox_id="x", reason="ttl", action="dry-run")
|
||||||
|
]
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/v1/sandboxes/expire")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()[0]["action"] == "dry-run"
|
||||||
@@ -126,9 +126,7 @@ def test_manager_snapshot_and_restore(store: SandboxStore, snapshots: SnapshotSt
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
|
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
|
||||||
patch("sandboxer.core.manager.load_extension"),
|
|
||||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||||
patch("sandboxer.core.manager.load_profile"),
|
|
||||||
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
||||||
):
|
):
|
||||||
record = manager.snapshot("test1234", name="pre-test")
|
record = manager.snapshot("test1234", name="pre-test")
|
||||||
|
|||||||
265
tests/test_ttl.py
Normal file
265
tests/test_ttl.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""TTL parsing, extend, and expire tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sandboxer.core.manager import SandboxManager
|
||||||
|
from sandboxer.lifecycle.expire import find_expire_candidates
|
||||||
|
from sandboxer.lifecycle.store import SandboxStore
|
||||||
|
from sandboxer.lifecycle.ttl import (
|
||||||
|
cap_duration,
|
||||||
|
extend_expires_at,
|
||||||
|
format_timedelta,
|
||||||
|
is_idle_expired,
|
||||||
|
is_past_expiry,
|
||||||
|
parse_duration,
|
||||||
|
resolve_initial_ttl,
|
||||||
|
)
|
||||||
|
from sandboxer.models import (
|
||||||
|
ActorType,
|
||||||
|
Consumer,
|
||||||
|
Profile,
|
||||||
|
Reachability,
|
||||||
|
SandboxCreateRequest,
|
||||||
|
SandboxState,
|
||||||
|
SandboxStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(**ttl_overrides) -> Profile:
|
||||||
|
ttl_data = {"default": "4h", "max": "24h", "idle_reap": None}
|
||||||
|
ttl_data.update(ttl_overrides)
|
||||||
|
return Profile.model_validate(
|
||||||
|
{
|
||||||
|
"id": "profile.compose-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"extension": "ext.compose-ssh",
|
||||||
|
"ttl": ttl_data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_duration_units() -> None:
|
||||||
|
assert parse_duration("30m") == timedelta(minutes=30)
|
||||||
|
assert parse_duration("4h") == timedelta(hours=4)
|
||||||
|
assert parse_duration("1d") == timedelta(days=1)
|
||||||
|
assert parse_duration("90s") == timedelta(seconds=90)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_duration_invalid() -> None:
|
||||||
|
with pytest.raises(ValueError, match="Invalid duration"):
|
||||||
|
parse_duration("4hours")
|
||||||
|
with pytest.raises(ValueError, match="positive"):
|
||||||
|
parse_duration("0h")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_duration() -> None:
|
||||||
|
assert cap_duration("4h", "24h") == "4h"
|
||||||
|
assert cap_duration("48h", "24h") == "24h"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_initial_ttl() -> None:
|
||||||
|
profile = _profile()
|
||||||
|
assert resolve_initial_ttl(profile, None) == "4h"
|
||||||
|
assert resolve_initial_ttl(profile, "2h") == "2h"
|
||||||
|
assert resolve_initial_ttl(profile, "48h") == "24h"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extend_expires_at_caps_at_max() -> None:
|
||||||
|
anchor = datetime(2026, 6, 24, 10, 0, tzinfo=UTC)
|
||||||
|
current = anchor + timedelta(hours=23)
|
||||||
|
new_expires, applied = extend_expires_at(
|
||||||
|
current,
|
||||||
|
anchor=anchor,
|
||||||
|
extension="4h",
|
||||||
|
max_duration="24h",
|
||||||
|
)
|
||||||
|
assert new_expires == anchor + timedelta(hours=24)
|
||||||
|
assert applied == "1h"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extend_expires_at_at_max_raises() -> None:
|
||||||
|
anchor = datetime(2026, 6, 24, 10, 0, tzinfo=UTC)
|
||||||
|
current = anchor + timedelta(hours=24)
|
||||||
|
with pytest.raises(ValueError, match="profile max"):
|
||||||
|
extend_expires_at(
|
||||||
|
current,
|
||||||
|
anchor=anchor,
|
||||||
|
extension="1h",
|
||||||
|
max_duration="24h",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_timedelta() -> None:
|
||||||
|
assert format_timedelta(timedelta(hours=2)) == "2h"
|
||||||
|
assert format_timedelta(timedelta(minutes=30)) == "30m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_past_expiry_and_idle() -> None:
|
||||||
|
now = datetime(2026, 6, 24, 12, 0, tzinfo=UTC)
|
||||||
|
assert is_past_expiry(now - timedelta(minutes=1), now=now)
|
||||||
|
assert not is_past_expiry(now + timedelta(minutes=1), now=now)
|
||||||
|
updated = now - timedelta(hours=2)
|
||||||
|
assert is_idle_expired(updated, "1h", now=now)
|
||||||
|
assert not is_idle_expired(updated, "4h", now=now)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path: Path) -> SandboxStore:
|
||||||
|
return SandboxStore(path=tmp_path / "sandboxes.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_expire_candidates_ttl_and_idle(store: SandboxStore) -> None:
|
||||||
|
now = datetime(2026, 6, 24, 12, 0, tzinfo=UTC)
|
||||||
|
store.save(
|
||||||
|
SandboxStatus(
|
||||||
|
sandbox_id="expired1",
|
||||||
|
profile_id="profile.compose-e2e",
|
||||||
|
extension_id="ext.compose-ssh",
|
||||||
|
state=SandboxState.READY,
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
expires_at=now - timedelta(minutes=5),
|
||||||
|
created_at=now - timedelta(hours=5),
|
||||||
|
updated_at=now - timedelta(hours=5),
|
||||||
|
ready_at=now - timedelta(hours=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.save(
|
||||||
|
SandboxStatus(
|
||||||
|
sandbox_id="idle1",
|
||||||
|
profile_id="profile.sandbox-canary",
|
||||||
|
extension_id="ext.compose-ssh",
|
||||||
|
state=SandboxState.READY,
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
expires_at=now + timedelta(hours=2),
|
||||||
|
created_at=now - timedelta(hours=5),
|
||||||
|
updated_at=now - timedelta(hours=3),
|
||||||
|
ready_at=now - timedelta(hours=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("sandboxer.lifecycle.expire.load_profile") as load_profile:
|
||||||
|
load_profile.side_effect = lambda pid: _profile(
|
||||||
|
idle_reap="2h" if pid == "profile.sandbox-canary" else None
|
||||||
|
)
|
||||||
|
candidates = find_expire_candidates(store, now=now)
|
||||||
|
|
||||||
|
reasons = {c.sandbox_id: c.reason for c in candidates}
|
||||||
|
assert reasons["expired1"] == "ttl"
|
||||||
|
assert reasons["idle1"] == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBackend:
|
||||||
|
def provision(self, profile, inputs, host):
|
||||||
|
return {
|
||||||
|
"sandbox_id": "test1234",
|
||||||
|
"host": host,
|
||||||
|
"remote_dir": "/tmp/sandboxer/test1234",
|
||||||
|
"compose_project": "sbx-e2e-test1234",
|
||||||
|
"compose_file": "docker-compose.yml",
|
||||||
|
"ssh_user": "root",
|
||||||
|
}
|
||||||
|
|
||||||
|
def wait_ready(self, handle):
|
||||||
|
return {
|
||||||
|
"ssh": f"root@{handle['host']}",
|
||||||
|
"remote_dir": handle["remote_dir"],
|
||||||
|
"compose_project": handle["compose_project"],
|
||||||
|
"host": handle["host"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def teardown(self, handle):
|
||||||
|
return {"compose_removed": "True", "remote_dir_removed": "True"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_create_sets_expires_at(store: SandboxStore) -> None:
|
||||||
|
manager = SandboxManager(store=store)
|
||||||
|
request = SandboxCreateRequest(
|
||||||
|
profile="profile.compose-e2e",
|
||||||
|
inputs={"repo": "/tmp/repo"},
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
ttl="2h",
|
||||||
|
)
|
||||||
|
fake = FakeBackend()
|
||||||
|
with (
|
||||||
|
patch("sandboxer.core.manager.resolve_backend", return_value=fake),
|
||||||
|
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||||
|
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
||||||
|
):
|
||||||
|
status = manager.create(request)
|
||||||
|
assert status.ttl == "2h"
|
||||||
|
assert status.expires_at is not None
|
||||||
|
assert status.ready_at is not None
|
||||||
|
assert status.expires_at > status.ready_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_extend_ttl(store: SandboxStore) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
store.save(
|
||||||
|
SandboxStatus(
|
||||||
|
sandbox_id="live1234",
|
||||||
|
profile_id="profile.compose-e2e",
|
||||||
|
extension_id="ext.compose-ssh",
|
||||||
|
state=SandboxState.READY,
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
host="coulombcore",
|
||||||
|
reachability=Reachability(remote_dir="/tmp/x", host="coulombcore"),
|
||||||
|
ttl="4h",
|
||||||
|
expires_at=now + timedelta(hours=1),
|
||||||
|
created_at=now - timedelta(hours=1),
|
||||||
|
updated_at=now,
|
||||||
|
ready_at=now - timedelta(hours=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manager = SandboxManager(store=store)
|
||||||
|
with patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None):
|
||||||
|
extended = manager.extend_ttl("live1234", "2h")
|
||||||
|
assert extended.expires_at > now + timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_expire_dry_run_and_apply(store: SandboxStore) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
store.save(
|
||||||
|
SandboxStatus(
|
||||||
|
sandbox_id="gone5678",
|
||||||
|
profile_id="profile.compose-e2e",
|
||||||
|
extension_id="ext.compose-ssh",
|
||||||
|
state=SandboxState.READY,
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
|
host="coulombcore",
|
||||||
|
reachability=Reachability(
|
||||||
|
remote_dir="/tmp/sandboxer/gone5678",
|
||||||
|
compose_project="sbx-e2e-gone5678",
|
||||||
|
host="coulombcore",
|
||||||
|
),
|
||||||
|
inputs={"compose_file": "docker-compose.yml"},
|
||||||
|
ttl="1h",
|
||||||
|
expires_at=now - timedelta(minutes=1),
|
||||||
|
created_at=now - timedelta(hours=2),
|
||||||
|
updated_at=now - timedelta(hours=2),
|
||||||
|
ready_at=now - timedelta(hours=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manager = SandboxManager(store=store)
|
||||||
|
fake = FakeBackend()
|
||||||
|
|
||||||
|
dry = manager.expire(apply=False, now=now)
|
||||||
|
assert len(dry) == 1
|
||||||
|
assert dry[0].action == "dry-run"
|
||||||
|
assert manager.get("gone5678").state == SandboxState.READY
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("sandboxer.core.manager.resolve_backend", return_value=fake),
|
||||||
|
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||||
|
patch("sandboxer.core.manager.load_extension"),
|
||||||
|
patch("sandboxer.core.manager.load_profile"),
|
||||||
|
):
|
||||||
|
applied = manager.expire(apply=True, now=now)
|
||||||
|
|
||||||
|
assert applied[0].action == "destroyed"
|
||||||
|
assert manager.get("gone5678").state == SandboxState.DESTROYED
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "TTL enforcement and operational hardening"
|
title: "TTL enforcement and operational hardening"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: sand-boxer
|
repo: sand-boxer
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-06-24"
|
created: "2026-06-24"
|
||||||
@@ -30,7 +30,7 @@ consumer profiles), SAND-WP-0012 (Packer orchestration)
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T01
|
id: SAND-WP-0009-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "44cee754-2874-40eb-9cb3-168e5bc8dd54"
|
state_hub_task_id: "44cee754-2874-40eb-9cb3-168e5bc8dd54"
|
||||||
```
|
```
|
||||||
@@ -43,7 +43,7 @@ max-cap enforcement.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T02
|
id: SAND-WP-0009-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a5a6503c-56a3-4876-8211-e06b9eed6292"
|
state_hub_task_id: "a5a6503c-56a3-4876-8211-e06b9eed6292"
|
||||||
```
|
```
|
||||||
@@ -56,7 +56,7 @@ Persist in `SandboxStore`. Emit expiry in State Hub `detail`.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T03
|
id: SAND-WP-0009-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ff32a3e5-0bf6-479c-8373-d601588461e7"
|
state_hub_task_id: "ff32a3e5-0bf6-479c-8373-d601588461e7"
|
||||||
```
|
```
|
||||||
@@ -69,7 +69,7 @@ HTTP: `PATCH /v1/sandboxes/{id}/ttl` with body `{"duration": "2h"}`.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T04
|
id: SAND-WP-0009-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ce597f28-a2f3-44ed-8e85-f8bd254bc4ce"
|
state_hub_task_id: "ce597f28-a2f3-44ed-8e85-f8bd254bc4ce"
|
||||||
```
|
```
|
||||||
@@ -83,7 +83,7 @@ with existing `reap-stale` docs (host inventory vs TTL are distinct concerns).
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T05
|
id: SAND-WP-0009-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "9ad34d90-bbc7-4ede-8549-f4291e27ba22"
|
state_hub_task_id: "9ad34d90-bbc7-4ede-8549-f4291e27ba22"
|
||||||
```
|
```
|
||||||
@@ -96,7 +96,7 @@ state; no Temporal code in this repo.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T06
|
id: SAND-WP-0009-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "ffde8196-18e3-4762-8cfd-1b69874e51e1"
|
state_hub_task_id: "ffde8196-18e3-4762-8cfd-1b69874e51e1"
|
||||||
```
|
```
|
||||||
@@ -110,7 +110,7 @@ run validate if reuse-surface CLI available in environment.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T07
|
id: SAND-WP-0009-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "69b192c7-8599-46e7-bb63-8457bfb72a81"
|
state_hub_task_id: "69b192c7-8599-46e7-bb63-8457bfb72a81"
|
||||||
```
|
```
|
||||||
@@ -122,21 +122,20 @@ Align OpenAPI with CLI surface from SAND-WP-0007.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T08
|
id: SAND-WP-0009-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "69d1a23f-b3a3-4aa7-846c-e953f02977f3"
|
state_hub_task_id: "69d1a23f-b3a3-4aa7-846c-e953f02977f3"
|
||||||
```
|
```
|
||||||
|
|
||||||
`docs/ttl.md` — semantics, extend, expire, profile fields. Update
|
`docs/ttl.md` — semantics, extend, expire, profile fields. Update
|
||||||
`docs/meta-framework.md`, `SCOPE.md`, `docs/migration-gaps.md`. Brief security
|
`docs/meta-framework.md`, `SCOPE.md`, `docs/migration-gaps.md`. Brief security
|
||||||
note in `docs/runbooks/` or `docs/security.md`: sandbox limits blast radius, not
|
note in `docs/security.md`: sandbox limits blast radius, not intent enforcement.
|
||||||
intent enforcement (INTENT design principle).
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SAND-WP-0009-T09
|
id: SAND-WP-0009-T09
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0683b09a-0dd9-4880-9bd0-13003e3621a6"
|
state_hub_task_id: "0683b09a-0dd9-4880-9bd0-13003e3621a6"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user