mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
feat: support rootless docker when pushing images to cluster (#347)
This commit is contained in:
+201
-44
@@ -5,7 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +35,12 @@ import (
|
|||||||
netproxy "golang.org/x/net/proxy"
|
netproxy "golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// This is the container image used to run socat proxy containers.
|
||||||
|
// TODO: it's an external dependency that we don't control, so we should consider vendoring it or
|
||||||
|
// automatically building an ad-hoc image with socat-like functionality (e.g. using a lightweight Go
|
||||||
|
// proxy) as part of the push process.
|
||||||
|
const socatImage = "alpine/socat:1.8.0.3"
|
||||||
|
|
||||||
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
images, err := cli.Docker.InspectImage(ctx, id)
|
images, err := cli.Docker.InspectImage(ctx, id)
|
||||||
if errdefs.IsNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
@@ -235,26 +244,55 @@ func (cli *Client) pushImageToMachine(
|
|||||||
return fmt.Errorf("get proxy dialer: %w", err)
|
return fmt.Errorf("get proxy dialer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dockerEnv, err := detectDockerEnvironment(ctx, dockerCli)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Debug("Detected Docker environment:", "virtualised", dockerEnv.Virtualised, "rootless", dockerEnv.Rootless)
|
||||||
|
|
||||||
proxyEventID := fmt.Sprintf("Proxy to unregistry on %s", boldStyle.Render(machine.Name))
|
proxyEventID := fmt.Sprintf("Proxy to unregistry on %s", boldStyle.Render(machine.Name))
|
||||||
pw.Event(progress.StartingEvent(proxyEventID))
|
pw.Event(progress.StartingEvent(proxyEventID))
|
||||||
|
|
||||||
// The proxy runs in a goroutine. Capture the first error in a channel
|
// The proxy runs in a goroutine. Capture the first error in a channel
|
||||||
// so we can surface it alongside the push error if push fails.
|
// so we can surface it alongside the push error if push fails.
|
||||||
proxyErrCh := make(chan error, 1)
|
proxyErrCh := make(chan error, 1)
|
||||||
// Forward local port 127.0.0.1:PORT to the machine's unregistry over the established client connection.
|
onProxyError := func(err error) {
|
||||||
unregProxy, err := newUnregistryProxy(ctx, unregistryAddr, dialer, func(err error) {
|
|
||||||
select {
|
select {
|
||||||
case proxyErrCh <- fmt.Errorf("proxy to unregistry: %w", err):
|
case proxyErrCh <- fmt.Errorf("proxy to unregistry: %w", err):
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
|
||||||
return fmt.Errorf("create local proxy to unregistry on machine '%s': %w", machine.Name, err)
|
|
||||||
}
|
}
|
||||||
// Get the local port the unregistry proxy is listening on.
|
|
||||||
proxyPort := unregProxy.Listener.Addr().(*net.TCPAddr).Port
|
// socketPath is set for plain rootless Docker (not running inside a VM): the Go proxy listens on a unix
|
||||||
|
// socket that is bind-mounted into the socat container, bypassing slirp4netns network routing entirely.
|
||||||
|
var (
|
||||||
|
socketPath string
|
||||||
|
proxyPort int
|
||||||
|
)
|
||||||
|
var unregProxy *proxy.Proxy
|
||||||
|
if shouldUseUnregistryUnixProxy(dockerEnv) {
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("generate socket path suffix: %w", err)
|
||||||
|
}
|
||||||
|
socketPath = filepath.Join(os.TempDir(), fmt.Sprintf("uncloud-push-%s.sock", suffix))
|
||||||
|
unregProxy, err = newUnregistryUnixProxy(ctx, unregistryAddr, dialer, socketPath, onProxyError)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("create local unix socket proxy to unregistry on machine '%s': %w", machine.Name, err)
|
||||||
|
}
|
||||||
|
slog.Debug("Listening for unregistry proxy connections.", "mode", "unix", "socket", socketPath)
|
||||||
|
} else {
|
||||||
|
unregProxy, err = newUnregistryTcpProxy(ctx, unregistryAddr, dialer, onProxyError)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("create local tcp proxy to unregistry on machine '%s': %w", machine.Name, err)
|
||||||
|
}
|
||||||
|
proxyPort = unregProxy.Listener.Addr().(*net.TCPAddr).Port
|
||||||
|
slog.Debug("Listening for unregistry proxy connections.", "mode", "tcp", "port", proxyPort)
|
||||||
|
}
|
||||||
|
|
||||||
proxyCtx, cancelProxy := context.WithCancel(ctx)
|
proxyCtx, cancelProxy := context.WithCancel(ctx)
|
||||||
proxyCtrID := ""
|
proxyCtrID := ""
|
||||||
@@ -272,31 +310,47 @@ func (cli *Client) pushImageToMachine(
|
|||||||
dockerCli.ContainerRemove(ctx, proxyCtrID, container.RemoveOptions{Force: true})
|
dockerCli.ContainerRemove(ctx, proxyCtrID, container.RemoveOptions{Force: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the unix socket file used for rootless Docker proxying.
|
||||||
|
if socketPath != "" {
|
||||||
|
os.Remove(socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
cancelProxy()
|
cancelProxy()
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
go unregProxy.Run(proxyCtx)
|
go unregProxy.Run(proxyCtx)
|
||||||
|
|
||||||
dockerVirtualised, err := isDockerVirtualised(ctx, dockerCli)
|
if dockerEnv.Virtualised {
|
||||||
if err != nil {
|
// VM-based Docker (Docker Desktop, Rancher Desktop, etc.): run a socat container inside the VM
|
||||||
return err
|
// to forward a port via host.docker.internal back to the host-side proxy.
|
||||||
}
|
|
||||||
|
|
||||||
if dockerVirtualised {
|
|
||||||
// Run socat proxy container to forward a localhost port from within the Docker VM to the host machine.
|
|
||||||
pw.Event(progress.Event{
|
pw.Event(progress.Event{
|
||||||
ID: proxyEventID,
|
ID: proxyEventID,
|
||||||
Status: progress.Working,
|
Status: progress.Working,
|
||||||
StatusText: "Starting",
|
StatusText: "Starting",
|
||||||
Text: "(detected Docker in VM locally, starting socat proxy container)",
|
Text: "(detected Docker in VM locally, starting socat proxy container)",
|
||||||
})
|
})
|
||||||
|
|
||||||
proxyCtrID, proxyPort, err = runDockerVMProxyContainer(ctx, dockerCli, proxyPort)
|
proxyCtrID, proxyPort, err = runDockerVMProxyContainer(ctx, dockerCli, proxyPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
return fmt.Errorf("run socat container to proxy unregistry to Docker VM: %w", err)
|
return fmt.Errorf("run socat container to proxy unregistry: %w", err)
|
||||||
}
|
}
|
||||||
|
slog.Debug("Started VM socat proxy container.", "id", proxyCtrID, "hostPort", proxyPort)
|
||||||
|
} else if shouldUseUnregistryUnixProxy(dockerEnv) {
|
||||||
|
// Plain rootless Docker: run a socat container that forwards via a bind-mounted unix socket,
|
||||||
|
// bypassing the slirp4netns --disable-host-loopback restriction.
|
||||||
|
pw.Event(progress.Event{
|
||||||
|
ID: proxyEventID,
|
||||||
|
Status: progress.Working,
|
||||||
|
StatusText: "Starting",
|
||||||
|
Text: "(detected rootless Docker, starting socat proxy container)",
|
||||||
|
})
|
||||||
|
proxyCtrID, proxyPort, err = runUnixSocketProxyContainer(ctx, dockerCli, socketPath)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("run socat container with unix socket to proxy unregistry: %w", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Started unix socket socat proxy container.", "id", proxyCtrID, "hostPort", proxyPort, "socket", socketPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
pw.Event(progress.Event{
|
pw.Event(progress.Event{
|
||||||
@@ -350,18 +404,27 @@ func (cli *Client) pushImageToMachine(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnregistryProxy(
|
// checkRemoteConnectivity verifies that remoteAddr is reachable via dialer within a timeout.
|
||||||
ctx context.Context, remoteAddr string, dialer netproxy.ContextDialer, onError func(error),
|
func checkRemoteConnectivity(ctx context.Context, dialer netproxy.ContextDialer, remoteAddr string) error {
|
||||||
) (*proxy.Proxy, error) {
|
|
||||||
// Test remote connectivity before creating a proxy.
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
testConn, err := dialer.DialContext(ctx, "tcp", remoteAddr)
|
conn, err := dialer.DialContext(ctx, "tcp", remoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("connect to remote address '%s': %w", remoteAddr, err)
|
return fmt.Errorf("connect to remote address '%s': %w", remoteAddr, err)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUnregistryTcpProxy creates a TCP proxy that listens on localhost and forwards to the unregistry
|
||||||
|
// address on the target machine.
|
||||||
|
func newUnregistryTcpProxy(
|
||||||
|
ctx context.Context, remoteAddr string, dialer netproxy.ContextDialer, onError func(error),
|
||||||
|
) (*proxy.Proxy, error) {
|
||||||
|
if err := checkRemoteConnectivity(ctx, dialer, remoteAddr); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
testConn.Close()
|
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -378,39 +441,109 @@ func newUnregistryProxy(
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDockerVirtualised checks if Docker is running in a virtualised environment like Docker/Rancher Desktop or Colima.
|
// newUnregistryUnixProxy creates a proxy that listens on a unix socket at socketPath and forwards to the
|
||||||
// On macOS, Docker always requires a VM, so it returns true unless OrbStack is detected (which handles host networking
|
// unregistry address on the target machine.
|
||||||
// natively). On other platforms, it checks for known virtualised Docker hostnames.
|
func newUnregistryUnixProxy(
|
||||||
func isDockerVirtualised(ctx context.Context, dockerCli *docker.Client) (bool, error) {
|
ctx context.Context, remoteAddr string, dialer netproxy.ContextDialer, socketPath string, onError func(error),
|
||||||
info, err := dockerCli.Info(ctx)
|
) (*proxy.Proxy, error) {
|
||||||
if err != nil {
|
if err := checkRemoteConnectivity(ctx, dialer, remoteAddr); err != nil {
|
||||||
return false, fmt.Errorf("get Docker info: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS, Docker always runs in a VM. OrbStack is the only known exception that doesn't need a proxy.
|
listener, err := net.Listen("unix", socketPath)
|
||||||
if runtime.GOOS == "darwin" {
|
if err != nil {
|
||||||
if info.Name == "orbstack" {
|
return nil, fmt.Errorf("listen on unix socket %s: %w", socketPath, err)
|
||||||
return false, nil
|
}
|
||||||
|
// Restrict socket access to the current user to prevent other local users from connecting
|
||||||
|
// to the proxy and reaching the unregistry for the duration of the push.
|
||||||
|
if err = os.Chmod(socketPath, 0o600); err != nil {
|
||||||
|
listener.Close()
|
||||||
|
return nil, fmt.Errorf("set unix socket permissions on %s: %w", socketPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &proxy.Proxy{
|
||||||
|
Listener: listener,
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
OnError: onError,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerEnvironment describes the local Docker environment type.
|
||||||
|
type dockerEnvironment struct {
|
||||||
|
// Virtualised indicates Docker runs inside a VM (Docker Desktop, Rancher Desktop, Colima).
|
||||||
|
Virtualised bool
|
||||||
|
// Rootless indicates Docker is running in rootless mode
|
||||||
|
Rootless bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectDockerEnvironment inspects the local Docker daemon to determine if it is running in a virtualised
|
||||||
|
// environment (VM) or in rootless mode. Both cases require a socat proxy container to route connections
|
||||||
|
// across the network boundary between the local Docker daemon and the host.
|
||||||
|
func detectDockerEnvironment(ctx context.Context, dockerCli *docker.Client) (dockerEnvironment, error) {
|
||||||
|
info, err := dockerCli.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return dockerEnvironment{}, fmt.Errorf("get Docker info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := dockerEnvironment{}
|
||||||
|
|
||||||
|
for _, opt := range info.SecurityOptions {
|
||||||
|
if strings.Contains(opt, "rootless") {
|
||||||
|
env.Rootless = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return true, nil
|
}
|
||||||
|
|
||||||
|
// On macOS, Docker always requires a VM, so set Virtualised to true unless OrbStack is
|
||||||
|
// detected (which handles host networking natively).
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
if info.Name != "orbstack" {
|
||||||
|
env.Virtualised = true
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// On other platforms, check for known virtualised Docker environments.
|
// On other platforms, check for known virtualised Docker environments.
|
||||||
virtualisedHostnames := []string{"docker-desktop", "rancher-desktop", "colima"}
|
virtualisedHostnames := []string{"docker-desktop", "rancher-desktop", "colima"}
|
||||||
for _, name := range virtualisedHostnames {
|
for _, name := range virtualisedHostnames {
|
||||||
if strings.Contains(strings.ToLower(info.Name), name) {
|
if strings.Contains(strings.ToLower(info.Name), name) {
|
||||||
return true, nil
|
env.Virtualised = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return env, nil
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDockerVMProxyContainer creates a socat container to proxy an available localhost port within the Docker VM
|
// shouldUseUnregistryUnixProxy reports whether the local Docker environment requires a unix socket proxy
|
||||||
// (e.g. Docker Desktop on macOS) to the specified target port on the host machine.
|
// to reach the unregistry. This is the case for rootless Docker not running inside a VM, where
|
||||||
|
// slirp4netns --disable-host-loopback blocks TCP routing from the container network namespace back to the host.
|
||||||
|
func shouldUseUnregistryUnixProxy(env dockerEnvironment) bool {
|
||||||
|
// TODO: handle Virtualised AND Rootless case when we encounter it.
|
||||||
|
return env.Rootless && !env.Virtualised
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDockerVMProxyContainer creates a socat container inside the Docker VM (e.g. Docker Desktop on macOS)
|
||||||
|
// to forward TCP connections from a localhost port to the specified target port on the host via host.docker.internal.
|
||||||
// Returns the container ID and the localhost port the container port is bound to.
|
// Returns the container ID and the localhost port the container port is bound to.
|
||||||
// TODO: accept custom image name.
|
|
||||||
func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, targetPort int) (string, int, error) {
|
func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, targetPort int) (string, int, error) {
|
||||||
|
return runSocatProxyContainer(ctx, dockerCli,
|
||||||
|
fmt.Sprintf("TCP-CONNECT:host.docker.internal:%d", targetPort), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUnixSocketProxyContainer creates a socat container that forwards TCP connections to the host-side Go
|
||||||
|
// proxy via a bind-mounted unix socket.
|
||||||
|
// Returns the container ID and the localhost port that 'docker push' should target.
|
||||||
|
func runUnixSocketProxyContainer(ctx context.Context, dockerCli *docker.Client, socketPath string) (string, int, error) {
|
||||||
|
return runSocatProxyContainer(ctx, dockerCli,
|
||||||
|
fmt.Sprintf("UNIX-CONNECT:%s", socketPath),
|
||||||
|
[]string{fmt.Sprintf("%s:%s", socketPath, socketPath)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSocatProxyContainer creates a socat container that listens on TCP port 5000 and forwards to socatDst.
|
||||||
|
// binds is an optional list of host:container bind mounts (e.g. for a unix socket).
|
||||||
|
// Returns the container ID and the localhost port that clients should connect to.
|
||||||
|
func runSocatProxyContainer(ctx context.Context, dockerCli *docker.Client, socatDst string, binds []string) (string, int, error) {
|
||||||
suffix, err := secret.RandomAlphaNumeric(4)
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("generate random suffix: %w", err)
|
return "", 0, fmt.Errorf("generate random suffix: %w", err)
|
||||||
@@ -420,14 +553,14 @@ func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, ta
|
|||||||
containerPort := nat.Port("5000/tcp")
|
containerPort := nat.Port("5000/tcp")
|
||||||
config := &container.Config{
|
config := &container.Config{
|
||||||
// TODO: make image configurable.
|
// TODO: make image configurable.
|
||||||
Image: "alpine/socat:latest",
|
Image: socatImage,
|
||||||
// Reset the default entrypoint "socat".
|
// Reset the default entrypoint "socat".
|
||||||
Entrypoint: []string{},
|
Entrypoint: []string{},
|
||||||
Cmd: []string{
|
Cmd: []string{
|
||||||
"timeout", "1800", // Auto-terminate socat after 30 minutes.
|
"timeout", "1800", // Auto-terminate socat after 30 minutes.
|
||||||
"socat",
|
"socat",
|
||||||
"TCP-LISTEN:5000,fork,reuseaddr",
|
"TCP-LISTEN:5000,fork,reuseaddr",
|
||||||
fmt.Sprintf("TCP-CONNECT:host.docker.internal:%d", targetPort),
|
socatDst,
|
||||||
},
|
},
|
||||||
ExposedPorts: nat.PortSet{
|
ExposedPorts: nat.PortSet{
|
||||||
containerPort: {},
|
containerPort: {},
|
||||||
@@ -449,6 +582,7 @@ func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, ta
|
|||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
|
Binds: binds,
|
||||||
PortBindings: nat.PortMap{
|
PortBindings: nat.PortMap{
|
||||||
containerPort: []nat.PortBinding{
|
containerPort: []nat.PortBinding{
|
||||||
{
|
{
|
||||||
@@ -474,9 +608,32 @@ func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, ta
|
|||||||
return "", 0, fmt.Errorf("start socat proxy container %s: %w", resp.ID, err)
|
return "", 0, fmt.Errorf("start socat proxy container %s: %w", resp.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for socat to start listening inside the container. ContainerStart returns as soon as the
|
||||||
|
// container process is launched, but socat may not have bound its port yet. Without this check,
|
||||||
|
// the first 'docker push' connection can arrive before socat is ready and get reset.
|
||||||
|
addr := fmt.Sprintf("127.0.0.1:%d", hostPort)
|
||||||
|
if err = waitForTCPPort(addr, 10*time.Second); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", 0, fmt.Errorf("socat proxy container %s: %w", resp.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
return resp.ID, hostPort, nil
|
return resp.ID, hostPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForTCPPort polls addr until a TCP connection succeeds or timeout is reached.
|
||||||
|
func waitForTCPPort(addr string, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("port %s did not become ready within %s", addr, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
// toPushProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
// toPushProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||||
// It's based on toPushProgressEvent from Docker Compose.
|
// It's based on toPushProgressEvent from Docker Compose.
|
||||||
func toPushProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
func toPushProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||||
|
|||||||
Reference in New Issue
Block a user