181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"werkdock/internal/api"
|
|
"werkdock/internal/instance"
|
|
"werkdock/internal/store"
|
|
)
|
|
|
|
// systemCmd is podman's `system` group; `service` is the API socket.
|
|
func systemCmd(args []string) int {
|
|
if len(args) == 0 {
|
|
return fail(errors.New("system needs a subcommand: service"))
|
|
}
|
|
switch args[0] {
|
|
case "service":
|
|
return serviceCmd(args[1:])
|
|
case "info":
|
|
return doctorCmd(args[1:])
|
|
default:
|
|
return fail(fmt.Errorf("unknown command %q for system (service, info)", args[0]))
|
|
}
|
|
}
|
|
|
|
// serviceCmd serves the Docker Engine API subset on a unix socket, so
|
|
// that a client which speaks docker (Testcontainers) can use werkdock
|
|
// without knowing it (RFC 0005 milestone 3). Podman's verb and
|
|
// podman's manners: the socket is optional, started when needed, and
|
|
// may exit when idle.
|
|
func serviceCmd(args []string) int {
|
|
fs := flag.NewFlagSet("system service", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
idle := fs.Duration("time", 0, "exit after this long without a request (0: never)")
|
|
quiet := fs.Bool("quiet", false, "do not log requests")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fail(err)
|
|
}
|
|
if len(fs.Args()) > 1 {
|
|
return fail(fmt.Errorf("unexpected argument %q", fs.Args()[1]))
|
|
}
|
|
st, err := store.Default()
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
socket, err := socketPath(fs.Arg(0), st.Root)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
ln, err := listen(socket)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
defer os.Remove(socket)
|
|
logger := log.New(os.Stderr, "", log.LstdFlags)
|
|
server := &api.Server{Store: st, Manager: instance.Manager{Root: st.Root}, Version: Version}
|
|
if !*quiet {
|
|
server.Log = logger
|
|
}
|
|
httpServer := &http.Server{Handler: server.Handler()}
|
|
logger.Printf("werkdock %s serving the docker API subset on %s", Version, socket)
|
|
logger.Printf("point a client at it with: export DOCKER_HOST=unix://%s", socket)
|
|
logger.Printf("Testcontainers additionally needs: export TESTCONTAINERS_RYUK_DISABLED=true")
|
|
|
|
stopped := make(chan error, 1)
|
|
go func() { stopped <- httpServer.Serve(ln) }()
|
|
signals := make(chan os.Signal, 1)
|
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
|
defer signal.Stop(signals)
|
|
reason := waitForShutdown(server, signals, stopped, *idle)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
httpServer.Shutdown(ctx)
|
|
logger.Printf("werkdock: %s", reason)
|
|
return 0
|
|
}
|
|
|
|
// waitForShutdown blocks until a signal arrives, the server stops, or
|
|
// the idle timeout passes without a request.
|
|
func waitForShutdown(server *api.Server, signals <-chan os.Signal, stopped <-chan error, idle time.Duration) string {
|
|
if idle <= 0 {
|
|
select {
|
|
case sig := <-signals:
|
|
return "stopped on " + sig.String()
|
|
case err := <-stopped:
|
|
return "stopped: " + err.Error()
|
|
}
|
|
}
|
|
// The clock starts now, not at the last request, so a service that
|
|
// is never used still exits.
|
|
last := time.Now()
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case sig := <-signals:
|
|
return "stopped on " + sig.String()
|
|
case err := <-stopped:
|
|
return "stopped: " + err.Error()
|
|
case <-ticker.C:
|
|
if seen := server.LastRequest(); seen.After(last) {
|
|
last = seen
|
|
}
|
|
if time.Since(last) >= idle {
|
|
return fmt.Sprintf("idle for %s, exiting", idle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// socketPath resolves where to listen: the argument (podman's
|
|
// unix://PATH spelling), else $XDG_RUNTIME_DIR/werkdock.sock, else the
|
|
// store root — a unix socket path is short by kernel limit, and the
|
|
// runtime directory is both short and cleaned up on logout.
|
|
func socketPath(arg, storeRoot string) (string, error) {
|
|
path := arg
|
|
if path == "" {
|
|
if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
|
|
path = filepath.Join(runtimeDir, "werkdock.sock")
|
|
} else {
|
|
path = filepath.Join(storeRoot, "werkdock.sock")
|
|
}
|
|
}
|
|
path = strings.TrimPrefix(path, "unix://")
|
|
if !filepath.IsAbs(path) {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
path = abs
|
|
}
|
|
// The kernel's sockaddr_un is 108 bytes; a long home directory can
|
|
// exceed it, and the error bwrap-style tools then give is obscure.
|
|
if len(path) > 100 {
|
|
return "", fmt.Errorf("socket path is too long for a unix socket (%d characters, the limit is about 100): %s", len(path), path)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// listen binds the socket, replacing one left behind by a service that
|
|
// died — but never one another service is still answering on.
|
|
func listen(socket string) (net.Listener, error) {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
conn, err := net.DialTimeout("unix", socket, time.Second)
|
|
if err == nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("another werkdock service is already listening on %s", socket)
|
|
}
|
|
if err := os.Remove(socket); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(socket), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
ln, err := net.Listen("unix", socket)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The socket is the API: whoever can open it can run commands as
|
|
// this user, so it is the user's alone.
|
|
if err := os.Chmod(socket, 0o600); err != nil {
|
|
ln.Close()
|
|
return nil, err
|
|
}
|
|
return ln, nil
|
|
}
|