mirror of
https://github.com/containers/podman.git
synced 2025-07-15 03:02:52 +08:00
Merge pull request #13075 from n1hility/mac-forward-helper
Mac API forwarding using a privileged docker socket claim helper
This commit is contained in:
12
Makefile
12
Makefile
@ -376,13 +376,23 @@ podman-winpath: .gopathok $(SOURCES) go.mod go.sum
|
|||||||
./cmd/winpath
|
./cmd/winpath
|
||||||
|
|
||||||
.PHONY: podman-remote-darwin
|
.PHONY: podman-remote-darwin
|
||||||
podman-remote-darwin: ## Build podman-remote for macOS
|
podman-remote-darwin: podman-mac-helper ## Build podman-remote for macOS
|
||||||
$(MAKE) \
|
$(MAKE) \
|
||||||
CGO_ENABLED=$(DARWIN_GCO) \
|
CGO_ENABLED=$(DARWIN_GCO) \
|
||||||
GOOS=darwin \
|
GOOS=darwin \
|
||||||
GOARCH=$(GOARCH) \
|
GOARCH=$(GOARCH) \
|
||||||
bin/darwin/podman
|
bin/darwin/podman
|
||||||
|
|
||||||
|
.PHONY: podman-mac-helper
|
||||||
|
podman-mac-helper: ## Build podman-mac-helper for macOS
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
GOOS=darwin \
|
||||||
|
GOARCH=$(GOARCH) \
|
||||||
|
$(GO) build \
|
||||||
|
$(BUILDFLAGS) \
|
||||||
|
-o bin/darwin/podman-mac-helper \
|
||||||
|
./cmd/podman-mac-helper
|
||||||
|
|
||||||
bin/rootlessport: .gopathok $(SOURCES) go.mod go.sum
|
bin/rootlessport: .gopathok $(SOURCES) go.mod go.sum
|
||||||
CGO_ENABLED=$(CGO_ENABLED) \
|
CGO_ENABLED=$(CGO_ENABLED) \
|
||||||
$(GO) build \
|
$(GO) build \
|
||||||
|
244
cmd/podman-mac-helper/install.go
Normal file
244
cmd/podman-mac-helper/install.go
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rwx_rx_rx = 0755
|
||||||
|
rw_r_r = 0644
|
||||||
|
)
|
||||||
|
|
||||||
|
const launchConfig = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.github.containers.podman.helper-{{.User}}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Program}}</string>
|
||||||
|
<string>service</string>
|
||||||
|
<string>{{.Target}}</string>
|
||||||
|
</array>
|
||||||
|
<key>inetdCompatibility</key>
|
||||||
|
<dict>
|
||||||
|
<key>Wait</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UserName</key>
|
||||||
|
<string>root</string>
|
||||||
|
<key>Sockets</key>
|
||||||
|
<dict>
|
||||||
|
<key>Listeners</key>
|
||||||
|
<dict>
|
||||||
|
<key>SockFamily</key>
|
||||||
|
<string>Unix</string>
|
||||||
|
<key>SockPathName</key>
|
||||||
|
<string>/private/var/run/podman-helper-{{.User}}.socket</string>
|
||||||
|
<key>SockPathOwner</key>
|
||||||
|
<integer>{{.UID}}</integer>
|
||||||
|
<key>SockPathMode</key>
|
||||||
|
<!-- SockPathMode takes base 10 (384 = 0600) -->
|
||||||
|
<integer>384</integer>
|
||||||
|
<key>SockType</key>
|
||||||
|
<string>stream</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`
|
||||||
|
|
||||||
|
type launchParams struct {
|
||||||
|
Program string
|
||||||
|
User string
|
||||||
|
UID string
|
||||||
|
Target string
|
||||||
|
}
|
||||||
|
|
||||||
|
var installCmd = &cobra.Command{
|
||||||
|
Use: "install",
|
||||||
|
Short: "installs the podman helper agent",
|
||||||
|
Long: "installs the podman helper agent, which manages the /var/run/docker.sock link",
|
||||||
|
PreRun: silentUsage,
|
||||||
|
RunE: install,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
addPrefixFlag(installCmd)
|
||||||
|
rootCmd.AddCommand(installCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(cmd *cobra.Command, args []string) error {
|
||||||
|
userName, uid, homeDir, err := getUser()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
labelName := fmt.Sprintf("com.github.containers.podman.helper-%s.plist", userName)
|
||||||
|
fileName := filepath.Join("/Library", "LaunchDaemons", labelName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(fileName); err == nil || !os.IsNotExist(err) {
|
||||||
|
return errors.New("helper is already installed, uninstall first")
|
||||||
|
}
|
||||||
|
|
||||||
|
prog, err := installExecutable(userName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(homeDir, ".local", "share", "containers", "podman", "machine", "podman.sock")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
t := template.Must(template.New("launchdConfig").Parse(launchConfig))
|
||||||
|
err = t.Execute(&buf, launchParams{prog, userName, uid, target})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, rw_r_r)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "error creating helper plist file")
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
_, err = buf.WriteTo(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = runDetectErr("launchctl", "load", fileName); err != nil {
|
||||||
|
return errors.Wrap(err, "launchctl failed loading service")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restrictRecursive(targetDir string, until string) error {
|
||||||
|
for targetDir != until && len(targetDir) > 1 {
|
||||||
|
info, err := os.Lstat(targetDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
return errors.Errorf("symlinks not allowed in helper paths (remove them and rerun): %s", targetDir)
|
||||||
|
}
|
||||||
|
if err = os.Chown(targetDir, 0, 0); err != nil {
|
||||||
|
return errors.Wrap(err, "could not update ownership of helper path")
|
||||||
|
}
|
||||||
|
if err = os.Chmod(targetDir, rwx_rx_rx|fs.ModeSticky); err != nil {
|
||||||
|
return errors.Wrap(err, "could not update permissions of helper path")
|
||||||
|
}
|
||||||
|
targetDir = filepath.Dir(targetDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyRootDeep(path string) error {
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
current := "/"
|
||||||
|
segs := strings.Split(path, "/")
|
||||||
|
depth := 0
|
||||||
|
for i := 1; i < len(segs); i++ {
|
||||||
|
seg := segs[i]
|
||||||
|
current = filepath.Join(current, seg)
|
||||||
|
info, err := os.Lstat(current)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stat := info.Sys().(*syscall.Stat_t)
|
||||||
|
if stat.Uid != 0 {
|
||||||
|
return errors.Errorf("installation target path must be solely owned by root: %s is not", current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
target, err := os.Readlink(current)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetParts := strings.Split(target, "/")
|
||||||
|
segs = append(targetParts, segs[i+1:]...)
|
||||||
|
|
||||||
|
if depth++; depth > 1000 {
|
||||||
|
return errors.New("reached max recursion depth, link structure is cyclical or too complex")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(target) {
|
||||||
|
current = filepath.Dir(current)
|
||||||
|
i = -1 // Start at 0
|
||||||
|
} else {
|
||||||
|
current = "/"
|
||||||
|
i = 0 // Skip empty first segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installExecutable(user string) (string, error) {
|
||||||
|
// Since the installed executable runs as root, as a precaution verify root ownership of
|
||||||
|
// the entire installation path, and utilize sticky + read only perms for the helper path
|
||||||
|
// suffix. The goal is to help users harden against privilege escalation from loose
|
||||||
|
// filesystem permissions.
|
||||||
|
//
|
||||||
|
// Since userpsace package management tools, such as brew, delegate management of system
|
||||||
|
// paths to standard unix users, the daemon executable is copied into a separate more
|
||||||
|
// restricted area of the filesystem.
|
||||||
|
if err := verifyRootDeep(installPrefix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDir := filepath.Join(installPrefix, "podman", "helper", user)
|
||||||
|
if err := os.MkdirAll(targetDir, rwx_rx_rx); err != nil {
|
||||||
|
return "", errors.Wrap(err, "could not create helper directory structure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct any incorrect perms on previously existing directories and verify no symlinks
|
||||||
|
if err := restrictRecursive(targetDir, installPrefix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
exec, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
install := filepath.Join(targetDir, filepath.Base(exec))
|
||||||
|
|
||||||
|
return install, copyFile(install, exec, rwx_rx_rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(dest string, source string, perms fs.FileMode) error {
|
||||||
|
in, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dest, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, perms)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
149
cmd/podman-mac-helper/main.go
Normal file
149
cmd/podman-mac-helper/main.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPrefix = "/usr/local"
|
||||||
|
dockerSock = "/var/run/docker.sock"
|
||||||
|
)
|
||||||
|
|
||||||
|
var installPrefix string
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "podman-mac-helper",
|
||||||
|
Short: "A system helper to manage docker.sock",
|
||||||
|
Long: `podman-mac-helper is a system helper service and tool for managing docker.sock `,
|
||||||
|
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
|
||||||
|
SilenceErrors: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note, this code is security sensitive since it runs under privilege.
|
||||||
|
// Limit actions to what is strictly necessary, and take appropriate
|
||||||
|
// safeguards
|
||||||
|
//
|
||||||
|
// After installation the service call is ran under launchd in a nowait
|
||||||
|
// inetd style fashion, so stdin, stdout, and stderr are all pointing to
|
||||||
|
// an accepted connection
|
||||||
|
//
|
||||||
|
// This service is installed once per user and will redirect
|
||||||
|
// /var/run/docker to the fixed user-assigned unix socket location.
|
||||||
|
//
|
||||||
|
// Control communication is restricted to each user specific service via
|
||||||
|
// unix file permissions
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
fmt.Printf("This command must be ran as root via sudo or osascript\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserInfo(name string) (string, string, string, error) {
|
||||||
|
// We exec id instead of using user.Lookup to remain compat
|
||||||
|
// with CGO disabled.
|
||||||
|
cmd := exec.Command("/usr/bin/id", "-P", name)
|
||||||
|
output, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := readCapped(output)
|
||||||
|
elements := strings.Split(entry, ":")
|
||||||
|
if len(elements) < 9 || elements[0] != name {
|
||||||
|
return "", "", "", errors.New("Could not lookup user")
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements[0], elements[2], elements[8], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUser() (string, string, string, error) {
|
||||||
|
name, found := os.LookupEnv("SUDO_USER")
|
||||||
|
if !found {
|
||||||
|
name, found = os.LookupEnv("USER")
|
||||||
|
if !found {
|
||||||
|
return "", "", "", errors.New("could not determine user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, uid, home, err := getUserInfo(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("could not lookup user: %s", name)
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(uid)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("invalid uid for user: %s", name)
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return "", "", "", fmt.Errorf("unexpected root user")
|
||||||
|
}
|
||||||
|
|
||||||
|
return name, uid, home, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for commands that don't return a proper exit code
|
||||||
|
func runDetectErr(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
errReader, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err == nil {
|
||||||
|
errString := readCapped(errReader)
|
||||||
|
if len(errString) > 0 {
|
||||||
|
re := regexp.MustCompile(`\r?\n`)
|
||||||
|
err = errors.New(re.ReplaceAllString(errString, ": "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if werr := cmd.Wait(); werr != nil {
|
||||||
|
err = werr
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCapped(reader io.Reader) string {
|
||||||
|
// Cap output
|
||||||
|
buffer := make([]byte, 2048)
|
||||||
|
n, _ := io.ReadFull(reader, buffer)
|
||||||
|
_, _ = io.Copy(ioutil.Discard, reader)
|
||||||
|
if n > 0 {
|
||||||
|
return string(buffer[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPrefixFlag(cmd *cobra.Command) {
|
||||||
|
cmd.Flags().StringVar(&installPrefix, "prefix", defaultPrefix, "Sets the install location prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
func silentUsage(cmd *cobra.Command, args []string) {
|
||||||
|
cmd.SilenceUsage = true
|
||||||
|
cmd.SilenceErrors = true
|
||||||
|
}
|
85
cmd/podman-mac-helper/service.go
Normal file
85
cmd/podman-mac-helper/service.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trigger = "GO\n"
|
||||||
|
fail = "NO"
|
||||||
|
success = "OK"
|
||||||
|
)
|
||||||
|
|
||||||
|
var serviceCmd = &cobra.Command{
|
||||||
|
Use: "service",
|
||||||
|
Short: "services requests",
|
||||||
|
Long: "services requests",
|
||||||
|
PreRun: silentUsage,
|
||||||
|
Run: serviceRun,
|
||||||
|
Hidden: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(serviceCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceRun(cmd *cobra.Command, args []string) {
|
||||||
|
info, err := os.Stdin.Stat()
|
||||||
|
if err != nil || info.Mode()&fs.ModeSocket == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "This is an internal command that is not intended for standard terminal usage")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(service())
|
||||||
|
}
|
||||||
|
|
||||||
|
func service() int {
|
||||||
|
defer os.Stdout.Close()
|
||||||
|
defer os.Stdin.Close()
|
||||||
|
defer os.Stderr.Close()
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Print(fail)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
target := os.Args[2]
|
||||||
|
|
||||||
|
request := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 3)
|
||||||
|
_, err := io.ReadFull(os.Stdin, buf)
|
||||||
|
request <- err == nil && string(buf) == trigger
|
||||||
|
}()
|
||||||
|
|
||||||
|
valid := false
|
||||||
|
select {
|
||||||
|
case valid = <-request:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
fmt.Println(fail)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.Remove(dockerSock)
|
||||||
|
if err == nil || os.IsNotExist(err) {
|
||||||
|
err = os.Symlink(target, dockerSock)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Print(fail)
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(success)
|
||||||
|
return 0
|
||||||
|
}
|
60
cmd/podman-mac-helper/uninstall.go
Normal file
60
cmd/podman-mac-helper/uninstall.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var uninstallCmd = &cobra.Command{
|
||||||
|
Use: "uninstall",
|
||||||
|
Short: "uninstalls the podman helper agent",
|
||||||
|
Long: "uninstalls the podman helper agent, which manages the /var/run/docker.sock link",
|
||||||
|
PreRun: silentUsage,
|
||||||
|
RunE: uninstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
addPrefixFlag(uninstallCmd)
|
||||||
|
rootCmd.AddCommand(uninstallCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstall(cmd *cobra.Command, args []string) error {
|
||||||
|
userName, _, _, err := getUser()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
labelName := fmt.Sprintf("com.github.containers.podman.helper-%s", userName)
|
||||||
|
fileName := filepath.Join("/Library", "LaunchDaemons", labelName+".plist")
|
||||||
|
|
||||||
|
if err = runDetectErr("launchctl", "unload", fileName); err != nil {
|
||||||
|
// Try removing the service by label in case the service is half uninstalled
|
||||||
|
if rerr := runDetectErr("launchctl", "remove", labelName); rerr != nil {
|
||||||
|
// Exit code 3 = no service to remove
|
||||||
|
if exitErr, ok := rerr.(*exec.ExitError); !ok || exitErr.ExitCode() != 3 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: service unloading failed: %s\n", err.Error())
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: remove also failed: %s\n", rerr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(fileName); err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return errors.Errorf("could not remove plist file: %s", fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
helperPath := filepath.Join(installPrefix, "podman", "helper", userName)
|
||||||
|
if err := os.RemoveAll(helperPath); err != nil {
|
||||||
|
return errors.Errorf("could not remove helper binary path: %s", helperPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
@ -26,7 +26,7 @@ var (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
initOpts = machine.InitOptions{}
|
initOpts = machine.InitOptions{}
|
||||||
defaultMachineName = "podman-machine-default"
|
defaultMachineName = machine.DefaultMachineName
|
||||||
now bool
|
now bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -99,6 +99,9 @@ func init() {
|
|||||||
IgnitionPathFlagName := "ignition-path"
|
IgnitionPathFlagName := "ignition-path"
|
||||||
flags.StringVar(&initOpts.IgnitionPath, IgnitionPathFlagName, "", "Path to ignition file")
|
flags.StringVar(&initOpts.IgnitionPath, IgnitionPathFlagName, "", "Path to ignition file")
|
||||||
_ = initCmd.RegisterFlagCompletionFunc(IgnitionPathFlagName, completion.AutocompleteDefault)
|
_ = initCmd.RegisterFlagCompletionFunc(IgnitionPathFlagName, completion.AutocompleteDefault)
|
||||||
|
|
||||||
|
rootfulFlagName := "rootful"
|
||||||
|
flags.BoolVar(&initOpts.Rootful, rootfulFlagName, false, "Whether this machine should prefer rootful container exectution")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO should we allow for a users to append to the qemu cmdline?
|
// TODO should we allow for a users to append to the qemu cmdline?
|
||||||
|
56
cmd/podman/machine/set.go
Normal file
56
cmd/podman/machine/set.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// +build amd64 arm64
|
||||||
|
|
||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/containers/common/pkg/completion"
|
||||||
|
"github.com/containers/podman/v4/cmd/podman/registry"
|
||||||
|
"github.com/containers/podman/v4/pkg/machine"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
setCmd = &cobra.Command{
|
||||||
|
Use: "set [options] [NAME]",
|
||||||
|
Short: "Sets a virtual machine setting",
|
||||||
|
Long: "Sets an updatable virtual machine setting",
|
||||||
|
RunE: setMachine,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
Example: `podman machine set --root=false`,
|
||||||
|
ValidArgsFunction: completion.AutocompleteNone,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
setOpts = machine.SetOptions{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registry.Commands = append(registry.Commands, registry.CliCommand{
|
||||||
|
Command: setCmd,
|
||||||
|
Parent: machineCmd,
|
||||||
|
})
|
||||||
|
flags := setCmd.Flags()
|
||||||
|
|
||||||
|
rootfulFlagName := "rootful"
|
||||||
|
flags.BoolVar(&setOpts.Rootful, rootfulFlagName, false, "Whether this machine should prefer rootful container execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMachine(cmd *cobra.Command, args []string) error {
|
||||||
|
var (
|
||||||
|
vm machine.VM
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
vmName := defaultMachineName
|
||||||
|
if len(args) > 0 && len(args[0]) > 0 {
|
||||||
|
vmName = args[0]
|
||||||
|
}
|
||||||
|
provider := getSystemDefaultProvider()
|
||||||
|
vm, err = provider.LoadVMByName(vmName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.Set(vmName, setOpts)
|
||||||
|
}
|
@ -55,6 +55,14 @@ Memory (in MB).
|
|||||||
|
|
||||||
Start the virtual machine immediately after it has been initialized.
|
Start the virtual machine immediately after it has been initialized.
|
||||||
|
|
||||||
|
#### **--rootful**=*true|false*
|
||||||
|
|
||||||
|
Whether this machine should prefer rootful (`true`) or rootless (`false`)
|
||||||
|
container execution. This option will also determine the remote connection default
|
||||||
|
if there is no existing remote connection configurations.
|
||||||
|
|
||||||
|
API forwarding, if available, will follow this setting.
|
||||||
|
|
||||||
#### **--timezone**
|
#### **--timezone**
|
||||||
|
|
||||||
Set the timezone for the machine and containers. Valid values are `local` or
|
Set the timezone for the machine and containers. Valid values are `local` or
|
||||||
@ -84,6 +92,7 @@ Print usage statement.
|
|||||||
```
|
```
|
||||||
$ podman machine init
|
$ podman machine init
|
||||||
$ podman machine init myvm
|
$ podman machine init myvm
|
||||||
|
$ podman machine init --rootful
|
||||||
$ podman machine init --disk-size 50
|
$ podman machine init --disk-size 50
|
||||||
$ podman machine init --memory=1024 myvm
|
$ podman machine init --memory=1024 myvm
|
||||||
$ podman machine init -v /Users:/mnt/Users
|
$ podman machine init -v /Users:/mnt/Users
|
||||||
|
59
docs/source/markdown/podman-machine-set.1.md
Normal file
59
docs/source/markdown/podman-machine-set.1.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
% podman-machine-set(1)
|
||||||
|
|
||||||
|
## NAME
|
||||||
|
podman\-machine\-set - Sets a virtual machine setting
|
||||||
|
|
||||||
|
## SYNOPSIS
|
||||||
|
**podman machine set** [*options*] [*name*]
|
||||||
|
|
||||||
|
## DESCRIPTION
|
||||||
|
|
||||||
|
Sets an updatable virtual machine setting.
|
||||||
|
|
||||||
|
Options mirror values passed to `podman machine init`. Only a limited
|
||||||
|
subset can be changed after machine initialization.
|
||||||
|
|
||||||
|
## OPTIONS
|
||||||
|
|
||||||
|
#### **--rootful**=*true|false*
|
||||||
|
|
||||||
|
Whether this machine should prefer rootful (`true`) or rootless (`false`)
|
||||||
|
container execution. This option will also update the current podman
|
||||||
|
remote connection default if it is currently pointing at the specified
|
||||||
|
machine name (or `podman-machine-default` if no name is specified).
|
||||||
|
|
||||||
|
API forwarding, if available, will follow this setting.
|
||||||
|
|
||||||
|
#### **--help**
|
||||||
|
|
||||||
|
Print usage statement.
|
||||||
|
|
||||||
|
## EXAMPLES
|
||||||
|
|
||||||
|
To switch the default VM `podman-machine-default` from rootless to rootful:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ podman machine set --rootful
|
||||||
|
```
|
||||||
|
|
||||||
|
or more explicitly:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ podman machine set --rootful=true
|
||||||
|
```
|
||||||
|
|
||||||
|
To switch the default VM `podman-machine-default` from rootful to rootless:
|
||||||
|
```
|
||||||
|
$ podman machine set --rootful=false
|
||||||
|
```
|
||||||
|
|
||||||
|
To switch the VM `myvm` from rootless to rootful:
|
||||||
|
```
|
||||||
|
$ podman machine set --rootful myvm
|
||||||
|
```
|
||||||
|
|
||||||
|
## SEE ALSO
|
||||||
|
**[podman(1)](podman.1.md)**, **[podman-machine(1)](podman-machine.1.md)**
|
||||||
|
|
||||||
|
## HISTORY
|
||||||
|
February 2022, Originally compiled by Jason Greene <jason.greene@redhat.com>
|
@ -16,6 +16,7 @@ podman\-machine - Manage Podman's virtual machine
|
|||||||
| init | [podman-machine-init(1)](podman-machine-init.1.md) | Initialize a new virtual machine |
|
| init | [podman-machine-init(1)](podman-machine-init.1.md) | Initialize a new virtual machine |
|
||||||
| list | [podman-machine-list(1)](podman-machine-list.1.md) | List virtual machines |
|
| list | [podman-machine-list(1)](podman-machine-list.1.md) | List virtual machines |
|
||||||
| rm | [podman-machine-rm(1)](podman-machine-rm.1.md) | Remove a virtual machine |
|
| rm | [podman-machine-rm(1)](podman-machine-rm.1.md) | Remove a virtual machine |
|
||||||
|
| set | [podman-machine-set(1)](podman-machine-set.1.md) | Sets a virtual machine setting |
|
||||||
| ssh | [podman-machine-ssh(1)](podman-machine-ssh.1.md) | SSH into a virtual machine |
|
| ssh | [podman-machine-ssh(1)](podman-machine-ssh.1.md) | SSH into a virtual machine |
|
||||||
| start | [podman-machine-start(1)](podman-machine-start.1.md) | Start a virtual machine |
|
| start | [podman-machine-start(1)](podman-machine-start.1.md) | Start a virtual machine |
|
||||||
| stop | [podman-machine-stop(1)](podman-machine-stop.1.md) | Stop a virtual machine |
|
| stop | [podman-machine-stop(1)](podman-machine-stop.1.md) | Stop a virtual machine |
|
||||||
|
@ -27,6 +27,7 @@ type InitOptions struct {
|
|||||||
URI url.URL
|
URI url.URL
|
||||||
Username string
|
Username string
|
||||||
ReExec bool
|
ReExec bool
|
||||||
|
Rootful bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type QemuMachineStatus = string
|
type QemuMachineStatus = string
|
||||||
@ -35,7 +36,8 @@ const (
|
|||||||
// Running indicates the qemu vm is running
|
// Running indicates the qemu vm is running
|
||||||
Running QemuMachineStatus = "running"
|
Running QemuMachineStatus = "running"
|
||||||
// Stopped indicates the vm has stopped
|
// Stopped indicates the vm has stopped
|
||||||
Stopped QemuMachineStatus = "stopped"
|
Stopped QemuMachineStatus = "stopped"
|
||||||
|
DefaultMachineName string = "podman-machine-default"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
@ -89,6 +91,10 @@ type ListResponse struct {
|
|||||||
IdentityPath string
|
IdentityPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetOptions struct {
|
||||||
|
Rootful bool
|
||||||
|
}
|
||||||
|
|
||||||
type SSHOptions struct {
|
type SSHOptions struct {
|
||||||
Username string
|
Username string
|
||||||
Args []string
|
Args []string
|
||||||
@ -107,6 +113,7 @@ type RemoveOptions struct {
|
|||||||
type VM interface {
|
type VM interface {
|
||||||
Init(opts InitOptions) (bool, error)
|
Init(opts InitOptions) (bool, error)
|
||||||
Remove(name string, opts RemoveOptions) (string, func() error, error)
|
Remove(name string, opts RemoveOptions) (string, func() error, error)
|
||||||
|
Set(name string, opts SetOptions) error
|
||||||
SSH(name string, opts SSHOptions) error
|
SSH(name string, opts SSHOptions) error
|
||||||
Start(name string, opts StartOptions) error
|
Start(name string, opts StartOptions) error
|
||||||
Stop(name string, opts StopOptions) error
|
Stop(name string, opts StopOptions) error
|
||||||
|
@ -39,6 +39,31 @@ func AddConnection(uri fmt.Stringer, name, identity string, isDefault bool) erro
|
|||||||
return cfg.Write()
|
return cfg.Write()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AnyConnectionDefault(name ...string) (bool, error) {
|
||||||
|
cfg, err := config.ReadCustomConfig()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, n := range name {
|
||||||
|
if n == cfg.Engine.ActiveService {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChangeDefault(name string) error {
|
||||||
|
cfg, err := config.ReadCustomConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Engine.ActiveService = name
|
||||||
|
|
||||||
|
return cfg.Write()
|
||||||
|
}
|
||||||
|
|
||||||
func RemoveConnection(name string) error {
|
func RemoveConnection(name string) error {
|
||||||
cfg, err := config.ReadCustomConfig()
|
cfg, err := config.ReadCustomConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
63
pkg/machine/qemu/claim_darwin.go
Normal file
63
pkg/machine/qemu/claim_darwin.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package qemu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dockerClaimSupported() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerClaimHelperInstalled() bool {
|
||||||
|
u, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
labelName := fmt.Sprintf("com.github.containers.podman.helper-%s", u.Username)
|
||||||
|
fileName := filepath.Join("/Library", "LaunchDaemons", labelName+".plist")
|
||||||
|
info, err := os.Stat(fileName)
|
||||||
|
return err == nil && info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimDockerSock() bool {
|
||||||
|
u, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
helperSock := fmt.Sprintf("/var/run/podman-helper-%s.socket", u.Username)
|
||||||
|
con, err := net.DialTimeout("unix", helperSock, time.Second*5)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = con.SetWriteDeadline(time.Now().Add(time.Second * 5))
|
||||||
|
_, err = fmt.Fprintln(con, "GO")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = con.SetReadDeadline(time.Now().Add(time.Second * 5))
|
||||||
|
read, err := ioutil.ReadAll(con)
|
||||||
|
|
||||||
|
return err == nil && string(read) == "OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
func findClaimHelper() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
exe, err = filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(filepath.Dir(exe), "podman-mac-helper")
|
||||||
|
}
|
20
pkg/machine/qemu/claim_unsupported.go
Normal file
20
pkg/machine/qemu/claim_unsupported.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//go:build !darwin && !windows
|
||||||
|
// +build !darwin,!windows
|
||||||
|
|
||||||
|
package qemu
|
||||||
|
|
||||||
|
func dockerClaimHelperInstalled() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimDockerSock() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerClaimSupported() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func findClaimHelper() string {
|
||||||
|
return ""
|
||||||
|
}
|
@ -33,6 +33,8 @@ type MachineVM struct {
|
|||||||
QMPMonitor Monitor
|
QMPMonitor Monitor
|
||||||
// RemoteUsername of the vm user
|
// RemoteUsername of the vm user
|
||||||
RemoteUsername string
|
RemoteUsername string
|
||||||
|
// Whether this machine should run in a rootful or rootless manner
|
||||||
|
Rootful bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mount struct {
|
type Mount struct {
|
||||||
|
@ -5,11 +5,15 @@ package qemu
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -39,8 +43,21 @@ func GetQemuProvider() machine.Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VolumeTypeVirtfs = "virtfs"
|
VolumeTypeVirtfs = "virtfs"
|
||||||
MountType9p = "9p"
|
MountType9p = "9p"
|
||||||
|
dockerSock = "/var/run/docker.sock"
|
||||||
|
dockerConnectTimeout = 5 * time.Second
|
||||||
|
apiUpTimeout = 20 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiForwardingState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
noForwarding apiForwardingState = iota
|
||||||
|
claimUnsupported
|
||||||
|
notInstalled
|
||||||
|
machineLocal
|
||||||
|
dockerGlobal
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewMachine initializes an instance of a virtual machine based on the qemu
|
// NewMachine initializes an instance of a virtual machine based on the qemu
|
||||||
@ -150,14 +167,8 @@ func (v *MachineVM) Init(opts machine.InitOptions) (bool, error) {
|
|||||||
key string
|
key string
|
||||||
)
|
)
|
||||||
sshDir := filepath.Join(homedir.Get(), ".ssh")
|
sshDir := filepath.Join(homedir.Get(), ".ssh")
|
||||||
// GetConfDir creates the directory so no need to check for
|
|
||||||
// its existence
|
|
||||||
vmConfigDir, err := machine.GetConfDir(vmtype)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
jsonFile := filepath.Join(vmConfigDir, v.Name) + ".json"
|
|
||||||
v.IdentityPath = filepath.Join(sshDir, v.Name)
|
v.IdentityPath = filepath.Join(sshDir, v.Name)
|
||||||
|
v.Rootful = opts.Rootful
|
||||||
|
|
||||||
switch opts.ImagePath {
|
switch opts.ImagePath {
|
||||||
case "testing", "next", "stable", "":
|
case "testing", "next", "stable", "":
|
||||||
@ -240,29 +251,33 @@ func (v *MachineVM) Init(opts machine.InitOptions) (bool, error) {
|
|||||||
// This kind of stinks but no other way around this r/n
|
// This kind of stinks but no other way around this r/n
|
||||||
if len(opts.IgnitionPath) < 1 {
|
if len(opts.IgnitionPath) < 1 {
|
||||||
uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/user/1000/podman/podman.sock", strconv.Itoa(v.Port), v.RemoteUsername)
|
uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/user/1000/podman/podman.sock", strconv.Itoa(v.Port), v.RemoteUsername)
|
||||||
if err := machine.AddConnection(&uri, v.Name, filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil {
|
uriRoot := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/podman/podman.sock", strconv.Itoa(v.Port), "root")
|
||||||
return false, err
|
identity := filepath.Join(sshDir, v.Name)
|
||||||
|
|
||||||
|
uris := []url.URL{uri, uriRoot}
|
||||||
|
names := []string{v.Name, v.Name + "-root"}
|
||||||
|
|
||||||
|
// The first connection defined when connections is empty will become the default
|
||||||
|
// regardless of IsDefault, so order according to rootful
|
||||||
|
if opts.Rootful {
|
||||||
|
uris[0], names[0], uris[1], names[1] = uris[1], names[1], uris[0], names[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
uriRoot := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/podman/podman.sock", strconv.Itoa(v.Port), "root")
|
for i := 0; i < 2; i++ {
|
||||||
if err := machine.AddConnection(&uriRoot, v.Name+"-root", filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil {
|
if err := machine.AddConnection(&uris[i], names[i], identity, opts.IsDefault && i == 0); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("An ignition path was provided. No SSH connection was added to Podman")
|
fmt.Println("An ignition path was provided. No SSH connection was added to Podman")
|
||||||
}
|
}
|
||||||
// Write the JSON file
|
// Write the JSON file
|
||||||
b, err := json.MarshalIndent(v, "", " ")
|
v.writeConfig()
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := ioutil.WriteFile(jsonFile, b, 0644); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// User has provided ignition file so keygen
|
// User has provided ignition file so keygen
|
||||||
// will be skipped.
|
// will be skipped.
|
||||||
if len(opts.IgnitionPath) < 1 {
|
if len(opts.IgnitionPath) < 1 {
|
||||||
|
var err error
|
||||||
key, err = machine.CreateSSHKeys(v.IdentityPath)
|
key, err = machine.CreateSSHKeys(v.IdentityPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@ -309,6 +324,30 @@ func (v *MachineVM) Init(opts machine.InitOptions) (bool, error) {
|
|||||||
return err == nil, err
|
return err == nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *MachineVM) Set(name string, opts machine.SetOptions) error {
|
||||||
|
if v.Rootful == opts.Rootful {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
changeCon, err := machine.AnyConnectionDefault(v.Name, v.Name+"-root")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if changeCon {
|
||||||
|
newDefault := v.Name
|
||||||
|
if opts.Rootful {
|
||||||
|
newDefault += "-root"
|
||||||
|
}
|
||||||
|
if err := machine.ChangeDefault(newDefault); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Rootful = opts.Rootful
|
||||||
|
return v.writeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// Start executes the qemu command line and forks it
|
// Start executes the qemu command line and forks it
|
||||||
func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
||||||
var (
|
var (
|
||||||
@ -318,7 +357,8 @@ func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
|||||||
wait time.Duration = time.Millisecond * 500
|
wait time.Duration = time.Millisecond * 500
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := v.startHostNetworking(); err != nil {
|
forwardSock, forwardState, err := v.startHostNetworking()
|
||||||
|
if err != nil {
|
||||||
return errors.Errorf("unable to start host networking: %q", err)
|
return errors.Errorf("unable to start host networking: %q", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,6 +479,9 @@ func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
|||||||
return fmt.Errorf("unknown mount type: %s", mount.Type)
|
return fmt.Errorf("unknown mount type: %s", mount.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitAPIAndPrintInfo(forwardState, forwardSock, v.Rootful, v.Name)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -869,19 +912,19 @@ func (p *Provider) CheckExclusiveActiveVM() (bool, string, error) {
|
|||||||
|
|
||||||
// startHostNetworking runs a binary on the host system that allows users
|
// startHostNetworking runs a binary on the host system that allows users
|
||||||
// to setup port forwarding to the podman virtual machine
|
// to setup port forwarding to the podman virtual machine
|
||||||
func (v *MachineVM) startHostNetworking() error {
|
func (v *MachineVM) startHostNetworking() (string, apiForwardingState, error) {
|
||||||
cfg, err := config.Default()
|
cfg, err := config.Default()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", noForwarding, err
|
||||||
}
|
}
|
||||||
binary, err := cfg.FindHelperBinary(machine.ForwarderBinaryName, false)
|
binary, err := cfg.FindHelperBinary(machine.ForwarderBinaryName, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", noForwarding, err
|
||||||
}
|
}
|
||||||
|
|
||||||
qemuSocket, pidFile, err := v.getSocketandPid()
|
qemuSocket, pidFile, err := v.getSocketandPid()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", noForwarding, err
|
||||||
}
|
}
|
||||||
attr := new(os.ProcAttr)
|
attr := new(os.ProcAttr)
|
||||||
// Pass on stdin, stdout, stderr
|
// Pass on stdin, stdout, stderr
|
||||||
@ -891,12 +934,82 @@ func (v *MachineVM) startHostNetworking() error {
|
|||||||
cmd = append(cmd, []string{"-listen-qemu", fmt.Sprintf("unix://%s", qemuSocket), "-pid-file", pidFile}...)
|
cmd = append(cmd, []string{"-listen-qemu", fmt.Sprintf("unix://%s", qemuSocket), "-pid-file", pidFile}...)
|
||||||
// Add the ssh port
|
// Add the ssh port
|
||||||
cmd = append(cmd, []string{"-ssh-port", fmt.Sprintf("%d", v.Port)}...)
|
cmd = append(cmd, []string{"-ssh-port", fmt.Sprintf("%d", v.Port)}...)
|
||||||
|
|
||||||
|
cmd, forwardSock, state := v.setupAPIForwarding(cmd)
|
||||||
|
|
||||||
if logrus.GetLevel() == logrus.DebugLevel {
|
if logrus.GetLevel() == logrus.DebugLevel {
|
||||||
cmd = append(cmd, "--debug")
|
cmd = append(cmd, "--debug")
|
||||||
fmt.Println(cmd)
|
fmt.Println(cmd)
|
||||||
}
|
}
|
||||||
_, err = os.StartProcess(cmd[0], cmd, attr)
|
_, err = os.StartProcess(cmd[0], cmd, attr)
|
||||||
return err
|
return forwardSock, state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *MachineVM) setupAPIForwarding(cmd []string) ([]string, string, apiForwardingState) {
|
||||||
|
socket, err := v.getForwardSocketPath()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return cmd, "", noForwarding
|
||||||
|
}
|
||||||
|
|
||||||
|
destSock := "/run/user/1000/podman/podman.sock"
|
||||||
|
forwardUser := "core"
|
||||||
|
|
||||||
|
if v.Rootful {
|
||||||
|
destSock = "/run/podman/podman.sock"
|
||||||
|
forwardUser = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = append(cmd, []string{"-forward-sock", socket}...)
|
||||||
|
cmd = append(cmd, []string{"-forward-dest", destSock}...)
|
||||||
|
cmd = append(cmd, []string{"-forward-user", forwardUser}...)
|
||||||
|
cmd = append(cmd, []string{"-forward-identity", v.IdentityPath}...)
|
||||||
|
link := filepath.Join(filepath.Dir(filepath.Dir(socket)), "podman.sock")
|
||||||
|
|
||||||
|
// The linking pattern is /var/run/docker.sock -> user global sock (link) -> machine sock (socket)
|
||||||
|
// This allows the helper to only have to maintain one constant target to the user, which can be
|
||||||
|
// repositioned without updating docker.sock.
|
||||||
|
if !dockerClaimSupported() {
|
||||||
|
return cmd, socket, claimUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dockerClaimHelperInstalled() {
|
||||||
|
return cmd, socket, notInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
if !alreadyLinked(socket, link) {
|
||||||
|
if checkSockInUse(link) {
|
||||||
|
return cmd, socket, machineLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(link)
|
||||||
|
if err = os.Symlink(socket, link); err != nil {
|
||||||
|
logrus.Warnf("could not create user global API forwarding link: %s", err.Error())
|
||||||
|
return cmd, socket, machineLocal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !alreadyLinked(link, dockerSock) {
|
||||||
|
if checkSockInUse(dockerSock) {
|
||||||
|
return cmd, socket, machineLocal
|
||||||
|
}
|
||||||
|
|
||||||
|
if !claimDockerSock() {
|
||||||
|
logrus.Warn("podman helper is installed, but was not able to claim the global docker sock")
|
||||||
|
return cmd, socket, machineLocal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd, dockerSock, dockerGlobal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *MachineVM) getForwardSocketPath() (string, error) {
|
||||||
|
path, err := machine.GetDataDir(v.Name)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("Error resolving data dir: %s", err.Error())
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return filepath.Join(path, "podman.sock"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *MachineVM) getSocketandPid() (string, string, error) {
|
func (v *MachineVM) getSocketandPid() (string, string, error) {
|
||||||
@ -912,3 +1025,103 @@ func (v *MachineVM) getSocketandPid() (string, string, error) {
|
|||||||
qemuSocket := filepath.Join(socketDir, fmt.Sprintf("qemu_%s.sock", v.Name))
|
qemuSocket := filepath.Join(socketDir, fmt.Sprintf("qemu_%s.sock", v.Name))
|
||||||
return qemuSocket, pidFile, nil
|
return qemuSocket, pidFile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkSockInUse(sock string) bool {
|
||||||
|
if info, err := os.Stat(sock); err == nil && info.Mode()&fs.ModeSocket == fs.ModeSocket {
|
||||||
|
_, err = net.DialTimeout("unix", dockerSock, dockerConnectTimeout)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func alreadyLinked(target string, link string) bool {
|
||||||
|
read, err := os.Readlink(link)
|
||||||
|
return err == nil && read == target
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitAndPingAPI(sock string) {
|
||||||
|
client := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||||
|
con, err := net.DialTimeout("unix", sock, apiUpTimeout)
|
||||||
|
if err == nil {
|
||||||
|
con.SetDeadline(time.Now().Add(apiUpTimeout))
|
||||||
|
}
|
||||||
|
return con, err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("http://host/_ping")
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
logrus.Warn("API socket failed ping test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitAPIAndPrintInfo(forwardState apiForwardingState, forwardSock string, rootFul bool, name string) {
|
||||||
|
if forwardState != noForwarding {
|
||||||
|
waitAndPingAPI(forwardSock)
|
||||||
|
if !rootFul {
|
||||||
|
fmt.Printf("\nThis machine is currently configured in rootless mode. If your containers\n")
|
||||||
|
fmt.Printf("require root permissions (e.g. ports < 1024), or if you run into compatibility\n")
|
||||||
|
fmt.Printf("issues with non-podman clients, you can switch using the following command: \n")
|
||||||
|
|
||||||
|
suffix := ""
|
||||||
|
if name != machine.DefaultMachineName {
|
||||||
|
suffix = " " + name
|
||||||
|
}
|
||||||
|
fmt.Printf("\n\tpodman machine set --rootful%s\n\n", suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("API forwarding listening on: %s\n", forwardSock)
|
||||||
|
if forwardState == dockerGlobal {
|
||||||
|
fmt.Printf("Docker API clients default to this address. You do not need to set DOCKER_HOST.\n\n")
|
||||||
|
} else {
|
||||||
|
stillString := "still "
|
||||||
|
switch forwardState {
|
||||||
|
case notInstalled:
|
||||||
|
fmt.Printf("\nThe system helper service is not installed; the default Docker API socket\n")
|
||||||
|
fmt.Printf("address can't be used by podman. ")
|
||||||
|
if helper := findClaimHelper(); len(helper) > 0 {
|
||||||
|
fmt.Printf("If you would like to install it run the\nfollowing command:\n")
|
||||||
|
fmt.Printf("\n\tsudo %s install\n\n", helper)
|
||||||
|
}
|
||||||
|
case machineLocal:
|
||||||
|
fmt.Printf("\nAnother process was listening on the default Docker API socket address.\n")
|
||||||
|
case claimUnsupported:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
stillString = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("You can %sconnect Docker API clients by setting DOCKER_HOST using the\n", stillString)
|
||||||
|
fmt.Printf("following command in your terminal session:\n")
|
||||||
|
fmt.Printf("\n\texport DOCKER_HOST='unix://%s'\n\n", forwardSock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *MachineVM) writeConfig() error {
|
||||||
|
// GetConfDir creates the directory so no need to check for
|
||||||
|
// its existence
|
||||||
|
vmConfigDir, err := machine.GetConfDir(vmtype)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonFile := filepath.Join(vmConfigDir, v.Name) + ".json"
|
||||||
|
// Write the JSON file
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ioutil.WriteFile(jsonFile, b, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
//go:build windows
|
||||||
// +build windows
|
// +build windows
|
||||||
|
|
||||||
package wsl
|
package wsl
|
||||||
@ -8,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -35,9 +37,6 @@ const (
|
|||||||
ErrorSuccessRebootRequired = 3010
|
ErrorSuccessRebootRequired = 3010
|
||||||
)
|
)
|
||||||
|
|
||||||
// Usermode networking avoids potential nftables compatibility issues between the distro
|
|
||||||
// and the WSL Kernel. Additionally it avoids fw rule conflicts between distros, since
|
|
||||||
// all instances run under the same Kernel at runtime
|
|
||||||
const containersConf = `[containers]
|
const containersConf = `[containers]
|
||||||
|
|
||||||
[engine]
|
[engine]
|
||||||
@ -162,6 +161,8 @@ type MachineVM struct {
|
|||||||
Port int
|
Port int
|
||||||
// RemoteUsername of the vm user
|
// RemoteUsername of the vm user
|
||||||
RemoteUsername string
|
RemoteUsername string
|
||||||
|
// Whether this machine should run in a rootful or rootless manner
|
||||||
|
Rootful bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExitCodeError struct {
|
type ExitCodeError struct {
|
||||||
@ -227,12 +228,13 @@ func (v *MachineVM) Init(opts machine.InitOptions) (bool, error) {
|
|||||||
homeDir := homedir.Get()
|
homeDir := homedir.Get()
|
||||||
sshDir := filepath.Join(homeDir, ".ssh")
|
sshDir := filepath.Join(homeDir, ".ssh")
|
||||||
v.IdentityPath = filepath.Join(sshDir, v.Name)
|
v.IdentityPath = filepath.Join(sshDir, v.Name)
|
||||||
|
v.Rootful = opts.Rootful
|
||||||
|
|
||||||
if err := downloadDistro(v, opts); err != nil {
|
if err := downloadDistro(v, opts); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeJSON(v); err != nil {
|
if err := v.writeConfig(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,7 +284,7 @@ func downloadDistro(v *MachineVM, opts machine.InitOptions) error {
|
|||||||
return machine.DownloadImage(dd)
|
return machine.DownloadImage(dd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(v *MachineVM) error {
|
func (v *MachineVM) writeConfig() error {
|
||||||
vmConfigDir, err := machine.GetConfDir(vmtype)
|
vmConfigDir, err := machine.GetConfDir(vmtype)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -302,14 +304,26 @@ func writeJSON(v *MachineVM) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupConnections(v *MachineVM, opts machine.InitOptions, sshDir string) error {
|
func setupConnections(v *MachineVM, opts machine.InitOptions, sshDir string) error {
|
||||||
|
uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/user/1000/podman/podman.sock", strconv.Itoa(v.Port), v.RemoteUsername)
|
||||||
uriRoot := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/podman/podman.sock", strconv.Itoa(v.Port), "root")
|
uriRoot := machine.SSHRemoteConnection.MakeSSHURL("localhost", "/run/podman/podman.sock", strconv.Itoa(v.Port), "root")
|
||||||
if err := machine.AddConnection(&uriRoot, v.Name+"-root", filepath.Join(sshDir, v.Name), opts.IsDefault); err != nil {
|
identity := filepath.Join(sshDir, v.Name)
|
||||||
return err
|
|
||||||
|
uris := []url.URL{uri, uriRoot}
|
||||||
|
names := []string{v.Name, v.Name + "-root"}
|
||||||
|
|
||||||
|
// The first connection defined when connections is empty will become the default
|
||||||
|
// regardless of IsDefault, so order according to rootful
|
||||||
|
if opts.Rootful {
|
||||||
|
uris[0], names[0], uris[1], names[1] = uris[1], names[1], uris[0], names[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
user := opts.Username
|
for i := 0; i < 2; i++ {
|
||||||
uri := machine.SSHRemoteConnection.MakeSSHURL("localhost", withUser("/run/[USER]/1000/podman/podman.sock", user), strconv.Itoa(v.Port), v.RemoteUsername)
|
if err := machine.AddConnection(&uris[i], names[i], identity, opts.IsDefault && i == 0); err != nil {
|
||||||
return machine.AddConnection(&uri, v.Name, filepath.Join(sshDir, v.Name), opts.IsDefault)
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func provisionWSLDist(v *MachineVM) (string, error) {
|
func provisionWSLDist(v *MachineVM) (string, error) {
|
||||||
@ -335,6 +349,16 @@ func provisionWSLDist(v *MachineVM) (string, error) {
|
|||||||
return "", errors.Wrap(err, "package upgrade on guest OS failed")
|
return "", errors.Wrap(err, "package upgrade on guest OS failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("Enabling Copr")
|
||||||
|
if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "install", "-y", "'dnf-command(copr)'"); err != nil {
|
||||||
|
return "", errors.Wrap(err, "enabling copr failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Enabling podman4 repo")
|
||||||
|
if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "-y", "copr", "enable", "rhcontainerbot/podman4"); err != nil {
|
||||||
|
return "", errors.Wrap(err, "enabling copr failed")
|
||||||
|
}
|
||||||
|
|
||||||
if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "install",
|
if err = runCmdPassThrough("wsl", "-d", dist, "dnf", "install",
|
||||||
"podman", "podman-docker", "openssh-server", "procps-ng", "-y"); err != nil {
|
"podman", "podman-docker", "openssh-server", "procps-ng", "-y"); err != nil {
|
||||||
return "", errors.Wrap(err, "package installation on guest OS failed")
|
return "", errors.Wrap(err, "package installation on guest OS failed")
|
||||||
@ -704,6 +728,30 @@ func pipeCmdPassThrough(name string, input string, arg ...string) error {
|
|||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *MachineVM) Set(name string, opts machine.SetOptions) error {
|
||||||
|
if v.Rootful == opts.Rootful {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
changeCon, err := machine.AnyConnectionDefault(v.Name, v.Name+"-root")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if changeCon {
|
||||||
|
newDefault := v.Name
|
||||||
|
if opts.Rootful {
|
||||||
|
newDefault += "-root"
|
||||||
|
}
|
||||||
|
if err := machine.ChangeDefault(newDefault); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Rootful = opts.Rootful
|
||||||
|
return v.writeConfig()
|
||||||
|
}
|
||||||
|
|
||||||
func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
||||||
if v.isRunning() {
|
if v.isRunning() {
|
||||||
return errors.Errorf("%q is already running", name)
|
return errors.Errorf("%q is already running", name)
|
||||||
@ -716,6 +764,18 @@ func (v *MachineVM) Start(name string, _ machine.StartOptions) error {
|
|||||||
return errors.Wrap(err, "WSL bootstrap script failed")
|
return errors.Wrap(err, "WSL bootstrap script failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !v.Rootful {
|
||||||
|
fmt.Printf("\nThis machine is currently configured in rootless mode. If your containers\n")
|
||||||
|
fmt.Printf("require root permissions (e.g. ports < 1024), or if you run into compatibility\n")
|
||||||
|
fmt.Printf("issues with non-podman clients, you can switch using the following command: \n")
|
||||||
|
|
||||||
|
suffix := ""
|
||||||
|
if name != machine.DefaultMachineName {
|
||||||
|
suffix = " " + name
|
||||||
|
}
|
||||||
|
fmt.Printf("\n\tpodman machine set --rootful%s\n\n", suffix)
|
||||||
|
}
|
||||||
|
|
||||||
globalName, pipeName, err := launchWinProxy(v)
|
globalName, pipeName, err := launchWinProxy(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "API forwarding for Docker API clients is not available due to the following startup failures.")
|
fmt.Fprintln(os.Stderr, "API forwarding for Docker API clients is not available due to the following startup failures.")
|
||||||
|
Reference in New Issue
Block a user