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.
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Chris <goabonga@pm.me>
3
+
4
+ """Synchronise Fastly CDN, rate limiter and WAF IP blocklist configuration."""
5
+
6
+ __version__ = "0.1.0"
@@ -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."""