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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
shipit/__main__.py
ADDED
shipit/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import LiveConfig, load
|
|
11
|
+
from .deploy import Deployer
|
|
12
|
+
from .detect import detect
|
|
13
|
+
from .easy import app_name_from_repo, append_app, default_branch, free_port, get_secret, webhook_secret
|
|
14
|
+
from .proxy import sync as _proxy_sync
|
|
15
|
+
|
|
16
|
+
def resync_proxy(cfg) -> None:
|
|
17
|
+
"""Re-apply Caddy routes at startup (e.g. after Caddy restarted)."""
|
|
18
|
+
if not any(a.domain for a in cfg.apps.values()):
|
|
19
|
+
return
|
|
20
|
+
try:
|
|
21
|
+
_proxy_sync(cfg, {n: Deployer(a, cfg.state_dir).state() for n, a in cfg.apps.items()})
|
|
22
|
+
except OSError as e:
|
|
23
|
+
print(f"[shipit] warning: could not sync Caddy: {e}", file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SAMPLE = '''[server]
|
|
27
|
+
port = 9000
|
|
28
|
+
state_dir = ".shipit"
|
|
29
|
+
|
|
30
|
+
[[app]]
|
|
31
|
+
name = "myapp"
|
|
32
|
+
repo = "https://github.com/you/myapp.git"
|
|
33
|
+
branch = "main"
|
|
34
|
+
port = 8080 # port inside the container
|
|
35
|
+
host_port = 8080 # port on this machine
|
|
36
|
+
health_path = "/"
|
|
37
|
+
# token_env = "GIT_TOKEN" # private repos
|
|
38
|
+
# secret_env = "MYAPP_SECRET" # webhook secret (default: SHIPIT_SECRET)
|
|
39
|
+
# notify_env = "SLACK_URL" # env var with a Slack/Discord webhook URL
|
|
40
|
+
# pre_deploy = "python manage.py migrate" # runs in the new image before release
|
|
41
|
+
# domain = "app.example.com" # zero-downtime via Caddy (blue/green on host_port and host_port+1)
|
|
42
|
+
# keep_images = 5
|
|
43
|
+
# env_file = "/etc/myapp.env"
|
|
44
|
+
# start = "gunicorn app:app -b 0.0.0.0:8080"
|
|
45
|
+
'''
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_up(a) -> int:
|
|
49
|
+
"""Zero-config: register a repo, deploy it now, and explain how to enable auto deploy."""
|
|
50
|
+
path = Path(a.config)
|
|
51
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
52
|
+
existing = raw.get("app", [])
|
|
53
|
+
name = a.name or app_name_from_repo(a.repo)
|
|
54
|
+
if any(x["name"] == name for x in existing):
|
|
55
|
+
print(f"app {name!r} already exists - redeploy it with: shipit deploy {name}", file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
taken = {p for x in existing for p in (x.get("host_port", 8080), x.get("host_port", 8080) + 1)}
|
|
58
|
+
host_port = free_port(taken)
|
|
59
|
+
fields = {"name": name, "repo": a.repo, "branch": a.branch or default_branch(a.repo),
|
|
60
|
+
"port": a.port, "host_port": host_port, "domain": a.domain}
|
|
61
|
+
append_app(path, fields)
|
|
62
|
+
cfg = load(path)
|
|
63
|
+
print(f"[shipit] added {name!r} to {path} (branch {fields['branch']}, port {host_port})", flush=True)
|
|
64
|
+
ok = Deployer(cfg.apps[name], cfg.state_dir, cfg).deploy()
|
|
65
|
+
if not ok:
|
|
66
|
+
print(f"\nDeploy failed. Fix the problem, then retry with: shipit deploy {name}", file=sys.stderr)
|
|
67
|
+
return 1
|
|
68
|
+
url = f"https://{a.domain}" if a.domain else f"http://localhost:{host_port}"
|
|
69
|
+
print(f"\n{name} is live: {url}\n")
|
|
70
|
+
print("Turn on auto deploy (every push to the branch redeploys):")
|
|
71
|
+
print(" 1. run: shipit serve")
|
|
72
|
+
print(f" 2. GitHub repos, automatic: shipit hook {name} --url http://<this-server>:{cfg.listen_port}")
|
|
73
|
+
print(f" or by hand: Settings -> Webhooks -> http://<this-server>:{cfg.listen_port}/hook/{name}")
|
|
74
|
+
print(f" content type: application/json secret: {get_secret(cfg.state_dir)}")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_hook(a, cfg) -> int:
|
|
79
|
+
"""Register the GitHub webhook for an app (needs the `gh` CLI, logged in)."""
|
|
80
|
+
from . import hooks
|
|
81
|
+
app = cfg.apps[a.app]
|
|
82
|
+
url = hooks.hook_url(a.url, app.name)
|
|
83
|
+
secret = webhook_secret(app, cfg.state_dir)
|
|
84
|
+
try:
|
|
85
|
+
result = hooks.register(app.repo, url, secret)
|
|
86
|
+
except (ValueError, RuntimeError, OSError) as e:
|
|
87
|
+
print(f"could not register automatically: {e}", file=sys.stderr)
|
|
88
|
+
print(f"Add it by hand: URL {url}, content type application/json, secret: {secret}", file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
print(f"webhook {result}: pushes to {app.branch} will now redeploy {app.name} ({url})")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_list(cfg) -> int:
|
|
95
|
+
for app in cfg.apps.values():
|
|
96
|
+
d = Deployer(app, cfg.state_dir, cfg)
|
|
97
|
+
url = f"https://{app.domain}" if app.domain else f"http://localhost:{app.host_port}"
|
|
98
|
+
state = "running" if d._running(d._live_name()) else "stopped"
|
|
99
|
+
print(f"{app.name:<20} {state:<8} {url:<34} {d.state().get('current', '-')}")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv: list[str] | None = None) -> int:
|
|
104
|
+
p = argparse.ArgumentParser(prog="shipit", description="Self-hosted auto deploy for any language.")
|
|
105
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
106
|
+
p.add_argument("-c", "--config", default=os.environ.get("SHIPIT_CONFIG", "shipit.toml"))
|
|
107
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
108
|
+
sub.add_parser("init", help="write a sample shipit.toml")
|
|
109
|
+
d = sub.add_parser("detect", help="show detected language / print generated Dockerfile")
|
|
110
|
+
d.add_argument("path", nargs="?", default=".")
|
|
111
|
+
d.add_argument("--port", type=int, default=8080)
|
|
112
|
+
for name, h in (("deploy", "deploy an app now"), ("rollback", "roll back to previous release")):
|
|
113
|
+
s = sub.add_parser(name, help=h)
|
|
114
|
+
s.add_argument("app")
|
|
115
|
+
st = sub.add_parser("status", help="show current release and recent history")
|
|
116
|
+
st.add_argument("app")
|
|
117
|
+
lg = sub.add_parser("logs", help="show container logs")
|
|
118
|
+
lg.add_argument("app")
|
|
119
|
+
lg.add_argument("-n", "--tail", type=int, default=100)
|
|
120
|
+
lg.add_argument("-f", "--follow", action="store_true")
|
|
121
|
+
sub.add_parser("doctor", help="check docker/git/caddy/secrets before deploying")
|
|
122
|
+
up = sub.add_parser("up", help="zero-config: deploy a git repo right now")
|
|
123
|
+
up.add_argument("repo", help="git URL of the project")
|
|
124
|
+
up.add_argument("--name")
|
|
125
|
+
up.add_argument("--branch", help="default: the repo's default branch")
|
|
126
|
+
up.add_argument("--port", type=int, help="port the app listens on in the container (usually auto-detected)")
|
|
127
|
+
up.add_argument("--domain", help="serve at this domain via Caddy with zero-downtime releases")
|
|
128
|
+
hk = sub.add_parser("hook", help="register the GitHub push webhook for an app (uses gh)")
|
|
129
|
+
hk.add_argument("app")
|
|
130
|
+
hk.add_argument("--url", required=True, help="public URL of this server, e.g. https://deploy.example.com or http://1.2.3.4:9000")
|
|
131
|
+
sub.add_parser("list", help="list apps and their status")
|
|
132
|
+
sub.add_parser("serve", help="run the webhook server")
|
|
133
|
+
a = p.parse_args(argv)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
if a.cmd == "up":
|
|
137
|
+
return cmd_up(a)
|
|
138
|
+
if a.cmd == "init":
|
|
139
|
+
f = Path(a.config)
|
|
140
|
+
if f.exists():
|
|
141
|
+
print(f"{f} already exists", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
f.write_text(SAMPLE, encoding="utf-8")
|
|
144
|
+
print(f"wrote {f}")
|
|
145
|
+
return 0
|
|
146
|
+
if a.cmd == "detect":
|
|
147
|
+
r = detect(a.path, a.port)
|
|
148
|
+
print(f"# language: {r.lang}\n{r.dockerfile}")
|
|
149
|
+
return 0
|
|
150
|
+
cfg = load(a.config)
|
|
151
|
+
if a.cmd == "hook":
|
|
152
|
+
if a.app not in cfg.apps:
|
|
153
|
+
print(f"unknown app {a.app!r}; known: {', '.join(cfg.apps)}", file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
return cmd_hook(a, cfg)
|
|
156
|
+
if a.cmd == "list":
|
|
157
|
+
return cmd_list(cfg)
|
|
158
|
+
if a.cmd == "doctor":
|
|
159
|
+
from . import doctor
|
|
160
|
+
return doctor.run(cfg)
|
|
161
|
+
if a.cmd == "serve":
|
|
162
|
+
from .webhook import make_server
|
|
163
|
+
resync_proxy(cfg)
|
|
164
|
+
srv = make_server(LiveConfig(a.config))
|
|
165
|
+
print(f"[shipit] listening on {cfg.listen_host}:{cfg.listen_port} (POST /hook/<app>)", flush=True)
|
|
166
|
+
try:
|
|
167
|
+
srv.serve_forever()
|
|
168
|
+
except KeyboardInterrupt:
|
|
169
|
+
pass
|
|
170
|
+
return 0
|
|
171
|
+
if a.app not in cfg.apps:
|
|
172
|
+
print(f"unknown app {a.app!r}; known: {', '.join(cfg.apps)}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
dep = Deployer(cfg.apps[a.app], cfg.state_dir, cfg)
|
|
175
|
+
if a.cmd == "status":
|
|
176
|
+
print(dep.status())
|
|
177
|
+
return 0
|
|
178
|
+
if a.cmd == "logs":
|
|
179
|
+
dep.logs(a.tail, a.follow)
|
|
180
|
+
return 0
|
|
181
|
+
ok = dep.deploy() if a.cmd == "deploy" else dep.rollback()
|
|
182
|
+
return 0 if ok else 1
|
|
183
|
+
except (ValueError, OSError) as e:
|
|
184
|
+
print(f"error: {e}", file=sys.stderr)
|
|
185
|
+
return 2
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
sys.exit(main())
|
shipit/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Load shipit.toml."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DOMAIN_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$")
|
|
11
|
+
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class App:
|
|
16
|
+
name: str
|
|
17
|
+
repo: str
|
|
18
|
+
branch: str = "main"
|
|
19
|
+
port: int = 8080 # port the app listens on inside the container
|
|
20
|
+
host_port: int = 8080 # port published on this machine
|
|
21
|
+
health_path: str = "/" # HTTP path polled after start; "" = just check container is running
|
|
22
|
+
health_timeout: int = 60
|
|
23
|
+
start: str | None = None # override start command (used by generated Dockerfiles)
|
|
24
|
+
dockerfile: str | None = None # path inside repo; default: ./Dockerfile, else auto-generated
|
|
25
|
+
env_file: str | None = None # path on the host
|
|
26
|
+
token_env: str | None = None # env var holding a git token for private https repos
|
|
27
|
+
secret_env: str | None = None # env var with the webhook secret for this app
|
|
28
|
+
pre_deploy: str | None = None # command run once in the new image before release (e.g. migrations); failure aborts
|
|
29
|
+
domain: str | None = None # set to serve via Caddy with zero-downtime blue/green (needs Caddy)
|
|
30
|
+
notify_env: str | None = None # env var holding a Slack/Discord/Mattermost-style webhook URL
|
|
31
|
+
drain_seconds: int = 5 # proxy mode: wait before removing the old slot
|
|
32
|
+
keep_images: int = 5 # old images kept on disk (current + previous are always kept)
|
|
33
|
+
build_args: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Config:
|
|
38
|
+
state_dir: Path
|
|
39
|
+
listen_host: str
|
|
40
|
+
listen_port: int
|
|
41
|
+
caddy_admin: str
|
|
42
|
+
apps: dict[str, App]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load(path: str | Path) -> Config:
|
|
46
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
47
|
+
server = data.get("server", {})
|
|
48
|
+
apps: dict[str, App] = {}
|
|
49
|
+
for raw in data.get("app", []):
|
|
50
|
+
app = App(**raw)
|
|
51
|
+
if not NAME_RE.match(app.name):
|
|
52
|
+
raise ValueError(f"invalid app name {app.name!r} (use a-z 0-9 _ . -)")
|
|
53
|
+
if app.domain and not DOMAIN_RE.match(app.domain):
|
|
54
|
+
raise ValueError(f"invalid domain {app.domain!r}")
|
|
55
|
+
if app.name in apps:
|
|
56
|
+
raise ValueError(f"duplicate app {app.name!r}")
|
|
57
|
+
apps[app.name] = app
|
|
58
|
+
if not apps:
|
|
59
|
+
raise ValueError("no [[app]] entries in config")
|
|
60
|
+
return Config(
|
|
61
|
+
# relative state_dir is relative to the config file, not the current directory
|
|
62
|
+
state_dir=(Path(path).resolve().parent / server.get("state_dir", ".shipit")).resolve(),
|
|
63
|
+
listen_host=server.get("host", "0.0.0.0"),
|
|
64
|
+
listen_port=int(server.get("port", 9000)),
|
|
65
|
+
caddy_admin=server.get("caddy_admin", "http://127.0.0.1:2019"),
|
|
66
|
+
apps=apps,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LiveConfig:
|
|
71
|
+
"""Re-reads the config file when it changes, so `shipit up` can add apps while `serve` runs."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, path: str | Path):
|
|
74
|
+
self.path = Path(path)
|
|
75
|
+
self._mtime: int | None = None
|
|
76
|
+
self._cfg: Config | None = None
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
self.get()
|
|
79
|
+
|
|
80
|
+
def get(self) -> Config:
|
|
81
|
+
with self._lock:
|
|
82
|
+
try:
|
|
83
|
+
mtime = self.path.stat().st_mtime_ns
|
|
84
|
+
if mtime != self._mtime:
|
|
85
|
+
self._cfg = load(self.path)
|
|
86
|
+
self._mtime = mtime
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
if self._cfg is None:
|
|
89
|
+
raise # first load must succeed; later bad edits keep the last good config
|
|
90
|
+
return self._cfg
|
shipit/deploy.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Build and run an app with Docker, health-check it, roll back on failure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse, urlunparse
|
|
12
|
+
|
|
13
|
+
from . import proxy
|
|
14
|
+
from .config import App, Config
|
|
15
|
+
from .detect import detect
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def log(msg: str) -> None:
|
|
19
|
+
print(f"[shipit] {msg}", flush=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
|
23
|
+
safe = " ".join(cmd)
|
|
24
|
+
log(f"$ {safe[:200]}")
|
|
25
|
+
return subprocess.run(cmd, cwd=cwd, check=check, text=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _out(cmd: list[str], cwd: Path | None = None) -> str:
|
|
29
|
+
return subprocess.run(cmd, cwd=cwd, check=True, text=True, capture_output=True).stdout.strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _auth_url(app: App) -> str:
|
|
33
|
+
"""Inject a token into an https repo URL without writing it to disk or logs."""
|
|
34
|
+
token = os.environ.get(app.token_env) if app.token_env else None
|
|
35
|
+
u = urlparse(app.repo)
|
|
36
|
+
if token and u.scheme in ("http", "https"):
|
|
37
|
+
return urlunparse(u._replace(netloc=f"x-access-token:{token}@{u.hostname}" + (f":{u.port}" if u.port else "")))
|
|
38
|
+
return app.repo
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Deployer:
|
|
42
|
+
def __init__(self, app: App, state_dir: Path, cfg: Config | None = None):
|
|
43
|
+
self.app = app
|
|
44
|
+
self.cfg = cfg
|
|
45
|
+
self.state_dir = state_dir
|
|
46
|
+
self._port: int | None = None # container port of the image being built
|
|
47
|
+
self.dir = state_dir / app.name
|
|
48
|
+
self.src = self.dir / "src"
|
|
49
|
+
self.state_file = self.dir / "state.json"
|
|
50
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# ---- state -------------------------------------------------------
|
|
53
|
+
def state(self) -> dict:
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(self.state_file.read_text())
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
def _save(self, **kw) -> None:
|
|
60
|
+
s = self.state()
|
|
61
|
+
s.update(kw)
|
|
62
|
+
self.state_file.write_text(json.dumps(s, indent=2))
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def container_port(self) -> int:
|
|
66
|
+
"""Port the app listens on inside the container (generated Dockerfiles may differ from config)."""
|
|
67
|
+
return self._port or self.state().get("container_port") or self.app.port
|
|
68
|
+
|
|
69
|
+
# ---- steps -------------------------------------------------------
|
|
70
|
+
def sync(self) -> str:
|
|
71
|
+
a = self.app
|
|
72
|
+
url = _auth_url(a)
|
|
73
|
+
if (self.src / ".git").exists():
|
|
74
|
+
run(["git", "remote", "set-url", "origin", url], cwd=self.src)
|
|
75
|
+
run(["git", "fetch", "--depth", "1", "origin", a.branch], cwd=self.src)
|
|
76
|
+
run(["git", "reset", "--hard", "FETCH_HEAD"], cwd=self.src)
|
|
77
|
+
run(["git", "clean", "-fdx"], cwd=self.src)
|
|
78
|
+
else:
|
|
79
|
+
run(["git", "clone", "--depth", "1", "-b", a.branch, url, str(self.src)])
|
|
80
|
+
run(["git", "remote", "set-url", "origin", a.repo], cwd=self.src) # drop token from .git/config
|
|
81
|
+
return _out(["git", "rev-parse", "--short=12", "HEAD"], cwd=self.src)
|
|
82
|
+
|
|
83
|
+
def build(self, sha: str) -> str:
|
|
84
|
+
a = self.app
|
|
85
|
+
image = f"shipit/{a.name}:{sha}"
|
|
86
|
+
dockerfile = self.src / (a.dockerfile or "Dockerfile")
|
|
87
|
+
self._port = a.port # a repo-provided Dockerfile listens where the config says
|
|
88
|
+
generated = not dockerfile.exists()
|
|
89
|
+
if generated:
|
|
90
|
+
d = detect(self.src, a.port, a.start)
|
|
91
|
+
self._port = d.port
|
|
92
|
+
log(f"detected {d.lang}; generating Dockerfile")
|
|
93
|
+
dockerfile = self.dir / "Dockerfile.generated"
|
|
94
|
+
dockerfile.write_text(d.dockerfile)
|
|
95
|
+
cmd = ["docker", "build", "-f", str(dockerfile), "-t", image]
|
|
96
|
+
for k, v in a.build_args.items():
|
|
97
|
+
cmd += ["--build-arg", f"{k}={v}"]
|
|
98
|
+
run(cmd + [str(self.src)])
|
|
99
|
+
if not generated:
|
|
100
|
+
exposed = self._exposed_ports(image)
|
|
101
|
+
if len(exposed) == 1: # zero-config: follow the Dockerfile's EXPOSE
|
|
102
|
+
self._port = exposed[0]
|
|
103
|
+
return image
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _exposed_ports(image: str) -> list[int]:
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(_out(["docker", "image", "inspect", "-f", "{{json .Config.ExposedPorts}}", image]))
|
|
109
|
+
except (subprocess.CalledProcessError, ValueError):
|
|
110
|
+
return []
|
|
111
|
+
return sorted(int(k.split("/")[0]) for k in (data or {}))
|
|
112
|
+
|
|
113
|
+
def _run_container(self, name: str, image: str, publish: str, restart: str = "unless-stopped") -> None:
|
|
114
|
+
a = self.app
|
|
115
|
+
run(["docker", "rm", "-f", name], check=False)
|
|
116
|
+
cmd = ["docker", "run", "-d", "--name", name, "--restart", restart,
|
|
117
|
+
"-p", publish, "--label", "shipit=1"]
|
|
118
|
+
if a.env_file:
|
|
119
|
+
cmd += ["--env-file", a.env_file]
|
|
120
|
+
run(cmd + [image])
|
|
121
|
+
|
|
122
|
+
def _pre_deploy(self, image: str) -> bool:
|
|
123
|
+
a = self.app
|
|
124
|
+
if not a.pre_deploy:
|
|
125
|
+
return True
|
|
126
|
+
cmd = ["docker", "run", "--rm"] + (["--env-file", a.env_file] if a.env_file else [])
|
|
127
|
+
ok = run(cmd + [image, "sh", "-c", a.pre_deploy], check=False).returncode == 0
|
|
128
|
+
if not ok:
|
|
129
|
+
log("pre_deploy command failed; release aborted")
|
|
130
|
+
self._record("pre-deploy-failed", image)
|
|
131
|
+
self.notify(f"❌ {a.name}: pre_deploy failed for {image}. The live version is unchanged.")
|
|
132
|
+
return ok
|
|
133
|
+
|
|
134
|
+
def start(self, image: str) -> None:
|
|
135
|
+
a = self.app
|
|
136
|
+
self._run_container(a.name, image, f"{a.host_port}:{self.container_port}")
|
|
137
|
+
|
|
138
|
+
def _running(self, name: str) -> bool:
|
|
139
|
+
r = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", name],
|
|
140
|
+
capture_output=True, text=True)
|
|
141
|
+
return r.stdout.strip() == "true"
|
|
142
|
+
|
|
143
|
+
def healthy(self, name: str | None = None, host_port: int | None = None) -> bool:
|
|
144
|
+
a = self.app
|
|
145
|
+
name = name or a.name
|
|
146
|
+
host_port = host_port or a.host_port
|
|
147
|
+
deadline = time.time() + a.health_timeout
|
|
148
|
+
while time.time() < deadline:
|
|
149
|
+
if not self._running(name):
|
|
150
|
+
return False
|
|
151
|
+
if not a.health_path:
|
|
152
|
+
time.sleep(5) # no HTTP check: require it to survive a few seconds
|
|
153
|
+
return self._running(name)
|
|
154
|
+
try:
|
|
155
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{host_port}{a.health_path}", timeout=3) as r:
|
|
156
|
+
if r.status < 500:
|
|
157
|
+
return True
|
|
158
|
+
except urllib.error.HTTPError as e:
|
|
159
|
+
if e.code < 500:
|
|
160
|
+
return True
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
time.sleep(2)
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
# ---- history / notify / prune -----------------------------------
|
|
167
|
+
def _record(self, status: str, image: str) -> None:
|
|
168
|
+
hist = self.state().get("history", [])
|
|
169
|
+
hist.append({"time": time.strftime("%Y-%m-%d %H:%M:%S"), "image": image, "status": status})
|
|
170
|
+
self._save(history=hist[-20:])
|
|
171
|
+
|
|
172
|
+
def notify(self, text: str) -> None:
|
|
173
|
+
env = self.app.notify_env
|
|
174
|
+
url = os.environ.get(env) if env else None
|
|
175
|
+
if not url or urlparse(url).scheme not in ("http", "https"):
|
|
176
|
+
return
|
|
177
|
+
# "text" works for Slack/Mattermost, "content" for Discord.
|
|
178
|
+
body = json.dumps({"text": text, "content": text}).encode()
|
|
179
|
+
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"})
|
|
180
|
+
try:
|
|
181
|
+
urllib.request.urlopen(req, timeout=10).close()
|
|
182
|
+
except OSError as e:
|
|
183
|
+
log(f"notify failed: {e}")
|
|
184
|
+
|
|
185
|
+
def prune(self) -> None:
|
|
186
|
+
s = self.state()
|
|
187
|
+
keep = {i for i in (s.get("current"), s.get("previous")) if i}
|
|
188
|
+
out = subprocess.run(["docker", "images", f"shipit/{self.app.name}", "--format", "{{.Repository}}:{{.Tag}}"],
|
|
189
|
+
capture_output=True, text=True).stdout.split()
|
|
190
|
+
for image in out[max(self.app.keep_images, 0):]: # docker lists newest first
|
|
191
|
+
if image not in keep:
|
|
192
|
+
run(["docker", "rmi", image], check=False)
|
|
193
|
+
|
|
194
|
+
# ---- zero-downtime release through Caddy ------------------------
|
|
195
|
+
def _all_states(self) -> dict[str, dict]:
|
|
196
|
+
return {n: Deployer(a, self.state_dir).state() for n, a in self.cfg.apps.items()}
|
|
197
|
+
|
|
198
|
+
def _release_proxy(self, image: str, prev: str | None) -> bool:
|
|
199
|
+
a = self.app
|
|
200
|
+
if self.cfg is None:
|
|
201
|
+
raise ValueError("`domain` needs the full config; run via the shipit CLI")
|
|
202
|
+
old = self.state()
|
|
203
|
+
old_slot = old.get("slot")
|
|
204
|
+
new_slot = "green" if old_slot == "blue" else "blue"
|
|
205
|
+
port = proxy.slot_port(a.host_port, new_slot)
|
|
206
|
+
name = f"{a.name}-{new_slot}"
|
|
207
|
+
self._run_container(name, image, f"127.0.0.1:{port}:{self.container_port}")
|
|
208
|
+
if not self.healthy(name, port):
|
|
209
|
+
log(f"health check FAILED for {image}; live version untouched")
|
|
210
|
+
run(["docker", "logs", "--tail", "40", name], check=False)
|
|
211
|
+
run(["docker", "rm", "-f", name], check=False)
|
|
212
|
+
self._record("failed", image)
|
|
213
|
+
self.notify(f"❌ {a.name}: {image} failed its health check. The live version is unchanged.")
|
|
214
|
+
return False
|
|
215
|
+
self._save(slot=new_slot, port=port, current=image, previous=prev, container_port=self.container_port)
|
|
216
|
+
try:
|
|
217
|
+
proxy.sync(self.cfg, self._all_states())
|
|
218
|
+
except OSError as e:
|
|
219
|
+
log(f"Caddy update failed ({e}); keeping the old version")
|
|
220
|
+
self._save(**{k: old.get(k) for k in ("slot", "port", "current", "previous")})
|
|
221
|
+
run(["docker", "rm", "-f", name], check=False)
|
|
222
|
+
self._record("proxy-failed", image)
|
|
223
|
+
self.notify(f"❌ {a.name}: could not update Caddy ({e}). The live version is unchanged.")
|
|
224
|
+
return False
|
|
225
|
+
if old_slot:
|
|
226
|
+
time.sleep(a.drain_seconds) # let in-flight requests finish
|
|
227
|
+
run(["docker", "rm", "-f", f"{a.name}-{old_slot}"], check=False)
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
# ---- public ------------------------------------------------------
|
|
231
|
+
def deploy(self) -> bool:
|
|
232
|
+
a = self.app
|
|
233
|
+
log(f"deploying {a.name} ({a.repo}@{a.branch})")
|
|
234
|
+
try:
|
|
235
|
+
sha = self.sync()
|
|
236
|
+
image = self.build(sha)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
self._record("build-failed", "-")
|
|
239
|
+
self.notify(f"❌ {a.name}: build failed ({e})")
|
|
240
|
+
raise
|
|
241
|
+
if not self._pre_deploy(image):
|
|
242
|
+
return False
|
|
243
|
+
prev = self.state().get("current")
|
|
244
|
+
if a.domain:
|
|
245
|
+
ok = self._release_proxy(image, prev)
|
|
246
|
+
if ok:
|
|
247
|
+
self._record("deployed", image)
|
|
248
|
+
self.notify(f"✅ {a.name} deployed: {image} (zero-downtime)")
|
|
249
|
+
self.prune()
|
|
250
|
+
log(f"OK {a.name} -> {image}")
|
|
251
|
+
return ok
|
|
252
|
+
|
|
253
|
+
# 1. Start the new version beside the live one on a random local port and test it.
|
|
254
|
+
cand = f"{a.name}-next"
|
|
255
|
+
self._run_container(cand, image, f"127.0.0.1::{self.container_port}", restart="no")
|
|
256
|
+
try:
|
|
257
|
+
port = int(_out(["docker", "port", cand, f"{self.container_port}/tcp"]).splitlines()[0].rsplit(":", 1)[1])
|
|
258
|
+
ok = self.healthy(cand, port)
|
|
259
|
+
except (subprocess.CalledProcessError, IndexError, ValueError):
|
|
260
|
+
ok = False
|
|
261
|
+
if not ok:
|
|
262
|
+
log(f"health check FAILED for {image}; live version untouched")
|
|
263
|
+
run(["docker", "logs", "--tail", "40", cand], check=False)
|
|
264
|
+
run(["docker", "rm", "-f", cand], check=False)
|
|
265
|
+
self._record("failed", image)
|
|
266
|
+
self.notify(f"❌ {a.name}: {image} failed its health check. The live version is unchanged.")
|
|
267
|
+
return False
|
|
268
|
+
run(["docker", "rm", "-f", cand], check=False)
|
|
269
|
+
|
|
270
|
+
# 2. Swap (a brief gap while the port is handed over), then verify again.
|
|
271
|
+
self.start(image)
|
|
272
|
+
if self.healthy():
|
|
273
|
+
self._save(current=image, previous=prev, container_port=self.container_port)
|
|
274
|
+
self._record("deployed", image)
|
|
275
|
+
self.notify(f"✅ {a.name} deployed: {image}")
|
|
276
|
+
self.prune()
|
|
277
|
+
log(f"OK {a.name} -> {image}")
|
|
278
|
+
return True
|
|
279
|
+
log("health check failed after swap")
|
|
280
|
+
run(["docker", "logs", "--tail", "40", a.name], check=False)
|
|
281
|
+
if prev:
|
|
282
|
+
log(f"rolling back to {prev}")
|
|
283
|
+
self.start(prev)
|
|
284
|
+
else:
|
|
285
|
+
run(["docker", "rm", "-f", a.name], check=False)
|
|
286
|
+
self._record("failed-after-swap", image)
|
|
287
|
+
self.notify(f"❌ {a.name}: {image} failed after swap; rolled back to {prev or 'nothing'}")
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def rollback(self) -> bool:
|
|
291
|
+
prev = self.state().get("previous")
|
|
292
|
+
if not prev:
|
|
293
|
+
log("no previous release recorded")
|
|
294
|
+
return False
|
|
295
|
+
cur = self.state().get("current")
|
|
296
|
+
if self.app.domain:
|
|
297
|
+
ok = self._release_proxy(prev, cur)
|
|
298
|
+
if ok:
|
|
299
|
+
self._record("rolled-back", prev)
|
|
300
|
+
self.notify(f"↩️ {self.app.name} rolled back to {prev}")
|
|
301
|
+
return ok
|
|
302
|
+
self.start(prev)
|
|
303
|
+
if self.healthy():
|
|
304
|
+
self._save(current=prev, previous=cur)
|
|
305
|
+
self._record("rolled-back", prev)
|
|
306
|
+
self.notify(f"↩️ {self.app.name} rolled back to {prev}")
|
|
307
|
+
log(f"rolled back to {prev}")
|
|
308
|
+
return True
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
def _live_name(self) -> str:
|
|
312
|
+
slot = self.state().get("slot")
|
|
313
|
+
return f"{self.app.name}-{slot}" if self.app.domain and slot else self.app.name
|
|
314
|
+
|
|
315
|
+
def status(self) -> str:
|
|
316
|
+
s = self.state()
|
|
317
|
+
lines = [f"app: {self.app.name}",
|
|
318
|
+
f"running: {'yes' if self._running(self._live_name()) else 'no'}",
|
|
319
|
+
f"current: {s.get('current', '-')}",
|
|
320
|
+
f"previous: {s.get('previous', '-')}",
|
|
321
|
+
"history:"]
|
|
322
|
+
lines += [f" {h['time']} {h['status']:<18} {h['image']}" for h in s.get("history", [])[-5:]]
|
|
323
|
+
return "\n".join(lines)
|
|
324
|
+
|
|
325
|
+
def logs(self, tail: int = 100, follow: bool = False) -> None:
|
|
326
|
+
cmd = ["docker", "logs", "--tail", str(tail)] + (["-f"] if follow else []) + [self._live_name()]
|
|
327
|
+
run(cmd, check=False)
|