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
20 changes: 12 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@
# other key forces a license-server network call on every container boot.
DBOS_CONDUCTOR_LICENSE_KEY=

# Conductor source for the integration test. No location is assumed: with
# neither of these set, the integration test fails. Provide one:
# CONDUCTOR_DIR - path to a local conductor checkout; built via its
# docker/Dockerfile. Required today (no published image has
# the API yet).
# CONDUCTOR_IMAGE - a conductor image tag that includes the API. Preferred
# once such an image exists.
CONDUCTOR_DIR=
# Conductor source for the integration test. Neither of these is needed: by
# default the harness pulls dbosdev/conductor:latest from Docker Hub, re-pulling
# each run so it really is the latest (see defaultImage in
# internal/conductortest/conductortest.go). Set one to override:
# CONDUCTOR_DIR - path to a local conductor checkout, built via its
# docker/Dockerfile. Use this to test conductor changes that
# aren't released yet. Wins over CONDUCTOR_IMAGE, and costs a
# multi-minute build on first run.
# CONDUCTOR_IMAGE - a specific conductor tag instead of latest, e.g. to check
# whether a failure arrived with a conductor release. Used
# as-is, with no re-pull, so a locally built tag works too.
# CONDUCTOR_DIR=
# CONDUCTOR_IMAGE=
66 changes: 54 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@ on:
branches: [main]
pull_request:

# One run per PR: a new push cancels the superseded one, which matters most for
# the integration job (containers, ~2 min) whose result is stale the moment the
# branch moves. Scoped to pull_request — pushes to main keep every run, so main
# has an unbroken per-commit history to bisect.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod

Expand All @@ -24,23 +32,57 @@ jobs:

- run: make lint
- run: make build
- run: make test
- run: make test JUNIT=unit-tests.xml

# `show: skip` is the point, not a detail: a skipped tier still reports a
# green job, so the summary is what distinguishes "passed" from "never ran".
- name: unit test summary
uses: test-summary/action@v2
if: always()
with:
paths: unit-tests.xml
show: "fail, skip"

- name: upload unit test results
uses: actions/upload-artifact@v7
if: always()
with:
name: unit-test-results
path: unit-tests.xml

integration:
# Container-backed (tier 3). Runs only when a conductor image with the API
# is configured: CI has no conductor checkout, and the harness fails (rather
# than assume a location) with no source set — so gate the whole job on the
# CONDUCTOR_IMAGE repo variable. Set it once such an image is published. The
# license key still gates the tier at runtime (fork PRs get no secret → skip).
if: ${{ vars.CONDUCTOR_IMAGE != '' }}
# Container-backed (tier 3). Needs only Docker — the harness pulls the latest
# published conductor image from Docker Hub, so there is no conductor checkout
# to build here. Because that tag moves, a conductor release can turn this job
# red with no change in this repo; set the CONDUCTOR_IMAGE repo variable to
# pin an older tag and confirm that, without a code change. The license key
# gates the tier at runtime (fork PRs get no secret → the tests skip and the
# job stays green).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: integration tests
env:
DBOS_CONDUCTOR_LICENSE_KEY: ${{ secrets.CONDUCTOR_LOCAL_LICENSE_KEY }}
CONDUCTOR_IMAGE: ${{ vars.CONDUCTOR_IMAGE }}
run: make test-integration
run: make test-integration JUNIT=integration-tests.xml

# Without the license key every conductor test skips and this job still
# goes green, which is indistinguishable from a pass on the checks list.
# The summary makes that visible.
- name: integration test summary
uses: test-summary/action@v2
if: always()
with:
paths: integration-tests.xml
show: "fail, skip"

- name: upload integration test results
uses: actions/upload-artifact@v7
if: always()
with:
name: integration-test-results
path: integration-tests.xml
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ openapi.json

# Editor settings are personal — not committed.
.vscode/

# JUnit reports from `make test JUNIT=...`.
*-tests.xml
17 changes: 11 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,21 +337,26 @@ Container-backed tests use **testcontainers-go** (core + `modules/postgres`, bot

**Tier 3 — conductor + Postgres containers.** The CLI never talks to Postgres; it exists only to back conductor. Its `docker/entrypoint.sh` runs migrations automatically. Wait on `/healthz`. Covers the real command surface end to end in `selfhosted` mode.

Source the conductor container **two ways, selected by env**, so the eventual switch is config rather than a rewrite:
Source the conductor container **three ways, in precedence order**, so switching is config rather than a rewrite:

- `CONDUCTOR_IMAGE` set → pull that image. Preferred once available.
- otherwise → `FromDockerfile{Context: $CONDUCTOR_DIR, Dockerfile: "docker/Dockerfile"}`. Required today (no published image has the API yet). **No location is assumed** — `CONDUCTOR_DIR` must be set explicitly. The build passes `TARGETARCH` as a build arg: conductor's Dockerfile needs it to fetch the right `golang-migrate` binary, and testcontainers' build doesn't set it the way BuildKit/`docker compose` do, so the harness supplies `runtime.GOARCH`.
- `CONDUCTOR_DIR` set → `FromDockerfile{Context: $CONDUCTOR_DIR, Dockerfile: "docker/Dockerfile"}`. The escape hatch for conductor changes that aren't released yet; it wins over `CONDUCTOR_IMAGE` and costs a multi-minute build. The build passes `TARGETARCH` as a build arg: conductor's Dockerfile needs it to fetch the right `golang-migrate` binary, and testcontainers' build doesn't set it the way BuildKit/`docker compose` do, so the harness supplies `runtime.GOARCH`.
- else `CONDUCTOR_IMAGE` set → pull that tag, as-is (no forced re-pull, so a locally built tag works). For pinning an older conductor to check whether a failure arrived with a release.
- else → pull `defaultImage` (`internal/conductortest/conductortest.go`). **This is the normal path**, including in CI.

> A public image exists — **`dbosdev/conductor`** on Docker Hub (multi-arch amd64/arm64, tags `latest`/`0`/`0.15`/`0.15.0` back to `0.6.0`) — but **no published tag contains the Conductor API**: every tag is built from `main`, where `controllers/apiv2` and the `openapi` subcommand do not exist. The publish workflow (`.github/workflows/docker-publish.yml`) is `workflow_dispatch`-only and has never produced a `dev-*` tag. Once it merges and a release is cut, set `CONDUCTOR_IMAGE=dbosdev/conductor:<tag>` and drop the checkout dependency from CI entirely. Before then, manually dispatching that workflow on the API branch would publish `dev-<shortsha>` and unblock image-based testing early.
> The default is **`dbosdev/conductor:latest`** on Docker Hub (multi-arch amd64/arm64), with `AlwaysPullImage` on. Deliberately a moving tag: the suite tests what conductor ships *today*, so an API break surfaces the day it lands instead of at the next bump. The trade is that a conductor release can turn this suite red with no commit here — when that happens, suspect drift between the released API and the vendored spec first, and re-vendor from the image: `docker run --rm --entrypoint /app/dbos-conductor dbosdev/conductor:latest openapi | jq -S . > internal/api/openapi-3.1.json` (the image equivalent of `make spec`, which needs a checkout). The forced re-pull applies **only** to this default — without it, testcontainers would reuse whatever `latest` a machine happened to cache and quietly test an old conductor.
>
> `0.17.0` is the first release with the v2 API — cut from a `main` that includes `controllers/apiv2` and the `openapi` subcommand, which `0.16.0` and earlier (back to `0.6.0`) do not. So `latest` is only safe to track from that release onward; anything older cannot serve this CLI at all.

> **Gate:** conductor **panics without `DBOS_CONDUCTOR_LICENSE_KEY`** (`~/conductor/config/config.go:206-208`). Use the **local** key, not the cloud one: `validateLicenseKey` short-circuits to "pro" when the key's SHA-256 matches a hardcoded entry in `localKeyHashes`, so validation is entirely offline. Any other key triggers a startup call to `https://cloud.dbos.dev/v1alpha1/conductor-api-keys/check` on every container boot. Skip the tier when the **key** is unset — never fail the suite for a missing secret (fork PRs don't get secrets). The conductor **source** is different: with neither `CONDUCTOR_IMAGE` nor `CONDUCTOR_DIR` set the harness **fails** rather than guess a location, so the CI integration job is gated on `CONDUCTOR_IMAGE` (it only runs once an image with the API exists).
> **Gate:** conductor **panics without `DBOS_CONDUCTOR_LICENSE_KEY`** (`~/conductor/config/config.go:206-208`). Use the **local** key, not the cloud one: `validateLicenseKey` short-circuits to "pro" when the key's SHA-256 matches a hardcoded entry in `localKeyHashes`, so validation is entirely offline. Any other key triggers a startup call to `https://cloud.dbos.dev/v1alpha1/conductor-api-keys/check` on every container boot. Skip the tier when the **key** is unset — never fail the suite for a missing secret (fork PRs don't get secrets). That skip is the *only* gate on the CI integration job: with the image default, the job needs nothing but Docker, so it runs on every PR and goes green-by-skip on forks.

**Secrets.** Same pattern as the conductor repo: a gitignored `.env` locally, a GitHub Actions secret in CI.

- Local: `cp .env.example .env` and fill in `DBOS_CONDUCTOR_LICENSE_KEY` — the value is the `CONDUCTOR_LOCAL_LICENSE_KEY` line in `~/conductor/.env`. The integration helper loads `.env` from the repo root via `github.com/joho/godotenv` (a test-only dependency), which does not override variables already set in the environment — so both `make test-integration` and a bare `go test -tags integration ./...` work. Keep `.env` in godotenv format (`KEY=value`, no `export` prefix); conductor's own `.env` uses the `export` form because it's meant to be `source`d.
- CI: `DBOS_CONDUCTOR_LICENSE_KEY: ${{ secrets.CONDUCTOR_LOCAL_LICENSE_KEY }}`, reusing conductor's secret name (`~/conductor/.github/workflows/test.yml:56`).
- `.env` is gitignored here and in conductor. Never commit a key value, and don't paste one into this file.

**Reports.** `make test JUNIT=<path>` (and `test-integration`) routes the run through `go tool gotestsum` and writes JUnit XML; without `JUNIT` it is a plain `go test`, so local output is unchanged and nothing extra gets built. CI sets it in both jobs and publishes the result twice: `test-summary/action@v2` for a summary on the run page, and `upload-artifact` for the raw XML. Both steps are `if: always()`, since a failing run is exactly when the report is wanted. **`show: "fail, skip"` is load-bearing** — this suite skips a whole tier when `DBOS_CONDUCTOR_LICENSE_KEY` is absent, and a skipped tier still reports a green job. The summary is what tells "137 passed" apart from "everything skipped", which the checks list cannot. Mirrors what the Java repo does with Gradle's surefire XML.

**Executor fixture — a conductor-connected Go Transact app (Tier 3+, milestone D4).** Three of the app reads (`versions`, populated `executors`, `metrics`) need a *live executor*, not just a registered app, and the Go SDK (`~/go-transact`) ships a real conductor WebSocket client (`dbos/conductor.go`), so the fixture stays in-language and in-repo — no TS/Python app. The `dbos_` key it authenticates with is minted by **D3's `api-key create`** (that's why D3 precedes this). Enable it via `dbos.NewDBOSContext(ctx, dbos.Config{ConductorAPIKey: "dbos_<orgID>_<secret>", ConductorURL: "ws://<host>:<port>", ...})` then `Launch()`. For a **local** conductor, `ConductorURL` must be a bare `ws://host:port` with **no path** — the SDK appends `/websocket/<app>/<key>` itself, and its default base carries a managed-only `/conductor/v1alpha1` prefix a local server doesn't have. Registration is **server-initiated**: on connect conductor sends an `ExecutorInfo` request and the app replies with its executor ID, app version, and `Language:"go"`; that reply is what conductor persists. There is no app-level heartbeat — only WS ping/pong keepalive.

Two prerequisites, both already mechanised: the **app row must pre-exist** (D1's `app register` → `RegisterApp`, else the websocket handler 404s), and the app authenticates with a valid **`dbos_<orgID>_<secret>` key** minted via `CreateToken` — the same API C5 already drives. So the fixture is *register app → mint token → launch the Go app with that token + `ws://` URL → it connects (and optionally runs a workflow)*.
Expand Down Expand Up @@ -379,7 +384,7 @@ Sliced so that **every step ends with a binary you can run and a test that prove
- [x] **A2. Root wiring** — entrypoint at `cmd/dbosctl/main.go`, command tree in `internal/cli`, `Use: "dbosctl"`, request-shaping flags (`-o/--output`, `-a/--app`, `--org`, `--url`, `--profile`; later scoped per-command via `addRequestFlags` — see Request-shaping flags), and a real `dbosctl version` with the ldflags/`ReadBuildInfo` resolution (see Versioning & release) plus `rootCmd.Version` for `--version`. No config file yet; `--url`/`--org` only. *Done when:* `dbosctl version` prints a commit from a plain `go build` and `dbosctl --help` is correct.
- [x] **A3. Transport + output, minimal** — `internal/client` building the generated client from `--url` with no auth; `internal/output` with `table` (array renderer) and `json`. *Done when:* unit tests cover both renderers.
- [x] **A4. `dbosctl app list`** — the first end-to-end command. *Done when:* it returns real rows from a locally running conductor. (Command + full path proven by a mock-server e2e test; the live-conductor proof is A5's containerized run.)
- [x] **A5. Conductor test harness (tier 3)** — testcontainers Postgres + conductor on a shared network, conductor sourced via `CONDUCTOR_IMAGE` or built `FromDockerfile` (see Testing), `/healthz` wait strategy, `.env` loader via godotenv, skip-on-missing-secret, CI wiring with `CONDUCTOR_LOCAL_LICENSE_KEY`. *Done when:* `make test-integration` proves A4 against a container. **Pulled early on purpose** — every later command reuses it, so building it now makes the rest of the roadmap self-verifying.
- [x] **A5. Conductor test harness (tier 3)** — testcontainers Postgres + conductor on a shared network, conductor sourced from the latest published image by default, or built `FromDockerfile` from `CONDUCTOR_DIR` (see Testing), `/healthz` wait strategy, `.env` loader via godotenv, skip-on-missing-secret, CI wiring with `CONDUCTOR_LOCAL_LICENSE_KEY`. *Done when:* `make test-integration` proves A4 against a container. **Pulled early on purpose** — every later command reuses it, so building it now makes the rest of the roadmap self-verifying.

**B — configuration.** Named targets so nothing after this retypes `--url`, and a place to persist a login.

Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
CONDUCTOR_DIR ?= $(HOME)/conductor
SPEC := internal/api/openapi-3.1.json

# Set JUNIT to a path to also write a JUnit XML report (CI publishes it as a
# summary and an artifact). Plain `go test` otherwise, so a local run needs
# nothing built and its output is unchanged.
JUNIT ?=
GOTEST := go test
ifneq ($(JUNIT),)
GOTEST := go tool gotestsum --junitfile $(JUNIT) --format testname --
endif

.PHONY: all generate spec build test lint tidy

all: generate build
Expand All @@ -28,11 +37,11 @@ build:

## test: unit tests only (no Docker)
test:
go test ./...
$(GOTEST) ./...

## test-integration: container-backed tests (needs Docker; see Testing in AGENTS.md)
test-integration:
go test -tags integration -timeout 20m ./...
$(GOTEST) -tags integration -timeout 20m ./...

## lint: go vet + gofmt check
lint:
Expand Down
13 changes: 12 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/bitfield/gotestdox v0.2.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
Expand All @@ -26,18 +27,22 @@ require (
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/getkin/kin-openapi v0.142.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand All @@ -49,6 +54,7 @@ require (
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
Expand Down Expand Up @@ -90,12 +96,17 @@ require (
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
gotest.tools/gotestsum v1.13.0 // indirect
modernc.org/libc v1.74.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.54.0 // indirect
)

tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
tool (
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
gotest.tools/gotestsum
)
Loading