@fireemu/darwin-arm64 0.41.0 → 0.43.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @fireemu/darwin-arm64
2
2
 
3
3
  The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `darwin-arm64`
4
- (Rust target `aarch64-apple-darwin`), version 0.41.0.
4
+ (Rust target `aarch64-apple-darwin`), version 0.43.0.
5
5
 
6
6
  macOS 13 or newer on Apple silicon.
7
7
 
@@ -13,5 +13,6 @@ npm install -D fireemu
13
13
  ```
14
14
 
15
15
  It contains the daemon (`bin/fireemu`, with the Emulator UI compiled in) and the
16
- Node runner that hosts a Functions codebase (`bin/runner-node/`). Nothing is downloaded at
16
+ Node runner that hosts a Functions codebase (`bin/runner-node/`) and the Python runner for
17
+ Python codebases (`bin/runner-python/`). Nothing is downloaded at
17
18
  install time and there is no install script.
package/bin/fireemu CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/fireemu
3
- size: 36776928 bytes
4
- sha256: 3d2b29d1c060a0c23b7e947cf39c0878484e149460cf95a01c1715f6c6c6312d
3
+ size: 36876128 bytes
4
+ sha256: a0e1688bf4e915c0be5d973019e3e643cb15a3f869e596cdf63937aeae506e7b
@@ -0,0 +1,5 @@
1
+ """The fireemu runner for Python Functions codebases (see docs/specifications and the design memo).
2
+
3
+ The daemon speaks to a runner over its stdin and stdout with length-prefixed JSON frames. This
4
+ package is imported by `runner.py`, which sits beside it.
5
+ """
@@ -0,0 +1,104 @@
1
+ """Invocation frames: checked against the manifest, run on their own thread, answered with a result."""
2
+
3
+ import os
4
+ import threading
5
+ import traceback
6
+
7
+ from .events import (
8
+ database_cloud_event,
9
+ eventarc_cloud_event,
10
+ firestore_cloud_event,
11
+ pubsub_cloud_event,
12
+ storage_cloud_event,
13
+ )
14
+ from .framing import FrameWriter
15
+
16
+ MAX_ACTIVE_INVOCATIONS = 4096
17
+ _MAX_ERROR_CHARS = 4096
18
+ _MAX_DIAGNOSTIC_CHARS = 256 * 1024
19
+
20
+ # Trigger types that arrive as an `invoke` frame, and how each becomes the handler's argument.
21
+ _EVENT_ADAPTERS = {
22
+ "firestore": firestore_cloud_event,
23
+ "pubsub": pubsub_cloud_event,
24
+ "storage": storage_cloud_event,
25
+ "eventarc": eventarc_cloud_event,
26
+ "database": database_cloud_event,
27
+ }
28
+
29
+
30
+ def _bound(text: str, limit: int) -> str:
31
+ return text if len(text) <= limit else text[: limit - 1] + "…"
32
+
33
+
34
+ class Dispatcher:
35
+ """Runs `invoke` frames against the functions the manifest serves."""
36
+
37
+ def __init__(self, functions: dict, manifest: dict, writer: FrameWriter):
38
+ self._functions = functions
39
+ self._specs = {spec["name"]: spec for spec in manifest["functions"]}
40
+ self._writer = writer
41
+ self._active = 0
42
+ self._lock = threading.Lock()
43
+
44
+ def _check(self, message: dict) -> tuple[dict, object]:
45
+ spec = self._specs.get(message["function"])
46
+ if spec is None:
47
+ raise LookupError("unknown function")
48
+ if "entryPoint" in message and message["entryPoint"] != spec["entryPoint"]:
49
+ raise LookupError("entry point does not match the function manifest")
50
+ trigger = spec["trigger"]["type"]
51
+ if message["trigger"] != trigger or trigger not in _EVENT_ADAPTERS:
52
+ raise LookupError("trigger does not match the function manifest")
53
+ function = self._functions.get(spec["entryPoint"])
54
+ if not callable(function):
55
+ raise LookupError("missing function entry point")
56
+ return spec, function
57
+
58
+ def reserve(self) -> bool:
59
+ with self._lock:
60
+ if self._active >= MAX_ACTIVE_INVOCATIONS:
61
+ return False
62
+ self._active += 1
63
+ return True
64
+
65
+ def start(self, message: dict) -> None:
66
+ """Runs one invoke frame on a new thread; the caller has reserved a slot with `reserve`."""
67
+ threading.Thread(target=self._run, args=(message,), daemon=True, name=f"invoke-{message['invocationId']}").start()
68
+
69
+ def _run(self, message: dict) -> None:
70
+ invocation = message["invocationId"]
71
+ try:
72
+ try:
73
+ spec, function = self._check(message)
74
+ # One process serves the whole codebase, so these name the invocation that started most
75
+ # recently, not a fixed target (the design memo, section 5).
76
+ os.environ["FUNCTION_TARGET"] = spec["entryPoint"]
77
+ os.environ["FUNCTION_SIGNATURE_TYPE"] = "cloudevent"
78
+ os.environ["K_SERVICE"] = spec["name"]
79
+ function(_EVENT_ADAPTERS[message["trigger"]](message["event"]))
80
+ result = {"type": "result", "invocationId": invocation, "ok": True}
81
+ except BaseException as error: # a SystemExit in user code must still answer the daemon
82
+ diagnostic = "".join(traceback.format_exception(error))
83
+ self._send_log("error", f"{message['function']}: {diagnostic}", invocation, message["function"])
84
+ text = str(error) or type(error).__name__
85
+ result = {"type": "result", "invocationId": invocation, "ok": False, "error": _bound(text, _MAX_ERROR_CHARS)}
86
+ self._writer.write(result)
87
+ finally:
88
+ with self._lock:
89
+ self._active -= 1
90
+
91
+ def _send_log(self, level: str, text: str, invocation: str, function: str) -> None:
92
+ try:
93
+ self._writer.write(
94
+ {
95
+ "type": "log",
96
+ "level": level,
97
+ "message": _bound(text, _MAX_DIAGNOSTIC_CHARS),
98
+ "invocationId": invocation,
99
+ "functionName": function,
100
+ "user": False,
101
+ }
102
+ )
103
+ except Exception: # a diagnostic can never suppress the result
104
+ pass
@@ -0,0 +1,113 @@
1
+ """Event adapters: the daemon's structured event as the CloudEvent the Python SDK expects.
2
+
3
+ Production delivers a Firestore event as a binary-mode CloudEvent (`ce-*` headers) whose body is a
4
+ protobuf `DocumentEventData`; the SDK's JSON branch needs a JSON *string* and fails on a decoded
5
+ dict (`firestore_fn.py:108-131`). The adapter therefore builds the production form and lets
6
+ `cloudevents.http.from_http` produce the object, exactly the path functions-framework takes
7
+ (`functions_framework/__init__.py:174-188`).
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+
14
+ from cloudevents.http import from_http
15
+ from google.events.cloud import firestore as firestore_events
16
+
17
+ _NOT_AN_ATTRIBUTE = frozenset({"data", "datacontenttype"})
18
+
19
+
20
+ _WHOLE_SECOND_TIME = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})$")
21
+
22
+
23
+ def _attribute_headers(event: dict) -> dict:
24
+ """The binary-mode `ce-*` headers of an event: its string members, except the data and the content type."""
25
+ if not isinstance(event.get("specversion"), str):
26
+ # Without it `from_http` would read the body as a structured JSON event and fail on the body.
27
+ raise ValueError("the event has no specversion")
28
+ return {
29
+ f"ce-{name}": value
30
+ for name, value in event.items()
31
+ if name not in _NOT_AN_ATTRIBUTE and isinstance(value, str)
32
+ }
33
+
34
+
35
+ def _is_json(content_type: str) -> bool:
36
+ media = content_type.split(";", 1)[0].strip().lower()
37
+ return media == "application/json" or media.endswith("+json")
38
+
39
+
40
+ def _json_cloud_event(event: dict):
41
+ """The `CloudEvent` for an event whose data is JSON (or text, with a text content type).
42
+
43
+ Eventarc delivers these in binary content mode: attributes as `ce-*` headers and the data as the body,
44
+ which functions-framework hands to `cloudevents.http.from_http` (`functions_framework/__init__.py:174-188`).
45
+ A delivery without a content type is JSON.
46
+ """
47
+ headers = _attribute_headers(event)
48
+ declared = event.get("datacontenttype")
49
+ content_type = declared if isinstance(declared, str) and declared else "application/json"
50
+ headers["content-type"] = content_type
51
+ if "data" not in event:
52
+ body = b""
53
+ elif isinstance(event["data"], str) and not _is_json(content_type):
54
+ body = event["data"].encode()
55
+ else:
56
+ body = json.dumps(event["data"]).encode()
57
+ return from_http(headers, body)
58
+
59
+
60
+ def _lenient_time(event: dict) -> dict:
61
+ """The emulator profile gives a whole-second `time` a fraction.
62
+
63
+ `storage_fn` and `eventarc_fn` parse `time` with `%f` (`storage_fn.py:249-252`, `eventarc_fn.py:65-68`) and
64
+ fail on `2026-01-01T00:00:02Z`. The official emulator always prints milliseconds, so what it served the
65
+ emulator profile must serve; the strict profile delivers the time as production's event has it.
66
+ """
67
+ time = event.get("time")
68
+ if os.environ.get("FIREEMU_HTTP_PROFILE", "emulator") == "strict" or not isinstance(time, str):
69
+ return event
70
+ match = _WHOLE_SECOND_TIME.match(time)
71
+ return event if match is None else {**event, "time": f"{match.group(1)}.000{match.group(2)}"}
72
+
73
+
74
+ def pubsub_cloud_event(event: dict):
75
+ """The `CloudEvent` for a Pub/Sub message event.
76
+
77
+ The SDK reads `message_id` and `publish_time` (`pubsub_fn.py:98-160`), the snake-case spellings Pub/Sub's
78
+ push body carries next to `messageId` and `publishTime`. `orderingKey` has no alternative: the SDK pops
79
+ it and passes `ordering_key=` itself, so a copied key would be passed twice (`TypeError`).
80
+ """
81
+ data = event.get("data")
82
+ message = data.get("message") if isinstance(data, dict) else None
83
+ if not isinstance(message, dict):
84
+ return _json_cloud_event(event)
85
+ message = dict(message)
86
+ for camel, snake in (("messageId", "message_id"), ("publishTime", "publish_time")):
87
+ if snake not in message and camel in message and message[camel] is not None:
88
+ message[snake] = message[camel]
89
+ return _json_cloud_event({**event, "data": {**data, "message": message}})
90
+
91
+
92
+ def storage_cloud_event(event: dict):
93
+ """The `CloudEvent` for a Storage object event."""
94
+ return _json_cloud_event(_lenient_time(event))
95
+
96
+
97
+ def eventarc_cloud_event(event: dict):
98
+ """The `CloudEvent` for a custom Eventarc event."""
99
+ return _json_cloud_event(_lenient_time(event))
100
+
101
+
102
+ def database_cloud_event(event: dict):
103
+ """The `CloudEvent` for a Realtime Database event."""
104
+ return _json_cloud_event(event)
105
+
106
+
107
+ def firestore_cloud_event(event: dict):
108
+ """The `CloudEvent` for a Firestore event frame (`created`, `updated`, `deleted`, `written`)."""
109
+ headers = _attribute_headers(event)
110
+ data = event.get("data")
111
+ message = firestore_events.DocumentEventData.from_json(json.dumps(data if isinstance(data, dict) else {}))
112
+ headers["content-type"] = "application/protobuf"
113
+ return from_http(headers, firestore_events.DocumentEventData.serialize(message))
@@ -0,0 +1,95 @@
1
+ """The frame codec: `<decimal byte length>\\n<UTF-8 JSON object>`.
2
+
3
+ It matches `crates/fireemu-adapter-functions/src/protocol.rs` and `tools/runner-node/protocol.mjs`:
4
+ a canonical decimal length (no sign, no leading zero, at most 8 digits), a cap of 16 MiB, and only
5
+ the daemon messages this runner serves (`invoke` and `shutdown`). Diagnostics are fixed strings:
6
+ a JSON parser error can quote the payload.
7
+ """
8
+
9
+ import json
10
+ import threading
11
+
12
+ MAX_FRAME_BYTES = 16 * 1024 * 1024
13
+ _MAX_HEADER_DIGITS = len(str(MAX_FRAME_BYTES))
14
+
15
+
16
+ class ProtocolError(Exception):
17
+ """A frame the runner cannot accept; the reason is a fixed string."""
18
+
19
+ def __init__(self, reason: str):
20
+ super().__init__(f"invalid runner frame ({reason})")
21
+
22
+
23
+ def encode_frame(message: dict) -> bytes:
24
+ payload = json.dumps(message, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
25
+ if len(payload) > MAX_FRAME_BYTES:
26
+ raise ProtocolError("frame too large")
27
+ return str(len(payload)).encode("ascii") + b"\n" + payload
28
+
29
+
30
+ def _text(value) -> bool:
31
+ return isinstance(value, str) and len(value) > 0
32
+
33
+
34
+ def _decode_message(payload: bytes) -> dict:
35
+ try:
36
+ message = json.loads(payload.decode("utf-8"))
37
+ except (UnicodeDecodeError, ValueError):
38
+ raise ProtocolError("UTF-8 or JSON") from None
39
+ if not isinstance(message, dict):
40
+ raise ProtocolError("message object")
41
+ if message.get("type") == "shutdown":
42
+ return message
43
+ if (
44
+ message.get("type") != "invoke"
45
+ or not _text(message.get("invocationId"))
46
+ or not _text(message.get("function"))
47
+ or not _text(message.get("trigger"))
48
+ or not isinstance(message.get("event"), dict)
49
+ or ("entryPoint" in message and not _text(message["entryPoint"]))
50
+ ):
51
+ raise ProtocolError("message fields")
52
+ return message
53
+
54
+
55
+ def read_frame(stream) -> dict | None:
56
+ """Reads one message from a binary stream; `None` at a clean end between frames."""
57
+ length = 0
58
+ digits = 0
59
+ while True:
60
+ byte = stream.read(1)
61
+ if not byte:
62
+ if digits == 0:
63
+ return None
64
+ raise ProtocolError("truncated")
65
+ value = byte[0]
66
+ if value == 10:
67
+ if digits == 0:
68
+ raise ProtocolError("empty length")
69
+ break
70
+ if value < 48 or value > 57 or (digits == 0 and value == 48):
71
+ raise ProtocolError("decimal length")
72
+ digits += 1
73
+ length = length * 10 + value - 48
74
+ if digits > _MAX_HEADER_DIGITS or length > MAX_FRAME_BYTES:
75
+ raise ProtocolError("frame too large")
76
+ payload = stream.read(length)
77
+ if len(payload) != length:
78
+ raise ProtocolError("truncated")
79
+ return _decode_message(payload)
80
+
81
+
82
+ class FrameWriter:
83
+ """Writes frames to the protocol channel, one whole frame at a time."""
84
+
85
+ def __init__(self, stream):
86
+ self._stream = stream
87
+ self._lock = threading.Lock()
88
+
89
+ def write(self, message: dict) -> None:
90
+ data = encode_frame(message)
91
+ with self._lock:
92
+ self._stream.write(data)
93
+ flush = getattr(self._stream, "flush", None)
94
+ if flush is not None:
95
+ flush()
@@ -0,0 +1,82 @@
1
+ """The runner process: load the codebase, say hello, serve frames until told to stop."""
2
+
3
+ import argparse
4
+ import os
5
+ import platform
6
+ import sys
7
+ import traceback
8
+
9
+ from .dispatch import Dispatcher
10
+ from .framing import FrameWriter, ProtocolError, read_frame
11
+ from .manifest import build_manifest
12
+ from .serve import start_server
13
+
14
+
15
+ def _parse(argv):
16
+ parser = argparse.ArgumentParser(prog="runner.py")
17
+ parser.add_argument("--source", required=True)
18
+ parser.add_argument("--codebase", default="default")
19
+ return parser.parse_args(argv)
20
+
21
+
22
+ def load_codebase(source: str) -> dict:
23
+ """Imports the codebase's `main.py` the way the SDK's own admin server does and returns its functions."""
24
+ os.chdir(source)
25
+ from firebase_functions.private import serving
26
+
27
+ return serving.get_functions()
28
+
29
+
30
+ def serve(functions: dict, codebase: str, secret: str, stdin, writer: FrameWriter) -> int:
31
+ """Says hello, then serves frames from `stdin` until `shutdown` or the end of the stream."""
32
+ manifest = build_manifest(functions)
33
+ server = None
34
+ port = None
35
+ if any(spec["trigger"]["type"] == "http" for spec in manifest["functions"]):
36
+ server, port = start_server(functions, manifest, secret)
37
+ writer.write(
38
+ {
39
+ "type": "hello",
40
+ "runner": "python",
41
+ "codebase": codebase,
42
+ "version": platform.python_version(),
43
+ "httpPort": port,
44
+ "manifest": manifest,
45
+ }
46
+ )
47
+ dispatcher = Dispatcher(functions, manifest, writer)
48
+ try:
49
+ while True:
50
+ message = read_frame(stdin)
51
+ if message is None or message["type"] == "shutdown":
52
+ return 0
53
+ if not dispatcher.reserve():
54
+ sys.stderr.write("[functions] too many active invocations\n")
55
+ return 2
56
+ dispatcher.start(message)
57
+ except ProtocolError as error:
58
+ sys.stderr.write(f"[functions] {error}\n")
59
+ return 2
60
+ finally:
61
+ if server is not None:
62
+ server.shutdown()
63
+
64
+
65
+ def run(argv) -> int:
66
+ args = _parse(argv)
67
+ # The protocol owns the original stdout. Everything that writes to file descriptor 1 afterwards
68
+ # (print, C extensions, child processes, the SDK logger) goes to stderr instead.
69
+ protocol = os.fdopen(os.dup(1), "wb", buffering=0)
70
+ os.dup2(2, 1)
71
+ writer = FrameWriter(protocol)
72
+ secret = os.environ.get("FIREEMU_RUNNER_SECRET", "")
73
+ # The daemon's local secrets are for the SDK's parameter lookup; user code does not see the channel
74
+ # that carried them, nor the key to this runner's HTTP port.
75
+ os.environ.pop("FIREEMU_LOCAL_SECRETS_JSON", None)
76
+ os.environ.pop("FIREEMU_RUNNER_SECRET", None)
77
+ try:
78
+ functions = load_codebase(args.source)
79
+ except BaseException: # a failing import in user code is reported with its traceback
80
+ traceback.print_exc()
81
+ return 1
82
+ return serve(functions, args.codebase, secret, sys.stdin.buffer, writer)
@@ -0,0 +1,287 @@
1
+ """The endpoints the Python SDK declares, as fireemu's canonical manifest.
2
+
3
+ `firebase_functions.private.serving.get_functions()` returns the decorated functions, each with a
4
+ `__firebase_endpoint__` (the SDK's `ManifestEndpoint`, the same v1alpha1 endpoint schema the Node
5
+ SDK produces). This module is the Python counterpart of `describe()` in
6
+ `tools/runner-node/index.mjs`: it resolves option expressions, treats `None` and the SDK's reset
7
+ sentinel as unset, and maps each trigger to the daemon's `functions[]` entry
8
+ (`crates/fireemu-adapter-functions/src/manifest_json.rs`). A function it cannot serve is reported
9
+ under `ignored[]`, never dropped.
10
+ """
11
+
12
+ import enum
13
+ from typing import Any
14
+
15
+ from firebase_functions.params import Expression
16
+ from firebase_functions.private.util import Sentinel
17
+
18
+ DEFAULT_REGION = "us-central1"
19
+
20
+ _FIRESTORE_PREFIX = "google.cloud.firestore."
21
+ _PUBSUB_EVENT = "google.cloud.pubsub.topic.v1.messagePublished"
22
+ _STORAGE_EVENTS = frozenset(
23
+ "google.cloud.storage.object.v1." + kind for kind in ("finalized", "deleted", "archived", "metadataUpdated")
24
+ )
25
+ _DATABASE_EVENTS = frozenset("google.firebase.database.ref.v1." + kind for kind in ("created", "updated", "deleted", "written"))
26
+ # Products the official emulator has a trigger service for and fireemu does not serve, matched on a substring of
27
+ # the event type as the Node runner does (`DEFERRED_TRIGGER_PRODUCTS` in `tools/runner-node/index.mjs`).
28
+ _UNSERVED_PRODUCTS = (
29
+ ("remoteconfig", "remoteConfig", "deferred", "deferred: Remote Config has no emulator in the active supported surface"),
30
+ ("dataconnect", "dataconnect", "deferred", "deferred: the Data Connect emulator is not in the active supported surface"),
31
+ ("ailogic", "ai", "notPlanned", "not planned: Firebase AI Logic blocking triggers have no local emulator"),
32
+ ("analytics", "analytics", "notPlanned", "not planned: Google Analytics triggers have no local emulator"),
33
+ ("testing", "testLab", "notPlanned", "not planned: Test Lab triggers have no local emulator"),
34
+ )
35
+ _PLANNED = "Python {family} triggers are planned for a later slice and are not served yet"
36
+
37
+
38
+ class Unserved(Exception):
39
+ """A function the runner reports under `ignored[]`."""
40
+
41
+ def __init__(self, trigger_type: str, scope: str, reason: str):
42
+ super().__init__(reason)
43
+ self.trigger_type = trigger_type
44
+ self.scope = scope
45
+ self.reason = reason
46
+
47
+
48
+ def _unset(value: Any) -> bool:
49
+ return value is None or isinstance(value, Sentinel)
50
+
51
+
52
+ def _resolved(value: Any, field: str) -> Any:
53
+ """An option's concrete value, or None when it is unset. Expressions read their parameter."""
54
+ if _unset(value):
55
+ return None
56
+ if isinstance(value, enum.Enum):
57
+ return value.value
58
+ if isinstance(value, Expression):
59
+ resolved = value.value
60
+ if resolved is None:
61
+ raise Unserved("unknown", "unsupported", f"{field} expression did not resolve to a value")
62
+ return resolved
63
+ return value
64
+
65
+
66
+ def _non_negative_integer(value: Any, field: str):
67
+ resolved = _resolved(value, field)
68
+ if resolved is None:
69
+ return None
70
+ if isinstance(resolved, bool) or not isinstance(resolved, int) or resolved < 0:
71
+ raise Unserved("unknown", "unsupported", f"{field} did not resolve to a non-negative integer")
72
+ return resolved
73
+
74
+
75
+ def _first_region(endpoint) -> str | None:
76
+ regions = _resolved(endpoint.region, "region")
77
+ if regions is None:
78
+ return None
79
+ values = [_resolved(region, "region") for region in (regions if isinstance(regions, list) else [regions])]
80
+ for value in values:
81
+ if not isinstance(value, str) or not value:
82
+ raise Unserved("unknown", "unsupported", "region did not resolve to a non-empty string")
83
+ return values[0] if values else None
84
+
85
+
86
+ def _platform_options(endpoint) -> dict | None:
87
+ options: dict[str, Any] = {}
88
+ for field, key in (
89
+ ("availableMemoryMb", "availableMemoryMb"),
90
+ ("minInstances", "minInstances"),
91
+ ("maxInstances", "maxInstances"),
92
+ ):
93
+ value = _non_negative_integer(getattr(endpoint, field), field)
94
+ if value is not None:
95
+ options[key] = value
96
+ for field, key in (("ingressSettings", "ingressSettings"), ("serviceAccountEmail", "serviceAccountEmail")):
97
+ value = _resolved(getattr(endpoint, field), field)
98
+ if value is not None:
99
+ options[key] = value
100
+ invoker = (endpoint.httpsTrigger or {}).get("invoker") if isinstance(endpoint.httpsTrigger, dict) else None
101
+ if invoker:
102
+ options["invoker"] = invoker
103
+ vpc = endpoint.vpc if isinstance(endpoint.vpc, dict) else None
104
+ if vpc:
105
+ if not _unset(vpc.get("connector")):
106
+ options["vpcConnector"] = vpc["connector"]
107
+ egress = _resolved(vpc.get("egressSettings"), "vpc.egressSettings")
108
+ if egress is not None:
109
+ options["vpcEgressSettings"] = egress
110
+ if isinstance(vpc.get("networkInterfaces"), list):
111
+ options["networkInterfaces"] = vpc["networkInterfaces"]
112
+ if isinstance(endpoint.labels, dict) and endpoint.labels:
113
+ options["labels"] = endpoint.labels
114
+ cpu = _resolved(endpoint.cpu, "cpu")
115
+ if cpu is not None:
116
+ options["cpu"] = str(cpu)
117
+ if isinstance(endpoint.secretEnvironmentVariables, list):
118
+ secrets = [secret["key"] for secret in endpoint.secretEnvironmentVariables if secret.get("key")]
119
+ if secrets:
120
+ options["secrets"] = secrets
121
+ return options or None
122
+
123
+
124
+ def _mapping(value) -> dict:
125
+ return value if isinstance(value, dict) else {}
126
+
127
+
128
+ def _text(filters: dict, key: str, field: str, family: str, *, required: bool = False, allow_empty: bool = True):
129
+ """A string filter, resolved. Absent is None (or an error when required); another type is an error."""
130
+ value = _resolved(filters.get(key), field)
131
+ if value is None and not required:
132
+ return None
133
+ if not isinstance(value, str) or (not allow_empty and not value):
134
+ what = "a string" if allow_empty else "a non-empty string"
135
+ raise Unserved(family, "unsupported", f"{field} did not resolve to {what}")
136
+ return value
137
+
138
+
139
+ def _firestore_trigger(event_trigger: dict) -> dict:
140
+ filters = _mapping(event_trigger.get("eventFilters"))
141
+ patterns = _mapping(event_trigger.get("eventFilterPathPatterns"))
142
+ # The SDK keeps a document path without wildcards in `eventFilters.document` and one with
143
+ # wildcards in `eventFilterPathPatterns.document` (`options.py:1065-1075`).
144
+ document = _text(patterns, "document", "eventFilterPathPatterns.document", "firestore") or _text(
145
+ filters, "document", "eventFilters.document", "firestore"
146
+ )
147
+ if not document:
148
+ raise Unserved("firestore", "unsupported", "the Firestore trigger names no document (eventFilters.document)")
149
+ return {
150
+ "type": "firestore",
151
+ "eventType": event_trigger["eventType"],
152
+ "database": _text(filters, "database", "eventFilters.database", "firestore") or "(default)",
153
+ "document": document,
154
+ }
155
+
156
+
157
+ def _pubsub_trigger(event_trigger: dict) -> dict:
158
+ topic = _text(_mapping(event_trigger.get("eventFilters")), "topic", "eventFilters.topic", "pubsub", required=True, allow_empty=False)
159
+ # A full resource name is reduced to the short name the daemon matches on.
160
+ short = topic.rsplit("/topics/", 1)[-1] if "/topics/" in topic else topic
161
+ if not short:
162
+ raise Unserved("pubsub", "unsupported", "eventFilters.topic resolved to an empty topic")
163
+ return {"type": "pubsub", "topic": short}
164
+
165
+
166
+ def _storage_trigger(event_trigger: dict, event_type: str) -> dict:
167
+ bucket = _text(_mapping(event_trigger.get("eventFilters")), "bucket", "eventFilters.bucket", "storage")
168
+ trigger = {"type": "storage", "eventType": event_type}
169
+ if bucket is not None:
170
+ trigger["bucket"] = bucket
171
+ return trigger
172
+
173
+
174
+ def _database_trigger(event_trigger: dict, event_type: str) -> dict:
175
+ filters = _mapping(event_trigger.get("eventFilters"))
176
+ patterns = _mapping(event_trigger.get("eventFilterPathPatterns"))
177
+ # The SDK keeps a reference without wildcards in `eventFilters` and one with wildcards in
178
+ # `eventFilterPathPatterns`; the Node runner reads the patterns first as well.
179
+ reference = _text(patterns, "ref", "eventFilterPathPatterns.ref", "database")
180
+ if reference is None:
181
+ reference = _text(filters, "ref", "eventFilters.ref", "database", required=True)
182
+ instance = _text(patterns, "instance", "eventFilterPathPatterns.instance", "database")
183
+ if instance is None:
184
+ instance = _text(filters, "instance", "eventFilters.instance", "database")
185
+ return {
186
+ "type": "database",
187
+ "eventType": event_type,
188
+ "ref": "/" + reference.strip("/"),
189
+ "instance": instance if instance is not None else "*",
190
+ }
191
+
192
+
193
+ def _eventarc_trigger(event_trigger: dict, event_type: str, channel: str) -> dict:
194
+ filters = _mapping(event_trigger.get("eventFilters"))
195
+ return {
196
+ "type": "eventarc",
197
+ "eventType": event_type,
198
+ "channel": channel,
199
+ "filters": {key: _text(filters, key, f"eventFilters.{key}", "eventarc", required=True) for key in filters},
200
+ }
201
+
202
+
203
+ def _event_trigger(event_trigger: dict) -> dict:
204
+ """The canonical trigger of an `eventTrigger`, or `Unserved` for an event type fireemu does not serve."""
205
+ event_type = str(_resolved(event_trigger.get("eventType"), "eventType") or "")
206
+ if event_type.startswith(_FIRESTORE_PREFIX):
207
+ return _firestore_trigger({**event_trigger, "eventType": event_type})
208
+ if event_type == _PUBSUB_EVENT:
209
+ return _pubsub_trigger(event_trigger)
210
+ if event_type in _STORAGE_EVENTS:
211
+ return _storage_trigger(event_trigger, event_type)
212
+ if event_type in _DATABASE_EVENTS:
213
+ return _database_trigger(event_trigger, event_type)
214
+ # `onCustomEventPublished` is the only SDK trigger that names a channel.
215
+ channel = _text(event_trigger, "channel", "eventTrigger.channel", "eventarc")
216
+ if channel is not None:
217
+ return _eventarc_trigger(event_trigger, event_type, channel)
218
+ for fragment, trigger_type, scope, reason in _UNSERVED_PRODUCTS:
219
+ if fragment in event_type:
220
+ raise Unserved(trigger_type, scope, reason)
221
+ family = "storage" if event_type.startswith("google.cloud.storage.") else (
222
+ "database" if event_type.startswith("google.firebase.database.") else "unknown"
223
+ )
224
+ raise Unserved(family, "unsupported", f"event type {event_type} is not recognised")
225
+
226
+
227
+ def describe_endpoint(name: str, endpoint) -> dict:
228
+ """The canonical `functions[]` entry of one endpoint, or `Unserved` when it cannot be served."""
229
+ if endpoint.platform != "gcfv2":
230
+ raise Unserved("unknown", "unsupported", "endpoint platform is not recognised")
231
+ described: dict[str, Any] = {"name": name, "entryPoint": name, "generation": 2}
232
+ region = _first_region(endpoint)
233
+ if region:
234
+ described["region"] = region
235
+ timeout = _non_negative_integer(endpoint.timeoutSeconds, "timeoutSeconds")
236
+ if timeout:
237
+ described["timeoutSeconds"] = timeout
238
+ concurrency = _non_negative_integer(endpoint.concurrency, "concurrency")
239
+ if concurrency is not None:
240
+ described["concurrency"] = concurrency
241
+ options = _platform_options(endpoint)
242
+ if options:
243
+ described["platformOptions"] = options
244
+ if endpoint.taskQueueTrigger is not None:
245
+ raise Unserved("tasks", "planned", _PLANNED.format(family="task queue"))
246
+ if endpoint.httpsTrigger is not None:
247
+ described["trigger"] = {"type": "http", "callable": False}
248
+ elif endpoint.callableTrigger is not None:
249
+ described["trigger"] = {"type": "http", "callable": True}
250
+ elif endpoint.scheduleTrigger is not None:
251
+ raise Unserved("schedule", "planned", _PLANNED.format(family="schedule"))
252
+ elif endpoint.blockingTrigger is not None:
253
+ raise Unserved("blockingAuth", "planned", _PLANNED.format(family="Auth blocking"))
254
+ elif endpoint.eventTrigger is not None:
255
+ described["trigger"] = _event_trigger(endpoint.eventTrigger)
256
+ retry = _resolved(endpoint.eventTrigger.get("retry"), "eventTrigger.retry")
257
+ if retry is not None and not isinstance(retry, bool):
258
+ raise Unserved(described["trigger"]["type"], "unsupported", "eventTrigger.retry did not resolve to a boolean")
259
+ described["retry"] = retry is True
260
+ else:
261
+ raise Unserved("unknown", "unsupported", "the endpoint declares no trigger")
262
+ return described
263
+
264
+
265
+ def build_manifest(functions: dict) -> dict:
266
+ """`{"functions": [...], "ignored": [...]}` for the decorated functions of a codebase."""
267
+ served: list[dict] = []
268
+ ignored: list[dict] = []
269
+ for name, function in functions.items():
270
+ endpoint = function.__firebase_endpoint__
271
+ try:
272
+ served.append(describe_endpoint(name, endpoint))
273
+ except Unserved as unserved:
274
+ try:
275
+ region = _first_region(endpoint) or DEFAULT_REGION
276
+ except Unserved:
277
+ region = DEFAULT_REGION
278
+ ignored.append(
279
+ {
280
+ "name": name,
281
+ "region": region,
282
+ "triggerType": unserved.trigger_type,
283
+ "scope": unserved.scope,
284
+ "reason": unserved.reason,
285
+ }
286
+ )
287
+ return {"functions": served, "ignored": ignored}
@@ -0,0 +1,93 @@
1
+ """The HTTP server for `onRequest` and `onCall` functions.
2
+
3
+ The daemon proxies `/<project>/<region>/<name>/...` here with the `x-fireemu-runner-secret` header
4
+ (`crates/fireemu-adapter-functions/src/http.rs`, `tools/runner-node/http-admission.mjs`). The
5
+ function sees the path below its mount point, as it does in production, and answers through the same
6
+ `flask.Request` handlers the SDK wraps. An unhandled error is functions-framework's crash response.
7
+ """
8
+
9
+ import hmac
10
+ import os
11
+
12
+ import flask
13
+ from functions_framework import crash_handler, read_request
14
+ from werkzeug.serving import WSGIRequestHandler, make_server
15
+
16
+ from .manifest import DEFAULT_REGION
17
+
18
+ SECRET_ENVIRON_KEY = "HTTP_X_FIREEMU_RUNNER_SECRET"
19
+ _METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
20
+
21
+
22
+ def _flask_app() -> flask.Flask:
23
+ app = flask.Flask("fireemu-runner")
24
+ app.register_error_handler(500, crash_handler)
25
+ app.after_request(read_request)
26
+
27
+ def run(path):
28
+ return flask.request.environ["fireemu.function"](flask.request._get_current_object())
29
+
30
+ # "OPTIONS" is listed so that Flask does not answer a callable's preflight itself.
31
+ app.add_url_rule("/", endpoint="run", view_func=run, defaults={"path": ""}, methods=_METHODS)
32
+ app.add_url_rule("/<path:path>", endpoint="run", view_func=run, methods=_METHODS)
33
+ return app
34
+
35
+
36
+ class RunnerRouter:
37
+ """Admission and routing in front of the Flask app."""
38
+
39
+ def __init__(self, functions: dict, manifest: dict, project: str, secret: str):
40
+ self._app = _flask_app()
41
+ self._project = project
42
+ self._secret = secret.encode()
43
+ self._routes = {}
44
+ for spec in manifest["functions"]:
45
+ if spec["trigger"]["type"] == "http":
46
+ self._routes[spec["name"]] = (
47
+ spec.get("region", DEFAULT_REGION),
48
+ functions[spec["entryPoint"]],
49
+ spec["entryPoint"],
50
+ )
51
+
52
+ @staticmethod
53
+ def _plain(start_response, status: str, text: str):
54
+ body = text.encode()
55
+ start_response(status, [("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(body)))])
56
+ return [body]
57
+
58
+ def __call__(self, environ, start_response):
59
+ presented = environ.pop(SECRET_ENVIRON_KEY, "").encode()
60
+ if not hmac.compare_digest(presented, self._secret):
61
+ return self._plain(start_response, "403 Forbidden", "forbidden")
62
+ parts = environ.get("PATH_INFO", "").split("/")
63
+ # ["", project, region, name, *rest]
64
+ if len(parts) < 4 or parts[0] != "" or parts[1] != self._project:
65
+ return self._plain(start_response, "404 Not Found", "no HTTP function")
66
+ region, name = parts[2], parts[3]
67
+ route = self._routes.get(name)
68
+ if route is None or route[0] != region:
69
+ return self._plain(start_response, "404 Not Found", f"no HTTP function {parts[1]}/{region}/{name}")
70
+ environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + "/".join(parts[:4])
71
+ environ["PATH_INFO"] = "/" + "/".join(parts[4:])
72
+ 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)
79
+
80
+
81
+ class _QuietHandler(WSGIRequestHandler):
82
+ def log_request(self, *args, **kwargs): # the daemon logs requests; the runner's stderr is for user output
83
+ pass
84
+
85
+
86
+ def start_server(functions: dict, manifest: dict, secret: str):
87
+ """Starts the server on a free loopback port; returns `(server, port)`."""
88
+ router = RunnerRouter(functions, manifest, os.environ.get("GCLOUD_PROJECT", ""), secret)
89
+ server = make_server("127.0.0.1", 0, router, threaded=True, request_handler=_QuietHandler)
90
+ import threading
91
+
92
+ threading.Thread(target=server.serve_forever, daemon=True, name="http").start()
93
+ return server, server.server_port
@@ -0,0 +1,19 @@
1
+ """Entry point of the fireemu Python runner: `python runner.py --source <dir> --codebase <name>`.
2
+
3
+ It is started by the fireemu daemon with the interpreter of the codebase's `venv` and speaks the
4
+ frame protocol on stdin and stdout (see `fireemu_runner/framing.py`).
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from fireemu_runner.main import run # noqa: E402
13
+
14
+ if __name__ == "__main__":
15
+ code = run(sys.argv[1:])
16
+ sys.stdout.flush()
17
+ sys.stderr.flush()
18
+ # Handlers may have left non-daemon threads behind; the daemon already decided to stop us.
19
+ os._exit(code)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireemu/darwin-arm64",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "The fireemu daemon for darwin-arm64",
5
5
  "homepage": "https://t-k.github.io/fireemu/",
6
6
  "repository": {