rostainer-deploy 0.0.3__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.
- rostainer_deploy/__init__.py +534 -0
- rostainer_deploy/api.py +242 -0
- rostainer_deploy/progress.py +129 -0
- rostainer_deploy/remote.py +531 -0
- rostainer_deploy-0.0.3.dist-info/METADATA +50 -0
- rostainer_deploy-0.0.3.dist-info/RECORD +8 -0
- rostainer_deploy-0.0.3.dist-info/WHEEL +4 -0
- rostainer_deploy-0.0.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Deploy: push package updates to robots over ssh, through a local registry.
|
|
2
|
+
|
|
3
|
+
``rostainer deploy push`` sends the newest package (``rostainer package
|
|
4
|
+
create``) to the workspace's targets - robots, listed in
|
|
5
|
+
``[plugins.deploy.targets.<name>]`` and picked by name or by group
|
|
6
|
+
(``[plugins.deploy.groups]``) - several at a time:
|
|
7
|
+
|
|
8
|
+
1. the package's image goes into a registry on this machine
|
|
9
|
+
(``rostainer deploy registry``), which keeps every layer it has seen;
|
|
10
|
+
2. each target pulls it through an ssh reverse tunnel, downloading only
|
|
11
|
+
the layers it doesn't have - after a code change, typically just the
|
|
12
|
+
workspace's build;
|
|
13
|
+
3. the bundle's small files (settings, Compose files, the scripts) are
|
|
14
|
+
copied over the same ssh connection, and its ``./rostainer install`` installs
|
|
15
|
+
them into the target's install directory and restarts the container,
|
|
16
|
+
keeping the package it replaces for ``rostainer deploy rollback``.
|
|
17
|
+
|
|
18
|
+
No image tar is copied or loaded. See :mod:`rostainer_deploy.remote` for
|
|
19
|
+
what runs where.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import difflib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
31
|
+
|
|
32
|
+
from pydantic import Field, field_validator, model_validator
|
|
33
|
+
from typing_extensions import Literal
|
|
34
|
+
|
|
35
|
+
from rostainer_cli.plugin_api import (
|
|
36
|
+
Command,
|
|
37
|
+
ConfigError,
|
|
38
|
+
Context,
|
|
39
|
+
Plugin,
|
|
40
|
+
PluginConfig,
|
|
41
|
+
image_id,
|
|
42
|
+
log,
|
|
43
|
+
)
|
|
44
|
+
from rostainer_deploy import remote
|
|
45
|
+
from rostainer_deploy.remote import Destination
|
|
46
|
+
|
|
47
|
+
#: What a target's or group's name may look like: it's typed on the
|
|
48
|
+
#: command line.
|
|
49
|
+
TARGET_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
|
|
50
|
+
|
|
51
|
+
#: The selector meaning every enabled target; no target or group has it.
|
|
52
|
+
ALL = "all"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TargetConfig(PluginConfig):
|
|
56
|
+
"""One ``[plugins.deploy.targets.<name>]`` table: a robot."""
|
|
57
|
+
|
|
58
|
+
host: str = Field(description="Host name, IP address, or a Host alias from ~/.ssh/config.")
|
|
59
|
+
user: str = Field("", description="ssh user (default: ssh's, e.g. from ~/.ssh/config).")
|
|
60
|
+
port: Optional[int] = Field(None, ge=1, le=65535, description="ssh port (default: ssh's).")
|
|
61
|
+
install_dir: str = Field(
|
|
62
|
+
"", description="Where the package is installed there (default: [plugins.deploy] install_dir)."
|
|
63
|
+
)
|
|
64
|
+
enabled: bool = Field(
|
|
65
|
+
True,
|
|
66
|
+
description="false: left out of 'all' and of its groups; deployed to only when named.",
|
|
67
|
+
)
|
|
68
|
+
ssh_options: List[str] = Field(
|
|
69
|
+
default_factory=list,
|
|
70
|
+
description="More ssh options for this target, before [plugins.deploy] ssh_options.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DeployConfig(PluginConfig):
|
|
75
|
+
"""The ``[plugins.deploy]`` table."""
|
|
76
|
+
|
|
77
|
+
install_dir: str = Field(
|
|
78
|
+
"rostainer/{workspace}",
|
|
79
|
+
description=(
|
|
80
|
+
"Where packages are installed on a target: relative to the ssh user's home,\n"
|
|
81
|
+
"or absolute. {workspace} is this workspace's directory name."
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
enable: Literal["docker", "user_systemd", "system_systemd", "none"] = Field(
|
|
85
|
+
"docker",
|
|
86
|
+
description=(
|
|
87
|
+
"How a target starts the package at boot: docker (Docker's restart policy),\n"
|
|
88
|
+
"user_systemd (a systemd user unit: needs 'loginctl enable-linger' for the\n"
|
|
89
|
+
"ssh user), system_systemd (a system unit: needs passwordless sudo), or none."
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
parallel: int = Field(4, ge=1, description="How many targets a push or status reaches at once.")
|
|
93
|
+
registry_port: int = Field(
|
|
94
|
+
5000, ge=1, le=65535, description="The local registry's port, on this machine's localhost."
|
|
95
|
+
)
|
|
96
|
+
tunnel_port: int = Field(
|
|
97
|
+
5000, ge=1, le=65535, description="The port targets reach it on, through ssh, on their localhost."
|
|
98
|
+
)
|
|
99
|
+
registry_image: str = Field(
|
|
100
|
+
"registry:2",
|
|
101
|
+
description="The registry's image: pull it once while online, for offline use.",
|
|
102
|
+
)
|
|
103
|
+
ssh_options: List[str] = Field(
|
|
104
|
+
default_factory=list,
|
|
105
|
+
description='More ssh options for every target, e.g. ["-J", "gateway"] for a jump host.',
|
|
106
|
+
)
|
|
107
|
+
connect_timeout: int = Field(
|
|
108
|
+
15, ge=1, description="Seconds to wait for a target's ssh connection."
|
|
109
|
+
)
|
|
110
|
+
connect_attempts: int = Field(
|
|
111
|
+
3,
|
|
112
|
+
ge=1,
|
|
113
|
+
description=(
|
|
114
|
+
"How many times to try connecting to a target (counting the first) before\n"
|
|
115
|
+
"giving up on it. Only connecting is retried, never a step that may have\n"
|
|
116
|
+
"changed the target; nor a refused login or host key."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
connect_retry_delay: int = Field(
|
|
120
|
+
10, ge=0, description="Seconds between those attempts."
|
|
121
|
+
)
|
|
122
|
+
accept_new_host_keys: bool = Field(
|
|
123
|
+
False,
|
|
124
|
+
description=(
|
|
125
|
+
"true: trust a target's host key the first time it's seen, rather than\n"
|
|
126
|
+
"failing until it's in ~/.ssh/known_hosts. A key that changed is still refused."
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
verify_settle: int = Field(
|
|
131
|
+
20,
|
|
132
|
+
ge=0,
|
|
133
|
+
description=(
|
|
134
|
+
"After a push, each target is checked to come up: it keeps running this many\n"
|
|
135
|
+
"seconds, and its runtime_healthcheck (if any) passes. 0: don't check."
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
rollback_on_failure: bool = Field(
|
|
139
|
+
True,
|
|
140
|
+
description=(
|
|
141
|
+
"Go back to the package a target ran before when the pushed one doesn't\n"
|
|
142
|
+
"come up (push --no-rollback: leave it, to look into)."
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
prune_images: bool = Field(
|
|
146
|
+
True,
|
|
147
|
+
description=(
|
|
148
|
+
"After a push, remove the images of the packages installed in the target's\n"
|
|
149
|
+
"install_dir before, keeping the one before the new one, for a rollback."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
groups: Dict[str, List[str]] = Field(
|
|
154
|
+
default_factory=dict,
|
|
155
|
+
description=(
|
|
156
|
+
"Named groups of targets, to deploy to together, after the settings above:\n"
|
|
157
|
+
" [plugins.deploy.groups]\n"
|
|
158
|
+
' robots = ["robot1", "robot2"]'
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
targets: Dict[str, TargetConfig] = Field(
|
|
162
|
+
default_factory=dict,
|
|
163
|
+
description=(
|
|
164
|
+
"The robots to deploy to, one table each, after the settings above:\n"
|
|
165
|
+
" [plugins.deploy.targets.robot1]\n"
|
|
166
|
+
' host = "10.0.0.5" # or a ~/.ssh/config alias\n'
|
|
167
|
+
' user = "ros" # optional, as are port, install_dir, enabled, ssh_options'
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@field_validator("targets", "groups")
|
|
172
|
+
@classmethod
|
|
173
|
+
def _names(cls, value: Dict[str, Any]) -> Dict[str, Any]:
|
|
174
|
+
for name in value:
|
|
175
|
+
if not TARGET_NAME.fullmatch(name):
|
|
176
|
+
raise ValueError(f"{name!r} isn't a valid name (letters, digits, '.', '_', '-')")
|
|
177
|
+
if name == ALL:
|
|
178
|
+
raise ValueError(f"'{ALL}' is reserved: it selects every enabled target")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
@model_validator(mode="after")
|
|
182
|
+
def _group_members(self) -> "DeployConfig":
|
|
183
|
+
for group, members in self.groups.items():
|
|
184
|
+
if group in self.targets:
|
|
185
|
+
raise ValueError(f"group '{group}' has the name of a target: rename one of them")
|
|
186
|
+
unknown = [member for member in members if member not in self.targets]
|
|
187
|
+
if unknown:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"group '{group}': no target named "
|
|
190
|
+
+ ", ".join(_with_suggestion(name, self.targets) for name in unknown)
|
|
191
|
+
)
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _with_suggestion(name: str, known: Sequence[str]) -> str:
|
|
196
|
+
"""``'name'``, and the closest of ``known`` if it looks like a typo."""
|
|
197
|
+
close = difflib.get_close_matches(name, list(known), n=1)
|
|
198
|
+
return f"'{name}'" + (f" (did you mean '{close[0]}'?)" if close else "")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def select(settings: DeployConfig, selectors: Sequence[str]) -> List[str]:
|
|
202
|
+
"""The targets ``selectors`` pick, by name, in the order they're listed.
|
|
203
|
+
|
|
204
|
+
A selector is a target, a group, or ``all``; none means ``all``. ``all``
|
|
205
|
+
and groups leave out targets with ``enabled = false``; a target named
|
|
206
|
+
itself is in, enabled or not. Each target is in once.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ConfigError: If a selector is none of those, or none are left.
|
|
210
|
+
"""
|
|
211
|
+
groups, targets = settings.groups, settings.targets
|
|
212
|
+
unknown = [s for s in selectors if s != ALL and s not in targets and s not in groups]
|
|
213
|
+
if unknown:
|
|
214
|
+
known = [*targets, *groups, ALL]
|
|
215
|
+
raise ConfigError(
|
|
216
|
+
"No deploy target or group named "
|
|
217
|
+
+ ", ".join(_with_suggestion(name, known) for name in unknown)
|
|
218
|
+
+ f" (targets: {', '.join(targets)}"
|
|
219
|
+
+ (f"; groups: {', '.join(groups)})" if groups else ")")
|
|
220
|
+
)
|
|
221
|
+
picked = set()
|
|
222
|
+
for selector in selectors or [ALL]:
|
|
223
|
+
if selector == ALL:
|
|
224
|
+
picked.update(name for name, target in targets.items() if target.enabled)
|
|
225
|
+
elif selector in groups:
|
|
226
|
+
picked.update(name for name in groups[selector] if targets[name].enabled)
|
|
227
|
+
else:
|
|
228
|
+
picked.add(selector)
|
|
229
|
+
if not picked:
|
|
230
|
+
raise ConfigError(
|
|
231
|
+
f"No enabled targets in {' '.join(selectors) or ALL}: those with "
|
|
232
|
+
"'enabled = false' are only deployed to when named."
|
|
233
|
+
)
|
|
234
|
+
return [name for name in targets if name in picked]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def destinations(ctx: Context, selectors: Sequence[str] = ()) -> List[Destination]:
|
|
238
|
+
"""The targets ``selectors`` pick (see :func:`select`), ready to reach.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
ConfigError: If there are no targets, or see :func:`select`.
|
|
242
|
+
"""
|
|
243
|
+
settings: DeployConfig = ctx.config
|
|
244
|
+
if not settings.targets:
|
|
245
|
+
raise ConfigError(
|
|
246
|
+
f"No deploy targets in {ctx.cfg.config_file}. Add one, e.g.:\n"
|
|
247
|
+
" [plugins.deploy.targets.robot1]\n"
|
|
248
|
+
' host = "10.0.0.5"'
|
|
249
|
+
)
|
|
250
|
+
names = select(settings, selectors)
|
|
251
|
+
connect = [
|
|
252
|
+
"-o",
|
|
253
|
+
f"ConnectTimeout={settings.connect_timeout}",
|
|
254
|
+
# A link that dies mid-push fails the push in a minute, not never.
|
|
255
|
+
"-o",
|
|
256
|
+
"ServerAliveInterval=15",
|
|
257
|
+
"-o",
|
|
258
|
+
"ServerAliveCountMax=4",
|
|
259
|
+
]
|
|
260
|
+
if settings.accept_new_host_keys:
|
|
261
|
+
connect += ["-o", "StrictHostKeyChecking=accept-new"]
|
|
262
|
+
workspace = re.sub(r"[^A-Za-z0-9_.-]+", "_", ctx.cfg.workspace_root.name) or "workspace"
|
|
263
|
+
return [
|
|
264
|
+
Destination(
|
|
265
|
+
name=name,
|
|
266
|
+
host=target.host,
|
|
267
|
+
user=target.user,
|
|
268
|
+
port=target.port,
|
|
269
|
+
install_dir=(target.install_dir or settings.install_dir).replace("{workspace}", workspace),
|
|
270
|
+
# ssh takes an option's first value: the target's own win.
|
|
271
|
+
ssh_options=(*target.ssh_options, *settings.ssh_options, *connect),
|
|
272
|
+
# Per workspace, so two workspaces' packages on one target don't
|
|
273
|
+
# share (and replace each other's) systemd unit.
|
|
274
|
+
service=f"rostainer-{workspace}",
|
|
275
|
+
attempts=settings.connect_attempts,
|
|
276
|
+
retry_delay=settings.connect_retry_delay,
|
|
277
|
+
)
|
|
278
|
+
for name, target in ((name, settings.targets[name]) for name in names)
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def bundle_for(ctx: Context, version: Optional[str]) -> Path:
|
|
283
|
+
"""The package bundle to deploy: ``version``'s, else the newest.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ConfigError: If there's none (with how to make one).
|
|
287
|
+
"""
|
|
288
|
+
from rostainer_package import create
|
|
289
|
+
|
|
290
|
+
bundles = create.package_bundles(ctx.plugin_context("package"))
|
|
291
|
+
if version is not None:
|
|
292
|
+
bundles = [bundle for bundle in bundles if bundle.name == version]
|
|
293
|
+
if not bundles:
|
|
294
|
+
raise ConfigError(f"No package '{version}' in builds/package/.")
|
|
295
|
+
if not bundles:
|
|
296
|
+
raise ConfigError("No package to deploy yet: 'rostainer package create' makes one.")
|
|
297
|
+
return bundles[0]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def bundle_image(bundle: Path) -> str:
|
|
301
|
+
"""The image a bundle runs (from its ``manifest/image.json``)."""
|
|
302
|
+
try:
|
|
303
|
+
return json.loads((bundle / "manifest" / "image.json").read_text())["image"]
|
|
304
|
+
except (OSError, ValueError, KeyError) as error:
|
|
305
|
+
raise ConfigError(f"'{bundle}' isn't a package bundle: no manifest/image.json") from error
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class DeployCommand(Command):
|
|
309
|
+
"""``rostainer deploy <push|setup|status|rollback|registry>``."""
|
|
310
|
+
|
|
311
|
+
name = "deploy"
|
|
312
|
+
summary = "Push package updates to robots over ssh, through a local registry"
|
|
313
|
+
|
|
314
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
315
|
+
actions = parser.add_subparsers(dest="action", metavar="ACTION")
|
|
316
|
+
actions.required = True
|
|
317
|
+
push = actions.add_parser(
|
|
318
|
+
"push", help="Send a package to targets: only the image layers they don't have"
|
|
319
|
+
)
|
|
320
|
+
push.add_argument(
|
|
321
|
+
"--version", help="The package to send (default: the newest in builds/package/)"
|
|
322
|
+
)
|
|
323
|
+
push.add_argument(
|
|
324
|
+
"--no-rollback",
|
|
325
|
+
action="store_true",
|
|
326
|
+
help="Leave a package that doesn't come up in place (rollback_on_failure)",
|
|
327
|
+
)
|
|
328
|
+
self._selectors(push)
|
|
329
|
+
setup = actions.add_parser(
|
|
330
|
+
"setup",
|
|
331
|
+
help=(
|
|
332
|
+
"Check targets have what deploying needs (key login, Docker, Python 3), "
|
|
333
|
+
"offering to copy your ssh key, then push"
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
setup.add_argument("--version", help="The package to send (default: the newest)")
|
|
337
|
+
self._selectors(setup)
|
|
338
|
+
status = actions.add_parser("status", help="What each target runs, and whether it's up")
|
|
339
|
+
self._selectors(status)
|
|
340
|
+
rollback = actions.add_parser(
|
|
341
|
+
"rollback", help="Go back to the package a target ran before (again: forward)"
|
|
342
|
+
)
|
|
343
|
+
self._selectors(rollback, required=True)
|
|
344
|
+
registry = actions.add_parser("registry", help="The local registry pushes go through")
|
|
345
|
+
registry.add_argument("registry_action", choices=("start", "stop", "status"))
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _selectors(parser: argparse.ArgumentParser, required: bool = False) -> None:
|
|
349
|
+
from rostainer_deploy import api
|
|
350
|
+
|
|
351
|
+
api.add_target_arguments(parser, required=required)
|
|
352
|
+
|
|
353
|
+
def run(self, args: argparse.Namespace, ctx: Optional[Context]) -> int:
|
|
354
|
+
assert ctx is not None
|
|
355
|
+
settings: DeployConfig = ctx.config
|
|
356
|
+
if args.action == "registry":
|
|
357
|
+
return registry(settings, args.registry_action)
|
|
358
|
+
targets = destinations(ctx, args.targets)
|
|
359
|
+
if args.action == "status":
|
|
360
|
+
return status(settings, targets)
|
|
361
|
+
if args.action == "rollback":
|
|
362
|
+
return rollback(settings, targets)
|
|
363
|
+
if args.action == "setup" and check(targets, settings.parallel) != 0:
|
|
364
|
+
return 1
|
|
365
|
+
rollback_on_failure = settings.rollback_on_failure and not getattr(args, "no_rollback", False)
|
|
366
|
+
return push(ctx, settings, targets, args.version, rollback_on_failure=rollback_on_failure)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def registry(settings: DeployConfig, action: str) -> int:
|
|
370
|
+
"""``rostainer deploy registry start|stop|status``."""
|
|
371
|
+
if action == "start":
|
|
372
|
+
if not remote.start_registry(settings.registry_image, settings.registry_port):
|
|
373
|
+
return 1
|
|
374
|
+
log.ok(f"The registry is running on localhost:{settings.registry_port}.")
|
|
375
|
+
return 0
|
|
376
|
+
if action == "stop":
|
|
377
|
+
return 0 if remote.stop_registry() else 1
|
|
378
|
+
state = remote.registry_state()
|
|
379
|
+
if state == "running":
|
|
380
|
+
log.ok(f"The registry is running on localhost:{settings.registry_port}.")
|
|
381
|
+
return 0
|
|
382
|
+
log.info(f"The registry is {state or 'not created'}: 'rostainer deploy registry start'.")
|
|
383
|
+
return 1
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def check(targets: List[Destination], parallel: int = 4) -> int:
|
|
387
|
+
"""Check each target has what a push needs, first offering to copy
|
|
388
|
+
the user's ssh key to those that refuse key-based login."""
|
|
389
|
+
log.header(f"Checking {', '.join(target.name for target in targets)}...")
|
|
390
|
+
refused: List[Destination] = []
|
|
391
|
+
|
|
392
|
+
def probe(target: Destination) -> remote.Outcome:
|
|
393
|
+
outcome = run_over_connection(target, remote.check_script())
|
|
394
|
+
if outcome.returncode == remote.LOGIN_REFUSED:
|
|
395
|
+
refused.append(target)
|
|
396
|
+
return outcome
|
|
397
|
+
|
|
398
|
+
outcomes = {o.name: o for o in remote.run_on_all(targets, probe, parallel, "Checking")}
|
|
399
|
+
if refused and not sys.stdin.isatty():
|
|
400
|
+
log.error(
|
|
401
|
+
"Key-based login was refused by "
|
|
402
|
+
+ ", ".join(target.name for target in refused)
|
|
403
|
+
+ ": run 'rostainer deploy setup' in a terminal to copy your key, or "
|
|
404
|
+
"'ssh-copy-id' yourself."
|
|
405
|
+
)
|
|
406
|
+
elif refused:
|
|
407
|
+
# One at a time: each asks for its password.
|
|
408
|
+
for target in sorted(refused, key=targets.index):
|
|
409
|
+
log.info(f"Copying your ssh key to {target.name} (ssh-copy-id asks for its password)...")
|
|
410
|
+
if remote.copy_id(target) == 0:
|
|
411
|
+
outcomes[target.name] = run_over_connection(target, remote.check_script())
|
|
412
|
+
return remote.report([outcomes[target.name] for target in targets], "The check")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def run_over_connection(
|
|
416
|
+
target: Destination, script: str, *, stdin: bytes = b"", forward: Sequence[str] = ()
|
|
417
|
+
) -> remote.Outcome:
|
|
418
|
+
"""Run ``script`` on ``target`` over a :func:`remote.connection`, with
|
|
419
|
+
its retries; a connection that fails is that target's failure."""
|
|
420
|
+
try:
|
|
421
|
+
with remote.connection(target, forward=forward) as via:
|
|
422
|
+
return remote.run_on(target, script, stdin=stdin, ssh_extra=via)
|
|
423
|
+
except remote.ConnectError as error:
|
|
424
|
+
remote.say(target.name, str(error))
|
|
425
|
+
return remote.Outcome(target.name, remote.LOGIN_REFUSED if error.refused else 255)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def unsupported_options(bundle: Path, options: List[str]) -> List[str]:
|
|
429
|
+
"""Those of ``options`` the bundle's ``./rostainer install`` doesn't
|
|
430
|
+
take: a package created by an older rostainer."""
|
|
431
|
+
script = (bundle / "rostainer").read_text(errors="replace")
|
|
432
|
+
return [option for option in options if f'"{option}"' not in script]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def runs_as_the_targets_user(bundle: Path) -> bool:
|
|
436
|
+
"""Whether the bundle's container takes the IDs of the user installing
|
|
437
|
+
it, rather than those of the machine it was made on."""
|
|
438
|
+
return "installing_user_ids" in (bundle / "rostainer").read_text(errors="replace")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def push(
|
|
442
|
+
ctx: Context,
|
|
443
|
+
settings: DeployConfig,
|
|
444
|
+
targets: List[Destination],
|
|
445
|
+
version: Optional[str],
|
|
446
|
+
*,
|
|
447
|
+
rollback_on_failure: bool = True,
|
|
448
|
+
) -> int:
|
|
449
|
+
"""Send a package to the targets, check each comes up (going back to
|
|
450
|
+
the package it ran before if not, with ``rollback_on_failure``), and
|
|
451
|
+
remove their older images of it."""
|
|
452
|
+
bundle = bundle_for(ctx, version)
|
|
453
|
+
image = bundle_image(bundle)
|
|
454
|
+
if not image_id(image):
|
|
455
|
+
log.error(
|
|
456
|
+
f"The package's image '{image}' isn't on this machine (pruned?): "
|
|
457
|
+
f"'docker load -i' its tar in '{bundle}', or 'rostainer package create' again."
|
|
458
|
+
)
|
|
459
|
+
return 1
|
|
460
|
+
options = []
|
|
461
|
+
if settings.verify_settle > 0:
|
|
462
|
+
options += ["--verify", "--settle"] + (["--rollback-on-failure"] if rollback_on_failure else [])
|
|
463
|
+
if settings.prune_images:
|
|
464
|
+
options.append("--prune")
|
|
465
|
+
unsupported = unsupported_options(bundle, options)
|
|
466
|
+
if unsupported:
|
|
467
|
+
log.error(
|
|
468
|
+
f"Package '{bundle.name}' was created by an older rostainer: its './rostainer install' "
|
|
469
|
+
f"doesn't take {', '.join(unsupported)}. Run 'rostainer package create' again."
|
|
470
|
+
)
|
|
471
|
+
return 1
|
|
472
|
+
if not runs_as_the_targets_user(bundle):
|
|
473
|
+
log.warning(
|
|
474
|
+
f"Package '{bundle.name}' was created by an older rostainer: its container runs "
|
|
475
|
+
"with the user and group IDs of the machine it was made on, not the robots' "
|
|
476
|
+
"users'. Run 'rostainer package create' again for theirs."
|
|
477
|
+
)
|
|
478
|
+
log.header(f"Deploying package '{bundle.name}' ({image})...")
|
|
479
|
+
if not remote.start_registry(settings.registry_image, settings.registry_port):
|
|
480
|
+
return 1
|
|
481
|
+
if not remote.push_to_registry(image, settings.registry_port):
|
|
482
|
+
return 1
|
|
483
|
+
archive = remote.bundle_archive(bundle)
|
|
484
|
+
|
|
485
|
+
def install(target: Destination) -> remote.Outcome:
|
|
486
|
+
script = remote.install_script(
|
|
487
|
+
image,
|
|
488
|
+
settings.tunnel_port,
|
|
489
|
+
target.install_dir,
|
|
490
|
+
settings.enable,
|
|
491
|
+
target.service,
|
|
492
|
+
verify_settle=settings.verify_settle,
|
|
493
|
+
rollback_on_failure=rollback_on_failure,
|
|
494
|
+
prune=settings.prune_images,
|
|
495
|
+
)
|
|
496
|
+
forward = remote.forward(settings.tunnel_port, settings.registry_port)
|
|
497
|
+
return run_over_connection(target, script, stdin=archive, forward=forward)
|
|
498
|
+
|
|
499
|
+
outcomes = remote.run_on_all(targets, install, settings.parallel, f"Deploying '{bundle.name}'")
|
|
500
|
+
return remote.report(outcomes, f"Deploying '{bundle.name}'")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def status(settings: DeployConfig, targets: List[Destination]) -> int:
|
|
504
|
+
"""What each target runs."""
|
|
505
|
+
outcomes = remote.run_on_all(
|
|
506
|
+
targets,
|
|
507
|
+
lambda target: run_over_connection(target, remote.manage_script(target.install_dir, "status")),
|
|
508
|
+
settings.parallel,
|
|
509
|
+
)
|
|
510
|
+
return remote.report(outcomes, "Status")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def rollback(settings: DeployConfig, targets: List[Destination]) -> int:
|
|
514
|
+
"""Go back to the package each target ran before."""
|
|
515
|
+
outcomes = remote.run_on_all(
|
|
516
|
+
targets,
|
|
517
|
+
lambda target: run_over_connection(target, remote.manage_script(target.install_dir, "rollback")),
|
|
518
|
+
settings.parallel,
|
|
519
|
+
"Rolling back",
|
|
520
|
+
)
|
|
521
|
+
return remote.report(outcomes, "Rollback")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class DeployPlugin(Plugin):
|
|
525
|
+
"""Push package updates to robots."""
|
|
526
|
+
|
|
527
|
+
name = "deploy"
|
|
528
|
+
api_version = 1
|
|
529
|
+
summary = "Push package updates to robots over ssh, through a local registry"
|
|
530
|
+
config_model = DeployConfig
|
|
531
|
+
requires = ("package",)
|
|
532
|
+
|
|
533
|
+
def commands(self) -> Sequence[Command]:
|
|
534
|
+
return [DeployCommand()]
|