@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.
@@ -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 `onCall` functions.
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"] == "http":
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
- # The variables a production instance has from its start name the function it serves. One process
74
- # serves the whole codebase here, so they name the call that started most recently, as for events.
75
- os.environ["FUNCTION_TARGET"] = route[2]
76
- os.environ["FUNCTION_SIGNATURE_TYPE"] = "http"
77
- os.environ["K_SERVICE"] = name
78
- return self._app(environ, start_response)
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireemu/darwin-arm64",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "The fireemu daemon for darwin-arm64",
5
5
  "homepage": "https://t-k.github.io/fireemu/",
6
6
  "repository": {