@fireemu/darwin-x64 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 +3 -2
- package/bin/fireemu +2 -2
- package/bin/runner-python/fireemu_runner/__init__.py +5 -0
- package/bin/runner-python/fireemu_runner/dispatch.py +104 -0
- package/bin/runner-python/fireemu_runner/events.py +113 -0
- package/bin/runner-python/fireemu_runner/framing.py +95 -0
- package/bin/runner-python/fireemu_runner/main.py +82 -0
- package/bin/runner-python/fireemu_runner/manifest.py +287 -0
- package/bin/runner-python/fireemu_runner/serve.py +93 -0
- package/bin/runner-python/runner.py +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @fireemu/darwin-x64
|
|
2
2
|
|
|
3
3
|
The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `darwin-x64`
|
|
4
|
-
(Rust target `x86_64-apple-darwin`), version 0.
|
|
4
|
+
(Rust target `x86_64-apple-darwin`), version 0.43.0.
|
|
5
5
|
|
|
6
6
|
macOS 13 or newer on Intel. Cross-compiled from the Apple silicon runner against the same universal SDK; its unit tests run on the arm64 host, which builds the same code.
|
|
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/`)
|
|
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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 40488344 bytes
|
|
4
|
+
sha256: c69d7b7ff0a2ade2bc47f666dfcfa531f1edcdd7d303edf522593bfcd8852d73
|
|
@@ -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)
|