forked from molecule-ai/molecule-core
Merge pull request #2412 from Molecule-AI/fix/restart-external-no-revoke
fix(workspace-server): skip provision pipeline on Restart for runtime=external
This commit is contained in:
commit
d2046c374d
@ -89,6 +89,27 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runtime=external: the workspace has no Docker container or EC2 — its
|
||||||
|
// lifecycle is operator-driven (a remote poller heartbeats from outside
|
||||||
|
// the platform). Pre-fix, this handler still ran the full re-provision
|
||||||
|
// pipeline, which calls issueAndInjectToken → RevokeAllForWorkspace.
|
||||||
|
// That silently destroyed the operator's local bearer token on every
|
||||||
|
// "Restart" click, leaving them with a 401-spamming poller and no
|
||||||
|
// platform-side recovery path short of regenerating from the canvas
|
||||||
|
// Tokens tab. Auto-restart already short-circuits external (see
|
||||||
|
// runRestartCycle below). Mirror that here so manual + automatic
|
||||||
|
// behavior agree, and surface a clear message instead of silently
|
||||||
|
// no-op'ing — the canvas can show the operator that the fix is on
|
||||||
|
// their side.
|
||||||
|
if dbRuntime == "external" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "noop",
|
||||||
|
"runtime": "external",
|
||||||
|
"message": "external workspaces are operator-driven — restart your local poller; platform has nothing to restart",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// SaaS mode: cpProv handles workspace EC2 lifecycle. Self-hosted mode:
|
// SaaS mode: cpProv handles workspace EC2 lifecycle. Self-hosted mode:
|
||||||
// provisioner handles local Docker containers. At least one must be
|
// provisioner handles local Docker containers. At least one must be
|
||||||
// available — previously only `provisioner` was checked, which broke
|
// available — previously only `provisioner` was checked, which broke
|
||||||
|
|||||||
@ -124,6 +124,61 @@ func TestRestartHandler_AncestorPausedBlocksRestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestartHandler_ExternalRuntimeNoOps(t *testing.T) {
|
||||||
|
// Manual Restart on a runtime=external workspace must short-circuit:
|
||||||
|
// no Stop, no provision, no token revoke. Pre-fix, this path ran the
|
||||||
|
// full re-provision pipeline and silently revoked the operator's
|
||||||
|
// bearer token on every click.
|
||||||
|
mock := setupTestDB(t)
|
||||||
|
setupTestRedis(t)
|
||||||
|
broadcaster := newTestBroadcaster()
|
||||||
|
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||||
|
WithArgs("ws-external").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||||
|
AddRow("offline", "External Agent", 1, "external"))
|
||||||
|
|
||||||
|
// isParentPaused: no parent
|
||||||
|
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||||
|
WithArgs("ws-external").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}))
|
||||||
|
|
||||||
|
// No further expectations — Restart must NOT touch the DB or the
|
||||||
|
// provisioner from this point. sqlmock will fail the test if any
|
||||||
|
// unexpected query runs (UPDATE workspaces SET status=..., the
|
||||||
|
// RevokeAllForWorkspace DELETE, etc.).
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: "ws-external"}}
|
||||||
|
c.Request = httptest.NewRequest("POST", "/workspaces/ws-external/restart", nil)
|
||||||
|
|
||||||
|
handler.Restart(c)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := resp["status"].(string); got != "noop" {
|
||||||
|
t.Errorf("expected status=noop, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if got, _ := resp["runtime"].(string); got != "external" {
|
||||||
|
t.Errorf("expected runtime=external, got %v", resp["runtime"])
|
||||||
|
}
|
||||||
|
if msg, _ := resp["message"].(string); !strings.Contains(msg, "operator-driven") {
|
||||||
|
t.Errorf("expected message about operator-driven, got %v", resp["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Errorf("unmet sqlmock expectations (or unexpected queries — token revoke or status update would trigger this): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestartHandler_NilProvisionerReturns503(t *testing.T) {
|
func TestRestartHandler_NilProvisionerReturns503(t *testing.T) {
|
||||||
mock := setupTestDB(t)
|
mock := setupTestDB(t)
|
||||||
setupTestRedis(t)
|
setupTestRedis(t)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user