Skip to content

Latest commit

 

History

History
494 lines (408 loc) · 21.5 KB

templates.md

File metadata and controls

494 lines (408 loc) · 21.5 KB

Templates

Angee templates are how a shape of deployment is declared once and re-used, rendered with copier-go. The operator renders three kinds:

  • Stack template (kind: stack) — produces an angee.yaml (and generated runtime files) for a runnable Stack root. Stack templates come in two flavours matching the two layouts: the dev flavour renders the framework-dev stack — the project host at the Stack root with the framework repos as git Sources and a src Workspace (plain angee init); the local flavour (local / staging / prod) renders a self-contained docker-compose root (angee stack init).
  • Workspace template (kind: workspace) — produces a workspace tree under $ANGEE_ROOT/workspaces/<name> and may declare Sources to materialize. It renders no Stack (see Workspaces and stacks).
  • Service template (kind: service) — produces one Service definition (see Service templates).

A fourth kind, project (kind: project), belongs to the Host rather than the operator: it scaffolds the application repository a Stack runs as a Source (for the default Host, a Django + React project — see the Host glossary). It is rendered by copier / the framework, not angee stack init; the operator lists it but does not render it.

Every kind is abstract: it only declares which Services to run, which Sources to materialize, and which inputs the user supplies. Rendering produces a Stack root, a Workspace directory, a Service, or a project repo the engine can operate on. A template must contain copier.yml with Angee metadata under _angee.

Angee's stateful reconciliation preserves Copier templates with _preserve_symlinks: true: it treats each rendered symlink as a first-class entry, fingerprinting it by its link target and applying it through a rooted symlink write, so layered and dry-run writes stay safe. Symlink parents are governed separately: existing workspace links for declared local Sources have their resolved target verified before Angee reads or writes through them, and an undeclared symlink parent is rejected.

Kinds

_angee:
  kind: stack
  name: dev
_angee:
  kind: workspace
  name: pr

angee stack init <template> resolves stack templates. angee workspace create <name> --template <template> resolves workspace templates.

Template References

A template reference (angee init --template, angee stack init <template>, angee workspace create --template, angee service create --template) takes one of these forms. The examples are stack refs; a workspace ref uses workspaces/ and a service ref services/.

Form Example Resolves to
<name> dev stacks/dev, from the local search paths, then the template registry.
<kind>/<name> stacks/dev The same, with the kind spelled out. The kind must be the one the command renders: angee init --template workspaces/dev-pr is refused.
<owner>/<repo>//<path> ang-ee/angee-django//templates/stacks/dev The template at <path> in that GitHub repository. The double slash separates the repository from the path.
any of the above + @<ref> dev@main The same template at a branch or tag of the registry (or of <owner>/<repo>). A pinned ref skips the local search.
Absolute path /home/me/templates/stacks/dev That template directory, used as is.
GitHub URL https://github.com/ang-ee/angee-django/tree/main/templates/stacks/dev The template path in that repository, at the branch in /tree/<ref>/ or ?ref=<ref>, or the default branch when neither is given.

A relative path such as ./templates/stacks/dev is not a template reference: name the template (stacks/dev) or pass an absolute path. An @<ref> pin cannot contain /; for a branch such as feature/x, use a GitHub URL with ?ref=feature/x. A reference that matches none of these forms, or names another kind, fails with a message listing them.

Local Resolution

For a short name like dev, stack resolution looks for stacks/dev. Workspace resolution looks for workspaces/dev.

Current local search includes:

$ANGEE_ROOT/.templates/<kind>/<name>
$ANGEE_ROOT/templates/<kind>/<name>
$ANGEE_ROOT/<kind>/<name>
$ANGEE_ROOT/<name>
ancestor/.templates/<kind>/<name>
$PWD/.templates/<kind>/<name>
$PWD/templates/<kind>/<name>
ancestor-of-PWD/.templates/<kind>/<name>

<kind> is stacks, workspaces, or services.

angee init resolves stacks/dev from the local search paths first, then from the template registry — ang-ee/angee-django by default; ANGEE_TEMPLATE_REGISTRY overrides it with another repository (URL, owner/repo, or an absolute local path).

Remote Resolution

HTTP(S) GitHub URLs are supported. The URL must include owner, repo, and a template path.

angee stack init https://github.com/example/templates/tree/main/.templates/stacks/dev
angee workspace create fix-issue-123 --template https://github.com/example/templates/tree/main/.templates/workspaces/pr

The resolver clones the repository into the user cache, checks out the requested branch or ?ref=, and renders the template path.

Questions and inputs

Every top-level key of copier.yml that is not prefixed with _ is a question. Angee reads the question metadata into the template descriptor (angee template get <ref>, GET /templates/{ref}, GraphQL templates) and drives its input form from it, so what you declare is what the user sees:

runtime_mode:
  type: str
  default: process
  choices: [process, docker]
  help: Run framework application services as local processes or Docker containers.

api_key:
  type: str
  secret: true
  help: Anthropic API key; stored in the stack's secret backend.

seed_users:
  type: str
  multiselect: true
  choices:
    Admin account: admin
    Demo tenant: demo
  help: Fixtures to load on first provision.
Field Effect in the form
Declaration order Questions are asked in copier.yml order; put the decisions a user must make first and image pins last.
help Shown under the input; keep it to one or two sentences.
default Pre-filled value, shown as (default) until changed.
choices Rendered as a list (a filterable one above eight entries); values are validated in every mode, including --input and --yes. The mapping form (Label: value) shows the label and stores the value. A Jinja string is kept as an expression and falls back to free text.
multiselect: true Multiple choices; the stored value is a JSON array string such as ["admin","demo"].
type: bool A Yes/No toggle; yes/no/y/n/true/false are accepted from flags and answers files.
type: int Validated as an integer.
type: path Free text; the value is rewritten relative to the stack root as described under Local Resolution. A leading ~ (~ or ~/…) expands to the home of the user running the render, and that absolute path is what the answers record, so a later render elsewhere reuses it. A value passed to stack init or stack update --template (--input, --answers, or a field changed in -i) is read relative to the directory copier renders into: the one ANGEE_ROOT is relative to, which holds the answers file (the project directory for a .angee or a deploy/.angee stack). stack update --template replays a recorded answer as it is, since it is already relative to the stack root, and so it keeps a value passed equal to the recorded one; it rewrites only other values passed on the update and the default of a question added since. The -i form therefore shows a recorded path relative to the stack root, but reads an edited one relative to the directory holding the answers file.
secret: true Masked while typing and in the summary; never written to the answers file, so it is asked again on re-render.
placeholder Hint shown while the input is empty.
required: true (in _angee.inputs) The form refuses an empty value; non-interactive runs name the --input flag to pass.
when A question whose condition is false, given the answers before it, is not asked: the form hides it, line prompts skip it, and it takes its default. A value passed for it is not validated, since copier ignores it too. copier-go evaluates the condition, so it means what it means at render time.
validator Not evaluated by the form; the render runs it on every answer it uses, inputs and defaults alike, as copier does. A failing validator, a value outside choices, or a required question with neither an input nor a default fails the render with code INVALID_INPUT and copier's message naming the question.

angee passes copier the inputs you give, plus generated values and the path inputs it rewrites, but not a template's plain defaults. copier works those out itself: it renders a templated default (default: "{{ name|lower }}"), and on stack update --template it keeps an answer recorded last time.

_angee.inputs declares metadata-only inputs (generated, immutable, required) that are not questions; they appear read-only in the form. When the same name is declared as a question and under _angee.inputs, the question wins.

Answers recorded by a render (.copier-answers.stack.yml for stacks, .copier-answers.yml for workspaces and services) can be replayed with --answers <file> on the create commands, and angee stack update --template -i re-opens the form with the recorded values. The --answers replay does not hold for the type: path answers of a stack whose ANGEE_ROOT is not .: the file records them relative to the stack root, but --answers passes them as new values, relative to the directory copier renders into, so they are resolved again (.. becomes ../..). Pass those with --input instead.

Workspace metadata

Workspace templates may declare inputs, sources to materialize, port pool ranges to ensure, and persistent paths:

_angee:
  kind: workspace
  name: pr
  instance_naming:
    pattern: "${inputs.branch | slug | truncate(40)}"
  inputs:
    branch:
      type: string
      required: true
  sources:
    app:
      source: app
      mode: worktree
      branch: "${inputs.branch}"
      subpath: app
  ensure:
    operator.port_pool.workspace:
      range: "8100-8199"
  persist:
    browser-data:
      subpath: .browser-data
      scope: workspace

When the workspace is created, each Source listed under sources: is materialized (a git worktree, a local mount, etc.) on the configured branch.

A source's mode, branch, ref and subpath accept ${...} substitution, so a stack can choose them through inputs without forking the template (for example mode: "${inputs.slot_mode}"). The resolved mode must be worktree, clone or empty; anything else fails the create before any source is materialized. A worktree source whose branch resolves to empty starts on a detached HEAD at ref.

Stack templates use the same Copier rendering path and must produce an angee.yaml under the initialized stack root. They are typically much simpler — just _angee.kind: stack plus the Jinja-templated angee.yaml and any seed files (env templates, runtime overlays).

Workspaces and stacks

A workspace and a stack are separate concerns. A workspace is files: its rendered template and its source slots. A stack is a manifest and its runtime, run by its own operator. A stack's services and jobs reach a workspace through workspace:// mounts, workdir and env values, so several services and jobs can each use a different workspace:

services:
  web:
    image: python:3.13
    workdir: workspace://pr-42/app
    mounts:
      - "workspace://pr-42/app:/app"

The workspace's status lists them under mountedBy.

A workspace template that declares chain or chain_root is refused, with an error naming angee-operator#110. Workspace templates used to render a stack into the workspace tree that way, which gave that stack's angee.yaml two writers and its ports two records. To run a workspace's code in a stack of its own, create that stack as any other (angee stack init) and point its services at the workspace's files.

A workspace created from a chaining template before that change keeps its files, the stack inside included, which is now an ordinary stack at that path. Its status reports no inner stack and destroy works as usual, but workspace update refuses it: a render of the workspace template alone would overwrite or delete what the chain rendered (the stack's own .copier-answers.yml among them), and an update cannot switch the template a workspace records. Destroy it and create it again from a template that renders no stack. A stack template's own chain: (a stack composing templates at its root, as stacks/dev chains projects/web) is unaffected.

Reaching outside the template directory

copier-go confines a template's includes to its own root, the directory holding copier.yml, so a template that includes a file from outside it — a shared partial kept beside the collection, say — would not find it. Declare how far the template reaches with _angee.include_root, and that ancestor becomes the boundary instead:

# templates/stacks/dev/copier.yml
_angee:
  kind: stack
  name: dev
  include_root: "../.."   # pins templates/, so ../_shared/ resolves
{% include "../_shared/AGENTS.md.jinja" %}

Include names resolve against the template's root, the directory holding copier.yml, as upstream Copier resolves them: from stacks/dev, ../_shared/ is stacks/_shared/.

include_root is relative to the template directory and must name one of its ancestors; an include may read the declared ancestor and nothing above it. Angee does not infer this from directory names: how you lay out a template collection is your business, so a collection named hosts/ behaves exactly like one named stacks/. Leave it unset for a self-contained template.

How "self-building" works

Putting templates and Sources together, the loop is:

  1. Sources are declared — your repos are listed under sources: in the rendered angee.yaml. Angee fetches and caches them.
  2. A Workspace renders a development shape — pick a Workspace template, supply the inputs (branch name, base ref, port ranges). Angee materializes Sources on that branch and renders the template under the workspace tree. Workspaces are a pure file primitive — they never start services.
  3. Services and jobs run the workspace's code — running services is always a Stack concern, not a Workspace concern. A stack's services and jobs reach the workspace through workspace:// mounts, workdir and env (see Workspaces and stacks), and the usual stack up/stack down/stack logs commands drive them.
  4. A production Stack runs the same Sources — point the operator at a different root with the same sources: referring to release branches or tags.

The templating system is therefore the only place where "what runs" needs to change. Promoting a feature to production does not rebuild any images or rewrite any compose files — it just updates which ref each Source points at and re-runs angee stack up.

Service templates

A Copier template with _angee.kind: service renders a single manifest.Service entry into the outer stack. Use it when an agent runtime or other reusable service shape needs a Dockerfile, multiple inputs, and per-instance port allocation.

Metadata fields (_angee:):

Field Required Meaning
kind: service yes Distinguishes service templates from workspace / stack templates.
name yes Display name of the template.
name_pattern no Substitution pattern for the resolved service name. Default: agent-${workspace.name}. Resolved against the workspace name + template inputs.
inputs no Caller-supplied inputs with required / default flags.
ensure no Port pools the template needs in the outer stack (operator.port_pool.<pool>). The operator allocates one port per declared pool, scoped to service/<name>/<pool>.

Rendered output: the template must emit a service.yaml containing exactly one service entry under services:. Anything else (jobs, volumes, secrets, sources) is rejected. Other files in the rendered tree — typically docker/Dockerfile and friends — are reconciled into <stack_root>/services/<service_name>/ so the rendered build.context: ./services/<service_name>/docker resolves.

Render variables available in Jinja:

Variable Source
service_name Resolved from name_pattern (or --name override).
workspace_name --workspace flag.
workspace_path Absolute path to the workspace dir.
alloc_<pool> Allocated port for each pool declared in ensure.
Caller inputs Every key from --input k=v, over the stack's workspace_defaults for the template, over the template's _angee.inputs defaults.

Secret markers (${secret.foo}) in the rendered output are resolved at compose-render time, not at service-create time.

Example skeleton:

# templates/services/my-agent/copier.yml
_subdirectory: template
_templates_suffix: .jinja
_angee:
  kind: service
  name: my-agent
  name_pattern: "agent-${workspace.name}"
  inputs:
    api_key:
      required: true
  ensure:
    operator.port_pool.acp:
      range: "3000-3999"

api_key:
  type: str
# templates/services/my-agent/template/service.yaml.jinja
services:
  {{ service_name }}:
    runtime: container
    build:
      context: ./services/{{ service_name }}/docker
    ports: ["{{ alloc_acp }}:3007"]
    mounts: ["workspace://{{ workspace_name }}:/workspace"]
    env:
      API_KEY: "{{ api_key }}"

Run with:

angee service create \
  --template services/my-agent \
  --workspace my-pa \
  --input api_key=sk-...

Template-created services can be refreshed in place:

angee service update agent-my-pa --template
angee service update agent-my-pa --template --input api_key=sk-new --dry-run
angee service update agent-my-pa --template --overwrite

Angee tracks the last rendered assets and service.yaml. Updates preserve independent local manifest edits, apply independent template edits, and report same-field or asset conflicts before writing. Lists and scalar values are atomic; maps such as env, structured build, and route merge recursively. --overwrite selects the newly rendered value for conflicts. The service name, workspace binding, and allocated ports remain authoritative from current state.

angee service destroy agent-my-pa removes the manifest entry, releases the port lease, and deletes the build-context dir.

Bundled templates

The repo ships a small set of templates under templates/. Today this is just agent-runtime; more will follow as the host integrations solidify.

agent-runtime

Materialises a single long-running process that an external host — today, angee-django — addresses over ACP. The template is the contract between the operator and any host that wants to provision per-agent workspaces; the actual runtime binary is expected to be wired in by the consuming host.

Inputs:

Name Required Purpose
AGENT_ID yes Identifier for this agent runtime instance. Slugged into the workspace name and passed through to the spawned process.
MCP_URL no URL of the MCP server the agent should connect to. Empty / unset means "no MCP".
MCP_TOKEN no Bearer token for MCP_URL. v1 stores it in the workspace env file; rotate by re-running the workspace with updated inputs.

Env contract. The materialised process receives these env vars; this shape is the load-bearing contract for host integrations:

Env var Source
AGENT_ID Caller input.
MCP_URL Caller input (may be empty).
MCP_TOKEN Caller input (may be empty).
ACP_PORT Allocated from the host stack's acp port pool — the host stack must declare one in operator.port_pool.acp.
ACP_TOKEN Resolved from ${secret:acp_token} against the operator's secret backend. The host is responsible for provisioning the secret before bringing the workspace up.

The v1 template renders a placeholder service that prints the contract and sleeps forever; replace the services.agent.command block in your fork with the real agent runtime invocation.

Provisioning shape (Django side):

mutation {
  workspaceCreate(input: {
    template: "agent-runtime"
    inputs: [
      {key: "AGENT_ID", value: "agent-claude-1"}
      {key: "MCP_URL", value: "https://mcp.internal/sse"}
      {key: "MCP_TOKEN", value: "..."}
    ]
  }) {
    name
    path
    processComposePort
  }
}

workspaceCreate only renders the workspace's files (including the inner angee.yaml) and materializes its sources — it does not start the agent process. Bring the agent up explicitly with a stack operation against the workspace's inner root:

angee stack up --root workspaces/agent-claude-1/.angee
# or run a per-workspace operator the host can talk to over HTTP:
angee operator --root workspaces/agent-claude-1/.angee --port 9100

Keep this contract in lockstep with templates/agent-runtime/copier.yml and the consuming host's provisioning code. Changes to the env var names or semantics need a coordinated bump on both sides.