mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
feat(gateway): add remote release upgrade trigger
This commit is contained in:
1 parent
ba3b535286
commit
f09b08e44f
7 files changed
+761
-9
No files matched your search
@@ -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())
|
||||
|
||||
@@ -50,6 +50,7 @@ export GOCLAW_DOMAIN=<public-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/<tag>`, 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 <gateway-token>" \
|
||||
-H "X-GoClaw-Upgrade-Token: <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 <gateway-token>" \
|
||||
-H "X-GoClaw-Upgrade-Token: <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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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" <<JSON
|
||||
{
|
||||
"jobId": $(json_escape "$job"),
|
||||
"state": $(json_escape "$state"),
|
||||
"requestedTag": $(json_escape "$requested"),
|
||||
"resolvedTag": $(json_escape "$resolved"),
|
||||
"startedAt": $(json_escape "$now"),
|
||||
"finishedAt": ${finished_json},
|
||||
"currentReleaseBefore": $(json_escape "$before"),
|
||||
"targetRelease": $(json_escape "$target"),
|
||||
"error": $(json_escape "$error_msg")
|
||||
}
|
||||
JSON
|
||||
chown "$STATUS_OWNER" "$tmp" 2>/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] <latest|vMAJOR.MINOR.PATCH[-beta.N|-rc.N]>
|
||||
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}"
|
||||
Reference in new issue
Block a user