Proposal: operator workspace file tools (read/edit)
Status: Partially implemented (v0.7.3) — the core file read/write API shipped; the optional directory list + delete remain. See ROADMAP. Asked by: the Angee marketplace (install/uninstall addons). Consumer: the platform console board (TS) + platform_integrate_vcs.
Implementation status
Shipped (v0.7.3): the generic scoped file read/write API modeled on secrets — REST GET/PUT /files (etag compare-and-set, 409 on stale), GraphQL file / fileWrite, typed client FileRead/FileWrite, CLI angee file get/set, source-root path containment, and a 1 MiB UTF-8 cap — all on the new internal/store substrate (localfs backend with atomic writes + CAS etags, Lister/Deleter/Versioned capability interfaces, backend registry). See CHANGELOG v0.7.3 and docs/reference/operator-api.md.
Remaining (roadmap): the optional GET /files/list?source=&path=<dir> directory listing and DELETE /files — deferred because the marketplace's install/uninstall flow needs only read + write. The internal/store layer already exposes Lister/Deleter, so these are a surface-wiring task (REST + GraphQL + client + CLI) when a consumer needs them.
The remainder of this document is the original design context.
Why
The addon marketplace lets an operator-user install/uninstall addons from a board. "Install an addon" = add its root to the deployment's settings.yamlINSTALLED_APPS, then rebuild + restart. The deployment is a workspace/source the operator already owns (the app source), and the operator already owns the rebuild lifecycle (/stack/build, /stack/up, /stack/dev). The one missing capability is reading and editing files inside a workspace's source over the API — so the console edits settings.yaml through the operator rather than the Django app touching its own config. Keep it generic (read/edit any file in a source); settings.yaml is just the first consumer.
This keeps the Django app a normal Django app (reads settings.yaml at boot, no DB-driven settings-load), and puts config ownership where it belongs — with the operator that already owns the stack files and the lifecycle.
Scope
In scope: a generic, scoped file read / write API on the operator daemon, modeled 1:1 on the existing secrets API.
Out of scope (the client/other systems own these):
- No YAML logic. The operator reads/writes raw bytes. The
INSTALLED_APPSedit (comment-preserving) is done by the board (theyamlnpm package) on the content it read back. Don't put settings.yaml/INSTALLED_APPS knowledge in the operator. - No rebuild here. The board calls the existing
/stack/build(+ restart) after writing. The file API does not trigger builds.
Proposed API (mirror secrets)
Model the shapes, routes, client, and GraphQL on the existing secrets path (api/types.go SecretSetRequest/SecretRef; internal/operator/operator.go route registration with s.auth(...); internal/platformclient/client.goSecretGet/SecretSet; internal/operator/schema.graphql + resolvers + the gql codegen).
REST (auth-gated like /secrets/*)
GET /files?source=app&path=<relpath>→{ path, source, content, etag }(etag = content hash, e.g. sha256, for optimistic concurrency).PUT /files?source=app&path=<relpath>body{ content, etag? }→{ path, source, etag }. Ifetagis supplied and the on-disk file has changed, return409 Conflict(don't clobber concurrent edits).- (optional, later)
GET /files/list?source=app&path=<dir>→ directory entries.
Use query params (not a path segment) since path contains slashes — /secrets/{name} uses a single segment, files don't.
GraphQL (mirror secretSet)
- query
file(source: String!, path: String!): FileContent!({ path, source, content, etag }). - mutation
fileWrite(source: String!, path: String!, content: String!, etag: String): FileRef!.
Typed client (mirror SecretGet/SecretSet in platformclient/client.go)
FileRead(ctx, source, path) (api.FileContent, error)FileWrite(ctx, source, path, content, etag string) (api.FileRef, error)
Scoping & security (the important part)
- Resolve the source root via the existing source-path resolution (
workspaceSourcePath(...)/source.Pathininternal/service).sourceis one of the stack's declared sources (app,framework, …) — reject unknown source keys. - Confine to the source root. Clean the requested
pathand verify the resolved absolute path is inside the source root (reject..traversal, absolute paths, and symlink escapes — resolve symlinks then re-check the prefix). - Auth: the same bearer gate as
/secrets/*and/stack/*(s.auth). - Optional allowlist: if you want to be conservative, gate writes to a configured set of editable paths (e.g.
settings.yaml) declared in the stack manifest — see Open Questions. Reads can stay broad (within the source).
Workspace targeting
The daemon runs for one stack (--root), so source selects among that stack's sources and the marketplace only needs source=app. If/when one daemon serves multiple workspaces, add an optional workspace param resolved via workspaceSourcePath(workspaceName, slot, source) — the resolution helper already exists; the API param is the only addition.
How the console uses it (for context, not to implement)
- Board reads
app/settings.yamlviaGET /files?source=app&path=settings.yaml(keeps theetag). - On install/uninstall, the board edits
INSTALLED_APPSin the YAML (comment-preserving, client-side) andPUTs it back with theetag. - Board calls the existing
/stack/build(+ restart). On success the Djangoplatform.Addonreflection updates on the next boot; on failure the old runtime keeps serving and the board shows the build error.
Open questions for the implementer
- Write allowlist vs any-file-in-source? Broad (any file under the source) is simplest and matches "file tools in the workspace"; an allowlist (declared in the manifest) is safer for an internet-exposed daemon. Recommend: broad read, manifest-allowlisted write — but your call given the operator's threat model.
- etag/concurrency: content-hash etag with
409on mismatch (proposed) vs last-write-wins. Recommend the etag — two console tabs shouldn't clobber. - List/delete: include
GET /files/listandDELETE /filesnow, or defer until a consumer needs them? The marketplace needs only read + write. - Binary/size limits: cap file size and treat content as UTF-8 text (config files); reject binary/oversize.
Implementation notes (operator side) — finalized architecture
This is the architecture the angee-operator team settled on after a prior-art review. The driving idea: a store (where bytes live) is a different axis from an object (secret vs file). Generalize the store; keep the objects distinct.
Store vs object
- Store = a generic
key → bytesbackend with zero domain knowledge.localfs,env-file, and OpenBao are all stores. - Object = a domain layer (secrets, files) that sits on a store and owns its own validation / codec / semantics. Secrets and files are siblings over a shared store substrate.
- Unifying the object layers is the anti-pattern; sharing the lower store primitive is the goal.
The internal/store substrate
- Minimal core interface (
Get/Set/Delete/Listover aBlob{Bytes, Etag}) with no secret-domain verbs — modeled on Vault'sphysical.Backend(exactly four generic methods). - Capability composition via optional interfaces discovered by type assertion (
Lister,Deleter,Versioned/CAS) — the Go stdlib idiom (io.WriterTo,http.Flusher,database/sql/driveroptionals) and Vault'sTransactional/HABackendpattern. The core stays tiny; do not widen it. - Backend registry (
store.Register(kind, factory)/store.Open(kind, cfg)), thedatabase/sql/ Go CDK structure — one construction path, backends self-register. Adding S3/git/remote later is one registration. localfsimplementsVersioned(sha256 etag + CAS) and owns the single path-containment resolver (clean, reject../absolute, resolve symlinks then re-verify the prefix). Files requireVersioned; env-file/OpenBao do not implement it (last-write-wins; files never run on them).
The discipline (the one rule from prior art)
Secret-specific actions (rotation, leases/TTLs, dynamic creds, transit encrypt/decrypt, no-readback) must live in the secrets object layer with its own request-oriented interface (à la Vault logical.Backend) — never as Store methods, never via a gocloud-style As() escape hatch (the Go CDK explicitly discourages it), never as storage capability interfaces. Today the operator's secrets are pure key → bytes (no leases/rotation), so a simple key→value secret object suffices; we just reserve (document, not build) the engine seam so Store is never overloaded. There is also a security rationale: Vault keeps its storage layer free of secret semantics on purpose — it is "completely untrusted," with encryption handled in a barrier layer above it.
Convergence
env-file and OpenBao become registered store backends now, while internal/secrets' exported API is preserved so callers compile unchanged and the existing secrets test suite stays green (the regression gate). localfs is the new backend for files.
Prior art (primary sources)
- Vault/OpenBao
physical.Backend(4-method generic store; optionalTransactional/HABackendcapability interfaces; untrusted storage layer):github.com/hashicorp/vault/sdk/physical,.../sdk/logical. - Go CDK / gocloud.dev deliberate separation (
blob.Bucketvssecrets.Keepervsruntimevar), portable-type-over-minimal-driver, and the discouragedAs()escape hatch:gocloud.dev/concepts/structure/,gocloud.dev/concepts/as/,google/go-cloud/internal/docs/design.md. - Go small-core + optional-interface idiom:
database/sql/driver,io,net/http(and "the bigger the interface, the weaker the abstraction").