diff --git a/README.md b/README.md index d8d7405..33fc29a 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,22 @@ node that is never confirmed puts itself back without anybody driving there. A fleet-wide apply refuses to give that window up: it is the one place warden will not let it be left out. +**And not always all of them.** A node says what it is, in its own +configuration — `tags = ["web", "eu-west"]` — and it travels in the +announcement it already sends, so the hub knows without anybody keeping a +second list: + +```sh +warden firewall apply --fleet --tag web --rollback 120 +warden update --fleet --tag eu-west +warden ls --all --tag web +``` + +Kept on the machine rather than in groups on the hub: two places that know what +a machine is disagree the first time one is rebuilt. A tag nothing carries is +refused and told which ones exist — an apply that quietly touched no machines +is worse than one that would not run. + Reading the rules is what a token already allows. **Changing** them needs `allow_remote_firewall` set on the machine being asked, and it is off out of the box — a warden that will change its own firewall on request, listens beyond diff --git a/src/warden/api.py b/src/warden/api.py index 7e5aa6b..2afd6c4 100644 --- a/src/warden/api.py +++ b/src/warden/api.py @@ -31,7 +31,7 @@ from warden.core.here import Here from warden.core.rounds import Rounds from warden.core.store import RuleStore, Snapshots, Store -from warden.errors import NotPermittedError, WardenError +from warden.errors import NotPermittedError, UnknownTagError, WardenError from warden.firewall import catalogue, guard, link from warden.firewall import model as firewall from warden.firewall.backends import base @@ -424,19 +424,39 @@ def forget(name: str, fleet: FleetDep) -> None: prefix="/v1/fleet", tags=["fleet"], dependencies=[Depends(known_caller)] ) + def _picked(fleet: Fleet, tag: str | None) -> tuple[list[Node], bool]: + """The nodes a tag picks out, and whether this warden is one of them. + + Nothing matching is a refusal rather than an empty answer: an apply + that quietly touched no machines is worse than one that would not run. + """ + if tag is None: + return fleet.nodes(), True + chosen = fleet.tagged(tag) + mine = tag in settings.tags + if not chosen and not mine: + known = sorted(set(fleet.tags()) | set(settings.tags)) + raise UnknownTagError( + f"no warden here is tagged {tag!r}" + + (f" - there is {', '.join(known)}" if known else " - none of them is tagged") + ) + return chosen, mine + @fleet_view.get("/services", summary="Everything the whole fleet holds") async def fleet_services( manager: Manager, fleet: FleetDep, project: str | None = None, kind: str | None = None, + tag: str | None = None, ) -> FleetServices: + chosen, mine = _picked(fleet, tag) async with aggregate.client(settings.cluster_token) as http: return await aggregate.gather_services( http, - fleet.nodes(), + chosen, here=settings.node, - local=manager.list(project=project, kind=kind), + local=manager.list(project=project, kind=kind) if mine else [], project=project, kind=kind, ) @@ -885,6 +905,7 @@ async def firewall_everywhere( fleet: FleetDep, rollback: int | None = None, snapshot: int | None = None, + tag: str | None = None, ) -> FleetFirewallResult: """Every node does it to its own firewall, and answers for itself. @@ -907,26 +928,29 @@ async def firewall_everywhere( "to open them again", ) - try: - here = FirewallResult( - node=settings.node, - url=settings.advertise_url, - ok=True, - detail=aggregate._said( - what, _here(what, rules, snapshots, rollback=seconds, snapshot=snapshot) - ), - ) - except WardenError as exc: - here = FirewallResult( - node=settings.node, url=settings.advertise_url, ok=False, detail=exc.message - ) + chosen, mine = _picked(fleet, tag) + here = None + if mine: + try: + here = FirewallResult( + node=settings.node, + url=settings.advertise_url, + ok=True, + detail=aggregate._said( + what, _here(what, rules, snapshots, rollback=seconds, snapshot=snapshot) + ), + ) + except WardenError as exc: + here = FirewallResult( + node=settings.node, url=settings.advertise_url, ok=False, detail=exc.message + ) params: dict[str, object] = {"rollback": seconds} if what == "apply" else {} if snapshot is not None: params["snapshot"] = snapshot async with aggregate.client(settings.cluster_token, timeout=30.0) as http: return await aggregate.firewall_fleet( - http, fleet.nodes(), what, params=params, here=here + http, chosen, what, params=params, here=here ) @@ -948,20 +972,23 @@ def update_self() -> dict[str, str]: return {"detail": updates.apply(settings)} @fleet_view.post("/update", summary="Ask every warden in the fleet to update itself") - async def update_everyone(fleet: FleetDep) -> FleetUpdate: - try: - here = UpdateResult( - node=settings.node, - url=settings.advertise_url, - ok=True, - detail=updates.apply(settings), - ) - except WardenError as exc: - here = UpdateResult( - node=settings.node, url=settings.advertise_url, ok=False, detail=exc.message - ) + async def update_everyone(fleet: FleetDep, tag: str | None = None) -> FleetUpdate: + chosen, mine = _picked(fleet, tag) + here = None + if mine: + try: + here = UpdateResult( + node=settings.node, + url=settings.advertise_url, + ok=True, + detail=updates.apply(settings), + ) + except WardenError as exc: + here = UpdateResult( + node=settings.node, url=settings.advertise_url, ok=False, detail=exc.message + ) async with aggregate.client(settings.cluster_token, timeout=300.0) as http: - return await aggregate.update_fleet(http, fleet.nodes(), here=here) + return await aggregate.update_fleet(http, chosen, here=here) def may_change_the_firewall() -> None: """The switch that has to be thrown before a caller may touch the rules. diff --git a/src/warden/cli/commands/firewall.py b/src/warden/cli/commands/firewall.py index 20103aa..6872d3d 100644 --- a/src/warden/cli/commands/firewall.py +++ b/src/warden/cli/commands/firewall.py @@ -602,15 +602,18 @@ def _across_the_fleet( what: str, *, rollback: int | None = None, + tag: str | None = None, as_json: bool, ) -> None: """One line per node, whichever way each of them went.""" if what == "apply": - found = _asking(url, token, lambda client: client.firewall_apply_fleet(rollback=rollback)) + found = _asking( + url, token, lambda client: client.firewall_apply_fleet(rollback=rollback, tag=tag) + ) elif what == "confirm": - found = _asking(url, token, lambda client: client.firewall_confirm_fleet()) + found = _asking(url, token, lambda client: client.firewall_confirm_fleet(tag=tag)) else: - found = _asking(url, token, lambda client: client.firewall_restore_fleet()) + found = _asking(url, token, lambda client: client.firewall_restore_fleet(tag=tag)) if as_json: _dump(found.model_dump(mode="json")) @@ -653,6 +656,9 @@ def firewall_apply( fleet: Annotated[ bool, typer.Option("--fleet", help="Every warden in the fleet, each on its own.") ] = False, + tagged: Annotated[ + str | None, typer.Option("--tag", help="Only the nodes carrying this tag.") + ] = None, at: Annotated[ str | None, typer.Option("--node", help="One node in the fleet, through the hub.") ] = None, @@ -670,7 +676,7 @@ def firewall_apply( at once. `--node build-01` asks exactly one. """ if fleet: - _across_the_fleet(url, token, "apply", rollback=rollback, as_json=as_json) + _across_the_fleet(url, token, "apply", rollback=rollback, tag=tagged, as_json=as_json) return if at: said = _asking(url, token, lambda client: client.firewall_apply_on(at, rollback=rollback)) @@ -863,6 +869,9 @@ def firewall_confirm( fleet: Annotated[ bool, typer.Option("--fleet", help="Every warden in the fleet, each on its own.") ] = False, + tagged: Annotated[ + str | None, typer.Option("--tag", help="Only the nodes carrying this tag.") + ] = None, at: Annotated[ str | None, typer.Option("--node", help="One node in the fleet, through the hub.") ] = None, @@ -876,7 +885,7 @@ def firewall_confirm( back when its own window runs out. """ if fleet: - _across_the_fleet(url, token, "confirm", as_json=as_json) + _across_the_fleet(url, token, "confirm", tag=tagged, as_json=as_json) return if at: said = _asking(url, token, lambda client: client.firewall_confirm_on(at)) diff --git a/src/warden/cli/commands/fleet.py b/src/warden/cli/commands/fleet.py index 5461aed..e469235 100644 --- a/src/warden/cli/commands/fleet.py +++ b/src/warden/cli/commands/fleet.py @@ -38,6 +38,9 @@ def update( fleet: Annotated[ bool, typer.Option("--fleet", help="Ask every warden in the fleet to update itself.") ] = False, + tagged: Annotated[ + str | None, typer.Option("--tag", help="Only the nodes carrying this tag.") + ] = None, yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask first.")] = False, url: UrlOption = None, token: TokenOption = None, @@ -56,13 +59,14 @@ def update( _show_update(client.update_status(), as_json=as_json, here=url is None) return - what = "every warden in the fleet" if fleet else f"the warden at {client.url}" + what = f"every warden tagged {tagged}" if tagged else "every warden in the fleet" + what = what if fleet else f"the warden at {client.url}" if not yes and not typer.confirm(f"Update {what}?"): console.print("left alone", style=theme.BONE_DIM) return if fleet: - _show_fleet_update(client.update_fleet(), as_json=as_json) + _show_fleet_update(client.update_fleet(tag=tagged), as_json=as_json) else: detail = client.update_self() _dump({"detail": detail}) if as_json else console.print(detail) @@ -174,7 +178,7 @@ def nodes( return table = Table(box=None, pad_edge=False, header_style=f"bold {theme.BONE_DIM}") - for column in ("NODE", "URL", "POOL", "VERSION", "STATUS", "LAST SEEN"): + for column in ("NODE", "URL", "POOL", "VERSION", "TAGS", "STATUS", "LAST SEEN"): table.add_column(column) for node in known: table.add_row( @@ -182,6 +186,7 @@ def nodes( Text(node.url, style=theme.BONE_DIM), Text(node.pool, style=theme.GLOW), Text(node.version, style=theme.BONE_DIM), + Text(", ".join(node.tags) if node.tags else "-", style=theme.BONE_DIM), Text(node.status, style=NODE_COLOURS[node.status]), Text(theme.age(node.last_seen), style=theme.BONE_DIM), ) diff --git a/src/warden/cli/commands/registry.py b/src/warden/cli/commands/registry.py index 552a8d6..8154412 100644 --- a/src/warden/cli/commands/registry.py +++ b/src/warden/cli/commands/registry.py @@ -219,12 +219,17 @@ def list_services( every: Annotated[ bool, typer.Option("--all", help="Ask every warden in the fleet, not just this one.") ] = False, + tagged: Annotated[ + str | None, typer.Option("--tag", help="Only the nodes carrying this tag. With --all.") + ] = None, url: UrlOption = None, token: TokenOption = None, as_json: JsonOption = False, ) -> None: """List every registered service.""" holders = holders or stale + if tagged and not every: + raise _fail(WardenError("a tag picks out nodes, so --tag goes with --all")) if holders and every: raise _fail( WardenError("a holder can only be checked on the machine it runs on, so --holders " @@ -233,7 +238,7 @@ def list_services( with shared._client(url, token) as client: try: fleet = ( - client.fleet_services(project=project, kind=kind) + client.fleet_services(project=project, kind=kind, tag=tagged) if every else None ) diff --git a/src/warden/client.py b/src/warden/client.py index 67f571f..c3c54e7 100644 --- a/src/warden/client.py +++ b/src/warden/client.py @@ -49,6 +49,12 @@ } +def _some(**params: object) -> dict[str, object] | None: + """The parameters that were actually given, or nothing at all.""" + said = {key: value for key, value in params.items() if value is not None} + return said or None + + def resolve_url(url: str | None = None) -> str: return (url or os.environ.get("WARDEN_URL") or DEFAULT_URL).rstrip("/") @@ -277,10 +283,14 @@ def forget(self, name: str) -> None: self._request("DELETE", f"/v1/nodes/{name}") def fleet_services( - self, *, project: str | None = None, kind: str | None = None + self, *, project: str | None = None, kind: str | None = None, tag: str | None = None ) -> FleetServices: """Everything the whole fleet holds, and the nodes that did not answer.""" - params = {key: value for key, value in (("project", project), ("kind", kind)) if value} + params = { + key: value + for key, value in (("project", project), ("kind", kind), ("tag", tag)) + if value + } return FleetServices.model_validate( self._request("GET", "/v1/fleet/services", params=params) ) @@ -470,29 +480,39 @@ def firewall_open_everywhere( self._request("POST", "/v1/fleet/firewall/open", json=body, timeout=60.0) ) - def firewall_apply_fleet(self, *, rollback: int | None = None) -> FleetFirewallResult: + def firewall_apply_fleet( + self, *, rollback: int | None = None, tag: str | None = None + ) -> FleetFirewallResult: """Make every node's rules true, each with its own rollback armed. A fleet-wide apply keeps its window: pass `rollback=0` and it is refused. Nothing is kept until `firewall_confirm_fleet`, and a node that is never confirmed puts itself back on its own. """ - params = {"rollback": rollback} if rollback is not None else None + params = _some(rollback=rollback, tag=tag) return FleetFirewallResult.model_validate( self._request("POST", "/v1/fleet/firewall/apply", params=params, timeout=60.0) ) - def firewall_confirm_fleet(self) -> FleetFirewallResult: + def firewall_confirm_fleet(self, *, tag: str | None = None) -> FleetFirewallResult: """Keep what every node applied.""" return FleetFirewallResult.model_validate( - self._request("POST", "/v1/fleet/firewall/confirm", timeout=60.0) + self._request( + "POST", "/v1/fleet/firewall/confirm", params=_some(tag=tag), timeout=60.0 + ) ) - def firewall_restore_fleet(self, snapshot: int | None = None) -> FleetFirewallResult: + def firewall_restore_fleet( + self, snapshot: int | None = None, *, tag: str | None = None + ) -> FleetFirewallResult: """Put every node back to a snapshot, whether or not one was waiting.""" - params = {"snapshot": snapshot} if snapshot is not None else None return FleetFirewallResult.model_validate( - self._request("POST", "/v1/fleet/firewall/restore", params=params, timeout=60.0) + self._request( + "POST", + "/v1/fleet/firewall/restore", + params=_some(snapshot=snapshot, tag=tag), + timeout=60.0, + ) ) def update_status(self) -> UpdateStatus: @@ -503,10 +523,10 @@ def update_self(self) -> str: """Ask that warden to run its own update command.""" return str(self._request("POST", "/v1/update")["detail"]) - def update_fleet(self) -> FleetUpdate: + def update_fleet(self, *, tag: str | None = None) -> FleetUpdate: """Ask every warden in the fleet to update itself.""" return FleetUpdate.model_validate( - self._request("POST", "/v1/fleet/update", timeout=310.0) + self._request("POST", "/v1/fleet/update", params=_some(tag=tag), timeout=310.0) ) def fleet_lookup(self, node: str, name: str) -> FleetRegistration: diff --git a/src/warden/core/config.py b/src/warden/core/config.py index eedb78f..f0fcd2a 100644 --- a/src/warden/core/config.py +++ b/src/warden/core/config.py @@ -326,6 +326,11 @@ class Settings(BaseSettings): upstream: str | None = None cluster_token: str | None = None node_ttl: int = Field(default=90, ge=10, le=86_400) + # `web`, `eu-west`, `db` - what this machine is, travelling in the + # announcement it already sends. On the node rather than in a list on the + # hub: two places that know what a machine is disagree the first time one + # is rebuilt, and the machine is the one that knows. + tags: WordSet = Field(default_factory=set) require_https: bool = False @classmethod diff --git a/src/warden/core/store.py b/src/warden/core/store.py index 76db57f..286051e 100644 --- a/src/warden/core/store.py +++ b/src/warden/core/store.py @@ -110,6 +110,7 @@ pool_start INTEGER NOT NULL, pool_end INTEGER NOT NULL, version TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, expires_at TEXT NOT NULL @@ -119,6 +120,10 @@ # Columns added after the first release, applied to databases that predate them. ADDED_COLUMNS = {"ttl": "INTEGER"} +# What a node says it is. A database written before tags existed has none, and +# a node without them is in no group rather than in every group. +ADDED_NODE_COLUMNS = {"tags": "TEXT NOT NULL DEFAULT ''"} + # The same, for the events table: a database written before events were about # more than ports has none of these. ADDED_RULE_COLUMNS = { @@ -207,6 +212,7 @@ def _row_to_node(row: sqlite3.Row) -> Node: pool_start=row["pool_start"], pool_end=row["pool_end"], version=row["version"], + tags=[tag for tag in (row["tags"] or "").split(",") if tag], first_seen=datetime.fromisoformat(row["first_seen"]), last_seen=datetime.fromisoformat(row["last_seen"]), expires_at=datetime.fromisoformat(row["expires_at"]), @@ -239,6 +245,7 @@ def _add_missing_columns(self) -> None: ("registrations", ADDED_COLUMNS), ("events", ADDED_EVENT_COLUMNS), ("rules", ADDED_RULE_COLUMNS), + ("nodes", ADDED_NODE_COLUMNS), ): present = {row["name"] for row in self._db.execute(f"PRAGMA table_info({table})")} for column, definition in columns.items(): @@ -521,14 +528,15 @@ def save_node(self, node: Node) -> None: self._db.execute( """ INSERT INTO nodes - (name, url, pool_start, pool_end, version, + (name, url, pool_start, pool_end, version, tags, first_seen, last_seen, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET url = excluded.url, pool_start = excluded.pool_start, pool_end = excluded.pool_end, version = excluded.version, + tags = excluded.tags, last_seen = excluded.last_seen, expires_at = excluded.expires_at """, @@ -538,6 +546,7 @@ def save_node(self, node: Node) -> None: node.pool_start, node.pool_end, node.version, + ",".join(node.tags), _isoformat(node.first_seen), _isoformat(node.last_seen), _isoformat(node.expires_at), diff --git a/src/warden/errors.py b/src/warden/errors.py index fd54bf5..071d964 100644 --- a/src/warden/errors.py +++ b/src/warden/errors.py @@ -40,6 +40,16 @@ class UnknownNodeError(WardenError): status_code = 404 +class UnknownTagError(WardenError): + """A tag no node in the fleet carries. + + A refusal rather than an empty answer: an apply that quietly touched no + machines is worse than one that would not run. + """ + + status_code = 404 + + class UpdateFailedError(WardenError): status_code = 500 diff --git a/src/warden/fleet/aggregate.py b/src/warden/fleet/aggregate.py index 6b4bd1d..8681c81 100644 --- a/src/warden/fleet/aggregate.py +++ b/src/warden/fleet/aggregate.py @@ -397,7 +397,7 @@ async def _update_one(http: httpx.AsyncClient, node: Node) -> UpdateResult: async def update_fleet( - http: httpx.AsyncClient, nodes: list[Node], *, here: UpdateResult + http: httpx.AsyncClient, nodes: list[Node], *, here: UpdateResult | None ) -> FleetUpdate: """Ask every node to update itself. @@ -405,7 +405,9 @@ async def update_fleet( request. A node with nothing configured refuses, and says so. """ results = list(await asyncio.gather(*(_update_one(http, node) for node in nodes))) - results.append(here) + if here is not None: + # None where a tag picked out machines and this one is not among them. + results.append(here) results.sort(key=lambda result: result.node) return FleetUpdate(results=results) @@ -629,7 +631,7 @@ async def firewall_fleet( what: str, *, params: dict[str, object] | None = None, - here: FirewallResult, + here: FirewallResult | None, ) -> FleetFirewallResult: """Ask every node to do the same thing to its own firewall. @@ -643,7 +645,8 @@ async def firewall_fleet( *(_firewall_one(http, node, what, params or {}) for node in nodes) ) ) - results.append(here) + if here is not None: + results.append(here) results.sort(key=lambda result: result.node) return FleetFirewallResult(results=results) diff --git a/src/warden/fleet/nodes.py b/src/warden/fleet/nodes.py index ff3ed77..4cf875b 100644 --- a/src/warden/fleet/nodes.py +++ b/src/warden/fleet/nodes.py @@ -41,6 +41,7 @@ def announce(self, announcement: NodeAnnouncement) -> tuple[Node, bool]: pool_start=announcement.pool_start, pool_end=announcement.pool_end, version=announcement.version, + tags=announcement.tags, first_seen=existing.first_seen if existing else now, last_seen=now, expires_at=now + timedelta(seconds=self.ttl), @@ -95,6 +96,14 @@ def _notice_the_quiet_ones(self, known: list[Node]) -> None: self.store.announce(NODE, STALE, name, url=node.url, last_seen=node.last_seen) self._said_stale = gone + def tags(self) -> list[str]: + """Every tag any node carries, which is what a wrong one is answered with.""" + return sorted({tag for node in self.store.list_nodes() for tag in node.tags}) + + def tagged(self, tag: str) -> list[Node]: + """The nodes carrying a tag. An empty match is the caller's to refuse.""" + return [node for node in self.nodes() if tag in node.tags] + def get(self, name: str) -> Node: node = self.store.get_node(name) if node is None: diff --git a/src/warden/fleet/upstream.py b/src/warden/fleet/upstream.py index f2ad0eb..b50f6eb 100644 --- a/src/warden/fleet/upstream.py +++ b/src/warden/fleet/upstream.py @@ -34,6 +34,7 @@ def announcement(self) -> dict[str, object]: "pool_start": self.settings.pool_start, "pool_end": self.settings.pool_end, "version": __version__, + "tags": sorted(self.settings.tags), } def client(self) -> httpx.AsyncClient: diff --git a/src/warden/models.py b/src/warden/models.py index f9aa88d..fc9f920 100644 --- a/src/warden/models.py +++ b/src/warden/models.py @@ -29,6 +29,13 @@ ] Port = Annotated[int, Field(ge=1, le=65535)] +# What a machine says it is: `web`, `eu-west`, `db`. Same shape as a kind, so a +# tag can be typed on a command line without quoting and read in a table. +Tag = Annotated[ + str, + StringConstraints(pattern=r"^[a-z0-9][a-z0-9._-]{0,31}$", strip_whitespace=True), +] + # Text that ends up in something which parses what it is given: a Caddyfile, an # nginx server block, an nftables comment, a chat message. A quote or a # backslash ends a quoted string somewhere, and a control character ends a @@ -171,6 +178,10 @@ class NodeAnnouncement(BaseModel): pool_start: Port pool_end: Port version: str + # What this machine says it is. Kept on the node rather than in a list on + # the hub: two places that know what a machine is disagree the first time + # one is rebuilt, and the machine is the one that knows. + tags: list[Tag] = Field(default_factory=list) @field_validator("url") @classmethod @@ -187,6 +198,7 @@ class Node(BaseModel): pool_start: int pool_end: int version: str + tags: list[Tag] = Field(default_factory=list) first_seen: datetime last_seen: datetime expires_at: datetime diff --git a/tests/test_fleet_tags.py b/tests/test_fleet_tags.py new file mode 100644 index 0000000..8e4f845 --- /dev/null +++ b/tests/test_fleet_tags.py @@ -0,0 +1,293 @@ +"""A node says what it is, and a fleet command can act on that and nothing else.""" + +import json + +import pytest +from fastapi.testclient import TestClient +from typer.testing import CliRunner + +from warden.api import create_app +from warden.cli import app +from warden.core.config import Settings +from warden.core.store import Store +from warden.fleet.nodes import Fleet +from warden.fleet.upstream import UpstreamReporter +from warden.models import Node, NodeAnnouncement + +runner_cli = CliRunner() + + +@pytest.fixture +def settings(tmp_path) -> Settings: + return Settings( + database=tmp_path / "warden.db", + node="hub", + update_check=False, + health_watch=False, + tags="hub", + ) + + +def announced(name: str, *tags: str) -> dict: + return { + "name": name, + "url": f"http://{name}:7010", + "pool_start": 9000, + "pool_end": 9099, + "version": "0.6.0", + "tags": list(tags), + } + + +def fleet_of(tmp_path, *nodes: tuple[str, list[str]]) -> Fleet: + fleet = Fleet(Store(tmp_path / "fleet.db")) + for name, tags in nodes: + fleet.announce(NodeAnnouncement.model_validate(announced(name, *tags))) + return fleet + + +# What a node says it is + + +def test_a_tag_travels_in_the_announcement_a_node_already_sends(tmp_path): + fleet = fleet_of(tmp_path, ("web-01", ["web", "eu-west"])) + assert fleet.get("web-01").tags == ["web", "eu-west"] + + +def test_it_survives_being_written_down(tmp_path): + fleet_of(tmp_path, ("web-01", ["web"])) + again = Fleet(Store(tmp_path / "fleet.db")) + assert again.get("web-01").tags == ["web"] + + +def test_a_node_that_says_nothing_is_in_no_group_rather_than_every_group(tmp_path): + fleet = fleet_of(tmp_path, ("web-01", [])) + assert fleet.get("web-01").tags == [] + assert fleet.tagged("web") == [] + + +def test_a_rebuilt_node_says_what_it_is_now(tmp_path): + """One source for what a machine is, and it is the machine.""" + fleet = fleet_of(tmp_path, ("web-01", ["web"])) + fleet.announce(NodeAnnouncement.model_validate(announced("web-01", "db"))) + assert fleet.get("web-01").tags == ["db"] + + +def test_a_node_reports_its_own_tags_upward(tmp_path): + said = Settings( + database=tmp_path / "w.db", + node="web-01", + tags="web, eu-west", + upstream="http://hub:7010", + advertise="http://web-01:7010", + ) + assert UpstreamReporter(said).announcement["tags"] == ["eu-west", "web"] + + +def test_which_nodes_carry_one(tmp_path): + fleet = fleet_of( + tmp_path, ("web-01", ["web", "eu-west"]), ("web-02", ["web"]), ("db-01", ["db"]) + ) + assert [node.name for node in fleet.tagged("web")] == ["web-01", "web-02"] + assert [node.name for node in fleet.tagged("eu-west")] == ["web-01"] + assert fleet.tags() == ["db", "eu-west", "web"] + + +def test_a_tag_is_a_word_rather_than_anything_at_all(): + with pytest.raises(ValueError): + Node( + name="web-01", + url="http://web-01:7010", + pool_start=1, + pool_end=2, + version="0.6.0", + tags=["Web Servers"], + first_seen="2026-01-01T00:00:00Z", + last_seen="2026-01-01T00:00:00Z", + expires_at="2026-01-01T00:01:00Z", + ) + + +def test_an_older_database_reads_back_without_tags(tmp_path): + import sqlite3 + + where = tmp_path / "fleet.db" + fleet_of(tmp_path, ("web-01", ["web"])) + with sqlite3.connect(where) as db: + db.execute("ALTER TABLE nodes DROP COLUMN tags") + + with Store(where) as store: + assert Fleet(store).get("web-01").tags == [] + + +# What a fleet command does with it + + +def hub(settings: Settings) -> TestClient: + client = TestClient(create_app(settings)) + client.__enter__() + for name, tags in (("web-01", ["web", "eu-west"]), ("web-02", ["web"]), ("db-01", ["db"])): + client.post("/v1/nodes", json=announced(name, *tags)) + return client + + +def test_a_tag_picks_out_the_nodes_it_names(settings: Settings): + with hub(settings) as client: + said = client.get("/v1/fleet/services", params={"tag": "web"}).json() + assert [one["node"] for one in said["unreachable"]] == ["web-01", "web-02"] + + +def test_this_warden_is_only_in_it_when_it_says_it_is(settings: Settings): + """The hub is a machine like any other, and `--tag web` is not about it.""" + with hub(settings) as client: + client.post("/v1/services", json={"name": "shop-api", "kind": "backend"}) + + web = client.get("/v1/fleet/services", params={"tag": "web"}).json() + assert [one["name"] for one in web["services"]] == [] + + mine = client.get("/v1/fleet/services", params={"tag": "hub"}).json() + assert [one["name"] for one in mine["services"]] == ["shop-api"] + assert mine["unreachable"] == [] + + +def test_a_tag_nothing_carries_is_a_refusal_rather_than_an_empty_answer(settings: Settings): + """An apply that quietly touched no machines is worse than one that would not run.""" + with hub(settings) as client: + said = client.get("/v1/fleet/services", params={"tag": "nothing"}) + assert said.status_code == 404 + assert "no warden here is tagged 'nothing'" in said.json()["detail"] + assert "db, eu-west, hub, web" in said.json()["detail"] + + +def test_no_tag_at_all_is_still_the_whole_fleet(settings: Settings): + with hub(settings) as client: + said = client.get("/v1/fleet/services").json() + assert len(said["unreachable"]) == 3 + + +def test_an_apply_across_a_tag_asks_only_those_nodes(settings: Settings): + said = settings.model_copy(update={"allow_remote_firewall": True}) + with hub(said) as client: + found = client.post( + "/v1/fleet/firewall/apply", params={"tag": "web", "rollback": 60} + ).json() + assert sorted(one["node"] for one in found["results"]) == ["web-01", "web-02"] + + +def test_an_apply_across_a_tag_this_warden_carries_includes_it(settings: Settings): + said = settings.model_copy(update={"allow_remote_firewall": True}) + with hub(said) as client: + found = client.post( + "/v1/fleet/firewall/apply", params={"tag": "hub", "rollback": 60} + ).json() + assert [one["node"] for one in found["results"]] == ["hub"] + + +def test_an_update_across_a_tag_asks_only_those_nodes(settings: Settings): + with hub(settings) as client: + found = client.post("/v1/fleet/update", params={"tag": "db"}).json() + assert [one["node"] for one in found["results"]] == ["db-01"] + + +def test_a_tag_nothing_carries_refuses_an_apply_too(settings: Settings): + said = settings.model_copy(update={"allow_remote_firewall": True}) + with hub(said) as client: + found = client.post("/v1/fleet/firewall/apply", params={"tag": "nothing"}) + assert found.status_code == 404 + + +# What a person types + + +def answering(monkeypatch, **answers) -> list[dict]: + asked: list[dict] = [] + + class Warden: + url = "http://127.0.0.1:7010" + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def __getattr__(self, name): + def said(*args, **kwargs): + asked.append({"what": name, **kwargs}) + return answers[name] + + return said + + monkeypatch.setattr("warden.cli.shared._client", lambda url, token: Warden()) + return asked + + +def test_the_flag_carries_the_tag_to_the_hub(monkeypatch): + from warden.models import FleetFirewallResult + + asked = answering( + monkeypatch, firewall_apply_fleet=FleetFirewallResult.model_validate({"results": []}) + ) + runner_cli.invoke(app, ["firewall", "apply", "--fleet", "--tag", "web", "--yes"]) + assert asked == [{"what": "firewall_apply_fleet", "rollback": None, "tag": "web"}] + + +def test_confirming_carries_it_too(monkeypatch): + from warden.models import FleetFirewallResult + + asked = answering( + monkeypatch, firewall_confirm_fleet=FleetFirewallResult.model_validate({"results": []}) + ) + runner_cli.invoke(app, ["firewall", "confirm", "--fleet", "--tag", "web"]) + assert asked == [{"what": "firewall_confirm_fleet", "tag": "web"}] + + +def test_updating_carries_it(monkeypatch): + from warden.models import FleetUpdate + + asked = answering(monkeypatch, update_fleet=FleetUpdate.model_validate({"results": []})) + said = runner_cli.invoke(app, ["update", "--fleet", "--tag", "eu-west", "--yes"]) + assert asked == [{"what": "update_fleet", "tag": "eu-west"}] + assert said.exit_code == 0 + + +def test_listing_carries_it(monkeypatch): + from warden.models import FleetServices + + asked = answering( + monkeypatch, + fleet_services=FleetServices.model_validate( + {"services": [], "unreachable": [], "duplicates": []} + ), + ) + runner_cli.invoke(app, ["ls", "--all", "--tag", "web"]) + assert asked == [{"what": "fleet_services", "project": None, "kind": None, "tag": "web"}] + + +def test_a_tag_without_the_fleet_is_a_sentence_about_why_not(monkeypatch): + said = runner_cli.invoke(app, ["ls", "--tag", "web"]) + assert said.exit_code == 1 + assert "--tag goes with --all" in said.stderr + + +def test_the_nodes_listing_says_what_each_one_is(monkeypatch, settings: Settings): + with hub(settings) as client: + known = client.get("/v1/nodes").json() + + class Warden: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def nodes(self): + return [Node.model_validate(one) for one in known] + + monkeypatch.setattr("warden.cli.shared._client", lambda url, token: Warden()) + said = runner_cli.invoke(app, ["nodes"]) + assert "TAGS" in said.stdout + assert "eu-west" in said.stdout + + found = json.loads(runner_cli.invoke(app, ["nodes", "--json"]).stdout) + assert {one["name"]: one["tags"] for one in found}["db-01"] == ["db"] diff --git a/tests/test_upstream.py b/tests/test_upstream.py index 4a76369..4897df2 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -29,6 +29,7 @@ def test_the_announcement_says_who_and_what(edge: Settings): "pool_start": 9000, "pool_end": 9099, "version": __version__, + "tags": [], }