mirror of
https://github.com/nickyeoman/docker-compose-cookbooks.git
synced 2026-09-03 18:36:22 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user