doover-cli 0.6.4__tar.gz → 0.6.5__tar.gz

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.
Files changed (92) hide show
  1. {doover_cli-0.6.4 → doover_cli-0.6.5}/PKG-INFO +1 -1
  2. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/__init__.py +1 -1
  3. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/apps/apps.py +238 -0
  4. doover_cli-0.6.5/src/doover_cli/apps/migrate.py +726 -0
  5. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/apps.py +6 -0
  6. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/conftest.py +18 -0
  7. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_apps.py +367 -0
  8. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/CONTRIBUTING.md +0 -0
  9. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  10. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  12. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  13. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/workflows/lint-test.yml +0 -0
  14. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/workflows/release-brew.yml +0 -0
  15. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/workflows/release-pypi.yml +0 -0
  16. {doover_cli-0.6.4 → doover_cli-0.6.5}/.github/workflows/release.yml +0 -0
  17. {doover_cli-0.6.4 → doover_cli-0.6.5}/.gitignore +0 -0
  18. {doover_cli-0.6.4 → doover_cli-0.6.5}/.pre-commit-config.yaml +0 -0
  19. {doover_cli-0.6.4 → doover_cli-0.6.5}/AGENT.md +0 -0
  20. {doover_cli-0.6.4 → doover_cli-0.6.5}/LICENSE +0 -0
  21. {doover_cli-0.6.4 → doover_cli-0.6.5}/README.md +0 -0
  22. {doover_cli-0.6.4 → doover_cli-0.6.5}/debian/changelog +0 -0
  23. {doover_cli-0.6.4 → doover_cli-0.6.5}/debian/control +0 -0
  24. {doover_cli-0.6.4 → doover_cli-0.6.5}/debian/rules +0 -0
  25. {doover_cli-0.6.4 → doover_cli-0.6.5}/docs/user_stories.md +0 -0
  26. {doover_cli-0.6.4 → doover_cli-0.6.5}/pyproject.toml +0 -0
  27. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/agent.py +0 -0
  28. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/api/__init__.py +0 -0
  29. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/api/auth.py +0 -0
  30. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/api/errors.py +0 -0
  31. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/api/session.py +0 -0
  32. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/apps/app_install.py +0 -0
  33. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/apps/device.py +0 -0
  34. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/apps/device_type.py +0 -0
  35. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/apps/tunnel.py +0 -0
  36. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/channel.py +0 -0
  37. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/colours.py +0 -0
  38. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/config_schema.py +0 -0
  39. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/dda_logs.py +0 -0
  40. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/doover_config.py +0 -0
  41. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/grpc.py +0 -0
  42. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/login.py +0 -0
  43. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/registry.py +0 -0
  44. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/renderer/__init__.py +0 -0
  45. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/renderer/_base.py +0 -0
  46. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/renderer/_basic.py +0 -0
  47. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/renderer/_default.py +0 -0
  48. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/renderer/_json.py +0 -0
  49. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/report.py +0 -0
  50. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/simulator.py +0 -0
  51. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/ui_schema.py +0 -0
  52. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/user.py +0 -0
  53. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/__init__.py +0 -0
  54. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/api.py +0 -0
  55. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/context.py +0 -0
  56. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/crud/__init__.py +0 -0
  57. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/crud/commands.py +0 -0
  58. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/crud/lookup.py +0 -0
  59. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/crud/prompting.py +0 -0
  60. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/crud/schema.py +0 -0
  61. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/crud/values.py +0 -0
  62. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/errors.py +0 -0
  63. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/formatters.py +0 -0
  64. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/misc.py +0 -0
  65. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/parsers.py +0 -0
  66. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/prompt.py +0 -0
  67. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/sentry.py +0 -0
  68. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/shell_commands.py +0 -0
  69. {doover_cli-0.6.4 → doover_cli-0.6.5}/src/doover_cli/utils/state.py +0 -0
  70. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/__init__.py +0 -0
  71. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_agent.py +0 -0
  72. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_app_install.py +0 -0
  73. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_auth_integration.py +0 -0
  74. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_auth_unit.py +0 -0
  75. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_basic.py +0 -0
  76. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_crud_commands.py +0 -0
  77. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_crud_lookup.py +0 -0
  78. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_crud_prompting.py +0 -0
  79. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_crud_schema.py +0 -0
  80. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_crud_values.py +0 -0
  81. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_default_renderer.py +0 -0
  82. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_device.py +0 -0
  83. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_device_cli_integration.py +0 -0
  84. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_device_type.py +0 -0
  85. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_device_type_cli_integration.py +0 -0
  86. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_discover.py +0 -0
  87. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_prompt.py +0 -0
  88. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_registry.py +0 -0
  89. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_sentry.py +0 -0
  90. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_user.py +0 -0
  91. {doover_cli-0.6.4 → doover_cli-0.6.5}/tests/test_utils_apps.py +0 -0
  92. {doover_cli-0.6.4 → doover_cli-0.6.5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doover-cli
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: CLI for Doover
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,7 +1,7 @@
1
1
  from doover_cli.renderer import Renderer
2
2
  from typing import Annotated, Optional
3
3
 
4
- __version__ = "0.6.4"
4
+ __version__ = "0.6.5"
5
5
 
6
6
  import click
7
7
  import typer
@@ -49,6 +49,7 @@ from ..utils.shell_commands import run as shell_run
49
49
  from ..utils.sentry import capture_handled_exception
50
50
  from ..utils.state import state
51
51
  from .app_install import application_app as installs_app
52
+ from .migrate import migrate as migrate_command
52
53
 
53
54
  if TYPE_CHECKING:
54
55
  from pydoover.api import ControlClient
@@ -67,6 +68,7 @@ app.add_typer(
67
68
  name="installs",
68
69
  help="Manage installations for an application.",
69
70
  )
71
+ app.command(name="migrate")(migrate_command)
70
72
 
71
73
 
72
74
  class AppType(Enum):
@@ -194,6 +196,150 @@ def _get_image_digest(image_name: str) -> str | None:
194
196
  return None
195
197
 
196
198
 
199
+ def _repository_of(image_name: str) -> str:
200
+ """`image_name` without its tag, so a digest can be appended to it."""
201
+ repository, sep, tail = image_name.rpartition(":")
202
+ # A colon in the last path segment is a tag; one before a `/` is a port.
203
+ if sep and "/" not in tail:
204
+ return repository
205
+ return image_name
206
+
207
+
208
+ # What the registry says when a manifest genuinely is not there, as opposed to
209
+ # the many other reasons a lookup can fail -- an expired token being the likely
210
+ # one, since the credential minted for a push is short-lived.
211
+ _NOT_FOUND_MARKERS = ("not found", "manifest unknown", "manifest_unknown")
212
+
213
+ PRESENT, MISSING, UNKNOWN = "present", "missing", "unknown"
214
+
215
+
216
+ def _read_manifest(ref: str) -> tuple[str, dict | None]:
217
+ """Look `ref` up in the registry: (PRESENT | MISSING | UNKNOWN, manifest).
218
+
219
+ Goes through `imagetools`, not a direct registry request, so it reuses
220
+ whatever `docker login` already established -- including credential helpers,
221
+ which is what the doover registry uses.
222
+
223
+ MISSING and UNKNOWN have to be told apart: only the first is evidence of a
224
+ bad push. Reporting a lapsed credential as a missing manifest would fail
225
+ publishes for a reason that has nothing to do with the image.
226
+ """
227
+ try:
228
+ result = subprocess.run(
229
+ ["docker", "buildx", "imagetools", "inspect", "--raw", ref],
230
+ capture_output=True,
231
+ text=True,
232
+ check=False,
233
+ )
234
+ except OSError:
235
+ return UNKNOWN, None
236
+
237
+ if result.returncode != 0:
238
+ stderr = (result.stderr or "").lower()
239
+ if any(marker in stderr for marker in _NOT_FOUND_MARKERS):
240
+ return MISSING, None
241
+ return UNKNOWN, None
242
+
243
+ try:
244
+ return PRESENT, json.loads(result.stdout)
245
+ except ValueError:
246
+ return UNKNOWN, None
247
+
248
+
249
+ def _is_attestation(child: dict) -> bool:
250
+ """Whether an index entry is buildx provenance rather than a runnable image.
251
+
252
+ buildx records attestations as extra children with an `unknown/unknown`
253
+ platform, which is how a pull knows to skip them.
254
+ """
255
+ if child.get("annotations", {}).get("vnd.docker.reference.type"):
256
+ return True
257
+ platform = child.get("platform") or {}
258
+ return platform.get("architecture") == "unknown" and platform.get("os") == "unknown"
259
+
260
+
261
+ _INDEX_MEDIA_TYPES = frozenset(
262
+ {
263
+ "application/vnd.oci.image.index.v1+json",
264
+ "application/vnd.docker.distribution.manifest.list.v2+json",
265
+ }
266
+ )
267
+
268
+
269
+ def _verify_pushed_image(image_name: str, digest: str | None) -> None:
270
+ """Fail unless the registry can serve every manifest the push referenced.
271
+
272
+ A multi-platform push uploads the per-platform manifests and then the index
273
+ that points at them. If a registry keeps the index but not its children, the
274
+ tag still resolves for whoever pushed it -- their build cache has the
275
+ children -- and every device fails to pull it with "manifest unknown". Docker
276
+ does not report that as a failed push, and a release would then pin a digest
277
+ nothing can pull, so the check belongs here: at publish time, where it is
278
+ still one command to re-run.
279
+ """
280
+ reference = f"{_repository_of(image_name)}@{digest}" if digest else image_name
281
+
282
+ state_, index = _read_manifest(reference)
283
+ if state_ == MISSING:
284
+ rich.print(
285
+ f"[bold red]Pushed image {reference} is not in the registry.[/bold red] "
286
+ "The push reported success, so this is a registry-side rejection "
287
+ "rather than a build failure."
288
+ )
289
+ raise typer.Exit(1)
290
+ if state_ == UNKNOWN:
291
+ rich.print(
292
+ f"Warning: could not read {reference} back from the registry, so the "
293
+ "push was not verified."
294
+ )
295
+ return
296
+
297
+ if index.get("mediaType") not in _INDEX_MEDIA_TYPES:
298
+ return
299
+
300
+ repository = _repository_of(image_name)
301
+ missing = [
302
+ child
303
+ for child in index.get("manifests", [])
304
+ if child.get("digest")
305
+ and _read_manifest(f"{repository}@{child['digest']}")[0] == MISSING
306
+ ]
307
+
308
+ # A missing attestation is not a broken image: a pull resolves the index by
309
+ # platform and never asks for the `unknown/unknown` entries buildx adds for
310
+ # provenance. Say so, but do not fail a publish over it.
311
+ runnable = [child for child in missing if not _is_attestation(child)]
312
+ if missing and not runnable:
313
+ rich.print(
314
+ f"Warning: {len(missing)} attestation manifest(s) referenced by "
315
+ f"{reference} are not in the registry. Pulls are unaffected."
316
+ )
317
+ return
318
+ if not runnable:
319
+ return
320
+ missing = runnable
321
+
322
+ rich.print(
323
+ f"[bold red]Incomplete push: the registry has the image index for "
324
+ f"{reference} but not {len(missing)} of the platform manifests it "
325
+ "references.[/bold red]\nAnything pulling this image will fail with "
326
+ "'manifest unknown'. Missing:"
327
+ )
328
+ for child in missing:
329
+ platform = child.get("platform") or {}
330
+ described = (
331
+ f"{platform.get('os', '?')}/{platform.get('architecture', '?')}"
332
+ if platform
333
+ else "no platform"
334
+ )
335
+ rich.print(f" {child['digest']} ({described})")
336
+ rich.print(
337
+ "The registry accepted the index but not everything it points at. "
338
+ "Nothing is released against this digest."
339
+ )
340
+ raise typer.Exit(1)
341
+
342
+
197
343
  def _require_publish_value(
198
344
  name: str, value, *, allow_empty_list: bool = False, allow_none: bool = False
199
345
  ):
@@ -1282,6 +1428,8 @@ def publish(
1282
1428
  "Warning: could not determine the pushed image digest. "
1283
1429
  "Pass --digest to release against it explicitly."
1284
1430
  )
1431
+
1432
+ _verify_pushed_image(image_name, detected_digest)
1285
1433
  else:
1286
1434
  print("App requested to not build. Skipping build step.")
1287
1435
 
@@ -1389,6 +1537,96 @@ def registry_login(
1389
1537
  print(f"Logged in to push {target}")
1390
1538
 
1391
1539
 
1540
+ @app.command(name="verify-releases")
1541
+ def verify_releases(
1542
+ app_fp: Annotated[
1543
+ Path, typer.Argument(help="Path to the application directory.")
1544
+ ] = Path(),
1545
+ app_name: Annotated[
1546
+ str | None,
1547
+ typer.Option(help="Which app in doover_config.json, if it defines several."),
1548
+ ] = None,
1549
+ staging: Annotated[
1550
+ bool | None,
1551
+ typer.Option(help="Force staging mode. Defaults to matching the API URL."),
1552
+ ] = None,
1553
+ _profile: ProfileAnnotation = None,
1554
+ ):
1555
+ """Check that every released version of an app can still be pulled.
1556
+
1557
+ Reads only. A release records a digest, and until the registry was fixed a
1558
+ digest stopped resolving as soon as its tag moved on, so releases from before
1559
+ then may already be gone with nothing else reporting it.
1560
+
1561
+ A release whose platform manifests have gone cannot be recovered: their bytes
1562
+ went with them, so it has to be rebuilt and published again.
1563
+ """
1564
+ _ = _profile
1565
+ client, _ = get_state()
1566
+ root_fp = get_app_directory(app_fp)
1567
+ app_config = get_app_config(root_fp, app_name=app_name)
1568
+
1569
+ application_id = _resolve_application_id(
1570
+ client, app_config, staging=_resolve_staging(staging)
1571
+ )
1572
+ if application_id is None:
1573
+ raise typer.BadParameter(f"'{app_config.name}' has not been published yet.")
1574
+
1575
+ versions = client.list_application_versions(application_id)
1576
+ rows = versions.get("results", versions) if isinstance(versions, dict) else versions
1577
+
1578
+ broken = checked = 0
1579
+ for version in sorted(rows, key=lambda v: v.get("number") or 0):
1580
+ image = version.get("image_name") or ""
1581
+ if "@" not in image or not is_doover_registry(image, _control_base_url()):
1582
+ continue
1583
+ checked += 1
1584
+ digest_ = image.split("@", 1)[1]
1585
+ state_, verdict = _release_state(image)
1586
+ rich.print(f" v{version.get('number'):<4} {digest_[:19]} {verdict}")
1587
+ if state_ == MISSING:
1588
+ broken += 1
1589
+
1590
+ if not checked:
1591
+ rich.print("No releases on the doover registry to check.")
1592
+ return
1593
+ if broken:
1594
+ rich.print(
1595
+ f"\n[bold red]{broken} of {checked} releases cannot be pulled.[/bold red] "
1596
+ "Rebuild and republish them; the images cannot be recovered."
1597
+ )
1598
+ raise typer.Exit(1)
1599
+ rich.print(f"\n[green]All {checked} releases are intact.[/green]")
1600
+
1601
+
1602
+ def _release_state(image: str) -> tuple[str, str]:
1603
+ """Whether a released image is still pullable, and a line describing it."""
1604
+ repository, digest = image.split("@", 1)
1605
+ state_, index = _read_manifest(image)
1606
+ if state_ == MISSING:
1607
+ return MISSING, "[red]GONE[/red] -- no longer in the registry"
1608
+ if state_ == UNKNOWN:
1609
+ return UNKNOWN, "could not be read (credentials?)"
1610
+
1611
+ if index.get("mediaType") not in _INDEX_MEDIA_TYPES:
1612
+ return PRESENT, "[green]pullable[/green]"
1613
+
1614
+ platforms = [c for c in index.get("manifests", []) if not _is_attestation(c)]
1615
+ gone = [
1616
+ c
1617
+ for c in platforms
1618
+ if _read_manifest(f"{repository}@{c['digest']}")[0] == MISSING
1619
+ ]
1620
+ if gone:
1621
+ missing = ", ".join(
1622
+ f"{(c.get('platform') or {}).get('os', '?')}/"
1623
+ f"{(c.get('platform') or {}).get('architecture', '?')}"
1624
+ for c in gone
1625
+ )
1626
+ return MISSING, f"[red]GONE[/red] -- missing platform manifests: {missing}"
1627
+ return PRESENT, f"[green]pullable[/green] ({len(platforms)} platform(s))"
1628
+
1629
+
1392
1630
  @app.command(name="release")
1393
1631
  def release_command(
1394
1632
  app_fp: Annotated[