doover-cli 0.5.4__tar.gz → 0.6.1__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.4 → doover_cli-0.6.1}/PKG-INFO +1 -1
- {doover_cli-0.5.4 → doover_cli-0.6.1}/pyproject.toml +5 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/__init__.py +1 -1
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/apps.py +145 -65
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/apps.py +138 -57
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_apps.py +156 -19
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_utils_apps.py +76 -34
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/CONTRIBUTING.md +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/lint-test.yml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/release-brew.yml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/release-pypi.yml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.github/workflows/release.yml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.gitignore +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/.pre-commit-config.yaml +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/AGENT.md +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/LICENSE +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/README.md +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/debian/changelog +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/debian/control +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/debian/rules +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/docs/user_stories.md +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/agent.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/__init__.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/auth.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/errors.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/api/session.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/app_install.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/device.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/device_type.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/apps/tunnel.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/channel.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/colours.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/config_schema.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/dda_logs.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/doover_config.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/grpc.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/login.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/__init__.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_base.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_basic.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_default.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/renderer/_json.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/report.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/simulator.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/ui_schema.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/user.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/__init__.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/api.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/context.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/__init__.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/commands.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/lookup.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/prompting.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/schema.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/crud/values.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/errors.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/formatters.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/misc.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/parsers.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/prompt.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/sentry.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/shell_commands.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/src/doover_cli/utils/state.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/__init__.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/conftest.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_agent.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_app_install.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_auth_integration.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_auth_unit.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_basic.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_commands.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_lookup.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_prompting.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_schema.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_crud_values.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_default_renderer.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device_cli_integration.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device_type.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_device_type_cli_integration.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_prompt.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_sentry.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/tests/test_user.py +0 -0
- {doover_cli-0.5.4 → doover_cli-0.6.1}/uv.lock +0 -0
|
@@ -40,3 +40,8 @@ dev = [
|
|
|
40
40
|
|
|
41
41
|
[tool.ty.environment]
|
|
42
42
|
python = "./.venv"
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
# Pin the rule set explicitly so a newer ruff release doesn't silently enable
|
|
46
|
+
# hundreds of new rules in CI. These are ruff's historical defaults.
|
|
47
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -132,6 +132,26 @@ def _push_container(image_name: str) -> None:
|
|
|
132
132
|
shell_run(f"docker push {image_name}")
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def _get_image_digest(image_name: str) -> str | None:
|
|
136
|
+
"""Best-effort repo digest (sha256:…) of a locally-tagged image after push."""
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image_name],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
check=False,
|
|
143
|
+
)
|
|
144
|
+
except OSError:
|
|
145
|
+
return None
|
|
146
|
+
if result.returncode != 0:
|
|
147
|
+
return None
|
|
148
|
+
# RepoDigests entries look like "ghcr.io/org/app@sha256:…".
|
|
149
|
+
repo_digest = result.stdout.strip()
|
|
150
|
+
if "@" in repo_digest:
|
|
151
|
+
return repo_digest.split("@", 1)[1]
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
135
155
|
def _require_publish_value(
|
|
136
156
|
name: str, value, *, allow_empty_list: bool = False, allow_none: bool = False
|
|
137
157
|
):
|
|
@@ -157,34 +177,36 @@ def _build_application_payload(
|
|
|
157
177
|
payload = app_config.to_request_payload(
|
|
158
178
|
include_deployment_data=include_deployment_data,
|
|
159
179
|
is_staging=staging,
|
|
160
|
-
method="POST",
|
|
161
180
|
)
|
|
162
181
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
182
|
+
# `name` is the only field the CLI insists on -- it is what the control plane
|
|
183
|
+
# upserts on, so without it there is nothing to publish to.
|
|
184
|
+
if not payload.get("name"):
|
|
185
|
+
raise typer.BadParameter("name is required in doover_config.json.")
|
|
186
|
+
|
|
187
|
+
# Everything else is validated only if the file declares it: an omitted field
|
|
188
|
+
# is not an error, it just isn't part of this patch, and the control plane
|
|
189
|
+
# still rejects a *create* that is missing something it requires. A FIX-ME is
|
|
190
|
+
# always caught, since that is never a deliberate value.
|
|
191
|
+
for field, kwargs in (
|
|
192
|
+
("name", {}),
|
|
193
|
+
("display_name", {}),
|
|
194
|
+
("description", {}),
|
|
195
|
+
("type", {}),
|
|
196
|
+
("visibility", {}),
|
|
197
|
+
("organisation_id", {"allow_none": True}),
|
|
198
|
+
("container_registry_profile_id", {"allow_none": True}),
|
|
199
|
+
("depends_on", {"allow_empty_list": True}),
|
|
200
|
+
):
|
|
201
|
+
if field in payload:
|
|
202
|
+
_require_publish_value(field, payload[field], **kwargs)
|
|
177
203
|
|
|
178
204
|
if include_deployment_data is False:
|
|
179
205
|
payload.pop("deployment_data", None)
|
|
180
206
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if value is not None
|
|
185
|
-
or key == "organisation_id"
|
|
186
|
-
or key == "container_registry_profile_id"
|
|
187
|
-
}
|
|
207
|
+
# No None-stripping here: a key only reaches this point because the config
|
|
208
|
+
# declared it, and declaring `"organisation_id": null` means clear it.
|
|
209
|
+
return payload
|
|
188
210
|
|
|
189
211
|
|
|
190
212
|
def _should_export_ui(app_config) -> bool:
|
|
@@ -202,18 +224,15 @@ def _get_persisted_application_id(app_config, *, staging: bool) -> int | None:
|
|
|
202
224
|
return int(app_id) if app_id is not None else None
|
|
203
225
|
|
|
204
226
|
|
|
205
|
-
def
|
|
206
|
-
if
|
|
207
|
-
|
|
208
|
-
else:
|
|
209
|
-
app_config.id = application_id
|
|
210
|
-
|
|
211
|
-
app_config.save_to_disk()
|
|
212
|
-
|
|
227
|
+
def _resolve_application_id(client, app_config, *, staging: bool) -> int | None:
|
|
228
|
+
"""The application's id, from doover_config.json if it is pinned there, else
|
|
229
|
+
looked up by name.
|
|
213
230
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
231
|
+
Application names are globally unique, so the name identifies the app on its
|
|
232
|
+
own and new projects need no id in their config at all. Older configs still
|
|
233
|
+
pin one; that wins, so renaming `name` there keeps renaming the app instead
|
|
234
|
+
of forking a new one.
|
|
235
|
+
"""
|
|
217
236
|
app_id = _get_persisted_application_id(app_config, staging=staging)
|
|
218
237
|
if app_id is not None:
|
|
219
238
|
return app_id
|
|
@@ -228,15 +247,13 @@ def _resolve_existing_application_id(
|
|
|
228
247
|
item for item in page.results if getattr(item, "name", None) == app_config.name
|
|
229
248
|
]
|
|
230
249
|
if len(matches) > 1:
|
|
250
|
+
# Shouldn't happen — the control plane enforces name uniqueness — but a
|
|
251
|
+
# silent wrong pick here would publish over the wrong app.
|
|
231
252
|
raise typer.BadParameter(
|
|
232
253
|
f"Multiple applications found matching name '{app_config.name}'. Set the application id in doover_config.json."
|
|
233
254
|
)
|
|
234
255
|
if len(matches) == 1:
|
|
235
|
-
|
|
236
|
-
_persist_application_id(
|
|
237
|
-
app_config, staging=staging, application_id=application_id
|
|
238
|
-
)
|
|
239
|
-
return application_id
|
|
256
|
+
return int(matches[0].id)
|
|
240
257
|
return None
|
|
241
258
|
|
|
242
259
|
|
|
@@ -764,10 +781,12 @@ def put_widget(
|
|
|
764
781
|
client, renderer = get_state()
|
|
765
782
|
|
|
766
783
|
resolved_staging = _resolve_staging(staging)
|
|
767
|
-
application_id =
|
|
784
|
+
application_id = _resolve_application_id(
|
|
785
|
+
client, app_config, staging=resolved_staging
|
|
786
|
+
)
|
|
768
787
|
if application_id is None:
|
|
769
788
|
rich.print(
|
|
770
|
-
"[red]No application
|
|
789
|
+
f"[red]No application named '{app_config.name}' found. Publish the app first.[/red]"
|
|
771
790
|
)
|
|
772
791
|
raise typer.Exit(1)
|
|
773
792
|
|
|
@@ -946,6 +965,36 @@ def publish(
|
|
|
946
965
|
"when publishing several apps that share a single build.",
|
|
947
966
|
),
|
|
948
967
|
] = True,
|
|
968
|
+
release: Annotated[
|
|
969
|
+
bool,
|
|
970
|
+
typer.Option(
|
|
971
|
+
help="Release the application after publishing. Disable with --no-release.",
|
|
972
|
+
),
|
|
973
|
+
] = True,
|
|
974
|
+
digest: Annotated[
|
|
975
|
+
str | None,
|
|
976
|
+
typer.Option(
|
|
977
|
+
help="Pushed image digest (e.g. sha256:…) to use for the release. "
|
|
978
|
+
"For container builds this overrides the automatically detected digest."
|
|
979
|
+
),
|
|
980
|
+
] = None,
|
|
981
|
+
tag: Annotated[
|
|
982
|
+
str | None,
|
|
983
|
+
typer.Option(help="Optional version label (e.g. semver / image tag)."),
|
|
984
|
+
] = None,
|
|
985
|
+
commit: Annotated[
|
|
986
|
+
str | None,
|
|
987
|
+
typer.Option(
|
|
988
|
+
help="Source git commit SHA for the release. Defaults to the repo's HEAD."
|
|
989
|
+
),
|
|
990
|
+
] = None,
|
|
991
|
+
notes: Annotated[str, typer.Option(help="Optional release notes.")] = "",
|
|
992
|
+
alpha: Annotated[
|
|
993
|
+
bool,
|
|
994
|
+
typer.Option(
|
|
995
|
+
help="Publish the release as a prerelease that is not auto-selected as 'Latest'."
|
|
996
|
+
),
|
|
997
|
+
] = False,
|
|
949
998
|
app_name: Annotated[
|
|
950
999
|
str | None,
|
|
951
1000
|
typer.Option(
|
|
@@ -1023,29 +1072,24 @@ def publish(
|
|
|
1023
1072
|
|
|
1024
1073
|
try:
|
|
1025
1074
|
with renderer.loading("Publishing application..."):
|
|
1026
|
-
# application_id = _resolve_existing_application_id(
|
|
1027
|
-
# client,
|
|
1028
|
-
# app_config,
|
|
1029
|
-
# staging=resolved_staging,
|
|
1030
|
-
# )
|
|
1031
1075
|
application_id = _get_persisted_application_id(
|
|
1032
1076
|
app_config, staging=resolved_staging
|
|
1033
1077
|
)
|
|
1034
|
-
if application_id is None:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
staging=resolved_staging,
|
|
1041
|
-
application_id=application_id,
|
|
1078
|
+
if application_id is not None:
|
|
1079
|
+
# Config pins an id — update that app directly, so changing
|
|
1080
|
+
# `name` renames it rather than creating a second app.
|
|
1081
|
+
response = client.applications.partial(
|
|
1082
|
+
str(application_id),
|
|
1083
|
+
body=dict(payload),
|
|
1042
1084
|
)
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1085
|
+
else:
|
|
1086
|
+
# No id: POST and let the control plane upsert on the globally
|
|
1087
|
+
# unique name — creating the app if the name is free, updating it
|
|
1088
|
+
# if we're allowed to. Nothing is written back to
|
|
1089
|
+
# doover_config.json, so the repo stays the source of truth and a
|
|
1090
|
+
# fresh clone publishes identically.
|
|
1091
|
+
response = client.applications.create(body=dict(payload))
|
|
1092
|
+
application_id = int(response.id)
|
|
1049
1093
|
except typer.Exit:
|
|
1050
1094
|
raise
|
|
1051
1095
|
except click.Abort:
|
|
@@ -1093,12 +1137,28 @@ def publish(
|
|
|
1093
1137
|
application_id,
|
|
1094
1138
|
root_fp,
|
|
1095
1139
|
)
|
|
1096
|
-
print(
|
|
1097
|
-
"Done! Run `doover app release` to publish an immutable version of this app."
|
|
1098
|
-
)
|
|
1140
|
+
print("Done!")
|
|
1099
1141
|
renderer.render(processor_response or response)
|
|
1142
|
+
if release:
|
|
1143
|
+
ctx.invoke(
|
|
1144
|
+
release_command,
|
|
1145
|
+
app_fp=root_fp,
|
|
1146
|
+
digest=digest,
|
|
1147
|
+
tag=tag,
|
|
1148
|
+
commit=commit,
|
|
1149
|
+
notes=notes,
|
|
1150
|
+
alpha=alpha,
|
|
1151
|
+
staging=staging,
|
|
1152
|
+
app_name=app_name,
|
|
1153
|
+
)
|
|
1154
|
+
else:
|
|
1155
|
+
rich.print(
|
|
1156
|
+
"Run [blue]doover app release[/blue] to publish an immutable "
|
|
1157
|
+
"version of this app."
|
|
1158
|
+
)
|
|
1100
1159
|
raise typer.Exit(0)
|
|
1101
1160
|
|
|
1161
|
+
detected_digest: str | None = None
|
|
1102
1162
|
if build_container:
|
|
1103
1163
|
image_name = _require_publish_value("image_name", payload.get("image_name"))
|
|
1104
1164
|
build_args = getattr(app_config, "build_args", "") or ""
|
|
@@ -1111,15 +1171,30 @@ def publish(
|
|
|
1111
1171
|
image_name=image_name,
|
|
1112
1172
|
)
|
|
1113
1173
|
_push_container(image_name)
|
|
1174
|
+
detected_digest = _get_image_digest(image_name)
|
|
1114
1175
|
else:
|
|
1115
1176
|
print("App requested to not build. Skipping build step.")
|
|
1116
1177
|
|
|
1178
|
+
release_digest = digest or detected_digest
|
|
1179
|
+
if release and release_digest is not None:
|
|
1180
|
+
ctx.invoke(
|
|
1181
|
+
release_command,
|
|
1182
|
+
app_fp=root_fp,
|
|
1183
|
+
digest=release_digest,
|
|
1184
|
+
tag=tag,
|
|
1185
|
+
commit=commit,
|
|
1186
|
+
notes=notes,
|
|
1187
|
+
alpha=alpha,
|
|
1188
|
+
staging=staging,
|
|
1189
|
+
app_name=app_name,
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1117
1192
|
print("\n\nDone!")
|
|
1118
1193
|
renderer.render(response)
|
|
1119
1194
|
|
|
1120
1195
|
|
|
1121
1196
|
@app.command(name="release")
|
|
1122
|
-
def
|
|
1197
|
+
def release_command(
|
|
1123
1198
|
app_fp: Annotated[
|
|
1124
1199
|
Path, typer.Argument(help="Path to the application directory.")
|
|
1125
1200
|
] = Path(),
|
|
@@ -1168,7 +1243,13 @@ def release(
|
|
|
1168
1243
|
app_config = get_app_config(root_fp, app_name=app_name)
|
|
1169
1244
|
resolved_staging = _resolve_staging(staging)
|
|
1170
1245
|
|
|
1171
|
-
|
|
1246
|
+
client, renderer = get_state()
|
|
1247
|
+
|
|
1248
|
+
# `release` runs as its own process (CI invokes it after `publish`), so it
|
|
1249
|
+
# resolves the id itself — by name when the config doesn't pin one.
|
|
1250
|
+
application_id = _resolve_application_id(
|
|
1251
|
+
client, app_config, staging=resolved_staging
|
|
1252
|
+
)
|
|
1172
1253
|
if application_id is None:
|
|
1173
1254
|
rich.print(
|
|
1174
1255
|
"[red]Application has not been published yet. Run `doover app publish` first.[/red]"
|
|
@@ -1183,7 +1264,6 @@ def release(
|
|
|
1183
1264
|
|
|
1184
1265
|
resolved_commit = commit if commit is not None else _detect_git_commit(root_fp)
|
|
1185
1266
|
|
|
1186
|
-
client, renderer = get_state()
|
|
1187
1267
|
with renderer.loading("Creating application version..."):
|
|
1188
1268
|
version = client.create_application_version(
|
|
1189
1269
|
application_id,
|
|
@@ -6,6 +6,7 @@ import contextlib
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
import rich
|
|
9
10
|
import typer
|
|
10
11
|
import questionary
|
|
11
12
|
from pydoover.models.control import Application as ControlApplication
|
|
@@ -13,11 +14,62 @@ from pydoover.models.control import Application as ControlApplication
|
|
|
13
14
|
from .shell_commands import run
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Legacy spellings of the two reference fields. Still read so existing configs
|
|
18
|
+
# keep working, but warned about — the canonical name is the target value.
|
|
19
|
+
DEPRECATED_REFS: dict[str, str] = {
|
|
20
|
+
"owner_org_id": "organisation_id",
|
|
21
|
+
"owner_org": "organisation_id",
|
|
22
|
+
"organisation": "organisation_id",
|
|
23
|
+
"container_registry_profile": "container_registry_profile_id",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Fields that do nothing: left behind by an older CLI that wrote them, or
|
|
27
|
+
# hand-written in the belief they did something. Maps to a replacement where one
|
|
28
|
+
# exists, or None where the field should simply be deleted.
|
|
29
|
+
RETIRED_FIELDS: dict[str, str | None] = {
|
|
30
|
+
# never read, in any version
|
|
31
|
+
"owner_org_key": "organisation_id",
|
|
32
|
+
"organisation_key": "organisation_id",
|
|
33
|
+
"container_registry_profile_key": "container_registry_profile_id",
|
|
34
|
+
"code_repo_key": None,
|
|
35
|
+
# retired here: never sent anywhere and never drove any behaviour
|
|
36
|
+
"code_repo_id": None,
|
|
37
|
+
"code_repo": None,
|
|
38
|
+
"repo_branch": None,
|
|
39
|
+
# cloud-owned state a config file has no business asserting. `lambda_arn` in
|
|
40
|
+
# particular used to be published, so a stale committed value could point an
|
|
41
|
+
# app at the wrong function.
|
|
42
|
+
"lambda_arn": None,
|
|
43
|
+
"stars": None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Never included in a publish payload, whatever the config says.
|
|
47
|
+
NEVER_SENT = frozenset({"lambda_arn", "stars"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_ref(data: dict[str, Any], canonical: str) -> Any:
|
|
51
|
+
"""Read a reference field by its canonical name, falling back to the legacy
|
|
52
|
+
spellings and telling the user to rename them."""
|
|
53
|
+
if canonical in data:
|
|
54
|
+
return data[canonical]
|
|
55
|
+
|
|
56
|
+
for legacy, target in DEPRECATED_REFS.items():
|
|
57
|
+
if target == canonical and legacy in data:
|
|
58
|
+
rich.print(
|
|
59
|
+
f"[yellow]doover_config.json: '{legacy}' is deprecated -- rename it "
|
|
60
|
+
f"to '{canonical}'.[/yellow]"
|
|
61
|
+
)
|
|
62
|
+
return data[legacy]
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def warn_retired_fields(data: dict[str, Any]) -> None:
|
|
67
|
+
for key in sorted(set(RETIRED_FIELDS) & set(data)):
|
|
68
|
+
replacement = RETIRED_FIELDS[key]
|
|
69
|
+
advice = f"use '{replacement}'" if replacement else "delete it"
|
|
70
|
+
rich.print(
|
|
71
|
+
f"[yellow]doover_config.json: '{key}' has no effect -- {advice}.[/yellow]"
|
|
72
|
+
)
|
|
21
73
|
|
|
22
74
|
|
|
23
75
|
@contextlib.contextmanager
|
|
@@ -61,18 +113,14 @@ class LocalApplication(ControlApplication):
|
|
|
61
113
|
depends_on: list[str] | None = None,
|
|
62
114
|
organisation: dict[str, Any] | str | int | None = None,
|
|
63
115
|
approx_installs: int | None = None,
|
|
64
|
-
stars: int | None = None,
|
|
65
116
|
container_registry_profile: dict[str, Any] | str | int | None = None,
|
|
66
117
|
deployment_data: str | None = None,
|
|
67
118
|
image_name: str | None = None,
|
|
68
|
-
lambda_arn: str | None = None,
|
|
69
119
|
lambda_config: Any | None = None,
|
|
70
120
|
config_profiles: list[Any] | None = None,
|
|
71
121
|
icon_url: str | None = None,
|
|
72
122
|
banner_url: str | None = None,
|
|
73
123
|
key: str | None = None,
|
|
74
|
-
code_repo_id: str | int | None = None,
|
|
75
|
-
repo_branch: str | None = None,
|
|
76
124
|
build_args: str | None = None,
|
|
77
125
|
widget: str | Path | None = None,
|
|
78
126
|
build_widget_command: str | None = None,
|
|
@@ -99,11 +147,9 @@ class LocalApplication(ControlApplication):
|
|
|
99
147
|
depends_on=depends_on,
|
|
100
148
|
organisation=organisation,
|
|
101
149
|
approx_installs=approx_installs,
|
|
102
|
-
stars=stars,
|
|
103
150
|
container_registry_profile=container_registry_profile,
|
|
104
151
|
deployment_data=deployment_data,
|
|
105
152
|
image_name=image_name,
|
|
106
|
-
lambda_arn=lambda_arn,
|
|
107
153
|
lambda_config=lambda_config,
|
|
108
154
|
config_profiles=config_profiles,
|
|
109
155
|
icon_url=icon_url,
|
|
@@ -131,8 +177,6 @@ class LocalApplication(ControlApplication):
|
|
|
131
177
|
|
|
132
178
|
self.build_widget_command = build_widget_command
|
|
133
179
|
self.key = key
|
|
134
|
-
self.code_repo_id = code_repo_id
|
|
135
|
-
self.repo_branch = repo_branch or "main"
|
|
136
180
|
self.build_args = build_args
|
|
137
181
|
self.export_config_command = export_config_command
|
|
138
182
|
self.export_ui_command = export_ui_command
|
|
@@ -141,16 +185,41 @@ class LocalApplication(ControlApplication):
|
|
|
141
185
|
self.staging_config = staging_config or {}
|
|
142
186
|
self.base_path = base_path
|
|
143
187
|
self.deployment_folder = deployment_folder or "deployment"
|
|
188
|
+
# Set by from_config. None means "built in code, not parsed from a file",
|
|
189
|
+
# in which case to_request_payload sends everything as it always did.
|
|
190
|
+
self._provided_keys: set[str] | None = None
|
|
191
|
+
self._null_keys: set[str] = set()
|
|
144
192
|
|
|
145
193
|
@property
|
|
146
194
|
def src_directory(self) -> Path:
|
|
147
195
|
return (self.base_path or Path()) / "src" / (self.name or "").replace("-", "_")
|
|
148
196
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
197
|
+
# Legacy spellings still land on one request field, so provided-key tracking
|
|
198
|
+
# has to normalise them.
|
|
199
|
+
_KEY_ALIASES = DEPRECATED_REFS
|
|
200
|
+
|
|
201
|
+
# The PATCH shape, which marks nothing as required. Publishing sends only what
|
|
202
|
+
# doover_config.json declares, so `name` -- the key the control plane upserts
|
|
203
|
+
# on -- is the one field the CLI insists on. Everything else is the server's
|
|
204
|
+
# problem: it still rejects a *create* that is missing something required,
|
|
205
|
+
# while an update simply leaves the undeclared fields alone.
|
|
206
|
+
_REQUEST_VERSION = "PatchedApplicationSerializerDetailRequest"
|
|
207
|
+
|
|
208
|
+
# Never filtered out: `name` identifies the app, and deployment_data is built
|
|
209
|
+
# from the deployment/ folder rather than read from the JSON.
|
|
210
|
+
_ALWAYS_SENT = frozenset({"name", "deployment_data"})
|
|
211
|
+
|
|
212
|
+
def _normalise_key(self, key: str) -> str:
|
|
213
|
+
return self._KEY_ALIASES.get(key, key)
|
|
214
|
+
|
|
215
|
+
def _provided_payload_keys(self) -> set[str] | None:
|
|
216
|
+
if self._provided_keys is None:
|
|
217
|
+
return None
|
|
218
|
+
return {self._normalise_key(key) for key in self._provided_keys}
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def _request_payload_keys(cls) -> set[str]:
|
|
222
|
+
fields = ControlApplication._versions[cls._REQUEST_VERSION]["fields"]
|
|
154
223
|
keys = set()
|
|
155
224
|
for field_name, config in fields.items():
|
|
156
225
|
keys.add(config.get("output_id", field_name))
|
|
@@ -167,7 +236,8 @@ class LocalApplication(ControlApplication):
|
|
|
167
236
|
|
|
168
237
|
@classmethod
|
|
169
238
|
def from_config(cls, data: dict[str, Any], app_base: Path) -> "LocalApplication":
|
|
170
|
-
|
|
239
|
+
warn_retired_fields(data)
|
|
240
|
+
instance = cls(
|
|
171
241
|
id=data.get("id"),
|
|
172
242
|
key=data.get("key"),
|
|
173
243
|
name=data.get("name"),
|
|
@@ -178,17 +248,12 @@ class LocalApplication(ControlApplication):
|
|
|
178
248
|
description=data.get("description"),
|
|
179
249
|
long_description=data.get("long_description"),
|
|
180
250
|
depends_on=list(data.get("depends_on", [])),
|
|
181
|
-
organisation=
|
|
182
|
-
or get_id_or_key(data, "organisation"),
|
|
183
|
-
code_repo_id=get_id_or_key(data, "code_repo"),
|
|
184
|
-
repo_branch=data.get("repo_branch"),
|
|
251
|
+
organisation=resolve_ref(data, "organisation_id"),
|
|
185
252
|
image_name=data.get("image_name"),
|
|
186
253
|
build_args=data.get("build_args"),
|
|
187
|
-
container_registry_profile=
|
|
188
|
-
data,
|
|
189
|
-
"container_registry_profile",
|
|
254
|
+
container_registry_profile=resolve_ref(
|
|
255
|
+
data, "container_registry_profile_id"
|
|
190
256
|
),
|
|
191
|
-
lambda_arn=data.get("lambda_arn"),
|
|
192
257
|
lambda_config=data.get("lambda_config"),
|
|
193
258
|
widget=data.get("widget"),
|
|
194
259
|
build_widget_command=data.get("build_widget_command"),
|
|
@@ -204,15 +269,37 @@ class LocalApplication(ControlApplication):
|
|
|
204
269
|
base_path=app_base,
|
|
205
270
|
deployment_folder=data.get("deployment_folder"),
|
|
206
271
|
)
|
|
272
|
+
# Which keys doover_config.json actually spelled out. Every attribute
|
|
273
|
+
# above defaults when absent, so without this the object can't tell
|
|
274
|
+
# "omitted" from "set to the default" — and publishing would push those
|
|
275
|
+
# defaults over whatever the cloud holds. See to_request_payload.
|
|
276
|
+
staging = data.get("staging_config") or {}
|
|
277
|
+
instance._provided_keys = set(data) | set(staging)
|
|
278
|
+
# Declared as an explicit null. `to_version` drops None-valued attributes,
|
|
279
|
+
# so these have to be put back by hand -- omitting a field and setting it
|
|
280
|
+
# to null are different instructions, and only the second one should
|
|
281
|
+
# clear the value in the cloud.
|
|
282
|
+
instance._null_keys = {
|
|
283
|
+
instance._normalise_key(key)
|
|
284
|
+
for source in (data, staging)
|
|
285
|
+
for key, value in source.items()
|
|
286
|
+
if value is None
|
|
287
|
+
}
|
|
288
|
+
return instance
|
|
207
289
|
|
|
208
290
|
def to_request_payload(
|
|
209
291
|
self,
|
|
210
292
|
*,
|
|
211
293
|
include_deployment_data: bool = False,
|
|
212
294
|
is_staging: bool = False,
|
|
213
|
-
method: str =
|
|
295
|
+
method: str | None = None,
|
|
296
|
+
only_provided: bool = True,
|
|
214
297
|
) -> dict[str, Any]:
|
|
215
|
-
|
|
298
|
+
# `method` is accepted for callers that still pass it, but the shape is
|
|
299
|
+
# always the PATCH one -- see _REQUEST_VERSION. The verb is decided by the
|
|
300
|
+
# publish flow (POST to upsert, PATCH when an id is pinned), not here.
|
|
301
|
+
_ = method
|
|
302
|
+
payload = self.to_version(self._REQUEST_VERSION)
|
|
216
303
|
if is_staging:
|
|
217
304
|
payload.update(
|
|
218
305
|
{
|
|
@@ -225,26 +312,43 @@ class LocalApplication(ControlApplication):
|
|
|
225
312
|
if include_deployment_data:
|
|
226
313
|
payload["deployment_data"] = self._deployment_data()
|
|
227
314
|
|
|
228
|
-
|
|
315
|
+
# doover_config.json is a patch, not a full replacement: a field it
|
|
316
|
+
# spells out is the source of truth, a field it omits keeps whatever the
|
|
317
|
+
# cloud already has. Without this, every attribute's fallback (False,
|
|
318
|
+
# None, []) would be published as if it were a deliberate choice —
|
|
319
|
+
# notably organisation_id, which would be nulled out on every publish.
|
|
320
|
+
provided = self._provided_payload_keys() if only_provided else None
|
|
321
|
+
if provided is not None:
|
|
322
|
+
payload = {
|
|
323
|
+
key: value
|
|
324
|
+
for key, value in payload.items()
|
|
325
|
+
if key in provided or key in self._ALWAYS_SENT
|
|
326
|
+
}
|
|
327
|
+
# `null` in the file is an instruction to clear the field, so restore
|
|
328
|
+
# the entries to_version dropped for being None.
|
|
329
|
+
for key in self._null_keys & self._request_payload_keys():
|
|
330
|
+
payload[key] = None
|
|
331
|
+
|
|
332
|
+
return {key: value for key, value in payload.items() if key not in NEVER_SENT}
|
|
229
333
|
|
|
230
334
|
def to_config_dict(
|
|
231
335
|
self,
|
|
232
336
|
include_deployment_data: bool = False,
|
|
233
337
|
is_staging: bool = False,
|
|
234
|
-
include_cloud_only: bool = False,
|
|
235
338
|
) -> dict[str, Any]:
|
|
236
339
|
data = self.to_request_payload(
|
|
237
340
|
include_deployment_data=include_deployment_data,
|
|
238
341
|
is_staging=is_staging,
|
|
239
342
|
method="POST",
|
|
343
|
+
# A config dump describes the app in full, so it is not a patch.
|
|
344
|
+
only_provided=False,
|
|
240
345
|
)
|
|
241
346
|
data.update(
|
|
242
347
|
{
|
|
243
348
|
"id": self.id,
|
|
244
349
|
"key": self.key,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"repo_branch": self.repo_branch,
|
|
350
|
+
# Deliberately no `owner_org_id` twin here — writing both is what
|
|
351
|
+
# left most repos carrying two spellings of the same field.
|
|
248
352
|
"build_args": self.build_args,
|
|
249
353
|
"image_name": self.image_name,
|
|
250
354
|
"lambda_config": self.lambda_config,
|
|
@@ -269,41 +373,18 @@ class LocalApplication(ControlApplication):
|
|
|
269
373
|
data["generate_ui"] = self.generate_ui
|
|
270
374
|
data["run_command"] = self.run_command
|
|
271
375
|
|
|
272
|
-
if include_cloud_only:
|
|
273
|
-
data["lambda_arn"] = self.lambda_arn
|
|
274
|
-
|
|
275
376
|
return data
|
|
276
377
|
|
|
277
378
|
def to_dict(
|
|
278
379
|
self,
|
|
279
380
|
include_deployment_data: bool = False,
|
|
280
381
|
is_staging: bool = False,
|
|
281
|
-
include_cloud_only: bool = False,
|
|
282
382
|
) -> dict[str, Any]:
|
|
283
383
|
return self.to_config_dict(
|
|
284
384
|
include_deployment_data=include_deployment_data,
|
|
285
385
|
is_staging=is_staging,
|
|
286
|
-
include_cloud_only=include_cloud_only,
|
|
287
386
|
)
|
|
288
387
|
|
|
289
|
-
def save_to_disk(self) -> None:
|
|
290
|
-
if self.base_path is None:
|
|
291
|
-
raise ValueError("Application base path is not set.")
|
|
292
|
-
|
|
293
|
-
config_path = self.base_path / "doover_config.json"
|
|
294
|
-
app_name = self.name or ""
|
|
295
|
-
data: dict[str, dict[str, Any]] = (
|
|
296
|
-
dict(json.loads(config_path.read_text()))
|
|
297
|
-
if config_path.exists()
|
|
298
|
-
else {app_name: {}}
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
upstream = self.to_config_dict(include_cloud_only=True)
|
|
302
|
-
upstream.pop("long_description", None)
|
|
303
|
-
|
|
304
|
-
data.setdefault(app_name, {}).update(**upstream)
|
|
305
|
-
config_path.write_text(json.dumps(data, indent=4))
|
|
306
|
-
|
|
307
388
|
|
|
308
389
|
def get_app_directory(root: Path | None = None) -> Path:
|
|
309
390
|
root_fp = root or Path()
|