diff --git a/examples/unikraft-procfile/.dockerignore b/examples/unikraft-procfile/.dockerignore new file mode 100644 index 0000000..4dcb4b9 --- /dev/null +++ b/examples/unikraft-procfile/.dockerignore @@ -0,0 +1,5 @@ +.unikraft +.rootfs-* +.Kraftfile.* +.config.* +__pycache__ diff --git a/examples/unikraft-procfile/.gitignore b/examples/unikraft-procfile/.gitignore new file mode 100644 index 0000000..f4ea10c --- /dev/null +++ b/examples/unikraft-procfile/.gitignore @@ -0,0 +1,8 @@ +# Everything below is generated by `bsdkrun pack .` — this example +# deliberately ships no Dockerfile/Kraftfile of its own; see the README. +/Kraftfile +/.Kraftfile.* +/.config* +/.unikraft/ +/.rootfs-*/ +/.rust-cache/ diff --git a/examples/unikraft-procfile/Procfile b/examples/unikraft-procfile/Procfile new file mode 100644 index 0000000..d9fd512 --- /dev/null +++ b/examples/unikraft-procfile/Procfile @@ -0,0 +1,9 @@ +# A unikernel runs exactly one program, so only `web` is used here. The +# others are reported as ignored rather than silently dropped. +# +# The command is written with absolute guest paths: there is no shell to +# resolve `python3` against PATH, and the working directory is not the +# source tree. `bsdkrun pack` prints the exact argv it will boot with. +web: /opt/python/bin/python3 -u /src/serve.py +worker: /opt/python/bin/python3 -u /src/serve.py --worker +release: /opt/python/bin/python3 -c "print('migrations would run here')" diff --git a/examples/unikraft-procfile/README.md b/examples/unikraft-procfile/README.md new file mode 100644 index 0000000..43283a6 --- /dev/null +++ b/examples/unikraft-procfile/README.md @@ -0,0 +1,101 @@ +# Procfile on Unikraft + +A project whose start command comes from a `Procfile`, and whose environment +comes from `railpack.json` — neither of which the provider could infer. + +## Build + +```sh +bsdkrun pack . +``` + +## Run + +```sh +bsdkrun unikraft . --cmdline "python -- /opt/python/bin/python3 -u /src/serve.py" +``` + +## Try it + +```sh +curl http://:8080/ +``` + +```json +{ + "message": "Hello from a Procfile on Unikraft!", + "python": "3.12.13", + "greeting": "hello from deploy.variables", + "port": 8080 +} +``` + +## What the Procfile decides + +The Python provider looks for `main.py`, `app.py` or `server.py` and finds none +of them — the entry point here is `serve.py`. The `Procfile` is what says so. + +Process types are chosen in railpack's order: **`web`**, then **`worker`**, then +whatever was declared first. That middle step matters for a Procfile carrying +only background processes: falling straight to "first declared" would pick +`release: migrate`, a command that exits immediately and leaves the guest dead. + +A unikernel runs exactly one program, so the others are named as ignored rather +than silently dropped. + +### Commands need absolute guest paths + +`web: python serve.py` is the Heroku spelling and it will **not** work here. +There is no shell to resolve `python` against `PATH`, and the working directory +is not the source tree. Write what the guest will actually execute: + +``` +web: /opt/python/bin/python3 -u /src/serve.py +``` + +`bsdkrun pack` prints the exact argv it will boot with, so a wrong path shows up +before you boot rather than after. + +## What railpack.json decides + +```json +{ + "packages": { "python": "3.12", "jq": "latest" }, + "deploy": { "variables": { "GREETING": "...", "PORT": "8080" } } +} +``` + +| Field | Effect | +| ----- | ------ | +| `packages.python` | The provider's own version pin | +| `packages.jq` | An extra build-time tool, installed with mise | +| `deploy.variables` | The guest's environment | + +A unikernel has no shell to export anything, so `deploy.variables` are compiled +into the image as kconfig. The indices are **allocated, not fixed** — the Python +provider already holds ENVP4 through ENVP6 for `PYTHONHOME` and friends, so these +land at ENVP7 and ENVP8: + +``` +CONFIG_LIBPOSIX_ENVIRON_ENVP6: "PYTHONDONTWRITEBYTECODE=1" +CONFIG_LIBPOSIX_ENVIRON_ENVP7: "GREETING=hello from deploy.variables" +CONFIG_LIBPOSIX_ENVIRON_ENVP8: "PORT=8080" +``` + +Setting a variable that already exists replaces it in place instead of adding a +second entry, so overriding `PATH` or `HOME` does what you would expect. + +## Secrets + +`railpack.json`'s `secrets` names values the build may read: + +```json +{ "secrets": ["NPM_TOKEN"] } +``` + +Each is mounted at `/run/secrets/` for the command that needs it, with the +value taken from the environment variable of the same name — so `export +NPM_TOKEN=...` locally and a repository secret in CI reach the build the same +way. A secret mount is not a layer: a token used to fetch a private dependency +does not stay readable in the finished image, which matters here because the +image is a unikernel pushed to a registry whole. diff --git a/examples/unikraft-procfile/railpack.json b/examples/unikraft-procfile/railpack.json new file mode 100644 index 0000000..b64ad03 --- /dev/null +++ b/examples/unikraft-procfile/railpack.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://schema.railpack.com", + "provider": "python", + "packages": { + "python": "3.12", + "jq": "latest" + }, + "deploy": { + "variables": { + "GREETING": "hello from deploy.variables", + "PORT": "8080" + } + } +} diff --git a/examples/unikraft-procfile/serve.py b/examples/unikraft-procfile/serve.py new file mode 100644 index 0000000..b1297aa --- /dev/null +++ b/examples/unikraft-procfile/serve.py @@ -0,0 +1,41 @@ +"""Started by the Procfile, not by the provider's own inference. + +The Python provider would look for main.py, app.py or server.py and find +none of them. The Procfile is what says to run this. +""" + +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(os.environ.get("PORT", "8080")) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + body = json.dumps( + { + "message": "Hello from a Procfile on Unikraft!", + "python": sys.version.split()[0], + # Set by railpack.json's deploy.variables, compiled into the + # image as kconfig — there is no shell in a unikernel to + # export anything. + "greeting": os.environ.get("GREETING", "(unset)"), + "port": PORT, + } + ).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + print(f"Procfile app listening on :{PORT}", flush=True) + HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/pack/internal/procfile/procfile.go b/pack/internal/procfile/procfile.go index e674dda..7165bf9 100644 --- a/pack/internal/procfile/procfile.go +++ b/pack/internal/procfile/procfile.go @@ -21,17 +21,31 @@ type Procfile struct { Order []string } -// Web returns the command a unikernel should run: the `web` process type if -// declared, else the first one. Reports false when there is nothing to run. +// Web returns the command a unikernel should run: `web`, else `worker`, +// else the first declared. Reports false when there is nothing to run. +// +// The worker step matters for a Procfile that declares only background +// processes — falling straight to "first declared" would pick whichever +// happened to be written first, which for `release: migrate` and +// `worker: consume` is a migration that exits immediately. func (p *Procfile) Web() (string, bool) { if p == nil || len(p.Order) == 0 { return "", false } - if cmd, ok := p.Commands["web"]; ok && cmd != "" { + if cmd := p.Commands[chosen(p)]; cmd != "" { return cmd, true } - first := p.Order[0] - return p.Commands[first], p.Commands[first] != "" + return "", false +} + +// chosen is the process type that will run. +func chosen(p *Procfile) string { + for _, preferred := range []string{"web", "worker"} { + if cmd, ok := p.Commands[preferred]; ok && cmd != "" { + return preferred + } + } + return p.Order[0] } // Ignored lists the process types that will not run, since only one can. @@ -39,13 +53,13 @@ func (p *Procfile) Ignored() []string { if p == nil { return nil } - chosen := "web" - if _, ok := p.Commands["web"]; !ok && len(p.Order) > 0 { - chosen = p.Order[0] + if len(p.Order) == 0 { + return nil } + running := chosen(p) var rest []string for _, name := range p.Order { - if name != chosen { + if name != running { rest = append(rest, name) } } diff --git a/pack/internal/procfile/procfile_test.go b/pack/internal/procfile/procfile_test.go new file mode 100644 index 0000000..b073e16 --- /dev/null +++ b/pack/internal/procfile/procfile_test.go @@ -0,0 +1,74 @@ +package procfile + +import ( + "os" + "path/filepath" + "testing" +) + +func write(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Procfile"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +// The order is railpack's: web, then worker, then whatever was declared +// first. The worker step matters for a Procfile with only background +// processes — "first declared" would pick `release: migrate`, a command +// that exits immediately and leaves the guest dead. +func TestWorkerBeatsFirstDeclared(t *testing.T) { + p := Read(write(t, "release: migrate\nworker: consume\n")) + cmd, ok := p.Web() + if !ok || cmd != "consume" { + t.Errorf("Web() = %q, %v; want the worker command", cmd, ok) + } + if ignored := p.Ignored(); len(ignored) != 1 || ignored[0] != "release" { + t.Errorf("Ignored() = %v, want [release]", ignored) + } +} + +func TestWebWins(t *testing.T) { + p := Read(write(t, "worker: consume\nweb: serve\n")) + if cmd, _ := p.Web(); cmd != "serve" { + t.Errorf("Web() = %q, want serve", cmd) + } + if ignored := p.Ignored(); len(ignored) != 1 || ignored[0] != "worker" { + t.Errorf("Ignored() = %v, want [worker]", ignored) + } +} + +// Neither web nor worker: the first declared runs, and the rest are named +// rather than silently dropped. +func TestFallsBackToFirstDeclared(t *testing.T) { + p := Read(write(t, "clock: tick\nrelease: migrate\n")) + if cmd, _ := p.Web(); cmd != "tick" { + t.Errorf("Web() = %q, want tick", cmd) + } + if ignored := p.Ignored(); len(ignored) != 1 || ignored[0] != "release" { + t.Errorf("Ignored() = %v, want [release]", ignored) + } +} + +func TestCommentsAndBlanksIgnored(t *testing.T) { + p := Read(write(t, "# a comment\n\nweb: serve --port 8080\n")) + if cmd, _ := p.Web(); cmd != "serve --port 8080" { + t.Errorf("Web() = %q", cmd) + } +} + +// No Procfile is the common case, not an error. +func TestMissingIsNil(t *testing.T) { + if p := Read(t.TempDir()); p != nil { + t.Errorf("Read() = %v, want nil", p) + } + var nilp *Procfile + if _, ok := nilp.Web(); ok { + t.Error("nil Procfile should have no command") + } + if ignored := nilp.Ignored(); ignored != nil { + t.Errorf("nil Procfile Ignored() = %v", ignored) + } +}