pipelex-api 0.89.0__tar.gz → 0.91.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.89.0 → pipelex_api-0.91.0}/PKG-INFO +9 -8
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/README.md +7 -6
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/api_config.py +13 -7
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/blob_store.py +66 -16
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/blob_upload.py +7 -7
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/bundle.py +39 -17
- pipelex_api-0.91.0/pipelex_api/bundle_resources.py +95 -0
- pipelex_api-0.91.0/pipelex_api/exception_handlers.py +747 -0
- pipelex_api-0.91.0/pipelex_api/exceptions.py +289 -0
- pipelex_api-0.91.0/pipelex_api/json_body.py +301 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/limits.py +16 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/main.py +11 -19
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/method_cache.py +9 -5
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/method_source.py +18 -12
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/method_tree_request.py +211 -115
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/middleware.py +28 -36
- pipelex_api-0.91.0/pipelex_api/openapi_responses.py +225 -0
- pipelex_api-0.91.0/pipelex_api/openapi_schema.py +180 -0
- pipelex_api-0.91.0/pipelex_api/problem_document.py +81 -0
- pipelex_api-0.91.0/pipelex_api/problem_view.py +35 -0
- pipelex_api-0.91.0/pipelex_api/request_refusals.py +126 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/blob_upload.py +22 -15
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/agent/models.py +18 -24
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/blobs.py +11 -8
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/codegen.py +15 -12
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/crate_ops.py +138 -72
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/method_selection.py +12 -9
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/pipe_io.py +19 -17
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/pipeline.py +229 -154
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/resolve.py +17 -13
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/tools.py +4 -3
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/validate.py +111 -73
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/version.py +3 -3
- pipelex_api-0.91.0/pipelex_api/run_extras_binding.py +54 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/schemas/models.py +119 -51
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/security.py +56 -32
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pyproject.toml +4 -1
- pipelex_api-0.89.0/pipelex_api/bundle_resources.py +0 -84
- pipelex_api-0.89.0/pipelex_api/disclosure.py +0 -43
- pipelex_api-0.89.0/pipelex_api/error_types.py +0 -116
- pipelex_api-0.89.0/pipelex_api/error_uri.py +0 -45
- pipelex_api-0.89.0/pipelex_api/errors.py +0 -144
- pipelex_api-0.89.0/pipelex_api/exception_handlers.py +0 -688
- pipelex_api-0.89.0/pipelex_api/json_body.py +0 -182
- pipelex_api-0.89.0/pipelex_api/openapi_responses.py +0 -207
- pipelex_api-0.89.0/pipelex_api/openapi_schema.py +0 -83
- pipelex_api-0.89.0/pipelex_api/problem_document.py +0 -134
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/.gitignore +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/LICENSE +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/hatch_build.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/__init__.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/api.toml +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/py.typed +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/__init__.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/health.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/utils.py +0 -0
- {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/schemas/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pipelex-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.91.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.91.0
|
|
20
20
|
Requires-Dist: pyjwt>=2.10.1
|
|
21
21
|
Requires-Dist: uvicorn>=0.37.0
|
|
22
22
|
Provides-Extra: dev
|
|
@@ -115,26 +115,27 @@ The API is now running at `http://localhost:8081`. To customize behavior (enable
|
|
|
115
115
|
|
|
116
116
|
# 🧪 Run your first pipeline
|
|
117
117
|
|
|
118
|
-
Once `/health` is green, send
|
|
118
|
+
Once `/health` is green, send a method and its inputs to `/v1/execute`. The method travels in `files`, a map from each file's path in the method's folder to that file's content. The example below summarizes a string with a method made of a single `.mthds` file, with no auth, ready to copy and paste:
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
121
|
curl -s http://localhost:8081/v1/execute \
|
|
122
122
|
-H "Content-Type: application/json" \
|
|
123
123
|
-d '{
|
|
124
|
-
"
|
|
125
|
-
|
|
124
|
+
"files": {
|
|
125
|
+
"summarize.mthds": "domain = \"hello\"\nmain_pipe = \"summarize\"\n\n[pipe.summarize]\ntype = \"PipeLLM\"\ndescription = \"Summarize the input text in one sentence\"\ninputs = { text = \"Text\" }\noutput = \"Text\"\nprompt = \"Summarize in one sentence:\\n@text\"\n"
|
|
126
|
+
},
|
|
126
127
|
"inputs": { "text": "Pipelex turns plain-language pipeline definitions into reproducible AI workflows that run as HTTP endpoints." }
|
|
127
128
|
}'
|
|
128
129
|
```
|
|
129
130
|
|
|
130
|
-
You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`.
|
|
131
|
+
You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`. The run starts at the method's `main_pipe`; to start at another pipe of the method, name it in `pipe_code`.
|
|
131
132
|
|
|
132
133
|
**Passing files (PDFs, images) as inputs.** Use the `Document` concept and point it at any HTTP(S) URL:
|
|
133
134
|
|
|
134
135
|
```json
|
|
135
136
|
{
|
|
136
137
|
"pipe_code": "your_pipe",
|
|
137
|
-
"
|
|
138
|
+
"files": { "your_method.mthds": "...your MTHDS..." },
|
|
138
139
|
"inputs": {
|
|
139
140
|
"cv": { "concept": "Document", "content": { "url": "https://example.com/resume.pdf" } }
|
|
140
141
|
}
|
|
@@ -143,7 +144,7 @@ You'll get back a JSON response with `state: "COMPLETED"` and the summary under
|
|
|
143
144
|
|
|
144
145
|
`Document` accepts public HTTP/HTTPS URLs, `pipelex-storage://` URIs, or base64 data URLs. For images, use the `Image` concept with the same `{ "url": "..." }` shape.
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
**Sending the method.** `files` holds the whole method, as the MTHDS standard's method tree: each key is a file's path relative to the method's folder, such as `summarize.mthds` or `templates/invoice.docx`, and each value is that file's content. A text file, such as a `.mthds` bundle or a Python module, is sent as its UTF-8 text in a JSON string, and a file that is not text, such as an office template or an image, as `{"base64": "..."}`. This server also keeps a blob store, which `GET /v1/version` reports as `blob_store: true`, so a client can send a file as `{"sha256": "...", "size": n}` instead: it asks `POST /v1/blobs/missing` which of its files the server lacks, uploads each of those to the target the answer names, and then sends the method with a hash entry for each file, so a method that has not changed is not uploaded again. The older `mthds_contents` field, a JSON array of `.mthds` texts, is still accepted, but it is being retired together with `bundle_b64` and `resources`, and a later release refuses them, so new clients send `files`. See the [Pipe Run page](https://docs.pipelex.com/latest/api-server/pipe-run/) for every supported input shape, the rules of [a method sent as a tree](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-a-method-as-a-tree) and of [sending files by hash](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-files-by-hash), and the full `/execute` reference.
|
|
147
148
|
|
|
148
149
|
# 📈 How to scale Pipelex
|
|
149
150
|
|
|
@@ -69,26 +69,27 @@ The API is now running at `http://localhost:8081`. To customize behavior (enable
|
|
|
69
69
|
|
|
70
70
|
# 🧪 Run your first pipeline
|
|
71
71
|
|
|
72
|
-
Once `/health` is green, send
|
|
72
|
+
Once `/health` is green, send a method and its inputs to `/v1/execute`. The method travels in `files`, a map from each file's path in the method's folder to that file's content. The example below summarizes a string with a method made of a single `.mthds` file, with no auth, ready to copy and paste:
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
75
|
curl -s http://localhost:8081/v1/execute \
|
|
76
76
|
-H "Content-Type: application/json" \
|
|
77
77
|
-d '{
|
|
78
|
-
"
|
|
79
|
-
|
|
78
|
+
"files": {
|
|
79
|
+
"summarize.mthds": "domain = \"hello\"\nmain_pipe = \"summarize\"\n\n[pipe.summarize]\ntype = \"PipeLLM\"\ndescription = \"Summarize the input text in one sentence\"\ninputs = { text = \"Text\" }\noutput = \"Text\"\nprompt = \"Summarize in one sentence:\\n@text\"\n"
|
|
80
|
+
},
|
|
80
81
|
"inputs": { "text": "Pipelex turns plain-language pipeline definitions into reproducible AI workflows that run as HTTP endpoints." }
|
|
81
82
|
}'
|
|
82
83
|
```
|
|
83
84
|
|
|
84
|
-
You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`.
|
|
85
|
+
You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`. The run starts at the method's `main_pipe`; to start at another pipe of the method, name it in `pipe_code`.
|
|
85
86
|
|
|
86
87
|
**Passing files (PDFs, images) as inputs.** Use the `Document` concept and point it at any HTTP(S) URL:
|
|
87
88
|
|
|
88
89
|
```json
|
|
89
90
|
{
|
|
90
91
|
"pipe_code": "your_pipe",
|
|
91
|
-
"
|
|
92
|
+
"files": { "your_method.mthds": "...your MTHDS..." },
|
|
92
93
|
"inputs": {
|
|
93
94
|
"cv": { "concept": "Document", "content": { "url": "https://example.com/resume.pdf" } }
|
|
94
95
|
}
|
|
@@ -97,7 +98,7 @@ You'll get back a JSON response with `state: "COMPLETED"` and the summary under
|
|
|
97
98
|
|
|
98
99
|
`Document` accepts public HTTP/HTTPS URLs, `pipelex-storage://` URIs, or base64 data URLs. For images, use the `Image` concept with the same `{ "url": "..." }` shape.
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
**Sending the method.** `files` holds the whole method, as the MTHDS standard's method tree: each key is a file's path relative to the method's folder, such as `summarize.mthds` or `templates/invoice.docx`, and each value is that file's content. A text file, such as a `.mthds` bundle or a Python module, is sent as its UTF-8 text in a JSON string, and a file that is not text, such as an office template or an image, as `{"base64": "..."}`. This server also keeps a blob store, which `GET /v1/version` reports as `blob_store: true`, so a client can send a file as `{"sha256": "...", "size": n}` instead: it asks `POST /v1/blobs/missing` which of its files the server lacks, uploads each of those to the target the answer names, and then sends the method with a hash entry for each file, so a method that has not changed is not uploaded again. The older `mthds_contents` field, a JSON array of `.mthds` texts, is still accepted, but it is being retired together with `bundle_b64` and `resources`, and a later release refuses them, so new clients send `files`. See the [Pipe Run page](https://docs.pipelex.com/latest/api-server/pipe-run/) for every supported input shape, the rules of [a method sent as a tree](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-a-method-as-a-tree) and of [sending files by hash](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-files-by-hash), and the full `/execute` reference.
|
|
101
102
|
|
|
102
103
|
# 📈 How to scale Pipelex
|
|
103
104
|
|
|
@@ -30,8 +30,7 @@ from pipelex.runtime_bridge.orchestration_mode import DIRECT_ORCHESTRATION_MODE
|
|
|
30
30
|
from pipelex.system.configuration.config_loader import config_manager
|
|
31
31
|
from pydantic import BaseModel, ConfigDict
|
|
32
32
|
|
|
33
|
-
from pipelex_api.
|
|
34
|
-
from pipelex_api.errors import raise_forbidden
|
|
33
|
+
from pipelex_api.exceptions import ApiBootConfigError, OrchestrationOverrideForbiddenError
|
|
35
34
|
|
|
36
35
|
API_CONFIG_NAME = "api"
|
|
37
36
|
|
|
@@ -79,7 +78,7 @@ def get_api_config() -> ApiConfig:
|
|
|
79
78
|
The config is immutable for the life of the process (``PIPELEX_ENV`` is fixed
|
|
80
79
|
at boot), so it is loaded once and cached. ``pipelex_api.main`` warms this at startup
|
|
81
80
|
so a malformed ``api.toml`` / baked override fails the app fast — the same
|
|
82
|
-
fail-fast posture as ``
|
|
81
|
+
fail-fast posture as ``AUTH_MODE``. Tests that need a different mode
|
|
83
82
|
patch this getter (or call :func:`resolve_orchestration_mode` with a hand-built
|
|
84
83
|
config) rather than mutating the cache.
|
|
85
84
|
"""
|
|
@@ -94,7 +93,7 @@ def resolve_orchestration_mode(requested: str | None, *, config: ApiConfig) -> s
|
|
|
94
93
|
override (``allow_request_orchestration_mode_override``). A caller-supplied
|
|
95
94
|
token equal to the default is always honored (it changes nothing). A caller
|
|
96
95
|
trying to FORCE a different mode on a runner whose policy forbids it is refused
|
|
97
|
-
with a
|
|
96
|
+
with a 422 ``pipelex.deployment.orchestration_override_forbidden`` — so a locked-down Temporal runner can never be coerced into
|
|
98
97
|
``direct`` (whose whole point would be to bypass distributed execution), and
|
|
99
98
|
vice versa. The token is a plain string compare; an *unregistered* token is
|
|
100
99
|
not rejected here — that surfaces at dispatch as ``MissingOrchestratorError``.
|
|
@@ -107,11 +106,18 @@ def resolve_orchestration_mode(requested: str | None, *, config: ApiConfig) -> s
|
|
|
107
106
|
f"This deployment does not allow overriding orchestration_mode per request "
|
|
108
107
|
f"(configured mode '{config.orchestration_mode}', requested '{requested}')."
|
|
109
108
|
)
|
|
110
|
-
|
|
109
|
+
raise OrchestrationOverrideForbiddenError(msg, requested_mode=requested)
|
|
111
110
|
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
"""
|
|
112
|
+
def is_callers_override(*, orchestration_mode: str, config: ApiConfig) -> bool:
|
|
113
|
+
"""Whether a resolved orchestration mode is a per-request override the caller named, not the deployment's default.
|
|
114
|
+
|
|
115
|
+
:func:`resolve_orchestration_mode` returns the default unless the caller named a different mode the policy
|
|
116
|
+
allows, so a resolved mode other than the default is always the caller's. A refusal of such a mode, such as
|
|
117
|
+
one no installed plugin serves, is the caller's to change (``runtime.unsupported``), where the same refusal of
|
|
118
|
+
the default is the operator's (``pipelex.plugin.not_found``).
|
|
119
|
+
"""
|
|
120
|
+
return orchestration_mode != config.orchestration_mode
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
def resolve_boot_orchestrator(config: ApiConfig) -> str | None:
|
|
@@ -36,14 +36,17 @@ From this the blob store answers without reading what it does not need:
|
|
|
36
36
|
- `POST /v1/blobs/missing` lists a hash this runner does not hold at the size the sender states, an object held at
|
|
37
37
|
another size included. The upload then tells the two cases apart, holding the bytes: bytes of the size held are the
|
|
38
38
|
ones held, and nothing is written, while bytes of another size mean the object held is damaged, which only a storage
|
|
39
|
-
that does not publish writes whole could leave, a 500 `
|
|
40
|
-
operator must remove the object.
|
|
39
|
+
that does not publish writes whole could leave, a 500 `pipelex.storage.damaged`, since nothing writes over a blob
|
|
40
|
+
and its operator must remove the object.
|
|
41
41
|
- A run looks up each hash's stored size first. A hash held at another size than an entry states is a 422
|
|
42
|
-
`
|
|
42
|
+
`request.invalid`, never `request.blobs_missing`, since uploading those bytes again would change nothing: the entry
|
|
43
|
+
is wrong.
|
|
43
44
|
Only a blob held at the size its entries state is read, so the bytes read are bounded by the sizes the request
|
|
44
45
|
states, which its census already held to the tree's limits, and each is checked against its hash once read. Bytes
|
|
45
|
-
that do not hash to their key mean the
|
|
46
|
-
`
|
|
46
|
+
that do not hash to their key mean the object stored under it is damaged, which no caller can repair and no retry
|
|
47
|
+
cures: a 500 `pipelex.storage.damaged` naming the kind of storage, whose verdict is not to retry, and an error
|
|
48
|
+
record, "Stored blob bytes do not match their hash", naming the hash for the operator to remove the object, after
|
|
49
|
+
which the blob is uploaded again.
|
|
47
50
|
|
|
48
51
|
Lookups and reads run a bounded number at a time, so one request naming many blobs never opens them all at once.
|
|
49
52
|
"""
|
|
@@ -54,15 +57,17 @@ from functools import partial
|
|
|
54
57
|
from mthds.method_tree.digest import DIGEST_PREFIX, sha256_hex, tree_listing
|
|
55
58
|
from mthds.method_tree.file_entry import BlobStore, InMemoryBlobStore
|
|
56
59
|
from mthds.method_tree.models import MethodTree
|
|
60
|
+
from pipelex import log
|
|
61
|
+
from pipelex.config import get_config
|
|
57
62
|
from pipelex.core.method_trees.exceptions import MethodFileUnavailableError
|
|
58
63
|
from pipelex.core.method_trees.method_files import TreeListing
|
|
59
64
|
from pipelex.runtime_hub import get_storage_provider
|
|
60
65
|
from pipelex.tools.misc.async_utils import gather_bounded
|
|
61
66
|
from pipelex.tools.storage.exceptions import StorageFileNotFoundError
|
|
67
|
+
from pipelex.tools.storage.storage_config import StorageMethod
|
|
62
68
|
from pipelex.tools.storage.storage_provider_abstract import PIPELEX_STORAGE_SCHEME, StorageProviderAbstract
|
|
63
69
|
|
|
64
|
-
from pipelex_api.
|
|
65
|
-
from pipelex_api.errors import raise_internal_server_error
|
|
70
|
+
from pipelex_api.exceptions import BlobStoreDamagedError
|
|
66
71
|
|
|
67
72
|
BLOBS_PREFIX = "blobs"
|
|
68
73
|
TREES_PREFIX = "trees"
|
|
@@ -72,19 +77,53 @@ _LISTING_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
|
72
77
|
# request runs at a time.
|
|
73
78
|
MAX_CONCURRENT_BLOB_LOOKUPS = 16
|
|
74
79
|
MAX_CONCURRENT_BLOB_TRANSFERS = 4
|
|
80
|
+
# The message of the error record a blob read whose bytes do not match their hash writes, for the operator to search.
|
|
81
|
+
BLOB_DAMAGED_MESSAGE = "Stored blob bytes do not match their hash"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# The kind of storage a `pipelex.storage.*` problem names for a storage plugin's own backend.
|
|
85
|
+
PLUGIN_STORAGE_SCHEME = "plugin"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def problem_scheme_of(*, storage_method: str) -> str:
|
|
89
|
+
"""The kind of storage a `pipelex.storage.*` problem names for a configured storage method, as the registry spells it.
|
|
90
|
+
|
|
91
|
+
`plugin` for a storage plugin's own method, whose kind the registry cannot list.
|
|
92
|
+
"""
|
|
93
|
+
match storage_method:
|
|
94
|
+
case StorageMethod.LOCAL:
|
|
95
|
+
return "local"
|
|
96
|
+
case StorageMethod.IN_MEMORY:
|
|
97
|
+
return "memory"
|
|
98
|
+
case StorageMethod.S3:
|
|
99
|
+
return "s3"
|
|
100
|
+
case StorageMethod.GCP:
|
|
101
|
+
return "gcs"
|
|
102
|
+
case _:
|
|
103
|
+
# An external storage plugin's method token: the configured method is an open string.
|
|
104
|
+
return PLUGIN_STORAGE_SCHEME
|
|
75
105
|
|
|
76
106
|
|
|
77
107
|
class RunnerBlobStore:
|
|
78
108
|
"""The blobs and tree listings of one caller's scope, over the deployment's storage provider."""
|
|
79
109
|
|
|
80
|
-
def __init__(self, *, scope: str, storage: StorageProviderAbstract) -> None:
|
|
110
|
+
def __init__(self, *, scope: str, storage: StorageProviderAbstract, scheme: str) -> None:
|
|
111
|
+
"""Build the blob store of one scope.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
scope: The prefix of the scope's blobs and listings.
|
|
115
|
+
storage: The storage provider holding them.
|
|
116
|
+
scheme: The kind of storage the provider is, as a `pipelex.storage.*` problem names it (`problem_scheme_of`).
|
|
117
|
+
"""
|
|
81
118
|
self._scope = scope
|
|
82
119
|
self._storage = storage
|
|
120
|
+
self._scheme = scheme
|
|
83
121
|
|
|
84
122
|
@classmethod
|
|
85
123
|
def make_for_scope(cls, *, scope: str) -> "RunnerBlobStore":
|
|
86
124
|
"""The blob store of `scope`, over the storage provider the runtime booted with."""
|
|
87
|
-
|
|
125
|
+
scheme = problem_scheme_of(storage_method=get_config().runtime.storage.method)
|
|
126
|
+
return cls(scope=scope, storage=get_storage_provider(), scheme=scheme)
|
|
88
127
|
|
|
89
128
|
@property
|
|
90
129
|
def scope(self) -> str:
|
|
@@ -119,15 +158,21 @@ class RunnerBlobStore:
|
|
|
119
158
|
async def _read_verified(self, *, sha256: str, size: int) -> bytes:
|
|
120
159
|
"""The stored bytes of `sha256`, held at `size`, checked against their hash.
|
|
121
160
|
|
|
122
|
-
Raises (as a problem document): a 500 `
|
|
161
|
+
Raises (as a problem document): a 500 `pipelex.storage.damaged` naming the kind of storage, for bytes that
|
|
162
|
+
are not `size` bytes hashing to `sha256`, after an error record naming the hash, the one place the operator
|
|
163
|
+
reads which damaged object to remove.
|
|
123
164
|
"""
|
|
124
165
|
data = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
|
|
125
166
|
if len(data) != size or sha256_hex(data) != sha256:
|
|
167
|
+
log.error(
|
|
168
|
+
BLOB_DAMAGED_MESSAGE,
|
|
169
|
+
fields={"blob_sha256": sha256, "blob_size_bytes": size, "read_size_bytes": len(data), "storage_scheme": self._scheme},
|
|
170
|
+
)
|
|
126
171
|
msg = (
|
|
127
|
-
f"The bytes this runner's blob store holds under {sha256} do not hash to it:
|
|
128
|
-
"
|
|
172
|
+
f"The bytes this runner's blob store holds under {sha256} do not hash to it: the storage holds a damaged "
|
|
173
|
+
"object, which its operator removes for the blob to be uploaded again."
|
|
129
174
|
)
|
|
130
|
-
|
|
175
|
+
raise BlobStoreDamagedError(msg, scheme=self._scheme)
|
|
131
176
|
return data
|
|
132
177
|
|
|
133
178
|
async def read_held(self, *, sizes: Mapping[str, int]) -> BlobStore:
|
|
@@ -149,8 +194,9 @@ class RunnerBlobStore:
|
|
|
149
194
|
free, with its hash as its checksum: a writer that created it meanwhile wrote the same bytes, and the key
|
|
150
195
|
holding an object is the blob held.
|
|
151
196
|
|
|
152
|
-
Raises (as a problem document): a 500 `
|
|
153
|
-
hash, which only damaged storage could hold and which this runner never writes
|
|
197
|
+
Raises (as a problem document): a 500 `pipelex.storage.damaged` naming the kind of storage when an object of
|
|
198
|
+
another size is held under the hash, which only damaged storage could hold and which this runner never writes
|
|
199
|
+
over, after the error record naming the hash that a read of damaged bytes writes.
|
|
154
200
|
"""
|
|
155
201
|
sha256 = sha256_hex(data)
|
|
156
202
|
await self._store_hashed_blob(sha256=sha256, data=data)
|
|
@@ -164,11 +210,15 @@ class RunnerBlobStore:
|
|
|
164
210
|
data=data, key=self._blob_key(sha256=sha256), content_type=_BLOB_CONTENT_TYPE, checksum_sha256_hex=sha256
|
|
165
211
|
)
|
|
166
212
|
elif held_size != len(data):
|
|
213
|
+
log.error(
|
|
214
|
+
BLOB_DAMAGED_MESSAGE,
|
|
215
|
+
fields={"blob_sha256": sha256, "blob_size_bytes": held_size, "read_size_bytes": len(data), "storage_scheme": self._scheme},
|
|
216
|
+
)
|
|
167
217
|
msg = (
|
|
168
218
|
f"This runner's blob store holds {held_size} bytes under {sha256}, whose bytes are {len(data)} long: its "
|
|
169
219
|
"storage is damaged, and its operator must remove that object for the blob to be uploaded again."
|
|
170
220
|
)
|
|
171
|
-
|
|
221
|
+
raise BlobStoreDamagedError(msg, scheme=self._scheme)
|
|
172
222
|
|
|
173
223
|
async def holds_tree(self, *, tree_digest: str) -> bool:
|
|
174
224
|
"""Whether the listing of the tree `tree_digest` names is held, which means every file of the tree is."""
|
|
@@ -5,8 +5,9 @@ must say that the upload is allowed, as a presigned URL does on an object store.
|
|
|
5
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
6
|
HMAC-SHA256 signature over the scope, the hash and the expiry. The upload route checks the signature and the expiry,
|
|
7
7
|
writes under the scope the signature binds and nowhere else, and answers a missing, forged or expired signature with a
|
|
8
|
-
403 `
|
|
9
|
-
deployment's authentication on `POST /v1/blobs/missing` holds a target, for one hash, in
|
|
8
|
+
403 `auth.forbidden` whose `requirement` is a signed upload target. The upload route therefore needs no bearer token:
|
|
9
|
+
only a caller who passed the deployment's authentication on `POST /v1/blobs/missing` holds a target, for one hash, in
|
|
10
|
+
its own scope, for a while.
|
|
10
11
|
|
|
11
12
|
The key is `BLOB_UPLOAD_SIGNING_KEY` when the deployment sets it, which it must when several instances serve one
|
|
12
13
|
deployment, since a target named by one instance is checked by whichever instance receives the upload; otherwise each
|
|
@@ -28,8 +29,7 @@ from urllib.parse import urlencode, urlsplit
|
|
|
28
29
|
from fastapi import Request
|
|
29
30
|
from pipelex.system.environment import get_optional_env
|
|
30
31
|
|
|
31
|
-
from pipelex_api.
|
|
32
|
-
from pipelex_api.errors import raise_internal_server_error
|
|
32
|
+
from pipelex_api.exceptions import RunnerMisconfiguredError
|
|
33
33
|
from pipelex_api.limits import BLOB_UPLOAD_URL_TTL_SECONDS
|
|
34
34
|
|
|
35
35
|
PUBLIC_BASE_URL_ENV = "PUBLIC_BASE_URL"
|
|
@@ -62,8 +62,8 @@ def signed_upload_query(*, scope: str, sha256: str) -> str:
|
|
|
62
62
|
def _public_base_url() -> str | None:
|
|
63
63
|
"""`PUBLIC_BASE_URL` without its trailing slash, or None when unset.
|
|
64
64
|
|
|
65
|
-
Raises (as a problem document): a 500 `
|
|
66
|
-
without a query or a fragment.
|
|
65
|
+
Raises (as a problem document): a 500 `runtime.misconfigured` naming `PUBLIC_BASE_URL`, for a value that is not an
|
|
66
|
+
absolute http or https URL without a query or a fragment.
|
|
67
67
|
"""
|
|
68
68
|
configured = get_optional_env(PUBLIC_BASE_URL_ENV)
|
|
69
69
|
if not configured:
|
|
@@ -71,7 +71,7 @@ def _public_base_url() -> str | None:
|
|
|
71
71
|
parts = urlsplit(configured)
|
|
72
72
|
if parts.scheme not in {"http", "https"} or not parts.netloc or parts.query or parts.fragment:
|
|
73
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
|
-
|
|
74
|
+
raise RunnerMisconfiguredError(msg, component=PUBLIC_BASE_URL_ENV)
|
|
75
75
|
return configured.rstrip("/")
|
|
76
76
|
|
|
77
77
|
|
|
@@ -16,30 +16,31 @@ import binascii
|
|
|
16
16
|
import zipfile
|
|
17
17
|
from io import BytesIO
|
|
18
18
|
|
|
19
|
+
from mthds.protocol.problem.registry import CoreCode, RequestMalformedReason
|
|
19
20
|
from pipelex import log
|
|
21
|
+
from pipelex.methods.package_reason import MethodPackageReason
|
|
20
22
|
from pipelex.tools.log.error_fields import error_fields
|
|
21
23
|
|
|
22
|
-
from pipelex_api.
|
|
23
|
-
from pipelex_api.
|
|
24
|
-
from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
|
|
24
|
+
from pipelex_api.exceptions import RequestMethodError, RunnerRequestError
|
|
25
|
+
from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES, base64_decoded_size
|
|
25
26
|
|
|
26
|
-
#
|
|
27
|
-
|
|
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
|
|
27
|
+
# The request field the archive rides, which the refusals of its encoding name.
|
|
28
|
+
_BUNDLE_FIELD = "bundle_b64"
|
|
31
29
|
|
|
32
30
|
|
|
33
31
|
def _guard_count(count: int) -> None:
|
|
34
32
|
if count == 0:
|
|
35
|
-
|
|
33
|
+
msg = "Bundle is empty (no files)"
|
|
34
|
+
raise RunnerRequestError(msg, field=_BUNDLE_FIELD)
|
|
36
35
|
if count > MAX_BUNDLE_FILES:
|
|
37
|
-
|
|
36
|
+
msg = f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})"
|
|
37
|
+
raise RequestMethodError(msg).with_params(reason=MethodPackageReason.BUNDLE_TOO_MANY_FILES, limit=MAX_BUNDLE_FILES)
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def _guard_running_total(total_bytes: int) -> None:
|
|
41
41
|
if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
|
|
42
|
-
|
|
42
|
+
msg = f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit"
|
|
43
|
+
raise RequestMethodError(msg).with_params(reason=MethodPackageReason.BUNDLE_TOO_LARGE, limit=MAX_BUNDLE_TOTAL_BYTES)
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def decode_bundle_archive(*, bundle_b64: str) -> list[tuple[str, bytes]]:
|
|
@@ -50,24 +51,45 @@ def decode_bundle_archive(*, bundle_b64: str) -> list[tuple[str, bytes]]:
|
|
|
50
51
|
cheap length check on the still-encoded string runs FIRST, so an oversized payload is refused before it is buffered
|
|
51
52
|
into memory as decoded bytes. A name the archive gives twice is returned twice, for the tree's reader to refuse.
|
|
52
53
|
|
|
53
|
-
Raises (as problem documents): an
|
|
54
|
-
|
|
54
|
+
Raises (as problem documents): an archive past the size limit (413 `request.too_large`, its limit and its size in
|
|
55
|
+
bytes, the size read off the encoded length), a value that is
|
|
56
|
+
not base64 or not a zip archive (422 `request.malformed`), an archive holding no file (422 `request.invalid`), and
|
|
57
|
+
one past the limit on its files or on its decompressed size (422 `method.package_invalid`).
|
|
55
58
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
# A zip is compressed, so a bundle whose decompressed content is within the ceiling is an archive well under it. Its
|
|
60
|
+
# size is read off the encoded length, so a ~100 MiB request body, the only other bound, is refused before it is
|
|
61
|
+
# expanded into ~75 MiB of heap just to be refused later by the decompressed-size guard.
|
|
62
|
+
archive_bytes = base64_decoded_size(encoded=bundle_b64)
|
|
63
|
+
if archive_bytes > MAX_BUNDLE_TOTAL_BYTES:
|
|
64
|
+
msg = f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit"
|
|
65
|
+
raise (
|
|
66
|
+
RunnerRequestError(msg, field=_BUNDLE_FIELD)
|
|
67
|
+
.with_error_code(CoreCode.REQUEST_TOO_LARGE)
|
|
68
|
+
.with_params(limit=MAX_BUNDLE_TOTAL_BYTES, size=archive_bytes)
|
|
69
|
+
)
|
|
58
70
|
try:
|
|
59
71
|
raw = base64.b64decode(bundle_b64, validate=True)
|
|
60
72
|
except (binascii.Error, ValueError) as decode_error:
|
|
61
73
|
# DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
|
|
62
74
|
log.debug("A bundle's base64 could not be decoded", fields=error_fields(exc=decode_error))
|
|
63
|
-
|
|
75
|
+
msg = "bundle_b64 is not valid base64"
|
|
76
|
+
raise (
|
|
77
|
+
RunnerRequestError(msg, field=_BUNDLE_FIELD)
|
|
78
|
+
.with_error_code(CoreCode.REQUEST_MALFORMED)
|
|
79
|
+
.with_params(reason=RequestMalformedReason.BAD_BASE64)
|
|
80
|
+
) from decode_error
|
|
64
81
|
|
|
65
82
|
try:
|
|
66
83
|
archive = zipfile.ZipFile(BytesIO(raw))
|
|
67
84
|
except zipfile.BadZipFile as zip_error:
|
|
68
85
|
# DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
|
|
69
86
|
log.debug("A bundle is not a valid zip archive", fields=error_fields(exc=zip_error))
|
|
70
|
-
|
|
87
|
+
msg = "bundle_b64 is not a valid zip archive"
|
|
88
|
+
raise (
|
|
89
|
+
RunnerRequestError(msg, field=_BUNDLE_FIELD)
|
|
90
|
+
.with_error_code(CoreCode.REQUEST_MALFORMED)
|
|
91
|
+
.with_params(reason=RequestMalformedReason.BAD_ZIP)
|
|
92
|
+
) from zip_error
|
|
71
93
|
|
|
72
94
|
entries: list[tuple[str, bytes]] = []
|
|
73
95
|
total_bytes = 0
|
|
@@ -0,0 +1,95 @@
|
|
|
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` → 422 `request.invalid`, naming the field;
|
|
14
|
+
- a path that leaves the bundle (absolute, a drive, a `..` segment) → 422 `security.input_path_refused`, with the rule
|
|
15
|
+
it breaks, before any value is decoded, which also logs a security event, as a key of a method tree does;
|
|
16
|
+
- a path of the wrong shape (a backslash, a colon, empty, or a second spelling of a path already submitted) → 422
|
|
17
|
+
`request.invalid`, naming the field;
|
|
18
|
+
- a file whose size, read off its encoded length, exceeds `MAX_RESOURCE_FILE_BYTES` → 413 `request.too_large`, before
|
|
19
|
+
decoding;
|
|
20
|
+
- a value that is not valid base64 → 422 `request.malformed`, as `bundle_b64` is;
|
|
21
|
+
- a running total of decoded files over `MAX_RESOURCES_TOTAL_BYTES` → 413 `request.too_large`.
|
|
22
|
+
|
|
23
|
+
Every `request.too_large` states its `limit` and its `size` in bytes, never in encoded characters.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import binascii
|
|
28
|
+
from typing import NoReturn
|
|
29
|
+
|
|
30
|
+
from mthds.protocol.problem.registry import CoreCode, RequestMalformedReason
|
|
31
|
+
from pipelex import log
|
|
32
|
+
from pipelex.core.bundle_resources.bundle_resource import SUBMITTED_FILES_FIELD, canonical_resource_path
|
|
33
|
+
from pipelex.tools.log.error_fields import error_fields
|
|
34
|
+
|
|
35
|
+
from pipelex_api.exceptions import RunnerRequestError
|
|
36
|
+
from pipelex_api.limits import MAX_RESOURCE_FILE_BYTES, MAX_RESOURCES_PER_REQUEST, MAX_RESOURCES_TOTAL_BYTES, base64_decoded_size
|
|
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 `request.invalid` on `resources`."""
|
|
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 RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _too_large(*, message: str, limit: int, size: int) -> RunnerRequestError:
|
|
49
|
+
"""The refusal of submitted files past one of this runner's byte limits, a 413 `request.too_large`."""
|
|
50
|
+
return RunnerRequestError(message, field=SUBMITTED_FILES_FIELD).with_error_code(CoreCode.REQUEST_TOO_LARGE).with_params(limit=limit, size=size)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decode_bundle_resources(*, resources: dict[str, str] | None) -> dict[str, bytes] | None:
|
|
54
|
+
"""The files `resources` sends, decoded and keyed by canonical path, or None when there are none."""
|
|
55
|
+
if not resources:
|
|
56
|
+
return None
|
|
57
|
+
if len(resources) > MAX_RESOURCES_PER_REQUEST:
|
|
58
|
+
# A count, not bytes, so not `request.too_large`, whose parameters are sizes in bytes: the field is refused.
|
|
59
|
+
msg = f"resources exceeds the {MAX_RESOURCES_PER_REQUEST}-file limit (got {len(resources)})"
|
|
60
|
+
raise RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
|
|
61
|
+
# Every path first, so a second spelling of one file is refused as such before any value is decoded. A path that
|
|
62
|
+
# leaves the bundle is a security rule's refusal (`security.input_path_refused`), whose security event the error
|
|
63
|
+
# handler logs; one of the wrong shape is the request's (`request.invalid`), and logs none. Both propagate as raised.
|
|
64
|
+
sent_by_path: dict[str, tuple[str, str]] = {}
|
|
65
|
+
for path, encoded in resources.items():
|
|
66
|
+
canonical_path = canonical_resource_path(path=path)
|
|
67
|
+
if canonical_path in sent_by_path:
|
|
68
|
+
msg = f"Two files submitted with the bundle name the same file '{canonical_path}'."
|
|
69
|
+
raise RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
|
|
70
|
+
sent_by_path[canonical_path] = (path, encoded)
|
|
71
|
+
files: dict[str, bytes] = {}
|
|
72
|
+
total_bytes = 0
|
|
73
|
+
for canonical_path, (path, encoded) in sent_by_path.items():
|
|
74
|
+
# Read off the encoded length, so an oversized file is refused before it is decoded into memory.
|
|
75
|
+
file_bytes = base64_decoded_size(encoded=encoded)
|
|
76
|
+
if file_bytes > MAX_RESOURCE_FILE_BYTES:
|
|
77
|
+
msg = f"The file '{path}' in resources exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit"
|
|
78
|
+
raise _too_large(message=msg, limit=MAX_RESOURCE_FILE_BYTES, size=file_bytes)
|
|
79
|
+
try:
|
|
80
|
+
data = base64.b64decode(encoded, validate=True)
|
|
81
|
+
except (binascii.Error, ValueError) as decode_error:
|
|
82
|
+
# DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
|
|
83
|
+
log.debug("A submitted file's base64 could not be decoded", fields=error_fields(exc=decode_error))
|
|
84
|
+
msg = f"The file '{path}' in resources is not valid base64"
|
|
85
|
+
raise (
|
|
86
|
+
RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
|
|
87
|
+
.with_error_code(CoreCode.REQUEST_MALFORMED)
|
|
88
|
+
.with_params(reason=RequestMalformedReason.BAD_BASE64)
|
|
89
|
+
) from decode_error
|
|
90
|
+
total_bytes += len(data)
|
|
91
|
+
if total_bytes > MAX_RESOURCES_TOTAL_BYTES:
|
|
92
|
+
msg = f"The files the bundle's steps read exceed the {MAX_RESOURCES_TOTAL_BYTES // 1024} KiB total-size limit"
|
|
93
|
+
raise _too_large(message=msg, limit=MAX_RESOURCES_TOTAL_BYTES, size=total_bytes)
|
|
94
|
+
files[canonical_path] = data
|
|
95
|
+
return files
|