@fireemu/darwin-arm64 0.43.0 → 0.45.0
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.
- package/README.md +1 -1
- package/bin/fireemu +2 -2
- package/bin/runner-node/index.mjs +25 -3
- package/bin/runner-python/fireemu_runner/appcheck.py +255 -0
- package/bin/runner-python/fireemu_runner/bodies.py +96 -0
- package/bin/runner-python/fireemu_runner/diagnostics.py +36 -0
- package/bin/runner-python/fireemu_runner/dispatch.py +63 -26
- package/bin/runner-python/fireemu_runner/environment.py +119 -0
- package/bin/runner-python/fireemu_runner/events.py +126 -28
- package/bin/runner-python/fireemu_runner/framing.py +51 -1
- package/bin/runner-python/fireemu_runner/main.py +20 -10
- package/bin/runner-python/fireemu_runner/manifest.py +91 -8
- package/bin/runner-python/fireemu_runner/request_view.py +65 -0
- package/bin/runner-python/fireemu_runner/scheduler.py +87 -0
- package/bin/runner-python/fireemu_runner/serve.py +83 -11
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The HTTP request a CloudEvent function runs inside.
|
|
2
|
+
|
|
3
|
+
functions-framework serves a CloudEvent function from a Flask view and calls the function inside the request context, so
|
|
4
|
+
`flask.request` shows the delivered request. Production's raw twins were recorded on 2026-11-04 (sandbox run
|
|
5
|
+
PYCHECK-EVENTS-20261104-01): `POST /` with the binary-mode `ce-*` headers, `content-type`, `content-length` and, in the strict
|
|
6
|
+
profile, constants every recorded frame carries (the sender's user agent, the encodings, the protocol) plus Cloud Run's plain
|
|
7
|
+
`traceparent` header. The body is the text the delivery had, not a re-encoding of the event: the layouts recorded per family are in
|
|
8
|
+
`bodies.py` (strict Pub/Sub and Storage; Eventarc keeps the publisher's text), and the emulator and unset profiles carry the framed JSON value text
|
|
9
|
+
of the data as the daemon sent it (a publisher's surrounding white space is trimmed upstream; a Pub/Sub body is the exception, it gets the SDK's snake-case aliases and is rewritten compactly). A Realtime Database body was not recorded. Header policy: a name that is not an HTTP token (or has an underscore) is
|
|
10
|
+
left out of the request, a control character other than TAB in a value becomes a space, and of two names that differ only in case the first one
|
|
11
|
+
is the header; the event the function receives is never changed by the view. The OIDC `Authorization`, `Forwarded`, `X-Forwarded-For`, `Function-Execution-Id`, `X-Cloud-Trace-Context`,
|
|
12
|
+
`From`, `Accept` and the Cloud Run host have no local source and are not invented.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
import flask
|
|
19
|
+
from werkzeug.test import EnvironBuilder
|
|
20
|
+
|
|
21
|
+
from .events import is_strict
|
|
22
|
+
|
|
23
|
+
_APP = flask.Flask("fireemu-cloudevent")
|
|
24
|
+
# What an HTTP header may be called: a token without an underscore. Header names with an underscore are dropped by the usual
|
|
25
|
+
# servers, and Werkzeug folds `foo_bar` into `foo-bar`; both are UNRECORDED, so such an attribute stays out of the request
|
|
26
|
+
# (the event keeps it).
|
|
27
|
+
_HEADER_NAME = re.compile(r"[!#$%&'*+.^`|~0-9A-Za-z-]+")
|
|
28
|
+
# A control character other than the tab cannot be in a header value; it becomes a space, as a line break does.
|
|
29
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
|
30
|
+
# Constant in every recorded Eventarc delivery (the four raw twins of run 57ab27296dedaedc).
|
|
31
|
+
_STRICT_CONSTANTS = {
|
|
32
|
+
"user-agent": "APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)",
|
|
33
|
+
"accept-encoding": "gzip, deflate, br",
|
|
34
|
+
"x-forwarded-proto": "https",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def request_headers(headers: dict, event: dict) -> dict:
|
|
39
|
+
"""The headers of the request: the event's binary-mode headers, and in the strict profile the recorded constants and the
|
|
40
|
+
trace context as the plain `traceparent` header.
|
|
41
|
+
|
|
42
|
+
The request is what a server would have received, so a name that is not a header name is left out, and when two names
|
|
43
|
+
differ only in case the first one stays. A control character in a value other than the tab, a line break included, is
|
|
44
|
+
replaced by a space. The event the function receives keeps every attribute and its text.
|
|
45
|
+
"""
|
|
46
|
+
view = {}
|
|
47
|
+
taken = set()
|
|
48
|
+
for name, value in headers.items():
|
|
49
|
+
if _HEADER_NAME.fullmatch(name) and name.lower() not in taken:
|
|
50
|
+
taken.add(name.lower())
|
|
51
|
+
view[name] = _CONTROL.sub(" ", value)
|
|
52
|
+
if is_strict():
|
|
53
|
+
view.update(_STRICT_CONSTANTS)
|
|
54
|
+
traceparent = event.get("traceparent")
|
|
55
|
+
if isinstance(traceparent, str):
|
|
56
|
+
view["traceparent"] = _CONTROL.sub(" ", traceparent)
|
|
57
|
+
return view
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextlib.contextmanager
|
|
61
|
+
def delivery_request(headers: dict, body: bytes, event: dict):
|
|
62
|
+
"""Runs the block inside the Flask request context of the delivery described by `headers` and `body`."""
|
|
63
|
+
environ = EnvironBuilder(method="POST", path="/", headers=request_headers(headers, event), data=body).get_environ()
|
|
64
|
+
with _APP.request_context(environ):
|
|
65
|
+
yield
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Scheduled runs: the request Cloud Scheduler sends a Gen2 function, built in process.
|
|
2
|
+
|
|
3
|
+
A Gen2 `on_schedule` function is an HTTP function. Production's Scheduler calls it with `POST /`, the job id in
|
|
4
|
+
`X-CloudScheduler-JobName` and the schedule time in `X-CloudScheduler-ScheduleTime`, and judges the attempt by the
|
|
5
|
+
status the SDK wrapper answers: 200, or 500 when the handler raised (the wrapper logs the error and answers its
|
|
6
|
+
message, `scheduler_fn.py:99-137`). The Node runner does the same in the strict profile
|
|
7
|
+
(`tools/runner-node/schedule-delivery.mjs`, recorded in run 156715222b86ea44).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from zoneinfo import ZoneInfo
|
|
14
|
+
|
|
15
|
+
import flask
|
|
16
|
+
from werkzeug.test import EnvironBuilder
|
|
17
|
+
|
|
18
|
+
_INSTANT = re.compile(r"^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d{1,9}))?Z$")
|
|
19
|
+
_LOS_ANGELES = ZoneInfo("America/Los_Angeles")
|
|
20
|
+
_JOB_NAME = re.compile(r"projects/([^/]*)/locations/([^/]*)/jobs/")
|
|
21
|
+
_NAME = re.compile(r"[A-Za-z0-9-]+")
|
|
22
|
+
_MAX_ANSWER_CHARS = 256
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SchedulerFailure(Exception):
|
|
26
|
+
"""A scheduled attempt that failed: the run was malformed or the function answered 400 or more."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _schedule_time(instant) -> str:
|
|
30
|
+
"""The schedule time as the header carries it: Los Angeles with its offset in the strict profile (whatever the
|
|
31
|
+
job's own zone, as recorded), the daemon's UTC instant otherwise."""
|
|
32
|
+
match = _INSTANT.match(instant) if isinstance(instant, str) else None
|
|
33
|
+
if match is None:
|
|
34
|
+
raise SchedulerFailure(f"the scheduled time is not an RFC 3339 UTC instant: {instant!r}")
|
|
35
|
+
year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
|
|
36
|
+
try:
|
|
37
|
+
moment = datetime.datetime(year, month, day, hour, minute, second, tzinfo=datetime.timezone.utc)
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise SchedulerFailure(f"the scheduled time is not an RFC 3339 UTC instant: {instant!r}") from None
|
|
40
|
+
if os.environ.get("FIREEMU_HTTP_PROFILE", "emulator") != "strict":
|
|
41
|
+
return instant
|
|
42
|
+
local = moment.astimezone(_LOS_ANGELES)
|
|
43
|
+
fraction = (match.group(7) or "").rstrip("0")
|
|
44
|
+
offset = local.strftime("%z")
|
|
45
|
+
return local.strftime("%Y-%m-%dT%H:%M:%S") + (f".{fraction}" if fraction else "") + f"{offset[:3]}:{offset[3:]}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _function_host(job: str) -> str | None:
|
|
49
|
+
"""The public host of a function, `<region>-<project>.cloudfunctions.net`, from a job's resource name.
|
|
50
|
+
|
|
51
|
+
Recorded for us-central1 only (all 76 distinct Gen2 frames of run 156715222b86ea44); another region follows the
|
|
52
|
+
same rule, unrecorded. A name without a project and a location in this form invents no host (the Node runner's
|
|
53
|
+
`functionHost`).
|
|
54
|
+
"""
|
|
55
|
+
match = _JOB_NAME.match(job)
|
|
56
|
+
if match is None or not _NAME.fullmatch(match.group(1)) or not _NAME.fullmatch(match.group(2)):
|
|
57
|
+
return None
|
|
58
|
+
return f"{match.group(2)}-{match.group(1)}.cloudfunctions.net"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scheduler_headers(data) -> dict:
|
|
62
|
+
"""The headers of the request for one scheduled run (`{jobName, scheduleTime}`)."""
|
|
63
|
+
job = data.get("jobName") if isinstance(data, dict) else None
|
|
64
|
+
if not isinstance(job, str) or not job:
|
|
65
|
+
raise SchedulerFailure("the scheduled run names no job name")
|
|
66
|
+
host = _function_host(job)
|
|
67
|
+
return {
|
|
68
|
+
"x-cloudscheduler": "true",
|
|
69
|
+
"x-cloudscheduler-jobname": job.rsplit("/", 1)[-1],
|
|
70
|
+
"x-cloudscheduler-scheduletime": _schedule_time(data.get("scheduleTime")),
|
|
71
|
+
"user-agent": "Google-Cloud-Scheduler",
|
|
72
|
+
"content-length": "0",
|
|
73
|
+
# Constant in every recorded frame: the front end's encodings and protocol.
|
|
74
|
+
"accept-encoding": "gzip, deflate, br",
|
|
75
|
+
"x-forwarded-proto": "https",
|
|
76
|
+
**({"host": host} if host else {}),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def deliver_schedule(function, data) -> None:
|
|
81
|
+
"""Calls the SDK wrapper with the scheduler's request. Returns when it answers below 400; raises otherwise."""
|
|
82
|
+
environ = EnvironBuilder(method="POST", path="/", headers=scheduler_headers(data)).get_environ()
|
|
83
|
+
with flask.Flask("fireemu-scheduler").request_context(environ):
|
|
84
|
+
response = function(flask.request)
|
|
85
|
+
if response.status_code >= 400:
|
|
86
|
+
body = response.get_data(as_text=True)[:_MAX_ANSWER_CHARS]
|
|
87
|
+
raise SchedulerFailure(f"the scheduled function answered {response.status_code}" + (f": {body}" if body else ""))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The HTTP server for `onRequest` and
|
|
1
|
+
"""The HTTP server for `onRequest`, `onCall`, task queue and Auth blocking functions.
|
|
2
2
|
|
|
3
3
|
The daemon proxies `/<project>/<region>/<name>/...` here with the `x-fireemu-runner-secret` header
|
|
4
4
|
(`crates/fireemu-adapter-functions/src/http.rs`, `tools/runner-node/http-admission.mjs`). The
|
|
@@ -6,16 +6,22 @@ function sees the path below its mount point, as it does in production, and answ
|
|
|
6
6
|
`flask.Request` handlers the SDK wraps. An unhandled error is functions-framework's crash response.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import contextlib
|
|
9
10
|
import hmac
|
|
10
11
|
import os
|
|
12
|
+
import sys
|
|
11
13
|
|
|
12
14
|
import flask
|
|
13
15
|
from functions_framework import crash_handler, read_request
|
|
14
16
|
from werkzeug.serving import WSGIRequestHandler, make_server
|
|
15
17
|
|
|
18
|
+
from .diagnostics import ASYNC_HANDLER, describe, is_async_handler
|
|
19
|
+
from .environment import EnvironmentGate
|
|
16
20
|
from .manifest import DEFAULT_REGION
|
|
17
21
|
|
|
18
22
|
SECRET_ENVIRON_KEY = "HTTP_X_FIREEMU_RUNNER_SECRET"
|
|
23
|
+
# Task queue and Auth blocking functions enter through the HTTP wrapper as well (the Node runner's `signatureType`).
|
|
24
|
+
HTTP_TRIGGERS = frozenset(("http", "tasks", "blockingAuth"))
|
|
19
25
|
_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
20
26
|
|
|
21
27
|
|
|
@@ -33,20 +39,68 @@ def _flask_app() -> flask.Flask:
|
|
|
33
39
|
return app
|
|
34
40
|
|
|
35
41
|
|
|
42
|
+
class ResponseBodyFailed(Exception):
|
|
43
|
+
"""A response body failed while it was being sent. The details were logged inside the function's environment."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _log_failure(name: str, error: BaseException) -> None:
|
|
47
|
+
diagnostic, _ = describe(error)
|
|
48
|
+
sys.stderr.write(f"[functions] {name}: {diagnostic[:8192]}\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _HeldBody:
|
|
52
|
+
"""A response body that keeps its function's environment until the body is finished, whatever happens to it.
|
|
53
|
+
|
|
54
|
+
Closing the body, and running the callbacks `Response.call_on_close` registered, is user code: if it raises the
|
|
55
|
+
gate must still be released, or the process is locked with a secret in its environment. Errors are described
|
|
56
|
+
here, while the environment is still held, and the server sees only a plain exception.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, body, held: contextlib.ExitStack, name: str):
|
|
60
|
+
self._body = body
|
|
61
|
+
self._iterator = iter(body)
|
|
62
|
+
self._held = held
|
|
63
|
+
self._name = name
|
|
64
|
+
|
|
65
|
+
def __iter__(self):
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __next__(self):
|
|
69
|
+
try:
|
|
70
|
+
return next(self._iterator)
|
|
71
|
+
except StopIteration:
|
|
72
|
+
raise
|
|
73
|
+
except BaseException as error:
|
|
74
|
+
_log_failure(self._name, error)
|
|
75
|
+
raise ResponseBodyFailed(type(error).__name__) from None
|
|
76
|
+
|
|
77
|
+
def close(self):
|
|
78
|
+
try:
|
|
79
|
+
close = getattr(self._body, "close", None)
|
|
80
|
+
if close is not None:
|
|
81
|
+
close()
|
|
82
|
+
except BaseException as error: # the response has been sent; there is nobody to tell but the log
|
|
83
|
+
_log_failure(self._name, error)
|
|
84
|
+
finally:
|
|
85
|
+
self._held.close()
|
|
86
|
+
|
|
87
|
+
|
|
36
88
|
class RunnerRouter:
|
|
37
89
|
"""Admission and routing in front of the Flask app."""
|
|
38
90
|
|
|
39
|
-
def __init__(self, functions: dict, manifest: dict, project: str, secret: str):
|
|
91
|
+
def __init__(self, functions: dict, manifest: dict, project: str, secret: str, gate: EnvironmentGate | None = None):
|
|
40
92
|
self._app = _flask_app()
|
|
93
|
+
self._gate = gate if gate is not None else EnvironmentGate({})
|
|
41
94
|
self._project = project
|
|
42
95
|
self._secret = secret.encode()
|
|
43
96
|
self._routes = {}
|
|
44
97
|
for spec in manifest["functions"]:
|
|
45
|
-
if spec["trigger"]["type"]
|
|
98
|
+
if spec["trigger"]["type"] in HTTP_TRIGGERS:
|
|
46
99
|
self._routes[spec["name"]] = (
|
|
47
100
|
spec.get("region", DEFAULT_REGION),
|
|
48
101
|
functions[spec["entryPoint"]],
|
|
49
102
|
spec["entryPoint"],
|
|
103
|
+
spec.get("platformOptions", {}).get("secrets"),
|
|
50
104
|
)
|
|
51
105
|
|
|
52
106
|
@staticmethod
|
|
@@ -57,6 +111,10 @@ class RunnerRouter:
|
|
|
57
111
|
|
|
58
112
|
def __call__(self, environ, start_response):
|
|
59
113
|
presented = environ.pop(SECRET_ENVIRON_KEY, "").encode()
|
|
114
|
+
if not self._secret:
|
|
115
|
+
# A runner with no secret would accept any request without the header (an empty value equals an empty
|
|
116
|
+
# secret). The Node runner answers every request this way (`http-admission.mjs`).
|
|
117
|
+
return self._plain(start_response, "500 Internal Server Error", "FIREEMU_RUNNER_SECRET is required")
|
|
60
118
|
if not hmac.compare_digest(presented, self._secret):
|
|
61
119
|
return self._plain(start_response, "403 Forbidden", "forbidden")
|
|
62
120
|
parts = environ.get("PATH_INFO", "").split("/")
|
|
@@ -70,12 +128,26 @@ class RunnerRouter:
|
|
|
70
128
|
environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + "/".join(parts[:4])
|
|
71
129
|
environ["PATH_INFO"] = "/" + "/".join(parts[4:])
|
|
72
130
|
environ["fireemu.function"] = route[1]
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
if is_async_handler(route[1]):
|
|
132
|
+
# Production answers 500 as well (the coroutine is not a response); this says why.
|
|
133
|
+
sys.stderr.write(f"[functions] {name}: {ASYNC_HANDLER}\n")
|
|
134
|
+
return self._plain(start_response, "500 Internal Server Error", ASYNC_HANDLER)
|
|
135
|
+
# The environment this function is entitled to is held until its response is finished, a streamed body
|
|
136
|
+
# included: the secrets it declares are in the process environment for exactly that long.
|
|
137
|
+
held = contextlib.ExitStack()
|
|
138
|
+
held.enter_context(self._gate.hold(route[3]))
|
|
139
|
+
try:
|
|
140
|
+
# The variables a production instance has from its start name the function it serves. One process
|
|
141
|
+
# serves the whole codebase here, so they name the call that started most recently, as for events.
|
|
142
|
+
os.environ["FUNCTION_TARGET"] = route[2]
|
|
143
|
+
os.environ["FUNCTION_SIGNATURE_TYPE"] = "http"
|
|
144
|
+
os.environ["K_SERVICE"] = name
|
|
145
|
+
body = self._app(environ, start_response)
|
|
146
|
+
except BaseException as error:
|
|
147
|
+
_log_failure(name, error)
|
|
148
|
+
held.close()
|
|
149
|
+
raise ResponseBodyFailed(type(error).__name__) from None
|
|
150
|
+
return _HeldBody(body, held, name)
|
|
79
151
|
|
|
80
152
|
|
|
81
153
|
class _QuietHandler(WSGIRequestHandler):
|
|
@@ -83,9 +155,9 @@ class _QuietHandler(WSGIRequestHandler):
|
|
|
83
155
|
pass
|
|
84
156
|
|
|
85
157
|
|
|
86
|
-
def start_server(functions: dict, manifest: dict, secret: str):
|
|
158
|
+
def start_server(functions: dict, manifest: dict, secret: str, gate: EnvironmentGate | None = None):
|
|
87
159
|
"""Starts the server on a free loopback port; returns `(server, port)`."""
|
|
88
|
-
router = RunnerRouter(functions, manifest, os.environ.get("GCLOUD_PROJECT", ""), secret)
|
|
160
|
+
router = RunnerRouter(functions, manifest, os.environ.get("GCLOUD_PROJECT", ""), secret, gate)
|
|
89
161
|
server = make_server("127.0.0.1", 0, router, threaded=True, request_handler=_QuietHandler)
|
|
90
162
|
import threading
|
|
91
163
|
|