From 715dceeae72a326ecafacd206a774e4a6c06bf48 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:02:12 +0000 Subject: [PATCH 1/2] feat: the spec's cloud section and keel cloud, for Keel Cloud (0046) The spec gains a cloud section, additive under version 1: endpoint (an https URL), api_key (skip or a file reference), entry_secret (a file reference), set (a lower case DNS label, required with a key) and ca_file. Secrets are file references only, never generate: the key is made by Keel Cloud and the entry secret by the set's first node. keel validates the section and diff never compares it; the peers Keel Cloud's node agent writes are compared under network.overlay as before. keel cloud runs keel-cloud-node of the keel-overlay-cloud package with the same arguments, and names the package when it is missing (exit 9). Proven on the test VM with Keel-Linux/keel-cloud: two Keel Core containers with this keel enrolled, gained each other as peers and brought the overlay up with spec apply --system and network confirm. --- README.md | 3 +- debian/changelog | 14 ++++ docs/spec.md | 36 ++++++++- keel/__init__.py | 2 +- keel/cli.py | 17 ++++ keel/commands.py | 18 +++++ keel/diff/compare.py | 2 + keel/spec/constants.py | 1 + keel/spec/validate.py | 2 + keel/spec/validate_cloud.py | 85 ++++++++++++++++++++ pyproject.toml | 2 +- tests/test_spec_cloud.py | 150 ++++++++++++++++++++++++++++++++++++ 12 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 keel/spec/validate_cloud.py create mode 100644 tests/test_spec_cloud.py diff --git a/README.md b/README.md index 2705a96..a0cc3ae 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ in [docs/apply.md](docs/apply.md). | `keel network revert` | Put back the interfaces file a pending network change replaced; what the revert timer runs. `--boot` restores the file only, for the boot unit | | `keel network wireguard key` | Print this node's WireGuard public key, making the key pair first when there is none (root, live system only); the private key is never printed (decision 0020, docs/spec.md) | | `keel network wireguard suggest-address` | Print a random unique local IPv6 address with its /64, for the first node of an overlay | +| `keel cloud` | Run `keel-cloud-node`, Keel Cloud's node agent from the `keel-overlay-cloud` package, with the same arguments; without the package it says so and exits 9 (decision 0046, docs/spec.md "cloud") | | `keel notify` | What monit runs when a check of the `monitor` section fails, lasts or recovers: sends one message, saying what happened and what to do, to every channel the section declares (email, Telegram, ntfy, a webhook). It reads the channels from `/etc/keel/monitor.json`, which `apply --system` writes as root, never from the spec, reads the tokens from their secret files and prints no URL (decision 0021, docs/apply.md) | | `keel inspect` | Write a spec from the running machine, or from an offline root, and report every field with its source or why it was not inferred; secrets are never read (brief sections 5.2 and 7) | | `keel diff` | Report drift between the spec and the running machine, or an offline root, field by field, through the same collector `inspect` uses; secrets are never compared and their files need not exist, nothing is written (brief section 5.2) | @@ -132,7 +133,7 @@ Defined in one place, `keel/exits.py`, and reproduced here. | 6 | `MANIFEST_INVALID` | A layer manifest cannot be read or fails validation; every problem is printed | | 7 | `LAYER_MISMATCH` | A layer tarball, parent chain or `.hash` digest does not match its manifest | | 8 | `SIGNATURE_UNVERIFIED` | Every layer matches, but a `.hash` file is present whose signature was not verified (no trusted key is configured yet) | -| 9 | `NOT_IMPLEMENTED` | The command is a documented stub, or the part of it that is (`verify` exits 9 after the layers pass, because packages are not checked yet) | +| 9 | `NOT_IMPLEMENTED` | The command is a documented stub, or the part of it that is (`verify` exits 9 after the layers pass, because packages are not checked yet), or another package's (`keel cloud` without `keel-overlay-cloud`) | | 10 | `LAYER_UNAVAILABLE` | `pull` could not fetch a manifest or tarball from the source or write the cache; `assemble` found a layer of the chain missing from the cache | | 11 | `ASSEMBLE_NEEDS_ROOT` | `assemble` was run by a user other than root; nothing was written | | 12 | `ASSEMBLE_FAILED` | The rootfs is not empty or cannot be created, or `tar` or `zstd` failed while extracting or packing | diff --git a/debian/changelog b/debian/changelog index 69b280c..6544fe3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,17 @@ +keel (0.16.0) unstable; urgency=medium + + * The spec gains a cloud section, handbook decision 0046: endpoint, + api_key (skip or a file reference), entry_secret (a file reference), + set and ca_file. Additive, so the spec stays version 1. keel only + validates it and diff never compares it; Keel Cloud's node agent reads + it and writes the peers the operator confirmed into + network.overlay.wireguard.peers, which apply converges as before. + * keel cloud runs keel-cloud-node, the agent of the keel-overlay-cloud + package, with the same arguments, and names the package when it is + not installed (exit 9). + + -- Marcos Mendez Fri, 02 Oct 2026 18:00:00 +0000 + keel (0.15.2) unstable; urgency=medium * inspect reads overlays.wireguard from the machine. The WireGuard diff --git a/docs/spec.md b/docs/spec.md index 98beb42..d25f4cb 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -45,7 +45,7 @@ version: 1 | `version` | read | Must be `1`. Absent or different is an error | The other top level keys are `instance`, `network`, `tls`, `secrets`, `app`, -`hub`, `security`, `first_login_wizard`, `preseed`, `users`, `locale`, +`hub`, `cloud`, `security`, `first_login_wizard`, `preseed`, `users`, `locale`, `database`, `monitor`, `appliance`, `installation`, `overlays` and `firewall`, each optional and each a mapping except `first_login_wizard`. A file with `version: 1` and nothing else is valid and renders to an empty conf. @@ -465,6 +465,40 @@ hub: Making the endpoint itself configurable is brief section 5.6 and is not part of this version: only the key is declared here. +## cloud + +This node in Keel Cloud (handbook decision 0046): the service that tells +the nodes of a set about each other, so they exchange WireGuard public +keys and endpoints without copy and paste. Keel Cloud is never required: +without the section, or with `api_key: skip`, the node is standalone. + +```yaml +cloud: + endpoint: https://cloud.example.org:8443 + api_key: + file: /etc/keel/secrets/cloud_api_key + entry_secret: + file: /etc/keel/secrets/cloud_entry_secret + set: shop + ca_file: /etc/keel/cloud-ca.pem +``` + +| Field | State | Notes | +| --- | --- | --- | +| `cloud.endpoint` | read | The instance, an `https://` URL with no path, user or query; TLS only. Absent: the project's instance | +| `cloud.api_key` | read | `skip`, or a reference to the enrollment key's file, root's and 0600. `file` only: the key is made by Keel Cloud, never by `generate` | +| `cloud.entry_secret` | read | A reference to the set's entry secret, `file` only: the set's first node makes it (`keel cloud entry-secret`) and the operator gives it to the others | +| `cloud.set` | read | The set this node joins, a lower case DNS label; required with an API key | +| `cloud.ca_file` | read | Optional, the certificate of a self-hosted instance whose certificate no public authority issued | + +keel only validates the section; `diff` never compares it. The node agent, +`keel-cloud-node` of the `keel-overlay-cloud` package, which `keel cloud` +runs with the same arguments, reads it, and writes the peers the operator +confirmed into `network.overlay.wireguard.peers`, which `apply --system` +converges under the window of `keel network confirm` like any other +change of the overlay. The agent never writes WireGuard's configuration +itself. + ## security ```yaml diff --git a/keel/__init__.py b/keel/__init__.py index 3a7bb0d..14fcb39 100644 --- a/keel/__init__.py +++ b/keel/__init__.py @@ -6,4 +6,4 @@ clients of it. """ -__version__ = "0.15.2" +__version__ = "0.16.0" diff --git a/keel/cli.py b/keel/cli.py index b8cc099..3212fc7 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -186,6 +186,7 @@ def build_parser() -> argparse.ArgumentParser: add_root_option(revert_parser, "revert the change of") revert_parser.set_defaults(handler=commands.network_revert) add_wireguard_parser(network_actions) + add_cloud_parser(subparsers) # no --spec: notify reads the settings apply wrote, never the spec notify_parser = subparsers.add_parser( @@ -287,6 +288,22 @@ def add_manifest_options(parser: argparse.ArgumentParser) -> None: " and the unit files and hooks the manifests name in") +def add_cloud_parser(subparsers) -> None: + """keel cloud: Keel Cloud's node agent, another package's command + + Everything after `cloud` goes to keel-cloud-node unread, its --help + included, so its actions are documented in one place (decision 0046). + """ + cloud_parser = subparsers.add_parser( + "cloud", + help="this node in Keel Cloud: keel-cloud-node of the" + " keel-overlay-cloud package, with the same arguments", + add_help=False, + ) + cloud_parser.add_argument("arguments", nargs=argparse.REMAINDER) + cloud_parser.set_defaults(handler=commands.cloud) + + def add_wireguard_parser(network_actions) -> None: """keel network wireguard: this node's side of the overlay (0020)""" wireguard_parser = network_actions.add_parser( diff --git a/keel/commands.py b/keel/commands.py index e50b94c..992f6f8 100644 --- a/keel/commands.py +++ b/keel/commands.py @@ -320,6 +320,24 @@ def make_key(root: str, key: str, path: str) -> int: return exits.OK +CLOUD_AGENT = "keel-cloud-node" + + +def cloud(args, execvp=os.execvp) -> int: + """keel cloud: hand the arguments to Keel Cloud's node agent + + The agent is its own package (keel-overlay-cloud), since a standalone + node needs none of it (decision 0046); keel only finds it. + """ + try: + execvp(CLOUD_AGENT, [CLOUD_AGENT, *args.arguments]) + except FileNotFoundError: + print(f"Error: keel cloud needs {CLOUD_AGENT}, from the" + " keel-overlay-cloud package", file=sys.stderr) + return exits.NOT_IMPLEMENTED + return exits.OK # pragma: no cover - execvp does not return + + def network_wireguard_suggest(args) -> int: """Print a fresh unique local address (RFC 4193) for a first node diff --git a/keel/diff/compare.py b/keel/diff/compare.py index 46ced0d..e210bec 100644 --- a/keel/diff/compare.py +++ b/keel/diff/compare.py @@ -44,6 +44,8 @@ " machine", "installation": "the mode is chosen once, at installation, and" " nothing on the machine records it (decision 0041)", + "cloud": "Keel Cloud's settings are read by its node agent; the peers" + " it writes are compared under network.overlay (decision 0046)", } SECRET_REASON = "a secret reference; values are never read" # Where a secret reference can stand outside the `secrets` section. Each is diff --git a/keel/spec/constants.py b/keel/spec/constants.py index de4f0fa..1bfdadf 100644 --- a/keel/spec/constants.py +++ b/keel/spec/constants.py @@ -23,6 +23,7 @@ "secrets", "app", "hub", + "cloud", "security", "first_login_wizard", "preseed", diff --git a/keel/spec/validate.py b/keel/spec/validate.py index 1c9e664..51d58c6 100644 --- a/keel/spec/validate.py +++ b/keel/spec/validate.py @@ -36,6 +36,7 @@ validate_appliance, ) from keel.spec.validate_database import validate_database +from keel.spec.validate_cloud import validate_cloud from keel.spec.validate_firewall import validate_firewall from keel.spec.validate_extras import validate_locale, validate_users from keel.spec.validate_monitor import validate_monitor @@ -90,6 +91,7 @@ def validate(doc: dict, *, check_secret_files: bool = True, errors.extend(validate_appliance(doc)) errors.extend(against_manifests(doc, facts)) errors.extend(validate_firewall(doc)) + errors.extend(validate_cloud(doc, check_secret_files)) return errors diff --git a/keel/spec/validate_cloud.py b/keel/spec/validate_cloud.py new file mode 100644 index 0000000..d8b5c17 --- /dev/null +++ b/keel/spec/validate_cloud.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The spec's cloud section: this node in Keel Cloud (decision 0046) + + cloud: + endpoint: https://cloud.example.org:8443 + api_key: + file: /etc/keel/secrets/cloud_api_key + entry_secret: + file: /etc/keel/secrets/cloud_entry_secret + set: shop + ca_file: /etc/keel/cloud-ca.pem + +keel only validates the section. The node agent of Keel Cloud, +keel-cloud-node of the keel-overlay-cloud package (`keel cloud`), reads +it, and writes what it learns into network.overlay.wireguard.peers, which +apply converges like any other field. The two secrets are references to +files, never values, and never `generate`: the API key is made by Keel +Cloud, and the entry secret by the set's first node, which shows it to +the operator so the other nodes can be given it. +""" + +import re +from typing import Any +from urllib.parse import urlsplit + +from keel.spec.fields import mapping_error +from keel.spec.validate_secret import validate_secret + +KEY = "cloud" +KEYS = ("endpoint", "api_key", "entry_secret", "set", "ca_file") +SECRETS = ("api_key", "entry_secret") +# One lower case DNS label, as Keel Cloud names sets +SET_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") +PATH_RE = re.compile(r"^/[A-Za-z0-9._/+-]+$") + + +def validate_cloud(doc: dict, check_secret_files: bool) -> list[str]: + cloud = doc.get(KEY) + error = mapping_error(KEY, cloud) + if error or not cloud: + return [error] if error else [] + errors = [f"{KEY}.{name}: unknown key" for name in cloud + if name not in KEYS] + errors += _endpoint(cloud.get("endpoint")) + joined = isinstance(cloud.get("api_key"), dict) + if not joined and str(cloud.get("api_key", "")).lower() != "skip": + errors.append(f"{KEY}.api_key: must be 'skip' or a secret mapping") + for name in SECRETS: + if name in cloud and (name != "api_key" or joined): + errors += _secret(name, cloud[name], check_secret_files) + if joined and "set" not in cloud: + errors.append(f"{KEY}.set: required with an API key, the set this" + " node joins") + if "set" in cloud and not SET_RE.match(str(cloud["set"])): + errors.append(f"{KEY}.set: a lower case DNS label (a-z, 0-9 and" + " '-', at most 63)") + ca_file = cloud.get("ca_file") + if ca_file is not None and not PATH_RE.match(str(ca_file)): + errors.append(f"{KEY}.ca_file: an absolute path") + return errors + + +def _endpoint(value: Any) -> list[str]: + """Absent means the project's instance; otherwise https, no path""" + if value is None: + return [] + parts = urlsplit(value) if isinstance(value, str) else None + try: + port_ok = parts is not None and (parts.port or 443) > 0 + except ValueError: + port_ok = False + if not port_ok or parts.scheme != "https" or not parts.hostname or \ + parts.path not in ("", "/") or parts.query or parts.fragment \ + or parts.username or parts.password: + return [f"{KEY}.endpoint: an https:// URL with no path, user or" + " query; Keel Cloud is TLS only"] + return [] + + +def _secret(name: str, value: Any, check_secret_files: bool) -> list[str]: + key = f"{KEY}.{name}" + if isinstance(value, dict) and "generate" in value: + return [f"{key}: a file reference only: it is made by Keel Cloud" + " or by the set's first node, never generated by keel"] + return validate_secret(key, value, check_secret_files) diff --git a/pyproject.toml b/pyproject.toml index c589cf3..656ff27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "keel" -version = "0.15.2" +version = "0.16.0" authors = [{name = "KeelLinux maintainers"}] description = "Declarative management of one appliance instance" readme = "README.md" diff --git a/tests/test_spec_cloud.py b/tests/test_spec_cloud.py new file mode 100644 index 0000000..409ceba --- /dev/null +++ b/tests/test_spec_cloud.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The cloud section (handbook decision 0046) and `keel cloud` + +keel validates the section and never compares it; Keel Cloud's node +agent reads it. `keel cloud` hands its arguments to that agent. +""" + +import argparse +import contextlib +import io +import os +import tempfile +import unittest +from os.path import join + +from helpers import errors + +from keel import commands, exits +from keel.cli import build_parser +from keel.diff import NOT_COMPARED, compare +from keel.inspect.report import Inspection + +KEY_FILE = "/etc/keel/secrets/cloud_api_key" +SECRET_FILE = "/etc/keel/secrets/cloud_entry_secret" +JOINED = ( + "version: 1\n" + "cloud:\n" + " endpoint: https://cloud.example.org:8443\n" + f" api_key:\n file: {KEY_FILE}\n" + f" entry_secret:\n file: {SECRET_FILE}\n" + " set: shop\n" + " ca_file: /etc/keel/cloud-ca.pem\n" +) + + +def structural(text: str) -> list[str]: + return errors(text, check_secret_files=False) + + +def only(found: list[str], fragment: str) -> None: + matching = [error for error in found if fragment in error] + if len(matching) != 1 or len(found) != 1: + raise AssertionError(f"expected one error with {fragment!r}: {found}") + + +class TestCloudSection(unittest.TestCase): + def test_a_joined_node_is_valid(self): + self.assertEqual(structural(JOINED), []) + + def test_skip_alone_is_a_standalone_node(self): + self.assertEqual(structural("version: 1\ncloud:\n api_key: skip\n"), + []) + self.assertEqual(structural("version: 1\ncloud:\n api_key: SKIP\n"), + []) + + def test_an_empty_section_is_accepted(self): + self.assertEqual(structural("version: 1\ncloud: {}\n"), []) + + def test_the_section_is_a_mapping(self): + only(structural("version: 1\ncloud: yes\n"), "cloud: must be a" + " mapping") + + def test_unknown_keys_are_named(self): + only(structural(JOINED + " colour: blue\n"), + "cloud.colour: unknown key") + + def test_the_api_key_is_skip_or_a_reference(self): + only(structural("version: 1\ncloud:\n set: shop\n"), + "cloud.api_key: must be 'skip' or a secret mapping") + only(structural("version: 1\ncloud:\n api_key: kc1e_x\n"), + "cloud.api_key: must be 'skip'") + + def test_secrets_are_file_references_never_generated(self): + text = JOINED.replace(f"file: {SECRET_FILE}", "generate: true") + only(structural(text), "cloud.entry_secret: a file reference only") + text = JOINED.replace(f"file: {KEY_FILE}", "vault: kv/x") + found = structural(text) + self.assertTrue(any("cloud.api_key.vault" in e for e in found), + found) + + def test_a_joined_node_names_its_set(self): + text = JOINED.replace(" set: shop\n", "") + only(structural(text), "cloud.set: required") + only(structural(JOINED.replace("set: shop", "set: Shop_1")), + "cloud.set: a lower case DNS label") + + def test_the_endpoint_is_https_with_no_path(self): + for endpoint in ("http://cloud.example.org", "https://", "ftp://x", + "https://cloud.example.org/v1", + "https://u:p@cloud.example.org", + "https://cloud.example.org:99999", "5"): + text = JOINED.replace("https://cloud.example.org:8443", endpoint) + only(structural(text), "cloud.endpoint: an https:// URL") + text = JOINED.replace(" endpoint: https://cloud.example.org:8443\n", + "") + self.assertEqual(structural(text), []) + text = JOINED.replace("https://cloud.example.org:8443", + "https://[2001:db8::1]:8443/") + self.assertEqual(structural(text), []) + + def test_the_ca_file_is_an_absolute_path(self): + only(structural(JOINED.replace("/etc/keel/cloud-ca.pem", "ca.pem")), + "cloud.ca_file: an absolute path") + + def test_secret_files_are_checked_like_every_secret(self): + found = errors(JOINED) + self.assertEqual(len(found), 2, found) + directory = tempfile.mkdtemp() + paths = [] + for name in ("key", "secret"): + path = join(directory, name) + with open(path, "w") as stream: + stream.write("value\n") + os.chmod(path, 0o600) + paths.append(path) + text = JOINED.replace(KEY_FILE, paths[0]).replace(SECRET_FILE, + paths[1]) + self.assertEqual(errors(text), []) + + def test_diff_never_compares_it(self): + declared = {"version": 1, "cloud": {"api_key": "skip"}} + result = compare(declared, Inspection("/", "core", {}, ())) + fields = [(f.field, f.status) for f in result.fields] + self.assertEqual(fields, [("cloud", NOT_COMPARED)]) + self.assertIn("node agent", result.fields[0].reason) + + +class TestCloudCommand(unittest.TestCase): + def test_every_argument_goes_to_the_agent(self): + args = build_parser().parse_args(["cloud", "sync", "--wait", "5", + "--help"]) + calls = [] + code = commands.cloud(args, execvp=lambda *a: calls.append(a)) + self.assertEqual(code, exits.OK) + self.assertEqual(calls, [("keel-cloud-node", [ + "keel-cloud-node", "sync", "--wait", "5", "--help"])]) + + def test_without_the_package_it_says_which_to_install(self): + def missing(*_): + raise FileNotFoundError + err = io.StringIO() + with contextlib.redirect_stderr(err): + code = commands.cloud(argparse.Namespace(arguments=[]), + execvp=missing) + self.assertEqual(code, exits.NOT_IMPLEMENTED) + self.assertIn("keel-overlay-cloud", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() From 264bb10bb4cde5dea0bea93c3334c0821f752b88 Mon Sep 17 00:00:00 2001 From: navigator Date: Fri, 2 Oct 2026 14:33:08 +0000 Subject: [PATCH 2/2] fix: cloud.set and cloud.ca_file are matched whole A trailing newline passed the anchored patterns, so "shop\n" was a valid set name. Found in the security review of keel-cloud's Phase A. --- keel/spec/validate_cloud.py | 8 ++++---- tests/test_spec_cloud.py | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/keel/spec/validate_cloud.py b/keel/spec/validate_cloud.py index d8b5c17..2691948 100644 --- a/keel/spec/validate_cloud.py +++ b/keel/spec/validate_cloud.py @@ -30,8 +30,8 @@ KEYS = ("endpoint", "api_key", "entry_secret", "set", "ca_file") SECRETS = ("api_key", "entry_secret") # One lower case DNS label, as Keel Cloud names sets -SET_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") -PATH_RE = re.compile(r"^/[A-Za-z0-9._/+-]+$") +SET_RE = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?") +PATH_RE = re.compile(r"/[A-Za-z0-9._/+-]+") def validate_cloud(doc: dict, check_secret_files: bool) -> list[str]: @@ -51,11 +51,11 @@ def validate_cloud(doc: dict, check_secret_files: bool) -> list[str]: if joined and "set" not in cloud: errors.append(f"{KEY}.set: required with an API key, the set this" " node joins") - if "set" in cloud and not SET_RE.match(str(cloud["set"])): + if "set" in cloud and not SET_RE.fullmatch(str(cloud["set"])): errors.append(f"{KEY}.set: a lower case DNS label (a-z, 0-9 and" " '-', at most 63)") ca_file = cloud.get("ca_file") - if ca_file is not None and not PATH_RE.match(str(ca_file)): + if ca_file is not None and not PATH_RE.fullmatch(str(ca_file)): errors.append(f"{KEY}.ca_file: an absolute path") return errors diff --git a/tests/test_spec_cloud.py b/tests/test_spec_cloud.py index 409ceba..41b8cef 100644 --- a/tests/test_spec_cloud.py +++ b/tests/test_spec_cloud.py @@ -83,6 +83,8 @@ def test_a_joined_node_names_its_set(self): only(structural(text), "cloud.set: required") only(structural(JOINED.replace("set: shop", "set: Shop_1")), "cloud.set: a lower case DNS label") + only(structural(JOINED.replace("set: shop", 'set: "shop\\n"')), + "cloud.set: a lower case DNS label") def test_the_endpoint_is_https_with_no_path(self): for endpoint in ("http://cloud.example.org", "https://", "ftp://x",