func/docker/docker_client.go

163 lines
4.2 KiB
Go

package docker
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"time"
"knative.dev/kn-plugin-func/ssh"
"github.com/docker/docker/client"
)
// NewClient creates a new docker client.
// reads the DOCKER_HOST envvar but it may or may not return it as dockerHost.
// - For local connection (unix socket and windows named pipe) it returns the
// DOCKER_HOST directly.
// - For ssh connections it reads the DOCKER_HOST from the ssh remote.
// - For TCP connections it returns "" so it defaults in the remote (note that
// one should not be use client.DefaultDockerHost in this situation). This is
// needed beaus of TCP+tls connections.
func NewClient(defaultHost string) (dockerClient client.CommonAPIClient, dockerHost string, err error) {
var _url *url.URL
dockerHost = os.Getenv("DOCKER_HOST")
if dockerHost == "" && runtime.GOOS == "linux" && podmanPresent() {
_url, err = url.Parse(defaultHost)
if err != nil {
return
}
_, err = os.Stat(_url.Path)
switch {
case err != nil && !os.IsNotExist(err):
return
case os.IsNotExist(err):
dockerClient, dockerHost, err = newClientWithPodmanService()
return
}
}
_url, err = url.Parse(dockerHost)
isSSH := err == nil && _url.Scheme == "ssh"
isTCP := err == nil && _url.Scheme == "tcp"
if isTCP {
// With TCP, it's difficult to determine how to expose the daemon socket to lifecycle containers,
// so we are defaulting to standard docker location by returning empty string.
// This should work well most of the time.
dockerHost = ""
}
if !isSSH {
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
return
}
credentialsConfig := ssh.Config{
Identity: os.Getenv("DOCKER_HOST_SSH_IDENTITY"),
PassPhrase: os.Getenv("DOCKER_HOST_SSH_IDENTITY_PASSPHRASE"),
PasswordCallback: ssh.NewPasswordCbk(),
PassPhraseCallback: ssh.NewPassPhraseCbk(),
HostKeyCallback: ssh.NewHostKeyCbk(),
}
contextDialer, dockerHost, err := ssh.NewDialContext(_url, credentialsConfig)
if err != nil {
return
}
httpClient := &http.Client{
// No tls
// No proxy
Transport: &http.Transport{
DialContext: contextDialer.DialContext,
},
}
dockerClient, err = client.NewClientWithOpts(
client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpClient),
client.WithHost("http://placeholder/"))
if closer, ok := contextDialer.(io.Closer); ok {
dockerClient = clientWithAdditionalCleanup{
pimpl: dockerClient,
cleanUp: func() {
closer.Close()
},
}
}
return dockerClient, dockerHost, err
}
func podmanPresent() bool {
_, err := exec.LookPath("podman")
return err == nil
}
// creates a docker client that has its own podman service associated with it
// the service is shutdown when Close() is called on the client
func newClientWithPodmanService() (dockerClient client.CommonAPIClient, dockerHost string, err error) {
tmpDir, err := os.MkdirTemp("", "func-podman-")
if err != nil {
return
}
podmanSocket := filepath.Join(tmpDir, "podman.sock")
dockerHost = fmt.Sprintf("unix://%s", podmanSocket)
cmd := exec.Command("podman", "system", "service", dockerHost, "--time=0")
err = cmd.Start()
if err != nil {
return
}
dockerClient, err = client.NewClientWithOpts(client.FromEnv, client.WithHost(dockerHost), client.WithAPIVersionNegotiation())
stopPodmanService := func() {
_ = cmd.Process.Signal(syscall.SIGTERM)
_ = os.RemoveAll(tmpDir)
}
dockerClient = clientWithAdditionalCleanup{
pimpl: dockerClient,
cleanUp: stopPodmanService,
}
podmanServiceRunning := false
// give a time to podman to start
for i := 0; i < 40; i++ {
if _, e := dockerClient.Ping(context.Background()); e == nil {
podmanServiceRunning = true
break
}
time.Sleep(time.Millisecond * 250)
}
if !podmanServiceRunning {
stopPodmanService()
err = errors.New("failed to start podman service")
}
return
}
type clientWithAdditionalCleanup struct {
cleanUp func()
pimpl client.CommonAPIClient
}
// Close function need to stop associated podman service
func (w clientWithAdditionalCleanup) Close() error {
defer w.cleanUp()
return w.pimpl.Close()
}