mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-20 00:28:00 +02:00
fix(cli): preflight Docker for --push-to builds and keep the push token out of verbose output
This commit is contained in:
@@ -1117,7 +1117,7 @@ def _run_local_build(
|
||||
"--password-stdin",
|
||||
registry_host,
|
||||
input=token_input,
|
||||
verbose=verbose,
|
||||
verbose=False,
|
||||
)
|
||||
)
|
||||
step += 1
|
||||
@@ -1440,12 +1440,19 @@ class CustomerRegistrySource:
|
||||
raise
|
||||
|
||||
|
||||
def _require_local_docker() -> None:
|
||||
supported, error = can_build_locally()
|
||||
if not supported:
|
||||
raise click.UsageError(error or "Unable to build locally.")
|
||||
|
||||
|
||||
def _push_reference(push_to: str, tag: str | None) -> ImageReference:
|
||||
if "@" in push_to:
|
||||
try:
|
||||
reference = ImageReference.parse(push_to)
|
||||
except ValueError:
|
||||
raise click.UsageError(
|
||||
"--push-to takes a repository with an optional tag, not a digest."
|
||||
)
|
||||
reference = ImageReference.parse(push_to)
|
||||
) from None
|
||||
if reference.tag is not None and tag is not None:
|
||||
raise click.UsageError(
|
||||
"--push-to already includes a tag; do not combine it with --tag."
|
||||
@@ -1466,9 +1473,10 @@ def _select_source(
|
||||
if push_to is not None:
|
||||
if remote_build_flag is True:
|
||||
raise click.UsageError("--push-to cannot be combined with --remote.")
|
||||
return CustomerRegistrySource(
|
||||
_push_reference(push_to, tag), prebuilt_image=image
|
||||
)
|
||||
reference = _push_reference(push_to, tag)
|
||||
if image is None:
|
||||
_require_local_docker()
|
||||
return CustomerRegistrySource(reference, prebuilt_image=image)
|
||||
if image and remote_build_flag is True:
|
||||
raise click.UsageError("--image cannot be combined with --remote builds.")
|
||||
use_remote_build, local_build_error = _resolve_build_mode(
|
||||
|
||||
@@ -62,7 +62,7 @@ def _is_cloud_host(hostname: str) -> bool:
|
||||
|
||||
|
||||
def _cloud_control_plane_host_for(langsmith_api_host: str) -> str:
|
||||
if langsmith_api_host.endswith(CLOUD_API_HOST):
|
||||
if langsmith_api_host.endswith(f".{CLOUD_API_HOST}"):
|
||||
region = langsmith_api_host[: -len(CLOUD_API_HOST)]
|
||||
return f"{region}{CLOUD_CONTROL_PLANE_HOST}"
|
||||
return CLOUD_CONTROL_PLANE_HOST
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
DIGEST_SEPARATOR = "@sha256:"
|
||||
DIGEST_MARKER = "@"
|
||||
TAG_SEPARATOR = ":"
|
||||
PATH_SEPARATOR = "/"
|
||||
|
||||
@@ -14,6 +15,8 @@ class ImageReference:
|
||||
|
||||
@classmethod
|
||||
def parse(cls, reference: str) -> ImageReference:
|
||||
if DIGEST_MARKER in reference:
|
||||
raise ValueError(f"{reference!r} carries a digest and cannot be tagged")
|
||||
path_start = reference.rfind(PATH_SEPARATOR) + 1
|
||||
name, separator, tag = reference[path_start:].partition(TAG_SEPARATOR)
|
||||
if not separator:
|
||||
|
||||
@@ -514,8 +514,9 @@ def test_push_to_builds_pushes_then_creates_an_external_deployment(
|
||||
def test_push_to_builds_directly_with_the_push_reference(
|
||||
deploy_project: DeployProject,
|
||||
) -> None:
|
||||
deploy_project.run("--push-to", PUSH_REPOSITORY)
|
||||
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert deploy_project.docker.builds[0]["tag"] == EXTERNAL_IMAGE
|
||||
assert deploy_project.docker.command("push").args == (
|
||||
"docker",
|
||||
@@ -525,11 +526,30 @@ def test_push_to_builds_directly_with_the_push_reference(
|
||||
|
||||
|
||||
def test_push_to_composes_with_the_tag_flag(deploy_project: DeployProject) -> None:
|
||||
deploy_project.run("--push-to", PUSH_REPOSITORY, "--tag", "v1")
|
||||
result = deploy_project.run("--push-to", PUSH_REPOSITORY, "--tag", "v1")
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert deploy_project.docker.command("push").args[-1] == f"{PUSH_REPOSITORY}:v1"
|
||||
|
||||
|
||||
def test_push_to_with_a_failing_push_creates_no_deployment(
|
||||
deploy_project: DeployProject,
|
||||
) -> None:
|
||||
deploy_project.docker.failing_pushes = 3
|
||||
|
||||
result = deploy_project.run("--push-to", PUSH_REPOSITORY)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert CREATE_DEPLOYMENT not in deploy_project.timeline
|
||||
|
||||
|
||||
def test_verbose_never_echoes_the_push_token(deploy_project: DeployProject) -> None:
|
||||
result = deploy_project.run("--no-remote", "--verbose")
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert deploy_project.docker.command("login").kwargs["verbose"] is False
|
||||
|
||||
|
||||
def test_push_to_retags_a_prebuilt_image_instead_of_building(
|
||||
deploy_project: DeployProject,
|
||||
) -> None:
|
||||
|
||||
@@ -692,6 +692,14 @@ class TestSelectSource:
|
||||
|
||||
assert _select_source(**{**self.OPTIONS, **flags}) == expected
|
||||
|
||||
def test_push_to_build_requires_local_docker(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
deploy_mod, "can_build_locally", lambda: (False, "Docker is required")
|
||||
)
|
||||
|
||||
with pytest.raises(click.UsageError, match="Docker is required"):
|
||||
_select_source(**{**self.OPTIONS, "push_to": self.REPOSITORY})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flags", "message"),
|
||||
[
|
||||
@@ -717,7 +725,9 @@ class TestSelectSource:
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_conflicting_flags_are_rejected(self, flags, message):
|
||||
def test_conflicting_flags_are_rejected(self, monkeypatch, flags, message):
|
||||
monkeypatch.setattr(deploy_mod, "can_build_locally", lambda: (True, None))
|
||||
|
||||
with pytest.raises(click.UsageError, match=message):
|
||||
_select_source(**{**self.OPTIONS, **flags})
|
||||
|
||||
|
||||
@@ -493,6 +493,12 @@ CLOUD = ("https://api.host.langchain.com", "https://smith.langchain.com")
|
||||
pytest.param(
|
||||
None, "https://api.langchain.com", CLOUD, id="cloud_langchain_api_alias"
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://xapi.smith.langchain.com",
|
||||
CLOUD,
|
||||
id="lookalike_cloud_host_is_not_rewritten_into_a_control_plane",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
"https://eu.api.smith.langchain.com",
|
||||
|
||||
@@ -64,3 +64,8 @@ def test_matches_digest_only_for_the_same_repository(repo_digest, expected):
|
||||
assert ImageReference("localhost:5000/app", "v1").matches_digest(repo_digest) is (
|
||||
expected
|
||||
)
|
||||
|
||||
|
||||
def test_parse_rejects_a_digest_reference():
|
||||
with pytest.raises(ValueError, match="digest"):
|
||||
ImageReference.parse("registry.example.com/app@sha256:abc")
|
||||
|
||||
Reference in New Issue
Block a user