blobhub-cli 0.3.0__tar.gz → 0.4.0__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.
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/PKG-INFO +2 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/README.md +1 -1
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__init__.py +1 -1
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/client.py +6 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/commands.py +14 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/app.py +2 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/codes.py +0 -1
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/blob.py +34 -40
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/execute.py +9 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/scheduler.py +32 -24
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/workflow.py +1 -1
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/ids.py +24 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/limits.py +0 -2
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/base.py +19 -3
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/resolver.py +18 -19
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/scheduler.py +15 -1
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/references.py +41 -28
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/.gitignore +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/LICENSE +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/pyproject.toml +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__main__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/errors.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/reference.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/auth.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/completion.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/doctor.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/eject.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/allowlist.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/emit.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/graph.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/sandbox.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/credentials.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/settings.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/sources.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/console.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/drift.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/eject.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/hashing.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/model.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/exit.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/io.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/workflow.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/statestore.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/__init__.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/definitions.py +0 -0
- {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/executions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: blobhub-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: BlobHub command line interface — work with blobs, revisions, and their contents from the terminal
|
|
5
5
|
Project-URL: Homepage, https://blobhub.io/
|
|
6
6
|
Project-URL: Documentation, https://docs.blobhub.io/
|
|
@@ -151,7 +151,7 @@ my-project/
|
|
|
151
151
|
type: workflow_blob_deployment
|
|
152
152
|
version: "1.0"
|
|
153
153
|
blob: acme-corp/checkout # <org>/<blob>, or a bare <blob> plus --org/BLOBHUB_ORG
|
|
154
|
-
revision:
|
|
154
|
+
revision: default # the blob's default revision, or a pinned revision UUID
|
|
155
155
|
definitions:
|
|
156
156
|
- alias: checkout_flow
|
|
157
157
|
source: definitions/checkout_flow.json
|
|
@@ -98,7 +98,7 @@ my-project/
|
|
|
98
98
|
type: workflow_blob_deployment
|
|
99
99
|
version: "1.0"
|
|
100
100
|
blob: acme-corp/checkout # <org>/<blob>, or a bare <blob> plus --org/BLOBHUB_ORG
|
|
101
|
-
revision:
|
|
101
|
+
revision: default # the blob's default revision, or a pinned revision UUID
|
|
102
102
|
definitions:
|
|
103
103
|
- alias: checkout_flow
|
|
104
104
|
source: definitions/checkout_flow.json
|
|
@@ -57,8 +57,12 @@ class Client:
|
|
|
57
57
|
def __exit__(self, *exc_info: object) -> None:
|
|
58
58
|
self._http.close()
|
|
59
59
|
|
|
60
|
-
def get(self, path: str) -> dict:
|
|
61
|
-
|
|
60
|
+
def get(self, path: str, params: dict[str, object] | None = None) -> dict:
|
|
61
|
+
# A `None` entry is dropped rather than sent: the paged routes validate their query
|
|
62
|
+
# parameters instead of ignoring them, so `?limit=` or `?cursor=None` is a 400.
|
|
63
|
+
if params is not None:
|
|
64
|
+
params = {key: value for key, value in params.items() if value is not None}
|
|
65
|
+
return self._send("GET", path, gate_status=True, params=params or None)
|
|
62
66
|
|
|
63
67
|
def probe(self, path: str = "/users/me") -> tuple[int, float]:
|
|
64
68
|
"""One unretried request, returning (status_code, elapsed_ms) without gating the body.
|
|
@@ -26,8 +26,20 @@ class Api:
|
|
|
26
26
|
def get_revision(self, revision_id: str) -> dict:
|
|
27
27
|
return self._client.get(f"/revisions/{revision_id}")["revision"]
|
|
28
28
|
|
|
29
|
-
def
|
|
30
|
-
|
|
29
|
+
def resolve_revision(self, org: str, blob: str, ref: str) -> dict:
|
|
30
|
+
# `ref` is a revision id or the literal `default`. Unlike `get_revision`, this route resolves
|
|
31
|
+
# the ref *within the blob*, so a revision id belonging to another blob answers the same 404
|
|
32
|
+
# as one that does not exist -- which `/revisions/{id}` cannot do, having no blob in its path.
|
|
33
|
+
return self._client.get(f"/blobs/{org}/{blob}/revisions/{ref}")["revision"]
|
|
34
|
+
|
|
35
|
+
def list_revisions(
|
|
36
|
+
self, org: str, blob: str, *, limit: int | None = None, cursor: str | None = None
|
|
37
|
+
) -> tuple[list[dict], str | None]:
|
|
38
|
+
# One page and the next cursor -- this method deliberately does not loop, for the reason
|
|
39
|
+
# list_schedules gives: a loop here would hide the paging from every test that uses it.
|
|
40
|
+
body = self._client.get(
|
|
41
|
+
f"/blobs/{org}/{blob}/revisions", {"limit": limit, "cursor": cursor})
|
|
42
|
+
return body["revisions"], body["cursor"]
|
|
31
43
|
|
|
32
44
|
def get_blob_limits(self, org: str, blob: str) -> dict:
|
|
33
45
|
return self._client.get(f"/blobs/{org}/{blob}/limits")["limits"]
|
|
@@ -156,7 +156,7 @@ def build_app() -> typer.Typer:
|
|
|
156
156
|
|
|
157
157
|
@blob_app.command("show")
|
|
158
158
|
def blob_show(blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org.")) -> None:
|
|
159
|
-
"""Show a blob's record and the phase of its
|
|
159
|
+
"""Show a blob's record and the phase of its default revision."""
|
|
160
160
|
from blobhub_cli.commands import blob as blob_cmd
|
|
161
161
|
|
|
162
162
|
blob_cmd.show(blob)
|
|
@@ -165,7 +165,7 @@ def build_app() -> typer.Typer:
|
|
|
165
165
|
def blob_revisions(
|
|
166
166
|
blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org."),
|
|
167
167
|
) -> None:
|
|
168
|
-
"""List a blob's revisions, newest first, marking the
|
|
168
|
+
"""List a blob's revisions, newest first, marking the default."""
|
|
169
169
|
from blobhub_cli.commands import blob as blob_cmd
|
|
170
170
|
|
|
171
171
|
blob_cmd.revisions(blob)
|
|
@@ -88,7 +88,6 @@ SCHEDULE_NOT_ACCESSIBLE = "SCHEDULE_NOT_ACCESSIBLE"
|
|
|
88
88
|
|
|
89
89
|
# Advisories (warnings, not exit-worthy failures)
|
|
90
90
|
DEFINITION_LIST_TRUNCATED = "DEFINITION_LIST_TRUNCATED"
|
|
91
|
-
REVISION_LIST_TRUNCATED = "REVISION_LIST_TRUNCATED"
|
|
92
91
|
ALLOWLIST_UNREADABLE = "ALLOWLIST_UNREADABLE"
|
|
93
92
|
ALLOWLIST_STALE = "ALLOWLIST_STALE"
|
|
94
93
|
ALLOWLIST_ENTRY_UNUSABLE = "ALLOWLIST_ENTRY_UNUSABLE"
|
|
@@ -11,7 +11,6 @@ from collections.abc import Callable
|
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
12
|
|
|
13
13
|
from blobhub_cli import codes, console
|
|
14
|
-
from blobhub_cli import limits as limits_module
|
|
15
14
|
from blobhub_cli.api.client import Client
|
|
16
15
|
from blobhub_cli.api.commands import Api
|
|
17
16
|
from blobhub_cli.api.errors import ApiAuthError, ApiError
|
|
@@ -49,9 +48,9 @@ def _render_show(payload: dict) -> None:
|
|
|
49
48
|
("Visibility", "visibility"), ("Status", "status"),
|
|
50
49
|
):
|
|
51
50
|
print(f"{label + ':':<12} {record.get(key, '')}")
|
|
52
|
-
revision_id = record.get("
|
|
53
|
-
revision = payload["
|
|
54
|
-
print(f"{'
|
|
51
|
+
revision_id = record.get("default_revision_id")
|
|
52
|
+
revision = payload["default_revision"]
|
|
53
|
+
print(f"{'Default:':<12} {revision_id or '(none)'}")
|
|
55
54
|
if revision is None:
|
|
56
55
|
# Two distinct states, and telling them apart is the difference between "mint a better key"
|
|
57
56
|
# and "this blob has no revision yet".
|
|
@@ -68,7 +67,7 @@ def show(ref_text: str, *, api_factory: Callable[[Settings], Api] = _default_api
|
|
|
68
67
|
record = _get_blob(api, ref)
|
|
69
68
|
|
|
70
69
|
revision = None
|
|
71
|
-
revision_id = record.get("
|
|
70
|
+
revision_id = record.get("default_revision_id")
|
|
72
71
|
if revision_id:
|
|
73
72
|
try:
|
|
74
73
|
revision = api.get_revision(revision_id)
|
|
@@ -79,7 +78,7 @@ def show(ref_text: str, *, api_factory: Callable[[Settings], Api] = _default_api
|
|
|
79
78
|
|
|
80
79
|
# `blob` is the "org/name" label on every command in this group, never the record itself: one
|
|
81
80
|
# key name across three sibling commands cannot carry two types and stay a machine contract.
|
|
82
|
-
payload = {"blob": ref.label(), "record": record, "
|
|
81
|
+
payload = {"blob": ref.label(), "record": record, "default_revision": revision}
|
|
83
82
|
console.result(payload, _render_show)
|
|
84
83
|
return payload
|
|
85
84
|
|
|
@@ -89,7 +88,7 @@ def _render_revisions(payload: dict) -> None:
|
|
|
89
88
|
return
|
|
90
89
|
print(f"{'':2}{'REVISION':<38} {'PHASE':<9} {'STATUS':<11} {'CREATED':<26} COMMENT")
|
|
91
90
|
for item in payload["revisions"]:
|
|
92
|
-
marker = "* " if item["
|
|
91
|
+
marker = "* " if item["default"] else " "
|
|
93
92
|
print(f"{marker}{item['id']:<38} {item['phase']:<9} {item['status']:<11} "
|
|
94
93
|
f"{item['created_at']:<26} {item['comment']}")
|
|
95
94
|
|
|
@@ -98,41 +97,36 @@ def revisions(ref_text: str, *, api_factory: Callable[[Settings], Api] = _defaul
|
|
|
98
97
|
ref, resolved = _resolve_ref(ref_text, "blob revisions")
|
|
99
98
|
api = api_factory(resolved)
|
|
100
99
|
|
|
101
|
-
# The listing does not report which revision is
|
|
100
|
+
# The listing does not report which revision is the default, so the blob record is required, not
|
|
102
101
|
# merely enrichment.
|
|
103
102
|
record = _get_blob(api, ref)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for item in found
|
|
132
|
-
),
|
|
133
|
-
key=lambda item: item["created_at"],
|
|
134
|
-
reverse=True,
|
|
135
|
-
)
|
|
103
|
+
default_revision_id = record.get("default_revision_id")
|
|
104
|
+
|
|
105
|
+
# Drained page by page, terminating on the cursor and never on a short page -- the same rule
|
|
106
|
+
# `scheduler/schedules.py`'s `load_all` follows, and for the same reason: the cursor is absent
|
|
107
|
+
# only once the query is exhausted, so a final page can legitimately be exactly full.
|
|
108
|
+
found: list[dict] = []
|
|
109
|
+
cursor: str | None = None
|
|
110
|
+
while True:
|
|
111
|
+
page, cursor = api.list_revisions(ref.org, ref.name, cursor=cursor)
|
|
112
|
+
found.extend(page)
|
|
113
|
+
if cursor is None:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
# Order comes from the route, which queries the `blob_id-created_at` index newest-first and
|
|
117
|
+
# documents that. Re-sorting a complete, ordered result could only reorder ties.
|
|
118
|
+
items = [
|
|
119
|
+
{
|
|
120
|
+
"id": item["id"],
|
|
121
|
+
"phase": item.get("phase", ""),
|
|
122
|
+
"status": item.get("status", ""),
|
|
123
|
+
"created_at": item.get("created_at", ""),
|
|
124
|
+
"comment": item.get("comment", ""),
|
|
125
|
+
"parent_id": item.get("parent_id"),
|
|
126
|
+
"default": item["id"] == default_revision_id,
|
|
127
|
+
}
|
|
128
|
+
for item in found
|
|
129
|
+
]
|
|
136
130
|
|
|
137
131
|
payload = {"blob": ref.label(), "revisions": items}
|
|
138
132
|
console.result(payload, _render_revisions)
|
|
@@ -30,7 +30,8 @@ from blobhub_cli.workflow import definitions, executions
|
|
|
30
30
|
|
|
31
31
|
# blobhub-web serves the routes `Navigation.workflowPath` builds (src/context/nav.js) from
|
|
32
32
|
# blobhub.io; the path itself -- `/{org_id}/{blob_id}/debugger/{session_id}/{execution_id}` -- is
|
|
33
|
-
# reproduced below rather than assumed (spec §1.4).
|
|
33
|
+
# reproduced below rather than assumed (spec §1.4). The revision rides as `?revision=`, never in the
|
|
34
|
+
# path: that path form is deleted, and the slot it used is the debugger's own.
|
|
34
35
|
DEBUGGER_BASE_URL = "https://blobhub.io"
|
|
35
36
|
|
|
36
37
|
|
|
@@ -85,7 +86,13 @@ def execute(
|
|
|
85
86
|
definition_id = set_.remote[(entry.category, entry.alias)].id
|
|
86
87
|
execution = api.create_execution(target.revision_id, session_id, definition_id=definition_id)
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
# `?revision=` carries the RESOLVED id, never the manifest's symbol: an execution lives in one
|
|
90
|
+
# revision, and a link without it opens on whatever revision the reader last chose, where this
|
|
91
|
+
# session does not exist. blobhub-web drops the parameter itself once it names the default.
|
|
92
|
+
url = (
|
|
93
|
+
f"{DEBUGGER_BASE_URL}/{target.org_id}/{target.blob_id}/debugger/{session_id}/{execution['id']}"
|
|
94
|
+
f"?revision={target.revision_id}"
|
|
95
|
+
)
|
|
89
96
|
# Launching a browser is a side effect, not output -- there is no `console.stream()` call this
|
|
90
97
|
# could route through -- so it is the one place this command asks `console.json_mode()` directly.
|
|
91
98
|
if open_browser and not console.json_mode():
|
|
@@ -58,7 +58,7 @@ from blobhub_cli.config import settings
|
|
|
58
58
|
from blobhub_cli.config.settings import Settings
|
|
59
59
|
from blobhub_cli.exit import CliExit
|
|
60
60
|
from blobhub_cli.formats import io
|
|
61
|
-
from blobhub_cli.manifest import resolver, scheduler
|
|
61
|
+
from blobhub_cli.manifest import base, resolver, scheduler
|
|
62
62
|
from blobhub_cli.scheduler import references, schedules
|
|
63
63
|
|
|
64
64
|
# Spec §8: the real workflow_executions_per_session maximum needs an admin key most callers don't
|
|
@@ -79,7 +79,7 @@ def _render_ls(payload: dict) -> None:
|
|
|
79
79
|
print(f"{'':2}{'ALIAS':<30} {'ID':<38} {'REPEAT':<15} {'ENABLED':<8} {'STATE':<10} WORKFLOW")
|
|
80
80
|
for item in payload["schedules"]:
|
|
81
81
|
# `*` marks a schedule the manifest covers -- the same marker `workflow ls` and
|
|
82
|
-
# `_render_revisions` (`commands/blob.py`) use for coverage/
|
|
82
|
+
# `_render_revisions` (`commands/blob.py`) use for coverage/default, so this table reads like
|
|
83
83
|
# its siblings at a glance.
|
|
84
84
|
marker = "* " if item["in_manifest"] else " "
|
|
85
85
|
# A schedule the platform stored with no alias has nothing to put in this column; the ID
|
|
@@ -136,15 +136,12 @@ def ls(
|
|
|
136
136
|
return payload
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def _reversed_entry(api: Api, record: dict, *, manifest_org_id: str
|
|
139
|
+
def _reversed_entry(api: Api, record: dict, *, manifest_org_id: str) -> dict:
|
|
140
140
|
# Built as a plain dict, not a `manifest.scheduler.ScheduleEntry` -- `pull` writes a document
|
|
141
141
|
# through `formats.io`, and the dataclass exists to be parsed *from* a manifest, not serialized
|
|
142
142
|
# back into one. Field order here matches the manifest sample in the spec, so a pulled file reads
|
|
143
143
|
# the same way a hand-authored one does.
|
|
144
|
-
target = references.reverse_target(
|
|
145
|
-
api, record["invocation_target"], manifest_org_id=manifest_org_id,
|
|
146
|
-
original_revision=original_revision,
|
|
147
|
-
)
|
|
144
|
+
target = references.reverse_target(api, record["invocation_target"], manifest_org_id=manifest_org_id)
|
|
148
145
|
target_doc = {
|
|
149
146
|
"type": target.type,
|
|
150
147
|
"blob": target.blob,
|
|
@@ -206,21 +203,11 @@ def pull(
|
|
|
206
203
|
# firing, and dropping it silently is how an operator ends up with a schedule they cannot see.
|
|
207
204
|
unmanageable = [record["id"] for record in records if record.get("alias") is None]
|
|
208
205
|
|
|
209
|
-
# Reversal must reproduce the symbol the entry actually wrote, and a stored `invocation_target`
|
|
210
|
-
# records only the resolved id -- so the manifest's own entries are the sole surviving evidence
|
|
211
|
-
# of whether a target revision was written as `latest` or deliberately pinned. Matched by alias,
|
|
212
|
-
# the same identity `deploy` decides create-versus-update on; a schedule with no manifest entry
|
|
213
|
-
# has no original symbol to reproduce, and `latest` stays the right default for it.
|
|
214
|
-
original_revisions = {entry.alias: entry.target.revision for entry in manifest.schedules}
|
|
215
|
-
|
|
216
206
|
# Sorted by alias, like `ls`'s rows: deterministic output a diff or a script can rely on, not an
|
|
217
207
|
# accident of `list_schedules`' newest-first paging order.
|
|
218
208
|
entries = sorted(
|
|
219
209
|
(
|
|
220
|
-
_reversed_entry(
|
|
221
|
-
api, record, manifest_org_id=target.org_id,
|
|
222
|
-
original_revision=original_revisions.get(record["alias"], "latest"),
|
|
223
|
-
)
|
|
210
|
+
_reversed_entry(api, record, manifest_org_id=target.org_id)
|
|
224
211
|
for record in records if record.get("alias") is not None
|
|
225
212
|
),
|
|
226
213
|
key=lambda entry: entry["alias"],
|
|
@@ -276,8 +263,12 @@ def _local_fields(
|
|
|
276
263
|
"enabled": entry.enabled,
|
|
277
264
|
"start_date": entry.start_date,
|
|
278
265
|
"end_date": entry.end_date,
|
|
279
|
-
|
|
280
|
-
|
|
266
|
+
# The symbols the platform stores, not the ids they resolved to. `target.blob` is not
|
|
267
|
+
# cosmetic: under `revision: default` the revision symbol is identical for every blob, so
|
|
268
|
+
# without it a manifest retargeted at a different blob compares equal and is never written.
|
|
269
|
+
"target.blob": target.blob_id,
|
|
270
|
+
"target.revision": target.revision_ref,
|
|
271
|
+
"target.session": _stored_session(target),
|
|
281
272
|
"target.workflow": target.workflow_alias,
|
|
282
273
|
"target.input_data": target.input_data,
|
|
283
274
|
"target.description": target.description,
|
|
@@ -298,8 +289,9 @@ def _remote_fields(record: dict) -> dict:
|
|
|
298
289
|
"enabled": record["enabled"],
|
|
299
290
|
"start_date": record.get("start_date"),
|
|
300
291
|
"end_date": record.get("end_date"),
|
|
301
|
-
"target.
|
|
302
|
-
"target.
|
|
292
|
+
"target.blob": invocation_target["blob_id"],
|
|
293
|
+
"target.revision": invocation_target["revision"],
|
|
294
|
+
"target.session": invocation_target["session"],
|
|
303
295
|
"target.workflow": invocation_target["workflow_alias"],
|
|
304
296
|
"target.input_data": invocation_target.get("input_data", []),
|
|
305
297
|
"target.description": invocation_target.get("description"),
|
|
@@ -373,9 +365,13 @@ def diff(
|
|
|
373
365
|
|
|
374
366
|
|
|
375
367
|
def _invocation_target(resolved: references.ResolvedTarget) -> dict:
|
|
368
|
+
# INVOCATION_TARGET_SCHEMA is additionalProperties: false and has no `org_id` -- the platform
|
|
369
|
+
# derives the org from the blob. `revision` is the manifest's own symbol, not the id it resolved
|
|
370
|
+
# to, because storing the id is exactly the pinning a following target exists to avoid.
|
|
376
371
|
target: dict[str, object] = {
|
|
377
|
-
"
|
|
378
|
-
"
|
|
372
|
+
"blob_id": resolved.blob_id,
|
|
373
|
+
"revision": resolved.revision_ref,
|
|
374
|
+
"session": _stored_session(resolved),
|
|
379
375
|
"workflow_alias": resolved.workflow_alias,
|
|
380
376
|
"input_data": resolved.input_data,
|
|
381
377
|
}
|
|
@@ -384,6 +380,18 @@ def _invocation_target(resolved: references.ResolvedTarget) -> dict:
|
|
|
384
380
|
return target
|
|
385
381
|
|
|
386
382
|
|
|
383
|
+
def _stored_session(target: references.PreviewedTarget | references.ResolvedTarget) -> str | None:
|
|
384
|
+
"""What the platform ends up holding in `invocation_target.session` for this target.
|
|
385
|
+
|
|
386
|
+
A following target keeps its alias -- `_resolve_invocation_target_blob` writes the resolved id
|
|
387
|
+
back only for a pinned one. Sending what the platform will store is what keeps a second `deploy`
|
|
388
|
+
of an unchanged manifest a no-op rather than a perpetual update.
|
|
389
|
+
"""
|
|
390
|
+
if target.revision_ref == base.REVISION_DEFAULT:
|
|
391
|
+
return target.session_ref
|
|
392
|
+
return target.session_id
|
|
393
|
+
|
|
394
|
+
|
|
387
395
|
def _schedule_fields(entry: scheduler.ScheduleEntry, resolved: references.ResolvedTarget) -> dict:
|
|
388
396
|
# Keys omitted rather than sent as null: every command schema is additionalProperties: false
|
|
389
397
|
# (api/commands.py), so an explicit start_date/end_date: null would be a 400, not an ignored field.
|
|
@@ -654,7 +654,7 @@ def _render_ls(payload: dict) -> None:
|
|
|
654
654
|
print(f"{'':2}{'CATEGORY':<10} {'ALIAS':<30} ID")
|
|
655
655
|
for item in payload["definitions"]:
|
|
656
656
|
# `*` marks a definition the manifest covers -- the same marker `_render_revisions`
|
|
657
|
-
# (`commands/blob.py`) uses for "
|
|
657
|
+
# (`commands/blob.py`) uses for "default", so this table reads like its sibling at a glance.
|
|
658
658
|
marker = "* " if item["in_manifest"] else " "
|
|
659
659
|
print(f"{marker}{item['category']:<10} {item['alias']:<30} {item['id']}")
|
|
660
660
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Identifier-shape checks shared across domains.
|
|
1
|
+
r"""Identifier-shape checks shared across domains.
|
|
2
2
|
|
|
3
3
|
Contract: `looks_like_uuid` is the one syntax check for whether a string is shaped like a UUID,
|
|
4
4
|
factored out here so no single domain module (blob references, sessions, and schedules) owns a
|
|
@@ -6,7 +6,7 @@ check every domain needs.
|
|
|
6
6
|
Invariant: this is a shape check, not an existence check -- a well-formed UUID may still resolve to
|
|
7
7
|
nothing, and callers must still handle a not-found/not-accessible response after this returns True.
|
|
8
8
|
|
|
9
|
-
There are
|
|
9
|
+
There are three predicates here, not one, because they answer different questions. `looks_like_uuid`
|
|
10
10
|
is read-side and permissive on purpose: it delegates to `uuid.UUID`, which also accepts a dashless
|
|
11
11
|
32-hex string, a `{braced}` form, and a `urn:uuid:` form, and every one of the CLI's existing
|
|
12
12
|
"is this an id or an alias" branches wants exactly that leniency -- a caller handing back any spelling
|
|
@@ -19,6 +19,15 @@ ordinary alias as far as the platform is concerned -- `is_canonical_uuid` must n
|
|
|
19
19
|
of `looks_like_uuid`, or it would reject aliases the platform accepts, and `looks_like_uuid` must
|
|
20
20
|
never be used to gate alias creation, or it would reject aliases the platform accepts for a different
|
|
21
21
|
reason (dashless 32-hex parses as a UUID but is not the canonical shape the platform refuses).
|
|
22
|
+
|
|
23
|
+
`matches_platform_session_id_shape` is the third, and it is neither of the others: it is a literal
|
|
24
|
+
copy of one JSON-Schema pattern, `INVOCATION_TARGET_SCHEMA`'s `SESSION_ID_SHAPE_PATTERN`, which
|
|
25
|
+
decides whether a following schedule target's `session` is refused at save. It separates from
|
|
26
|
+
`looks_like_uuid` in BOTH directions, which is why substituting either for the other is wrong:
|
|
27
|
+
dashless 32-hex is a UUID to `uuid.UUID` and an ordinary alias to the schema, while a
|
|
28
|
+
whitespace-padded UUID is not a UUID to `uuid.UUID` and IS a session id to the schema, whose pattern
|
|
29
|
+
is wrapped in `\s*`. Use it only where the question is "what will that schema do", never as a
|
|
30
|
+
general id check.
|
|
22
31
|
"""
|
|
23
32
|
|
|
24
33
|
import re
|
|
@@ -29,6 +38,14 @@ import uuid
|
|
|
29
38
|
# docstring for why a dashless 32-hex string must stay outside this pattern.
|
|
30
39
|
_CANONICAL_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
31
40
|
|
|
41
|
+
# `INVOCATION_TARGET_SCHEMA`'s SESSION_ID_SHAPE_PATTERN, character for character (blobhub-infra
|
|
42
|
+
# `api/blob_types/blobhub/compute/scheduler/component.py:147`). The `\s*` wrapper and the
|
|
43
|
+
# case-insensitive hex are the platform's, not a convenience: `get_session_by_session_id_or_alias`
|
|
44
|
+
# strips and lower-cases before deciding id-from-alias, so a padded or upper-case UUID reaches it as
|
|
45
|
+
# an id. Copied rather than approximated -- an approximation refuses manifests the platform accepts.
|
|
46
|
+
_PLATFORM_SESSION_ID_SHAPE = re.compile(
|
|
47
|
+
r"^\s*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\s*$")
|
|
48
|
+
|
|
32
49
|
|
|
33
50
|
def looks_like_uuid(value: str) -> bool:
|
|
34
51
|
try:
|
|
@@ -40,3 +57,8 @@ def looks_like_uuid(value: str) -> bool:
|
|
|
40
57
|
|
|
41
58
|
def is_canonical_uuid(value: str) -> bool:
|
|
42
59
|
return bool(_CANONICAL_UUID.match(value))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def matches_platform_session_id_shape(value: str) -> bool:
|
|
63
|
+
"""True when `INVOCATION_TARGET_SCHEMA` would read `value` as a session id rather than an alias."""
|
|
64
|
+
return bool(_PLATFORM_SESSION_ID_SHAPE.match(value))
|
|
@@ -10,8 +10,6 @@ answer confidently and wrongly.
|
|
|
10
10
|
from blobhub_cli.api.commands import Api
|
|
11
11
|
from blobhub_cli.api.errors import ApiError
|
|
12
12
|
|
|
13
|
-
LIMIT_REVISIONS_PER_BLOB = "revisions_per_blob"
|
|
14
|
-
|
|
15
13
|
|
|
16
14
|
def read_optional(api: Api, org: str | None, blob: str | None) -> dict | None:
|
|
17
15
|
# Limits require an admin key (F1 spec §1.6); a read/write key gets a 403 here. There is no
|
|
@@ -16,6 +16,13 @@ from blobhub_cli import codes
|
|
|
16
16
|
from blobhub_cli.blob import reference
|
|
17
17
|
from blobhub_cli.exit import CliExit
|
|
18
18
|
|
|
19
|
+
# The one revision reference that is a resolution rule rather than an id: it names whatever the
|
|
20
|
+
# blob's `default_revision_id` points at right now (blobhub-infra `RevisionsTable.REF_DEFAULT`).
|
|
21
|
+
REVISION_DEFAULT = "default"
|
|
22
|
+
|
|
23
|
+
# What `REVISION_DEFAULT` replaced. Kept only so the check below can name the rename.
|
|
24
|
+
_REVISION_RENAMED_FROM = "latest"
|
|
25
|
+
|
|
19
26
|
|
|
20
27
|
@dataclass(frozen=True)
|
|
21
28
|
class ManifestBase:
|
|
@@ -59,18 +66,27 @@ def check_blob_reference(blob: str, hint: str, *, label: str = "blob") -> None:
|
|
|
59
66
|
|
|
60
67
|
|
|
61
68
|
def check_revision(revision: str, hint: str, *, label: str = "revision") -> None:
|
|
62
|
-
"""A revision reference is `
|
|
69
|
+
"""A revision reference is `default` or a platform revision id, which is a bare lowercase UUID
|
|
63
70
|
(`Handler.generate_id`). Shared with `target.revision` for the same reason as above -- answered
|
|
64
71
|
offline, where the message can name the malformed value, rather than as an unreachable revision
|
|
65
72
|
one network round trip later.
|
|
66
73
|
"""
|
|
67
|
-
if revision ==
|
|
74
|
+
if revision == REVISION_DEFAULT:
|
|
68
75
|
return
|
|
76
|
+
if revision == _REVISION_RENAMED_FROM:
|
|
77
|
+
# A well-formed string, so the generic message below would send its author looking for a
|
|
78
|
+
# typo instead of a rename -- and the platform reads it as a revision id and answers 404.
|
|
79
|
+
raise CliExit(
|
|
80
|
+
codes.MANIFEST_INVALID,
|
|
81
|
+
f"{label} {revision!r} is no longer a revision reference; a blob's moving pointer is now "
|
|
82
|
+
f"named {REVISION_DEFAULT!r}",
|
|
83
|
+
hint,
|
|
84
|
+
)
|
|
69
85
|
try:
|
|
70
86
|
uuid.UUID(revision)
|
|
71
87
|
except ValueError as exc:
|
|
72
88
|
raise CliExit(
|
|
73
|
-
codes.MANIFEST_INVALID, f"{label} {revision!r} is neither
|
|
89
|
+
codes.MANIFEST_INVALID, f"{label} {revision!r} is neither {REVISION_DEFAULT!r} nor a UUID", hint,
|
|
74
90
|
) from exc
|
|
75
91
|
|
|
76
92
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""General reference resolver: turns a parsed `ManifestBase` into concrete platform coordinates.
|
|
2
2
|
|
|
3
3
|
Contract: `resolve` checks blob accessibility, the caller-supplied `expect` blob type (before any
|
|
4
|
-
data-channel call, which would otherwise answer an opaque `500`),
|
|
5
|
-
|
|
4
|
+
data-channel call, which would otherwise answer an opaque `500`), the revision through the
|
|
5
|
+
blob-scoped resolve route, then the status/phase gate, in that fixed order. `expect` is a required
|
|
6
6
|
keyword-only argument -- this module resolves `blob`/`revision` coordinates for any blob type, and
|
|
7
7
|
a default of the workflow type would leave that one type's assumption sitting inside a resolver
|
|
8
8
|
that calls itself general.
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
|
|
14
14
|
|
|
15
15
|
from blobhub_cli import codes
|
|
16
16
|
from blobhub_cli.api.commands import Api
|
|
17
|
-
from blobhub_cli.api.errors import ApiAuthError
|
|
17
|
+
from blobhub_cli.api.errors import ApiAuthError, ApiCommandError
|
|
18
18
|
from blobhub_cli.blob import reference
|
|
19
19
|
from blobhub_cli.blob.reference import BlobRef
|
|
20
20
|
from blobhub_cli.exit import CliExit
|
|
@@ -34,7 +34,6 @@ class Target:
|
|
|
34
34
|
revision_id: str
|
|
35
35
|
phase: str
|
|
36
36
|
status: str
|
|
37
|
-
latest_revision_id: str
|
|
38
37
|
|
|
39
38
|
|
|
40
39
|
def command_group(expect: str) -> str:
|
|
@@ -80,19 +79,26 @@ def resolve(
|
|
|
80
79
|
f"blob {org}/{blob_name} is {blob_type!r}, not a {expect!r} blob",
|
|
81
80
|
)
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
# The manifest's symbol goes to the platform verbatim: `default` is the platform's own
|
|
83
|
+
# resolution rule, and the route resolves it against the blob in the path -- which is also what
|
|
84
|
+
# makes it refuse a pinned revision belonging to some other blob, something `/revisions/{id}`
|
|
85
|
+
# has no blob to check against.
|
|
88
86
|
try:
|
|
89
|
-
revision = api.
|
|
90
|
-
except ApiAuthError as exc:
|
|
87
|
+
revision = api.resolve_revision(org, blob_name, base.revision)
|
|
88
|
+
except (ApiAuthError, ApiCommandError) as exc:
|
|
89
|
+
# Both, not just the first: the route answers 403 for a blob the key cannot read and a
|
|
90
|
+
# `revision_not_found` body at 404 for a revision that is missing or belongs elsewhere, and
|
|
91
|
+
# the client maps those to two different classes.
|
|
92
|
+
# The second clause is not decoration: a well-formed UUID that resolved yesterday now fails
|
|
93
|
+
# because the route checks ownership, and without it the message sends its author hunting
|
|
94
|
+
# for a deleted revision instead of at the `blob:` line above it.
|
|
91
95
|
raise CliExit(
|
|
92
96
|
codes.REVISION_NOT_ACCESSIBLE,
|
|
93
|
-
f"revision {
|
|
97
|
+
f"revision {base.revision} is absent or not reachable on {org}/{blob_name}; a pinned "
|
|
98
|
+
f"revision must belong to the blob named in 'blob:'",
|
|
94
99
|
f"blobhub {group} diff -f {base.path}",
|
|
95
100
|
) from exc
|
|
101
|
+
revision_id = revision["id"]
|
|
96
102
|
|
|
97
103
|
status = revision["status"]
|
|
98
104
|
phase = revision["phase"]
|
|
@@ -111,11 +117,4 @@ def resolve(
|
|
|
111
117
|
revision_id=revision_id,
|
|
112
118
|
phase=phase,
|
|
113
119
|
status=status,
|
|
114
|
-
latest_revision_id=latest_revision_id,
|
|
115
120
|
)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def reverse_revision(base_revision: str, resolved_id: str, latest_revision_id: str) -> str:
|
|
119
|
-
if base_revision != "latest":
|
|
120
|
-
return base_revision
|
|
121
|
-
return "latest" if resolved_id == latest_revision_id else resolved_id
|
|
@@ -128,10 +128,24 @@ def _parse_target(raw: object, hint: str) -> TargetRef:
|
|
|
128
128
|
revision_ref = _require_str(raw, "revision", hint)
|
|
129
129
|
base.check_revision(revision_ref, hint, label="target revision")
|
|
130
130
|
|
|
131
|
+
# INVOCATION_TARGET_SCHEMA's one cross-field rule, mirrored offline: a following target may not
|
|
132
|
+
# name its session by id, because no session id survives a revision boundary. The predicate is
|
|
133
|
+
# the schema's own pattern rather than `looks_like_uuid`, which differs from it in both
|
|
134
|
+
# directions and would refuse a legal alias as readily as it would miss a real id.
|
|
135
|
+
session_ref = _require_str(raw, "session", hint)
|
|
136
|
+
if revision_ref == base.REVISION_DEFAULT and ids.matches_platform_session_id_shape(session_ref):
|
|
137
|
+
raise CliExit(
|
|
138
|
+
codes.MANIFEST_INVALID,
|
|
139
|
+
f"target session {session_ref!r} is a session id, and a target following "
|
|
140
|
+
f"{base.REVISION_DEFAULT!r} must name its session by alias -- no session id survives the "
|
|
141
|
+
"revision the default moves to",
|
|
142
|
+
hint,
|
|
143
|
+
)
|
|
144
|
+
|
|
131
145
|
return TargetRef(
|
|
132
146
|
blob=blob_ref,
|
|
133
147
|
revision=revision_ref,
|
|
134
|
-
session=
|
|
148
|
+
session=session_ref,
|
|
135
149
|
workflow=_require_str(raw, "workflow", hint),
|
|
136
150
|
input_data=input_data,
|
|
137
151
|
description=description,
|
|
@@ -11,19 +11,19 @@ refuse the target runs while refusing is still free. A failure anywhere in the b
|
|
|
11
11
|
workflow-alias trio is re-raised as one `SCHEDULE_TARGET_INVALID` naming the target's own blob or
|
|
12
12
|
alias, matching the code's documented scope; a session failure is not folded in and keeps
|
|
13
13
|
`resolve_session`'s own codes, since `SCHEDULE_TARGET_INVALID` names only blob/revision/workflow.
|
|
14
|
-
`reverse_target` undoes it
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
`reverse_target` undoes it, and now mostly by *reading* rather than deriving: a stored target records
|
|
15
|
+
`blob_id`, the `revision` symbol itself (`default` or a pinned id) and, for a following target, the
|
|
16
|
+
session alias -- so `revision` reverses verbatim and a following target needs no session lookup at
|
|
17
|
+
all. Only a pinned target still resolves its session id back to an alias, since that is the one shape
|
|
18
|
+
the platform rewrites. `blob` reverses to a bare name when the target lives in the *manifest's own*
|
|
19
|
+
org -- reproducing the original symbol, not a UUID, which is the whole point of `pull` (spec §12.1,
|
|
20
|
+
§13.3) -- and to a fully-qualified `org_id/blob_alias` reference otherwise, since a blob record
|
|
21
|
+
carries no human-readable org handle to qualify it with instead. The caller supplies
|
|
22
22
|
`manifest_org_id` because it already has it: `pull` must resolve the manifest's own scheduler blob
|
|
23
|
-
before it can list schedules at all, and that resolution already returns `Target.org_id`.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
before it can list schedules at all, and that resolution already returns `Target.org_id`. Why the
|
|
24
|
+
symbol is read rather than derived from the blob's pointer: docs/specs/2026-09-21-revision-default.md
|
|
25
|
+
Invariant: a target revision must admit writes (`{draft, managed}`), because the schedule's whole
|
|
26
|
+
purpose is to write an execution into it and the platform checks that phase at save time.
|
|
27
27
|
|
|
28
28
|
`ResolvedTarget.blob_id` (Task 8) exists only so `deploy`'s saturation check can read the target
|
|
29
29
|
blob's limits without a second resolution: `get_blob`/`get_blob_limits` both resolve `blob` by
|
|
@@ -53,7 +53,7 @@ from blobhub_cli import codes, console, ids, limits
|
|
|
53
53
|
from blobhub_cli.api.commands import Api
|
|
54
54
|
from blobhub_cli.api.errors import ApiAuthError
|
|
55
55
|
from blobhub_cli.exit import CliExit
|
|
56
|
-
from blobhub_cli.manifest import resolver
|
|
56
|
+
from blobhub_cli.manifest import base, resolver
|
|
57
57
|
from blobhub_cli.manifest.base import ManifestBase
|
|
58
58
|
from blobhub_cli.manifest.resolver import Target
|
|
59
59
|
from blobhub_cli.manifest.scheduler import TargetRef
|
|
@@ -66,6 +66,9 @@ _WORKFLOW_DEFINITIONS_LIMIT = "workflow_definitions_per_revision"
|
|
|
66
66
|
@dataclass(frozen=True)
|
|
67
67
|
class ResolvedTarget:
|
|
68
68
|
revision_id: str
|
|
69
|
+
revision_ref: str # the manifest's own symbol -- `default` or a pinned id -- which is what the
|
|
70
|
+
# platform stores; `revision_id` is what it resolved to here and is what every other call needs.
|
|
71
|
+
session_ref: str # likewise the manifest's own symbol, which is what a FOLLOWING target stores
|
|
69
72
|
session_id: str
|
|
70
73
|
session_created: bool
|
|
71
74
|
workflow_alias: str
|
|
@@ -88,8 +91,12 @@ def _target_base(target: TargetRef) -> ManifestBase:
|
|
|
88
91
|
|
|
89
92
|
def _resolve_blob_revision(api: Api, target: TargetRef, *, manifest_org: str) -> Target:
|
|
90
93
|
try:
|
|
94
|
+
# `require_writable=True`, because firing this schedule writes an execution into the target
|
|
95
|
+
# revision and `create_schedule` refuses a phase outside PHASES_WRITE
|
|
96
|
+
# (blobhub-infra scheduler `component.py:623`). Refusing here is what keeps that 403 out of
|
|
97
|
+
# `deploy`'s pass 2, where earlier entries are already written and already firing.
|
|
91
98
|
return resolver.resolve(
|
|
92
|
-
api, _target_base(target), require_writable=
|
|
99
|
+
api, _target_base(target), require_writable=True, expect=resolver.WORKFLOW_BLOB,
|
|
93
100
|
default_org=manifest_org,
|
|
94
101
|
)
|
|
95
102
|
except CliExit as exc:
|
|
@@ -167,6 +174,8 @@ def resolve_target(api: Api, target: TargetRef, *, manifest_org: str) -> Resolve
|
|
|
167
174
|
|
|
168
175
|
return ResolvedTarget(
|
|
169
176
|
revision_id=resolved.revision_id,
|
|
177
|
+
revision_ref=target.revision,
|
|
178
|
+
session_ref=target.session,
|
|
170
179
|
session_id=session_id,
|
|
171
180
|
session_created=session_created,
|
|
172
181
|
workflow_alias=target.workflow,
|
|
@@ -179,11 +188,14 @@ def resolve_target(api: Api, target: TargetRef, *, manifest_org: str) -> Resolve
|
|
|
179
188
|
@dataclass(frozen=True)
|
|
180
189
|
class PreviewedTarget:
|
|
181
190
|
revision_id: str
|
|
191
|
+
revision_ref: str
|
|
192
|
+
session_ref: str
|
|
182
193
|
workflow_alias: str
|
|
183
194
|
input_data: list
|
|
184
195
|
description: str | None
|
|
185
196
|
session_id: str | None # None means the session does not exist yet -- diff must not create it
|
|
186
197
|
session_would_create: bool
|
|
198
|
+
blob_id: str
|
|
187
199
|
|
|
188
200
|
|
|
189
201
|
def preview_target(api: Api, target: TargetRef, *, manifest_org: str) -> PreviewedTarget:
|
|
@@ -213,19 +225,19 @@ def preview_target(api: Api, target: TargetRef, *, manifest_org: str) -> Preview
|
|
|
213
225
|
|
|
214
226
|
return PreviewedTarget(
|
|
215
227
|
revision_id=resolved.revision_id,
|
|
228
|
+
revision_ref=target.revision,
|
|
229
|
+
session_ref=target.session,
|
|
216
230
|
workflow_alias=target.workflow,
|
|
217
231
|
input_data=target.input_data,
|
|
218
232
|
description=target.description,
|
|
219
233
|
session_id=session_id,
|
|
220
234
|
session_would_create=session_would_create,
|
|
235
|
+
blob_id=resolved.blob_id,
|
|
221
236
|
)
|
|
222
237
|
|
|
223
238
|
|
|
224
|
-
def reverse_target(
|
|
225
|
-
|
|
226
|
-
) -> TargetRef:
|
|
227
|
-
revision_id = invocation_target["revision_id"]
|
|
228
|
-
blob_id = api.get_revision(revision_id)["blob_id"]
|
|
239
|
+
def reverse_target(api: Api, invocation_target: dict, *, manifest_org_id: str) -> TargetRef:
|
|
240
|
+
blob_id = invocation_target["blob_id"]
|
|
229
241
|
|
|
230
242
|
# `GET /blobs/{org}/{blob}` resolves `{blob}` by primary key, and never consults `{org}` at all,
|
|
231
243
|
# the moment `{blob}` is a real id rather than an alias (`get_blob_by_org_id_and_blob_id`,
|
|
@@ -238,16 +250,17 @@ def reverse_target(
|
|
|
238
250
|
# handle, only its raw `org_id`, so a genuinely cross-org target is qualified with that id rather
|
|
239
251
|
# than an alias -- it was explicitly qualified by whoever authored it in the first place.
|
|
240
252
|
blob_ref = blob["alias"] if blob["org_id"] == manifest_org_id else f"{blob['org_id']}/{blob['alias']}"
|
|
241
|
-
#
|
|
242
|
-
|
|
243
|
-
# resolved id. A manifest that pinned a literal revision did so precisely so the next revision
|
|
244
|
-
# cut would not retarget the schedule, and reversing that to `latest` would undo the pin
|
|
245
|
-
# silently -- indistinguishably so when the pinned id happens to still be the blob's latest.
|
|
246
|
-
# `"latest"` is only the right default where there is no entry to have written anything.
|
|
247
|
-
revision_ref = resolver.reverse_revision(original_revision, revision_id, blob["latest_revision_id"])
|
|
253
|
+
# Verbatim: the stored target records the symbol the manifest wrote, `default` included.
|
|
254
|
+
revision_ref = invocation_target["revision"]
|
|
248
255
|
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
if revision_ref == base.REVISION_DEFAULT:
|
|
257
|
+
# A following target keeps the alias it was deployed with -- the platform deliberately does
|
|
258
|
+
# not pin it, because no session id survives the revision the default moves to. There is
|
|
259
|
+
# nothing to look up, and no revision id to look it up in.
|
|
260
|
+
session_ref = invocation_target["session"]
|
|
261
|
+
else:
|
|
262
|
+
session = api.get_session(revision_ref, invocation_target["session"])
|
|
263
|
+
session_ref = session.get("alias") or invocation_target["session"]
|
|
251
264
|
|
|
252
265
|
return TargetRef(
|
|
253
266
|
blob=blob_ref,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|