From f09b08e44fde0e6a90b538c769d1bd59eb04d853 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 18 May 2026 06:55:49 +0700 Subject: [PATCH] feat(gateway): add remote release upgrade trigger --- cmd/gateway_http_wiring.go | 1 + docs/deployment-guide.md | 37 ++++ docs/project-changelog.md | 5 + internal/gateway/server.go | 23 ++- internal/http/gateway_upgrade.go | 262 ++++++++++++++++++++++++ internal/http/gateway_upgrade_test.go | 280 ++++++++++++++++++++++++++ scripts/goclaw-upgrade-release.sh | 162 +++++++++++++++ 7 files changed, 761 insertions(+), 9 deletions(-) create mode 100644 internal/http/gateway_upgrade.go create mode 100644 internal/http/gateway_upgrade_test.go create mode 100644 scripts/goclaw-upgrade-release.sh diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index 802b5702..1bc06ade 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -140,6 +140,7 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( initGitHubInstaller() pkgHandler := wirePackagesHandler(d) d.server.SetPackagesHandler(pkgHandler) + d.server.SetGatewayUpgradeHandler(httpapi.NewGatewayUpgradeHandlerFromEnv()) // API documentation (OpenAPI spec + Swagger UI at /docs) d.server.SetDocsHandler(httpapi.NewDocsHandler()) diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index ea6c3ee5..15fad389 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -50,6 +50,7 @@ export GOCLAW_DOMAIN= | `/usr/local/bin/goclaw-deploy` | Release switch, upgrade, health-check, rollback | | `/usr/local/bin/goclaw-issue-ssl` | Certbot wrapper for the deployment domain | | `/usr/local/bin/goclaw-backup-r2` | Postgres dump, R2 upload, retention cleanup | +| `/usr/local/bin/goclaw-upgrade-release` | Download and deploy a GitHub Release tarball | Secrets are stored only in server env files. Do not copy tokens or database passwords into repo docs. @@ -122,6 +123,42 @@ sudo /usr/local/bin/goclaw-issue-ssl ## Deploy A New Release +Preferred server-side upgrade flow: + +```bash +sudo /usr/local/bin/goclaw-upgrade-release --dry-run latest +sudo /usr/local/bin/goclaw-upgrade-release latest +sudo /usr/local/bin/goclaw-upgrade-release v3.12.0 +``` + +The script downloads the Linux amd64 GitHub Release tarball from `digitopvn/goclaw`, follows GitHub release redirects, verifies `CHECKSUMS.sha256`, extracts to `/opt/goclaw/releases/`, and calls `goclaw-deploy`. + +The HTTP API still accepts only `tag`; it does not accept repo names or custom download URLs. + +Remote API trigger is available in builds that include the gateway upgrade endpoint: + +```bash +curl -fsS -X POST "https://$GOCLAW_DOMAIN/v1/system/gateway/upgrade" \ + -H "Authorization: Bearer " \ + -H "X-GoClaw-Upgrade-Token: " \ + -H "Content-Type: application/json" \ + --data '{"tag":"latest"}' +``` + +Check status: + +```bash +curl -fsS "https://$GOCLAW_DOMAIN/v1/system/gateway/upgrade/status" \ + -H "Authorization: Bearer " \ + -H "X-GoClaw-Upgrade-Token: " +``` + +Keep upgrade tokens in server env files or secret managers. Do not put real tokens in docs. + +The remote trigger endpoint fails closed unless `GOCLAW_UPGRADE_TRIGGER_TOKEN` is configured in the gateway environment. + +Manual local-build fallback: + Build locally with embedded web UI: ```bash diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 596fc922..d094481f 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -29,6 +29,11 @@ Significant changes, features, and fixes in reverse chronological order. - Added `goclaw-backup-r2.timer` to dump PostgreSQL every 6 hours, upload to private Cloudflare R2 storage, and retain the latest 20 backups. - Added deployment runbook in `docs/deployment-guide.md`. +**Features** + +- Added a protected gateway upgrade HTTP API that triggers the fixed host-local upgrade script asynchronously. +- Added `scripts/goclaw-upgrade-release.sh` and installed the VPS copy at `/usr/local/bin/goclaw-upgrade-release`; dry-run verifies the latest stable server release asset and checksum before deploy. + --- ### CI/CD: dev branch beta automation diff --git a/internal/gateway/server.go b/internal/gateway/server.go index e3091134..a0f72cec 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -21,11 +21,11 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" - "github.com/nextlevelbuilder/goclaw/internal/webui" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" + "github.com/nextlevelbuilder/goclaw/internal/webui" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -50,21 +50,21 @@ type Server struct { // Non-handler dependencies (don't implement RegisterRoutes) policyEngine *permissions.PolicyEngine pairingService store.PairingStore - apiKeyStore store.APIKeyStore // for API key auth lookup - agentStore store.AgentStore // for context injection in tools_invoke - msgBus *bus.MessageBus // for MCP bridge media delivery + apiKeyStore store.APIKeyStore // for API key auth lookup + agentStore store.AgentStore // for context injection in tools_invoke + msgBus *bus.MessageBus // for MCP bridge media delivery upgrader websocket.Upgrader rateLimiter *RateLimiter clients map[string]*Client mu sync.RWMutex - startedAt time.Time - version string - db interface{ PingContext(context.Context) error } // for health check DB ping - updateChecker *UpdateChecker + startedAt time.Time + version string + db interface{ PingContext(context.Context) error } // for health check DB ping + updateChecker *UpdateChecker - logTee *LogTee // optional; auto-unsubscribes clients on disconnect + logTee *LogTee // optional; auto-unsubscribes clients on disconnect postTurn tools.PostTurnProcessor // optional; for team task dispatch in HTTP API paths httpServer *http.Server @@ -464,6 +464,11 @@ func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) { s.handlers = append(s.handlers, h) } +// SetGatewayUpgradeHandler sets the host-local gateway upgrade trigger handler. +func (s *Server) SetGatewayUpgradeHandler(h *httpapi.GatewayUpgradeHandler) { + s.handlers = append(s.handlers, h) +} + // SetOAuthHandler sets the OAuth handler (available in all modes). func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.handlers = append(s.handlers, h) } diff --git a/internal/http/gateway_upgrade.go b/internal/http/gateway_upgrade.go new file mode 100644 index 00000000..73f17449 --- /dev/null +++ b/internal/http/gateway_upgrade.go @@ -0,0 +1,262 @@ +package http + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/permissions" +) + +const ( + defaultGatewayUpgradeScript = "/usr/local/bin/goclaw-upgrade-release" + defaultGatewayUpgradeStatus = "/var/lib/goclaw/update-jobs/current.json" + gatewayUpgradeTokenHeader = "X-GoClaw-Upgrade-Token" +) + +var gatewayUpgradeTagRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$`) + +type gatewayUpgradeRunner interface { + Start(tag string) error +} + +type gatewayUpgradeCommandRunner struct { + scriptPath string +} + +func (r gatewayUpgradeCommandRunner) Start(tag string) error { + cmd := exec.Command("sudo", "-n", r.scriptPath, tag) + if err := cmd.Start(); err != nil { + return err + } + go func() { + if err := cmd.Wait(); err != nil { + slog.Warn("gateway upgrade command exited with error", "error", err) + } + }() + return nil +} + +// GatewayUpgradeHandler triggers the host-local GoClaw release upgrade script. +// It never accepts arbitrary commands or URLs. +type GatewayUpgradeHandler struct { + ScriptPath string + StatusPath string + TriggerToken string + Runner gatewayUpgradeRunner + mu sync.Mutex +} + +func NewGatewayUpgradeHandlerFromEnv() *GatewayUpgradeHandler { + scriptPath := strings.TrimSpace(os.Getenv("GOCLAW_UPGRADE_SCRIPT")) + if scriptPath == "" { + scriptPath = defaultGatewayUpgradeScript + } + statusPath := strings.TrimSpace(os.Getenv("GOCLAW_UPGRADE_STATUS_PATH")) + if statusPath == "" { + statusPath = defaultGatewayUpgradeStatus + } + h := &GatewayUpgradeHandler{ + ScriptPath: scriptPath, + StatusPath: statusPath, + TriggerToken: os.Getenv("GOCLAW_UPGRADE_TRIGGER_TOKEN"), + } + h.Runner = gatewayUpgradeCommandRunner{scriptPath: h.ScriptPath} + return h +} + +func (h *GatewayUpgradeHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/system/gateway/upgrade/status", requireAuth(permissions.RoleAdmin, h.handleStatus)) + mux.HandleFunc("POST /v1/system/gateway/upgrade", requireAuth(permissions.RoleAdmin, h.handleStart)) +} + +func (h *GatewayUpgradeHandler) handleStatus(w http.ResponseWriter, r *http.Request) { + if !requireMasterScope(w, r) { + return + } + if !h.requireTriggerToken(w, r) { + return + } + + status, err := h.readStatus() + if err != nil { + slog.Error("gateway upgrade status read failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read gateway upgrade status"}) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (h *GatewayUpgradeHandler) handleStart(w http.ResponseWriter, r *http.Request) { + if !requireMasterScope(w, r) { + return + } + if !enforcePackagesWriteLimit(w, r, "/v1/system/gateway/upgrade") { + return + } + if !h.requireTriggerToken(w, r) { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1024) + var req struct { + Tag string `json:"tag"` + } + if !bindJSON(w, r, extractLocale(r), &req) { + return + } + tag := strings.TrimSpace(req.Tag) + if !validGatewayUpgradeTag(tag) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tag must be latest or vMAJOR.MINOR.PATCH[-beta.N|-rc.N]"}) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + status, err := h.readStatus() + if err != nil { + slog.Error("gateway upgrade status read failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read gateway upgrade status"}) + return + } + if status["state"] == "running" { + writeJSON(w, http.StatusConflict, map[string]string{"error": "gateway upgrade already running"}) + return + } + + runner := h.Runner + if runner == nil { + runner = gatewayUpgradeCommandRunner{scriptPath: h.ScriptPath} + } + if err := h.writeRunningStatus(tag); err != nil { + slog.Error("gateway upgrade status write failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write gateway upgrade status"}) + return + } + if err := runner.Start(tag); err != nil { + slog.Error("gateway upgrade start failed", "error", err) + _ = h.writeFailedStatus(tag, "failed to start gateway upgrade") + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to start gateway upgrade"}) + return + } + + writeJSON(w, http.StatusAccepted, map[string]any{ + "ok": true, + "accepted": true, + "tag": tag, + }) +} + +func (h *GatewayUpgradeHandler) requireTriggerToken(w http.ResponseWriter, r *http.Request) bool { + if h.TriggerToken == "" { + slog.Warn("security.gateway_upgrade_token_unconfigured", "path", r.URL.Path) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "gateway upgrade trigger token is not configured"}) + return false + } + provided := r.Header.Get(gatewayUpgradeTokenHeader) + if subtle.ConstantTimeCompare([]byte(provided), []byte(h.TriggerToken)) == 1 { + return true + } + slog.Warn("security.gateway_upgrade_token_denied", "path", r.URL.Path) + writeJSON(w, http.StatusForbidden, map[string]string{"error": "upgrade trigger token required"}) + return false +} + +func (h *GatewayUpgradeHandler) readStatus() (map[string]any, error) { + path := h.statusPath() + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return map[string]any{"state": "idle"}, nil + } + if err != nil { + return nil, fmt.Errorf("read upgrade status: %w", err) + } + if len(data) > 64*1024 { + return nil, fmt.Errorf("upgrade status too large") + } + var status map[string]any + if err := json.Unmarshal(data, &status); err != nil { + return nil, fmt.Errorf("decode upgrade status: %w", err) + } + if status == nil { + return map[string]any{"state": "idle"}, nil + } + return status, nil +} + +func (h *GatewayUpgradeHandler) writeRunningStatus(tag string) error { + return h.writeStatus(map[string]any{ + "jobId": time.Now().UTC().Format("20060102T150405Z") + "-" + tag, + "state": "running", + "requestedTag": tag, + "resolvedTag": "", + "startedAt": time.Now().UTC().Format(time.RFC3339), + "finishedAt": nil, + "error": nil, + }) +} + +func (h *GatewayUpgradeHandler) writeFailedStatus(tag, reason string) error { + return h.writeStatus(map[string]any{ + "jobId": time.Now().UTC().Format("20060102T150405Z") + "-" + tag, + "state": "failed", + "requestedTag": tag, + "resolvedTag": "", + "startedAt": time.Now().UTC().Format(time.RFC3339), + "finishedAt": time.Now().UTC().Format(time.RFC3339), + "error": reason, + }) +} + +func (h *GatewayUpgradeHandler) writeStatus(status map[string]any) error { + path := h.statusPath() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create upgrade status dir: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".current-*.json") + if err != nil { + return fmt.Errorf("create upgrade status tmp: %w", err) + } + tmpName := tmp.Name() + encErr := json.NewEncoder(tmp).Encode(status) + closeErr := tmp.Close() + if encErr != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("encode upgrade status: %w", encErr) + } + if closeErr != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("close upgrade status tmp: %w", closeErr) + } + if err := os.Chmod(tmpName, 0o640); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("chmod upgrade status tmp: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("replace upgrade status: %w", err) + } + return nil +} + +func (h *GatewayUpgradeHandler) statusPath() string { + if h.StatusPath == "" { + return defaultGatewayUpgradeStatus + } + return filepath.Clean(h.StatusPath) +} + +func validGatewayUpgradeTag(tag string) bool { + return tag == "latest" || gatewayUpgradeTagRE.MatchString(tag) +} diff --git a/internal/http/gateway_upgrade_test.go b/internal/http/gateway_upgrade_test.go new file mode 100644 index 00000000..4737fb6e --- /dev/null +++ b/internal/http/gateway_upgrade_test.go @@ -0,0 +1,280 @@ +package http + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +type fakeGatewayUpgradeRunner struct { + tags []string + err error +} + +func (r *fakeGatewayUpgradeRunner) Start(tag string) error { + r.tags = append(r.tags, tag) + return r.err +} + +func TestValidGatewayUpgradeTag(t *testing.T) { + tests := []struct { + tag string + want bool + }{ + {"latest", true}, + {"v3.12.0", true}, + {"v3.12.0-beta.1", true}, + {"v3.12.0-rc.2", true}, + {"", false}, + {"3.12.0", false}, + {"v3.12", false}, + {"v3.12.0-beta", false}, + {"https://example.com/goclaw.tar.gz", false}, + {"v3.12.0;reboot", false}, + {"../v3.12.0", false}, + {"v3.12.0 linux", false}, + } + for _, tt := range tests { + if got := validGatewayUpgradeTag(tt.tag); got != tt.want { + t.Fatalf("validGatewayUpgradeTag(%q) = %v, want %v", tt.tag, got, tt.want) + } + } +} + +func TestGatewayUpgradeStatusMissingReturnsIdle(t *testing.T) { + h := &GatewayUpgradeHandler{StatusPath: filepath.Join(t.TempDir(), "missing.json"), TriggerToken: "secret-token"} + req := httptest.NewRequest(http.MethodGet, "/v1/system/gateway/upgrade/status", nil) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(ownerCtx(req.Context(), "gateway-status-owner")) + w := httptest.NewRecorder() + + h.handleStatus(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body["state"] != "idle" { + t.Fatalf("state = %v, want idle", body["state"]) + } +} + +func TestGatewayUpgradeStartAcceptsValidTag(t *testing.T) { + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + TriggerToken: "secret-token", + Runner: runner, + } + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"v3.12.0"}`)) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(ownerCtx(req.Context(), "gateway-start-owner")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("want 202, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 1 || runner.tags[0] != "v3.12.0" { + t.Fatalf("runner tags = %#v, want [v3.12.0]", runner.tags) + } +} + +func TestGatewayUpgradeStartRejectsInvalidTag(t *testing.T) { + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + TriggerToken: "secret-token", + Runner: runner, + } + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"https://example.com/x"}`)) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(ownerCtx(req.Context(), "gateway-invalid-owner")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 0 { + t.Fatalf("runner should not be called, got %#v", runner.tags) + } +} + +func TestGatewayUpgradeStartRejectsRunningJob(t *testing.T) { + dir := t.TempDir() + statusPath := filepath.Join(dir, "status.json") + if err := os.WriteFile(statusPath, []byte(`{"state":"running"}`), 0o600); err != nil { + t.Fatalf("write status: %v", err) + } + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{StatusPath: statusPath, TriggerToken: "secret-token", Runner: runner} + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`)) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(ownerCtx(req.Context(), "gateway-running-owner")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("want 409, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 0 { + t.Fatalf("runner should not be called, got %#v", runner.tags) + } +} + +func TestGatewayUpgradeTriggerTokenGuard(t *testing.T) { + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + TriggerToken: "secret-token", + Runner: runner, + } + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`)) + req = req.WithContext(ownerCtx(req.Context(), "gateway-token-owner")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 0 { + t.Fatalf("runner should not be called, got %#v", runner.tags) + } +} + +func TestGatewayUpgradeRejectsNonMasterScope(t *testing.T) { + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + TriggerToken: "secret-token", + Runner: runner, + } + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`)) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(tenantAdminCtx(req.Context(), "gateway-tenant-admin")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 0 { + t.Fatalf("runner should not be called, got %#v", runner.tags) + } +} + +func TestGatewayUpgradeStartRunnerError(t *testing.T) { + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + TriggerToken: "secret-token", + Runner: &fakeGatewayUpgradeRunner{err: errors.New("boom")}, + } + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`)) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(ownerCtx(req.Context(), "gateway-runner-error-owner")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGatewayUpgradeFailsClosedWithoutConfiguredTriggerToken(t *testing.T) { + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + Runner: runner, + } + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`)) + req = req.WithContext(ownerCtx(req.Context(), "gateway-no-token-owner")) + w := httptest.NewRecorder() + + h.handleStart(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("want 503, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 0 { + t.Fatalf("runner should not be called, got %#v", runner.tags) + } +} + +func TestGatewayUpgradeRegisterRoutes(t *testing.T) { + runner := &fakeGatewayUpgradeRunner{} + h := &GatewayUpgradeHandler{ + StatusPath: filepath.Join(t.TempDir(), "status.json"), + TriggerToken: "secret-token", + Runner: runner, + } + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`)) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("want 202, got %d: %s", w.Code, w.Body.String()) + } + if len(runner.tags) != 1 || runner.tags[0] != "latest" { + t.Fatalf("runner tags = %#v, want [latest]", runner.tags) + } +} + +func TestNewGatewayUpgradeHandlerFromEnvDefaults(t *testing.T) { + t.Setenv("GOCLAW_UPGRADE_SCRIPT", "") + t.Setenv("GOCLAW_UPGRADE_STATUS_PATH", "") + t.Setenv("GOCLAW_UPGRADE_TRIGGER_TOKEN", "secret-token") + + h := NewGatewayUpgradeHandlerFromEnv() + + if h.ScriptPath != defaultGatewayUpgradeScript { + t.Fatalf("ScriptPath = %q, want %q", h.ScriptPath, defaultGatewayUpgradeScript) + } + if h.StatusPath != defaultGatewayUpgradeStatus { + t.Fatalf("StatusPath = %q, want %q", h.StatusPath, defaultGatewayUpgradeStatus) + } + if h.TriggerToken != "secret-token" { + t.Fatalf("TriggerToken was not loaded from env") + } +} + +func TestGatewayUpgradeStatusRejectsInvalidJSON(t *testing.T) { + statusPath := filepath.Join(t.TempDir(), "status.json") + if err := os.WriteFile(statusPath, []byte(`{bad json`), 0o600); err != nil { + t.Fatalf("write status: %v", err) + } + h := &GatewayUpgradeHandler{StatusPath: statusPath, TriggerToken: "secret-token"} + req := httptest.NewRequest(http.MethodGet, "/v1/system/gateway/upgrade/status", nil) + req.Header.Set(gatewayUpgradeTokenHeader, "secret-token") + req = req.WithContext(ownerCtx(req.Context(), "gateway-invalid-status-owner")) + w := httptest.NewRecorder() + + h.handleStatus(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), statusPath) || strings.Contains(w.Body.String(), "decode upgrade status") { + t.Fatalf("response leaked internal status details: %s", w.Body.String()) + } +} diff --git a/scripts/goclaw-upgrade-release.sh b/scripts/goclaw-upgrade-release.sh new file mode 100644 index 00000000..d2b45061 --- /dev/null +++ b/scripts/goclaw-upgrade-release.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REPO="digitopvn/goclaw" +BASE_DIR="/opt/goclaw" +RELEASES_DIR="${BASE_DIR}/releases" +DEPLOY_BIN="/usr/local/bin/goclaw-deploy" +STATUS_DIR="/var/lib/goclaw/update-jobs" +STATUS_FILE="${STATUS_DIR}/current.json" +STATUS_OWNER="${GOCLAW_STATUS_OWNER:-goclaw:goclaw}" +DRY_RUN=0 + +log() { printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; } +json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1"; } + +write_status() { + local state="$1" requested="$2" resolved="$3" error_msg="${4:-}" + mkdir -p "$STATUS_DIR" + local now job target before tmp + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + job="$(date -u +%Y%m%dT%H%M%SZ)-${resolved:-$requested}" + target="${RELEASES_DIR}/${resolved:-$requested}" + before="" + if [ -L "${BASE_DIR}/current" ]; then + before="$(readlink -f "${BASE_DIR}/current" || true)" + fi + local finished_json + if [ "$state" = "running" ]; then + finished_json="null" + else + finished_json="$(json_escape "$now")" + fi + tmp="$(mktemp "${STATUS_DIR}/current.XXXXXX")" + cat > "$tmp" </dev/null || true + chmod 0640 "$tmp" + mv "$tmp" "$STATUS_FILE" +} + +fail() { + local msg="$1" + log "ERROR: $msg" + if [ "$DRY_RUN" != "1" ]; then + write_status "failed" "${REQUESTED_TAG:-}" "${RESOLVED_TAG:-}" "$msg" || true + fi + exit 1 +} + +usage() { + cat <<'EOF' +usage: goclaw-upgrade-release [--dry-run] +EOF +} + +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi + +REQUESTED_TAG="${1:-}" +if [ -z "$REQUESTED_TAG" ] || [ "${2:-}" != "" ]; then + usage >&2 + exit 2 +fi + +if [ "$REQUESTED_TAG" != "latest" ] && ! [[ "$REQUESTED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then + fail "invalid tag" +fi + +require_bin() { command -v "$1" >/dev/null 2>&1 || fail "missing dependency: $1"; } +require_bin curl +require_bin flock +require_bin tar +require_bin sha256sum +require_bin python3 + +if [ "$DRY_RUN" != "1" ]; then + mkdir -p "$STATUS_DIR" + exec 9>"${STATUS_DIR}/upgrade.lock" + flock -n 9 || fail "gateway upgrade already running" +fi + +RESOLVED_TAG="$REQUESTED_TAG" +if [ "$REQUESTED_TAG" = "latest" ]; then + log "resolving latest stable server release" + RESOLVED_TAG="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=50" | python3 -c ' +import json, re, sys +for rel in json.load(sys.stdin): + tag = rel.get("tag_name", "") + if rel.get("draft") or rel.get("prerelease"): + continue + if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag): + print(tag) + raise SystemExit(0) +raise SystemExit("no stable server release found") +')" +fi + +if ! [[ "$RESOLVED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then + fail "resolved invalid tag: $RESOLVED_TAG" +fi + +VERSION="${RESOLVED_TAG#v}" +ASSET="goclaw-${VERSION}-linux-amd64.tar.gz" +ASSET_URL="https://github.com/${REPO}/releases/download/${RESOLVED_TAG}/${ASSET}" +CHECKSUM_URL="https://github.com/${REPO}/releases/download/${RESOLVED_TAG}/CHECKSUMS.sha256" +TARGET_DIR="${RELEASES_DIR}/${RESOLVED_TAG}" + +log "requested=${REQUESTED_TAG} resolved=${RESOLVED_TAG} asset=${ASSET}" + +if [ "$DRY_RUN" = "1" ]; then + TMP_DIR="$(mktemp -d)" + cleanup() { rm -rf "$TMP_DIR"; } + trap cleanup EXIT + cd "$TMP_DIR" + curl -fsSLO "$ASSET_URL" + curl -fsSLO "$CHECKSUM_URL" + grep " ${ASSET}$\|${ASSET}$" CHECKSUMS.sha256 | sha256sum -c - + log "dry-run ok" + exit 0 +fi + +write_status "running" "$REQUESTED_TAG" "$RESOLVED_TAG" "" + +TMP_DIR="$(mktemp -d)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +cd "$TMP_DIR" +log "downloading release asset" +curl -fsSLO "$ASSET_URL" +curl -fsSLO "$CHECKSUM_URL" + +grep " ${ASSET}$\|${ASSET}$" CHECKSUMS.sha256 | sha256sum -c - || fail "checksum verification failed" + +if [ -e "$TARGET_DIR" ]; then + fail "target release already exists: $TARGET_DIR" +fi +mkdir -p "$TARGET_DIR" +tar -xzf "$ASSET" -C "$TARGET_DIR" +chmod +x "$TARGET_DIR/goclaw" + +if [ ! -x "$TARGET_DIR/goclaw" ] || [ ! -d "$TARGET_DIR/migrations" ]; then + fail "release archive missing goclaw binary or migrations directory" +fi + +log "deploying ${RESOLVED_TAG}" +"$DEPLOY_BIN" "$TARGET_DIR" || fail "deploy failed" +write_status "succeeded" "$REQUESTED_TAG" "$RESOLVED_TAG" "" +log "upgrade complete: ${RESOLVED_TAG}"