Files
miandmhoennig f9c1ae835d Api socket (#5)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #5
2026-09-10 22:09:58 +02:00

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
}