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 }