doover-cli 0.6.4__tar.gz → 0.6.6__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 (96) hide show
  1. {doover_cli-0.6.4 → doover_cli-0.6.6}/PKG-INFO +2 -2
  2. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/__init__.py +1 -1
  3. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/apps/apps.py +249 -0
  4. doover_cli-0.6.6/src/doover_cli/apps/migrate.py +956 -0
  5. doover_cli-0.6.6/src/doover_cli/apps/widget_plugin.py +182 -0
  6. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/channel.py +7 -0
  7. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/config_schema.py +13 -3
  8. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/ui_schema.py +16 -7
  9. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/apps.py +102 -2
  10. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/conftest.py +18 -0
  11. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_apps.py +372 -0
  12. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_discover.py +21 -0
  13. doover_cli-0.6.6/tests/test_migrate.py +137 -0
  14. doover_cli-0.6.6/tests/test_schema_export.py +174 -0
  15. doover_cli-0.6.6/tests/test_widget_plugin.py +70 -0
  16. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/CONTRIBUTING.md +0 -0
  17. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  18. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  20. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/workflows/lint-test.yml +0 -0
  22. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/workflows/release-brew.yml +0 -0
  23. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/workflows/release-pypi.yml +0 -0
  24. {doover_cli-0.6.4 → doover_cli-0.6.6}/.github/workflows/release.yml +0 -0
  25. {doover_cli-0.6.4 → doover_cli-0.6.6}/.gitignore +0 -0
  26. {doover_cli-0.6.4 → doover_cli-0.6.6}/.pre-commit-config.yaml +0 -0
  27. {doover_cli-0.6.4 → doover_cli-0.6.6}/AGENT.md +0 -0
  28. {doover_cli-0.6.4 → doover_cli-0.6.6}/LICENSE +0 -0
  29. {doover_cli-0.6.4 → doover_cli-0.6.6}/README.md +0 -0
  30. {doover_cli-0.6.4 → doover_cli-0.6.6}/debian/changelog +0 -0
  31. {doover_cli-0.6.4 → doover_cli-0.6.6}/debian/control +0 -0
  32. {doover_cli-0.6.4 → doover_cli-0.6.6}/debian/rules +0 -0
  33. {doover_cli-0.6.4 → doover_cli-0.6.6}/docs/user_stories.md +0 -0
  34. {doover_cli-0.6.4 → doover_cli-0.6.6}/pyproject.toml +0 -0
  35. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/agent.py +0 -0
  36. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/api/__init__.py +0 -0
  37. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/api/auth.py +0 -0
  38. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/api/errors.py +0 -0
  39. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/api/session.py +0 -0
  40. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/apps/app_install.py +0 -0
  41. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/apps/device.py +0 -0
  42. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/apps/device_type.py +0 -0
  43. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/apps/tunnel.py +0 -0
  44. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/colours.py +0 -0
  45. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/dda_logs.py +0 -0
  46. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/doover_config.py +0 -0
  47. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/grpc.py +0 -0
  48. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/login.py +0 -0
  49. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/registry.py +0 -0
  50. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/renderer/__init__.py +0 -0
  51. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/renderer/_base.py +0 -0
  52. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/renderer/_basic.py +0 -0
  53. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/renderer/_default.py +0 -0
  54. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/renderer/_json.py +0 -0
  55. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/report.py +0 -0
  56. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/simulator.py +0 -0
  57. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/user.py +0 -0
  58. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/__init__.py +0 -0
  59. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/api.py +0 -0
  60. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/context.py +0 -0
  61. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/crud/__init__.py +0 -0
  62. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/crud/commands.py +0 -0
  63. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/crud/lookup.py +0 -0
  64. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/crud/prompting.py +0 -0
  65. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/crud/schema.py +0 -0
  66. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/crud/values.py +0 -0
  67. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/errors.py +0 -0
  68. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/formatters.py +0 -0
  69. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/misc.py +0 -0
  70. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/parsers.py +0 -0
  71. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/prompt.py +0 -0
  72. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/sentry.py +0 -0
  73. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/shell_commands.py +0 -0
  74. {doover_cli-0.6.4 → doover_cli-0.6.6}/src/doover_cli/utils/state.py +0 -0
  75. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/__init__.py +0 -0
  76. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_agent.py +0 -0
  77. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_app_install.py +0 -0
  78. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_auth_integration.py +0 -0
  79. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_auth_unit.py +0 -0
  80. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_basic.py +0 -0
  81. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_crud_commands.py +0 -0
  82. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_crud_lookup.py +0 -0
  83. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_crud_prompting.py +0 -0
  84. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_crud_schema.py +0 -0
  85. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_crud_values.py +0 -0
  86. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_default_renderer.py +0 -0
  87. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_device.py +0 -0
  88. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_device_cli_integration.py +0 -0
  89. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_device_type.py +0 -0
  90. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_device_type_cli_integration.py +0 -0
  91. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_prompt.py +0 -0
  92. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_registry.py +0 -0
  93. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_sentry.py +0 -0
  94. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_user.py +0 -0
  95. {doover_cli-0.6.4 → doover_cli-0.6.6}/tests/test_utils_apps.py +0 -0
  96. {doover_cli-0.6.4 → doover_cli-0.6.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: doover-cli
3
- Version: 0.6.4
3
+ Version: 0.6.6
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.6"
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
  ):
@@ -1129,6 +1275,17 @@ def publish(
1129
1275
  if publish_org_id is not None:
1130
1276
  client.organisation_id = int(publish_org_id)
1131
1277
 
1278
+ # The deployment folder is published verbatim and is what a device pulls, so
1279
+ # an image pinned there wins over image_name in practice. Warn rather than
1280
+ # abort: a deliberate pin (an older tag, a fork) is legitimate, and this
1281
+ # can't tell one from a reference left behind by a registry change.
1282
+ for path, reference in app_config.stale_deployment_images():
1283
+ rich.print(
1284
+ f"[yellow]{path.name} deploys '{reference}', but this app is "
1285
+ f"registered as '{app_config.image_name}'. The deployment folder is "
1286
+ f"what the device pulls, so it will run the former.[/yellow]"
1287
+ )
1288
+
1132
1289
  rich.print(
1133
1290
  f"Updating application on doover site ({_control_base_url() or 'unknown base URL'})...\n"
1134
1291
  )
@@ -1282,6 +1439,8 @@ def publish(
1282
1439
  "Warning: could not determine the pushed image digest. "
1283
1440
  "Pass --digest to release against it explicitly."
1284
1441
  )
1442
+
1443
+ _verify_pushed_image(image_name, detected_digest)
1285
1444
  else:
1286
1445
  print("App requested to not build. Skipping build step.")
1287
1446
 
@@ -1389,6 +1548,96 @@ def registry_login(
1389
1548
  print(f"Logged in to push {target}")
1390
1549
 
1391
1550
 
1551
+ @app.command(name="verify-releases")
1552
+ def verify_releases(
1553
+ app_fp: Annotated[
1554
+ Path, typer.Argument(help="Path to the application directory.")
1555
+ ] = Path(),
1556
+ app_name: Annotated[
1557
+ str | None,
1558
+ typer.Option(help="Which app in doover_config.json, if it defines several."),
1559
+ ] = None,
1560
+ staging: Annotated[
1561
+ bool | None,
1562
+ typer.Option(help="Force staging mode. Defaults to matching the API URL."),
1563
+ ] = None,
1564
+ _profile: ProfileAnnotation = None,
1565
+ ):
1566
+ """Check that every released version of an app can still be pulled.
1567
+
1568
+ Reads only. A release records a digest, and until the registry was fixed a
1569
+ digest stopped resolving as soon as its tag moved on, so releases from before
1570
+ then may already be gone with nothing else reporting it.
1571
+
1572
+ A release whose platform manifests have gone cannot be recovered: their bytes
1573
+ went with them, so it has to be rebuilt and published again.
1574
+ """
1575
+ _ = _profile
1576
+ client, _ = get_state()
1577
+ root_fp = get_app_directory(app_fp)
1578
+ app_config = get_app_config(root_fp, app_name=app_name)
1579
+
1580
+ application_id = _resolve_application_id(
1581
+ client, app_config, staging=_resolve_staging(staging)
1582
+ )
1583
+ if application_id is None:
1584
+ raise typer.BadParameter(f"'{app_config.name}' has not been published yet.")
1585
+
1586
+ versions = client.list_application_versions(application_id)
1587
+ rows = versions.get("results", versions) if isinstance(versions, dict) else versions
1588
+
1589
+ broken = checked = 0
1590
+ for version in sorted(rows, key=lambda v: v.get("number") or 0):
1591
+ image = version.get("image_name") or ""
1592
+ if "@" not in image or not is_doover_registry(image, _control_base_url()):
1593
+ continue
1594
+ checked += 1
1595
+ digest_ = image.split("@", 1)[1]
1596
+ state_, verdict = _release_state(image)
1597
+ rich.print(f" v{version.get('number'):<4} {digest_[:19]} {verdict}")
1598
+ if state_ == MISSING:
1599
+ broken += 1
1600
+
1601
+ if not checked:
1602
+ rich.print("No releases on the doover registry to check.")
1603
+ return
1604
+ if broken:
1605
+ rich.print(
1606
+ f"\n[bold red]{broken} of {checked} releases cannot be pulled.[/bold red] "
1607
+ "Rebuild and republish them; the images cannot be recovered."
1608
+ )
1609
+ raise typer.Exit(1)
1610
+ rich.print(f"\n[green]All {checked} releases are intact.[/green]")
1611
+
1612
+
1613
+ def _release_state(image: str) -> tuple[str, str]:
1614
+ """Whether a released image is still pullable, and a line describing it."""
1615
+ repository, digest = image.split("@", 1)
1616
+ state_, index = _read_manifest(image)
1617
+ if state_ == MISSING:
1618
+ return MISSING, "[red]GONE[/red] -- no longer in the registry"
1619
+ if state_ == UNKNOWN:
1620
+ return UNKNOWN, "could not be read (credentials?)"
1621
+
1622
+ if index.get("mediaType") not in _INDEX_MEDIA_TYPES:
1623
+ return PRESENT, "[green]pullable[/green]"
1624
+
1625
+ platforms = [c for c in index.get("manifests", []) if not _is_attestation(c)]
1626
+ gone = [
1627
+ c
1628
+ for c in platforms
1629
+ if _read_manifest(f"{repository}@{c['digest']}")[0] == MISSING
1630
+ ]
1631
+ if gone:
1632
+ missing = ", ".join(
1633
+ f"{(c.get('platform') or {}).get('os', '?')}/"
1634
+ f"{(c.get('platform') or {}).get('architecture', '?')}"
1635
+ for c in gone
1636
+ )
1637
+ return MISSING, f"[red]GONE[/red] -- missing platform manifests: {missing}"
1638
+ return PRESENT, f"[green]pullable[/green] ({len(platforms)} platform(s))"
1639
+
1640
+
1392
1641
  @app.command(name="release")
1393
1642
  def release_command(
1394
1643
  app_fp: Annotated[