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/__init__.py +1 -0
- shipit/__main__.py +5 -0
- shipit/cli.py +189 -0
- shipit/config.py +90 -0
- shipit/deploy.py +327 -0
- shipit/detect.py +243 -0
- shipit/doctor.py +58 -0
- shipit/easy.py +82 -0
- shipit/hooks.py +53 -0
- shipit/proxy.py +52 -0
- shipit/webhook.py +98 -0
- shipit_deploy-0.2.0.dist-info/METADATA +174 -0
- shipit_deploy-0.2.0.dist-info/RECORD +17 -0
- shipit_deploy-0.2.0.dist-info/WHEEL +5 -0
- shipit_deploy-0.2.0.dist-info/entry_points.txt +2 -0
- shipit_deploy-0.2.0.dist-info/licenses/LICENSE +21 -0
- shipit_deploy-0.2.0.dist-info/top_level.txt +1 -0
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)
|