Contributing Services - thekaveh/atlas GitHub Wiki
The stack uses a per-service folder layout under services/<name>/. Each service family (one or more co-lifecycled containers) owns:
-
service.ymlโ manifest: env vars, source variants, image refs, dependencies, plus per-source bootstrapper runtime data underruntime_sc:/runtime_adaptive:/runtime_deps: -
compose.ymlโ Docker Compose fragment for that family's containers - optional subdirectories holding Dockerfiles, init scripts, source code, configuration files, snapshots, etc. โ see the Subdirectory naming convention below
The thin top-level docker-compose.yml merges fragments via Compose's native include: directive (requires Compose โฅ v2.20.3; v2.26+ recommended).
Each step links to its deep-dive section.
- Study the candidate service's upstream docs (license, default port, API shape, runtime deps) โ Pre-flight
- Pick a folder flavor โ Decision 1
- Pick a category โ Decision 2
- Pick your sources โ Decision 3
- Write
services/<name>/service.ymlโ Mechanics - Write
services/<name>/compose.yml(only if folder flavor = container) โ Mechanics - Add the
include:line todocker-compose.yml(only if you wrote a compose fragment) - Register CLI key in
source_mappingโ Mechanics โ source_override_manager registration. Without this the wizard silently skips your service. - Add the new folder to the relevant track(s) in
bootstrapper/tracks.yml(source-configurable services only). A configurable service absent from a named track'sservices:list is force-disabled (*_SOURCE=disabled) there โ it only runs under--track all. Always-on infra and the always-prompted LLM/Prometheus/Grafana tier are exempt. - Add the row's display name to
EXPECTED_DISCOVEREDinbootstrapper/tests/test_wizard_app_discovery.py(source-configurable services only). The discovery-count test fails until you do. -
runtime_sckey differs from the folder name (for exampleray-headin a multi-container family): add the mapping to_FAMILY_KEY_ALIASESinbootstrapper/tracks.py. Otherwise the track filter does not match it. - Add the new containers to the fragment baseline โ Byte-equivalence
- Run the root-safe regen, lint, and required-check checklist โ After you save the files, then CI gates
- Update audit-script allowlists if your service has hard deps โ Audit-script + CI implications
- Commit and push. CI gates the change with four required jobs: manifest-lint+pytest, compose-equivalence+permutation matrix, docs-drift+audit-scripts, and build-validation.
If you're new to this codebase, read Decisions 1โ6 in sequence; the Qdrant worked example illustrates each one.
| # | Decision | Default if you're unsure | Drill-down |
|---|---|---|---|
| 1 | Folder flavor | container |
Decision 1 |
| 2 | Category | the category of the service you're most similar to | Decision 2 |
| 3 | Source variants |
container + disabled (minimum); add localhost if users might run this themselves |
Decision 3 |
| 4 | Port allocation | nothing โ it's auto-assigned. Just declare <NAME>_PORT in the env block. |
Decision 4 |
| 5 | Dependencies | the manifest of the closest sibling in your category, to preserve display order | Decision 5 |
| 6 | Adaptive / hooks | none โ start with declarative runtime_sc, escalate to a Python helper only when YAML can't express it |
Decision 6 |
Worked example throughout: Adding Qdrant, a self-hosted vector database. (Qdrant is real software but NOT currently in the stack โ Weaviate is our vector DB. Used here purely as an instructional example, not a proposal.)
Read the Pre-flight study (ยง4), then Decisions 1โ6 (ยง5โยง10). The Qdrant example runs through all of them. ยง12 lists the regen and lint commands; skipping one fails the byte-equivalence test or the docs-drift gate.
Read the candidate service's upstream docs before you write a manifest. Every later decision depends on these facts. A wrong port or API assumption carries through to category, sources, compose and Kong.
| Question | Where to find it | Why it matters downstream |
|---|---|---|
| One-line elevator pitch | Upstream README / project homepage | Drives label: in your manifest + the wizard prompt hint |
| Protocol exposed (REST/HTTP, gRPC, WebSocket, custom binary) | Upstream API docs | Determines whether Kong can proxy it + which existing services can call it |
| OpenAI-API-compatible? | Upstream "API compatibility" docs | If yes โ likely belongs behind LiteLLM. If no โ direct integration. |
| Default in-container listen port(s) | Upstream Dockerfile / docker-compose example / docker run examples |
Compose ports: <host>:<container> mapping (Decision 4) |
| Published container images (CPU/GPU/MLX, arch tags) | Docker Hub / GHCR / quay.io page | Decision 3 โ which container-* source variants to offer |
| License | LICENSE file in upstream repo | Compatibility check. Apache 2.0, MIT, BSD-3 are fine; AGPL / SSPL / source-available licenses require explicit maintainer review before adoption. |
| Runtime dependencies (DB? cache? object store? files?) | Upstream "Configuration" / "Deployment" docs | Decision 5 โ depends_on.required entries AND whether existing stack services can satisfy them (no need to add a fresh Postgres if Supabase Postgres works). |
| Healthcheck endpoint | Upstream Dockerfile HEALTHCHECK or operational docs |
For the compose fragment's healthcheck: block |
| Managed cloud version available? | Upstream project website | Currently moot โ external source variants are paused stack-wide (see Decision 3). Note the answer for the future auth design. |
| Common host-install footprint (do users already run this themselves?) | Project ecosystem knowledge / Reddit / HN | Decide if localhost source variant is worth offering |
| Configuration style (env vars, mounted YAML, both) | Upstream "Configuration" docs | Drives runtime_sc.<key>.environment vs. volumes: mounts in compose |
| GPU passthrough required? | Upstream "Hardware requirements" / README | If yes โ split container-gpu from container-cpu, set runtime: nvidia in compose |
| Persistent state (writes to disk vs. fully stateless) | Upstream "Storage" / "Persistence" docs | Determines whether you need a named volume in compose |
Once you understand the candidate, scan our existing service manifests (run grep -l "^data_flow:" services/*/service.yml) to identify integration points:
-
Upstream callers (who in our stack would call this new service). Run
grep -l "^data_flow:" services/*/service.ymland skim each service'sdata_flow.callslist. Which existing services would benefit from calling this new one? For a new vector DB: Backend, n8n, JupyterHub, possibly Hermes Agent. Add these edges to those EXISTING manifests'data_flow.callslists, NOT to your new service'sdepends_on. (See Decision 5 for whydata_flow.callsis separate fromdepends_on.) -
Downstream callees (what this service calls). Does the candidate make outbound calls to anything we already run? Most app-tier services touch Supabase (auth/storage), LiteLLM (LLM access), and Redis (caching). These would be entries in YOUR new service's
data_flow.calls. -
Source-variant precedents. Find the closest existing service that ships similar source variants and use its manifest as a template:
- New vector DB โ
services/weaviate/service.yml - New LLM gateway / engine โ
services/litellm/,services/ollama/ - New STT/TTS engine โ
services/parakeet/,services/speaches/, plus the aggregator pattern inservices/stt-provider/+services/tts-provider/ - New app-tier UI โ
services/open-webui/,services/jupyterhub/ - New cloud-API toggle (not a container) โ
services/cloud-providers/service.yml
- New vector DB โ
| Question | Qdrant answer | Source |
|---|---|---|
| Elevator pitch | Open-source vector database with HTTP+gRPC APIs and built-in clustering. | qdrant.tech |
| Protocol | REST/HTTP on 6333, gRPC on 6334. | Qdrant API docs |
| OpenAI-compatible? | No โ Qdrant has its own REST API. Backend would call it directly. | API reference |
| Default port | 6333 (HTTP). | Upstream docker-compose.yml
|
| Container images |
qdrant/qdrant:vX.Y.Z (Docker Hub). CPU-only public image; GPU support is built in but isn't a separate image tag. |
hub.docker.com/r/qdrant/qdrant |
| License | Apache 2.0 (verified) | LICENSE file in repo |
| Runtime deps | Self-contained โ writes its own storage to a mounted volume. No external DB or cache. | Qdrant "Storage" docs |
| Healthcheck |
GET /healthz returns 200 when ready. The image (Debian slim) has no wget or curl, so a Compose healthcheck must use another probe. |
Qdrant operational docs; upstream Dockerfile
|
| Managed cloud? | Yes โ Qdrant Cloud. Worth offering external once the stack-wide auth design lands. |
cloud.qdrant.io |
| Host install common? | Less common than Postgres/Weaviate for typical users; offer localhost for flexibility but expect rare use. |
Ecosystem knowledge |
| Config style | Env vars (QDRANT__SERVICE__GRPC_PORT, โฆ) + optional config.yaml volume mount. |
Qdrant "Configuration" docs |
| GPU passthrough? | No. | โ |
| Persistent state | Yes โ writes to /qdrant/storage (mount a named volume). |
Qdrant "Storage" docs |
Integration discovery for Qdrant. Backend and n8n already call Weaviate; Qdrant would be a sibling vector-DB option for users who prefer it. JupyterHub users running RAG experiments might want a second vector store for A/B comparisons. No service in our stack would be a downstream caller โ Qdrant is a leaf vector store. โ On Qdrant's manifest, data_flow.calls: []. Integration entries appear in services/{backend,n8n,jupyterhub}/service.yml's data_flow.calls lists (added in those manifests, not in Qdrant's).
Source-variant precedent. Weaviate is the closest sibling โ same category (data), same role (vector DB), similar source-variant shape. Open services/weaviate/service.yml side-by-side while drafting services/qdrant/service.yml.
With pre-flight complete, the six decisions become near-mechanical โ proceed to Decision 1.
Three legitimate flavors of folder live under services/. Pick the right one before writing anything else.
| Flavor |
service.yml? |
virtual: true? |
compose.yml? |
Examples in this repo |
|---|---|---|---|---|
| Container service | yes | absent / false | yes | most services โ backend, supabase, ollama, weaviate, โฆ |
| Virtual manifest | yes | true |
no |
cloud-providers/ (LiteLLM-routed APIs), globals/ (project + branding env), tts-provider/ (engine selector) |
| Doc-only folder | no | n/a | no |
stt-provider/, doc-processor/, multi2vec-clip/ โ aggregator docs + diagrams for a role whose engines live in sibling folders |
Flowchart:
- Does it run as a container with its own image? โ container.
- Does it own env vars /
SOURCEtoggles but with no compose fragment of its own? โ virtual. Setvirtual: true; omitcompose.yml. The validator enforces this. - Is it documentation-only โ aggregating other manifests under one user-facing role? โ doc-only. No
service.yml, nocompose.yml, justREADME.md+architecture.svg/architecture.html.
Worked example โ Qdrant: Qdrant ships as a container image (
qdrant/qdrant:v1.12.0), exposes a real HTTP API, and has its own env vars โ container flavor.
Common mistakes:
- Adding a virtual manifest with a compose fragment โ the manifest validator rejects it (
unexpected_fragment). Either removecompose.yml(if no container runs) or unsetvirtual: trueand keep the compose fragment (container flavor). - Adding a doc-only folder when the role has env vars to manage โ use a virtual manifest instead.
Every manifest declares one of six categories. The category drives two things: the wizard block your row renders in, and the port-slot block your service draws from.
| Category | Wizard block | Representative services (not exhaustive) | When to pick |
|---|---|---|---|
infra |
Infra | Kong, globals, Prometheus, Grafana, Ray, backup, cloudflared | Gateways, project-wide config, observability, prod-readiness |
data |
Data | Supabase, Redis, MinIO, Neo4j, Weaviate (+ multi2vec-clip as a Weaviate sub-module), Spark |
Databases, caches, object storage |
llm |
LLM Core | LiteLLM, Ollama, cloud-providers, TEI Reranker | LLM gateways / engines |
media |
Media | ComfyUI, parakeet, speaches, chatterbox, docling, searxng, tts-provider | Multimodal AI (image / audio / doc / search) |
agents |
Agents & Workflows | Hermes, n8n, openclaw, Airflow, LightRAG | Programmable AI agents, workflow runners |
apps |
Apps & UIs | Backend, Open WebUI, JupyterHub, Local Deep Researcher, Zeppelin | User-facing UIs |
Effects of the category:
-
Wizard placement. Categories render in fixed order (
infraโdataโllmโmediaโagentsโapps). Within a category, services follow topological order (driven bydepends_on.required). - Port-slot block. Each category gets its own port-offset range โ see Decision 4.
-
Architecture-diagram clustering. The full-stack diagram at
docs/diagrams/architecture.svg(hand-authored via the architecture-diagram skill) groups services by category band. Per-service architecture diagrams underservices/<name>/architecture.svguse the same category palette but cluster the call graph instead.
Worked example โ Qdrant: Qdrant is a vector database. Its closest siblings in the stack are Weaviate and Supabase (which are also
data-tier). โcategory: data.
How to pick when you're unsure: find the most-similar existing service and use its category. If your service genuinely doesn't fit any of the six, that's a design conversation, not a category decision โ open an issue first.
Every user-configurable service has an <SVC>_SOURCE env var. The wizard reads its values from the sources.options block in your manifest.
Standard option names (use these; don't invent new ones unless your service genuinely needs them):
| Option | Meaning | When to offer it |
|---|---|---|
container |
Run as a Docker container alongside the stack | Always, for container-flavor services |
container-cpu / container-gpu
|
Split when the container has CPU/GPU variants | When you publish a GPU variant of the image |
localhost |
Connect to a user-managed instance on the host | When users typically already have this software installed locally (e.g. Ollama, ComfyUI) |
external |
Connect to a remote URL |
Currently disabled stack-wide pending an authenticated-remote design (API keys, bearer tokens, mTLS). Do NOT add new external source variants until the auth design ships โ see the external/api retirement entry in docs/CHANGELOG.md. |
api |
Use a hosted cloud API (no container) | Retired as a service source value. Cloud providers now use `CLOUD_*_SOURCE=enabled |
disabled |
Excluded from compose entirely | Always โ every optional service must support this |
<engine>-* |
Engine-specific sub-variants | For aggregator services that pick from multiple engines (STT/TTS) |
Implications of your choices:
-
Locked vs. user-choice. A service with only one source variant is "locked" โ the wizard skips its prompt entirely. The
_is_lockedhelper inbootstrapper/services/topology.pyenforces this. Services like Backend, Kong, LiteLLM are locked because they're always-on. -
requires:per option. Userequires: [<ENV_VAR>]on a source option to declare prerequisite env vars (e.g.localhosttypically requires<SVC>_LOCALHOST_PORT). -
profiles:per option. Mark each dev-only optionprofiles: [default], especiallylocalhost, becausehost.docker.internalis useless on a remote host. An option withoutprofiles:is offered in every profile, which suitscontaineranddisabled. Under--profile prodthe wizard hides dev-only options, andbootstrapper/services/source_validator.py::validate_sources_for_profilerejects them.- The manifest-validator lint fails only when no prod-eligible option is left. Annotate each dev-only option yourself.
- The matching Python predicate is
option_in_profileinbootstrapper/services/manifests.py. - The profile bundles live in
bootstrapper/profiles.yml:defaultandprodmaps ofsources,envandhost_bind_ipthatapply_profile_overridesapplies at start. Consumers override fields withprofile_overrides:inatlas.consumer.yml(Reusing Atlas ยง6.1).
-
<SVC>_LOCALHOST_PORTis the single source of truth. If you offerlocalhost, declare<SVC>_LOCALHOST_PORT(an integer string, defaulting to the upstream host port). The URL is derived ashttp://host.docker.internal:${<SVC>_LOCALHOST_PORT:-<default>}. The in-container consumers (runtime_sc.<svc>.localhost.environment) and the Kong route generator (bootstrapper/utils/kong_config_generator.py) must read the same var. The wizard shows an inline port box on thelocalhostrow. See also Common gotchas. -
runtime_scslice per source. Every source variant declared insources.optionsmust have a matchingruntime_sc.<key>.<source>slice. By convention each slice setsscale,environment,deployandextra_hosts. The manifest validator reports a missing slice asruntime_sc_missing_variant, so a declared option cannot silently scale to 0. -
auto_prefer:(optional). This enables<SVC>_SOURCE: autoinatlas.consumer.yml. The resolver picks the first entry whoserequires_capability(apple_silicon,nvidia_gpu,host_ollama; probed bybootstrapper/services/host_capabilities.py) holds on the host and whose option the active profile offers.- Each id must be in
options[].id. The last entry must have norequires_capability; it is the terminal fallback, usually the CPU container. The lint (_check_auto_prefer_integrity) enforces both rules. - Without
auto_prefer,autoresolves todefaultwith a warning. - Reference shapes:
services/comfyui/service.yml,services/ollama/service.yml.
- Each id must be in
Worked example โ Qdrant: Most users won't already run Qdrant locally, so
containeris the primary path. We offer three variants โexternalis deliberately omitted per the stack-wide moratorium noted above:
containerโ default, scale=1localhostโQDRANT_LOCALHOST_PORTdefaults to"6333"(Qdrant's standard host port); the URL is derived at compose-render time ashttp://host.docker.internal:6333disabledโ scale=0
The bootstrapper auto-assigns ports. You do not pick a port.
How it works:
-
BASE_PORT(default63000) is the bottom of the port range. Users can override with./start.sh --base-port 64000. -
CATEGORY_SLOTSinbootstrapper/services/topology.pyassigns each category an(offset, block_size)tuple. Current values:Category Offset Block size Resolved range with default BASE_PORT=63000infra0 10 63000-63009 data10 30 63010-63039 llm40 10 63040-63049 media50 20 63050-63069 agents70 20 63070-63089 apps90 20 63090-63109 -
Within each category block, services consume slots in topological order (driven by
depends_on.requiredโ see Decision 5). Multi-port services (e.g. Supabase's 8 port vars, Weaviate's HTTP + gRPC pair, MinIO's API + Console pair) get a contiguous run. -
A category-overflow lint trips if you blow past your block. Fixes: move manifests to a different category (rare), or extend the block size in
CATEGORY_SLOTS(also rare โ coordinate with maintainers).
How to declare a port:
In your env: block, declare name: <SVC>_PORT with NO default: line. Convention is a single-line comment:
env:
- name: QDRANT_PORT
# default removed โ computed by services/topology.py slot allocatorThe services/env_assembler.py regen step emits the resolved port into .env.example. Never hand-edit .env.example โ it's a generated artifact (byte-equivalence-tested in CI).
Worked example โ Qdrant: Qdrant declares
QDRANT_PORTwith no default. Topology slots it into thedatablock at the next free offset. Its place in the topo sort relative to its siblings sets that offset (Supabase microservices, Redis, MinIO, Neo4j, Weaviate). The exact number is auto-resolved at every regen โ don't pin it.
This is the most nuanced decision. The field depends_on.required in service.yml has two semantics that get conflated:
Runtime semantics (intended): "These services must be up before this one boots." Maps to the corresponding depends_on: block in your compose.yml fragment.
Display-ordering semantics (overloaded): The bootstrapper uses depends_on.required as the canonical-order backbone for the wizard's row order and the overview-box display. Within a category, services sort topologically; an edge from A โ B means A appears AFTER B in the same category's wizard block.
Removing a non-runtime edge can still break the wizard. ollama lists litellm only to pin its row after LiteLLM. Removing that edge fails test_row_order_stability.py and shifts port slots in the LLM and media blocks.
-
Use
requiredfor genuine bootstrap blockers AND for cross-category display-ordering pins. -
Comment any non-runtime edge inline as a display-ordering pin so readers don't try to "fix" it. Example from
services/ollama/service.yml:depends_on: # `litellm` is listed here NOT because the ollama container calls # LiteLLM (the call direction is the opposite), but because the # bootstrapper uses `depends_on.required` as the canonical-order # backbone for the wizard / overview-box display. [โฆ] required: - supabase - litellm
-
A future schema change may add a separate
display_order:field that takes over the ordering job; until then, comment the intent.
-
Don't list every service you call in
required. Usedata_flow.callsfor that (it drives the architecture diagram and the per-service README's Dependencies & Integrations block โ not topology). -
Don't list a virtual aggregate (
globals,cloud-providers,tts-provider) inrequiredas a boot dependency, because it has no container. You may list one as a commented display-order pin, asspeachesandchatterboxdo withtts-provider. -
Don't list
optionaldeps that compose doesn't enforce. Theoptionallist is documentation; if compose doesn't gate on it, it has no effect. -
Don't list a depends-on for a service that's source-replaceable (
localhost,disabled, or a future authenticated remote mode). The audit scriptscripts/check-compose-source-deps.pyenforces this; SOURCE-replaceable consumers should reach their target via endpoint env vars + runtime readiness checks, not via composedepends_on.
Worked example โ Qdrant: Qdrant's only data-tier sibling that always runs is Supabase (the substrate the bootstrapper provisions before any other service starts). Listing it as a required dep pins Qdrant's position in the data block topologically. โ
required: [supabase],optional: [].
The data_flow.calls field is a runtime call graph that drives the architecture diagram and the per-service README's Dependencies & Integrations block. It is independent of depends_on. Use it to describe which services this one calls at runtime in the request path (excluding init-time bootstrap calls).
An entry is either a plain name, which declares a current edge, or an object that qualifies the edge:
data_flow:
calls:
- litellm # current: happens whenever both services run
- target: mcp-servers
status: optional # current | optional | planned
condition: MCP_SERVERS_SOURCE=container
evidence: services/jupyterhub/build/notebooks/15_mcp_clients.ipynb-
optionaledges happen only under theircondition, which is a SOURCE expression or a short free-text condition such as an operator setup step. -
plannededges are documented or intended but not wired yet. -
evidenceis a repository path,path:line, or anhttpsURL showing the call. The validator fails a repository path that does not exist. - Each target appears once, whichever shape declares it.
The generated tables add a Status column, and the diagram draws a dashed pill, for any edge that is not current.
Most services express their per-source behavior with declarative runtime_sc YAML. The synthesizer in bootstrapper/services/sc_synthesizer.py concatenates all manifests' runtime_sc slices into the runtime service-config dict the bootstrapper consumes.
Declarative example (suffices for ~90% of services):
runtime_sc:
qdrant:
container:
scale: 1
environment:
QDRANT_LOG_LEVEL: info
deploy: {}
extra_hosts: []
localhost:
scale: 0
environment:
QDRANT_ENDPOINT: http://host.docker.internal:${QDRANT_LOCALHOST_PORT:-6333}
deploy: {}
extra_hosts:
- host.docker.internal:host-gateway
disabled:
scale: 0
environment: {}
deploy: {}
extra_hosts: []Write a helper in bootstrapper/services/service_config.py and wire it into generate_service_environment() ONLY when one of these is true:
-
Multi-input SOURCE dependencies. Your output depends on more than one
<SVC>_SOURCEvalue. Example:_generate_stt_provider_configand_generate_tts_provider_configshare ashared_envdict. STT runs first and writesSPEACHES_SCALE. TTS reads it, so Speaches is not scheduled twice when both roles pick a Speaches variant. -
Derived / aggregated state. You need to compute env vars from a set of toggles. Example:
_generate_cloud_providers_configreads the threeCLOUD_*_SOURCEtoggles and emits a per-provider enabled flag plusLITELLM_ENABLED_PROVIDERS(a comma-separated string). -
Runtime-computed values. You need an env var whose value depends on another service's port, computed at runtime from
BASE_PORT.
For everything else, stay declarative. Adding a hook means writing Python, adding a unit test for it, and giving future maintainers an extra place to read.
Worked example โ Qdrant: Single SOURCE, no cross-service dependencies, no derived state. โ No hook needed. The declarative
runtime_sccovers every declared source.
Two adjacent fields that occasionally apply:
-
runtime_adaptiveโ for services likebackendthat adapt their behavior based on which upstream services are enabled. Declaresadapts_to:(a list of provider keys) andenvironment_adaptation:(env vars conditionally set when those providers are active). Seeservices/backend/service.ymlfor the reference pattern. -
runtime_depsโ launch-time dependency rules keyed by container.-
requiresnames services this one cannot run without. If one is disabled, the bootstrapper printserror_messageand auto-disables this service. -
optionalonly prints which of the listed services are enabled ([INFO] <svc> will connect to: โฆ). -
conditional_requiresadds rules that apply only when given env values are set. - Reference:
services/n8n/service.yml.
-
Use these only if your service is genuinely adaptive. Today eight manifests declare runtime_adaptive (backend, comfyui, hermes, jupyterhub, lightrag, n8n, ollama, weaviate); backend is the most heavily adaptive and the canonical reference. Don't reach for these fields by default โ start with declarative runtime_sc and only escalate when the adaptive behavior is non-trivial.
Every variable that generate_service_environment() writes to .env must have a consumer. Either a Compose fragment interpolates it (${VAR}), or it has a reviewed entry in FORWARDING_EXCEPTIONS (bootstrapper/tests/test_env_forwarding_contract.py).
- Valid exception reasons: host-only values, derivation inputs folded into another var, consumer-contract exports, container-less virtual families.
- An unconsumed variable fails that test; it never becomes a silent, inert
.enventry. - Remove an exception as soon as its variable gains a Compose consumer. The stale-entry guard checks both directions.
After making the six decisions, you write two files. Here's the full result for Qdrant. Line callouts (# โ Decision N) point back to the decision section that explains the choice.
# services/qdrant/service.yml โ Qdrant vector database
name: qdrant
label: "Qdrant (vector database)"
category: data # โ Decision 2
docs: services/qdrant/README.md
support: # required by tests (ยง22)
tier: experimental
evidence: "No qualification run cited yet."
evidence_revision: v0.1.0 # release tag or full 40-char commit id
capabilities: # required by the schema
- name: "Vector search over HTTP"
status: supported # supported | partial | stubbed | not-supported
verification: untested # tested | documented | untested
note: "Single-node only; no cluster mode."
containers:
- qdrant
images:
- var: QDRANT_IMAGE
default: "qdrant/qdrant:v1.12.0"
container: qdrant
sources: # โ Decision 3
var: QDRANT_SOURCE
default: disabled # off by default; user opts in
options:
- id: container
label: "Container"
- id: localhost
label: "Host (existing Qdrant)"
profiles: [default] # dev-only: hidden in the prod wizard, rejected by --profile prod
- id: disabled
label: "Disabled"
# NOTE: `external` is deliberately omitted per the stack-wide moratorium
# on `external` source variants โ see Decision 3 above and the
# `Removed (breaking)` section of docs/CHANGELOG.md.
env:
- name: QDRANT_SOURCE
default: disabled
- name: QDRANT_PORT # โ Decision 4 (no default โ auto-assigned)
# default removed โ computed by services/topology.py slot allocator
- name: QDRANT_LOCALHOST_PORT
default: "6333"
description: "Host port for the qdrant-localhost source variant. URL is derived at compose-render time as http://host.docker.internal:6333. Same var consumed by Kong's qdrant.localhost route."
- name: QDRANT_ENDPOINT
auto_managed: true
- name: QDRANT_SCALE
auto_managed: true
depends_on: # โ Decision 5
required:
- supabase
optional: []
exports: []
rows:
- display_name: "Qdrant"
source_var: QDRANT_SOURCE
port_var: QDRANT_PORT
scale_var: QDRANT_SCALE
alias: qdrant.localhost
description: "Vector database (Qdrant)."
localhost_endpoint_var: QDRANT_ENDPOINT
localhost_port_var: QDRANT_LOCALHOST_PORT
runtime_sc: # โ Decision 6 (declarative, no hook)
qdrant:
container:
scale: 1
environment:
QDRANT_ENDPOINT: http://qdrant:6333
deploy: {}
extra_hosts: []
localhost:
scale: 0
environment:
QDRANT_ENDPOINT: http://host.docker.internal:${QDRANT_LOCALHOST_PORT:-6333}
deploy: {}
extra_hosts: [host.docker.internal:host-gateway]
disabled:
scale: 0
environment:
QDRANT_ENDPOINT: ''
deploy: {}
extra_hosts: []
data_flow:
calls: []# services/qdrant/compose.yml โ Qdrant vector database
services:
qdrant:
image: ${QDRANT_IMAGE}
container_name: ${PROJECT_NAME}-qdrant
restart: unless-stopped
deploy:
replicas: ${QDRANT_SCALE:-0}
ports:
# Keep the HOST_BIND_IP prefix on every published port.
- "${HOST_BIND_IP-127.0.0.1:}${QDRANT_PORT}:6333"
volumes:
- qdrant-data:/qdrant/storage
healthcheck:
# debian:13-slim base: no wget or curl, so probe the port with bash.
test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/6333' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- backend-network
volumes:
qdrant-data:
name: ${PROJECT_NAME}-qdrant-data
driver: localAdd to the top-level include: block in the # Data tier section:
# Data tier
- services/supabase/compose.yml
- services/redis/compose.yml
- services/minio/compose.yml
- services/neo4j/compose.yml
- services/qdrant/compose.yml # โ newThis is a mandatory registration step that's easy to forget. SourceOverrideManager.source_mapping is a hardcoded dict; the wizard's ServiceDiscovery.discover() filters every service through it. A service NOT in this mapping is silently dropped from the wizard โ the user never sees a prompt for it.
For a single-container family (most services), add ONE entry:
self.source_mapping = {
# โฆ existing entries โฆ
'qdrant_source': 'QDRANT_SOURCE', # โ new
}For a multi-container family (head + worker, or app + init), the runtime_sc top-level key drives discovery. Map the "main" container's <key>_source to the family's actual env var. Example: Ray has ray-head and ray-worker in runtime_sc but one RAY_SOURCE env var. The mapping is 'ray_head_source': 'RAY_SOURCE'. The worker has no entry, so it is filtered out, as comfyui-init and hermes-init are.
The CLI flag binding in bootstrapper/start.py (@click.option('--qdrant-source', โฆ) + the source_args dict) uses the family-level qdrant_source key โ different from the discovery key for multi-container families. If your service is multi-container, you'll have TWO entries in source_mapping pointing to the SAME env var (one for CLI plumbing, one for discovery).
The pinning test bootstrapper/tests/test_wizard_app_discovery.py::test_source_mapping_includes_app_service_flags enforces this โ add your service's CLI key to the assertion list so future regressions fail loudly.
Run these root-safe commands from the repository root:
# 1. Regenerate .env.example from manifests
uv run --project bootstrapper python -m services.env_assembler
# 2. Regenerate README.md TOPOLOGY block (auto-includes the new row)
uv run --project bootstrapper python -m tools.generate_readme_topology
# 3. Generate the per-service README and diagrams
# (required when the new manifest owns a same-folder README)
PYTHONPATH=bootstrapper uv run --project bootstrapper python -m bootstrapper.docs.regen qdrant
# After this, services/qdrant/{README.md, architecture.svg, architecture.html} exist.
# 4. Update docs/diagrams/architecture.html and docs/diagrams/architecture.svg together
# via the architecture-diagram skill if the full-stack topology changes.
# Render the committed PNG, then refresh the generated surfaces.
uv run --project bootstrapper python -m scripts.docs.render_diagrams
make docs-build
# 5. Give every new `images:` entry a row in the supply-chain license inventory
# (docs/reference/license-inventory.yaml) and check it
uv run --project bootstrapper python -m scripts.docs.license_inventory --check
# 6. Regenerate the canonical reference pages (env vars, sources, ports,
# dependencies, manifest fields, license inventory, the validator catalog below)
uv run --project bootstrapper python -m scripts.docs.canonical_references
# 7. Lint manifests and all three documentation surfaces
uv run --project bootstrapper python -m tools.validate_fragments
make docs-checkWhen to re-run each step:
-
env_assemblerโ after any change to a manifest'senv:block, port allocation, or source variants. -
generate_readme_topologyโ after any change to a manifest'srows:,display_name,category, oralias. -
Top-level
docs/diagrams/architecture.htmlandarchitecture.svgโ hand-authored masters. Update both when a band-level change occurs, such as a new category or a new gateway. After either master changes, runpython -m scripts.docs.render_diagramsas shown above beforemake docs-build. Routinedata_flow.callsedits flow into per-service diagrams viabootstrapper.docs.regen. -
license_inventoryโ after adding animages:entry or moving any image pin or catalogue model source. The row records the image exactly as pinned and its license at the upstream revision of that version. It also records what the terms allow for hosted use, source integration and redistribution. Anything not yet adjudicated is markedunresolvedwith a named open item. See the supply-chain license inventory. -
canonical_referencesโ after any manifest change.make docs-checkfails on stale reference pages. -
validate_fragmentsโ always, before the finalmake docs-checkgate. -
docs.regenโ required after creating a new service that owns a same-folder README, or after editingdata_flow.callson an existing service. Manifests whosedocs:field points to an aggregate/doc-only README are exempt from same-folder generation. The drift gate in CI (bootstrapper.docs.regen --all --check) catches stale existing per-service READMEs/SVGs/HTMLs.
Prerequisite: Cairo. Diagram rendering uses cairosvg, which needs the native Cairo library: libcairo2 (Debian/Ubuntu) or brew install cairo (macOS). CI installs libcairo2.
Atlas hero art: regenerate with python bootstrapper/scripts/generate_logo.py (needs pip install pillow + chafa on PATH); commit the refreshed bootstrapper/ui/textual/assets/atlas_hero_*.json. Not gated in CI.
After adding a service, check these allowlists. A hard depends_on into a SOURCE-replaceable family fails CI unless ALLOWED_REPLACEABLE_DEPENDS_ON lists it. A default-on Kong route fails CI unless check-kong-routes.py lists it.
The script renders Compose against .env.example (or --env-file PATH), never your local .env, so it gives CI's answer everywhere. A hard depends_on fails unless it is listed below when it points into either:
- a container of another family whose manifest
sources:offerlocalhostordisabled; - an engine that such a source's endpoint names (for example
speachesbehindTTS_PROVIDER_SOURCE).
Edges inside one family, such as spark-worker โ spark-master, are exempt.
-
REQUIRED_DEPENDS_ONโ reviewed(service, dependency)edges that must stay in the rendered Composedepends_on. The audit fails if one disappears. Add an edge here only to guard it. The script does not compare manifestdepends_onwith Compose. -
FORBIDDEN_OPTIONAL_DEPENDS_ONโ edges that MUST NOT exist even though the rule above does not derive them, such as LightRAG's edges to stores it can replace in-process. -
ALLOWED_REPLACEABLE_DEPENDS_ONโ reviewed exceptions to the derived rule: the dependent cannot work without that SOURCE-replaceable dependency and is only enabled alongside it (for exampletrinoโiceberg-rest). Add an entry only with that justification.
The script runs the Kong route generator against .env.example defaults in a temporary directory. It checks the routes against the EXPECTED_HOST_ROUTES table at the top of the script. If your service publishes a *.localhost alias and its default <SVC>_SOURCE renders a route, add the host and its upstream URL to EXPECTED_HOST_ROUTES. Services that are off by default need no entry.
It also compares every default route by name, in both directions, against EXPECTED_ROUTES. It checks service, upstream URL, hosts, paths, strip_path, preserve_host, and service and route plugins. Global plugins are checked against EXPECTED_GLOBAL_PLUGINS. A path-only route, a dropped key-auth, or a flipped strip_path fails it. A default-on route you add or change needs its EXPECTED_ROUTES entry updated in the same change.
If your service ships a requirements.txt / pyproject.toml in a build/ or provider/ subdirectory, add the path to the directories: list of the pip ecosystem block. Note: all active manifests must be enumerated; omitted paths drop from scan coverage and silent vulnerabilities accumulate.
The .github/workflows/services-lint.yml workflow produces the four checks below.
All four are required status checks in the live gitflow ruleset.
Pull-request edits, including title edits, rerun the full workflow.
A new push or edit cancels that pull request's superseded run.
Runs on main and develop are never cancelled.
| Job | What it catches |
|---|---|
| Manifest lint + unit tests | Aggregates the four jobs listed below the table; green only when all four pass. Catches: manifest schema violations, dependency cycles, env-example drift, category overflow, backend route regressions. |
| Compose merge + byte-equivalence + source-permutation matrix | Renders docker compose config for the merged fragment list + verifies it matches the golden baseline + tests every source variant of every service. Catches: compose-syntax errors, source-permutation regressions. |
| Docs drift + audit scripts | Runs every step of the audit-scripts job (listed below the table). Catches: stale per-service docs, three-surface drift, broken links and anchors, and missing local assets. Also catches missing REQUIRED_DEPENDS_ON entries, Kong route drift, research-schema violations, stale or vulnerable locks, and track-membership omissions. |
| Build-validation | Required. Verifies the commit-pinned remote build contexts against their reviewed base-image digests and Trivy-scans manifest-owned remote images whose declarations changed. It builds no local Dockerfile. The non-required Final-image scan job runs docker buildx build over every local Compose and init context; unsatisfiable pip pins and broken Dockerfiles surface there. |
Manifest lint + unit tests aggregates these four jobs:
-
Bootstrapper and Backend suites (with containers):
validate_fragments, ShellCheck, and the pull-request title and changelog checks. It also runs the 7,000+ bootstrapper tests (with the container-backed backup/restore tests and the coverage floor) and the backend suite (services/backend/app/app/tests/). - Bootstrapper suite without Docker (fast): the whole suite with no Docker daemon, so a failing unit test turns red first.
- Bootstrapper suite on Python 3.10: the full suite on the supported floor.
- MCP and asset API tests: those isolated suites.
Docs drift + audit scripts runs, in the audit-scripts job:
-
regen --all --checkandmake docs-check; - the audit scripts:
check_doc_links(with#anchorvalidation),check-compose-source-deps,check-docs-drift,check-kong-routes,validate_research_schema,check-track-membership; - notebook source hygiene;
- lock checks: Docling localhost provider, Local Deep Researcher, compiled runtimes, ComfyUI custom nodes, CI test locks;
- the ComfyUI custom-node overlay check and the container-image inventory and exception policy;
- a vulnerability audit of the compiled runtime locks.
Run this representative local subset before pushing (from the repository root
unless a subshell changes directory). The authoritative command list is
.github/workflows/services-lint.yml, which also runs ShellCheck, isolated MCP
and asset-service suites, and the bootstrapper suite under Python 3.10:
(
cd bootstrapper
uv run pytest tests/ -q \
--ignore=tests/test_fragment_equivalence.py \
--ignore=tests/test_source_permutations.py \
--cov=. --cov-config=pyproject.toml --cov-branch \
--cov-report=term --cov-fail-under=69
) # job 1 bootstrapper tests
(
cd services/backend/app/app
BACKEND_TEST_VENV="${TMPDIR:-/tmp}/atlas-backend-ci-venv"
uv venv --python 3.12 "$BACKEND_TEST_VENV"
VIRTUAL_ENV="$BACKEND_TEST_VENV" uv pip install \
-r requirements.txt -r requirements-dev.txt -c requirements-test-locked.txt
"$BACKEND_TEST_VENV/bin/python" -m pytest tests/ -q -W error \
--cov=. --cov-config=.coveragerc --cov-branch \
--cov-report=term --cov-fail-under=79
) # job 1 backend tests
uv run --project bootstrapper python -m tools.validate_fragments # job 1 lint
docker compose --env-file .env.example -f docker-compose.yml config -q # job 2 merge check
make docs-check # job 3 three-surface contracts + strict build
uv run --project bootstrapper python scripts/check_doc_links.py # job 3 link check
PYTHONPATH=bootstrapper uv run --project bootstrapper python -m bootstrapper.docs.regen --all --check # job 3 docs drift
uv run --project bootstrapper python scripts/check-docs-drift.py # job 3 docs structural audit
uv run --project bootstrapper python scripts/check-compose-source-deps.py # job 3 deps audit
uv run --project bootstrapper python scripts/check-kong-routes.py # job 3 kong audit
uv run --project bootstrapper python scripts/validate_research_schema.py --all # job 3 research schema
uv run --project bootstrapper python scripts/check-track-membership.py # job 3 track coverage
uv run --project bootstrapper python -m scripts.bounded_subprocess \
--label "Docling localhost lock check" \
--cwd services/docling/provider/localhost -- uv lock --locked # job 3 docling lock
uv run --project bootstrapper python scripts/refresh-local-deep-researcher-lock.py --check # job 3 Local Deep Researcher lock
uv run --project bootstrapper python -m scripts.check_runtime_locks # job 3 compiled runtime locks
uv run --project bootstrapper python -m scripts.bounded_subprocess \
--label "pip-audit tool installation" -- \
uv tool install pip-audit==2.10.0 # job 3 pinned vulnerability-audit tool
uv run --project bootstrapper python -m scripts.audit_runtime_locks # job 3 runtime vulnerability audit
# Final-image scan (not required): docker buildx build over every local Compose
# and init build context. The full, current list lives in that job's build loop
# in .github/workflows/services-lint.yml โ treat that workflow file as the
# source of truth, not this doc. Build contexts validated
# in CI include services/asset-worker/app, services/asset-baker/app,
# services/backend/app, and others โ representative example:
docker buildx build --load -f services/backend/app/Dockerfile services/backend/app/A --<svc>-models-style flag (anything persisted via the wizard's
user-model-selection path) needs FOUR coordinated edits, not two:
- the
@click.optiondeclaration instart.py, - the
source_mapping/ collector-dict entry, - the
user_model_selections[KEY] = kwargassignment inmain(), - the wizard bucket.
_selections_to_argsinui/textual/integration.pyreturns it. The "Apply user model selections" lambda inui/textual/screens/wizard_screen.pyreads it with.get(...). A new bucket key needs both edits. Missing seam 4 silently drops the flag's value. AST-based seam-parity tests (tests/test_user_model_selections_seam_parity.py,tests/test_wizard_app_discovery.py) guard all four โ extend them when adding a picker.
.env*_IMAGEvalues are refreshed from the manifests at every start (_refresh_image_pins_from_manifests()). To pin a different image, export the var in the shell before./start.sh.
depends_on.required doubles as a runtime-boot gate and the wizard's display-order backbone โ see Decision 5 for the full footgun and convention. In short:
- Don't drop a cross-category edge only because it is not a runtime dependency; it may pin wizard display order.
- Don't list a virtual aggregate (
globals,cloud-providers,tts-provider) as a boot dependency; it has no container. A commented display-order pin is allowed.
<SVC>_LOCALHOST_PORT is the single source of truth for localhost URLs โ see Decision 3 for the full rule. The gotcha: the in-container consumer, the Kong route generator and the wizard's inline input must read the same PORT var. Otherwise they silently disagree about where the localhost upstream lives.
-
Kong routes fronting browser SPAs need
preserve_host: True. Without it the SPA emits unreachable redirect URLs containing the internal Docker hostname.
-
Init jobs never install OS packages at container startup. Bake tools into a service-owned build context (for example
init/Dockerfile). Pin the multi-architecture base and every Alpine package version, and give the image a project-local name. Runtimeapk addmakes startup depend on mutable repositories and can silently change a deployed revision.- Application add-ons are the exception.
n8n-initinstalls community nodes withnpm cifrom a committed lockfile. ComfyUI provisioning runspip installfor custom-node requirements. - A Debian image takes security fixes through a reviewed pinned-base refresh.
- If the newest base still has fixable findings, a build-time
apt-get upgradeis accepted, with the rationale written next to it. - The non-required Final-image scan job rescans each refreshed image.
- Application add-ons are the exception.
-
TTS/STT engine in-container ports are NOT all 8000. Parakeet, Speaches, Docling listen on
8000; Chatterbox listens on4123. Don't assume.
-
.env.exampleis byte-equivalence-tested. After any manifest change affecting env vars or port allocation, regen.env.exampleortest_env_example_consistencywill fail CI. -
test_fragment_equivalenceis sensitive to Compose-version defaults. Don't regenerate the golden baseline reflexively when this test fails. Extend_strip_volatile_defaultsin the test fixture instead.
-
A new service in a full category block trips the category-overflow lint. The
infrablock (10 slots) is full. A new infra*_PORTneeds a new block or a different category.datahas 30 slots (Supabase alone uses 8) andmedia20. Check current utilization before assuming there's room:PYTHONPATH=bootstrapper python -c "from services.topology import get_topology; print(get_topology().port_defaults)". -
Renaming a
row.display_namebreaks tests that hardcode it.test_wizard_app_discovery.pyhas anEXPECTED_DISCOVEREDfrozenset; update it when renaming.
A service missing from SourceOverrideManager.source_mapping is silently dropped from the wizard โ no error, the prompt just never appears. See ยง11.4 for the registration mechanics, including the two-entry pattern multi-container families need.
Each service folder can hold additional subdirectories beyond service.yml and compose.yml. Use these well-known names so navigation stays predictable across the stack:
| Subdir | Holds | Example |
|---|---|---|
app/ |
Source code for an app the manifest builds (the manifest's primary container is this code) |
services/backend/app/ (FastAPI source) |
build/ |
Dockerfile + build inputs when the manifest builds a container from scratch |
services/jupyterhub/build/, services/neo4j/build/, services/local-deep-researcher/build/
|
init/ |
Scripts + templates bind-mounted into a <service>-init sidecar container that prepares state before the main container starts |
services/n8n/init/, services/hermes/init/, services/comfyui/init/, services/minio/init/, services/weaviate/init/
|
pull/ |
Same as init/ but the sidecar is named <service>-pull (only ollama) |
services/ollama/pull/ |
config/ |
Read-only configuration files bind-mounted into the main container at runtime |
services/searxng/config/ (settings.yml) |
db/ |
SQL seed and migration scripts |
services/supabase/db/ (scripts/ + the downstream _user/ SQL slot) |
provider/ |
Multiple host/container providers for a single capability the manifest exposes via a SOURCE variable (one engine = one subfolder of provider/) |
services/parakeet/provider/{gpu,mlx,whisper-cpp,shared}, services/docling/provider/{gpu,localhost,shared}, services/tts-provider/provider/localhost
|
extras/ |
User-managed bind-mounted data exposed inside the running container (tools, functions, workflows you can edit on the host) | services/open-webui/extras/{tools,functions,workflows} |
workflows-stage/ |
Example workflow JSON staged for manual import via the n8n UI (the init sidecar installs community nodes but does not auto-import workflows) | services/n8n/workflows-stage/ |
Family folders. Some manifests own a family of related containers (e.g. supabase = 9 containers, n8n = main + worker + init). Each family folder gets a brief README.md listing the containers it ships and pointing at their definition in compose.yml. The fragment's services: keys are the authoritative container list; the manifest's containers: must match 1:1 (the manifest_validator enforces this โ see "Validator rules" below).
- Env var default change โ edit the manifest's
env:block. Re-run the lint. - Image version bump โ see Bump a container image version below.
- New container in the family โ add to
containers:in the manifest AND toservices:in the fragment. - New source variant โ add to
sources.options[]in the manifest.
Short walk-throughs for the modifications you'll do most often:
-
Add a new source variant to an existing service. Edit
sources.options+runtime_sc.<key>to include the new source. Regen.env.example. The source-permutation matrix in CI will exercise every variant โ make sure your new variant has a validruntime_scslice. -
Rename a service's display name (
rows[].display_name). Update the row, then regen the README topology block. Search the test suite for the old name โbootstrapper/tests/test_wizard_app_discovery.py::EXPECTED_DISCOVEREDis the most common dependency. -
Bump a container image version. Edit
images[].default. The fragment uses${X_IMAGE}, so it does not change. Also:- regenerate
.env.example; - update the image line in the fragment baseline (ยง20);
- update the image's row in
docs/reference/license-inventory.yaml, then runmake docs-check; - test the new image locally before you commit.
- regenerate
- Split a service family into multiple manifests. Non-trivial. The supabase manifest (9 containers in one family) is the reference pattern; consult it before splitting.
Service READMEs follow a numbered convention (## 1. Overview, ## 2. Access, โฆ). The "Dependencies & Integrations" block sits at whatever section number N the README's structure places it โ typically 5, but 7/9/12/14 in READMEs with extra pre-Deps content. The bootstrapper/docs/regen.py tool detects N and emits matching subsection numbering (### N.1 through ### N.6) inside the block.
Never link to a sub-section by number across services. "See section 5.4 in the backend README" breaks when a new pre-Deps section makes it 6.4. Always reference by heading text instead: "See Future โ Missing pair integrations in the backend README."
This section is a quick field reference. For guidance on which fields apply when (and why), see Decisions 1โ6 above.
name: myservice
label: "My service (human-readable)"
category: apps # infra | data | llm | media | agents | apps
docs: services/myservice/README.md # safe repo-relative Markdown path
# docs_exception: "No guide is published because compatibility migrations preserve retired environment keys."
# Use only when neither docs nor a sibling README.md is available.
virtual: false # true for env-only manifests like cloud-providers
support: # required by tests; see ยง22
tier: experimental # stable | experimental | community | unsupported
evidence: "No qualification run cited yet."
evidence_revision: v0.1.0 # release tag or full 40-char commit id
capabilities: # required by the schema; at least one entry
- name: "Short capability label"
status: supported # supported | partial | stubbed | not-supported
verification: untested # tested | documented | untested
note: "One-line boundary or limitation."
containers: # may be [] when virtual: true
- myservice
images:
- var: MYSERVICE_IMAGE
default: "myorg/myservice:1.0.0"
container: myservice
sources: # OPTIONAL; omit for single-source services
var: MYSERVICE_SOURCE
default: container
auto_prefer: # OPTIONAL; enables `MYSERVICE_SOURCE: auto`.
- id: container # Ordered; last entry must be unconditional.
options:
- id: container
label: "Container"
- id: disabled
label: "Disabled"
# Per-source runtime data โ what the bootstrapper actually consumes.
# The synthesizer (bootstrapper/services/sc_synthesizer.py) concatenates
# this block (plus all other manifests' runtime_sc blocks) into the dict
# that ConfigParser.load_yaml_config() returns.
runtime_sc:
myservice: # the key matches a name service_config.py reads
container:
scale: 1
environment:
MYSERVICE_ENDPOINT: "http://myservice:8080"
deploy: {}
extra_hosts: []
disabled:
scale: 0
environment:
MYSERVICE_ENDPOINT: ""
deploy: {}
extra_hosts: []
env:
- name: MYSERVICE_SOURCE
default: container
- name: MYSERVICE_PORT # no default: the slot allocator assigns it
- name: MYSERVICE_API_KEY
default: ""
secret: true # logged redacted; a default IS written to .env.example, so keep "" for real secrets
- name: MYSERVICE_SCALE
auto_managed: true # computed by service_config.py from source value
- name: MYSERVICE_ENDPOINT
auto_managed: true
depends_on: # logical deps (compose-level lives in compose.yml)
required: []
optional: []
exports: # documents the cross-service env-var contract
- name: MYSERVICE_ENDPOINT
consumers: [backend, n8n]
rows:
- display_name: "My Service"
source_var: MYSERVICE_SOURCE
port_var: MYSERVICE_PORT
scale_var: MYSERVICE_SCALE
alias: myservice.localhost
description: "Short description shown in the wizard subtitle."
# OPTIONAL โ inline numeric input mounted on the source prompt. Used by
# Prometheus (TSDB retention days). The wizard
# renders a SecondaryNumberInput widget next to the source picker so
# the user picks source AND numeric refinement in one keystroke
# sequence โ no follow-up cascade step.
secondary_number:
env_var: MYSERVICE_KNOB
label: "Knob value"
default: "10"
visible_when_source: ["container"] # hide for `disabled`
min: 1
max: 100Every service.yml first passes bootstrapper/schemas/service.schema.json.
The table below is generated from the ordered registry that also executes the
cross-manifest rules; do not edit it by hand.
| Rule | Diagnostics | Contract |
|---|---|---|
unique_env_vars |
duplicate_env_var |
Each environment variable has exactly one owning manifest. |
unique_containers |
duplicate_container |
Each Compose container name has exactly one owning manifest. |
unique_capabilities |
duplicate_capability |
Capability names are unique within each manifest. |
support_evidence |
support_stable_without_release_evidence |
A stable support tier cites evidence gathered at a release tag. |
data_flow_targets |
data_flow_unknown_target, data_flow_duplicate_target, data_flow_missing_evidence
|
Every runtime data-flow target names a manifest or approved aggregate documentation folder, appears once, and any repo-path evidence exists. |
dependency_closure |
unknown_dependency |
Required and optional dependencies name existing manifests. |
export_consumer_closure |
unknown_consumer |
Every exported-variable consumer names an existing manifest. |
per_manifest_contract |
undeclared_source_var, undeclared_export
|
Source variables and exported values are declared or produced by their owning manifest. |
dependency_tier_members |
undeclared_tier_member |
Every runtime dependency-tier member names a declared container. |
topology_acyclic |
topology_cycle |
The combined required-dependency graph is acyclic. |
alias_uniqueness |
duplicate_alias |
Kong row aliases are unique across manifests. |
category_capacity |
category_overflow |
Port-owning variables fit within their category's allocated slot block. |
engine_reachability |
engine_orphan |
Engine-only manifests are reachable from a parent source option. |
runtime_source_coverage |
runtime_sc_unknown_variant, runtime_sc_missing_variant
|
Main runtime slices match the manifest's declared source variants exactly. |
production_source_availability |
no_prod_option, duplicate_source_option
|
Every source-configurable service retains an option available in the production profile, and declares each option id once. |
secondary_number_inputs |
invalid_secondary_number |
Manifest-driven numeric inputs have valid bounds, defaults, sources, and owned environment variables. |
automatic_source_preferences |
auto_prefer_unknown_option, auto_prefer_unknown_capability, auto_prefer_no_fallback, auto_prefer_fallback_not_terminal
|
Automatic source preferences use known options and capabilities with a terminal fallback. |
fragment_container_contract |
fragment_container_drift, missing_fragment, unexpected_fragment
|
Non-virtual manifests match a sibling Compose fragment; virtual manifests have none. |
fragment_include_contract |
missing_fragment_manifest, fragment_include_drift
|
Every on-disk Compose fragment has a manifest and appears exactly once in the root include list. |
manifest_documentation |
missing_documentation, invalid_documentation, invalid_documentation_exception, documentation_exception_conflict
|
Every manifest has safe Markdown documentation or a specific validated exception. |
bootstrapper/tests/test_fragment_equivalence.py asserts that
docker compose -f docker-compose.yml config matches the golden baseline at
bootstrapper/tests/fixtures/rendered_config_baseline.yml. If you change a
fragment's compose shape, you'll either need to update the baseline (after
confirming the change is intentional) or restore byte-equivalence.
The gate does not compare a raw docker compose config dump. It renders from
.env.example plus a few test overrides and passes -p atlas. It also rewrites
machine-specific absolute paths to the {REPO_ROOT} and {HOME} tokens the committed
fixture stores. Redirecting docker compose config into the fixture does none of that.
The result can never match the gate. That command also reads your live .env, so it
writes real secrets into a tracked file.
Run these commands from the repository root.
# Inspect drift โ the gate is the diff; it reports the first mismatching key path.
(cd bootstrapper && uv run pytest tests/test_fragment_equivalence.py -q)
# Refresh the baseline (only when the drift is intentional), reusing the gate's
# own helpers so the refreshed fixture is what the gate will compare against.
(cd bootstrapper && uv run python -c "
import subprocess, yaml
from tests.test_fragment_equivalence import _build_test_env, _normalize_paths, BASELINE, REPO_ROOT
rendered = subprocess.run(
['docker', 'compose', '--env-file', str(_build_test_env()), '-p', 'atlas',
'-f', str(REPO_ROOT / 'docker-compose.yml'), 'config'],
capture_output=True, text=True, check=True,
).stdout
BASELINE.write_text(yaml.safe_dump(_normalize_paths(yaml.safe_load(rendered)), sort_keys=True))
")A full refresh re-sorts and reflows the whole (5,000+ line) fixture, which buries the change you actually made. For a one- or two-key change, edit the fixture surgically instead and let the gate confirm it.
Downstream repos that vendor Atlas as a git submodule can add services without
editing the upstream tree. Put each one under services/_user/<name>/:
services/
โโโ _user/ # gitignored upstream; tracked downstream
โ โโโ my-extra-service/
โ โโโ compose.yml # required โ auto-merged & launched (see below)
โ # (a service.yml is optional; the core loader
โ # skips _-prefixed dirs, so it's only for your
โ # own tooling)
โโโ supabase/
โโโ ollama/
โโโ โฆ
Auto-launch. At start, DockerManager._compose_file_args appends each
services/_user/*/compose.yml after -f docker-compose.yml, so overlay services
start and stop with the stack. With no overlay, the bootstrapper passes only
-f docker-compose.yml, so the core stack and the byte-equivalence baseline do
not change.
An overlay is a self-contained Compose fragment:
- It brings its own image, host ports and environment.
- It joins the shared network with
networks: { backend-network: { name: ${PROJECT_NAME}-network, external: true } }. - Prefix its published ports with
${HOST_BIND_IP-127.0.0.1:}to keep Atlas's loopback binding default. - It is not wired into the wizard, the topology port allocator or the generated
.env.example. -
bootstrapper.services.manifests.load_manifestsskips_-prefixed folders, so a_user/<name>/service.ymlis ignored. A wrapper can callload_manifests(services_dir / "_user")for the manifest model.
The upstream .gitignore excludes services/_user/, so the folder never leaks
into a fork's pull requests. New consumers should prefer atlas.consumer.yml
overlays (Reusing Atlas ยง6.1);
_user/ stays for existing integrations. Walkthrough:
Reusing Atlas ยง6.1.1.
A few fields on service.yml document ownership and capability contracts even
though containers do not consume them directly:
-
support:โ the family's support tier and its evidence.- Required keys:
tier(stable,experimental,community,unsupported),evidenceandevidence_revision. -
evidenceis a one-line pointer to the qualification run, or a plain statement that none exists.evidence_revisionis the release tag or full commit. Optional keys:ownerand one-linelimitations. - A selectable family is not thereby validated: every family starts
experimental.stableneeds cited cold-start, workflow and upgrade evidence from a release tag. The validator rejects a bare commit (support_stable_without_release_evidence), and a test checks that the tag is in the release record. - The tier appears in the generated catalog's Support column and at the top of each README's Capabilities & limitations section. Every non-stable family also gets a
[support: โฆ]badge on its wizard prompt.
- Required keys:
-
images[].notesโ free-form note on what the image is used for. Not read by any Python code. -
docs:โ repository-relative pointer to existing Markdown. The validator rejects missing, escaping, non-Markdown, directory, and symlink targets. -
docs_exception:โ a last-resort reason when a manifest has no docs and no siblingREADME.md. Write one printable line with an explicitbecauseclause followed by at least four substantive rationale words, including at least three distinct terms. The validator:- applies NFKC and case-folding, and rejects every Unicode category-C character (controls and zero-width formatting);
- removes generic boilerplate and link-like text before locating and scoring the rationale. Link-like text is Markdown links/labels, angle autolinks, RFC URI schemes, email-like tokens, validated IP links and dotted host candidates;
- treats as email-like an atom or quoted local part (spaces allowed) followed by a Unicode dotted host or bracketed domain literal. This grammar is deliberately not a full RFC email parser;
- for hosts: Host candidates are IDNA-normalized. A valid dotted host
with a port/path/query/fragment is stripped regardless of case, except invalid
all-numeric candidates. Bare lowercase hosts with an alphabetic 2โ63 character final label
(or a punycode label) are also stripped. So an ambiguous lowercase identifier such as
module.foois removed; - keeps as technical context: bare uppercase
module.Class, digit-suffixed identifiers and versions, single-label service DNS, numeric host-port pairs. It also keeps bare.local/.internal/.localhostnames, includingwww-prefixed service DNS. Otherwise, suffix-bearing reserved DNS is stripped; - strips Validated IPv4 and bracketed IPv6 only with a port/path/query/fragment;
bare IP mentions remain. Bracketed IPv6 may carry an optional non-empty zone identifier in
raw
%eth0or RFC 6874%25eth0form. The identifier is bounded to[A-Za-z0-9._~-]+and checked by the IP address parser; - strips, with a bounded fallback, malformed bracketed colon/address-like tokens that carry a port/path/query/fragment, so suffix prose cannot be scored. It does not assert that those fallbacks are valid addresses;
- accepts a tracking link after a real reason, but not as the reason, and rejects an exception on a documented service.
This is a bounded authoring gate, not proof that a rationale is semantically correct; reviewers still assess the concrete claim.
-
exports[]โ declares the env-var contract this service offers to other services. The cross-manifest validator (bootstrapper/services/manifest_validator.py) checks closure (every consumer name resolves) but does NOT check that the exported value is actually produced at runtime.
Every manifest, including virtual: true manifests, must resolve to safe
documentation or carry a validated exception. Doc-only folders have no
manifest and remain outside this rule.