Skip to content

Commit bb63358

Browse files
authored
Merge pull request #586 from latchbio/ayush/private-ecr
2 parents bff1a93 + f223765 commit bb63358

8 files changed

Lines changed: 522 additions & 203 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ Types of changes
1616

1717
# Latch SDK Changelog
1818

19+
## 2.70.0 - 2026-03-17
20+
21+
### Added
22+
23+
* `latch image upload` command for uploading custom images to private latch storage
24+
1925
## 2.69.1 - 2026-03-02
2026

2127
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ include = ["src/**/*.py", "src/**/py.typed", "src/latch_cli/services/init/*"]
1212

1313
[project]
1414
name = "latch"
15-
version = "2.69.1"
15+
version = "2.70.0"
1616
description = "The Latch SDK"
1717
authors = [{ name = "Kenny Workman", email = "kenny@latch.bio" }]
1818
maintainers = [{ name = "Ayush Kamat", email = "ayush@latch.bio" }]

src/latch_cli/main.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,43 @@ def execute(
543543
_exec(execution_id=execution_id, egn_id=egn_id, container_index=container_index)
544544

545545

546+
@main.group()
547+
def image():
548+
"""Manage Private Image Uploads"""
549+
550+
551+
@image.command("upload")
552+
@click.argument("image-reference", type=str)
553+
@click.option("-n", "--image-name", is_flag=False, type=str)
554+
@click.option("-v", "--version", is_flag=False, type=str)
555+
@click.option(
556+
"-y",
557+
"--yes",
558+
is_flag=True,
559+
default=False,
560+
type=bool,
561+
help="Skip the confirmation dialog.",
562+
)
563+
@requires_login
564+
def upload_image(
565+
image_reference: str,
566+
*,
567+
image_name: Optional[str] = None,
568+
version: Optional[str] = None,
569+
yes: bool = False,
570+
):
571+
"""Uploads an existing Docker image to Latch ECR"""
572+
573+
from .services.private_images import upload_image
574+
575+
upload_image(
576+
image_ref=image_reference,
577+
image_name=image_name,
578+
version=version,
579+
skip_confirmation=yes,
580+
)
581+
582+
546583
@main.command("register")
547584
@click.argument("pkg_root", type=click.Path(exists=True, file_okay=False))
548585
@click.option(

src/latch_cli/services/docker/__init__.py

Whitespace-only changes.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import base64
2+
import os
3+
from dataclasses import asdict, dataclass
4+
from json import JSONDecodeError
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
from urllib.parse import urljoin
8+
9+
import boto3.session
10+
import click
11+
import docker
12+
import docker.auth
13+
import docker.errors
14+
import paramiko
15+
from docker.transport import SSHHTTPAdapter
16+
17+
from latch.utils import current_workspace
18+
from latch_cli import tinyrequests
19+
from latch_sdk_config.latch import NUCLEUS_URL, config
20+
21+
from ...utils import TemporarySSHCredentials, get_auth_header
22+
from ..register.register import print_and_write_build_logs, print_upload_logs
23+
24+
if TYPE_CHECKING:
25+
from collections.abc import Iterable
26+
27+
from ..register.utils import DockerBuildLogItem
28+
29+
30+
@dataclass
31+
class DockerCredentials:
32+
username: str
33+
password: str
34+
35+
36+
def get_credentials(image: str) -> DockerCredentials:
37+
response = tinyrequests.post(
38+
urljoin(NUCLEUS_URL, "/sdk/initiate-image-upload"),
39+
headers={"Authorization": get_auth_header()},
40+
json={"pkg_name": image, "ws_account_id": current_workspace()},
41+
)
42+
43+
try:
44+
data = response.json()
45+
46+
# todo(ayush): compute the authorization token in the endpoint and send it directly
47+
access_key = data["tmp_access_key"]
48+
secret_key = data["tmp_secret_key"]
49+
session_token = data["tmp_session_token"]
50+
except (JSONDecodeError, KeyError) as err:
51+
raise ValueError(
52+
f"malformed response on image upload: {response.content}"
53+
) from err
54+
55+
ecr = boto3.session.Session(
56+
aws_access_key_id=access_key,
57+
aws_secret_access_key=secret_key,
58+
aws_session_token=session_token,
59+
region_name="us-west-2",
60+
).client("ecr")
61+
62+
token = ecr.get_authorization_token()["authorizationData"][0]["authorizationToken"]
63+
username, password = base64.b64decode(token).decode("utf-8").split(":")
64+
65+
return DockerCredentials(username=username, password=password)
66+
67+
68+
def get_local_docker_client() -> docker.APIClient:
69+
try:
70+
host = os.environ.get("DOCKER_HOST")
71+
72+
if host is None or host == "":
73+
return docker.APIClient(base_url="unix://var/run/docker.sock")
74+
75+
cert_path = os.environ.get("DOCKER_CERT_PATH")
76+
if cert_path == "":
77+
cert_path = None
78+
79+
tls_verify = os.environ.get("DOCKER_TLS_VERIFY") != ""
80+
enable_tls = tls_verify or cert_path is not None
81+
82+
if not enable_tls:
83+
return docker.APIClient(host)
84+
85+
if cert_path is None:
86+
cert_path = Path.home() / ".docker"
87+
else:
88+
cert_path = Path(cert_path)
89+
90+
return docker.APIClient(
91+
host,
92+
tls=docker.tls.TLSConfig(
93+
client_cert=(str(cert_path / "cert.pem"), str(cert_path / "key.pem")),
94+
ca_cert=str(cert_path / "ca.pem"),
95+
verify=tls_verify,
96+
),
97+
)
98+
except docker.errors.DockerException as de:
99+
click.secho(
100+
"Docker is not running. Make sure that Docker is running before attempting to register a workflow.",
101+
fg="red",
102+
)
103+
raise click.exceptions.Exit(1) from de
104+
105+
106+
def dbnp(
107+
client: docker.APIClient,
108+
pkg_root: Path,
109+
image: str,
110+
version: str,
111+
dockerfile: Path,
112+
*,
113+
progress_plain: bool,
114+
):
115+
credentials = get_credentials(image)
116+
client._auth_configs = docker.auth.AuthConfig({ # noqa: SLF001
117+
"auths": {config.dkr_repo: asdict(credentials)}
118+
})
119+
120+
build_logs: Iterable[DockerBuildLogItem] = client.build(
121+
path=str(pkg_root),
122+
tag=f"{config.dkr_repo}/{image}:{version}",
123+
dockerfile=str(dockerfile),
124+
buildargs={"tag": f"{config.dkr_repo}/{image}:{version}"},
125+
decode=True,
126+
)
127+
128+
print_and_write_build_logs(
129+
build_logs, image, pkg_root, progress_plain=progress_plain
130+
)
131+
132+
upload_logs = client.push(
133+
repository=f"{config.dkr_repo}/{image}",
134+
tag=version,
135+
stream=True,
136+
decode=True,
137+
auth_config=asdict(credentials),
138+
)
139+
140+
print_upload_logs(upload_logs, image)
141+
142+
143+
def remote_dbnp(
144+
pkg_root: Path, image: str, version: str, dockerfile: Path, *, progress_plain: bool
145+
):
146+
key_path = pkg_root / ".latch" / "ssh_key"
147+
148+
with TemporarySSHCredentials(key_path) as keys:
149+
response = tinyrequests.post(
150+
urljoin(NUCLEUS_URL, "/sdk/provision-centromere"),
151+
headers={"Authorization": get_auth_header()},
152+
json={"public_key": keys.public_key},
153+
)
154+
155+
resp = response.json()
156+
try:
157+
hostname = resp["ip"]
158+
username = resp["username"]
159+
except KeyError as e:
160+
raise ValueError(
161+
f"Malformed response from request to provision centromere {resp}"
162+
) from e
163+
164+
ssh = paramiko.SSHClient()
165+
ssh.load_system_host_keys()
166+
ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy)
167+
168+
pkey = paramiko.PKey.from_path(key_path)
169+
ssh.connect(hostname, username=username, pkey=pkey)
170+
171+
transport = ssh.get_transport()
172+
assert transport is not None
173+
174+
transport.set_keepalive(30)
175+
176+
def _patched_connect(self: SSHHTTPAdapter): ...
177+
178+
def _patched_create_paramiko_client(self: SSHHTTPAdapter, base_url: str):
179+
self.ssh_client = ssh
180+
181+
SSHHTTPAdapter._create_paramiko_client = _patched_create_paramiko_client
182+
SSHHTTPAdapter._connect = _patched_connect
183+
184+
# todo(ayush): drop pydocker and connect to the socket directly
185+
client = docker.APIClient("ssh://fake", version="1.41")
186+
187+
dbnp(
188+
client, pkg_root, image, version, dockerfile, progress_plain=progress_plain
189+
)

0 commit comments

Comments
 (0)