diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 2ddc72e..5bce734 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -291,6 +291,15 @@ type E2e { "generate", ) + let runtimeDangPath = tomlGenerateModulePath + "/runtime.dang" + assertAdded(changes, runtimeDangPath) + let runtimeDang = changes.layer.file(runtimeDangPath).contents + assertContains(runtimeDang, generatedMarkerContents, "the embedded runtime is missing the generated-file marker") + assertContains(runtimeDang, "pub moduleRuntime(", "the embedded runtime does not declare moduleRuntime") + assertContains(runtimeDang, "@sha256:", "the embedded runtime lost its pinned images") + assertContains(runtimeDang, "dagger.mod.cli", "the embedded runtime lost its inlined entrypoint script") + assertNotContains(runtimeDang, "currentModule", "the embedded runtime must not read a module source") + null } @@ -323,12 +332,12 @@ type E2e { } """ - New modules still target the engine's builtin runtime: they move to this - repository's own only once `runtime/` is on the default branch, where the - recorded ref resolves. + New modules embed this repository's runtime: generate writes runtime.dang + next to dagger-module.toml and the engine evaluates it in-process, so no + runtime module is resolved at load. """ pub targetRuntimeCheck: Void @check { - assert(pythonSdk.targetRuntime == "python", "targetRuntime should be the python builtin runtime") + assert(pythonSdk.targetRuntime == "embed:runtime.dang", "targetRuntime should embed the generated runtime.dang") null } @@ -366,6 +375,36 @@ type E2e { null } + """ + A module whose runtime source is embed:runtime.dang builds and runs: the + runtime fixture is rebased onto the embed form with a freshly generated + runtime.dang, then driven through a released CLI. Known red until a released + engine understands the embed runtime source. + """ + pub embedRuntimeCallCheck(ws: Workspace!): Void @check { + let embedded = pythonSdk + .mod(ws, path: tomlGenerateModulePath) + .generate + .layer + .file(tomlGenerateModulePath + "/runtime.dang") + + let tree = ws + .directory("/") + .withNewFile( + runtimeModulePath + "/dagger-module.toml", + "name = \"runtime-app\"\nengineVersion = \"v1.0.0-0\"\n\n[runtime]\nsource = \"embed:runtime.dang\"\n", + ) + .withFile(runtimeModulePath + "/runtime.dang", embedded) + + let run = sdkSdk + .target(tree, ".") + .runInstalled(["call", "-m", runtimeFixturePath, "greeting"]) + run.assertSuccess + assertContains(run.stdout, runtimeGreeting, "the module did not run on the embedded runtime") + + null + } + """ The fixture as seen from sdk-sdk's scratch workspace, where this repository is vendored. diff --git a/dagger.json b/dagger.json index 6fac37c..899c771 100644 --- a/dagger.json +++ b/dagger.json @@ -5,7 +5,6 @@ "source": "dang" }, "include": [ - "!runtime", "!.dagger", "!future", "!docs" diff --git a/mod.dang b/mod.dang index 6617ed0..e427fd0 100644 --- a/mod.dang +++ b/mod.dang @@ -66,7 +66,7 @@ type Mod { let generated = if (isModern) { # Generated here: the runtime generates nothing, so the engine's # generated context would be empty. - vendoredDir(stagedWs) + vendoredDir(stagedWs).withNewFile(embeddedRuntimeFilename, embeddedRuntime) } else { # A pre-1.0 module is still generated by the engine's builtin Python SDK. stagedWs @@ -101,6 +101,57 @@ type Mod { ) } + """ + The module runtime as a single self-contained file: the live runtime source + with its marked externals block — the only part that reads the runtime's own + module source — replaced by literals. The engine reads this file from the + module root and evaluates it in-process, resolving no runtime module at all. + """ + let embeddedRuntime: String! { + let source = currentModule.source.file(runtimeSourcePath).contents + let opening = source.split("#") + let closing = (opening[1] ?? "").split("#") + if (opening.length != 2 or closing.length != 2) { + raise runtimeSourcePath + " must fence its module-source reads with exactly one # block" + } + let assembled = "# Code generated by dagger. DO NOT EDIT.\n" + + (opening[0] ?? "") + + embeddedExternals + + (closing[1] ?? "") + if (assembled.contains("currentModule")) { + raise runtimeSourcePath + " reads the module source outside the # block; the embedded runtime would not be self-contained" + } + assembled + } + + """ + Literal replacements for the runtime's externals block: the image pins from + the runtime's Dockerfiles and the inlined entrypoint script. + """ + let embeddedExternals: String! { + "# Pinned when the SDK generated this file.\n" + + " let defaultBaseImage: String! = " + dangQuoted(runtimeImageFrom("runtime/images/base/Dockerfile")) + "\n" + + " let defaultUvImage: String! = " + dangQuoted(runtimeImageFrom("runtime/images/uv/Dockerfile")) + "\n" + + " let runtimeScriptContents: String! = " + dangQuoted(currentModule.source.file("runtime/runtime.py").contents) + } + + """ + The same extraction as the runtime's own imageFrom, run against this + module's source at generation time. + """ + let runtimeImageFrom(path: String!): String! { + let line = currentModule.source.file(path).contents.match("(?m)^FROM\\s+(\\S+)") + if (line == null) { + raise "no FROM line in " + path + } else { + line.captures[0] ?? "" + } + } + + let dangQuoted(text: String!): String! { + "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"" + } + """ Bindings generated from a module's schema, straight from the synced environment: `uv run --isolated` built a throwaway one per module. @@ -180,6 +231,8 @@ type Mod { let vendorDirName: String! = "sdk" let generatedBindingsPath: String! = "src/dagger/client/gen.py" let schemaPath: String! = "/schema.json" + let embeddedRuntimeFilename: String! = "runtime.dang" + let runtimeSourcePath: String! = "runtime/main.dang" # musl runs the generator ~0.3s slower than glibc, but the glibc image is # 25 MiB larger to pull, which costs more on the first generate. diff --git a/python-sdk.dang b/python-sdk.dang index e34dcbb..eea6abc 100644 --- a/python-sdk.dang +++ b/python-sdk.dang @@ -8,9 +8,10 @@ type PythonSdk { pub skipGenerateFilename: String! = ".dagger-python-sdk-skip-generate" """ - Runtime source to write into modules created by this SDK. + Runtime source to write into modules created by this SDK: the engine reads + the named file from the module root and evaluates it in-process. """ - pub targetRuntime: String! { "python" } + pub targetRuntime: String! { "embed:runtime.dang" } """ Return every managed Python SDK module in the client's cwd scope: every module diff --git a/runtime/main.dang b/runtime/main.dang index 0096b0f..62613ad 100644 --- a/runtime/main.dang +++ b/runtime/main.dang @@ -16,9 +16,24 @@ type PythonSdkRuntime { let uvCacheVolume: String! = "modpython-uv" let pipCacheVolume: String! = "modpython-pip" + # + # Everything between these markers reads this runtime's own module source; + # Mod.generate replaces the whole block with literals when it embeds this + # file into a generated module, so the copy is fully self-contained. # Committed single-FROM Dockerfiles so Dependabot keeps the digests fresh. let defaultBaseImage: String! { imageFrom("images/base/Dockerfile") } let defaultUvImage: String! { imageFrom("images/uv/Dockerfile") } + let runtimeScriptContents: String! { currentModule.source.file("runtime.py").contents } + + let imageFrom(path: String!): String! { + let line = currentModule.source.file(path).contents.match("(?m)^FROM\\s+(\\S+)") + if (line == null) { + raise "no FROM line in " + path + } else { + line.captures[0] ?? "" + } + } + # """ Container for executing the Python module runtime. introspectionJson is @@ -46,7 +61,7 @@ type PythonSdkRuntime { let packageName = packageNameFor(modName, cfg) let built = base(baseImage, uvImage, cfg) - .withFile(runtimeExecutablePath, currentModule.source.file("runtime.py"), permissions: 493) + .withNewFile(runtimeExecutablePath, runtimeScriptContents, permissions: 493) .withEntrypoint([runtimeExecutablePath]) .withWorkdir(join(contextDirPath, subPath)) .withMountedDirectory(contextDirPath, withoutVenv(contextDir, source, subPath)) @@ -260,15 +275,6 @@ type PythonSdkRuntime { if (rel == "" or rel == ".") { base } else { base + "/" + rel } } - let imageFrom(path: String!): String! { - let line = currentModule.source.file(path).contents.match("(?m)^FROM\\s+(\\S+)") - if (line == null) { - raise "no FROM line in " + path - } else { - line.captures[0] ?? "" - } - } - let imageTag(ref: String!): String! { let named = ref.split("@").takeFirst(1).join("") let parts = named.split(":")