Skip to content

Generate Go modules with the in-repo codegen and add an opt-in build-only runtime - #35

Draft
eunomie wants to merge 12 commits into
dagger:mainfrom
eunomie:go-sdk-module-codegen-runtime-lead-640790c0
Draft

Generate Go modules with the in-repo codegen and add an opt-in build-only runtime#35
eunomie wants to merge 12 commits into
dagger:mainfrom
eunomie:go-sdk-module-codegen-runtime-lead-640790c0

Conversation

@eunomie

@eunomie eunomie commented Aug 28, 2026

Copy link
Copy Markdown
Member

Brings Go module codegen into this repository and adds a small, opt-in, build-only runtime — the same shape python-sdk and java-sdk already went through. The engine's builtin go routing and targetRuntime are untouched: a module that does nothing special behaves exactly as before.

Design: design/go-module-codegen-and-runtime.md (problem, verified engine constraints, approach, alternatives, testing, risks, patch plan).

What changes

Module codegen (dagger-module.toml modules). Mod.generate now splits on config format, as python-sdk's does: a dagger-module.toml module is generated by this SDK's own engine-free codegen helper; a dagger.json module keeps going through the engine's generatedContextDirectory (the engine regenerates it at load anyway). The helper runs twice around one in-engine merge — pass A bootstraps the module and emits its own types as introspection JSON, the Dang layer merges them into the dependency schema through the engine's public schema(json:).merge(moduleTypes:, moduleName:) (reachable since v1.0.0-0, the single implementation every SDK shares), pass B renders the bindings from the merged schema. The container works on the module's whole context at /src in the module's source subpath, exactly like the engine's codegen, so a parent go.work, a replace ../lib and a nested source resolve the same way. Output matches the engine's: dagger.gen.go, internal/dagger/*.gen.go (including the module's own bindings), go.mod/go.sum pinned to the engine's dagger.io/dagger commit, .gitattributes, .gitignore; stale per-dependency bindings are removed; a go.work the module was enrolled in is carried back when the caller's cwd can reach it. Regeneration of an already-generated module is a no-op (checked).

Escape hatch. An engine-free codegen has no host credentials, so a module with private Go dependencies keeps the engine's codegen with a .dagger-go-sdk-engine-codegen marker, found at or above the module root like the skip marker.

Opt-in runtime. runtime/ is a second Dang module, go-sdk-runtime, the shape of java-sdk's: a no-op codegen and a moduleRuntime that builds the committed, already-generated source into the entrypoint binary — what the engine's native runtime does for a toml module, and nothing more. A module opts in with [runtime] source = "github.com/dagger/go-sdk/runtime" (or a local path). It refuses a module missing its generated files with the same actionable message the engine gives.

Helper. helpers/codegen gains the module template set from dagger/dagger@v1.0.0-beta.11 (byte-identical modulo import paths), a go/packages loader that fails on unresolvable modules but tolerates the type errors body-stripping produces, module-types / generate-module modes, and the engine's beta.11 nullable-object returns.

Behaviour changes to be aware of

  • generateClient: optional-object fields now return (*T, error) (nil on null) on engines ≥ v1.0.0-beta.10, matching the engine's own client generator; the extracted generator predated that change. Required so module bindings do not flip signatures between an engine-generated and an in-repo-generated run.
  • Fresh client go.mod files get the toolchain's language version (go 1.26) rather than the builder image's patch level; existing client go.mod files are untouched.
  • codegenBuilder moves to golang:1.26-alpine (the pinned dagger.io/dagger needs go ≥ 1.26.1) and installs git for go mod tidy's VCS fallback.
  • GoSdk.generate stages modules sequentially so edits to shared files (a parent go.work) compose instead of conflicting.

Not in this PR

The engine's core/sdk/loader.go case sdkGo routing, toolchains/engine-dev/build/sdk.go, and flipping targetRuntime to runtime/ — that cutover is a separate workstream. Credential forwarding into the engine-free codegen and the runtime (GOPRIVATE, gitconfig, SSH agent) is out of scope; see the marker above.

Testing

  • helpers/codegen: unit + golden tests, including the ported module template tests, loader error-kind gating, syncModReplaceAndTidy seeding/replace copy, VCS file merging, automaticGitignore.
  • e2e (e-2-e:*): generated shape and self bindings for a toml module, nested source = "src", stale-binding removal, engine-codegen marker, go.work enrolment from the root / from inside the module / for two modules at once, idempotent regeneration, a plain-toolchain go build of the output, and — through the sdk-sdk harness with a released CLI — dagger call on the engine's native runtime and on runtime/, plus the runtime refusing a module with missing generated files.
  • sdk-sdk:* black-box checks now exercise the in-repo codegen on scaffolded toml modules end to end (module:loads, chain:*, monorepo:*).

All check groups (e-2-e, sdk-sdk, dang-sdk) were run locally against engine v1.0.0-beta.11 before pushing.

eunomie added 12 commits August 28, 2026 02:07
Module codegen is the one asymmetry PR dagger#15 left: clients are generated by
this repository's engine-free helper, modules still go through the engine's
native goSDK.Codegen. This doc scopes bringing module codegen in-repo for
dagger-module.toml modules (two engine-free passes around the engine's public
schema().merge(), reachable from Dang since v1.0.0-0) and adds a small,
opt-in, build-only runtime module at runtime/, mirroring python-sdk and
java-sdk. Legacy dagger.json modules and the engine's builtin routing are
untouched.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The shared generator was extracted before engine beta.10 started returning
optional object fields as (*T, error), nil on null. A module generated by the
engine at beta.10 or later already carries those signatures, so module
codegen in this repository must emit the same shape or a regenerate would
rewrite the user's bindings. This is the engine's change (dagger/dagger
41472c623c) applied to the extracted files: SupportsNullableObjects gates on
the schema version, the field/interface signature builders and the object and
dag templates return the nullable form, and GeneratedState grows the
RemovePaths/PostCommands/NeedRegenerate fields plus Apply that module
generation relies on.

Behaviour change for generateClient: optional-object fields now return
(*T, error) on engines at or above v1.0.0-beta.10, matching the engine's own
client generator. The helper test check moves to golang:1.26-alpine, the
image the codegen builder moves to next.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The client extraction (PR dagger#15) narrowed the shared generator to what a
standalone client needs. Module generation renders against the parsed module
package, so this restores the plumbing that carried it: the loaded package,
fileset and pass index on the template funcs behind a GoTemplateFuncsForModule
constructor, the IsPartial template hook for the bootstrap pass, and a
moduleGenCtx threaded through generateCode so one render path serves both
modes — the client passes nil and is unchanged. Per-module binding files land
under internal/dagger in module mode, where the root package is main.
PackageInfo learns whether dagger.io/dagger is replaced and the module config
gains the library version to pin.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Engine-free: the module's own source is loaded with go/packages, which needs
a Go toolchain but no engine. The trace span goes, as in the client port.

One check the engine's loader does not make: packages.Load runs `go list
-e` and returns a package even when an import cannot be resolved, leaving
the affected types `invalid` and the emitter free to drop those functions
without a word. Under the engine every import resolves because credentials
are forwarded; here they are not, so a parse error or an import whose module
cannot be found is fatal. The root package only records the latter as a
TypeError, so NeedImports is requested (without NeedDeps) to read the
ListError on the imported stub. Other TypeErrors stay tolerated: stripping
function bodies makes "imported and not used" routine.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Pure go/types and jennifer AST rendering, engine-free: the module_* type
definition templates, the invoke dispatcher (modules.go), the introspection
emitter that turns the module's own declarations into introspection JSON,
the visit/optional helpers, the module .tmpl files (the dispatcher body of
dagger.gen.go and the internal/dagger bindings package) and their tests,
from dagger/dagger@v1.0.0-beta.11 with imports rewritten. The
isModuleCode/isStandaloneClient/moduleRelPath helpers already live in
client.go since the client extraction; the client-only Dependencies helpers
are not ported because a standalone client here serves a single bound
module.

Adds github.com/dave/jennifer and github.com/mitchellh/mapstructure, the
versions the engine uses.

Signed-off-by: Yves Brissaud <yves@dagger.io>
GenerateModuleTypes bootstraps a module to a loadable state (go.mod, base
bindings from the dependency schema, go get + go mod tidy post-commands,
another pass while anything was scaffolded) and emits the module's own types
as introspection JSON. GenerateModule takes a schema that already carries
those types and renders the bindings, the invoke dispatcher, .gitattributes
and .gitignore, and reports per-dependency bindings that went stale. The one
engine call the engine's generator makes — merging the module's types into
the schema — is left to the caller: the SDK's Dang layer runs the engine's
schema().merge() between the two passes, so the binary stays engine-free.

Where the engine embeds the dagger.io/dagger go.mod to seed minimum versions
and copy its replace directives, this resolves the pinned library's go.mod
with `go list -m -json` from outside any module (only .info and .mod are
fetched) and runs the same seeding. A fresh go.mod gets the toolchain's
major.minor as its go directive; `go get dagger.io/dagger@<pin>` raises it
to the library's own, keeping the builder image's patch level out of users'
go.mod. Self types are emitted only for schema views from v0.12.0, where
older views alias schema types into the main package.

.gitattributes carries the engine's three linguist-generated entries and
.gitignore its surviving /.env entry — appended with the same substring
de-duplication — unless dagger-module.toml sets [codegen] automaticGitignore
to false, decoded with github.com/pelletier/go-toml as the engine does.

Adds github.com/pelletier/go-toml.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The helper grows two subcommands around the caller's merge: module-types
bootstraps the module and writes its own types as introspection JSON;
generate-module renders the bindings from the merged schema, then writes
the stale dependency bindings it removed and the go.work in effect (both
relative to --output) for the caller to carry back to the workspace. Both
loop while a pass reports NeedRegenerate, applying the overlay and running
the post-commands in the module source directory between passes; a
post-command failure is fatal, as under the engine.

The flag-only client invocation stays the default so the existing
clientDirectory call keeps working; generate-client is its explicit name.
--module-parent-path feeds the source-map links on generated types, as the
engine computes it. --module-root-path names the directory holding
dagger-module.toml, so the [codegen] automaticGitignore setting is read from
the module's own config rather than from whichever ancestor happens to carry
one.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A fresh client's go.mod took its go directive from runtime.Version(), the
codegen builder's full patch version. That builder moves to Go 1.26 for
module generation, so a client generated today would pin users to 1.26.7 —
the image's patch level, not anything the client needs.

Write the toolchain's language version instead, as module generation already
does: the dagger.io/dagger requirement raises the directive to whatever the
library itself needs. An existing client go.mod keeps its own directive
untouched, as before.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Mod.generate splits on config format, as python-sdk's does: a
dagger-module.toml module is generated here by this SDK's own codegen; a
dagger.json module keeps going through the engine's generatedContextDirectory,
which is the engine's builtin Go SDK regenerating it at load anyway.

The in-repo path runs the codegen helper twice around one in-engine merge.
Pass A mounts the module's whole context at /src (so a parent go.work, a
`replace ../lib` and a nested `source` resolve exactly as under the engine),
bootstraps the module and emits its own types; the engine's schema().merge()
folds them into the dependency schema — the one implementation every SDK
shares, reachable from Dang since v1.0.0-0 — and pass B renders the
bindings from the merged schema. The generated module root is layered onto
the workspace with withDirectory, never replaced, so a file the workspace
view does not carry is never reported as removed; stale dependency bindings
are dropped by name, and a go.work the module was enrolled in is carried
back when the caller's cwd can reach it, with an actionable error otherwise.

Mod.generate is Mod.generated(ws).changes(ws): generated returns the workspace
with the module's output staged onto it, and GoSdk.generate folds every managed
module through it before taking one changeset at the end. Modules share files —
two modules under one go.work each run `go work use .` on it — so generating
them all against a common baseline and merging the changesets would keep only
one of the two enrolments.

An engine-free codegen has no host credentials. A module with private Go
dependencies keeps the engine's codegen with a .dagger-go-sdk-engine-codegen
marker, found at or above the module root like the skip marker.

codegenBuilder moves to golang:1.26-alpine (the pinned dagger.io/dagger needs
go >= 1.26.1) with git for `go mod tidy`'s VCS fallback, and to the module
level so client and module generation share it; goSDKLibVersion is the
engine's beta.11 pin.

Four dagger-module.toml fixtures and e2e checks cover the generated shape and
self bindings, a nested source, stale-binding removal, regenerating an already
generated module as a no-op, go.work enrolment from the root, from inside the
module and for two modules sharing one go.work, the marker, and a
plain-toolchain build of the output.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The in-process checks prove the generated files; this proves they load and
run. The sdk-sdk harness (now an e2e dependency, pinned as python-sdk's e2e
pins it) installs this SDK into a scratch workspace with a released CLI and
`dagger call`s the freshly generated toml fixture on the engine's native Go
runtime — the committed-files path every `[runtime] source = "go"` module
takes. The fixture is generated in-process and the staged workspace handed
to the harness whole; Changeset.after holds only the touched subtree.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A second Dang module, go-sdk-runtime, the shape of java-sdk's: codegen is a
no-op that returns the module source unchanged, keeping the SDK
code-generator contract satisfied, and moduleRuntime builds the module's
committed, already-generated source into its entrypoint binary — what the
engine's native Go runtime does for a dagger-module.toml module and nothing
more. introspectionJson is declared optional and never read: its optionality
is what the engine reads (RuntimeTrustsCommittedFiles) to skip codegen at
module load. A module missing its generated files is refused with the same
actionable message the engine gives, before go build can fail on an
undefined dag.

A module opts in with `[runtime] source = "github.com/dagger/go-sdk/runtime"`
or a local path. targetRuntime stays "go": the engine's builtin routing is
untouched, and a ref recorded at init would resolve from the default branch
rather than from this working tree.

The runtime is registered as a workspace module so `load` validates it in CI,
and its build container gets git: `go build` falls back to VCS when the module
proxy cannot serve a dependency, the same reason codegenBuilder has it.

The fixture points its runtime at runtime/ by relative path and is driven
through a released CLI by the sdk-sdk harness, once to run it and once,
with dagger.gen.go removed, to prove the runtime refuses to regenerate.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Draft PR dagger#35 is green (65/65 checks at 6ef236e), so the design
moves to design/done with its final ledger.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant