feat: add server shutdown endpoint and client method

Ayman Bagabas created

Change summary

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(-)

Detailed changes

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
+}

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()
 

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)
 }

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)