Skip to content
Merged
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
146 changes: 146 additions & 0 deletions .github/scripts/preset_lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Check that every preset carries what lerd needs to present it.

The schema guard answers whether an older lerd can still read the store. This
answers whether a definition is complete and consistent, so the two failures
never read the same: what this finds is something to fix in the definition,
not a change that breaks an install.

Usage: preset_lint.py [store-dir]
"""

import json
import pathlib
import re
import sys
import yaml

HEX_COLOR = re.compile(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})")
TEST_DOMAIN = re.compile(r"\.test\b")
# The fields an index entry repeats from its preset, for the picker to render
# without fetching every definition.
INDEX_MIRRORED = ("label", "description", "dashboard", "image", "category", "icon", "color")


def presets(root):
"""Every preset file in a services/ tree, with its parsed document."""
for path in sorted(pathlib.Path(root).rglob("*.yaml")):
if path.parent.name != "services":
continue
try:
with open(path, "r", encoding="utf-8") as handle:
doc = yaml.safe_load(handle) or {}
except yaml.YAMLError:
continue
yield path, doc


def indexes(root):
"""Every index.json in a services/ tree, keyed by its directory."""
out = {}
for path in sorted(pathlib.Path(root).rglob("index.json")):
if path.parent.name != "services":
continue
with open(path, "r", encoding="utf-8") as handle:
out[path.parent] = (path, json.load(handle).get("services", []))
return out


def missing_labels(root):
"""lerd shows a service under its label, so every preset and entry names one."""
missing = []
for path, doc in presets(root):
label = doc.get("label")
if not isinstance(label, str) or not label.strip():
missing.append(f"{path.relative_to(root)}: no `label`, the name lerd shows the service under")
for path, entries in indexes(root).values():
for entry in entries:
if not str(entry.get("label", "")).strip():
missing.append(f"{path.relative_to(root)}: entry `{entry.get('name')}` has no `label`")
return missing


def misnamed(root):
"""lerd fetches <name>.yaml, so the file has to be named after the preset."""
return [
f"{path.relative_to(root)}: `name: {doc.get('name')}` does not match the file name"
for path, doc in presets(root)
if doc.get("name") != path.stem
]


def bad_colors(root):
"""A colour that is not a plain hex literal is dropped and the mark goes untinted."""
return [
f"{path.relative_to(root)}: `color: {doc['color']}` is not a plain hex literal like #003545"
for path, doc in presets(root)
if "color" in doc and not HEX_COLOR.fullmatch(str(doc["color"]))
]


def test_domains(root):
"""A .test domain written out breaks every install on another TLD, and every
one that has not set the domain: a container or localhost URL is what lerd
env points at the service's domain once it has one."""
found = []
for path, doc in presets(root):
values = [str(v) for v in doc.get("env_vars") or []] + [str(doc.get("dashboard") or "")]
for value in values:
if TEST_DOMAIN.search(value):
found.append(f"{path.relative_to(root)}: `{value}` names a .test domain; write the container or localhost URL instead")
return found


def floating_data_images(root):
"""A preset that keeps data must not float on latest: a new image can change
the data format under it. One with no data_dir may, as the README allows."""
found = []
for path, doc in presets(root):
if not doc.get("data_dir"):
continue
images = [doc.get("image")] + [v.get("image") for v in doc.get("versions") or []]
for image in filter(None, images):
if "@" in image:
continue
last = image.rsplit("/", 1)[-1]
if ":" not in last or last.endswith(":latest"):
found.append(f"{path.relative_to(root)}: `{image}` floats, but the preset keeps data; pin a version tag")
return found


def index_drift(root):
"""An index entry repeats its preset's presentation, and must say the same."""
drift = []
trees = indexes(root)
for path, doc in presets(root):
tree = trees.get(path.parent)
if tree is None:
continue
index_path, entries = tree
entry = next((e for e in entries if e.get("name") == doc.get("name")), None)
if entry is None:
drift.append(f"{path.relative_to(root)}: no entry in {index_path.relative_to(root)}")
continue
for key in INDEX_MIRRORED:
if entry.get(key) != doc.get(key):
drift.append(f"{index_path.relative_to(root)}: `{doc.get('name')}` {key} is {entry.get(key)!r}, the preset says {doc.get(key)!r}")
return drift


RULES = (missing_labels, misnamed, bad_colors, test_domains, floating_data_images, index_drift)


def main():
root = sys.argv[1] if len(sys.argv) > 1 else "."
failures = [line for rule in RULES for line in rule(root)]
for line in failures:
print(f"error: {line}")
if failures:
print(f"\n{len(failures)} preset problem(s) to fix.")
return 1
print("preset lint passed.")
return 0


if __name__ == "__main__":
sys.exit(main())
73 changes: 73 additions & 0 deletions .github/scripts/test_preset_lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Checks on the preset lint, run in CI beside it.

Each case builds a throwaway store with one fault and asserts the rule that
should catch it does, and that a clean preset passes every rule.
"""

import json
import pathlib
import sys
import tempfile

sys.dont_write_bytecode = True
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import preset_lint # noqa: E402

CLEAN = {
"name": "queue",
"label": "Queue",
"description": "A queue",
"image": "docker.io/acme/queue:2",
"dashboard": "http://localhost:8090",
"category": "messaging",
"icon": "queue",
"color": "#123abc",
"data_dir": "/data",
"env_vars": ["QUEUE_URL=http://lerd-queue:8090"],
}


def entry_of(doc):
return {k: doc[k] for k in ("name", *preset_lint.INDEX_MIRRORED) if k in doc}


def lint(doc, entry=None, filename=None):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
services = root / "services"
services.mkdir()
(services / f"{filename or doc['name']}.yaml").write_text(json.dumps(doc))
index = [entry if entry is not None else entry_of(doc)]
(services / "index.json").write_text(json.dumps({"services": index}))
return [line for rule in preset_lint.RULES for line in rule(root)]


def expect(doc, needle, **kw):
found = lint(doc, **kw)
assert any(needle in f for f in found), (needle, found)


def main():
assert lint(CLEAN) == [], lint(CLEAN)

expect({**CLEAN, "label": " "}, "no `label`")
expect(CLEAN, "has no `label`", entry={k: v for k, v in entry_of(CLEAN).items() if k != "label"})
expect(CLEAN, "does not match the file name", filename="queues")
expect({**CLEAN, "color": "var(--brand)"}, "not a plain hex literal")
expect({**CLEAN, "env_vars": ["QUEUE_URL=https://queue.test"]}, "names a .test domain")
expect({**CLEAN, "dashboard": "https://queue.test"}, "names a .test domain")
expect({**CLEAN, "image": "docker.io/acme/queue:latest"}, "floats")
expect({**CLEAN, "image": "docker.io/acme/queue"}, "floats")
expect(CLEAN, "dashboard is", entry={**entry_of(CLEAN), "dashboard": "http://localhost:9999"})

# A preset that keeps no data may float, as the README allows, and a digest
# is as pinned as a tag.
stateless = {k: v for k, v in CLEAN.items() if k != "data_dir"}
assert lint({**stateless, "image": "docker.io/acme/queue:latest"}) == []
assert lint({**CLEAN, "image": "docker.io/acme/queue@sha256:" + "a" * 64}) == []
print("preset lint checks passed.")


if __name__ == "__main__":
main()
17 changes: 17 additions & 0 deletions .github/workflows/schema-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,20 @@ jobs:

- name: Compare this pull request against it
run: python3 .github/scripts/schema_guard.py "$RUNNER_TEMP/published" .

# Separate from the guard, so its failure always means an older lerd breaks;
# this one means a definition is missing something lerd shows.
preset-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml

- name: Check the lint
run: python3 .github/scripts/test_preset_lint.py

- name: Every preset is complete
run: python3 .github/scripts/preset_lint.py .
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,20 @@ New services and version bumps are welcome — this store is only as good as the
1. Fork this repo
2. Add or update `services/<name>.yaml`
3. Optionally add the service's own mark as `services/<name>.svg` (see below), then run `python3 .github/scripts/readme_logos.py` to draw its tile for this README
4. Add or update the matching entry in `services/index.json` (name, description, and optionally label, family, dashboard, depends_on)
4. Add or update the matching entry in `services/index.json` (name, label, description, and optionally family, dashboard, depends_on)
5. Open a pull request

Keep images pinned to a specific tag or a `track_latest` family; avoid `:latest` for anything that stores data.

`.github/scripts/preset_lint.py` checks every pull request for what makes a preset complete: a `label`, a file named after `name`, a plain hex `color`, no `.test` domain written into `env_vars` or `dashboard` (write the container or localhost URL, which `lerd env` points at the service's domain once it has one), a pinned image on any preset with a `data_dir`, and an `index.json` entry that says what its preset says. Run it before opening one.

### Preset schema

Definitions live under `services/`: an `index.json` catalogue plus one `<name>.yaml` per preset. A minimal preset:

```yaml
name: my-service
label: My Service
image: docker.io/example/my-service:1
description: What it is
ports:
Expand Down Expand Up @@ -201,16 +204,17 @@ Four fields decide that, and all four live here rather than in lerd, so a new
service looks right the day it lands:

```yaml
label: "MariaDB" # the name it is shown under; omit it when capitalizing
# the name already spells it right (mongo-express)
label: "MariaDB" # the name it is shown under, required on every preset
category: databases # discovery section: databases, cache, messaging, search,
# mail, admin, storage, testing, other
icon: database # one of lerd's built-in glyphs
color: "#003545" # the brand colour its mark is tinted with
```

Set `label` in the preset and in its `index.json` entry. A lerd that predates the
key ignores it and keeps naming the service as it always has.
Set `label` in the preset and in its `index.json` entry; the preset lint refuses
a preset or an entry without one, so no name is left for lerd to guess from the
lowercase name. A lerd that predates the key ignores it and keeps naming the
service as it always has.

`color` must be a plain hex literal, `#003545` or `#abc`. Anything else, a colour
function or a CSS variable, is dropped and the service falls back to its
Expand Down
1 change: 1 addition & 0 deletions schema/2/services/adminer.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: adminer
label: "Adminer"
image: docker.io/library/adminer:6
update_strategy: rolling
description: Adminer web UI for every database lerd runs
Expand Down
Loading
Loading