Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
14 changes: 14 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <mendez.foto@gmail.com> Fri, 02 Oct 2026 18:00:00 +0000

keel (0.15.3) unstable; urgency=medium

* inspect keeps overlays.wireguard from /etc/keel/instance.yaml when
Expand Down
36 changes: 35 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion keel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
clients of it.
"""

__version__ = "0.15.3"
__version__ = "0.16.0"
17 changes: 17 additions & 0 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions keel/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions keel/diff/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions keel/spec/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"secrets",
"app",
"hub",
"cloud",
"security",
"first_login_wizard",
"preseed",
Expand Down
2 changes: 2 additions & 0 deletions keel/spec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
85 changes: 85 additions & 0 deletions keel/spec/validate_cloud.py
Original file line number Diff line number Diff line change
@@ -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.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.fullmatch(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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "keel"
version = "0.15.3"
version = "0.16.0"
authors = [{name = "KeelLinux maintainers"}]
description = "Declarative management of one appliance instance"
readme = "README.md"
Expand Down
Loading
Loading