Skip to content

Adding a new service

1. Give it a home

Create the directory at the repository root. Not under infra/services/, which is closed (CHANGE-5). Name it what you want the image called.

Write go.mod with a lyceum.technology/<name> module path and the shared library (GO-3):

module lyceum.technology/<name>

go 1.26

require lyceum.technology/daedalus v0.0.0

replace lyceum.technology/daedalus => ../daedalus_go

Binary under cmd/<binary>/main.go, everything else under internal/. Full layout in Go style guidelines.

2. Wire it into the build

Add mise.toml:

[task_config]
includes = [
    "{{vars.repo_root}}/mise/includes/go.toml",
    "{{vars.repo_root}}/mise/includes/go-service-image.toml",
]

[env]
DOCKER_PROJECT_DIR = "<dir>"
DOCKER_IMAGE_NAME = "<image-name>"
DOCKER_BINARY = "<binary>"

For Python:

  • Include python.toml and python-service-image.toml.
  • Declare DOCKER_SERVICE_DIR, DOCKER_PACKAGE and DOCKER_IMAGE_NAME instead of DOCKER_PROJECT_DIR and DOCKER_BINARY. DOCKER_SERVICE_DIR is separate, because DOCKER_PROJECT_DIR is the Go selector.
  • Add the project to [tool.uv.workspace] in the root pyproject.toml (PYTHON-1).

Then list the directory in [monorepo].config_roots in the root mise.toml. Do not skip this. An unlisted project is matched by no wildcard: nothing lints it, tests it or builds it, and nothing warns you (BUILD-6).

Check it worked:

mise tasks --all | grep <dir>
mise run //<dir>:build
mise run //<dir>:lint

You do not write tasks. If you think you need one, read BUILD-1 first. Shared logic goes in mise/scripts/, and a project must never redefine a task the includes already provide.

3. Satisfy the service contract

From service rules:

  • Import lyceum.technology/daedalus/version. Log build info at startup and register the build_info gauge (SERVICE-1). A binary that does not link the version package ignores the linker flags without saying so.
  • Expose /metrics, even with nothing else to serve (SERVICE-2). Use daedalus/metrics.MustNewRegistry.
  • Read config from the environment or a mount (BUILD-22).
  • Handle SIGTERM (SERVICE-3). Keep no state on local disk (SERVICE-4).
  • Take auth, messaging, logging and metrics from daedalus (SERVICE-5, SERVICE-6).
  • Name the owning team (SERVICE-10).

Once an image exists, check build_info carries a real version. 0.0.0/unknown means the build arguments never reached the binary (SERVICE-1).

No Dockerfile either. The shared one for your language builds from the identity in step 2 (BUILD-16).

4. Give the image a repository

One repository per image (BUILD-24). Add the hyphenated image name to image_repositories in infra/terraform/_modules/gcp/platform_artifact_registries/variables.tf. Add a keep_most_recent_overrides entry there if the default retention is wrong.

Grant pull to the runtime service accounts in the runtime_pull_bindings input in infra/terraform/platform/artifact_registries/terragrunt.hcl. The module default is always overridden by that input.

The runtime service account MUST exist before you grant it. A binding to a missing account fails the apply.

Apply the unit against platform-staging.

5. Push the image

There is nothing to add. The service-image include from step 2 makes it a service with an image. build-service-images.yml builds every such service with //<service_dir>:push-image, and docker-env.bash derives region, repository, Dockerfile, tag and build arguments from your mise.toml.

The workflow is dispatched by hand, so no image appears until someone runs it. See Trying a branch on staging.

The same build runs locally:

DOCKER_GCP_PROJECT=platform-staging-506908 mise run //<dir>:build-image

6. Deploy it

Add a module under infra/terraform/_modules/gcp/<name> holding the Cloud Run service. Add a unit under infra/terraform/gcp/<name> that sources it, modelled on infra/terraform/gcp/croesus.

Take the image as an image_ref_* variable so each environment pins its own. Export the pin as TF_VAR_image_ref_<service> in infra/terraform/envs/<env>.env. Without the export the unit reads an empty string and applies an empty image reference.

Pin an immutable version or digest, never a floating tag (BUILD-15). The platform registries push no :latest, so such a reference resolves to nothing. Promotion between environments is a commit changing that pin (ENV-5). CI applies, from a merged commit (INFRA-1).

7. CI

There is nothing to add. The sweep, check-lint-and-test.yml, lints and tests every directory in config_roots (MERGE-1).

DO NOT add a workflow for your service (BUILD-25). If your service needs a check the workflows lack, then add it for every service of its kind.

8. Document it

  • A README.md saying what the service is, linking to the wiki (DOC-7).
  • A wiki page and its nav entry (DOC-8).
  • Update any page this change makes wrong, in the same pull request (DOC-6).

Checklist

[ ] Directory at the repository root, not in a closed one
[ ] go.mod / pyproject entry, daedalus wired up
[ ] mise.toml with the shared includes and this service's identity
[ ] Listed in [monorepo].config_roots
[ ] Version package linked, startup log and build_info gauge
[ ] /metrics endpoint
[ ] Config from environment or mount, SIGTERM handled, no local state
[ ] Owning team named
[ ] Repository in image_repositories, pull bindings granted, unit applied
[ ] Terraform module and unit, unit takes an image_ref_* variable
[ ] TF_VAR_image_ref_<service> exported in envs/<env>.env
[ ] Deploy config pins a version or digest
[ ] README, wiki page, nav entry