Skip to content

Commit 859038d

Browse files
committed
python buildpack update
1 parent a42c16f commit 859038d

File tree

12 files changed

+435
-199
lines changed

12 files changed

+435
-199
lines changed

docs/building-functions/on_cluster_build.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ kubectl apply -f https://storage.googleapis.com/tekton-releases/pipeline/previou
1414

1515
## Enabling a namespace to run Function related Tekton Pipelines
1616

17-
Set up RBAC permissions for the `default` Service Account to deploy Functions: (This is not needed on OpenShift).
18-
Depending on the to be used deployers, different permissions are required:
17+
Set up RBAC permissions for the `default` Service Account to deploy Functions: (This is not needed on OpenShift).
18+
Depending on the to be used deployers, different permissions are required:
1919

2020
### Option A: Permissions for all deployers (knative, raw and Keda)
2121

@@ -33,11 +33,11 @@ kubectl create rolebinding func-deployer-binding \
3333
--role=func-deployer \
3434
--serviceaccount=$NAMESPACE:default \
3535
--namespace=$NAMESPACE
36-
36+
3737
kubectl create clusterrolebinding $NAMESPACE:knative-eventing-namespaced-admin \
3838
--clusterrole=knative-eventing-namespaced-admin \
3939
--serviceaccount=$NAMESPACE:default
40-
40+
4141
kubectl create clusterrolebinding $NAMESPACE:knative-serving-namespaced-admin \
4242
--clusterrole=knative-serving-namespaced-admin \
4343
--serviceaccount=$NAMESPACE:default

e2e/e2e_matrix_test.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,6 @@ func matrixExceptionsShared(t *testing.T, initArgs []string, funcRuntime, builde
217217

218218
// Python Special Treatment
219219
// --------------------------
220-
// Skip Pack builder (not supported)
221-
if funcRuntime == "python" && builder == "pack" {
222-
t.Skip("The pack builder does not currently support Python Functions")
223-
}
224220

225221
// Echo Implementation
226222
// Replace the simple "OK" implementation with an echo.

e2e/e2e_userdeps_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//go:build e2e
2+
// +build e2e
3+
4+
package e2e
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
"time"
12+
)
13+
14+
// TestPython_UserDeps_Run verifies that user code and its local dependencies
15+
// survive the scaffolding process during a local pack build. The test template
16+
// includes a local mylib package inside function/ that func.py imports;
17+
// if scaffolding misplaces user files, the import fails at runtime.
18+
func TestPython_UserDeps_Run(t *testing.T) {
19+
name := "func-e2e-python-userdeps-run"
20+
_ = fromCleanEnv(t, name)
21+
t.Cleanup(func() { cleanImages(t, name) })
22+
23+
// Init with testdata Python HTTP template (includes function/mylib/)
24+
initArgs := []string{"init", "-l", "python", "-t", "http",
25+
"--repository", "file://" + filepath.Join(Testdata, "templates-userdeps")}
26+
if err := newCmd(t, initArgs...).Run(); err != nil {
27+
t.Fatalf("init failed: %v", err)
28+
}
29+
30+
// Run with pack builder
31+
cmd := newCmd(t, "run", "--builder", "pack", "--json")
32+
address := parseRunJSON(t, cmd)
33+
34+
if !waitFor(t, address,
35+
withWaitTimeout(6*time.Minute),
36+
withContentMatch("hello from mylib")) {
37+
t.Fatal("function did not return mylib greeting — user code not preserved")
38+
}
39+
40+
if err := cmd.Process.Signal(os.Interrupt); err != nil {
41+
fmt.Fprintf(os.Stderr, "error interrupting: %v", err)
42+
}
43+
}
44+
45+
// TestPython_UserDeps_Remote verifies that user code and its local
46+
// dependencies survive a remote (Tekton) build.
47+
func TestPython_UserDeps_Remote(t *testing.T) {
48+
name := "func-e2e-python-userdeps-remote"
49+
_ = fromCleanEnv(t, name)
50+
t.Cleanup(func() { cleanImages(t, name) })
51+
t.Cleanup(func() { clean(t, name, Namespace) })
52+
53+
// Init with testdata Python HTTP template (includes function/mylib/)
54+
initArgs := []string{"init", "-l", "python", "-t", "http",
55+
"--repository", "file://" + filepath.Join(Testdata, "templates-userdeps")}
56+
if err := newCmd(t, initArgs...).Run(); err != nil {
57+
t.Fatalf("init failed: %v", err)
58+
}
59+
60+
// Deploy remotely via Tekton
61+
if err := newCmd(t, "deploy", "--builder", "pack", "--remote",
62+
fmt.Sprintf("--registry=%s", ClusterRegistry)).Run(); err != nil {
63+
t.Fatal(err)
64+
}
65+
66+
if !waitFor(t, ksvcUrl(name),
67+
withWaitTimeout(5*time.Minute),
68+
withContentMatch("hello from mylib")) {
69+
t.Fatal("function did not return mylib greeting — user code not preserved in remote build")
70+
}
71+
}

e2e/testdata/templates-userdeps/python/http/function/__init__.py

Whitespace-only changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import logging
2+
from mylib import greet
3+
4+
5+
def new():
6+
return Function()
7+
8+
9+
class Function:
10+
async def handle(self, scope, receive, send):
11+
logging.info("Request Received")
12+
await send({
13+
'type': 'http.response.start',
14+
'status': 200,
15+
'headers': [[b'content-type', b'text/plain']],
16+
})
17+
await send({
18+
'type': 'http.response.body',
19+
'body': greet().encode(),
20+
})
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def greet():
2+
return "hello from mylib"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "function"
3+
version = "0.1.0"
4+
requires-python = ">=3.9"
5+
license = "MIT"
6+
7+
[build-system]
8+
requires = ["hatchling"]
9+
build-backend = "hatchling.build"

pkg/buildpacks/builder.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,30 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
210210
// only trust our known builders
211211
opts.TrustBuilder = TrustBuilder
212212

213+
// Python scaffolding via inline pre-buildpack.
214+
// Injects a script that rearranges user code into fn/ and copies
215+
// scaffolding from .func/build/ to the workspace root.
216+
if f.Runtime == "python" {
217+
opts.ProjectDescriptor.Build.Pre = types.GroupAddition{
218+
Buildpacks: []types.Buildpack{
219+
{
220+
ID: "knative.dev/func/python-scaffolding",
221+
Script: types.Script{
222+
API: "0.10",
223+
Inline: pythonScaffoldScript(),
224+
Shell: "/bin/bash",
225+
},
226+
},
227+
},
228+
}
229+
// Tell the procfile buildpack what command to run.
230+
// The Procfile written by the pre-buildpack is only
231+
// available during the build phase, but the procfile
232+
// buildpack needs to detect it earlier. Setting this
233+
// env var makes it detect from environment instead.
234+
opts.Env["BP_PROCFILE_DEFAULT_PROCESS"] = "python -m service.main"
235+
}
236+
213237
var impl = b.impl
214238
// Instantiate the pack build client implementation
215239
// (and update build opts as necessary)
@@ -230,19 +254,6 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
230254
return fmt.Errorf("podman 4.3 is not supported, use podman 4.2 or 4.4")
231255
}
232256

233-
if f.Runtime == "python" {
234-
if fi, _ := os.Lstat(filepath.Join(f.Root, "Procfile")); fi == nil {
235-
// HACK (of a hack): need to get the right invocation signature
236-
// the standard scaffolding does this in toSignature() func.
237-
// we know we have python here.
238-
invoke := f.Invoke
239-
if invoke == "" {
240-
invoke = "http"
241-
}
242-
cli = pyScaffoldInjector{cli, invoke}
243-
}
244-
}
245-
246257
// Client with a logger which is enabled if in Verbose mode and a dockerClient that supports SSH docker daemon connection.
247258
if impl, err = pack.NewClient(pack.WithLogger(b.logger), pack.WithDockerClient(cli)); err != nil {
248259
return fmt.Errorf("cannot create pack client: %w", err)

pkg/buildpacks/scaffolder.go

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"strings"
89

910
fn "knative.dev/func/pkg/functions"
1011
"knative.dev/func/pkg/scaffolding"
@@ -21,14 +22,14 @@ func NewScaffolder(verbose bool) *Scaffolder {
2122
return &Scaffolder{verbose: verbose}
2223
}
2324

24-
// Scaffold the function so that it can be built via buildpacks builder.
25-
// 'path' is an optional override. Assign "" (empty string) most of the time
25+
// Scaffold writes scaffolding for the function's runtime to the target path
26+
// using embedded templates. Pass "" for path to use the default (.func/build/).
27+
// Runtime-specific processing is applied after scaffolding is written.
2628
func (s Scaffolder) Scaffold(ctx context.Context, f fn.Function, path string) error {
27-
// Because of Python injector we currently dont scaffold python.
28-
// Add it here once the injector is removed
29-
if f.Runtime != "go" {
29+
// TODO: can be written as switch statement when we add more runtimes for readability
30+
if f.Runtime != "go" && f.Runtime != "python" {
3031
if s.verbose {
31-
fmt.Println("Scaffolding skipped. Currently available for runtime go")
32+
fmt.Println("Scaffolding skipped. Currently available for runtimes go, python")
3233
}
3334
return nil
3435
}
@@ -38,13 +39,90 @@ func (s Scaffolder) Scaffold(ctx context.Context, f fn.Function, path string) er
3839
appRoot = filepath.Join(f.Root, defaultPath)
3940
}
4041
if s.verbose {
41-
fmt.Printf("Writing buildpacks scaffolding at path '%v'\n", appRoot)
42+
fmt.Printf("Writing %s buildpacks scaffolding at path '%v'\n", f.Runtime, appRoot)
4243
}
4344

4445
embeddedRepo, err := fn.NewRepository("", "")
4546
if err != nil {
4647
return fmt.Errorf("unable to load embedded scaffolding: %w", err)
4748
}
48-
_ = os.RemoveAll(appRoot)
49-
return scaffolding.Write(appRoot, f.Root, f.Runtime, f.Invoke, embeddedRepo.FS())
49+
if err = os.RemoveAll(appRoot); err != nil {
50+
return fmt.Errorf("cannot clean scaffolding directory: %w", err)
51+
}
52+
if err = scaffolding.Write(appRoot, f.Root, f.Runtime, f.Invoke, embeddedRepo.FS()); err != nil {
53+
return err
54+
}
55+
56+
if f.Runtime != "python" {
57+
return nil
58+
}
59+
// Python specific: patch pyproject.toml for Poetry and write Procfile.
60+
//
61+
// Add procfile for python+pack scaffolding. This will be used for buildpacks
62+
// build/run phases to tell pack what to run. Note that this is not for
63+
// detection as pre-buildpack runs only at build phase.
64+
// Variable BP_PROCFILE_DEFAULT_PROCESS (see builder.go) is used for detection
65+
// for local deployment.
66+
if err = patchPyprojectForPack(filepath.Join(appRoot, "pyproject.toml")); err != nil {
67+
return err
68+
}
69+
return os.WriteFile(
70+
filepath.Join(appRoot, "Procfile"),
71+
[]byte(PythonScaffoldingProcfile()),
72+
os.FileMode(0644),
73+
)
74+
}
75+
76+
// patchPyprojectForPack applies pack-specific modifications to the template
77+
// pyproject.toml:
78+
// - Replaces {root:uri}/f with ./f — Poetry doesn't understand hatchling's
79+
// {root:uri} context variable. The inline buildpack creates a symlink
80+
// f -> fn/ so ./f resolves correctly.
81+
// - Appends [tool.poetry.dependencies] — Poetry needs this section for
82+
// dependency solving
83+
func patchPyprojectForPack(pyprojectPath string) error {
84+
data, err := os.ReadFile(pyprojectPath)
85+
if err != nil {
86+
return fmt.Errorf("cannot read pyproject.toml for patching: %w", err)
87+
}
88+
content := strings.Replace(string(data), "{root:uri}/f", "./f", 1)
89+
content += "[tool.poetry.dependencies]\npython = \">=3.9,<4.0\"\n"
90+
if err = os.WriteFile(pyprojectPath, []byte(content), 0644); err != nil {
91+
return fmt.Errorf("cannot write patched pyproject.toml: %w", err)
92+
}
93+
return nil
94+
}
95+
96+
// PythonScaffoldingProcfile returns the Procfile content that tells the
97+
// buildpack how to start the service.
98+
func PythonScaffoldingProcfile() string {
99+
return "web: python -m service.main\n"
100+
}
101+
102+
// pythonScaffoldScript returns a bash script for use as an inline buildpack.
103+
// The script rearranges user code into fn/ and moves pre-written scaffolding
104+
// from .func/build/ (populated by Scaffold) to the workspace root.
105+
func pythonScaffoldScript() string {
106+
return `#!/bin/bash
107+
set -eo pipefail
108+
109+
# Move user code into fn/ subdirectory, preserving infrastructure entries
110+
shopt -s dotglob
111+
mkdir -p fn
112+
for item in *; do
113+
case "$item" in
114+
fn|.func|.git|.gitignore|func.yaml) continue ;;
115+
esac
116+
mv "$item" fn/
117+
done
118+
shopt -u dotglob
119+
120+
# Move scaffolding from .func/build/ to root
121+
mv .func/build/* .
122+
rm -rf .func
123+
124+
# Create symlink so ./f in pyproject.toml resolves to fn/
125+
# -n: treat existing symlink as file, not follow it to directory
126+
ln -snf fn f
127+
`
50128
}

0 commit comments

Comments
 (0)