diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7987a42..8c754cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,13 +25,26 @@ jobs: go-version-file: go.mod - name: gofmt run: make fmt-check - - name: vet - run: go vet ./... + - name: vet (including the e2e and live test files, which are not run here) + run: make vet - name: test run: go test -race -count=1 -coverprofile=coverage.out ./... - name: cross-build linux amd64/arm64 run: make build-linux + # The adapter kinds left out of the default build (experimental_adapters + # tag) must keep compiling and passing their fake-server tests. + go-experimental: + name: go (experimental adapters) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: build, vet and test with -tags experimental_adapters + run: make experimental + postgres: name: postgres conformance runs-on: ubuntu-latest @@ -90,7 +103,10 @@ jobs: sdk-python: name: sdk (python ${{ matrix.python }}) - runs-on: ubuntu-latest + # ubuntu-latest moved to Ubuntu 26.04, which has no Python 3.9 build for + # setup-python. The SDK still declares requires-python >= 3.9, so keep + # testing it on the last image that provides it. + runs-on: ubuntu-24.04 strategy: matrix: python: ["3.9", "3.13"] @@ -177,7 +193,7 @@ jobs: # again. It approves what CI checked, not what a reviewer read. approve: name: approve when CI passes - needs: [go, postgres, sdk-typescript, console, sdk-python, helm, images, dco, release-source] + needs: [go, go-experimental, postgres, sdk-typescript, console, sdk-python, helm, images, dco, release-source] if: >- always() && github.event_name == 'pull_request' && diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4a2112..b36400d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,23 @@ permissions: attestations: write jobs: + # Nothing is published from a tag whose code does not pass the checks CI + # runs: a tag can be pushed on any commit, checked or not. + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: gofmt, vet and tests (default and experimental builds) + run: make fmt-check vet test experimental + - uses: azure/setup-helm@v4 + - name: helm lint + run: make helm-lint + binaries: + needs: verify runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -66,6 +82,7 @@ jobs: dist/differently.spdx.json images: + needs: verify runs-on: ubuntu-latest strategy: matrix: diff --git a/AGENTS.md b/AGENTS.md index 54a9a88..f36e129 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,11 @@ for humans). - Never push to or open feature PRs against `main`; releases reach `main` only through a release PR (see CONTRIBUTING.md). - When CI is green you may merge your own PR into `pre-release` - (`gh pr merge --squash`), or queue it with `--auto`. CI's `approve when CI - passes` job approves same-repository PRs once every job is green; that - approval is what `main`'s ruleset needs for release PRs. + (`gh pr merge --squash`). `pre-release` has no required checks, so + `--auto` cannot wait for CI there: check the run, then merge. CI's + `approve when CI passes` job approves same-repository PRs once every job + is green; that approval is what `main`'s ruleset needs for release and + sync PRs ([releasing](docs/maintaining.md#releasing)). - One focused outcome per PR, Conventional Commit title, `git commit -s`. ## Before you push @@ -22,9 +24,13 @@ for humans). make verify ``` -It runs gofmt, go vet, Go tests with `-race`, the SDK tests and the Helm lint, -including the replay of recorded OpenSandbox runtimes. For changes touching -`internal/store/postgres`, also run `make test-postgres`. For anything a real +It runs gofmt, go vet, Go tests with `-race`, the same build, vet and tests +with `-tags experimental_adapters` (`make experimental`), the SDK tests and +the Helm lint, including the replay of recorded OpenSandbox runtimes. For changes touching +`internal/store/postgres`, also run `make test-postgres`. For `console/`, run +`npm run typecheck && npm run build && npm run build:server` there; for +`site/` or any page it publishes from `docs/`, `npm run build` in `site/` +(it fails on a broken link). For anything a real backend or cluster could break, pick the cheapest rung of the verification ladder in [docs/maintaining.md](docs/maintaining.md#prove-a-change-at-the-right-cost) that covers it, and say in the PR what did not run. @@ -32,19 +38,32 @@ that covers it, and say in the PR what did not run. ## Map - Contracts: `pkg/api` (wire types and error codes), `pkg/adapter` - (backend contract), `api/openapi.yaml` (HTTP). + (backend contract), `api/openapi.yaml` (HTTP). A new field or route also + goes into both SDKs (`sdk/typescript`, `sdk/python`) and, where people + see it, the dashboard and the console. - Placement and security decisions: `internal/policy`, `internal/auth`, `internal/store` (atomic admission/activation). Treat changes here as security-sensitive and keep the tests in `internal/server/server_test.go` (acceptance scenarios A–L) green. - Backends: `internal/adapters/*` (`opensandbox` is the default private runtime; Differently runs no sandboxes itself), `internal/wiring` (adapter - factories), `internal/pools`. The real-stack suite is `test/e2e` + factories), `internal/pools`. The default build holds `opensandbox`, + `daytona`, `freestyle`, `remote`, `devhost` and `fake`. Every other + adapter, and the `cloudhttp`, `pyshell` and `shellcmd` helpers, carries + `//go:build experimental_adapters`: a plain `go build`, `go vet` or + `go test` does not see them, so use `make experimental` when you touch + them or anything they share (`httpx`, `pkg/adapter`). The real-stack suite is `test/e2e` (`make e2e-prepare e2e-up e2e`, or against any install). - Recorded runtimes: `internal/adapters/opensandbox/testdata/recordings`, replayed by `TestReplayRecordings`; re-record with the live test after an OpenSandbox upgrade or a new adapter call. - Stored credentials: `internal/secrets` (sealed values, `secret:` refs). +- Capacity: `internal/scheduler/capacity.go` (slots, resume queue, + preemption, lifecycle limits) and its model `internal/capsim` + ([capacity](docs/scheduling/capacity.md)). +- Interfaces: `internal/dashboard` (the built-in dashboard), `console/` (the + separate admin app, static or on Node with sign-in), `site/` (the website; + its docs sync from `docs/**`). - How to change, prove, re-record, optimise and release: [docs/maintaining.md](docs/maintaining.md). - Docs that must stay truthful: `docs/feature-parity.md`, `docs/roadmap.md`, diff --git a/CHANGELOG.md b/CHANGELOG.md index b87c818..4c98a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,190 @@ All notable changes are recorded here. The format follows ## [Unreleased] — on `pre-release` +## [0.1.0-alpha.3] — 2026-11-10 + +Third alpha, published as a GitHub pre-release. Not for production. It is +mostly a correctness release: a review of the code found ten defects in +placement, the scheduler, the reconciler and the clients, and each is fixed +with a test that fails on the old behaviour. The default build is cut to the +adapters that have run against real backends, and the release workflow now +runs the checks before it publishes. + +What has not run for this release: the end-to-end suite and the live adapter +tests. They last ran on the code before these changes (see `Verified` under +0.1.0-alpha.2 and the sections below). The fixes are covered by in-process +tests and, for the store, by the Postgres conformance job in CI. + +Upgrading from 0.1.0-alpha.2: + +- A config file or API call that names one of the 13 experimental adapter + kinds is refused by the published image (first section below). +- A client that lists sandboxes by status needs to add `pausing`. +- `PATCH /v1/sandboxes/{id}` with `ttlSeconds: 0` answers 400; use `DELETE`. + +### Documentation and CI + +- The SDK READMEs install the SDKs from this repository. Neither is + published: `pip install differently` installs an unrelated project of that + name, and `npm install @differently/sdk` finds nothing. Their method + tables no longer list `admin.agents.list`, which does not exist. +- `make vet` and CI compile and vet the end-to-end and live test files + (`-tags "e2e live"`), which no job runs, so they cannot stop compiling + unnoticed. +- The release workflow runs gofmt, vet, the Go tests (default and + experimental builds) and the Helm lint before it publishes binaries, the + chart or images. +- The chart's `appVersion` names this release, so installing the chart from + a checkout pulls an image that exists. It used to name `0.1.0`, which was + never published; the air-gapped and day-2 guides named the same tag. + +### Changed (breaking): the default build holds only the verified adapters + +The published image, the release binaries and `make build` now contain the +`opensandbox`, `daytona` and `freestyle` adapters, the `remote` plugin +protocol and the dev-mode `devhost` and `fake`. The other 13 adapter kinds +are experimental and compile in only with the Go build tag +`experimental_adapters`: `agent-sandbox`, `cubesandbox`, `e2b`, `boat`, +`morph`, `vercel`, `fly`, `blaxel`, `aws-agentcore`, `azure-sandboxes`, +`azure-sessions`, `gcp-sandboxes` and `northflank`. Apart from +`agent-sandbox`, which ran live on a kind cluster, they have only been +tested against fake servers. No adapter code was removed, and CI still +builds, vets and tests it with the tag. + +- **If you use one of the 13 kinds**, the next published image will not + start with a config file that names it: the error names the pool, the + kind and the build tag. A pool of such a kind added earlier through the + API stays registered and reports `unavailable` with the same reason. + Build the server with `go build -tags experimental_adapters`, or the image + with `--build-arg GO_TAGS=experimental_adapters` + ([provider catalog](docs/providers/README.md#what-the-server-contains)). +- `POST /v1/admin/pools` answers 400 `invalid_request` for these kinds on a + default build. +- Helm: pools of these kinds are refused unless `server.experimentalAdapters` + is `true`, which you set together with a `server.image` built with the + tag. +- The dashboard, the console and `differently admin pool add --help` list + only the default kinds. Experimental pools are added in the config file, + with the CLI or through the API. +- `make verify` and CI gained `make experimental` (build, vet and test with + the tag). The live tests of `agent-sandbox` and `cubesandbox` now need + `-tags live,experimental_adapters`. + +### Fixed (placement, scheduler and clients) + +Found by reading the code, each with a test that fails on the old behaviour. +None has run against a live backend. + +- Placement no longer treats a policy it could not read as a policy that + does not exist. During a database error, a sandbox whose organization sets + `external: never` or denies a pool could be placed with that scope left + out. The sandbox now stays queued, and create, the placement explanation + and `GET /v1/capabilities` answer 503 `provider_unavailable`. +- The reconciler never deletes or adopts a backend instance that carries no + sandbox ID. The `daytona` and `freestyle` adapters check ownership + themselves instead of trusting the provider's label or metadata filter, + and Daytona's create reuses a sandbox only when its label carries the ID. + A provider that ignored the filter would have had every sandbox in the + account deleted as an orphan. The other provider adapters still rely on + their backend's filter for the installation match. +- A negative `ttlSeconds` on create is rejected; it used to pass the + `maxTTLSeconds` quota and give a sandbox with no expiry. A `PATCH` must set + a positive `ttlSeconds`: 0 used to delete the sandbox within a second and + now answers 400, so a client that ended sandboxes that way needs `DELETE`. +- A pool that stops answering no longer slows placement on the others. Pool + probes and the reconciler ran on the scheduler loop and held it for their + timeouts (5 s per probe, 30 s per list); they now run beside it. +- A delete or a TTL extension that lands while the scheduler is deciding now + wins. Expiry, the reconciler's "failed" and crash adoption wrote the status + without reading the record again, so an extended sandbox could still be + expired and a deleted one could come back as running. +- OpenSandbox, Daytona and Freestyle are given the TTL plus five minutes as + their own deletion deadline. Theirs counts from the create request and + Differently's from the moment the sandbox is ready, so the backend deleted + first and the sandbox ended as failed instead of expired. +- A pool keeps its working adapter when its credential cannot be read for a + moment. One failed read used to leave the pool without an adapter until a + later refresh. A deleted secret or file still takes the pool down, and a + changed value still rebuilds the adapter. +- The CLI uses the dev server's operator token only for `localhost`, + `*.localhost` and loopback addresses. It used to send it to any URL + containing "localhost", and not to `127.0.0.1`. +- A streamed exec through the Go client (`differently sandbox exec`) is no + longer cut off after ten minutes. +- The audit views show the latest events. `differently usage audit`, the + dashboard and the console asked for the first page, which is the oldest + events, so anything after the first 100 or 200 never appeared. + `GET /v1/audit-events` takes `order=desc` (newest first) and `beforeSeq`; + the TypeScript and Python SDKs take `order` and `beforeSeq`/`before_seq`. +- CI: the Python SDK job runs on `ubuntu-24.04`, the last runner image with + a Python 3.9 build; on `ubuntu-latest` it failed at setup. + +### Fixed (lifecycle) + +- A sandbox being paused reports the new status `pausing` instead of `running` + and refuses exec, file and port calls. A Fast Sandbox VM runs on for minutes + after its memory dump, and what it did then was lost on resume while every + call reported success. A resume during a pause calls it off on Fast Sandbox, + keeping the sandbox as it is; on other backends it waits for the pause. A + client that lists sandboxes by status needs to add `pausing`. +- Idle preemption no longer pauses sandboxes in use. Without an idle timeout of + their own their activity was never recorded, so they all looked idle a minute + after starting, and a running command now counts as activity until it ends. + +### Added (capacity) + +- A paused sandbox on a pool that checkpoints on pause + (`lifecycle.pause-checkpoint`: OpenSandbox Fast Sandbox, Freestyle) gives + its slot back. Resuming takes one again; on a full pool the resume waits in + a queue (202) and runs when a slot frees. +- Pool preemption (`preemption.mode: idle | lru`) pauses running sandboxes to + make room for waiting work; `preemptible: false` opts a sandbox out. +- Lifecycle limits `maxRunSeconds` and `maxPausedSeconds` (request, `PATCH` + and operator defaults). +- Capacity metrics (`differently_pool_slots`, `differently_resume_wait_seconds`, + `differently_preemptions_total` and more) and a capacity model, + `cmd/differently-capsim`, with results in `docs/scheduling/capacity.md`. +- The TypeScript and Python SDKs take `maxRunSeconds`, `maxPausedSeconds` + and `preemptible` on create and update, and return `pausedAt`, + `slotReleased`, `resumeRequestedAt`, and a pool's `preemption` and + `released`. The console marks queued resumes, released slots and idle + sandboxes, and the Providers page counts paused sandboxes that hold no slot. +- `differently_pause_seconds{pool}`: how long each pause Differently asks for + takes on its backend, the time a checkpointing pool needs to free a slot. +- `differently-capsim -pause` and `-resume`; the Workbot model's pause is the + measured 182 s. + +### Documentation + +- `docs/research/fast-sandbox-pause-time.md`: where a Workbot desktop's + pause on Fast Sandbox spends its time (memory dump, a checksum pass that + reads the whole checkpoint back, a single-stream upload, all limited by + the state-root disk), what was measured before and after leaving templates + sparse (median 216 s → 180 s, resumes unchanged, state kept), ranked + candidates, and two Fast Sandbox patches (sparse pull, parallel multipart + upload). The pools guide gains "Shortening pauses"; `test/pause` holds the + harness that measured it. +- `docs/research/fast-sandbox-shared-image-cache.patch`: stops Fast Sandbox's + image cache GC deleting the checkpoints paused sandboxes resume from, which + turned a 2 s resume into two minutes. +- `docs/deploy/azure-vms.md`: a verified install on two Azure VMs with Fast + Sandbox microVMs, managed Postgres, ACR and Key Vault, serving OpenWork's + Workbot computers, and what it took (guest kernel, template registry, + Key Vault CSI on VMs, ACR token scopes, sandbox network policy). +- The OpenWork guide records the end-to-end run, Freestyle template slugs + and OpenSandbox's maximum timeout; the e2e suite runs on installs with + several pools and replicas, and gives Fast Sandbox checkpoints time. +- The README, roadmap, feature parity, end-to-end and keys pages, and the + website now state the same verified results: the end-to-end suite on a + Fast Sandbox pool, two-node k3s, and stored provider keys in use on Azure. + The website lists capacity and the admin console, and its sidebar gains + Capacity, Keys and providers, OpenWork and Maintaining. +- `AGENTS.md` and `docs/maintaining.md` record what building and releasing + taught so far: where capacity, the console and the website live and how to + check them, the lists a new provider kind touches, new traps (Fast Sandbox + clock and checkpoints, template builds, zero times, CI on conflicting PRs), + and how release tags and `main` syncs are done. + ### Added (admin console) - `console/`: an admin app built with Astro, separate from the server. It has @@ -402,6 +586,7 @@ OpenSandbox server on its Docker runtime. beyond unit and in-process API tests: no live provider account, real cluster or multi-replica run yet. -[Unreleased]: https://github.com/different-ai/differently/compare/v0.1.0-alpha.2...pre-release +[Unreleased]: https://github.com/different-ai/differently/compare/v0.1.0-alpha.3...pre-release +[0.1.0-alpha.3]: https://github.com/different-ai/differently/releases/tag/v0.1.0-alpha.3 [0.1.0-alpha.2]: https://github.com/different-ai/differently/releases/tag/v0.1.0-alpha.2 [0.1.0-alpha.1]: https://github.com/different-ai/differently/releases/tag/v0.1.0-alpha.1 diff --git a/Makefile b/Makefile index c08dfbb..2cd6bd4 100644 --- a/Makefile +++ b/Makefile @@ -4,29 +4,45 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.1. COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) LDFLAGS := -s -w -X github.com/different-ai/differently/internal/version.Version=$(VERSION) -X github.com/different-ai/differently/internal/version.Commit=$(COMMIT) BINS := differently-server differently +# Go build tags for `make build`, `build-linux` and `images`. The default (none) +# is the published build: opensandbox, daytona, freestyle, remote and devhost. +# GO_TAGS=experimental_adapters adds the other adapter kinds (docs/providers). +GO_TAGS ?= +EXPERIMENTAL_TAG := experimental_adapters PG_PORT ?= 55432 -.PHONY: build build-linux test test-postgres fmt-check vet sdk-test helm-lint verify dev images clean +.PHONY: build build-linux test test-postgres fmt-check vet experimental sdk-test helm-lint verify dev images clean build: @mkdir -p bin - CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/ $(addprefix ./cmd/,$(BINS)) + CGO_ENABLED=0 go build -trimpath -tags "$(GO_TAGS)" -ldflags "$(LDFLAGS)" -o bin/ $(addprefix ./cmd/,$(BINS)) build-linux: @for arch in amd64 arm64; do \ mkdir -p bin/linux-$$arch; \ - CGO_ENABLED=0 GOOS=linux GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o bin/linux-$$arch/ $(addprefix ./cmd/,$(BINS)) || exit 1; \ + CGO_ENABLED=0 GOOS=linux GOARCH=$$arch go build -trimpath -tags "$(GO_TAGS)" -ldflags "$(LDFLAGS)" -o bin/linux-$$arch/ $(addprefix ./cmd/,$(BINS)) || exit 1; \ done fmt-check: @out=$$(gofmt -l cmd internal pkg api); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi +# The second pass compiles and vets the tests that need a real stack or a live +# backend (test/e2e, the adapters' live tests). Nothing runs them here, so +# without it they can stop compiling unnoticed. vet: go vet ./... + go vet -tags "e2e live" ./... test: go test -race -count=1 ./... +# The adapter kinds outside the default build must keep compiling and passing +# their fake-server tests: build, vet and test everything with the tag. +experimental: + go build -tags $(EXPERIMENTAL_TAG) ./... + go vet -tags "$(EXPERIMENTAL_TAG) e2e live" ./... + go test -race -count=1 -tags $(EXPERIMENTAL_TAG) ./... + # Runs the store conformance suite against a disposable Postgres container. test-postgres: @docker rm -f differently-pg-test >/dev/null 2>&1 || true @@ -46,13 +62,13 @@ helm-lint: helm template differently deploy/helm/differently -f $$f >/dev/null || exit 1; \ done -verify: fmt-check vet test sdk-test helm-lint +verify: fmt-check vet test experimental sdk-test helm-lint dev: build ./bin/differently-server --dev images: - docker build -f build/server.Dockerfile -t ghcr.io/different-ai/differently-server:$(VERSION) . + docker build -f build/server.Dockerfile --build-arg GO_TAGS=$(GO_TAGS) -t ghcr.io/different-ai/differently-server:$(VERSION) . docker build -f build/cli.Dockerfile -t ghcr.io/different-ai/differently-cli:$(VERSION) . clean: diff --git a/README.md b/README.md index d457be0..7afd4f1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- Differently: your apps and agents call one API; differently-server handles tenancy, quotas, policy and audit, and places each sandbox on OpenSandbox or your own backend inside your infrastructure, or on a managed provider such as E2B or Daytona only if policy allows + Differently: your apps and agents call one API; differently-server handles tenancy, quotas, policy and audit, and places each sandbox on OpenSandbox or your own backend inside your infrastructure, or on a managed provider such as Daytona or Freestyle only if policy allows

@@ -30,10 +30,12 @@ account of it: - by default on [OpenSandbox](https://github.com/opensandbox-group/OpenSandbox) on your own Docker hosts, Kubernetes clusters or microVM nodes; -- on [15 other built-in provider adapters](docs/providers/README.md), from - E2B, Daytona and Vercel to the AWS, Azure and Google Cloud sandbox - services, or a plugin for any other backend, only where your policies - allow it. +- on Daytona or Freestyle, or a plugin for any other backend, only where + your policies allow it; +- on 13 more [provider adapters](docs/providers/README.md#what-the-server-contains), + from E2B and Vercel to the AWS, Azure and Google Cloud sandbox services, + which are experimental: tested against fake servers and left out of the + published build unless you compile them in. Apache-2.0, fully self-hosted, no telemetry. Built by the [OpenWork](https://github.com/different-ai) team. @@ -43,10 +45,11 @@ Apache-2.0, fully self-hosted, no telemetry. Built by the
▶ Watch the 36-second intro

-> **Pre-release ([`v0.1.0-alpha.2`](https://github.com/different-ai/differently/releases)).** +> **Pre-release ([`v0.1.0-alpha.3`](https://github.com/different-ai/differently/releases)).** > The whole platform is implemented and tested end to end against real -> OpenSandbox servers (Docker and Kubernetes runtimes). It isn't ready for -> production workloads yet: see [what is verified](#status). +> OpenSandbox servers (Docker, Kubernetes and Fast Sandbox microVM +> runtimes). It isn't ready for production workloads yet: see +> [what is verified](#status). ## Why @@ -67,7 +70,8 @@ that layer. | **Multi-tenant from day one** | Organizations, projects, scoped API keys with rotation, quotas and fair queueing. | | **Provable** | A tamper-evident audit chain, a placement explanation for every sandbox, and an operator trust report. | | **Keys stay with you** | Provider keys are stored sealed or by reference. Switch where new sandboxes go with one command. | -| **Self-hosted, all of it** | API, dashboard, scheduler, queue, audit and metrics run on your infrastructure. No phone-home. | +| **Capacity that follows use** | Paused microVMs give their pool slot back, resumes queue on a full pool, and preemption and run limits keep pools from filling up. | +| **Self-hosted, all of it** | API, dashboard and admin console, scheduler, queue, audit and metrics run on your infrastructure. No phone-home. | ## Get started @@ -92,7 +96,7 @@ Differently and a real OpenSandbox server, as the end-to-end suite runs it: ```bash make e2e-prepare e2e-up # API on :18090, OpenSandbox on :18091 -make e2e # optional: the 12 end-to-end tests +make e2e # optional: the end-to-end tests ``` **3. Deploy it for your team.** On [one Linux VM on any cloud](docs/deploy/single-vm.md) @@ -101,13 +105,15 @@ make e2e # optional: the 12 end-to-end tests images from the [latest release](https://github.com/different-ai/differently/releases). Then call it from code ([TypeScript](sdk/typescript), [Python](sdk/python), -[Go](pkg/client)): +[Go](pkg/client)). The SDKs are not on npm or PyPI yet; each README says how +to install it from this repository. ```ts import { Differently } from "@differently/sdk"; const differently = new Differently({ apiKey: process.env.DIFFERENTLY_API_KEY }); const sandbox = await differently.sandboxes.create({ + // projectId: "prj_default", // needed with an operator key, such as the dev token profile: "small", placement: { strategy: "private-first", external: "never" }, }); @@ -143,22 +149,27 @@ prerequisites, [day-2 operations](docs/deploy/day-2.md) and the - the published `v0.1.0-alpha.1` chart and images install from public artifacts alone; - real OpenSandbox responses are recorded and replayed in every `go test`; -- the `agent-sandbox` adapter against a Kubernetes cluster (kind); +- the experimental `agent-sandbox` adapter against a Kubernetes cluster + (kind); - the Daytona (container class) and Freestyle adapters against live accounts: exec, files, port URLs, stop/start and snapshots on both, and egress policy, pause/resume, memory snapshots and fork on Freestyle; - [OpenWork](https://github.com/different-ai/openwork)'s Workbot computers run - through Differently as a sandbox gateway, behind a flag; -- Fast Sandbox microVMs on one node: the adapter scenario (recorded), and + through Differently as a sandbox gateway, behind a flag, desktops on Fast + Sandbox microVMs included, driven by OpenWork's runner; +- Fast Sandbox microVMs on Azure (two-node k3s with one KVM node): the + adapter scenario (recorded), the end-to-end suite on the microVM pool, and Workbot's desktop streamed over a port URL, paused and resumed with its memory, idled and woken by a request (pause takes minutes). **Not verified yet:** -- the end-to-end suite on Fast Sandbox microVMs; -- multi-node clusters; +- node drains, and more than one KVM node; - managed EKS, GKE and AKS; - the other 12 provider adapters against live accounts: each is tested - against a fake of its provider's published API. + against a fake of its provider's published API. These 12 and + `agent-sandbox` are experimental and not in the published image or + binaries; a server built with `-tags experimental_adapters` has them + ([what the server contains](docs/providers/README.md#what-the-server-contains)). Each of the 26 providers has a page with its tested status, capabilities and configuration in the [provider catalog](docs/providers/README.md). The @@ -176,8 +187,11 @@ against global, organization and project policy, ranks what is left, and records why. Adapters talk to the backends: - `opensandbox`, the default private runtime; -- 15 more provider adapters, from `agent-sandbox` and `cubesandbox` you run - to `e2b`, `daytona`, `vercel` and the cloud providers' sandbox services +- `daytona` and `freestyle`, managed providers that receive work only when + policy allows it; +- 13 experimental adapters behind the `experimental_adapters` build tag, + from `agent-sandbox` and `cubesandbox` you run to `e2b`, `vercel` and the + cloud providers' sandbox services ([provider catalog](docs/providers/README.md)); - `remote`, any service speaking the [adapter protocol](docs/adapters/specification.md). @@ -217,6 +231,7 @@ under a control plane you run. See the | `internal/` | Server, scheduler, reconciler, policy, store (memory and Postgres), auth, audit, secrets, adapters | | `api/openapi.yaml` | The HTTP API contract (a test keeps it in sync with the router) | | `sdk/typescript`, `sdk/python` | Official SDKs | +| `console/` | The admin console: static files for any host, or Node with sign-in | | `deploy/` | Helm chart (control plane only), Terraform (AWS) | | `docs/` | Guides, deployment, architecture, security, roadmap | | `site/` | The website and docs site | diff --git a/api/openapi.yaml b/api/openapi.yaml index 2d961ae..659d2cc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -152,10 +152,10 @@ paths: post: { tags: [Lifecycle], summary: Start a stopped sandbox from its disk, responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } } /v1/sandboxes/{id}/pause: parameters: [{ $ref: "#/components/parameters/SandboxID" }] - post: { tags: [Lifecycle], summary: Pause (freeze vCPUs; memory stays resident), responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } } + post: { tags: [Lifecycle], summary: "Pause (freeze vCPUs; memory stays resident). The sandbox is pausing, and refuses exec, file and port calls, until this finishes; on a sandbox already pausing, 202 with the sandbox; wait for paused", responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "202": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } } /v1/sandboxes/{id}/resume: parameters: [{ $ref: "#/components/parameters/SandboxID" }] - post: { tags: [Lifecycle], summary: Resume a paused sandbox, responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } } + post: { tags: [Lifecycle], summary: "Resume a paused sandbox. During a pause, this calls it off where the backend can and keeps the sandbox as it is; elsewhere it is queued. On a full pool whose pause released the slot, the resume is queued. Queued: 202 with the sandbox still pausing or paused; wait for running", responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "202": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } } /v1/sandboxes/{id}/snapshots: parameters: [{ $ref: "#/components/parameters/SandboxID" }] post: @@ -247,8 +247,14 @@ paths: get: tags: [Usage and audit] summary: List audit events in sequence order (audit:read) + description: >- + Oldest first by default: pass the page's nextCursor as afterSeq for the + next page. With order=desc the newest events come first, and nextCursor + is the beforeSeq of the next, older page. parameters: - - { name: afterSeq, in: query, schema: { type: integer } } + - { name: afterSeq, in: query, schema: { type: integer }, description: Only events after this sequence number } + - { name: beforeSeq, in: query, schema: { type: integer }, description: Only events before this sequence number } + - { name: order, in: query, schema: { type: string, enum: [asc, desc], default: asc }, description: desc returns the newest events first } - { name: resource, in: query, schema: { type: string } } - { name: action, in: query, schema: { type: string } } - { $ref: "#/components/parameters/Limit" } @@ -375,7 +381,7 @@ components: properties: { keyId: { type: string }, orgId: { type: string }, projectId: { type: string }, scopes: { type: array, items: { type: string } }, operator: { type: boolean } } SandboxStatus: type: string - enum: [queued, provisioning, running, stopped, paused, terminating, terminated, failed] + enum: [queued, provisioning, running, stopped, pausing, paused, terminating, terminated, failed] Resources: type: object properties: { cpu: { type: integer, minimum: 1 }, memoryMiB: { type: integer, minimum: 128 }, diskGiB: { type: integer, minimum: 0 } } @@ -400,15 +406,18 @@ components: capabilities: { type: array, items: { $ref: "#/components/schemas/Capability" } } classification: { type: string, description: "Operator-defined, e.g. private-only, internal, public" } placement: { $ref: "#/components/schemas/Placement" } - ttlSeconds: { type: integer } + ttlSeconds: { type: integer, minimum: 0, description: "Delete the sandbox this long after it starts. 0 uses the operator default; a negative value is rejected" } env: { type: object, additionalProperties: { type: string }, description: Write-only; never returned } labels: { type: object, additionalProperties: { type: string } } priority: { type: integer } fromSnapshot: { type: string } idleTimeoutSeconds: { type: integer, description: "Stop or pause after this many seconds with no exec, file, port or keepalive call (at least 60); the next such call wakes it. 0 uses the operator default, -1 turns it off" } + maxRunSeconds: { type: integer, description: "Pause (or stop) after running this long without a break, busy or not (at least 60); the next call resumes it. 0 uses the operator default, -1 turns it off" } + maxPausedSeconds: { type: integer, description: "Delete after staying paused or stopped this long (at least 60). 0 uses the operator default, -1 turns it off" } + preemptible: { type: boolean, description: "false keeps the scheduler from pausing this sandbox to make room on a full pool. Default true" } UpdateSandboxRequest: type: object - properties: { labels: { type: object, additionalProperties: { type: string } }, ttlSeconds: { type: integer }, idleTimeoutSeconds: { type: integer } } + properties: { labels: { type: object, additionalProperties: { type: string } }, ttlSeconds: { type: integer, minimum: 1, description: "New lifetime, counted from now; must be positive (delete the sandbox to end it now)" }, idleTimeoutSeconds: { type: integer }, maxRunSeconds: { type: integer }, maxPausedSeconds: { type: integer }, preemptible: { type: boolean } } Sandbox: type: object required: [id, orgId, projectId, status, resources, classification, createdAt] @@ -434,8 +443,14 @@ components: ttlSeconds: { type: integer } expiresAt: { type: string, format: date-time } idleTimeoutSeconds: { type: integer } + maxRunSeconds: { type: integer } + maxPausedSeconds: { type: integer } + preemptible: { type: boolean } + pausedAt: { type: string, format: date-time } + slotReleased: { type: boolean, description: "A paused sandbox on a pool that checkpoints on pause holds no pool slot until it resumes" } + resumeRequestedAt: { type: string, format: date-time, description: Set while a resume waits for a free slot or for a pause to finish } lastActivityAt: { type: string, format: date-time } - idledAt: { type: string, format: date-time, description: Set while stopped or paused for being idle; the next exec, file, port or keepalive call wakes it } + idledAt: { type: string, format: date-time, description: Set while stopped, pausing or paused for being idle; the next exec, file, port or keepalive call wakes it } fromSnapshot: { type: string } attempts: { type: integer } createdBy: { type: string } diff --git a/build/server.Dockerfile b/build/server.Dockerfile index f9e8bc8..10efb14 100644 --- a/build/server.Dockerfile +++ b/build/server.Dockerfile @@ -1,11 +1,14 @@ # differently-server image: control plane plus the CLI (used by `helm test`). +# The published image is built with no Go build tags. To include the +# experimental adapter kinds (docs/providers/README.md): +# docker build -f build/server.Dockerfile --build-arg GO_TAGS=experimental_adapters . FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build -ARG TARGETOS TARGETARCH VERSION=0.1.0-dev COMMIT=unknown +ARG TARGETOS TARGETARCH VERSION=0.1.0-dev COMMIT=unknown GO_TAGS="" WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \ +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags "${GO_TAGS}" \ -ldflags "-s -w -X github.com/different-ai/differently/internal/version.Version=${VERSION} -X github.com/different-ai/differently/internal/version.Commit=${COMMIT}" \ -o /out/ ./cmd/differently-server ./cmd/differently diff --git a/cmd/differently-capsim/main.go b/cmd/differently-capsim/main.go new file mode 100644 index 0000000..d7f8377 --- /dev/null +++ b/cmd/differently-capsim/main.go @@ -0,0 +1,40 @@ +// Command differently-capsim prints how a pool's capacity strategies compare +// for Workbot-like users: see internal/capsim. +// +// go run ./cmd/differently-capsim -slots 4 -users 4,6,8,12,16,24 +// go run ./cmd/differently-capsim -slots 4 -users 12 -pause 90 +package main + +import ( + "flag" + "fmt" + "strconv" + "strings" + + "github.com/different-ai/differently/internal/capsim" +) + +func main() { + slots := flag.Int("slots", 4, "pool slots (maxConcurrency)") + users := flag.String("users", "4,6,8,12,16,24", "comma-separated user counts") + days := flag.Int("days", 5, "days to simulate") + seeds := flag.Int("seeds", 5, "runs per row, averaged") + pause := flag.Int("pause", capsim.Workbot(capsim.Hold, 0, 0).PauseTime, "seconds a pause takes to free its slot") + resume := flag.Int("resume", capsim.Workbot(capsim.Hold, 0, 0).ResumeTime, "seconds a resume takes once it holds a slot") + flag.Parse() + fmt.Println("| users | strategy | users on the pool | external computer-hours | slot utilization | wait p50 | wait p95 | preemptions/day | interrupted sessions/day |") + fmt.Println("|---:|---|---:|---:|---:|---:|---:|---:|---:|") + for _, u := range strings.Split(*users, ",") { + n, err := strconv.Atoi(strings.TrimSpace(u)) + if err != nil { + panic(err) + } + for _, s := range []capsim.Strategy{capsim.Hold, capsim.Release, capsim.PreemptIdle, capsim.PreemptLRU} { + cfg := capsim.Workbot(s, *slots, n) + cfg.Days, cfg.PauseTime, cfg.ResumeTime = *days, *pause, *resume + r := capsim.Average(cfg, *seeds) + fmt.Printf("| %d | %s | %.0f%% | %.0f | %.0f%% | %.0f s | %.0f s | %.1f | %.1f |\n", n, s, + 100*r.PrivateUsers, r.ExternalHours, 100*r.Utilization, r.WaitP50, r.WaitP95, r.PreemptionsPerDay, r.InterruptionsPerDay) + } + } +} diff --git a/cmd/differently/main.go b/cmd/differently/main.go index c8a71f8..8844827 100644 --- a/cmd/differently/main.go +++ b/cmd/differently/main.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net" "net/url" "os" "os/signal" @@ -58,8 +59,8 @@ func load() settings { if s.URL == "" { s.URL = "http://localhost:8080" } - // Local dev servers write an operator token; use it for localhost. - if s.APIKey == "" && strings.Contains(s.URL, "localhost") { + // Local dev servers write an operator token; use it for this machine only. + if s.APIKey == "" && isLoopbackURL(s.URL) { home, _ := os.UserHomeDir() if b, err := os.ReadFile(filepath.Join(home, ".differently", "dev", "token")); err == nil { s.APIKey = strings.TrimSpace(string(b)) @@ -71,6 +72,23 @@ func load() settings { return s } +// isLoopbackURL reports whether raw points at this machine: localhost, a +// *.localhost name or a loopback address. The dev operator token is sent +// nowhere else; a host that merely contains "localhost" (for example +// localhost.example.com) is someone else's server. +func isLoopbackURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func cli() *client.Client { s := load() return client.New(s.URL, s.APIKey) @@ -614,7 +632,7 @@ func poolAddCmd() *cobra.Command { return raw("POST", "/v1/admin/pools", body) }} f := cmd.Flags() - f.StringVar(&adapterKind, "adapter", "", "opensandbox, daytona, freestyle, remote, vercel, fly, agent-sandbox, cubesandbox, aws-agentcore, azure-sandboxes, azure-sessions, gcp-sandboxes, northflank, boat, e2b, morph or blaxel") + f.StringVar(&adapterKind, "adapter", "", "opensandbox, daytona, freestyle or remote; a server built with -tags experimental_adapters has more (docs/providers)") f.StringVar(&trust, "trust", "", "external (someone else runs it) or private (you do); managed providers are always external, self-hostable kinds must set one") f.StringArrayVar(&settings, "config", nil, "adapter setting key=value (repeatable), e.g. apiURL=https://app.daytona.io/api") f.StringVar(®ion, "region", "", "region label") @@ -654,7 +672,7 @@ func usageCmd() *cobra.Command { cmd.Flags().String("group-by", "pool", "pool, project, org or trust") cmd.AddCommand( &cobra.Command{Use: "quota", Short: "Show quota and current consumption", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/quotas", nil) }}, - &cobra.Command{Use: "audit", Short: "List audit events", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/audit-events?limit=100", nil) }}, + &cobra.Command{Use: "audit", Short: "List the latest audit events, newest first", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/audit-events?limit=100&order=desc", nil) }}, ) return cmd } diff --git a/cmd/differently/main_test.go b/cmd/differently/main_test.go index f410185..a71454f 100644 --- a/cmd/differently/main_test.go +++ b/cmd/differently/main_test.go @@ -29,3 +29,25 @@ func TestReportErrorNeverSilent(t *testing.T) { }) } } + +// The dev operator token goes to this machine only. +func TestDevTokenOnlyForLoopback(t *testing.T) { + for raw, want := range map[string]bool{ + "http://localhost:8080": true, + "http://LOCALHOST:8080/": true, + "http://api.localhost:8080": true, + "http://127.0.0.1:18180": true, + "http://[::1]:8080": true, + "https://localhost.example.com": false, + "https://differently.example.com": false, + "https://example.com/localhost": false, + "https://example.com/?next=localhost": false, + "https://notlocalhost": false, + "http://10.0.0.5:8080": false, + "://bad": false, + } { + if got := isLoopbackURL(raw); got != want { + t.Errorf("isLoopbackURL(%q) = %v, want %v", raw, got, want) + } + } +} diff --git a/console/src/lib/providers.ts b/console/src/lib/providers.ts index 5eb0823..45a198b 100644 --- a/console/src/lib/providers.ts +++ b/console/src/lib/providers.ts @@ -1,6 +1,9 @@ -// Provider kinds the console can add, with their main config fields. Kept in -// step with the built-in dashboard (internal/dashboard/static/app.js); the full -// list of keys per provider is in docs/providers/. +// Provider kinds the console can add, with their main config fields: the kinds +// in the default server build. Experimental kinds (a server built with +// -tags experimental_adapters) are added with the CLI, the API or the config +// file. Kept in step with the built-in dashboard +// (internal/dashboard/static/app.js); the full list of keys per provider is in +// docs/providers/. export interface ProviderField { key: string; label: string; choices?: string[]; required?: boolean } export interface ProviderKind { kind: string; label: string; trust: "external" | null; fields: ProviderField[] } @@ -59,141 +62,6 @@ export const providerKinds: ProviderKind[] = [ } ] }, - { - "kind": "boat", - "label": "Boat", - "trust": "external", - "fields": [ - { - "key": "apiURL", - "label": "API URL (default https://boat.dev/api/v1)" - }, - { - "key": "type", - "label": "smallest size", - "choices": [ - "small", - "default", - "large", - "xlarge" - ] - }, - { - "key": "snapshot", - "label": "saved snapshot" - }, - { - "key": "environment", - "label": "environment" - }, - { - "key": "org", - "label": "organization" - }, - { - "key": "autoStopSeconds", - "label": "auto-stop seconds for sandboxes without a TTL" - }, - { - "key": "portAccess", - "label": "port URLs", - "choices": [ - "token", - "public" - ] - } - ] - }, - { - "kind": "e2b", - "label": "E2B", - "trust": null, - "fields": [ - { - "key": "apiURL", - "label": "API URL (default https://api.e2b.app)" - }, - { - "key": "domain", - "label": "domain (default e2b.app)" - }, - { - "key": "template", - "label": "template (default base)" - }, - { - "key": "maxLifetimeSeconds", - "label": "plan's maximum runtime in seconds (default 3600)" - }, - { - "key": "ports", - "label": "ports", - "choices": [ - "private", - "public" - ] - }, - { - "key": "egress", - "label": "egress", - "choices": [ - "public", - "none" - ] - } - ] - }, - { - "kind": "morph", - "label": "Morph Cloud", - "trust": "external", - "fields": [ - { - "key": "apiURL", - "label": "API URL (default https://cloud.morph.so/api)" - }, - { - "key": "snapshot", - "label": "snapshot" - }, - { - "key": "image", - "label": "image (default morphvm-minimal)" - }, - { - "key": "portAuth", - "label": "port auth", - "choices": [ - "none", - "api_key" - ] - } - ] - }, - { - "kind": "blaxel", - "label": "Blaxel", - "trust": "external", - "fields": [ - { - "key": "workspace", - "label": "workspace", - "required": true - }, - { - "key": "apiURL", - "label": "API URL (default https://api.blaxel.ai/v0)" - }, - { - "key": "region", - "label": "region (e.g. us-pdx-1)" - }, - { - "key": "image", - "label": "image (default blaxel/base-image:latest)" - } - ] - }, { "kind": "opensandbox", "label": "OpenSandbox", @@ -277,278 +145,5 @@ export const providerKinds: ProviderKind[] = [ "required": true } ] - }, - { - "kind": "vercel", - "label": "Vercel Sandbox", - "trust": "external", - "fields": [ - { - "key": "project", - "label": "project ID or name", - "required": true - }, - { - "key": "team", - "label": "team ID" - }, - { - "key": "region", - "label": "region (default iad1)" - }, - { - "key": "image", - "label": "image" - }, - { - "key": "architecture", - "label": "architecture", - "choices": [ - "amd64", - "arm64" - ] - }, - { - "key": "ports", - "label": "ports (comma separated)" - }, - { - "key": "maxTimeoutSeconds", - "label": "max session seconds (default 2700)" - } - ] - }, - { - "kind": "fly", - "label": "Fly.io Machines", - "trust": "external", - "fields": [ - { - "key": "app", - "label": "Fly app name", - "required": true - }, - { - "key": "region", - "label": "region" - }, - { - "key": "image", - "label": "image" - }, - { - "key": "cpuKind", - "label": "CPU kind", - "choices": [ - "shared", - "performance" - ] - }, - { - "key": "suspend", - "label": "suspend for pause", - "choices": [ - "true", - "false" - ] - } - ] - }, - { - "kind": "agent-sandbox", - "label": "agent-sandbox (Kubernetes)", - "trust": null, - "fields": [ - { - "key": "namespace", - "label": "namespace (default default)" - }, - { - "key": "apiServer", - "label": "API server URL (default: in-cluster)" - }, - { - "key": "caFile", - "label": "API server CA file on the server" - }, - { - "key": "image", - "label": "image (default ubuntu:24.04)" - }, - { - "key": "warmPool", - "label": "warm pool (SandboxWarmPool name)" - }, - { - "key": "runtimeClassName", - "label": "RuntimeClass (e.g. gvisor)" - }, - { - "key": "isolation", - "label": "isolation", - "choices": [ - "container", - "microvm" - ] - }, - { - "key": "volumeSize", - "label": "volume size, e.g. 10Gi (enables stop/start)" - } - ] - }, - { - "kind": "cubesandbox", - "label": "CubeSandbox", - "trust": null, - "fields": [ - { - "key": "apiURL", - "label": "CubeAPI URL", - "required": true - }, - { - "key": "templateId", - "label": "template id" - }, - { - "key": "sandboxURL", - "label": "CubeProxy URL (header routing)" - }, - { - "key": "domain", - "label": "sandbox domain (default: CubeAPI's)" - }, - { - "key": "proxyScheme", - "label": "proxy scheme", - "choices": [ - "https", - "http" - ] - } - ] - }, - { - "kind": "aws-agentcore", - "label": "AWS AgentCore Code Interpreter", - "trust": null, - "fields": [ - { - "key": "region", - "label": "AWS region", - "required": true - }, - { - "key": "codeInterpreter", - "label": "code interpreter (default aws.codeinterpreter.v1)" - } - ] - }, - { - "kind": "azure-sandboxes", - "label": "Azure Container Apps Sandboxes", - "trust": null, - "fields": [ - { - "key": "endpoint", - "label": "data-plane endpoint", - "required": true - }, - { - "key": "subscriptionId", - "label": "subscription ID", - "required": true - }, - { - "key": "resourceGroup", - "label": "resource group", - "required": true - }, - { - "key": "sandboxGroup", - "label": "sandbox group", - "required": true - }, - { - "key": "diskImage", - "label": "public disk image" - }, - { - "key": "egress", - "label": "egress", - "choices": [ - "none", - "public" - ] - } - ] - }, - { - "kind": "azure-sessions", - "label": "Azure dynamic sessions (Python)", - "trust": null, - "fields": [ - { - "key": "endpoint", - "label": "pool management endpoint", - "required": true - }, - { - "key": "outputLimit", - "label": "output limit (default 1048576)" - } - ] - }, - { - "kind": "gcp-sandboxes", - "label": "Google Cloud Agent Platform sandboxes", - "trust": null, - "fields": [ - { - "key": "project", - "label": "project ID", - "required": true - }, - { - "key": "location", - "label": "location", - "required": true - }, - { - "key": "reasoningEngine", - "label": "engine ID", - "required": true - }, - { - "key": "template", - "label": "shell template ID", - "required": true - } - ] - }, - { - "kind": "northflank", - "label": "Northflank", - "trust": null, - "fields": [ - { - "key": "projectId", - "label": "project ID", - "required": true - }, - { - "key": "teamId", - "label": "team ID (organization tokens)" - }, - { - "key": "image", - "label": "image (default ubuntu:24.04)" - }, - { - "key": "plan", - "label": "plan (default nf-compute-200)" - } - ] } ]; diff --git a/console/src/lib/ui.ts b/console/src/lib/ui.ts index a991b13..abcfc15 100644 --- a/console/src/lib/ui.ts +++ b/console/src/lib/ui.ts @@ -45,7 +45,7 @@ export function mount(target: string | Element, ...nodes: Child[]): void { const tone: Record = { running: 'ok', healthy: 'ok', active: 'ok', ok: 'ok', valid: 'ok', placed: 'ok', - queued: 'warn', provisioning: 'warn', degraded: 'warn', draining: 'warn', terminating: 'warn', + queued: 'warn', provisioning: 'warn', degraded: 'warn', draining: 'warn', pausing: 'warn', terminating: 'warn', failed: 'bad', unavailable: 'bad', revoked: 'bad', denied: 'bad', rejected: 'bad', invalid: 'bad', }; export const badge = (status: string) => el('span', { class: `badge ${tone[status] ?? ''}` }, status.replace(/_/g, ' ')); diff --git a/console/src/pages/audit-log.astro b/console/src/pages/audit-log.astro index 0a38feb..bed2ebf 100644 --- a/console/src/pages/audit-log.astro +++ b/console/src/pages/audit-log.astro @@ -15,7 +15,7 @@ import PageHeader from '../components/PageHeader.astro'; const c = client(); void section('#list', async () => { - const page = await c.auditEvents.list({ limit: 200 }); + const page = await c.auditEvents.list({ limit: 200, order: 'desc' }); const events = [...page.items].sort((a, b) => b.seq - a.seq); return table([ { label: 'Time', cell: (e) => el('span', { class: 'muted' }, date(e.time)) }, diff --git a/console/src/pages/index.astro b/console/src/pages/index.astro index 20cf36b..e7402de 100644 --- a/console/src/pages/index.astro +++ b/console/src/pages/index.astro @@ -23,7 +23,7 @@ const base = import.meta.env.BASE_URL; const { items } = await c.sandboxes.list({ limit: 500 }); const count = (...s: string[]) => items.filter((x) => s.includes(x.status)).length; const stat = (label: string, value: number) => el('div', { class: 'stat' }, el('div', { class: 'label' }, label), el('div', { class: 'value' }, value)); - return [stat('Running', count('running')), stat('Queued', count('queued', 'provisioning')), stat('Stopped or paused', count('stopped', 'paused')), stat('Failed', count('failed'))]; + return [stat('Running', count('running')), stat('Queued', count('queued', 'provisioning')), stat('Stopped or paused', count('stopped', 'pausing', 'paused')), stat('Failed', count('failed'))]; }); section('#pools', async () => { const pools = await c.capabilities.list(); diff --git a/console/src/pages/providers.astro b/console/src/pages/providers.astro index f848ef8..a1661d0 100644 --- a/console/src/pages/providers.astro +++ b/console/src/pages/providers.astro @@ -19,7 +19,7 @@ import { providerKinds } from '../lib/providers'; - +