Skip to content

Commit 4eb5e9e

Browse files
committed
feat: add support for VPC direct connect
1 parent d5567f6 commit 4eb5e9e

3 files changed

Lines changed: 225 additions & 16 deletions

File tree

src/firebase_functions/options.py

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,29 @@ def __str__(self) -> str:
5858
return self.value
5959

6060

61+
@_dataclasses.dataclass(frozen=True)
62+
class NetworkInterface:
63+
"""
64+
Interface for a direct VPC network connection.
65+
At least one of ``network`` or ``subnetwork`` must be specified.
66+
"""
67+
68+
network: str | Expression[str] | _util.Sentinel | None = None
69+
"""
70+
Network to use for Direct VPC Egress.
71+
"""
72+
73+
subnetwork: str | Expression[str] | _util.Sentinel | None = None
74+
"""
75+
Subnetwork to use for Direct VPC Egress.
76+
"""
77+
78+
tags: str | list[str] | Expression[str] | Expression[list] | _util.Sentinel | None = None
79+
"""
80+
Optional tags to apply to Direct VPC Egress traffic.
81+
"""
82+
83+
6184
@_dataclasses.dataclass(frozen=True)
6285
class CorsOptions:
6386
"""
@@ -257,6 +280,7 @@ class RuntimeOptions:
257280
vpc_connector: str | _util.Sentinel | None = None
258281
"""
259282
Connect function to specified VPC connector.
283+
Mutually exclusive with ``network_interface``.
260284
A value of ``RESET_VALUE`` removes the VPC connector.
261285
"""
262286

@@ -266,6 +290,18 @@ class RuntimeOptions:
266290
A value of ``RESET_VALUE`` turns off VPC connector egress settings.
267291
"""
268292

293+
vpc_egress: VpcEgressSetting | _util.Sentinel | None = None
294+
"""
295+
Alias for ``vpc_connector_egress_settings``.
296+
"""
297+
298+
network_interface: NetworkInterface | _util.Sentinel | None = None
299+
"""
300+
Direct VPC Egress network interface settings.
301+
Mutually exclusive with ``vpc_connector``.
302+
A value of ``RESET_VALUE`` removes Direct VPC Egress settings.
303+
"""
304+
269305
service_account: str | _util.Sentinel | None = None
270306
"""
271307
Specific service account for the function to run as.
@@ -339,6 +375,8 @@ def _asdict_with_global_options(self) -> dict:
339375
"service_account",
340376
"vpc_connector",
341377
"vpc_connector_egress_settings",
378+
"vpc_egress",
379+
"network_interface",
342380
]
343381
if not preserve_external_changes:
344382
for option in resettable_options:
@@ -380,18 +418,76 @@ def convert_secret(secret) -> _manifest.SecretEnvironmentVariable:
380418
elif options.region is not None:
381419
region = [_typing.cast(str, options.region)]
382420

383-
vpc: _manifest.VpcSettings | None = None
384-
if isinstance(options.vpc_connector, str):
385-
vpc = (
386-
{
387-
"connector": options.vpc_connector,
388-
"egressSettings": options.vpc_connector_egress_settings.value
389-
if isinstance(options.vpc_connector_egress_settings, VpcEgressSetting)
390-
else options.vpc_connector_egress_settings,
391-
}
392-
if options.vpc_connector_egress_settings is not None
393-
else {"connector": options.vpc_connector}
394-
)
421+
vpc: _manifest.VpcSettings | _util.Sentinel | None = None
422+
vpc_egress = options.vpc_connector_egress_settings
423+
if options.vpc_egress is not None and (
424+
not isinstance(options.vpc_egress, _util.Sentinel) or vpc_egress is None
425+
):
426+
vpc_egress = options.vpc_egress
427+
428+
connector = options.vpc_connector
429+
has_connector = isinstance(connector, str)
430+
reset_connector = isinstance(connector, _util.Sentinel)
431+
432+
raw_network_interface = options_dict.get("network_interface")
433+
has_network_interface = isinstance(raw_network_interface, dict)
434+
reset_network_interface = isinstance(raw_network_interface, _util.Sentinel)
435+
436+
if has_network_interface:
437+
network_interface_dict = _typing.cast(dict[str, object], raw_network_interface)
438+
if (
439+
network_interface_dict.get("network") is None
440+
and network_interface_dict.get("subnetwork") is None
441+
):
442+
raise ValueError(
443+
"At least one of network or subnetwork must be specified in network_interface."
444+
)
445+
446+
if has_connector and has_network_interface:
447+
raise ValueError("Cannot set both vpc_connector and network_interface")
448+
449+
if (
450+
has_connector
451+
or vpc_egress is not None
452+
or has_network_interface
453+
or reset_connector
454+
or reset_network_interface
455+
):
456+
if (reset_connector and not has_network_interface) or (
457+
reset_network_interface and not has_connector
458+
):
459+
vpc = RESET_VALUE
460+
else:
461+
vpc = {}
462+
if has_connector:
463+
vpc["connector"] = _typing.cast(str, connector)
464+
if vpc_egress is not None:
465+
vpc["egressSettings"] = (
466+
vpc_egress.value if isinstance(vpc_egress, VpcEgressSetting) else vpc_egress
467+
)
468+
if has_network_interface:
469+
network_interface_dict = _typing.cast(dict[str, object], raw_network_interface)
470+
network_interface_spec: _manifest.VpcNetworkInterface = {}
471+
if network_interface_dict.get("network") is not None:
472+
network_interface_spec["network"] = _typing.cast(
473+
str | Expression[str] | _util.Sentinel,
474+
network_interface_dict["network"],
475+
)
476+
if network_interface_dict.get("subnetwork") is not None:
477+
network_interface_spec["subnetwork"] = _typing.cast(
478+
str | Expression[str] | _util.Sentinel,
479+
network_interface_dict["subnetwork"],
480+
)
481+
if network_interface_dict.get("tags") is not None:
482+
network_interface_spec["tags"] = _typing.cast(
483+
str
484+
| list[str]
485+
| Expression[str]
486+
| Expression[list]
487+
| _util.Sentinel,
488+
network_interface_dict["tags"],
489+
)
490+
vpc["networkInterfaces"] = [network_interface_spec]
395491

396492
endpoint = _manifest.ManifestEndpoint(
397493
entryPoint=kwargs["func_name"],
@@ -1254,8 +1350,10 @@ def set_global_options(
12541350
max_instances: int | Expression[int] | _util.Sentinel | None = None,
12551351
concurrency: int | Expression[int] | _util.Sentinel | None = None,
12561352
cpu: int | _typing.Literal["gcf_gen1"] | _util.Sentinel = "gcf_gen1",
1257-
vpc_connector: str | None = None,
1258-
vpc_connector_egress_settings: VpcEgressSetting | None = None,
1353+
vpc_connector: str | _util.Sentinel | None = None,
1354+
vpc_connector_egress_settings: VpcEgressSetting | _util.Sentinel | None = None,
1355+
vpc_egress: VpcEgressSetting | _util.Sentinel | None = None,
1356+
network_interface: NetworkInterface | _util.Sentinel | None = None,
12591357
service_account: str | _util.Sentinel | None = None,
12601358
ingress: IngressSetting | _util.Sentinel | None = None,
12611359
labels: dict[str, str] | None = None,
@@ -1277,6 +1375,8 @@ def set_global_options(
12771375
cpu=cpu,
12781376
vpc_connector=vpc_connector,
12791377
vpc_connector_egress_settings=vpc_connector_egress_settings,
1378+
vpc_egress=vpc_egress,
1379+
network_interface=network_interface,
12801380
service_account=service_account,
12811381
ingress=ingress,
12821382
labels=labels,

src/firebase_functions/private/manifest.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,18 @@ class BlockingTrigger(_typing.TypedDict):
138138
options: _typing_extensions.NotRequired[BlockingTriggerOptions]
139139

140140

141+
class VpcNetworkInterface(_typing.TypedDict):
142+
network: _typing_extensions.NotRequired[str | _params.Expression[str] | _util.Sentinel]
143+
subnetwork: _typing_extensions.NotRequired[str | _params.Expression[str] | _util.Sentinel]
144+
tags: _typing_extensions.NotRequired[
145+
str | list[str] | _params.Expression[str] | _params.Expression[list] | _util.Sentinel
146+
]
147+
148+
141149
class VpcSettings(_typing.TypedDict):
142-
connector: _typing_extensions.Required[str]
150+
connector: _typing_extensions.NotRequired[str]
143151
egressSettings: _typing_extensions.NotRequired[str | _util.Sentinel]
152+
networkInterfaces: _typing_extensions.NotRequired[list[VpcNetworkInterface] | _util.Sentinel]
144153

145154

146155
@_dataclasses.dataclass(frozen=True)
@@ -157,7 +166,7 @@ class ManifestEndpoint:
157166
serviceAccountEmail: str | _util.Sentinel | None = None
158167
timeoutSeconds: int | _params.Expression[int] | _util.Sentinel | None = None
159168
cpu: int | str | _util.Sentinel | None = None
160-
vpc: VpcSettings | None = None
169+
vpc: VpcSettings | _util.Sentinel | None = None
161170
labels: dict[str, str] | None = None
162171
ingressSettings: str | None | _util.Sentinel = None
163172
secretEnvironmentVariables: list[SecretEnvironmentVariable] | _util.Sentinel | None = (

tests/test_options.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,69 @@ def test_options_asdict_uses_cel_representation():
8686
)
8787

8888

89+
def test_vpc_connector_endpoint_unchanged():
90+
endpoint = options.HttpsOptions(
91+
vpc_connector="projects/test/locations/us-central1/connectors/test",
92+
vpc_connector_egress_settings=options.VpcEgressSetting.ALL_TRAFFIC,
93+
)._endpoint(func_name="test")
94+
95+
assert endpoint.vpc == {
96+
"connector": "projects/test/locations/us-central1/connectors/test",
97+
"egressSettings": "ALL_TRAFFIC",
98+
}
99+
100+
101+
def test_network_interface_endpoint():
102+
endpoint = options.HttpsOptions(
103+
network_interface=options.NetworkInterface(
104+
network="default",
105+
tags=["internal", "egress"],
106+
),
107+
vpc_egress=options.VpcEgressSetting.PRIVATE_RANGES_ONLY,
108+
)._endpoint(func_name="test")
109+
110+
assert endpoint.vpc == {
111+
"networkInterfaces": [
112+
{
113+
"network": "default",
114+
"tags": ["internal", "egress"],
115+
}
116+
],
117+
"egressSettings": "PRIVATE_RANGES_ONLY",
118+
}
119+
120+
121+
def test_vpc_egress_alias_takes_precedence():
122+
endpoint = options.HttpsOptions(
123+
vpc_connector="projects/test/locations/us-central1/connectors/test",
124+
vpc_connector_egress_settings=options.VpcEgressSetting.ALL_TRAFFIC,
125+
vpc_egress=options.VpcEgressSetting.PRIVATE_RANGES_ONLY,
126+
)._endpoint(func_name="test")
127+
128+
assert endpoint.vpc == {
129+
"connector": "projects/test/locations/us-central1/connectors/test",
130+
"egressSettings": "PRIVATE_RANGES_ONLY",
131+
}
132+
133+
134+
def test_network_interface_requires_network_or_subnetwork():
135+
with raises(
136+
ValueError,
137+
match="At least one of network or subnetwork must be specified in network_interface.",
138+
):
139+
options.HttpsOptions(
140+
network_interface=options.NetworkInterface(),
141+
)._endpoint(func_name="test")
142+
143+
144+
def test_vpc_connector_and_network_interface_are_mutually_exclusive():
145+
with raises(ValueError, match="Cannot set both vpc_connector and network_interface"):
146+
options.HttpsOptions(
147+
vpc_connector="projects/test/locations/us-central1/connectors/test",
148+
network_interface=options.NetworkInterface(network="default"),
149+
)._endpoint(func_name="test")
150+
151+
89152
def test_options_preserve_external_changes():
90153
"""
91154
Testing if setting a global option internally change the values.
@@ -100,6 +163,10 @@ def test_options_preserve_external_changes():
100163
options_asdict = options._GLOBAL_OPTIONS._asdict_with_global_options()
101164
assert options_asdict["max_instances"] is options.RESET_VALUE, "option should be RESET_VALUE"
102165
assert options_asdict["min_instances"] == 5, "option should be set"
166+
assert options_asdict["network_interface"] is options.RESET_VALUE, (
167+
"network_interface should be RESET_VALUE"
168+
)
169+
assert options_asdict["vpc_egress"] is options.RESET_VALUE, "vpc_egress should be RESET_VALUE"
103170

104171
firebase_functions = {
105172
"asamplefunction": asamplefunction,
@@ -109,6 +176,7 @@ def test_options_preserve_external_changes():
109176
# where we expect.
110177
assert " availableMemoryMb: null\n" in yaml, "availableMemoryMb not in yaml"
111178
assert " serviceAccountEmail: null\n" in yaml, "serviceAccountEmail not in yaml"
179+
assert " vpc: null\n" in yaml, "vpc not in yaml"
112180

113181
firebase_functions2 = {
114182
"asamplefunctionpreserved": asamplefunctionpreserved,
@@ -118,6 +186,38 @@ def test_options_preserve_external_changes():
118186
assert " serviceAccountEmail: null\n" not in yaml, "serviceAccountEmail found in yaml"
119187

120188

189+
def test_network_interface_reset_sets_vpc_reset():
190+
endpoint = options.HttpsOptions(
191+
network_interface=options.RESET_VALUE,
192+
)._endpoint(func_name="test")
193+
194+
assert endpoint.vpc == options.RESET_VALUE, "vpc should be RESET_VALUE"
195+
196+
197+
def test_global_options_support_network_interface():
198+
previous_options = options._GLOBAL_OPTIONS
199+
try:
200+
options.set_global_options(
201+
network_interface=options.NetworkInterface(
202+
subnetwork="projects/test/regions/us-central1/subnetworks/test"
203+
),
204+
vpc_egress=options.VpcEgressSetting.ALL_TRAFFIC,
205+
)
206+
207+
endpoint = options.HttpsOptions()._endpoint(func_name="test")
208+
209+
assert endpoint.vpc == {
210+
"networkInterfaces": [
211+
{
212+
"subnetwork": "projects/test/regions/us-central1/subnetworks/test",
213+
}
214+
],
215+
"egressSettings": "ALL_TRAFFIC",
216+
}
217+
finally:
218+
options._GLOBAL_OPTIONS = previous_options
219+
220+
121221
def test_merge_apis_empty_input():
122222
"""
123223
This test checks the behavior of the merge_required_apis function

0 commit comments

Comments
 (0)