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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
shipit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)