diff --git a/cmd/legacy_parliament_test.go b/cmd/legacy_parliament_test.go new file mode 100644 index 0000000000..20d82b092c --- /dev/null +++ b/cmd/legacy_parliament_test.go @@ -0,0 +1,85 @@ +package cmd + +// LEGACY PYTHON: func-run host rejection test. + +import ( + "errors" + "os" + "path/filepath" + "testing" + + fn "knative.dev/func/pkg/functions" + "knative.dev/func/pkg/mock" + "knative.dev/func/pkg/oci" + . "knative.dev/func/pkg/testing" +) + +// TestRun_LegacyParliamentHostRejected verifies `func run --builder=host` on a +// legacy parliament function is rejected up front, before any build or run. +func TestRun_LegacyParliamentHostRejected(t *testing.T) { + root := FromTempDirectory(t) + if _, err := fn.New().Init(fn.Function{Root: root, Runtime: "python"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Procfile"), []byte("web: python -m parliament .\n"), 0644); err != nil { + t.Fatal(err) + } + + builder := mock.NewBuilder() + runner := mock.NewRunner() + cmd := NewRunCmd(NewTestClient( + fn.WithBuilder(builder), + fn.WithRunner(runner), + fn.WithRegistry("ghcr.com/reg"), + )) + cmd.SetArgs([]string{"--builder=host"}) + + _, err := cmd.ExecuteContextC(t.Context()) + if !errors.Is(err, oci.ErrLegacyParliamentHost) { + t.Fatalf("expected ErrLegacyParliamentHost, got %v", err) + } + if builder.BuildInvoked { + t.Error("build should not run for a rejected legacy parliament host run") + } + if runner.RunInvoked { + t.Error("run should not run for a rejected legacy parliament host run") + } +} + +// TestRun_UnsupportedLegacyPythonHostRejected verifies `func run --builder=host` +// on a pre-v1.18 non-parliament Procfile layout (old flask/wsgi templates) is +// rejected up front with the migration error. +func TestRun_UnsupportedLegacyPythonHostRejected(t *testing.T) { + root := FromTempDirectory(t) + if _, err := fn.New().Init(fn.Function{Root: root, Runtime: "python"}); err != nil { + t.Fatal(err) + } + // turn the modern scaffold into the old flask/wsgi shape: a gunicorn + // Procfile at root, no root pyproject.toml. + if err := os.Remove(filepath.Join(root, "pyproject.toml")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Procfile"), []byte("web: gunicorn func:main --bind=0.0.0.0:8080\n"), 0644); err != nil { + t.Fatal(err) + } + + builder := mock.NewBuilder() + runner := mock.NewRunner() + cmd := NewRunCmd(NewTestClient( + fn.WithBuilder(builder), + fn.WithRunner(runner), + fn.WithRegistry("ghcr.com/reg"), + )) + cmd.SetArgs([]string{"--builder=host"}) + + _, err := cmd.ExecuteContextC(t.Context()) + if !errors.Is(err, fn.ErrUnsupportedLegacyPython) { + t.Fatalf("expected ErrUnsupportedLegacyPython, got %v", err) + } + if builder.BuildInvoked { + t.Error("build should not run for a rejected legacy python host run") + } + if runner.RunInvoked { + t.Error("run should not run for a rejected legacy python host run") + } +} diff --git a/cmd/run.go b/cmd/run.go index bea2c961cd..f60ef3e9aa 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -175,6 +175,15 @@ func runRun(cmd *cobra.Command, newClient ClientFactory) (err error) { container := f.Build.Builder != "host" + // LEGACY PYTHON: host runner can't run old parliament functions — reject. + if !container && f.IsLegacyParliament() { + return oci.ErrLegacyParliamentHost + } + // LEGACY PYTHON: other pre-v1.18 Procfile-based python layouts are rejected too. + if !container && f.IsUnsupportedLegacyPython() { + return fn.ErrUnsupportedLegacyPython + } + // Ignore the verbose flag if JSON output if cfg.JSON { cfg.Verbose = false diff --git a/docs/function-templates/python.md b/docs/function-templates/python.md index aeba68e80f..7fa55ad8b9 100644 --- a/docs/function-templates/python.md +++ b/docs/function-templates/python.md @@ -377,3 +377,26 @@ func deploy --builder=host --registry docker.io/myuser For all deploy options, see `func deploy --help` + +## Legacy parliament functions (deprecated) + +Before func v1.18, Python functions used the "parliament" style: a bare `func.py` +exposing `def main(context)`, a `requirements.txt` depending on +`parliament-functions`, and a `Procfile` (`web: python -m parliament .`). + +These functions still build and deploy with the `pack` and `s2i` builders, locally +and remotely. func builds them the pre-v1.18 way, from the function's own `Procfile` +and `requirements.txt`, pinning `cloudevents` below 2.0 during the build (the +parliament stack requires the 1.x line; with the `pack` builder the pin is written +to a `constraints.txt` file at the function root). The `host` builder does not +support them. This support is deprecated and will be removed in a future release; +every build prints a deprecation warning. + +The other pre-v1.18 Procfile-based layouts (the old `flask` and `wsgi` templates) +are not supported and are rejected with an error. + +To migrate, recreate the function with the current layout (`func create -l python`) +and move your logic into the `function` package: `def main(context)` becomes the +ASGI `handle` (or an instanced `new()`) described above, where the Flask request +object is replaced by the `scope`/`receive`/`send` interface. Functions that work +with CloudEvents must also adopt the cloudevents 2.x API. diff --git a/e2e/e2e_core_test.go b/e2e/e2e_core_test.go index 5a71b5674f..91be9c4b74 100644 --- a/e2e/e2e_core_test.go +++ b/e2e/e2e_core_test.go @@ -417,6 +417,77 @@ func TestCore_StaticSignature_Python(t *testing.T) { } } +// LEGACY PYTHON: old parliament functions (func.py def main(context) + parliament +// Procfile) must build and serve via pack and s2i, built locally and in-cluster. +func TestCore_LegacyParliamentSignature_Python(t *testing.T) { + // The fixtures in testdata/legacy-parliament were generated with func + // v1.17.0 (the last parliament-era release): + // + // func create -l python -t http parliament-http + // func create -l python -t cloudevents parliament-cloudevents + // + // The cells pair builders, flavors and localities so that each builder + // builds both locally and remotely (in-cluster, where scaffolding runs + // inside the Tekton pipeline) and each flavor passes through both builders. + for _, tc := range []struct { + builder string + template string + remote bool + }{ + {"pack", "http", false}, + {"s2i", "cloudevents", false}, + {"pack", "cloudevents", true}, + {"s2i", "http", true}, + } { + cell := tc.builder + if tc.remote { + cell += "-remote" + } + t.Run(cell, func(t *testing.T) { + name := "func-e2e-parliament-" + cell + root := fromCleanEnv(t, name) + + // Copy in the v1.17.0-generated fixture, renaming it for this run; + // the rest of the era func.yaml stays as generated. + fixture := "parliament-" + tc.template + if err := os.CopyFS(root, os.DirFS(filepath.Join(Testdata, "legacy-parliament", fixture))); err != nil { + t.Fatal(err) + } + yamlPath := filepath.Join(root, "func.yaml") + y, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatal(err) + } + y = bytes.Replace(y, []byte("name: "+fixture), []byte("name: "+name), 1) + if err := os.WriteFile(yamlPath, y, 0644); err != nil { + t.Fatal(err) + } + + args := []string{"deploy", "--builder", tc.builder} + if tc.remote { + args = append(args, "--remote") + } + if err := newCmd(t, args...).Run(); err != nil { + t.Fatal(err) + } + defer func() { + clean(t, name, Namespace) + }() + + if tc.template == "cloudevents" { + if !waitFor(t, ksvcUrl(name), withTemplate("cloudevents")) { + t.Fatalf("legacy parliament cloudevents function did not deploy correctly with %s builder", tc.builder) + } + } else { + // The parliament http template echoes the query as JSON. + if !waitFor(t, ksvcUrl(name)+"?foo=bar", withContentMatch(`{"foo": "bar"}`)) { + t.Fatalf("legacy parliament http function did not deploy correctly with %s builder", tc.builder) + } + } + }) + } +} + // TestCore_Delete ensures that a function registered as deleted when deleted. // Also tests list as a side-effect. // diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/.funcignore b/e2e/testdata/legacy-parliament/parliament-cloudevents/.funcignore new file mode 100644 index 0000000000..e8e281c661 --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/.funcignore @@ -0,0 +1,5 @@ + +# Use the .funcignore file to exclude files which should not be +# tracked in the image build. To instruct the system not to track +# files in the image build, add the regex pattern or file information +# to this file. diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/Procfile b/e2e/testdata/legacy-parliament/parliament-cloudevents/Procfile new file mode 100644 index 0000000000..bb57f5a81c --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/Procfile @@ -0,0 +1 @@ +web: python -m parliament . diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/README.md b/e2e/testdata/legacy-parliament/parliament-cloudevents/README.md new file mode 100644 index 0000000000..4338c195cf --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/README.md @@ -0,0 +1,29 @@ +# Python CloudEvents Function + +Welcome to your new Python function project! The boilerplate function +code can be found in [`func.py`](./func.py). This function is meant +to respond to [Cloud Events](https://cloudevents.io/). + +## Endpoints + +Running this function will expose three endpoints. + + * `/` The endpoint for your function. + * `/health/readiness` The endpoint for a readiness health check + * `/health/liveness` The endpoint for a liveness health check + +The health checks can be accessed in your browser at +[http://localhost:8080/health/readiness]() and +[http://localhost:8080/health/liveness](). + +You can use `func invoke` to send an event to the function endpoint. + + +## Testing + +This function project includes a [unit test](./test_func.py). Update this +as you add business logic to your function in order to test its behavior. + +```console +python test_func.py +``` diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/app.sh b/e2e/testdata/legacy-parliament/parliament-cloudevents/app.sh new file mode 100755 index 0000000000..4da37d4d8d --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/app.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exec python -m parliament "$(dirname "$0")" diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/func.py b/e2e/testdata/legacy-parliament/parliament-cloudevents/func.py new file mode 100644 index 0000000000..1f1ee7c6d1 --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/func.py @@ -0,0 +1,16 @@ +from parliament import Context, event + + +@event +def main(context: Context): + """ + Function template + The context parameter contains the Flask request object and any + CloudEvent received with the request. + """ + + # Add your business logic here + + # The return value here will be applied as the data attribute + # of a CloudEvent returned to the function invoker + return context.cloud_event.data diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/func.yaml b/e2e/testdata/legacy-parliament/parliament-cloudevents/func.yaml new file mode 100644 index 0000000000..85f6c0238d --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/func.yaml @@ -0,0 +1,5 @@ +specVersion: 0.36.0 +name: parliament-cloudevents +runtime: python +created: 2026-07-02T14:07:38.971733295+02:00 +invoke: cloudevent diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/requirements.txt b/e2e/testdata/legacy-parliament/parliament-cloudevents/requirements.txt new file mode 100644 index 0000000000..0229b3095e --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/requirements.txt @@ -0,0 +1 @@ +parliament-functions==0.1.0 diff --git a/e2e/testdata/legacy-parliament/parliament-cloudevents/test_func.py b/e2e/testdata/legacy-parliament/parliament-cloudevents/test_func.py new file mode 100644 index 0000000000..d1b752b049 --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-cloudevents/test_func.py @@ -0,0 +1,26 @@ +from cloudevents.http import CloudEvent +from parliament import Context + +import unittest + +func = __import__("func") + +class TestFunc(unittest.TestCase): + + def test_func(self): + # Create a CloudEvent + # - The CloudEvent "id" is generated if omitted. "specversion" defaults to "1.0". + attributes = { + "type": "dev.knative.function", + "source": "https://knative.dev/python.event", + } + data = {"message": "Hello World!"} + event = CloudEvent(attributes, data) + context = Context(req=None) + context.cloud_event = event + + body = func.main(context) + self.assertEqual(body.data, event.data) + +if __name__ == "__main__": + unittest.main() diff --git a/e2e/testdata/legacy-parliament/parliament-http/.funcignore b/e2e/testdata/legacy-parliament/parliament-http/.funcignore new file mode 100644 index 0000000000..e8e281c661 --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/.funcignore @@ -0,0 +1,5 @@ + +# Use the .funcignore file to exclude files which should not be +# tracked in the image build. To instruct the system not to track +# files in the image build, add the regex pattern or file information +# to this file. diff --git a/e2e/testdata/legacy-parliament/parliament-http/Procfile b/e2e/testdata/legacy-parliament/parliament-http/Procfile new file mode 100644 index 0000000000..bb57f5a81c --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/Procfile @@ -0,0 +1 @@ +web: python -m parliament . diff --git a/e2e/testdata/legacy-parliament/parliament-http/README.md b/e2e/testdata/legacy-parliament/parliament-http/README.md new file mode 100644 index 0000000000..5137e241cc --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/README.md @@ -0,0 +1,29 @@ +# Python HTTP Function + +Welcome to your new Python function project! The boilerplate function +code can be found in [`func.py`](./func.py). This function will respond +to incoming HTTP GET and POST requests. + +## Endpoints + +Running this function will expose three endpoints. + + * `/` The endpoint for your function. + * `/health/readiness` The endpoint for a readiness health check + * `/health/liveness` The endpoint for a liveness health check + +The health checks can be accessed in your browser at +[http://localhost:8080/health/readiness]() and +[http://localhost:8080/health/liveness](). + +You can use `func invoke` to send an HTTP request to the function endpoint. + + +## Testing + +This function project includes a [unit test](./test_func.py). Update this +as you add business logic to your function in order to test its behavior. + +```console +python test_func.py +``` diff --git a/e2e/testdata/legacy-parliament/parliament-http/app.sh b/e2e/testdata/legacy-parliament/parliament-http/app.sh new file mode 100755 index 0000000000..4da37d4d8d --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/app.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exec python -m parliament "$(dirname "$0")" diff --git a/e2e/testdata/legacy-parliament/parliament-http/func.py b/e2e/testdata/legacy-parliament/parliament-http/func.py new file mode 100644 index 0000000000..12b64165cc --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/func.py @@ -0,0 +1,63 @@ +from parliament import Context +from flask import Request +import json + + +# parse request body, json data or URL query parameters +def payload_print(req: Request) -> str: + if req.method == "POST": + if req.is_json: + return json.dumps(req.json) + "\n" + else: + # MultiDict needs some iteration + ret = "{" + + for key in req.form.keys(): + ret += '"' + key + '": "'+ req.form[key] + '", ' + + return ret[:-2] + "}\n" if len(ret) > 2 else "{}" + + elif req.method == "GET": + # MultiDict needs some iteration + ret = "{" + + for key in req.args.keys(): + ret += '"' + key + '": "' + req.args[key] + '", ' + + return ret[:-2] + "}\n" if len(ret) > 2 else "{}" + + +# pretty print the request to stdout instantaneously +def pretty_print(req: Request) -> str: + ret = str(req.method) + ' ' + str(req.url) + ' ' + str(req.host) + '\n' + for (header, values) in req.headers: + ret += " " + str(header) + ": " + values + '\n' + + if req.method == "POST": + ret += "Request body:\n" + ret += " " + payload_print(req) + '\n' + + elif req.method == "GET": + ret += "URL Query String:\n" + ret += " " + payload_print(req) + '\n' + + return ret + + +def main(context: Context): + """ + Function template + The context parameter contains the Flask request object and any + CloudEvent received with the request. + """ + + # Add your business logic here + print("Received request") + + if 'request' in context.keys(): + ret = pretty_print(context.request) + print(ret, flush=True) + return payload_print(context.request), 200 + else: + print("Empty request", flush=True) + return "{}", 200 diff --git a/e2e/testdata/legacy-parliament/parliament-http/func.yaml b/e2e/testdata/legacy-parliament/parliament-http/func.yaml new file mode 100644 index 0000000000..d8c44fb5de --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/func.yaml @@ -0,0 +1,4 @@ +specVersion: 0.36.0 +name: parliament-http +runtime: python +created: 2026-07-02T14:07:38.934485565+02:00 diff --git a/e2e/testdata/legacy-parliament/parliament-http/requirements.txt b/e2e/testdata/legacy-parliament/parliament-http/requirements.txt new file mode 100644 index 0000000000..0229b3095e --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/requirements.txt @@ -0,0 +1 @@ +parliament-functions==0.1.0 diff --git a/e2e/testdata/legacy-parliament/parliament-http/test_func.py b/e2e/testdata/legacy-parliament/parliament-http/test_func.py new file mode 100644 index 0000000000..83a6adf1e9 --- /dev/null +++ b/e2e/testdata/legacy-parliament/parliament-http/test_func.py @@ -0,0 +1,13 @@ +import unittest + +func = __import__("func") + +class TestFunc(unittest.TestCase): + + def test_func_empty_request(self): + resp, code = func.main({}) + self.assertEqual(resp, "{}") + self.assertEqual(code, 200) + +if __name__ == "__main__": + unittest.main() diff --git a/pkg/buildpacks/builder.go b/pkg/buildpacks/builder.go index 832a15aaee..83f710bf97 100644 --- a/pkg/buildpacks/builder.go +++ b/pkg/buildpacks/builder.go @@ -218,10 +218,14 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf // only trust our known builders opts.TrustBuilder = TrustBuilder - // Python scaffolding via inline pre-buildpack. - // Injects a script that rearranges user code into fn/ and copies - // scaffolding from .func/build/ to the workspace root. - if f.Runtime == "python" { + // LEGACY PYTHON: old parliament functions build from their own Procfile + + // requirements.txt; point pip at the cloudevents<2 constraint. + if f.Runtime == "python" && f.IsLegacyParliament() { + legacyPackEnv(opts.Env) + } else if f.Runtime == "python" { + // Python scaffolding via inline pre-buildpack. + // Injects a script that rearranges user code into fn/ and copies + // scaffolding from .func/build/ to the workspace root. opts.ProjectDescriptor.Build.Pre = types.GroupAddition{ Buildpacks: []types.Buildpack{ { diff --git a/pkg/buildpacks/legacy_parliament.go b/pkg/buildpacks/legacy_parliament.go new file mode 100644 index 0000000000..058a415f97 --- /dev/null +++ b/pkg/buildpacks/legacy_parliament.go @@ -0,0 +1,74 @@ +package buildpacks + +// LEGACY PYTHON: pack support for old parliament functions — skip scaffolding and +// build from the user's Procfile + requirements.txt with cloudevents pinned <2. +// Delete this file on parliament sunset. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + fn "knative.dev/func/pkg/functions" +) + +const ( + // pip constraints file at the build root, referenced via PIP_CONSTRAINT. + legacyConstraintsFile = "constraints.txt" + legacyCloudeventsPin = "cloudevents<2" +) + +const legacyDeprecationWarning = "Warning: this function uses the legacy parliament Python signature (def main(context)), " + + "which is deprecated and will be removed in a future release.\n" + + "func builds it the old way, from your Procfile and requirements.txt, pinning cloudevents below 2.0 (required by parliament).\n" + + "Migrate to the current Python function layout, documented at https://github.com/knative/func/blob/main/docs/function-templates/python.md" + +// legacyScaffold replaces pack scaffolding: warn, clear stale scaffolding, and +// write the cloudevents<2 constraint (leaving the user's Procfile/requirements). +func legacyScaffold(verbose bool, f fn.Function) error { + fmt.Fprintln(os.Stderr, legacyDeprecationWarning) + if verbose { + fmt.Printf("Legacy parliament function detected; skipping python scaffolding for '%v'\n", f.Root) + } + // clear stale scaffolding from a prior non-legacy build + if err := os.RemoveAll(filepath.Join(f.Root, defaultPath)); err != nil { + return fmt.Errorf("cannot clean stale scaffolding directory: %w", err) + } + if err := writeCloudeventsConstraint(f.Root); err != nil { + return fmt.Errorf("unable to write legacy parliament constraints file: %w", err) + } + return nil +} + +// cloudeventsConstrainedRe matches a (non-comment) cloudevents requirement line +// carrying a version specifier. A bare "cloudevents" line or a comment merely +// mentioning it does not constrain anything. +var cloudeventsConstrainedRe = regexp.MustCompile(`(?im)^[ \t]*cloudevents[ \t]*(\[[^\]]*\])?[ \t]*[<>=!~]`) + +// writeCloudeventsConstraint appends cloudevents<2 to constraints.txt at root, +// preserving any existing user constraints and skipping only when the user +// already carries a cloudevents version constraint. +// Must live at root (not .func/) — the remote PVC upload excludes .func/. +func writeCloudeventsConstraint(root string) error { + path := filepath.Join(root, legacyConstraintsFile) + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if cloudeventsConstrainedRe.Match(existing) { + return nil + } + content := string(existing) + if len(content) > 0 && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += legacyCloudeventsPin + "\n" + return os.WriteFile(path, []byte(content), 0644) +} + +// legacyPackEnv points pip at the constraints file. +func legacyPackEnv(env map[string]string) { + env["PIP_CONSTRAINT"] = legacyConstraintsFile +} diff --git a/pkg/buildpacks/legacy_parliament_test.go b/pkg/buildpacks/legacy_parliament_test.go new file mode 100644 index 0000000000..eca3650ec0 --- /dev/null +++ b/pkg/buildpacks/legacy_parliament_test.go @@ -0,0 +1,161 @@ +package buildpacks + +// LEGACY PYTHON: pack legacy-scaffold tests. + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + fn "knative.dev/func/pkg/functions" +) + +// TestScaffoldRejectsUnsupportedLegacyPython verifies a pre-v1.18 non-parliament +// Procfile layout (old flask/wsgi templates) is rejected at the scaffold seam. +func TestScaffoldRejectsUnsupportedLegacyPython(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "Procfile"), "web: gunicorn func:main --bind=0.0.0.0:8080\n") + mustWrite(t, filepath.Join(root, "func.py"), "def main(environ, start_response):\n ...\n") + f := fn.Function{Root: root, Runtime: "python"} + + err := NewScaffolder(false).Scaffold(context.Background(), f, "") + if !errors.Is(err, fn.ErrUnsupportedLegacyPython) { + t.Fatalf("expected ErrUnsupportedLegacyPython, got %v", err) + } +} + +// TestLegacyScaffold verifies the pack legacy path: it writes a cloudevents<2 +// constraints file at the function root, clears any stale modern scaffolding, +// and leaves the user's own files untouched. +func TestLegacyScaffold(t *testing.T) { + root := t.TempDir() + // user's own files (must survive) + mustWrite(t, filepath.Join(root, "Procfile"), "web: python -m parliament .\n") + mustWrite(t, filepath.Join(root, "requirements.txt"), "parliament-functions==0.1.0\n") + // stale modern scaffolding from a prior non-legacy build (must be cleared) + stale := filepath.Join(root, fn.RunDataDir, fn.BuildDir, "service") + if err := os.MkdirAll(stale, 0755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(stale, "main.py"), "# stale\n") + + f := fn.Function{Root: root, Runtime: "python"} + if err := legacyScaffold(false, f); err != nil { + t.Fatalf("legacyScaffold: %v", err) + } + + // constraints.txt written with the cloudevents pin + got, err := os.ReadFile(filepath.Join(root, legacyConstraintsFile)) + if err != nil { + t.Fatalf("constraints file not written: %v", err) + } + if !strings.Contains(string(got), legacyCloudeventsPin) { + t.Errorf("constraints.txt = %q, want it to contain %q", got, legacyCloudeventsPin) + } + // stale scaffolding cleared + if _, err := os.Stat(filepath.Join(root, fn.RunDataDir, fn.BuildDir)); !os.IsNotExist(err) { + t.Errorf("stale .func/build was not removed (err=%v)", err) + } + // user files untouched + for _, name := range []string{"Procfile", "requirements.txt"} { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Errorf("user file %q was disturbed: %v", name, err) + } + } +} + +// TestLegacyPackEnv verifies the pip constraint env is injected for pack builds. +func TestLegacyPackEnv(t *testing.T) { + env := map[string]string{} + legacyPackEnv(env) + if env["PIP_CONSTRAINT"] != legacyConstraintsFile { + t.Errorf("PIP_CONSTRAINT = %q, want %q", env["PIP_CONSTRAINT"], legacyConstraintsFile) + } +} + +// TestWriteCloudeventsConstraint covers the clobber guard: a pre-existing user +// constraints.txt must be preserved, an existing cloudevents constraint left +// untouched (idempotent on rebuild), and the pin appended otherwise. +func TestWriteCloudeventsConstraint(t *testing.T) { + read := func(t *testing.T, root string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, legacyConstraintsFile)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + + t.Run("absent: writes the pin", func(t *testing.T) { + root := t.TempDir() + if err := writeCloudeventsConstraint(root); err != nil { + t.Fatal(err) + } + if got := read(t, root); !strings.Contains(got, legacyCloudeventsPin) { + t.Errorf("got %q, want it to contain %q", got, legacyCloudeventsPin) + } + }) + + t.Run("preserves user constraints and appends the pin", func(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, legacyConstraintsFile), "requests==2.31.0") + if err := writeCloudeventsConstraint(root); err != nil { + t.Fatal(err) + } + got := read(t, root) + if !strings.Contains(got, "requests==2.31.0") { + t.Errorf("clobbered user constraint: %q", got) + } + if !strings.Contains(got, legacyCloudeventsPin) { + t.Errorf("pin not appended: %q", got) + } + }) + + t.Run("no-op when cloudevents already constrained (idempotent)", func(t *testing.T) { + root := t.TempDir() + original := "cloudevents==1.11.0\n" + mustWrite(t, filepath.Join(root, legacyConstraintsFile), original) + if err := writeCloudeventsConstraint(root); err != nil { + t.Fatal(err) + } + if got := read(t, root); got != original { + t.Errorf("user cloudevents constraint disturbed: got %q, want %q", got, original) + } + }) + + t.Run("appends the pin when cloudevents is mentioned but unconstrained", func(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, legacyConstraintsFile), "# constraints for the cloudevents stack\ncloudevents\n") + if err := writeCloudeventsConstraint(root); err != nil { + t.Fatal(err) + } + if got := read(t, root); !strings.Contains(got, legacyCloudeventsPin) { + t.Errorf("pin not appended over an unconstrained mention: %q", got) + } + }) + + t.Run("no-op on a case-insensitive constrained entry", func(t *testing.T) { + root := t.TempDir() + original := "CloudEvents<1.12\n" + mustWrite(t, filepath.Join(root, legacyConstraintsFile), original) + if err := writeCloudeventsConstraint(root); err != nil { + t.Fatal(err) + } + if got := read(t, root); got != original { + t.Errorf("case-insensitive existing constraint disturbed: got %q", got) + } + }) +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/buildpacks/scaffolder.go b/pkg/buildpacks/scaffolder.go index a7422d6513..a18abd6758 100644 --- a/pkg/buildpacks/scaffolder.go +++ b/pkg/buildpacks/scaffolder.go @@ -35,6 +35,16 @@ func (s Scaffolder) Scaffold(ctx context.Context, f fn.Function, path string) er return nil } + // LEGACY PYTHON: old parliament functions skip modern scaffolding. + if f.Runtime == "python" && f.IsLegacyParliament() { + return legacyScaffold(s.verbose, f) + } + // LEGACY PYTHON: other pre-v1.18 Procfile-based layouts (old flask/wsgi + // templates) are rejected. + if f.Runtime == "python" && f.IsUnsupportedLegacyPython() { + return fn.ErrUnsupportedLegacyPython + } + appRoot := path if appRoot == "" { appRoot = filepath.Join(f.Root, defaultPath) diff --git a/pkg/functions/legacy_parliament.go b/pkg/functions/legacy_parliament.go new file mode 100644 index 0000000000..348fb31b17 --- /dev/null +++ b/pkg/functions/legacy_parliament.go @@ -0,0 +1,79 @@ +package functions + +// LEGACY PYTHON: detects old parliament functions (func.py with def main(context), +// or a "python -m parliament" Procfile), and rejects the other pre-v1.18 +// Procfile-based python layouts (old flask/wsgi templates). Delete this file on +// parliament sunset. + +import ( + "errors" + "os" + "path/filepath" + "regexp" +) + +var ( + // Procfile line launching parliament (python / python3 / python3.x). + procfileParliamentRe = regexp.MustCompile(`python[0-9.]*\s+-m\s+parliament\b`) + // parliament import at line start (skips "parliamentarian" and comments). + pyImportParliamentRe = regexp.MustCompile(`(?m)^[ \t]*(from|import)[ \t]+parliament\b`) +) + +// IsLegacyParliament reports whether f.Root holds an old parliament function: +// a "python -m parliament" Procfile, or a top-level *.py importing parliament. +func (f Function) IsLegacyParliament() bool { + if f.Runtime != "python" { + return false + } + if procfileInvokesParliament(filepath.Join(f.Root, "Procfile")) { + return true + } + return pyImportsParliament(f.Root) +} + +// procfileInvokesParliament reports whether the Procfile at path launches parliament. +func procfileInvokesParliament(path string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + return procfileParliamentRe.Match(b) +} + +// pyImportsParliament reports whether any top-level *.py at root imports parliament. +func pyImportsParliament(root string) bool { + matches, err := filepath.Glob(filepath.Join(root, "*.py")) + if err != nil { + return false + } + for _, p := range matches { + b, err := os.ReadFile(p) + if err != nil { + continue + } + if pyImportParliamentRe.Match(b) { + return true + } + } + return false +} + +// ErrUnsupportedLegacyPython rejects pre-v1.18 Procfile-based python layouts other +// than parliament, which would otherwise fail confusingly inside modern scaffolding. +var ErrUnsupportedLegacyPython = errors.New("this function uses a legacy Procfile-based Python layout " + + "(pre-v1.18, e.g. the old flask or wsgi templates), which is not supported. " + + "Migrate to the current Python function layout, documented at https://github.com/knative/func/blob/main/docs/function-templates/python.md") + +// IsUnsupportedLegacyPython reports whether f.Root holds a pre-v1.18 Procfile-based +// python function other than parliament: a root Procfile with no root pyproject.toml. +// Parliament functions (IsLegacyParliament) are the supported, deprecated exception. +func (f Function) IsUnsupportedLegacyPython() bool { + if f.Runtime != "python" || f.IsLegacyParliament() { + return false + } + if _, err := os.Stat(filepath.Join(f.Root, "Procfile")); err != nil { + return false + } + _, err := os.Stat(filepath.Join(f.Root, "pyproject.toml")) + return os.IsNotExist(err) +} diff --git a/pkg/functions/legacy_parliament_test.go b/pkg/functions/legacy_parliament_test.go new file mode 100644 index 0000000000..078b630209 --- /dev/null +++ b/pkg/functions/legacy_parliament_test.go @@ -0,0 +1,227 @@ +package functions_test + +// LEGACY PYTHON: parliament detection tests. + +import ( + "os" + "path/filepath" + "testing" + + fn "knative.dev/func/pkg/functions" +) + +// writeFile writes content to root/name, creating parent dirs, failing the test +// on error. +func writeFile(t *testing.T, root, name, content string) { + t.Helper() + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +// TestIsLegacyParliament covers detection of old parliament-era python +// functions and the false-positive guards (a modern function and a WSGI +// `def main(environ, start_response)` must NOT be detected). +func TestIsLegacyParliament(t *testing.T) { + tests := []struct { + name string + runtime string + files map[string]string // relative path -> content + want bool + }{ + { + name: "legacy http via Procfile", + runtime: "python", + files: map[string]string{ + "Procfile": "web: python -m parliament .\n", + "requirements.txt": "parliament-functions==0.1.0\n", + "func.py": "def main(context):\n return \"OK\", 200\n", + }, + want: true, + }, + { + name: "legacy via 'from parliament import' (no Procfile)", + runtime: "python", + files: map[string]string{ + "func.py": "from parliament import Context\n\ndef main(context: Context):\n return \"OK\", 200\n", + }, + want: true, + }, + { + name: "legacy cloudevents (Procfile + @event)", + runtime: "python", + files: map[string]string{ + "Procfile": "web: python -m parliament .\n", + "func.py": "from parliament import Context, event\n\n@event\ndef main(context):\n return context.cloud_event.data\n", + }, + want: true, + }, + { + name: "modern function (new layout, no parliament)", + runtime: "python", + files: map[string]string{ + "pyproject.toml": "[project]\nname = \"f\"\ndependencies = [\"func-python\"]\n", + "function/__init__.py": "from .func import new\n", + "function/func.py": "class new:\n async def handle(self, scope, receive, send):\n ...\n", + }, + want: false, + }, + { + name: "false-positive guard: WSGI def main(environ, start_response)", + runtime: "python", + files: map[string]string{ + "func.py": "def main(environ, start_response):\n start_response('200 OK', [])\n return [b'hi']\n", + }, + want: false, + }, + { + name: "non-python runtime is never legacy", + runtime: "go", + files: map[string]string{ + "Procfile": "web: python -m parliament .\n", + }, + want: false, + }, + { + name: "Procfile with python3 and submodule import", + runtime: "python", + files: map[string]string{ + "Procfile": "web: python3 -m parliament .\n", + "func.py": "from parliament.invocation import Context\n\ndef main(context):\n return \"OK\", 200\n", + }, + want: true, + }, + { + name: "import parliament as alias", + runtime: "python", + files: map[string]string{ + "func.py": "import parliament as p\n\ndef main(context):\n return \"OK\", 200\n", + }, + want: true, + }, + { + name: "false-positive guard: import parliamentarian (different package)", + runtime: "python", + files: map[string]string{ + "func.py": "import parliamentarian\n\ndef main(context):\n return parliamentarian.run()\n", + }, + want: false, + }, + { + name: "false-positive guard: commented-out parliament import", + runtime: "python", + files: map[string]string{ + "func.py": "# from parliament import Context -- migrated away\nasync def handle(scope, receive, send):\n ...\n", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + for name, content := range tt.files { + writeFile(t, root, name, content) + } + f := fn.Function{Root: root, Runtime: tt.runtime} + if got := f.IsLegacyParliament(); got != tt.want { + t.Errorf("IsLegacyParliament() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestIsUnsupportedLegacyPython covers detection of the pre-v1.18 Procfile-based +// layouts other than parliament (old flask/wsgi templates): a root Procfile with +// no root pyproject.toml. Parliament is the supported exception (false), and the +// modern layout must never match. +func TestIsUnsupportedLegacyPython(t *testing.T) { + tests := []struct { + name string + runtime string + files map[string]string // relative path -> content + want bool + }{ + { + name: "old flask template layout (gunicorn Procfile)", + runtime: "python", + files: map[string]string{ + "Procfile": "web: gunicorn func:application --bind=0.0.0.0:8080 --access-logfile=-\n", + "requirements.txt": "gunicorn==22.0.0\nFlask==2.2.5\n", + "func.py": "from flask import Flask\napplication = Flask(__name__)\n", + }, + want: true, + }, + { + name: "old wsgi template layout (gunicorn Procfile)", + runtime: "python", + files: map[string]string{ + "Procfile": "web: gunicorn func:main --bind=0.0.0.0:8080 --access-logfile=-\n", + "requirements.txt": "gunicorn==22.0.0\n", + "func.py": "def main(environ, start_response):\n start_response('200 OK', [])\n return [b'hi']\n", + }, + want: true, + }, + { + name: "parliament function is the supported exception", + runtime: "python", + files: map[string]string{ + "Procfile": "web: python -m parliament .\n", + "requirements.txt": "parliament-functions==0.1.0\n", + "func.py": "def main(context):\n return \"OK\", 200\n", + }, + want: false, + }, + { + name: "modern function (pyproject.toml, no Procfile)", + runtime: "python", + files: map[string]string{ + "pyproject.toml": "[project]\nname = \"f\"\n", + "function/__init__.py": "from .func import new\n", + }, + want: false, + }, + { + name: "modern function with a stray Procfile (pyproject.toml wins)", + runtime: "python", + files: map[string]string{ + "pyproject.toml": "[project]\nname = \"f\"\n", + "Procfile": "web: gunicorn func:main\n", + }, + want: false, + }, + { + name: "no Procfile is not a legacy layout", + runtime: "python", + files: map[string]string{ + "func.py": "def main(environ, start_response):\n ...\n", + }, + want: false, + }, + { + name: "non-python runtime never matches", + runtime: "go", + files: map[string]string{ + "Procfile": "web: gunicorn func:main\n", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + for name, content := range tt.files { + writeFile(t, root, name, content) + } + f := fn.Function{Root: root, Runtime: tt.runtime} + if got := f.IsUnsupportedLegacyPython(); got != tt.want { + t.Errorf("IsUnsupportedLegacyPython() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/oci/builder.go b/pkg/oci/builder.go index 19f2486417..e015c15053 100644 --- a/pkg/oci/builder.go +++ b/pkg/oci/builder.go @@ -86,6 +86,14 @@ func NewBuilder(name string, verbose bool) *Builder { // // Platforms are optional and default to fn.DefaultPlatforms. func (b *Builder) Build(ctx context.Context, f fn.Function, pp []fn.Platform) (err error) { + // LEGACY PYTHON: host builder can't build old parliament functions — reject. + if f.IsLegacyParliament() { + return ErrLegacyParliamentHost + } + // LEGACY PYTHON: other pre-v1.18 Procfile-based python layouts are rejected too. + if f.IsUnsupportedLegacyPython() { + return fn.ErrUnsupportedLegacyPython + } if len(pp) == 0 { pp = fn.DefaultPlatforms // Use Default platforms if not provided } diff --git a/pkg/oci/legacy_parliament.go b/pkg/oci/legacy_parliament.go new file mode 100644 index 0000000000..0fde2b6931 --- /dev/null +++ b/pkg/oci/legacy_parliament.go @@ -0,0 +1,9 @@ +package oci + +// LEGACY PYTHON: the host builder can't build the old parliament layout, so it is +// rejected. Delete this file on parliament sunset. + +import "errors" + +// ErrLegacyParliamentHost rejects a parliament function on the host builder. +var ErrLegacyParliamentHost = errors.New("the host builder cannot build legacy parliament Python functions (def main(context)); use --builder=pack or --builder=s2i") diff --git a/pkg/oci/legacy_parliament_test.go b/pkg/oci/legacy_parliament_test.go new file mode 100644 index 0000000000..902a0220b4 --- /dev/null +++ b/pkg/oci/legacy_parliament_test.go @@ -0,0 +1,60 @@ +package oci + +// LEGACY PYTHON: host-builder rejection tests. + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + fn "knative.dev/func/pkg/functions" +) + +// TestLegacyParliamentHostRejected verifies the host builder refuses a legacy +// parliament function up front (rather than attempting to build a broken image). +func TestLegacyParliamentHostRejected(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "Procfile"), []byte("web: python -m parliament .\n"), 0644); err != nil { + t.Fatal(err) + } + f := fn.Function{Root: root, Runtime: "python"} + + err := NewBuilder("host", false).Build(context.Background(), f, nil) + if !errors.Is(err, ErrLegacyParliamentHost) { + t.Fatalf("expected ErrLegacyParliamentHost, got %v", err) + } +} + +// TestNonLegacyPythonNotRejected guards against the host rejection firing on a +// modern python function (which the host builder does support). +func TestNonLegacyPythonNotRejected(t *testing.T) { + root := t.TempDir() + // modern layout: no parliament Procfile / import + if err := os.WriteFile(filepath.Join(root, "pyproject.toml"), []byte("[project]\nname = \"f\"\n"), 0644); err != nil { + t.Fatal(err) + } + f := fn.Function{Root: root, Runtime: "python"} + + err := NewBuilder("host", false).Build(context.Background(), f, nil) + if errors.Is(err, ErrLegacyParliamentHost) || errors.Is(err, fn.ErrUnsupportedLegacyPython) { + t.Fatal("modern python function must not be rejected as legacy") + } +} + +// TestUnsupportedLegacyPythonHostRejected verifies the host builder also refuses +// the pre-v1.18 non-parliament Procfile layouts (old flask/wsgi templates), with +// the migration error. +func TestUnsupportedLegacyPythonHostRejected(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "Procfile"), []byte("web: gunicorn func:main --bind=0.0.0.0:8080\n"), 0644); err != nil { + t.Fatal(err) + } + f := fn.Function{Root: root, Runtime: "python"} + + err := NewBuilder("host", false).Build(context.Background(), f, nil) + if !errors.Is(err, fn.ErrUnsupportedLegacyPython) { + t.Fatalf("expected ErrUnsupportedLegacyPython, got %v", err) + } +} diff --git a/pkg/pipelines/tekton/legacy_parliament_test.go b/pkg/pipelines/tekton/legacy_parliament_test.go new file mode 100644 index 0000000000..64f2ad924d --- /dev/null +++ b/pkg/pipelines/tekton/legacy_parliament_test.go @@ -0,0 +1,23 @@ +package tekton + +// LEGACY PYTHON: asserts the remote buildpack task carries the PIP_CONSTRAINT block. + +import ( + "strings" + "testing" +) + +// TestLegacyParliamentBuildpackTask asserts the rendered buildpack task carries +// the PIP_CONSTRAINT wiring (the remote path has no opts.Env to fall back on). +func TestLegacyParliamentBuildpackTask(t *testing.T) { + task := getBuildpackTask() + for _, want := range []string{ + "LEGACY PYTHON begin", + `"${ENV_DIR}/PIP_CONSTRAINT"`, + "constraints.txt", + } { + if !strings.Contains(task, want) { + t.Errorf("rendered buildpack task is missing %q", want) + } + } +} diff --git a/pkg/pipelines/tekton/task-buildpack.yaml.tmpl b/pkg/pipelines/tekton/task-buildpack.yaml.tmpl index 2fafd465f4..c60e2c06df 100644 --- a/pkg/pipelines/tekton/task-buildpack.yaml.tmpl +++ b/pkg/pipelines/tekton/task-buildpack.yaml.tmpl @@ -205,6 +205,14 @@ spec: echo "===> Python scaffolding complete" fi + # LEGACY PYTHON begin: the scaffolder writes constraints.txt (cloudevents<2) + # for old parliament functions; point the remote pip-install at it. + if [ -f "$src_path/constraints.txt" ]; then + echo "--> Legacy parliament function detected, setting PIP_CONSTRAINT" + echo -n "constraints.txt" > "${ENV_DIR}/PIP_CONSTRAINT" + fi + # LEGACY PYTHON end + ############################################ volumeMounts: - name: layers-dir diff --git a/pkg/s2i/builder.go b/pkg/s2i/builder.go index 3e98ef476c..d62db6e61a 100644 --- a/pkg/s2i/builder.go +++ b/pkg/s2i/builder.go @@ -101,6 +101,7 @@ func NewBuilder(options ...Option) *Builder { func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platform) (err error) { // Builder image from the function if defined, default otherwise. + // (LEGACY PYTHON image pin is applied inside BuilderImage.) builderImage, err := BuilderImage(f, b.name) if err != nil { return @@ -274,5 +275,10 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf // Builder Image chooses the correct builder image or defaults. func BuilderImage(f fn.Function, builderName string) (string, error) { // delegate as the logic is shared amongst builders - return builders.Image(f, builderName, DefaultBuilderImages) + img, err := builders.Image(f, builderName, DefaultBuilderImages) + if err != nil { + return img, err + } + // LEGACY PYTHON: pin the s2i image (here, so local + remote pipeline-gen share it). + return legacyImageOverride(f, img), nil } diff --git a/pkg/s2i/legacy_parliament.go b/pkg/s2i/legacy_parliament.go new file mode 100644 index 0000000000..a3f86d1c6a --- /dev/null +++ b/pkg/s2i/legacy_parliament.go @@ -0,0 +1,80 @@ +package s2i + +// LEGACY PYTHON: s2i support for old parliament functions — skip scaffolding and +// write an assemble that installs requirements.txt with cloudevents pinned <2. +// The entrypoint stays the function's own app.sh (shipped by the 1.17 python +// templates), exactly as on 1.17. Delete this file on parliament sunset. + +import ( + "fmt" + "os" + "path/filepath" + + fn "knative.dev/func/pkg/functions" +) + +// parliament is a python-3.9-era stack; pin the s2i image to match. +const legacyPythonBuilder = "registry.access.redhat.com/ubi8/python-39" + +const legacyDeprecationWarning = "Warning: this function uses the legacy parliament Python signature (def main(context)), " + + "which is deprecated and will be removed in a future release.\n" + + "func builds it the old way, from your Procfile and requirements.txt, pinning cloudevents below 2.0 (required by parliament).\n" + + "Migrate to the current Python function layout, documented at https://github.com/knative/func/blob/main/docs/function-templates/python.md" + +// legacyImageOverride pins ubi8/python-39, unless the user set an explicit image. +func legacyImageOverride(f fn.Function, current string) string { + if f.IsLegacyParliament() && current == DefaultPythonBuilder { + return legacyPythonBuilder + } + return current +} + +// writeLegacyAssemble replaces s2i scaffolding: warn, clear appRoot, and write +// the legacy assemble to appRoot/bin/assemble (where cfg.ScriptsURL points). +func writeLegacyAssemble(verbose bool, f fn.Function, appRoot string) error { + fmt.Fprintln(os.Stderr, legacyDeprecationWarning) + if verbose { + fmt.Printf("Legacy parliament function detected; writing legacy s2i assemble for '%v'\n", f.Root) + } + if err := os.RemoveAll(appRoot); err != nil { + return fmt.Errorf("cannot clean scaffolding directory: %w", err) + } + binDir := filepath.Join(appRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + return fmt.Errorf("unable to create legacy scaffolding bin dir: %w", err) + } + if err := os.WriteFile(filepath.Join(binDir, "assemble"), []byte(LegacyParliamentAssembler), 0700); err != nil { + return fmt.Errorf("unable to write legacy assembler script: %w", err) + } + return nil +} + +// LegacyParliamentAssembler mimics the stock ubi8 python assemble: install the +// source + requirements at $HOME, with cloudevents pinned <2. No entrypoint is +// generated — the stock run script execs the function's own app.sh, as on 1.17. +const LegacyParliamentAssembler = `#!/bin/bash +set -e + +shopt -s dotglob +echo "---> (Functions/parliament) Installing application source ..." +mv /tmp/src/* "$HOME" +cd "$HOME" + +# set permissions for any installed artifacts +fix-permissions /opt/app-root -P + +if [[ ! -f requirements.txt ]]; then + echo "ERROR: parliament function is missing requirements.txt (expected parliament-functions)" >&2 + exit 1 +fi +echo "---> Installing dependencies ..." +pip install -r requirements.txt + +# parliament-functions==0.1.0 leaves cloudevents unpinned, which resolves to +# 2.x and breaks 'import cloudevents.http'. Pin it back to the 1.x line. +echo "---> (Functions/parliament) Pinning cloudevents below 2.x ..." +pip install 'cloudevents<2' + +# set permissions for any installed artifacts +fix-permissions /opt/app-root -P +` diff --git a/pkg/s2i/legacy_parliament_test.go b/pkg/s2i/legacy_parliament_test.go new file mode 100644 index 0000000000..f4f9939ac5 --- /dev/null +++ b/pkg/s2i/legacy_parliament_test.go @@ -0,0 +1,114 @@ +package s2i + +// LEGACY PYTHON: s2i legacy-assemble tests. + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + fn "knative.dev/func/pkg/functions" +) + +// TestScaffoldRejectsUnsupportedLegacyPython verifies a pre-v1.18 non-parliament +// Procfile layout (old flask/wsgi templates) is rejected at the scaffold seam. +func TestScaffoldRejectsUnsupportedLegacyPython(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "Procfile"), []byte("web: gunicorn func:application --bind=0.0.0.0:8080\n"), 0644); err != nil { + t.Fatal(err) + } + f := fn.Function{Root: root, Runtime: "python"} + + err := NewScaffolder(false).Scaffold(context.Background(), f, "") + if !errors.Is(err, fn.ErrUnsupportedLegacyPython) { + t.Fatalf("expected ErrUnsupportedLegacyPython, got %v", err) + } +} + +// TestLegacyParliamentAssembler checks the assemble is valid bash, pins +// cloudevents<2 after the requirements install, and fails fast without one. +func TestLegacyParliamentAssembler(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash not available") + } + + // bash -n: syntax check without executing. + cmd := exec.Command(bash, "-n") + cmd.Stdin = strings.NewReader(LegacyParliamentAssembler) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("assemble script is not valid bash: %v\n%s", err, out) + } + + // the cloudevents<2 pin must run after the requirements install + reqIdx := strings.Index(LegacyParliamentAssembler, "pip install -r requirements.txt") + pinIdx := strings.Index(LegacyParliamentAssembler, "cloudevents<2") + if reqIdx < 0 || pinIdx < 0 { + t.Fatalf("assembler missing requirements install (%d) or cloudevents pin (%d)", reqIdx, pinIdx) + } + if pinIdx < reqIdx { + t.Error("cloudevents<2 pin must come after the requirements install") + } + + if !strings.Contains(LegacyParliamentAssembler, "missing requirements.txt") { + t.Error("assembler must fail fast when requirements.txt is absent") + } +} + +// TestWriteLegacyAssemble checks the assemble lands at .func/build/bin/assemble +// (executable), not the root .s2i/bin that WarnIfLegacyS2IScaffolding flags. +func TestWriteLegacyAssemble(t *testing.T) { + root := t.TempDir() + appRoot := filepath.Join(root, defaultPath) + f := fn.Function{Root: root, Runtime: "python"} + + if err := writeLegacyAssemble(false, f, appRoot); err != nil { + t.Fatalf("writeLegacyAssemble: %v", err) + } + + assemble := filepath.Join(appRoot, "bin", "assemble") + info, err := os.Stat(assemble) + if err != nil { + t.Fatalf("assemble not written at %s: %v", assemble, err) + } + // Windows has no unix permission bits; the 0700 matters where the script + // actually executes (the linux build container). + if runtime.GOOS != "windows" && info.Mode().Perm()&0100 == 0 { + t.Errorf("assemble is not executable: mode %v", info.Mode()) + } + if _, err := os.Stat(filepath.Join(root, ".s2i", "bin", "assemble")); !os.IsNotExist(err) { + t.Errorf("legacy assemble must not be written to root .s2i/bin (would trip WarnIfLegacyS2IScaffolding)") + } +} + +// TestLegacyImageOverride pins python-39 only when the resolved image is still +// the default; an explicit user override is respected. +func TestLegacyImageOverride(t *testing.T) { + legacy := fn.Function{Root: t.TempDir(), Runtime: "python"} + mustWriteFile(t, filepath.Join(legacy.Root, "Procfile"), "web: python -m parliament .\n") + + if got := legacyImageOverride(legacy, DefaultPythonBuilder); got != legacyPythonBuilder { + t.Errorf("default image not pinned: got %q, want %q", got, legacyPythonBuilder) + } + const custom = "registry.example.com/my/python:custom" + if got := legacyImageOverride(legacy, custom); got != custom { + t.Errorf("user override not respected: got %q, want %q", got, custom) + } + // non-legacy function is never pinned + modern := fn.Function{Root: t.TempDir(), Runtime: "python"} + if got := legacyImageOverride(modern, DefaultPythonBuilder); got != DefaultPythonBuilder { + t.Errorf("modern function should keep default: got %q", got) + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/s2i/scaffolder.go b/pkg/s2i/scaffolder.go index e1a313c58e..31375b3d2d 100644 --- a/pkg/s2i/scaffolder.go +++ b/pkg/s2i/scaffolder.go @@ -34,6 +34,17 @@ func (s Scaffolder) Scaffold(ctx context.Context, f fn.Function, path string) er if appRoot == "" { appRoot = filepath.Join(f.Root, defaultPath) } + + // LEGACY PYTHON: old parliament functions get the legacy assemble, not scaffolding. + if f.IsLegacyParliament() { + return writeLegacyAssemble(s.verbose, f, appRoot) + } + // LEGACY PYTHON: other pre-v1.18 Procfile-based layouts (old flask/wsgi + // templates) are rejected. + if f.IsUnsupportedLegacyPython() { + return fn.ErrUnsupportedLegacyPython + } + if s.verbose { fmt.Printf("Writing s2i scaffolding at path '%v'\n", appRoot) }