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`
|
- 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
|
- `_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)
|
- 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}`
|
- 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.
|
- Timezone defaults to `America/Vancouver`, but only include if the container requires it.
|
||||||
@@ -18,6 +19,20 @@ networks:
|
|||||||
proxy:
|
proxy:
|
||||||
external: true
|
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
|
## 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.
|
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:
|
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