mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d707ab6650 | ||
|
|
c5d0500e76 | ||
|
|
1c9b0c791c | ||
|
|
1fa604f694 | ||
|
|
a6fb26816d | ||
|
|
45fbbfb391 |
@@ -666,6 +666,11 @@ def _smith_dashboard_base_url(host_url: str | None) -> str:
|
||||
return "https://smith.langchain.com"
|
||||
parsed = urlparse(host_url)
|
||||
hostname = parsed.hostname or ""
|
||||
# Self-hosted: host_url is <scheme>://<host>/api-host — return just the root
|
||||
path = parsed.path.rstrip("/")
|
||||
if path == "/api-host" or path.endswith("/api-host"):
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
if hostname in ("localhost", "127.0.0.1"):
|
||||
return host_url.rstrip("/")
|
||||
|
||||
@@ -927,6 +932,59 @@ def _resolve_pushed_image_digest(
|
||||
return remote_image
|
||||
|
||||
|
||||
def _build_image_tagged(
|
||||
runner,
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
base_image: str | None,
|
||||
api_version: str | None,
|
||||
pull: bool,
|
||||
tag: str,
|
||||
docker_build_args: Sequence[str],
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
"""Build a Docker image to *tag*, using buildx on non-x86_64 hosts to target linux/amd64."""
|
||||
if platform.machine() != "x86_64":
|
||||
build_flags: list[str] = ["--platform", "linux/amd64", "--load"]
|
||||
if not verbose:
|
||||
build_flags.append("--progress=quiet")
|
||||
with Progress(message="Building...", elapsed=not verbose):
|
||||
build_docker_image(
|
||||
runner,
|
||||
lambda _msg: None,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
docker_command=("docker", "buildx", "build"),
|
||||
extra_flags=build_flags,
|
||||
verbose=verbose,
|
||||
)
|
||||
else:
|
||||
with Progress(message="Building...", elapsed=not verbose):
|
||||
build_docker_image(
|
||||
runner,
|
||||
lambda _msg: None,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
|
||||
def _run_local_build(
|
||||
*,
|
||||
client: HostBackendClient,
|
||||
@@ -949,9 +1007,6 @@ def _run_local_build(
|
||||
tracked_packages: list[str] | None,
|
||||
) -> BuildResult:
|
||||
"""Build locally with Docker, push to registry, update deployment."""
|
||||
# Use buildx to cross-compile for amd64 when running on a non-x86_64 host
|
||||
# (e.g. Apple Silicon). On amd64 hosts, plain docker build is sufficient.
|
||||
needs_buildx = platform.machine() != "x86_64"
|
||||
local_tag = f"langgraph-deploy-tmp:{int(time.time())}"
|
||||
image_to_push = prebuilt_image or local_tag
|
||||
|
||||
@@ -961,49 +1016,20 @@ def _run_local_build(
|
||||
_validate_prebuilt_image(runner, prebuilt_image, verbose=verbose)
|
||||
click.secho(" Image is available for linux/amd64", fg="green")
|
||||
else:
|
||||
# -- Step: Build image --
|
||||
_log_deploy_step(step, "Building image")
|
||||
if needs_buildx:
|
||||
build_flags: list[str] = [
|
||||
"--platform",
|
||||
"linux/amd64",
|
||||
"--load",
|
||||
]
|
||||
if not verbose:
|
||||
build_flags.append("--progress=quiet")
|
||||
with Progress(message="Building...", elapsed=not verbose):
|
||||
build_docker_image(
|
||||
runner,
|
||||
lambda _msg: None,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
local_tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
docker_command=("docker", "buildx", "build"),
|
||||
extra_flags=build_flags,
|
||||
verbose=verbose,
|
||||
)
|
||||
else:
|
||||
with Progress(message="Building...", elapsed=not verbose):
|
||||
build_docker_image(
|
||||
runner,
|
||||
lambda _msg: None,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
local_tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
verbose=verbose,
|
||||
)
|
||||
_build_image_tagged(
|
||||
runner,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
local_tag,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
verbose=verbose,
|
||||
)
|
||||
step += 1
|
||||
|
||||
# -- Step: Get push token and authenticate --
|
||||
@@ -1126,6 +1152,71 @@ def _run_local_build(
|
||||
)
|
||||
|
||||
|
||||
def _run_external_deploy(
|
||||
*,
|
||||
client: HostBackendClient,
|
||||
deployment_id: str,
|
||||
step: int,
|
||||
config: pathlib.Path,
|
||||
config_json: dict,
|
||||
image_uri: str,
|
||||
verbose: bool,
|
||||
pull: bool,
|
||||
api_version: str | None,
|
||||
base_image: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
docker_build_args: Sequence[str],
|
||||
secrets: list[dict[str, str]],
|
||||
tracked_packages: list[str] | None,
|
||||
) -> "BuildResult":
|
||||
"""Build image, push using existing Docker credentials, and update the deployment."""
|
||||
with Runner() as runner:
|
||||
_log_deploy_step(step, f"Building image {image_uri}")
|
||||
_build_image_tagged(
|
||||
runner,
|
||||
config,
|
||||
config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
pull,
|
||||
image_uri,
|
||||
docker_build_args,
|
||||
install_command,
|
||||
build_command,
|
||||
verbose=verbose,
|
||||
)
|
||||
step += 1
|
||||
|
||||
_log_deploy_step(step, f"Pushing image {image_uri}")
|
||||
with Progress(message="Pushing...", elapsed=not verbose):
|
||||
runner.run(subp_exec("docker", "push", image_uri, verbose=verbose))
|
||||
step += 1
|
||||
|
||||
resolved_image = _resolve_pushed_image_digest(
|
||||
runner,
|
||||
remote_image=image_uri,
|
||||
docker_config_dir=None,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
_log_deploy_step(step, f"Updating deployment {deployment_id}")
|
||||
updated = client.update_deployment_external(
|
||||
deployment_id,
|
||||
resolved_image,
|
||||
secrets=secrets,
|
||||
tracked_packages=tracked_packages,
|
||||
)
|
||||
|
||||
return BuildResult(
|
||||
updated=updated if isinstance(updated, dict) else {},
|
||||
progress_message="Deploying...",
|
||||
timeout_seconds=300,
|
||||
poll_interval_seconds=1,
|
||||
no_result_message="Deployment updated",
|
||||
)
|
||||
|
||||
|
||||
def _run_remote_build(
|
||||
*,
|
||||
client: HostBackendClient,
|
||||
@@ -1261,7 +1352,30 @@ def _create_host_backend_client(
|
||||
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
|
||||
"LANGSMITH_TENANT_ID"
|
||||
)
|
||||
return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id)
|
||||
# If no explicit host URL was provided, check LANGSMITH_ENDPOINT as a
|
||||
# fallback so self-hosted customers don't need to know about LANGGRAPH_HOST_URL.
|
||||
# Self-hosted control plane always lives at <langsmith_endpoint>/api-host.
|
||||
_cloud_default = "https://api.host.langchain.com"
|
||||
_cloud_endpoints = {
|
||||
"https://api.smith.langchain.com",
|
||||
"https://api.langchain.com",
|
||||
}
|
||||
resolved_host = host_url
|
||||
if not resolved_host or resolved_host == _cloud_default:
|
||||
langsmith_endpoint = env_vars.get("LANGSMITH_ENDPOINT") or os.environ.get(
|
||||
"LANGSMITH_ENDPOINT"
|
||||
)
|
||||
if (
|
||||
langsmith_endpoint
|
||||
and langsmith_endpoint.rstrip("/") not in _cloud_endpoints
|
||||
):
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_p = _urlparse(langsmith_endpoint)
|
||||
resolved_host = f"{_p.scheme}://{_p.netloc}/api-host"
|
||||
else:
|
||||
resolved_host = _cloud_default
|
||||
return HostBackendClient(resolved_host, resolved_api_key, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def _call_host_backend_with_optional_tenant(
|
||||
@@ -1479,6 +1593,16 @@ def _deploy_base_options(
|
||||
"skip building. The image must target linux/amd64."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--image-uri",
|
||||
help=(
|
||||
"Image URI to build, push, and deploy "
|
||||
"(e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com/repo:tag). "
|
||||
"Builds the project, pushes using existing Docker credentials, "
|
||||
"and triggers a deployment revision. "
|
||||
"Required for self-hosted deployments."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
@@ -1580,6 +1704,7 @@ def _deploy_cmd(
|
||||
name: str | None,
|
||||
image_name: str | None,
|
||||
image: str | None,
|
||||
image_uri: str | None,
|
||||
tag: str,
|
||||
base_image: str | None,
|
||||
install_command: str | None,
|
||||
@@ -1626,16 +1751,25 @@ def _deploy_cmd(
|
||||
|
||||
secrets = _secrets_from_env(_env_without_deployment_name(env_vars))
|
||||
|
||||
if image and remote_build_flag is True:
|
||||
raise click.UsageError("--image cannot be combined with --remote builds.")
|
||||
if image_uri and remote_build_flag is True:
|
||||
raise click.UsageError("--image-uri cannot be combined with --remote.")
|
||||
if image_uri and image:
|
||||
raise click.UsageError("--image-uri cannot be combined with --image.")
|
||||
|
||||
use_remote_build, local_build_error = _resolve_build_mode(
|
||||
remote_build_flag, force_local=image is not None
|
||||
)
|
||||
if use_remote_build and remote_build_flag is None and local_build_error:
|
||||
em.note(f"{local_build_error}\nUsing remote build instead.")
|
||||
if not json_output:
|
||||
click.echo()
|
||||
use_external_docker = image_uri is not None
|
||||
|
||||
if use_external_docker:
|
||||
use_remote_build = False
|
||||
else:
|
||||
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(
|
||||
remote_build_flag, force_local=image is not None
|
||||
)
|
||||
if use_remote_build and remote_build_flag is None and local_build_error:
|
||||
em.note(f"{local_build_error}\nUsing remote build instead.")
|
||||
if not json_output:
|
||||
click.echo()
|
||||
|
||||
# -- 2. Resolve / create deployment --
|
||||
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
|
||||
@@ -1648,24 +1782,47 @@ def _deploy_cmd(
|
||||
name,
|
||||
not_found_message=(
|
||||
"No deployment found. Will create."
|
||||
if use_remote_build
|
||||
if (use_remote_build or use_external_docker)
|
||||
else "No deployment found. Will create after build."
|
||||
),
|
||||
)
|
||||
|
||||
if needs_creation:
|
||||
if use_external_docker:
|
||||
source = "external_docker"
|
||||
elif use_remote_build:
|
||||
source = "internal_source"
|
||||
else:
|
||||
source = "internal_docker"
|
||||
deployment_id, step = _create_deployment(
|
||||
client,
|
||||
step,
|
||||
name=name,
|
||||
deployment_type=deployment_type,
|
||||
source="internal_source" if use_remote_build else "internal_docker",
|
||||
source=source,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
if not deployment_id:
|
||||
raise click.ClickException("Failed to determine deployment ID")
|
||||
|
||||
# Validate that an existing deployment is compatible with --image-uri.
|
||||
# update_deployment_external sends an external_docker revision; applying
|
||||
# it to a deployment created with a different source mode may be rejected
|
||||
# by the backend or silently produce an inconsistent revision.
|
||||
if use_external_docker and not needs_creation:
|
||||
existing = _call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.get_deployment(deployment_id)
|
||||
)
|
||||
existing_source = existing.get("source") if isinstance(existing, dict) else None
|
||||
if existing_source and existing_source != "external_docker":
|
||||
raise click.UsageError(
|
||||
f"Deployment {deployment_id} uses a different build mode and "
|
||||
f"cannot be updated with --image-uri. To use --image-uri, omit "
|
||||
f"--deployment-id to create a new deployment, or remove "
|
||||
f"--image-uri to continue using the current build mode."
|
||||
)
|
||||
|
||||
# Scan local sources for tracked packages so the new revision carries
|
||||
# the same metadata GitHub-backed deploys produce. Failures must never
|
||||
# block a deploy.
|
||||
@@ -1676,7 +1833,25 @@ def _deploy_cmd(
|
||||
tracked_packages = None
|
||||
|
||||
# -- 3. Build (divergent path) --
|
||||
if use_remote_build:
|
||||
if use_external_docker:
|
||||
build_result = _run_external_deploy(
|
||||
client=client,
|
||||
deployment_id=deployment_id,
|
||||
step=step,
|
||||
config=config,
|
||||
config_json=config_json,
|
||||
image_uri=image_uri,
|
||||
verbose=verbose,
|
||||
pull=pull,
|
||||
api_version=api_version,
|
||||
base_image=base_image,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
docker_build_args=docker_build_args,
|
||||
secrets=secrets,
|
||||
tracked_packages=tracked_packages,
|
||||
)
|
||||
elif use_remote_build:
|
||||
build_result = _run_remote_build(
|
||||
client=client,
|
||||
deployment_id=deployment_id,
|
||||
@@ -1715,7 +1890,7 @@ def _deploy_cmd(
|
||||
dep_status_url = _emit_deployment_status_url(
|
||||
build_result.updated,
|
||||
deployment_id,
|
||||
host_url,
|
||||
client.base_url,
|
||||
)
|
||||
|
||||
if no_wait:
|
||||
|
||||
@@ -36,12 +36,15 @@ class HostBackendClient:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
transport=transport,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -50,7 +53,8 @@ class HostBackendClient:
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
try:
|
||||
resp = self._client.request(method, path, json=payload, params=params)
|
||||
full_url = self._base_url + path
|
||||
resp = self._client.request(method, full_url, json=payload, params=params)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as err:
|
||||
detail = err.response.text or str(err.response.status_code)
|
||||
@@ -138,6 +142,27 @@ class HostBackendClient:
|
||||
payload,
|
||||
)
|
||||
|
||||
def update_deployment_external(
|
||||
self,
|
||||
deployment_id: str,
|
||||
image_uri: str,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
tracked_packages: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update a deployment with a pre-built external image."""
|
||||
payload: dict[str, Any] = {
|
||||
"source_revision_config": {"image_uri": image_uri},
|
||||
}
|
||||
if tracked_packages:
|
||||
payload["tracked_packages"] = tracked_packages
|
||||
if secrets is not None:
|
||||
payload["secrets"] = secrets
|
||||
return self._request(
|
||||
"PATCH",
|
||||
f"/v2/deployments/{deployment_id}",
|
||||
payload,
|
||||
)
|
||||
|
||||
def update_deployment_internal_source(
|
||||
self,
|
||||
deployment_id: str,
|
||||
|
||||
@@ -1354,3 +1354,49 @@ def test_prepare_args_and_stdin_distributed_mode() -> None:
|
||||
assert "langgraph-executor:" in actual_stdin
|
||||
assert "FROM langchain/langgraph-executor:" in actual_stdin
|
||||
assert "executor_entrypoint.sh" in actual_stdin
|
||||
|
||||
|
||||
def test_deploy_image_uri_rejects_incompatible_source(monkeypatch, tmp_path) -> None:
|
||||
"""--image-uri raises UsageError when applied to a non-external_docker deployment."""
|
||||
# --no-input sets the module-level _no_input global; ensure it's restored.
|
||||
monkeypatch.setattr(deploy_module, "_no_input", False)
|
||||
|
||||
config = tmp_path / "langgraph.json"
|
||||
config.write_text('{"graphs": {"agent": "agent.py:graph"}, "dependencies": ["."]}')
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
||||
self.base_url = host_url
|
||||
|
||||
def get_deployment(self, deployment_id: str):
|
||||
return {
|
||||
"id": deployment_id,
|
||||
"name": "test-deploy",
|
||||
"source": "internal_docker",
|
||||
"tenant_id": "tenant-1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"deploy",
|
||||
"--api-key",
|
||||
"test-key",
|
||||
"--host-url",
|
||||
"https://api.example.com",
|
||||
"--deployment-id",
|
||||
"dep-123",
|
||||
"--image-uri",
|
||||
"registry.example.com/app:latest",
|
||||
"--config",
|
||||
str(config),
|
||||
"--no-input",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "different build mode" in result.output
|
||||
assert "cannot be updated with --image-uri" in result.output
|
||||
|
||||
@@ -543,6 +543,55 @@ class TestCreateHostBackendClientNoInput:
|
||||
assert client is not None
|
||||
|
||||
|
||||
class TestCreateHostBackendClientEndpointFallback:
|
||||
def test_langsmith_endpoint_env_var_used_as_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
|
||||
monkeypatch.delenv("LANGGRAPH_HOST_URL", raising=False)
|
||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
||||
assert client.base_url == "https://smith.example.com/api-host"
|
||||
|
||||
def test_langsmith_endpoint_from_env_vars_dict(self, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
||||
client = _create_host_backend_client(
|
||||
host_url=None,
|
||||
api_key=None,
|
||||
env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"},
|
||||
)
|
||||
assert client.base_url == "https://smith.example.com/api-host"
|
||||
|
||||
def test_cloud_langsmith_endpoint_not_used_as_self_hosted(self, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
||||
assert client.base_url == "https://api.host.langchain.com"
|
||||
|
||||
def test_langchain_api_endpoint_not_used_as_self_hosted(self, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.langchain.com")
|
||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
||||
assert client.base_url == "https://api.host.langchain.com"
|
||||
|
||||
def test_explicit_host_url_takes_precedence_over_langsmith_endpoint(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
|
||||
client = _create_host_backend_client(
|
||||
host_url="https://custom.host.com",
|
||||
api_key=None,
|
||||
env_vars={},
|
||||
)
|
||||
assert client.base_url == "https://custom.host.com"
|
||||
|
||||
def test_no_endpoint_falls_back_to_cloud_default(self, monkeypatch):
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
||||
assert client.base_url == "https://api.host.langchain.com"
|
||||
|
||||
|
||||
class TestSmithDashboardBaseUrl:
|
||||
def test_none_returns_default(self):
|
||||
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
|
||||
@@ -598,6 +647,24 @@ class TestSmithDashboardBaseUrl:
|
||||
== "https://smith.langchain.com"
|
||||
)
|
||||
|
||||
def test_self_hosted_api_host_suffix(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://smith.example.com/api-host")
|
||||
== "https://smith.example.com"
|
||||
)
|
||||
|
||||
def test_self_hosted_api_host_trailing_slash(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("https://smith.example.com/api-host/")
|
||||
== "https://smith.example.com"
|
||||
)
|
||||
|
||||
def test_self_hosted_localhost_api_host(self):
|
||||
assert (
|
||||
_smith_dashboard_base_url("http://localhost:8080/api-host")
|
||||
== "http://localhost:8080"
|
||||
)
|
||||
|
||||
|
||||
class TestResolvePushedImageDigest:
|
||||
"""Tests for ``_resolve_pushed_image_digest`` — runner is mocked to
|
||||
|
||||
@@ -25,7 +25,7 @@ def client(mock_transport):
|
||||
|
||||
def test_constructor_strips_trailing_slash():
|
||||
c = HostBackendClient("https://api.example.com/", "key")
|
||||
assert str(c._client.base_url) == "https://api.example.com"
|
||||
assert c.base_url == "https://api.example.com"
|
||||
|
||||
|
||||
def test_constructor_empty_url_raises():
|
||||
@@ -178,6 +178,59 @@ def test_update_deployment_no_secrets(client):
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_update_deployment_external():
|
||||
captured: dict = {}
|
||||
c = _capturing_client(captured)
|
||||
result = c.update_deployment_external(
|
||||
"dep-123", "registry.example.com/app@sha256:abc123"
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
body = json.loads(captured["body"])
|
||||
assert "revision_source" not in body
|
||||
assert body["source_revision_config"]["image_uri"] == (
|
||||
"registry.example.com/app@sha256:abc123"
|
||||
)
|
||||
|
||||
|
||||
def test_update_deployment_external_forwards_tracked_packages():
|
||||
captured: dict = {}
|
||||
c = _capturing_client(captured)
|
||||
c.update_deployment_external(
|
||||
"dep-123",
|
||||
"registry.example.com/app:latest",
|
||||
tracked_packages=["google-adk:1.0.0"],
|
||||
)
|
||||
body = json.loads(captured["body"])
|
||||
assert body["tracked_packages"] == ["google-adk:1.0.0"]
|
||||
assert "revision_source" not in body
|
||||
|
||||
|
||||
def test_update_deployment_external_omits_tracked_packages_when_absent():
|
||||
captured: dict = {}
|
||||
c = _capturing_client(captured)
|
||||
c.update_deployment_external("dep-123", "registry.example.com/app:latest")
|
||||
body = json.loads(captured["body"])
|
||||
assert "tracked_packages" not in body
|
||||
assert "revision_source" not in body
|
||||
|
||||
|
||||
def test_request_builds_full_url_with_path_prefix():
|
||||
"""base_url with a path prefix (/api-host) must not be dropped when paths start with /."""
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert str(req.url) == "https://smith.example.com/api-host/v2/deployments"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient("https://smith.example.com/api-host", "key")
|
||||
c._client = httpx.Client(
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c._request("GET", "/v2/deployments")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def _capturing_client(captured: dict) -> HostBackendClient:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = req.read()
|
||||
|
||||
Reference in New Issue
Block a user