mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(daemon): enforce runtime ownership on daemon routes (MUL-8154) /api/daemon/* accepts any workspace member's PAT/JWT, and authorization only checked workspace membership. Any member could claim another member's tasks (receiving the agent's custom_env, MCP config and an owner-scoped task token), write task results/messages, and take a runtime over by re-registering its daemon_id. - Runtime-level check on every runtime/task daemon route, HTTP heartbeat, deregister, batch claim (and so the WS claim RPC) and the WS handshake: user credentials must own the runtime (NULL owner fails closed); daemon tokens must match a non-empty runtime daemon_id. - Register: pre-check that every existing row on the daemon_id belongs to the caller, plus a required_owner_id fence in both upserts (incl. the failed-profile path) so a concurrent registration cannot win the row. Daemon-token registration keeps the existing owner. - Legacy merge: only for user-credential registrations, and only when the caller owns both rows, verified on the FOR UPDATE-locked rows. - Tasks without a runtime are not writable through daemon routes; status stays readable. - GetTaskStatus / WS lease queries carry runtime owner/daemon so the hot paths keep one round trip. Co-authored-by: multica-agent <[email protected]> * test(daemon): authenticate fixtures as the runtime owner (MUL-8154) Most handler tests drove daemon routes with a daemon token whose daemon_id did not match the fixture runtime, relying on the workspace-wide token scope this fix removes. They now authenticate the way real daemons do, with the runtime owner's user credential (newOwnerDaemonRequest). Tests about daemon token scoping keep mdt_ and get fixtures with a matching daemon_id; the ownerless-runtime tests also assert that a user credential is refused. Co-authored-by: multica-agent <[email protected]> * fix(daemon): serialize registration per daemon and keep Qoder WS claims working (MUL-8154) Review of #9423: - P1: the owner pre-check and the per-provider/profile upserts hit different conflict keys, so two members registering different providers of the same daemon could both pass the pre-check and both insert, leaving one daemon with two owners (and a daemon token for either runtime reaching the other's). Registration now runs the pre-check and every runtime write in one transaction under a pg_advisory_xact_lock on (workspace_id, daemon_id), on a single connection so queued registrations cannot starve the holder. Legacy merges, analytics and the register event run after commit; failed-profile records use savepoints so they stay best-effort. - P2: the Qoder revocation check read the Authorization header, which the WS RPC bridge's in-process request does not carry, so valid Qoder WS claims silently returned nothing. DaemonAuth now records the mdt_ token hash in the request context, the WS identity keeps it, the RPC bridge re-attaches it, and the check validates that hash on every call (revocation still applies to an open connection). - Regression tests for cross-provider and provider+profile concurrent registration, and for valid/revoked Qoder credentials over WS RPC. Co-authored-by: multica-agent <[email protected]> * fix(daemon): bind daemon tokens to the runtime owner they were issued for (MUL-8154) Second review of #9423: a daemon_id can be re-registered by another member after its runtimes are deleted, and a daemon token was bound only to (workspace, daemon_id), so the previous owner's Remote MCP token reached the new owner's runtime (heartbeat, claim, owner-scoped task token). - daemon_token.owner_id (migration 588). Remote MCP tokens are bound to the locked runtime owner inside FinalizeTaskClaim; live tokens are backfilled only where their daemon has exactly one owner. - DaemonAuth carries the owner binding (DB and cache; cache key bumped to v2 so pre-upgrade entries are never read), the WS identity keeps it and the RPC bridge replays it. - Daemon-token authorization now also requires token owner == runtime owner. Qoder-managed tokens stay unbound (governed by cloud_agent_connection), and user credentials can no longer register the reserved qoder-managed-* ids. - Tests: the reuse lifecycle over HTTP, a new WS handshake and an already open WS connection; the reserved daemon_id; the concurrency barrier now hooks the registration transaction (TxStarter) so the interleaving is real. Co-authored-by: multica-agent <[email protected]> * fix(daemon): owner-bind daemon-token registration; drop unsafe token backfill (MUL-8154) Third review of #9423: - Migration 588 no longer backfills daemon_token.owner_id from the daemon's current owner: that owner may be a later re-registrant, which re-bound the previous owner's live token to them. Existing non-Qoder tokens stay NULL and are refused (24h Remote MCP credentials, re-issued on the next claim). - owner_id is a plain UUID column, no foreign key / cascade (AGENTS.md). - Register via a non-Qoder daemon token now uses the token's owner for the in-transaction pre-check and the SQL owner fence, and refuses unbound tokens. Qoder keeps its connection-based check; daemon tokens still never trigger legacy merges. - Tests: an old owner's bound token and an unbound legacy token can neither register over nor heartbeat the replacement owner's runtime. Co-authored-by: multica-agent <[email protected]> * fix(daemon): grant the Qoder registration exemption only with a live connection (MUL-8154) Fourth review of #9423: register skipped the token owner binding for any qoder-managed-* daemon token, but checked the Qoder connection only when the body declared a qoder_cloud runtime. A pre-upgrade Remote MCP token under that prefix (no owner, no connection) could register type=claude and rewrite another member's runtime. Register now verifies the token against a live cloud_agent_connection before anything else whenever the token is in the Qoder namespace, regardless of the declared runtimes, and restricts that namespace to the bridge's own shape: a built-in qoder_cloud runtime, no custom profiles, no failed_profiles. Test covers built-in, custom-profile, failed-profiles-only and qoder_cloud bodies with an unconnected token, plus the connected token's allowed and refused shapes. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
640 lines
24 KiB
Go
640 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/qoderruntime"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
type qoderSettings struct {
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
EnvironmentID string `json:"environment_id"`
|
|
QoderToken string `json:"qoder_token,omitempty"`
|
|
GitHubTokens map[string]string `json:"github_tokens,omitempty"`
|
|
MulticaToken string `json:"multica_token,omitempty"`
|
|
MaxConcurrency int `json:"max_concurrency,omitempty"`
|
|
RunTimeoutMinutes int `json:"run_timeout_minutes,omitempty"`
|
|
}
|
|
|
|
const (
|
|
defaultQoderConcurrency = 3
|
|
defaultQoderRunTimeout = 60
|
|
maxQoderRunTimeout = 240
|
|
)
|
|
|
|
// withDefaults fills limits for connections saved before they were configurable.
|
|
func (s qoderSettings) withDefaults() qoderSettings {
|
|
if s.MaxConcurrency == 0 {
|
|
s.MaxConcurrency = defaultQoderConcurrency
|
|
}
|
|
if s.RunTimeoutMinutes == 0 {
|
|
s.RunTimeoutMinutes = defaultQoderRunTimeout
|
|
}
|
|
return s
|
|
}
|
|
|
|
type qoderSettingsView struct {
|
|
Configured bool `json:"configured"`
|
|
Available bool `json:"available"`
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
EnvironmentID string `json:"environment_id"`
|
|
HasToken bool `json:"has_token"`
|
|
Repositories []string `json:"repositories"`
|
|
Enabled bool `json:"enabled"`
|
|
Status string `json:"status"`
|
|
LastError string `json:"last_error"`
|
|
MaxConcurrency int `json:"max_concurrency"`
|
|
RunTimeoutMinutes int `json:"run_timeout_minutes"`
|
|
ActiveRuns int `json:"active_runs"`
|
|
QueuedRuns int `json:"queued_runs"`
|
|
}
|
|
|
|
func (h *Handler) readQoderSettings(ctx context.Context, ws string) (qoderSettings, bool, string, string, error) {
|
|
var encrypted, status, lastError string
|
|
var enabled bool
|
|
err := h.DB.QueryRow(ctx, `SELECT config_encrypted, enabled, status, last_error FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1`, ws).Scan(&encrypted, &enabled, &status, &lastError)
|
|
if err != nil {
|
|
return qoderSettings{}, false, "", "", err
|
|
}
|
|
raw, err := h.openVCSSecret(encrypted)
|
|
if err != nil {
|
|
return qoderSettings{}, false, "", "", fmt.Errorf("cannot decrypt QCA configuration")
|
|
}
|
|
var cfg qoderSettings
|
|
err = json.Unmarshal([]byte(raw), &cfg)
|
|
return cfg.withDefaults(), enabled, status, lastError, err
|
|
}
|
|
|
|
func (h *Handler) GetQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
view := qoderSettingsView{Available: h.VCSSecretBox != nil, Repositories: []string{}}
|
|
if !view.Available {
|
|
writeJSON(w, 200, view)
|
|
return
|
|
}
|
|
cfg, enabled, status, lastError, err := h.readQoderSettings(r.Context(), uuidToString(ws))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeJSON(w, 200, view)
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, 500, "cannot read QCA configuration")
|
|
return
|
|
}
|
|
var online bool
|
|
_ = h.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM agent_runtime WHERE workspace_id=$1 AND provider='qoder_cloud' AND last_seen_at>now()-interval '45 seconds' AND status='online')`, ws).Scan(&online)
|
|
if status == "online" && enabled && !online {
|
|
status = "offline"
|
|
}
|
|
view.Configured = true
|
|
view.Name = cfg.Name
|
|
view.BaseURL = cfg.BaseURL
|
|
view.EnvironmentID = cfg.EnvironmentID
|
|
view.HasToken = cfg.QoderToken != ""
|
|
view.Enabled = enabled
|
|
view.Status = status
|
|
view.LastError = lastError
|
|
view.MaxConcurrency = cfg.MaxConcurrency
|
|
view.RunTimeoutMinutes = cfg.RunTimeoutMinutes
|
|
_ = h.DB.QueryRow(r.Context(), `SELECT count(*) FILTER (WHERE t.status IN ('dispatched','running')), count(*) FILTER (WHERE t.status='queued') FROM agent_task_queue t JOIN agent_runtime rt ON rt.id=t.runtime_id WHERE rt.workspace_id=$1 AND rt.provider='qoder_cloud' AND t.status IN ('queued','dispatched','running')`, ws).Scan(&view.ActiveRuns, &view.QueuedRuns)
|
|
for repo := range cfg.GitHubTokens {
|
|
view.Repositories = append(view.Repositories, repo)
|
|
}
|
|
sort.Strings(view.Repositories)
|
|
writeJSON(w, 200, view)
|
|
}
|
|
|
|
func (h *Handler) qoderInput(w http.ResponseWriter, r *http.Request, ws string) (qoderSettings, bool) {
|
|
var cfg qoderSettings
|
|
if h.VCSSecretBox == nil {
|
|
writeError(w, 503, "QCA requires MULTICA_VCS_SECRET_KEY on the server")
|
|
return cfg, false
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
BaseURL string `json:"base_url"`
|
|
EnvironmentID string `json:"environment_id"`
|
|
QoderToken string `json:"qoder_token"`
|
|
GitHubTokens map[string]string `json:"github_tokens"`
|
|
MaxConcurrency int `json:"max_concurrency"`
|
|
RunTimeoutMinutes int `json:"run_timeout_minutes"`
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
writeError(w, 400, "invalid QCA configuration")
|
|
return cfg, false
|
|
}
|
|
var extra any
|
|
if decoder.Decode(&extra) != io.EOF {
|
|
writeError(w, 400, "configuration must contain one JSON object")
|
|
return cfg, false
|
|
}
|
|
cfg, _, _, _, err := h.readQoderSettings(r.Context(), ws)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, 500, "cannot read QCA configuration")
|
|
return cfg, false
|
|
}
|
|
cfg.Name = strings.TrimSpace(req.Name)
|
|
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
|
cfg.EnvironmentID = strings.TrimSpace(req.EnvironmentID)
|
|
if !qoderAPIURLs[cfg.BaseURL] || cfg.EnvironmentID == "" || cfg.Name == "" {
|
|
writeError(w, 400, "name, environment and official API URL are required")
|
|
return cfg, false
|
|
}
|
|
if req.MaxConcurrency != 0 {
|
|
cfg.MaxConcurrency = req.MaxConcurrency
|
|
}
|
|
if req.RunTimeoutMinutes != 0 {
|
|
cfg.RunTimeoutMinutes = req.RunTimeoutMinutes
|
|
}
|
|
cfg = cfg.withDefaults()
|
|
if cfg.MaxConcurrency < 1 || cfg.MaxConcurrency > qoderruntime.MaxConcurrencyLimit {
|
|
writeError(w, 400, fmt.Sprintf("concurrency must be between 1 and %d", qoderruntime.MaxConcurrencyLimit))
|
|
return cfg, false
|
|
}
|
|
if cfg.RunTimeoutMinutes < 5 || cfg.RunTimeoutMinutes > maxQoderRunTimeout {
|
|
writeError(w, 400, fmt.Sprintf("run timeout must be between 5 and %d minutes", maxQoderRunTimeout))
|
|
return cfg, false
|
|
}
|
|
if req.QoderToken != "" {
|
|
cfg.QoderToken = strings.TrimSpace(req.QoderToken)
|
|
}
|
|
if cfg.QoderToken == "" || strings.ContainsAny(cfg.QoderToken, "\r\n\t ") {
|
|
writeError(w, 400, "QCA token is required")
|
|
return cfg, false
|
|
}
|
|
if req.GitHubTokens != nil {
|
|
next := map[string]string{}
|
|
for raw, token := range req.GitHubTokens {
|
|
key, err := qoderruntime.RepositoryKey(raw)
|
|
if err != nil {
|
|
writeError(w, 400, "invalid GitHub repository URL")
|
|
return cfg, false
|
|
}
|
|
if _, exists := next[key]; exists {
|
|
writeError(w, 400, "duplicate repository binding")
|
|
return cfg, false
|
|
}
|
|
if token == "" {
|
|
token = cfg.GitHubTokens[key]
|
|
}
|
|
if strings.TrimSpace(token) == "" || strings.ContainsAny(strings.TrimSpace(token), "\r\n\t ") {
|
|
writeError(w, 400, "GitHub token is required for each repository")
|
|
return cfg, false
|
|
}
|
|
next[key] = strings.TrimSpace(token)
|
|
}
|
|
cfg.GitHubTokens = next
|
|
}
|
|
return cfg, true
|
|
}
|
|
|
|
func (h *Handler) CheckQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
cfg, ok := h.qoderInput(w, r, uuidToString(ws))
|
|
if !ok {
|
|
return
|
|
}
|
|
client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
for path, expectedID := range map[string]string{"/environments/" + url.PathEscape(cfg.EnvironmentID): cfg.EnvironmentID} {
|
|
req, err := http.NewRequestWithContext(r.Context(), "GET", cfg.BaseURL+path, nil)
|
|
if err != nil {
|
|
writeError(w, 400, "invalid QCA resource")
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+cfg.QoderToken)
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
writeError(w, 502, "QCA connection failed")
|
|
return
|
|
}
|
|
if res.StatusCode != 200 {
|
|
res.Body.Close()
|
|
writeError(w, 400, fmt.Sprintf("QCA access check returned HTTP %d", res.StatusCode))
|
|
return
|
|
}
|
|
var resource struct {
|
|
ID string `json:"id"`
|
|
}
|
|
err = json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&resource)
|
|
res.Body.Close()
|
|
if err != nil || resource.ID != expectedID {
|
|
writeError(w, 502, "invalid QCA resource response")
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// SaveQoderConnection keeps a paused connection paused; only
|
|
// ResumeQoderConnection (or the first connect) enables execution.
|
|
func (h *Handler) SaveQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
h.saveQoderConnection(w, r, false)
|
|
}
|
|
|
|
func (h *Handler) ResumeQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
h.saveQoderConnection(w, r, true)
|
|
}
|
|
|
|
func (h *Handler) saveQoderConnection(w http.ResponseWriter, r *http.Request, resume bool) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
user, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
cfg, ok := h.qoderInput(w, r, uuidToString(ws))
|
|
if !ok {
|
|
return
|
|
}
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, 500, "cannot save QCA configuration")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
// Serialize writes even before a connection exists.
|
|
if _, err = tx.Exec(r.Context(), `SELECT pg_advisory_xact_lock(hashtextextended($1, 19))`, uuidToString(ws)); err != nil {
|
|
writeError(w, 500, "cannot lock QCA configuration")
|
|
return
|
|
}
|
|
var busy bool
|
|
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1 AND busy_until>now())`, ws).Scan(&busy); err != nil {
|
|
writeError(w, 500, "cannot check QCA connection")
|
|
return
|
|
}
|
|
if busy {
|
|
writeError(w, 409, "QCA is synchronizing; retry shortly")
|
|
return
|
|
}
|
|
|
|
var journal string
|
|
if err := tx.QueryRow(r.Context(), `SELECT journal_encrypted FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1`, ws).Scan(&journal); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
writeError(w, 500, "cannot read execution state")
|
|
return
|
|
}
|
|
if journal != "" {
|
|
raw, err := h.openVCSSecret(journal)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot read execution state")
|
|
return
|
|
}
|
|
var state struct {
|
|
Run json.RawMessage `json:"run"`
|
|
Runs []json.RawMessage `json:"runs"`
|
|
Cleanup []string `json:"cleanup"`
|
|
}
|
|
if json.Unmarshal([]byte(raw), &state) != nil {
|
|
writeError(w, 500, "cannot read execution state")
|
|
return
|
|
}
|
|
if len(state.Cleanup) > 0 || len(state.Runs) > 0 || (len(state.Run) > 0 && string(state.Run) != "null") {
|
|
writeError(w, 409, "wait for QCA execution cleanup before changing configuration")
|
|
return
|
|
}
|
|
}
|
|
var revokedHash string
|
|
_ = tx.QueryRow(r.Context(), `SELECT d.token_hash FROM daemon_token d JOIN cloud_agent_connection c ON c.token_id=d.id AND c.provider='qoder_cloud' WHERE c.workspace_id=$1`, ws).Scan(&revokedHash)
|
|
var active bool
|
|
err = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM agent_task_queue t JOIN agent_runtime rt ON rt.id=t.runtime_id WHERE rt.workspace_id=$1 AND rt.provider='qoder_cloud' AND t.status IN ('running','dispatched'))`, ws).Scan(&active)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot check active QCA tasks")
|
|
return
|
|
}
|
|
if active {
|
|
writeError(w, 409, "wait for active QCA tasks to finish before changing configuration")
|
|
return
|
|
}
|
|
rawToken, err := auth.GenerateDaemonToken()
|
|
if err != nil {
|
|
writeError(w, 500, "cannot generate service credential")
|
|
return
|
|
}
|
|
q := h.Queries.WithTx(tx)
|
|
pat, err := q.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{WorkspaceID: ws, DaemonID: "qoder-managed-" + uuidToString(ws), TokenHash: auth.HashToken(rawToken), ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}})
|
|
if err != nil {
|
|
writeError(w, 500, "cannot create service credential")
|
|
return
|
|
}
|
|
cfg.MulticaToken = rawToken
|
|
raw, _ := json.Marshal(cfg)
|
|
encrypted, err := h.sealVCSSecret(string(raw))
|
|
if err != nil {
|
|
writeError(w, 500, "cannot encrypt QCA configuration")
|
|
return
|
|
}
|
|
_, err = tx.Exec(r.Context(), `DELETE FROM daemon_token WHERE id IN (SELECT token_id FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1)`, ws)
|
|
if err == nil {
|
|
_, err = tx.Exec(r.Context(), `INSERT INTO cloud_agent_connection(workspace_id,provider,owner_id,token_id,config_encrypted) VALUES($1,'qoder_cloud',$2,$3,$4) ON CONFLICT(workspace_id,provider) DO UPDATE SET owner_id=excluded.owner_id,token_id=excluded.token_id,config_encrypted=excluded.config_encrypted,enabled=($5 OR cloud_agent_connection.enabled),journal_encrypted='',revision=cloud_agent_connection.revision+1,status=CASE WHEN $5 OR cloud_agent_connection.enabled THEN 'starting' ELSE cloud_agent_connection.status END,last_error='',updated_at=now()`, ws, parseUUID(user), pat.ID, encrypted, resume)
|
|
}
|
|
if err != nil || tx.Commit(r.Context()) != nil {
|
|
writeError(w, 500, "cannot save QCA configuration")
|
|
return
|
|
}
|
|
h.DaemonTokenCache.Invalidate(r.Context(), revokedHash)
|
|
h.GetQoderConnection(w, r)
|
|
}
|
|
|
|
// RunQoderConnections owns managed bridges for this server. A database advisory
|
|
// lock elects one supervisor, and each bridge journal lives encrypted in
|
|
// cloud_agent_connection, so a restart or another replica resumes it.
|
|
func (h *Handler) RunQoderConnections(ctx context.Context, pool *pgxpool.Pool, serverURL string) {
|
|
if h.VCSSecretBox == nil {
|
|
return
|
|
}
|
|
for ctx.Err() == nil {
|
|
conn, err := pool.Acquire(ctx)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
slog.WarnContext(ctx, "Qoder supervisor database connection unavailable", "error", err)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
continue
|
|
}
|
|
}
|
|
var locked bool
|
|
err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock(71243, 8191)`).Scan(&locked)
|
|
if err == nil && locked {
|
|
h.runQoderSupervisor(ctx, conn, serverURL)
|
|
}
|
|
if locked {
|
|
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock(71243,8191)`)
|
|
}
|
|
conn.Release()
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
type managedQoderRun struct {
|
|
revision int64
|
|
cancel context.CancelFunc
|
|
done chan error
|
|
}
|
|
|
|
func (h *Handler) runQoderSupervisor(ctx context.Context, conn *pgxpool.Conn, serverURL string) {
|
|
runs := map[string]managedQoderRun{}
|
|
retryAt := map[string]time.Time{}
|
|
failures := map[string]int{}
|
|
defer func() {
|
|
for _, run := range runs {
|
|
run.cancel()
|
|
<-run.done
|
|
}
|
|
}()
|
|
for ctx.Err() == nil {
|
|
_, _ = conn.Exec(ctx, `UPDATE daemon_token d SET expires_at=now()+interval '1 hour' FROM cloud_agent_connection c JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND d.id=c.token_id AND c.enabled=true AND m.role IN ('owner','admin') AND d.expires_at<now()+interval '30 minutes'`)
|
|
revoked, revokeErr := conn.Query(ctx, `DELETE FROM daemon_token d USING cloud_agent_connection c WHERE c.provider='qoder_cloud' AND d.id=c.token_id AND (NOT c.enabled OR NOT EXISTS(SELECT 1 FROM member m WHERE m.workspace_id=c.workspace_id AND m.user_id=c.owner_id AND m.role IN ('owner','admin'))) RETURNING d.token_hash`)
|
|
if revokeErr != nil {
|
|
return
|
|
}
|
|
for revoked.Next() {
|
|
var hash string
|
|
if revoked.Scan(&hash) == nil {
|
|
h.DaemonTokenCache.Invalidate(ctx, hash)
|
|
}
|
|
}
|
|
revoked.Close()
|
|
rows, err := conn.Query(ctx, `SELECT c.workspace_id::text,c.revision FROM cloud_agent_connection c JOIN workspace w ON w.id=c.workspace_id JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND c.enabled=true AND m.role IN ('owner','admin')`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
desired := map[string]int64{}
|
|
for rows.Next() {
|
|
var ws string
|
|
var rev int64
|
|
if rows.Scan(&ws, &rev) != nil {
|
|
rows.Close()
|
|
return
|
|
}
|
|
desired[ws] = rev
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return
|
|
}
|
|
retryLater := map[string]bool{}
|
|
for ws, run := range runs {
|
|
if desired[ws] != run.revision {
|
|
run.cancel()
|
|
<-run.done
|
|
delete(runs, ws)
|
|
continue
|
|
}
|
|
select {
|
|
case err := <-run.done:
|
|
message := "execution service stopped; retrying"
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Qoder bridge stopped", "workspace_id", ws, "error", err)
|
|
message = "remote execution unavailable; retrying"
|
|
}
|
|
_, _ = conn.Exec(ctx, `UPDATE cloud_agent_connection SET status='error',last_error=$2 WHERE provider='qoder_cloud' AND workspace_id=$1`, ws, message)
|
|
delete(runs, ws)
|
|
retryLater[ws] = true
|
|
failures[ws]++
|
|
delay := 10 * time.Second << min(failures[ws]-1, 5)
|
|
retryAt[ws] = time.Now().Add(delay)
|
|
default:
|
|
}
|
|
}
|
|
h.cleanupInactiveQoder(ctx)
|
|
for ws, rev := range desired {
|
|
if retryLater[ws] || time.Now().Before(retryAt[ws]) {
|
|
continue
|
|
}
|
|
if _, ok := runs[ws]; ok {
|
|
continue
|
|
}
|
|
cfg, _, _, _, err := h.readQoderSettings(ctx, ws)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
bridge, err := qoderruntime.New(qoderruntime.Config{DaemonID: "qoder-managed-" + ws, MulticaURL: serverURL, MulticaToken: cfg.MulticaToken, WorkspaceID: ws, QoderURL: cfg.BaseURL, QoderToken: cfg.QoderToken, EnvironmentID: cfg.EnvironmentID, Name: cfg.Name, MaxConcurrency: cfg.MaxConcurrency, RunTimeout: time.Duration(cfg.RunTimeoutMinutes) * time.Minute, GitHubTokens: cfg.GitHubTokens,
|
|
LoadState: func(loadCtx context.Context) ([]byte, error) {
|
|
var encrypted string
|
|
if err := h.DB.QueryRow(loadCtx, `SELECT journal_encrypted FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1`, ws).Scan(&encrypted); err != nil {
|
|
return nil, err
|
|
}
|
|
if encrypted == "" {
|
|
return nil, nil
|
|
}
|
|
raw, err := h.openVCSSecret(encrypted)
|
|
return []byte(raw), err
|
|
},
|
|
SaveState: func(saveCtx context.Context, raw []byte) error {
|
|
encrypted, err := h.sealVCSSecret(string(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = h.DB.Exec(saveCtx, `UPDATE cloud_agent_connection SET journal_encrypted=$2 WHERE provider='qoder_cloud' AND workspace_id=$1`, ws, encrypted)
|
|
return err
|
|
},
|
|
OnReady: func(readyCtx context.Context, runtimeID string) error {
|
|
return h.qoderReady(readyCtx, ws, rev, runtimeID)
|
|
},
|
|
BeforeStep: func(stepCtx context.Context) (func(), error) {
|
|
return h.acquireQoderStep(stepCtx, ws, rev)
|
|
}}, nil)
|
|
if err != nil {
|
|
_, _ = conn.Exec(ctx, `UPDATE cloud_agent_connection SET status='error',last_error='invalid execution configuration' WHERE provider='qoder_cloud' AND workspace_id=$1`, ws)
|
|
continue
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
runs[ws] = managedQoderRun{rev, cancel, done}
|
|
_, _ = conn.Exec(ctx, `UPDATE cloud_agent_connection SET status='starting',last_error='' WHERE provider='qoder_cloud' AND workspace_id=$1`, ws)
|
|
go func() { done <- bridge.Run(runCtx) }()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
// StopQoderConnection pauses an idle connection without discarding its secrets.
|
|
// Active work must be cancelled through the task API before stopping service.
|
|
func (h *Handler) StopQoderConnection(w http.ResponseWriter, r *http.Request) {
|
|
ws, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, 500, "cannot stop QCA runtime")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
_, err = tx.Exec(r.Context(), `SELECT pg_advisory_xact_lock(hashtextextended($1,19))`, uuidToString(ws))
|
|
if err != nil {
|
|
writeError(w, 500, "cannot lock QCA configuration")
|
|
return
|
|
}
|
|
var busy bool
|
|
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1 AND busy_until>now())`, ws).Scan(&busy); err != nil {
|
|
writeError(w, 500, "cannot check QCA connection")
|
|
return
|
|
}
|
|
if busy {
|
|
writeError(w, 409, "QCA is synchronizing; retry shortly")
|
|
return
|
|
}
|
|
var revokedHash string
|
|
_ = tx.QueryRow(r.Context(), `SELECT d.token_hash FROM daemon_token d JOIN cloud_agent_connection c ON c.token_id=d.id AND c.provider='qoder_cloud' WHERE c.workspace_id=$1`, ws).Scan(&revokedHash)
|
|
var active bool
|
|
err = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM agent_task_queue t JOIN agent_runtime rt ON rt.id=t.runtime_id WHERE rt.workspace_id=$1 AND rt.provider='qoder_cloud' AND t.status IN ('running','dispatched'))`, ws).Scan(&active)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot check active QCA tasks")
|
|
return
|
|
}
|
|
if active {
|
|
writeError(w, 409, "wait for or cancel active QCA tasks before stopping")
|
|
return
|
|
}
|
|
_, err = tx.Exec(r.Context(), `DELETE FROM daemon_token WHERE id IN (SELECT token_id FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$1)`, ws)
|
|
if err != nil {
|
|
writeError(w, 500, "cannot revoke runtime credential")
|
|
return
|
|
}
|
|
_, err = tx.Exec(r.Context(), `UPDATE cloud_agent_connection SET enabled=false,revision=revision+1,status='stopped',last_error='' WHERE provider='qoder_cloud' AND workspace_id=$1`, ws)
|
|
if err != nil || tx.Commit(r.Context()) != nil {
|
|
writeError(w, 500, "cannot stop QCA runtime")
|
|
return
|
|
}
|
|
h.DaemonTokenCache.Invalidate(r.Context(), revokedHash)
|
|
h.GetQoderConnection(w, r)
|
|
}
|
|
|
|
func (h *Handler) qoderReady(ctx context.Context, ws string, revision int64, runtimeID string) error {
|
|
if _, err := h.DB.Exec(ctx, `UPDATE agent_runtime SET visibility='public',owner_id=(SELECT owner_id FROM cloud_agent_connection WHERE provider='qoder_cloud' AND workspace_id=$2) WHERE id=$1 AND workspace_id=$2`, runtimeID, ws); err != nil {
|
|
return fmt.Errorf("cannot publish managed runtime")
|
|
}
|
|
_, err := h.DB.Exec(ctx, `UPDATE cloud_agent_connection SET status='online',last_error='' WHERE provider='qoder_cloud' AND workspace_id=$1 AND revision=$2`, ws, revision)
|
|
return err
|
|
}
|
|
|
|
func (h *Handler) qoderCredentialAllowed(r *http.Request, ws, daemonID string) (bool, error) {
|
|
// Prefer the hash DaemonAuth recorded for the authenticating token: a WS
|
|
// RPC replays the request in-process without an Authorization header
|
|
// (MUL-8154). Fall back to the header for callers outside DaemonAuth.
|
|
tokenHash := middleware.DaemonTokenHashFromContext(r.Context())
|
|
if tokenHash == "" {
|
|
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if raw == "" {
|
|
return false, nil
|
|
}
|
|
tokenHash = auth.HashToken(raw)
|
|
}
|
|
var allowed bool
|
|
err := h.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection c JOIN daemon_token d ON d.id=c.token_id JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND c.workspace_id=$1 AND c.enabled AND d.daemon_id=$2 AND d.token_hash=$3 AND d.expires_at>now() AND m.role IN ('owner','admin'))`, ws, daemonID, tokenHash).Scan(&allowed)
|
|
return allowed, err
|
|
}
|
|
|
|
func (h *Handler) acquireQoderStep(stepCtx context.Context, ws string, rev int64) (func(), error) {
|
|
tx, err := h.TxStarter.Begin(stepCtx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(stepCtx)
|
|
release := func() {
|
|
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_, _ = h.DB.Exec(releaseCtx, `UPDATE cloud_agent_connection SET busy_until=NULL WHERE provider='qoder_cloud' AND workspace_id=$1 AND revision=$2`, ws, rev)
|
|
}
|
|
if _, err = tx.Exec(stepCtx, `SELECT pg_advisory_xact_lock(hashtextextended($1,19))`, ws); err != nil {
|
|
return nil, err
|
|
}
|
|
var valid bool
|
|
err = tx.QueryRow(stepCtx, `SELECT EXISTS(SELECT 1 FROM cloud_agent_connection c JOIN member m ON m.workspace_id=c.workspace_id AND m.user_id=c.owner_id WHERE c.provider='qoder_cloud' AND c.workspace_id=$1 AND c.revision=$2 AND c.enabled=true AND m.role IN ('owner','admin') AND (c.busy_until IS NULL OR c.busy_until<now()))`, ws, rev).Scan(&valid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !valid {
|
|
return nil, context.Canceled
|
|
}
|
|
if _, err := tx.Exec(stepCtx, `UPDATE cloud_agent_connection SET busy_until=now()+interval '45 seconds' WHERE provider='qoder_cloud' AND workspace_id=$1 AND revision=$2`, ws, rev); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(stepCtx); err != nil {
|
|
return nil, err
|
|
}
|
|
return release, nil
|
|
}
|