Skip to content

Unified clients for the Python SDK (phase 1) - #33

Draft
eunomie wants to merge 165 commits into
dagger:mainfrom
eunomie:uc-phase1
Draft

eunomie wants to merge 165 commits into
dagger:mainfrom
eunomie:uc-phase1

Conversation

@eunomie

@eunomie eunomie commented Sep 18, 2026

Copy link
Copy Markdown
Member

Draft, for review of the approach as much as the code.

One generated client artifact per module, used by a module and by a plain Python program alike. It replaces the per-dependency bindings and [[dependencies]].

The design is committed with the code: hack/designs/2026-09-15-unified-clients.md.

What a scope looks like

Any directory that uses clients — a module's own directory, or a plain project:

<scope>/pyproject.toml          the uv workspace root, written by the SDK
<scope>/sdk/                    the SDK files, plus dagger_global/ while migrating
<scope>/clients/core/           the generated core client
<scope>/clients/<name>/         one member per declared client

Each client is a PEP 420 namespace package under dagger_clients, a uv workspace member with no path of its own, so the same artifact is byte-identical in two different scopes.

from dagger import function, object_type
from dagger_clients.core import Container, core
from dagger_clients.lib import lib


@object_type
class Demo:
    @function
    async def hello(self, name: str) -> str:
        return await lib().greeting(name=name)

    @function
    def base(self) -> Container:
        return core().container().from_("alpine:3.21")

What works

Verified by hand with the released CLI, outside the check harness. hack/try-unified-clients.sh builds the whole walkthrough from nothing in a temporary workspace.

  • A module calling another module through a client, on released v1.0.0-beta.14, through Query.serveModule and under both entrypoint forms.
  • A plain program with no connection handling: it provisions an engine on the first query, prints its answer, exits 0, and leaves no session process.
  • A plain scope with no [project] table at all, calling a client through dagger.connection().
  • An existing module migrating: dag.container() and dagger.Container keep working behind [tool.dagger] global-client = true, and clearing the flag gives back exactly the tree of a module that never had one.

What is open, and not fixable here

A changed client target does not invalidate its caller's cached result. [[dependencies]] used to put the target into the caller's identity; a unified client records a path and loads the module at run time, so nothing about the target reaches the caller's cache key. Change the target, call the caller with the same arguments, and the old answer comes back; call it with a different argument and the change appears.

This reproduces on a released engine and on 284cd849 alike, so it is a property of loading a module at run time rather than of the new field. Every SDK that drops [[dependencies]] inherits it. Written up language-neutrally, with the measured behaviour kept separate from the inferred mechanism: hack/designs/2026-09-18-serve-module-cache.md.

A module driven by a Dang entrypoint cannot reach a second hop. In a chain demo → lib → leaf, lib's entrypoint is handed the caller's container-derived workspace rescoped to /.dagger/modules/lib, so lib cannot load its own local client. One hop works; two do not. core/sdk/entrypoint/entrypoint.go, Workspace() at beta.14.

The SDK that generates a module must be the one that runs it. A generated manifest names the published shared entrypoint, which predates this layout and refuses the module. An entrypoint source may name only a git ref or a path inside the module, so a checkout under development cannot point at its own. Written up in hack/designs/2026-09-18-workspace-sdk-runtime.md. Before release, tag entrypoint/v1.x from this branch.

Smaller, also engine-side: dagger module client add ../lib records ./.dagger/modules/lib, but dagger module client rm ../lib refuses the same argument, because withoutClient does not normalise the address the way withClient does.

To try this branch today: hack/try-unified-clients.sh builds the walkthrough from nothing and copies this checkout's entrypoint into each module, which is what makes a development checkout runnable.

Modules run on a Dang entrypoint, and are handed only their clients

Generated manifests carry no [runtime]: a module runs through the shared Dang entrypoint, or the one --static-entrypoint generates inside it. The floor is released v1.0.0-beta.14, which drives entrypoints and has serveModule; the load path for engines without it is gone.

That exposed a defect worth recording. A Dang entrypoint runs the module's Python in an ordinary nested exec, and the engine attaches module context only to execs it starts itself — so the process reports its own container as its workspace and no currentModule at all. Whatever module context the code needs, the entrypoint must hand over.

What it hands over is the least thing that works: one module source per declared client, built from the files the engine already loaded, detached from the workspace they came from, re-read from the caller's config at every call. Not the workspace, and not a handle that leads back to one — both were tried and both leaked, the second because a ModuleSource from Workspace.moduleSource keeps its origin and withIncludes("../…") climbs back out.

A probe check runs module code that walks every ID the loader holds and every ModuleSource route that could rebuild a directory — at every climb depth, distinguishing an absent field from a refused capability — and reads nothing: 4,305 routes, reads=[], absent=0, under both entrypoint forms. Against the leaking shape the same probe reads the caller's file, so the check has a negative control.

The rule most of the review time went into

Generation writes into a directory the user also owns, so it must touch only what it made. Making that true took one critical defect and eleven more:

  • The [tool.dagger] generated marker is read as TOML, never as text, and only client, core and runtime count. A regex over raw text once deleted a user's directory because a multi-line string in it happened to quote a marker.
  • When the marker cannot be read, nothing is deleted and nothing is overwritten — a corrupted clients/core stops generation rather than being replaced.
  • The scope file is edited byte-preservingly. A user's comments, table order, member and dependency entries survive; regeneration over an untouched scope reports no changes to apply.
  • The parser boundary is where the user's content begins. [tool.uv.workspace] members and [project] dependencies are parsed with tomllib in the SDK's pinned image. A hand-written reader failed this twice in opposite directions: first too generous, then refusing "tomli; python_version < \"3.11\"" as "not an array of strings".

Checks

  • sdk: 546 passed.
  • helpers/pyproject: 39 Go tests.
  • 33 end-to-end checks, including a module calling a client through each entrypoint form, and the capability probe above. hack/e2e-local.sh runs them against your engine; hack/e2e-floor.sh pins the floor release and asserts it before any check runs.
  • Each fix carries an inversion: break the behaviour and a named check fails.

Phases

This is phase 1. Phase 2 publishes the SDK files and ends the migration: the sdk/ copy leaves the scope, and the global client is removed after a deprecation period.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The clients directory is a convention, and existing modules keep a
temporary global client behind a pyproject.toml flag.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A scope is one pyproject.toml, a uv workspace root. Each client is a
workspace member, so a consumer installs only the clients it names.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Record the decided questions, build on contextModuleSource without
[[dependencies]], keep the runtime copy in sdk/, and describe the shared
default session.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The SDK never edits [project] dependencies except for the global client,
a module calls itself through a self client, and exported signatures name
only core types and the module's own types.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Every question is decided: a client project keeps its clients at the scope
root, a module keeps them in clients/, and a module gets a self client only
when the user declares one.

Signed-off-by: Yves Brissaud <yves@dagger.io>
One structure in every scope: src, sdk and clients. A client is generated
inside the scope that uses it, and dagger.toml joins a scope to its clients.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The SDK writes the client dependency into the project file, and a client
loads its module with one serveModule call, surfaced through core.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The generator passes the field name, so the SDK never derives it from a
class name and cannot drift from the engine's naming rule.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The engine marks what a module contributes with @sourcemap(module:).
Read it to tell core from a client, to name the package of a client,
and to digest core alone, so that the digest stays the same whatever
clients the schema holds.

Signed-off-by: Yves Brissaud <yves@dagger.io>
`from dagger import X` takes a name from the package init, which
star-imports the generated bindings. The query builder and the session
then depend on generated code only to reach hand-written exceptions.

Import them from `dagger._exceptions`, where they live.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A GraphQL enum literal is bare, and the query builder only rendered an
`enum.Enum` that way. The SDK files can't import a generated enum, so
they need to send a value they only know by its schema name.

`EnumName` marks such a string. It is rendered bare, and refused when it
is not a GraphQL name, because it would otherwise inject query syntax.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The message hard-coded `dagger.client.gen`, so the SDK files named a
generated module, and bindings generated anywhere else would report the
wrong place.

Generated code passes only the class and method, so read the module from
the calling frame. Today's message is unchanged.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Split the rendering of types from the flat client, so that a package
can render the types it owns, and leave out the fields a client
contributes to a core type. A field can now say what it selects on and
how, so that a module-level function can reuse the method rendering.
The output of generate is unchanged.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Type registration, error reporting and the function call protocol went
through the generated `dag`, `TypeDef`, `TypeDefKind`,
`FunctionCachePolicy` and `JSON`. The SDK files may import nothing
generated.

`dagger.mod._api` selects the handful of fields module support needs by
name, with the same arguments and defaults as the generated bindings, so
the queries do not change. A TypeDefKind travels as its schema name, as
`describe_json` already sends it; a cache policy and a JSON scalar go
the same way. Input arguments now arrive in one query instead of one per
argument, as the comment there always claimed.

The tests compare the selections of the hand-written calls with those of
the generated bindings, field by field.

Signed-off-by: Yves Brissaud <yves@dagger.io>
`dagger.provisioning` imported `Client` to hand it out from
`dagger.Connection` and to check the engine version.

The version check is one raw query. The isolated client stays the
generated root, because that is what `async with dagger.Connection()`
yields today, but the package init now registers that root with
`dagger.client.base`, the one file that may name generated code being
the init itself. The package exports do not change.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Three checks over `sdk/src/dagger`, with `__init__.py` as the one
exception: every module imports in an interpreter that refuses
`dagger.client.gen`, `dagger_gen` and `dagger_clients`; no file
names those packages; and no file imports a name the package init
provides, since such a name may be generated.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Core becomes dagger_clients.core: its types, the entry function core(),
and CORE_DIGEST. A client becomes dagger_clients.<name>: its own types,
an entry function named after the client, and one module-level function
per field it contributes to a core type, with the core receiver first;
one name on two receivers gets an overload per receiver. The descriptor
is plain data in _target.py. A client's files depend only on the client
and on core, never on the other clients in the schema.

The generated code targets Session, Target, client_root and
client_select in dagger.client, which a later slice adds.

Signed-off-by: Yves Brissaud <yves@dagger.io>
generate keeps its behaviour, because mod.dang still calls it.
generate-core and generate-client write one package each into the
dagger_clients namespace of an output directory, with py.typed. A
client name that can't become a package is a usage error.

Signed-off-by: Yves Brissaud <yves@dagger.io>
…less

The descriptor is plain data, so a client package carries nothing it must
resolve at import beyond the core digest it checks.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The entry function hands client_root the GraphQL field name, so the
SDK never derives it from the class or the target. Each client checks
its CORE_DIGEST against the installed core on import, through
check_core. The caller passes --core-digest from the core it
generated, because the two must match for the client to import.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The guard matched generated names with a line regex and only looked at
module-level `import dagger` / `from dagger import X`. A lazy
`from .gen import Client` inside a function passed all 69 tests.

Walk every AST node, resolve relative imports to absolute names against
the file's package, and reject anything landing on dagger.client.gen,
dagger_gen or dagger_clients. The lazy relative import is kept as a
regression case, along with `from . import gen`, TYPE_CHECKING blocks
and method bodies.

The submodule check compared paths, which a case-insensitive filesystem
resolves `Client` to `client/`; it now compares listed names.

`dagger/__init__.py` is the one file still allowed to name generated
code. Pin that to the single star-import block so a second coupling
fails, until the later slice swaps it for the optional dagger_global
import.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The digest lied in both directions. Legacy ID names were computed
from the whole schema, so a client type named DirectoryID took the
compatibility class out of core while the digest stayed the same.
The raw schema version was hashed, so two modern versions that
render one core gave two digests.

Legacy names now come from the type map of the part being rendered,
and the digest hashes the canonical core surface plus only the
compatibility mode, which is what changes generation.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A field of module A's type attributed to module B was emitted in A,
queried with A's target, and B got nothing. A @sourcemap on a core
enum value or input field was ignored. Only a field of a core object
or interface type can be contributed; anything else is now an error
at generation that names the type, the member and the two modules.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The inherited convert_id branch ran the query straight through the
receiver's context, so TARGET was never attached and the field ran
before its module was served. The selection now goes through
client_select like every other contributed field, executes for the
ID, and reloads the receiver from it.

Signed-off-by: Yves Brissaud <yves@dagger.io>
--core-digest recorded any value, including an empty string or the
digest of another schema, so check_core could pass on a client
generated against different core types. The client's schema holds
core, so its digest is computed and a given value that differs is
refused, naming both. An empty --name or --ref is refused at parse
time.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Two tests pinned whole literal fragments, including blank-line layout
and the structure of the private dispatcher. They now assert on the
imported names, the public signatures, the client_select arguments,
the overloads and compilation, which is what a client relies on.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A client type named Arg replaced the imported query builder's Arg,
and the entry function then failed while building its arguments. A
package now imports every runtime and generator helper as _<name>
and emits that alias everywhere. A generated type never starts with
an underscore, and the per-receiver function of an overload now
carries the GraphQL type name, so no generated name can spell an
alias. The names in the contract don't change. The temporary
one-file client keeps its plain names.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Core symbols were imported in the same statement as the digest, so a
stale core that dropped a symbol raised a plain ImportError before
check_core ran, and the message to regenerate was never seen. The
client now imports the digest, runs the check, then imports the
symbols.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A generated interface is a runtime-checkable protocol, so an object
that implements it matched the interface's branch structurally and
the first branch won: a Zebra took the Animal hook, which rejected
its own argument. The dispatcher now goes by the receiver's GraphQL
type name first, and falls back to the structural match only for an
object with no hook of its own.

The tests load the generated core and client against a fake of the
runtime, so the dispatch is checked by calling it.

Signed-off-by: Yves Brissaud <yves@dagger.io>
…missed

A nested array in members made the runtime refuse the whole array, and
generation then reported that it "misses sdk, clients/core, ...": the
members that were fine, which sends the user to inspect the wrong entries.
When the runtime refuses the file, the message now gives its own diagnosis,
"members is not an array of strings". The list of missed or extra members
stays for the case it is meant for, a runtime that read some members and
disagrees about which.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The stricter array reader still scanned raw lines: it decoded no escapes
and did not skip multi-line strings, so a dependency such as
`tomli; python_version < "3.11"` made the runtime refuse a valid
pyproject.toml, telling its author to write what they had written.
[project] dependencies are the user's content, which generation cannot
shape, so they must be read the way TOML defines them.

The members, the project's dependencies and each member's are now read by
tomllib, in one exec in the pinned default base image over only the scope's
pyproject.toml files, so it is cached until one of them changes. The same
script picks the members to install. It is inline, so the shared
entrypoint's copy of the build carries it. The Go helper, which already
parses TOML, is out of reach there: the shared entrypoint can read none of
its own non-Dang files. The regular expressions keep the single-line keys
the templates and `mod config set` write.

An array holding anything but strings is still refused, and generation's
message names the key the runtime refused, members or dependencies. The
runtime now reads a quoted [tool.uv.workspace] header, so that no longer
stops generation, and the message no longer lists it as unreadable.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Signed-off-by: Yves Brissaud <yves@dagger.io>
The comment on the refusal's wording claimed it matched generation's
refusal of a file the runtime cannot read. The runtime now reads the
workspace with tomllib, so that refusal no longer exists; the forms the
editor asks for are its own limits, and the comment now says so.

Signed-off-by: Yves Brissaud <yves@dagger.io>
…ly candidates

The workspace reader caught a decode error only in the scope's own file. A
malformed member, or a UTF-16 file, surfaced as a traceback from an inline
script, with a line and column but no path, so the user had to guess which
member to open. Every file is now read in one place that names it:
"tools/m/pyproject.toml is not valid TOML: ...". Tables and names that are
not what they should be are read as absent rather than raising.

The mount took every pyproject.toml in the tree, a virtualenv's hundreds
included, all in the read's cache key. .venv, __pycache__, node_modules and
.git are left out: no member lives there.

Signed-off-by: Yves Brissaud <yves@dagger.io>
* uc-fixer-dang-session-9176327a: (70 commits)
  runtime: name the pyproject.toml the reader cannot read, and mount only candidates
  pyproject: say whose limits the editor's refusal describes
  runtime: read workspace members and dependencies as TOML
  generate: when the runtime refuses the members, say why, not what it missed
  generate: refuse members the runtime and uv read differently, either way
  sdk: a module says it is one, so it never provisions an engine
  e2e: an unreadable marker licenses neither a delete nor an overwrite
  pyproject: say what to write when the editor refuses a scope file
  e2e: refuse a quoted workspace header in a scope generation wrote
  generate: name the scope-file shapes the runtime does not read
  engine-e2e: let a revert of the pin leave checks that can pass
  generate: refuse a scope file the module runtime would read differently
  runtime: install only the workspace members the project depends on
  sdk: say what to do with a src/dagger_gen.py generation keeps
  hack: run e2e-local.sh with CDPATH set
  pyproject: read a source written with dotted keys
  pyproject: keep a comment with the line it sits on
  sdk: provision the engine for the default session of a plain program
  generate: replace only the sdk/ an earlier SDK vendored
  generate: read the member marker as TOML, and only the kinds the SDK writes
  ...
…s it

Signed-off-by: Yves Brissaud <yves@dagger.io>
Signed-off-by: Yves Brissaud <yves@dagger.io>
… hands over

Under a Dang entrypoint, a module's code runs in an exec the entrypoint
starts. The engine gives that exec no module context, so the process is a
plain nested client whose current workspace is the one the engine finds in
its own container. serveModule resolves a workspace path there, and a local
client fails with "dir module source does not contain a dagger config file".

The entrypoint holds the module's workspace: the engine passes it to every
call. It now sends it with the call, and `python -m dagger.mod call` keeps
it for the process. A local target then loads through that workspace:
node(id) -> moduleSource(path) -> asModule -> serve. A git target keeps
serveModule, which depends on no workspace, and so does every target in a
process no entrypoint handed a workspace. The descriptor does not change.

Signed-off-by: Yves Brissaud <yves@dagger.io>
…ace with the call

Both entrypoints, the shared one and the one generated with a static
entrypoint, found the module they serve through the workspace the engine
hands them, and built it from there. That workspace is the caller's. It
holds the module when the module sits in it, and not when the module was
loaded from git, or served into another module's process: there the caller
is that process, and its workspace is its own container. A module on an
entrypoint that another module loaded as a client failed to build, with
"workspace file .../dagger-module.toml: no such file or directory".

Inside an entrypoint `currentModule` is the module it serves, so both now
build from `currentModule.source`, and the static entrypoint no longer
bakes in the module's workspace path: a moved module needs no regeneration.

Both also send the workspace with each call, which the SDK now resolves a
local client's target in.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A generated manifest named the builtin runtime, `[runtime] source =
"python"`, beside the shared entrypoint, for engines that predate
entrypoints. The floor is now one that runs them, and one that follows the
entrypoint over the runtime, so the runtime table went unread and misled:
its engineVersion looked like the view the module ran in, which on an
entrypoint is always the engine's own.

Generation now writes the entrypoint alone. The runtime this SDK wrote
goes, with what only a runtime reads: engineVersion and [[dependencies]].
A runtime the user named is kept as it is, with no entrypoint added,
because the engine would follow the entrypoint instead. What a runtime
manifest can say and an entrypoint manifest cannot, include, exclude and a
source elsewhere, is refused before anything is written rather than
dropped; the keys are read with TOML and JSON parsers, since the manifest
is the user's.

Scope used a non-empty engineVersion to mean "a module's scope", and with
it gone the check that the runtime reads the members uv reads stopped
running for modules. Scope now takes isModule, and engineVersion only
chooses the view.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The walkthrough pointed each module's [runtime] at this checkout. With
generation writing the entrypoint alone, and an engine following the
entrypoint over a runtime anyway, that ran the published @v1 entrypoint,
which predates this layout. Each module now gets a copy of this checkout's
entrypoint/, named by path, since the engine takes an entrypoint only from
a git ref or from inside the module. The client loads through serveModule
on a released engine, so the pointer to the development engine goes.

Signed-off-by: Yves Brissaud <yves@dagger.io>
…h entrypoint

engine-e2e existed because released engines lacked serveModule: its checks
ran in a playground of an engine built from dagger/dagger. v1.0.0-beta.14
has serveModule and runs Dang entrypoints, so the e2e suite runs on the
local engine again, and dagger.toml is its workspace config once more, as
before 3955025. e2e-local.sh no longer swaps it in.

devSdkCheck becomes cliSdkCheck, on the CLI of the beta.14 engine image,
without the `dagger check` it ran, which would now recurse into itself.
devClientCallCheck, which ran the caller on the runtime of this checkout,
is replaced by entrypointClientCallCheck: nothing ran a module through an
entrypoint end to end before, which is how a module on one could not load
a local client. It does now, in both forms, the shared entrypoint of this
checkout and the one --dang-entrypoint generates, from `dagger module init`
to `dagger call`, with the client's target on an entrypoint too.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The first release that runs Dang entrypoints and serves serveModule. The
SDK module declares it, and a plain program that provisions its own engine
downloads that CLI instead of beta.10, which had neither. The README says
how a module runs now, and what happens to a manifest that names a runtime.

Signed-off-by: Yves Brissaud <yves@dagger.io>
It served engines released before serveModule. The floor is now
v1.0.0-beta.14, which has the field, so an engine without it is below the
floor, and its load fails as a ClientLoadError whose cause names the
field. It is no stale client: regenerating a client cannot give the engine
a field, so the message does not tell the user to.

The staleness signal keeps its narrow reading: only the validator's own
answer, with no path, GRAPHQL_VALIDATION_FAILED and the field and parent
named, marks a stale client, and its tests are unchanged. The tests of the
fallback's wire shape go with it, and so does the parametrized one that
proved which load errors did not trigger the fallback: with no fallback,
no load error triggers anything, and it no longer tested a decision.

Signed-off-by: Yves Brissaud <yves@dagger.io>
…st match

Signed-off-by: Yves Brissaud <yves@dagger.io>
Both entrypoints sent the caller's Workspace ID into the module's process,
and module code could read any caller file through it. In Dagger an ID is a
capability, and a module is third-party code.

The entrypoint now resolves the local clients the caller's dagger.toml
declares on the module's scope, and each call carries them by name. The
scope is the one registered under the module's name that holds the module's
own files, so a module loaded from elsewhere under that name gets nothing.

Each client goes over as a directory-kind module source built from the files
the engine loaded for it, plus its local dependencies'. Not the source
Workspace.moduleSource returns, nor a Module: on beta.14 both keep the
workspace, and withIncludes(["../secret"]) reloads the context from it, which
reads the caller's file just as the workspace did.

The loader looks a local target up by name and serves it. A name not handed
over fails naming the client and pointing at `dagger generate`. A process an
entrypoint runs never falls back to serveModule for a local target, since
that resolves the path in its own container; an entrypoint that sends no
clients says so.

entrypointClientCallCheck proves it in both forms: module code tries every
ID the loader holds as a workspace, a module source and a module, and its
own current workspace, against a file only the caller has, and reads
nothing, while a function declaring `ws: Workspace` still reads it. Against
the workspace handover the same code reads the file.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Generation took any local Dang entrypoint for its own static one and
replaced it with the shared entrypoint, so a user's
`source = "./my-entrypoint"` was lost. And the manifest reader matched
double-quoted values only, so `kind = 'dang'` read as no kind and a
single-quoted fork was dropped as a stale entrypoint.

The SDK's own static entrypoint is now the one at the path generation
writes, ./sdk/entrypoint; every other entrypoint is the user's and is kept
as written. Kind and source are read with TOML.decode, and the entrypoint
table header is recognised however it is spaced or quoted. The static
path's objections are read the same way: a builtin runtime written
`source = 'python'` was refused as a runtime of the user's own.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Generation kept any Dang entrypoint already in the manifest. One that does
not send the handover, an older pin or a fork, leaves a local client with
nothing to load through, and the module failed at its first call to it.

Refuse, not repair: repairing means replacing an entrypoint table the user
wrote, and generation cannot tell whether a given pin or fork sends the
handover. The refusal is narrow. It applies only to an entrypoint this SDK
does not write, the shared source or its own static one, in a scope that
declares a local client; without one, any entrypoint that runs the module
will do. The message names the entrypoint and the clients, and says to
remove the [entrypoint] table or the local clients.

At run time the module process already says what happened when an
entrypoint hands over nothing.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A manifest naming both a custom runtime and a Dang entrypoint was kept
whole. The engine runs the entrypoint, on the session's version, while
generation took the core it generates against from the runtime's
engineVersion: the core/session skew the digest check exists to prevent.
Beside this SDK's static entrypoint it was worse: that branch rebuilt the
manifest and dropped the runtime without a word.

Both tables are the user's and they contradict each other, so generation
refuses before either branch runs. `dagger generate`, `dagger module init`
and `dagger module client add` on such a module stop with:

  dagger-module.toml names both a runtime (<source>) and an entrypoint
  (<source>); the engine runs the entrypoint and never calls the runtime.
  Remove [entrypoint] to run on your runtime, or [runtime] to run on the
  entrypoint

Signed-off-by: Yves Brissaud <yves@dagger.io>
A static entrypoint could carry the client list from generation. It must
not: a baked list lives in the module's own files, which the module's
author controls, while the caller's workspace config is what the caller
agreed to. The comment says so, so the next change does not simplify it
back.

Signed-off-by: Yves Brissaud <yves@dagger.io>
static-scope-init-check still expected the static entrypoint to send
`workspace: (workspace.id :: String!)`, the handover 41b5aad removed. It
now expects the declared clients, refuses any workspace ID, and checks the
static entrypoint carries the shared entrypoint's handover.dang byte for
byte.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The checks that drive the floor release's CLI run it as a nested client
of whatever engine runs the e2e module, so a pass on a later engine was
taken for a pass on beta.14. The playground now asks the engine for its
version first, and every check through it fails unless the engine is
v1.0.0-beta.14 (a release's "+<build>" suffix aside), naming the command
that runs the checks on the floor engine.

This replaces the `dagger check` the old devSdkCheck ran rather than
restoring it. From inside the e2e module it would run the whole suite
inside itself on the same engine: repetition that doubles a run already
near its timeouts. Pinning the engine the suite runs on proves what that
nested run proved.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Five checks went through sdk-sdk, whose pinned harness (its latest commit)
drives a v1.0.0-beta.10 CLI. On the beta.14 engine that CLI's
`dagger sdk install` connects and then hangs until sdk-sdk's 10-minute
timeout kills it. It does the same for a two-file Dang SDK module with
nothing of this repository in it, so the hang is the CLI/engine pairing,
not this SDK. A later CLI cannot stand in: sdk-sdk passes `-y` and
`--progress` to `dagger sdk`, which beta.14 refuses.

None of the five needed sdk-sdk for more than running `dagger call` in a
workspace holding this checkout, which the e2e playground already is: the
floor release's CLI, on an engine the playground checks is the floor. The
checks keep their assertions, and the missing-generated-files one still
asserts the failure and its message. The vendored-layout detour the client
call needed is gone, and so is the sdk-sdk dependency.

Signed-off-by: Yves Brissaud <yves@dagger.io>
… workspace

Signed-off-by: Yves Brissaud <yves@dagger.io>
The refusal of an entrypoint this SDK does not write, in a scope with a
local client, offered two ways out: drop the entrypoint or drop the
clients. The one a fork's author would take was missing: make the fork
hand clients over. Offering it alone would not have helped, since
generation refused every such entrypoint whatever it did.

Generation now reads the entrypoint from where the engine resolves it (a
path in the module, an address, or a module reference) and accepts it
when it carries this SDK's handover.dang unchanged and another of its
files sends `ClientHandover(workspace: workspace).clients` with the call.
The refusal names that as its first option, with the line to add. A pin
of the shared entrypoint is accepted the same way once its release
carries the handover. One that cannot be read, or carries the file
without calling it, is still refused.

Signed-off-by: Yves Brissaud <yves@dagger.io>
4a819e2 made every check that drives the floor release's CLI refuse an
engine that is not exactly v1.0.0-beta.14. That is the right condition for
a job that claims the floor, and the wrong one for running the suite at
all: the day beta.15 exists, the ordinary suite stops running, a cost paid
by everyone every day to protect one property.

The playground no longer asserts anything about its engine. The floor
job, hack/e2e-floor.sh, provisions the floor's engine with
DAGGER_ENGINE=image://registry.dagger.io/engine:v<floorVersion>, reads the
version from .dagger/modules/e2e/main.dang so there is one to change, and
runs `assert-floor-engine` before any check: the engine must report the
floor (a release's "+<build>" aside) or the job fails, naming itself as
the command to run. hack/e2e-local.sh runs the same checks on whatever
engine it finds.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The independent correctness re-check of the handover never completed, so
the probe stands in for it. It walked three routes; it now walks every
route on a ModuleSource that could rebuild a directory from outside the
source's own loaded files, which is the shape that defeated attempt two
(a Workspace.moduleSource result reloading its context on withIncludes).

Added: generatedContextDirectory, updatedConfigDirectory,
generatedContextChangeset.layer, withSourceSubpath, and the withName /
withSDK / withEngineVersion mutations, each into contextDirectory; one
level of recursion through dependencies and asModule.source. The
docstring lists what it rejects and why (scalars, schema JSON,
generate(workspace:), the with* attach/mutate fields whose result is
still walked).

Two corrections the reviewer's instinct demanded. A climb leak lands on
the workspace root only when the number of "../" equals the module's
depth: too few stays inside, too many escapes and the engine refuses the
pattern. A fixed depth of 8 overshot and missed the exact-depth read, so
every climbing route is now tried at depths 1..12. And a route that fails
is only reassuring if a resolver refused it, not if the field is absent
from the engine; the probe tells a validation error from a refusal and
reports absent=0, which the check asserts.

Verified: against attempt two's shape (handing over the workspace-
retaining source) the probe reads the caller file at withIncludes(../x3),
the module's depth, and the check fails; against the fix it walks 4305
routes with absent=0 and reads nothing, under both entrypoint forms.

Signed-off-by: Yves Brissaud <yves@dagger.io>
* uc-fixer-entrypoint-handover-32e9c924:
  e2e: extend the handover probe with every context-rebuilding route
  e2e: prove the floor in its own job, and run the suite on any engine
  python-sdk: let a forked entrypoint hand clients over, and say how
  e2e: run the runtime checks on the floor CLI, not sdk-sdk's
  e2e: refuse to prove the floor on an engine that is not the floor
  e2e: the static entrypoint hands over clients, not the workspace
  entrypoint: say why the handover is read at every call, not baked
  python-sdk: refuse a runtime of the user's own beside an entrypoint
  python-sdk: refuse an entrypoint that cannot hand a local client over
  python-sdk: replace only the entrypoint generation wrote, read as TOML
  entrypoint: hand the module its declared clients, never the workspace
  client: remove the load path for engines without serveModule
  python-sdk: move the floor to v1.0.0-beta.14
  e2e: fold engine-e2e into the e2e suite, and run a module through each entrypoint
  hack: run the walkthrough on this checkout's entrypoint
  python-sdk: generate a module on its Dang entrypoint alone
  entrypoint: build the module from its own source, and send the workspace with the call
  client: load a local target through the workspace a module entrypoint hands over
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