From 699326151f9fe28f3e01c0637bdbe0ef2d1c5969 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 24 Sep 2025 16:14:38 -0400 Subject: [PATCH] feat: add server shutdown endpoint and client method --- internal/client/client.go | 19 +++++++++++++++++++ internal/cmd/server.go | 6 +++++- internal/proto/server.go | 5 +++++ internal/server/proto.go | 24 ++++++++++++++++++++++++ internal/server/server.go | 3 ++- 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 internal/proto/server.go diff --git a/internal/client/client.go b/internal/client/client.go index eb4f814fc5f8cac006d5980433b5918e256592fb..686b94ee237d3b19da352b329b1e11a340871389 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -106,3 +106,22 @@ func (c *Client) VersionInfo() (*proto.VersionInfo, error) { } return &vi, nil } + +// ShutdownServer sends a shutdown request to the server. +func (c *Client) ShutdownServer() error { + req, err := http.NewRequest("POST", "http://localhost/v1/control", jsonBody(proto.ServerControl{ + Command: "shutdown", + })) + if err != nil { + return err + } + rsp, err := c.h.Do(req) + if err != nil { + return err + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusOK { + return fmt.Errorf("server shutdown failed: %s", rsp.Status) + } + return nil +} diff --git a/internal/cmd/server.go b/internal/cmd/server.go index afcd2b3eaa2a49fabebc998f2f0e4b4d9d72de94..499889de7a8554296cebfc81f3bb0cd7a6aba738 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -60,7 +60,7 @@ var serverCmd = &cobra.Command{ select { case <-sigch: slog.Info("Received interrupt signal...") - case err := <-errch: + case err = <-errch: if err != nil && !errors.Is(err, server.ErrServerClosed) { _ = srv.Close() slog.Error("Server error", "error", err) @@ -68,6 +68,10 @@ var serverCmd = &cobra.Command{ } } + if errors.Is(err, server.ErrServerClosed) { + return nil + } + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) defer cancel() diff --git a/internal/proto/server.go b/internal/proto/server.go new file mode 100644 index 0000000000000000000000000000000000000000..3e43315b759ace57fe689784a24cce61d5026b35 --- /dev/null +++ b/internal/proto/server.go @@ -0,0 +1,5 @@ +package proto + +type ServerControl struct { + Command string `json:"command"` +} diff --git a/internal/server/proto.go b/internal/server/proto.go index 5e8562b7b4e85b8fb710e39802cc03e9346993f6..40bcba880646176cba10c775c854bbc57740e64c 100644 --- a/internal/server/proto.go +++ b/internal/server/proto.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "fmt" "log/slog" @@ -36,6 +37,29 @@ func (c *controllerV1) handleGetVersion(w http.ResponseWriter, r *http.Request) }) } +func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) { + var req proto.ServerControl + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.logError(r, "failed to decode request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") + return + } + + switch req.Command { + case "shutdown": + go func() { + slog.Info("shutting down server...") + if err := c.Shutdown(context.Background()); err != nil { + c.logError(r, "failed to shutdown server", "error", err) + } + }() + default: + c.logError(r, "unknown command", "command", req.Command) + jsonError(w, http.StatusBadRequest, "unknown command") + return + } +} + func (c *controllerV1) handleGetConfig(w http.ResponseWriter, r *http.Request) { jsonEncode(w, c.cfg) } diff --git a/internal/server/server.go b/internal/server/server.go index 10ee9194fea94dc40b4e3b7f24f260bb1fe122fd..d88eb1c7f0bd7f555a8edc0ecef1a28c08ae6380 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,7 +16,7 @@ import ( ) // ErrServerClosed is returned when the server is closed. -var ErrServerClosed = fmt.Errorf("server closed") +var ErrServerClosed = http.ErrServerClosed // InstanceState represents the state of a running [app.App] instance. type InstanceState uint8 @@ -115,6 +115,7 @@ func NewServer(cfg *config.Config, network, address string) *Server { mux.HandleFunc("GET /v1/health", c.handleGetHealth) mux.HandleFunc("GET /v1/version", c.handleGetVersion) mux.HandleFunc("GET /v1/config", c.handleGetConfig) + mux.HandleFunc("POST /v1/control", c.handlePostControl) mux.HandleFunc("GET /v1/instances", c.handleGetInstances) mux.HandleFunc("POST /v1/instances", c.handlePostInstances) mux.HandleFunc("DELETE /v1/instances", c.handleDeleteInstances)