doover-cli 0.5.5__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.5.5 → doover_cli-0.6.2}/PKG-INFO +2 -2
- {doover_cli-0.5.5 → doover_cli-0.6.2}/pyproject.toml +7 -1
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/__init__.py +1 -1
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/apps.py +274 -75
- {doover_cli-0.5.5 → 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.2/src/doover_cli/utils/apps.py +663 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_apps.py +144 -13
- 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.5.5 → doover_cli-0.6.2}/tests/test_utils_apps.py +162 -34
- {doover_cli-0.5.5 → doover_cli-0.6.2}/uv.lock +609 -610
- doover_cli-0.5.5/src/doover_cli/utils/apps.py +0 -432
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/CONTRIBUTING.md +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/lint-test.yml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/release-brew.yml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/release-pypi.yml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.github/workflows/release.yml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.gitignore +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/.pre-commit-config.yaml +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/AGENT.md +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/LICENSE +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/README.md +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/debian/changelog +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/debian/control +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/debian/rules +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/docs/user_stories.md +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/agent.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/__init__.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/auth.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/errors.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/api/session.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/app_install.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/device.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/device_type.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/apps/tunnel.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/channel.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/colours.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/config_schema.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/dda_logs.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/doover_config.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/grpc.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/__init__.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_base.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_basic.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_default.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/renderer/_json.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/report.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/simulator.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/ui_schema.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/user.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/__init__.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/api.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/context.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/__init__.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/commands.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/lookup.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/prompting.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/schema.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/crud/values.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/errors.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/formatters.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/misc.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/parsers.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/prompt.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/sentry.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/shell_commands.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/src/doover_cli/utils/state.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/__init__.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/conftest.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_agent.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_app_install.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_auth_integration.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_auth_unit.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_basic.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_commands.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_lookup.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_prompting.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_schema.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_crud_values.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_default_renderer.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device_cli_integration.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device_type.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_device_type_cli_integration.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_prompt.py +0 -0
- {doover_cli-0.5.5 → doover_cli-0.6.2}/tests/test_sentry.py +0 -0
- {doover_cli-0.5.5 → 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.
|
|
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 = [
|
|
@@ -40,3 +41,8 @@ dev = [
|
|
|
40
41
|
|
|
41
42
|
[tool.ty.environment]
|
|
42
43
|
python = "./.venv"
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
# Pin the rule set explicitly so a newer ruff release doesn't silently enable
|
|
47
|
+
# hundreds of new rules in CI. These are ruff's historical defaults.
|
|
48
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -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
|
|
|
@@ -177,34 +219,36 @@ def _build_application_payload(
|
|
|
177
219
|
payload = app_config.to_request_payload(
|
|
178
220
|
include_deployment_data=include_deployment_data,
|
|
179
221
|
is_staging=staging,
|
|
180
|
-
method="POST",
|
|
181
222
|
)
|
|
182
223
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
224
|
+
# `name` is the only field the CLI insists on -- it is what the control plane
|
|
225
|
+
# upserts on, so without it there is nothing to publish to.
|
|
226
|
+
if not payload.get("name"):
|
|
227
|
+
raise typer.BadParameter("name is required in doover_config.json.")
|
|
228
|
+
|
|
229
|
+
# Everything else is validated only if the file declares it: an omitted field
|
|
230
|
+
# is not an error, it just isn't part of this patch, and the control plane
|
|
231
|
+
# still rejects a *create* that is missing something it requires. A FIX-ME is
|
|
232
|
+
# always caught, since that is never a deliberate value.
|
|
233
|
+
for field, kwargs in (
|
|
234
|
+
("name", {}),
|
|
235
|
+
("display_name", {}),
|
|
236
|
+
("description", {}),
|
|
237
|
+
("type", {}),
|
|
238
|
+
("visibility", {}),
|
|
239
|
+
("organisation_id", {"allow_none": True}),
|
|
240
|
+
("container_registry_profile_id", {"allow_none": True}),
|
|
241
|
+
("depends_on", {"allow_empty_list": True}),
|
|
242
|
+
):
|
|
243
|
+
if field in payload:
|
|
244
|
+
_require_publish_value(field, payload[field], **kwargs)
|
|
197
245
|
|
|
198
246
|
if include_deployment_data is False:
|
|
199
247
|
payload.pop("deployment_data", None)
|
|
200
248
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if value is not None
|
|
205
|
-
or key == "organisation_id"
|
|
206
|
-
or key == "container_registry_profile_id"
|
|
207
|
-
}
|
|
249
|
+
# No None-stripping here: a key only reaches this point because the config
|
|
250
|
+
# declared it, and declaring `"organisation_id": null` means clear it.
|
|
251
|
+
return payload
|
|
208
252
|
|
|
209
253
|
|
|
210
254
|
def _should_export_ui(app_config) -> bool:
|
|
@@ -222,18 +266,15 @@ def _get_persisted_application_id(app_config, *, staging: bool) -> int | None:
|
|
|
222
266
|
return int(app_id) if app_id is not None else None
|
|
223
267
|
|
|
224
268
|
|
|
225
|
-
def
|
|
226
|
-
if
|
|
227
|
-
|
|
228
|
-
else:
|
|
229
|
-
app_config.id = application_id
|
|
230
|
-
|
|
231
|
-
app_config.save_to_disk()
|
|
269
|
+
def _resolve_application_id(client, app_config, *, staging: bool) -> int | None:
|
|
270
|
+
"""The application's id, from doover_config.json if it is pinned there, else
|
|
271
|
+
looked up by name.
|
|
232
272
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
273
|
+
Application names are globally unique, so the name identifies the app on its
|
|
274
|
+
own and new projects need no id in their config at all. Older configs still
|
|
275
|
+
pin one; that wins, so renaming `name` there keeps renaming the app instead
|
|
276
|
+
of forking a new one.
|
|
277
|
+
"""
|
|
237
278
|
app_id = _get_persisted_application_id(app_config, staging=staging)
|
|
238
279
|
if app_id is not None:
|
|
239
280
|
return app_id
|
|
@@ -248,15 +289,13 @@ def _resolve_existing_application_id(
|
|
|
248
289
|
item for item in page.results if getattr(item, "name", None) == app_config.name
|
|
249
290
|
]
|
|
250
291
|
if len(matches) > 1:
|
|
292
|
+
# Shouldn't happen — the control plane enforces name uniqueness — but a
|
|
293
|
+
# silent wrong pick here would publish over the wrong app.
|
|
251
294
|
raise typer.BadParameter(
|
|
252
295
|
f"Multiple applications found matching name '{app_config.name}'. Set the application id in doover_config.json."
|
|
253
296
|
)
|
|
254
297
|
if len(matches) == 1:
|
|
255
|
-
|
|
256
|
-
_persist_application_id(
|
|
257
|
-
app_config, staging=staging, application_id=application_id
|
|
258
|
-
)
|
|
259
|
-
return application_id
|
|
298
|
+
return int(matches[0].id)
|
|
260
299
|
return None
|
|
261
300
|
|
|
262
301
|
|
|
@@ -784,10 +823,12 @@ def put_widget(
|
|
|
784
823
|
client, renderer = get_state()
|
|
785
824
|
|
|
786
825
|
resolved_staging = _resolve_staging(staging)
|
|
787
|
-
application_id =
|
|
826
|
+
application_id = _resolve_application_id(
|
|
827
|
+
client, app_config, staging=resolved_staging
|
|
828
|
+
)
|
|
788
829
|
if application_id is None:
|
|
789
830
|
rich.print(
|
|
790
|
-
"[red]No application
|
|
831
|
+
f"[red]No application named '{app_config.name}' found. Publish the app first.[/red]"
|
|
791
832
|
)
|
|
792
833
|
raise typer.Exit(1)
|
|
793
834
|
|
|
@@ -926,7 +967,13 @@ def publish(
|
|
|
926
967
|
] = Path(),
|
|
927
968
|
build_container: Annotated[
|
|
928
969
|
bool,
|
|
929
|
-
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
|
+
),
|
|
930
977
|
] = False,
|
|
931
978
|
staging: Annotated[
|
|
932
979
|
bool | None,
|
|
@@ -969,9 +1016,33 @@ def publish(
|
|
|
969
1016
|
release: Annotated[
|
|
970
1017
|
bool,
|
|
971
1018
|
typer.Option(
|
|
972
|
-
help="Release
|
|
1019
|
+
help="Release the application after publishing. Disable with --no-release.",
|
|
973
1020
|
),
|
|
974
1021
|
] = True,
|
|
1022
|
+
digest: Annotated[
|
|
1023
|
+
str | None,
|
|
1024
|
+
typer.Option(
|
|
1025
|
+
help="Pushed image digest (e.g. sha256:…) to use for the release. "
|
|
1026
|
+
"For container builds this overrides the automatically detected digest."
|
|
1027
|
+
),
|
|
1028
|
+
] = None,
|
|
1029
|
+
tag: Annotated[
|
|
1030
|
+
str | None,
|
|
1031
|
+
typer.Option(help="Optional version label (e.g. semver / image tag)."),
|
|
1032
|
+
] = None,
|
|
1033
|
+
commit: Annotated[
|
|
1034
|
+
str | None,
|
|
1035
|
+
typer.Option(
|
|
1036
|
+
help="Source git commit SHA for the release. Defaults to the repo's HEAD."
|
|
1037
|
+
),
|
|
1038
|
+
] = None,
|
|
1039
|
+
notes: Annotated[str, typer.Option(help="Optional release notes.")] = "",
|
|
1040
|
+
alpha: Annotated[
|
|
1041
|
+
bool,
|
|
1042
|
+
typer.Option(
|
|
1043
|
+
help="Publish the release as a prerelease that is not auto-selected as 'Latest'."
|
|
1044
|
+
),
|
|
1045
|
+
] = False,
|
|
975
1046
|
app_name: Annotated[
|
|
976
1047
|
str | None,
|
|
977
1048
|
typer.Option(
|
|
@@ -1049,29 +1120,24 @@ def publish(
|
|
|
1049
1120
|
|
|
1050
1121
|
try:
|
|
1051
1122
|
with renderer.loading("Publishing application..."):
|
|
1052
|
-
# application_id = _resolve_existing_application_id(
|
|
1053
|
-
# client,
|
|
1054
|
-
# app_config,
|
|
1055
|
-
# staging=resolved_staging,
|
|
1056
|
-
# )
|
|
1057
1123
|
application_id = _get_persisted_application_id(
|
|
1058
1124
|
app_config, staging=resolved_staging
|
|
1059
1125
|
)
|
|
1060
|
-
if application_id is None:
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
staging=resolved_staging,
|
|
1067
|
-
application_id=application_id,
|
|
1126
|
+
if application_id is not None:
|
|
1127
|
+
# Config pins an id — update that app directly, so changing
|
|
1128
|
+
# `name` renames it rather than creating a second app.
|
|
1129
|
+
response = client.applications.partial(
|
|
1130
|
+
str(application_id),
|
|
1131
|
+
body=dict(payload),
|
|
1068
1132
|
)
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1133
|
+
else:
|
|
1134
|
+
# No id: POST and let the control plane upsert on the globally
|
|
1135
|
+
# unique name — creating the app if the name is free, updating it
|
|
1136
|
+
# if we're allowed to. Nothing is written back to
|
|
1137
|
+
# doover_config.json, so the repo stays the source of truth and a
|
|
1138
|
+
# fresh clone publishes identically.
|
|
1139
|
+
response = client.applications.create(body=dict(payload))
|
|
1140
|
+
application_id = int(response.id)
|
|
1075
1141
|
except typer.Exit:
|
|
1076
1142
|
raise
|
|
1077
1143
|
except click.Abort:
|
|
@@ -1105,7 +1171,7 @@ def publish(
|
|
|
1105
1171
|
)
|
|
1106
1172
|
rich.print("[green]Widget uploaded.[/green]")
|
|
1107
1173
|
|
|
1108
|
-
if app_config.type in
|
|
1174
|
+
if app_config.type in PACKAGE_APP_TYPES:
|
|
1109
1175
|
if build_package:
|
|
1110
1176
|
print("\nBuilding package.zip for upload...")
|
|
1111
1177
|
shell_run("./build.sh", cwd=root_fp)
|
|
@@ -1125,6 +1191,11 @@ def publish(
|
|
|
1125
1191
|
ctx.invoke(
|
|
1126
1192
|
release_command,
|
|
1127
1193
|
app_fp=root_fp,
|
|
1194
|
+
digest=digest,
|
|
1195
|
+
tag=tag,
|
|
1196
|
+
commit=commit,
|
|
1197
|
+
notes=notes,
|
|
1198
|
+
alpha=alpha,
|
|
1128
1199
|
staging=staging,
|
|
1129
1200
|
app_name=app_name,
|
|
1130
1201
|
)
|
|
@@ -1135,28 +1206,68 @@ def publish(
|
|
|
1135
1206
|
)
|
|
1136
1207
|
raise typer.Exit(0)
|
|
1137
1208
|
|
|
1138
|
-
|
|
1209
|
+
detected_digest: str | None = None
|
|
1139
1210
|
if build_container:
|
|
1140
1211
|
image_name = _require_publish_value("image_name", payload.get("image_name"))
|
|
1141
1212
|
build_args = getattr(app_config, "build_args", "") or ""
|
|
1142
1213
|
if build_args != "NO_BUILD":
|
|
1143
1214
|
print("\nBuilding and pushing container image to the registry...")
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
)
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
+
)
|
|
1152
1258
|
else:
|
|
1153
1259
|
print("App requested to not build. Skipping build step.")
|
|
1154
1260
|
|
|
1155
|
-
|
|
1261
|
+
release_digest = digest or detected_digest
|
|
1262
|
+
if release and release_digest is not None:
|
|
1156
1263
|
ctx.invoke(
|
|
1157
1264
|
release_command,
|
|
1158
1265
|
app_fp=root_fp,
|
|
1159
|
-
digest=
|
|
1266
|
+
digest=release_digest,
|
|
1267
|
+
tag=tag,
|
|
1268
|
+
commit=commit,
|
|
1269
|
+
notes=notes,
|
|
1270
|
+
alpha=alpha,
|
|
1160
1271
|
staging=staging,
|
|
1161
1272
|
app_name=app_name,
|
|
1162
1273
|
)
|
|
@@ -1165,6 +1276,89 @@ def publish(
|
|
|
1165
1276
|
renderer.render(response)
|
|
1166
1277
|
|
|
1167
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
|
+
|
|
1168
1362
|
@app.command(name="release")
|
|
1169
1363
|
def release_command(
|
|
1170
1364
|
app_fp: Annotated[
|
|
@@ -1215,7 +1409,13 @@ def release_command(
|
|
|
1215
1409
|
app_config = get_app_config(root_fp, app_name=app_name)
|
|
1216
1410
|
resolved_staging = _resolve_staging(staging)
|
|
1217
1411
|
|
|
1218
|
-
|
|
1412
|
+
client, renderer = get_state()
|
|
1413
|
+
|
|
1414
|
+
# `release` runs as its own process (CI invokes it after `publish`), so it
|
|
1415
|
+
# resolves the id itself — by name when the config doesn't pin one.
|
|
1416
|
+
application_id = _resolve_application_id(
|
|
1417
|
+
client, app_config, staging=resolved_staging
|
|
1418
|
+
)
|
|
1219
1419
|
if application_id is None:
|
|
1220
1420
|
rich.print(
|
|
1221
1421
|
"[red]Application has not been published yet. Run `doover app publish` first.[/red]"
|
|
@@ -1230,7 +1430,6 @@ def release_command(
|
|
|
1230
1430
|
|
|
1231
1431
|
resolved_commit = commit if commit is not None else _detect_git_commit(root_fp)
|
|
1232
1432
|
|
|
1233
|
-
client, renderer = get_state()
|
|
1234
1433
|
with renderer.loading("Creating application version..."):
|
|
1235
1434
|
version = client.create_application_version(
|
|
1236
1435
|
application_id,
|
|
@@ -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
|