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.
@@ -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()]