expense-tracker
the shared z5labs GoApp archetype, so CI and local builds run the sameopinionated pipeline (fmt + vet + lint + test -race, scratch image,
standardized image tags) and every binary shares one build recipe.
The recipe lives entirely in the pinned z5labs dependency (see
dagger.json); the functions here differ only by package path and binary
name. Common commands:
dagger call check # fmt + vet + lint + test -race (no build)
dagger call templ-check # generated *_templ.go is in sync with the .templ
dagger call server-binary -o ./bin/server # the exact CI artifact, single-arch, locally
dagger call importer-binary -o ./bin/importer
dagger call server-image # the scratch image CI would publish
dagger call image-smoke-test # run that image and check it actually serves
dagger call ci # full pipeline for both binaries (publishes on match)
It also owns the app's run configuration and the dependencies that go
with it — this repo's replacement for a compose file:
dagger call run-against local up --ports 8080:8080 # the app + a seeded emulator, on localhost:8080
dagger call emulator --seed up --ports 8085:8085 # just the emulator, for a `go run ./cmd/server` loop
dagger call integration-test # `go test -tags integration` against its own emulator
...and, on the same principle, the cloud the app runs on: the Terraform in
deploy/ is run from a pinned container with an explicit short-lived token,
never from a `terraform` on someone's PATH (see terraform.go):
dagger call terraform validate # the CI gate; no cloud, no credentials
dagger call terraform --project=… … plan # and state-bucket / apply / output
The z5labs pipeline is deliberately narrow: it has no Firestore to talk
to, and it does not run templ. So the emulator-backed tests are
build-tagged out of it, and both they and the templ-diff check are their
own functions — which is also how CI runs them, one `dagger call` each.
Dagger does not allow a module function to return a dependency's type,
so each function is terminal: it constructs the z5labs GoApp internally
and returns a File, Container, or error.
Installation
dagger install github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145Entrypoint
Return Type
ExpenseTracker Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
func (m *MyModule) Example() *dagger.ExpenseTracker {
return dag.
Expensetracker()
}@function
def example() -> dagger.ExpenseTracker:
return (
dag.expense_tracker()
)@func()
example(): ExpenseTracker {
return dag
.expenseTracker()
}Types
ExpenseTracker 🔗
ExpenseTracker is this repository’s Dagger module.
check() 🔗
Check runs the shared z5labs check stages once for the whole module:
gofmt, go vet, golangci-lint, and go test -race. It builds nothing,
so it is the fast pre-commit / PR gate.
Return Type
Void !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
checkfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Expensetracker().
Check(ctx)
}@function
async def example() -> None:
return await (
dag.expense_tracker()
.check()
)@func()
async example(): Promise<void> {
return dag
.expenseTracker()
.check()
}ci() 🔗
Ci runs the full standardized pipeline for BOTH binaries: the shared checks, a multi-arch scratch build, and — when registry is set and the source’s HEAD ref matches z5labs’ publishOn filter (default main) — a publish. With no registry it is a checks + build gate safe to run anywhere.
Return Type
Void !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
| registry | String | - | Container registry to publish to (e.g. “us-docker.pkg.dev//”). Empty disables publishing. |
| auth | Secret | - | Registry password/token; required by z5labs when registry is set. |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
cifunc (m *MyModule) Example(ctx context.Context) {
return dag.
Expensetracker().
Ci(ctx)
}@function
async def example() -> None:
return await (
dag.expense_tracker()
.ci()
)@func()
async example(): Promise<void> {
return dag
.expenseTracker()
.ci()
}deploy() 🔗
Deploy publishes this commit’s images through the z5labs pipeline and rolls the server image out to Cloud Run. It returns the service’s URL.
One token does both halves: Artifact Registry takes it as the password for the oauth2accesstoken user, and gcloud reads it from the environment.
Not cached, and the exec inside carries a nonce for the same reason terraform.go’s do: what a deploy changes lives in Google’s API, which Dagger cannot see, so an identical second call must actually run again rather than replay the first one’s output.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
| project | String ! | - | Google Cloud project that owns the registry and the service. |
| accessToken | Secret ! | - | A short-lived Google OAuth access token — it authenticates both the registry push and gcloud:
|
| region | String | "us-central1" | Region of the Cloud Run service and the registry. Must match the region deploy/ was applied with. |
| service | String | "expense-tracker" | Cloud Run service to roll the image out to; deploy/variables.tf’s service_name. |
| artifactRegistryRepository | String | "containers" | Artifact Registry repository the images are published to; deploy/variables.tf’s artifact_registry_repository. |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
deploy --project string --access-token env:MYSECRETfunc (m *MyModule) Example(ctx context.Context, project string, accessToken *dagger.Secret) string {
return dag.
Expensetracker().
Deploy(ctxproject, accessToken)
}@function
async def example(project: str, accesstoken: dagger.Secret) -> str:
return await (
dag.expense_tracker()
.deploy(project, accesstoken)
)@func()
async example(project: string, accessToken: Secret): Promise<string> {
return dag
.expenseTracker()
.deploy(project, accessToken)
}emulator() 🔗
Emulator is a Firestore emulator on its own, for the fast edit loop — publish it to the host and run the app from source against it:
dagger call emulator --seed up --ports 8085:8085
FIRESTORE_EMULATOR_HOST=localhost:8085 GCP_PROJECT=demo-expense-tracker \
OWNER_EMAIL=you@example.com \
OAUTH_CLIENT_ID=local.apps.googleusercontent.com OAUTH_CLIENT_SECRET=x \
SESSION_KEY="$(openssl rand -base64 32)" go run ./cmd/server
For the whole app, dependencies and all, use run-against local.
Its data lives only as long as the service does: stop it and the log is gone. –seed fills a fresh one with a couple of months of sample events, so there is something to look at.
Return Type
Service !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| port | Integer | 8085 | Port to listen on, inside the container. |
| seed | Boolean | - | Write a sample event log into it once it is up. |
| project | String | "demo-expense-tracker" | Project id the sample events are written under; must match the GCP_PROJECT the app runs with, since it namespaces the data. |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
emulatorfunc (m *MyModule) Example() *dagger.Service {
return dag.
Expensetracker().
Emulator()
}@function
def example() -> dagger.Service:
return (
dag.expense_tracker()
.emulator()
)@func()
example(): Service {
return dag
.expenseTracker()
.emulator()
}imageSmokeTest() 🔗
ImageSmokeTest starts the scratch image that server-image builds — the
exact artifact CI publishes — with a Firestore emulator behind it, and
checks that the running container serves liveness, readiness, redirects
protected routes to sign in, and mounts the sign-in flow on $PORT.
It is the only thing that tests the deployable rather than the source, which is why CI runs it as its own leg.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
image-smoke-testfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Imagesmoketest(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.imagesmoketest()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.imageSmokeTest()
}imageTag() 🔗
ImageTag is the tag this commit’s images are published under — “-”, the z5labs pipeline’s scheme for a branch build:
dagger call image-tag
# 4f2a1c9-2026-07-12T14-03-22-05-00
The pipeline does not tell anyone what it pushed, so the deploy has to derive it. That is a duplicated assumption, and the honest thing to do with one is to state it and then check it: Deploy asks the registry whether that exact tag is really there before it rolls anything out, so a change to z5labs’ scheme fails the deploy with a message that says so, rather than deploying a stale image that happens to still exist.
It reads git, so — like ci — it needs a real .git directory and does not
work from inside a git worktree.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
image-tagfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Imagetag(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.imagetag()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.imageTag()
}importerBinary() 🔗
ImporterBinary compiles cmd/importer into the same shape of artifact as ServerBinary, single-arch for the host.
Return Type
File !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
importer-binaryfunc (m *MyModule) Example() *dagger.File {
return dag.
Expensetracker().
Importerbinary()
}@function
def example() -> dagger.File:
return (
dag.expense_tracker()
.importerbinary()
)@func()
example(): File {
return dag
.expenseTracker()
.importerBinary()
}importerImage() 🔗
ImporterImage builds the scratch image CI would publish for cmd/importer, single-arch for the host.
Return Type
Container !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
importer-imagefunc (m *MyModule) Example() *dagger.Container {
return dag.
Expensetracker().
Importerimage()
}@function
def example() -> dagger.Container:
return (
dag.expense_tracker()
.importerimage()
)@func()
example(): Container {
return dag
.expenseTracker()
.importerImage()
}integrationTest() 🔗
IntegrationTest runs the emulator-backed tests — the ones tagged
integration, which the z5labs go test -race stage skips because it
has no database — against an emulator bound into the test container.
It is self-contained: no host emulator, no host ports.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
integration-testfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Integrationtest(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.integrationtest()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.integrationTest()
}runAgainst() 🔗
RunAgainst starts the run-configuration chain. Source is contextual, so
dagger call run-against local needs no arguments.
Return Type
ExpenseTrackerRunAgainst !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
run-againstfunc (m *MyModule) Example() *dagger.ExpenseTrackerRunAgainst {
return dag.
Expensetracker().
Runagainst()
}@function
def example() -> dagger.ExpenseTrackerRunAgainst:
return (
dag.expense_tracker()
.runagainst()
)@func()
example(): ExpenseTrackerRunAgainst {
return dag
.expenseTracker()
.runAgainst()
}serverBinary() 🔗
ServerBinary compiles cmd/server into the exact binary CI publishes
(CGO disabled, -trimpath, -s -w), single-arch for the host. Export it
with -o:
dagger call server-binary -o ./bin/server
Return Type
File !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
server-binaryfunc (m *MyModule) Example() *dagger.File {
return dag.
Expensetracker().
Serverbinary()
}@function
def example() -> dagger.File:
return (
dag.expense_tracker()
.serverbinary()
)@func()
example(): File {
return dag
.expenseTracker()
.serverBinary()
}serverImage() 🔗
ServerImage builds the scratch image CI would publish for cmd/server, single-arch for the host. Run it, export it, or inspect it:
dagger call server-image export --path server.tar
Return Type
Container !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
server-imagefunc (m *MyModule) Example() *dagger.Container {
return dag.
Expensetracker().
Serverimage()
}@function
def example() -> dagger.Container:
return (
dag.expense_tracker()
.serverimage()
)@func()
example(): Container {
return dag
.expenseTracker()
.serverImage()
}templCheck() 🔗
TemplCheck re-runs templ generate and fails if the result differs
from what is committed. The z5labs pipeline does not run templ, and the
generated *_templ.go is committed precisely so it doesn’t have to — a
fresh checkout has to already compile. That trade only holds if
something enforces the two staying in sync; this is that something.
It compares content, not timestamps: templ leaves a file alone when the output is unchanged, but a check that keyed on writes rather than bytes would rot the moment that stopped being true.
Return Type
Void !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
templ-checkfunc (m *MyModule) Example(ctx context.Context) {
return dag.
Expensetracker().
Templcheck(ctx)
}@function
async def example() -> None:
return await (
dag.expense_tracker()
.templcheck()
)@func()
async example(): Promise<void> {
return dag
.expenseTracker()
.templCheck()
}terraform() 🔗
Terraform builds the root module’s command surface. Only validate works
with no arguments — it is the one command that touches no cloud, which is
exactly why CI can run it on every pull request.
Return Type
ExpenseTrackerTerraform !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| source | Directory | - | The root module. Only deploy/ is uploaded — the Go sources have no bearing on the infrastructure, and a source change that could not possibly alter a plan should not invalidate one. |
| project | String | - | Google Cloud project id. Required by everything except |
| region | String | "us-central1" | No description provided |
| ownerEmail | String | - | The owner’s Google account. Required by |
| oauthClientId | String | - | The Google Sign-In client id (“….apps.googleusercontent.com”).
Required by |
| stateBucket | String | - | GCS bucket for the remote state. Defaults to “-tfstate”. |
| allowUnauthenticated | Boolean | - | Grant roles/run.invoker to allUsers. Leave it off until the app checks who is calling. |
| accessToken | Secret | - | A short-lived Google OAuth access token:
|
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraformfunc (m *MyModule) Example() *dagger.ExpenseTrackerTerraform {
return dag.
Expensetracker().
Terraform()
}@function
def example() -> dagger.ExpenseTrackerTerraform:
return (
dag.expense_tracker()
.terraform()
)@func()
example(): ExpenseTrackerTerraform {
return dag
.expenseTracker()
.terraform()
}ExpenseTrackerRunAgainst 🔗
RunAgainst is the app’s run configuration, as code. It answers “where does this thing run” the way an IDE’s run configuration would, except it is reproducible and shared: Local() stands the whole stack up on the local engine, which is what this repo has instead of a compose file. A NonProd() sibling — the same app container, pointed at services that already exist somewhere — is the natural next one.
source() 🔗
Source is the repository: the app that gets built and run.
Return Type
Directory ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
run-against \
sourcefunc (m *MyModule) Example() *dagger.Directory {
return dag.
Expensetracker().
Runagainst().
Source()
}@function
def example() -> dagger.Directory:
return (
dag.expense_tracker()
.runagainst()
.source()
)@func()
example(): Directory {
return dag
.expenseTracker()
.runAgainst()
.source()
}local() 🔗
Local runs the app against a complete local stack: a Firestore emulator
with a sample event log already in it, and the server — the very
container CI builds and publishes, not go run — wired to it. One
command brings up every dependency and the app:
dagger call run-against local up --ports 8080:8080
Then the app is on localhost:8080, backed by the emulator. Nothing is published to the host but the app itself; the emulator is reachable only from inside, which is where the app is.
Not cached, and it must not be: a cached call would hand back the previous session’s service without running any of this — without starting an emulator, without seeding it — and the app would come up against a database that does not exist.
Return Type
Service !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| port | Integer | 8080 | Port the app listens on, in the container and on the host. |
| project | String | "demo-expense-tracker" | Google Cloud project id. Against the emulator it only namespaces the data, so any value will do — but the app and the seed have to agree on it. |
| ownerEmail | String | "owner@example.com" | The owner allowlist’s single address. Nothing enforces it until the auth stories land. |
| seed | Boolean | true | Write a sample event log into the emulator before the app starts. Off means an empty database — the app runs, there is nothing in it. |
| oauthClientId | String | "local-client-id.apps.googleusercontent.com" | Google Sign-In credentials. The defaults are placeholders — enough for the app to boot and serve, which is all a run against a fake database needs. To actually sign in locally, pass a real client and register http://localhost:8080/auth/callback as one of its authorized redirect URIs:
|
| oauthClientSecret | Secret | - | No description provided |
Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
run-against \
localfunc (m *MyModule) Example() *dagger.Service {
return dag.
Expensetracker().
Runagainst().
Local()
}@function
def example() -> dagger.Service:
return (
dag.expense_tracker()
.runagainst()
.local()
)@func()
example(): Service {
return dag
.expenseTracker()
.runAgainst()
.local()
}ExpenseTrackerTerraform 🔗
Terraform is the deploy/ root module together with everything it needs to
talk to a project. Constructed by ExpenseTracker.Terraform; every command
is a method on it, so --project and friends are given once:
dagger call terraform --project=p --owner-email=o --access-token=… plan
source() 🔗
Source is deploy/ — the root module, and nothing else in the repo.
Return Type
Directory ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
sourcefunc (m *MyModule) Example() *dagger.Directory {
return dag.
Expensetracker().
Terraform().
Source()
}@function
def example() -> dagger.Directory:
return (
dag.expense_tracker()
.terraform()
.source()
)@func()
example(): Directory {
return dag
.expenseTracker()
.terraform()
.source()
}project() 🔗
Project is the Google Cloud project every resource is created in.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
projectfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Project(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.project()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.project()
}region() 🔗
Region carries Cloud Run, Artifact Registry, and the state bucket.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
regionfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Region(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.region()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.region()
}ownerEmail() 🔗
OwnerEmail is the app’s allowlist and, until the service goes public, the one principal that may invoke it.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
owner-emailfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Owneremail(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.owneremail()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.ownerEmail()
}oauthClientId() 🔗
OAuthClientID is the Google Sign-In client the app signs people in with. Required by
planandapplyfor the same reason OwnerEmail is: it goes into the service’s environment, and the app refuses to boot without it. The matching secret never passes through here — it is added to Secret Manager out of band.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
oauth-client-idfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Oauthclientid(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.oauthclientid()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.oauthClientId()
}bucket() 🔗
Bucket holds the remote state. Empty means “-tfstate”. It is not called StateBucket because the function that creates it is, and a Dagger object cannot expose a field and a method under one name.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
bucketfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Bucket(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.bucket()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.bucket()
}allowUnauthenticated() 🔗
AllowUnauthenticated opens the service to allUsers. Off until the app authenticates its own callers — see deploy/variables.tf.
Return Type
Boolean ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
allow-unauthenticatedfunc (m *MyModule) Example(ctx context.Context) bool {
return dag.
Expensetracker().
Terraform().
Allowunauthenticated(ctx)
}@function
async def example() -> bool:
return await (
dag.expense_tracker()
.terraform()
.allowunauthenticated()
)@func()
async example(): Promise<boolean> {
return dag
.expenseTracker()
.terraform()
.allowUnauthenticated()
}accessToken() 🔗
AccessToken is a short-lived Google OAuth token. Both the provider and the GCS backend read it from GOOGLE_OAUTH_ACCESS_TOKEN, and gcloud from CLOUDSDK_AUTH_ACCESS_TOKEN, so one secret authenticates everything here.
Return Type
Secret ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
access-tokenfunc (m *MyModule) Example() *dagger.Secret {
return dag.
Expensetracker().
Terraform().
Accesstoken()
}@function
def example() -> dagger.Secret:
return (
dag.expense_tracker()
.terraform()
.accesstoken()
)@func()
example(): Secret {
return dag
.expenseTracker()
.terraform()
.accessToken()
}apply() 🔗
Apply makes it so.
-auto-approve, because there is no terminal here to approve at: a Dagger
function’s exec has no stdin. plan is the review step, and it is not
optional in the way an interactive prompt lets you pretend it is — read it.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
applyfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Apply(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.apply()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.apply()
}output() 🔗
Output returns the root module’s outputs as JSON — the registry to publish to, the service to deploy, the identities to impersonate. It is how the deploy story (#7) learns what this story created, instead of repeating the names in a workflow file where they can go stale.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
outputfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Output(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.output()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.output()
}plan() 🔗
Plan reports what an apply would change, and changes nothing. Run it first — always, but especially on this module: an apply that recreates the Firestore database is a plan you want to have read.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
planfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Plan(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.plan()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.plan()
}stateBucket() 🔗
StateBucket creates the GCS bucket the remote state lives in, and is idempotent — run it once per project, or every time, it makes no difference.
It exists because of a genuine ordering problem, not for want of a resource: Terraform initializes its backend before it evaluates any configuration, so a bucket declared in this root module could never be the bucket this root module stores its state in. Somebody has to create it out of band. The usual “somebody” is a human following a README, which is how state buckets end up with no versioning; this is that step, written down and executable.
Versioning is the point of the exercise. A corrupted or truncated state file is recoverable from an earlier generation; without it, the only record of what Terraform believes it created is gone, and every resource has to be imported by hand.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
state-bucketfunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Statebucket(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.statebucket()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.stateBucket()
}validate() 🔗
Validate is the pull-request gate: the module is formatted, its syntax and its references hold, and every provider it names can be resolved.
It initializes with -backend=false, so it needs no bucket, no project,
and no credentials — which is the only reason CI can run it at all. A
validate that required cloud access would either not run on pull requests
or would hand every fork’s pull request a token.
What it therefore cannot catch is anything only the API knows: a quota, a
name already taken, an invalid region. That is what plan is for.
Return Type
String ! Example
dagger -m github.com/Zaba505/expense-tracker@c67b6e164c86155d0226a1d72ed99d39c145d145 call \
terraform \
validatefunc (m *MyModule) Example(ctx context.Context) string {
return dag.
Expensetracker().
Terraform().
Validate(ctx)
}@function
async def example() -> str:
return await (
dag.expense_tracker()
.terraform()
.validate()
)@func()
async example(): Promise<string> {
return dag
.expenseTracker()
.terraform()
.validate()
}