Skip to content

feat: gate the implicit image pull behind --pull - #613

Merged
AidanAbd merged 13 commits into
latchbio:mainfrom
ameynert:am_image_gate_pull
Aug 18, 2026
Merged

AidanAbd merged 13 commits into
latchbio:mainfrom
ameynert:am_image_gate_pull

Conversation

@ameynert

Copy link
Copy Markdown
Contributor

latch image upload no longer pulls a missing local image implicitly. An unqualified reference resolves to Docker Hub, so the old behavior could publish a third party's image under your own tag. Pass --pull to fetch a missing image.

Changes

  • Require --pull to fetch an image that is absent locally; otherwise exit 1.
  • Resolve the pull reference the way Docker does, and warn when an unqualified reference would resolve to a namespace you may not own.

Tests: the reference-resolution table, including the port-host case.

Stacked on am_image_workspace_id. Opened against main because that is the only base available cross-fork; against main this diff also includes the commits from the PRs below it. Retarget to am_image_workspace_id once that branch exists upstream.

ameynert and others added 13 commits August 12, 2026 16:22
`print_upload_logs` acted on one error string, "denied: Your authorization
token has expired.". Every other error in the stream - a failed layer upload, a
denied push, ECR throttling, a network reset - fell through and was recorded as
progress. The caller then recorded the image and printed a green success
message for an image that never reached the registry.

Any error now ends the upload with exit 1 and prints the error verbatim. The
same parser reads the pull stream, so a failed pull is no longer silent either.

Move the cursor below the progress block before printing, so the error does not
overwrite a progress line, and raise `click.exceptions.Exit` rather than calling
`sys.exit` so click can clean up.
Add `DockerPushLogItem` beside `DockerBuildLogItem` so the push and pull streams
are typed rather than `dict[str, Any]`, and widen the progress map key to
`Optional[str]` to match what the stream actually carries.

Cover the pull path, which shares the parser with the header suppressed.
Address msto review: the header-suppressed path asserted the header is
absent, but nothing covered that it appears by default. Add a positive
assertion so the print_header=True path is exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`record_in_db` ran unconditionally after the push and issued a plain
`createPrivateImage` create. A failure there exited 1, the same code as a failed
push, so a caller could not tell "the image is not in the registry" from "the
image is in the registry but Latch does not know about it". Retrying on that
exit code can never succeed, because the tag is immutable.

- `is_recorded_in_db` queries for one image and version.
- `record_in_db` skips the mutation when the record is already present, so a
  retry of a partly completed upload no longer hits the uniqueness constraint.
- `record_in_db_or_exit` reports a record failure as distinct from a push
  failure and exits 3, and both upload paths now use it.

`latch image upload` documents the three exit codes.
`is_recorded_in_db` returned the truthiness of the node list rather than checking
that a returned node was the image in question. The filter is only correct if the
server honours all three fields; one that ignored an unrecognised filter field
would report every image in the workspace as a match, `record_in_db` would skip
the create, and the upload would exit 0 with the image published and no record of
it - the one silent failure the exit codes exist to prevent.

Verified against the live API that all three fields are honoured, so this is not
reachable today. Matching client-side makes the check independent of filter
semantics at no cost.

Correct the recovery advice: re-running `upload` pushes again before it records,
so it is not a retry of the record. State what is broken instead.

Replace the filter test, which passed against a query that filtered on the
workspace alone, with tests that feed a non-matching node.
Address msto review: the PrivateImageExists query reused PrivateImageNode,
dropping creationTime and adding workspaceId. That broke ls()'s typing,
which still reads node["creationTime"]. Split out
PrivateImageExistsNode/PrivateImageExistsResult so each query has a type
matching the fields it selects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address nh13 review: is_recorded_in_db compared the BigInt workspaceId
against a str workspace id. Postgraphile serializes BigInt as a string
today, but if it ever returns a number the match silently fails, the
create runs anyway, and the uniqueness constraint this guard prevents
fires. Normalize with str(), and cover the click.Abort passthrough arm
that was previously untested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`latch register` takes `--workspace-id`, but `latch image upload` and
`latch image ls` resolved the workspace from ambient config. A CI job could pin
the workspace for registration but not for image publishing, and the two could
disagree silently: the upload went to one workspace, and the `ls` used to
confirm it read that same wrong workspace, so it looked like it worked.

Add `--workspace-id` to both commands and thread the resolved value through all
four consumers - the namespaced repository name, the credentials, the push
target, and the database record.

`get_credentials` resolved the workspace itself, so it now takes `ws_id` as a
required keyword argument. `dbnp` and `remote_dbnp` carry it through, which
covers the local and remote build paths.

`resolve_workspace` checks an explicit id against the workspaces the user can
reach and names the target, so a typo fails before the push rather than
publishing into a namespace they did not intend.
- Rename `resolve_workspace` to `resolve_workspace_id`: it returns an id.
- Share one `--workspace-id` option between the two commands.
- Drive `upload_image` end to end in a test, rather than only the pieces.
- Replace the `type: ignore[arg-type]` stub argument with a real instance.

Validate only an explicit id, not the active workspace. `latch register` checks
both, but `get_workspaces` measures ~230ms against ~120ms for the `ls` query it
would precede, which is a poor trade for a value that comes from the user's own
config. Recorded as a deliberate difference in the docstring and its test.
Address msto review: register_staging binds ws_id once so the repo name,
build credentials, and DB record all agree on one workspace, but the
record mutation still re-called current_workspace(). Use the bound ws_id
so the record honors the single-binding intent the comment documents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address nh13 review: the single-workspace binding sat below the
LatestVersion duplicate-check query and its error message, which still
re-read current_workspace(). Hoist the bind above the check so all four
consumers - the query, the already-exists message, the repository name,
and the record - share one value, making the invariant the comment
documents fully true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`upload_image` pulled any reference missing from the local daemon and pushed it
to the workspace registry. An unqualified reference resolves to Docker Hub, so a
build that silently failed to produce the tag would publish whatever a third
party had parked at that name, under our tag, in our workspace.

A missing image now fails before the confirmation prompt and before credentials
are minted, and names the reference it would have pulled:

    No local image matches `team/tool:v1`.

    That reference is unqualified, so Docker resolves it to
    `docker.io/team/tool:v1` on Docker Hub. Check that you own that namespace.

    Build the image first, or pass `--pull` to fetch `docker.io/team/tool:v1`.

The Docker Hub warning is omitted when the reference is already qualified, so it
does not cry wolf on the case it does not apply to.

`resolve_pull_reference` applies Docker's rule that the first path component is a
registry when it contains a `.` or `:`, is `localhost`, or is not all lowercase -
a repository path cannot contain uppercase, so such a component can only be a
host. `--pull` pulls that resolved reference rather than the original, so the
message cannot drift from the call it describes.
Address nh13 review: the parametrized table exercised a dot-host,
localhost, uppercase host, digests, and official images, but no first
component marked a host by a port alone. Add localhost:5000/tool:v1 so
the ":" in head branch is covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ameynert
ameynert marked this pull request as ready for review August 17, 2026 22:02
@AidanAbd
AidanAbd merged commit 6717c52 into latchbio:main Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants