fastly-sync 0.1.0__py3-none-any.whl
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.
- fastly_sync/__init__.py +6 -0
- fastly_sync/blocklist.py +65 -0
- fastly_sync/cli.py +457 -0
- fastly_sync/config.py +66 -0
- fastly_sync/errors.py +30 -0
- fastly_sync/fastly.py +303 -0
- fastly_sync/loader.py +57 -0
- fastly_sync/models.py +86 -0
- fastly_sync/show.py +63 -0
- fastly_sync/spec.py +172 -0
- fastly_sync/sync.py +167 -0
- fastly_sync/waf.py +114 -0
- fastly_sync-0.1.0.dist-info/METADATA +240 -0
- fastly_sync-0.1.0.dist-info/RECORD +17 -0
- fastly_sync-0.1.0.dist-info/WHEEL +4 -0
- fastly_sync-0.1.0.dist-info/entry_points.txt +2 -0
- fastly_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
fastly_sync/__init__.py
ADDED
fastly_sync/blocklist.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Chris <goabonga@pm.me>
|
|
3
|
+
|
|
4
|
+
"""Load and validate an IP blocklist from a local or remote text file."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ipaddress
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import BlocklistError
|
|
14
|
+
from .loader import read_source
|
|
15
|
+
from .models import BlockEntry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_blocklist(
|
|
19
|
+
source: str, *, client: httpx.Client | None = None
|
|
20
|
+
) -> tuple[BlockEntry, ...]:
|
|
21
|
+
"""Load a blocklist of IPs/CIDRs (one per line, ``#`` for comments).
|
|
22
|
+
|
|
23
|
+
The source is a filesystem path or an ``http(s)`` URL. Each entry is
|
|
24
|
+
validated with :mod:`ipaddress`; duplicates are collapsed (last comment
|
|
25
|
+
wins) and the result is sorted for deterministic output.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
SourceError: if the source cannot be read or fetched.
|
|
29
|
+
BlocklistError: if a line is not a valid IP address or CIDR.
|
|
30
|
+
"""
|
|
31
|
+
text = read_source(source, client=client)
|
|
32
|
+
entries: dict[tuple[str, int | None], BlockEntry] = {}
|
|
33
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
34
|
+
value, _, comment = raw.partition("#")
|
|
35
|
+
token = value.strip()
|
|
36
|
+
if not token:
|
|
37
|
+
continue
|
|
38
|
+
entry = _parse_entry(token, comment.strip(), source, lineno)
|
|
39
|
+
entries[entry.key] = entry
|
|
40
|
+
return tuple(sorted(entries.values(), key=lambda e: (e.ip, e.subnet or 0)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dump_blocklist(entries: Sequence[BlockEntry]) -> str:
|
|
44
|
+
"""Render block entries back to the text blocklist format.
|
|
45
|
+
|
|
46
|
+
The output round-trips through :func:`load_blocklist`: one ``ip`` or
|
|
47
|
+
``ip/subnet`` per line, with an optional ``# comment`` suffix.
|
|
48
|
+
"""
|
|
49
|
+
lines = []
|
|
50
|
+
for entry in entries:
|
|
51
|
+
cidr = entry.ip if entry.subnet is None else f"{entry.ip}/{entry.subnet}"
|
|
52
|
+
lines.append(f"{cidr} # {entry.comment}" if entry.comment else cidr)
|
|
53
|
+
return "".join(f"{line}\n" for line in lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_entry(token: str, comment: str, source: str, lineno: int) -> BlockEntry:
|
|
57
|
+
try:
|
|
58
|
+
network = ipaddress.ip_network(token, strict=False)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
raise BlocklistError(
|
|
61
|
+
f"{source}:{lineno}: invalid IP/CIDR '{token}': {exc}"
|
|
62
|
+
) from exc
|
|
63
|
+
is_host = network.prefixlen == network.max_prefixlen
|
|
64
|
+
subnet = None if is_host else network.prefixlen
|
|
65
|
+
return BlockEntry(ip=str(network.network_address), subnet=subnet, comment=comment)
|
fastly_sync/cli.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Chris <goabonga@pm.me>
|
|
3
|
+
|
|
4
|
+
"""Command-line entry point for fastly-sync (built with Typer)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Collection
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .blocklist import dump_blocklist, load_blocklist
|
|
15
|
+
from .config import load_settings
|
|
16
|
+
from .errors import FastlySyncError
|
|
17
|
+
from .fastly import FastlyClient
|
|
18
|
+
from .models import SyncResult
|
|
19
|
+
from .show import LiveConfig, gather
|
|
20
|
+
from .spec import build_desired_state, load_spec
|
|
21
|
+
from .sync import ALL_COMPONENTS, Component, select_state, synchronize
|
|
22
|
+
from .waf import DEFAULT_ACL_NAME, bootstrap_acl, synchronize_blocklist
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=True,
|
|
27
|
+
help=(
|
|
28
|
+
"Synchronise Fastly configuration on demand: CDN cache and rate "
|
|
29
|
+
"limiters from a local or remote OpenAPI (openapi.json) document, "
|
|
30
|
+
"and the WAF IP blocklist (Edge ACL) from a text file."
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
sync_app = typer.Typer(
|
|
34
|
+
no_args_is_help=True, help="Apply configuration to Fastly (per target)."
|
|
35
|
+
)
|
|
36
|
+
show_app = typer.Typer(
|
|
37
|
+
no_args_is_help=True, help="Show the live configuration applied on Fastly."
|
|
38
|
+
)
|
|
39
|
+
app.add_typer(sync_app, name="sync")
|
|
40
|
+
app.add_typer(show_app, name="show")
|
|
41
|
+
|
|
42
|
+
_OPENAPI_OPTION = typer.Option(
|
|
43
|
+
..., "--openapi", metavar="PATH_OR_URL", help="path or URL to openapi.json"
|
|
44
|
+
)
|
|
45
|
+
_PRUNE_OPTION = typer.Option(
|
|
46
|
+
True,
|
|
47
|
+
"--prune/--no-prune",
|
|
48
|
+
help="delete managed objects no longer in the spec (default: on)",
|
|
49
|
+
)
|
|
50
|
+
_ACL_NAME_OPTION = typer.Option(
|
|
51
|
+
DEFAULT_ACL_NAME, "--acl-name", help="name of the WAF Edge ACL"
|
|
52
|
+
)
|
|
53
|
+
_TOKEN_OPTION = typer.Option(
|
|
54
|
+
None, "--token", help="Fastly API token ($FASTLY_API_TOKEN)"
|
|
55
|
+
)
|
|
56
|
+
_SERVICE_OPTION = typer.Option(
|
|
57
|
+
None, "--service-id", help="Fastly service id ($FASTLY_SERVICE_ID)"
|
|
58
|
+
)
|
|
59
|
+
_DRY_RUN_OPTION = typer.Option(
|
|
60
|
+
False, "--dry-run", help="report the plan without applying it"
|
|
61
|
+
)
|
|
62
|
+
_NO_CONFIRM_OPTION = typer.Option(
|
|
63
|
+
False, "--no-confirm", help="apply without the interactive confirmation prompt"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _guard(action: Callable[[], None]) -> None:
|
|
68
|
+
"""Run a command body, mapping domain errors to a clean exit code 1."""
|
|
69
|
+
try:
|
|
70
|
+
action()
|
|
71
|
+
except FastlySyncError as exc:
|
|
72
|
+
typer.echo(f"error: {exc}", err=True)
|
|
73
|
+
raise typer.Exit(1) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _confirmed(no_confirm: bool) -> bool:
|
|
77
|
+
"""Return whether to proceed: ``--no-confirm`` or a positive prompt answer."""
|
|
78
|
+
if no_confirm or typer.confirm("Apply these changes?"):
|
|
79
|
+
return True
|
|
80
|
+
typer.echo("aborted, nothing applied")
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _echo_sync_plan(plan: SyncResult) -> None:
|
|
85
|
+
typer.echo(
|
|
86
|
+
f"fastly-sync plan: {len(plan.applied)} to apply, {len(plan.removed)} to prune"
|
|
87
|
+
)
|
|
88
|
+
for action in plan.applied:
|
|
89
|
+
typer.echo(f" ~ [{action.kind}] {action.name} ({action.detail})")
|
|
90
|
+
for action in plan.removed:
|
|
91
|
+
typer.echo(f" - [{action.kind}] {action.name} ({action.detail})")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _version_callback(value: bool) -> None:
|
|
95
|
+
if value:
|
|
96
|
+
typer.echo(f"fastly-sync {__version__}")
|
|
97
|
+
raise typer.Exit()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.callback()
|
|
101
|
+
def _root(
|
|
102
|
+
version: bool = typer.Option(
|
|
103
|
+
False,
|
|
104
|
+
"--version",
|
|
105
|
+
callback=_version_callback,
|
|
106
|
+
is_eager=True,
|
|
107
|
+
help="show the version and exit",
|
|
108
|
+
),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""fastly-sync command-line interface."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- sync <target> -----------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_openapi_sync(
|
|
117
|
+
*,
|
|
118
|
+
openapi: str,
|
|
119
|
+
components: Collection[Component],
|
|
120
|
+
prune: bool,
|
|
121
|
+
no_confirm: bool,
|
|
122
|
+
dry_run: bool,
|
|
123
|
+
token: str | None,
|
|
124
|
+
service_id: str | None,
|
|
125
|
+
) -> None:
|
|
126
|
+
def run() -> None:
|
|
127
|
+
settings = load_settings(token, service_id)
|
|
128
|
+
state = select_state(build_desired_state(load_spec(openapi)), components)
|
|
129
|
+
with FastlyClient(settings.token, settings.service_id) as client:
|
|
130
|
+
plan = synchronize(
|
|
131
|
+
state, client, components=components, prune=prune, dry_run=True
|
|
132
|
+
)
|
|
133
|
+
_echo_sync_plan(plan)
|
|
134
|
+
if dry_run:
|
|
135
|
+
typer.echo("(dry run — nothing applied)")
|
|
136
|
+
return
|
|
137
|
+
if not _confirmed(no_confirm):
|
|
138
|
+
return
|
|
139
|
+
result = synchronize(
|
|
140
|
+
state, client, components=components, prune=prune, dry_run=False
|
|
141
|
+
)
|
|
142
|
+
typer.echo(
|
|
143
|
+
f"fastly-sync: applied {len(result.applied)} change(s), "
|
|
144
|
+
f"pruned {len(result.removed)} orphan(s)"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
_guard(run)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@sync_app.command("cdn")
|
|
151
|
+
def sync_cdn(
|
|
152
|
+
openapi: str = _OPENAPI_OPTION,
|
|
153
|
+
prune: bool = _PRUNE_OPTION,
|
|
154
|
+
no_confirm: bool = _NO_CONFIRM_OPTION,
|
|
155
|
+
token: str | None = _TOKEN_OPTION,
|
|
156
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
157
|
+
dry_run: bool = _DRY_RUN_OPTION,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Synchronise only the CDN cache settings."""
|
|
160
|
+
_run_openapi_sync(
|
|
161
|
+
openapi=openapi,
|
|
162
|
+
components=frozenset({Component.CDN}),
|
|
163
|
+
prune=prune,
|
|
164
|
+
no_confirm=no_confirm,
|
|
165
|
+
dry_run=dry_run,
|
|
166
|
+
token=token,
|
|
167
|
+
service_id=service_id,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@sync_app.command("rate-limiter")
|
|
172
|
+
def sync_rate_limiter(
|
|
173
|
+
openapi: str = _OPENAPI_OPTION,
|
|
174
|
+
prune: bool = _PRUNE_OPTION,
|
|
175
|
+
no_confirm: bool = _NO_CONFIRM_OPTION,
|
|
176
|
+
token: str | None = _TOKEN_OPTION,
|
|
177
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
178
|
+
dry_run: bool = _DRY_RUN_OPTION,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Synchronise only the rate limiters."""
|
|
181
|
+
_run_openapi_sync(
|
|
182
|
+
openapi=openapi,
|
|
183
|
+
components=frozenset({Component.RATELIMIT}),
|
|
184
|
+
prune=prune,
|
|
185
|
+
no_confirm=no_confirm,
|
|
186
|
+
dry_run=dry_run,
|
|
187
|
+
token=token,
|
|
188
|
+
service_id=service_id,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@sync_app.command("all")
|
|
193
|
+
def sync_all(
|
|
194
|
+
openapi: str = _OPENAPI_OPTION,
|
|
195
|
+
blocklist: str = typer.Option(
|
|
196
|
+
...,
|
|
197
|
+
"--blocklist",
|
|
198
|
+
metavar="PATH_OR_URL",
|
|
199
|
+
help="path or URL to the IP/CIDR blocklist (one per line)",
|
|
200
|
+
),
|
|
201
|
+
acl_name: str = _ACL_NAME_OPTION,
|
|
202
|
+
bootstrap: bool = typer.Option(
|
|
203
|
+
False, "--bootstrap", help="create the WAF ACL and VCL snippet before syncing"
|
|
204
|
+
),
|
|
205
|
+
prune: bool = _PRUNE_OPTION,
|
|
206
|
+
no_confirm: bool = _NO_CONFIRM_OPTION,
|
|
207
|
+
token: str | None = _TOKEN_OPTION,
|
|
208
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
209
|
+
dry_run: bool = _DRY_RUN_OPTION,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Synchronise CDN cache, rate limiters and the WAF blocklist together."""
|
|
212
|
+
|
|
213
|
+
def run() -> None:
|
|
214
|
+
settings = load_settings(token, service_id)
|
|
215
|
+
state = select_state(build_desired_state(load_spec(openapi)), ALL_COMPONENTS)
|
|
216
|
+
entries = load_blocklist(blocklist)
|
|
217
|
+
with FastlyClient(settings.token, settings.service_id) as client:
|
|
218
|
+
plan = synchronize(
|
|
219
|
+
state, client, components=ALL_COMPONENTS, prune=prune, dry_run=True
|
|
220
|
+
)
|
|
221
|
+
_echo_sync_plan(plan)
|
|
222
|
+
if bootstrap:
|
|
223
|
+
typer.echo(
|
|
224
|
+
f"fastly-sync plan: bootstrap ACL '{acl_name}' and load "
|
|
225
|
+
f"{len(entries)} entry(ies)"
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
waf_plan = synchronize_blocklist(
|
|
229
|
+
entries, client, acl_name, dry_run=True
|
|
230
|
+
)
|
|
231
|
+
typer.echo(
|
|
232
|
+
f"fastly-sync plan: ACL '{acl_name}' "
|
|
233
|
+
f"+{len(waf_plan.added)} / -{len(waf_plan.removed)}"
|
|
234
|
+
)
|
|
235
|
+
if dry_run:
|
|
236
|
+
typer.echo("(dry run — nothing applied)")
|
|
237
|
+
return
|
|
238
|
+
if not _confirmed(no_confirm):
|
|
239
|
+
return
|
|
240
|
+
result = synchronize(
|
|
241
|
+
state, client, components=ALL_COMPONENTS, prune=prune, dry_run=False
|
|
242
|
+
)
|
|
243
|
+
typer.echo(
|
|
244
|
+
f"fastly-sync: applied {len(result.applied)} change(s), "
|
|
245
|
+
f"pruned {len(result.removed)} orphan(s)"
|
|
246
|
+
)
|
|
247
|
+
if bootstrap:
|
|
248
|
+
bootstrap_acl(client, acl_name)
|
|
249
|
+
waf_result = synchronize_blocklist(entries, client, acl_name, dry_run=False)
|
|
250
|
+
typer.echo(
|
|
251
|
+
f"fastly-sync: applied blocklist '{waf_result.acl_name}': "
|
|
252
|
+
f"+{len(waf_result.added)} / -{len(waf_result.removed)}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
_guard(run)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@sync_app.command("waf")
|
|
259
|
+
def sync_waf(
|
|
260
|
+
blocklist: str = typer.Option(
|
|
261
|
+
...,
|
|
262
|
+
"--blocklist",
|
|
263
|
+
metavar="PATH_OR_URL",
|
|
264
|
+
help="path or URL to the IP/CIDR blocklist (one per line)",
|
|
265
|
+
),
|
|
266
|
+
acl_name: str = _ACL_NAME_OPTION,
|
|
267
|
+
bootstrap: bool = typer.Option(
|
|
268
|
+
False, "--bootstrap", help="create the ACL and VCL snippet before syncing"
|
|
269
|
+
),
|
|
270
|
+
no_confirm: bool = _NO_CONFIRM_OPTION,
|
|
271
|
+
token: str | None = _TOKEN_OPTION,
|
|
272
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
273
|
+
dry_run: bool = _DRY_RUN_OPTION,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Reconcile the WAF IP blocklist (Edge ACL) from a text file."""
|
|
276
|
+
|
|
277
|
+
def run() -> None:
|
|
278
|
+
settings = load_settings(token, service_id)
|
|
279
|
+
entries = load_blocklist(blocklist)
|
|
280
|
+
with FastlyClient(settings.token, settings.service_id) as client:
|
|
281
|
+
if bootstrap:
|
|
282
|
+
# The ACL does not exist yet, so a diff is not possible; the
|
|
283
|
+
# plan is simply "create the ACL and load every entry".
|
|
284
|
+
typer.echo(
|
|
285
|
+
f"fastly-sync plan: bootstrap ACL '{acl_name}' and load "
|
|
286
|
+
f"{len(entries)} entry(ies)"
|
|
287
|
+
)
|
|
288
|
+
if dry_run:
|
|
289
|
+
typer.echo("(dry run — nothing applied)")
|
|
290
|
+
return
|
|
291
|
+
if not _confirmed(no_confirm):
|
|
292
|
+
return
|
|
293
|
+
bootstrap_acl(client, acl_name)
|
|
294
|
+
else:
|
|
295
|
+
plan = synchronize_blocklist(entries, client, acl_name, dry_run=True)
|
|
296
|
+
typer.echo(
|
|
297
|
+
f"fastly-sync plan: ACL '{acl_name}' "
|
|
298
|
+
f"+{len(plan.added)} / -{len(plan.removed)}"
|
|
299
|
+
)
|
|
300
|
+
if dry_run:
|
|
301
|
+
typer.echo("(dry run — nothing applied)")
|
|
302
|
+
return
|
|
303
|
+
if not _confirmed(no_confirm):
|
|
304
|
+
return
|
|
305
|
+
result = synchronize_blocklist(entries, client, acl_name, dry_run=False)
|
|
306
|
+
typer.echo(
|
|
307
|
+
f"fastly-sync: applied blocklist '{result.acl_name}': "
|
|
308
|
+
f"+{len(result.added)} / -{len(result.removed)}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
_guard(run)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# --- show <target> -----------------------------------------------------
|
|
315
|
+
|
|
316
|
+
_OUTPUT_OPTION = typer.Option(
|
|
317
|
+
None, "--output", "-o", help="write the output to this file instead of stdout"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _lines(*parts: str) -> str:
|
|
322
|
+
return "".join(f"{part}\n" for part in parts)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _render_version(config: LiveConfig) -> str:
|
|
326
|
+
return _lines(f"fastly-sync: active version {config.version}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _render_cdn(config: LiveConfig) -> str:
|
|
330
|
+
lines = [f"CDN cache settings ({len(config.cache_settings)}):"]
|
|
331
|
+
for setting in config.cache_settings:
|
|
332
|
+
description = setting.get("description") or ""
|
|
333
|
+
suffix = f" # {description}" if description else ""
|
|
334
|
+
lines.append(
|
|
335
|
+
f" {setting.get('name')} "
|
|
336
|
+
f"action={setting.get('action')} ttl={setting.get('ttl')}{suffix}"
|
|
337
|
+
)
|
|
338
|
+
return _lines(*lines)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _render_rate_limiters(config: LiveConfig) -> str:
|
|
342
|
+
lines = [f"Rate limiters ({len(config.rate_limiters)}):"]
|
|
343
|
+
lines += [
|
|
344
|
+
f" {limiter.get('name')} "
|
|
345
|
+
f"{limiter.get('rps_limit')} req / {limiter.get('window_size')}s"
|
|
346
|
+
for limiter in config.rate_limiters
|
|
347
|
+
]
|
|
348
|
+
return _lines(*lines)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _render_waf(config: LiveConfig, acl_name: str) -> str:
|
|
352
|
+
lines = [f"WAF blocklist '{acl_name}' ({len(config.blocklist)}):"]
|
|
353
|
+
for entry in config.blocklist:
|
|
354
|
+
cidr = entry.ip if entry.subnet is None else f"{entry.ip}/{entry.subnet}"
|
|
355
|
+
suffix = f" # {entry.comment}" if entry.comment else ""
|
|
356
|
+
lines.append(f" {cidr}{suffix}")
|
|
357
|
+
return _lines(*lines)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _emit(text: str, output: Path | None) -> None:
|
|
361
|
+
if output is None:
|
|
362
|
+
typer.echo(text, nl=False)
|
|
363
|
+
else:
|
|
364
|
+
output.write_text(text, encoding="utf-8")
|
|
365
|
+
typer.echo(f"fastly-sync: wrote to {output}", err=True)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _show(
|
|
369
|
+
token: str | None,
|
|
370
|
+
service_id: str | None,
|
|
371
|
+
render: Callable[[LiveConfig], str],
|
|
372
|
+
output: Path | None,
|
|
373
|
+
acl_name: str = DEFAULT_ACL_NAME,
|
|
374
|
+
) -> None:
|
|
375
|
+
def run() -> None:
|
|
376
|
+
settings = load_settings(token, service_id)
|
|
377
|
+
with FastlyClient(settings.token, settings.service_id) as client:
|
|
378
|
+
config = gather(client, acl_name)
|
|
379
|
+
_emit(render(config), output)
|
|
380
|
+
|
|
381
|
+
_guard(run)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@show_app.command("cdn")
|
|
385
|
+
def show_cdn(
|
|
386
|
+
output: Path | None = _OUTPUT_OPTION,
|
|
387
|
+
token: str | None = _TOKEN_OPTION,
|
|
388
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Show the live CDN cache settings (``--output`` writes to a file)."""
|
|
391
|
+
_show(
|
|
392
|
+
token,
|
|
393
|
+
service_id,
|
|
394
|
+
lambda config: _render_version(config) + _render_cdn(config),
|
|
395
|
+
output,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@show_app.command("rate-limiter")
|
|
400
|
+
def show_rate_limiter(
|
|
401
|
+
output: Path | None = _OUTPUT_OPTION,
|
|
402
|
+
token: str | None = _TOKEN_OPTION,
|
|
403
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Show the live rate limiters (``--output`` writes to a file)."""
|
|
406
|
+
_show(
|
|
407
|
+
token,
|
|
408
|
+
service_id,
|
|
409
|
+
lambda config: _render_version(config) + _render_rate_limiters(config),
|
|
410
|
+
output,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@show_app.command("all")
|
|
415
|
+
def show_all(
|
|
416
|
+
acl_name: str = _ACL_NAME_OPTION,
|
|
417
|
+
token: str | None = _TOKEN_OPTION,
|
|
418
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Show the live CDN, rate limiter and WAF config."""
|
|
421
|
+
|
|
422
|
+
def render(config: LiveConfig) -> str:
|
|
423
|
+
return (
|
|
424
|
+
_render_version(config)
|
|
425
|
+
+ _render_cdn(config)
|
|
426
|
+
+ _render_rate_limiters(config)
|
|
427
|
+
+ _render_waf(config, acl_name)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
_show(token, service_id, render, None, acl_name)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@show_app.command("waf")
|
|
434
|
+
def show_waf(
|
|
435
|
+
acl_name: str = _ACL_NAME_OPTION,
|
|
436
|
+
output: Path | None = _OUTPUT_OPTION,
|
|
437
|
+
token: str | None = _TOKEN_OPTION,
|
|
438
|
+
service_id: str | None = _SERVICE_OPTION,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Show the live WAF blocklist (``--output`` writes the blocklist format)."""
|
|
441
|
+
|
|
442
|
+
def run() -> None:
|
|
443
|
+
settings = load_settings(token, service_id)
|
|
444
|
+
with FastlyClient(settings.token, settings.service_id) as client:
|
|
445
|
+
config = gather(client, acl_name)
|
|
446
|
+
if output is None:
|
|
447
|
+
typer.echo(
|
|
448
|
+
_render_version(config) + _render_waf(config, acl_name), nl=False
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
output.write_text(dump_blocklist(config.blocklist), encoding="utf-8")
|
|
452
|
+
typer.echo(
|
|
453
|
+
f"fastly-sync: wrote {len(config.blocklist)} entry(ies) to {output}",
|
|
454
|
+
err=True,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
_guard(run)
|
fastly_sync/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Chris <goabonga@pm.me>
|
|
3
|
+
|
|
4
|
+
"""Runtime configuration resolved from CLI flags and environment variables."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, ValidationError
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
from .errors import ConfigError
|
|
12
|
+
|
|
13
|
+
TOKEN_ENV = "FASTLY_API_TOKEN" # nosec B105 - env var name, not a secret
|
|
14
|
+
SERVICE_ID_ENV = "FASTLY_SERVICE_ID"
|
|
15
|
+
|
|
16
|
+
_ENV_NAMES = {
|
|
17
|
+
"token": TOKEN_ENV,
|
|
18
|
+
"service_id": SERVICE_ID_ENV,
|
|
19
|
+
TOKEN_ENV: TOKEN_ENV,
|
|
20
|
+
SERVICE_ID_ENV: SERVICE_ID_ENV,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Settings(BaseSettings):
|
|
25
|
+
"""Credentials required to talk to the Fastly API.
|
|
26
|
+
|
|
27
|
+
Values are read from the ``FASTLY_API_TOKEN`` and ``FASTLY_SERVICE_ID``
|
|
28
|
+
environment variables; explicit values passed to :func:`load_settings`
|
|
29
|
+
take precedence.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
33
|
+
|
|
34
|
+
token: str = Field(min_length=1, validation_alias=TOKEN_ENV)
|
|
35
|
+
service_id: str = Field(min_length=1, validation_alias=SERVICE_ID_ENV)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_settings(
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
service_id: str | None = None,
|
|
41
|
+
) -> Settings:
|
|
42
|
+
"""Resolve settings from explicit values, falling back to the environment.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
token: Fastly API token; falls back to ``FASTLY_API_TOKEN``.
|
|
46
|
+
service_id: Fastly service id; falls back to ``FASTLY_SERVICE_ID``.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ConfigError: if either value is missing (or empty) after resolution.
|
|
50
|
+
"""
|
|
51
|
+
overrides: dict[str, str] = {}
|
|
52
|
+
if token is not None:
|
|
53
|
+
overrides[TOKEN_ENV] = token
|
|
54
|
+
if service_id is not None:
|
|
55
|
+
overrides[SERVICE_ID_ENV] = service_id
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return Settings(**overrides)
|
|
59
|
+
except ValidationError as exc:
|
|
60
|
+
missing = [
|
|
61
|
+
_ENV_NAMES.get(str(error["loc"][0]), str(error["loc"][0]))
|
|
62
|
+
for error in exc.errors()
|
|
63
|
+
]
|
|
64
|
+
raise ConfigError(
|
|
65
|
+
"missing required configuration: " + ", ".join(missing)
|
|
66
|
+
) from exc
|
fastly_sync/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 Chris <goabonga@pm.me>
|
|
3
|
+
|
|
4
|
+
"""Exception hierarchy for fastly-sync."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FastlySyncError(Exception):
|
|
10
|
+
"""Base class for every error raised by fastly-sync."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SourceError(FastlySyncError):
|
|
14
|
+
"""Raised when a local or remote source cannot be read or fetched."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SpecError(FastlySyncError):
|
|
18
|
+
"""Raised when an OpenAPI spec is malformed once loaded."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BlocklistError(FastlySyncError):
|
|
22
|
+
"""Raised when an IP blocklist contains an invalid entry."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigError(FastlySyncError):
|
|
26
|
+
"""Raised when required configuration (token, service id) is missing."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FastlyAPIError(FastlySyncError):
|
|
30
|
+
"""Raised when the Fastly API returns an error response."""
|