@fireemu/darwin-x64 0.42.0 → 0.44.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 +3 -2
- package/bin/fireemu +2 -2
- package/bin/runner-python/fireemu_runner/__init__.py +5 -0
- 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 +141 -0
- package/bin/runner-python/fireemu_runner/environment.py +119 -0
- package/bin/runner-python/fireemu_runner/events.py +203 -0
- package/bin/runner-python/fireemu_runner/framing.py +145 -0
- package/bin/runner-python/fireemu_runner/main.py +92 -0
- package/bin/runner-python/fireemu_runner/manifest.py +370 -0
- 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 +165 -0
- package/bin/runner-python/runner.py +19 -0
- package/package.json +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Local secrets: which invocation sees which secret in the one process environment.
|
|
2
|
+
|
|
3
|
+
Production gives every function its own instance, so a secret a function declares is in that instance's environment
|
|
4
|
+
only. One runner serves a whole codebase here and has one `os.environ`, so the runner takes the daemon's local
|
|
5
|
+
secrets (`FIREEMU_LOCAL_SECRETS_JSON`, from `.secret.local`) out of the environment at start and puts them back for
|
|
6
|
+
the duration of an invocation, as the Node runner's `withFunctionEnvironment` does:
|
|
7
|
+
|
|
8
|
+
- a plain invocation (one that declares no secret with a local value) runs with every local secret hidden, beside
|
|
9
|
+
other plain invocations;
|
|
10
|
+
- an invocation that declares a secret with a local value runs alone, with only the secrets it declares;
|
|
11
|
+
- invocations start in arrival order, so a plain one that arrives behind a queued secret invocation waits.
|
|
12
|
+
|
|
13
|
+
The model is `EnvGate.tla` (checked with TLC): an exclusive invocation never overlaps any other, a plain one never
|
|
14
|
+
sees a secret, and nothing is exposed while nothing runs.
|
|
15
|
+
|
|
16
|
+
What this does not isolate, as in the Node runner (one process serves the whole codebase):
|
|
17
|
+
- work a function leaves running after its invocation ends (a thread it started, a child process it launched) runs in
|
|
18
|
+
the shared process and sees whatever the environment holds at that moment;
|
|
19
|
+
- a function that holds a secret and waits, synchronously, for another function of the same codebase (a request to its
|
|
20
|
+
own URL) deadlocks: the other invocation queues behind the gate the caller holds.
|
|
21
|
+
Both are stated on the Functions page.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import collections
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import threading
|
|
29
|
+
|
|
30
|
+
SECRETS_VARIABLE = "FIREEMU_LOCAL_SECRETS_JSON"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_local_secrets(environ) -> dict:
|
|
34
|
+
"""Takes the daemon's local secrets out of `environ`: name to value, string values only."""
|
|
35
|
+
text = environ.pop(SECRETS_VARIABLE, None)
|
|
36
|
+
if not text:
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
parsed = json.loads(text)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValueError("the local secrets are not valid JSON") from None
|
|
42
|
+
if not isinstance(parsed, dict):
|
|
43
|
+
raise ValueError("the local secrets are not a JSON object")
|
|
44
|
+
return {name: value for name, value in parsed.items() if isinstance(value, str)}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EnvironmentGate:
|
|
48
|
+
"""One queue of invocations and the environment each may see."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, secrets: dict):
|
|
51
|
+
self._secrets = dict(secrets)
|
|
52
|
+
self._condition = threading.Condition()
|
|
53
|
+
self._queue = collections.deque() # tickets waiting, oldest first
|
|
54
|
+
self._running = 0
|
|
55
|
+
self._exclusive_running = False
|
|
56
|
+
self._saved = {}
|
|
57
|
+
|
|
58
|
+
def _hide(self) -> None:
|
|
59
|
+
for name in self._secrets:
|
|
60
|
+
self._saved[name] = os.environ.pop(name, None)
|
|
61
|
+
|
|
62
|
+
def _restore(self) -> None:
|
|
63
|
+
for name, value in self._saved.items():
|
|
64
|
+
if value is None:
|
|
65
|
+
os.environ.pop(name, None)
|
|
66
|
+
else:
|
|
67
|
+
os.environ[name] = value
|
|
68
|
+
self._saved = {}
|
|
69
|
+
|
|
70
|
+
def _may_start(self, ticket) -> bool:
|
|
71
|
+
if self._queue[0] is not ticket:
|
|
72
|
+
return False
|
|
73
|
+
return self._running == 0 if ticket.exclusive else not self._exclusive_running
|
|
74
|
+
|
|
75
|
+
def _enter(self, ticket) -> None:
|
|
76
|
+
with self._condition:
|
|
77
|
+
self._queue.append(ticket)
|
|
78
|
+
self._condition.wait_for(lambda: self._may_start(ticket))
|
|
79
|
+
self._queue.popleft()
|
|
80
|
+
if self._running == 0:
|
|
81
|
+
self._hide()
|
|
82
|
+
self._running += 1
|
|
83
|
+
if ticket.exclusive:
|
|
84
|
+
self._exclusive_running = True
|
|
85
|
+
for name in ticket.names:
|
|
86
|
+
os.environ[name] = self._secrets[name]
|
|
87
|
+
# The next in line may be able to start beside this one.
|
|
88
|
+
self._condition.notify_all()
|
|
89
|
+
|
|
90
|
+
def _leave(self, ticket) -> None:
|
|
91
|
+
with self._condition:
|
|
92
|
+
if ticket.exclusive:
|
|
93
|
+
self._exclusive_running = False
|
|
94
|
+
for name in ticket.names:
|
|
95
|
+
os.environ.pop(name, None)
|
|
96
|
+
self._running -= 1
|
|
97
|
+
if self._running == 0:
|
|
98
|
+
self._restore()
|
|
99
|
+
self._condition.notify_all()
|
|
100
|
+
|
|
101
|
+
@contextlib.contextmanager
|
|
102
|
+
def hold(self, declared):
|
|
103
|
+
"""Runs the body with the environment `declared` (the function's secret names) is entitled to."""
|
|
104
|
+
if not self._secrets:
|
|
105
|
+
yield
|
|
106
|
+
return
|
|
107
|
+
names = [name for name in dict.fromkeys(declared or ()) if name in self._secrets]
|
|
108
|
+
ticket = _Ticket(names)
|
|
109
|
+
self._enter(ticket)
|
|
110
|
+
try:
|
|
111
|
+
yield
|
|
112
|
+
finally:
|
|
113
|
+
self._leave(ticket)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _Ticket:
|
|
117
|
+
def __init__(self, names):
|
|
118
|
+
self.names = names
|
|
119
|
+
self.exclusive = bool(names)
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
from cloudevents.http import from_http
|
|
16
|
+
from google.events.cloud import firestore as firestore_events
|
|
17
|
+
|
|
18
|
+
from . import bodies
|
|
19
|
+
|
|
20
|
+
_NOT_AN_ATTRIBUTE = frozenset({"data", "datacontenttype"})
|
|
21
|
+
# The daemon's strict 2nd gen frame names a `traceparent` for the Node runner's event. Production sends the trace context as an
|
|
22
|
+
# ordinary HTTP header (Cloud Run's), never as a `ce-` attribute, so the strict Python event has none (sandbox run
|
|
23
|
+
# PYCHECK-EVENTS-20261104-01, the raw twins of the Pub/Sub, Storage, Eventarc and Firestore events). The emulator profile
|
|
24
|
+
# stamps no such member, so a `traceparent` there is the publisher's own tracing extension and stays an attribute.
|
|
25
|
+
_HTTP_HEADER_ONLY = frozenset({"traceparent"})
|
|
26
|
+
# Eventarc sends `ce-dataschema` with every Firestore event. Recorded for `created` only; for `updated`, `deleted`, `written`
|
|
27
|
+
# and any other type routed here it is UNRECORDED (the schema is one document for the family in google-cloudevents). The strict
|
|
28
|
+
# profile delivers it; the emulator profile keeps the official emulator's attributes, which have none.
|
|
29
|
+
_FIRESTORE_DATASCHEMA = (
|
|
30
|
+
"https://github.com/googleapis/google-cloudevents/blob/main/proto/google/events/cloud/firestore/v1/data.proto"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_WHOLE_SECOND_TIME = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _attribute_headers(event: dict) -> dict:
|
|
38
|
+
"""The binary-mode `ce-*` headers of an event: its string members, except the data and the content type."""
|
|
39
|
+
if not isinstance(event.get("specversion"), str):
|
|
40
|
+
# Without it `from_http` would read the body as a structured JSON event and fail on the body.
|
|
41
|
+
raise ValueError("the event has no specversion")
|
|
42
|
+
header_only = _HTTP_HEADER_ONLY if is_strict() else frozenset()
|
|
43
|
+
return {
|
|
44
|
+
f"ce-{name}": value
|
|
45
|
+
for name, value in event.items()
|
|
46
|
+
if name not in _NOT_AN_ATTRIBUTE and name not in header_only and isinstance(value, str)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_json(content_type: str) -> bool:
|
|
51
|
+
media = content_type.split(";", 1)[0].strip().lower()
|
|
52
|
+
return media == "application/json" or media.endswith("+json")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Delivery(NamedTuple):
|
|
56
|
+
"""One delivery: the headers, the body the event is built from, and the body the request carries.
|
|
57
|
+
|
|
58
|
+
The two bodies are the same bytes for a Firestore event. For the JSON families the event is built from the encoding of
|
|
59
|
+
the data the adapters have always used (`event_body`), and the request shows the layout the recorded delivery had
|
|
60
|
+
(`request_body`); the function is given the same event whichever layout the request has.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
headers: dict
|
|
64
|
+
event_body: bytes
|
|
65
|
+
request_body: bytes
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _json_delivery(event: dict, request_text: str | None = None) -> Delivery:
|
|
69
|
+
"""The delivery of an event whose data is JSON (or text, with a text content type).
|
|
70
|
+
|
|
71
|
+
Eventarc delivers these in binary content mode: attributes as `ce-*` headers and the data as the body,
|
|
72
|
+
which functions-framework hands to `cloudevents.http.from_http` (`functions_framework/__init__.py:174-188`).
|
|
73
|
+
A delivery without a content type is JSON. `request_text` is the body text of the request when it differs from the
|
|
74
|
+
compact encoding of the data (see `bodies.py`).
|
|
75
|
+
"""
|
|
76
|
+
headers = _attribute_headers(event)
|
|
77
|
+
declared = event.get("datacontenttype")
|
|
78
|
+
content_type = declared if isinstance(declared, str) and declared else "application/json"
|
|
79
|
+
headers["content-type"] = content_type
|
|
80
|
+
if "data" not in event:
|
|
81
|
+
return Delivery(headers, b"", b"")
|
|
82
|
+
if isinstance(event["data"], str) and not _is_json(content_type):
|
|
83
|
+
body = event["data"].encode()
|
|
84
|
+
return Delivery(headers, body, body)
|
|
85
|
+
request = request_text if request_text is not None else bodies.compact(event["data"])
|
|
86
|
+
return Delivery(headers, json.dumps(event["data"]).encode(), request.encode())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _delivered_value(event: dict, raw_data: str | None):
|
|
90
|
+
"""The data with its numbers as the daemon wrote them, or the event's own data when there is no text or it does not parse."""
|
|
91
|
+
if raw_data is not None:
|
|
92
|
+
try:
|
|
93
|
+
return bodies.parse_keeping_numbers(raw_data)
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
return event.get("data")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_strict() -> bool:
|
|
100
|
+
return os.environ.get("FIREEMU_HTTP_PROFILE") == "strict"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _lenient_time(event: dict) -> dict:
|
|
104
|
+
"""The emulator profile gives a whole-second `time` a fraction.
|
|
105
|
+
|
|
106
|
+
`storage_fn` and `eventarc_fn` parse `time` with `%f` (`storage_fn.py:249-252`, `eventarc_fn.py:65-68`) and
|
|
107
|
+
fail on `2026-01-01T00:00:02Z`. The official emulator always prints milliseconds, so what it served the
|
|
108
|
+
emulator profile must serve; the strict profile delivers the time as production's event has it.
|
|
109
|
+
"""
|
|
110
|
+
time = event.get("time")
|
|
111
|
+
if is_strict() or not isinstance(time, str):
|
|
112
|
+
return event
|
|
113
|
+
match = _WHOLE_SECOND_TIME.match(time)
|
|
114
|
+
return event if match is None else {**event, "time": f"{match.group(1)}.000{match.group(2)}"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _with_snake_case_keys(data):
|
|
118
|
+
"""A Pub/Sub push body with `message_id` and `publish_time` next to `messageId` and `publishTime` (`pubsub_fn.py:98-160`)."""
|
|
119
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
120
|
+
if not isinstance(message, dict):
|
|
121
|
+
return data
|
|
122
|
+
message = dict(message)
|
|
123
|
+
for camel, snake in (("messageId", "message_id"), ("publishTime", "publish_time")):
|
|
124
|
+
if snake not in message and camel in message and message[camel] is not None:
|
|
125
|
+
message[snake] = message[camel]
|
|
126
|
+
return {**data, "message": message}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pubsub_request(event: dict, raw_data: str | None = None) -> Delivery:
|
|
130
|
+
"""The delivery of a Pub/Sub message event.
|
|
131
|
+
|
|
132
|
+
The SDK reads `message_id` and `publish_time` (`pubsub_fn.py:98-160`), the snake-case spellings Pub/Sub's
|
|
133
|
+
push body carries next to `messageId` and `publishTime`. `orderingKey` has no alternative: the SDK pops
|
|
134
|
+
it and passes `ordering_key=` itself, so a copied key would be passed twice (`TypeError`).
|
|
135
|
+
The strict request carries the recorded layout: sorted compact JSON and a newline.
|
|
136
|
+
"""
|
|
137
|
+
data = event.get("data")
|
|
138
|
+
if not isinstance(data, dict) or not isinstance(data.get("message"), dict):
|
|
139
|
+
return _json_delivery(event)
|
|
140
|
+
event = {**event, "data": _with_snake_case_keys(data)}
|
|
141
|
+
value = _with_snake_case_keys(_delivered_value(event, raw_data))
|
|
142
|
+
text = bodies.pubsub_layout(value) if is_strict() else bodies.compact(value)
|
|
143
|
+
return _json_delivery(event, text)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def storage_request(event: dict, raw_data: str | None = None) -> Delivery:
|
|
147
|
+
"""The delivery of a Storage object event: the text the daemon sent, and in the strict profile the recorded layout."""
|
|
148
|
+
event = _lenient_time(event)
|
|
149
|
+
if is_strict() and "data" in event:
|
|
150
|
+
return _json_delivery(event, bodies.storage_layout(_delivered_value(event, raw_data)))
|
|
151
|
+
return _json_delivery(event, raw_data)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def eventarc_request(event: dict, raw_data: str | None = None) -> Delivery:
|
|
155
|
+
"""The delivery of a custom Eventarc event: the text of the data as the daemon framed it (the publisher's own in the strict profile)."""
|
|
156
|
+
return _json_delivery(_lenient_time(event), raw_data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def database_request(event: dict, raw_data: str | None = None) -> Delivery:
|
|
160
|
+
"""The delivery of a Realtime Database event: the text the daemon sent (no raw delivery was recorded; UNRECORDED)."""
|
|
161
|
+
return _json_delivery(event, raw_data)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def firestore_request(event: dict, raw_data: str | None = None) -> Delivery:
|
|
165
|
+
"""The delivery of a Firestore event frame (`created`, `updated`, `deleted`, `written`): a protobuf body."""
|
|
166
|
+
headers = _attribute_headers(event)
|
|
167
|
+
if is_strict():
|
|
168
|
+
headers.setdefault("ce-dataschema", _FIRESTORE_DATASCHEMA)
|
|
169
|
+
data = event.get("data")
|
|
170
|
+
message = firestore_events.DocumentEventData.from_json(json.dumps(data if isinstance(data, dict) else {}))
|
|
171
|
+
headers["content-type"] = "application/protobuf"
|
|
172
|
+
body = firestore_events.DocumentEventData.serialize(message)
|
|
173
|
+
return Delivery(headers, body, body)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def pubsub_cloud_event(event: dict):
|
|
177
|
+
"""The `CloudEvent` for a Pub/Sub message event (see `pubsub_request`)."""
|
|
178
|
+
delivery = pubsub_request(event)
|
|
179
|
+
return from_http(delivery.headers, delivery.event_body)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def storage_cloud_event(event: dict):
|
|
183
|
+
"""The `CloudEvent` for a Storage object event."""
|
|
184
|
+
delivery = storage_request(event)
|
|
185
|
+
return from_http(delivery.headers, delivery.event_body)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def eventarc_cloud_event(event: dict):
|
|
189
|
+
"""The `CloudEvent` for a custom Eventarc event."""
|
|
190
|
+
delivery = eventarc_request(event)
|
|
191
|
+
return from_http(delivery.headers, delivery.event_body)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def database_cloud_event(event: dict):
|
|
195
|
+
"""The `CloudEvent` for a Realtime Database event."""
|
|
196
|
+
delivery = database_request(event)
|
|
197
|
+
return from_http(delivery.headers, delivery.event_body)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def firestore_cloud_event(event: dict):
|
|
201
|
+
"""The `CloudEvent` for a Firestore event frame (`created`, `updated`, `deleted`, `written`)."""
|
|
202
|
+
delivery = firestore_request(event)
|
|
203
|
+
return from_http(delivery.headers, delivery.event_body)
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
_DECODER = json.JSONDecoder()
|
|
31
|
+
_WHITESPACE = " \t\n\r"
|
|
32
|
+
# The trigger types whose request body is the event's JSON data as the daemon framed it.
|
|
33
|
+
_RAW_DATA_TRIGGERS = frozenset(("eventarc", "pubsub", "storage", "database"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _after_whitespace(text: str, index: int) -> int:
|
|
37
|
+
while index < len(text) and text[index] in _WHITESPACE:
|
|
38
|
+
index += 1
|
|
39
|
+
return index
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def raw_event_data(text: str) -> str | None:
|
|
43
|
+
"""The text of `event.data` exactly as it stands in a frame, or None when the frame has none or is malformed.
|
|
44
|
+
|
|
45
|
+
The decoder turns the data into Python values and forgets its spelling (`1e-7`, `\\u00e9`, member order, white space).
|
|
46
|
+
For a custom Eventarc event in the strict profile the daemon frames the publisher's own JSON text, and that text is the
|
|
47
|
+
request body production delivered, so it is read here from the frame text, member by member, without parsing the data.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
index = _after_whitespace(text, 0)
|
|
51
|
+
for wanted in ("event", "data"):
|
|
52
|
+
if text[index] != "{":
|
|
53
|
+
return None
|
|
54
|
+
index += 1
|
|
55
|
+
while True:
|
|
56
|
+
index = _after_whitespace(text, index)
|
|
57
|
+
key, index = _DECODER.raw_decode(text, index)
|
|
58
|
+
index = _after_whitespace(text, index)
|
|
59
|
+
if text[index] != ":" or not isinstance(key, str):
|
|
60
|
+
return None
|
|
61
|
+
index = _after_whitespace(text, index + 1)
|
|
62
|
+
if key == wanted:
|
|
63
|
+
break
|
|
64
|
+
index = _after_whitespace(text, _DECODER.raw_decode(text, index)[1])
|
|
65
|
+
if text[index] == ",":
|
|
66
|
+
index += 1
|
|
67
|
+
start = index
|
|
68
|
+
end = _DECODER.raw_decode(text, start)[1]
|
|
69
|
+
# A value must be followed by what ends it, or the frame is cut short.
|
|
70
|
+
return text[start:end] if text[_after_whitespace(text, end)] in ",}" else None
|
|
71
|
+
except (IndexError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _text(value) -> bool:
|
|
76
|
+
return isinstance(value, str) and len(value) > 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decode_message(payload: bytes) -> dict:
|
|
80
|
+
try:
|
|
81
|
+
text = payload.decode("utf-8")
|
|
82
|
+
message = json.loads(text)
|
|
83
|
+
except (UnicodeDecodeError, ValueError):
|
|
84
|
+
raise ProtocolError("UTF-8 or JSON") from None
|
|
85
|
+
if not isinstance(message, dict):
|
|
86
|
+
raise ProtocolError("message object")
|
|
87
|
+
if message.get("type") == "shutdown":
|
|
88
|
+
return message
|
|
89
|
+
if (
|
|
90
|
+
message.get("type") != "invoke"
|
|
91
|
+
or not _text(message.get("invocationId"))
|
|
92
|
+
or not _text(message.get("function"))
|
|
93
|
+
or not _text(message.get("trigger"))
|
|
94
|
+
or not isinstance(message.get("event"), dict)
|
|
95
|
+
or ("entryPoint" in message and not _text(message["entryPoint"]))
|
|
96
|
+
):
|
|
97
|
+
raise ProtocolError("message fields")
|
|
98
|
+
if message["trigger"] in _RAW_DATA_TRIGGERS and "data" in message["event"]:
|
|
99
|
+
raw = raw_event_data(text)
|
|
100
|
+
if raw is not None:
|
|
101
|
+
message["rawEventData"] = raw
|
|
102
|
+
return message
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_frame(stream) -> dict | None:
|
|
106
|
+
"""Reads one message from a binary stream; `None` at a clean end between frames."""
|
|
107
|
+
length = 0
|
|
108
|
+
digits = 0
|
|
109
|
+
while True:
|
|
110
|
+
byte = stream.read(1)
|
|
111
|
+
if not byte:
|
|
112
|
+
if digits == 0:
|
|
113
|
+
return None
|
|
114
|
+
raise ProtocolError("truncated")
|
|
115
|
+
value = byte[0]
|
|
116
|
+
if value == 10:
|
|
117
|
+
if digits == 0:
|
|
118
|
+
raise ProtocolError("empty length")
|
|
119
|
+
break
|
|
120
|
+
if value < 48 or value > 57 or (digits == 0 and value == 48):
|
|
121
|
+
raise ProtocolError("decimal length")
|
|
122
|
+
digits += 1
|
|
123
|
+
length = length * 10 + value - 48
|
|
124
|
+
if digits > _MAX_HEADER_DIGITS or length > MAX_FRAME_BYTES:
|
|
125
|
+
raise ProtocolError("frame too large")
|
|
126
|
+
payload = stream.read(length)
|
|
127
|
+
if len(payload) != length:
|
|
128
|
+
raise ProtocolError("truncated")
|
|
129
|
+
return _decode_message(payload)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FrameWriter:
|
|
133
|
+
"""Writes frames to the protocol channel, one whole frame at a time."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, stream):
|
|
136
|
+
self._stream = stream
|
|
137
|
+
self._lock = threading.Lock()
|
|
138
|
+
|
|
139
|
+
def write(self, message: dict) -> None:
|
|
140
|
+
data = encode_frame(message)
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._stream.write(data)
|
|
143
|
+
flush = getattr(self._stream, "flush", None)
|
|
144
|
+
if flush is not None:
|
|
145
|
+
flush()
|
|
@@ -0,0 +1,92 @@
|
|
|
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 . import appcheck
|
|
10
|
+
from .dispatch import Dispatcher
|
|
11
|
+
from .environment import EnvironmentGate, load_local_secrets
|
|
12
|
+
from .framing import FrameWriter, ProtocolError, read_frame
|
|
13
|
+
from .manifest import build_manifest
|
|
14
|
+
from .serve import HTTP_TRIGGERS, start_server
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse(argv):
|
|
18
|
+
parser = argparse.ArgumentParser(prog="runner.py")
|
|
19
|
+
parser.add_argument("--source", required=True)
|
|
20
|
+
parser.add_argument("--codebase", default="default")
|
|
21
|
+
return parser.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_codebase(source: str) -> dict:
|
|
25
|
+
"""Imports the codebase's `main.py` the way the SDK's own admin server does and returns its functions."""
|
|
26
|
+
os.chdir(source)
|
|
27
|
+
from firebase_functions.private import serving
|
|
28
|
+
|
|
29
|
+
return serving.get_functions()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def serve(
|
|
33
|
+
functions: dict, codebase: str, secret: str, stdin, writer: FrameWriter, gate: EnvironmentGate | None = None, app_check_verified: bool = False
|
|
34
|
+
) -> int:
|
|
35
|
+
"""Says hello, then serves frames from `stdin` until `shutdown` or the end of the stream."""
|
|
36
|
+
manifest = build_manifest(functions, app_check_verified)
|
|
37
|
+
server = None
|
|
38
|
+
port = None
|
|
39
|
+
if any(spec["trigger"]["type"] in HTTP_TRIGGERS for spec in manifest["functions"]):
|
|
40
|
+
server, port = start_server(functions, manifest, secret, gate)
|
|
41
|
+
writer.write(
|
|
42
|
+
{
|
|
43
|
+
"type": "hello",
|
|
44
|
+
"runner": "python",
|
|
45
|
+
"codebase": codebase,
|
|
46
|
+
"version": platform.python_version(),
|
|
47
|
+
"httpPort": port,
|
|
48
|
+
"manifest": manifest,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
dispatcher = Dispatcher(functions, manifest, writer, gate)
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
message = read_frame(stdin)
|
|
55
|
+
if message is None or message["type"] == "shutdown":
|
|
56
|
+
return 0
|
|
57
|
+
if not dispatcher.reserve():
|
|
58
|
+
sys.stderr.write("[functions] too many active invocations\n")
|
|
59
|
+
return 2
|
|
60
|
+
dispatcher.start(message)
|
|
61
|
+
except ProtocolError as error:
|
|
62
|
+
sys.stderr.write(f"[functions] {error}\n")
|
|
63
|
+
return 2
|
|
64
|
+
finally:
|
|
65
|
+
if server is not None:
|
|
66
|
+
server.shutdown()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run(argv) -> int:
|
|
70
|
+
args = _parse(argv)
|
|
71
|
+
# The protocol owns the original stdout. Everything that writes to file descriptor 1 afterwards
|
|
72
|
+
# (print, C extensions, child processes, the SDK logger) goes to stderr instead.
|
|
73
|
+
protocol = os.fdopen(os.dup(1), "wb", buffering=0)
|
|
74
|
+
os.dup2(2, 1)
|
|
75
|
+
writer = FrameWriter(protocol)
|
|
76
|
+
secret = os.environ.get("FIREEMU_RUNNER_SECRET", "")
|
|
77
|
+
# The daemon's local secrets are put back into the environment, a function at a time, by the gate; user code
|
|
78
|
+
# does not see the channel that carried them, nor the key to this runner's HTTP port.
|
|
79
|
+
os.environ.pop("FIREEMU_RUNNER_SECRET", None)
|
|
80
|
+
try:
|
|
81
|
+
gate = EnvironmentGate(load_local_secrets(os.environ))
|
|
82
|
+
except ValueError as error:
|
|
83
|
+
sys.stderr.write(f"[functions] {error}\n")
|
|
84
|
+
return 1
|
|
85
|
+
# Before any user code runs, so the Admin SDK's App Check service is built against the emulator's keys.
|
|
86
|
+
app_check_verified = appcheck.install_from_environment(os.environ)
|
|
87
|
+
try:
|
|
88
|
+
functions = load_codebase(args.source)
|
|
89
|
+
except BaseException: # a failing import in user code is reported with its traceback
|
|
90
|
+
traceback.print_exc()
|
|
91
|
+
return 1
|
|
92
|
+
return serve(functions, args.codebase, secret, sys.stdin.buffer, writer, gate, app_check_verified)
|