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.
- portainer_redeploy_cli/__init__.py +1 -0
- portainer_redeploy_cli/__main__.py +972 -0
- portainer_redeploy_cli-0.1.0.dist-info/METADATA +172 -0
- portainer_redeploy_cli-0.1.0.dist-info/RECORD +7 -0
- portainer_redeploy_cli-0.1.0.dist-info/WHEEL +4 -0
- portainer_redeploy_cli-0.1.0.dist-info/entry_points.txt +2 -0
- portainer_redeploy_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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()
|