Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,35 @@ XTERM_TAG=dev-0.1.2-6cdf181
QDRANT_PORT=6333
QDRANT_API_KEY=devkit-local-qdrant

# ─────────────────────────────────────────────────────────────────────────────
# Container runtime — which CLI runs the stack and the builds.
#
# Leave it commented out to auto-detect, which tries docker then podman, taking the first one
# INSTALLED (presence only — it is not checked for being reachable), so a machine that has always had
# Docker keeps behaving exactly as before. Installing Docker alongside podman therefore moves you onto
# Docker silently. Set it to pin one:
#
# docker | podman
#
# The requirement is a Docker-compatible CLI that also has a `compose` subcommand — podman delegates
# that to podman-compose or docker-compose, so install one of those alongside it.
#
# NOT a valid value: runc (or crun, youki, runsc…). Those are low-level OCI runtimes that execute an
# already-unpacked bundle given by path — they have no images, registries, networks or compose, so
# there is no `runc run <image>`. They are what the CLIs above call underneath; to choose one, tell
# your CLI (e.g. `podman --runtime crun`), not this variable.
#
# Rootless podman is handled automatically: builds run as uid 0 inside the user namespace, which is
# what makes the extension bundle come out owned by you rather than by an unreachable subuid.
# ─────────────────────────────────────────────────────────────────────────────
#RUNTIME=docker

# Extension build toolchain (.NET SDK 8 + Node 22). Only pulled the first time you build an
# extension — the platform itself doesn't use it. An already-pulled tag is reused without checking the
# registry: BUILDER_PULL=1 forces a re-pull (this tag moves), BUILDER_IMAGE=<ref> replaces the image
# outright, BUILDER_PLATFORM=<os/arch> forces an architecture. Each build's package caches live in
# per-uid named volumes that nothing prunes — `docker volume ls | grep duplo_devkit` to find and
# `docker volume rm` to reclaim them (each holds a full NuGet + npm closure).
# per-uid named volumes that nothing prunes — `<runtime> volume ls | grep duplo_devkit` to find and
# `<runtime> volume rm` to reclaim them (each holds a full NuGet + npm closure).
BUILDER_TAG=latest

# Build on THIS machine instead of in the toolchain container. Set to 1 if you already have .NET SDK 8
Expand All @@ -37,6 +60,15 @@ BUILDER_TAG=latest
# same thing for a single run, and wins over this. Leave it commented out to build in the container.
#DUPLO_BUILD_NATIVE=1

# Minimum podman VM size run.sh will start on (macOS/Windows only — a native-Linux podman has no VM,
# and docker is not checked). The VM's memory is fixed at create time and shared by this
# stack AND every extension build; too little and the Angular frontend build is OOM-killed, reporting
# itself only as 'exit status 137' at the foot of a Go stack trace. Undersized memory is a hard failure;
# a low CPU count is just a warning. Lower the floor only if you know a smaller machine suits you —
# 2 GiB is known to fail, 8 GiB is comfortable.
#PODMAN_MIN_MEMORY_MIB=6144
#PODMAN_MIN_CPUS=4

# On Apple Silicon the studio image is amd64-only (emulated). Leave as-is unless you have an arm64 image.
STUDIO_PLATFORM=linux/amd64

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![Release](https://img.shields.io/github/v/release/duplocloud/devkit?label=release)](https://github.com/duplocloud/devkit/releases)

Run the DuploCloud AI HelpDesk platform on your laptop via Docker — then build your own
Run the DuploCloud AI HelpDesk platform on your laptop in containers — then build your own
**Agent** against it. Each Agent you write ships a backend, a UI, and its own provisioning, and
hot-loads into the running platform with no restart.

Expand All @@ -20,7 +20,8 @@ hot-loads into the running platform with no restart.

## Quick start

You need **Docker with Compose v2**, **Python 3**, and access to an LLM. `./run.sh` prompts for
You need **a container runtime with Compose v2** (docker or podman — set `RUNTIME`
in `.env` to pin one, otherwise it is auto-detected), **Python 3**, and access to an LLM. `./run.sh` prompts for
Anthropic, AWS Bedrock, or an Anthropic-compatible LLM gateway (OpenRouter, Bifrost, LiteLLM, …).

```bash
Expand Down
27 changes: 19 additions & 8 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,34 +157,45 @@ services:
environment:
- CORS_ORIGIN=*

# Build toolchain for extensions (.NET SDK 8 + Node 22). Behind a profile so `docker compose pull`
# Build toolchain for extensions (.NET SDK 8 + Node 22). Behind a profile so the `compose pull`
# and `up` in run.sh skip it entirely — a user who only runs the platform never downloads it. The
# build wrapper starts it on demand with `docker compose run --rm --no-deps builder`.
# build wrapper starts it on demand with `$RUNTIME compose run --rm --no-deps builder`.
#
# Being a compose service (rather than a bare `docker run`) is what lets the build reach the studio over
# Being a compose service (rather than a bare `run`) is what lets the build reach the studio over
# the compose network — the SDK feed comes from AIStudio at http://duplo-ai-studio:60021 — but only when
# the studio is in THIS compose project. `docker compose` is project-scoped (the project defaults to the
# the studio is in THIS compose project. Compose is project-scoped (the project defaults to the
# directory name), so a platform started from a second checkout or a git worktree — which is how this
# repo is developed — is not on this network and that name does not resolve. scripts/_builder.sh detects
# which case it is and falls back to the published host port via host.docker.internal (see extra_hosts
# below). Both routes are live; the compose network is simply the preferred one.
builder:
profiles: [tools]
image: ${BUILDER_IMAGE:-quay.io/duplocloud/duplo-extension-builder:${BUILDER_TAG:-latest}}
# Deliberately ONE level of interpolation, not a nested ${BUILDER_IMAGE:-…:${BUILDER_TAG:-latest}}:
# podman-compose 1.5 stops at the first '}' and appends the remainder literally, yielding
# "…busybox:1.36}" and an "invalid reference format" error even when BUILDER_IMAGE is set. Nothing
# is lost — BUILDER_TAG is resolved into BUILDER_IMAGE by scripts/_builder.sh:builder_resolve_image,
# which always exports it before compose is invoked, so the default below is only ever reached by a
# hand-run `compose run` with no BUILDER_IMAGE set (where it now means :latest regardless of
# BUILDER_TAG — use the build scripts, or set BUILDER_IMAGE outright, to pin another tag).
image: ${BUILDER_IMAGE:-quay.io/duplocloud/duplo-extension-builder:latest}
# Unset by default, so the build runs on the host's native architecture. Set BUILDER_PLATFORM only
# to force one (e.g. linux/amd64 if a publish ever ends up amd64-only) — an unset value is dropped
# by compose, whereas copying duplo-ai-studio's linux/amd64 default would silently emulate a .NET
# build on Apple Silicon.
platform: ${BUILDER_PLATFORM:-}
# The caller's real uid/gid, exported by scripts/_builder.sh — so dist/ ends up owned by the
# invoking user, not root. The default is only a fallback for a hand-run `docker compose run`.
# The ids that make dist/ come out owned by the invoking user, exported by scripts/_builder.sh.
# NOT always `id -u`: under rootless podman the caller is already mapped to uid 0 inside the user
# namespace, so 0:0 is what yields caller-owned files and a real uid maps to an unwritable subuid.
# scripts/_runtime.sh:runtime_builder_ids picks per runtime. The default here is only a fallback for
# a hand-run `compose run`, and is correct for docker; under rootless podman pass DUPLO_UID=0.
user: "${DUPLO_UID:-1000}:${DUPLO_GID:-1000}"
working_dir: /work
# Lets the build reach a studio that is NOT in this compose project via the published host port.
# compose scopes services per project (the directory name), so a stack started in another checkout
# is not on this project's network and duplo-ai-studio does not resolve; _builder.sh detects that and
# points DUPLO_BASE at host.docker.internal instead. A no-op on Docker Desktop, where the name
# already resolves; on Linux it is what makes it resolve at all.
# already resolves; on Linux (and under podman, which maps host-gateway the same way) it is what
# makes it resolve at all.
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
Expand Down
40 changes: 40 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Everything the dev kit reads comes from `.env` in the repo root. Copy it from `.
> [SUPPORT.md](../SUPPORT.md) before sharing any output.

- [How `.env` is maintained](#how-env-is-maintained)
- [Container runtime](#container-runtime)
- [Licensing](#licensing)
- [Image tags](#image-tags)
- [Host ports](#host-ports)
Expand All @@ -27,6 +28,45 @@ Framework-shipped defaults — the image tags, the studio platform, and the lice
your `.env` **only if you have not diverged from the previously-applied default**. A tag you pinned is always
kept. The mechanism is described in [upgrading.md](upgrading.md#how-defaults-are-adopted).

## Container runtime

| Variable | Default | Effect |
| --- | --- | --- |
| `RUNTIME` | *unset* — auto-detect | Which container CLI every script drives. Accepts `docker` or `podman`. |

Docker is the default and needs no configuration. Left unset, the scripts auto-detect by walking the
supported CLIs in order and taking the first one found.

Two things about that detection are worth knowing, because both have cost people time:

- **It tests presence only — there is no daemon check.** A CLI that is installed but not running still
wins the detection. This is deliberate (`scripts/_runtime.sh`): conflating "installed" with "working"
would make an unstarted Docker Desktop silently fall through to another runtime, and a build would
then run somewhere you did not intend.
- **Order is precedence.** Installing Docker alongside an existing podman silently moves you onto Docker.
A correctly configured podman machine can be up and idle while `./run.sh` uses Docker instead.

Check which one you will actually get, and pin it if you care:

```bash
bash -c 'source scripts/_runtime.sh; echo "using: $(runtime_detect)"'
```

```bash
RUNTIME=podman # docker | podman
```

An explicitly requested runtime that turns out to be unusable is a **hard error**, not a fallback —
silently building with something other than what you asked for is never right. Auto-detection finding
nothing may still fall back to a native toolchain build.

`RUNTIME` does not select a *low-level* OCI runtime. `runc`, `crun`, `youki` and `runsc` are not valid
values — they run an already-unpacked bundle by path and have no images, registries or compose. Choose
one through your CLI instead (`podman --runtime crun`).

See [prerequisites](getting-started/prerequisites.md#01-a-container-runtime-with-compose-v2) for
install-time requirements, including the podman-specific setup on Apple Silicon.

## Licensing

The dev kit is licensed, and the studio will not serve without a license. `run.sh` handles this on first
Expand Down
87 changes: 78 additions & 9 deletions docs/getting-started/prerequisites.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
# 0. Prerequisites

**What you'll do:** Confirm you have what this kit needs — Docker with Compose v2, Python 3, LLM access,
a verified email address, and five free ports.
**What you'll do:** Confirm you have what this kit needs — a container runtime with Compose v2, Python 3,
LLM access, a verified email address, and five free ports.

**What you need first:** Nothing. This is the first page.

The dev kit runs the whole platform from published images. There is no *language* toolchain to install —
no .NET, no Node: every container it starts, and every extension you later build, is built and run inside
Docker. The two things that must exist on the host are Docker itself and `python3`, which the setup
scripts use as their JSON and `.env` editor.
a container. The two things that must exist on the host are a container runtime and `python3`, which the
setup scripts use as their JSON and `.env` editor.

---

## 0.1 Docker, with Compose v2
## 0.1 A container runtime, with Compose v2

Docker Desktop, Colima, Rancher Desktop, or a plain Docker Engine all work. What matters is that the
daemon is running and that `docker compose` (two words, the v2 plugin) exists.
Docker is the default and needs no configuration. **podman** is also supported — Docker Desktop,
Colima, Rancher Desktop, plain Docker Engine and rootless podman all work.
What matters is that the runtime is reachable and that its `compose` subcommand (two words) exists.

1. Check both at once.
1. Check both at once — substitute your runtime for `docker`.

```bash
docker --version && docker compose version
Expand All @@ -33,6 +34,73 @@ daemon is running and that `docker compose` (two words, the v2 plugin) exists.
Any Compose `v2.x` or newer is fine. If the second line does not print, you have Compose v1 only and
need the v2 plugin.

2. If you have more than one runtime installed, or want to pin one, set `RUNTIME` in `.env`:

```bash
RUNTIME=podman # docker | podman
```

Left unset, the scripts auto-detect, trying `docker` then `podman`, and taking the first one
**installed** — presence only, with no check that it is running. Installing Docker alongside podman
therefore moves you onto Docker silently. Confirm which you will get:

```bash
bash -c 'source scripts/_runtime.sh; echo "using: $(runtime_detect)"'
```

> **podman:** it needs a compose provider, because podman shells out to one rather than implementing
> compose itself. Podman Desktop installs one during setup (a `docker-compose` binary, which podman
> delegates to), or `brew install podman-compose`. Either satisfies the `podman compose version` check
> `run.sh` runs — so if that already answers, there is nothing to install. Rootless
> podman needs nothing else; the build scripts handle the user-namespace uid mapping for you, so
> extension bundles come out owned by you rather than by root.
>
> **podman on Apple Silicon** needs three more things before the studio will start, because the studio
> image is amd64 on some tags and runs emulated:
>
> 1. **Create the machine with `--provider applehv`.** Rosetta is an applehv feature. podman 6 defaults
> to **libkrun** on Apple Silicon, and libkrun has no Rosetta support at all — upstream's
> `LibKrunStubber.GetRosetta` returns false unconditionally, so a `rosetta = true` under libkrun is
> read and silently discarded, and `podman machine inspect` keeps reporting `Rosetta: false` however
> many times you edit the config. **Unlike the rosetta key, the provider is fixed at
> `podman machine init`** — it is the one setting an existing machine cannot be talked out of, so
> switching means destroying and recreating the VM.
> 2. **Enable Rosetta.** podman does **not** enable it by default, and without it amd64 binaries fall
> through to QEMU, which cannot run the studio's .NET runtime. Create
> `~/.config/containers/containers.conf` with `[machine] provider = "applehv"` and
> `[machine] rosetta = true` before starting the machine (on applehv the rosetta key is re-read on
> every `podman machine start`, so *that* key only needs a stop/start, not re-creating). **If that
> file already exists, edit its `[machine]` section — never append a second `[machine]` table, which
> is a TOML duplicate-key error that stops podman running at all.**
> 3. **Size the machine.** `podman machine init` defaults to 2048 MiB; `run.sh` hard-fails below
> 6144 MiB, because an undersized VM does not stop the stack coming up — it poisons later extension
> builds with an OOM kill that reports itself as `exit status 137`.
>
> Which makes the whole first-time sequence:
>
> ```bash
> mkdir -p ~/.config/containers
> printf '[machine]\nprovider = "applehv"\nrosetta = true\n' > ~/.config/containers/containers.conf
> podman machine init --provider applehv --cpus 4 -m 8192 --disk-size 100
> podman machine start
> ```
>
> Verify all three before your first run:
>
> ```bash
> podman machine list --format '{{.Name}} {{.VMType}}' # want applehv, NOT libkrun
> podman machine ssh 'ls /proc/sys/fs/binfmt_misc/' # want a `rosetta` entry, no `qemu-x86_64`
> podman machine list # want MEMORY >= 6144 MiB
> ```
>
> If the studio hangs on startup anyway, see
> [troubleshooting](../troubleshooting.md#runsh-hangs-on-waiting-for-studio-apple-silicon).
>
> **Not a runtime:** `runc` (and `crun`, `youki`, `runsc`). Those are low-level OCI runtimes that run an
> already-unpacked bundle by path — they have no images, registries or compose, so they cannot drive
> this kit. They are what the CLIs above use underneath; select one via your CLI, e.g.
> `podman --runtime crun`.

## 0.2 Python 3

`run.sh` and everything under `scripts/` shell out to `python3` to rewrite `.env` safely (tokens and keys
Expand All @@ -46,7 +114,8 @@ python3 --version
macOS ships one with the Xcode command line tools (`xcode-select --install`) or `brew install python3`;
Debian/Ubuntu, `sudo apt-get install -y python3`; RHEL/Amazon Linux, `sudo dnf install -y python3`.

`./run.sh` checks for this and for Docker before it does anything else, and names whatever is missing.
`./run.sh` checks for this and for your container runtime before it does anything else, and names
whatever is missing.

## 0.3 LLM access

Expand Down
Loading
Loading