Skip to content

Commit d0ddd2e

Browse files
authored
Merge pull request #1421 from yuvipanda/docker-login
Switch to using CLI for everything except running the container
2 parents 17a23c4 + 342eea9 commit d0ddd2e

17 files changed

+418
-279
lines changed

.github/workflows/test.yml

+3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ env:
4040
GIT_AUTHOR_EMAIL: [email protected]
4141
GIT_AUTHOR_NAME: CI User
4242

43+
4344
jobs:
4445
test:
4546
# Don't run scheduled tests on forks
@@ -64,6 +65,7 @@ jobs:
6465
- unit
6566
- venv
6667
- contentproviders
68+
- norun
6769
# Playwright test
6870
- ui
6971
include:
@@ -74,6 +76,7 @@ jobs:
7476

7577
steps:
7678
- uses: actions/checkout@v4
79+
7780
- uses: actions/setup-python@v5
7881
with:
7982
python-version: "${{ matrix.python_version }}"

dev-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pytest-cov
55
pytest>=7
66
pyyaml
77
requests_mock
8+
bcrypt

docs/source/architecture.md

+2-4
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,8 @@ At the end of the assemble step, the docker image is ready to be used in various
9898

9999
### Push
100100

101-
Optionally, repo2docker can **push** a built image to a [docker registry](https://docs.docker.com/registry/).
102-
This is done as a convenience only (since you can do the same with a `docker push` after using repo2docker
103-
only to build), and implemented in `Repo2Docker.push` method. It is only activated if using the
104-
`--push` commandline flag.
101+
Optionally, repo2docker can **push** a built image to a [docker registry](https://docs.docker.com/registry/),
102+
if you specify the `--push` flag.
105103

106104
### Run
107105

repo2docker/app.py

+11-74
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
RBuildPack,
3838
)
3939
from .engine import BuildError, ContainerEngineException, ImageLoadError
40-
from .utils import ByteSpecification, R2dState, chdir, get_platform
40+
from .utils import ByteSpecification, R2dState, chdir, get_free_port, get_platform
4141

4242

4343
class Repo2Docker(Application):
@@ -572,56 +572,6 @@ def initialize(self, *args, **kwargs):
572572
if self.volumes and not self.run:
573573
raise ValueError("Cannot mount volumes if container is not run")
574574

575-
def push_image(self):
576-
"""Push docker image to registry"""
577-
client = self.get_engine()
578-
# Build a progress setup for each layer, and only emit per-layer
579-
# info every 1.5s
580-
progress_layers = {}
581-
layers = {}
582-
last_emit_time = time.time()
583-
for chunk in client.push(self.output_image_spec):
584-
if client.string_output:
585-
self.log.info(chunk, extra=dict(phase=R2dState.PUSHING))
586-
continue
587-
# else this is Docker output
588-
589-
# each chunk can be one or more lines of json events
590-
# split lines here in case multiple are delivered at once
591-
for line in chunk.splitlines():
592-
line = line.decode("utf-8", errors="replace")
593-
try:
594-
progress = json.loads(line)
595-
except Exception as e:
596-
self.log.warning("Not a JSON progress line: %r", line)
597-
continue
598-
if "error" in progress:
599-
self.log.error(progress["error"], extra=dict(phase=R2dState.FAILED))
600-
raise ImageLoadError(progress["error"])
601-
if "id" not in progress:
602-
continue
603-
# deprecated truncated-progress data
604-
if "progressDetail" in progress and progress["progressDetail"]:
605-
progress_layers[progress["id"]] = progress["progressDetail"]
606-
else:
607-
progress_layers[progress["id"]] = progress["status"]
608-
# include full progress data for each layer in 'layers' data
609-
layers[progress["id"]] = progress
610-
if time.time() - last_emit_time > 1.5:
611-
self.log.info(
612-
"Pushing image\n",
613-
extra=dict(
614-
progress=progress_layers,
615-
layers=layers,
616-
phase=R2dState.PUSHING,
617-
),
618-
)
619-
last_emit_time = time.time()
620-
self.log.info(
621-
f"Successfully pushed {self.output_image_spec}",
622-
extra=dict(phase=R2dState.PUSHING),
623-
)
624-
625575
def run_image(self):
626576
"""Run docker container from built image
627577
@@ -660,7 +610,7 @@ def start_container(self):
660610
container_port = int(container_port_proto.split("/", 1)[0])
661611
else:
662612
# no port specified, pick a random one
663-
container_port = host_port = str(self._get_free_port())
613+
container_port = host_port = str(get_free_port())
664614
self.ports = {f"{container_port}/tcp": host_port}
665615
self.port = host_port
666616
# To use the option --NotebookApp.custom_display_url
@@ -744,30 +694,14 @@ def wait_for_container(self, container):
744694
if exit_code:
745695
sys.exit(exit_code)
746696

747-
def _get_free_port(self):
748-
"""
749-
Hacky method to get a free random port on local host
750-
"""
751-
import socket
752-
753-
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
754-
s.bind(("", 0))
755-
port = s.getsockname()[1]
756-
s.close()
757-
return port
758-
759697
def find_image(self):
760698
# if this is a dry run it is Ok for dockerd to be unreachable so we
761699
# always return False for dry runs.
762700
if self.dry_run:
763701
return False
764702
# check if we already have an image for this content
765-
client = self.get_engine()
766-
for image in client.images():
767-
for tag in image.tags:
768-
if tag == self.output_image_spec + ":latest":
769-
return True
770-
return False
703+
engine = self.get_engine()
704+
return engine.inspect_image(self.output_image_spec) is not None
771705

772706
def build(self):
773707
"""
@@ -863,14 +797,20 @@ def build(self):
863797
extra=dict(phase=R2dState.BUILDING),
864798
)
865799

800+
extra_build_kwargs = self.extra_build_kwargs.copy()
801+
# Set "push" and "load" parameters in a backwards compat way, without
802+
# having to change the signature of every buildpack
803+
extra_build_kwargs["push"] = self.push
804+
extra_build_kwargs["load"] = self.run
805+
866806
for l in picked_buildpack.build(
867807
docker_client,
868808
self.output_image_spec,
869809
# This is deprecated, but passing it anyway to not break backwards compatibility
870810
self.build_memory_limit,
871811
build_args,
872812
self.cache_from,
873-
self.extra_build_kwargs,
813+
extra_build_kwargs,
874814
platform=self.platform,
875815
):
876816
if docker_client.string_output:
@@ -902,8 +842,5 @@ def build(self):
902842
def start(self):
903843
self.build()
904844

905-
if self.push:
906-
self.push_image()
907-
908845
if self.run:
909846
self.run_image()

repo2docker/buildpacks/docker.py

-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
import os
55

6-
import docker
7-
86
from .base import BuildPack
97

108

repo2docker/docker.py

+82-39
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22
Docker container engine for repo2docker
33
"""
44

5+
import json
6+
import os
57
import shutil
8+
import subprocess
69
import tarfile
710
import tempfile
11+
from argparse import ArgumentError
12+
from contextlib import ExitStack, contextmanager
13+
from pathlib import Path
814

915
from iso8601 import parse_date
1016
from traitlets import Dict, List, Unicode
1117

1218
import docker
1319

14-
from .engine import Container, ContainerEngine, ContainerEngineException, Image
20+
from .engine import Container, ContainerEngine, Image
1521
from .utils import execute_cmd
1622

1723

@@ -58,7 +64,7 @@ class DockerEngine(ContainerEngine):
5864
https://docker-py.readthedocs.io/en/4.2.0/api.html#module-docker.api.build
5965
"""
6066

61-
string_output = False
67+
string_output = True
6268

6369
extra_init_args = Dict(
6470
{},
@@ -82,19 +88,11 @@ class DockerEngine(ContainerEngine):
8288
config=True,
8389
)
8490

85-
def __init__(self, *, parent):
86-
super().__init__(parent=parent)
87-
try:
88-
kwargs = docker.utils.kwargs_from_env()
89-
kwargs.update(self.extra_init_args)
90-
kwargs.setdefault("version", "auto")
91-
self._apiclient = docker.APIClient(**kwargs)
92-
except docker.errors.DockerException as e:
93-
raise ContainerEngineException("Check if docker is running on the host.", e)
94-
9591
def build(
9692
self,
9793
*,
94+
push=False,
95+
load=False,
9896
buildargs=None,
9997
cache_from=None,
10098
container_limits=None,
@@ -109,7 +107,17 @@ def build(
109107
):
110108
if not shutil.which("docker"):
111109
raise RuntimeError("The docker commandline client must be installed")
112-
args = ["docker", "buildx", "build", "--progress", "plain", "--load"]
110+
args = ["docker", "buildx", "build", "--progress", "plain"]
111+
if load:
112+
if push:
113+
raise ValueError(
114+
"Setting push=True and load=True is currently not supported"
115+
)
116+
args.append("--load")
117+
118+
if push:
119+
args.append("--push")
120+
113121
if buildargs:
114122
for k, v in buildargs.items():
115123
args += ["--build-arg", f"{k}={v}"]
@@ -134,38 +142,73 @@ def build(
134142
# place extra args right *before* the path
135143
args += self.extra_buildx_build_args
136144

137-
if fileobj:
138-
with tempfile.TemporaryDirectory() as d:
139-
tarf = tarfile.open(fileobj=fileobj)
140-
tarf.extractall(d)
145+
with ExitStack() as stack:
146+
if self.registry_credentials:
147+
stack.enter_context(self.docker_login(**self.registry_credentials))
148+
if fileobj:
149+
with tempfile.TemporaryDirectory() as d:
150+
tarf = tarfile.open(fileobj=fileobj)
151+
tarf.extractall(d)
141152

142-
args += [d]
143-
144-
for line in execute_cmd(args, True):
145-
# Simulate structured JSON output from buildx build, since we
146-
# do get structured json output from pushing and running
147-
yield {"stream": line}
148-
else:
149-
# Assume 'path' is passed in
150-
args += [path]
153+
args += [d]
151154

152-
for line in execute_cmd(args, True):
153-
# Simulate structured JSON output from buildx build, since we
154-
# do get structured json output from pushing and running
155-
yield {"stream": line}
155+
yield from execute_cmd(args, True)
156+
else:
157+
# Assume 'path' is passed in
158+
args += [path]
156159

157-
def images(self):
158-
images = self._apiclient.images()
159-
return [Image(tags=image["RepoTags"]) for image in images]
160+
yield from execute_cmd(args, True)
160161

161162
def inspect_image(self, image):
162-
image = self._apiclient.inspect_image(image)
163-
return Image(tags=image["RepoTags"], config=image["Config"])
163+
"""
164+
Return image configuration if it exists, otherwise None
165+
"""
166+
proc = subprocess.run(
167+
["docker", "image", "inspect", image], capture_output=True
168+
)
169+
170+
if proc.returncode != 0:
171+
return None
164172

165-
def push(self, image_spec):
166-
if self.registry_credentials:
167-
self._apiclient.login(**self.registry_credentials)
168-
return self._apiclient.push(image_spec, stream=True)
173+
config = json.loads(proc.stdout.decode())[0]
174+
return Image(tags=config["RepoTags"], config=config["Config"])
175+
176+
@contextmanager
177+
def docker_login(self, username, password, registry):
178+
# Determine existing DOCKER_CONFIG
179+
old_dc_path = os.environ.get("DOCKER_CONFIG")
180+
if old_dc_path is None:
181+
dc_path = Path("~/.docker/config.json").expanduser()
182+
else:
183+
dc_path = Path(old_dc_path)
184+
185+
with tempfile.TemporaryDirectory() as d:
186+
new_dc_path = Path(d) / "config.json"
187+
if dc_path.exists():
188+
# If there is an existing DOCKER_CONFIG, copy it to new location so we inherit
189+
# whatever configuration the user has already set
190+
shutil.copy2(dc_path, new_dc_path)
191+
192+
os.environ["DOCKER_CONFIG"] = d
193+
try:
194+
subprocess.run(
195+
[
196+
"docker",
197+
"login",
198+
"--username",
199+
username,
200+
"--password-stdin",
201+
registry,
202+
],
203+
input=password.encode(),
204+
check=True,
205+
)
206+
yield
207+
finally:
208+
if old_dc_path:
209+
os.environ["DOCKER_CONFIG"] = old_dc_path
210+
else:
211+
del os.environ["DOCKER_CONFIG"]
169212

170213
def run(
171214
self,

0 commit comments

Comments
 (0)