shipit-deploy 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shipit/detect.py ADDED
@@ -0,0 +1,243 @@
1
+ """Detect a project's language and generate a Dockerfile for it.
2
+
3
+ A Dockerfile in the repo always wins, so *any* language works; detection is
4
+ only a convenience for the common cases.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import tomllib
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass
16
+ class Detected:
17
+ lang: str
18
+ port: int
19
+ dockerfile: str
20
+
21
+
22
+ def _cmd(default: str, start: str | None) -> str:
23
+ return "CMD " + json.dumps(["sh", "-c", start or default])
24
+
25
+
26
+ def _exists(root: Path, *names: str) -> bool:
27
+ return any((root / n).exists() for n in names)
28
+
29
+
30
+ def _glob(root: Path, pattern: str) -> list[Path]:
31
+ return sorted(root.glob(pattern))
32
+
33
+
34
+ def _node(root, port, start):
35
+ return Detected("node", port, f"""FROM node:22-alpine
36
+ WORKDIR /app
37
+ COPY . .
38
+ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
39
+ RUN npm run build --if-present
40
+ ENV PORT={port} NODE_ENV=production
41
+ EXPOSE {port}
42
+ {_cmd("npm start", start)}
43
+ """)
44
+
45
+
46
+ _APP_RE = re.compile(r"^(\w+)\s*=\s*(Flask|FastAPI|Starlette|Quart)\(", re.M)
47
+
48
+
49
+ def _infer_python(root: Path, port: int) -> tuple[str, str] | None:
50
+ """Guess (start command, extra pip packages) for Django / FastAPI / Flask projects."""
51
+ deps = ""
52
+ for f in ("requirements.txt", "pyproject.toml"):
53
+ if (root / f).exists():
54
+ deps += (root / f).read_text(encoding="utf-8", errors="ignore").lower()
55
+ bind = f"0.0.0.0:{port}"
56
+ if (root / "manage.py").exists():
57
+ for wsgi in sorted(root.glob("*/wsgi.py")):
58
+ return f"gunicorn {wsgi.parent.name}.wsgi:application -b {bind}", "gunicorn"
59
+ for f in ("main.py", "app.py", "server.py", "api.py"):
60
+ path = root / f
61
+ if not path.exists():
62
+ continue
63
+ m = _APP_RE.search(path.read_text(encoding="utf-8", errors="ignore"))
64
+ if not m:
65
+ continue
66
+ target = f"{path.stem}:{m.group(1)}"
67
+ if m.group(2) in ("FastAPI", "Starlette"):
68
+ return f"uvicorn {target} --host 0.0.0.0 --port {port}", "uvicorn"
69
+ return f"gunicorn {target} -b {bind}", "gunicorn"
70
+ return None
71
+
72
+
73
+ def _python(root, port, start):
74
+ extra = ""
75
+ if start is None:
76
+ guess = _infer_python(root, port)
77
+ if guess:
78
+ start, extra = guess
79
+ else:
80
+ for f in ("app.py", "main.py", "server.py"):
81
+ if (root / f).exists():
82
+ start = f"python {f}"
83
+ break
84
+ else:
85
+ raise ValueError("Python project: set `start` in config (e.g. 'gunicorn app:app -b 0.0.0.0:8000')")
86
+ pip_extra = f"RUN pip install --no-cache-dir {extra}\n" if extra else ""
87
+ return Detected("python", port, f"""FROM python:3.12-slim
88
+ WORKDIR /app
89
+ COPY . .
90
+ RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; \\
91
+ elif [ -f pyproject.toml ]; then pip install --no-cache-dir .; fi
92
+ {pip_extra}ENV PORT={port} PYTHONUNBUFFERED=1
93
+ EXPOSE {port}
94
+ {_cmd(start, start)}
95
+ """)
96
+
97
+
98
+ def _go(root, port, start):
99
+ return Detected("go", port, f"""FROM golang:1.23-alpine AS build
100
+ WORKDIR /src
101
+ COPY . .
102
+ RUN CGO_ENABLED=0 go build -o /out/app .
103
+ FROM alpine:3.20
104
+ RUN apk add --no-cache ca-certificates
105
+ COPY --from=build /out/app /app
106
+ ENV PORT={port}
107
+ EXPOSE {port}
108
+ {_cmd("/app", start)}
109
+ """)
110
+
111
+
112
+ def _rust(root, port, start):
113
+ name = tomllib.loads((root / "Cargo.toml").read_text(encoding="utf-8")).get("package", {}).get("name")
114
+ if not name:
115
+ raise ValueError("Cargo.toml has no [package].name (workspace?) - provide your own Dockerfile")
116
+ return Detected("rust", port, f"""FROM rust:1-slim AS build
117
+ WORKDIR /src
118
+ COPY . .
119
+ RUN cargo build --release
120
+ FROM debian:bookworm-slim
121
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
122
+ COPY --from=build /src/target/release/{name} /app
123
+ ENV PORT={port}
124
+ EXPOSE {port}
125
+ {_cmd("/app", start)}
126
+ """)
127
+
128
+
129
+ def _java_maven(root, port, start):
130
+ return Detected("java-maven", port, f"""FROM maven:3-eclipse-temurin-21 AS build
131
+ WORKDIR /src
132
+ COPY . .
133
+ RUN mvn -q -B package -DskipTests
134
+ FROM eclipse-temurin:21-jre
135
+ WORKDIR /app
136
+ COPY --from=build /src/target/*.jar /tmp/jars/
137
+ RUN cp "$(ls /tmp/jars/*.jar | grep -v original | head -1)" /app/app.jar
138
+ ENV PORT={port} SERVER_PORT={port}
139
+ EXPOSE {port}
140
+ {_cmd("java -jar /app/app.jar", start)}
141
+ """)
142
+
143
+
144
+ def _java_gradle(root, port, start):
145
+ return Detected("java-gradle", port, f"""FROM gradle:8-jdk21 AS build
146
+ WORKDIR /src
147
+ COPY . .
148
+ RUN gradle build -x test --no-daemon
149
+ FROM eclipse-temurin:21-jre
150
+ WORKDIR /app
151
+ COPY --from=build /src/build/libs/ /tmp/jars/
152
+ RUN cp "$(ls /tmp/jars/*.jar | grep -v plain | head -1)" /app/app.jar
153
+ ENV PORT={port} SERVER_PORT={port}
154
+ EXPOSE {port}
155
+ {_cmd("java -jar /app/app.jar", start)}
156
+ """)
157
+
158
+
159
+ def _php(root, port, start):
160
+ return Detected("php", 80, """FROM php:8.3-apache
161
+ RUN a2enmod rewrite
162
+ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
163
+ WORKDIR /var/www/html
164
+ COPY . .
165
+ RUN if [ -f composer.json ]; then composer install --no-dev --no-interaction; fi
166
+ EXPOSE 80
167
+ """)
168
+
169
+
170
+ def _ruby(root, port, start):
171
+ if start is None:
172
+ if not (root / "config.ru").exists():
173
+ raise ValueError("Ruby project: set `start` in config")
174
+ start = f"bundle exec rackup -o 0.0.0.0 -p {port}"
175
+ return Detected("ruby", port, f"""FROM ruby:3.3-slim
176
+ RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
177
+ WORKDIR /app
178
+ COPY . .
179
+ RUN bundle install
180
+ ENV PORT={port}
181
+ EXPOSE {port}
182
+ {_cmd(start, start)}
183
+ """)
184
+
185
+
186
+ def _dotnet(root, port, start):
187
+ proj = (_glob(root, "*.csproj") or _glob(root, "*.fsproj"))[0].stem
188
+ return Detected("dotnet", port, f"""FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
189
+ WORKDIR /src
190
+ COPY . .
191
+ RUN dotnet publish -c Release -o /out
192
+ FROM mcr.microsoft.com/dotnet/aspnet:8.0
193
+ WORKDIR /app
194
+ COPY --from=build /out .
195
+ ENV ASPNETCORE_URLS=http://+:{port}
196
+ EXPOSE {port}
197
+ {_cmd(f"dotnet {proj}.dll", start)}
198
+ """)
199
+
200
+
201
+ def _elixir(root, port, start):
202
+ return Detected("elixir", port, f"""FROM elixir:1.17-alpine
203
+ WORKDIR /app
204
+ RUN mix local.hex --force && mix local.rebar --force
205
+ COPY . .
206
+ ENV MIX_ENV=prod PORT={port}
207
+ RUN mix deps.get --only prod && mix compile
208
+ EXPOSE {port}
209
+ {_cmd("mix phx.server", start)}
210
+ """)
211
+
212
+
213
+ def _static(root, port, start):
214
+ return Detected("static", 80, """FROM nginx:alpine
215
+ COPY . /usr/share/nginx/html
216
+ EXPOSE 80
217
+ """)
218
+
219
+
220
+ # Order matters: first match wins.
221
+ _RULES = [
222
+ (lambda r: _exists(r, "package.json"), _node),
223
+ (lambda r: _exists(r, "go.mod"), _go),
224
+ (lambda r: _exists(r, "Cargo.toml"), _rust),
225
+ (lambda r: _exists(r, "pom.xml"), _java_maven),
226
+ (lambda r: _exists(r, "build.gradle", "build.gradle.kts"), _java_gradle),
227
+ (lambda r: _exists(r, "composer.json") or _glob(r, "*.php"), _php),
228
+ (lambda r: _exists(r, "Gemfile"), _ruby),
229
+ (lambda r: _glob(r, "*.csproj") or _glob(r, "*.fsproj"), _dotnet),
230
+ (lambda r: _exists(r, "mix.exs"), _elixir),
231
+ (lambda r: _exists(r, "requirements.txt", "pyproject.toml", "app.py", "main.py"), _python),
232
+ (lambda r: _exists(r, "index.html"), _static),
233
+ ]
234
+
235
+
236
+ def detect(root: str | Path, port: int = 8080, start: str | None = None) -> Detected:
237
+ root = Path(root)
238
+ for matches, build in _RULES:
239
+ if matches(root):
240
+ return build(root, port, start)
241
+ raise ValueError(
242
+ f"Could not detect language in {root}. Add a Dockerfile to the repo - any language works with one."
243
+ )
shipit/doctor.py ADDED
@@ -0,0 +1,58 @@
1
+ """`shipit doctor`: check the host and config before the first deploy."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+
12
+ def _docker_ok() -> tuple[bool, str]:
13
+ if not shutil.which("docker"):
14
+ return False, "docker not found in PATH"
15
+ r = subprocess.run(["docker", "info"], capture_output=True, text=True)
16
+ return r.returncode == 0, "daemon reachable" if r.returncode == 0 else "docker daemon not reachable (is it running / do you have access?)"
17
+
18
+
19
+ def _caddy_ok(admin: str) -> tuple[bool, str]:
20
+ try:
21
+ urllib.request.urlopen(admin.rstrip("/") + "/config/", timeout=3).close()
22
+ return True, f"admin API reachable at {admin}"
23
+ except OSError as e:
24
+ return False, f"cannot reach Caddy admin API at {admin} ({e})"
25
+
26
+
27
+ def checks(cfg) -> list[tuple[str, bool, str]]:
28
+ out: list[tuple[str, bool, str]] = []
29
+ out.append(("python >= 3.11", sys.version_info >= (3, 11), sys.version.split()[0]))
30
+ out.append(("git", bool(shutil.which("git")), shutil.which("git") or "not found in PATH"))
31
+ out.append(("docker", *_docker_ok()))
32
+ if any(a.domain for a in cfg.apps.values()):
33
+ out.append(("caddy", *_caddy_ok(cfg.caddy_admin)))
34
+ ports: dict[int, str] = {}
35
+ for a in cfg.apps.values():
36
+ used = [a.host_port] + ([a.host_port + 1] if a.domain else [])
37
+ for p in used:
38
+ if p in ports:
39
+ out.append((f"{a.name}: port {p}", False, f"also used by {ports[p]}"))
40
+ ports[p] = a.name
41
+ env = a.secret_env or "SHIPIT_SECRET"
42
+ out.append((f"{a.name}: webhook secret ${env}", True, "set" if os.environ.get(env) else f"not set; using auto-generated secret in {cfg.state_dir / 'webhook-secret'}"))
43
+ if a.token_env:
44
+ out.append((f"{a.name}: git token ${a.token_env}", bool(os.environ.get(a.token_env)), "set" if os.environ.get(a.token_env) else "not set"))
45
+ if a.env_file:
46
+ out.append((f"{a.name}: env_file", Path(a.env_file).is_file(), a.env_file))
47
+ if a.notify_env:
48
+ out.append((f"{a.name}: notify ${a.notify_env}", bool(os.environ.get(a.notify_env)), "set" if os.environ.get(a.notify_env) else "not set (notifications off)"))
49
+ return out
50
+
51
+
52
+ def run(cfg) -> int:
53
+ bad = 0
54
+ for name, ok, detail in checks(cfg):
55
+ print(f" [{'ok' if ok else 'FAIL'}] {name}: {detail}")
56
+ bad += not ok
57
+ print("all good" if not bad else f"{bad} problem(s) found")
58
+ return 1 if bad else 0
shipit/easy.py ADDED
@@ -0,0 +1,82 @@
1
+ """Zero-config helpers behind `shipit up`."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import secrets
7
+ import socket
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+
12
+ def app_name_from_repo(url: str) -> str:
13
+ base = url.rstrip("/").rsplit("/", 1)[-1].rsplit(":", 1)[-1]
14
+ base = base.removesuffix(".git").lower()
15
+ name = re.sub(r"[^a-z0-9_.-]+", "-", base).strip("-._")
16
+ return name[:60] or "app"
17
+
18
+
19
+ def _port_in_use(port: int) -> bool:
20
+ with socket.socket() as s:
21
+ s.settimeout(0.3)
22
+ if s.connect_ex(("127.0.0.1", port)) == 0:
23
+ return True
24
+ with socket.socket() as s:
25
+ try:
26
+ s.bind(("0.0.0.0", port))
27
+ except OSError:
28
+ return True
29
+ return False
30
+
31
+
32
+ def free_port(taken: set[int], start: int = 8080) -> int:
33
+ """Lowest port >= start that no configured app uses and nothing is listening on."""
34
+ port = start
35
+ while port < 65535:
36
+ if port not in taken and port + 1 not in taken and not _port_in_use(port) and not _port_in_use(port + 1):
37
+ return port
38
+ port += 2
39
+ raise RuntimeError("no free port found")
40
+
41
+
42
+ def default_branch(repo: str) -> str:
43
+ """Ask the remote which branch HEAD points to (falls back to 'main')."""
44
+ try:
45
+ out = subprocess.run(["git", "ls-remote", "--symref", repo, "HEAD"],
46
+ capture_output=True, text=True, timeout=30).stdout
47
+ except (OSError, subprocess.TimeoutExpired):
48
+ return "main"
49
+ for line in out.splitlines():
50
+ if line.startswith("ref: refs/heads/"):
51
+ return line.split("\t")[0].removeprefix("ref: refs/heads/")
52
+ return "main"
53
+
54
+
55
+ def get_secret(state_dir: Path) -> str:
56
+ """Webhook secret: auto-generated once and kept in the state dir."""
57
+ f = state_dir / "webhook-secret"
58
+ if f.exists():
59
+ return f.read_text().strip()
60
+ state_dir.mkdir(parents=True, exist_ok=True)
61
+ value = secrets.token_hex(20)
62
+ f.write_text(value)
63
+ try:
64
+ f.chmod(0o600)
65
+ except OSError:
66
+ pass
67
+ return value
68
+
69
+
70
+ def webhook_secret(app, state_dir: Path) -> str:
71
+ """Per-app env var wins, then SHIPIT_SECRET, then the auto-generated one."""
72
+ import os
73
+ return os.environ.get(app.secret_env or "SHIPIT_SECRET", "") or get_secret(state_dir)
74
+
75
+
76
+ def append_app(config_path: Path, fields: dict) -> None:
77
+ """Append an [[app]] block, creating the config file if needed."""
78
+ text = config_path.read_text(encoding="utf-8") if config_path.exists() else '[server]\nport = 9000\nstate_dir = ".shipit"\n'
79
+ if not text.endswith("\n"):
80
+ text += "\n"
81
+ block = "\n[[app]]\n" + "".join(f"{k} = {json.dumps(v)}\n" for k, v in fields.items() if v is not None)
82
+ config_path.write_text(text + block, encoding="utf-8")
shipit/hooks.py ADDED
@@ -0,0 +1,53 @@
1
+ """Register the push webhook on GitHub through the `gh` CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import subprocess
7
+
8
+ _GITHUB = re.compile(
9
+ r"^(?:https?://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)([^/]+)/([^/]+?)(?:\.git)?/?$"
10
+ )
11
+
12
+
13
+ def parse_github_repo(url: str) -> tuple[str, str] | None:
14
+ m = _GITHUB.match(url.strip())
15
+ return (m.group(1), m.group(2)) if m else None
16
+
17
+
18
+ def hook_url(base: str, app: str) -> str:
19
+ base = base.rstrip("/")
20
+ return base if "/hook/" in base else f"{base}/hook/{app}"
21
+
22
+
23
+ def payload(url: str, secret: str) -> dict:
24
+ return {
25
+ "name": "web",
26
+ "active": True,
27
+ "events": ["push"],
28
+ "config": {"url": url, "content_type": "json", "secret": secret, "insecure_ssl": "0"},
29
+ }
30
+
31
+
32
+ def _gh(method: str, path: str, body: dict | None = None) -> str:
33
+ """Call the GitHub API via gh. The body goes through stdin so the secret never shows in `ps`."""
34
+ cmd = ["gh", "api", "--method", method, path] + (["--input", "-"] if body is not None else [])
35
+ r = subprocess.run(cmd, input=json.dumps(body) if body is not None else None, capture_output=True, text=True)
36
+ if r.returncode != 0:
37
+ raise RuntimeError((r.stderr or r.stdout).strip() or f"gh exited with {r.returncode}")
38
+ return r.stdout
39
+
40
+
41
+ def register(repo_url: str, url: str, secret: str) -> str:
42
+ """Create (or update) the webhook. Returns 'created' or 'updated'."""
43
+ parsed = parse_github_repo(repo_url)
44
+ if not parsed:
45
+ raise ValueError("automatic registration only supports github.com repos; add the webhook manually")
46
+ owner, repo = parsed
47
+ base = f"repos/{owner}/{repo}/hooks"
48
+ for h in json.loads(_gh("GET", base) or "[]"):
49
+ if h.get("config", {}).get("url") == url:
50
+ _gh("PATCH", f"{base}/{h['id']}", payload(url, secret))
51
+ return "updated"
52
+ _gh("POST", base, payload(url, secret))
53
+ return "created"
shipit/proxy.py ADDED
@@ -0,0 +1,52 @@
1
+ """Zero-downtime cutover through Caddy's admin API.
2
+
3
+ Apps that set `domain` run as two slots (blue/green) on 127.0.0.1:<host_port> and
4
+ 127.0.0.1:<host_port+1>. Caddy routes the domain to the active slot; a release
5
+ starts the idle slot, health-checks it, flips the Caddy route, then removes the old slot.
6
+
7
+ shipit owns the Caddy config: every sync POSTs a full config to /load.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import urllib.request
13
+
14
+ SLOTS = ("blue", "green")
15
+
16
+
17
+ def slot_port(host_port: int, slot: str) -> int:
18
+ return host_port + SLOTS.index(slot)
19
+
20
+
21
+ def build_config(entries: list[tuple[str, int]]) -> dict:
22
+ """entries: [(domain, upstream_port)] -> Caddy JSON config."""
23
+ routes = [
24
+ {
25
+ "match": [{"host": [domain]}],
26
+ "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": f"127.0.0.1:{port}"}]}],
27
+ "terminal": True,
28
+ }
29
+ for domain, port in sorted(entries)
30
+ ]
31
+ return {"apps": {"http": {"servers": {"shipit": {"listen": [":443"], "routes": routes}}}}}
32
+
33
+
34
+ def apply(admin_url: str, config: dict) -> None:
35
+ req = urllib.request.Request(
36
+ admin_url.rstrip("/") + "/load",
37
+ data=json.dumps(config).encode(),
38
+ headers={"Content-Type": "application/json"},
39
+ method="POST",
40
+ )
41
+ with urllib.request.urlopen(req, timeout=15) as r:
42
+ r.read()
43
+
44
+
45
+ def sync(cfg, states: dict[str, dict]) -> None:
46
+ """Point Caddy at every proxied app's active slot."""
47
+ entries = []
48
+ for name, app in cfg.apps.items():
49
+ port = states.get(name, {}).get("port")
50
+ if app.domain and port:
51
+ entries.append((app.domain, port))
52
+ apply(cfg.caddy_admin, build_config(entries))
shipit/webhook.py ADDED
@@ -0,0 +1,98 @@
1
+ """Webhook server: GitHub (HMAC) and GitLab (token) push events trigger deploys."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import queue
9
+ import threading
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+
12
+ from .config import Config
13
+ from .deploy import Deployer, log
14
+ from .easy import webhook_secret
15
+
16
+ MAX_BODY = 5 * 1024 * 1024
17
+
18
+
19
+ def verify(secret: str, headers, body: bytes) -> bool:
20
+ sig = headers.get("X-Hub-Signature-256")
21
+ if sig: # GitHub / Gitea / Forgejo
22
+ want = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
23
+ return hmac.compare_digest(sig, want)
24
+ tok = headers.get("X-Gitlab-Token")
25
+ if tok: # GitLab
26
+ return hmac.compare_digest(tok, secret)
27
+ return False
28
+
29
+
30
+ def make_server(source) -> ThreadingHTTPServer:
31
+ """`source` is a Config or a LiveConfig (anything with .get())."""
32
+ get = source.get if hasattr(source, "get") else (lambda: source)
33
+ cfg = get()
34
+ jobs: queue.Queue[str] = queue.Queue()
35
+ pending: set[str] = set()
36
+ lock = threading.Lock()
37
+
38
+ def worker():
39
+ while True:
40
+ name = jobs.get()
41
+ with lock:
42
+ pending.discard(name)
43
+ try:
44
+ cfg = get()
45
+ Deployer(cfg.apps[name], cfg.state_dir, cfg).deploy()
46
+ except Exception as e: # keep the worker alive
47
+ log(f"deploy of {name} crashed: {e}")
48
+
49
+ threading.Thread(target=worker, daemon=True).start()
50
+
51
+ def enqueue(name: str) -> bool:
52
+ with lock:
53
+ if name in pending:
54
+ return False # a build is already queued; it will pick up the latest commit
55
+ pending.add(name)
56
+ jobs.put(name)
57
+ return True
58
+
59
+ class Handler(BaseHTTPRequestHandler):
60
+ def _reply(self, code: int, msg: str):
61
+ body = json.dumps({"message": msg}).encode()
62
+ self.send_response(code)
63
+ self.send_header("Content-Type", "application/json")
64
+ self.send_header("Content-Length", str(len(body)))
65
+ self.end_headers()
66
+ self.wfile.write(body)
67
+
68
+ def do_GET(self):
69
+ self._reply(200, "ok") if self.path == "/healthz" else self._reply(404, "not found")
70
+
71
+ def do_POST(self):
72
+ cfg = get()
73
+ parts = self.path.strip("/").split("/")
74
+ if len(parts) != 2 or parts[0] != "hook" or parts[1] not in cfg.apps:
75
+ return self._reply(404, "not found")
76
+ app = cfg.apps[parts[1]]
77
+ secret = webhook_secret(app, cfg.state_dir)
78
+ if not secret:
79
+ return self._reply(500, "webhook secret not configured")
80
+ n = int(self.headers.get("Content-Length") or 0)
81
+ if n > MAX_BODY:
82
+ return self._reply(413, "payload too large")
83
+ body = self.rfile.read(n)
84
+ if not verify(secret, self.headers, body):
85
+ return self._reply(401, "bad signature")
86
+ try:
87
+ ref = json.loads(body or b"{}").get("ref", "")
88
+ except ValueError:
89
+ return self._reply(400, "bad json")
90
+ if ref and ref != f"refs/heads/{app.branch}":
91
+ return self._reply(200, f"ignored ref {ref}")
92
+ queued = enqueue(app.name)
93
+ self._reply(202, "queued" if queued else "already queued")
94
+
95
+ def log_message(self, fmt, *args):
96
+ log("http " + fmt % args)
97
+
98
+ return ThreadingHTTPServer((cfg.listen_host, cfg.listen_port), Handler)