funcd-shim 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- funcd_shim-0.2.0/.gitignore +6 -0
- funcd_shim-0.2.0/PKG-INFO +27 -0
- funcd_shim-0.2.0/README.md +13 -0
- funcd_shim-0.2.0/embed.go +68 -0
- funcd_shim-0.2.0/embed_test.go +33 -0
- funcd_shim-0.2.0/pyproject.toml +57 -0
- funcd_shim-0.2.0/src/funcd_shim/__init__.py +20 -0
- funcd_shim-0.2.0/src/funcd_shim/__main__.py +10 -0
- funcd_shim-0.2.0/src/funcd_shim/_poolworker.py +134 -0
- funcd_shim-0.2.0/src/funcd_shim/blob.py +117 -0
- funcd_shim-0.2.0/src/funcd_shim/build.py +318 -0
- funcd_shim-0.2.0/src/funcd_shim/contract.py +89 -0
- funcd_shim-0.2.0/src/funcd_shim/funclog.py +166 -0
- funcd_shim-0.2.0/src/funcd_shim/invcontext.py +44 -0
- funcd_shim-0.2.0/src/funcd_shim/invoke.py +51 -0
- funcd_shim-0.2.0/src/funcd_shim/kv.py +107 -0
- funcd_shim-0.2.0/src/funcd_shim/pool.py +182 -0
- funcd_shim-0.2.0/src/funcd_shim/py.typed +0 -0
- funcd_shim-0.2.0/src/funcd_shim/runtime.py +82 -0
- funcd_shim-0.2.0/src/funcd_shim/shim.py +238 -0
- funcd_shim-0.2.0/src/funcd_shim/tracespan.py +133 -0
- funcd_shim-0.2.0/src/funcd_shim/types.py +76 -0
- funcd_shim-0.2.0/tests/test_blob.py +104 -0
- funcd_shim-0.2.0/tests/test_build.py +262 -0
- funcd_shim-0.2.0/tests/test_coldstart.py +68 -0
- funcd_shim-0.2.0/tests/test_contract.py +121 -0
- funcd_shim-0.2.0/tests/test_funclog.py +144 -0
- funcd_shim-0.2.0/tests/test_kv.py +75 -0
- funcd_shim-0.2.0/tests/test_pool.py +297 -0
- funcd_shim-0.2.0/tests/test_poolworker_malformed.py +59 -0
- funcd_shim-0.2.0/tests/test_runtime.py +82 -0
- funcd_shim-0.2.0/tests/test_shim.py +300 -0
- funcd_shim-0.2.0/tests/test_tracespan.py +225 -0
- funcd_shim-0.2.0/uv.lock +420 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: funcd-shim
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: The funcd Python runtime shim, the types handlers are written against, and the contract build
|
|
5
|
+
Project-URL: Homepage, https://github.com/pyvvo/funcd-python
|
|
6
|
+
Project-URL: Repository, https://github.com/pyvvo/funcd-python
|
|
7
|
+
Author: The funcd Authors
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: fastjsonschema>=2.21.2
|
|
11
|
+
Provides-Extra: build
|
|
12
|
+
Requires-Dist: pydantic>=2.13.4; extra == 'build'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# funcd-shim
|
|
16
|
+
|
|
17
|
+
The Python side of [funcd](https://github.com/pyvvo/funcd): the runtime shim that loads a
|
|
18
|
+
function's handler inside a funcd worker, the types handlers are written against, and the
|
|
19
|
+
contract build.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install "funcd-shim[build]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `from funcd_shim import CloudEvent, FunctionContext` types a handler.
|
|
26
|
+
- `from funcd_shim.build import build` bakes its input and output contract. It needs the `build`
|
|
27
|
+
extra.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# funcd-shim
|
|
2
|
+
|
|
3
|
+
The Python side of [funcd](https://github.com/pyvvo/funcd): the runtime shim that loads a
|
|
4
|
+
function's handler inside a funcd worker, the types handlers are written against, and the
|
|
5
|
+
contract build.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install "funcd-shim[build]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- `from funcd_shim import CloudEvent, FunctionContext` types a handler.
|
|
12
|
+
- `from funcd_shim.build import build` bakes its input and output contract. It needs the `build`
|
|
13
|
+
extra.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Package python embeds the funcd Python runtime shim (ADR-0049) into the binary, so the single
|
|
2
|
+
// self-contained `funcd` daemon ships it with no sidecar (ADR-0036, mirroring shim/nodejs). The
|
|
3
|
+
// daemon extracts the package tree to its data dir on boot and points WithRuntimeShimFor("python", …)
|
|
4
|
+
// at the extracted entry script. The shim calls the artifact's precompiled fastjsonschema validator
|
|
5
|
+
// (ADR-0058 — pure-Python, subinterpreter-safe); pydantic + fastjsonschema run only at build time.
|
|
6
|
+
package python
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"embed"
|
|
10
|
+
"io/fs"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// Explicit file list (not a directory glob): includes the underscore-prefixed __init__.py /
|
|
16
|
+
// __main__.py that an `all:` glob would need but that would also drag in __pycache__/*.pyc. Listing
|
|
17
|
+
// the sources by name embeds exactly the shim, nothing machine-generated. Every module the extracted
|
|
18
|
+
// shim imports at runtime is listed — solo (shim.py) and pool (pool.py/_poolworker.py) both import
|
|
19
|
+
// funclog + tracespan (+ tracespan→invcontext) at load, and contract.py (ADR-0123) is compiled at
|
|
20
|
+
// worker init; kv.py backs context.kv. build.py is the push-time AST baker (build-only) — not shipped.
|
|
21
|
+
//
|
|
22
|
+
//go:embed src/funcd_shim/__init__.py src/funcd_shim/__main__.py src/funcd_shim/shim.py src/funcd_shim/invoke.py src/funcd_shim/pool.py src/funcd_shim/_poolworker.py src/funcd_shim/runtime.py src/funcd_shim/types.py src/funcd_shim/contract.py src/funcd_shim/funclog.py src/funcd_shim/tracespan.py src/funcd_shim/invcontext.py src/funcd_shim/kv.py src/funcd_shim/blob.py src/funcd_shim/py.typed
|
|
23
|
+
var shimFS embed.FS
|
|
24
|
+
|
|
25
|
+
// The entry scripts launch the package: Python prepends a script's own directory to sys.path, so
|
|
26
|
+
// placing them beside the extracted `funcd_shim/` package makes `from funcd_shim...` resolve with
|
|
27
|
+
// no PYTHONPATH. Each runs a main() that reads its config (FUNCD_ARTIFACT / FUNCD_POOL_MANIFEST /
|
|
28
|
+
// FUNCD_PORT…) from the env.
|
|
29
|
+
const (
|
|
30
|
+
shimEntryScript = "from funcd_shim.shim import main\nraise SystemExit(main())\n"
|
|
31
|
+
poolEntryScript = "from funcd_shim.pool import main\nraise SystemExit(main())\n" // ADR-0050, needs Python ≥3.14
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// Extract writes the embedded Python shim package under dir and returns the entry script paths to
|
|
35
|
+
// launch as `python3 <entry>`: shimEntry is the solo shim (ADR-0049), poolEntry the subinterpreter
|
|
36
|
+
// pool host (ADR-0050, requires Python ≥3.14). Idempotent: overwrites whatever is there.
|
|
37
|
+
func Extract(dir string) (shimEntry, poolEntry string, err error) {
|
|
38
|
+
walkErr := fs.WalkDir(shimFS, "src/funcd_shim", func(path string, d fs.DirEntry, e error) error {
|
|
39
|
+
if e != nil {
|
|
40
|
+
return e
|
|
41
|
+
}
|
|
42
|
+
rel, rerr := filepath.Rel("src", path) // funcd_shim/...
|
|
43
|
+
if rerr != nil {
|
|
44
|
+
return rerr
|
|
45
|
+
}
|
|
46
|
+
dst := filepath.Join(dir, rel)
|
|
47
|
+
if d.IsDir() {
|
|
48
|
+
return os.MkdirAll(dst, 0o750)
|
|
49
|
+
}
|
|
50
|
+
data, readErr := shimFS.ReadFile(path)
|
|
51
|
+
if readErr != nil {
|
|
52
|
+
return readErr
|
|
53
|
+
}
|
|
54
|
+
return os.WriteFile(dst, data, 0o600)
|
|
55
|
+
})
|
|
56
|
+
if walkErr != nil {
|
|
57
|
+
return "", "", walkErr
|
|
58
|
+
}
|
|
59
|
+
shimEntry = filepath.Join(dir, "funcd_shim_entry.py")
|
|
60
|
+
if werr := os.WriteFile(shimEntry, []byte(shimEntryScript), 0o600); werr != nil {
|
|
61
|
+
return "", "", werr
|
|
62
|
+
}
|
|
63
|
+
poolEntry = filepath.Join(dir, "funcd_pool_entry.py")
|
|
64
|
+
if werr := os.WriteFile(poolEntry, []byte(poolEntryScript), 0o600); werr != nil {
|
|
65
|
+
return "", "", werr
|
|
66
|
+
}
|
|
67
|
+
return shimEntry, poolEntry, nil
|
|
68
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package python
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// TestEmbedIncludesEveryRuntimeModule guards against a new funcd_shim module (e.g. blob.py for context.blob)
|
|
11
|
+
// being added to src/funcd_shim/ but forgotten in embed.go's explicit go:embed list — which would ship a shim
|
|
12
|
+
// that ModuleNotFoundErrors the moment a handler touches the missing accessor (the exact ADR-0127 blob.py miss).
|
|
13
|
+
func TestEmbedIncludesEveryRuntimeModule(t *testing.T) {
|
|
14
|
+
// notShipped: source modules intentionally NOT embedded (build-time only, never imported at runtime).
|
|
15
|
+
notShipped := map[string]bool{
|
|
16
|
+
"build.py": true, // the push-time AST baker (pydantic/typia)
|
|
17
|
+
}
|
|
18
|
+
srcDir := filepath.Join("src", "funcd_shim")
|
|
19
|
+
ents, err := os.ReadDir(srcDir)
|
|
20
|
+
if err != nil {
|
|
21
|
+
t.Fatalf("read %s: %v", srcDir, err)
|
|
22
|
+
}
|
|
23
|
+
for _, e := range ents {
|
|
24
|
+
name := e.Name()
|
|
25
|
+
if e.IsDir() || !strings.HasSuffix(name, ".py") || notShipped[name] {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if _, oerr := shimFS.Open("src/funcd_shim/" + name); oerr != nil {
|
|
29
|
+
t.Errorf("source module %q is not in embed.go's go:embed list — the extracted shim would be missing it "+
|
|
30
|
+
"(add it, or add it to notShipped if build-only): %v", name, oerr)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "funcd-shim"
|
|
3
|
+
# Not bumped per release: a bump would make every uv.lock stale. The vX.Y.Z git tag is the version.
|
|
4
|
+
version = "0.2.0"
|
|
5
|
+
description = "The funcd Python runtime shim, the types handlers are written against, and the contract build"
|
|
6
|
+
authors = [{ name = "The funcd Authors" }]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = "Apache-2.0"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastjsonschema>=2.21.2",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
# the contract build (funcd_shim.build) reads FuncInput/FuncOutput through pydantic
|
|
16
|
+
build = ["pydantic>=2.13.4"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/pyvvo/funcd-python"
|
|
20
|
+
Repository = "https://github.com/pyvvo/funcd-python"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
funcd-shim = "funcd_shim.shim:main"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/funcd_shim"]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"mypy>=1.13",
|
|
35
|
+
"pydantic>=2.13.4",
|
|
36
|
+
"pytest>=8.3",
|
|
37
|
+
"ruff>=0.8",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.mypy]
|
|
41
|
+
strict = true
|
|
42
|
+
python_version = "3.12"
|
|
43
|
+
files = ["src", "tests"]
|
|
44
|
+
|
|
45
|
+
[[tool.mypy.overrides]]
|
|
46
|
+
module = "fastjsonschema.*" # ships no type stubs; used only as a build-time validator generator
|
|
47
|
+
ignore_missing_imports = true
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 110
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "N", "UP", "B", "A", "S"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"tests/*" = ["S101", "S310"] # asserts are the point of tests; S310 = loopback urllib in tests
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""funcd Python runtime shim + the typed authoring contract.
|
|
2
|
+
|
|
3
|
+
Function authors import the contract types::
|
|
4
|
+
|
|
5
|
+
from funcd_shim import Handler, CloudEvent, FunctionContext
|
|
6
|
+
|
|
7
|
+
def handle(context: FunctionContext, event: CloudEvent) -> dict:
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
The shim entrypoint is ``python -m funcd_shim`` (see :mod:`funcd_shim.shim`). It serves the
|
|
11
|
+
runtime-shim HTTP contract and validates ``event.data`` / the result against the optional
|
|
12
|
+
``FuncInput`` / ``FuncOutput`` **pydantic models** an artifact declares (ADR-0058, supersedes the
|
|
13
|
+
ADR-0038 JTD ``event_schema``). Requires pydantic at runtime.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .types import CloudEvent, FunctionContext, Handler, Json
|
|
19
|
+
|
|
20
|
+
__all__ = ["CloudEvent", "FunctionContext", "Handler", "Json"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Worker-side logic for the funcd Python pool host (ADR-0050), run INSIDE each subinterpreter by
|
|
2
|
+
``InterpreterPoolExecutor``. ``init`` loads the handler + the optional I/O validators once per worker
|
|
3
|
+
interpreter (state persists across invocations); ``invoke`` runs the contract + handler for one
|
|
4
|
+
request and returns a status-tagged envelope; ``ready`` is a side-effect-free load probe.
|
|
5
|
+
|
|
6
|
+
No ``concurrent.*`` here — plain per-interpreter Python. ``init``/``invoke``/``ready`` are referenced
|
|
7
|
+
by the executor across the interpreter boundary, so they live in this small importable module (the
|
|
8
|
+
host puts the package dir on ``PYTHONPATH`` so the worker can import ``funcd_shim``)."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from .runtime import Validators
|
|
17
|
+
from .types import CloudEvent, Handler
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .blob import BlobClient
|
|
21
|
+
from .kv import KVClient
|
|
22
|
+
|
|
23
|
+
# Per-interpreter state, set by init() and read by invoke() — isolated to this worker interpreter.
|
|
24
|
+
_handler: Handler | None = None
|
|
25
|
+
_validators: Validators = Validators()
|
|
26
|
+
_channel: Any = None # the shared telemetry channel (ADR-0101), opened once in init()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init(src: str, artifact: str, handler: str, contract_path: str | None = None) -> None:
|
|
30
|
+
"""Load the handler + I/O validators into this interpreter (the materialization shape-gate,
|
|
31
|
+
ADR-0058/0123). Runs once per worker; a failure breaks the pool → exit 3.
|
|
32
|
+
|
|
33
|
+
ADR-0123: when *contract_path* is given, compile the validators from the delivered schema
|
|
34
|
+
(``fastjsonschema.compile``) **before** the untrusted handler module is imported — the bounded
|
|
35
|
+
eval-free reversal + the m3 reorder. A set-but-broken path fails the worker closed. When absent,
|
|
36
|
+
fall back to the module-baked ``__funcd_validate_*`` (transition back-compat)."""
|
|
37
|
+
global _handler, _validators, _channel
|
|
38
|
+
if src not in sys.path:
|
|
39
|
+
sys.path.insert(0, src)
|
|
40
|
+
from funcd_shim import contract, runtime
|
|
41
|
+
from funcd_shim.funclog import install_log_capture, open_channel
|
|
42
|
+
|
|
43
|
+
# Path B capture (ADR-0081) + traces (ADR-0101): each pool worker runs in its own subinterpreter
|
|
44
|
+
# with its own root logger, so open the channel + install capture here (per-interpreter), before
|
|
45
|
+
# the handler loads. One shared channel per worker. No-op unless FUNCD_LOG_FD/SOCK is set.
|
|
46
|
+
_channel = open_channel()
|
|
47
|
+
install_log_capture(_channel)
|
|
48
|
+
|
|
49
|
+
# ADR-0123: compile the delivered contract AHEAD of the handler import (m3 reorder).
|
|
50
|
+
delivered = contract.load_from_path(contract_path) if contract_path else None
|
|
51
|
+
module = runtime.load_module(artifact)
|
|
52
|
+
_handler = runtime.resolve_handler(module, handler)
|
|
53
|
+
_validators = delivered if delivered is not None else runtime.resolve_validators(module)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ready() -> bool:
|
|
57
|
+
"""A load probe: True once init() succeeded (no handler call). The host submits this at startup
|
|
58
|
+
so a bad member surfaces as a broken pool before serving."""
|
|
59
|
+
return _handler is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Ctx:
|
|
63
|
+
def log(self, *args: object) -> None:
|
|
64
|
+
print(*args, flush=True)
|
|
65
|
+
|
|
66
|
+
def invoke(self, alias: str, payload: Any) -> Any:
|
|
67
|
+
from .invoke import invoke as _invoke
|
|
68
|
+
|
|
69
|
+
return _invoke(alias, payload)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def kv(self) -> KVClient:
|
|
73
|
+
from .kv import KVClient
|
|
74
|
+
|
|
75
|
+
return KVClient()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def blob(self) -> BlobClient:
|
|
79
|
+
from .blob import BlobClient
|
|
80
|
+
|
|
81
|
+
return BlobClient()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def invoke(
|
|
85
|
+
body: str,
|
|
86
|
+
traceparent: str | None = None,
|
|
87
|
+
fn_name: str = "invoke",
|
|
88
|
+
span_id: str | None = None,
|
|
89
|
+
links: list[str] | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Run one request: parse → optional input validation → handler → optional output validation →
|
|
92
|
+
a status-tagged envelope the host maps to the HTTP response (identical to the solo shim). ADR-0101:
|
|
93
|
+
a successful-past-input-validation request emits a SERVER span on the worker's channel."""
|
|
94
|
+
if _handler is None: # defensive — init() always runs first
|
|
95
|
+
return {"status": 500, "body": {"error": "handler not loaded"}}
|
|
96
|
+
try:
|
|
97
|
+
event: CloudEvent[Any] = json.loads(body) if body else CloudEvent()
|
|
98
|
+
except (json.JSONDecodeError, ValueError):
|
|
99
|
+
return {"status": 400, "body": {"error": "request body is not valid JSON"}}
|
|
100
|
+
if not isinstance(event, dict):
|
|
101
|
+
# A valid-JSON but non-object body (null / array / scalar) is not a CloudEvent envelope.
|
|
102
|
+
# Reject it cleanly — never let `event.get("data")` raise AttributeError and crash the pooled
|
|
103
|
+
# worker (that surfaced as a gateway `proxy error: EOF` / empty-body 502).
|
|
104
|
+
return {
|
|
105
|
+
"status": 400,
|
|
106
|
+
"body": {"error": "request body must be a JSON object (CloudEvent envelope)"},
|
|
107
|
+
}
|
|
108
|
+
if _validators.input is not None:
|
|
109
|
+
errors = _validators.input(event.get("data"))
|
|
110
|
+
if errors:
|
|
111
|
+
# ADR-0101: input-mismatch short-circuits before the handler → no invocation, no span.
|
|
112
|
+
return {
|
|
113
|
+
"status": 422,
|
|
114
|
+
"body": {"error": "event data does not match the input contract", "details": errors},
|
|
115
|
+
}
|
|
116
|
+
from .tracespan import InvocationSpan
|
|
117
|
+
|
|
118
|
+
with InvocationSpan(_channel, fn_name, traceparent, span_id, links) as span:
|
|
119
|
+
try:
|
|
120
|
+
result = _handler(_Ctx(), event)
|
|
121
|
+
except Exception as err: # noqa: BLE001 - user handler errors become 500
|
|
122
|
+
span.fail(str(err))
|
|
123
|
+
return {"status": 500, "body": {"error": str(err)}}
|
|
124
|
+
if _validators.output is not None:
|
|
125
|
+
errors = _validators.output(result)
|
|
126
|
+
if errors:
|
|
127
|
+
span.fail("handler result does not match the output contract")
|
|
128
|
+
return {
|
|
129
|
+
"status": 500,
|
|
130
|
+
"body": {"error": "handler result does not match the output contract", "details": errors},
|
|
131
|
+
}
|
|
132
|
+
if result is None:
|
|
133
|
+
return {"status": 204}
|
|
134
|
+
return {"status": 200, "body": result}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Function-facing blob over the worker-node local API (HTTP-over-UDS, ADR-0127).
|
|
2
|
+
|
|
3
|
+
Dials the same per-sandbox socket as ``context.kv``/``context.invoke`` (``FUNCD_INVOKE_SOCKET``); the
|
|
4
|
+
platform routes ``/blob/…`` to the binding-gated, PDP-authorized Facade with the sandbox's function
|
|
5
|
+
identity (bind-as-grant on ``spec.blob``). Stdlib-only — no boto3, no keypair. The blob twin of
|
|
6
|
+
``context.kv``; v1 is bytes-in-memory (streaming is a v2 follow-up).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import http.client
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _UnixHTTPConnection(http.client.HTTPConnection):
|
|
19
|
+
"""An HTTPConnection that dials a Unix domain socket instead of TCP."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, socket_path: str) -> None:
|
|
22
|
+
super().__init__("localhost")
|
|
23
|
+
self._socket_path = socket_path
|
|
24
|
+
|
|
25
|
+
def connect(self) -> None:
|
|
26
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
27
|
+
sock.connect(self._socket_path)
|
|
28
|
+
self.sock = sock
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _conn() -> _UnixHTTPConnection:
|
|
32
|
+
socket_path = os.environ.get("FUNCD_INVOKE_SOCKET")
|
|
33
|
+
if not socket_path:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
"context.blob: worker-node local API socket unavailable (FUNCD_INVOKE_SOCKET unset)"
|
|
36
|
+
)
|
|
37
|
+
return _UnixHTTPConnection(socket_path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _key_path(binding: str, key: str) -> str:
|
|
41
|
+
# key may be hierarchical ("a/b"); keep the "/" separators (the server's {key...} captures them).
|
|
42
|
+
segs = "/".join(quote(s, safe="") for s in key.split("/"))
|
|
43
|
+
return f"/blob/{quote(binding, safe='')}/{segs}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BlobClient:
|
|
47
|
+
"""A function's binding-scoped blob storage (ADR-0127): get/put/delete/list a bound prefix's objects,
|
|
48
|
+
or mint a presigned URL — the blob twin of :class:`KVClient`."""
|
|
49
|
+
|
|
50
|
+
def get(self, binding: str, key: str) -> bytes | None:
|
|
51
|
+
conn = _conn()
|
|
52
|
+
try:
|
|
53
|
+
conn.request("GET", _key_path(binding, key))
|
|
54
|
+
resp = conn.getresponse()
|
|
55
|
+
data = resp.read()
|
|
56
|
+
if resp.status == 404:
|
|
57
|
+
return None
|
|
58
|
+
if not 200 <= resp.status < 300:
|
|
59
|
+
text = data.decode("utf-8", "replace")
|
|
60
|
+
raise RuntimeError(f"context.blob.get failed: {resp.status} {text}")
|
|
61
|
+
return data
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
def put(self, binding: str, key: str, data: bytes) -> None:
|
|
66
|
+
conn = _conn()
|
|
67
|
+
try:
|
|
68
|
+
conn.request("PUT", _key_path(binding, key), body=data)
|
|
69
|
+
resp = conn.getresponse()
|
|
70
|
+
text = resp.read().decode("utf-8", "replace")
|
|
71
|
+
if not 200 <= resp.status < 300:
|
|
72
|
+
raise RuntimeError(f"context.blob.put failed: {resp.status} {text}")
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
def delete(self, binding: str, key: str) -> None:
|
|
77
|
+
conn = _conn()
|
|
78
|
+
try:
|
|
79
|
+
conn.request("DELETE", _key_path(binding, key))
|
|
80
|
+
resp = conn.getresponse()
|
|
81
|
+
text = resp.read().decode("utf-8", "replace")
|
|
82
|
+
if not 200 <= resp.status < 300:
|
|
83
|
+
raise RuntimeError(f"context.blob.delete failed: {resp.status} {text}")
|
|
84
|
+
finally:
|
|
85
|
+
conn.close()
|
|
86
|
+
|
|
87
|
+
def list(self, binding: str, prefix: str = "") -> list[str]:
|
|
88
|
+
path = f"/blob/{quote(binding, safe='')}"
|
|
89
|
+
if prefix:
|
|
90
|
+
path += f"?prefix={quote(prefix, safe='')}"
|
|
91
|
+
conn = _conn()
|
|
92
|
+
try:
|
|
93
|
+
conn.request("GET", path)
|
|
94
|
+
resp = conn.getresponse()
|
|
95
|
+
text = resp.read().decode("utf-8")
|
|
96
|
+
if not 200 <= resp.status < 300:
|
|
97
|
+
raise RuntimeError(f"context.blob.list failed: {resp.status} {text}")
|
|
98
|
+
return json.loads(text) if text else []
|
|
99
|
+
finally:
|
|
100
|
+
conn.close()
|
|
101
|
+
|
|
102
|
+
def signed_url(self, binding: str, key: str, method: str = "GET", expiry: float | None = None) -> str:
|
|
103
|
+
"""Return a presigned external URL for the object. ``method`` is GET (read) / PUT / DELETE (write);
|
|
104
|
+
``expiry`` is in seconds (the driver's default when ``None``). A PUT/DELETE URL requires s3::write."""
|
|
105
|
+
path = f"{_key_path(binding, key)}?sign=1&method={quote(method, safe='')}"
|
|
106
|
+
if expiry is not None:
|
|
107
|
+
path += f"&expiry={expiry}s"
|
|
108
|
+
conn = _conn()
|
|
109
|
+
try:
|
|
110
|
+
conn.request("GET", path)
|
|
111
|
+
resp = conn.getresponse()
|
|
112
|
+
text = resp.read().decode("utf-8", "replace")
|
|
113
|
+
if not 200 <= resp.status < 300:
|
|
114
|
+
raise RuntimeError(f"context.blob.signed_url failed: {resp.status} {text}")
|
|
115
|
+
return text
|
|
116
|
+
finally:
|
|
117
|
+
conn.close()
|