init
This commit is contained in:
265
server/handlers.go
Normal file
265
server/handlers.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"AbstractWizard/config"
|
||||
)
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
relPath := r.PathValue("path")
|
||||
|
||||
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
format, err := config.DetectFormat(fullPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := config.Parse(data, format)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.Log(r.RemoteAddr, "read", relPath, "config read")
|
||||
writeJSON(w, http.StatusOK, parsed)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.GetMode() == ModeReadOnly {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
|
||||
return
|
||||
}
|
||||
|
||||
relPath := r.PathValue("path")
|
||||
|
||||
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
format, err := config.DetectFormat(fullPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that the body is valid config
|
||||
if _, err := config.Parse(body, format); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.AtomicWrite(fullPath, body, 0o644); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.Log(r.RemoteAddr, "replace", relPath, "full replace")
|
||||
resp := map[string]string{"status": "ok"}
|
||||
if version != "" {
|
||||
resp["backup_version"] = version
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.GetMode() == ModeReadOnly {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
|
||||
return
|
||||
}
|
||||
|
||||
relPath := r.PathValue("path")
|
||||
|
||||
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
format, err := config.DetectFormat(fullPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
existing := make(map[string]any)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read existing file"})
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
existing, err = config.Parse(data, format)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "existing file is invalid: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse patch body
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
|
||||
return
|
||||
}
|
||||
|
||||
patch, err := config.Parse(body, format)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
merged := config.DeepMerge(existing, patch)
|
||||
|
||||
out, err := config.Serialize(merged, format)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "serialization failed"})
|
||||
return
|
||||
}
|
||||
|
||||
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.AtomicWrite(fullPath, out, 0o644); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed"})
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.Log(r.RemoteAddr, "patch", relPath, "deep merge update")
|
||||
resp := map[string]string{"status": "ok"}
|
||||
if version != "" {
|
||||
resp["backup_version"] = version
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
relPath := r.PathValue("path")
|
||||
|
||||
backups, err := config.ListBackups(s.cfg.ConfigDir, relPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type backupResponse struct {
|
||||
Version string `json:"version"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
var resp []backupResponse
|
||||
for _, b := range backups {
|
||||
resp = append(resp, backupResponse{Version: b.Version, Timestamp: b.Timestamp})
|
||||
}
|
||||
if resp == nil {
|
||||
resp = []backupResponse{}
|
||||
}
|
||||
|
||||
s.audit.Log(r.RemoteAddr, "list_backups", relPath, "listed backups")
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.GetMode() == ModeReadOnly {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
|
||||
return
|
||||
}
|
||||
|
||||
relPath := r.PathValue("path")
|
||||
|
||||
var body struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.Version == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "version is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Backup current state before rollback
|
||||
if _, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "pre-rollback backup failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.Rollback(s.cfg.ConfigDir, relPath, body.Version); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rollback failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.audit.Log(r.RemoteAddr, "rollback", relPath, "rolled back to "+body.Version)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "restored_version": body.Version})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMode(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": s.GetMode().String()})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetMode(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
mode, ok := ParseMode(body.Mode)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid mode; must be 'init' or 'readonly'"})
|
||||
return
|
||||
}
|
||||
|
||||
s.SetMode(mode)
|
||||
s.audit.Log(r.RemoteAddr, "set_mode", "", "mode changed to "+body.Mode)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "mode": mode.String()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
27
server/middleware.go
Normal file
27
server/middleware.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
r.statusCode = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs each incoming request with method, path, status, and duration.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
137
server/server.go
Normal file
137
server/server.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"AbstractWizard/audit"
|
||||
)
|
||||
|
||||
// Mode represents the server operating mode.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeInit Mode = iota // init: read/write allowed
|
||||
ModeReadOnly // readonly: only reads allowed
|
||||
)
|
||||
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeInit:
|
||||
return "init"
|
||||
case ModeReadOnly:
|
||||
return "readonly"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseMode converts a string to a Mode.
|
||||
func ParseMode(s string) (Mode, bool) {
|
||||
switch s {
|
||||
case "init":
|
||||
return ModeInit, true
|
||||
case "readonly":
|
||||
return ModeReadOnly, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// AppConfig holds environment-based configuration.
|
||||
type AppConfig struct {
|
||||
ConfigDir string
|
||||
ListenAddr string
|
||||
MaxBackups int
|
||||
}
|
||||
|
||||
// Server is the main HTTP server.
|
||||
type Server struct {
|
||||
cfg AppConfig
|
||||
audit *audit.Logger
|
||||
mode Mode
|
||||
modeMu sync.RWMutex
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
// New creates a new Server.
|
||||
func New(cfg AppConfig, auditLog *audit.Logger) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
audit: auditLog,
|
||||
mode: ModeInit,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
s.registerRoutes(mux)
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: LoggingMiddleware(mux),
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// registerRoutes sets up all API routes.
|
||||
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/v1/config/{path...}", s.handleGetConfig)
|
||||
mux.HandleFunc("PUT /api/v1/config/{path...}", s.handlePutConfig)
|
||||
mux.HandleFunc("PATCH /api/v1/config/{path...}", s.handlePatchConfig)
|
||||
mux.HandleFunc("GET /api/v1/backups/{path...}", s.handleListBackups)
|
||||
mux.HandleFunc("POST /api/v1/rollback/{path...}", s.handleRollback)
|
||||
mux.HandleFunc("GET /api/v1/mode", s.handleGetMode)
|
||||
mux.HandleFunc("PUT /api/v1/mode", s.handleSetMode)
|
||||
}
|
||||
|
||||
// GetMode returns the current server mode.
|
||||
func (s *Server) GetMode() Mode {
|
||||
s.modeMu.RLock()
|
||||
defer s.modeMu.RUnlock()
|
||||
return s.mode
|
||||
}
|
||||
|
||||
// SetMode changes the server mode.
|
||||
func (s *Server) SetMode(m Mode) {
|
||||
s.modeMu.Lock()
|
||||
defer s.modeMu.Unlock()
|
||||
s.mode = m
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server and blocks until shutdown.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("listening on %s", s.cfg.ListenAddr)
|
||||
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case sig := <-stop:
|
||||
log.Printf("received signal %v, shutting down", sig)
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return s.srv.Shutdown(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user