doover-cli 0.6.1__tar.gz → 0.6.2__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.
- {doover_cli-0.6.1 → doover_cli-0.6.2}/PKG-INFO +2 -2
- {doover_cli-0.6.1 → doover_cli-0.6.2}/pyproject.toml +2 -1
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/__init__.py +1 -1
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/apps/apps.py +176 -10
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/login.py +18 -0
- doover_cli-0.6.2/src/doover_cli/registry.py +373 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/apps.py +150 -0
- doover_cli-0.6.2/tests/test_discover.py +157 -0
- doover_cli-0.6.2/tests/test_registry.py +432 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_utils_apps.py +86 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/uv.lock +609 -610
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/CONTRIBUTING.md +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/workflows/lint-test.yml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/workflows/release-brew.yml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/workflows/release-pypi.yml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.github/workflows/release.yml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.gitignore +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/.pre-commit-config.yaml +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/AGENT.md +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/LICENSE +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/README.md +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/debian/changelog +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/debian/control +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/debian/rules +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/docs/user_stories.md +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/agent.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/api/__init__.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/api/auth.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/api/errors.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/api/session.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/apps/app_install.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/apps/device.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/apps/device_type.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/apps/tunnel.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/channel.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/colours.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/config_schema.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/dda_logs.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/doover_config.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/grpc.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/renderer/__init__.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/renderer/_base.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/renderer/_basic.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/renderer/_default.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/renderer/_json.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/report.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/simulator.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/ui_schema.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/user.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/__init__.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/api.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/context.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/crud/__init__.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/crud/commands.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/crud/lookup.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/crud/prompting.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/crud/schema.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/crud/values.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/errors.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/formatters.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/misc.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/parsers.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/prompt.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/sentry.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/shell_commands.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/src/doover_cli/utils/state.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/__init__.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/conftest.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_agent.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_app_install.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_apps.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_auth_integration.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_auth_unit.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_basic.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_crud_commands.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_crud_lookup.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_crud_prompting.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_crud_schema.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_crud_values.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_default_renderer.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_device.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_device_cli_integration.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_device_type.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_device_type_cli_integration.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_prompt.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_sentry.py +0 -0
- {doover_cli-0.6.1 → doover_cli-0.6.2}/tests/test_user.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: doover-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: CLI for Doover
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -10,7 +10,7 @@ Requires-Dist: docker>=7.1.0
|
|
|
10
10
|
Requires-Dist: jsf>=0.11.2
|
|
11
11
|
Requires-Dist: paramiko>=3.5.1
|
|
12
12
|
Requires-Dist: prompt-toolkit>=3.0.52
|
|
13
|
-
Requires-Dist: pydoover>=1.
|
|
13
|
+
Requires-Dist: pydoover>=1.12.0
|
|
14
14
|
Requires-Dist: pytz>=2025.2
|
|
15
15
|
Requires-Dist: questionary>=1.10.0
|
|
16
16
|
Requires-Dist: requests>=2.32.3
|
|
@@ -10,7 +10,7 @@ dependencies = [
|
|
|
10
10
|
"docker>=7.1.0",
|
|
11
11
|
"jsf>=0.11.2",
|
|
12
12
|
"paramiko>=3.5.1",
|
|
13
|
-
"pydoover>=1.
|
|
13
|
+
"pydoover>=1.12.0",
|
|
14
14
|
"pytz>=2025.2",
|
|
15
15
|
"questionary>=1.10.0",
|
|
16
16
|
"requests>=2.32.3",
|
|
@@ -31,6 +31,7 @@ path = "src/doover_cli/__init__.py"
|
|
|
31
31
|
|
|
32
32
|
[project.scripts]
|
|
33
33
|
doover = "doover_cli:main"
|
|
34
|
+
docker-credential-doover = "doover_cli.registry:credential_helper"
|
|
34
35
|
|
|
35
36
|
[dependency-groups]
|
|
36
37
|
dev = [
|
|
@@ -7,6 +7,8 @@ import re
|
|
|
7
7
|
import shutil
|
|
8
8
|
import socket
|
|
9
9
|
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
10
12
|
import time
|
|
11
13
|
from urllib.parse import urlencode
|
|
12
14
|
from pathlib import Path
|
|
@@ -26,7 +28,10 @@ import questionary
|
|
|
26
28
|
from ..config_schema import export as export_config_command
|
|
27
29
|
from ..ui_schema import export as export_ui_command
|
|
28
30
|
from ..utils.api import ProfileAnnotation
|
|
31
|
+
from ..registry import is_doover_registry, login_for_push, publish_github_output
|
|
29
32
|
from ..utils.apps import (
|
|
33
|
+
PACKAGE_APP_TYPES,
|
|
34
|
+
discover_apps,
|
|
30
35
|
get_app_directory,
|
|
31
36
|
call_with_uv,
|
|
32
37
|
get_docker_path,
|
|
@@ -120,6 +125,20 @@ def _detect_git_commit(root_fp: Path) -> str:
|
|
|
120
125
|
return result.stdout.strip() if result.returncode == 0 else ""
|
|
121
126
|
|
|
122
127
|
|
|
128
|
+
def _is_multi_platform(build_args: str) -> bool:
|
|
129
|
+
"""Whether `build_args` asks for more than one platform.
|
|
130
|
+
|
|
131
|
+
Multi-platform changes how the image has to be built: buildx cannot load a
|
|
132
|
+
multi-platform result into the local daemon, so it must push directly, and
|
|
133
|
+
the digest has to come from buildx rather than the daemon.
|
|
134
|
+
"""
|
|
135
|
+
for token in ("--platform", "--platform="):
|
|
136
|
+
if token in build_args:
|
|
137
|
+
tail = build_args.split(token, 1)[1].lstrip("= ").split()[0]
|
|
138
|
+
return "," in tail
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
123
142
|
def _build_container(
|
|
124
143
|
root_fp: Path, *, buildx: bool, build_args: str, image_name: str
|
|
125
144
|
) -> None:
|
|
@@ -128,6 +147,29 @@ def _build_container(
|
|
|
128
147
|
)
|
|
129
148
|
|
|
130
149
|
|
|
150
|
+
def _build_and_push_multi_platform(
|
|
151
|
+
root_fp: Path, *, build_args: str, image_name: str
|
|
152
|
+
) -> str | None:
|
|
153
|
+
"""Build and push a multi-platform image in one step, returning its digest.
|
|
154
|
+
|
|
155
|
+
A multi-platform build cannot be loaded into the local daemon, so `build`
|
|
156
|
+
then `push` does not work -- and `docker inspect` afterwards reports either
|
|
157
|
+
nothing or the single-platform digest, not the index. buildx writes the real
|
|
158
|
+
digest to a metadata file, which is the only reliable source.
|
|
159
|
+
"""
|
|
160
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
161
|
+
metadata_fp = Path(tmp) / "metadata.json"
|
|
162
|
+
shell_run(
|
|
163
|
+
f"docker buildx build {build_args} --push "
|
|
164
|
+
f"--metadata-file {metadata_fp} -t {image_name} {str(root_fp)}",
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
metadata = json.loads(metadata_fp.read_text())
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
return None
|
|
170
|
+
return metadata.get("containerimage.digest")
|
|
171
|
+
|
|
172
|
+
|
|
131
173
|
def _push_container(image_name: str) -> None:
|
|
132
174
|
shell_run(f"docker push {image_name}")
|
|
133
175
|
|
|
@@ -925,7 +967,13 @@ def publish(
|
|
|
925
967
|
] = Path(),
|
|
926
968
|
build_container: Annotated[
|
|
927
969
|
bool,
|
|
928
|
-
typer.Option(
|
|
970
|
+
typer.Option(
|
|
971
|
+
# `--build` is the name this is known by; the longer form is kept so
|
|
972
|
+
# existing scripts and CI keep working.
|
|
973
|
+
"--build/--no-build",
|
|
974
|
+
"--build-container/--no-build-container",
|
|
975
|
+
help="Build and push the container image to the registry.",
|
|
976
|
+
),
|
|
929
977
|
] = False,
|
|
930
978
|
staging: Annotated[
|
|
931
979
|
bool | None,
|
|
@@ -1123,7 +1171,7 @@ def publish(
|
|
|
1123
1171
|
)
|
|
1124
1172
|
rich.print("[green]Widget uploaded.[/green]")
|
|
1125
1173
|
|
|
1126
|
-
if app_config.type in
|
|
1174
|
+
if app_config.type in PACKAGE_APP_TYPES:
|
|
1127
1175
|
if build_package:
|
|
1128
1176
|
print("\nBuilding package.zip for upload...")
|
|
1129
1177
|
shell_run("./build.sh", cwd=root_fp)
|
|
@@ -1164,14 +1212,49 @@ def publish(
|
|
|
1164
1212
|
build_args = getattr(app_config, "build_args", "") or ""
|
|
1165
1213
|
if build_args != "NO_BUILD":
|
|
1166
1214
|
print("\nBuilding and pushing container image to the registry...")
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
)
|
|
1173
|
-
|
|
1174
|
-
|
|
1215
|
+
|
|
1216
|
+
# Pushing to the doover registry needs a credential scoped to this
|
|
1217
|
+
# app's repository, minted against the publish permission we just
|
|
1218
|
+
# exercised. Other registries keep using whatever docker login the
|
|
1219
|
+
# user already has.
|
|
1220
|
+
if is_doover_registry(image_name, _control_base_url()):
|
|
1221
|
+
# `response` is an Application model on create/partial; fall back to
|
|
1222
|
+
# resolving by name for the paths that don't return one.
|
|
1223
|
+
app_id = getattr(response, "id", None)
|
|
1224
|
+
if app_id is None and isinstance(response, dict):
|
|
1225
|
+
app_id = response.get("id")
|
|
1226
|
+
if app_id is None:
|
|
1227
|
+
app_id = _resolve_application_id(
|
|
1228
|
+
client, app_config, staging=resolved_staging
|
|
1229
|
+
)
|
|
1230
|
+
if not app_id:
|
|
1231
|
+
raise typer.BadParameter(
|
|
1232
|
+
"Could not determine the application id to mint a registry "
|
|
1233
|
+
"credential. Publish the app first."
|
|
1234
|
+
)
|
|
1235
|
+
login_for_push(client, app_id)
|
|
1236
|
+
|
|
1237
|
+
if _is_multi_platform(build_args):
|
|
1238
|
+
# buildx pushes as part of the build here; see the helper for why
|
|
1239
|
+
# build-then-push cannot work for multi-platform.
|
|
1240
|
+
detected_digest = _build_and_push_multi_platform(
|
|
1241
|
+
root_fp, build_args=build_args, image_name=image_name
|
|
1242
|
+
)
|
|
1243
|
+
else:
|
|
1244
|
+
_build_container(
|
|
1245
|
+
root_fp,
|
|
1246
|
+
buildx=buildx,
|
|
1247
|
+
build_args=build_args,
|
|
1248
|
+
image_name=image_name,
|
|
1249
|
+
)
|
|
1250
|
+
_push_container(image_name)
|
|
1251
|
+
detected_digest = _get_image_digest(image_name)
|
|
1252
|
+
|
|
1253
|
+
if detected_digest is None:
|
|
1254
|
+
print(
|
|
1255
|
+
"Warning: could not determine the pushed image digest. "
|
|
1256
|
+
"Pass --digest to release against it explicitly."
|
|
1257
|
+
)
|
|
1175
1258
|
else:
|
|
1176
1259
|
print("App requested to not build. Skipping build step.")
|
|
1177
1260
|
|
|
@@ -1193,6 +1276,89 @@ def publish(
|
|
|
1193
1276
|
renderer.render(response)
|
|
1194
1277
|
|
|
1195
1278
|
|
|
1279
|
+
@app.command()
|
|
1280
|
+
def discover(
|
|
1281
|
+
root: Annotated[Path, typer.Argument(help="Repository root to search.")] = Path(),
|
|
1282
|
+
as_json: Annotated[
|
|
1283
|
+
bool,
|
|
1284
|
+
typer.Option(
|
|
1285
|
+
"--json",
|
|
1286
|
+
help="Emit a single-line JSON array, suitable for a CI matrix.",
|
|
1287
|
+
),
|
|
1288
|
+
] = False,
|
|
1289
|
+
):
|
|
1290
|
+
"""List every application in this repository and how to build it.
|
|
1291
|
+
|
|
1292
|
+
Handles both shapes a repo can take: several app entries in one
|
|
1293
|
+
doover_config.json, and several self-contained app directories each with their
|
|
1294
|
+
own. CI reads this to build its matrix instead of the workflow hard-coding app
|
|
1295
|
+
names, which is what lets one workflow file serve every app repo.
|
|
1296
|
+
"""
|
|
1297
|
+
found = discover_apps(root)
|
|
1298
|
+
if not found:
|
|
1299
|
+
print(
|
|
1300
|
+
f"No applications found under {root}. Each app needs a "
|
|
1301
|
+
f"doover_config.json with a `type` set.",
|
|
1302
|
+
file=sys.stderr,
|
|
1303
|
+
)
|
|
1304
|
+
raise typer.Exit(1)
|
|
1305
|
+
|
|
1306
|
+
if as_json:
|
|
1307
|
+
# Compact and on one line so it can be captured straight into a step output.
|
|
1308
|
+
print(json.dumps(found, separators=(",", ":")))
|
|
1309
|
+
raise typer.Exit(0)
|
|
1310
|
+
|
|
1311
|
+
for entry in found:
|
|
1312
|
+
bits = [entry["type"] or "?", entry["language"] or "unknown language"]
|
|
1313
|
+
if entry["widget"]:
|
|
1314
|
+
bits.append("widget")
|
|
1315
|
+
print(f"{entry['name']:<40} {entry['dir']:<24} {', '.join(bits)}")
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
@app.command(name="registry-login")
|
|
1319
|
+
def registry_login(
|
|
1320
|
+
app_fp: Annotated[
|
|
1321
|
+
Path, typer.Argument(help="Path to the application directory.")
|
|
1322
|
+
] = Path(),
|
|
1323
|
+
app_name: Annotated[
|
|
1324
|
+
str | None,
|
|
1325
|
+
typer.Option(help="Which app in doover_config.json, if it defines several."),
|
|
1326
|
+
] = None,
|
|
1327
|
+
staging: Annotated[
|
|
1328
|
+
bool | None,
|
|
1329
|
+
typer.Option(help="Force staging mode. Defaults to matching the API URL."),
|
|
1330
|
+
] = None,
|
|
1331
|
+
):
|
|
1332
|
+
"""`docker login` to the Doover registry for this application.
|
|
1333
|
+
|
|
1334
|
+
For CI, which builds with buildx rather than `publish --build` and so needs
|
|
1335
|
+
the credential in place beforehand. The token is scoped to this application's
|
|
1336
|
+
repository only, and is piped straight into docker -- it is never printed, so
|
|
1337
|
+
it cannot end up in a workflow log or a step output.
|
|
1338
|
+
|
|
1339
|
+
Publish the app first: the credential is minted against its publish
|
|
1340
|
+
permission, and the repository comes from its registered image name.
|
|
1341
|
+
"""
|
|
1342
|
+
client, _ = get_state()
|
|
1343
|
+
root_fp = get_app_directory(app_fp)
|
|
1344
|
+
app_config = get_app_config(root_fp, app_name=app_name)
|
|
1345
|
+
|
|
1346
|
+
app_id = _resolve_application_id(
|
|
1347
|
+
client, app_config, staging=_resolve_staging(staging)
|
|
1348
|
+
)
|
|
1349
|
+
if app_id is None:
|
|
1350
|
+
raise typer.BadParameter(
|
|
1351
|
+
f"'{app_config.name}' does not exist yet. Run `doover app publish` "
|
|
1352
|
+
f"before logging in to the registry."
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
target = login_for_push(client, app_id)
|
|
1356
|
+
# Emitted so the build step can tag from the app's registered image name
|
|
1357
|
+
# instead of the workflow repeating it.
|
|
1358
|
+
publish_github_output(target)
|
|
1359
|
+
print(f"Logged in to push {target}")
|
|
1360
|
+
|
|
1361
|
+
|
|
1196
1362
|
@app.command(name="release")
|
|
1197
1363
|
def release_command(
|
|
1198
1364
|
app_fp: Annotated[
|
|
@@ -5,6 +5,8 @@ from typer import Typer
|
|
|
5
5
|
|
|
6
6
|
from doover_cli.api import DooverCLIAuthClient
|
|
7
7
|
|
|
8
|
+
from .registry import register_credential_helper, registry_host
|
|
9
|
+
|
|
8
10
|
from .utils.sentry import capture_handled_exception
|
|
9
11
|
from .utils.state import state
|
|
10
12
|
|
|
@@ -47,3 +49,19 @@ def login(
|
|
|
47
49
|
print(
|
|
48
50
|
f"Successfully logged into Doover ({environment}). You can now run `doover ... --profile {profile_name}`."
|
|
49
51
|
)
|
|
52
|
+
|
|
53
|
+
# Point docker at our credential helper so `docker pull`/`push` against the
|
|
54
|
+
# doover registry just work, with no `docker login` and no credentials
|
|
55
|
+
# written to disk. Best-effort: failing to edit the user's docker config is
|
|
56
|
+
# not a reason to fail the login.
|
|
57
|
+
try:
|
|
58
|
+
# Register the registry that belongs to the environment just logged into,
|
|
59
|
+
# so a staging login makes staging images pullable rather than production's.
|
|
60
|
+
if register_credential_helper(registry_host(auth.control_base_url)):
|
|
61
|
+
print(
|
|
62
|
+
"Configured docker to use your Doover login for "
|
|
63
|
+
f"{registry_host(auth.control_base_url)} images."
|
|
64
|
+
)
|
|
65
|
+
except Exception:
|
|
66
|
+
if state.debug:
|
|
67
|
+
raise
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Docker integration for the doover container registry.
|
|
2
|
+
|
|
3
|
+
Two things live here:
|
|
4
|
+
|
|
5
|
+
* ``credential_helper`` -- the ``docker-credential-doover`` entry point. Docker
|
|
6
|
+
finds credential helpers by name on PATH, so installing the CLI is what makes
|
|
7
|
+
``docker pull registry.doover.com/...`` work; there is nothing else to install
|
|
8
|
+
and no ``docker login`` to run. It hands docker the current session token, and
|
|
9
|
+
the registry's token realm reads the caller's permissions from their own
|
|
10
|
+
``dv-registry`` channel -- so a user reaches exactly the apps they can reach in
|
|
11
|
+
the UI, resolved live.
|
|
12
|
+
|
|
13
|
+
* ``login_for_push`` -- a ``docker login`` with a credential scoped to one
|
|
14
|
+
repository, for pushing a build. Push needs the narrow credential rather than
|
|
15
|
+
the session, because it is minted by the control plane against the app's
|
|
16
|
+
publish permission.
|
|
17
|
+
|
|
18
|
+
The helper protocol is deliberately minimal: docker writes a registry host on
|
|
19
|
+
stdin and expects ``{"ServerURL","Username","Secret"}`` on stdout for ``get``.
|
|
20
|
+
|
|
21
|
+
Registering the helper for a host means docker routes *every* credential
|
|
22
|
+
operation for it here, ``store`` included -- so the helper has to persist what
|
|
23
|
+
``docker login`` gives it. It used to discard it, which silently defeated
|
|
24
|
+
``login_for_push``: the scoped credential went nowhere and the subsequent push
|
|
25
|
+
fell back to the session token. For a PUBLIC or CORE app that token carries no
|
|
26
|
+
push scope at all (doover-control's ``entitlements_for_user`` excludes those
|
|
27
|
+
apps), so the brokered credential is not an optimisation, it is the only thing
|
|
28
|
+
that can push.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import base64
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import time
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RegistryLoginError(RuntimeError):
|
|
42
|
+
"""`docker login` to the registry failed, carrying what docker reported."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULT_REGISTRY = "registry.doover.com"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def registry_host(control_base_url: str | None = None) -> str:
|
|
49
|
+
"""The registry that belongs to a given control plane.
|
|
50
|
+
|
|
51
|
+
Derived rather than configured: every environment names its hosts the same
|
|
52
|
+
way, so api.doover.com pairs with registry.doover.com and
|
|
53
|
+
api.staging.udoover.com with registry.staging.udoover.com. Hardcoding the
|
|
54
|
+
production host meant a staging image was not recognised as ours, so no
|
|
55
|
+
credential was minted and the push failed with a bare 401.
|
|
56
|
+
"""
|
|
57
|
+
if not control_base_url:
|
|
58
|
+
return DEFAULT_REGISTRY
|
|
59
|
+
host = control_base_url.split("://", 1)[-1].split("/", 1)[0].split(":")[0]
|
|
60
|
+
if host.startswith("api."):
|
|
61
|
+
return "registry." + host[len("api.") :]
|
|
62
|
+
return DEFAULT_REGISTRY
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _docker_config_path():
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
return Path.home() / ".docker" / "config.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_credential_helper(registry: str = DEFAULT_REGISTRY) -> bool:
|
|
72
|
+
"""Point docker at our helper for `registry`, leaving the rest of the file
|
|
73
|
+
alone. Returns True if the config was changed.
|
|
74
|
+
|
|
75
|
+
Merged rather than rewritten: this file is the user's, and usually holds
|
|
76
|
+
credentials for other registries.
|
|
77
|
+
"""
|
|
78
|
+
path = _docker_config_path()
|
|
79
|
+
try:
|
|
80
|
+
config = json.loads(path.read_text()) if path.exists() else {}
|
|
81
|
+
except (OSError, ValueError):
|
|
82
|
+
# A malformed or unreadable config is the user's to fix; silently
|
|
83
|
+
# replacing it could lose their other registry credentials.
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
helpers = config.setdefault("credHelpers", {})
|
|
87
|
+
if helpers.get(registry) == "doover":
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
helpers[registry] = "doover"
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text(json.dumps(config, indent=2) + "\n")
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _credential_store_path():
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
|
|
99
|
+
return Path.home() / ".doover" / "registry-credentials.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_stored() -> dict:
|
|
103
|
+
try:
|
|
104
|
+
entries = json.loads(_credential_store_path().read_text())
|
|
105
|
+
except (OSError, ValueError):
|
|
106
|
+
# Nothing stored, or a file we did not write. Falling back to the session
|
|
107
|
+
# token is always safe; the worst case is a clear entitlement error.
|
|
108
|
+
return {}
|
|
109
|
+
return entries if isinstance(entries, dict) else {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_stored(entries: dict) -> None:
|
|
113
|
+
path = _credential_store_path()
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
# 0600 from the moment it exists: these are bearer credentials for pushing
|
|
116
|
+
# images, so the mode cannot be applied as an afterthought.
|
|
117
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
118
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
119
|
+
json.dump(entries, fh)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _seconds_until_expiry(secret: str) -> float | None:
|
|
123
|
+
"""Seconds left on `secret`, or None if it carries no readable `exp`.
|
|
124
|
+
|
|
125
|
+
Decoded without verifying the signature: the realm is the only thing that
|
|
126
|
+
needs to trust this token, and all the helper needs to know is whether
|
|
127
|
+
handing it to docker is pointless. A credential that is not a JWT is treated
|
|
128
|
+
as non-expiring -- docker gave it to us, so it is not ours to second-guess.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
payload = secret.split(".")[1]
|
|
132
|
+
payload += "=" * (-len(payload) % 4)
|
|
133
|
+
exp = json.loads(base64.urlsafe_b64decode(payload))["exp"]
|
|
134
|
+
return float(exp) - time.time()
|
|
135
|
+
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# A push credential lives 30 minutes. Handing docker one with only seconds left
|
|
140
|
+
# buys a failed request rather than a completed upload.
|
|
141
|
+
EXPIRY_MARGIN_SECONDS = 30
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _token_for_registry(server_url: str) -> str:
|
|
145
|
+
"""A session token valid for `server_url`'s environment.
|
|
146
|
+
|
|
147
|
+
Docker hands the helper a registry host and nothing else -- no `--profile`,
|
|
148
|
+
no environment -- so the profile has to be recovered from that host.
|
|
149
|
+
Whichever profile's control plane pairs with this registry is the one holding
|
|
150
|
+
the right token, which is the same pairing `registry_host` applies going the
|
|
151
|
+
other way.
|
|
152
|
+
|
|
153
|
+
Matched on the derived host rather than the profile *name*: several profiles
|
|
154
|
+
routinely point at the same environment under different names, and a staging
|
|
155
|
+
push must not be signed with a production token. For the same reason every
|
|
156
|
+
match is tried rather than just the first -- a long-lived config accumulates
|
|
157
|
+
profiles whose refresh token has since been invalidated, and one of those
|
|
158
|
+
sitting earlier in the file must not mask the one that still works.
|
|
159
|
+
"""
|
|
160
|
+
# Imported here, not at module scope: docker invokes this on every registry
|
|
161
|
+
# operation, and the CLI's import graph is far too heavy to pay for that.
|
|
162
|
+
from .api.session import DooverCLISession
|
|
163
|
+
|
|
164
|
+
def token_from(session) -> str:
|
|
165
|
+
session.auth.ensure_token()
|
|
166
|
+
if not session.auth.token:
|
|
167
|
+
# Returning an empty secret would make docker retry anonymously and
|
|
168
|
+
# report a 401 that looks like a permissions problem.
|
|
169
|
+
raise RuntimeError("session produced no token")
|
|
170
|
+
return session.auth.token
|
|
171
|
+
|
|
172
|
+
# Set in CI, where there is no profile config to read.
|
|
173
|
+
if os.environ.get("DOOVER_API_TOKEN"):
|
|
174
|
+
return token_from(DooverCLISession.from_env())
|
|
175
|
+
|
|
176
|
+
# GitHub Actions with no stored token: the CLI authenticates over the
|
|
177
|
+
# trusted-publisher OIDC flow, and there is no profile to match on. A push
|
|
178
|
+
# never reaches here -- `docker login` stores the brokered credential and the
|
|
179
|
+
# `get` above serves it -- but a *pull* does, and without this the helper
|
|
180
|
+
# falls through to a profile config that does not exist on a runner.
|
|
181
|
+
from .utils.api import _trusted_publisher_provider
|
|
182
|
+
|
|
183
|
+
provider = _trusted_publisher_provider()
|
|
184
|
+
if provider:
|
|
185
|
+
control_url = os.environ.get("DOOVER_CONTROL_API_BASE_URL")
|
|
186
|
+
# Same host check as the profile path below: a workflow targeting one
|
|
187
|
+
# environment must not hand its token to another environment's registry.
|
|
188
|
+
if registry_host(control_url) != server_url:
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
f"this workflow targets {control_url or 'production'}, which does "
|
|
191
|
+
f"not serve {server_url}"
|
|
192
|
+
)
|
|
193
|
+
return token_from(
|
|
194
|
+
DooverCLISession.from_trusted_publisher(
|
|
195
|
+
provider=provider,
|
|
196
|
+
audience=os.environ.get("DOOVER_OIDC_AUDIENCE"),
|
|
197
|
+
control_base_url=control_url,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
from pydoover.api.auth import ConfigManager
|
|
202
|
+
|
|
203
|
+
manager = ConfigManager()
|
|
204
|
+
candidates = []
|
|
205
|
+
for name, profile in manager.entries.items():
|
|
206
|
+
control_url = profile.control_base_url
|
|
207
|
+
if not profile.token or not control_url:
|
|
208
|
+
continue
|
|
209
|
+
host = control_url.split("://", 1)[-1].split("/", 1)[0].split(":")[0]
|
|
210
|
+
# `registry_host` falls back to production for anything that isn't an
|
|
211
|
+
# `api.` host, so a local profile would otherwise answer for
|
|
212
|
+
# registry.doover.com.
|
|
213
|
+
if host.startswith("api.") and registry_host(control_url) == server_url:
|
|
214
|
+
candidates.append(name)
|
|
215
|
+
|
|
216
|
+
if not candidates:
|
|
217
|
+
raise RuntimeError(f"no logged-in profile has a control plane for {server_url}")
|
|
218
|
+
|
|
219
|
+
failures = []
|
|
220
|
+
for name in candidates:
|
|
221
|
+
try:
|
|
222
|
+
session = DooverCLISession.from_profile(name, config_manager=manager)
|
|
223
|
+
session.auth.ensure_token()
|
|
224
|
+
except Exception as e: # noqa: BLE001 - try the next profile
|
|
225
|
+
failures.append(f"{name}: {e}")
|
|
226
|
+
continue
|
|
227
|
+
if session.auth.token:
|
|
228
|
+
return session.auth.token
|
|
229
|
+
failures.append(f"{name}: no token after refresh")
|
|
230
|
+
|
|
231
|
+
raise RuntimeError("; ".join(failures))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def credential_helper() -> None:
|
|
235
|
+
"""`docker-credential-doover` entry point.
|
|
236
|
+
|
|
237
|
+
Never prompts. Docker captures stdin and stdout, so an interactive login
|
|
238
|
+
would hang the pull; and returning empty credentials would make docker retry
|
|
239
|
+
anonymously and report a misleading 401. So a missing session exits non-zero
|
|
240
|
+
with an explanation on stderr instead.
|
|
241
|
+
"""
|
|
242
|
+
verb = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
243
|
+
|
|
244
|
+
# `docker login` sends the credential here as JSON. Keeping it is what makes
|
|
245
|
+
# a repo-scoped push credential survive to the push.
|
|
246
|
+
if verb == "store":
|
|
247
|
+
try:
|
|
248
|
+
entry = json.loads(sys.stdin.read())
|
|
249
|
+
except ValueError as e:
|
|
250
|
+
print(f"doover: malformed credential on stdin ({e})", file=sys.stderr)
|
|
251
|
+
raise SystemExit(1) from e
|
|
252
|
+
entries = _read_stored()
|
|
253
|
+
entries[entry.get("ServerURL") or DEFAULT_REGISTRY] = {
|
|
254
|
+
"Username": entry.get("Username") or "doover",
|
|
255
|
+
"Secret": entry.get("Secret") or "",
|
|
256
|
+
}
|
|
257
|
+
_write_stored(entries)
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
# `docker logout` sends the bare host.
|
|
261
|
+
if verb == "erase":
|
|
262
|
+
entries = _read_stored()
|
|
263
|
+
if entries.pop(sys.stdin.read().strip(), None) is not None:
|
|
264
|
+
_write_stored(entries)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
if verb == "list":
|
|
268
|
+
print(
|
|
269
|
+
json.dumps(
|
|
270
|
+
{
|
|
271
|
+
server: entry.get("Username", "doover")
|
|
272
|
+
for server, entry in _read_stored().items()
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
if verb != "get":
|
|
278
|
+
print(f"unknown verb: {verb!r}", file=sys.stderr)
|
|
279
|
+
raise SystemExit(2)
|
|
280
|
+
|
|
281
|
+
server_url = sys.stdin.read().strip() or DEFAULT_REGISTRY
|
|
282
|
+
|
|
283
|
+
# A stored credential wins over the session token: it is the narrow one the
|
|
284
|
+
# control plane brokered for a specific repository, and for a public or core
|
|
285
|
+
# app it is the only one that carries push at all.
|
|
286
|
+
stored = _read_stored().get(server_url)
|
|
287
|
+
if stored:
|
|
288
|
+
remaining = _seconds_until_expiry(stored.get("Secret", ""))
|
|
289
|
+
if remaining is None or remaining > EXPIRY_MARGIN_SECONDS:
|
|
290
|
+
print(
|
|
291
|
+
json.dumps(
|
|
292
|
+
{
|
|
293
|
+
"ServerURL": server_url,
|
|
294
|
+
"Username": stored.get("Username", "doover"),
|
|
295
|
+
"Secret": stored.get("Secret", ""),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
# Dropped rather than kept and skipped, so a dead push credential cannot
|
|
301
|
+
# keep shadowing the session token that would still serve pulls.
|
|
302
|
+
entries = _read_stored()
|
|
303
|
+
entries.pop(server_url, None)
|
|
304
|
+
_write_stored(entries)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
token = _token_for_registry(server_url)
|
|
308
|
+
except Exception as e: # noqa: BLE001 - any failure means "not logged in"
|
|
309
|
+
print(
|
|
310
|
+
f"doover: no usable session for {server_url} ({e}).\n"
|
|
311
|
+
f"Run `doover login` and try again.",
|
|
312
|
+
file=sys.stderr,
|
|
313
|
+
)
|
|
314
|
+
raise SystemExit(1) from e
|
|
315
|
+
|
|
316
|
+
# The username is a label only -- the realm resolves the agent from the
|
|
317
|
+
# token's claims and ignores it.
|
|
318
|
+
print(json.dumps({"ServerURL": server_url, "Username": "doover", "Secret": token}))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def is_doover_registry(
|
|
322
|
+
image_name: str | None, control_base_url: str | None = None
|
|
323
|
+
) -> bool:
|
|
324
|
+
"""Whether an image lives on this environment's doover registry, and so needs
|
|
325
|
+
a credential minted by the control plane rather than the user's own docker
|
|
326
|
+
login."""
|
|
327
|
+
if not image_name:
|
|
328
|
+
return False
|
|
329
|
+
host = image_name.split("/", 1)[0].split(":")[0].lower()
|
|
330
|
+
return host == registry_host(control_base_url)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def publish_github_output(image: str) -> None:
|
|
334
|
+
"""Expose the image reference to later workflow steps.
|
|
335
|
+
|
|
336
|
+
Lets a workflow stay identical across app repos: the reference comes from the
|
|
337
|
+
app's own doover_config.json rather than being spelled out in the yaml, which
|
|
338
|
+
also removes the chance of it drifting from the registered image name.
|
|
339
|
+
"""
|
|
340
|
+
output_path = os.environ.get("GITHUB_OUTPUT")
|
|
341
|
+
if not output_path:
|
|
342
|
+
return
|
|
343
|
+
try:
|
|
344
|
+
with open(output_path, "a", encoding="utf-8") as fh:
|
|
345
|
+
fh.write(f"image={image}\n")
|
|
346
|
+
except OSError as e:
|
|
347
|
+
print(f"could not write GITHUB_OUTPUT: {e}", file=sys.stderr)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def login_for_push(control_client, application_id: int | str) -> str:
|
|
351
|
+
"""`docker login` with a credential scoped to one application's repository.
|
|
352
|
+
|
|
353
|
+
Returns the repository path to push to. Raises if the app does not publish to
|
|
354
|
+
the doover registry, which is what stops a stale config pushing into a
|
|
355
|
+
repository nothing will pull.
|
|
356
|
+
"""
|
|
357
|
+
result = control_client.mint_registry_token(application_id)
|
|
358
|
+
registry = result["registry"]
|
|
359
|
+
completed = subprocess.run(
|
|
360
|
+
["docker", "login", registry, "-u", result["username"], "--password-stdin"],
|
|
361
|
+
input=result["password"],
|
|
362
|
+
text=True,
|
|
363
|
+
capture_output=True,
|
|
364
|
+
)
|
|
365
|
+
if completed.returncode != 0:
|
|
366
|
+
# Surface what docker said. Swallowing it leaves only a
|
|
367
|
+
# CalledProcessError, which says nothing about whether the registry was
|
|
368
|
+
# unreachable, the certificate was wrong, or the token was rejected.
|
|
369
|
+
detail = (completed.stderr or completed.stdout or "").strip()
|
|
370
|
+
raise RegistryLoginError(
|
|
371
|
+
f"docker login to {registry} failed: {detail or 'no output from docker'}"
|
|
372
|
+
)
|
|
373
|
+
return f"{registry}/{result['repository']}"
|