feat(gateway): add remote release upgrade trigger

This commit is contained in:
Duy Nguyen committed 2026-05-18 06:56:17 +07:00
1 parent ba3b535286
commit f09b08e44f
7 files changed
+761 -9

No files matched your search

+1
View File
@@ -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())
+37
View File
@@ -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
+5
View File
@@ -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
+14 -9
View File
@@ -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) }
+262
View File
@@ -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)
}
+280
View File
@@ -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())
}
}
+162
View File
@@ -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}"