document conventions and add lint script

AGENTS.md: internal network pattern, ChangeThisPassword secrets convention,
_notes exemption, and a Known exceptions list (pihole, zammad-init,
compose-aio, VOL_PROJECTS nesting, mailu env_file, headscale config vars).
README.md: fix Preferred typo.
_docs/lint.py: checks every convention plus compose<->sample.env sync;
run with python3 _docs/lint.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
code
2026-07-16 06:25:05 +00:00
co-authored by Claude Fable 5
parent 340866f9f4
commit 102c207bfc
3 changed files with 183 additions and 1 deletions
+15
View File
@@ -4,6 +4,7 @@
- Every stack has at least 3 files: `compose.yaml` (dockhand style), `sample.env`, `README.md`
- `_dev` = experimental, `_notes` = docs/minimal examples, no suffix = production-ready
- `_notes` stacks are exempt from all conventions below — they hold notes, example commands, or minimal compose files only
- Always references an external `proxy` network (managed by Nginx Proxy Manager; `docker network create proxy` once)
- Volumes use `${VOL_PATH:-/data}` as the base path (absolute `/data`, never `./data`); project directories use `${VOL_PROJECTS:-/data}`
- Timezone defaults to `America/Vancouver`, but only include if the container requires it.
@@ -18,6 +19,20 @@ networks:
proxy:
external: true
```
- Multi-service stacks also use a bridge network named `internal` (see `network.yml` at the repo root for the sample): databases and other backing services join `internal` only; the app service joins both `proxy` and `internal`
- Default secrets (passwords, keys, tokens) are always the literal `ChangeThisPassword` — same string everywhere so it is easy to grep. Users must change them; never ship a real-looking secret as a default
- Lint the repo with `python3 _docs/lint.py` before committing — it checks all of the conventions above
## Known exceptions
These deliberately violate the conventions above — do not "fix" them:
- `pihole` uses `network_mode: host` (it's a DNS server), so it has no `proxy` network
- `zammad_dev` `zammad-init` service hardcodes `restart: on-failure` (init container)
- `nextcloud/compose-aio.yaml` follows the upstream AIO layout: fixed container/volume names, no env templating, no proxy network
- `opencode` and `vscode` default `VOL_PROJECTS` to `${VOL_PATH:-/data}/projects` (nested default), not plain `/data`
- `mailu_dev` passes env via `env_file: .env`, so its sample.env holds vars the compose file never references
- `headscale_dev` sample.env documents `HEADSCALE_DOMAIN`/`HEADSCALE_URL` for the config file even though compose doesn't use them
## No CI/CD, no dependency management
+1 -1
View File
@@ -51,7 +51,7 @@ docker compose --env-file sample.env --env-file /data/test.env down
Multiple --env-file flags are supported and applied in order.
## Prefered Containers
## Preferred Containers
These are projects that are not supported in this repo and the alternative:
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Convention linter for this repo — run: python3 _docs/lint.py
Checks every stack (skipping _notes dirs) against the conventions in AGENTS.md:
required files, compose section order, VOL_PATH defaults, TZ default, proxy
network, no healthchecks, env-configurable image/restart/port, README section
order and completeness, and compose <-> sample.env consistency.
Exit code 1 if any errors are found. Stale sample.env vars are warnings only.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECTION_ORDER = ["image", "restart", "volumes", "environment", "ports",
"networks", "depends_on", "healthcheck", "labels", "command", "user"]
README_ORDER = ["Overview", "Project Details", "Getting Started",
"Environment Variable Notes", "Volume Notes", "Network Notes",
"Docker Run", "Additional Notes / Gotchas",
"Dockhand Stack, Deploy from Git"]
UNIVERSAL_VARS = {"VOL_PATH", "VOL_PROJECTS", "TZ", "CONFIG_PATH", "LOG_PATH"}
# Known exceptions — see AGENTS.md "Known exceptions"
NO_PROXY_OK = {"pihole"}
HARDCODED_RESTART_OK = {("zammad_dev", "on-failure")}
STALE_VARS_OK = {
"mailu_dev": None, # env_file: .env — all vars consumed in-container
"headscale_dev": {"HEADSCALE_DOMAIN", "HEADSCALE_URL"}, # config-file docs
}
errors = []
warnings = []
def err(stack, msg):
errors.append(f"{stack}: {msg}")
def warn(stack, msg):
warnings.append(f"{stack}: WARNING: {msg}")
def check_compose(stack, path):
c = open(path).read()
if "\t" in c:
err(stack, "compose.yaml contains tab characters")
if not re.search(r"^networks:\n(?:.*\n)*?\s+proxy:\n\s+external:\s*true", c, re.M):
if stack not in NO_PROXY_OK:
err(stack, "proxy network not declared external:true at root")
if re.search(r"^\s+healthcheck:", c, re.M):
err(stack, "contains healthcheck block (healthchecks are a future feature)")
for m in re.finditer(r"^\s*-\s*(\./[^\s:]*):", c, re.M):
err(stack, f"relative volume path: {m.group(1)}")
if re.search(r"\$\{VOL_PATH\}", c):
err(stack, "VOL_PATH used without :-/data default")
if re.search(r"VOL_PATH:-(?!/data\})", c):
err(stack, "VOL_PATH default is not /data")
for m in re.finditer(r"TZ:-([^}\s]+)", c):
if m.group(1) != "America/Vancouver":
err(stack, f"TZ default is {m.group(1)}, expected America/Vancouver")
for m in re.finditer(r"^\s+restart:\s*(.+)$", c, re.M):
val = m.group(1).strip()
if "${" not in val and (stack, val) not in HARDCODED_RESTART_OK:
err(stack, f"restart not env-configurable: {val}")
for m in re.finditer(r"^\s+image:\s*(.+)$", c, re.M):
if "${" not in m.group(1):
err(stack, f"image not env-configurable: {m.group(1).strip()}")
in_ports = False
for line in c.splitlines():
if re.match(r"^\s+ports:", line):
in_ports = True
continue
if in_ports:
if re.match(r"^\s+-\s", line):
if "${" not in line:
err(stack, f"port not env-configurable: {line.strip()}")
else:
in_ports = False
# per-service section order
cur_svc, cur_keys, in_services = None, [], False
def flush():
if cur_svc and cur_keys:
idx = [SECTION_ORDER.index(k) for k in cur_keys if k in SECTION_ORDER]
if idx != sorted(idx):
err(stack, f"service '{cur_svc}' section order: {cur_keys}")
for line in c.splitlines():
if re.match(r"^services:\s*$", line):
in_services = True
continue
if in_services and re.match(r"^\S", line):
in_services = False
if not in_services:
continue
m = re.match(r"^( )([A-Za-z0-9_-]+):\s*$", line)
if m:
flush()
cur_svc, cur_keys = m.group(2), []
continue
m = re.match(r"^ ([a-z_]+):", line)
if m and cur_svc:
cur_keys.append(m.group(1))
flush()
return c
def check_readme(stack, path):
r = open(path).read()
heads = [h.strip() for h in re.findall(r"^#{1,3}\s+(.+)$", r, re.M)]
norm = [x for h in heads for x in README_ORDER if x.lower() == h.lower()]
idx = [README_ORDER.index(h) for h in norm]
if idx != sorted(idx):
err(stack, f"README section order off: {norm}")
missing = [h for h in README_ORDER if h not in norm]
if missing:
err(stack, f"README missing sections: {missing}")
def check_env(stack, compose_text, env_path):
envtext = open(env_path).read()
used = set(re.findall(r"\$\{([A-Z0-9_]+)", compose_text))
defined = set(re.findall(r"^\s*#?\s*([A-Z0-9_]+)=", envtext, re.M))
missing = sorted(used - defined - UNIVERSAL_VARS)
if missing:
err(stack, f"compose vars missing from sample.env: {missing}")
ok = STALE_VARS_OK.get(stack, set())
if ok is None:
return
active = set(re.findall(r"^([A-Z0-9_]+)=", envtext, re.M)) # commented vars aren't stale
stale = sorted((defined & active) - used - UNIVERSAL_VARS - ok)
if stale:
warn(stack, f"sample.env vars not used in compose: {stale}")
for m in re.finditer(r'^TZ="', envtext, re.M):
err(stack, "TZ value is quoted in sample.env (convention: unquoted)")
def main():
for d in sorted(os.listdir(ROOT)):
p = os.path.join(ROOT, d)
if not os.path.isdir(p) or d.startswith((".", "_")):
continue
if d.endswith("_notes"):
continue
files = os.listdir(p)
for req in ("compose.yaml", "sample.env", "README.md"):
if req not in files:
err(d, f"missing {req}")
compose_text = ""
if "compose.yaml" in files:
compose_text = check_compose(d, os.path.join(p, "compose.yaml"))
if "README.md" in files:
check_readme(d, os.path.join(p, "README.md"))
if compose_text and "sample.env" in files:
check_env(d, compose_text, os.path.join(p, "sample.env"))
for w in warnings:
print(w)
for e in errors:
print(e)
print(f"\n{len(errors)} errors, {len(warnings)} warnings")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()