portainer-redeploy-cli 0.1.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.
@@ -0,0 +1,972 @@
1
+ """Redeploy a Portainer stack.
2
+ The tool first checks that the new version exists. Then it watches the rollout.
3
+
4
+ Usage:
5
+ redeploy [options] [INSTANCE] STACK [VERSION]
6
+ portainer-redeploy [options] [INSTANCE] STACK [VERSION]
7
+
8
+ The config file is ~/.config/portainer-redeploy.json.
9
+ To use a different file, set $PORTAINER_REDEPLOY_CONFIG to its path.
10
+
11
+ In the config file, map each instance name to its URL and API key:
12
+ {
13
+ "prod": {
14
+ "url": "https://portainer.example.com",
15
+ "api_key_file": "/run/secrets/portainer_prod"
16
+ }
17
+ }
18
+
19
+ Give each value inline ("url", "api_key") or in a file ("url_file", "api_key_file").
20
+ {"insecure": true} skips TLS certificate verification.
21
+ {"tag_var": "APP_VERSION"} changes the stack variable that the tool sets to VERSION.
22
+ The default variable is IMAGE_TAG.
23
+
24
+ Environment variables can also configure a single instance, but you cannot give it a name.
25
+ Environment variables:
26
+ PORTAINER_URL Portainer base URL.
27
+ PORTAINER_API_KEY API key (Portainer > My account > Access tokens).
28
+ PORTAINER_INSECURE Set to 1 to skip TLS certificate verification.
29
+ PORTAINER_TAG_VAR Stack variable that the tool sets to VERSION (default: IMAGE_TAG).
30
+ """
31
+
32
+ import argparse
33
+ import base64
34
+ import contextlib
35
+ import difflib
36
+ import json
37
+ import os
38
+ import re
39
+ import signal
40
+ import ssl
41
+ import sys
42
+ import threading
43
+ import time
44
+ from collections import Counter, namedtuple
45
+
46
+ import httpx
47
+ import yaml
48
+ from expandvars import ExpandvarsException, expand
49
+ from rich.console import Console, Group
50
+ from rich.live import Live
51
+ from rich.padding import Padding
52
+ from rich.progress_bar import ProgressBar
53
+ from rich.spinner import Spinner
54
+ from rich.table import Table
55
+ from rich.text import Text
56
+
57
+ from . import __version__
58
+
59
+ # ── config ────────────────────────────────────────────────────────────────────
60
+
61
+ CONFIG = os.environ.get("PORTAINER_REDEPLOY_CONFIG") or os.path.join(
62
+ os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
63
+ "portainer-redeploy.json",
64
+ )
65
+ HOME = os.path.expanduser("~")
66
+ CONFIG_SHOWN = "~" + CONFIG[len(HOME) :] if CONFIG.startswith(HOME + os.sep) else CONFIG
67
+ EXAMPLE = '{ "prod": { "url": "https://portainer.example.com", "api_key_file": "/run/secrets/portainer" } }'
68
+ DEFAULT_TAG_VAR = "IMAGE_TAG"
69
+ SETUP_HINT = f"Set PORTAINER_URL and PORTAINER_API_KEY, or describe your instances in {CONFIG_SHOWN}. For example:\n {EXAMPLE}"
70
+
71
+ POLL_INTERVAL = 2 # seconds between looks at the stack's containers
72
+ STABLE_POLLS = 4 # looks in a row without changes for the rollout to count as settled
73
+ MIN_WATCH = 10 # seconds to watch after the deploy is done (swarm rolls out lazily)
74
+
75
+ # Stack.Status values. "Deploying" and "error" only exist since Portainer 2.36,
76
+ # which deploys in the background instead of answering once it's done.
77
+ DEPLOYING, DEPLOY_ERROR = 3, 4
78
+
79
+ Instance = namedtuple("Instance", "name url api_key insecure tag_var")
80
+ Row = namedtuple("Row", "kind service replaced total running label")
81
+
82
+ # ── helpers ───────────────────────────────────────────────────────────────────
83
+
84
+
85
+ def die(msg):
86
+ error = Text.assemble(("Error:", "red"), f" {msg}")
87
+ Console(stderr=True).print(error, soft_wrap=True)
88
+ sys.exit(1)
89
+
90
+
91
+ def fmt_dur(s):
92
+ s = int(s)
93
+ return f"{s // 60}m {s % 60:02d}s" if s >= 60 else f"{s}s"
94
+
95
+
96
+ def join_names(names, word="and"):
97
+ *rest, last = names
98
+ return f"{', '.join(rest)} {word} {last}" if rest else last
99
+
100
+
101
+ class Failure(Exception):
102
+ """Ends the run with a ✗ line, and any details under it."""
103
+
104
+ def __init__(self, msg, details="", status=0):
105
+ super().__init__(msg)
106
+ self.details, self.status = details, status
107
+
108
+
109
+ class ApiError(Failure):
110
+ """A Portainer API call that failed; status is the HTTP one, or 0 without an answer."""
111
+
112
+
113
+ class Job(threading.Thread):
114
+ """Runs fn(*args) in the background, keeping what it raises in error. On a daemon
115
+ thread, which - unlike an executor's - Ctrl-C needn't wait for."""
116
+
117
+ def __init__(self, fn, *args, **kwargs):
118
+ super().__init__(target=fn, args=args, kwargs=kwargs, daemon=True)
119
+ self.error = None
120
+ self.start()
121
+
122
+ def run(self):
123
+ try:
124
+ super().run()
125
+ except Exception as e:
126
+ self.error = e
127
+
128
+
129
+ # ── setup ─────────────────────────────────────────────────────────────────────
130
+
131
+
132
+ def load_config():
133
+ try:
134
+ with open(CONFIG) as f:
135
+ config = json.load(f)
136
+ except FileNotFoundError:
137
+ return {}
138
+ except (OSError, ValueError) as e:
139
+ die(f"Cannot read {CONFIG}: {e}")
140
+ valid = isinstance(config, dict) and all(
141
+ isinstance(v, dict) for v in config.values()
142
+ )
143
+ if not valid:
144
+ die(
145
+ f"{CONFIG} should map each instance name to its settings. For example:\n {EXAMPLE}"
146
+ )
147
+ return config
148
+
149
+
150
+ def setting(entry, key, instance):
151
+ """A config value, given inline ("api_key") or as a file to read it from ("api_key_file")."""
152
+ path = entry.get(f"{key}_file")
153
+ if entry.get(key) or not path:
154
+ return str(entry.get(key) or "").strip()
155
+ try:
156
+ with open(os.path.expanduser(path)) as f:
157
+ return f.read().strip()
158
+ except OSError as e:
159
+ die(f'Cannot read the {key} of instance "{instance}" from {path}: {e.strerror}')
160
+
161
+
162
+ def base_url(url):
163
+ url = url.strip().rstrip("/")
164
+ url = url if "://" in url else f"https://{url}"
165
+ try:
166
+ httpx.URL(url)
167
+ except httpx.InvalidURL as e:
168
+ die(f"{url} is not a valid URL ({e})")
169
+ return url
170
+
171
+
172
+ def resolve_instance(name, config, insecure):
173
+ if name is None and os.environ.get("PORTAINER_URL"):
174
+ url = base_url(os.environ["PORTAINER_URL"])
175
+ key = os.environ.get("PORTAINER_API_KEY", "").strip()
176
+ if not key:
177
+ die("PORTAINER_URL is set, but PORTAINER_API_KEY is not set.")
178
+ on = os.environ.get("PORTAINER_INSECURE", "").lower() not in (
179
+ "",
180
+ "0",
181
+ "false",
182
+ "no",
183
+ )
184
+ tag_var = os.environ.get("PORTAINER_TAG_VAR", "").strip() or DEFAULT_TAG_VAR
185
+ return Instance(httpx.URL(url).host or url, url, key, insecure or on, tag_var)
186
+ known = ", ".join(sorted(config))
187
+ if name is None and not config:
188
+ die(f"No Portainer instance is configured.\n{SETUP_HINT}")
189
+ if name is None and len(config) > 1:
190
+ die(
191
+ f"More than one Portainer instance is configured. Give one of these instance names: {known}."
192
+ )
193
+ name = next(iter(config)) if name is None else name
194
+ if name not in config and not config:
195
+ die(f'Unknown instance "{name}". There is no {CONFIG_SHOWN}.\n{SETUP_HINT}')
196
+ if name not in config:
197
+ die(f'Unknown instance "{name}". Known instances: {known}.')
198
+ entry = config[name]
199
+ url, key = setting(entry, "url", name), setting(entry, "api_key", name)
200
+ if not url or not key:
201
+ die(
202
+ f'Instance "{name}" in {CONFIG_SHOWN} needs a "url" (or "url_file") and an "api_key" (or "api_key_file").'
203
+ )
204
+ tag_var = entry.get("tag_var") or DEFAULT_TAG_VAR
205
+ if not isinstance(tag_var, str):
206
+ die(
207
+ f'The "tag_var" of instance "{name}" in {CONFIG_SHOWN} must be a variable name in quotes, for example "IMAGE_TAG".'
208
+ )
209
+ return Instance(
210
+ name, base_url(url), key, insecure or bool(entry.get("insecure")), tag_var
211
+ )
212
+
213
+
214
+ # ── portainer api ─────────────────────────────────────────────────────────────
215
+
216
+
217
+ def unreachable(url, error):
218
+ if isinstance(error, httpx.TimeoutException):
219
+ return f"{url} did not answer in time"
220
+ while error.__context__: # down to the socket or TLS error under httpx's
221
+ error = error.__context__
222
+ if isinstance(error, ssl.SSLCertVerificationError):
223
+ return (
224
+ f"the TLS certificate of {url} is not trusted ({error.verify_message}) - "
225
+ f'fix the certificate, or skip the check with -k or "insecure": true'
226
+ )
227
+ return f"could not reach {url} ({getattr(error, 'strerror', None) or error})"
228
+
229
+
230
+ def http_error(resp, url):
231
+ try:
232
+ body = dict(resp.json())
233
+ except (ValueError, TypeError):
234
+ body = {}
235
+ status = f"HTTP {resp.status_code} {resp.reason_phrase}".strip()
236
+ msg = str(body.get("message") or body.get("error") or status)
237
+ details = str(body.get("details") or "")
238
+ if resp.status_code == 401:
239
+ msg = f"Portainer rejected the API key ({msg})"
240
+ elif resp.is_redirect:
241
+ # httpx doesn't follow redirects, which keeps the API key from going along.
242
+ target = str(resp.url.join(resp.headers["Location"]))
243
+ target = re.sub(r"/api(/.*)?$", "", target)
244
+ msg = f"{url} redirects to {target} - use {target} as the instance URL"
245
+ return ApiError(msg, "" if details == msg else details, resp.status_code)
246
+
247
+
248
+ class Portainer:
249
+ def __init__(self, instance):
250
+ self.url = instance.url
251
+ self.http = httpx.Client(
252
+ base_url=f"{instance.url}/api",
253
+ headers={"X-API-Key": instance.api_key},
254
+ # The system's CA certificates rather than httpx's own, so private CAs work.
255
+ verify=False if instance.insecure else ssl.create_default_context(),
256
+ timeout=30,
257
+ )
258
+
259
+ @contextlib.contextmanager
260
+ def stream(self, method, path, **kwargs):
261
+ """Send a request to /api<path>; yields the response, unread."""
262
+ try:
263
+ with self.http.stream(method, path, **kwargs) as resp:
264
+ if not resp.is_success:
265
+ resp.read()
266
+ raise http_error(resp, self.url)
267
+ yield resp
268
+ except httpx.HTTPError as e:
269
+ raise ApiError(unreachable(self.url, e)) from None
270
+
271
+ def request(self, method, path, **kwargs):
272
+ """Call /api<path> and return the JSON reply."""
273
+ with self.stream(method, path, **kwargs) as resp:
274
+ data = resp.read()
275
+ try:
276
+ return json.loads(data) if data.strip() else None
277
+ except ValueError:
278
+ raise ApiError(
279
+ f"{self.url} sent a reply that is not JSON - check that this is the Portainer URL"
280
+ ) from None
281
+
282
+ def get(self, path):
283
+ return self.request("GET", path)
284
+
285
+
286
+ # ── stack file ────────────────────────────────────────────────────────────────
287
+
288
+
289
+ class ComposeLoader(yaml.SafeLoader):
290
+ """SafeLoader that also takes compose's own tags (!reset, !override)."""
291
+
292
+
293
+ def _construct_tagged(loader, suffix, node):
294
+ if isinstance(node, yaml.MappingNode):
295
+ return loader.construct_mapping(node, deep=True)
296
+ if isinstance(node, yaml.SequenceNode):
297
+ return loader.construct_sequence(node, deep=True)
298
+ return loader.construct_scalar(node)
299
+
300
+
301
+ ComposeLoader.add_multi_constructor("!", _construct_tagged)
302
+
303
+
304
+ def tagged_images(stack_file, env, tag_var):
305
+ """The images in the stack file that use tag_var, with every variable filled in from
306
+ the stack env (anchors and merge keys resolved, so tags like "${IMAGE_TAG}-nginx" work).
307
+ """
308
+ try:
309
+ services = (yaml.load(stack_file, Loader=ComposeLoader) or {}).get("services")
310
+ except yaml.YAMLError as e:
311
+ raise Failure("Could not parse the stack file", str(e)) from None
312
+ uses_var, refs = re.compile(r"\$\{?" + re.escape(tag_var) + r"\b"), set()
313
+ for service in (services or {}).values():
314
+ image = (service or {}).get("image")
315
+ if isinstance(image, str) and uses_var.search(image):
316
+ try:
317
+ ref = expand(image, environ=env)
318
+ except ExpandvarsException as e:
319
+ raise Failure(
320
+ f"Could not expand the variables in image {image}", str(e)
321
+ ) from None
322
+ # Untagged means :latest to compose, but to the pull API it means every tag.
323
+ tagged = "@" in ref or ":" in ref.rsplit("/", 1)[-1]
324
+ refs.add(ref if tagged else f"{ref}:latest")
325
+ return sorted(refs)
326
+
327
+
328
+ # ── images ────────────────────────────────────────────────────────────────────
329
+
330
+ DOCKER_HUB = (
331
+ "docker.io",
332
+ "index.docker.io",
333
+ "registry-1.docker.io",
334
+ "registry.hub.docker.com",
335
+ )
336
+ LAYER_DONE = ("Pull complete", "Already exists")
337
+
338
+
339
+ def repository(ref):
340
+ """Where an image lives, as registry host/path - short names are on Docker Hub."""
341
+ repo = re.sub(r":[^:/]*$", "", ref.split("@")[0])
342
+ first, _, rest = repo.partition("/")
343
+ if not rest:
344
+ return f"docker.io/library/{repo}"
345
+ if first in DOCKER_HUB:
346
+ return f"docker.io/{rest}"
347
+ if "." not in first and ":" not in first and first != "localhost":
348
+ return f"docker.io/{repo}"
349
+ return repo
350
+
351
+
352
+ def registry_for(ref, registries):
353
+ """Id of the Portainer registry whose credentials should pull ref: the longest matching
354
+ URL prefix wins, where a GitLab registry's own prefix includes its project path (it can
355
+ still match on just its host, but loses ties to a registry whose own URL that is).
356
+ """
357
+ repo, best = repository(ref), ((0, False), None)
358
+ for reg in registries:
359
+ url = re.sub(r"^[a-z]+://", "", reg.get("URL") or "").rstrip("/")
360
+ host, _, path = url.partition("/")
361
+ if host in DOCKER_HUB:
362
+ url = "/".join(filter(None, ("docker.io", path)))
363
+ project = (reg.get("Gitlab") or {}).get("ProjectPath")
364
+ own = f"{url}/{project}" if project else url
365
+ for key in {own, url} if reg.get("Authentication") else ():
366
+ rank = (len(key), key == own)
367
+ if key and (repo == key or repo.startswith(key + "/")) and rank > best[0]:
368
+ best = (rank, reg.get("Id"))
369
+ return best[1]
370
+
371
+
372
+ def pull(api, endpoint_id, ref, registry_id, layers):
373
+ """Pull ref onto the environment through Portainer's Docker API, keeping each layer's
374
+ latest (status, progressDetail) in layers as the progress streams in. Returns Docker's
375
+ closing status ("Downloaded newer image", ...); raises ApiError when the pull fails.
376
+ """
377
+ # Portainer swaps a registryId for the credentials it has stored for that registry -
378
+ # the same ones it deploys the stack with - so private registries work without this
379
+ # tool knowing any registry passwords.
380
+ auth = base64.b64encode(json.dumps({"registryId": registry_id}).encode()).decode()
381
+ headers = {} if registry_id is None else {"X-Registry-Auth": auth}
382
+ path, final = f"/endpoints/{endpoint_id}/docker/images/create", None
383
+ query = {"fromImage": ref}
384
+ with api.stream("POST", path, params=query, headers=headers, timeout=300) as resp:
385
+ try:
386
+ for line in resp.iter_lines():
387
+ msg = json.loads(line) if line.strip() else {}
388
+ error = msg.get("error") or msg.get("errorDetail", {}).get("message")
389
+ if error:
390
+ raise ApiError(" ".join(str(error).split()))
391
+ status = msg.get("status") or ""
392
+ if msg.get("id") and "progressDetail" in msg:
393
+ layers[msg["id"]] = (status, msg["progressDetail"] or {})
394
+ elif status.startswith("Status: "):
395
+ final = re.sub(r" for .*$", "", status[len("Status: ") :])
396
+ except (httpx.HTTPError, ValueError) as e:
397
+ raise ApiError(f"the pull stopped before it completed ({e})") from None
398
+ if not final: # only Docker's closing status counts, so a cut-off stream can't pass
399
+ raise ApiError("the pull ended, but Docker did not report success")
400
+ return final
401
+
402
+
403
+ def pull_progress(layers):
404
+ """How far along a pull is, 0-1: downloading counts for 80 % of a layer, extracting the rest."""
405
+ done = 0
406
+ for status, detail in layers:
407
+ total = detail.get("total")
408
+ part = min((detail.get("current") or 0) / total, 1) if total else 0
409
+ if status in LAYER_DONE:
410
+ done += 1
411
+ elif status == "Downloading":
412
+ done += 0.8 * part
413
+ elif status in ("Download complete", "Verifying Checksum"):
414
+ done += 0.8
415
+ elif status == "Extracting":
416
+ done += 0.8 + 0.2 * part
417
+ return done / len(layers) if layers else 0
418
+
419
+
420
+ # ── containers ────────────────────────────────────────────────────────────────
421
+
422
+
423
+ def stack_containers(api, endpoint_id, stack_name):
424
+ """The stack's containers, with their service: compose labels them with the project,
425
+ swarm with the stack namespace (and prefixes its service names with "<stack>_")."""
426
+ name, found = stack_name.lower(), []
427
+ for c in api.get(f"/endpoints/{endpoint_id}/docker/containers/json?all=true"):
428
+ labels = c.get("Labels") or {}
429
+ if labels.get("com.docker.compose.project", "").lower() == name:
430
+ service = labels.get("com.docker.compose.service")
431
+ elif labels.get("com.docker.stack.namespace", "").lower() == name:
432
+ service = labels.get("com.docker.swarm.service.name") or ""
433
+ if service.lower().startswith(name + "_"):
434
+ service = service[len(name) + 1 :]
435
+ else:
436
+ continue
437
+ found.append({**c, "service": service or c["Names"][0].lstrip("/")})
438
+ return found
439
+
440
+
441
+ def classify(c, new, swarm):
442
+ """(kind, label) for a container - or None to leave it out, as swarm keeps the
443
+ containers of replaced tasks around (exited) for history."""
444
+ state, status = c["State"], c["Status"]
445
+ if state == "running":
446
+ if "(unhealthy)" in status:
447
+ return "fail", "unhealthy"
448
+ if "(health: starting)" in status:
449
+ return "busy", "starting"
450
+ return "ok", "healthy" if "(healthy)" in status else "running"
451
+ if swarm and not new:
452
+ return None
453
+ if state == "exited":
454
+ # A one-off job exiting cleanly is fine; a new container failing is not. An old
455
+ # one that had already failed before the redeploy isn't this rollout's doing.
456
+ code = int((re.search(r"\((-?\d+)\)", status) or [0, 0])[1])
457
+ return ("fail" if code and new else "skip"), f"exited ({code})"
458
+ if state in ("created", "restarting", "removing"):
459
+ return "busy", state
460
+ return ("fail", "dead") if state == "dead" else ("skip", state or "unknown")
461
+
462
+
463
+ SEVERITY = ("fail", "busy", "ok", "skip") # a service shows its most pressing state
464
+
465
+
466
+ def service_rows(containers, old_ids, swarm):
467
+ by_service = {}
468
+ for c in containers:
469
+ new = c["Id"] not in old_ids
470
+ if shown := classify(c, new, swarm):
471
+ entry = (*shown, new, c["State"] == "running")
472
+ by_service.setdefault(c["service"], []).append(entry)
473
+ rows = []
474
+ for service, entries in sorted(by_service.items()):
475
+ kinds = {label: kind for kind, label, _, _ in entries}
476
+ counts = Counter(label for _, label, _, _ in entries)
477
+ labels = sorted(counts, key=lambda l: (SEVERITY.index(kinds[l]), -counts[l]))
478
+ label = (
479
+ ", ".join(f"{counts[l]} {l}" for l in labels) if labels[1:] else labels[0]
480
+ )
481
+ kind = min(kinds.values(), key=SEVERITY.index)
482
+ replaced, running = sum(e[2] for e in entries), sum(e[3] for e in entries)
483
+ rows.append(Row(kind, service, replaced, len(entries), running, label))
484
+ return rows
485
+
486
+
487
+ def totals(rows):
488
+ """How many containers have been replaced, how many there are, and how many run."""
489
+ return [sum(getattr(r, f) for r in rows) for f in ("replaced", "total", "running")]
490
+
491
+
492
+ # ── rendering ─────────────────────────────────────────────────────────────────
493
+
494
+ ICONS = {"ok": "✓", "fail": "✗", "busy": "●", "skip": "-", "info": "▶", "stop": "⊘"}
495
+ STYLE = {
496
+ "ok": "green bold",
497
+ "fail": "red bold",
498
+ "busy": "yellow bold",
499
+ "skip": "dim",
500
+ "info": "blue",
501
+ "stop": "dim",
502
+ }
503
+ SPINNER = Spinner("dots")
504
+
505
+
506
+ def spinner():
507
+ """The spinner's current frame, to put where a line's icon goes."""
508
+ return SPINNER.render(time.monotonic()).plain
509
+
510
+
511
+ def item_line(kind, text, *cols, glyph=None, indent=4):
512
+ """✓ text col col (cols are (text, style) pairs)"""
513
+ head = (f"{' ' * indent}{glyph or ICONS[kind]} {text}", STYLE[kind])
514
+ return Text.assemble(head, *((f" {col}", style) for col, style in cols))
515
+
516
+
517
+ def status_line(kind, text, *meta, glyph=None, indent=2):
518
+ """✓ text · meta · meta"""
519
+ meta = ((f"· {m}", "dim") for m in meta)
520
+ return item_line(kind, text, *meta, glyph=glyph, indent=indent)
521
+
522
+
523
+ class Ui(Console):
524
+ """Rich keeps a live view redrawn at the bottom of a terminal; anywhere else (a pipe,
525
+ a CI log) the live view is skipped and only the lines printed for good show up."""
526
+
527
+ def __init__(self):
528
+ super().__init__(highlight=False)
529
+ self.soft_wrap = not self.is_terminal # lines stay whole in a pipe or CI log
530
+ self.blank = True # whether the last line printed for good was empty
531
+
532
+ def details(self, text, limit=15):
533
+ """Detail lines hanging under the line above; long ones wrap on a terminal."""
534
+ lines = [l.rstrip() for l in str(text or "").splitlines() if l.strip()]
535
+ if len(lines) > limit:
536
+ lines = ["…"] + lines[-limit:]
537
+ if self.soft_wrap: # where Padding would cut them off instead
538
+ return [Text(f" {line}", "dim") for line in lines]
539
+ return [Padding(Text("\n".join(lines), "dim"), (0, 0, 0, 5))] if lines else []
540
+
541
+ def say(self, *lines):
542
+ """Print lines for good, above the live view."""
543
+ for line in lines:
544
+ self.print(line)
545
+ self.blank = not str(line).strip()
546
+
547
+ def gap(self):
548
+ if not self.blank:
549
+ self.say("")
550
+
551
+ def live(self, view):
552
+ """Keep view() redrawn at the bottom for the length of a with block."""
553
+ return Live(
554
+ get_renderable=view, console=self, transient=True, refresh_per_second=12
555
+ )
556
+
557
+
558
+ # ── redeploy ──────────────────────────────────────────────────────────────────
559
+
560
+ PHASES = {
561
+ "sending": "redeploying",
562
+ "deploying": "Portainer is deploying the stack",
563
+ "settling": "waiting for the containers to settle",
564
+ }
565
+
566
+
567
+ class Redeploy:
568
+ def __init__(self, ui, api, instance, stack_name, version, args):
569
+ self.ui, self.api, self.instance = ui, api, instance
570
+ self.name, self.version, self.tag_var = stack_name, version, instance.tag_var
571
+ self.environment, self.timeout = args.environment, args.timeout
572
+ self.issued = False # whether Portainer has been told to redeploy
573
+ self.phase = "sending" # how far the redeploy is, as a key of PHASES
574
+ self.rows = [] # the rollout as last seen
575
+
576
+ def wait_for(self, label, fn, *args, fail=None):
577
+ """Run fn(*args) behind a spinner line and return its result."""
578
+ with self.ui.live(lambda: status_line("busy", f"{label}…", glyph=spinner())):
579
+ try:
580
+ return fn(*args)
581
+ except ApiError as e:
582
+ if not fail:
583
+ raise
584
+ raise Failure(fail, f"{e}\n{e.details}".strip()) from None
585
+
586
+ def run(self):
587
+ title = f"{self.name} → {self.version}" if self.version else self.name
588
+ self.ui.rule(characters="═", style="cyan bold")
589
+ header = f" Portainer Redeploy · {title} · {self.instance.name}"
590
+ self.ui.say(Text(header, "cyan bold"), "")
591
+
592
+ label, fail = f"Finding stack {self.name}", "Could not get the list of stacks"
593
+ stack, env_name = self.wait_for(label, self.lookup, fail=fail)
594
+ name, kind = stack["Name"], {1: "swarm", 2: "compose"}.get(stack["Type"])
595
+ if not kind:
596
+ raise Failure(
597
+ f"{name} is not a compose or swarm stack - the tool supports only those two types"
598
+ )
599
+ if stack["Status"] == DEPLOYING:
600
+ raise Failure(
601
+ f"Portainer is already deploying {name} - try again when that deploy is done"
602
+ )
603
+ git = bool(stack.get("GitConfig") or stack.get("WorkflowID"))
604
+ kind = f"{kind} from git" if git else kind
605
+ self.ui.say(status_line("ok", f"Stack {name}", kind, f"environment {env_name}"))
606
+
607
+ env = {pair["name"]: pair["value"] for pair in stack.get("Env") or []}
608
+ if self.version:
609
+ # Added when the stack env doesn't have it yet (say the stack file falls back
610
+ # to a default) - otherwise the version would be silently ignored.
611
+ old, env[self.tag_var] = env.get(self.tag_var), self.version
612
+ change = f"{'(unset)' if old is None else old} → {self.version}"
613
+ change = f"{self.version} (unchanged)" if old == self.version else change
614
+ self.ui.say(status_line("info", f"{self.tag_var} {change}"))
615
+
616
+ # File-based stacks are redeployed by sending their stack file back; git stacks
617
+ # only need it for checking the images.
618
+ stack_file = None
619
+ if self.version or not git:
620
+ label, fail = "Fetching the stack file", "Could not fetch the stack file"
621
+ path = f"/stacks/{stack['Id']}/file"
622
+ reply = self.wait_for(label, self.api.get, path, fail=fail)
623
+ stack_file = (reply or {}).get("StackFileContent")
624
+ if not stack_file:
625
+ raise Failure(f"Portainer sent an empty stack file for {name}")
626
+ if self.version:
627
+ self.check_images(stack, stack_file, env)
628
+ self.deploy(stack, stack_file, env, swarm=stack["Type"] == 1, git=git)
629
+
630
+ def lookup(self):
631
+ """Find the stack, and the name of its environment (runs behind a spinner)."""
632
+ stacks, envs = self.api.get("/stacks"), {}
633
+ with contextlib.suppress(ApiError): # the key may not be allowed to list them
634
+ endpoints = self.api.get("/endpoints?excludeSnapshots=true")
635
+ envs = {e["Id"]: e["Name"] for e in endpoints}
636
+
637
+ def env_of(stack):
638
+ return envs.get(stack["EndpointId"]) or f"#{stack['EndpointId']}"
639
+
640
+ def in_env(stack):
641
+ want = (self.environment or "").lower()
642
+ return want in ("", str(stack["EndpointId"]), env_of(stack).lower())
643
+
644
+ named = [s for s in stacks if s["Name"].lower() == self.name.lower()]
645
+ matches = list(filter(in_env, named))
646
+ if not matches and named:
647
+ raise Failure(
648
+ f'There is no stack named "{self.name}" in environment {self.environment}',
649
+ f"There is a stack with that name in {join_names(sorted(map(env_of, named)))}.",
650
+ )
651
+ if not matches:
652
+ names = sorted({s["Name"] for s in stacks})
653
+ close = difflib.get_close_matches(self.name, names, n=3)
654
+ hint = "The API key does not see any stacks. Check what the user of the API key has access to."
655
+ if names:
656
+ more = ", …" if len(names) > 15 else ""
657
+ hint = f"Stacks on {self.instance.name}: {', '.join(names[:15])}{more}"
658
+ if close:
659
+ hint = f"Did you mean {join_names(close, 'or')}?"
660
+ raise Failure(
661
+ f'There is no stack named "{self.name}" on {self.instance.name}', hint
662
+ )
663
+ if len(matches) > 1:
664
+ raise Failure(
665
+ f'{len(matches)} environments have a stack named "{self.name}" - select the environment with --environment',
666
+ "\n".join(f"{env_of(s)} (#{s['EndpointId']})" for s in matches),
667
+ )
668
+ return matches[0], env_of(matches[0])
669
+
670
+ def check_images(self, stack, stack_file, env):
671
+ """Pull the images that use the tag variable, at the new version, onto the stack's
672
+ environment: a version that doesn't exist then fails here, before the stack is
673
+ touched - and as the redeploy pulls these images anyway, a passing check just
674
+ saves it the download."""
675
+ refs, eid = tagged_images(stack_file, env, self.tag_var), stack["EndpointId"]
676
+ if not refs:
677
+ raise Failure(
678
+ f"No image in {stack['Name']} uses {self.tag_var}, so version {self.version} would have no effect",
679
+ f'If the image tag is in a different variable, put the name of that variable in "tag_var" in {CONFIG_SHOWN} or in PORTAINER_TAG_VAR.',
680
+ )
681
+ # The registries let each pull use the credentials Portainer has for its image.
682
+ # Best-effort: without them the pulls are anonymous, which still works for public images.
683
+ registries, path = None, f"/endpoints/{eid}/registries"
684
+ with contextlib.suppress(ApiError):
685
+ registries = self.wait_for("Listing the registries", self.api.get, path)
686
+
687
+ self.ui.say("", Text(" ── images", "dim"))
688
+ width = max(map(len, refs))
689
+ for ref in refs:
690
+ registry_id = registry_for(ref, registries or [])
691
+ name, layers = ref.ljust(width), {}
692
+ try:
693
+ with self.ui.live(lambda: self.pull_line(name, list(layers.values()))):
694
+ result = pull(self.api, eid, ref, registry_id, layers)
695
+ except ApiError as e:
696
+ self.ui.say(item_line("fail", name, (str(e), "red")))
697
+ hint = ""
698
+ if registry_id is None:
699
+ hint = "No registry in Portainer matches this image, so the pull was anonymous."
700
+ if registries is None:
701
+ hint = "Could not list the registries in Portainer, so the pull was anonymous."
702
+ raise Failure(
703
+ f"Version {self.version} is not available for {stack['Name']} - nothing was redeployed",
704
+ hint,
705
+ ) from None
706
+ self.ui.say(item_line("ok", name, (result[:1].lower() + result[1:], "dim")))
707
+
708
+ @staticmethod
709
+ def pull_line(name, layers):
710
+ line = item_line("busy", name, glyph=spinner())
711
+ if not layers:
712
+ return line.append(" pulling…", "dim")
713
+ done = sum(status in LAYER_DONE for status, _ in layers)
714
+ grid = Table.grid(padding=(0, 2))
715
+ bar = ProgressBar(1, pull_progress(layers), 20, complete_style="blue")
716
+ grid.add_row(line, bar, Text(f"{done}/{len(layers)} layers", "dim"))
717
+ return grid
718
+
719
+ def deploy(self, stack, stack_file, env, swarm, git):
720
+ sid, eid, name = stack["Id"], stack["EndpointId"], stack["Name"]
721
+ # PullImage is what Portainer called RepullImageAndRedeploy before 2.36; send both.
722
+ # Prune goes along unchanged, as 2.36+ saves whatever it gets as the stack's setting.
723
+ body = {
724
+ "Env": [{"name": k, "value": v} for k, v in env.items()],
725
+ "Prune": bool((stack.get("Option") or {}).get("Prune")),
726
+ "PullImage": True,
727
+ "RepullImageAndRedeploy": True,
728
+ }
729
+ if git:
730
+ # Updating a git stack like a file-based one would detach it from its repository,
731
+ # so it goes through the git redeploy - with the ref and credentials it already
732
+ # has (a blank password keeps the stored one).
733
+ repo = stack.get("GitConfig") or {}
734
+ auth = repo.get("Authentication") or {}
735
+ body.update(
736
+ RepositoryReferenceName=repo.get("ReferenceName") or "",
737
+ RepositoryAuthentication=bool(auth),
738
+ RepositoryUsername=auth.get("Username") or "",
739
+ RepositoryPassword="",
740
+ RepositoryGitCredentialID=auth.get("GitCredentialID") or 0,
741
+ )
742
+ else:
743
+ body["StackFileContent"] = stack_file
744
+ path = f"/stacks/{sid}{'/git/redeploy' if git else ''}?endpointId={eid}"
745
+
746
+ # The containers from before the redeploy: one with a new Id has been replaced.
747
+ label = "Listing the current containers"
748
+ fail = f"Could not list the containers of {name} - nothing was redeployed"
749
+ before = self.wait_for(label, stack_containers, self.api, eid, name, fail=fail)
750
+ old_ids = {c["Id"] for c in before}
751
+
752
+ self.ui.say("", Text(" ── rollout", "dim"))
753
+ start = time.monotonic()
754
+ put = Job(self.api.request, "PUT", path, json=body, timeout=self.timeout)
755
+ self.issued = True
756
+
757
+ # Watch until the rollout settles: the deploy is done, the set of containers has
758
+ # stopped changing and none is still starting. That's keyed on stability rather
759
+ # than "all running", as some services are one-off jobs that rightly exit.
760
+ containers, ids, stable, deployed_at, shown = before, None, 0, 0, None
761
+ lost = timed_out = False
762
+ with self.ui.live(lambda: self.rollout_view(time.monotonic() - start)):
763
+ while True:
764
+ now = time.monotonic()
765
+ if put and not put.is_alive():
766
+ error, put, self.phase = put.error, None, "deploying"
767
+ if isinstance(error, ApiError) and error.status in (0, 502, 504):
768
+ # A proxy in front of Portainer gave up waiting (or the connection
769
+ # dropped), but Portainer carries on - and moves the stack's
770
+ # UpdateDate once it's done.
771
+ lost = True
772
+ why = f"Portainer's reply did not arrive ({error}) - the redeploy continues in Portainer, and the tool keeps following it"
773
+ self.ui.say(item_line("busy", why))
774
+ elif isinstance(error, ApiError):
775
+ details = f"{error}\n{error.details}".strip()
776
+ raise Failure(f"The redeploy of {name} failed", details)
777
+ elif error:
778
+ raise error
779
+ if self.phase == "deploying":
780
+ with contextlib.suppress(ApiError): # tries again next time
781
+ if self.deployed(stack, lost):
782
+ self.phase, deployed_at, stable = "settling", now, 0
783
+
784
+ with contextlib.suppress(ApiError): # keeps showing the last look
785
+ containers = stack_containers(self.api, eid, name)
786
+ new_ids = {c["Id"] for c in containers}
787
+ stable, ids = (stable + 1 if new_ids == ids else 0), new_ids
788
+ self.rows = rows = service_rows(containers, old_ids, swarm)
789
+
790
+ watched = now - deployed_at >= MIN_WATCH
791
+ if self.phase == "settling" and stable >= STABLE_POLLS and watched:
792
+ if rows and not any(r.kind == "busy" for r in rows):
793
+ break
794
+ if not rows and not swarm:
795
+ # Compose has created the containers by the time the deploy is
796
+ # done, so none showing up now means there's nothing to watch
797
+ # (swarm can be slower).
798
+ raise Failure(
799
+ f"Portainer deployed {name}, but the tool found no containers of the stack",
800
+ f'The tool searches for containers that have the com.docker.compose.project label "{name.lower()}".',
801
+ )
802
+ if now - start >= self.timeout:
803
+ timed_out = True
804
+ break
805
+ if not self.ui.is_interactive and self.phase != shown:
806
+ phase = (f"{PHASES[self.phase]}…", (fmt_dur(now - start), "dim"))
807
+ self.ui.say(item_line("busy", *phase))
808
+ shown = self.phase
809
+ (put.join if put else time.sleep)(POLL_INTERVAL) # put.join ends early
810
+
811
+ took = time.monotonic() - start
812
+ self.ui.say(*self.row_lines(rows), "")
813
+ replaced, total, running = totals(rows)
814
+ if timed_out:
815
+ why = "no containers appeared"
816
+ if total:
817
+ why = "containers were still starting or restarting"
818
+ if self.phase != "settling":
819
+ why = "Portainer did not report that the deploy was done"
820
+ raise Failure(
821
+ f"{name} did not settle within {fmt_dur(self.timeout)} ({why})",
822
+ f"If {name} only needs more time, increase --timeout.",
823
+ )
824
+ if failing := [r.service for r in rows if r.kind == "fail"]:
825
+ are = "are" if failing[1:] else "is"
826
+ raise Failure(
827
+ f"{name} redeployed, but {join_names(failing)} {are} failing - check their logs"
828
+ )
829
+ done, meta = f"Redeployed {name}", [f"{replaced}/{total} containers replaced"]
830
+ if not replaced:
831
+ done, meta = (
832
+ f"{name} is up to date - no containers needed to be replaced",
833
+ [],
834
+ )
835
+ meta += [f"{running}/{total} running", fmt_dur(took)]
836
+ self.ui.say(status_line("ok", done, *meta))
837
+
838
+ def deployed(self, stack, lost):
839
+ """Whether Portainer has finished deploying the stack - before 2.36 it answers
840
+ the redeploy only then; newer ones answer right away, and mark it as deploying.
841
+ """
842
+ found = self.api.get(f"/stacks/{stack['Id']}")
843
+ if found["Status"] == DEPLOY_ERROR:
844
+ statuses = found.get("DeploymentStatus") or []
845
+ message = next(
846
+ (s["Message"] for s in reversed(statuses) if s["Message"]), ""
847
+ )
848
+ raise Failure(f"Portainer could not deploy {stack['Name']}", message)
849
+ unchanged = lost and found["UpdateDate"] == stack["UpdateDate"]
850
+ return found["Status"] != DEPLOYING and not unchanged
851
+
852
+ def rollout_view(self, elapsed):
853
+ rows, meta = self.rows, [f"{fmt_dur(elapsed)}"]
854
+ replaced, total, running = totals(rows)
855
+ if total:
856
+ meta.append(f"{replaced}/{total} replaced · {running}/{total} running")
857
+ hint = " Ctrl-C stops watching - the redeploy continues in Portainer"
858
+ return Group(
859
+ item_line(
860
+ "busy",
861
+ PHASES[self.phase],
862
+ *((f"· {m}", "dim") for m in meta),
863
+ glyph=spinner(),
864
+ ),
865
+ *(self.row_lines(rows) or [Text(" no containers yet", "dim")]),
866
+ Text(hint, "dim"),
867
+ )
868
+
869
+ @staticmethod
870
+ def row_lines(rows):
871
+ width, lines = max([12] + [len(r.service) for r in rows]), []
872
+ for r in rows:
873
+ replaced = f"{r.replaced}/{r.total} replaced".ljust(14)
874
+ cols = [(replaced, "" if r.replaced else "dim"), (r.label, STYLE[r.kind])]
875
+ lines.append(item_line(r.kind, r.service.ljust(width), *cols))
876
+ return lines
877
+
878
+
879
+ # ── entry point ───────────────────────────────────────────────────────────────
880
+
881
+ EPILOG = f"""\
882
+ Describe your instances in {CONFIG_SHOWN}:
883
+ {{ "prod": {{ "url": "https://portainer.example.com",
884
+ "api_key_file": "/run/secrets/portainer_api_key" }},
885
+ "lab": {{ "url_file": "/run/secrets/lab_url", "api_key": "ptr_...",
886
+ "insecure": true }} }}
887
+ Or, for a single instance, set PORTAINER_URL and PORTAINER_API_KEY.
888
+ To skip TLS verification for that instance, also set PORTAINER_INSECURE=1."""
889
+
890
+
891
+ def parse_args():
892
+ """The options, and the config. The instance can be left out of the arguments: of
893
+ two, the first is only taken as one (into args.instance) if it's a configured one.
894
+ """
895
+ prog = os.path.basename(sys.argv[0])
896
+ parser = argparse.ArgumentParser(
897
+ prog="portainer-redeploy" if prog in ("", "__main__.py") else prog,
898
+ usage="%(prog)s [options] [INSTANCE] STACK [VERSION]",
899
+ description=__doc__,
900
+ epilog=EPILOG,
901
+ formatter_class=argparse.RawDescriptionHelpFormatter,
902
+ )
903
+ add = parser.add_argument
904
+ add("target", nargs="+", metavar="STACK", help=argparse.SUPPRESS)
905
+ add(
906
+ "-i",
907
+ "--instance",
908
+ help="Portainer instance from the config file (you can also give it as the first argument)",
909
+ )
910
+ add(
911
+ "-E",
912
+ "--environment",
913
+ metavar="NAME|ID",
914
+ help="Portainer environment that has the stack, if stacks in several environments have the same name",
915
+ )
916
+ add(
917
+ "--timeout",
918
+ type=int,
919
+ default=600,
920
+ metavar="SECONDS",
921
+ help="how long to wait for the rollout to settle (default: %(default)s)",
922
+ )
923
+ add(
924
+ "-k",
925
+ "--insecure",
926
+ action="store_true",
927
+ help="skip TLS certificate verification",
928
+ )
929
+ add("-V", "--version", action="version", version=f"%(prog)s {__version__}")
930
+ args = parser.parse_args()
931
+ if args.timeout <= 0:
932
+ parser.error("--timeout must be a positive number")
933
+ config, target = load_config(), args.target
934
+ if len(target) > 3:
935
+ parser.error("too many arguments")
936
+ if len(target) == 3 or (
937
+ len(target) == 2 and args.instance is None and target[0] in config
938
+ ):
939
+ if args.instance is not None:
940
+ parser.error(
941
+ "you gave the instance twice: with -i and as the first argument"
942
+ )
943
+ args.instance = target.pop(0)
944
+ return args, config
945
+
946
+
947
+ def main():
948
+ args, config = parse_args()
949
+ instance = resolve_instance(args.instance, config, args.insecure)
950
+ stack, version = args.target[0], args.target[1] if len(args.target) > 1 else None
951
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) # still restore the cursor
952
+
953
+ ui = Ui()
954
+ run = Redeploy(ui, Portainer(instance), instance, stack, version, args)
955
+ try:
956
+ run.run()
957
+ except Failure as f:
958
+ ui.gap()
959
+ ui.say(status_line("fail", str(f)), *ui.details(f.details))
960
+ sys.exit(1)
961
+ except KeyboardInterrupt:
962
+ if run.issued:
963
+ stop = "Stopped watching - the redeploy continues in Portainer"
964
+ ui.say(*run.row_lines(run.rows), "", status_line("stop", stop))
965
+ else:
966
+ ui.gap()
967
+ ui.say(status_line("stop", "Cancelled - nothing was redeployed"))
968
+ sys.exit(130)
969
+
970
+
971
+ if __name__ == "__main__":
972
+ main()