pipelex-api 0.86.1__tar.gz → 0.87.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.
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/PKG-INFO +2 -2
- pipelex_api-0.87.0/pipelex_api/blob_store.py +190 -0
- pipelex_api-0.87.0/pipelex_api/blob_upload.py +102 -0
- pipelex_api-0.87.0/pipelex_api/bundle.py +87 -0
- pipelex_api-0.87.0/pipelex_api/bundle_resources.py +84 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/error_types.py +27 -2
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/errors.py +14 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/exception_handlers.py +0 -5
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/limits.py +19 -1
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/main.py +6 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/method_cache.py +59 -12
- pipelex_api-0.87.0/pipelex_api/method_source.py +365 -0
- pipelex_api-0.87.0/pipelex_api/method_tree_request.py +727 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/openapi_responses.py +6 -0
- pipelex_api-0.87.0/pipelex_api/routes/blob_upload.py +71 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/__init__.py +2 -0
- pipelex_api-0.87.0/pipelex_api/routes/pipelex/blobs.py +143 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/codegen.py +3 -3
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/crate_ops.py +7 -5
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/pipe_io.py +1 -1
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/pipeline.py +192 -182
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/resolve.py +1 -1
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/validate.py +125 -101
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/version.py +4 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/schemas/models.py +95 -40
- pipelex_api-0.86.1/pipelex_api/bundle.py +0 -436
- pipelex_api-0.86.1/pipelex_api/bundle_resources.py +0 -148
- pipelex_api-0.86.1/pipelex_api/method_source.py +0 -248
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/.gitignore +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/LICENSE +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/README.md +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/hatch_build.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/__init__.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/api.toml +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/api_config.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/disclosure.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/error_uri.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/json_body.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/middleware.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/openapi_schema.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/problem_document.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/py.typed +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/__init__.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/health.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/tools.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/routes/pipelex/utils.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/schemas/__init__.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pipelex_api/security.py +0 -0
- {pipelex_api-0.86.1 → pipelex_api-0.87.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pipelex-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.87.0
|
|
4
4
|
Summary: Pipelex API
|
|
5
5
|
Project-URL: Homepage, https://pipelex.com
|
|
6
6
|
Project-URL: Repository, https://github.com/Pipelex/pipelex
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Requires-Python: <3.15,>=3.11
|
|
18
18
|
Requires-Dist: fastapi<0.142,>=0.118.0
|
|
19
|
-
Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.
|
|
19
|
+
Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.87.0
|
|
20
20
|
Requires-Dist: pyjwt>=2.10.1
|
|
21
21
|
Requires-Dist: uvicorn>=0.37.0
|
|
22
22
|
Provides-Extra: dev
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The runner's blob store: bytes keyed by their SHA-256, and the listings of the trees it holds in full.
|
|
2
|
+
|
|
3
|
+
A sender asks which of its hashes the runner lacks (`POST /v1/blobs/missing`), uploads only those
|
|
4
|
+
(`PUT /v1/blobs/{sha256}`), then sends its method as a map of file entries naming them by hash. This runner keeps those
|
|
5
|
+
bytes in its configured storage provider, local or cloud, so a self-hosted deployment gets the shape the hosted plane
|
|
6
|
+
has (`wip/extra-files/design.md`, D5 and D6), under one prefix per caller:
|
|
7
|
+
|
|
8
|
+
<scope>/blobs/<sha256> a file's bytes, written once and never changed
|
|
9
|
+
<scope>/trees/<hex> a tree's listing, under the hexadecimal part of its digest
|
|
10
|
+
|
|
11
|
+
The scope is the run's trusted read scope, the one a multi-tenant host sends for the organization it serves, or else the
|
|
12
|
+
caller's own id (`single-tenant` on a deployment that identifies no caller): a hash entry resolves only in the caller's
|
|
13
|
+
own scope, never across tenants, so asking whether a hash is held tells a caller nothing of another's files.
|
|
14
|
+
|
|
15
|
+
A listing is written only after every file it names is held, by this runner once it has read a request's tree in
|
|
16
|
+
full, and never by an upload: `PUT /v1/blobs/{sha256}` writes under `blobs/` alone, so an uploaded file that happens to
|
|
17
|
+
be a well-formed listing proves nothing. A listing present means the whole tree is present, which lets
|
|
18
|
+
`POST /v1/blobs/missing` answer a tree's digest with one lookup.
|
|
19
|
+
|
|
20
|
+
Every object under `blobs/<sha256>` holds, whole, the bytes whose SHA-256 is that hash: the key is the hash this runner
|
|
21
|
+
computes from the bytes it writes, the runner's own `PUT /v1/blobs/{sha256}` writes only bytes it checked against the
|
|
22
|
+
path's hash, and every built-in storage provider publishes a write whole or not at all (the local one through a
|
|
23
|
+
temporary file renamed onto the key). So an object's stored size, read from its metadata without reading its bytes, is
|
|
24
|
+
the size of that hash's bytes, and nothing deletes or rewrites a blob. A listing, written once each of its files was
|
|
25
|
+
held at its size, therefore stays true.
|
|
26
|
+
|
|
27
|
+
From this the blob store answers without reading what it does not need:
|
|
28
|
+
|
|
29
|
+
- `POST /v1/blobs/missing` lists a hash this runner does not hold at the size the sender states, an object held at
|
|
30
|
+
another size included, which only a storage that does not publish writes whole could leave short, and which the
|
|
31
|
+
upload then writes over.
|
|
32
|
+
- A run looks up each hash's stored size first. A hash held at another size than an entry states is a 422
|
|
33
|
+
`BlobMismatch`, never `MissingBlobs`, since uploading those bytes again would change nothing: the entry is wrong.
|
|
34
|
+
Only a blob held at the size its entries state is read, so the bytes read are bounded by the sizes the request
|
|
35
|
+
states, which its census already held to the tree's limits, and each is checked against its hash once read. Bytes
|
|
36
|
+
that do not hash to their key mean the storage itself is damaged, which no caller can repair: a 500
|
|
37
|
+
`BlobStoreCorrupted`.
|
|
38
|
+
|
|
39
|
+
Lookups and reads run a bounded number at a time, so one request naming many blobs never opens them all at once.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from collections.abc import Iterable, Mapping
|
|
43
|
+
from functools import partial
|
|
44
|
+
|
|
45
|
+
from mthds.method_tree.digest import DIGEST_PREFIX, sha256_hex, tree_listing
|
|
46
|
+
from mthds.method_tree.file_entry import BlobStore, InMemoryBlobStore
|
|
47
|
+
from mthds.method_tree.models import MethodTree
|
|
48
|
+
from pipelex.core.method_trees.exceptions import MethodFileUnavailableError
|
|
49
|
+
from pipelex.core.method_trees.method_files import TreeListing
|
|
50
|
+
from pipelex.runtime_hub import get_storage_provider
|
|
51
|
+
from pipelex.tools.misc.async_utils import gather_bounded
|
|
52
|
+
from pipelex.tools.storage.exceptions import StorageFileNotFoundError
|
|
53
|
+
from pipelex.tools.storage.storage_provider_abstract import PIPELEX_STORAGE_SCHEME, StorageProviderAbstract
|
|
54
|
+
|
|
55
|
+
from pipelex_api.error_types import ErrorType
|
|
56
|
+
from pipelex_api.errors import raise_internal_server_error
|
|
57
|
+
|
|
58
|
+
BLOBS_PREFIX = "blobs"
|
|
59
|
+
TREES_PREFIX = "trees"
|
|
60
|
+
_BLOB_CONTENT_TYPE = "application/octet-stream"
|
|
61
|
+
_LISTING_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
62
|
+
# How many lookups of a blob's size, which read metadata alone, and how many reads or writes of a blob's bytes, one
|
|
63
|
+
# request runs at a time.
|
|
64
|
+
MAX_CONCURRENT_BLOB_LOOKUPS = 16
|
|
65
|
+
MAX_CONCURRENT_BLOB_TRANSFERS = 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RunnerBlobStore:
|
|
69
|
+
"""The blobs and tree listings of one caller's scope, over the deployment's storage provider."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, *, scope: str, storage: StorageProviderAbstract) -> None:
|
|
72
|
+
self._scope = scope
|
|
73
|
+
self._storage = storage
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def make_for_scope(cls, *, scope: str) -> "RunnerBlobStore":
|
|
77
|
+
"""The blob store of `scope`, over the storage provider the runtime booted with."""
|
|
78
|
+
return cls(scope=scope, storage=get_storage_provider())
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def scope(self) -> str:
|
|
82
|
+
return self._scope
|
|
83
|
+
|
|
84
|
+
def _blob_key(self, *, sha256: str) -> str:
|
|
85
|
+
return f"{self._scope}/{BLOBS_PREFIX}/{sha256}"
|
|
86
|
+
|
|
87
|
+
def _tree_key(self, *, tree_digest: str) -> str:
|
|
88
|
+
return f"{self._scope}/{TREES_PREFIX}/{tree_digest.removeprefix(DIGEST_PREFIX)}"
|
|
89
|
+
|
|
90
|
+
async def _holds_key(self, *, key: str) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
await self._storage.load_size(f"{PIPELEX_STORAGE_SCHEME}{key}")
|
|
93
|
+
except StorageFileNotFoundError:
|
|
94
|
+
return False
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
async def stored_size(self, *, sha256: str) -> int | None:
|
|
98
|
+
"""The size of the object stored under the hash `sha256`, read from its metadata, or None when there is none."""
|
|
99
|
+
try:
|
|
100
|
+
return await self._storage.load_size(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
|
|
101
|
+
except StorageFileNotFoundError:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
async def holds_blob(self, *, sha256: str, size: int) -> bool:
|
|
105
|
+
"""Whether the bytes whose SHA-256 is `sha256` are held at `size`, an object of another size being no blob."""
|
|
106
|
+
return await self.stored_size(sha256=sha256) == size
|
|
107
|
+
|
|
108
|
+
async def stored_sizes(self, *, sha256s: Iterable[str]) -> dict[str, int | None]:
|
|
109
|
+
"""Each hash's stored size, None for a hash not held, looked up a bounded number at a time."""
|
|
110
|
+
hashes = list(sha256s)
|
|
111
|
+
sizes = await gather_bounded([partial(self.stored_size, sha256=sha256) for sha256 in hashes], max_concurrency=MAX_CONCURRENT_BLOB_LOOKUPS)
|
|
112
|
+
return dict(zip(hashes, sizes, strict=True))
|
|
113
|
+
|
|
114
|
+
async def _read_verified(self, *, sha256: str, size: int) -> bytes:
|
|
115
|
+
"""The stored bytes of `sha256`, held at `size`, checked against their hash.
|
|
116
|
+
|
|
117
|
+
Raises (as a problem document): a 500 `BlobStoreCorrupted` for bytes that are not `size` bytes hashing to `sha256`.
|
|
118
|
+
"""
|
|
119
|
+
data = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
|
|
120
|
+
if len(data) != size or sha256_hex(data) != sha256:
|
|
121
|
+
msg = (
|
|
122
|
+
f"The bytes this runner's blob store holds under {sha256} do not hash to it: its storage is damaged, "
|
|
123
|
+
"and its operator must remove that object for the blob to be uploaded again."
|
|
124
|
+
)
|
|
125
|
+
raise_internal_server_error(message=msg, error_type=ErrorType.BLOB_STORE_CORRUPTED)
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
async def read_held(self, *, sizes: Mapping[str, int]) -> BlobStore:
|
|
129
|
+
"""Read these held blobs, each at its stored size, as the synchronous blob store a map of file entries is read with.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
sizes: Each hash to read, with the size it is held at, which the caller bounded before asking.
|
|
133
|
+
"""
|
|
134
|
+
blobs = await gather_bounded(
|
|
135
|
+
[partial(self._read_verified, sha256=sha256, size=size) for sha256, size in sizes.items()],
|
|
136
|
+
max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS,
|
|
137
|
+
)
|
|
138
|
+
return InMemoryBlobStore(blobs)
|
|
139
|
+
|
|
140
|
+
async def store_blob(self, *, data: bytes) -> str:
|
|
141
|
+
"""Hold `data` under its SHA-256, writing it unless it is held at its size already, and return the hash.
|
|
142
|
+
|
|
143
|
+
An object under the hash at another size, which only a storage that does not publish writes whole could leave,
|
|
144
|
+
is written over.
|
|
145
|
+
"""
|
|
146
|
+
sha256 = sha256_hex(data)
|
|
147
|
+
if not await self.holds_blob(sha256=sha256, size=len(data)):
|
|
148
|
+
await self._storage.store(data, key=self._blob_key(sha256=sha256), content_type=_BLOB_CONTENT_TYPE)
|
|
149
|
+
return sha256
|
|
150
|
+
|
|
151
|
+
async def holds_tree(self, *, tree_digest: str) -> bool:
|
|
152
|
+
"""Whether the listing of the tree `tree_digest` names is held, which means every file of the tree is."""
|
|
153
|
+
return await self._holds_key(key=self._tree_key(tree_digest=tree_digest))
|
|
154
|
+
|
|
155
|
+
async def tree_hashes(self, *, tree_digest: str) -> set[str] | None:
|
|
156
|
+
"""The hashes of the files of the tree `tree_digest` names, when its listing is held, else None.
|
|
157
|
+
|
|
158
|
+
The listing is read and checked against the digest, so a sender naming a tree it does not send learns nothing
|
|
159
|
+
held that its own blobs are not part of.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
listing = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._tree_key(tree_digest=tree_digest)}")
|
|
163
|
+
except StorageFileNotFoundError:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
tree_listing_read = TreeListing.parse(tree_digest=tree_digest, listing=listing)
|
|
167
|
+
except MethodFileUnavailableError:
|
|
168
|
+
# Not the listing the digest names: the tree is not held.
|
|
169
|
+
return None
|
|
170
|
+
return set(tree_listing_read.files.values())
|
|
171
|
+
|
|
172
|
+
async def record_tree(self, *, tree: MethodTree, contents: Mapping[str, bytes]) -> None:
|
|
173
|
+
"""Hold every file of a tree read in full, then its listing, so a later request can send it by hash.
|
|
174
|
+
|
|
175
|
+
The listing is written last, once every file it names is held, so a listing present always means its whole
|
|
176
|
+
tree is, and a tree whose listing is held already is left as it is, without a lookup per file.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
tree: The tree, as a map of file entries made it.
|
|
180
|
+
contents: Each file's bytes by tree path.
|
|
181
|
+
"""
|
|
182
|
+
if await self.holds_tree(tree_digest=tree.digest):
|
|
183
|
+
return
|
|
184
|
+
await gather_bounded([partial(self.store_blob, data=data) for data in contents.values()], max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS)
|
|
185
|
+
await self._storage.store(tree_listing(tree.files), key=self._tree_key(tree_digest=tree.digest), content_type=_LISTING_CONTENT_TYPE)
|
|
186
|
+
|
|
187
|
+
async def missing_blobs(self, *, sizes: Mapping[str, int]) -> list[str]:
|
|
188
|
+
"""The hashes of `sizes` not held at their size, in the mapping's order."""
|
|
189
|
+
stored = await self.stored_sizes(sha256s=sizes)
|
|
190
|
+
return [sha256 for sha256, size in sizes.items() if stored[sha256] != size]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The signed upload targets `POST /v1/blobs/missing` names, and their check on `PUT /v1/blobs/{sha256}`.
|
|
2
|
+
|
|
3
|
+
A sender uploads a blob to the target the runner named, carrying exactly the headers it named, so the target itself
|
|
4
|
+
must say that the upload is allowed, as a presigned URL does on an object store. This runner signs each target it
|
|
5
|
+
names: its URL is its own `PUT /v1/blobs/{sha256}` with a query naming the blob store's scope, an expiry and an
|
|
6
|
+
HMAC-SHA256 signature over the scope, the hash and the expiry. The upload route checks the signature and the expiry,
|
|
7
|
+
writes under the scope the signature binds and nowhere else, and answers a missing, forged or expired signature with a
|
|
8
|
+
403 `BlobUploadNotAuthorized`. The upload route therefore needs no bearer token: only a caller who passed the
|
|
9
|
+
deployment's authentication on `POST /v1/blobs/missing` holds a target, for one hash, in its own scope, for a while.
|
|
10
|
+
|
|
11
|
+
The key is `BLOB_UPLOAD_SIGNING_KEY` when the deployment sets it, which it must when several instances serve one
|
|
12
|
+
deployment, since a target named by one instance is checked by whichever instance receives the upload; otherwise each
|
|
13
|
+
process draws a random key of its own at its first signature.
|
|
14
|
+
|
|
15
|
+
A target's URL starts with `PUBLIC_BASE_URL` when the deployment sets it, the address its clients reach the API at,
|
|
16
|
+
such as `https://runner.example.com`: behind a proxy that terminates TLS or serves the API under a path prefix, the URL
|
|
17
|
+
the runner received a request at is not the one a client can upload to. Unset, the target starts with the URL of the
|
|
18
|
+
request that asked for it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import secrets
|
|
24
|
+
import time
|
|
25
|
+
from functools import cache
|
|
26
|
+
from urllib.parse import urlencode, urlsplit
|
|
27
|
+
|
|
28
|
+
from fastapi import Request
|
|
29
|
+
from pipelex.system.environment import get_optional_env
|
|
30
|
+
|
|
31
|
+
from pipelex_api.error_types import ErrorType
|
|
32
|
+
from pipelex_api.errors import raise_internal_server_error
|
|
33
|
+
from pipelex_api.limits import BLOB_UPLOAD_URL_TTL_SECONDS
|
|
34
|
+
|
|
35
|
+
PUBLIC_BASE_URL_ENV = "PUBLIC_BASE_URL"
|
|
36
|
+
_UPLOAD_ROUTE_NAME = "put_blob"
|
|
37
|
+
|
|
38
|
+
SCOPE_PARAM = "scope"
|
|
39
|
+
EXPIRES_PARAM = "expires"
|
|
40
|
+
SIGNATURE_PARAM = "signature"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cache
|
|
44
|
+
def _signing_key() -> bytes:
|
|
45
|
+
configured = get_optional_env("BLOB_UPLOAD_SIGNING_KEY")
|
|
46
|
+
if configured:
|
|
47
|
+
return configured.encode("utf-8")
|
|
48
|
+
return secrets.token_bytes(32)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _signature(*, scope: str, sha256: str, expires: int) -> str:
|
|
52
|
+
message = f"{scope}\n{sha256}\n{expires}".encode()
|
|
53
|
+
return hmac.new(_signing_key(), message, hashlib.sha256).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def signed_upload_query(*, scope: str, sha256: str) -> str:
|
|
57
|
+
"""The query string of an upload target for one blob in one scope, valid for `BLOB_UPLOAD_URL_TTL_SECONDS`."""
|
|
58
|
+
expires = int(time.time()) + BLOB_UPLOAD_URL_TTL_SECONDS
|
|
59
|
+
return urlencode({SCOPE_PARAM: scope, EXPIRES_PARAM: expires, SIGNATURE_PARAM: _signature(scope=scope, sha256=sha256, expires=expires)})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _public_base_url() -> str | None:
|
|
63
|
+
"""`PUBLIC_BASE_URL` without its trailing slash, or None when unset.
|
|
64
|
+
|
|
65
|
+
Raises (as a problem document): a 500 `ServerMisconfigured` for a value that is not an absolute http or https URL
|
|
66
|
+
without a query or a fragment.
|
|
67
|
+
"""
|
|
68
|
+
configured = get_optional_env(PUBLIC_BASE_URL_ENV)
|
|
69
|
+
if not configured:
|
|
70
|
+
return None
|
|
71
|
+
parts = urlsplit(configured)
|
|
72
|
+
if parts.scheme not in {"http", "https"} or not parts.netloc or parts.query or parts.fragment:
|
|
73
|
+
msg = f"Server configuration error: {PUBLIC_BASE_URL_ENV} must be an absolute http or https URL, such as https://runner.example.com."
|
|
74
|
+
raise_internal_server_error(message=msg, error_type=ErrorType.SERVER_MISCONFIGURED)
|
|
75
|
+
return configured.rstrip("/")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def upload_target_url(*, request: Request, scope: str, sha256: str) -> str:
|
|
79
|
+
"""The signed URL a sender uploads one blob to: this runner's `PUT /v1/blobs/{sha256}`, at its public address."""
|
|
80
|
+
query = signed_upload_query(scope=scope, sha256=sha256)
|
|
81
|
+
base_url = _public_base_url()
|
|
82
|
+
if base_url is None:
|
|
83
|
+
return f"{request.url_for(_UPLOAD_ROUTE_NAME, sha256=sha256)}?{query}"
|
|
84
|
+
return f"{base_url}{request.app.url_path_for(_UPLOAD_ROUTE_NAME, sha256=sha256)}?{query}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_upload_authorized(*, scope: str | None, sha256: str, expires: str | None, signature: str | None) -> bool:
|
|
88
|
+
"""Whether an upload carries a signature this runner made for this hash and scope, still within its expiry.
|
|
89
|
+
|
|
90
|
+
Every part of the query is the caller's text, read as such: an expiry that is not a decimal integer, or a signature
|
|
91
|
+
holding characters no signature this runner makes holds, is no valid target, never a failure of the check.
|
|
92
|
+
"""
|
|
93
|
+
if scope is None or expires is None or signature is None:
|
|
94
|
+
return False
|
|
95
|
+
if not expires.isascii() or not expires.isdecimal():
|
|
96
|
+
return False
|
|
97
|
+
expires_at = int(expires)
|
|
98
|
+
if expires_at < int(time.time()):
|
|
99
|
+
return False
|
|
100
|
+
# Compared as bytes: `compare_digest` refuses text holding a character outside ASCII, which a forged signature may.
|
|
101
|
+
expected = _signature(scope=scope, sha256=sha256, expires=expires_at).encode("ascii")
|
|
102
|
+
return hmac.compare_digest(expected, signature.encode("utf-8", "surrogatepass"))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Decode a `bundle_b64` archive into its entries, under the guards a zip archive needs.
|
|
2
|
+
|
|
3
|
+
A run request may carry its method as `bundle_b64`, a base64-encoded zip archive of the method's folder, beside the
|
|
4
|
+
map of file entries `files` that replaces it. This module only opens the archive: it bounds the encoded string before
|
|
5
|
+
decoding it, refuses an archive with too many members, and reads each member through a bounded stream so a zip bomb
|
|
6
|
+
whose header under-reports its size cannot decompress past the ceiling. The entries it returns keep the names the
|
|
7
|
+
archive gives them; `pipelex_api.method_tree_request` reads them as a map of file entries, so the archive's names are
|
|
8
|
+
judged by the one rule of the method tree, as the keys of `files` are: a name that is not a tree path, or that names a
|
|
9
|
+
file the membership rule keeps out of every tree, is refused, and so is one name given twice.
|
|
10
|
+
|
|
11
|
+
Nothing here writes to disk, imports or executes the archive's Python.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import zipfile
|
|
17
|
+
from io import BytesIO
|
|
18
|
+
|
|
19
|
+
from pipelex import log
|
|
20
|
+
from pipelex.tools.log.error_fields import error_fields
|
|
21
|
+
|
|
22
|
+
from pipelex_api.error_types import ErrorType
|
|
23
|
+
from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
|
|
24
|
+
from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
|
|
25
|
+
|
|
26
|
+
# Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
|
|
27
|
+
# the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
|
|
28
|
+
# ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
|
|
29
|
+
# be rejected later by the decompressed-size guard. Slack (+4) covers padding.
|
|
30
|
+
_MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _guard_count(count: int) -> None:
|
|
34
|
+
if count == 0:
|
|
35
|
+
raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
|
|
36
|
+
if count > MAX_BUNDLE_FILES:
|
|
37
|
+
raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _guard_running_total(total_bytes: int) -> None:
|
|
41
|
+
if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
|
|
42
|
+
raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode_bundle_archive(*, bundle_b64: str) -> list[tuple[str, bytes]]:
|
|
46
|
+
"""Decode a base64 zip and return its file members as `(name, bytes)`, in archive order, directories skipped.
|
|
47
|
+
|
|
48
|
+
Zip-bomb guard: each member is read through a bounded stream so a lying uncompressed-size header cannot force
|
|
49
|
+
unbounded decompression — the running total is checked against `MAX_BUNDLE_TOTAL_BYTES` as bytes are pulled. A
|
|
50
|
+
cheap length check on the still-encoded string runs FIRST, so an oversized payload is refused before it is buffered
|
|
51
|
+
into memory as decoded bytes. A name the archive gives twice is returned twice, for the tree's reader to refuse.
|
|
52
|
+
|
|
53
|
+
Raises (as problem documents): an oversized archive (413 `PayloadTooLarge`), a value that is not base64 (400
|
|
54
|
+
`InvalidBase64`), and one that is not a zip archive or holds no file (422 `InvalidBundle`).
|
|
55
|
+
"""
|
|
56
|
+
if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
|
|
57
|
+
raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
|
|
58
|
+
try:
|
|
59
|
+
raw = base64.b64decode(bundle_b64, validate=True)
|
|
60
|
+
except (binascii.Error, ValueError) as decode_error:
|
|
61
|
+
# DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
|
|
62
|
+
log.debug("A bundle's base64 could not be decoded", fields=error_fields(exc=decode_error))
|
|
63
|
+
raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
archive = zipfile.ZipFile(BytesIO(raw))
|
|
67
|
+
except zipfile.BadZipFile as zip_error:
|
|
68
|
+
# DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
|
|
69
|
+
log.debug("A bundle is not a valid zip archive", fields=error_fields(exc=zip_error))
|
|
70
|
+
raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
|
|
71
|
+
|
|
72
|
+
entries: list[tuple[str, bytes]] = []
|
|
73
|
+
total_bytes = 0
|
|
74
|
+
with archive:
|
|
75
|
+
members = [info for info in archive.infolist() if not info.is_dir()]
|
|
76
|
+
_guard_count(len(members))
|
|
77
|
+
budget = MAX_BUNDLE_TOTAL_BYTES
|
|
78
|
+
for info in members:
|
|
79
|
+
# Bounded read: pull at most (remaining budget + 1) bytes so a zip bomb whose header
|
|
80
|
+
# under-reports its size still cannot decompress past the ceiling.
|
|
81
|
+
with archive.open(info) as member:
|
|
82
|
+
data = member.read(budget + 1)
|
|
83
|
+
total_bytes += len(data)
|
|
84
|
+
_guard_running_total(total_bytes)
|
|
85
|
+
budget = MAX_BUNDLE_TOTAL_BYTES - total_bytes
|
|
86
|
+
entries.append((info.filename, data))
|
|
87
|
+
return entries
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Decode the files a request sends in `resources`, the files its bundle's steps read, such as a template file.
|
|
2
|
+
|
|
3
|
+
`resources` is a JSON object mapping each file's bundle-relative path, the exact path a step names (a `PipeDocGen`
|
|
4
|
+
step's `template_file`), to the file's bytes in standard base64. Beside `mthds_contents`, every bundle of the request is
|
|
5
|
+
read as sitting in one directory, so one path holds one file, and the decoded files go to the runtime as its
|
|
6
|
+
`submitted_files`, keyed by canonical path, which refuses a file none of the bundle's steps names once the bundle is
|
|
7
|
+
parsed, and holds the rest as the files of the tree the bundle sits at the root of. Beside a method tree (`files` or
|
|
8
|
+
`bundle_b64`), the decoded files join the tree at their paths from its root (`pipelex_api.method_tree_request`), where
|
|
9
|
+
the tree's rule judges them as it judges the tree's own files.
|
|
10
|
+
|
|
11
|
+
Refusals, in the order they are checked:
|
|
12
|
+
|
|
13
|
+
- more `resources` than `MAX_RESOURCES_PER_REQUEST` → 413 `PayloadTooLarge`;
|
|
14
|
+
- a path that cannot name a file inside the bundle (absolute, a `..` segment, a backslash, a colon, empty, or a
|
|
15
|
+
second spelling of a path already submitted) → 422 `InvalidResourcePath`, before any value is decoded;
|
|
16
|
+
- a file whose encoded length already exceeds the per-file ceiling → 413 `PayloadTooLarge`, before decoding;
|
|
17
|
+
- a value that is not valid base64 → 400 `InvalidBase64`, as `bundle_b64` is;
|
|
18
|
+
- a decoded file over `MAX_RESOURCE_FILE_BYTES`, or a running total over `MAX_RESOURCES_TOTAL_BYTES` → 413.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import binascii
|
|
23
|
+
from typing import NoReturn
|
|
24
|
+
|
|
25
|
+
from pipelex import log
|
|
26
|
+
from pipelex.core.bundle_resources.bundle_resource import canonical_resource_path
|
|
27
|
+
from pipelex.core.bundle_resources.exceptions import BundleResourcePathError
|
|
28
|
+
from pipelex.tools.log.error_fields import error_fields
|
|
29
|
+
|
|
30
|
+
from pipelex_api.error_types import ErrorType
|
|
31
|
+
from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
|
|
32
|
+
from pipelex_api.limits import MAX_RESOURCE_FILE_BYTES, MAX_RESOURCES_PER_REQUEST, MAX_RESOURCES_TOTAL_BYTES
|
|
33
|
+
|
|
34
|
+
# Base64 inflates by 4/3, so a file within the ceiling encodes to at most this many characters (+4 covers padding).
|
|
35
|
+
# Checking the encoded length first refuses an oversized file before it is decoded into memory.
|
|
36
|
+
_MAX_RESOURCE_B64_CHARS = MAX_RESOURCE_FILE_BYTES * 4 // 3 + 4
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def refuse_two_files_under_one_path(*, path: str, where: str) -> NoReturn:
|
|
40
|
+
"""Refuse two different files sent under one path, naming the path, with a 422 `InvalidResourcePath`."""
|
|
41
|
+
msg = (
|
|
42
|
+
f"Two different files are named '{path}', {where}. A path names one file of a method: send one of them, or rename "
|
|
43
|
+
"one and the step that reads it."
|
|
44
|
+
)
|
|
45
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_RESOURCE_PATH)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decode_bundle_resources(*, resources: dict[str, str] | None) -> dict[str, bytes] | None:
|
|
49
|
+
"""The files `resources` sends, decoded and keyed by canonical path, or None when there are none."""
|
|
50
|
+
if not resources:
|
|
51
|
+
return None
|
|
52
|
+
if len(resources) > MAX_RESOURCES_PER_REQUEST:
|
|
53
|
+
raise_payload_too_large(message=f"resources exceeds the {MAX_RESOURCES_PER_REQUEST}-file limit (got {len(resources)})")
|
|
54
|
+
# Every path first, so a second spelling of one file is refused as such before any value is decoded.
|
|
55
|
+
sent_by_path: dict[str, tuple[str, str]] = {}
|
|
56
|
+
for path, encoded in resources.items():
|
|
57
|
+
try:
|
|
58
|
+
canonical_path = canonical_resource_path(path=path)
|
|
59
|
+
except BundleResourcePathError as exc:
|
|
60
|
+
raise_validation_error(message=exc.message, error_type=ErrorType.INVALID_RESOURCE_PATH)
|
|
61
|
+
if canonical_path in sent_by_path:
|
|
62
|
+
msg = f"Two files submitted with the bundle name the same file '{canonical_path}'."
|
|
63
|
+
raise_validation_error(message=msg, error_type=ErrorType.INVALID_RESOURCE_PATH)
|
|
64
|
+
sent_by_path[canonical_path] = (path, encoded)
|
|
65
|
+
files: dict[str, bytes] = {}
|
|
66
|
+
total_bytes = 0
|
|
67
|
+
for canonical_path, (path, encoded) in sent_by_path.items():
|
|
68
|
+
if len(encoded) > _MAX_RESOURCE_B64_CHARS:
|
|
69
|
+
raise_payload_too_large(message=f"The file '{path}' in resources exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit")
|
|
70
|
+
try:
|
|
71
|
+
data = base64.b64decode(encoded, validate=True)
|
|
72
|
+
except (binascii.Error, ValueError) as decode_error:
|
|
73
|
+
# DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
|
|
74
|
+
log.debug("A submitted file's base64 could not be decoded", fields=error_fields(exc=decode_error))
|
|
75
|
+
raise_bad_request(message=f"The file '{path}' in resources is not valid base64", error_type=ErrorType.INVALID_BASE64)
|
|
76
|
+
if len(data) > MAX_RESOURCE_FILE_BYTES:
|
|
77
|
+
raise_payload_too_large(
|
|
78
|
+
message=f"The file '{canonical_path}' a step reads exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit"
|
|
79
|
+
)
|
|
80
|
+
total_bytes += len(data)
|
|
81
|
+
if total_bytes > MAX_RESOURCES_TOTAL_BYTES:
|
|
82
|
+
raise_payload_too_large(message=f"The files the bundle's steps read exceed the {MAX_RESOURCES_TOTAL_BYTES // 1024} KiB total-size limit")
|
|
83
|
+
files[canonical_path] = data
|
|
84
|
+
return files
|
|
@@ -58,9 +58,34 @@ class ErrorType(StrEnum):
|
|
|
58
58
|
INVALID_MODEL_REFERENCE = "InvalidModelReference"
|
|
59
59
|
INVALID_BASE64 = "InvalidBase64"
|
|
60
60
|
PAYLOAD_TOO_LARGE = "PayloadTooLarge"
|
|
61
|
-
# A run request carried a malformed method bundle (`bundle_b64` / `files`): a corrupt zip,
|
|
62
|
-
#
|
|
61
|
+
# A run request carried a malformed method bundle (`bundle_b64` / `files`): a corrupt zip, a
|
|
62
|
+
# bundle with no `.mthds` file, or both transport forms supplied at once.
|
|
63
63
|
INVALID_BUNDLE = "InvalidBundle"
|
|
64
|
+
# A request's map of file entries (`files`, a tree of `dependencies`, a `bundle_b64` archive read as
|
|
65
|
+
# one) is not a method tree the MTHDS standard admits: a key given twice, a key that is not a tree
|
|
66
|
+
# path or names a file the membership rule keeps out of every tree, keys that collide, an empty map,
|
|
67
|
+
# or a malformed entry. A 422 whose `tree_refusal` member carries the standard's refusal code and
|
|
68
|
+
# the paths it names.
|
|
69
|
+
INVALID_METHOD_FILES = "InvalidMethodFiles"
|
|
70
|
+
# A request names, by hash, bytes this runner's blob store does not hold at the stated size. A 422
|
|
71
|
+
# whose `missing_blobs` member lists every such hash at once, so a sender learns in one answer what to
|
|
72
|
+
# upload (`POST /v1/blobs/missing`, then `PUT /v1/blobs/{sha256}`).
|
|
73
|
+
MISSING_BLOBS = "MissingBlobs"
|
|
74
|
+
# A method tree, or a blob, passes one of this runner's limits on a tree's file count, its total
|
|
75
|
+
# size or one file's size (`MAX_METHOD_TREE_FILES`, `MAX_METHOD_TREE_TOTAL_KIB`,
|
|
76
|
+
# `MAX_METHOD_TREE_FILE_KIB`). A 422 naming the limit, refused before any blob is read or uploaded.
|
|
77
|
+
METHOD_TREE_TOO_LARGE = "MethodTreeTooLarge"
|
|
78
|
+
# `PUT /v1/blobs/{sha256}` was sent bytes whose SHA-256 is not the one its path names,
|
|
79
|
+
# `POST /v1/blobs/missing` named one hash twice with different sizes, or a request's hash entry states a size other
|
|
80
|
+
# than the one this runner holds that hash's bytes at, which uploading them again would not change. A 422 whose
|
|
81
|
+
# `mismatched_blobs` member, on a request's entries, lists each such hash with the size held.
|
|
82
|
+
BLOB_MISMATCH = "BlobMismatch"
|
|
83
|
+
# The bytes this runner's blob store holds under a hash do not hash to it: the storage is damaged. A 500 no caller
|
|
84
|
+
# can repair; the operator removes the object, and the blob is then uploaded again.
|
|
85
|
+
BLOB_STORE_CORRUPTED = "BlobStoreCorrupted"
|
|
86
|
+
# `PUT /v1/blobs/{sha256}` was called without the signed query `POST /v1/blobs/missing` names in its upload
|
|
87
|
+
# target, with one whose signature does not hold, or past its expiry. A 403: the upload is not stored.
|
|
88
|
+
BLOB_UPLOAD_NOT_AUTHORIZED = "BlobUploadNotAuthorized"
|
|
64
89
|
# A request's `resources` map keys a submitted file by a path that cannot name a file inside the
|
|
65
90
|
# bundle: absolute, with a `..` segment, a backslash or a colon, empty, or a second spelling of a
|
|
66
91
|
# path already submitted. A 422: the key is the path a step names, relative to the bundle.
|
|
@@ -15,6 +15,7 @@ are the single translation point for everything the API does not author
|
|
|
15
15
|
itself.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
from collections.abc import Mapping
|
|
18
19
|
from typing import Any, NoReturn
|
|
19
20
|
|
|
20
21
|
from pipelex.base_exceptions import ErrorDomain
|
|
@@ -49,6 +50,7 @@ def _raise_api_error(
|
|
|
49
50
|
status: int,
|
|
50
51
|
error_domain: ErrorDomain,
|
|
51
52
|
headers: dict[str, str] | None = None,
|
|
53
|
+
members: Mapping[str, Any] | None = None,
|
|
52
54
|
) -> NoReturn:
|
|
53
55
|
"""Build the RFC 7807 document and raise `ApiError`.
|
|
54
56
|
|
|
@@ -67,6 +69,9 @@ def _raise_api_error(
|
|
|
67
69
|
request_id=None,
|
|
68
70
|
error_domain=error_domain,
|
|
69
71
|
)
|
|
72
|
+
if members:
|
|
73
|
+
# Extension members of RFC 7807 (section 3.2), such as `missing_blobs`: data a client acts on, beside the prose.
|
|
74
|
+
document.update(members)
|
|
70
75
|
raise ApiError(status_code=status, document=document, headers=headers)
|
|
71
76
|
|
|
72
77
|
|
|
@@ -75,6 +80,15 @@ def raise_validation_error(message: str, error_type: ErrorType = ErrorType.VALID
|
|
|
75
80
|
_raise_api_error(error_type=error_type, message=message, status=422, error_domain=ErrorDomain.INPUT)
|
|
76
81
|
|
|
77
82
|
|
|
83
|
+
def raise_validation_problem(*, message: str, error_type: ErrorType, members: Mapping[str, Any]) -> NoReturn:
|
|
84
|
+
"""Raise a 422 RFC 7807 problem response for invalid caller input, carrying extension members a client acts on.
|
|
85
|
+
|
|
86
|
+
The members join the document beside `detail`, such as the `missing_blobs` a sender uploads before it sends the
|
|
87
|
+
request again, or the `tree_refusal` naming the standard's refusal code and the paths it names.
|
|
88
|
+
"""
|
|
89
|
+
_raise_api_error(error_type=error_type, message=message, status=422, error_domain=ErrorDomain.INPUT, members=members)
|
|
90
|
+
|
|
91
|
+
|
|
78
92
|
def raise_bad_request(message: str, error_type: ErrorType = ErrorType.BAD_REQUEST) -> NoReturn:
|
|
79
93
|
"""Raise a 400 RFC 7807 problem response for a malformed request."""
|
|
80
94
|
_raise_api_error(error_type=error_type, message=message, status=400, error_domain=ErrorDomain.INPUT)
|
|
@@ -341,11 +341,6 @@ _ERROR_TYPE_STATUS_OVERRIDES: dict[str, int] = {
|
|
|
341
341
|
# sandboxed PipeFuncs, not in-process Python) → 403, the same status as the bundle
|
|
342
342
|
# transport's custom-code gate.
|
|
343
343
|
"MethodStructuresRefusedError": 403,
|
|
344
|
-
# The files submitted in `resources` that the bundle's steps read when the method loads, its HTML templates,
|
|
345
|
-
# total more than they may, since they travel inline with the method wherever it runs. Only the parsed bundle
|
|
346
|
-
# says which files those are, so the library refuses them rather than this server's decoding: the request
|
|
347
|
-
# entity itself is too large → 413, as the decoder's own size refusals are.
|
|
348
|
-
"BundleResourcesTooLargeError": 413,
|
|
349
344
|
}
|
|
350
345
|
|
|
351
346
|
|
|
@@ -23,11 +23,20 @@ MAX_METHOD_REF_LEN = 512 # `method_ref` selector strings; a fixed schema bound,
|
|
|
23
23
|
MAX_JSON_NESTING_DEPTH = 128
|
|
24
24
|
DEFAULT_MAX_CALLBACK_URLS = 5
|
|
25
25
|
DEFAULT_MAX_CALLBACK_URL_LEN = 2048
|
|
26
|
-
DEFAULT_MAX_BUNDLE_FILES = 128 #
|
|
26
|
+
DEFAULT_MAX_BUNDLE_FILES = 128 # members of a `bundle_b64` archive, its shipped packages' files included
|
|
27
27
|
DEFAULT_MAX_BUNDLE_TOTAL_KIB = 8 * 1024 # 8 MiB decompressed across the whole bundle (zip-bomb guard)
|
|
28
28
|
DEFAULT_MAX_RESOURCES_PER_REQUEST = 32 # files submitted with a bundle that its steps read (`resources`)
|
|
29
29
|
DEFAULT_MAX_RESOURCE_FILE_KIB = 5 * 1024 # 5 MiB decoded per submitted file, such as an office template
|
|
30
30
|
DEFAULT_MAX_RESOURCES_TOTAL_KIB = 10 * 1024 # 10 MiB decoded across a request's submitted files
|
|
31
|
+
# A method tree a request sends (`files`, each tree of `dependencies`, a `bundle_b64` archive) or fetches by `method_ref`,
|
|
32
|
+
# whatever form its files travel in: inline, Base64 or by hash. The request body bounds only the inline entries.
|
|
33
|
+
DEFAULT_MAX_METHOD_TREE_FILES = 1000
|
|
34
|
+
DEFAULT_MAX_METHOD_TREE_TOTAL_KIB = 64 * 1024 # 64 MiB across the files of one tree
|
|
35
|
+
DEFAULT_MAX_METHOD_TREE_FILE_KIB = 32 * 1024 # 32 MiB for one file of a tree, and for one blob uploaded
|
|
36
|
+
# Every tree one request carries together, the method's own and its dependency trees, beside each tree's own limits.
|
|
37
|
+
DEFAULT_MAX_METHOD_DEPENDENCIES = 32 # dependency trees beside the method's own
|
|
38
|
+
DEFAULT_MAX_METHOD_REQUEST_TOTAL_KIB = 128 * 1024 # 128 MiB across the method's tree and its dependency trees
|
|
39
|
+
DEFAULT_BLOB_UPLOAD_URL_TTL_SECONDS = 3600 # how long an upload target `POST /v1/blobs/missing` names stays valid
|
|
31
40
|
DEFAULT_MAX_METHOD_CACHE_CLONES = 64 # cached method-package clones (one per resolved commit SHA)
|
|
32
41
|
DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB = 512 * 1024 # 512 MiB across all cached clones
|
|
33
42
|
DEFAULT_MAX_METHOD_CACHE_AGE_HOURS = 24 # a cached clone unused for this long is evicted
|
|
@@ -50,6 +59,15 @@ MAX_RESOURCES_PER_REQUEST = get_positive_int_env(env_var="MAX_RESOURCES_PER_REQU
|
|
|
50
59
|
MAX_RESOURCE_FILE_BYTES = get_positive_int_env(env_var="MAX_RESOURCE_FILE_KIB", default=DEFAULT_MAX_RESOURCE_FILE_KIB) * 1024
|
|
51
60
|
MAX_RESOURCES_TOTAL_BYTES = get_positive_int_env(env_var="MAX_RESOURCES_TOTAL_KIB", default=DEFAULT_MAX_RESOURCES_TOTAL_KIB) * 1024
|
|
52
61
|
|
|
62
|
+
MAX_METHOD_TREE_FILES = get_positive_int_env(env_var="MAX_METHOD_TREE_FILES", default=DEFAULT_MAX_METHOD_TREE_FILES)
|
|
63
|
+
MAX_METHOD_TREE_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_TREE_TOTAL_KIB", default=DEFAULT_MAX_METHOD_TREE_TOTAL_KIB) * 1024
|
|
64
|
+
MAX_METHOD_TREE_FILE_BYTES = get_positive_int_env(env_var="MAX_METHOD_TREE_FILE_KIB", default=DEFAULT_MAX_METHOD_TREE_FILE_KIB) * 1024
|
|
65
|
+
|
|
66
|
+
MAX_METHOD_DEPENDENCIES = get_positive_int_env(env_var="MAX_METHOD_DEPENDENCIES", default=DEFAULT_MAX_METHOD_DEPENDENCIES)
|
|
67
|
+
MAX_METHOD_REQUEST_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_REQUEST_TOTAL_KIB", default=DEFAULT_MAX_METHOD_REQUEST_TOTAL_KIB) * 1024
|
|
68
|
+
|
|
69
|
+
BLOB_UPLOAD_URL_TTL_SECONDS = get_positive_int_env(env_var="BLOB_UPLOAD_URL_TTL_SECONDS", default=DEFAULT_BLOB_UPLOAD_URL_TTL_SECONDS)
|
|
70
|
+
|
|
53
71
|
MAX_METHOD_CACHE_CLONES = get_positive_int_env(env_var="MAX_METHOD_CACHE_CLONES", default=DEFAULT_MAX_METHOD_CACHE_CLONES)
|
|
54
72
|
MAX_METHOD_CACHE_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_CACHE_TOTAL_KIB", default=DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB) * 1024
|
|
55
73
|
MAX_METHOD_CACHE_AGE_SECONDS = get_positive_int_env(env_var="MAX_METHOD_CACHE_AGE_HOURS", default=DEFAULT_MAX_METHOD_CACHE_AGE_HOURS) * 3600
|
|
@@ -31,6 +31,7 @@ from pipelex_api.exception_handlers import register_exception_handlers
|
|
|
31
31
|
from pipelex_api.middleware import RequestIdMiddleware, request_body_size_middleware
|
|
32
32
|
from pipelex_api.openapi_schema import PipelexFastAPI
|
|
33
33
|
from pipelex_api.routes import router as api_router
|
|
34
|
+
from pipelex_api.routes.blob_upload import router as blob_upload_router
|
|
34
35
|
from pipelex_api.routes.health import router as health_router
|
|
35
36
|
from pipelex_api.routes.version import router as version_router
|
|
36
37
|
from pipelex_api.security import get_auth_dependency
|
|
@@ -187,6 +188,11 @@ fastapi_app.include_router(health_router)
|
|
|
187
188
|
# detection before they have credentials).
|
|
188
189
|
fastapi_app.include_router(version_router, prefix="/v1")
|
|
189
190
|
|
|
191
|
+
# `PUT /v1/blobs/{sha256}` is authorized by the signed query of the upload target `POST /v1/blobs/missing` names
|
|
192
|
+
# (`pipelex_api.blob_upload`), so a sender uploads with exactly the headers that target names: it mounts without the
|
|
193
|
+
# auth dependency, and only a caller who passed it on `POST /v1/blobs/missing` holds a target.
|
|
194
|
+
fastapi_app.include_router(blob_upload_router, prefix="/v1")
|
|
195
|
+
|
|
190
196
|
# Register all other routes WITH authentication, the dependency selected by the AUTH_MODE env var. An
|
|
191
197
|
# unrecognized AUTH_MODE raises `InvalidAuthModeError` here and the app fails to boot, the same fail-fast
|
|
192
198
|
# posture as ERROR_DISCLOSURE above: a mistyped mode must never serve these routes unauthenticated.
|