diff --git a/.design/project-log/2026-06-03-b5-3-chaos-gate.md b/.design/project-log/2026-06-03-b5-3-chaos-gate.md new file mode 100644 index 000000000..f92a9a769 --- /dev/null +++ b/.design/project-log/2026-06-03-b5-3-chaos-gate.md @@ -0,0 +1,31 @@ +# B5-3 Chaos Gate — GB5 PASSED + +**Date:** 2026-06-03 +**Agent:** qa-agent +**Branch:** `postgres/wave-b-integration` @ `62186381` +**Gate:** GB5 (GA gate for broker dispatch) + +## Result: PASS + +All five chaos scenarios completed against two-VM + CloudSQL topology. +Full results at `/scion-volumes/scratchpad/B5-3-CHAOS-GATE-RESULTS.md`. + +| Scenario | Result | +|----------|--------| +| A: Kill owning hub mid-start | **PASS** — Hub B claimed and completed dispatch in 1.3s; no double-execution; `state=done, attempts=0` | +| B: Broker flap A→B | **PARTIAL/PASS** — Co-located topology prevents literal A→B flap; CAS claim, reconcile drain, and reaper all verified via equivalent tests | +| C: Pool saturation during PublishTx | **PASS** — Message dispatched 60ms post-creation despite external pool pressure; no corruption, no orphaned pending rows | +| D: Command-bus listener drop | **PASS** — Reconnected in ~280ms; cross-node dispatch succeeded immediately after | +| E: Reaper correctness | **PASS** — Stuck `in_progress` dispatch re-driven within 1 min of threshold; stale `connected_hub_id` cleared within 1 min of stale window | + +## Key Timing Evidence (Scenario E) + +- Hub killed: 22:48:02; last heartbeat: 22:49:38 +- Dispatch requeued (`in_progress→pending`): 22:50:26 — within 1 min of `dispatchStuckAge` +- Affinity cleared: 22:53:26 — within 1 min of `affinityStaleAge` (180s from last heartbeat) + +## Notes + +- Scenario B limitation: in this deployment brokers are co-located with their hubs (same process). A cross-hub broker reconnect can't be manually induced. The mechanisms that handle it (CAS claim on reconnect, reconcile drain, reaper) were each independently verified. +- `ConnectionMaxIdleTime` fix (from LIVE-RETEST-RESULTS.md §7) not yet implemented. No stall observed during chaos recovery — command-bus reconnect was clean. Recommend as a follow-up hardening item, not a blocker. +- VMs left running `62186381` on Postgres (healthy) after gate. diff --git a/.design/project-log/2026-06-03-cross-replica-signing-key-login-loop.md b/.design/project-log/2026-06-03-cross-replica-signing-key-login-loop.md new file mode 100644 index 000000000..541d88dfc --- /dev/null +++ b/.design/project-log/2026-06-03-cross-replica-signing-key-login-loop.md @@ -0,0 +1,73 @@ +# Fix: cross-replica login loop (`session_expired`) after cookie-store fix + +**Date:** 2026-06-03 +**Branch:** postgres/wave-b-integration +**Symptom:** After OAuth login the dashboard flashes, then the browser is +redirected to `/login?error=session_expired&returnTo=/`, repeatedly. + +## Background + +Commit `0515e2a8` replaced the per-replica gorilla `FilesystemStore` with an +encrypted+signed `CookieStore` whose keys derive from the shared +`SESSION_SECRET`, so the whole web session (OAuth state + Hub JWTs) rides in the +client cookie and any replica can read it. That fixed the OAuth `state_mismatch` +and made the *session container* replica-portable. + +## Root cause (one layer deeper) + +The cookie is portable, but the **Hub JWT inside it is signed with a per-replica +key**. Signing keys are resolved by `ensureSigningKey()` scoped to +`(scope=hub, scope_id=hubID)`, and `hubID = sha256(hostname)[:12]` +(`DefaultHubID`). The integration deployment runs **two replicas of one logical +hub** behind a single LB (`multi.demo.scion-ai.dev`), sharing one Postgres DB +and one `SESSION_SECRET`, but with different hostnames: + +| Replica | hub_id | user_signing_key fp | +|---|---|---| +| scion-integration | `ca39430276ee` | `9a35ae24cfeedba0` | +| scion-integration2 | `9662ebe99da4` | `97d3f30a36554d7a` | + +So each replica minted/validated user JWTs with a *different* HS256 key. When a +post-login request landed on the replica that did **not** mint the token, +`ValidateUserToken` failed (`go-jose: error in cryptographic primitive`), +refresh failed too (the refresh token is signed with the same foreign key), and +`sessionToBearerMiddleware` declared the session "irrecoverably invalid", +**deleted the cookie** (`MaxAge=-1`) and returned `session_expired`. The cookie +deletion is what turns it into a loop. Logs show the same user alternating +between "User authenticated" and "Hub token irrecoverably invalid, clearing +session" depending on which replica served the request. + +## Fix + +Extend the `0515e2a8` philosophy from the cookie to the keys inside it: derive +the agent and user JWT signing keys deterministically from the shared +`SESSION_SECRET`. + +- `ServerConfig.SharedSigningSecret` (new field). +- `ensureSigningKey()`: when `SharedSigningSecret != ""`, return + `deriveSharedSigningKey(secret, keyName)` (domain-separated by key name), + bypassing per-host secret-backend storage. Empty secret → unchanged per-hub + behavior (no regression for single-node/local dev). +- `cmd/server_foreground.go`: new `resolveSessionSecret()` helper feeds the same + value into both the web cookie store and `hubCfg.SharedSigningSecret`. + +Now every replica with the same `SESSION_SECRET` agrees on the signing keys, +regardless of hostname/hubID — no operator coordination (matching HubID) needed. + +## Tests + +`pkg/hub/signing_key_shared_test.go`: +- derivation is deterministic, 32 bytes, domain-separated, secret-sensitive; +- two servers with **different hubID, same secret** derive identical keys and a + token minted on one validates on the other; a different secret cannot; +- an explicit pre-configured key still wins over derivation. + +## Deploy note + +Rolling out the new binary changes the signing keys (they now derive from +`SESSION_SECRET` instead of the stored per-host keys), so existing web sessions +and CLI tokens are invalidated **once** — users log in again, CLI/agents +re-auth. Both replicas already share `SESSION_SECRET`, so no config change is +required. (Faster stopgap without a rebuild: pin the same +`SCION_SERVER_HUB_HUBID` on both VMs to an existing hub ID so they share the +already-stored keys.) diff --git a/.design/project-log/2026-06-05-pr305-review-feedback.md b/.design/project-log/2026-06-05-pr305-review-feedback.md new file mode 100644 index 000000000..73d1195ba --- /dev/null +++ b/.design/project-log/2026-06-05-pr305-review-feedback.md @@ -0,0 +1,32 @@ +# PR #305 Review Feedback — First Fix Round + +**Date:** 2026-06-05 +**PR:** #305 — feat(hub): multi-node broker dispatch +**Branch:** pr/broker-dispatch +**Commit:** c5f8b3c + +## Summary + +Addressed all 6 review comments from gemini-code-assist on PR #305. + +### HIGH Priority Fixes + +1. **server_migrate.go — nil-checked deferred close**: Changed `defer src.Close()` to a nil-checked closure so the source DB can be manually closed and set to nil before `dropSQLiteFile`, preventing Windows sharing violations. + +2. **server_migrate.go — close before drop**: Added explicit `src.Close()` + `src = nil` before the `dropSQLiteFile` call in the `migrateDropSource` path. + +3. **server_foreground.go — stale closure capture**: Moved `mgr := hubSrv.GetControlChannelManager()` inside the `ownsLocally` closure. Previously it was captured once at closure creation time, so if the manager was nil at that point but initialized later, `ownsLocally` would permanently return false. + +### MEDIUM Priority Fixes + +4. **server_migrate.go — file:// prefix handling**: Added a `file://` case before the `file:` case in `parseSQLiteSourceDSN` so that `file:///tmp/hub.db` correctly resolves to `/tmp/hub.db` instead of `//tmp/hub.db`. + +5. **server_migrate_test.go — triple-slash test**: Added a test case verifying `file:///tmp/hub.db` is parsed correctly. + +6. **server_test.go — subtest name sanitization**: Used `strings.ReplaceAll(t.Name(), "/", "_")` in `newTestStore` to prevent SQLite from interpreting subtest slashes as directory paths. + +## Verification + +- `gofmt` clean on all changed files +- `go vet ./cmd/` passes +- All relevant tests pass including the new `file_url_with_triple_slashes` test case diff --git a/.scion/project-id b/.scion/project-id new file mode 100644 index 000000000..59df1969e --- /dev/null +++ b/.scion/project-id @@ -0,0 +1 @@ +c7c7775e-e3a0-43de-9d26-274688d467d0 diff --git a/GLOSSARY.md b/GLOSSARY.md index fe929d28f..d221851d6 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -200,7 +200,7 @@ The three run modes at a glance — distinguish them by whether a server runs an | Mode | Server | Tenancy | State & isolation | Canonical use | |------|--------|---------|-------------------|----------------| | **Local mode** | None | Single user | Local machine; isolation via git worktrees | Agents launched directly via the `scion` CLI, no server | -| **Workstation mode** | Combo server (Hub + Runtime Broker + Web) on loopback | Single-tenant | Local machine; single-tenant state | The hosted experience locally, on your own machine | +| **Workstation mode** | Combo server (Hub + Runtime Broker + Web) on loopback | Single-tenant | That machine | The hosted experience locally, on your own machine | | **Hosted mode** | Multi-user server deployment | Multi-user | Hub-coordinated across brokers | Coordinating state across users, projects, and runtime brokers | **Local mode**: diff --git a/agents.md b/agents.md index 505c232ea..195f215db 100644 --- a/agents.md +++ b/agents.md @@ -86,6 +86,13 @@ All icons in the web frontend use the Shoelace `` component (Bootstrap > **Canonical engineering glossary:** See [`GLOSSARY.md`](./GLOSSARY.md) at the repo root for the canonical, opinionated terminology used throughout the codebase — the preferred term for each concept and the synonyms to avoid. Prefer these terms in new code, comments, and docs. +These terms may be used in shorthand with prompts + +- **hub-broker, combo server** References running the server command with both the hub function and the broker function running in the same invocation. +- **hub-native, hub-project** A special variant of a project/project space, that is created on a hub server for use by agents dispatched from clients. These live in ~/.scion/projects/ on any broker that is a provider to the hub project. This is in contrast to the arbitrary local path on a broker for a linked project. +- **agent-home** The directory that gets mounted as the home folder of the container user in the agent container +- **linked-project** A project and project folder that pre-existed on a broker machine, and is linked as a hub resource project for visibility, metadata, and agent management across other brokers that may have such a linked project. May be based on name or git-URI + ## Project use of the scion cli itself Do not commit changes in the project's own `.scion` folder to git as part of committing progress on code and docs. These are managed and committed manually when template defaults are intentionally updated. diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index 7519261e9..822d99a98 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -230,6 +230,10 @@ func runServerStart(cmd *cobra.Command, args []string) error { log.Fatalf("Hub server failed to start: %v", hubInitErr) } + // Wire command bus for cross-node dispatch (B2-4). + cmdBus := newCommandBus(ctx, cfg, hubSrv) + hubSrv.SetCommandBus(cmdBus) + if !enableWeb { // Hub runs its own HTTP server (standalone mode). eventPub := newEventPublisher(ctx, cfg) @@ -1083,6 +1087,29 @@ func newEventPublisher(ctx context.Context, cfg *config.GlobalConfig) hub.EventP return hub.NewChannelEventPublisher() } +// newCommandBus selects the command bus backend. With Postgres it returns a +// PostgresCommandBus (LISTEN/NOTIFY on scion_broker_cmd); otherwise it returns +// a no-op bus (single-process SQLite always owns all brokers locally). +func newCommandBus(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Server) hub.CommandBus { + if !strings.EqualFold(cfg.Database.Driver, "postgres") { + return hub.NoopCommandBus{} + } + ownsLocally := func(brokerID string) bool { + mgr := hubSrv.GetControlChannelManager() + if mgr == nil { + return false + } + return mgr.IsConnected(brokerID) + } + bus, err := hub.NewPostgresCommandBus(ctx, cfg.Database.URL, ownsLocally, hubSrv.ReconcileBroker, logging.Subsystem("hub.commandbus")) + if err != nil { + log.Printf("WARNING: failed to start Postgres command bus (%v); falling back to no-op. Cross-replica dispatch signals will not work.", err) + return hub.NoopCommandBus{} + } + log.Printf("Using Postgres command bus on channel scion_broker_cmd") + return bus +} + // initWebServer creates and configures the Web server. The provided context is // threaded to the event publisher so that the Postgres LISTEN/NOTIFY goroutine // is cancelled cleanly on shutdown, preventing connection leaks. diff --git a/cmd/server_migrate.go b/cmd/server_migrate.go index 5811a4bf3..02c89c594 100644 --- a/cmd/server_migrate.go +++ b/cmd/server_migrate.go @@ -100,7 +100,11 @@ func runServerMigrate(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("opening source sqlite: %w", err) } - defer src.Close() + defer func() { + if src != nil { + _ = src.Close() + } + }() fmt.Fprintln(out, "Opening destination PostgreSQL") dst, err := entc.OpenPostgres(dstDSN, entc.PoolConfig{MaxOpenConns: 10, MaxIdleConns: 5}) @@ -133,6 +137,8 @@ func runServerMigrate(cmd *cobra.Command, _ []string) error { len(report.Entities), total, report.ChildGroupEdgs) if migrateDropSource { + _ = src.Close() + src = nil fmt.Fprintf(out, "Dropping source SQLite file: %s\n", srcPath) if err := dropSQLiteFile(srcPath); err != nil { return fmt.Errorf("dropping source: %w", err) @@ -164,7 +170,6 @@ func parseSQLiteSourceDSN(raw string) (dsn, path string, err error) { path = strings.TrimPrefix(raw, "sqlite:") case strings.HasPrefix(raw, "file://"): path = strings.TrimPrefix(raw, "file://") - // file:///abs -> "/abs"; the third slash begins the absolute path. if i := strings.IndexByte(path, '?'); i >= 0 { path = path[:i] } diff --git a/cmd/server_migrate_test.go b/cmd/server_migrate_test.go index f94c8eb79..195f099b3 100644 --- a/cmd/server_migrate_test.go +++ b/cmd/server_migrate_test.go @@ -43,26 +43,14 @@ func TestParseSQLiteSourceDSN(t *testing.T) { wantPath: "/tmp/hub.db", }, { - name: "file triple-slash absolute url", - in: "file:///var/lib/scion/hub.db", - wantDSN: "file:/var/lib/scion/hub.db?cache=shared", - wantPath: "/var/lib/scion/hub.db", - }, - { - name: "file double-slash relative url", - in: "file://data/hub.db", - wantDSN: "file:data/hub.db?cache=shared", - wantPath: "data/hub.db", - }, - { - name: "file triple-slash with query", - in: "file:///tmp/hub.db?mode=ro", + name: "file url with query", + in: "file:/tmp/hub.db?cache=shared", wantDSN: "file:/tmp/hub.db?cache=shared", wantPath: "/tmp/hub.db", }, { - name: "file url with query", - in: "file:/tmp/hub.db?cache=shared", + name: "file url with triple slashes", + in: "file:///tmp/hub.db", wantDSN: "file:/tmp/hub.db?cache=shared", wantPath: "/tmp/hub.db", }, diff --git a/cmd/server_test.go b/cmd/server_test.go index cd74fe60d..47bd4184e 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -18,6 +18,7 @@ package cmd import ( "context" + "strings" "testing" "github.com/GoogleCloudPlatform/scion/pkg/config" @@ -30,7 +31,8 @@ import ( func newTestStore(t *testing.T) store.Store { t.Helper() - client, err := entc.OpenSQLite("file:"+t.Name()+"?mode=memory&cache=shared", entc.PoolConfig{}) + dbName := strings.ReplaceAll(t.Name(), "/", "_") + client, err := entc.OpenSQLite("file:"+dbName+"?mode=memory&cache=shared", entc.PoolConfig{}) require.NoError(t, err) require.NoError(t, entc.AutoMigrate(context.Background(), client)) s := entadapter.NewCompositeStore(client) diff --git a/pkg/ent/brokerdispatch.go b/pkg/ent/brokerdispatch.go new file mode 100644 index 000000000..5fe957779 --- /dev/null +++ b/pkg/ent/brokerdispatch.go @@ -0,0 +1,263 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" + "github.com/google/uuid" +) + +// BrokerDispatch is the model entity for the BrokerDispatch schema. +type BrokerDispatch struct { + config `json:"-"` + // ID of the ent. + ID uuid.UUID `json:"id,omitempty"` + // BrokerID holds the value of the "broker_id" field. + BrokerID uuid.UUID `json:"broker_id,omitempty"` + // AgentID holds the value of the "agent_id" field. + AgentID *uuid.UUID `json:"agent_id,omitempty"` + // AgentSlug holds the value of the "agent_slug" field. + AgentSlug string `json:"agent_slug,omitempty"` + // ProjectID holds the value of the "project_id" field. + ProjectID *uuid.UUID `json:"project_id,omitempty"` + // Op holds the value of the "op" field. + Op string `json:"op,omitempty"` + // Args holds the value of the "args" field. + Args string `json:"args,omitempty"` + // State holds the value of the "state" field. + State string `json:"state,omitempty"` + // Result holds the value of the "result" field. + Result string `json:"result,omitempty"` + // ClaimedBy holds the value of the "claimed_by" field. + ClaimedBy string `json:"claimed_by,omitempty"` + // Attempts holds the value of the "attempts" field. + Attempts int `json:"attempts,omitempty"` + // Error holds the value of the "error" field. + Error string `json:"error,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // UpdatedAt holds the value of the "updated_at" field. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // DeadlineAt holds the value of the "deadline_at" field. + DeadlineAt *time.Time `json:"deadline_at,omitempty"` + selectValues sql.SelectValues +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*BrokerDispatch) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case brokerdispatch.FieldAgentID, brokerdispatch.FieldProjectID: + values[i] = &sql.NullScanner{S: new(uuid.UUID)} + case brokerdispatch.FieldAttempts: + values[i] = new(sql.NullInt64) + case brokerdispatch.FieldAgentSlug, brokerdispatch.FieldOp, brokerdispatch.FieldArgs, brokerdispatch.FieldState, brokerdispatch.FieldResult, brokerdispatch.FieldClaimedBy, brokerdispatch.FieldError: + values[i] = new(sql.NullString) + case brokerdispatch.FieldCreatedAt, brokerdispatch.FieldUpdatedAt, brokerdispatch.FieldDeadlineAt: + values[i] = new(sql.NullTime) + case brokerdispatch.FieldID, brokerdispatch.FieldBrokerID: + values[i] = new(uuid.UUID) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the BrokerDispatch fields. +func (_m *BrokerDispatch) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case brokerdispatch.FieldID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field id", values[i]) + } else if value != nil { + _m.ID = *value + } + case brokerdispatch.FieldBrokerID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field broker_id", values[i]) + } else if value != nil { + _m.BrokerID = *value + } + case brokerdispatch.FieldAgentID: + if value, ok := values[i].(*sql.NullScanner); !ok { + return fmt.Errorf("unexpected type %T for field agent_id", values[i]) + } else if value.Valid { + _m.AgentID = new(uuid.UUID) + *_m.AgentID = *value.S.(*uuid.UUID) + } + case brokerdispatch.FieldAgentSlug: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field agent_slug", values[i]) + } else if value.Valid { + _m.AgentSlug = value.String + } + case brokerdispatch.FieldProjectID: + if value, ok := values[i].(*sql.NullScanner); !ok { + return fmt.Errorf("unexpected type %T for field project_id", values[i]) + } else if value.Valid { + _m.ProjectID = new(uuid.UUID) + *_m.ProjectID = *value.S.(*uuid.UUID) + } + case brokerdispatch.FieldOp: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field op", values[i]) + } else if value.Valid { + _m.Op = value.String + } + case brokerdispatch.FieldArgs: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field args", values[i]) + } else if value.Valid { + _m.Args = value.String + } + case brokerdispatch.FieldState: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field state", values[i]) + } else if value.Valid { + _m.State = value.String + } + case brokerdispatch.FieldResult: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field result", values[i]) + } else if value.Valid { + _m.Result = value.String + } + case brokerdispatch.FieldClaimedBy: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field claimed_by", values[i]) + } else if value.Valid { + _m.ClaimedBy = value.String + } + case brokerdispatch.FieldAttempts: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field attempts", values[i]) + } else if value.Valid { + _m.Attempts = int(value.Int64) + } + case brokerdispatch.FieldError: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field error", values[i]) + } else if value.Valid { + _m.Error = value.String + } + case brokerdispatch.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case brokerdispatch.FieldUpdatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field updated_at", values[i]) + } else if value.Valid { + _m.UpdatedAt = value.Time + } + case brokerdispatch.FieldDeadlineAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field deadline_at", values[i]) + } else if value.Valid { + _m.DeadlineAt = new(time.Time) + *_m.DeadlineAt = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the BrokerDispatch. +// This includes values selected through modifiers, order, etc. +func (_m *BrokerDispatch) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// Update returns a builder for updating this BrokerDispatch. +// Note that you need to call BrokerDispatch.Unwrap() before calling this method if this BrokerDispatch +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *BrokerDispatch) Update() *BrokerDispatchUpdateOne { + return NewBrokerDispatchClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the BrokerDispatch entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *BrokerDispatch) Unwrap() *BrokerDispatch { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: BrokerDispatch is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *BrokerDispatch) String() string { + var builder strings.Builder + builder.WriteString("BrokerDispatch(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("broker_id=") + builder.WriteString(fmt.Sprintf("%v", _m.BrokerID)) + builder.WriteString(", ") + if v := _m.AgentID; v != nil { + builder.WriteString("agent_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + builder.WriteString("agent_slug=") + builder.WriteString(_m.AgentSlug) + builder.WriteString(", ") + if v := _m.ProjectID; v != nil { + builder.WriteString("project_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + builder.WriteString("op=") + builder.WriteString(_m.Op) + builder.WriteString(", ") + builder.WriteString("args=") + builder.WriteString(_m.Args) + builder.WriteString(", ") + builder.WriteString("state=") + builder.WriteString(_m.State) + builder.WriteString(", ") + builder.WriteString("result=") + builder.WriteString(_m.Result) + builder.WriteString(", ") + builder.WriteString("claimed_by=") + builder.WriteString(_m.ClaimedBy) + builder.WriteString(", ") + builder.WriteString("attempts=") + builder.WriteString(fmt.Sprintf("%v", _m.Attempts)) + builder.WriteString(", ") + builder.WriteString("error=") + builder.WriteString(_m.Error) + builder.WriteString(", ") + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("updated_at=") + builder.WriteString(_m.UpdatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + if v := _m.DeadlineAt; v != nil { + builder.WriteString("deadline_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteByte(')') + return builder.String() +} + +// BrokerDispatches is a parsable slice of BrokerDispatch. +type BrokerDispatches []*BrokerDispatch diff --git a/pkg/ent/brokerdispatch/brokerdispatch.go b/pkg/ent/brokerdispatch/brokerdispatch.go new file mode 100644 index 000000000..a7968ca0f --- /dev/null +++ b/pkg/ent/brokerdispatch/brokerdispatch.go @@ -0,0 +1,171 @@ +// Code generated by ent, DO NOT EDIT. + +package brokerdispatch + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "github.com/google/uuid" +) + +const ( + // Label holds the string label denoting the brokerdispatch type in the database. + Label = "broker_dispatch" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldBrokerID holds the string denoting the broker_id field in the database. + FieldBrokerID = "broker_id" + // FieldAgentID holds the string denoting the agent_id field in the database. + FieldAgentID = "agent_id" + // FieldAgentSlug holds the string denoting the agent_slug field in the database. + FieldAgentSlug = "agent_slug" + // FieldProjectID holds the string denoting the project_id field in the database. + FieldProjectID = "project_id" + // FieldOp holds the string denoting the op field in the database. + FieldOp = "op" + // FieldArgs holds the string denoting the args field in the database. + FieldArgs = "args" + // FieldState holds the string denoting the state field in the database. + FieldState = "state" + // FieldResult holds the string denoting the result field in the database. + FieldResult = "result" + // FieldClaimedBy holds the string denoting the claimed_by field in the database. + FieldClaimedBy = "claimed_by" + // FieldAttempts holds the string denoting the attempts field in the database. + FieldAttempts = "attempts" + // FieldError holds the string denoting the error field in the database. + FieldError = "error" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldUpdatedAt holds the string denoting the updated_at field in the database. + FieldUpdatedAt = "updated_at" + // FieldDeadlineAt holds the string denoting the deadline_at field in the database. + FieldDeadlineAt = "deadline_at" + // Table holds the table name of the brokerdispatch in the database. + Table = "broker_dispatch" +) + +// Columns holds all SQL columns for brokerdispatch fields. +var Columns = []string{ + FieldID, + FieldBrokerID, + FieldAgentID, + FieldAgentSlug, + FieldProjectID, + FieldOp, + FieldArgs, + FieldState, + FieldResult, + FieldClaimedBy, + FieldAttempts, + FieldError, + FieldCreatedAt, + FieldUpdatedAt, + FieldDeadlineAt, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // OpValidator is a validator for the "op" field. It is called by the builders before save. + OpValidator func(string) error + // DefaultState holds the default value on creation for the "state" field. + DefaultState string + // DefaultAttempts holds the default value on creation for the "attempts" field. + DefaultAttempts int + // DefaultCreatedAt holds the default value on creation for the "created_at" field. + DefaultCreatedAt func() time.Time + // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. + DefaultUpdatedAt func() time.Time + // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. + UpdateDefaultUpdatedAt func() time.Time + // DefaultID holds the default value on creation for the "id" field. + DefaultID func() uuid.UUID +) + +// OrderOption defines the ordering options for the BrokerDispatch queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByBrokerID orders the results by the broker_id field. +func ByBrokerID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBrokerID, opts...).ToFunc() +} + +// ByAgentID orders the results by the agent_id field. +func ByAgentID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAgentID, opts...).ToFunc() +} + +// ByAgentSlug orders the results by the agent_slug field. +func ByAgentSlug(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAgentSlug, opts...).ToFunc() +} + +// ByProjectID orders the results by the project_id field. +func ByProjectID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProjectID, opts...).ToFunc() +} + +// ByOp orders the results by the op field. +func ByOp(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldOp, opts...).ToFunc() +} + +// ByArgs orders the results by the args field. +func ByArgs(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldArgs, opts...).ToFunc() +} + +// ByState orders the results by the state field. +func ByState(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldState, opts...).ToFunc() +} + +// ByResult orders the results by the result field. +func ByResult(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldResult, opts...).ToFunc() +} + +// ByClaimedBy orders the results by the claimed_by field. +func ByClaimedBy(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClaimedBy, opts...).ToFunc() +} + +// ByAttempts orders the results by the attempts field. +func ByAttempts(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAttempts, opts...).ToFunc() +} + +// ByError orders the results by the error field. +func ByError(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldError, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByUpdatedAt orders the results by the updated_at field. +func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc() +} + +// ByDeadlineAt orders the results by the deadline_at field. +func ByDeadlineAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDeadlineAt, opts...).ToFunc() +} diff --git a/pkg/ent/brokerdispatch/where.go b/pkg/ent/brokerdispatch/where.go new file mode 100644 index 000000000..459128180 --- /dev/null +++ b/pkg/ent/brokerdispatch/where.go @@ -0,0 +1,956 @@ +// Code generated by ent, DO NOT EDIT. + +package brokerdispatch + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" + "github.com/google/uuid" +) + +// ID filters vertices based on their ID field. +func ID(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldID, id)) +} + +// BrokerID applies equality check predicate on the "broker_id" field. It's identical to BrokerIDEQ. +func BrokerID(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldBrokerID, v)) +} + +// AgentID applies equality check predicate on the "agent_id" field. It's identical to AgentIDEQ. +func AgentID(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldAgentID, v)) +} + +// AgentSlug applies equality check predicate on the "agent_slug" field. It's identical to AgentSlugEQ. +func AgentSlug(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldAgentSlug, v)) +} + +// ProjectID applies equality check predicate on the "project_id" field. It's identical to ProjectIDEQ. +func ProjectID(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldProjectID, v)) +} + +// Op applies equality check predicate on the "op" field. It's identical to OpEQ. +func Op(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldOp, v)) +} + +// Args applies equality check predicate on the "args" field. It's identical to ArgsEQ. +func Args(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldArgs, v)) +} + +// State applies equality check predicate on the "state" field. It's identical to StateEQ. +func State(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldState, v)) +} + +// Result applies equality check predicate on the "result" field. It's identical to ResultEQ. +func Result(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldResult, v)) +} + +// ClaimedBy applies equality check predicate on the "claimed_by" field. It's identical to ClaimedByEQ. +func ClaimedBy(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldClaimedBy, v)) +} + +// Attempts applies equality check predicate on the "attempts" field. It's identical to AttemptsEQ. +func Attempts(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldAttempts, v)) +} + +// Error applies equality check predicate on the "error" field. It's identical to ErrorEQ. +func Error(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldError, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldCreatedAt, v)) +} + +// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ. +func UpdatedAt(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// DeadlineAt applies equality check predicate on the "deadline_at" field. It's identical to DeadlineAtEQ. +func DeadlineAt(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldDeadlineAt, v)) +} + +// BrokerIDEQ applies the EQ predicate on the "broker_id" field. +func BrokerIDEQ(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldBrokerID, v)) +} + +// BrokerIDNEQ applies the NEQ predicate on the "broker_id" field. +func BrokerIDNEQ(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldBrokerID, v)) +} + +// BrokerIDIn applies the In predicate on the "broker_id" field. +func BrokerIDIn(vs ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldBrokerID, vs...)) +} + +// BrokerIDNotIn applies the NotIn predicate on the "broker_id" field. +func BrokerIDNotIn(vs ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldBrokerID, vs...)) +} + +// BrokerIDGT applies the GT predicate on the "broker_id" field. +func BrokerIDGT(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldBrokerID, v)) +} + +// BrokerIDGTE applies the GTE predicate on the "broker_id" field. +func BrokerIDGTE(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldBrokerID, v)) +} + +// BrokerIDLT applies the LT predicate on the "broker_id" field. +func BrokerIDLT(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldBrokerID, v)) +} + +// BrokerIDLTE applies the LTE predicate on the "broker_id" field. +func BrokerIDLTE(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldBrokerID, v)) +} + +// AgentIDEQ applies the EQ predicate on the "agent_id" field. +func AgentIDEQ(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldAgentID, v)) +} + +// AgentIDNEQ applies the NEQ predicate on the "agent_id" field. +func AgentIDNEQ(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldAgentID, v)) +} + +// AgentIDIn applies the In predicate on the "agent_id" field. +func AgentIDIn(vs ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldAgentID, vs...)) +} + +// AgentIDNotIn applies the NotIn predicate on the "agent_id" field. +func AgentIDNotIn(vs ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldAgentID, vs...)) +} + +// AgentIDGT applies the GT predicate on the "agent_id" field. +func AgentIDGT(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldAgentID, v)) +} + +// AgentIDGTE applies the GTE predicate on the "agent_id" field. +func AgentIDGTE(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldAgentID, v)) +} + +// AgentIDLT applies the LT predicate on the "agent_id" field. +func AgentIDLT(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldAgentID, v)) +} + +// AgentIDLTE applies the LTE predicate on the "agent_id" field. +func AgentIDLTE(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldAgentID, v)) +} + +// AgentIDIsNil applies the IsNil predicate on the "agent_id" field. +func AgentIDIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldAgentID)) +} + +// AgentIDNotNil applies the NotNil predicate on the "agent_id" field. +func AgentIDNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldAgentID)) +} + +// AgentSlugEQ applies the EQ predicate on the "agent_slug" field. +func AgentSlugEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldAgentSlug, v)) +} + +// AgentSlugNEQ applies the NEQ predicate on the "agent_slug" field. +func AgentSlugNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldAgentSlug, v)) +} + +// AgentSlugIn applies the In predicate on the "agent_slug" field. +func AgentSlugIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldAgentSlug, vs...)) +} + +// AgentSlugNotIn applies the NotIn predicate on the "agent_slug" field. +func AgentSlugNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldAgentSlug, vs...)) +} + +// AgentSlugGT applies the GT predicate on the "agent_slug" field. +func AgentSlugGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldAgentSlug, v)) +} + +// AgentSlugGTE applies the GTE predicate on the "agent_slug" field. +func AgentSlugGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldAgentSlug, v)) +} + +// AgentSlugLT applies the LT predicate on the "agent_slug" field. +func AgentSlugLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldAgentSlug, v)) +} + +// AgentSlugLTE applies the LTE predicate on the "agent_slug" field. +func AgentSlugLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldAgentSlug, v)) +} + +// AgentSlugContains applies the Contains predicate on the "agent_slug" field. +func AgentSlugContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldAgentSlug, v)) +} + +// AgentSlugHasPrefix applies the HasPrefix predicate on the "agent_slug" field. +func AgentSlugHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldAgentSlug, v)) +} + +// AgentSlugHasSuffix applies the HasSuffix predicate on the "agent_slug" field. +func AgentSlugHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldAgentSlug, v)) +} + +// AgentSlugIsNil applies the IsNil predicate on the "agent_slug" field. +func AgentSlugIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldAgentSlug)) +} + +// AgentSlugNotNil applies the NotNil predicate on the "agent_slug" field. +func AgentSlugNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldAgentSlug)) +} + +// AgentSlugEqualFold applies the EqualFold predicate on the "agent_slug" field. +func AgentSlugEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldAgentSlug, v)) +} + +// AgentSlugContainsFold applies the ContainsFold predicate on the "agent_slug" field. +func AgentSlugContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldAgentSlug, v)) +} + +// ProjectIDEQ applies the EQ predicate on the "project_id" field. +func ProjectIDEQ(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldProjectID, v)) +} + +// ProjectIDNEQ applies the NEQ predicate on the "project_id" field. +func ProjectIDNEQ(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldProjectID, v)) +} + +// ProjectIDIn applies the In predicate on the "project_id" field. +func ProjectIDIn(vs ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldProjectID, vs...)) +} + +// ProjectIDNotIn applies the NotIn predicate on the "project_id" field. +func ProjectIDNotIn(vs ...uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldProjectID, vs...)) +} + +// ProjectIDGT applies the GT predicate on the "project_id" field. +func ProjectIDGT(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldProjectID, v)) +} + +// ProjectIDGTE applies the GTE predicate on the "project_id" field. +func ProjectIDGTE(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldProjectID, v)) +} + +// ProjectIDLT applies the LT predicate on the "project_id" field. +func ProjectIDLT(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldProjectID, v)) +} + +// ProjectIDLTE applies the LTE predicate on the "project_id" field. +func ProjectIDLTE(v uuid.UUID) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldProjectID, v)) +} + +// ProjectIDIsNil applies the IsNil predicate on the "project_id" field. +func ProjectIDIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldProjectID)) +} + +// ProjectIDNotNil applies the NotNil predicate on the "project_id" field. +func ProjectIDNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldProjectID)) +} + +// OpEQ applies the EQ predicate on the "op" field. +func OpEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldOp, v)) +} + +// OpNEQ applies the NEQ predicate on the "op" field. +func OpNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldOp, v)) +} + +// OpIn applies the In predicate on the "op" field. +func OpIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldOp, vs...)) +} + +// OpNotIn applies the NotIn predicate on the "op" field. +func OpNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldOp, vs...)) +} + +// OpGT applies the GT predicate on the "op" field. +func OpGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldOp, v)) +} + +// OpGTE applies the GTE predicate on the "op" field. +func OpGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldOp, v)) +} + +// OpLT applies the LT predicate on the "op" field. +func OpLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldOp, v)) +} + +// OpLTE applies the LTE predicate on the "op" field. +func OpLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldOp, v)) +} + +// OpContains applies the Contains predicate on the "op" field. +func OpContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldOp, v)) +} + +// OpHasPrefix applies the HasPrefix predicate on the "op" field. +func OpHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldOp, v)) +} + +// OpHasSuffix applies the HasSuffix predicate on the "op" field. +func OpHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldOp, v)) +} + +// OpEqualFold applies the EqualFold predicate on the "op" field. +func OpEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldOp, v)) +} + +// OpContainsFold applies the ContainsFold predicate on the "op" field. +func OpContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldOp, v)) +} + +// ArgsEQ applies the EQ predicate on the "args" field. +func ArgsEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldArgs, v)) +} + +// ArgsNEQ applies the NEQ predicate on the "args" field. +func ArgsNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldArgs, v)) +} + +// ArgsIn applies the In predicate on the "args" field. +func ArgsIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldArgs, vs...)) +} + +// ArgsNotIn applies the NotIn predicate on the "args" field. +func ArgsNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldArgs, vs...)) +} + +// ArgsGT applies the GT predicate on the "args" field. +func ArgsGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldArgs, v)) +} + +// ArgsGTE applies the GTE predicate on the "args" field. +func ArgsGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldArgs, v)) +} + +// ArgsLT applies the LT predicate on the "args" field. +func ArgsLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldArgs, v)) +} + +// ArgsLTE applies the LTE predicate on the "args" field. +func ArgsLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldArgs, v)) +} + +// ArgsContains applies the Contains predicate on the "args" field. +func ArgsContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldArgs, v)) +} + +// ArgsHasPrefix applies the HasPrefix predicate on the "args" field. +func ArgsHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldArgs, v)) +} + +// ArgsHasSuffix applies the HasSuffix predicate on the "args" field. +func ArgsHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldArgs, v)) +} + +// ArgsIsNil applies the IsNil predicate on the "args" field. +func ArgsIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldArgs)) +} + +// ArgsNotNil applies the NotNil predicate on the "args" field. +func ArgsNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldArgs)) +} + +// ArgsEqualFold applies the EqualFold predicate on the "args" field. +func ArgsEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldArgs, v)) +} + +// ArgsContainsFold applies the ContainsFold predicate on the "args" field. +func ArgsContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldArgs, v)) +} + +// StateEQ applies the EQ predicate on the "state" field. +func StateEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldState, v)) +} + +// StateNEQ applies the NEQ predicate on the "state" field. +func StateNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldState, v)) +} + +// StateIn applies the In predicate on the "state" field. +func StateIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldState, vs...)) +} + +// StateNotIn applies the NotIn predicate on the "state" field. +func StateNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldState, vs...)) +} + +// StateGT applies the GT predicate on the "state" field. +func StateGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldState, v)) +} + +// StateGTE applies the GTE predicate on the "state" field. +func StateGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldState, v)) +} + +// StateLT applies the LT predicate on the "state" field. +func StateLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldState, v)) +} + +// StateLTE applies the LTE predicate on the "state" field. +func StateLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldState, v)) +} + +// StateContains applies the Contains predicate on the "state" field. +func StateContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldState, v)) +} + +// StateHasPrefix applies the HasPrefix predicate on the "state" field. +func StateHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldState, v)) +} + +// StateHasSuffix applies the HasSuffix predicate on the "state" field. +func StateHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldState, v)) +} + +// StateEqualFold applies the EqualFold predicate on the "state" field. +func StateEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldState, v)) +} + +// StateContainsFold applies the ContainsFold predicate on the "state" field. +func StateContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldState, v)) +} + +// ResultEQ applies the EQ predicate on the "result" field. +func ResultEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldResult, v)) +} + +// ResultNEQ applies the NEQ predicate on the "result" field. +func ResultNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldResult, v)) +} + +// ResultIn applies the In predicate on the "result" field. +func ResultIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldResult, vs...)) +} + +// ResultNotIn applies the NotIn predicate on the "result" field. +func ResultNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldResult, vs...)) +} + +// ResultGT applies the GT predicate on the "result" field. +func ResultGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldResult, v)) +} + +// ResultGTE applies the GTE predicate on the "result" field. +func ResultGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldResult, v)) +} + +// ResultLT applies the LT predicate on the "result" field. +func ResultLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldResult, v)) +} + +// ResultLTE applies the LTE predicate on the "result" field. +func ResultLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldResult, v)) +} + +// ResultContains applies the Contains predicate on the "result" field. +func ResultContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldResult, v)) +} + +// ResultHasPrefix applies the HasPrefix predicate on the "result" field. +func ResultHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldResult, v)) +} + +// ResultHasSuffix applies the HasSuffix predicate on the "result" field. +func ResultHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldResult, v)) +} + +// ResultIsNil applies the IsNil predicate on the "result" field. +func ResultIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldResult)) +} + +// ResultNotNil applies the NotNil predicate on the "result" field. +func ResultNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldResult)) +} + +// ResultEqualFold applies the EqualFold predicate on the "result" field. +func ResultEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldResult, v)) +} + +// ResultContainsFold applies the ContainsFold predicate on the "result" field. +func ResultContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldResult, v)) +} + +// ClaimedByEQ applies the EQ predicate on the "claimed_by" field. +func ClaimedByEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldClaimedBy, v)) +} + +// ClaimedByNEQ applies the NEQ predicate on the "claimed_by" field. +func ClaimedByNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldClaimedBy, v)) +} + +// ClaimedByIn applies the In predicate on the "claimed_by" field. +func ClaimedByIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldClaimedBy, vs...)) +} + +// ClaimedByNotIn applies the NotIn predicate on the "claimed_by" field. +func ClaimedByNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldClaimedBy, vs...)) +} + +// ClaimedByGT applies the GT predicate on the "claimed_by" field. +func ClaimedByGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldClaimedBy, v)) +} + +// ClaimedByGTE applies the GTE predicate on the "claimed_by" field. +func ClaimedByGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldClaimedBy, v)) +} + +// ClaimedByLT applies the LT predicate on the "claimed_by" field. +func ClaimedByLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldClaimedBy, v)) +} + +// ClaimedByLTE applies the LTE predicate on the "claimed_by" field. +func ClaimedByLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldClaimedBy, v)) +} + +// ClaimedByContains applies the Contains predicate on the "claimed_by" field. +func ClaimedByContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldClaimedBy, v)) +} + +// ClaimedByHasPrefix applies the HasPrefix predicate on the "claimed_by" field. +func ClaimedByHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldClaimedBy, v)) +} + +// ClaimedByHasSuffix applies the HasSuffix predicate on the "claimed_by" field. +func ClaimedByHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldClaimedBy, v)) +} + +// ClaimedByIsNil applies the IsNil predicate on the "claimed_by" field. +func ClaimedByIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldClaimedBy)) +} + +// ClaimedByNotNil applies the NotNil predicate on the "claimed_by" field. +func ClaimedByNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldClaimedBy)) +} + +// ClaimedByEqualFold applies the EqualFold predicate on the "claimed_by" field. +func ClaimedByEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldClaimedBy, v)) +} + +// ClaimedByContainsFold applies the ContainsFold predicate on the "claimed_by" field. +func ClaimedByContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldClaimedBy, v)) +} + +// AttemptsEQ applies the EQ predicate on the "attempts" field. +func AttemptsEQ(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldAttempts, v)) +} + +// AttemptsNEQ applies the NEQ predicate on the "attempts" field. +func AttemptsNEQ(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldAttempts, v)) +} + +// AttemptsIn applies the In predicate on the "attempts" field. +func AttemptsIn(vs ...int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldAttempts, vs...)) +} + +// AttemptsNotIn applies the NotIn predicate on the "attempts" field. +func AttemptsNotIn(vs ...int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldAttempts, vs...)) +} + +// AttemptsGT applies the GT predicate on the "attempts" field. +func AttemptsGT(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldAttempts, v)) +} + +// AttemptsGTE applies the GTE predicate on the "attempts" field. +func AttemptsGTE(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldAttempts, v)) +} + +// AttemptsLT applies the LT predicate on the "attempts" field. +func AttemptsLT(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldAttempts, v)) +} + +// AttemptsLTE applies the LTE predicate on the "attempts" field. +func AttemptsLTE(v int) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldAttempts, v)) +} + +// ErrorEQ applies the EQ predicate on the "error" field. +func ErrorEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldError, v)) +} + +// ErrorNEQ applies the NEQ predicate on the "error" field. +func ErrorNEQ(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldError, v)) +} + +// ErrorIn applies the In predicate on the "error" field. +func ErrorIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldError, vs...)) +} + +// ErrorNotIn applies the NotIn predicate on the "error" field. +func ErrorNotIn(vs ...string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldError, vs...)) +} + +// ErrorGT applies the GT predicate on the "error" field. +func ErrorGT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldError, v)) +} + +// ErrorGTE applies the GTE predicate on the "error" field. +func ErrorGTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldError, v)) +} + +// ErrorLT applies the LT predicate on the "error" field. +func ErrorLT(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldError, v)) +} + +// ErrorLTE applies the LTE predicate on the "error" field. +func ErrorLTE(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldError, v)) +} + +// ErrorContains applies the Contains predicate on the "error" field. +func ErrorContains(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContains(FieldError, v)) +} + +// ErrorHasPrefix applies the HasPrefix predicate on the "error" field. +func ErrorHasPrefix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasPrefix(FieldError, v)) +} + +// ErrorHasSuffix applies the HasSuffix predicate on the "error" field. +func ErrorHasSuffix(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldHasSuffix(FieldError, v)) +} + +// ErrorIsNil applies the IsNil predicate on the "error" field. +func ErrorIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldError)) +} + +// ErrorNotNil applies the NotNil predicate on the "error" field. +func ErrorNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldError)) +} + +// ErrorEqualFold applies the EqualFold predicate on the "error" field. +func ErrorEqualFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEqualFold(FieldError, v)) +} + +// ErrorContainsFold applies the ContainsFold predicate on the "error" field. +func ErrorContainsFold(v string) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldContainsFold(FieldError, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldCreatedAt, v)) +} + +// UpdatedAtEQ applies the EQ predicate on the "updated_at" field. +func UpdatedAtEQ(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. +func UpdatedAtNEQ(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtIn applies the In predicate on the "updated_at" field. +func UpdatedAtIn(vs ...time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. +func UpdatedAtNotIn(vs ...time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtGT applies the GT predicate on the "updated_at" field. +func UpdatedAtGT(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldUpdatedAt, v)) +} + +// UpdatedAtGTE applies the GTE predicate on the "updated_at" field. +func UpdatedAtGTE(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldUpdatedAt, v)) +} + +// UpdatedAtLT applies the LT predicate on the "updated_at" field. +func UpdatedAtLT(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldUpdatedAt, v)) +} + +// UpdatedAtLTE applies the LTE predicate on the "updated_at" field. +func UpdatedAtLTE(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldUpdatedAt, v)) +} + +// DeadlineAtEQ applies the EQ predicate on the "deadline_at" field. +func DeadlineAtEQ(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldEQ(FieldDeadlineAt, v)) +} + +// DeadlineAtNEQ applies the NEQ predicate on the "deadline_at" field. +func DeadlineAtNEQ(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNEQ(FieldDeadlineAt, v)) +} + +// DeadlineAtIn applies the In predicate on the "deadline_at" field. +func DeadlineAtIn(vs ...time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIn(FieldDeadlineAt, vs...)) +} + +// DeadlineAtNotIn applies the NotIn predicate on the "deadline_at" field. +func DeadlineAtNotIn(vs ...time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotIn(FieldDeadlineAt, vs...)) +} + +// DeadlineAtGT applies the GT predicate on the "deadline_at" field. +func DeadlineAtGT(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGT(FieldDeadlineAt, v)) +} + +// DeadlineAtGTE applies the GTE predicate on the "deadline_at" field. +func DeadlineAtGTE(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldGTE(FieldDeadlineAt, v)) +} + +// DeadlineAtLT applies the LT predicate on the "deadline_at" field. +func DeadlineAtLT(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLT(FieldDeadlineAt, v)) +} + +// DeadlineAtLTE applies the LTE predicate on the "deadline_at" field. +func DeadlineAtLTE(v time.Time) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldLTE(FieldDeadlineAt, v)) +} + +// DeadlineAtIsNil applies the IsNil predicate on the "deadline_at" field. +func DeadlineAtIsNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldIsNull(FieldDeadlineAt)) +} + +// DeadlineAtNotNil applies the NotNil predicate on the "deadline_at" field. +func DeadlineAtNotNil() predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.FieldNotNull(FieldDeadlineAt)) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.BrokerDispatch) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.BrokerDispatch) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.BrokerDispatch) predicate.BrokerDispatch { + return predicate.BrokerDispatch(sql.NotPredicates(p)) +} diff --git a/pkg/ent/brokerdispatch_create.go b/pkg/ent/brokerdispatch_create.go new file mode 100644 index 000000000..599839130 --- /dev/null +++ b/pkg/ent/brokerdispatch_create.go @@ -0,0 +1,1437 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" + "github.com/google/uuid" +) + +// BrokerDispatchCreate is the builder for creating a BrokerDispatch entity. +type BrokerDispatchCreate struct { + config + mutation *BrokerDispatchMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetBrokerID sets the "broker_id" field. +func (_c *BrokerDispatchCreate) SetBrokerID(v uuid.UUID) *BrokerDispatchCreate { + _c.mutation.SetBrokerID(v) + return _c +} + +// SetAgentID sets the "agent_id" field. +func (_c *BrokerDispatchCreate) SetAgentID(v uuid.UUID) *BrokerDispatchCreate { + _c.mutation.SetAgentID(v) + return _c +} + +// SetNillableAgentID sets the "agent_id" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableAgentID(v *uuid.UUID) *BrokerDispatchCreate { + if v != nil { + _c.SetAgentID(*v) + } + return _c +} + +// SetAgentSlug sets the "agent_slug" field. +func (_c *BrokerDispatchCreate) SetAgentSlug(v string) *BrokerDispatchCreate { + _c.mutation.SetAgentSlug(v) + return _c +} + +// SetNillableAgentSlug sets the "agent_slug" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableAgentSlug(v *string) *BrokerDispatchCreate { + if v != nil { + _c.SetAgentSlug(*v) + } + return _c +} + +// SetProjectID sets the "project_id" field. +func (_c *BrokerDispatchCreate) SetProjectID(v uuid.UUID) *BrokerDispatchCreate { + _c.mutation.SetProjectID(v) + return _c +} + +// SetNillableProjectID sets the "project_id" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableProjectID(v *uuid.UUID) *BrokerDispatchCreate { + if v != nil { + _c.SetProjectID(*v) + } + return _c +} + +// SetOp sets the "op" field. +func (_c *BrokerDispatchCreate) SetOp(v string) *BrokerDispatchCreate { + _c.mutation.SetOpField(v) + return _c +} + +// SetArgs sets the "args" field. +func (_c *BrokerDispatchCreate) SetArgs(v string) *BrokerDispatchCreate { + _c.mutation.SetArgs(v) + return _c +} + +// SetNillableArgs sets the "args" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableArgs(v *string) *BrokerDispatchCreate { + if v != nil { + _c.SetArgs(*v) + } + return _c +} + +// SetState sets the "state" field. +func (_c *BrokerDispatchCreate) SetState(v string) *BrokerDispatchCreate { + _c.mutation.SetState(v) + return _c +} + +// SetNillableState sets the "state" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableState(v *string) *BrokerDispatchCreate { + if v != nil { + _c.SetState(*v) + } + return _c +} + +// SetResult sets the "result" field. +func (_c *BrokerDispatchCreate) SetResult(v string) *BrokerDispatchCreate { + _c.mutation.SetResult(v) + return _c +} + +// SetNillableResult sets the "result" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableResult(v *string) *BrokerDispatchCreate { + if v != nil { + _c.SetResult(*v) + } + return _c +} + +// SetClaimedBy sets the "claimed_by" field. +func (_c *BrokerDispatchCreate) SetClaimedBy(v string) *BrokerDispatchCreate { + _c.mutation.SetClaimedBy(v) + return _c +} + +// SetNillableClaimedBy sets the "claimed_by" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableClaimedBy(v *string) *BrokerDispatchCreate { + if v != nil { + _c.SetClaimedBy(*v) + } + return _c +} + +// SetAttempts sets the "attempts" field. +func (_c *BrokerDispatchCreate) SetAttempts(v int) *BrokerDispatchCreate { + _c.mutation.SetAttempts(v) + return _c +} + +// SetNillableAttempts sets the "attempts" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableAttempts(v *int) *BrokerDispatchCreate { + if v != nil { + _c.SetAttempts(*v) + } + return _c +} + +// SetError sets the "error" field. +func (_c *BrokerDispatchCreate) SetError(v string) *BrokerDispatchCreate { + _c.mutation.SetError(v) + return _c +} + +// SetNillableError sets the "error" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableError(v *string) *BrokerDispatchCreate { + if v != nil { + _c.SetError(*v) + } + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *BrokerDispatchCreate) SetCreatedAt(v time.Time) *BrokerDispatchCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableCreatedAt(v *time.Time) *BrokerDispatchCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetUpdatedAt sets the "updated_at" field. +func (_c *BrokerDispatchCreate) SetUpdatedAt(v time.Time) *BrokerDispatchCreate { + _c.mutation.SetUpdatedAt(v) + return _c +} + +// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableUpdatedAt(v *time.Time) *BrokerDispatchCreate { + if v != nil { + _c.SetUpdatedAt(*v) + } + return _c +} + +// SetDeadlineAt sets the "deadline_at" field. +func (_c *BrokerDispatchCreate) SetDeadlineAt(v time.Time) *BrokerDispatchCreate { + _c.mutation.SetDeadlineAt(v) + return _c +} + +// SetNillableDeadlineAt sets the "deadline_at" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableDeadlineAt(v *time.Time) *BrokerDispatchCreate { + if v != nil { + _c.SetDeadlineAt(*v) + } + return _c +} + +// SetID sets the "id" field. +func (_c *BrokerDispatchCreate) SetID(v uuid.UUID) *BrokerDispatchCreate { + _c.mutation.SetID(v) + return _c +} + +// SetNillableID sets the "id" field if the given value is not nil. +func (_c *BrokerDispatchCreate) SetNillableID(v *uuid.UUID) *BrokerDispatchCreate { + if v != nil { + _c.SetID(*v) + } + return _c +} + +// Mutation returns the BrokerDispatchMutation object of the builder. +func (_c *BrokerDispatchCreate) Mutation() *BrokerDispatchMutation { + return _c.mutation +} + +// Save creates the BrokerDispatch in the database. +func (_c *BrokerDispatchCreate) Save(ctx context.Context) (*BrokerDispatch, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *BrokerDispatchCreate) SaveX(ctx context.Context) *BrokerDispatch { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *BrokerDispatchCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *BrokerDispatchCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *BrokerDispatchCreate) defaults() { + if _, ok := _c.mutation.State(); !ok { + v := brokerdispatch.DefaultState + _c.mutation.SetState(v) + } + if _, ok := _c.mutation.Attempts(); !ok { + v := brokerdispatch.DefaultAttempts + _c.mutation.SetAttempts(v) + } + if _, ok := _c.mutation.CreatedAt(); !ok { + v := brokerdispatch.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + v := brokerdispatch.DefaultUpdatedAt() + _c.mutation.SetUpdatedAt(v) + } + if _, ok := _c.mutation.ID(); !ok { + v := brokerdispatch.DefaultID() + _c.mutation.SetID(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *BrokerDispatchCreate) check() error { + if _, ok := _c.mutation.BrokerID(); !ok { + return &ValidationError{Name: "broker_id", err: errors.New(`ent: missing required field "BrokerDispatch.broker_id"`)} + } + if _, ok := _c.mutation.GetOp(); !ok { + return &ValidationError{Name: "op", err: errors.New(`ent: missing required field "BrokerDispatch.op"`)} + } + if v, ok := _c.mutation.GetOp(); ok { + if err := brokerdispatch.OpValidator(v); err != nil { + return &ValidationError{Name: "op", err: fmt.Errorf(`ent: validator failed for field "BrokerDispatch.op": %w`, err)} + } + } + if _, ok := _c.mutation.State(); !ok { + return &ValidationError{Name: "state", err: errors.New(`ent: missing required field "BrokerDispatch.state"`)} + } + if _, ok := _c.mutation.Attempts(); !ok { + return &ValidationError{Name: "attempts", err: errors.New(`ent: missing required field "BrokerDispatch.attempts"`)} + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "BrokerDispatch.created_at"`)} + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "BrokerDispatch.updated_at"`)} + } + return nil +} + +func (_c *BrokerDispatchCreate) sqlSave(ctx context.Context) (*BrokerDispatch, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + if _spec.ID.Value != nil { + if id, ok := _spec.ID.Value.(*uuid.UUID); ok { + _node.ID = *id + } else if err := _node.ID.Scan(_spec.ID.Value); err != nil { + return nil, err + } + } + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *BrokerDispatchCreate) createSpec() (*BrokerDispatch, *sqlgraph.CreateSpec) { + var ( + _node = &BrokerDispatch{config: _c.config} + _spec = sqlgraph.NewCreateSpec(brokerdispatch.Table, sqlgraph.NewFieldSpec(brokerdispatch.FieldID, field.TypeUUID)) + ) + _spec.OnConflict = _c.conflict + if id, ok := _c.mutation.ID(); ok { + _node.ID = id + _spec.ID.Value = &id + } + if value, ok := _c.mutation.BrokerID(); ok { + _spec.SetField(brokerdispatch.FieldBrokerID, field.TypeUUID, value) + _node.BrokerID = value + } + if value, ok := _c.mutation.AgentID(); ok { + _spec.SetField(brokerdispatch.FieldAgentID, field.TypeUUID, value) + _node.AgentID = &value + } + if value, ok := _c.mutation.AgentSlug(); ok { + _spec.SetField(brokerdispatch.FieldAgentSlug, field.TypeString, value) + _node.AgentSlug = value + } + if value, ok := _c.mutation.ProjectID(); ok { + _spec.SetField(brokerdispatch.FieldProjectID, field.TypeUUID, value) + _node.ProjectID = &value + } + if value, ok := _c.mutation.GetOp(); ok { + _spec.SetField(brokerdispatch.FieldOp, field.TypeString, value) + _node.Op = value + } + if value, ok := _c.mutation.Args(); ok { + _spec.SetField(brokerdispatch.FieldArgs, field.TypeString, value) + _node.Args = value + } + if value, ok := _c.mutation.State(); ok { + _spec.SetField(brokerdispatch.FieldState, field.TypeString, value) + _node.State = value + } + if value, ok := _c.mutation.Result(); ok { + _spec.SetField(brokerdispatch.FieldResult, field.TypeString, value) + _node.Result = value + } + if value, ok := _c.mutation.ClaimedBy(); ok { + _spec.SetField(brokerdispatch.FieldClaimedBy, field.TypeString, value) + _node.ClaimedBy = value + } + if value, ok := _c.mutation.Attempts(); ok { + _spec.SetField(brokerdispatch.FieldAttempts, field.TypeInt, value) + _node.Attempts = value + } + if value, ok := _c.mutation.Error(); ok { + _spec.SetField(brokerdispatch.FieldError, field.TypeString, value) + _node.Error = value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(brokerdispatch.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.UpdatedAt(); ok { + _spec.SetField(brokerdispatch.FieldUpdatedAt, field.TypeTime, value) + _node.UpdatedAt = value + } + if value, ok := _c.mutation.DeadlineAt(); ok { + _spec.SetField(brokerdispatch.FieldDeadlineAt, field.TypeTime, value) + _node.DeadlineAt = &value + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.BrokerDispatch.Create(). +// SetBrokerID(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.BrokerDispatchUpsert) { +// SetBrokerID(v+v). +// }). +// Exec(ctx) +func (_c *BrokerDispatchCreate) OnConflict(opts ...sql.ConflictOption) *BrokerDispatchUpsertOne { + _c.conflict = opts + return &BrokerDispatchUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.BrokerDispatch.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *BrokerDispatchCreate) OnConflictColumns(columns ...string) *BrokerDispatchUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &BrokerDispatchUpsertOne{ + create: _c, + } +} + +type ( + // BrokerDispatchUpsertOne is the builder for "upsert"-ing + // one BrokerDispatch node. + BrokerDispatchUpsertOne struct { + create *BrokerDispatchCreate + } + + // BrokerDispatchUpsert is the "OnConflict" setter. + BrokerDispatchUpsert struct { + *sql.UpdateSet + } +) + +// SetBrokerID sets the "broker_id" field. +func (u *BrokerDispatchUpsert) SetBrokerID(v uuid.UUID) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldBrokerID, v) + return u +} + +// UpdateBrokerID sets the "broker_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateBrokerID() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldBrokerID) + return u +} + +// SetAgentID sets the "agent_id" field. +func (u *BrokerDispatchUpsert) SetAgentID(v uuid.UUID) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldAgentID, v) + return u +} + +// UpdateAgentID sets the "agent_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateAgentID() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldAgentID) + return u +} + +// ClearAgentID clears the value of the "agent_id" field. +func (u *BrokerDispatchUpsert) ClearAgentID() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldAgentID) + return u +} + +// SetAgentSlug sets the "agent_slug" field. +func (u *BrokerDispatchUpsert) SetAgentSlug(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldAgentSlug, v) + return u +} + +// UpdateAgentSlug sets the "agent_slug" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateAgentSlug() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldAgentSlug) + return u +} + +// ClearAgentSlug clears the value of the "agent_slug" field. +func (u *BrokerDispatchUpsert) ClearAgentSlug() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldAgentSlug) + return u +} + +// SetProjectID sets the "project_id" field. +func (u *BrokerDispatchUpsert) SetProjectID(v uuid.UUID) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldProjectID, v) + return u +} + +// UpdateProjectID sets the "project_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateProjectID() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldProjectID) + return u +} + +// ClearProjectID clears the value of the "project_id" field. +func (u *BrokerDispatchUpsert) ClearProjectID() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldProjectID) + return u +} + +// SetOp sets the "op" field. +func (u *BrokerDispatchUpsert) SetOp(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldOp, v) + return u +} + +// UpdateOp sets the "op" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateOp() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldOp) + return u +} + +// SetArgs sets the "args" field. +func (u *BrokerDispatchUpsert) SetArgs(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldArgs, v) + return u +} + +// UpdateArgs sets the "args" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateArgs() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldArgs) + return u +} + +// ClearArgs clears the value of the "args" field. +func (u *BrokerDispatchUpsert) ClearArgs() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldArgs) + return u +} + +// SetState sets the "state" field. +func (u *BrokerDispatchUpsert) SetState(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldState, v) + return u +} + +// UpdateState sets the "state" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateState() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldState) + return u +} + +// SetResult sets the "result" field. +func (u *BrokerDispatchUpsert) SetResult(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldResult, v) + return u +} + +// UpdateResult sets the "result" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateResult() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldResult) + return u +} + +// ClearResult clears the value of the "result" field. +func (u *BrokerDispatchUpsert) ClearResult() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldResult) + return u +} + +// SetClaimedBy sets the "claimed_by" field. +func (u *BrokerDispatchUpsert) SetClaimedBy(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldClaimedBy, v) + return u +} + +// UpdateClaimedBy sets the "claimed_by" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateClaimedBy() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldClaimedBy) + return u +} + +// ClearClaimedBy clears the value of the "claimed_by" field. +func (u *BrokerDispatchUpsert) ClearClaimedBy() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldClaimedBy) + return u +} + +// SetAttempts sets the "attempts" field. +func (u *BrokerDispatchUpsert) SetAttempts(v int) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldAttempts, v) + return u +} + +// UpdateAttempts sets the "attempts" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateAttempts() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldAttempts) + return u +} + +// AddAttempts adds v to the "attempts" field. +func (u *BrokerDispatchUpsert) AddAttempts(v int) *BrokerDispatchUpsert { + u.Add(brokerdispatch.FieldAttempts, v) + return u +} + +// SetError sets the "error" field. +func (u *BrokerDispatchUpsert) SetError(v string) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldError, v) + return u +} + +// UpdateError sets the "error" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateError() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldError) + return u +} + +// ClearError clears the value of the "error" field. +func (u *BrokerDispatchUpsert) ClearError() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldError) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *BrokerDispatchUpsert) SetUpdatedAt(v time.Time) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldUpdatedAt, v) + return u +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateUpdatedAt() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldUpdatedAt) + return u +} + +// SetDeadlineAt sets the "deadline_at" field. +func (u *BrokerDispatchUpsert) SetDeadlineAt(v time.Time) *BrokerDispatchUpsert { + u.Set(brokerdispatch.FieldDeadlineAt, v) + return u +} + +// UpdateDeadlineAt sets the "deadline_at" field to the value that was provided on create. +func (u *BrokerDispatchUpsert) UpdateDeadlineAt() *BrokerDispatchUpsert { + u.SetExcluded(brokerdispatch.FieldDeadlineAt) + return u +} + +// ClearDeadlineAt clears the value of the "deadline_at" field. +func (u *BrokerDispatchUpsert) ClearDeadlineAt() *BrokerDispatchUpsert { + u.SetNull(brokerdispatch.FieldDeadlineAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. +// Using this option is equivalent to using: +// +// client.BrokerDispatch.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// sql.ResolveWith(func(u *sql.UpdateSet) { +// u.SetIgnore(brokerdispatch.FieldID) +// }), +// ). +// Exec(ctx) +func (u *BrokerDispatchUpsertOne) UpdateNewValues() *BrokerDispatchUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.ID(); exists { + s.SetIgnore(brokerdispatch.FieldID) + } + if _, exists := u.create.mutation.CreatedAt(); exists { + s.SetIgnore(brokerdispatch.FieldCreatedAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.BrokerDispatch.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *BrokerDispatchUpsertOne) Ignore() *BrokerDispatchUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *BrokerDispatchUpsertOne) DoNothing() *BrokerDispatchUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the BrokerDispatchCreate.OnConflict +// documentation for more info. +func (u *BrokerDispatchUpsertOne) Update(set func(*BrokerDispatchUpsert)) *BrokerDispatchUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&BrokerDispatchUpsert{UpdateSet: update}) + })) + return u +} + +// SetBrokerID sets the "broker_id" field. +func (u *BrokerDispatchUpsertOne) SetBrokerID(v uuid.UUID) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetBrokerID(v) + }) +} + +// UpdateBrokerID sets the "broker_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateBrokerID() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateBrokerID() + }) +} + +// SetAgentID sets the "agent_id" field. +func (u *BrokerDispatchUpsertOne) SetAgentID(v uuid.UUID) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetAgentID(v) + }) +} + +// UpdateAgentID sets the "agent_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateAgentID() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateAgentID() + }) +} + +// ClearAgentID clears the value of the "agent_id" field. +func (u *BrokerDispatchUpsertOne) ClearAgentID() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearAgentID() + }) +} + +// SetAgentSlug sets the "agent_slug" field. +func (u *BrokerDispatchUpsertOne) SetAgentSlug(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetAgentSlug(v) + }) +} + +// UpdateAgentSlug sets the "agent_slug" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateAgentSlug() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateAgentSlug() + }) +} + +// ClearAgentSlug clears the value of the "agent_slug" field. +func (u *BrokerDispatchUpsertOne) ClearAgentSlug() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearAgentSlug() + }) +} + +// SetProjectID sets the "project_id" field. +func (u *BrokerDispatchUpsertOne) SetProjectID(v uuid.UUID) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetProjectID(v) + }) +} + +// UpdateProjectID sets the "project_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateProjectID() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateProjectID() + }) +} + +// ClearProjectID clears the value of the "project_id" field. +func (u *BrokerDispatchUpsertOne) ClearProjectID() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearProjectID() + }) +} + +// SetOp sets the "op" field. +func (u *BrokerDispatchUpsertOne) SetOp(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetOp(v) + }) +} + +// UpdateOp sets the "op" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateOp() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateOp() + }) +} + +// SetArgs sets the "args" field. +func (u *BrokerDispatchUpsertOne) SetArgs(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetArgs(v) + }) +} + +// UpdateArgs sets the "args" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateArgs() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateArgs() + }) +} + +// ClearArgs clears the value of the "args" field. +func (u *BrokerDispatchUpsertOne) ClearArgs() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearArgs() + }) +} + +// SetState sets the "state" field. +func (u *BrokerDispatchUpsertOne) SetState(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetState(v) + }) +} + +// UpdateState sets the "state" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateState() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateState() + }) +} + +// SetResult sets the "result" field. +func (u *BrokerDispatchUpsertOne) SetResult(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetResult(v) + }) +} + +// UpdateResult sets the "result" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateResult() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateResult() + }) +} + +// ClearResult clears the value of the "result" field. +func (u *BrokerDispatchUpsertOne) ClearResult() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearResult() + }) +} + +// SetClaimedBy sets the "claimed_by" field. +func (u *BrokerDispatchUpsertOne) SetClaimedBy(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetClaimedBy(v) + }) +} + +// UpdateClaimedBy sets the "claimed_by" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateClaimedBy() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateClaimedBy() + }) +} + +// ClearClaimedBy clears the value of the "claimed_by" field. +func (u *BrokerDispatchUpsertOne) ClearClaimedBy() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearClaimedBy() + }) +} + +// SetAttempts sets the "attempts" field. +func (u *BrokerDispatchUpsertOne) SetAttempts(v int) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetAttempts(v) + }) +} + +// AddAttempts adds v to the "attempts" field. +func (u *BrokerDispatchUpsertOne) AddAttempts(v int) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.AddAttempts(v) + }) +} + +// UpdateAttempts sets the "attempts" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateAttempts() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateAttempts() + }) +} + +// SetError sets the "error" field. +func (u *BrokerDispatchUpsertOne) SetError(v string) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetError(v) + }) +} + +// UpdateError sets the "error" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateError() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateError() + }) +} + +// ClearError clears the value of the "error" field. +func (u *BrokerDispatchUpsertOne) ClearError() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearError() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *BrokerDispatchUpsertOne) SetUpdatedAt(v time.Time) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateUpdatedAt() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateUpdatedAt() + }) +} + +// SetDeadlineAt sets the "deadline_at" field. +func (u *BrokerDispatchUpsertOne) SetDeadlineAt(v time.Time) *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetDeadlineAt(v) + }) +} + +// UpdateDeadlineAt sets the "deadline_at" field to the value that was provided on create. +func (u *BrokerDispatchUpsertOne) UpdateDeadlineAt() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateDeadlineAt() + }) +} + +// ClearDeadlineAt clears the value of the "deadline_at" field. +func (u *BrokerDispatchUpsertOne) ClearDeadlineAt() *BrokerDispatchUpsertOne { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearDeadlineAt() + }) +} + +// Exec executes the query. +func (u *BrokerDispatchUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for BrokerDispatchCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *BrokerDispatchUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *BrokerDispatchUpsertOne) ID(ctx context.Context) (id uuid.UUID, err error) { + if u.create.driver.Dialect() == dialect.MySQL { + // In case of "ON CONFLICT", there is no way to get back non-numeric ID + // fields from the database since MySQL does not support the RETURNING clause. + return id, errors.New("ent: BrokerDispatchUpsertOne.ID is not supported by MySQL driver. Use BrokerDispatchUpsertOne.Exec instead") + } + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *BrokerDispatchUpsertOne) IDX(ctx context.Context) uuid.UUID { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// BrokerDispatchCreateBulk is the builder for creating many BrokerDispatch entities in bulk. +type BrokerDispatchCreateBulk struct { + config + err error + builders []*BrokerDispatchCreate + conflict []sql.ConflictOption +} + +// Save creates the BrokerDispatch entities in the database. +func (_c *BrokerDispatchCreateBulk) Save(ctx context.Context) ([]*BrokerDispatch, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*BrokerDispatch, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*BrokerDispatchMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *BrokerDispatchCreateBulk) SaveX(ctx context.Context) []*BrokerDispatch { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *BrokerDispatchCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *BrokerDispatchCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.BrokerDispatch.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.BrokerDispatchUpsert) { +// SetBrokerID(v+v). +// }). +// Exec(ctx) +func (_c *BrokerDispatchCreateBulk) OnConflict(opts ...sql.ConflictOption) *BrokerDispatchUpsertBulk { + _c.conflict = opts + return &BrokerDispatchUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.BrokerDispatch.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *BrokerDispatchCreateBulk) OnConflictColumns(columns ...string) *BrokerDispatchUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &BrokerDispatchUpsertBulk{ + create: _c, + } +} + +// BrokerDispatchUpsertBulk is the builder for "upsert"-ing +// a bulk of BrokerDispatch nodes. +type BrokerDispatchUpsertBulk struct { + create *BrokerDispatchCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.BrokerDispatch.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// sql.ResolveWith(func(u *sql.UpdateSet) { +// u.SetIgnore(brokerdispatch.FieldID) +// }), +// ). +// Exec(ctx) +func (u *BrokerDispatchUpsertBulk) UpdateNewValues() *BrokerDispatchUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.ID(); exists { + s.SetIgnore(brokerdispatch.FieldID) + } + if _, exists := b.mutation.CreatedAt(); exists { + s.SetIgnore(brokerdispatch.FieldCreatedAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.BrokerDispatch.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *BrokerDispatchUpsertBulk) Ignore() *BrokerDispatchUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *BrokerDispatchUpsertBulk) DoNothing() *BrokerDispatchUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the BrokerDispatchCreateBulk.OnConflict +// documentation for more info. +func (u *BrokerDispatchUpsertBulk) Update(set func(*BrokerDispatchUpsert)) *BrokerDispatchUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&BrokerDispatchUpsert{UpdateSet: update}) + })) + return u +} + +// SetBrokerID sets the "broker_id" field. +func (u *BrokerDispatchUpsertBulk) SetBrokerID(v uuid.UUID) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetBrokerID(v) + }) +} + +// UpdateBrokerID sets the "broker_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateBrokerID() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateBrokerID() + }) +} + +// SetAgentID sets the "agent_id" field. +func (u *BrokerDispatchUpsertBulk) SetAgentID(v uuid.UUID) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetAgentID(v) + }) +} + +// UpdateAgentID sets the "agent_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateAgentID() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateAgentID() + }) +} + +// ClearAgentID clears the value of the "agent_id" field. +func (u *BrokerDispatchUpsertBulk) ClearAgentID() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearAgentID() + }) +} + +// SetAgentSlug sets the "agent_slug" field. +func (u *BrokerDispatchUpsertBulk) SetAgentSlug(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetAgentSlug(v) + }) +} + +// UpdateAgentSlug sets the "agent_slug" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateAgentSlug() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateAgentSlug() + }) +} + +// ClearAgentSlug clears the value of the "agent_slug" field. +func (u *BrokerDispatchUpsertBulk) ClearAgentSlug() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearAgentSlug() + }) +} + +// SetProjectID sets the "project_id" field. +func (u *BrokerDispatchUpsertBulk) SetProjectID(v uuid.UUID) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetProjectID(v) + }) +} + +// UpdateProjectID sets the "project_id" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateProjectID() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateProjectID() + }) +} + +// ClearProjectID clears the value of the "project_id" field. +func (u *BrokerDispatchUpsertBulk) ClearProjectID() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearProjectID() + }) +} + +// SetOp sets the "op" field. +func (u *BrokerDispatchUpsertBulk) SetOp(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetOp(v) + }) +} + +// UpdateOp sets the "op" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateOp() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateOp() + }) +} + +// SetArgs sets the "args" field. +func (u *BrokerDispatchUpsertBulk) SetArgs(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetArgs(v) + }) +} + +// UpdateArgs sets the "args" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateArgs() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateArgs() + }) +} + +// ClearArgs clears the value of the "args" field. +func (u *BrokerDispatchUpsertBulk) ClearArgs() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearArgs() + }) +} + +// SetState sets the "state" field. +func (u *BrokerDispatchUpsertBulk) SetState(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetState(v) + }) +} + +// UpdateState sets the "state" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateState() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateState() + }) +} + +// SetResult sets the "result" field. +func (u *BrokerDispatchUpsertBulk) SetResult(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetResult(v) + }) +} + +// UpdateResult sets the "result" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateResult() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateResult() + }) +} + +// ClearResult clears the value of the "result" field. +func (u *BrokerDispatchUpsertBulk) ClearResult() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearResult() + }) +} + +// SetClaimedBy sets the "claimed_by" field. +func (u *BrokerDispatchUpsertBulk) SetClaimedBy(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetClaimedBy(v) + }) +} + +// UpdateClaimedBy sets the "claimed_by" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateClaimedBy() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateClaimedBy() + }) +} + +// ClearClaimedBy clears the value of the "claimed_by" field. +func (u *BrokerDispatchUpsertBulk) ClearClaimedBy() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearClaimedBy() + }) +} + +// SetAttempts sets the "attempts" field. +func (u *BrokerDispatchUpsertBulk) SetAttempts(v int) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetAttempts(v) + }) +} + +// AddAttempts adds v to the "attempts" field. +func (u *BrokerDispatchUpsertBulk) AddAttempts(v int) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.AddAttempts(v) + }) +} + +// UpdateAttempts sets the "attempts" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateAttempts() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateAttempts() + }) +} + +// SetError sets the "error" field. +func (u *BrokerDispatchUpsertBulk) SetError(v string) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetError(v) + }) +} + +// UpdateError sets the "error" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateError() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateError() + }) +} + +// ClearError clears the value of the "error" field. +func (u *BrokerDispatchUpsertBulk) ClearError() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearError() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *BrokerDispatchUpsertBulk) SetUpdatedAt(v time.Time) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateUpdatedAt() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateUpdatedAt() + }) +} + +// SetDeadlineAt sets the "deadline_at" field. +func (u *BrokerDispatchUpsertBulk) SetDeadlineAt(v time.Time) *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.SetDeadlineAt(v) + }) +} + +// UpdateDeadlineAt sets the "deadline_at" field to the value that was provided on create. +func (u *BrokerDispatchUpsertBulk) UpdateDeadlineAt() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.UpdateDeadlineAt() + }) +} + +// ClearDeadlineAt clears the value of the "deadline_at" field. +func (u *BrokerDispatchUpsertBulk) ClearDeadlineAt() *BrokerDispatchUpsertBulk { + return u.Update(func(s *BrokerDispatchUpsert) { + s.ClearDeadlineAt() + }) +} + +// Exec executes the query. +func (u *BrokerDispatchUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the BrokerDispatchCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for BrokerDispatchCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *BrokerDispatchUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/pkg/ent/brokerdispatch_delete.go b/pkg/ent/brokerdispatch_delete.go new file mode 100644 index 000000000..88adbccfb --- /dev/null +++ b/pkg/ent/brokerdispatch_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" + "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" +) + +// BrokerDispatchDelete is the builder for deleting a BrokerDispatch entity. +type BrokerDispatchDelete struct { + config + hooks []Hook + mutation *BrokerDispatchMutation +} + +// Where appends a list predicates to the BrokerDispatchDelete builder. +func (_d *BrokerDispatchDelete) Where(ps ...predicate.BrokerDispatch) *BrokerDispatchDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *BrokerDispatchDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *BrokerDispatchDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *BrokerDispatchDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(brokerdispatch.Table, sqlgraph.NewFieldSpec(brokerdispatch.FieldID, field.TypeUUID)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// BrokerDispatchDeleteOne is the builder for deleting a single BrokerDispatch entity. +type BrokerDispatchDeleteOne struct { + _d *BrokerDispatchDelete +} + +// Where appends a list predicates to the BrokerDispatchDelete builder. +func (_d *BrokerDispatchDeleteOne) Where(ps ...predicate.BrokerDispatch) *BrokerDispatchDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *BrokerDispatchDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{brokerdispatch.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *BrokerDispatchDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/pkg/ent/brokerdispatch_query.go b/pkg/ent/brokerdispatch_query.go new file mode 100644 index 000000000..67bb47347 --- /dev/null +++ b/pkg/ent/brokerdispatch_query.go @@ -0,0 +1,565 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" + "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" + "github.com/google/uuid" +) + +// BrokerDispatchQuery is the builder for querying BrokerDispatch entities. +type BrokerDispatchQuery struct { + config + ctx *QueryContext + order []brokerdispatch.OrderOption + inters []Interceptor + predicates []predicate.BrokerDispatch + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the BrokerDispatchQuery builder. +func (_q *BrokerDispatchQuery) Where(ps ...predicate.BrokerDispatch) *BrokerDispatchQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *BrokerDispatchQuery) Limit(limit int) *BrokerDispatchQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *BrokerDispatchQuery) Offset(offset int) *BrokerDispatchQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *BrokerDispatchQuery) Unique(unique bool) *BrokerDispatchQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *BrokerDispatchQuery) Order(o ...brokerdispatch.OrderOption) *BrokerDispatchQuery { + _q.order = append(_q.order, o...) + return _q +} + +// First returns the first BrokerDispatch entity from the query. +// Returns a *NotFoundError when no BrokerDispatch was found. +func (_q *BrokerDispatchQuery) First(ctx context.Context) (*BrokerDispatch, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{brokerdispatch.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *BrokerDispatchQuery) FirstX(ctx context.Context) *BrokerDispatch { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first BrokerDispatch ID from the query. +// Returns a *NotFoundError when no BrokerDispatch ID was found. +func (_q *BrokerDispatchQuery) FirstID(ctx context.Context) (id uuid.UUID, err error) { + var ids []uuid.UUID + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{brokerdispatch.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *BrokerDispatchQuery) FirstIDX(ctx context.Context) uuid.UUID { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single BrokerDispatch entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one BrokerDispatch entity is found. +// Returns a *NotFoundError when no BrokerDispatch entities are found. +func (_q *BrokerDispatchQuery) Only(ctx context.Context) (*BrokerDispatch, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{brokerdispatch.Label} + default: + return nil, &NotSingularError{brokerdispatch.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *BrokerDispatchQuery) OnlyX(ctx context.Context) *BrokerDispatch { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only BrokerDispatch ID in the query. +// Returns a *NotSingularError when more than one BrokerDispatch ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *BrokerDispatchQuery) OnlyID(ctx context.Context) (id uuid.UUID, err error) { + var ids []uuid.UUID + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{brokerdispatch.Label} + default: + err = &NotSingularError{brokerdispatch.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *BrokerDispatchQuery) OnlyIDX(ctx context.Context) uuid.UUID { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of BrokerDispatches. +func (_q *BrokerDispatchQuery) All(ctx context.Context) ([]*BrokerDispatch, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*BrokerDispatch, *BrokerDispatchQuery]() + return withInterceptors[[]*BrokerDispatch](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *BrokerDispatchQuery) AllX(ctx context.Context) []*BrokerDispatch { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of BrokerDispatch IDs. +func (_q *BrokerDispatchQuery) IDs(ctx context.Context) (ids []uuid.UUID, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(brokerdispatch.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *BrokerDispatchQuery) IDsX(ctx context.Context) []uuid.UUID { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *BrokerDispatchQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*BrokerDispatchQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *BrokerDispatchQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *BrokerDispatchQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("ent: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *BrokerDispatchQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the BrokerDispatchQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *BrokerDispatchQuery) Clone() *BrokerDispatchQuery { + if _q == nil { + return nil + } + return &BrokerDispatchQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]brokerdispatch.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.BrokerDispatch{}, _q.predicates...), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// BrokerID uuid.UUID `json:"broker_id,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.BrokerDispatch.Query(). +// GroupBy(brokerdispatch.FieldBrokerID). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *BrokerDispatchQuery) GroupBy(field string, fields ...string) *BrokerDispatchGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &BrokerDispatchGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = brokerdispatch.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// BrokerID uuid.UUID `json:"broker_id,omitempty"` +// } +// +// client.BrokerDispatch.Query(). +// Select(brokerdispatch.FieldBrokerID). +// Scan(ctx, &v) +func (_q *BrokerDispatchQuery) Select(fields ...string) *BrokerDispatchSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &BrokerDispatchSelect{BrokerDispatchQuery: _q} + sbuild.label = brokerdispatch.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a BrokerDispatchSelect configured with the given aggregations. +func (_q *BrokerDispatchQuery) Aggregate(fns ...AggregateFunc) *BrokerDispatchSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *BrokerDispatchQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !brokerdispatch.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *BrokerDispatchQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*BrokerDispatch, error) { + var ( + nodes = []*BrokerDispatch{} + _spec = _q.querySpec() + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*BrokerDispatch).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &BrokerDispatch{config: _q.config} + nodes = append(nodes, node) + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + return nodes, nil +} + +func (_q *BrokerDispatchQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *BrokerDispatchQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(brokerdispatch.Table, brokerdispatch.Columns, sqlgraph.NewFieldSpec(brokerdispatch.FieldID, field.TypeUUID)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, brokerdispatch.FieldID) + for i := range fields { + if fields[i] != brokerdispatch.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *BrokerDispatchQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(brokerdispatch.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = brokerdispatch.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *BrokerDispatchQuery) ForUpdate(opts ...sql.LockOption) *BrokerDispatchQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *BrokerDispatchQuery) ForShare(opts ...sql.LockOption) *BrokerDispatchQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// BrokerDispatchGroupBy is the group-by builder for BrokerDispatch entities. +type BrokerDispatchGroupBy struct { + selector + build *BrokerDispatchQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *BrokerDispatchGroupBy) Aggregate(fns ...AggregateFunc) *BrokerDispatchGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *BrokerDispatchGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*BrokerDispatchQuery, *BrokerDispatchGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *BrokerDispatchGroupBy) sqlScan(ctx context.Context, root *BrokerDispatchQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// BrokerDispatchSelect is the builder for selecting fields of BrokerDispatch entities. +type BrokerDispatchSelect struct { + *BrokerDispatchQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *BrokerDispatchSelect) Aggregate(fns ...AggregateFunc) *BrokerDispatchSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *BrokerDispatchSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*BrokerDispatchQuery, *BrokerDispatchSelect](ctx, _s.BrokerDispatchQuery, _s, _s.inters, v) +} + +func (_s *BrokerDispatchSelect) sqlScan(ctx context.Context, root *BrokerDispatchQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/pkg/ent/brokerdispatch_update.go b/pkg/ent/brokerdispatch_update.go new file mode 100644 index 000000000..be039862b --- /dev/null +++ b/pkg/ent/brokerdispatch_update.go @@ -0,0 +1,811 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" + "github.com/GoogleCloudPlatform/scion/pkg/ent/predicate" + "github.com/google/uuid" +) + +// BrokerDispatchUpdate is the builder for updating BrokerDispatch entities. +type BrokerDispatchUpdate struct { + config + hooks []Hook + mutation *BrokerDispatchMutation +} + +// Where appends a list predicates to the BrokerDispatchUpdate builder. +func (_u *BrokerDispatchUpdate) Where(ps ...predicate.BrokerDispatch) *BrokerDispatchUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetBrokerID sets the "broker_id" field. +func (_u *BrokerDispatchUpdate) SetBrokerID(v uuid.UUID) *BrokerDispatchUpdate { + _u.mutation.SetBrokerID(v) + return _u +} + +// SetNillableBrokerID sets the "broker_id" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableBrokerID(v *uuid.UUID) *BrokerDispatchUpdate { + if v != nil { + _u.SetBrokerID(*v) + } + return _u +} + +// SetAgentID sets the "agent_id" field. +func (_u *BrokerDispatchUpdate) SetAgentID(v uuid.UUID) *BrokerDispatchUpdate { + _u.mutation.SetAgentID(v) + return _u +} + +// SetNillableAgentID sets the "agent_id" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableAgentID(v *uuid.UUID) *BrokerDispatchUpdate { + if v != nil { + _u.SetAgentID(*v) + } + return _u +} + +// ClearAgentID clears the value of the "agent_id" field. +func (_u *BrokerDispatchUpdate) ClearAgentID() *BrokerDispatchUpdate { + _u.mutation.ClearAgentID() + return _u +} + +// SetAgentSlug sets the "agent_slug" field. +func (_u *BrokerDispatchUpdate) SetAgentSlug(v string) *BrokerDispatchUpdate { + _u.mutation.SetAgentSlug(v) + return _u +} + +// SetNillableAgentSlug sets the "agent_slug" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableAgentSlug(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetAgentSlug(*v) + } + return _u +} + +// ClearAgentSlug clears the value of the "agent_slug" field. +func (_u *BrokerDispatchUpdate) ClearAgentSlug() *BrokerDispatchUpdate { + _u.mutation.ClearAgentSlug() + return _u +} + +// SetProjectID sets the "project_id" field. +func (_u *BrokerDispatchUpdate) SetProjectID(v uuid.UUID) *BrokerDispatchUpdate { + _u.mutation.SetProjectID(v) + return _u +} + +// SetNillableProjectID sets the "project_id" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableProjectID(v *uuid.UUID) *BrokerDispatchUpdate { + if v != nil { + _u.SetProjectID(*v) + } + return _u +} + +// ClearProjectID clears the value of the "project_id" field. +func (_u *BrokerDispatchUpdate) ClearProjectID() *BrokerDispatchUpdate { + _u.mutation.ClearProjectID() + return _u +} + +// SetOp sets the "op" field. +func (_u *BrokerDispatchUpdate) SetOp(v string) *BrokerDispatchUpdate { + _u.mutation.SetOpField(v) + return _u +} + +// SetNillableOp sets the "op" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableOp(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetOp(*v) + } + return _u +} + +// SetArgs sets the "args" field. +func (_u *BrokerDispatchUpdate) SetArgs(v string) *BrokerDispatchUpdate { + _u.mutation.SetArgs(v) + return _u +} + +// SetNillableArgs sets the "args" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableArgs(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetArgs(*v) + } + return _u +} + +// ClearArgs clears the value of the "args" field. +func (_u *BrokerDispatchUpdate) ClearArgs() *BrokerDispatchUpdate { + _u.mutation.ClearArgs() + return _u +} + +// SetState sets the "state" field. +func (_u *BrokerDispatchUpdate) SetState(v string) *BrokerDispatchUpdate { + _u.mutation.SetState(v) + return _u +} + +// SetNillableState sets the "state" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableState(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetState(*v) + } + return _u +} + +// SetResult sets the "result" field. +func (_u *BrokerDispatchUpdate) SetResult(v string) *BrokerDispatchUpdate { + _u.mutation.SetResult(v) + return _u +} + +// SetNillableResult sets the "result" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableResult(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetResult(*v) + } + return _u +} + +// ClearResult clears the value of the "result" field. +func (_u *BrokerDispatchUpdate) ClearResult() *BrokerDispatchUpdate { + _u.mutation.ClearResult() + return _u +} + +// SetClaimedBy sets the "claimed_by" field. +func (_u *BrokerDispatchUpdate) SetClaimedBy(v string) *BrokerDispatchUpdate { + _u.mutation.SetClaimedBy(v) + return _u +} + +// SetNillableClaimedBy sets the "claimed_by" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableClaimedBy(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetClaimedBy(*v) + } + return _u +} + +// ClearClaimedBy clears the value of the "claimed_by" field. +func (_u *BrokerDispatchUpdate) ClearClaimedBy() *BrokerDispatchUpdate { + _u.mutation.ClearClaimedBy() + return _u +} + +// SetAttempts sets the "attempts" field. +func (_u *BrokerDispatchUpdate) SetAttempts(v int) *BrokerDispatchUpdate { + _u.mutation.ResetAttempts() + _u.mutation.SetAttempts(v) + return _u +} + +// SetNillableAttempts sets the "attempts" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableAttempts(v *int) *BrokerDispatchUpdate { + if v != nil { + _u.SetAttempts(*v) + } + return _u +} + +// AddAttempts adds value to the "attempts" field. +func (_u *BrokerDispatchUpdate) AddAttempts(v int) *BrokerDispatchUpdate { + _u.mutation.AddAttempts(v) + return _u +} + +// SetError sets the "error" field. +func (_u *BrokerDispatchUpdate) SetError(v string) *BrokerDispatchUpdate { + _u.mutation.SetError(v) + return _u +} + +// SetNillableError sets the "error" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableError(v *string) *BrokerDispatchUpdate { + if v != nil { + _u.SetError(*v) + } + return _u +} + +// ClearError clears the value of the "error" field. +func (_u *BrokerDispatchUpdate) ClearError() *BrokerDispatchUpdate { + _u.mutation.ClearError() + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *BrokerDispatchUpdate) SetUpdatedAt(v time.Time) *BrokerDispatchUpdate { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetDeadlineAt sets the "deadline_at" field. +func (_u *BrokerDispatchUpdate) SetDeadlineAt(v time.Time) *BrokerDispatchUpdate { + _u.mutation.SetDeadlineAt(v) + return _u +} + +// SetNillableDeadlineAt sets the "deadline_at" field if the given value is not nil. +func (_u *BrokerDispatchUpdate) SetNillableDeadlineAt(v *time.Time) *BrokerDispatchUpdate { + if v != nil { + _u.SetDeadlineAt(*v) + } + return _u +} + +// ClearDeadlineAt clears the value of the "deadline_at" field. +func (_u *BrokerDispatchUpdate) ClearDeadlineAt() *BrokerDispatchUpdate { + _u.mutation.ClearDeadlineAt() + return _u +} + +// Mutation returns the BrokerDispatchMutation object of the builder. +func (_u *BrokerDispatchUpdate) Mutation() *BrokerDispatchMutation { + return _u.mutation +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *BrokerDispatchUpdate) Save(ctx context.Context) (int, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *BrokerDispatchUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *BrokerDispatchUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *BrokerDispatchUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *BrokerDispatchUpdate) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := brokerdispatch.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *BrokerDispatchUpdate) check() error { + if v, ok := _u.mutation.GetOp(); ok { + if err := brokerdispatch.OpValidator(v); err != nil { + return &ValidationError{Name: "op", err: fmt.Errorf(`ent: validator failed for field "BrokerDispatch.op": %w`, err)} + } + } + return nil +} + +func (_u *BrokerDispatchUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(brokerdispatch.Table, brokerdispatch.Columns, sqlgraph.NewFieldSpec(brokerdispatch.FieldID, field.TypeUUID)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.BrokerID(); ok { + _spec.SetField(brokerdispatch.FieldBrokerID, field.TypeUUID, value) + } + if value, ok := _u.mutation.AgentID(); ok { + _spec.SetField(brokerdispatch.FieldAgentID, field.TypeUUID, value) + } + if _u.mutation.AgentIDCleared() { + _spec.ClearField(brokerdispatch.FieldAgentID, field.TypeUUID) + } + if value, ok := _u.mutation.AgentSlug(); ok { + _spec.SetField(brokerdispatch.FieldAgentSlug, field.TypeString, value) + } + if _u.mutation.AgentSlugCleared() { + _spec.ClearField(brokerdispatch.FieldAgentSlug, field.TypeString) + } + if value, ok := _u.mutation.ProjectID(); ok { + _spec.SetField(brokerdispatch.FieldProjectID, field.TypeUUID, value) + } + if _u.mutation.ProjectIDCleared() { + _spec.ClearField(brokerdispatch.FieldProjectID, field.TypeUUID) + } + if value, ok := _u.mutation.GetOp(); ok { + _spec.SetField(brokerdispatch.FieldOp, field.TypeString, value) + } + if value, ok := _u.mutation.Args(); ok { + _spec.SetField(brokerdispatch.FieldArgs, field.TypeString, value) + } + if _u.mutation.ArgsCleared() { + _spec.ClearField(brokerdispatch.FieldArgs, field.TypeString) + } + if value, ok := _u.mutation.State(); ok { + _spec.SetField(brokerdispatch.FieldState, field.TypeString, value) + } + if value, ok := _u.mutation.Result(); ok { + _spec.SetField(brokerdispatch.FieldResult, field.TypeString, value) + } + if _u.mutation.ResultCleared() { + _spec.ClearField(brokerdispatch.FieldResult, field.TypeString) + } + if value, ok := _u.mutation.ClaimedBy(); ok { + _spec.SetField(brokerdispatch.FieldClaimedBy, field.TypeString, value) + } + if _u.mutation.ClaimedByCleared() { + _spec.ClearField(brokerdispatch.FieldClaimedBy, field.TypeString) + } + if value, ok := _u.mutation.Attempts(); ok { + _spec.SetField(brokerdispatch.FieldAttempts, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedAttempts(); ok { + _spec.AddField(brokerdispatch.FieldAttempts, field.TypeInt, value) + } + if value, ok := _u.mutation.Error(); ok { + _spec.SetField(brokerdispatch.FieldError, field.TypeString, value) + } + if _u.mutation.ErrorCleared() { + _spec.ClearField(brokerdispatch.FieldError, field.TypeString) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(brokerdispatch.FieldUpdatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.DeadlineAt(); ok { + _spec.SetField(brokerdispatch.FieldDeadlineAt, field.TypeTime, value) + } + if _u.mutation.DeadlineAtCleared() { + _spec.ClearField(brokerdispatch.FieldDeadlineAt, field.TypeTime) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{brokerdispatch.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// BrokerDispatchUpdateOne is the builder for updating a single BrokerDispatch entity. +type BrokerDispatchUpdateOne struct { + config + fields []string + hooks []Hook + mutation *BrokerDispatchMutation +} + +// SetBrokerID sets the "broker_id" field. +func (_u *BrokerDispatchUpdateOne) SetBrokerID(v uuid.UUID) *BrokerDispatchUpdateOne { + _u.mutation.SetBrokerID(v) + return _u +} + +// SetNillableBrokerID sets the "broker_id" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableBrokerID(v *uuid.UUID) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetBrokerID(*v) + } + return _u +} + +// SetAgentID sets the "agent_id" field. +func (_u *BrokerDispatchUpdateOne) SetAgentID(v uuid.UUID) *BrokerDispatchUpdateOne { + _u.mutation.SetAgentID(v) + return _u +} + +// SetNillableAgentID sets the "agent_id" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableAgentID(v *uuid.UUID) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetAgentID(*v) + } + return _u +} + +// ClearAgentID clears the value of the "agent_id" field. +func (_u *BrokerDispatchUpdateOne) ClearAgentID() *BrokerDispatchUpdateOne { + _u.mutation.ClearAgentID() + return _u +} + +// SetAgentSlug sets the "agent_slug" field. +func (_u *BrokerDispatchUpdateOne) SetAgentSlug(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetAgentSlug(v) + return _u +} + +// SetNillableAgentSlug sets the "agent_slug" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableAgentSlug(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetAgentSlug(*v) + } + return _u +} + +// ClearAgentSlug clears the value of the "agent_slug" field. +func (_u *BrokerDispatchUpdateOne) ClearAgentSlug() *BrokerDispatchUpdateOne { + _u.mutation.ClearAgentSlug() + return _u +} + +// SetProjectID sets the "project_id" field. +func (_u *BrokerDispatchUpdateOne) SetProjectID(v uuid.UUID) *BrokerDispatchUpdateOne { + _u.mutation.SetProjectID(v) + return _u +} + +// SetNillableProjectID sets the "project_id" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableProjectID(v *uuid.UUID) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetProjectID(*v) + } + return _u +} + +// ClearProjectID clears the value of the "project_id" field. +func (_u *BrokerDispatchUpdateOne) ClearProjectID() *BrokerDispatchUpdateOne { + _u.mutation.ClearProjectID() + return _u +} + +// SetOp sets the "op" field. +func (_u *BrokerDispatchUpdateOne) SetOp(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetOpField(v) + return _u +} + +// SetNillableOp sets the "op" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableOp(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetOp(*v) + } + return _u +} + +// SetArgs sets the "args" field. +func (_u *BrokerDispatchUpdateOne) SetArgs(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetArgs(v) + return _u +} + +// SetNillableArgs sets the "args" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableArgs(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetArgs(*v) + } + return _u +} + +// ClearArgs clears the value of the "args" field. +func (_u *BrokerDispatchUpdateOne) ClearArgs() *BrokerDispatchUpdateOne { + _u.mutation.ClearArgs() + return _u +} + +// SetState sets the "state" field. +func (_u *BrokerDispatchUpdateOne) SetState(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetState(v) + return _u +} + +// SetNillableState sets the "state" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableState(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetState(*v) + } + return _u +} + +// SetResult sets the "result" field. +func (_u *BrokerDispatchUpdateOne) SetResult(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetResult(v) + return _u +} + +// SetNillableResult sets the "result" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableResult(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetResult(*v) + } + return _u +} + +// ClearResult clears the value of the "result" field. +func (_u *BrokerDispatchUpdateOne) ClearResult() *BrokerDispatchUpdateOne { + _u.mutation.ClearResult() + return _u +} + +// SetClaimedBy sets the "claimed_by" field. +func (_u *BrokerDispatchUpdateOne) SetClaimedBy(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetClaimedBy(v) + return _u +} + +// SetNillableClaimedBy sets the "claimed_by" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableClaimedBy(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetClaimedBy(*v) + } + return _u +} + +// ClearClaimedBy clears the value of the "claimed_by" field. +func (_u *BrokerDispatchUpdateOne) ClearClaimedBy() *BrokerDispatchUpdateOne { + _u.mutation.ClearClaimedBy() + return _u +} + +// SetAttempts sets the "attempts" field. +func (_u *BrokerDispatchUpdateOne) SetAttempts(v int) *BrokerDispatchUpdateOne { + _u.mutation.ResetAttempts() + _u.mutation.SetAttempts(v) + return _u +} + +// SetNillableAttempts sets the "attempts" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableAttempts(v *int) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetAttempts(*v) + } + return _u +} + +// AddAttempts adds value to the "attempts" field. +func (_u *BrokerDispatchUpdateOne) AddAttempts(v int) *BrokerDispatchUpdateOne { + _u.mutation.AddAttempts(v) + return _u +} + +// SetError sets the "error" field. +func (_u *BrokerDispatchUpdateOne) SetError(v string) *BrokerDispatchUpdateOne { + _u.mutation.SetError(v) + return _u +} + +// SetNillableError sets the "error" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableError(v *string) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetError(*v) + } + return _u +} + +// ClearError clears the value of the "error" field. +func (_u *BrokerDispatchUpdateOne) ClearError() *BrokerDispatchUpdateOne { + _u.mutation.ClearError() + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *BrokerDispatchUpdateOne) SetUpdatedAt(v time.Time) *BrokerDispatchUpdateOne { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetDeadlineAt sets the "deadline_at" field. +func (_u *BrokerDispatchUpdateOne) SetDeadlineAt(v time.Time) *BrokerDispatchUpdateOne { + _u.mutation.SetDeadlineAt(v) + return _u +} + +// SetNillableDeadlineAt sets the "deadline_at" field if the given value is not nil. +func (_u *BrokerDispatchUpdateOne) SetNillableDeadlineAt(v *time.Time) *BrokerDispatchUpdateOne { + if v != nil { + _u.SetDeadlineAt(*v) + } + return _u +} + +// ClearDeadlineAt clears the value of the "deadline_at" field. +func (_u *BrokerDispatchUpdateOne) ClearDeadlineAt() *BrokerDispatchUpdateOne { + _u.mutation.ClearDeadlineAt() + return _u +} + +// Mutation returns the BrokerDispatchMutation object of the builder. +func (_u *BrokerDispatchUpdateOne) Mutation() *BrokerDispatchMutation { + return _u.mutation +} + +// Where appends a list predicates to the BrokerDispatchUpdate builder. +func (_u *BrokerDispatchUpdateOne) Where(ps ...predicate.BrokerDispatch) *BrokerDispatchUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *BrokerDispatchUpdateOne) Select(field string, fields ...string) *BrokerDispatchUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated BrokerDispatch entity. +func (_u *BrokerDispatchUpdateOne) Save(ctx context.Context) (*BrokerDispatch, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *BrokerDispatchUpdateOne) SaveX(ctx context.Context) *BrokerDispatch { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *BrokerDispatchUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *BrokerDispatchUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *BrokerDispatchUpdateOne) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := brokerdispatch.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *BrokerDispatchUpdateOne) check() error { + if v, ok := _u.mutation.GetOp(); ok { + if err := brokerdispatch.OpValidator(v); err != nil { + return &ValidationError{Name: "op", err: fmt.Errorf(`ent: validator failed for field "BrokerDispatch.op": %w`, err)} + } + } + return nil +} + +func (_u *BrokerDispatchUpdateOne) sqlSave(ctx context.Context) (_node *BrokerDispatch, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(brokerdispatch.Table, brokerdispatch.Columns, sqlgraph.NewFieldSpec(brokerdispatch.FieldID, field.TypeUUID)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "BrokerDispatch.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, brokerdispatch.FieldID) + for _, f := range fields { + if !brokerdispatch.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != brokerdispatch.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.BrokerID(); ok { + _spec.SetField(brokerdispatch.FieldBrokerID, field.TypeUUID, value) + } + if value, ok := _u.mutation.AgentID(); ok { + _spec.SetField(brokerdispatch.FieldAgentID, field.TypeUUID, value) + } + if _u.mutation.AgentIDCleared() { + _spec.ClearField(brokerdispatch.FieldAgentID, field.TypeUUID) + } + if value, ok := _u.mutation.AgentSlug(); ok { + _spec.SetField(brokerdispatch.FieldAgentSlug, field.TypeString, value) + } + if _u.mutation.AgentSlugCleared() { + _spec.ClearField(brokerdispatch.FieldAgentSlug, field.TypeString) + } + if value, ok := _u.mutation.ProjectID(); ok { + _spec.SetField(brokerdispatch.FieldProjectID, field.TypeUUID, value) + } + if _u.mutation.ProjectIDCleared() { + _spec.ClearField(brokerdispatch.FieldProjectID, field.TypeUUID) + } + if value, ok := _u.mutation.GetOp(); ok { + _spec.SetField(brokerdispatch.FieldOp, field.TypeString, value) + } + if value, ok := _u.mutation.Args(); ok { + _spec.SetField(brokerdispatch.FieldArgs, field.TypeString, value) + } + if _u.mutation.ArgsCleared() { + _spec.ClearField(brokerdispatch.FieldArgs, field.TypeString) + } + if value, ok := _u.mutation.State(); ok { + _spec.SetField(brokerdispatch.FieldState, field.TypeString, value) + } + if value, ok := _u.mutation.Result(); ok { + _spec.SetField(brokerdispatch.FieldResult, field.TypeString, value) + } + if _u.mutation.ResultCleared() { + _spec.ClearField(brokerdispatch.FieldResult, field.TypeString) + } + if value, ok := _u.mutation.ClaimedBy(); ok { + _spec.SetField(brokerdispatch.FieldClaimedBy, field.TypeString, value) + } + if _u.mutation.ClaimedByCleared() { + _spec.ClearField(brokerdispatch.FieldClaimedBy, field.TypeString) + } + if value, ok := _u.mutation.Attempts(); ok { + _spec.SetField(brokerdispatch.FieldAttempts, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedAttempts(); ok { + _spec.AddField(brokerdispatch.FieldAttempts, field.TypeInt, value) + } + if value, ok := _u.mutation.Error(); ok { + _spec.SetField(brokerdispatch.FieldError, field.TypeString, value) + } + if _u.mutation.ErrorCleared() { + _spec.ClearField(brokerdispatch.FieldError, field.TypeString) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(brokerdispatch.FieldUpdatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.DeadlineAt(); ok { + _spec.SetField(brokerdispatch.FieldDeadlineAt, field.TypeTime, value) + } + if _u.mutation.DeadlineAtCleared() { + _spec.ClearField(brokerdispatch.FieldDeadlineAt, field.TypeTime) + } + _node = &BrokerDispatch{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{brokerdispatch.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/pkg/ent/client.go b/pkg/ent/client.go index 8b87bf6a9..13fbfb24b 100644 --- a/pkg/ent/client.go +++ b/pkg/ent/client.go @@ -20,6 +20,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/agent" "github.com/GoogleCloudPlatform/scion/pkg/ent/allowlistentry" "github.com/GoogleCloudPlatform/scion/pkg/ent/apikey" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" @@ -61,6 +62,8 @@ type Client struct { AllowListEntry *AllowListEntryClient // ApiKey is the client for interacting with the ApiKey builders. ApiKey *ApiKeyClient + // BrokerDispatch is the client for interacting with the BrokerDispatch builders. + BrokerDispatch *BrokerDispatchClient // BrokerJoinToken is the client for interacting with the BrokerJoinToken builders. BrokerJoinToken *BrokerJoinTokenClient // BrokerSecret is the client for interacting with the BrokerSecret builders. @@ -128,6 +131,7 @@ func (c *Client) init() { c.Agent = NewAgentClient(c.config) c.AllowListEntry = NewAllowListEntryClient(c.config) c.ApiKey = NewApiKeyClient(c.config) + c.BrokerDispatch = NewBrokerDispatchClient(c.config) c.BrokerJoinToken = NewBrokerJoinTokenClient(c.config) c.BrokerSecret = NewBrokerSecretClient(c.config) c.EnvVar = NewEnvVarClient(c.config) @@ -250,6 +254,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { Agent: NewAgentClient(cfg), AllowListEntry: NewAllowListEntryClient(cfg), ApiKey: NewApiKeyClient(cfg), + BrokerDispatch: NewBrokerDispatchClient(cfg), BrokerJoinToken: NewBrokerJoinTokenClient(cfg), BrokerSecret: NewBrokerSecretClient(cfg), EnvVar: NewEnvVarClient(cfg), @@ -299,6 +304,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) Agent: NewAgentClient(cfg), AllowListEntry: NewAllowListEntryClient(cfg), ApiKey: NewApiKeyClient(cfg), + BrokerDispatch: NewBrokerDispatchClient(cfg), BrokerJoinToken: NewBrokerJoinTokenClient(cfg), BrokerSecret: NewBrokerSecretClient(cfg), EnvVar: NewEnvVarClient(cfg), @@ -354,13 +360,14 @@ func (c *Client) Close() error { // In order to add hooks to a specific client, call: `client.Node.Use(...)`. func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ - c.AccessPolicy, c.Agent, c.AllowListEntry, c.ApiKey, c.BrokerJoinToken, - c.BrokerSecret, c.EnvVar, c.GCPServiceAccount, c.GithubInstallation, c.Group, - c.GroupMembership, c.HarnessConfig, c.InviteCode, c.MaintenanceOperation, - c.MaintenanceOperationRun, c.Message, c.Notification, - c.NotificationSubscription, c.PolicyBinding, c.Project, c.ProjectContributor, - c.ProjectSyncState, c.RuntimeBroker, c.Schedule, c.ScheduledEvent, c.Secret, - c.SubscriptionTemplate, c.Template, c.User, c.UserAccessToken, + c.AccessPolicy, c.Agent, c.AllowListEntry, c.ApiKey, c.BrokerDispatch, + c.BrokerJoinToken, c.BrokerSecret, c.EnvVar, c.GCPServiceAccount, + c.GithubInstallation, c.Group, c.GroupMembership, c.HarnessConfig, + c.InviteCode, c.MaintenanceOperation, c.MaintenanceOperationRun, c.Message, + c.Notification, c.NotificationSubscription, c.PolicyBinding, c.Project, + c.ProjectContributor, c.ProjectSyncState, c.RuntimeBroker, c.Schedule, + c.ScheduledEvent, c.Secret, c.SubscriptionTemplate, c.Template, c.User, + c.UserAccessToken, } { n.Use(hooks...) } @@ -370,13 +377,14 @@ func (c *Client) Use(hooks ...Hook) { // In order to add interceptors to a specific client, call: `client.Node.Intercept(...)`. func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ - c.AccessPolicy, c.Agent, c.AllowListEntry, c.ApiKey, c.BrokerJoinToken, - c.BrokerSecret, c.EnvVar, c.GCPServiceAccount, c.GithubInstallation, c.Group, - c.GroupMembership, c.HarnessConfig, c.InviteCode, c.MaintenanceOperation, - c.MaintenanceOperationRun, c.Message, c.Notification, - c.NotificationSubscription, c.PolicyBinding, c.Project, c.ProjectContributor, - c.ProjectSyncState, c.RuntimeBroker, c.Schedule, c.ScheduledEvent, c.Secret, - c.SubscriptionTemplate, c.Template, c.User, c.UserAccessToken, + c.AccessPolicy, c.Agent, c.AllowListEntry, c.ApiKey, c.BrokerDispatch, + c.BrokerJoinToken, c.BrokerSecret, c.EnvVar, c.GCPServiceAccount, + c.GithubInstallation, c.Group, c.GroupMembership, c.HarnessConfig, + c.InviteCode, c.MaintenanceOperation, c.MaintenanceOperationRun, c.Message, + c.Notification, c.NotificationSubscription, c.PolicyBinding, c.Project, + c.ProjectContributor, c.ProjectSyncState, c.RuntimeBroker, c.Schedule, + c.ScheduledEvent, c.Secret, c.SubscriptionTemplate, c.Template, c.User, + c.UserAccessToken, } { n.Intercept(interceptors...) } @@ -393,6 +401,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.AllowListEntry.mutate(ctx, m) case *ApiKeyMutation: return c.ApiKey.mutate(ctx, m) + case *BrokerDispatchMutation: + return c.BrokerDispatch.mutate(ctx, m) case *BrokerJoinTokenMutation: return c.BrokerJoinToken.mutate(ctx, m) case *BrokerSecretMutation: @@ -1078,6 +1088,139 @@ func (c *ApiKeyClient) mutate(ctx context.Context, m *ApiKeyMutation) (Value, er } } +// BrokerDispatchClient is a client for the BrokerDispatch schema. +type BrokerDispatchClient struct { + config +} + +// NewBrokerDispatchClient returns a client for the BrokerDispatch from the given config. +func NewBrokerDispatchClient(c config) *BrokerDispatchClient { + return &BrokerDispatchClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `brokerdispatch.Hooks(f(g(h())))`. +func (c *BrokerDispatchClient) Use(hooks ...Hook) { + c.hooks.BrokerDispatch = append(c.hooks.BrokerDispatch, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `brokerdispatch.Intercept(f(g(h())))`. +func (c *BrokerDispatchClient) Intercept(interceptors ...Interceptor) { + c.inters.BrokerDispatch = append(c.inters.BrokerDispatch, interceptors...) +} + +// Create returns a builder for creating a BrokerDispatch entity. +func (c *BrokerDispatchClient) Create() *BrokerDispatchCreate { + mutation := newBrokerDispatchMutation(c.config, OpCreate) + return &BrokerDispatchCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of BrokerDispatch entities. +func (c *BrokerDispatchClient) CreateBulk(builders ...*BrokerDispatchCreate) *BrokerDispatchCreateBulk { + return &BrokerDispatchCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *BrokerDispatchClient) MapCreateBulk(slice any, setFunc func(*BrokerDispatchCreate, int)) *BrokerDispatchCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &BrokerDispatchCreateBulk{err: fmt.Errorf("calling to BrokerDispatchClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*BrokerDispatchCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &BrokerDispatchCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for BrokerDispatch. +func (c *BrokerDispatchClient) Update() *BrokerDispatchUpdate { + mutation := newBrokerDispatchMutation(c.config, OpUpdate) + return &BrokerDispatchUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *BrokerDispatchClient) UpdateOne(_m *BrokerDispatch) *BrokerDispatchUpdateOne { + mutation := newBrokerDispatchMutation(c.config, OpUpdateOne, withBrokerDispatch(_m)) + return &BrokerDispatchUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *BrokerDispatchClient) UpdateOneID(id uuid.UUID) *BrokerDispatchUpdateOne { + mutation := newBrokerDispatchMutation(c.config, OpUpdateOne, withBrokerDispatchID(id)) + return &BrokerDispatchUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for BrokerDispatch. +func (c *BrokerDispatchClient) Delete() *BrokerDispatchDelete { + mutation := newBrokerDispatchMutation(c.config, OpDelete) + return &BrokerDispatchDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *BrokerDispatchClient) DeleteOne(_m *BrokerDispatch) *BrokerDispatchDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *BrokerDispatchClient) DeleteOneID(id uuid.UUID) *BrokerDispatchDeleteOne { + builder := c.Delete().Where(brokerdispatch.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &BrokerDispatchDeleteOne{builder} +} + +// Query returns a query builder for BrokerDispatch. +func (c *BrokerDispatchClient) Query() *BrokerDispatchQuery { + return &BrokerDispatchQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeBrokerDispatch}, + inters: c.Interceptors(), + } +} + +// Get returns a BrokerDispatch entity by its id. +func (c *BrokerDispatchClient) Get(ctx context.Context, id uuid.UUID) (*BrokerDispatch, error) { + return c.Query().Where(brokerdispatch.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *BrokerDispatchClient) GetX(ctx context.Context, id uuid.UUID) *BrokerDispatch { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// Hooks returns the client hooks. +func (c *BrokerDispatchClient) Hooks() []Hook { + return c.hooks.BrokerDispatch +} + +// Interceptors returns the client interceptors. +func (c *BrokerDispatchClient) Interceptors() []Interceptor { + return c.inters.BrokerDispatch +} + +func (c *BrokerDispatchClient) mutate(ctx context.Context, m *BrokerDispatchMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&BrokerDispatchCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&BrokerDispatchUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&BrokerDispatchUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&BrokerDispatchDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown BrokerDispatch mutation op: %q", m.Op()) + } +} + // BrokerJoinTokenClient is a client for the BrokerJoinToken schema. type BrokerJoinTokenClient struct { config @@ -4827,19 +4970,21 @@ func (c *UserAccessTokenClient) mutate(ctx context.Context, m *UserAccessTokenMu // hooks and interceptors per client, for fast access. type ( hooks struct { - AccessPolicy, Agent, AllowListEntry, ApiKey, BrokerJoinToken, BrokerSecret, - EnvVar, GCPServiceAccount, GithubInstallation, Group, GroupMembership, - HarnessConfig, InviteCode, MaintenanceOperation, MaintenanceOperationRun, - Message, Notification, NotificationSubscription, PolicyBinding, Project, - ProjectContributor, ProjectSyncState, RuntimeBroker, Schedule, ScheduledEvent, - Secret, SubscriptionTemplate, Template, User, UserAccessToken []ent.Hook + AccessPolicy, Agent, AllowListEntry, ApiKey, BrokerDispatch, BrokerJoinToken, + BrokerSecret, EnvVar, GCPServiceAccount, GithubInstallation, Group, + GroupMembership, HarnessConfig, InviteCode, MaintenanceOperation, + MaintenanceOperationRun, Message, Notification, NotificationSubscription, + PolicyBinding, Project, ProjectContributor, ProjectSyncState, RuntimeBroker, + Schedule, ScheduledEvent, Secret, SubscriptionTemplate, Template, User, + UserAccessToken []ent.Hook } inters struct { - AccessPolicy, Agent, AllowListEntry, ApiKey, BrokerJoinToken, BrokerSecret, - EnvVar, GCPServiceAccount, GithubInstallation, Group, GroupMembership, - HarnessConfig, InviteCode, MaintenanceOperation, MaintenanceOperationRun, - Message, Notification, NotificationSubscription, PolicyBinding, Project, - ProjectContributor, ProjectSyncState, RuntimeBroker, Schedule, ScheduledEvent, - Secret, SubscriptionTemplate, Template, User, UserAccessToken []ent.Interceptor + AccessPolicy, Agent, AllowListEntry, ApiKey, BrokerDispatch, BrokerJoinToken, + BrokerSecret, EnvVar, GCPServiceAccount, GithubInstallation, Group, + GroupMembership, HarnessConfig, InviteCode, MaintenanceOperation, + MaintenanceOperationRun, Message, Notification, NotificationSubscription, + PolicyBinding, Project, ProjectContributor, ProjectSyncState, RuntimeBroker, + Schedule, ScheduledEvent, Secret, SubscriptionTemplate, Template, User, + UserAccessToken []ent.Interceptor } ) diff --git a/pkg/ent/ent.go b/pkg/ent/ent.go index 2b9315510..3bae0f4c2 100644 --- a/pkg/ent/ent.go +++ b/pkg/ent/ent.go @@ -16,6 +16,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/agent" "github.com/GoogleCloudPlatform/scion/pkg/ent/allowlistentry" "github.com/GoogleCloudPlatform/scion/pkg/ent/apikey" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" @@ -106,6 +107,7 @@ func checkColumn(t, c string) error { agent.Table: agent.ValidColumn, allowlistentry.Table: allowlistentry.ValidColumn, apikey.Table: apikey.ValidColumn, + brokerdispatch.Table: brokerdispatch.ValidColumn, brokerjointoken.Table: brokerjointoken.ValidColumn, brokersecret.Table: brokersecret.ValidColumn, envvar.Table: envvar.ValidColumn, diff --git a/pkg/ent/hook/hook.go b/pkg/ent/hook/hook.go index 743dd42be..840f24a60 100644 --- a/pkg/ent/hook/hook.go +++ b/pkg/ent/hook/hook.go @@ -57,6 +57,18 @@ func (f ApiKeyFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, erro return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ApiKeyMutation", m) } +// The BrokerDispatchFunc type is an adapter to allow the use of ordinary +// function as BrokerDispatch mutator. +type BrokerDispatchFunc func(context.Context, *ent.BrokerDispatchMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f BrokerDispatchFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.BrokerDispatchMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.BrokerDispatchMutation", m) +} + // The BrokerJoinTokenFunc type is an adapter to allow the use of ordinary // function as BrokerJoinToken mutator. type BrokerJoinTokenFunc func(context.Context, *ent.BrokerJoinTokenMutation) (ent.Value, error) diff --git a/pkg/ent/message.go b/pkg/ent/message.go index d4aaee729..31cf52e9b 100644 --- a/pkg/ent/message.go +++ b/pkg/ent/message.go @@ -42,6 +42,10 @@ type Message struct { AgentID string `json:"agent_id,omitempty"` // GroupID holds the value of the "group_id" field. GroupID string `json:"group_id,omitempty"` + // DispatchState holds the value of the "dispatch_state" field. + DispatchState string `json:"dispatch_state,omitempty"` + // DispatchedAt holds the value of the "dispatched_at" field. + DispatchedAt *time.Time `json:"dispatched_at,omitempty"` // Created holds the value of the "created" field. Created time.Time `json:"created,omitempty"` selectValues sql.SelectValues @@ -54,9 +58,9 @@ func (*Message) scanValues(columns []string) ([]any, error) { switch columns[i] { case message.FieldUrgent, message.FieldBroadcasted, message.FieldRead: values[i] = new(sql.NullBool) - case message.FieldSender, message.FieldSenderID, message.FieldRecipient, message.FieldRecipientID, message.FieldMsg, message.FieldType, message.FieldAgentID, message.FieldGroupID: + case message.FieldSender, message.FieldSenderID, message.FieldRecipient, message.FieldRecipientID, message.FieldMsg, message.FieldType, message.FieldAgentID, message.FieldGroupID, message.FieldDispatchState: values[i] = new(sql.NullString) - case message.FieldCreated: + case message.FieldDispatchedAt, message.FieldCreated: values[i] = new(sql.NullTime) case message.FieldID, message.FieldProjectID: values[i] = new(uuid.UUID) @@ -153,6 +157,19 @@ func (_m *Message) assignValues(columns []string, values []any) error { } else if value.Valid { _m.GroupID = value.String } + case message.FieldDispatchState: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field dispatch_state", values[i]) + } else if value.Valid { + _m.DispatchState = value.String + } + case message.FieldDispatchedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field dispatched_at", values[i]) + } else if value.Valid { + _m.DispatchedAt = new(time.Time) + *_m.DispatchedAt = value.Time + } case message.FieldCreated: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created", values[i]) @@ -231,6 +248,14 @@ func (_m *Message) String() string { builder.WriteString("group_id=") builder.WriteString(_m.GroupID) builder.WriteString(", ") + builder.WriteString("dispatch_state=") + builder.WriteString(_m.DispatchState) + builder.WriteString(", ") + if v := _m.DispatchedAt; v != nil { + builder.WriteString("dispatched_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") builder.WriteString("created=") builder.WriteString(_m.Created.Format(time.ANSIC)) builder.WriteByte(')') diff --git a/pkg/ent/message/message.go b/pkg/ent/message/message.go index 13aa68ae6..c93f6e82c 100644 --- a/pkg/ent/message/message.go +++ b/pkg/ent/message/message.go @@ -38,6 +38,10 @@ const ( FieldAgentID = "agent_id" // FieldGroupID holds the string denoting the group_id field in the database. FieldGroupID = "group_id" + // FieldDispatchState holds the string denoting the dispatch_state field in the database. + FieldDispatchState = "dispatch_state" + // FieldDispatchedAt holds the string denoting the dispatched_at field in the database. + FieldDispatchedAt = "dispatched_at" // FieldCreated holds the string denoting the created field in the database. FieldCreated = "created" // Table holds the table name of the message in the database. @@ -59,6 +63,8 @@ var Columns = []string{ FieldRead, FieldAgentID, FieldGroupID, + FieldDispatchState, + FieldDispatchedAt, FieldCreated, } @@ -87,6 +93,8 @@ var ( DefaultBroadcasted bool // DefaultRead holds the default value on creation for the "read" field. DefaultRead bool + // DefaultDispatchState holds the default value on creation for the "dispatch_state" field. + DefaultDispatchState string // DefaultCreated holds the default value on creation for the "created" field. DefaultCreated func() time.Time // DefaultID holds the default value on creation for the "id" field. @@ -161,6 +169,16 @@ func ByGroupID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldGroupID, opts...).ToFunc() } +// ByDispatchState orders the results by the dispatch_state field. +func ByDispatchState(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDispatchState, opts...).ToFunc() +} + +// ByDispatchedAt orders the results by the dispatched_at field. +func ByDispatchedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDispatchedAt, opts...).ToFunc() +} + // ByCreated orders the results by the created field. func ByCreated(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreated, opts...).ToFunc() diff --git a/pkg/ent/message/where.go b/pkg/ent/message/where.go index 4cbf1a3a3..d38547160 100644 --- a/pkg/ent/message/where.go +++ b/pkg/ent/message/where.go @@ -115,6 +115,16 @@ func GroupID(v string) predicate.Message { return predicate.Message(sql.FieldEQ(FieldGroupID, v)) } +// DispatchState applies equality check predicate on the "dispatch_state" field. It's identical to DispatchStateEQ. +func DispatchState(v string) predicate.Message { + return predicate.Message(sql.FieldEQ(FieldDispatchState, v)) +} + +// DispatchedAt applies equality check predicate on the "dispatched_at" field. It's identical to DispatchedAtEQ. +func DispatchedAt(v time.Time) predicate.Message { + return predicate.Message(sql.FieldEQ(FieldDispatchedAt, v)) +} + // Created applies equality check predicate on the "created" field. It's identical to CreatedEQ. func Created(v time.Time) predicate.Message { return predicate.Message(sql.FieldEQ(FieldCreated, v)) @@ -750,6 +760,121 @@ func GroupIDContainsFold(v string) predicate.Message { return predicate.Message(sql.FieldContainsFold(FieldGroupID, v)) } +// DispatchStateEQ applies the EQ predicate on the "dispatch_state" field. +func DispatchStateEQ(v string) predicate.Message { + return predicate.Message(sql.FieldEQ(FieldDispatchState, v)) +} + +// DispatchStateNEQ applies the NEQ predicate on the "dispatch_state" field. +func DispatchStateNEQ(v string) predicate.Message { + return predicate.Message(sql.FieldNEQ(FieldDispatchState, v)) +} + +// DispatchStateIn applies the In predicate on the "dispatch_state" field. +func DispatchStateIn(vs ...string) predicate.Message { + return predicate.Message(sql.FieldIn(FieldDispatchState, vs...)) +} + +// DispatchStateNotIn applies the NotIn predicate on the "dispatch_state" field. +func DispatchStateNotIn(vs ...string) predicate.Message { + return predicate.Message(sql.FieldNotIn(FieldDispatchState, vs...)) +} + +// DispatchStateGT applies the GT predicate on the "dispatch_state" field. +func DispatchStateGT(v string) predicate.Message { + return predicate.Message(sql.FieldGT(FieldDispatchState, v)) +} + +// DispatchStateGTE applies the GTE predicate on the "dispatch_state" field. +func DispatchStateGTE(v string) predicate.Message { + return predicate.Message(sql.FieldGTE(FieldDispatchState, v)) +} + +// DispatchStateLT applies the LT predicate on the "dispatch_state" field. +func DispatchStateLT(v string) predicate.Message { + return predicate.Message(sql.FieldLT(FieldDispatchState, v)) +} + +// DispatchStateLTE applies the LTE predicate on the "dispatch_state" field. +func DispatchStateLTE(v string) predicate.Message { + return predicate.Message(sql.FieldLTE(FieldDispatchState, v)) +} + +// DispatchStateContains applies the Contains predicate on the "dispatch_state" field. +func DispatchStateContains(v string) predicate.Message { + return predicate.Message(sql.FieldContains(FieldDispatchState, v)) +} + +// DispatchStateHasPrefix applies the HasPrefix predicate on the "dispatch_state" field. +func DispatchStateHasPrefix(v string) predicate.Message { + return predicate.Message(sql.FieldHasPrefix(FieldDispatchState, v)) +} + +// DispatchStateHasSuffix applies the HasSuffix predicate on the "dispatch_state" field. +func DispatchStateHasSuffix(v string) predicate.Message { + return predicate.Message(sql.FieldHasSuffix(FieldDispatchState, v)) +} + +// DispatchStateEqualFold applies the EqualFold predicate on the "dispatch_state" field. +func DispatchStateEqualFold(v string) predicate.Message { + return predicate.Message(sql.FieldEqualFold(FieldDispatchState, v)) +} + +// DispatchStateContainsFold applies the ContainsFold predicate on the "dispatch_state" field. +func DispatchStateContainsFold(v string) predicate.Message { + return predicate.Message(sql.FieldContainsFold(FieldDispatchState, v)) +} + +// DispatchedAtEQ applies the EQ predicate on the "dispatched_at" field. +func DispatchedAtEQ(v time.Time) predicate.Message { + return predicate.Message(sql.FieldEQ(FieldDispatchedAt, v)) +} + +// DispatchedAtNEQ applies the NEQ predicate on the "dispatched_at" field. +func DispatchedAtNEQ(v time.Time) predicate.Message { + return predicate.Message(sql.FieldNEQ(FieldDispatchedAt, v)) +} + +// DispatchedAtIn applies the In predicate on the "dispatched_at" field. +func DispatchedAtIn(vs ...time.Time) predicate.Message { + return predicate.Message(sql.FieldIn(FieldDispatchedAt, vs...)) +} + +// DispatchedAtNotIn applies the NotIn predicate on the "dispatched_at" field. +func DispatchedAtNotIn(vs ...time.Time) predicate.Message { + return predicate.Message(sql.FieldNotIn(FieldDispatchedAt, vs...)) +} + +// DispatchedAtGT applies the GT predicate on the "dispatched_at" field. +func DispatchedAtGT(v time.Time) predicate.Message { + return predicate.Message(sql.FieldGT(FieldDispatchedAt, v)) +} + +// DispatchedAtGTE applies the GTE predicate on the "dispatched_at" field. +func DispatchedAtGTE(v time.Time) predicate.Message { + return predicate.Message(sql.FieldGTE(FieldDispatchedAt, v)) +} + +// DispatchedAtLT applies the LT predicate on the "dispatched_at" field. +func DispatchedAtLT(v time.Time) predicate.Message { + return predicate.Message(sql.FieldLT(FieldDispatchedAt, v)) +} + +// DispatchedAtLTE applies the LTE predicate on the "dispatched_at" field. +func DispatchedAtLTE(v time.Time) predicate.Message { + return predicate.Message(sql.FieldLTE(FieldDispatchedAt, v)) +} + +// DispatchedAtIsNil applies the IsNil predicate on the "dispatched_at" field. +func DispatchedAtIsNil() predicate.Message { + return predicate.Message(sql.FieldIsNull(FieldDispatchedAt)) +} + +// DispatchedAtNotNil applies the NotNil predicate on the "dispatched_at" field. +func DispatchedAtNotNil() predicate.Message { + return predicate.Message(sql.FieldNotNull(FieldDispatchedAt)) +} + // CreatedEQ applies the EQ predicate on the "created" field. func CreatedEQ(v time.Time) predicate.Message { return predicate.Message(sql.FieldEQ(FieldCreated, v)) diff --git a/pkg/ent/message_create.go b/pkg/ent/message_create.go index 198a9b98f..f29d7aa46 100644 --- a/pkg/ent/message_create.go +++ b/pkg/ent/message_create.go @@ -160,6 +160,34 @@ func (_c *MessageCreate) SetNillableGroupID(v *string) *MessageCreate { return _c } +// SetDispatchState sets the "dispatch_state" field. +func (_c *MessageCreate) SetDispatchState(v string) *MessageCreate { + _c.mutation.SetDispatchState(v) + return _c +} + +// SetNillableDispatchState sets the "dispatch_state" field if the given value is not nil. +func (_c *MessageCreate) SetNillableDispatchState(v *string) *MessageCreate { + if v != nil { + _c.SetDispatchState(*v) + } + return _c +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (_c *MessageCreate) SetDispatchedAt(v time.Time) *MessageCreate { + _c.mutation.SetDispatchedAt(v) + return _c +} + +// SetNillableDispatchedAt sets the "dispatched_at" field if the given value is not nil. +func (_c *MessageCreate) SetNillableDispatchedAt(v *time.Time) *MessageCreate { + if v != nil { + _c.SetDispatchedAt(*v) + } + return _c +} + // SetCreated sets the "created" field. func (_c *MessageCreate) SetCreated(v time.Time) *MessageCreate { _c.mutation.SetCreated(v) @@ -239,6 +267,10 @@ func (_c *MessageCreate) defaults() { v := message.DefaultRead _c.mutation.SetRead(v) } + if _, ok := _c.mutation.DispatchState(); !ok { + v := message.DefaultDispatchState + _c.mutation.SetDispatchState(v) + } if _, ok := _c.mutation.Created(); !ok { v := message.DefaultCreated() _c.mutation.SetCreated(v) @@ -290,6 +322,9 @@ func (_c *MessageCreate) check() error { if _, ok := _c.mutation.Read(); !ok { return &ValidationError{Name: "read", err: errors.New(`ent: missing required field "Message.read"`)} } + if _, ok := _c.mutation.DispatchState(); !ok { + return &ValidationError{Name: "dispatch_state", err: errors.New(`ent: missing required field "Message.dispatch_state"`)} + } if _, ok := _c.mutation.Created(); !ok { return &ValidationError{Name: "created", err: errors.New(`ent: missing required field "Message.created"`)} } @@ -377,6 +412,14 @@ func (_c *MessageCreate) createSpec() (*Message, *sqlgraph.CreateSpec) { _spec.SetField(message.FieldGroupID, field.TypeString, value) _node.GroupID = value } + if value, ok := _c.mutation.DispatchState(); ok { + _spec.SetField(message.FieldDispatchState, field.TypeString, value) + _node.DispatchState = value + } + if value, ok := _c.mutation.DispatchedAt(); ok { + _spec.SetField(message.FieldDispatchedAt, field.TypeTime, value) + _node.DispatchedAt = &value + } if value, ok := _c.mutation.Created(); ok { _spec.SetField(message.FieldCreated, field.TypeTime, value) _node.Created = value @@ -601,6 +644,36 @@ func (u *MessageUpsert) ClearGroupID() *MessageUpsert { return u } +// SetDispatchState sets the "dispatch_state" field. +func (u *MessageUpsert) SetDispatchState(v string) *MessageUpsert { + u.Set(message.FieldDispatchState, v) + return u +} + +// UpdateDispatchState sets the "dispatch_state" field to the value that was provided on create. +func (u *MessageUpsert) UpdateDispatchState() *MessageUpsert { + u.SetExcluded(message.FieldDispatchState) + return u +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (u *MessageUpsert) SetDispatchedAt(v time.Time) *MessageUpsert { + u.Set(message.FieldDispatchedAt, v) + return u +} + +// UpdateDispatchedAt sets the "dispatched_at" field to the value that was provided on create. +func (u *MessageUpsert) UpdateDispatchedAt() *MessageUpsert { + u.SetExcluded(message.FieldDispatchedAt) + return u +} + +// ClearDispatchedAt clears the value of the "dispatched_at" field. +func (u *MessageUpsert) ClearDispatchedAt() *MessageUpsert { + u.SetNull(message.FieldDispatchedAt) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. // Using this option is equivalent to using: // @@ -848,6 +921,41 @@ func (u *MessageUpsertOne) ClearGroupID() *MessageUpsertOne { }) } +// SetDispatchState sets the "dispatch_state" field. +func (u *MessageUpsertOne) SetDispatchState(v string) *MessageUpsertOne { + return u.Update(func(s *MessageUpsert) { + s.SetDispatchState(v) + }) +} + +// UpdateDispatchState sets the "dispatch_state" field to the value that was provided on create. +func (u *MessageUpsertOne) UpdateDispatchState() *MessageUpsertOne { + return u.Update(func(s *MessageUpsert) { + s.UpdateDispatchState() + }) +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (u *MessageUpsertOne) SetDispatchedAt(v time.Time) *MessageUpsertOne { + return u.Update(func(s *MessageUpsert) { + s.SetDispatchedAt(v) + }) +} + +// UpdateDispatchedAt sets the "dispatched_at" field to the value that was provided on create. +func (u *MessageUpsertOne) UpdateDispatchedAt() *MessageUpsertOne { + return u.Update(func(s *MessageUpsert) { + s.UpdateDispatchedAt() + }) +} + +// ClearDispatchedAt clears the value of the "dispatched_at" field. +func (u *MessageUpsertOne) ClearDispatchedAt() *MessageUpsertOne { + return u.Update(func(s *MessageUpsert) { + s.ClearDispatchedAt() + }) +} + // Exec executes the query. func (u *MessageUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1262,6 +1370,41 @@ func (u *MessageUpsertBulk) ClearGroupID() *MessageUpsertBulk { }) } +// SetDispatchState sets the "dispatch_state" field. +func (u *MessageUpsertBulk) SetDispatchState(v string) *MessageUpsertBulk { + return u.Update(func(s *MessageUpsert) { + s.SetDispatchState(v) + }) +} + +// UpdateDispatchState sets the "dispatch_state" field to the value that was provided on create. +func (u *MessageUpsertBulk) UpdateDispatchState() *MessageUpsertBulk { + return u.Update(func(s *MessageUpsert) { + s.UpdateDispatchState() + }) +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (u *MessageUpsertBulk) SetDispatchedAt(v time.Time) *MessageUpsertBulk { + return u.Update(func(s *MessageUpsert) { + s.SetDispatchedAt(v) + }) +} + +// UpdateDispatchedAt sets the "dispatched_at" field to the value that was provided on create. +func (u *MessageUpsertBulk) UpdateDispatchedAt() *MessageUpsertBulk { + return u.Update(func(s *MessageUpsert) { + s.UpdateDispatchedAt() + }) +} + +// ClearDispatchedAt clears the value of the "dispatched_at" field. +func (u *MessageUpsertBulk) ClearDispatchedAt() *MessageUpsertBulk { + return u.Update(func(s *MessageUpsert) { + s.ClearDispatchedAt() + }) +} + // Exec executes the query. func (u *MessageUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/pkg/ent/message_update.go b/pkg/ent/message_update.go index 0fdce57a7..5e925590f 100644 --- a/pkg/ent/message_update.go +++ b/pkg/ent/message_update.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "time" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" @@ -220,6 +221,40 @@ func (_u *MessageUpdate) ClearGroupID() *MessageUpdate { return _u } +// SetDispatchState sets the "dispatch_state" field. +func (_u *MessageUpdate) SetDispatchState(v string) *MessageUpdate { + _u.mutation.SetDispatchState(v) + return _u +} + +// SetNillableDispatchState sets the "dispatch_state" field if the given value is not nil. +func (_u *MessageUpdate) SetNillableDispatchState(v *string) *MessageUpdate { + if v != nil { + _u.SetDispatchState(*v) + } + return _u +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (_u *MessageUpdate) SetDispatchedAt(v time.Time) *MessageUpdate { + _u.mutation.SetDispatchedAt(v) + return _u +} + +// SetNillableDispatchedAt sets the "dispatched_at" field if the given value is not nil. +func (_u *MessageUpdate) SetNillableDispatchedAt(v *time.Time) *MessageUpdate { + if v != nil { + _u.SetDispatchedAt(*v) + } + return _u +} + +// ClearDispatchedAt clears the value of the "dispatched_at" field. +func (_u *MessageUpdate) ClearDispatchedAt() *MessageUpdate { + _u.mutation.ClearDispatchedAt() + return _u +} + // Mutation returns the MessageMutation object of the builder. func (_u *MessageUpdate) Mutation() *MessageMutation { return _u.mutation @@ -332,6 +367,15 @@ func (_u *MessageUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.GroupIDCleared() { _spec.ClearField(message.FieldGroupID, field.TypeString) } + if value, ok := _u.mutation.DispatchState(); ok { + _spec.SetField(message.FieldDispatchState, field.TypeString, value) + } + if value, ok := _u.mutation.DispatchedAt(); ok { + _spec.SetField(message.FieldDispatchedAt, field.TypeTime, value) + } + if _u.mutation.DispatchedAtCleared() { + _spec.ClearField(message.FieldDispatchedAt, field.TypeTime) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{message.Label} @@ -544,6 +588,40 @@ func (_u *MessageUpdateOne) ClearGroupID() *MessageUpdateOne { return _u } +// SetDispatchState sets the "dispatch_state" field. +func (_u *MessageUpdateOne) SetDispatchState(v string) *MessageUpdateOne { + _u.mutation.SetDispatchState(v) + return _u +} + +// SetNillableDispatchState sets the "dispatch_state" field if the given value is not nil. +func (_u *MessageUpdateOne) SetNillableDispatchState(v *string) *MessageUpdateOne { + if v != nil { + _u.SetDispatchState(*v) + } + return _u +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (_u *MessageUpdateOne) SetDispatchedAt(v time.Time) *MessageUpdateOne { + _u.mutation.SetDispatchedAt(v) + return _u +} + +// SetNillableDispatchedAt sets the "dispatched_at" field if the given value is not nil. +func (_u *MessageUpdateOne) SetNillableDispatchedAt(v *time.Time) *MessageUpdateOne { + if v != nil { + _u.SetDispatchedAt(*v) + } + return _u +} + +// ClearDispatchedAt clears the value of the "dispatched_at" field. +func (_u *MessageUpdateOne) ClearDispatchedAt() *MessageUpdateOne { + _u.mutation.ClearDispatchedAt() + return _u +} + // Mutation returns the MessageMutation object of the builder. func (_u *MessageUpdateOne) Mutation() *MessageMutation { return _u.mutation @@ -686,6 +764,15 @@ func (_u *MessageUpdateOne) sqlSave(ctx context.Context) (_node *Message, err er if _u.mutation.GroupIDCleared() { _spec.ClearField(message.FieldGroupID, field.TypeString) } + if value, ok := _u.mutation.DispatchState(); ok { + _spec.SetField(message.FieldDispatchState, field.TypeString, value) + } + if value, ok := _u.mutation.DispatchedAt(); ok { + _spec.SetField(message.FieldDispatchedAt, field.TypeTime, value) + } + if _u.mutation.DispatchedAtCleared() { + _spec.ClearField(message.FieldDispatchedAt, field.TypeTime) + } _node = &Message{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/pkg/ent/migrate/schema.go b/pkg/ent/migrate/schema.go index c12fac15e..a68e381ac 100644 --- a/pkg/ent/migrate/schema.go +++ b/pkg/ent/migrate/schema.go @@ -155,6 +155,37 @@ var ( }, }, } + // BrokerDispatchColumns holds the columns for the "broker_dispatch" table. + BrokerDispatchColumns = []*schema.Column{ + {Name: "id", Type: field.TypeUUID}, + {Name: "broker_id", Type: field.TypeUUID}, + {Name: "agent_id", Type: field.TypeUUID, Nullable: true}, + {Name: "agent_slug", Type: field.TypeString, Nullable: true}, + {Name: "project_id", Type: field.TypeUUID, Nullable: true}, + {Name: "op", Type: field.TypeString}, + {Name: "args", Type: field.TypeString, Nullable: true}, + {Name: "state", Type: field.TypeString, Default: "pending"}, + {Name: "result", Type: field.TypeString, Nullable: true}, + {Name: "claimed_by", Type: field.TypeString, Nullable: true}, + {Name: "attempts", Type: field.TypeInt, Default: 0}, + {Name: "error", Type: field.TypeString, Nullable: true}, + {Name: "created_at", Type: field.TypeTime}, + {Name: "updated_at", Type: field.TypeTime}, + {Name: "deadline_at", Type: field.TypeTime, Nullable: true}, + } + // BrokerDispatchTable holds the schema information for the "broker_dispatch" table. + BrokerDispatchTable = &schema.Table{ + Name: "broker_dispatch", + Columns: BrokerDispatchColumns, + PrimaryKey: []*schema.Column{BrokerDispatchColumns[0]}, + Indexes: []*schema.Index{ + { + Name: "brokerdispatch_broker_id_state", + Unique: false, + Columns: []*schema.Column{BrokerDispatchColumns[1], BrokerDispatchColumns[7]}, + }, + }, + } // BrokerJoinTokensColumns holds the columns for the "broker_join_tokens" table. BrokerJoinTokensColumns = []*schema.Column{ {Name: "broker_id", Type: field.TypeUUID}, @@ -492,6 +523,8 @@ var ( {Name: "read", Type: field.TypeBool, Default: false}, {Name: "agent_id", Type: field.TypeString, Nullable: true}, {Name: "group_id", Type: field.TypeString, Nullable: true}, + {Name: "dispatch_state", Type: field.TypeString, Default: "pending"}, + {Name: "dispatched_at", Type: field.TypeTime, Nullable: true}, {Name: "created", Type: field.TypeTime}, } // MessagesTable holds the schema information for the "messages" table. @@ -513,7 +546,7 @@ var ( { Name: "message_created", Unique: false, - Columns: []*schema.Column{MessagesColumns[13]}, + Columns: []*schema.Column{MessagesColumns[15]}, }, }, } @@ -724,6 +757,9 @@ var ( {Name: "endpoint", Type: field.TypeString, Nullable: true}, {Name: "created_by", Type: field.TypeString, Nullable: true}, {Name: "auto_provide", Type: field.TypeBool, Default: false}, + {Name: "connected_hub_id", Type: field.TypeString, Nullable: true}, + {Name: "connected_session_id", Type: field.TypeString, Nullable: true}, + {Name: "connected_at", Type: field.TypeTime, Nullable: true}, {Name: "created", Type: field.TypeTime}, {Name: "updated", Type: field.TypeTime}, } @@ -1024,6 +1060,7 @@ var ( AgentsTable, AllowListTable, APIKeysTable, + BrokerDispatchTable, BrokerJoinTokensTable, BrokerSecretsTable, EnvVarsTable, @@ -1064,6 +1101,9 @@ func init() { APIKeysTable.Annotation = &entsql.Annotation{ Table: "api_keys", } + BrokerDispatchTable.Annotation = &entsql.Annotation{ + Table: "broker_dispatch", + } BrokerJoinTokensTable.Annotation = &entsql.Annotation{ Table: "broker_join_tokens", } diff --git a/pkg/ent/mutation.go b/pkg/ent/mutation.go index 37ce86a44..514f2a0e8 100644 --- a/pkg/ent/mutation.go +++ b/pkg/ent/mutation.go @@ -15,6 +15,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/agent" "github.com/GoogleCloudPlatform/scion/pkg/ent/allowlistentry" "github.com/GoogleCloudPlatform/scion/pkg/ent/apikey" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" @@ -59,6 +60,7 @@ const ( TypeAgent = "Agent" TypeAllowListEntry = "AllowListEntry" TypeApiKey = "ApiKey" + TypeBrokerDispatch = "BrokerDispatch" TypeBrokerJoinToken = "BrokerJoinToken" TypeBrokerSecret = "BrokerSecret" TypeEnvVar = "EnvVar" @@ -6016,6 +6018,1231 @@ func (m *ApiKeyMutation) ResetEdge(name string) error { return fmt.Errorf("unknown ApiKey edge %s", name) } +// BrokerDispatchMutation represents an operation that mutates the BrokerDispatch nodes in the graph. +type BrokerDispatchMutation struct { + config + op Op + typ string + id *uuid.UUID + broker_id *uuid.UUID + agent_id *uuid.UUID + agent_slug *string + project_id *uuid.UUID + _op *string + args *string + state *string + result *string + claimed_by *string + attempts *int + addattempts *int + error *string + created_at *time.Time + updated_at *time.Time + deadline_at *time.Time + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*BrokerDispatch, error) + predicates []predicate.BrokerDispatch +} + +var _ ent.Mutation = (*BrokerDispatchMutation)(nil) + +// brokerdispatchOption allows management of the mutation configuration using functional options. +type brokerdispatchOption func(*BrokerDispatchMutation) + +// newBrokerDispatchMutation creates new mutation for the BrokerDispatch entity. +func newBrokerDispatchMutation(c config, op Op, opts ...brokerdispatchOption) *BrokerDispatchMutation { + m := &BrokerDispatchMutation{ + config: c, + op: op, + typ: TypeBrokerDispatch, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withBrokerDispatchID sets the ID field of the mutation. +func withBrokerDispatchID(id uuid.UUID) brokerdispatchOption { + return func(m *BrokerDispatchMutation) { + var ( + err error + once sync.Once + value *BrokerDispatch + ) + m.oldValue = func(ctx context.Context) (*BrokerDispatch, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().BrokerDispatch.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withBrokerDispatch sets the old BrokerDispatch of the mutation. +func withBrokerDispatch(node *BrokerDispatch) brokerdispatchOption { + return func(m *BrokerDispatchMutation) { + m.oldValue = func(context.Context) (*BrokerDispatch, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m BrokerDispatchMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m BrokerDispatchMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// SetID sets the value of the id field. Note that this +// operation is only accepted on creation of BrokerDispatch entities. +func (m *BrokerDispatchMutation) SetID(id uuid.UUID) { + m.id = &id +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *BrokerDispatchMutation) ID() (id uuid.UUID, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *BrokerDispatchMutation) IDs(ctx context.Context) ([]uuid.UUID, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []uuid.UUID{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().BrokerDispatch.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetBrokerID sets the "broker_id" field. +func (m *BrokerDispatchMutation) SetBrokerID(u uuid.UUID) { + m.broker_id = &u +} + +// BrokerID returns the value of the "broker_id" field in the mutation. +func (m *BrokerDispatchMutation) BrokerID() (r uuid.UUID, exists bool) { + v := m.broker_id + if v == nil { + return + } + return *v, true +} + +// OldBrokerID returns the old "broker_id" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldBrokerID(ctx context.Context) (v uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBrokerID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBrokerID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBrokerID: %w", err) + } + return oldValue.BrokerID, nil +} + +// ResetBrokerID resets all changes to the "broker_id" field. +func (m *BrokerDispatchMutation) ResetBrokerID() { + m.broker_id = nil +} + +// SetAgentID sets the "agent_id" field. +func (m *BrokerDispatchMutation) SetAgentID(u uuid.UUID) { + m.agent_id = &u +} + +// AgentID returns the value of the "agent_id" field in the mutation. +func (m *BrokerDispatchMutation) AgentID() (r uuid.UUID, exists bool) { + v := m.agent_id + if v == nil { + return + } + return *v, true +} + +// OldAgentID returns the old "agent_id" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldAgentID(ctx context.Context) (v *uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAgentID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAgentID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAgentID: %w", err) + } + return oldValue.AgentID, nil +} + +// ClearAgentID clears the value of the "agent_id" field. +func (m *BrokerDispatchMutation) ClearAgentID() { + m.agent_id = nil + m.clearedFields[brokerdispatch.FieldAgentID] = struct{}{} +} + +// AgentIDCleared returns if the "agent_id" field was cleared in this mutation. +func (m *BrokerDispatchMutation) AgentIDCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldAgentID] + return ok +} + +// ResetAgentID resets all changes to the "agent_id" field. +func (m *BrokerDispatchMutation) ResetAgentID() { + m.agent_id = nil + delete(m.clearedFields, brokerdispatch.FieldAgentID) +} + +// SetAgentSlug sets the "agent_slug" field. +func (m *BrokerDispatchMutation) SetAgentSlug(s string) { + m.agent_slug = &s +} + +// AgentSlug returns the value of the "agent_slug" field in the mutation. +func (m *BrokerDispatchMutation) AgentSlug() (r string, exists bool) { + v := m.agent_slug + if v == nil { + return + } + return *v, true +} + +// OldAgentSlug returns the old "agent_slug" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldAgentSlug(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAgentSlug is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAgentSlug requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAgentSlug: %w", err) + } + return oldValue.AgentSlug, nil +} + +// ClearAgentSlug clears the value of the "agent_slug" field. +func (m *BrokerDispatchMutation) ClearAgentSlug() { + m.agent_slug = nil + m.clearedFields[brokerdispatch.FieldAgentSlug] = struct{}{} +} + +// AgentSlugCleared returns if the "agent_slug" field was cleared in this mutation. +func (m *BrokerDispatchMutation) AgentSlugCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldAgentSlug] + return ok +} + +// ResetAgentSlug resets all changes to the "agent_slug" field. +func (m *BrokerDispatchMutation) ResetAgentSlug() { + m.agent_slug = nil + delete(m.clearedFields, brokerdispatch.FieldAgentSlug) +} + +// SetProjectID sets the "project_id" field. +func (m *BrokerDispatchMutation) SetProjectID(u uuid.UUID) { + m.project_id = &u +} + +// ProjectID returns the value of the "project_id" field in the mutation. +func (m *BrokerDispatchMutation) ProjectID() (r uuid.UUID, exists bool) { + v := m.project_id + if v == nil { + return + } + return *v, true +} + +// OldProjectID returns the old "project_id" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldProjectID(ctx context.Context) (v *uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProjectID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProjectID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProjectID: %w", err) + } + return oldValue.ProjectID, nil +} + +// ClearProjectID clears the value of the "project_id" field. +func (m *BrokerDispatchMutation) ClearProjectID() { + m.project_id = nil + m.clearedFields[brokerdispatch.FieldProjectID] = struct{}{} +} + +// ProjectIDCleared returns if the "project_id" field was cleared in this mutation. +func (m *BrokerDispatchMutation) ProjectIDCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldProjectID] + return ok +} + +// ResetProjectID resets all changes to the "project_id" field. +func (m *BrokerDispatchMutation) ResetProjectID() { + m.project_id = nil + delete(m.clearedFields, brokerdispatch.FieldProjectID) +} + +// SetOpField sets the "op" field. +func (m *BrokerDispatchMutation) SetOpField(s string) { + m._op = &s +} + +// GetOp returns the value of the "op" field in the mutation. +func (m *BrokerDispatchMutation) GetOp() (r string, exists bool) { + v := m._op + if v == nil { + return + } + return *v, true +} + +// OldOp returns the old "op" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldOp(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldOp is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldOp requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldOp: %w", err) + } + return oldValue.Op, nil +} + +// ResetOp resets all changes to the "op" field. +func (m *BrokerDispatchMutation) ResetOp() { + m._op = nil +} + +// SetArgs sets the "args" field. +func (m *BrokerDispatchMutation) SetArgs(s string) { + m.args = &s +} + +// Args returns the value of the "args" field in the mutation. +func (m *BrokerDispatchMutation) Args() (r string, exists bool) { + v := m.args + if v == nil { + return + } + return *v, true +} + +// OldArgs returns the old "args" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldArgs(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldArgs is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldArgs requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldArgs: %w", err) + } + return oldValue.Args, nil +} + +// ClearArgs clears the value of the "args" field. +func (m *BrokerDispatchMutation) ClearArgs() { + m.args = nil + m.clearedFields[brokerdispatch.FieldArgs] = struct{}{} +} + +// ArgsCleared returns if the "args" field was cleared in this mutation. +func (m *BrokerDispatchMutation) ArgsCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldArgs] + return ok +} + +// ResetArgs resets all changes to the "args" field. +func (m *BrokerDispatchMutation) ResetArgs() { + m.args = nil + delete(m.clearedFields, brokerdispatch.FieldArgs) +} + +// SetState sets the "state" field. +func (m *BrokerDispatchMutation) SetState(s string) { + m.state = &s +} + +// State returns the value of the "state" field in the mutation. +func (m *BrokerDispatchMutation) State() (r string, exists bool) { + v := m.state + if v == nil { + return + } + return *v, true +} + +// OldState returns the old "state" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldState(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldState is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldState requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldState: %w", err) + } + return oldValue.State, nil +} + +// ResetState resets all changes to the "state" field. +func (m *BrokerDispatchMutation) ResetState() { + m.state = nil +} + +// SetResult sets the "result" field. +func (m *BrokerDispatchMutation) SetResult(s string) { + m.result = &s +} + +// Result returns the value of the "result" field in the mutation. +func (m *BrokerDispatchMutation) Result() (r string, exists bool) { + v := m.result + if v == nil { + return + } + return *v, true +} + +// OldResult returns the old "result" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldResult(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldResult is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldResult requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldResult: %w", err) + } + return oldValue.Result, nil +} + +// ClearResult clears the value of the "result" field. +func (m *BrokerDispatchMutation) ClearResult() { + m.result = nil + m.clearedFields[brokerdispatch.FieldResult] = struct{}{} +} + +// ResultCleared returns if the "result" field was cleared in this mutation. +func (m *BrokerDispatchMutation) ResultCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldResult] + return ok +} + +// ResetResult resets all changes to the "result" field. +func (m *BrokerDispatchMutation) ResetResult() { + m.result = nil + delete(m.clearedFields, brokerdispatch.FieldResult) +} + +// SetClaimedBy sets the "claimed_by" field. +func (m *BrokerDispatchMutation) SetClaimedBy(s string) { + m.claimed_by = &s +} + +// ClaimedBy returns the value of the "claimed_by" field in the mutation. +func (m *BrokerDispatchMutation) ClaimedBy() (r string, exists bool) { + v := m.claimed_by + if v == nil { + return + } + return *v, true +} + +// OldClaimedBy returns the old "claimed_by" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldClaimedBy(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClaimedBy is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClaimedBy requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClaimedBy: %w", err) + } + return oldValue.ClaimedBy, nil +} + +// ClearClaimedBy clears the value of the "claimed_by" field. +func (m *BrokerDispatchMutation) ClearClaimedBy() { + m.claimed_by = nil + m.clearedFields[brokerdispatch.FieldClaimedBy] = struct{}{} +} + +// ClaimedByCleared returns if the "claimed_by" field was cleared in this mutation. +func (m *BrokerDispatchMutation) ClaimedByCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldClaimedBy] + return ok +} + +// ResetClaimedBy resets all changes to the "claimed_by" field. +func (m *BrokerDispatchMutation) ResetClaimedBy() { + m.claimed_by = nil + delete(m.clearedFields, brokerdispatch.FieldClaimedBy) +} + +// SetAttempts sets the "attempts" field. +func (m *BrokerDispatchMutation) SetAttempts(i int) { + m.attempts = &i + m.addattempts = nil +} + +// Attempts returns the value of the "attempts" field in the mutation. +func (m *BrokerDispatchMutation) Attempts() (r int, exists bool) { + v := m.attempts + if v == nil { + return + } + return *v, true +} + +// OldAttempts returns the old "attempts" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldAttempts(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAttempts is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAttempts requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAttempts: %w", err) + } + return oldValue.Attempts, nil +} + +// AddAttempts adds i to the "attempts" field. +func (m *BrokerDispatchMutation) AddAttempts(i int) { + if m.addattempts != nil { + *m.addattempts += i + } else { + m.addattempts = &i + } +} + +// AddedAttempts returns the value that was added to the "attempts" field in this mutation. +func (m *BrokerDispatchMutation) AddedAttempts() (r int, exists bool) { + v := m.addattempts + if v == nil { + return + } + return *v, true +} + +// ResetAttempts resets all changes to the "attempts" field. +func (m *BrokerDispatchMutation) ResetAttempts() { + m.attempts = nil + m.addattempts = nil +} + +// SetError sets the "error" field. +func (m *BrokerDispatchMutation) SetError(s string) { + m.error = &s +} + +// Error returns the value of the "error" field in the mutation. +func (m *BrokerDispatchMutation) Error() (r string, exists bool) { + v := m.error + if v == nil { + return + } + return *v, true +} + +// OldError returns the old "error" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldError(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldError is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldError requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldError: %w", err) + } + return oldValue.Error, nil +} + +// ClearError clears the value of the "error" field. +func (m *BrokerDispatchMutation) ClearError() { + m.error = nil + m.clearedFields[brokerdispatch.FieldError] = struct{}{} +} + +// ErrorCleared returns if the "error" field was cleared in this mutation. +func (m *BrokerDispatchMutation) ErrorCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldError] + return ok +} + +// ResetError resets all changes to the "error" field. +func (m *BrokerDispatchMutation) ResetError() { + m.error = nil + delete(m.clearedFields, brokerdispatch.FieldError) +} + +// SetCreatedAt sets the "created_at" field. +func (m *BrokerDispatchMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *BrokerDispatchMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *BrokerDispatchMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *BrokerDispatchMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *BrokerDispatchMutation) UpdatedAt() (r time.Time, exists bool) { + v := m.updated_at + if v == nil { + return + } + return *v, true +} + +// OldUpdatedAt returns the old "updated_at" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) + } + return oldValue.UpdatedAt, nil +} + +// ResetUpdatedAt resets all changes to the "updated_at" field. +func (m *BrokerDispatchMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// SetDeadlineAt sets the "deadline_at" field. +func (m *BrokerDispatchMutation) SetDeadlineAt(t time.Time) { + m.deadline_at = &t +} + +// DeadlineAt returns the value of the "deadline_at" field in the mutation. +func (m *BrokerDispatchMutation) DeadlineAt() (r time.Time, exists bool) { + v := m.deadline_at + if v == nil { + return + } + return *v, true +} + +// OldDeadlineAt returns the old "deadline_at" field's value of the BrokerDispatch entity. +// If the BrokerDispatch object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *BrokerDispatchMutation) OldDeadlineAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDeadlineAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDeadlineAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDeadlineAt: %w", err) + } + return oldValue.DeadlineAt, nil +} + +// ClearDeadlineAt clears the value of the "deadline_at" field. +func (m *BrokerDispatchMutation) ClearDeadlineAt() { + m.deadline_at = nil + m.clearedFields[brokerdispatch.FieldDeadlineAt] = struct{}{} +} + +// DeadlineAtCleared returns if the "deadline_at" field was cleared in this mutation. +func (m *BrokerDispatchMutation) DeadlineAtCleared() bool { + _, ok := m.clearedFields[brokerdispatch.FieldDeadlineAt] + return ok +} + +// ResetDeadlineAt resets all changes to the "deadline_at" field. +func (m *BrokerDispatchMutation) ResetDeadlineAt() { + m.deadline_at = nil + delete(m.clearedFields, brokerdispatch.FieldDeadlineAt) +} + +// Where appends a list predicates to the BrokerDispatchMutation builder. +func (m *BrokerDispatchMutation) Where(ps ...predicate.BrokerDispatch) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the BrokerDispatchMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *BrokerDispatchMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.BrokerDispatch, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *BrokerDispatchMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *BrokerDispatchMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (BrokerDispatch). +func (m *BrokerDispatchMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *BrokerDispatchMutation) Fields() []string { + fields := make([]string, 0, 14) + if m.broker_id != nil { + fields = append(fields, brokerdispatch.FieldBrokerID) + } + if m.agent_id != nil { + fields = append(fields, brokerdispatch.FieldAgentID) + } + if m.agent_slug != nil { + fields = append(fields, brokerdispatch.FieldAgentSlug) + } + if m.project_id != nil { + fields = append(fields, brokerdispatch.FieldProjectID) + } + if m._op != nil { + fields = append(fields, brokerdispatch.FieldOp) + } + if m.args != nil { + fields = append(fields, brokerdispatch.FieldArgs) + } + if m.state != nil { + fields = append(fields, brokerdispatch.FieldState) + } + if m.result != nil { + fields = append(fields, brokerdispatch.FieldResult) + } + if m.claimed_by != nil { + fields = append(fields, brokerdispatch.FieldClaimedBy) + } + if m.attempts != nil { + fields = append(fields, brokerdispatch.FieldAttempts) + } + if m.error != nil { + fields = append(fields, brokerdispatch.FieldError) + } + if m.created_at != nil { + fields = append(fields, brokerdispatch.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, brokerdispatch.FieldUpdatedAt) + } + if m.deadline_at != nil { + fields = append(fields, brokerdispatch.FieldDeadlineAt) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *BrokerDispatchMutation) Field(name string) (ent.Value, bool) { + switch name { + case brokerdispatch.FieldBrokerID: + return m.BrokerID() + case brokerdispatch.FieldAgentID: + return m.AgentID() + case brokerdispatch.FieldAgentSlug: + return m.AgentSlug() + case brokerdispatch.FieldProjectID: + return m.ProjectID() + case brokerdispatch.FieldOp: + return m.GetOp() + case brokerdispatch.FieldArgs: + return m.Args() + case brokerdispatch.FieldState: + return m.State() + case brokerdispatch.FieldResult: + return m.Result() + case brokerdispatch.FieldClaimedBy: + return m.ClaimedBy() + case brokerdispatch.FieldAttempts: + return m.Attempts() + case brokerdispatch.FieldError: + return m.Error() + case brokerdispatch.FieldCreatedAt: + return m.CreatedAt() + case brokerdispatch.FieldUpdatedAt: + return m.UpdatedAt() + case brokerdispatch.FieldDeadlineAt: + return m.DeadlineAt() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *BrokerDispatchMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case brokerdispatch.FieldBrokerID: + return m.OldBrokerID(ctx) + case brokerdispatch.FieldAgentID: + return m.OldAgentID(ctx) + case brokerdispatch.FieldAgentSlug: + return m.OldAgentSlug(ctx) + case brokerdispatch.FieldProjectID: + return m.OldProjectID(ctx) + case brokerdispatch.FieldOp: + return m.OldOp(ctx) + case brokerdispatch.FieldArgs: + return m.OldArgs(ctx) + case brokerdispatch.FieldState: + return m.OldState(ctx) + case brokerdispatch.FieldResult: + return m.OldResult(ctx) + case brokerdispatch.FieldClaimedBy: + return m.OldClaimedBy(ctx) + case brokerdispatch.FieldAttempts: + return m.OldAttempts(ctx) + case brokerdispatch.FieldError: + return m.OldError(ctx) + case brokerdispatch.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case brokerdispatch.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + case brokerdispatch.FieldDeadlineAt: + return m.OldDeadlineAt(ctx) + } + return nil, fmt.Errorf("unknown BrokerDispatch field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *BrokerDispatchMutation) SetField(name string, value ent.Value) error { + switch name { + case brokerdispatch.FieldBrokerID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBrokerID(v) + return nil + case brokerdispatch.FieldAgentID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAgentID(v) + return nil + case brokerdispatch.FieldAgentSlug: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAgentSlug(v) + return nil + case brokerdispatch.FieldProjectID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProjectID(v) + return nil + case brokerdispatch.FieldOp: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetOpField(v) + return nil + case brokerdispatch.FieldArgs: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetArgs(v) + return nil + case brokerdispatch.FieldState: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetState(v) + return nil + case brokerdispatch.FieldResult: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetResult(v) + return nil + case brokerdispatch.FieldClaimedBy: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClaimedBy(v) + return nil + case brokerdispatch.FieldAttempts: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAttempts(v) + return nil + case brokerdispatch.FieldError: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetError(v) + return nil + case brokerdispatch.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case brokerdispatch.FieldUpdatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedAt(v) + return nil + case brokerdispatch.FieldDeadlineAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDeadlineAt(v) + return nil + } + return fmt.Errorf("unknown BrokerDispatch field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *BrokerDispatchMutation) AddedFields() []string { + var fields []string + if m.addattempts != nil { + fields = append(fields, brokerdispatch.FieldAttempts) + } + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *BrokerDispatchMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case brokerdispatch.FieldAttempts: + return m.AddedAttempts() + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *BrokerDispatchMutation) AddField(name string, value ent.Value) error { + switch name { + case brokerdispatch.FieldAttempts: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAttempts(v) + return nil + } + return fmt.Errorf("unknown BrokerDispatch numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *BrokerDispatchMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(brokerdispatch.FieldAgentID) { + fields = append(fields, brokerdispatch.FieldAgentID) + } + if m.FieldCleared(brokerdispatch.FieldAgentSlug) { + fields = append(fields, brokerdispatch.FieldAgentSlug) + } + if m.FieldCleared(brokerdispatch.FieldProjectID) { + fields = append(fields, brokerdispatch.FieldProjectID) + } + if m.FieldCleared(brokerdispatch.FieldArgs) { + fields = append(fields, brokerdispatch.FieldArgs) + } + if m.FieldCleared(brokerdispatch.FieldResult) { + fields = append(fields, brokerdispatch.FieldResult) + } + if m.FieldCleared(brokerdispatch.FieldClaimedBy) { + fields = append(fields, brokerdispatch.FieldClaimedBy) + } + if m.FieldCleared(brokerdispatch.FieldError) { + fields = append(fields, brokerdispatch.FieldError) + } + if m.FieldCleared(brokerdispatch.FieldDeadlineAt) { + fields = append(fields, brokerdispatch.FieldDeadlineAt) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *BrokerDispatchMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *BrokerDispatchMutation) ClearField(name string) error { + switch name { + case brokerdispatch.FieldAgentID: + m.ClearAgentID() + return nil + case brokerdispatch.FieldAgentSlug: + m.ClearAgentSlug() + return nil + case brokerdispatch.FieldProjectID: + m.ClearProjectID() + return nil + case brokerdispatch.FieldArgs: + m.ClearArgs() + return nil + case brokerdispatch.FieldResult: + m.ClearResult() + return nil + case brokerdispatch.FieldClaimedBy: + m.ClearClaimedBy() + return nil + case brokerdispatch.FieldError: + m.ClearError() + return nil + case brokerdispatch.FieldDeadlineAt: + m.ClearDeadlineAt() + return nil + } + return fmt.Errorf("unknown BrokerDispatch nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *BrokerDispatchMutation) ResetField(name string) error { + switch name { + case brokerdispatch.FieldBrokerID: + m.ResetBrokerID() + return nil + case brokerdispatch.FieldAgentID: + m.ResetAgentID() + return nil + case brokerdispatch.FieldAgentSlug: + m.ResetAgentSlug() + return nil + case brokerdispatch.FieldProjectID: + m.ResetProjectID() + return nil + case brokerdispatch.FieldOp: + m.ResetOp() + return nil + case brokerdispatch.FieldArgs: + m.ResetArgs() + return nil + case brokerdispatch.FieldState: + m.ResetState() + return nil + case brokerdispatch.FieldResult: + m.ResetResult() + return nil + case brokerdispatch.FieldClaimedBy: + m.ResetClaimedBy() + return nil + case brokerdispatch.FieldAttempts: + m.ResetAttempts() + return nil + case brokerdispatch.FieldError: + m.ResetError() + return nil + case brokerdispatch.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case brokerdispatch.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + case brokerdispatch.FieldDeadlineAt: + m.ResetDeadlineAt() + return nil + } + return fmt.Errorf("unknown BrokerDispatch field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *BrokerDispatchMutation) AddedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *BrokerDispatchMutation) AddedIDs(name string) []ent.Value { + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *BrokerDispatchMutation) RemovedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *BrokerDispatchMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *BrokerDispatchMutation) ClearedEdges() []string { + edges := make([]string, 0, 0) + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *BrokerDispatchMutation) EdgeCleared(name string) bool { + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *BrokerDispatchMutation) ClearEdge(name string) error { + return fmt.Errorf("unknown BrokerDispatch unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *BrokerDispatchMutation) ResetEdge(name string) error { + return fmt.Errorf("unknown BrokerDispatch edge %s", name) +} + // BrokerJoinTokenMutation represents an operation that mutates the BrokerJoinToken nodes in the graph. type BrokerJoinTokenMutation struct { config @@ -16037,26 +17264,28 @@ func (m *MaintenanceOperationRunMutation) ResetEdge(name string) error { // MessageMutation represents an operation that mutates the Message nodes in the graph. type MessageMutation struct { config - op Op - typ string - id *uuid.UUID - project_id *uuid.UUID - sender *string - sender_id *string - recipient *string - recipient_id *string - msg *string - _type *string - urgent *bool - broadcasted *bool - read *bool - agent_id *string - group_id *string - created *time.Time - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*Message, error) - predicates []predicate.Message + op Op + typ string + id *uuid.UUID + project_id *uuid.UUID + sender *string + sender_id *string + recipient *string + recipient_id *string + msg *string + _type *string + urgent *bool + broadcasted *bool + read *bool + agent_id *string + group_id *string + dispatch_state *string + dispatched_at *time.Time + created *time.Time + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*Message, error) + predicates []predicate.Message } var _ ent.Mutation = (*MessageMutation)(nil) @@ -16647,6 +17876,91 @@ func (m *MessageMutation) ResetGroupID() { delete(m.clearedFields, message.FieldGroupID) } +// SetDispatchState sets the "dispatch_state" field. +func (m *MessageMutation) SetDispatchState(s string) { + m.dispatch_state = &s +} + +// DispatchState returns the value of the "dispatch_state" field in the mutation. +func (m *MessageMutation) DispatchState() (r string, exists bool) { + v := m.dispatch_state + if v == nil { + return + } + return *v, true +} + +// OldDispatchState returns the old "dispatch_state" field's value of the Message entity. +// If the Message object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *MessageMutation) OldDispatchState(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDispatchState is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDispatchState requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDispatchState: %w", err) + } + return oldValue.DispatchState, nil +} + +// ResetDispatchState resets all changes to the "dispatch_state" field. +func (m *MessageMutation) ResetDispatchState() { + m.dispatch_state = nil +} + +// SetDispatchedAt sets the "dispatched_at" field. +func (m *MessageMutation) SetDispatchedAt(t time.Time) { + m.dispatched_at = &t +} + +// DispatchedAt returns the value of the "dispatched_at" field in the mutation. +func (m *MessageMutation) DispatchedAt() (r time.Time, exists bool) { + v := m.dispatched_at + if v == nil { + return + } + return *v, true +} + +// OldDispatchedAt returns the old "dispatched_at" field's value of the Message entity. +// If the Message object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *MessageMutation) OldDispatchedAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDispatchedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDispatchedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDispatchedAt: %w", err) + } + return oldValue.DispatchedAt, nil +} + +// ClearDispatchedAt clears the value of the "dispatched_at" field. +func (m *MessageMutation) ClearDispatchedAt() { + m.dispatched_at = nil + m.clearedFields[message.FieldDispatchedAt] = struct{}{} +} + +// DispatchedAtCleared returns if the "dispatched_at" field was cleared in this mutation. +func (m *MessageMutation) DispatchedAtCleared() bool { + _, ok := m.clearedFields[message.FieldDispatchedAt] + return ok +} + +// ResetDispatchedAt resets all changes to the "dispatched_at" field. +func (m *MessageMutation) ResetDispatchedAt() { + m.dispatched_at = nil + delete(m.clearedFields, message.FieldDispatchedAt) +} + // SetCreated sets the "created" field. func (m *MessageMutation) SetCreated(t time.Time) { m.created = &t @@ -16717,7 +18031,7 @@ func (m *MessageMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *MessageMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 15) if m.project_id != nil { fields = append(fields, message.FieldProjectID) } @@ -16754,6 +18068,12 @@ func (m *MessageMutation) Fields() []string { if m.group_id != nil { fields = append(fields, message.FieldGroupID) } + if m.dispatch_state != nil { + fields = append(fields, message.FieldDispatchState) + } + if m.dispatched_at != nil { + fields = append(fields, message.FieldDispatchedAt) + } if m.created != nil { fields = append(fields, message.FieldCreated) } @@ -16789,6 +18109,10 @@ func (m *MessageMutation) Field(name string) (ent.Value, bool) { return m.AgentID() case message.FieldGroupID: return m.GroupID() + case message.FieldDispatchState: + return m.DispatchState() + case message.FieldDispatchedAt: + return m.DispatchedAt() case message.FieldCreated: return m.Created() } @@ -16824,6 +18148,10 @@ func (m *MessageMutation) OldField(ctx context.Context, name string) (ent.Value, return m.OldAgentID(ctx) case message.FieldGroupID: return m.OldGroupID(ctx) + case message.FieldDispatchState: + return m.OldDispatchState(ctx) + case message.FieldDispatchedAt: + return m.OldDispatchedAt(ctx) case message.FieldCreated: return m.OldCreated(ctx) } @@ -16919,6 +18247,20 @@ func (m *MessageMutation) SetField(name string, value ent.Value) error { } m.SetGroupID(v) return nil + case message.FieldDispatchState: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDispatchState(v) + return nil + case message.FieldDispatchedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDispatchedAt(v) + return nil case message.FieldCreated: v, ok := value.(time.Time) if !ok { @@ -16968,6 +18310,9 @@ func (m *MessageMutation) ClearedFields() []string { if m.FieldCleared(message.FieldGroupID) { fields = append(fields, message.FieldGroupID) } + if m.FieldCleared(message.FieldDispatchedAt) { + fields = append(fields, message.FieldDispatchedAt) + } return fields } @@ -16994,6 +18339,9 @@ func (m *MessageMutation) ClearField(name string) error { case message.FieldGroupID: m.ClearGroupID() return nil + case message.FieldDispatchedAt: + m.ClearDispatchedAt() + return nil } return fmt.Errorf("unknown Message nullable field %s", name) } @@ -17038,6 +18386,12 @@ func (m *MessageMutation) ResetField(name string) error { case message.FieldGroupID: m.ResetGroupID() return nil + case message.FieldDispatchState: + m.ResetDispatchState() + return nil + case message.FieldDispatchedAt: + m.ResetDispatchedAt() + return nil case message.FieldCreated: m.ResetCreated() return nil @@ -22704,34 +24058,37 @@ func (m *ProjectSyncStateMutation) ResetEdge(name string) error { // RuntimeBrokerMutation represents an operation that mutates the RuntimeBroker nodes in the graph. type RuntimeBrokerMutation struct { config - op Op - typ string - id *uuid.UUID - name *string - slug *string - _type *string - mode *string - version *string - lock_version *int64 - addlock_version *int64 - status *string - connection_state *string - last_heartbeat *time.Time - capabilities *string - supported_harnesses *string - resources *string - runtimes *string - labels *string - annotations *string - endpoint *string - created_by *string - auto_provide *bool - created *time.Time - updated *time.Time - clearedFields map[string]struct{} - done bool - oldValue func(context.Context) (*RuntimeBroker, error) - predicates []predicate.RuntimeBroker + op Op + typ string + id *uuid.UUID + name *string + slug *string + _type *string + mode *string + version *string + lock_version *int64 + addlock_version *int64 + status *string + connection_state *string + last_heartbeat *time.Time + capabilities *string + supported_harnesses *string + resources *string + runtimes *string + labels *string + annotations *string + endpoint *string + created_by *string + auto_provide *bool + connected_hub_id *string + connected_session_id *string + connected_at *time.Time + created *time.Time + updated *time.Time + clearedFields map[string]struct{} + done bool + oldValue func(context.Context) (*RuntimeBroker, error) + predicates []predicate.RuntimeBroker } var _ ent.Mutation = (*RuntimeBrokerMutation)(nil) @@ -23649,6 +25006,153 @@ func (m *RuntimeBrokerMutation) ResetAutoProvide() { m.auto_provide = nil } +// SetConnectedHubID sets the "connected_hub_id" field. +func (m *RuntimeBrokerMutation) SetConnectedHubID(s string) { + m.connected_hub_id = &s +} + +// ConnectedHubID returns the value of the "connected_hub_id" field in the mutation. +func (m *RuntimeBrokerMutation) ConnectedHubID() (r string, exists bool) { + v := m.connected_hub_id + if v == nil { + return + } + return *v, true +} + +// OldConnectedHubID returns the old "connected_hub_id" field's value of the RuntimeBroker entity. +// If the RuntimeBroker object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *RuntimeBrokerMutation) OldConnectedHubID(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectedHubID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectedHubID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectedHubID: %w", err) + } + return oldValue.ConnectedHubID, nil +} + +// ClearConnectedHubID clears the value of the "connected_hub_id" field. +func (m *RuntimeBrokerMutation) ClearConnectedHubID() { + m.connected_hub_id = nil + m.clearedFields[runtimebroker.FieldConnectedHubID] = struct{}{} +} + +// ConnectedHubIDCleared returns if the "connected_hub_id" field was cleared in this mutation. +func (m *RuntimeBrokerMutation) ConnectedHubIDCleared() bool { + _, ok := m.clearedFields[runtimebroker.FieldConnectedHubID] + return ok +} + +// ResetConnectedHubID resets all changes to the "connected_hub_id" field. +func (m *RuntimeBrokerMutation) ResetConnectedHubID() { + m.connected_hub_id = nil + delete(m.clearedFields, runtimebroker.FieldConnectedHubID) +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (m *RuntimeBrokerMutation) SetConnectedSessionID(s string) { + m.connected_session_id = &s +} + +// ConnectedSessionID returns the value of the "connected_session_id" field in the mutation. +func (m *RuntimeBrokerMutation) ConnectedSessionID() (r string, exists bool) { + v := m.connected_session_id + if v == nil { + return + } + return *v, true +} + +// OldConnectedSessionID returns the old "connected_session_id" field's value of the RuntimeBroker entity. +// If the RuntimeBroker object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *RuntimeBrokerMutation) OldConnectedSessionID(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectedSessionID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectedSessionID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectedSessionID: %w", err) + } + return oldValue.ConnectedSessionID, nil +} + +// ClearConnectedSessionID clears the value of the "connected_session_id" field. +func (m *RuntimeBrokerMutation) ClearConnectedSessionID() { + m.connected_session_id = nil + m.clearedFields[runtimebroker.FieldConnectedSessionID] = struct{}{} +} + +// ConnectedSessionIDCleared returns if the "connected_session_id" field was cleared in this mutation. +func (m *RuntimeBrokerMutation) ConnectedSessionIDCleared() bool { + _, ok := m.clearedFields[runtimebroker.FieldConnectedSessionID] + return ok +} + +// ResetConnectedSessionID resets all changes to the "connected_session_id" field. +func (m *RuntimeBrokerMutation) ResetConnectedSessionID() { + m.connected_session_id = nil + delete(m.clearedFields, runtimebroker.FieldConnectedSessionID) +} + +// SetConnectedAt sets the "connected_at" field. +func (m *RuntimeBrokerMutation) SetConnectedAt(t time.Time) { + m.connected_at = &t +} + +// ConnectedAt returns the value of the "connected_at" field in the mutation. +func (m *RuntimeBrokerMutation) ConnectedAt() (r time.Time, exists bool) { + v := m.connected_at + if v == nil { + return + } + return *v, true +} + +// OldConnectedAt returns the old "connected_at" field's value of the RuntimeBroker entity. +// If the RuntimeBroker object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *RuntimeBrokerMutation) OldConnectedAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectedAt: %w", err) + } + return oldValue.ConnectedAt, nil +} + +// ClearConnectedAt clears the value of the "connected_at" field. +func (m *RuntimeBrokerMutation) ClearConnectedAt() { + m.connected_at = nil + m.clearedFields[runtimebroker.FieldConnectedAt] = struct{}{} +} + +// ConnectedAtCleared returns if the "connected_at" field was cleared in this mutation. +func (m *RuntimeBrokerMutation) ConnectedAtCleared() bool { + _, ok := m.clearedFields[runtimebroker.FieldConnectedAt] + return ok +} + +// ResetConnectedAt resets all changes to the "connected_at" field. +func (m *RuntimeBrokerMutation) ResetConnectedAt() { + m.connected_at = nil + delete(m.clearedFields, runtimebroker.FieldConnectedAt) +} + // SetCreated sets the "created" field. func (m *RuntimeBrokerMutation) SetCreated(t time.Time) { m.created = &t @@ -23755,7 +25259,7 @@ func (m *RuntimeBrokerMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *RuntimeBrokerMutation) Fields() []string { - fields := make([]string, 0, 20) + fields := make([]string, 0, 23) if m.name != nil { fields = append(fields, runtimebroker.FieldName) } @@ -23810,6 +25314,15 @@ func (m *RuntimeBrokerMutation) Fields() []string { if m.auto_provide != nil { fields = append(fields, runtimebroker.FieldAutoProvide) } + if m.connected_hub_id != nil { + fields = append(fields, runtimebroker.FieldConnectedHubID) + } + if m.connected_session_id != nil { + fields = append(fields, runtimebroker.FieldConnectedSessionID) + } + if m.connected_at != nil { + fields = append(fields, runtimebroker.FieldConnectedAt) + } if m.created != nil { fields = append(fields, runtimebroker.FieldCreated) } @@ -23860,6 +25373,12 @@ func (m *RuntimeBrokerMutation) Field(name string) (ent.Value, bool) { return m.CreatedBy() case runtimebroker.FieldAutoProvide: return m.AutoProvide() + case runtimebroker.FieldConnectedHubID: + return m.ConnectedHubID() + case runtimebroker.FieldConnectedSessionID: + return m.ConnectedSessionID() + case runtimebroker.FieldConnectedAt: + return m.ConnectedAt() case runtimebroker.FieldCreated: return m.Created() case runtimebroker.FieldUpdated: @@ -23909,6 +25428,12 @@ func (m *RuntimeBrokerMutation) OldField(ctx context.Context, name string) (ent. return m.OldCreatedBy(ctx) case runtimebroker.FieldAutoProvide: return m.OldAutoProvide(ctx) + case runtimebroker.FieldConnectedHubID: + return m.OldConnectedHubID(ctx) + case runtimebroker.FieldConnectedSessionID: + return m.OldConnectedSessionID(ctx) + case runtimebroker.FieldConnectedAt: + return m.OldConnectedAt(ctx) case runtimebroker.FieldCreated: return m.OldCreated(ctx) case runtimebroker.FieldUpdated: @@ -24048,6 +25573,27 @@ func (m *RuntimeBrokerMutation) SetField(name string, value ent.Value) error { } m.SetAutoProvide(v) return nil + case runtimebroker.FieldConnectedHubID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectedHubID(v) + return nil + case runtimebroker.FieldConnectedSessionID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectedSessionID(v) + return nil + case runtimebroker.FieldConnectedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectedAt(v) + return nil case runtimebroker.FieldCreated: v, ok := value.(time.Time) if !ok { @@ -24140,6 +25686,15 @@ func (m *RuntimeBrokerMutation) ClearedFields() []string { if m.FieldCleared(runtimebroker.FieldCreatedBy) { fields = append(fields, runtimebroker.FieldCreatedBy) } + if m.FieldCleared(runtimebroker.FieldConnectedHubID) { + fields = append(fields, runtimebroker.FieldConnectedHubID) + } + if m.FieldCleared(runtimebroker.FieldConnectedSessionID) { + fields = append(fields, runtimebroker.FieldConnectedSessionID) + } + if m.FieldCleared(runtimebroker.FieldConnectedAt) { + fields = append(fields, runtimebroker.FieldConnectedAt) + } return fields } @@ -24187,6 +25742,15 @@ func (m *RuntimeBrokerMutation) ClearField(name string) error { case runtimebroker.FieldCreatedBy: m.ClearCreatedBy() return nil + case runtimebroker.FieldConnectedHubID: + m.ClearConnectedHubID() + return nil + case runtimebroker.FieldConnectedSessionID: + m.ClearConnectedSessionID() + return nil + case runtimebroker.FieldConnectedAt: + m.ClearConnectedAt() + return nil } return fmt.Errorf("unknown RuntimeBroker nullable field %s", name) } @@ -24249,6 +25813,15 @@ func (m *RuntimeBrokerMutation) ResetField(name string) error { case runtimebroker.FieldAutoProvide: m.ResetAutoProvide() return nil + case runtimebroker.FieldConnectedHubID: + m.ResetConnectedHubID() + return nil + case runtimebroker.FieldConnectedSessionID: + m.ResetConnectedSessionID() + return nil + case runtimebroker.FieldConnectedAt: + m.ResetConnectedAt() + return nil case runtimebroker.FieldCreated: m.ResetCreated() return nil diff --git a/pkg/ent/predicate/predicate.go b/pkg/ent/predicate/predicate.go index 894f3d7e3..bb8ac70de 100644 --- a/pkg/ent/predicate/predicate.go +++ b/pkg/ent/predicate/predicate.go @@ -18,6 +18,9 @@ type AllowListEntry func(*sql.Selector) // ApiKey is the predicate function for apikey builders. type ApiKey func(*sql.Selector) +// BrokerDispatch is the predicate function for brokerdispatch builders. +type BrokerDispatch func(*sql.Selector) + // BrokerJoinToken is the predicate function for brokerjointoken builders. type BrokerJoinToken func(*sql.Selector) diff --git a/pkg/ent/runtime.go b/pkg/ent/runtime.go index df13e271e..060050f6d 100644 --- a/pkg/ent/runtime.go +++ b/pkg/ent/runtime.go @@ -9,6 +9,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/ent/agent" "github.com/GoogleCloudPlatform/scion/pkg/ent/allowlistentry" "github.com/GoogleCloudPlatform/scion/pkg/ent/apikey" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerjointoken" "github.com/GoogleCloudPlatform/scion/pkg/ent/brokersecret" "github.com/GoogleCloudPlatform/scion/pkg/ent/envvar" @@ -163,6 +164,34 @@ func init() { apikeyDescID := apikeyFields[0].Descriptor() // apikey.DefaultID holds the default value on creation for the id field. apikey.DefaultID = apikeyDescID.Default.(func() uuid.UUID) + brokerdispatchFields := schema.BrokerDispatch{}.Fields() + _ = brokerdispatchFields + // brokerdispatchDescOp is the schema descriptor for op field. + brokerdispatchDescOp := brokerdispatchFields[5].Descriptor() + // brokerdispatch.OpValidator is a validator for the "op" field. It is called by the builders before save. + brokerdispatch.OpValidator = brokerdispatchDescOp.Validators[0].(func(string) error) + // brokerdispatchDescState is the schema descriptor for state field. + brokerdispatchDescState := brokerdispatchFields[7].Descriptor() + // brokerdispatch.DefaultState holds the default value on creation for the state field. + brokerdispatch.DefaultState = brokerdispatchDescState.Default.(string) + // brokerdispatchDescAttempts is the schema descriptor for attempts field. + brokerdispatchDescAttempts := brokerdispatchFields[10].Descriptor() + // brokerdispatch.DefaultAttempts holds the default value on creation for the attempts field. + brokerdispatch.DefaultAttempts = brokerdispatchDescAttempts.Default.(int) + // brokerdispatchDescCreatedAt is the schema descriptor for created_at field. + brokerdispatchDescCreatedAt := brokerdispatchFields[12].Descriptor() + // brokerdispatch.DefaultCreatedAt holds the default value on creation for the created_at field. + brokerdispatch.DefaultCreatedAt = brokerdispatchDescCreatedAt.Default.(func() time.Time) + // brokerdispatchDescUpdatedAt is the schema descriptor for updated_at field. + brokerdispatchDescUpdatedAt := brokerdispatchFields[13].Descriptor() + // brokerdispatch.DefaultUpdatedAt holds the default value on creation for the updated_at field. + brokerdispatch.DefaultUpdatedAt = brokerdispatchDescUpdatedAt.Default.(func() time.Time) + // brokerdispatch.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + brokerdispatch.UpdateDefaultUpdatedAt = brokerdispatchDescUpdatedAt.UpdateDefault.(func() time.Time) + // brokerdispatchDescID is the schema descriptor for id field. + brokerdispatchDescID := brokerdispatchFields[0].Descriptor() + // brokerdispatch.DefaultID holds the default value on creation for the id field. + brokerdispatch.DefaultID = brokerdispatchDescID.Default.(func() uuid.UUID) brokerjointokenFields := schema.BrokerJoinToken{}.Fields() _ = brokerjointokenFields // brokerjointokenDescTokenHash is the schema descriptor for token_hash field. @@ -503,8 +532,12 @@ func init() { messageDescRead := messageFields[10].Descriptor() // message.DefaultRead holds the default value on creation for the read field. message.DefaultRead = messageDescRead.Default.(bool) + // messageDescDispatchState is the schema descriptor for dispatch_state field. + messageDescDispatchState := messageFields[13].Descriptor() + // message.DefaultDispatchState holds the default value on creation for the dispatch_state field. + message.DefaultDispatchState = messageDescDispatchState.Default.(string) // messageDescCreated is the schema descriptor for created field. - messageDescCreated := messageFields[13].Descriptor() + messageDescCreated := messageFields[15].Descriptor() // message.DefaultCreated holds the default value on creation for the created field. message.DefaultCreated = messageDescCreated.Default.(func() time.Time) // messageDescID is the schema descriptor for id field. @@ -680,11 +713,11 @@ func init() { // runtimebroker.DefaultAutoProvide holds the default value on creation for the auto_provide field. runtimebroker.DefaultAutoProvide = runtimebrokerDescAutoProvide.Default.(bool) // runtimebrokerDescCreated is the schema descriptor for created field. - runtimebrokerDescCreated := runtimebrokerFields[19].Descriptor() + runtimebrokerDescCreated := runtimebrokerFields[22].Descriptor() // runtimebroker.DefaultCreated holds the default value on creation for the created field. runtimebroker.DefaultCreated = runtimebrokerDescCreated.Default.(func() time.Time) // runtimebrokerDescUpdated is the schema descriptor for updated field. - runtimebrokerDescUpdated := runtimebrokerFields[20].Descriptor() + runtimebrokerDescUpdated := runtimebrokerFields[23].Descriptor() // runtimebroker.DefaultUpdated holds the default value on creation for the updated field. runtimebroker.DefaultUpdated = runtimebrokerDescUpdated.Default.(func() time.Time) // runtimebroker.UpdateDefaultUpdated holds the default value on update for the updated field. diff --git a/pkg/ent/runtimebroker.go b/pkg/ent/runtimebroker.go index ddcba587b..41a41a795 100644 --- a/pkg/ent/runtimebroker.go +++ b/pkg/ent/runtimebroker.go @@ -54,6 +54,12 @@ type RuntimeBroker struct { CreatedBy string `json:"created_by,omitempty"` // AutoProvide holds the value of the "auto_provide" field. AutoProvide bool `json:"auto_provide,omitempty"` + // ConnectedHubID holds the value of the "connected_hub_id" field. + ConnectedHubID *string `json:"connected_hub_id,omitempty"` + // ConnectedSessionID holds the value of the "connected_session_id" field. + ConnectedSessionID *string `json:"connected_session_id,omitempty"` + // ConnectedAt holds the value of the "connected_at" field. + ConnectedAt *time.Time `json:"connected_at,omitempty"` // Created holds the value of the "created" field. Created time.Time `json:"created,omitempty"` // Updated holds the value of the "updated" field. @@ -70,9 +76,9 @@ func (*RuntimeBroker) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case runtimebroker.FieldLockVersion: values[i] = new(sql.NullInt64) - case runtimebroker.FieldName, runtimebroker.FieldSlug, runtimebroker.FieldType, runtimebroker.FieldMode, runtimebroker.FieldVersion, runtimebroker.FieldStatus, runtimebroker.FieldConnectionState, runtimebroker.FieldCapabilities, runtimebroker.FieldSupportedHarnesses, runtimebroker.FieldResources, runtimebroker.FieldRuntimes, runtimebroker.FieldLabels, runtimebroker.FieldAnnotations, runtimebroker.FieldEndpoint, runtimebroker.FieldCreatedBy: + case runtimebroker.FieldName, runtimebroker.FieldSlug, runtimebroker.FieldType, runtimebroker.FieldMode, runtimebroker.FieldVersion, runtimebroker.FieldStatus, runtimebroker.FieldConnectionState, runtimebroker.FieldCapabilities, runtimebroker.FieldSupportedHarnesses, runtimebroker.FieldResources, runtimebroker.FieldRuntimes, runtimebroker.FieldLabels, runtimebroker.FieldAnnotations, runtimebroker.FieldEndpoint, runtimebroker.FieldCreatedBy, runtimebroker.FieldConnectedHubID, runtimebroker.FieldConnectedSessionID: values[i] = new(sql.NullString) - case runtimebroker.FieldLastHeartbeat, runtimebroker.FieldCreated, runtimebroker.FieldUpdated: + case runtimebroker.FieldLastHeartbeat, runtimebroker.FieldConnectedAt, runtimebroker.FieldCreated, runtimebroker.FieldUpdated: values[i] = new(sql.NullTime) case runtimebroker.FieldID: values[i] = new(uuid.UUID) @@ -206,6 +212,27 @@ func (_m *RuntimeBroker) assignValues(columns []string, values []any) error { } else if value.Valid { _m.AutoProvide = value.Bool } + case runtimebroker.FieldConnectedHubID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field connected_hub_id", values[i]) + } else if value.Valid { + _m.ConnectedHubID = new(string) + *_m.ConnectedHubID = value.String + } + case runtimebroker.FieldConnectedSessionID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field connected_session_id", values[i]) + } else if value.Valid { + _m.ConnectedSessionID = new(string) + *_m.ConnectedSessionID = value.String + } + case runtimebroker.FieldConnectedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field connected_at", values[i]) + } else if value.Valid { + _m.ConnectedAt = new(time.Time) + *_m.ConnectedAt = value.Time + } case runtimebroker.FieldCreated: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created", values[i]) @@ -310,6 +337,21 @@ func (_m *RuntimeBroker) String() string { builder.WriteString("auto_provide=") builder.WriteString(fmt.Sprintf("%v", _m.AutoProvide)) builder.WriteString(", ") + if v := _m.ConnectedHubID; v != nil { + builder.WriteString("connected_hub_id=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.ConnectedSessionID; v != nil { + builder.WriteString("connected_session_id=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.ConnectedAt; v != nil { + builder.WriteString("connected_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") builder.WriteString("created=") builder.WriteString(_m.Created.Format(time.ANSIC)) builder.WriteString(", ") diff --git a/pkg/ent/runtimebroker/runtimebroker.go b/pkg/ent/runtimebroker/runtimebroker.go index 529fe8f01..eae7dc3ff 100644 --- a/pkg/ent/runtimebroker/runtimebroker.go +++ b/pkg/ent/runtimebroker/runtimebroker.go @@ -50,6 +50,12 @@ const ( FieldCreatedBy = "created_by" // FieldAutoProvide holds the string denoting the auto_provide field in the database. FieldAutoProvide = "auto_provide" + // FieldConnectedHubID holds the string denoting the connected_hub_id field in the database. + FieldConnectedHubID = "connected_hub_id" + // FieldConnectedSessionID holds the string denoting the connected_session_id field in the database. + FieldConnectedSessionID = "connected_session_id" + // FieldConnectedAt holds the string denoting the connected_at field in the database. + FieldConnectedAt = "connected_at" // FieldCreated holds the string denoting the created field in the database. FieldCreated = "created" // FieldUpdated holds the string denoting the updated field in the database. @@ -79,6 +85,9 @@ var Columns = []string{ FieldEndpoint, FieldCreatedBy, FieldAutoProvide, + FieldConnectedHubID, + FieldConnectedSessionID, + FieldConnectedAt, FieldCreated, FieldUpdated, } @@ -216,6 +225,21 @@ func ByAutoProvide(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldAutoProvide, opts...).ToFunc() } +// ByConnectedHubID orders the results by the connected_hub_id field. +func ByConnectedHubID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldConnectedHubID, opts...).ToFunc() +} + +// ByConnectedSessionID orders the results by the connected_session_id field. +func ByConnectedSessionID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldConnectedSessionID, opts...).ToFunc() +} + +// ByConnectedAt orders the results by the connected_at field. +func ByConnectedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldConnectedAt, opts...).ToFunc() +} + // ByCreated orders the results by the created field. func ByCreated(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreated, opts...).ToFunc() diff --git a/pkg/ent/runtimebroker/where.go b/pkg/ent/runtimebroker/where.go index b67733216..f81468889 100644 --- a/pkg/ent/runtimebroker/where.go +++ b/pkg/ent/runtimebroker/where.go @@ -145,6 +145,21 @@ func AutoProvide(v bool) predicate.RuntimeBroker { return predicate.RuntimeBroker(sql.FieldEQ(FieldAutoProvide, v)) } +// ConnectedHubID applies equality check predicate on the "connected_hub_id" field. It's identical to ConnectedHubIDEQ. +func ConnectedHubID(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEQ(FieldConnectedHubID, v)) +} + +// ConnectedSessionID applies equality check predicate on the "connected_session_id" field. It's identical to ConnectedSessionIDEQ. +func ConnectedSessionID(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEQ(FieldConnectedSessionID, v)) +} + +// ConnectedAt applies equality check predicate on the "connected_at" field. It's identical to ConnectedAtEQ. +func ConnectedAt(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEQ(FieldConnectedAt, v)) +} + // Created applies equality check predicate on the "created" field. It's identical to CreatedEQ. func Created(v time.Time) predicate.RuntimeBroker { return predicate.RuntimeBroker(sql.FieldEQ(FieldCreated, v)) @@ -1330,6 +1345,206 @@ func AutoProvideNEQ(v bool) predicate.RuntimeBroker { return predicate.RuntimeBroker(sql.FieldNEQ(FieldAutoProvide, v)) } +// ConnectedHubIDEQ applies the EQ predicate on the "connected_hub_id" field. +func ConnectedHubIDEQ(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEQ(FieldConnectedHubID, v)) +} + +// ConnectedHubIDNEQ applies the NEQ predicate on the "connected_hub_id" field. +func ConnectedHubIDNEQ(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNEQ(FieldConnectedHubID, v)) +} + +// ConnectedHubIDIn applies the In predicate on the "connected_hub_id" field. +func ConnectedHubIDIn(vs ...string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldIn(FieldConnectedHubID, vs...)) +} + +// ConnectedHubIDNotIn applies the NotIn predicate on the "connected_hub_id" field. +func ConnectedHubIDNotIn(vs ...string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNotIn(FieldConnectedHubID, vs...)) +} + +// ConnectedHubIDGT applies the GT predicate on the "connected_hub_id" field. +func ConnectedHubIDGT(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldGT(FieldConnectedHubID, v)) +} + +// ConnectedHubIDGTE applies the GTE predicate on the "connected_hub_id" field. +func ConnectedHubIDGTE(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldGTE(FieldConnectedHubID, v)) +} + +// ConnectedHubIDLT applies the LT predicate on the "connected_hub_id" field. +func ConnectedHubIDLT(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldLT(FieldConnectedHubID, v)) +} + +// ConnectedHubIDLTE applies the LTE predicate on the "connected_hub_id" field. +func ConnectedHubIDLTE(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldLTE(FieldConnectedHubID, v)) +} + +// ConnectedHubIDContains applies the Contains predicate on the "connected_hub_id" field. +func ConnectedHubIDContains(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldContains(FieldConnectedHubID, v)) +} + +// ConnectedHubIDHasPrefix applies the HasPrefix predicate on the "connected_hub_id" field. +func ConnectedHubIDHasPrefix(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldHasPrefix(FieldConnectedHubID, v)) +} + +// ConnectedHubIDHasSuffix applies the HasSuffix predicate on the "connected_hub_id" field. +func ConnectedHubIDHasSuffix(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldHasSuffix(FieldConnectedHubID, v)) +} + +// ConnectedHubIDIsNil applies the IsNil predicate on the "connected_hub_id" field. +func ConnectedHubIDIsNil() predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldIsNull(FieldConnectedHubID)) +} + +// ConnectedHubIDNotNil applies the NotNil predicate on the "connected_hub_id" field. +func ConnectedHubIDNotNil() predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNotNull(FieldConnectedHubID)) +} + +// ConnectedHubIDEqualFold applies the EqualFold predicate on the "connected_hub_id" field. +func ConnectedHubIDEqualFold(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEqualFold(FieldConnectedHubID, v)) +} + +// ConnectedHubIDContainsFold applies the ContainsFold predicate on the "connected_hub_id" field. +func ConnectedHubIDContainsFold(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldContainsFold(FieldConnectedHubID, v)) +} + +// ConnectedSessionIDEQ applies the EQ predicate on the "connected_session_id" field. +func ConnectedSessionIDEQ(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEQ(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDNEQ applies the NEQ predicate on the "connected_session_id" field. +func ConnectedSessionIDNEQ(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNEQ(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDIn applies the In predicate on the "connected_session_id" field. +func ConnectedSessionIDIn(vs ...string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldIn(FieldConnectedSessionID, vs...)) +} + +// ConnectedSessionIDNotIn applies the NotIn predicate on the "connected_session_id" field. +func ConnectedSessionIDNotIn(vs ...string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNotIn(FieldConnectedSessionID, vs...)) +} + +// ConnectedSessionIDGT applies the GT predicate on the "connected_session_id" field. +func ConnectedSessionIDGT(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldGT(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDGTE applies the GTE predicate on the "connected_session_id" field. +func ConnectedSessionIDGTE(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldGTE(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDLT applies the LT predicate on the "connected_session_id" field. +func ConnectedSessionIDLT(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldLT(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDLTE applies the LTE predicate on the "connected_session_id" field. +func ConnectedSessionIDLTE(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldLTE(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDContains applies the Contains predicate on the "connected_session_id" field. +func ConnectedSessionIDContains(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldContains(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDHasPrefix applies the HasPrefix predicate on the "connected_session_id" field. +func ConnectedSessionIDHasPrefix(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldHasPrefix(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDHasSuffix applies the HasSuffix predicate on the "connected_session_id" field. +func ConnectedSessionIDHasSuffix(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldHasSuffix(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDIsNil applies the IsNil predicate on the "connected_session_id" field. +func ConnectedSessionIDIsNil() predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldIsNull(FieldConnectedSessionID)) +} + +// ConnectedSessionIDNotNil applies the NotNil predicate on the "connected_session_id" field. +func ConnectedSessionIDNotNil() predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNotNull(FieldConnectedSessionID)) +} + +// ConnectedSessionIDEqualFold applies the EqualFold predicate on the "connected_session_id" field. +func ConnectedSessionIDEqualFold(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEqualFold(FieldConnectedSessionID, v)) +} + +// ConnectedSessionIDContainsFold applies the ContainsFold predicate on the "connected_session_id" field. +func ConnectedSessionIDContainsFold(v string) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldContainsFold(FieldConnectedSessionID, v)) +} + +// ConnectedAtEQ applies the EQ predicate on the "connected_at" field. +func ConnectedAtEQ(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldEQ(FieldConnectedAt, v)) +} + +// ConnectedAtNEQ applies the NEQ predicate on the "connected_at" field. +func ConnectedAtNEQ(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNEQ(FieldConnectedAt, v)) +} + +// ConnectedAtIn applies the In predicate on the "connected_at" field. +func ConnectedAtIn(vs ...time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldIn(FieldConnectedAt, vs...)) +} + +// ConnectedAtNotIn applies the NotIn predicate on the "connected_at" field. +func ConnectedAtNotIn(vs ...time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNotIn(FieldConnectedAt, vs...)) +} + +// ConnectedAtGT applies the GT predicate on the "connected_at" field. +func ConnectedAtGT(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldGT(FieldConnectedAt, v)) +} + +// ConnectedAtGTE applies the GTE predicate on the "connected_at" field. +func ConnectedAtGTE(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldGTE(FieldConnectedAt, v)) +} + +// ConnectedAtLT applies the LT predicate on the "connected_at" field. +func ConnectedAtLT(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldLT(FieldConnectedAt, v)) +} + +// ConnectedAtLTE applies the LTE predicate on the "connected_at" field. +func ConnectedAtLTE(v time.Time) predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldLTE(FieldConnectedAt, v)) +} + +// ConnectedAtIsNil applies the IsNil predicate on the "connected_at" field. +func ConnectedAtIsNil() predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldIsNull(FieldConnectedAt)) +} + +// ConnectedAtNotNil applies the NotNil predicate on the "connected_at" field. +func ConnectedAtNotNil() predicate.RuntimeBroker { + return predicate.RuntimeBroker(sql.FieldNotNull(FieldConnectedAt)) +} + // CreatedEQ applies the EQ predicate on the "created" field. func CreatedEQ(v time.Time) predicate.RuntimeBroker { return predicate.RuntimeBroker(sql.FieldEQ(FieldCreated, v)) diff --git a/pkg/ent/runtimebroker_create.go b/pkg/ent/runtimebroker_create.go index f59f58467..26f4aaa55 100644 --- a/pkg/ent/runtimebroker_create.go +++ b/pkg/ent/runtimebroker_create.go @@ -260,6 +260,48 @@ func (_c *RuntimeBrokerCreate) SetNillableAutoProvide(v *bool) *RuntimeBrokerCre return _c } +// SetConnectedHubID sets the "connected_hub_id" field. +func (_c *RuntimeBrokerCreate) SetConnectedHubID(v string) *RuntimeBrokerCreate { + _c.mutation.SetConnectedHubID(v) + return _c +} + +// SetNillableConnectedHubID sets the "connected_hub_id" field if the given value is not nil. +func (_c *RuntimeBrokerCreate) SetNillableConnectedHubID(v *string) *RuntimeBrokerCreate { + if v != nil { + _c.SetConnectedHubID(*v) + } + return _c +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (_c *RuntimeBrokerCreate) SetConnectedSessionID(v string) *RuntimeBrokerCreate { + _c.mutation.SetConnectedSessionID(v) + return _c +} + +// SetNillableConnectedSessionID sets the "connected_session_id" field if the given value is not nil. +func (_c *RuntimeBrokerCreate) SetNillableConnectedSessionID(v *string) *RuntimeBrokerCreate { + if v != nil { + _c.SetConnectedSessionID(*v) + } + return _c +} + +// SetConnectedAt sets the "connected_at" field. +func (_c *RuntimeBrokerCreate) SetConnectedAt(v time.Time) *RuntimeBrokerCreate { + _c.mutation.SetConnectedAt(v) + return _c +} + +// SetNillableConnectedAt sets the "connected_at" field if the given value is not nil. +func (_c *RuntimeBrokerCreate) SetNillableConnectedAt(v *time.Time) *RuntimeBrokerCreate { + if v != nil { + _c.SetConnectedAt(*v) + } + return _c +} + // SetCreated sets the "created" field. func (_c *RuntimeBrokerCreate) SetCreated(v time.Time) *RuntimeBrokerCreate { _c.mutation.SetCreated(v) @@ -518,6 +560,18 @@ func (_c *RuntimeBrokerCreate) createSpec() (*RuntimeBroker, *sqlgraph.CreateSpe _spec.SetField(runtimebroker.FieldAutoProvide, field.TypeBool, value) _node.AutoProvide = value } + if value, ok := _c.mutation.ConnectedHubID(); ok { + _spec.SetField(runtimebroker.FieldConnectedHubID, field.TypeString, value) + _node.ConnectedHubID = &value + } + if value, ok := _c.mutation.ConnectedSessionID(); ok { + _spec.SetField(runtimebroker.FieldConnectedSessionID, field.TypeString, value) + _node.ConnectedSessionID = &value + } + if value, ok := _c.mutation.ConnectedAt(); ok { + _spec.SetField(runtimebroker.FieldConnectedAt, field.TypeTime, value) + _node.ConnectedAt = &value + } if value, ok := _c.mutation.Created(); ok { _spec.SetField(runtimebroker.FieldCreated, field.TypeTime, value) _node.Created = value @@ -866,6 +920,60 @@ func (u *RuntimeBrokerUpsert) UpdateAutoProvide() *RuntimeBrokerUpsert { return u } +// SetConnectedHubID sets the "connected_hub_id" field. +func (u *RuntimeBrokerUpsert) SetConnectedHubID(v string) *RuntimeBrokerUpsert { + u.Set(runtimebroker.FieldConnectedHubID, v) + return u +} + +// UpdateConnectedHubID sets the "connected_hub_id" field to the value that was provided on create. +func (u *RuntimeBrokerUpsert) UpdateConnectedHubID() *RuntimeBrokerUpsert { + u.SetExcluded(runtimebroker.FieldConnectedHubID) + return u +} + +// ClearConnectedHubID clears the value of the "connected_hub_id" field. +func (u *RuntimeBrokerUpsert) ClearConnectedHubID() *RuntimeBrokerUpsert { + u.SetNull(runtimebroker.FieldConnectedHubID) + return u +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (u *RuntimeBrokerUpsert) SetConnectedSessionID(v string) *RuntimeBrokerUpsert { + u.Set(runtimebroker.FieldConnectedSessionID, v) + return u +} + +// UpdateConnectedSessionID sets the "connected_session_id" field to the value that was provided on create. +func (u *RuntimeBrokerUpsert) UpdateConnectedSessionID() *RuntimeBrokerUpsert { + u.SetExcluded(runtimebroker.FieldConnectedSessionID) + return u +} + +// ClearConnectedSessionID clears the value of the "connected_session_id" field. +func (u *RuntimeBrokerUpsert) ClearConnectedSessionID() *RuntimeBrokerUpsert { + u.SetNull(runtimebroker.FieldConnectedSessionID) + return u +} + +// SetConnectedAt sets the "connected_at" field. +func (u *RuntimeBrokerUpsert) SetConnectedAt(v time.Time) *RuntimeBrokerUpsert { + u.Set(runtimebroker.FieldConnectedAt, v) + return u +} + +// UpdateConnectedAt sets the "connected_at" field to the value that was provided on create. +func (u *RuntimeBrokerUpsert) UpdateConnectedAt() *RuntimeBrokerUpsert { + u.SetExcluded(runtimebroker.FieldConnectedAt) + return u +} + +// ClearConnectedAt clears the value of the "connected_at" field. +func (u *RuntimeBrokerUpsert) ClearConnectedAt() *RuntimeBrokerUpsert { + u.SetNull(runtimebroker.FieldConnectedAt) + return u +} + // SetUpdated sets the "updated" field. func (u *RuntimeBrokerUpsert) SetUpdated(v time.Time) *RuntimeBrokerUpsert { u.Set(runtimebroker.FieldUpdated, v) @@ -1265,6 +1373,69 @@ func (u *RuntimeBrokerUpsertOne) UpdateAutoProvide() *RuntimeBrokerUpsertOne { }) } +// SetConnectedHubID sets the "connected_hub_id" field. +func (u *RuntimeBrokerUpsertOne) SetConnectedHubID(v string) *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.SetConnectedHubID(v) + }) +} + +// UpdateConnectedHubID sets the "connected_hub_id" field to the value that was provided on create. +func (u *RuntimeBrokerUpsertOne) UpdateConnectedHubID() *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.UpdateConnectedHubID() + }) +} + +// ClearConnectedHubID clears the value of the "connected_hub_id" field. +func (u *RuntimeBrokerUpsertOne) ClearConnectedHubID() *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.ClearConnectedHubID() + }) +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (u *RuntimeBrokerUpsertOne) SetConnectedSessionID(v string) *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.SetConnectedSessionID(v) + }) +} + +// UpdateConnectedSessionID sets the "connected_session_id" field to the value that was provided on create. +func (u *RuntimeBrokerUpsertOne) UpdateConnectedSessionID() *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.UpdateConnectedSessionID() + }) +} + +// ClearConnectedSessionID clears the value of the "connected_session_id" field. +func (u *RuntimeBrokerUpsertOne) ClearConnectedSessionID() *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.ClearConnectedSessionID() + }) +} + +// SetConnectedAt sets the "connected_at" field. +func (u *RuntimeBrokerUpsertOne) SetConnectedAt(v time.Time) *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.SetConnectedAt(v) + }) +} + +// UpdateConnectedAt sets the "connected_at" field to the value that was provided on create. +func (u *RuntimeBrokerUpsertOne) UpdateConnectedAt() *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.UpdateConnectedAt() + }) +} + +// ClearConnectedAt clears the value of the "connected_at" field. +func (u *RuntimeBrokerUpsertOne) ClearConnectedAt() *RuntimeBrokerUpsertOne { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.ClearConnectedAt() + }) +} + // SetUpdated sets the "updated" field. func (u *RuntimeBrokerUpsertOne) SetUpdated(v time.Time) *RuntimeBrokerUpsertOne { return u.Update(func(s *RuntimeBrokerUpsert) { @@ -1833,6 +2004,69 @@ func (u *RuntimeBrokerUpsertBulk) UpdateAutoProvide() *RuntimeBrokerUpsertBulk { }) } +// SetConnectedHubID sets the "connected_hub_id" field. +func (u *RuntimeBrokerUpsertBulk) SetConnectedHubID(v string) *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.SetConnectedHubID(v) + }) +} + +// UpdateConnectedHubID sets the "connected_hub_id" field to the value that was provided on create. +func (u *RuntimeBrokerUpsertBulk) UpdateConnectedHubID() *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.UpdateConnectedHubID() + }) +} + +// ClearConnectedHubID clears the value of the "connected_hub_id" field. +func (u *RuntimeBrokerUpsertBulk) ClearConnectedHubID() *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.ClearConnectedHubID() + }) +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (u *RuntimeBrokerUpsertBulk) SetConnectedSessionID(v string) *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.SetConnectedSessionID(v) + }) +} + +// UpdateConnectedSessionID sets the "connected_session_id" field to the value that was provided on create. +func (u *RuntimeBrokerUpsertBulk) UpdateConnectedSessionID() *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.UpdateConnectedSessionID() + }) +} + +// ClearConnectedSessionID clears the value of the "connected_session_id" field. +func (u *RuntimeBrokerUpsertBulk) ClearConnectedSessionID() *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.ClearConnectedSessionID() + }) +} + +// SetConnectedAt sets the "connected_at" field. +func (u *RuntimeBrokerUpsertBulk) SetConnectedAt(v time.Time) *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.SetConnectedAt(v) + }) +} + +// UpdateConnectedAt sets the "connected_at" field to the value that was provided on create. +func (u *RuntimeBrokerUpsertBulk) UpdateConnectedAt() *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.UpdateConnectedAt() + }) +} + +// ClearConnectedAt clears the value of the "connected_at" field. +func (u *RuntimeBrokerUpsertBulk) ClearConnectedAt() *RuntimeBrokerUpsertBulk { + return u.Update(func(s *RuntimeBrokerUpsert) { + s.ClearConnectedAt() + }) +} + // SetUpdated sets the "updated" field. func (u *RuntimeBrokerUpsertBulk) SetUpdated(v time.Time) *RuntimeBrokerUpsertBulk { return u.Update(func(s *RuntimeBrokerUpsert) { diff --git a/pkg/ent/runtimebroker_update.go b/pkg/ent/runtimebroker_update.go index 680b13fa0..ebbd7ee22 100644 --- a/pkg/ent/runtimebroker_update.go +++ b/pkg/ent/runtimebroker_update.go @@ -353,6 +353,66 @@ func (_u *RuntimeBrokerUpdate) SetNillableAutoProvide(v *bool) *RuntimeBrokerUpd return _u } +// SetConnectedHubID sets the "connected_hub_id" field. +func (_u *RuntimeBrokerUpdate) SetConnectedHubID(v string) *RuntimeBrokerUpdate { + _u.mutation.SetConnectedHubID(v) + return _u +} + +// SetNillableConnectedHubID sets the "connected_hub_id" field if the given value is not nil. +func (_u *RuntimeBrokerUpdate) SetNillableConnectedHubID(v *string) *RuntimeBrokerUpdate { + if v != nil { + _u.SetConnectedHubID(*v) + } + return _u +} + +// ClearConnectedHubID clears the value of the "connected_hub_id" field. +func (_u *RuntimeBrokerUpdate) ClearConnectedHubID() *RuntimeBrokerUpdate { + _u.mutation.ClearConnectedHubID() + return _u +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (_u *RuntimeBrokerUpdate) SetConnectedSessionID(v string) *RuntimeBrokerUpdate { + _u.mutation.SetConnectedSessionID(v) + return _u +} + +// SetNillableConnectedSessionID sets the "connected_session_id" field if the given value is not nil. +func (_u *RuntimeBrokerUpdate) SetNillableConnectedSessionID(v *string) *RuntimeBrokerUpdate { + if v != nil { + _u.SetConnectedSessionID(*v) + } + return _u +} + +// ClearConnectedSessionID clears the value of the "connected_session_id" field. +func (_u *RuntimeBrokerUpdate) ClearConnectedSessionID() *RuntimeBrokerUpdate { + _u.mutation.ClearConnectedSessionID() + return _u +} + +// SetConnectedAt sets the "connected_at" field. +func (_u *RuntimeBrokerUpdate) SetConnectedAt(v time.Time) *RuntimeBrokerUpdate { + _u.mutation.SetConnectedAt(v) + return _u +} + +// SetNillableConnectedAt sets the "connected_at" field if the given value is not nil. +func (_u *RuntimeBrokerUpdate) SetNillableConnectedAt(v *time.Time) *RuntimeBrokerUpdate { + if v != nil { + _u.SetConnectedAt(*v) + } + return _u +} + +// ClearConnectedAt clears the value of the "connected_at" field. +func (_u *RuntimeBrokerUpdate) ClearConnectedAt() *RuntimeBrokerUpdate { + _u.mutation.ClearConnectedAt() + return _u +} + // SetUpdated sets the "updated" field. func (_u *RuntimeBrokerUpdate) SetUpdated(v time.Time) *RuntimeBrokerUpdate { _u.mutation.SetUpdated(v) @@ -517,6 +577,24 @@ func (_u *RuntimeBrokerUpdate) sqlSave(ctx context.Context) (_node int, err erro if value, ok := _u.mutation.AutoProvide(); ok { _spec.SetField(runtimebroker.FieldAutoProvide, field.TypeBool, value) } + if value, ok := _u.mutation.ConnectedHubID(); ok { + _spec.SetField(runtimebroker.FieldConnectedHubID, field.TypeString, value) + } + if _u.mutation.ConnectedHubIDCleared() { + _spec.ClearField(runtimebroker.FieldConnectedHubID, field.TypeString) + } + if value, ok := _u.mutation.ConnectedSessionID(); ok { + _spec.SetField(runtimebroker.FieldConnectedSessionID, field.TypeString, value) + } + if _u.mutation.ConnectedSessionIDCleared() { + _spec.ClearField(runtimebroker.FieldConnectedSessionID, field.TypeString) + } + if value, ok := _u.mutation.ConnectedAt(); ok { + _spec.SetField(runtimebroker.FieldConnectedAt, field.TypeTime, value) + } + if _u.mutation.ConnectedAtCleared() { + _spec.ClearField(runtimebroker.FieldConnectedAt, field.TypeTime) + } if value, ok := _u.mutation.Updated(); ok { _spec.SetField(runtimebroker.FieldUpdated, field.TypeTime, value) } @@ -865,6 +943,66 @@ func (_u *RuntimeBrokerUpdateOne) SetNillableAutoProvide(v *bool) *RuntimeBroker return _u } +// SetConnectedHubID sets the "connected_hub_id" field. +func (_u *RuntimeBrokerUpdateOne) SetConnectedHubID(v string) *RuntimeBrokerUpdateOne { + _u.mutation.SetConnectedHubID(v) + return _u +} + +// SetNillableConnectedHubID sets the "connected_hub_id" field if the given value is not nil. +func (_u *RuntimeBrokerUpdateOne) SetNillableConnectedHubID(v *string) *RuntimeBrokerUpdateOne { + if v != nil { + _u.SetConnectedHubID(*v) + } + return _u +} + +// ClearConnectedHubID clears the value of the "connected_hub_id" field. +func (_u *RuntimeBrokerUpdateOne) ClearConnectedHubID() *RuntimeBrokerUpdateOne { + _u.mutation.ClearConnectedHubID() + return _u +} + +// SetConnectedSessionID sets the "connected_session_id" field. +func (_u *RuntimeBrokerUpdateOne) SetConnectedSessionID(v string) *RuntimeBrokerUpdateOne { + _u.mutation.SetConnectedSessionID(v) + return _u +} + +// SetNillableConnectedSessionID sets the "connected_session_id" field if the given value is not nil. +func (_u *RuntimeBrokerUpdateOne) SetNillableConnectedSessionID(v *string) *RuntimeBrokerUpdateOne { + if v != nil { + _u.SetConnectedSessionID(*v) + } + return _u +} + +// ClearConnectedSessionID clears the value of the "connected_session_id" field. +func (_u *RuntimeBrokerUpdateOne) ClearConnectedSessionID() *RuntimeBrokerUpdateOne { + _u.mutation.ClearConnectedSessionID() + return _u +} + +// SetConnectedAt sets the "connected_at" field. +func (_u *RuntimeBrokerUpdateOne) SetConnectedAt(v time.Time) *RuntimeBrokerUpdateOne { + _u.mutation.SetConnectedAt(v) + return _u +} + +// SetNillableConnectedAt sets the "connected_at" field if the given value is not nil. +func (_u *RuntimeBrokerUpdateOne) SetNillableConnectedAt(v *time.Time) *RuntimeBrokerUpdateOne { + if v != nil { + _u.SetConnectedAt(*v) + } + return _u +} + +// ClearConnectedAt clears the value of the "connected_at" field. +func (_u *RuntimeBrokerUpdateOne) ClearConnectedAt() *RuntimeBrokerUpdateOne { + _u.mutation.ClearConnectedAt() + return _u +} + // SetUpdated sets the "updated" field. func (_u *RuntimeBrokerUpdateOne) SetUpdated(v time.Time) *RuntimeBrokerUpdateOne { _u.mutation.SetUpdated(v) @@ -1059,6 +1197,24 @@ func (_u *RuntimeBrokerUpdateOne) sqlSave(ctx context.Context) (_node *RuntimeBr if value, ok := _u.mutation.AutoProvide(); ok { _spec.SetField(runtimebroker.FieldAutoProvide, field.TypeBool, value) } + if value, ok := _u.mutation.ConnectedHubID(); ok { + _spec.SetField(runtimebroker.FieldConnectedHubID, field.TypeString, value) + } + if _u.mutation.ConnectedHubIDCleared() { + _spec.ClearField(runtimebroker.FieldConnectedHubID, field.TypeString) + } + if value, ok := _u.mutation.ConnectedSessionID(); ok { + _spec.SetField(runtimebroker.FieldConnectedSessionID, field.TypeString, value) + } + if _u.mutation.ConnectedSessionIDCleared() { + _spec.ClearField(runtimebroker.FieldConnectedSessionID, field.TypeString) + } + if value, ok := _u.mutation.ConnectedAt(); ok { + _spec.SetField(runtimebroker.FieldConnectedAt, field.TypeTime, value) + } + if _u.mutation.ConnectedAtCleared() { + _spec.ClearField(runtimebroker.FieldConnectedAt, field.TypeTime) + } if value, ok := _u.mutation.Updated(); ok { _spec.SetField(runtimebroker.FieldUpdated, field.TypeTime, value) } diff --git a/pkg/ent/schema/brokerdispatch.go b/pkg/ent/schema/brokerdispatch.go new file mode 100644 index 000000000..43ddf70e0 --- /dev/null +++ b/pkg/ent/schema/brokerdispatch.go @@ -0,0 +1,99 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" + "github.com/google/uuid" +) + +// BrokerDispatch holds the schema definition for the BrokerDispatch entity — the +// durable intent table for the "DB as state machine, NOTIFY as the signal" +// dispatch model (design §5.2). A row records a lifecycle/create-time command +// targeted at a broker; the socket-holding node reconciles it (claim → run local +// tunnel op → mark done/failed). `args`/`result` are TEXT (JSON) to stay +// dialect-neutral and keep secrets out of NOTIFY payloads. +type BrokerDispatch struct { + ent.Schema +} + +// Fields of the BrokerDispatch. +func (BrokerDispatch) Fields() []ent.Field { + return []ent.Field{ + field.UUID("id", uuid.UUID{}). + Default(uuid.New). + Immutable(), + field.UUID("broker_id", uuid.UUID{}), + // agent_id is null for project-scoped ops (e.g. create-with-gather). + field.UUID("agent_id", uuid.UUID{}). + Optional(). + Nillable(), + field.String("agent_slug"). + Optional(), + field.UUID("project_id", uuid.UUID{}). + Optional(). + Nillable(), + // op: start|stop|restart|delete|finalize_env|check_prompt|create|message + field.String("op"). + NotEmpty(), + // args: JSON; bulky/secret-bearing fields (resolvedEnv, resolvedSecrets, + // inlineConfig, structured bodies) live here, NOT in the NOTIFY payload. + field.String("args"). + Optional(), + // state: pending|in_progress|done|failed + field.String("state"). + Default("pending"), + // result: JSON; for ops that return data (check_prompt, env-gather). + field.String("result"). + Optional(), + // claimed_by: hub instanceID that reconciled this intent. + field.String("claimed_by"). + Optional(), + field.Int("attempts"). + Default(0), + field.String("error"). + Optional(), + field.Time("created_at"). + Default(time.Now). + Immutable(), + field.Time("updated_at"). + Default(time.Now). + UpdateDefault(time.Now), + field.Time("deadline_at"). + Optional(). + Nillable(), + } +} + +// Indexes of the BrokerDispatch. +func (BrokerDispatch) Indexes() []ent.Index { + return []ent.Index{ + // Drain query: WHERE broker_id=$X AND state='pending'. + index.Fields("broker_id", "state"), + } +} + +// Annotations of the BrokerDispatch. +func (BrokerDispatch) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "broker_dispatch"}, + } +} diff --git a/pkg/ent/schema/message.go b/pkg/ent/schema/message.go index a909e9884..8ff64e2cf 100644 --- a/pkg/ent/schema/message.go +++ b/pkg/ent/schema/message.go @@ -64,6 +64,14 @@ func (Message) Fields() []ent.Field { Optional(), field.String("group_id"). Optional(), + // dispatch_state tracks cross-node delivery (the message row IS the durable + // dispatch intent): pending|dispatched|failed. The owning node CAS-flips + // pending->dispatched after tunneling. Default keeps existing rows valid. + field.String("dispatch_state"). + Default("pending"), + field.Time("dispatched_at"). + Optional(). + Nillable(), field.Time("created"). Default(time.Now). Immutable(), diff --git a/pkg/ent/schema/runtimebroker.go b/pkg/ent/schema/runtimebroker.go index 5fcbc7fb2..e4e4290df 100644 --- a/pkg/ent/schema/runtimebroker.go +++ b/pkg/ent/schema/runtimebroker.go @@ -86,6 +86,15 @@ func (RuntimeBroker) Fields() []ent.Field { Optional(), field.Bool("auto_provide"). Default(false), + field.String("connected_hub_id"). + Optional(). + Nillable(), + field.String("connected_session_id"). + Optional(). + Nillable(), + field.Time("connected_at"). + Optional(). + Nillable(), field.Time("created"). Default(time.Now). Immutable(), diff --git a/pkg/ent/tx.go b/pkg/ent/tx.go index adf201cf2..64f55b502 100644 --- a/pkg/ent/tx.go +++ b/pkg/ent/tx.go @@ -20,6 +20,8 @@ type Tx struct { AllowListEntry *AllowListEntryClient // ApiKey is the client for interacting with the ApiKey builders. ApiKey *ApiKeyClient + // BrokerDispatch is the client for interacting with the BrokerDispatch builders. + BrokerDispatch *BrokerDispatchClient // BrokerJoinToken is the client for interacting with the BrokerJoinToken builders. BrokerJoinToken *BrokerJoinTokenClient // BrokerSecret is the client for interacting with the BrokerSecret builders. @@ -207,6 +209,7 @@ func (tx *Tx) init() { tx.Agent = NewAgentClient(tx.config) tx.AllowListEntry = NewAllowListEntryClient(tx.config) tx.ApiKey = NewApiKeyClient(tx.config) + tx.BrokerDispatch = NewBrokerDispatchClient(tx.config) tx.BrokerJoinToken = NewBrokerJoinTokenClient(tx.config) tx.BrokerSecret = NewBrokerSecretClient(tx.config) tx.EnvVar = NewEnvVarClient(tx.config) diff --git a/pkg/hub/broker_routing.go b/pkg/hub/broker_routing.go new file mode 100644 index 000000000..f90aa3d9c --- /dev/null +++ b/pkg/hub/broker_routing.go @@ -0,0 +1,122 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "errors" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" +) + +// ErrMessageDeferred is returned by HybridBrokerClient.MessageAgent when the +// broker is not locally connected and has no HTTP endpoint: the message row +// (already persisted with dispatch_state=pending) is the durable intent, and +// the caller should emit a NOTIFY wakeup and return 202 Accepted. +var ErrMessageDeferred = errors.New("message deferred: broker not locally reachable") + +// ErrLifecycleDeferred is returned by HybridBrokerClient.StartAgent/StopAgent/ +// RestartAgent when the broker is not locally connected and has no HTTP +// endpoint. The caller should serialize resolved params into a broker_dispatch +// row, signal the owning node via the command bus, and wait for the resulting +// agent status transition (design §5.4, §6.2). +var ErrLifecycleDeferred = errors.New("lifecycle deferred: broker not locally reachable") + +// routeDecision is the outcome of HybridBrokerClient.route — how a dispatch for a +// broker should be delivered when this node does not hold the broker's socket. +type routeDecision int + +const ( + // routeLocal: this node holds the broker's control-channel socket — tunnel + // directly (the unchanged, zero-added-latency fast path). + routeLocal routeDecision = iota + // routeForward: some other node is believed to own the broker (affinity hint + // is alive) — write durable intent + NOTIFY and let the owner self-select. + routeForward + // routeHTTP: no live owner, but the broker exposes a direct HTTP endpoint + // (direct-mode broker; existing fallback — rare under NAT'd deployments). + routeHTTP + // routeUndeliverable: no owner and no endpoint — write durable pending intent + // and return a retryable status; reconciled on the broker's next reconnect. + routeUndeliverable +) + +func (d routeDecision) String() string { + switch d { + case routeLocal: + return "local" + case routeForward: + return "forward" + case routeHTTP: + return "http" + default: + return "undeliverable" + } +} + +// defaultAffinityFreshness bounds how long a broker's last_heartbeat is trusted +// as "owner alive" for routing. Generous (a multiple of the heartbeat interval); +// a stale hint only costs one dispatch timeout before falling through, and the +// reaper (B5-1) clears dead owners. +const defaultAffinityFreshness = 90 * time.Second + +// route decides how to deliver a dispatch for brokerID. The local fast path is +// checked first and unchanged; affinity is consulted only to choose between +// forwarding (durable intent + signal) and fast-failing (design §5.3). The +// affinity lookup is a hint — a wrong "alive" costs one timeout (intent stays +// durable and reconciles later); a wrong "dead" is reaped by §7.1. +func (c *HybridBrokerClient) route(ctx context.Context, brokerID, brokerEndpoint string) routeDecision { + if c.controlChannel.manager.IsConnected(brokerID) { + return routeLocal + } + var owner string + var alive bool + if c.affinity != nil { + owner, alive = c.affinity(ctx, brokerID) + } + switch { + case owner != "" && alive: + return routeForward + case brokerEndpoint != "": + return routeHTTP + default: + return routeUndeliverable + } +} + +// SetAffinityLookup injects the affinity hint used by route(). Wired by the +// server to a store-backed lookup (StoreAffinityLookup). +func (c *HybridBrokerClient) SetAffinityLookup(fn func(ctx context.Context, brokerID string) (owner string, alive bool)) { + c.affinity = fn +} + +// StoreAffinityLookup returns an affinity lookup backed by runtime_brokers: the +// owner is connected_hub_id, and "alive" means last_heartbeat is within +// freshness. (Liveness is inferred from heartbeat freshness because there is no +// hub-to-hub addressability to ping a peer — design §5.3.) +func StoreAffinityLookup(st store.Store, freshness time.Duration) func(ctx context.Context, brokerID string) (string, bool) { + if freshness <= 0 { + freshness = defaultAffinityFreshness + } + return func(ctx context.Context, brokerID string) (string, bool) { + b, err := st.GetRuntimeBroker(ctx, brokerID) + if err != nil || b == nil || b.ConnectedHubID == nil || *b.ConnectedHubID == "" { + return "", false + } + alive := !b.LastHeartbeat.IsZero() && time.Since(b.LastHeartbeat) < freshness + return *b.ConnectedHubID, alive + } +} diff --git a/pkg/hub/broker_routing_test.go b/pkg/hub/broker_routing_test.go new file mode 100644 index 000000000..a3cbe773b --- /dev/null +++ b/pkg/hub/broker_routing_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/messages" + "github.com/stretchr/testify/assert" +) + +// fakeHTTPClient records calls to MessageAgent so we can verify the HTTP +// fallback path. Other methods are stubs. +type fakeHTTPClient struct { + messageAgentCalled bool +} + +func (f *fakeHTTPClient) MessageAgent(context.Context, string, string, string, string, string, bool, *messages.StructuredMessage) error { + f.messageAgentCalled = true + return nil +} + +// Stub implementations for the RuntimeBrokerClient interface — only MessageAgent matters. +func (f *fakeHTTPClient) CreateAgent(context.Context, string, string, *RemoteCreateAgentRequest) (*RemoteAgentResponse, error) { + return nil, nil +} +func (f *fakeHTTPClient) StartAgent(context.Context, string, string, string, string, string, string, string, string, map[string]string, []ResolvedSecret, *api.ScionConfig, []api.SharedDir, bool) (*RemoteAgentResponse, error) { + return nil, nil +} +func (f *fakeHTTPClient) StopAgent(context.Context, string, string, string, string) error { + return nil +} +func (f *fakeHTTPClient) RestartAgent(context.Context, string, string, string, string, map[string]string) error { + return nil +} +func (f *fakeHTTPClient) DeleteAgent(context.Context, string, string, string, string, bool, bool, bool, time.Time) error { + return nil +} +func (f *fakeHTTPClient) CheckAgentPrompt(context.Context, string, string, string, string) (bool, error) { + return false, nil +} +func (f *fakeHTTPClient) CreateAgentWithGather(context.Context, string, string, *RemoteCreateAgentRequest) (*RemoteAgentResponse, *RemoteEnvRequirementsResponse, error) { + return nil, nil, nil +} +func (f *fakeHTTPClient) FinalizeEnv(context.Context, string, string, string, map[string]string) (*RemoteAgentResponse, error) { + return nil, nil +} +func (f *fakeHTTPClient) GetAgentLogs(context.Context, string, string, string, string, int) (string, error) { + return "", nil +} +func (f *fakeHTTPClient) ExecAgent(context.Context, string, string, string, string, []string, int) (string, int, error) { + return "", 0, nil +} +func (f *fakeHTTPClient) CleanupProject(context.Context, string, string, string) error { + return nil +} + +func TestHybridBrokerClient_Route(t *testing.T) { + ctx := context.Background() + const localBroker = "broker-local" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + // Seed a live local socket for localBroker only. + mgr.mu.Lock() + mgr.connections[localBroker] = &BrokerConnection{brokerID: localBroker, sessionID: "s1"} + mgr.mu.Unlock() + + c := NewHybridBrokerClient(mgr, nil, nil, false) + + cases := []struct { + name string + brokerID string + endpoint string + affOwner string + affAlive bool + want routeDecision + }{ + {"local socket wins", localBroker, "", "", false, routeLocal}, + {"local wins even over alive affinity", localBroker, "http://x", "hubA", true, routeLocal}, + {"alive owner -> forward", "b1", "", "hubA", true, routeForward}, + {"alive owner -> forward (endpoint ignored)", "b1", "http://x", "hubA", true, routeForward}, + {"no owner, endpoint set -> http", "b2", "http://x", "", false, routeHTTP}, + {"stale owner, endpoint set -> http", "b3", "http://x", "hubA", false, routeHTTP}, + {"stale owner, no endpoint -> undeliverable", "b4", "", "hubA", false, routeUndeliverable}, + {"no owner, no endpoint -> undeliverable", "b5", "", "", false, routeUndeliverable}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return tc.affOwner, tc.affAlive }) + got := c.route(ctx, tc.brokerID, tc.endpoint) + assert.Equal(t, tc.want, got, "route(%s, endpoint=%q, owner=%q alive=%v)", tc.brokerID, tc.endpoint, tc.affOwner, tc.affAlive) + }) + } +} + +func TestHybridBrokerClient_Route_NilAffinityIsSafe(t *testing.T) { + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, nil, nil, false) + // No affinity lookup set: a non-local broker with no endpoint is undeliverable. + assert.Equal(t, routeUndeliverable, c.route(context.Background(), "b-none", "")) + assert.Equal(t, routeHTTP, c.route(context.Background(), "b-ep", "http://x")) +} + +func TestHybridBrokerClient_MessageAgent_RouteGate(t *testing.T) { + const localBroker = "broker-local" + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + mgr.mu.Lock() + mgr.connections[localBroker] = &BrokerConnection{brokerID: localBroker, sessionID: "s1"} + mgr.mu.Unlock() + + httpClient := &fakeHTTPClient{} + c := NewHybridBrokerClient(mgr, httpClient, nil, false) + + t.Run("routeLocal uses control channel (not deferred)", func(t *testing.T) { + // Verify route() returns routeLocal for the locally connected broker. + // We don't call MessageAgent directly because the stub BrokerConnection + // doesn't have a real tunnel; the route decision is what matters. + got := c.route(context.Background(), localBroker, "") + assert.Equal(t, routeLocal, got, "should pick local tunnel for connected broker") + }) + + t.Run("routeHTTP delivers via HTTP client", func(t *testing.T) { + httpClient.messageAgentCalled = false + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + err := c.MessageAgent(context.Background(), remoteBroker, "http://endpoint", "a1", "p1", "hi", false, nil) + assert.NoError(t, err) + assert.True(t, httpClient.messageAgentCalled, "HTTP fallback should be used") + }) + + t.Run("routeForward returns ErrMessageDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + err := c.MessageAgent(context.Background(), remoteBroker, "", "a1", "p1", "hi", false, nil) + assert.ErrorIs(t, err, ErrMessageDeferred) + }) + + t.Run("routeUndeliverable returns ErrMessageDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + err := c.MessageAgent(context.Background(), remoteBroker, "", "a1", "p1", "hi", false, nil) + assert.ErrorIs(t, err, ErrMessageDeferred) + }) +} diff --git a/pkg/hub/command_bus.go b/pkg/hub/command_bus.go new file mode 100644 index 000000000..f504bc25f --- /dev/null +++ b/pkg/hub/command_bus.go @@ -0,0 +1,337 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// CommandBus abstracts the inter-node command signal channel. The Postgres +// implementation LISTENs on scion_broker_cmd; the no-op implementation is +// used for SQLite (single-process, all brokers are local). +type CommandBus interface { + // NotifyBrokerCmd issues a NOTIFY signal inside the caller's transaction, + // so the signal commits atomically with the durable intent row. + NotifyBrokerCmd(ctx context.Context, tx pgExecutor, brokerID string) error + // SignalBrokerCmd is a best-effort NOTIFY using the bus's own pool (not + // tx-scoped). Used by the message dispatch path where the durable intent + // is the message row itself and the NOTIFY is only a wakeup hint. + SignalBrokerCmd(ctx context.Context, brokerID string) error + Close() +} + +const ( + // pgCommandChannel is the global Postgres NOTIFY channel for broker + // command signals. Every hub instance LISTENs on this single channel and + // filters by local ownership. + pgCommandChannel = "scion_broker_cmd" +) + +// cmdSignal is the JSON wire format for the NOTIFY payload on scion_broker_cmd. +// It is intentionally tiny: the durable command lives in the DB; this is only +// a wakeup. +type cmdSignal struct { + BrokerID string `json:"broker_id"` + Kind string `json:"kind"` +} + +// PostgresCommandBus is a sibling of PostgresEventPublisher that LISTENs on +// scion_broker_cmd for dispatch wakeup signals. It maintains its OWN pgx +// connection (listener) and pool (publisher) so dispatch and event-fanout are +// independently pooled (design §5.1). +// +// On receiving a signal the bus checks local ownership via the injected +// ownsLocally func: if this node holds the broker's WebSocket, it invokes the +// onSignal callback (which will be wired to the reconcile drain in B2-5). +type PostgresCommandBus struct { + pool *pgxpool.Pool + dsn string + log *slog.Logger + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + + mu sync.RWMutex + ownsLocally func(brokerID string) bool + onSignal func(ctx context.Context, brokerID string) + onReconnect func() + closed bool +} + +var _ CommandBus = (*PostgresCommandBus)(nil) + +// NewPostgresCommandBus creates a command bus backed by Postgres LISTEN/NOTIFY. +// ownsLocally should return true when this process holds the broker's control- +// channel WebSocket (typically controlChannel.manager.IsConnected). onSignal +// is the reconcile callback invoked when a signal arrives for a locally-owned +// broker. +func NewPostgresCommandBus( + ctx context.Context, + dsn string, + ownsLocally func(brokerID string) bool, + onSignal func(ctx context.Context, brokerID string), + log *slog.Logger, +) (*PostgresCommandBus, error) { + if log == nil { + log = slog.Default() + } + if ownsLocally == nil { + ownsLocally = func(string) bool { return false } + } + if onSignal == nil { + onSignal = func(context.Context, string) {} + } + + poolCfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parsing command bus dsn: %w", err) + } + applyEventPoolKeepalives(poolCfg) + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("creating command bus pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging postgres for command bus: %w", err) + } + + busCtx, cancel := context.WithCancel(context.Background()) + b := &PostgresCommandBus{ + pool: pool, + dsn: dsn, + log: log, + ctx: busCtx, + cancel: cancel, + ownsLocally: ownsLocally, + onSignal: onSignal, + } + + b.wg.Add(1) + go b.runListener() + + log.Info("Postgres command bus started", "channel", pgCommandChannel) + return b, nil +} + +// SetOnReconnect sets a callback invoked each time the listener reconnects +// after a connection loss. Used by B5-2 to increment a reconnects counter. +func (b *PostgresCommandBus) SetOnReconnect(fn func()) { + b.mu.Lock() + defer b.mu.Unlock() + b.onReconnect = fn +} + +// SetOnSignal replaces the reconcile callback. This allows wiring the +// reconcile drain (B2-5) after construction. +func (b *PostgresCommandBus) SetOnSignal(fn func(ctx context.Context, brokerID string)) { + b.mu.Lock() + defer b.mu.Unlock() + if fn == nil { + fn = func(context.Context, string) {} + } + b.onSignal = fn +} + +// NotifyBrokerCmd issues NOTIFY scion_broker_cmd inside the caller's +// transaction, so the signal commits atomically with the durable intent. +func (b *PostgresCommandBus) NotifyBrokerCmd(ctx context.Context, tx pgExecutor, brokerID string) error { + sig := cmdSignal{BrokerID: brokerID, Kind: "dispatch"} + payload, err := json.Marshal(sig) + if err != nil { + return fmt.Errorf("marshaling command signal: %w", err) + } + _, err = tx.Exec(ctx, `SELECT pg_notify($1, $2)`, pgCommandChannel, string(payload)) + if err != nil { + return fmt.Errorf("pg_notify on %s: %w", pgCommandChannel, err) + } + return nil +} + +// SignalBrokerCmd issues a best-effort NOTIFY using the bus's own pool. +func (b *PostgresCommandBus) SignalBrokerCmd(ctx context.Context, brokerID string) error { + return b.NotifyBrokerCmd(ctx, b.pool, brokerID) +} + +// Close stops the listener and releases the pool. +func (b *PostgresCommandBus) Close() { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return + } + b.closed = true + b.mu.Unlock() + + b.cancel() + b.wg.Wait() + b.pool.Close() +} + +// runListener mirrors PostgresEventPublisher.runListener: maintain a dedicated +// LISTEN connection with backoff-reconnect. +func (b *PostgresCommandBus) runListener() { + defer b.wg.Done() + + const ( + minBackoff = 250 * time.Millisecond + maxBackoff = 10 * time.Second + ) + backoff := minBackoff + firstConnect := true + + for { + if b.ctx.Err() != nil { + return + } + + conn, err := b.connectListener(b.ctx) + if err != nil { + if b.ctx.Err() != nil { + return + } + b.log.Warn("Command bus listener connect failed, retrying", "error", err, "backoff", backoff) + if !b.sleep(backoff) { + return + } + backoff = nextBackoff(backoff, maxBackoff) + continue + } + + if !firstConnect { + b.mu.RLock() + fn := b.onReconnect + b.mu.RUnlock() + if fn != nil { + fn() + } + } + firstConnect = false + b.log.Info("Command bus listener connected") + backoff = minBackoff + + loopErr := b.listenLoop(conn) + conn.Close(context.Background()) + + if b.ctx.Err() != nil { + return + } + + b.log.Warn("Command bus listener connection lost, reconnecting", "error", loopErr, "backoff", backoff) + if !b.sleep(backoff) { + return + } + backoff = nextBackoff(backoff, maxBackoff) + } +} + +// connectListener opens a dedicated LISTEN connection with TCP keepalives, +// reusing the same helper as PostgresEventPublisher. +func (b *PostgresCommandBus) connectListener(ctx context.Context) (*pgx.Conn, error) { + cc, err := pgx.ParseConfig(b.dsn) + if err != nil { + return nil, fmt.Errorf("parsing command bus listener dsn: %w", err) + } + applyConnKeepalives(cc) + return pgx.ConnectConfig(ctx, cc) +} + +// listenLoop LISTENs on scion_broker_cmd and dispatches signals. +func (b *PostgresCommandBus) listenLoop(conn *pgx.Conn) error { + if err := execListen(b.ctx, conn, "LISTEN", pgCommandChannel); err != nil { + return fmt.Errorf("LISTEN %s: %w", pgCommandChannel, err) + } + + for { + if b.ctx.Err() != nil { + return b.ctx.Err() + } + + waitCtx, cancel := context.WithTimeout(b.ctx, listenPollInterval) + notif, err := conn.WaitForNotification(waitCtx) + cancel() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + continue + } + return err + } + + b.handleSignal(notif.Payload) + } +} + +// handleSignal decodes a command signal and, if this node owns the broker, +// invokes the reconcile callback. +func (b *PostgresCommandBus) handleSignal(payload string) { + var sig cmdSignal + if err := json.Unmarshal([]byte(payload), &sig); err != nil { + b.log.Error("Failed to decode command signal", "error", err) + return + } + + if sig.BrokerID == "" { + b.log.Warn("Command signal missing broker_id, ignoring") + return + } + + b.mu.RLock() + owns := b.ownsLocally(sig.BrokerID) + onSig := b.onSignal + b.mu.RUnlock() + + if !owns { + return + } + + b.log.Info("Command signal received for local broker, invoking reconcile", + "broker_id", sig.BrokerID, "kind", sig.Kind) + onSig(b.ctx, sig.BrokerID) +} + +// sleep waits for d or until the bus context is canceled. +func (b *PostgresCommandBus) sleep(d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-b.ctx.Done(): + return false + case <-t.C: + return true + } +} + +// --- No-op command bus for SQLite (single-process) --- + +// NoopCommandBus is a no-op CommandBus for the SQLite backend. In single- +// process mode every broker is local; no inter-node signal is needed. +type NoopCommandBus struct{} + +var _ CommandBus = NoopCommandBus{} + +func (NoopCommandBus) NotifyBrokerCmd(context.Context, pgExecutor, string) error { return nil } +func (NoopCommandBus) SignalBrokerCmd(context.Context, string) error { return nil } +func (NoopCommandBus) Close() {} diff --git a/pkg/hub/command_bus_test.go b/pkg/hub/command_bus_test.go new file mode 100644 index 000000000..b3494b708 --- /dev/null +++ b/pkg/hub/command_bus_test.go @@ -0,0 +1,476 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// --- pure unit tests (no database required) --- + +// TestNotifyBrokerCmd_Payload verifies the SQL and JSON shape of a NOTIFY call +// issued by NotifyBrokerCmd, using the same recExec test double as the event +// publisher tests. +func TestNotifyBrokerCmd_Payload(t *testing.T) { + bus := &PostgresCommandBus{ + ctx: context.Background(), + } + + tx := &recExec{} + if err := bus.NotifyBrokerCmd(context.Background(), tx, "broker-123"); err != nil { + t.Fatalf("NotifyBrokerCmd: %v", err) + } + + calls := tx.notifyCalls() + if len(calls) != 1 { + t.Fatalf("expected 1 pg_notify call, got %d", len(calls)) + } + + channel := calls[0].args[0].(string) + if channel != pgCommandChannel { + t.Fatalf("channel = %q, want %q", channel, pgCommandChannel) + } + + var sig cmdSignal + payload := calls[0].args[1].(string) + if err := json.Unmarshal([]byte(payload), &sig); err != nil { + t.Fatalf("decode signal payload: %v", err) + } + if sig.BrokerID != "broker-123" { + t.Fatalf("broker_id = %q, want %q", sig.BrokerID, "broker-123") + } + if sig.Kind != "dispatch" { + t.Fatalf("kind = %q, want %q", sig.Kind, "dispatch") + } +} + +// TestHandleSignal_OwnsLocally verifies that handleSignal invokes the onSignal +// callback only when ownsLocally returns true. +func TestHandleSignal_OwnsLocally(t *testing.T) { + var mu sync.Mutex + var reconciled []string + + bus := &PostgresCommandBus{ + ctx: context.Background(), + log: slog.Default(), + ownsLocally: func(brokerID string) bool { + return brokerID == "local-broker" + }, + onSignal: func(_ context.Context, brokerID string) { + mu.Lock() + defer mu.Unlock() + reconciled = append(reconciled, brokerID) + }, + } + + // Signal for a locally-owned broker -> should invoke callback. + sig1, _ := json.Marshal(cmdSignal{BrokerID: "local-broker", Kind: "dispatch"}) + bus.handleSignal(string(sig1)) + + // Signal for a remote broker -> should be ignored. + sig2, _ := json.Marshal(cmdSignal{BrokerID: "remote-broker", Kind: "dispatch"}) + bus.handleSignal(string(sig2)) + + mu.Lock() + defer mu.Unlock() + if len(reconciled) != 1 { + t.Fatalf("expected 1 reconcile call, got %d", len(reconciled)) + } + if reconciled[0] != "local-broker" { + t.Fatalf("reconciled broker = %q, want %q", reconciled[0], "local-broker") + } +} + +// TestHandleSignal_EmptyBrokerID verifies signals with a missing broker_id are +// silently ignored. +func TestHandleSignal_EmptyBrokerID(t *testing.T) { + called := false + bus := &PostgresCommandBus{ + ctx: context.Background(), + log: slog.Default(), + ownsLocally: func(string) bool { return true }, + onSignal: func(context.Context, string) { called = true }, + } + + sig, _ := json.Marshal(cmdSignal{Kind: "dispatch"}) + bus.handleSignal(string(sig)) + + if called { + t.Fatal("onSignal should not be called for an empty broker_id") + } +} + +// TestHandleSignal_MalformedJSON verifies malformed payloads don't panic. +func TestHandleSignal_MalformedJSON(t *testing.T) { + called := false + bus := &PostgresCommandBus{ + ctx: context.Background(), + log: slog.Default(), + ownsLocally: func(string) bool { return true }, + onSignal: func(context.Context, string) { called = true }, + } + + bus.handleSignal("not valid json{{{") + + if called { + t.Fatal("onSignal should not be called for malformed JSON") + } +} + +// TestSetOnSignal verifies the reconcile callback can be replaced after +// construction. +func TestSetOnSignal(t *testing.T) { + var mu sync.Mutex + var called string + + bus := &PostgresCommandBus{ + ctx: context.Background(), + log: slog.Default(), + ownsLocally: func(string) bool { return true }, + onSignal: func(_ context.Context, id string) { mu.Lock(); called = "original-" + id; mu.Unlock() }, + } + + bus.SetOnSignal(func(_ context.Context, id string) { + mu.Lock() + called = "replaced-" + id + mu.Unlock() + }) + + sig, _ := json.Marshal(cmdSignal{BrokerID: "b1", Kind: "dispatch"}) + bus.handleSignal(string(sig)) + + mu.Lock() + defer mu.Unlock() + if called != "replaced-b1" { + t.Fatalf("called = %q, want %q", called, "replaced-b1") + } +} + +// TestNoopCommandBus_NotifyBrokerCmd verifies the no-op bus is a safe no-op. +func TestNoopCommandBus_NotifyBrokerCmd(t *testing.T) { + bus := NoopCommandBus{} + tx := &recExec{} + + if err := bus.NotifyBrokerCmd(context.Background(), tx, "any-broker"); err != nil { + t.Fatalf("NoopCommandBus.NotifyBrokerCmd: %v", err) + } + + // No SQL should have been issued. + if len(tx.notifyCalls()) != 0 { + t.Fatalf("NoopCommandBus should not issue any SQL, got %d calls", len(tx.notifyCalls())) + } + + // Close is a safe no-op. + bus.Close() +} + +// --- integration tests (require a live Postgres via SCION_TEST_POSTGRES_DSN) --- + +// TestCommandBusIntegration_SignalDelivery starts a real PostgresCommandBus and +// verifies a NOTIFY on scion_broker_cmd is received and invokes the callback. +func TestCommandBusIntegration_SignalDelivery(t *testing.T) { + dsn := requirePostgres(t) + ctx := context.Background() + + var mu sync.Mutex + var reconciled []string + + bus, err := NewPostgresCommandBus(ctx, dsn, + func(brokerID string) bool { return brokerID == "owned-broker" }, + func(_ context.Context, brokerID string) { + mu.Lock() + defer mu.Unlock() + reconciled = append(reconciled, brokerID) + }, + nil, + ) + if err != nil { + t.Fatalf("NewPostgresCommandBus: %v", err) + } + defer bus.Close() + + // Give the listener time to LISTEN. + time.Sleep(2 * listenPollInterval) + + // Publish a signal via a direct NOTIFY (simulating the tx-scoped path). + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer conn.Close(context.Background()) + + sig, _ := json.Marshal(cmdSignal{BrokerID: "owned-broker", Kind: "dispatch"}) + if _, err := conn.Exec(ctx, `SELECT pg_notify($1, $2)`, pgCommandChannel, string(sig)); err != nil { + t.Fatalf("pg_notify: %v", err) + } + + // Also send a signal for a non-owned broker; it should be ignored. + sig2, _ := json.Marshal(cmdSignal{BrokerID: "remote-broker", Kind: "dispatch"}) + if _, err := conn.Exec(ctx, `SELECT pg_notify($1, $2)`, pgCommandChannel, string(sig2)); err != nil { + t.Fatalf("pg_notify: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(reconciled) + mu.Unlock() + if n >= 1 { + break + } + time.Sleep(100 * time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + if len(reconciled) != 1 { + t.Fatalf("expected exactly 1 reconcile, got %d: %v", len(reconciled), reconciled) + } + if reconciled[0] != "owned-broker" { + t.Fatalf("reconciled %q, want %q", reconciled[0], "owned-broker") + } +} + +// TestCommandBusIntegration_NotifyBrokerCmd verifies NotifyBrokerCmd publishes a +// signal inside a caller's transaction that is received by the listener. +func TestCommandBusIntegration_NotifyBrokerCmd(t *testing.T) { + dsn := requirePostgres(t) + ctx := context.Background() + + var mu sync.Mutex + var reconciled []string + + bus, err := NewPostgresCommandBus(ctx, dsn, + func(string) bool { return true }, + func(_ context.Context, brokerID string) { + mu.Lock() + defer mu.Unlock() + reconciled = append(reconciled, brokerID) + }, + nil, + ) + if err != nil { + t.Fatalf("NewPostgresCommandBus: %v", err) + } + defer bus.Close() + + time.Sleep(2 * listenPollInterval) + + // Use the bus's own pool to create a transaction. + tx, err := bus.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + if err := bus.NotifyBrokerCmd(ctx, tx, "txn-broker"); err != nil { + t.Fatalf("NotifyBrokerCmd: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(reconciled) + mu.Unlock() + if n >= 1 { + break + } + time.Sleep(100 * time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + if len(reconciled) != 1 { + t.Fatalf("expected 1 reconcile, got %d", len(reconciled)) + } + if reconciled[0] != "txn-broker" { + t.Fatalf("reconciled %q, want %q", reconciled[0], "txn-broker") + } +} + +// TestCommandBusIntegration_TransactionalRollback verifies that a NOTIFY enrolled +// in a rolled-back transaction is never delivered (mirrors the event publisher's +// transactional rollback test). +func TestCommandBusIntegration_TransactionalRollback(t *testing.T) { + dsn := requirePostgres(t) + ctx := context.Background() + + var mu sync.Mutex + var reconciled []string + + bus, err := NewPostgresCommandBus(ctx, dsn, + func(string) bool { return true }, + func(_ context.Context, brokerID string) { + mu.Lock() + defer mu.Unlock() + reconciled = append(reconciled, brokerID) + }, + nil, + ) + if err != nil { + t.Fatalf("NewPostgresCommandBus: %v", err) + } + defer bus.Close() + + time.Sleep(2 * listenPollInterval) + + // Rolled-back publish: must NOT be delivered. + txRollback, err := bus.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + if err := bus.NotifyBrokerCmd(ctx, txRollback, "rolled-back-broker"); err != nil { + t.Fatalf("NotifyBrokerCmd: %v", err) + } + if err := txRollback.Rollback(ctx); err != nil { + t.Fatalf("rollback: %v", err) + } + + // Wait to ensure no spurious delivery. + time.Sleep(2 * time.Second) + + mu.Lock() + n := len(reconciled) + mu.Unlock() + if n != 0 { + t.Fatalf("rolled-back signal was delivered: %v", reconciled) + } + + // Committed publish: must be delivered. + txCommit, err := bus.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + if err := bus.NotifyBrokerCmd(ctx, txCommit, "committed-broker"); err != nil { + t.Fatalf("NotifyBrokerCmd: %v", err) + } + if err := txCommit.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n = len(reconciled) + mu.Unlock() + if n >= 1 { + break + } + time.Sleep(100 * time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + if len(reconciled) != 1 || reconciled[0] != "committed-broker" { + t.Fatalf("expected [committed-broker], got %v", reconciled) + } +} + +// TestCommandBusIntegration_Reconnect terminates the listener's backend +// connection and verifies the bus reconnects and resumes delivery. +func TestCommandBusIntegration_Reconnect(t *testing.T) { + dsn := requirePostgres(t) + ctx := context.Background() + + var mu sync.Mutex + var reconciled []string + + bus, err := NewPostgresCommandBus(ctx, dsn, + func(string) bool { return true }, + func(_ context.Context, brokerID string) { + mu.Lock() + defer mu.Unlock() + reconciled = append(reconciled, brokerID) + }, + nil, + ) + if err != nil { + t.Fatalf("NewPostgresCommandBus: %v", err) + } + defer bus.Close() + + time.Sleep(2 * listenPollInterval) + + // Forcibly terminate all LISTENing backends for this database. + if _, err := bus.pool.Exec(ctx, + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE query ILIKE 'LISTEN %' AND pid <> pg_backend_pid()`); err != nil { + t.Fatalf("terminate backends: %v", err) + } + + // Wait for reconnect + resubscribe. + time.Sleep(3 * time.Second) + + // Publish a signal after reconnect. + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer conn.Close(context.Background()) + + sig, _ := json.Marshal(cmdSignal{BrokerID: "after-reconnect", Kind: "dispatch"}) + if _, err := conn.Exec(ctx, `SELECT pg_notify($1, $2)`, pgCommandChannel, string(sig)); err != nil { + t.Fatalf("pg_notify: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + n := len(reconciled) + mu.Unlock() + if n >= 1 { + break + } + time.Sleep(200 * time.Millisecond) + } + + mu.Lock() + defer mu.Unlock() + if len(reconciled) == 0 { + t.Fatal("expected delivery after reconnect, got none") + } + found := false + for _, id := range reconciled { + if id == "after-reconnect" { + found = true + break + } + } + if !found { + t.Fatalf("expected after-reconnect in reconciled, got %v", reconciled) + } +} + +// TestCommandBusIntegration_CloseIsIdempotent verifies double-close is safe. +func TestCommandBusIntegration_CloseIsIdempotent(t *testing.T) { + dsn := requirePostgres(t) + ctx := context.Background() + + bus, err := NewPostgresCommandBus(ctx, dsn, nil, nil, nil) + if err != nil { + t.Fatalf("NewPostgresCommandBus: %v", err) + } + bus.Close() + bus.Close() // must not panic +} + diff --git a/pkg/hub/controlchannel.go b/pkg/hub/controlchannel.go index 83faa1007..3408179c1 100644 --- a/pkg/hub/controlchannel.go +++ b/pkg/hub/controlchannel.go @@ -65,7 +65,7 @@ type ControlChannelManager struct { config ControlChannelConfig log *slog.Logger upgrader websocket.Upgrader - onDisconnect func(brokerID string) + onDisconnect func(brokerID, sessionID string) } // NewControlChannelManager creates a new control channel manager. @@ -86,8 +86,10 @@ func NewControlChannelManager(config ControlChannelConfig, log *slog.Logger) *Co } // SetOnDisconnect sets a callback that is invoked when a broker disconnects. -// The callback is called asynchronously after the connection is removed. -func (m *ControlChannelManager) SetOnDisconnect(fn func(brokerID string)) { +// The callback is called asynchronously after the connection is removed and +// receives the sessionID of the connection that dropped, so the handler can +// compare-and-clear affinity (avoiding the flap clobber race). +func (m *ControlChannelManager) SetOnDisconnect(fn func(brokerID, sessionID string)) { m.mu.Lock() defer m.mu.Unlock() m.onDisconnect = fn @@ -183,10 +185,12 @@ func (s *StreamProxy) Close() { } // HandleUpgrade upgrades an HTTP connection to a WebSocket control channel. -func (m *ControlChannelManager) HandleUpgrade(w http.ResponseWriter, r *http.Request, brokerID string) error { +// It returns the sessionID generated for the new connection so the caller can +// claim broker affinity for this exact session. +func (m *ControlChannelManager) HandleUpgrade(w http.ResponseWriter, r *http.Request, brokerID string) (string, error) { conn, err := m.upgrader.Upgrade(w, r, nil) if err != nil { - return fmt.Errorf("websocket upgrade failed: %w", err) + return "", fmt.Errorf("websocket upgrade failed: %w", err) } wsConn := wsprotocol.NewConnection(conn, wsprotocol.ConnectionConfig{ @@ -232,19 +236,19 @@ func (m *ControlChannelManager) HandleUpgrade(w http.ResponseWriter, r *http.Req if err := wsConn.WriteJSON(connectedMsg); err != nil { m.log.Error("Failed to send connected message", "brokerID", brokerID, "error", err) brokerConn.Close() - m.removeConnection(brokerID) - return err + m.removeConnection(brokerID, sessionID) + return "", err } - return nil + return sessionID, nil } // handleConnection handles messages from a connected broker. func (m *ControlChannelManager) handleConnection(hc *BrokerConnection) { defer func() { hc.Close() - m.removeConnection(hc.brokerID) - m.log.Info("Broker control channel disconnected", "brokerID", hc.brokerID) + m.removeConnection(hc.brokerID, hc.sessionID) + m.log.Info("Broker control channel disconnected", "brokerID", hc.brokerID, "sessionID", hc.sessionID) }() // Set up pong handler @@ -450,16 +454,23 @@ func (m *ControlChannelManager) pingLoop(hc *BrokerConnection) { } } -// removeConnection removes a broker connection from the manager. -func (m *ControlChannelManager) removeConnection(brokerID string) { +// removeConnection removes a broker connection from the manager. It only +// removes (and fires onDisconnect for) the entry if it is still THIS session: +// when a broker flaps, HandleUpgrade replaces the map entry with a newer +// session, and the older connection's teardown must not drop the live socket or +// stamp a spurious disconnect for the session that already moved on. +func (m *ControlChannelManager) removeConnection(brokerID, sessionID string) { m.mu.Lock() - _, existed := m.connections[brokerID] - delete(m.connections, brokerID) + cur, ok := m.connections[brokerID] + existed := ok && cur.sessionID == sessionID + if existed { + delete(m.connections, brokerID) + } cb := m.onDisconnect m.mu.Unlock() if cb != nil && existed { - go cb(brokerID) + go cb(brokerID, sessionID) } } diff --git a/pkg/hub/controlchannel_client.go b/pkg/hub/controlchannel_client.go index 8878a6ec7..bba8e4e8a 100644 --- a/pkg/hub/controlchannel_client.go +++ b/pkg/hub/controlchannel_client.go @@ -456,6 +456,11 @@ type HybridBrokerClient struct { controlChannel *ControlChannelBrokerClient httpClient RuntimeBrokerClient debug bool + // affinity returns the believed owning hub instanceID for a broker and + // whether that owner is alive (last_heartbeat fresh). It is a routing HINT + // only (correctness comes from durable intent + drain); injected so route() + // is unit-testable. Nil means "no affinity info" (treated as no owner). + affinity func(ctx context.Context, brokerID string) (owner string, alive bool) } // NewHybridBrokerClient creates a hybrid client that prefers control channel. @@ -480,60 +485,107 @@ func (c *HybridBrokerClient) CreateAgent(ctx context.Context, brokerID, brokerEn return c.httpClient.CreateAgent(ctx, brokerID, brokerEndpoint, req) } -// StartAgent starts an agent, preferring control channel. +// StartAgent starts an agent, using route() to decide the delivery path. +// routeLocal uses the control-channel tunnel (unchanged fast path), routeHTTP +// falls back to the broker's HTTP endpoint, and routeForward/routeUndeliverable +// return ErrLifecycleDeferred so the caller can write durable intent + wait. func (c *HybridBrokerClient) StartAgent(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID, task, projectPath, projectSlug, harnessConfig string, resolvedEnv map[string]string, resolvedSecrets []ResolvedSecret, inlineConfig *api.ScionConfig, sharedDirs []api.SharedDir, sharedWorkspace bool) (*RemoteAgentResponse, error) { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.StartAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, task, projectPath, projectSlug, harnessConfig, resolvedEnv, resolvedSecrets, inlineConfig, sharedDirs, sharedWorkspace) + case routeHTTP: + return c.httpClient.StartAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, task, projectPath, projectSlug, harnessConfig, resolvedEnv, resolvedSecrets, inlineConfig, sharedDirs, sharedWorkspace) + default: + return nil, ErrLifecycleDeferred } - return c.httpClient.StartAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, task, projectPath, projectSlug, harnessConfig, resolvedEnv, resolvedSecrets, inlineConfig, sharedDirs, sharedWorkspace) } -// StopAgent stops an agent, preferring control channel. +// StopAgent stops an agent, using route() to decide the delivery path. +// routeLocal uses the control-channel tunnel, routeHTTP falls back to HTTP, +// and routeForward/routeUndeliverable return ErrLifecycleDeferred. func (c *HybridBrokerClient) StopAgent(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID string) error { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.StopAgent(ctx, brokerID, brokerEndpoint, agentID, projectID) + case routeHTTP: + return c.httpClient.StopAgent(ctx, brokerID, brokerEndpoint, agentID, projectID) + default: + return ErrLifecycleDeferred } - return c.httpClient.StopAgent(ctx, brokerID, brokerEndpoint, agentID, projectID) } -// RestartAgent restarts an agent, preferring control channel. +// RestartAgent restarts an agent, using route() to decide the delivery path. +// routeLocal uses the control-channel tunnel, routeHTTP falls back to HTTP, +// and routeForward/routeUndeliverable return ErrLifecycleDeferred. func (c *HybridBrokerClient) RestartAgent(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID string, resolvedEnv map[string]string) error { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.RestartAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, resolvedEnv) + case routeHTTP: + return c.httpClient.RestartAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, resolvedEnv) + default: + return ErrLifecycleDeferred } - return c.httpClient.RestartAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, resolvedEnv) } -// DeleteAgent deletes an agent, preferring control channel. +// DeleteAgent deletes an agent, using route() to decide the delivery path. +// routeLocal uses the control-channel tunnel, routeHTTP falls back to HTTP, +// and routeForward/routeUndeliverable return ErrLifecycleDeferred. func (c *HybridBrokerClient) DeleteAgent(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID string, deleteFiles, removeBranch, softDelete bool, deletedAt time.Time) error { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.DeleteAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, deleteFiles, removeBranch, softDelete, deletedAt) + case routeHTTP: + return c.httpClient.DeleteAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, deleteFiles, removeBranch, softDelete, deletedAt) + default: + return ErrLifecycleDeferred } - return c.httpClient.DeleteAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, deleteFiles, removeBranch, softDelete, deletedAt) } -// MessageAgent sends a message to an agent, preferring control channel. +// MessageAgent sends a message to an agent, using route() to decide the +// delivery path (B3-2). routeLocal uses the control-channel tunnel (unchanged +// fast path), routeHTTP falls back to the broker's HTTP endpoint, and +// routeForward/routeUndeliverable return ErrMessageDeferred so the caller +// can emit a NOTIFY wakeup and return 202 (the message row is durable). func (c *HybridBrokerClient) MessageAgent(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID, message string, interrupt bool, structuredMsg *messages.StructuredMessage) error { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.MessageAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, message, interrupt, structuredMsg) + case routeHTTP: + return c.httpClient.MessageAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, message, interrupt, structuredMsg) + default: + return ErrMessageDeferred } - return c.httpClient.MessageAgent(ctx, brokerID, brokerEndpoint, agentID, projectID, message, interrupt, structuredMsg) } -// CheckAgentPrompt checks if an agent has a non-empty prompt.md file. +// CheckAgentPrompt checks if an agent has a non-empty prompt.md file, using +// route() to decide the delivery path. routeLocal uses the control-channel +// tunnel, routeHTTP falls back to HTTP, and routeForward/routeUndeliverable +// return ErrLifecycleDeferred so the caller can write durable intent + wait. func (c *HybridBrokerClient) CheckAgentPrompt(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID string) (bool, error) { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.CheckAgentPrompt(ctx, brokerID, brokerEndpoint, agentID, projectID) + case routeHTTP: + return c.httpClient.CheckAgentPrompt(ctx, brokerID, brokerEndpoint, agentID, projectID) + default: + return false, ErrLifecycleDeferred } - return c.httpClient.CheckAgentPrompt(ctx, brokerID, brokerEndpoint, agentID, projectID) } -// CreateAgentWithGather creates an agent with env-gather support, preferring control channel. +// CreateAgentWithGather creates an agent with env-gather support, using route() +// to decide the delivery path. routeLocal uses the control-channel tunnel, +// routeHTTP falls back to HTTP, and routeForward/routeUndeliverable return +// ErrLifecycleDeferred so the caller can write durable intent + wait. func (c *HybridBrokerClient) CreateAgentWithGather(ctx context.Context, brokerID, brokerEndpoint string, req *RemoteCreateAgentRequest) (*RemoteAgentResponse, *RemoteEnvRequirementsResponse, error) { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.CreateAgentWithGather(ctx, brokerID, brokerEndpoint, req) + case routeHTTP: + return c.httpClient.CreateAgentWithGather(ctx, brokerID, brokerEndpoint, req) + default: + return nil, nil, ErrLifecycleDeferred } - return c.httpClient.CreateAgentWithGather(ctx, brokerID, brokerEndpoint, req) } // GetAgentLogs retrieves agent.log content, preferring control channel. @@ -559,10 +611,17 @@ func (c *HybridBrokerClient) CleanupProject(ctx context.Context, brokerID, broke return c.httpClient.CleanupProject(ctx, brokerID, brokerEndpoint, projectSlug) } -// FinalizeEnv sends gathered env vars to a broker, preferring control channel. +// FinalizeEnv sends gathered env vars to a broker, using route() to decide the +// delivery path. routeLocal uses the control-channel tunnel, routeHTTP falls +// back to HTTP, and routeForward/routeUndeliverable return ErrLifecycleDeferred +// so the caller can write durable intent + wait. func (c *HybridBrokerClient) FinalizeEnv(ctx context.Context, brokerID, brokerEndpoint, agentID string, env map[string]string) (*RemoteAgentResponse, error) { - if c.useControlChannel(brokerID) { + switch c.route(ctx, brokerID, brokerEndpoint) { + case routeLocal: return c.controlChannel.FinalizeEnv(ctx, brokerID, brokerEndpoint, agentID, env) + case routeHTTP: + return c.httpClient.FinalizeEnv(ctx, brokerID, brokerEndpoint, agentID, env) + default: + return nil, ErrLifecycleDeferred } - return c.httpClient.FinalizeEnv(ctx, brokerID, brokerEndpoint, agentID, env) } diff --git a/pkg/hub/controlchannel_test.go b/pkg/hub/controlchannel_test.go index e817cccfd..8e86380f9 100644 --- a/pkg/hub/controlchannel_test.go +++ b/pkg/hub/controlchannel_test.go @@ -29,21 +29,23 @@ func TestControlChannelManager_OnDisconnectCallback(t *testing.T) { var mu sync.Mutex var receivedBrokerID string + var receivedSessionID string done := make(chan struct{}) - mgr.SetOnDisconnect(func(brokerID string) { + mgr.SetOnDisconnect(func(brokerID, sessionID string) { mu.Lock() defer mu.Unlock() receivedBrokerID = brokerID + receivedSessionID = sessionID close(done) }) // Manually add a connection entry so removeConnection has something to remove mgr.mu.Lock() - mgr.connections[tid("broker-1")] = &BrokerConnection{brokerID: tid("broker-1")} + mgr.connections[tid("broker-1")] = &BrokerConnection{brokerID: tid("broker-1"), sessionID: "sess-1"} mgr.mu.Unlock() - mgr.removeConnection(tid("broker-1")) + mgr.removeConnection(tid("broker-1"), "sess-1") // Wait for async callback select { @@ -55,21 +57,54 @@ func TestControlChannelManager_OnDisconnectCallback(t *testing.T) { mu.Lock() defer mu.Unlock() assert.Equal(t, tid("broker-1"), receivedBrokerID) + assert.Equal(t, "sess-1", receivedSessionID) // Verify connection was removed require.False(t, mgr.IsConnected(tid("broker-1"))) } +// TestControlChannelManager_RemoveStaleSessionNoop verifies that a teardown for +// an OLD session does not remove a NEWER connection that replaced it (flap), and +// does not fire onDisconnect for the stale session. +func TestControlChannelManager_RemoveStaleSessionNoop(t *testing.T) { + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + + var fired bool + var mu sync.Mutex + mgr.SetOnDisconnect(func(brokerID, sessionID string) { + mu.Lock() + defer mu.Unlock() + fired = true + }) + + // Current live connection is session "new". + mgr.mu.Lock() + mgr.connections[tid("broker-1")] = &BrokerConnection{brokerID: tid("broker-1"), sessionID: "new"} + mgr.mu.Unlock() + + // The old session's teardown must be a no-op. + mgr.removeConnection(tid("broker-1"), "old") + + // Give any (erroneous) async callback a chance to run. + time.Sleep(100 * time.Millisecond) + + mu.Lock() + assert.False(t, fired, "onDisconnect must not fire for a stale session") + mu.Unlock() + // The live (new) connection must still be present. + require.True(t, mgr.IsConnected(tid("broker-1"))) +} + func TestControlChannelManager_OnDisconnectCallback_NilSafe(t *testing.T) { mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) // Don't set any callback - verify removeConnection doesn't panic mgr.mu.Lock() - mgr.connections[tid("broker-2")] = &BrokerConnection{brokerID: tid("broker-2")} + mgr.connections[tid("broker-2")] = &BrokerConnection{brokerID: tid("broker-2"), sessionID: "sess-2"} mgr.mu.Unlock() // This should not panic - mgr.removeConnection(tid("broker-2")) + mgr.removeConnection(tid("broker-2"), "sess-2") require.False(t, mgr.IsConnected(tid("broker-2"))) } diff --git a/pkg/hub/dispatch_args.go b/pkg/hub/dispatch_args.go new file mode 100644 index 000000000..f595fd5df --- /dev/null +++ b/pkg/hub/dispatch_args.go @@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "encoding/json" + "time" +) + +// StartDispatchArgs carries the parameters for a cross-node agent start. +// Only fields that the owner's DispatchAgentStart cannot re-derive are +// included. Env/secret resolution is performed by the OWNER via +// DispatchAgentStart (all hub instances share the same store + secret +// backend), so resolved env/secrets are NOT serialized here. +type StartDispatchArgs struct { + Task string `json:"task,omitempty"` +} + +// RestartDispatchArgs is intentionally empty — the owner's +// DispatchAgentRestart re-resolves auth tokens and identity vars from the +// shared store on the owning node. +type RestartDispatchArgs struct{} + +// StopDispatchArgs is intentionally empty — a stop needs no additional params +// beyond what the dispatch row already carries (agentID, projectID). +type StopDispatchArgs struct{} + +// DeleteDispatchArgs carries the parameters for a cross-node agent delete. +type DeleteDispatchArgs struct { + DeleteFiles bool `json:"deleteFiles,omitempty"` + RemoveBranch bool `json:"removeBranch,omitempty"` + SoftDelete bool `json:"softDelete,omitempty"` + DeletedAt time.Time `json:"deletedAt,omitempty"` +} + +// CheckPromptDispatchArgs is intentionally empty — the agent slug/ID in the +// dispatch row is sufficient for the owner to run the local check. +type CheckPromptDispatchArgs struct{} + +// FinalizeEnvDispatchArgs carries the gathered env vars for cross-node finalize. +type FinalizeEnvDispatchArgs struct { + Env map[string]string `json:"env,omitempty"` +} + +// CreateWithGatherDispatchArgs is intentionally empty — the owner rebuilds the +// full RemoteCreateAgentRequest from the shared store (same pattern as start). +type CreateWithGatherDispatchArgs struct{} + +// CheckPromptResult is serialized into broker_dispatch.result by the owner. +type CheckPromptResult struct { + HasPrompt bool `json:"hasPrompt"` +} + +// FinalizeEnvResult is serialized into broker_dispatch.result by the owner. +type FinalizeEnvResult struct { + Success bool `json:"success"` +} + +// CreateWithGatherResult is serialized into broker_dispatch.result by the owner. +type CreateWithGatherResult struct { + EnvRequirements *RemoteEnvRequirementsResponse `json:"envRequirements,omitempty"` +} + +// MarshalDispatchArgs serializes a dispatch args struct to JSON for storage in +// broker_dispatch.args. +func MarshalDispatchArgs(v interface{}) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +// UnmarshalStartArgs deserializes start dispatch args from the broker_dispatch row. +func UnmarshalStartArgs(raw string) (*StartDispatchArgs, error) { + var a StartDispatchArgs + if err := json.Unmarshal([]byte(raw), &a); err != nil { + return nil, err + } + return &a, nil +} + +// UnmarshalDeleteArgs deserializes delete dispatch args from the broker_dispatch row. +func UnmarshalDeleteArgs(raw string) (*DeleteDispatchArgs, error) { + var a DeleteDispatchArgs + if err := json.Unmarshal([]byte(raw), &a); err != nil { + return nil, err + } + return &a, nil +} + +// UnmarshalFinalizeEnvArgs deserializes finalize_env dispatch args. +func UnmarshalFinalizeEnvArgs(raw string) (*FinalizeEnvDispatchArgs, error) { + var a FinalizeEnvDispatchArgs + if err := json.Unmarshal([]byte(raw), &a); err != nil { + return nil, err + } + return &a, nil +} diff --git a/pkg/hub/dispatch_exec_test.go b/pkg/hub/dispatch_exec_test.go new file mode 100644 index 000000000..0e7b8e877 --- /dev/null +++ b/pkg/hub/dispatch_exec_test.go @@ -0,0 +1,711 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !no_sqlite + +package hub + +import ( + "context" + "encoding/json" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/messages" + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/GoogleCloudPlatform/scion/pkg/store/entadapter" + "github.com/GoogleCloudPlatform/scion/pkg/store/enttest" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lifecycleTestDispatcher captures which lifecycle op was called and with +// what args, so we can verify executeDispatch routes correctly. +type lifecycleTestDispatcher struct { + startCalled atomic.Int32 + stopCalled atomic.Int32 + restartCalled atomic.Int32 + deleteCalled atomic.Int32 + checkPromptCalled atomic.Int32 + finalizeEnvCalled atomic.Int32 + createCalled atomic.Int32 + lastTask string + checkPromptResult bool + lastDeleteFiles bool + lastFinalizeEnv map[string]string +} + +func (d *lifecycleTestDispatcher) DispatchAgentCreate(context.Context, *store.Agent) error { return nil } +func (d *lifecycleTestDispatcher) DispatchAgentProvision(context.Context, *store.Agent) error { + return nil +} +func (d *lifecycleTestDispatcher) DispatchAgentStart(_ context.Context, _ *store.Agent, task string) error { + d.startCalled.Add(1) + d.lastTask = task + return nil +} +func (d *lifecycleTestDispatcher) DispatchAgentStop(_ context.Context, _ *store.Agent) error { + d.stopCalled.Add(1) + return nil +} +func (d *lifecycleTestDispatcher) DispatchAgentRestart(_ context.Context, _ *store.Agent) error { + d.restartCalled.Add(1) + return nil +} +func (d *lifecycleTestDispatcher) DispatchAgentDelete(_ context.Context, _ *store.Agent, deleteFiles, _, _ bool, _ time.Time) error { + d.deleteCalled.Add(1) + d.lastDeleteFiles = deleteFiles + return nil +} +func (d *lifecycleTestDispatcher) DispatchAgentMessage(_ context.Context, _ *store.Agent, _ string, _ bool, _ *messages.StructuredMessage) error { + return nil +} +func (d *lifecycleTestDispatcher) DispatchAgentLogs(context.Context, *store.Agent, int) (string, error) { + return "", nil +} +func (d *lifecycleTestDispatcher) DispatchAgentExec(context.Context, *store.Agent, []string, int) (string, int, error) { + return "", 0, nil +} +func (d *lifecycleTestDispatcher) DispatchCheckAgentPrompt(context.Context, *store.Agent) (bool, error) { + d.checkPromptCalled.Add(1) + return d.checkPromptResult, nil +} +func (d *lifecycleTestDispatcher) DispatchAgentCreateWithGather(context.Context, *store.Agent) (*RemoteEnvRequirementsResponse, error) { + d.createCalled.Add(1) + return nil, nil +} +func (d *lifecycleTestDispatcher) DispatchFinalizeEnv(_ context.Context, _ *store.Agent, env map[string]string) error { + d.finalizeEnvCalled.Add(1) + d.lastFinalizeEnv = env + return nil +} + +func newLifecycleTestServer(t *testing.T) (*Server, *lifecycleTestDispatcher, store.Store) { + t.Helper() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + disp := &lifecycleTestDispatcher{} + events := NewChannelEventPublisher() + t.Cleanup(func() { events.Close() }) + srv := &Server{ + store: cs, + instanceID: "hub-test-" + uuid.NewString()[:8], + agentLifecycleLog: slog.Default(), + events: events, + } + srv.SetDispatcher(disp) + srv.execDispatch = srv.executeDispatch + srv.deliverMsg = srv.deliverMessage + return srv, disp, cs +} + +// seedAgent creates a project + runtime broker + agent and returns the agent. +// The broker has no endpoint (simulates a NAT'd control-channel-only broker). +func seedAgent(t *testing.T, cs store.Store) *store.Agent { + return seedAgentWithBrokerID(t, cs, uuid.NewString()) +} + +func seedAgentWithBrokerID(t *testing.T, cs store.Store, brokerID string) *store.Agent { + t.Helper() + ctx := context.Background() + proj := &store.Project{ + ID: uuid.NewString(), + Name: "test-proj", + Slug: "tp-" + uuid.NewString()[:8], + Visibility: store.VisibilityPrivate, + OwnerID: uuid.NewString(), + } + require.NoError(t, cs.CreateProject(ctx, proj)) + broker := &store.RuntimeBroker{ + ID: brokerID, + Name: "test-broker", + Slug: "tb-" + uuid.NewString()[:8], + Status: "online", + } + require.NoError(t, cs.CreateRuntimeBroker(ctx, broker)) + agent := &store.Agent{ + ID: uuid.NewString(), + Name: "test-agent", + Slug: "ta-" + uuid.NewString()[:8], + ProjectID: proj.ID, + RuntimeBrokerID: brokerID, + } + require.NoError(t, cs.CreateAgent(ctx, agent)) + return agent +} + +func TestExecuteDispatch_Start(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + args, err := MarshalDispatchArgs(&StartDispatchArgs{ + Task: "run tests", + }) + require.NoError(t, err) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "start", + Args: args, + } + + result, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Empty(t, result) + assert.Equal(t, int32(1), disp.startCalled.Load()) + assert.Equal(t, "run tests", disp.lastTask) +} + +func TestExecuteDispatch_Stop(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "stop", + } + + _, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Equal(t, int32(1), disp.stopCalled.Load()) +} + +func TestExecuteDispatch_Restart(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "restart", + } + + _, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Equal(t, int32(1), disp.restartCalled.Load()) +} + +func TestExecuteDispatch_UnknownOp(t *testing.T) { + ctx := context.Background() + srv, _, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "exec_agent", + } + + _, err := srv.executeDispatch(ctx, d) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not yet wired") +} + +func TestExecuteDispatch_MissingAgent(t *testing.T) { + ctx := context.Background() + srv, _, _ := newLifecycleTestServer(t) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: uuid.NewString(), + AgentID: uuid.NewString(), + Op: "start", + } + + _, err := srv.executeDispatch(ctx, d) + assert.Error(t, err) + assert.Contains(t, err.Error(), "resolve agent") +} + +// ========================================================================= +// Deferred lifecycle integration test (originator side) +// ========================================================================= + +// deferredTestClient is a RuntimeBrokerClient that returns ErrLifecycleDeferred +// for Start/Stop/Restart when the broker is "remote", and succeeds for "local". +type deferredTestClient struct { + fakeHTTPClient + localBroker string + startCalled atomic.Int32 +} + +func (c *deferredTestClient) StartAgent(_ context.Context, brokerID, _, _, _, _, _, _, _ string, _ map[string]string, _ []ResolvedSecret, _ *api.ScionConfig, _ []api.SharedDir, _ bool) (*RemoteAgentResponse, error) { + c.startCalled.Add(1) + if brokerID != c.localBroker { + return nil, ErrLifecycleDeferred + } + return &RemoteAgentResponse{}, nil +} + +func (c *deferredTestClient) StopAgent(_ context.Context, brokerID, _, _, _ string) error { + if brokerID != c.localBroker { + return ErrLifecycleDeferred + } + return nil +} + +func (c *deferredTestClient) RestartAgent(_ context.Context, brokerID, _, _, _ string, _ map[string]string) error { + if brokerID != c.localBroker { + return ErrLifecycleDeferred + } + return nil +} + +func TestDeferredStart_WritesIntentAndWaits(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + remoteBroker := uuid.NewString() + fakeClient := &deferredTestClient{localBroker: "local-broker"} + + events := NewChannelEventPublisher() + defer events.Close() + + dispatcher := NewHTTPAgentDispatcherWithClient(cs, fakeClient, false, slog.Default()) + dispatcher.SetCrossNodeDeps(events, NoopCommandBus{}) + + agent := seedAgentWithBrokerID(t, cs, remoteBroker) + + // Simulate the owner publishing "running" shortly after intent is written. + go func() { + time.Sleep(50 * time.Millisecond) + updatedAgent := *agent + updatedAgent.Phase = "running" + events.PublishAgentStatus(ctx, &updatedAgent) + }() + + err := dispatcher.DispatchAgentStart(ctx, agent, "my-task") + require.NoError(t, err, "deferred start should succeed when 'running' event arrives") + + // Verify a broker_dispatch row was written (intent is durable). No owner + // claimed it in this test, so it stays pending. + pending, err := cs.ListPendingDispatch(ctx, remoteBroker) + require.NoError(t, err) + assert.Len(t, pending, 1, "durable intent row should exist") + assert.Equal(t, "start", pending[0].Op) + assert.Equal(t, agent.ID, pending[0].AgentID) +} + +func TestDeferredStart_ReturnsErrorOnErrorPhase(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + remoteBroker := uuid.NewString() + fakeClient := &deferredTestClient{localBroker: "local-broker"} + + events := NewChannelEventPublisher() + defer events.Close() + + dispatcher := NewHTTPAgentDispatcherWithClient(cs, fakeClient, false, slog.Default()) + dispatcher.SetCrossNodeDeps(events, NoopCommandBus{}) + + agent := seedAgentWithBrokerID(t, cs, remoteBroker) + + go func() { + time.Sleep(50 * time.Millisecond) + updatedAgent := *agent + updatedAgent.Phase = "error" + updatedAgent.Message = "container crash" + events.PublishAgentStatus(ctx, &updatedAgent) + }() + + err := dispatcher.DispatchAgentStart(ctx, agent, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "error phase") +} + +func TestLocalStart_SkipsIntentRow(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + localBroker := uuid.NewString() + fakeClient := &deferredTestClient{localBroker: localBroker} + + events := NewChannelEventPublisher() + defer events.Close() + + dispatcher := NewHTTPAgentDispatcherWithClient(cs, fakeClient, false, slog.Default()) + dispatcher.SetCrossNodeDeps(events, NoopCommandBus{}) + + agent := seedAgentWithBrokerID(t, cs, localBroker) + + err := dispatcher.DispatchAgentStart(ctx, agent, "local-task") + require.NoError(t, err, "local start should succeed directly") + + // Verify no broker_dispatch row was written (local path skips intent). + pending, err := cs.ListPendingDispatch(ctx, localBroker) + require.NoError(t, err) + assert.Empty(t, pending, "local path should not write intent rows") + + assert.Equal(t, int32(1), fakeClient.startCalled.Load(), "client.StartAgent called once") +} + +// TestReconcileBroker_LifecycleEndToEnd verifies the full reconcile path: +// insert a start dispatch, reconcile, verify the dispatcher was called and +// the dispatch row is marked done. +func TestReconcileBroker_LifecycleEndToEnd(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + args, err := MarshalDispatchArgs(&StartDispatchArgs{Task: "deploy"}) + require.NoError(t, err) + + d := &store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "start", + Args: args, + } + require.NoError(t, cs.InsertBrokerDispatch(ctx, d)) + + srv.reconcileBroker(ctx, agent.RuntimeBrokerID) + + assert.Equal(t, int32(1), disp.startCalled.Load()) + assert.Equal(t, "deploy", disp.lastTask) + + pending, err := cs.ListPendingDispatch(ctx, agent.RuntimeBrokerID) + require.NoError(t, err) + assert.Empty(t, pending, "dispatch should be completed") +} + +// ========================================================================= +// B4-3: delete dispatch tests +// ========================================================================= + +func TestExecuteDispatch_Delete(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + args, err := MarshalDispatchArgs(&DeleteDispatchArgs{ + DeleteFiles: true, + }) + require.NoError(t, err) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "delete", + Args: args, + } + + result, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Empty(t, result) + assert.Equal(t, int32(1), disp.deleteCalled.Load()) + assert.True(t, disp.lastDeleteFiles) +} + +func TestReconcileBroker_DeleteEndToEnd(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + args, err := MarshalDispatchArgs(&DeleteDispatchArgs{DeleteFiles: true}) + require.NoError(t, err) + + d := &store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "delete", + Args: args, + } + require.NoError(t, cs.InsertBrokerDispatch(ctx, d)) + + srv.reconcileBroker(ctx, agent.RuntimeBrokerID) + + assert.Equal(t, int32(1), disp.deleteCalled.Load()) + + pending, err := cs.ListPendingDispatch(ctx, agent.RuntimeBrokerID) + require.NoError(t, err) + assert.Empty(t, pending, "dispatch should be completed") + + // Verify result row is readable and in done state. + row, err := cs.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateDone, row.State) +} + +// ========================================================================= +// B4-4: data ops dispatch tests (check_prompt, finalize_env, create) +// ========================================================================= + +func TestExecuteDispatch_CheckPrompt(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + disp.checkPromptResult = true + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "check_prompt", + } + + result, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Equal(t, int32(1), disp.checkPromptCalled.Load()) + + var cr CheckPromptResult + require.NoError(t, json.Unmarshal([]byte(result), &cr)) + assert.True(t, cr.HasPrompt) +} + +func TestExecuteDispatch_FinalizeEnv(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + args, err := MarshalDispatchArgs(&FinalizeEnvDispatchArgs{ + Env: map[string]string{"KEY": "value"}, + }) + require.NoError(t, err) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "finalize_env", + Args: args, + } + + result, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Equal(t, int32(1), disp.finalizeEnvCalled.Load()) + assert.Equal(t, map[string]string{"KEY": "value"}, disp.lastFinalizeEnv) + + var fr FinalizeEnvResult + require.NoError(t, json.Unmarshal([]byte(result), &fr)) + assert.True(t, fr.Success) +} + +func TestExecuteDispatch_Create(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + + d := store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "create", + } + + result, execErr := srv.executeDispatch(ctx, d) + require.NoError(t, execErr) + assert.Equal(t, int32(1), disp.createCalled.Load()) + + var cr CreateWithGatherResult + require.NoError(t, json.Unmarshal([]byte(result), &cr)) + assert.Nil(t, cr.EnvRequirements) +} + +func TestReconcileBroker_CheckPromptEndToEnd(t *testing.T) { + ctx := context.Background() + srv, disp, cs := newLifecycleTestServer(t) + agent := seedAgent(t, cs) + disp.checkPromptResult = true + + d := &store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + Op: "check_prompt", + } + require.NoError(t, cs.InsertBrokerDispatch(ctx, d)) + + srv.reconcileBroker(ctx, agent.RuntimeBrokerID) + + assert.Equal(t, int32(1), disp.checkPromptCalled.Load()) + + row, err := cs.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateDone, row.State) + + var cr CheckPromptResult + require.NoError(t, json.Unmarshal([]byte(row.Result), &cr)) + assert.True(t, cr.HasPrompt) +} + +// ========================================================================= +// GetBrokerDispatch round-trip +// ========================================================================= + +func TestGetBrokerDispatch_RoundTrip(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + d := &store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: seedAgent(t, cs).RuntimeBrokerID, + Op: "check_prompt", + } + require.NoError(t, cs.InsertBrokerDispatch(ctx, d)) + + got, err := cs.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, d.ID, got.ID) + assert.Equal(t, "check_prompt", got.Op) + assert.Equal(t, store.DispatchStatePending, got.State) + + // Claim (pending→in_progress) before completing, matching the CAS guard. + claimed, err := cs.ClaimBrokerDispatch(ctx, d.ID, "hub-test") + require.NoError(t, err) + require.True(t, claimed) + + require.NoError(t, cs.CompleteBrokerDispatch(ctx, d.ID, `{"hasPrompt":true}`)) + + got, err = cs.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateDone, got.State) + assert.Equal(t, `{"hasPrompt":true}`, got.Result) +} + +// ========================================================================= +// B4-3: Deferred delete integration test (originator side) +// ========================================================================= + +func TestDeferredDelete_WritesIntentAndCompletes(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + remoteBroker := uuid.NewString() + fakeClient := &deferredTestClient{localBroker: "local-broker"} + + events := NewChannelEventPublisher() + defer events.Close() + + dispatcher := NewHTTPAgentDispatcherWithClient(cs, fakeClient, false, slog.Default()) + dispatcher.SetCrossNodeDeps(events, NoopCommandBus{}) + + agent := seedAgentWithBrokerID(t, cs, remoteBroker) + + // Simulate the owner completing the delete dispatch shortly after intent is written. + go func() { + time.Sleep(50 * time.Millisecond) + // Find the pending dispatch row. + pending, err := cs.ListPendingDispatch(ctx, remoteBroker) + if err != nil || len(pending) == 0 { + return + } + d := pending[0] + _, _ = cs.ClaimBrokerDispatch(ctx, d.ID, "owner-hub") + _ = cs.CompleteBrokerDispatch(ctx, d.ID, "") + events.PublishDispatchDone(ctx, d.ID) + }() + + err := dispatcher.DispatchAgentDelete(ctx, agent, true, false, false, time.Time{}) + require.NoError(t, err, "deferred delete should succeed when completion event arrives") + + pending, err := cs.ListPendingDispatch(ctx, remoteBroker) + require.NoError(t, err) + assert.Empty(t, pending, "dispatch should be completed") +} + +// ========================================================================= +// B4-4: Deferred check_prompt integration test (originator side) +// ========================================================================= + +func TestDeferredCheckPrompt_ReturnsResult(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + remoteBroker := uuid.NewString() + fakeClient := &deferredDataOpTestClient{localBroker: "local-broker"} + + events := NewChannelEventPublisher() + defer events.Close() + + dispatcher := NewHTTPAgentDispatcherWithClient(cs, fakeClient, false, slog.Default()) + dispatcher.SetCrossNodeDeps(events, NoopCommandBus{}) + + agent := seedAgentWithBrokerID(t, cs, remoteBroker) + + // Simulate the owner completing check_prompt with result JSON. + go func() { + time.Sleep(50 * time.Millisecond) + pending, err := cs.ListPendingDispatch(ctx, remoteBroker) + if err != nil || len(pending) == 0 { + return + } + d := pending[0] + _, _ = cs.ClaimBrokerDispatch(ctx, d.ID, "owner-hub") + resultJSON, _ := json.Marshal(CheckPromptResult{HasPrompt: true}) + _ = cs.CompleteBrokerDispatch(ctx, d.ID, string(resultJSON)) + events.PublishDispatchDone(ctx, d.ID) + }() + + hasPrompt, err := dispatcher.DispatchCheckAgentPrompt(ctx, agent) + require.NoError(t, err, "deferred check_prompt should succeed") + assert.True(t, hasPrompt, "should return true from result row") +} + +// deferredDataOpTestClient returns ErrLifecycleDeferred for data ops when the +// broker is not "local", simulating a cross-node dispatch. +type deferredDataOpTestClient struct { + fakeHTTPClient + localBroker string +} + +func (c *deferredDataOpTestClient) DeleteAgent(_ context.Context, brokerID, _, _, _ string, _, _, _ bool, _ time.Time) error { + if brokerID != c.localBroker { + return ErrLifecycleDeferred + } + return nil +} + +func (c *deferredDataOpTestClient) CheckAgentPrompt(_ context.Context, brokerID, _, _, _ string) (bool, error) { + if brokerID != c.localBroker { + return false, ErrLifecycleDeferred + } + return false, nil +} + +func (c *deferredDataOpTestClient) FinalizeEnv(_ context.Context, brokerID, _, _ string, _ map[string]string) (*RemoteAgentResponse, error) { + if brokerID != c.localBroker { + return nil, ErrLifecycleDeferred + } + return nil, nil +} + +func (c *deferredDataOpTestClient) CreateAgentWithGather(_ context.Context, brokerID, _ string, _ *RemoteCreateAgentRequest) (*RemoteAgentResponse, *RemoteEnvRequirementsResponse, error) { + if brokerID != c.localBroker { + return nil, nil, ErrLifecycleDeferred + } + return nil, nil, nil +} diff --git a/pkg/hub/dispatch_lifecycle_test.go b/pkg/hub/dispatch_lifecycle_test.go new file mode 100644 index 000000000..2326cbdf5 --- /dev/null +++ b/pkg/hub/dispatch_lifecycle_test.go @@ -0,0 +1,252 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ========================================================================= +// Route-gating tests for StartAgent / StopAgent / RestartAgent +// ========================================================================= + +func TestHybridBrokerClient_StartAgent_RouteGate(t *testing.T) { + const localBroker = "broker-local" + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + mgr.mu.Lock() + mgr.connections[localBroker] = &BrokerConnection{brokerID: localBroker, sessionID: "s1"} + mgr.mu.Unlock() + + httpClient := &fakeHTTPClient{} + c := NewHybridBrokerClient(mgr, httpClient, nil, false) + + t.Run("routeLocal uses control channel (not deferred)", func(t *testing.T) { + got := c.route(context.Background(), localBroker, "") + assert.Equal(t, routeLocal, got) + }) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + _, err := c.StartAgent(context.Background(), remoteBroker, "", "a1", "p1", "", "", "", "", nil, nil, nil, nil, false) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + _, err := c.StartAgent(context.Background(), remoteBroker, "", "a1", "p1", "", "", "", "", nil, nil, nil, nil, false) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +func TestHybridBrokerClient_StopAgent_RouteGate(t *testing.T) { + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, &fakeHTTPClient{}, nil, false) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + err := c.StopAgent(context.Background(), remoteBroker, "", "a1", "p1") + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + err := c.StopAgent(context.Background(), remoteBroker, "", "a1", "p1") + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +func TestHybridBrokerClient_RestartAgent_RouteGate(t *testing.T) { + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, &fakeHTTPClient{}, nil, false) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + err := c.RestartAgent(context.Background(), remoteBroker, "", "a1", "p1", nil) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + err := c.RestartAgent(context.Background(), remoteBroker, "", "a1", "p1", nil) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +// ========================================================================= +// Dispatch args round-trip (serialize -> deserialize lossless) +// ========================================================================= + +func TestStartDispatchArgs_RoundTrip(t *testing.T) { + original := &StartDispatchArgs{ + Task: "build the widget", + } + + raw, err := MarshalDispatchArgs(original) + require.NoError(t, err) + require.NotEmpty(t, raw) + + got, err := UnmarshalStartArgs(raw) + require.NoError(t, err) + assert.Equal(t, original.Task, got.Task) +} + +func TestRestartDispatchArgs_RoundTrip(t *testing.T) { + raw, err := MarshalDispatchArgs(&RestartDispatchArgs{}) + require.NoError(t, err) + assert.Equal(t, "{}", raw) +} + +func TestStopDispatchArgs_RoundTrip(t *testing.T) { + raw, err := MarshalDispatchArgs(&StopDispatchArgs{}) + require.NoError(t, err) + assert.Equal(t, "{}", raw) +} + +// ========================================================================= +// B4-3: Route-gating tests for DeleteAgent +// ========================================================================= + +func TestHybridBrokerClient_DeleteAgent_RouteGate(t *testing.T) { + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, &fakeHTTPClient{}, nil, false) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + err := c.DeleteAgent(context.Background(), remoteBroker, "", "a1", "p1", false, false, false, time.Time{}) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + err := c.DeleteAgent(context.Background(), remoteBroker, "", "a1", "p1", false, false, false, time.Time{}) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +// ========================================================================= +// B4-4: Route-gating tests for CheckAgentPrompt / CreateAgentWithGather / FinalizeEnv +// ========================================================================= + +func TestHybridBrokerClient_CheckAgentPrompt_RouteGate(t *testing.T) { + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, &fakeHTTPClient{}, nil, false) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + _, err := c.CheckAgentPrompt(context.Background(), remoteBroker, "", "a1", "p1") + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + _, err := c.CheckAgentPrompt(context.Background(), remoteBroker, "", "a1", "p1") + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +func TestHybridBrokerClient_CreateAgentWithGather_RouteGate(t *testing.T) { + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, &fakeHTTPClient{}, nil, false) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + _, _, err := c.CreateAgentWithGather(context.Background(), remoteBroker, "", &RemoteCreateAgentRequest{}) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + _, _, err := c.CreateAgentWithGather(context.Background(), remoteBroker, "", &RemoteCreateAgentRequest{}) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +func TestHybridBrokerClient_FinalizeEnv_RouteGate(t *testing.T) { + const remoteBroker = "broker-remote" + + mgr := NewControlChannelManager(DefaultControlChannelConfig(), slog.Default()) + c := NewHybridBrokerClient(mgr, &fakeHTTPClient{}, nil, false) + + t.Run("routeForward returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "hubA", true }) + _, err := c.FinalizeEnv(context.Background(), remoteBroker, "", "a1", nil) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) + + t.Run("routeUndeliverable returns ErrLifecycleDeferred", func(t *testing.T) { + c.SetAffinityLookup(func(context.Context, string) (string, bool) { return "", false }) + _, err := c.FinalizeEnv(context.Background(), remoteBroker, "", "a1", nil) + assert.ErrorIs(t, err, ErrLifecycleDeferred) + }) +} + +// ========================================================================= +// B4-3/B4-4: Dispatch args round-trip +// ========================================================================= + +func TestDeleteDispatchArgs_RoundTrip(t *testing.T) { + original := &DeleteDispatchArgs{ + DeleteFiles: true, + RemoveBranch: true, + SoftDelete: false, + } + + raw, err := MarshalDispatchArgs(original) + require.NoError(t, err) + require.NotEmpty(t, raw) + + got, err := UnmarshalDeleteArgs(raw) + require.NoError(t, err) + assert.Equal(t, original.DeleteFiles, got.DeleteFiles) + assert.Equal(t, original.RemoveBranch, got.RemoveBranch) + assert.Equal(t, original.SoftDelete, got.SoftDelete) +} + +func TestFinalizeEnvDispatchArgs_RoundTrip(t *testing.T) { + original := &FinalizeEnvDispatchArgs{ + Env: map[string]string{"KEY": "val", "SECRET": "abc"}, + } + + raw, err := MarshalDispatchArgs(original) + require.NoError(t, err) + + got, err := UnmarshalFinalizeEnvArgs(raw) + require.NoError(t, err) + assert.Equal(t, original.Env, got.Env) +} + +func TestCheckPromptDispatchArgs_RoundTrip(t *testing.T) { + raw, err := MarshalDispatchArgs(&CheckPromptDispatchArgs{}) + require.NoError(t, err) + assert.Equal(t, "{}", raw) +} diff --git a/pkg/hub/dispatch_wait.go b/pkg/hub/dispatch_wait.go new file mode 100644 index 000000000..94c95ec4b --- /dev/null +++ b/pkg/hub/dispatch_wait.go @@ -0,0 +1,148 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" +) + +// ErrDispatchFailed is returned when a lifecycle dispatch rolling timeout +// expires without receiving any status update within the window — the broker +// went silent and the operation is considered failed (design §6.4). +var ErrDispatchFailed = errors.New("dispatch failed: rolling timeout expired with no status update") + +// dispatchRollingTimeout is the default rolling window for +// waitForAgentTransition. Each status event (phase/activity/detail change) +// resets this timer. If no event arrives within the window, the dispatch is +// considered failed. Single tunable per design §6.4. +const dispatchRollingTimeout = 90 * time.Second + +// waitForAgentTransition waits for an agent's phase to reach a terminal state, +// using a rolling timeout that resets on ANY AgentStatusEvent (phase, activity, +// or detail change). The caller must subscribe to the agent's status events +// BEFORE writing the durable intent, and pass the subscription channel + +// unsubscribe function here. +// +// Parameters: +// - events: the subscription channel from EventPublisher.Subscribe("agent..status") +// - unsub: the unsubscribe function returned by Subscribe (called on return) +// - terminal: returns true when the agent's phase indicates the op is done +// (e.g. "running" or "error" for start; "stopped" or "error" for stop) +// +// Returns the terminal phase on success, or ErrDispatchFailed on rolling +// timeout, or ctx.Err() on context cancellation. +func waitForAgentTransition( + ctx context.Context, + events <-chan Event, + unsub func(), + terminal func(phase string) bool, +) (string, error) { + defer unsub() + + timeout := dispatchRollingTimeout + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case ev, ok := <-events: + if !ok { + return "", ErrDispatchFailed + } + var status AgentStatusEvent + if err := json.Unmarshal(ev.Data, &status); err != nil { + continue + } + if terminal(status.Phase) { + return status.Phase, nil + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(timeout) + + case <-timer.C: + return "", ErrDispatchFailed + + case <-ctx.Done(): + return "", ctx.Err() + } + } +} + +// waitForDispatchDone waits for a broker_dispatch row to reach terminal state. +// The caller subscribes to broker.dispatch..done BEFORE writing intent and +// passes the channel + unsub here. On event arrival (or timeout), the row is +// read from the store — the DB row is authoritative (design §6.3), so a missed +// event is recoverable. +func waitForDispatchDone( + ctx context.Context, + events <-chan Event, + unsub func(), + st store.BrokerDispatchStore, + dispatchID string, +) (*store.BrokerDispatch, error) { + defer unsub() + + timeout := dispatchRollingTimeout + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case _, ok := <-events: + if !ok { + return nil, ErrDispatchFailed + } + d, err := st.GetBrokerDispatch(ctx, dispatchID) + if err != nil { + return nil, fmt.Errorf("read dispatch result: %w", err) + } + if d.State == store.DispatchStateDone || d.State == store.DispatchStateFailed { + return d, nil + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(timeout) + + case <-timer.C: + // Bounded re-read: the event may have been missed (design §6.3). + d, err := st.GetBrokerDispatch(ctx, dispatchID) + if err != nil { + return nil, fmt.Errorf("read dispatch result on timeout: %w", err) + } + if d.State == store.DispatchStateDone || d.State == store.DispatchStateFailed { + return d, nil + } + return nil, ErrDispatchFailed + + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} diff --git a/pkg/hub/dispatch_wait_test.go b/pkg/hub/dispatch_wait_test.go new file mode 100644 index 000000000..de12f8fad --- /dev/null +++ b/pkg/hub/dispatch_wait_test.go @@ -0,0 +1,312 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeDispatchStore is a minimal in-memory BrokerDispatchStore for unit tests. +type fakeDispatchStore struct { + dispatches map[string]*store.BrokerDispatch +} + +func (f *fakeDispatchStore) GetBrokerDispatch(_ context.Context, id string) (*store.BrokerDispatch, error) { + d, ok := f.dispatches[id] + if !ok { + return nil, store.ErrNotFound + } + return d, nil +} + +func (f *fakeDispatchStore) InsertBrokerDispatch(_ context.Context, d *store.BrokerDispatch) error { + return nil +} +func (f *fakeDispatchStore) ClaimBrokerDispatch(_ context.Context, _, _ string) (bool, error) { + return false, nil +} +func (f *fakeDispatchStore) CompleteBrokerDispatch(_ context.Context, _, _ string) error { + return nil +} +func (f *fakeDispatchStore) FailBrokerDispatch(_ context.Context, _, _ string) error { return nil } +func (f *fakeDispatchStore) ListPendingDispatch(_ context.Context, _ string) ([]store.BrokerDispatch, error) { + return nil, nil +} +func (f *fakeDispatchStore) MarkMessageDispatched(_ context.Context, _ string) (bool, error) { + return false, nil +} +func (f *fakeDispatchStore) ListPendingMessages(_ context.Context, _ string) ([]store.Message, error) { + return nil, nil +} +func (f *fakeDispatchStore) ReapStuckDispatch(_ context.Context, _ time.Time, _ int) (int, int, error) { + return 0, 0, nil +} +func (f *fakeDispatchStore) CountStuckPendingMessages(_ context.Context, _ time.Time) (int, error) { + return 0, nil +} + +// sendStatus pushes a fake AgentStatusEvent onto the channel. +func sendStatus(ch chan<- Event, phase, activity string, detail *AgentDetail) { + evt := AgentStatusEvent{ + AgentID: "agent-1", + Phase: phase, + Activity: activity, + Detail: detail, + } + data, _ := json.Marshal(evt) + ch <- Event{Subject: "agent.agent-1.status", Data: data} +} + +func TestWaitForAgentTransition_TerminalPhase(t *testing.T) { + ch := make(chan Event, 8) + unsub := func() {} + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + go func() { + sendStatus(ch, "starting", "pulling image", nil) + sendStatus(ch, "running", "", nil) + }() + + phase, err := waitForAgentTransition( + context.Background(), ch, unsub, + func(p string) bool { return p == "running" || p == "error" }, + ) + require.NoError(t, err) + assert.Equal(t, "running", phase) +} + +func TestWaitForAgentTransition_ErrorPhase(t *testing.T) { + ch := make(chan Event, 8) + unsub := func() {} + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + go func() { + sendStatus(ch, "starting", "", nil) + sendStatus(ch, "error", "", nil) + }() + + phase, err := waitForAgentTransition( + context.Background(), ch, unsub, + func(p string) bool { return p == "running" || p == "error" }, + ) + require.NoError(t, err) + assert.Equal(t, "error", phase) +} + +func TestWaitForAgentTransition_RollingReset(t *testing.T) { + // Interim detail updates keep the wait alive past one window. + // We use a very short timeout override for testing speed. + ch := make(chan Event, 64) + unsub := func() {} + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + // Override the timeout by wrapping: we cannot easily override the + // const, but we can send events faster than the 90s default and + // confirm the terminal is reached. The real test is that interim + // events don't cause early return. Send 5 interim events, then terminal. + go func() { + for i := 0; i < 5; i++ { + sendStatus(ch, "starting", "step", &AgentDetail{Message: "progress"}) + time.Sleep(5 * time.Millisecond) + } + sendStatus(ch, "running", "", nil) + }() + + phase, err := waitForAgentTransition( + context.Background(), ch, unsub, + func(p string) bool { return p == "running" || p == "error" }, + ) + require.NoError(t, err) + assert.Equal(t, "running", phase) +} + +func TestWaitForAgentTransition_SilenceExpiry(t *testing.T) { + // Override the rolling timeout to something very short so the test + // completes quickly. We can't mutate the const, so instead we close + // the channel which produces a zero Event -> ErrDispatchFailed via + // the ok=false branch. + ch := make(chan Event, 4) + unsub := func() {} + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + // Close immediately: simulates silence (no events). + close(ch) + + _, err := waitForAgentTransition( + context.Background(), ch, unsub, + func(p string) bool { return p == "running" }, + ) + assert.ErrorIs(t, err, ErrDispatchFailed) +} + +func TestWaitForAgentTransition_ContextCancel(t *testing.T) { + ch := make(chan Event, 4) + unsub := func() {} + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := waitForAgentTransition( + ctx, ch, unsub, + func(p string) bool { return p == "running" }, + ) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestWaitForAgentTransition_UnsubCalled(t *testing.T) { + ch := make(chan Event, 4) + var unsubCalled bool + unsub := func() { unsubCalled = true } + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + close(ch) + _, _ = waitForAgentTransition( + context.Background(), ch, unsub, + func(p string) bool { return p == "running" }, + ) + assert.True(t, unsubCalled, "unsub must be called on return") +} + +func TestWaitForAgentTransition_StopTerminal(t *testing.T) { + ch := make(chan Event, 4) + unsub := func() {} + _ = &Server{} // ensure Server type compiles; waitForAgentTransition is standalone + + go func() { + sendStatus(ch, "stopped", "", nil) + }() + + phase, err := waitForAgentTransition( + context.Background(), ch, unsub, + func(p string) bool { return p == "stopped" || p == "error" }, + ) + require.NoError(t, err) + assert.Equal(t, "stopped", phase) +} + +// ========================================================================= +// waitForDispatchDone tests (data-op completion path) +// ========================================================================= + +func TestWaitForDispatchDone_ReturnsOnDone(t *testing.T) { + const dispatchID = "dispatch-1" + ch := make(chan Event, 4) + unsub := func() {} + + fs := &fakeDispatchStore{ + dispatches: map[string]*store.BrokerDispatch{ + dispatchID: { + ID: dispatchID, + State: store.DispatchStateDone, + Result: `{"hasPrompt":true}`, + }, + }, + } + + go func() { + ch <- Event{Subject: "broker.dispatch." + dispatchID + ".done"} + }() + + result, err := waitForDispatchDone(context.Background(), ch, unsub, fs, dispatchID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateDone, result.State) + assert.Equal(t, `{"hasPrompt":true}`, result.Result) +} + +func TestWaitForDispatchDone_ReturnsOnFailed(t *testing.T) { + const dispatchID = "dispatch-2" + ch := make(chan Event, 4) + unsub := func() {} + + fs := &fakeDispatchStore{ + dispatches: map[string]*store.BrokerDispatch{ + dispatchID: { + ID: dispatchID, + State: store.DispatchStateFailed, + Error: "container crashed", + }, + }, + } + + go func() { + ch <- Event{Subject: "broker.dispatch." + dispatchID + ".done"} + }() + + result, err := waitForDispatchDone(context.Background(), ch, unsub, fs, dispatchID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateFailed, result.State) + assert.Equal(t, "container crashed", result.Error) +} + +func TestWaitForDispatchDone_ChannelClose(t *testing.T) { + const dispatchID = "dispatch-3" + ch := make(chan Event, 4) + unsub := func() {} + + fs := &fakeDispatchStore{dispatches: map[string]*store.BrokerDispatch{}} + + close(ch) + + _, err := waitForDispatchDone(context.Background(), ch, unsub, fs, dispatchID) + assert.ErrorIs(t, err, ErrDispatchFailed) +} + +func TestWaitForDispatchDone_ContextCancel(t *testing.T) { + const dispatchID = "dispatch-4" + ch := make(chan Event, 4) + unsub := func() {} + + fs := &fakeDispatchStore{dispatches: map[string]*store.BrokerDispatch{}} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := waitForDispatchDone(ctx, ch, unsub, fs, dispatchID) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestWaitForDispatchDone_TimeoutReread(t *testing.T) { + // Verify that on timeout, the row is re-read and if done, returned. + const dispatchID = "dispatch-5" + ch := make(chan Event, 4) + var unsubCalled bool + unsub := func() { unsubCalled = true } + + fs := &fakeDispatchStore{ + dispatches: map[string]*store.BrokerDispatch{ + dispatchID: { + ID: dispatchID, + State: store.DispatchStateDone, + Result: `{"success":true}`, + }, + }, + } + + // Don't send any event — let it time out and re-read. + // We can't easily override the 90s rolling timeout in a unit test, + // so we test the channel-close path instead (above) and verify the + // unsub is called on all paths. + close(ch) + _, _ = waitForDispatchDone(context.Background(), ch, unsub, fs, dispatchID) + assert.True(t, unsubCalled, "unsub must be called on return") +} diff --git a/pkg/hub/events.go b/pkg/hub/events.go index 37ca6b796..2d644ce09 100644 --- a/pkg/hub/events.go +++ b/pkg/hub/events.go @@ -40,6 +40,10 @@ type EventPublisher interface { PublishUserMessage(ctx context.Context, msg *store.Message) PublishAllowListChanged(ctx context.Context, action string, email string) PublishInviteChanged(ctx context.Context, action string, inviteID string, codePrefix string) + // PublishDispatchDone emits a slim completion event on + // broker.dispatch..done so the originator's subscription wakes + // and reads the result from the dispatch row (design §6.3). + PublishDispatchDone(ctx context.Context, dispatchID string) // Subscribe returns a channel that receives events matching the given // subject patterns, along with an unsubscribe function. Patterns use // NATS-style wildcards: '*' matches a single token, '>' matches the @@ -66,6 +70,7 @@ func (noopEventPublisher) PublishNotification(_ context.Context, _ *store.Notifi func (noopEventPublisher) PublishUserMessage(_ context.Context, _ *store.Message) {} func (noopEventPublisher) PublishAllowListChanged(_ context.Context, _, _ string) {} func (noopEventPublisher) PublishInviteChanged(_ context.Context, _, _, _ string) {} +func (noopEventPublisher) PublishDispatchDone(_ context.Context, _ string) {} func (noopEventPublisher) Close() {} // Subscribe on the no-op publisher returns a nil channel (which blocks forever @@ -212,6 +217,14 @@ type InviteChangedEvent struct { CodePrefix string `json:"codePrefix,omitempty"` } +// DispatchDoneEvent is a slim completion event emitted by the owner when a +// broker_dispatch reaches terminal state (done/failed). The originator +// subscribes to broker.dispatch..done BEFORE writing intent and reads the +// result from the dispatch row on wake (design §6.3). +type DispatchDoneEvent struct { + DispatchID string `json:"dispatchId"` +} + // eventBuilder holds the EventPublisher Publish* method implementations shared // by every publisher backend. Each method marshals a typed event struct and // hands the (subject, event) pair to sink, which the embedding publisher wires @@ -553,6 +566,15 @@ func (p *eventBuilder) PublishUserMessage(_ context.Context, msg *store.Message) } } +// PublishDispatchDone emits a slim completion event when a broker_dispatch row +// reaches terminal state. The subject broker.dispatch..done is what the +// originator subscribes to before writing intent (design §6.3). +func (p *eventBuilder) PublishDispatchDone(_ context.Context, dispatchID string) { + p.sink("broker.dispatch."+dispatchID+".done", DispatchDoneEvent{ + DispatchID: dispatchID, + }) +} + // subjectMatchesPattern checks if a subject matches a NATS-style pattern. // '*' matches exactly one token, '>' matches one or more remaining tokens. // Tokens are dot-separated. diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index 4e1b45067..1c5c5db18 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -207,16 +207,15 @@ type CreateAgentRequest struct { // GatherEnv enables the env-gather flow where the broker evaluates env // completeness and may return a 202 requiring the CLI to supply missing values. GatherEnv bool `json:"gatherEnv,omitempty"` - // Resume signals that the caller wants to resume an existing stopped agent - // in-place rather than deleting and recreating it. When true and the - // existing agent is in PhaseStopped, the agent record is preserved and - // the broker is asked to restart the container. - Resume bool `json:"resume,omitempty"` // Notify subscribes the creating agent/user to status notifications for the new agent. Notify bool `json:"notify,omitempty"` // CleanupMode controls stale-existing-agent cleanup behavior during create: // "strict" (default) fails create if broker cleanup fails; "force" continues. CleanupMode string `json:"cleanupMode,omitempty"` + // Resume signals that the caller wants to resume an existing stopped agent + // rather than create a brand-new one. When true and a stopped agent with + // the same name exists, the Hub recovers it instead of creating fresh. + Resume bool `json:"resume,omitempty"` // GCPIdentity specifies the GCP identity assignment for the agent. // Controls metadata server behavior and optional service account binding. GCPIdentity *GCPIdentityAssignment `json:"gcp_identity,omitempty"` @@ -783,7 +782,7 @@ func (s *Server) createAgentInProject( } } - // Hub-managed/shared-workspace project remote broker support: if the project has + // Hub-native/shared-workspace project remote broker support: if the project has // a managed workspace and the workspace path is set, upload it to GCS so // a remote broker can download it. if (project.GitRemote == "" || project.IsSharedWorkspace()) && agent.AppliedConfig != nil && agent.AppliedConfig.Workspace != "" { @@ -1926,16 +1925,12 @@ func (s *Server) handleAgentTokenRefresh(w http.ResponseWriter, r *http.Request, // OutboundMessageRequest is the request body for POST /api/v1/agents/{id}/outbound-message. type OutboundMessageRequest struct { - Recipient string `json:"recipient,omitempty"` - RecipientID string `json:"recipient_id,omitempty"` - Msg string `json:"msg"` - Type string `json:"type,omitempty"` - Urgent bool `json:"urgent,omitempty"` - Attachments []string `json:"attachments,omitempty"` - Visibility string `json:"visibility,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Channel string `json:"channel,omitempty"` - ThreadID string `json:"thread_id,omitempty"` + Recipient string `json:"recipient,omitempty"` + RecipientID string `json:"recipient_id,omitempty"` + Msg string `json:"msg"` + Type string `json:"type,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Attachments []string `json:"attachments,omitempty"` } // handleAgentOutboundMessage handles POST /api/v1/agents/{id}/outbound-message. @@ -2047,15 +2042,11 @@ func (s *Server) handleAgentOutboundMessage(w http.ResponseWriter, r *http.Reque Type: req.Type, Urgent: req.Urgent, AgentID: agent.ID, - Channel: req.Channel, - ThreadID: req.ThreadID, CreatedAt: time.Now(), } // Build a structured message for external dispatch paths. structuredMsg := &messages.StructuredMessage{ - Version: messages.Version, - Timestamp: time.Now().UTC().Format(time.RFC3339), Sender: storeMsg.Sender, SenderID: storeMsg.SenderID, Recipient: storeMsg.Recipient, @@ -2064,15 +2055,6 @@ func (s *Server) handleAgentOutboundMessage(w http.ResponseWriter, r *http.Reque Type: storeMsg.Type, Urgent: storeMsg.Urgent, Attachments: req.Attachments, - Visibility: req.Visibility, - Metadata: req.Metadata, - Channel: req.Channel, - ThreadID: req.ThreadID, - } - - if err := structuredMsg.Validate(); err != nil { - ValidationError(w, err.Error(), nil) - return } // Route through broker when available; otherwise persist and publish @@ -2287,9 +2269,6 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s if structuredMsg.Type == "" { structuredMsg.Type = messages.TypeInstruction } - if structuredMsg.Channel == "" && GetAgentIdentityFromContext(ctx) == nil { - structuredMsg.Channel = "web" - } } else if req.Message != "" { plainMessage = req.Message // Build a structured message from the plain text so that downstream @@ -2306,16 +2285,13 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s } structuredMsg = messages.NewInstruction(sender, "agent:"+id, plainMessage) structuredMsg.SenderID = senderID - if GetAgentIdentityFromContext(ctx) == nil { - structuredMsg.Channel = "web" - } } else { ValidationError(w, "message or structured_message is required", nil) return } - // Detect group recipient for multi-target fan-out. - if structuredMsg != nil && messages.IsGroupRecipient(structuredMsg.Recipient) { + // Detect set[] recipient for multi-target fan-out. + if structuredMsg != nil && messages.IsSetRecipient(structuredMsg.Recipient) { s.handleGroupMessage(w, r, id, structuredMsg, plainMessage, req.Interrupt) return } @@ -2413,6 +2389,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s s.logMessage("message dispatched", logAttrs...) // Persist to message store (write-through; non-fatal if store fails) + var persistedMsgID string if structuredMsg != nil { storeMsg := &store.Message{ ID: api.NewUUID(), @@ -2428,7 +2405,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s AgentID: agent.ID, CreatedAt: time.Now(), } - // Propagate GroupID from metadata so CLI-originated group messages + // Propagate GroupID from metadata so CLI-originated set[] messages // preserve correlation in the store. if structuredMsg.Metadata != nil { if gid, ok := structuredMsg.Metadata["group_id"]; ok { @@ -2437,6 +2414,8 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s } if err := s.store.CreateMessage(ctx, storeMsg); err != nil { s.messageLog.Error("Failed to persist message", "error", err) + } else { + persistedMsgID = storeMsg.ID } // Publish SSE event so connected browser clients can update the // per-agent conversation view in real time — mirrors the agent→user @@ -2454,11 +2433,39 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s ServiceNotReady(w, "Agent has no runtime broker assigned — the server may still be starting up") return } - if err := dispatcher.DispatchAgentMessage(ctx, agent, plainMessage, req.Interrupt, structuredMsg); err != nil { + if err := dispatcher.DispatchAgentMessage(ctx, agent, plainMessage, req.Interrupt, structuredMsg); errors.Is(err, ErrMessageDeferred) { + s.signalDeferredMessage(ctx, agent.RuntimeBrokerID, agent.ID) + // Create notification subscription if requested (before returning 202) + if req.Notify { + var notifySubscriberType, notifySubscriberID, createdBy string + if agentIdent := GetAgentIdentityFromContext(ctx); agentIdent != nil { + createdBy = agentIdent.ID() + if creatorAgent, err := s.store.GetAgent(ctx, agentIdent.ID()); err == nil { + notifySubscriberType = store.SubscriberTypeAgent + notifySubscriberID = creatorAgent.Slug + } + } else if userIdent := GetUserIdentityFromContext(ctx); userIdent != nil { + createdBy = userIdent.ID() + notifySubscriberType = store.SubscriberTypeUser + notifySubscriberID = userIdent.ID() + } + s.createNotifySubscription(ctx, agent.ID, agent.ProjectID, notifySubscriberType, notifySubscriberID, createdBy) + } + w.WriteHeader(http.StatusAccepted) + return + } else if err != nil { RuntimeError(w, "Failed to send message to runtime broker: "+err.Error()) return } + // Mark the message as dispatched so reconcileBroker does not + // re-deliver it on the next broker reconnect. + if persistedMsgID != "" { + if _, err := s.store.MarkMessageDispatched(ctx, persistedMsgID); err != nil { + s.messageLog.Error("Failed to mark message dispatched", "id", persistedMsgID, "error", err) + } + } + // Publish agent-to-agent messages through the broker so plugin observers // (Telegram, broker-log) can see them. ObserverOnly prevents the hub's own // subscription from re-dispatching. @@ -2494,29 +2501,28 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s w.WriteHeader(http.StatusOK) } -// GroupMessageRecipientResult holds the delivery status for a single recipient -// in a message group fan-out. +// GroupMessageRecipientResult represents the delivery status for one recipient in a set[] delivery. type GroupMessageRecipientResult struct { Recipient string `json:"recipient"` Status string `json:"status"` Error string `json:"error,omitempty"` } -// GroupMessageResponse is the JSON response for a group message delivery. +// GroupMessageResponse is the JSON response for a set[] message delivery. type GroupMessageResponse struct { - GroupID string `json:"group_id"` - Delivered int `json:"delivered"` - Failed int `json:"failed"` + GroupID string `json:"group_id"` + Delivered int `json:"delivered"` + Failed int `json:"failed"` Results []GroupMessageRecipientResult `json:"results"` } -// handleGroupMessage fans out a structured message to multiple recipients in a message group. +// handleGroupMessage fans out a structured message to multiple recipients parsed from set[]. func (s *Server) handleGroupMessage(w http.ResponseWriter, r *http.Request, anchorID string, msg *messages.StructuredMessage, plainMessage string, interrupt bool) { ctx := r.Context() - recipients, err := messages.ParseGroupRecipient(msg.Recipient) + recipients, err := messages.ParseSetRecipient(msg.Recipient) if err != nil { - ValidationError(w, "invalid group recipient: "+err.Error(), nil) + ValidationError(w, "invalid set[] recipient: "+err.Error(), nil) return } @@ -2532,7 +2538,7 @@ func (s *Server) handleGroupMessage(w http.ResponseWriter, r *http.Request, anch for i, r := range recipients { recipientStrs[i] = r.String() } - recipientsSet := messages.FormatGroupRecipients(msg.Sender, recipientStrs) + recipientsSet := messages.FormatSetRecipients(msg.Sender, recipientStrs) groupID := api.NewUUID() results := make([]GroupMessageRecipientResult, len(recipients)) @@ -2571,12 +2577,17 @@ func (s *Server) handleGroupMessage(w http.ResponseWriter, r *http.Request, anch CreatedAt: time.Now(), } if err := s.store.CreateMessage(ctx, storeMsg); err != nil { - s.messageLog.Error("Failed to persist group message", "recipient", recipStr, "error", err) + s.messageLog.Error("Failed to persist set message", "recipient", recipStr, "error", err) } s.events.PublishUserMessage(ctx, storeMsg) if dispatcher != nil && agent.RuntimeBrokerID != "" { - if err := dispatcher.DispatchAgentMessage(ctx, agent, plainMessage, interrupt, &agentMsg); err != nil { + if err := dispatcher.DispatchAgentMessage(ctx, agent, plainMessage, interrupt, &agentMsg); errors.Is(err, ErrMessageDeferred) { + s.signalDeferredMessage(ctx, agent.RuntimeBrokerID, agent.ID) + results[i] = GroupMessageRecipientResult{Recipient: recipStr, Status: "deferred"} + delivered++ + continue + } else if err != nil { results[i] = GroupMessageRecipientResult{Recipient: recipStr, Status: "failed", Error: err.Error()} continue } @@ -2588,13 +2599,19 @@ func (s *Server) handleGroupMessage(w http.ResponseWriter, r *http.Request, anch continue } + // Mark the message as dispatched so reconcileBroker does not + // re-deliver it on the next broker reconnect. + if _, err := s.store.MarkMessageDispatched(ctx, storeMsg.ID); err != nil { + s.messageLog.Error("Failed to mark set message dispatched", "id", storeMsg.ID, "error", err) + } + // Publish agent-to-agent messages through the broker for plugin observers. if strings.HasPrefix(agentMsg.Sender, "agent:") { if bp := s.GetMessageBrokerProxy(); bp != nil { observerMsg := agentMsg observerMsg.ObserverOnly = true if err := bp.PublishMessage(ctx, projectID, &observerMsg); err != nil { - s.messageLog.Error("Failed to publish group observer message", + s.messageLog.Error("Failed to publish set[] observer message", "recipient", recipStr, "error", err) } } @@ -2657,7 +2674,7 @@ func (s *Server) handleGroupMessage(w http.ResponseWriter, r *http.Request, anch CreatedAt: time.Now(), } if err := s.store.CreateMessage(ctx, storeMsg); err != nil { - s.messageLog.Error("Failed to persist group message", "recipient", recipStr, "error", err) + s.messageLog.Error("Failed to persist set message", "recipient", recipStr, "error", err) } s.events.PublishUserMessage(ctx, storeMsg) @@ -2666,7 +2683,7 @@ func (s *Server) handleGroupMessage(w http.ResponseWriter, r *http.Request, anch } } - s.logMessage("group message dispatched", + s.logMessage("set message dispatched", "project_id", projectID, "group_id", groupID, "total", len(recipients), @@ -2794,10 +2811,15 @@ func (s *Server) broadcastDirect(w http.ResponseWriter, r *http.Request, project agentMsg := *msg agentMsg.Recipient = "agent:" + agent.Slug agentMsg.RecipientID = agent.ID - if err := dispatcher.DispatchAgentMessage(ctx, &agent, agentMsg.Msg, interrupt, &agentMsg); err != nil { + dispatched := false + if err := dispatcher.DispatchAgentMessage(ctx, &agent, agentMsg.Msg, interrupt, &agentMsg); errors.Is(err, ErrMessageDeferred) { + s.signalDeferredMessage(ctx, agent.RuntimeBrokerID, agent.ID) + } else if err != nil { s.messageLog.Error("Failed to deliver broadcast message to agent", "agent_id", agent.ID, "agentSlug", agent.Slug, "error", err) + } else { + dispatched = true } // Persist broadcast message per recipient (non-fatal) storeMsg := &store.Message{ @@ -2816,6 +2838,11 @@ func (s *Server) broadcastDirect(w http.ResponseWriter, r *http.Request, project } if err := s.store.CreateMessage(ctx, storeMsg); err != nil { s.messageLog.Error("Failed to persist broadcast message", "agent_id", agent.ID, "error", err) + } else if dispatched { + // Mark dispatched so reconcileBroker does not re-deliver. + if _, err := s.store.MarkMessageDispatched(ctx, storeMsg.ID); err != nil { + s.messageLog.Error("Failed to mark broadcast message dispatched", "id", storeMsg.ID, "error", err) + } } } @@ -3545,7 +3572,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { return } } else if project.GitRemote == "" { - // Hub-managed project (no git remote): create workspace directory. + // Hub-native project (no git remote): create workspace directory. if err := s.initHubManagedProject(project); err != nil { slog.Warn("failed to initialize project workspace", "project_id", project.ID, "slug", project.Slug, "error", err) @@ -3817,7 +3844,7 @@ func (s *Server) initHubManagedProject(project *store.Project) error { return fmt.Errorf("failed to create .scion directory: %w", err) } - // Seed default settings.yaml directly in scionDir. Hub-managed projects + // Seed default settings.yaml directly in scionDir. Hub-native projects // bypass InitProject (which uses split storage for git repos) and keep // all configuration in-place. settingsPath := filepath.Join(scionDir, "settings.yaml") @@ -3849,7 +3876,7 @@ func (s *Server) initHubManagedProject(project *store.Project) error { } // cloneSharedWorkspaceProject performs the host-side git clone for a shared-workspace -// git project. It clones the repository into the hub-managed workspace path and +// git project. It clones the repository into the hub-native workspace path and // seeds the .scion project structure on top. If the clone fails, the workspace // directory is cleaned up and an error is returned. func (s *Server) cloneSharedWorkspaceProject(ctx context.Context, project *store.Project) error { @@ -3995,7 +4022,7 @@ func (s *Server) syncWorkspaceOnStop(ctx context.Context, agent *store.Agent) { project, err := s.store.GetProject(ctx, agent.ProjectID) if err != nil || (project.GitRemote != "" && !project.IsSharedWorkspace()) { - return // Not hub-managed/shared-workspace or project not found + return // Not hub-native/shared-workspace or project not found } // Check if broker is co-located (embedded or has local path) @@ -4202,7 +4229,7 @@ func (s *Server) handleProjectRegister(w http.ResponseWriter, r *http.Request) { // Add as project provider. When the project already existed and the // broker is already a provider, preserve the existing localPath to - // avoid converting a hub-managed git project into a linked project. + // avoid converting a hub-native git project into a linked project. localPath := req.Path if !created { if existingProvider, err := s.store.GetProjectProvider(ctx, project.ID, broker.ID); err == nil { @@ -4298,7 +4325,7 @@ func (s *Server) handleProjectRegister(w http.ResponseWriter, r *http.Request) { // Add as project provider. When the project already existed and the // broker is already a provider, preserve the existing localPath to - // avoid converting a hub-managed git project into a linked project. + // avoid converting a hub-native git project into a linked project. localPath := req.Path if !created { if existingProvider, err := s.store.GetProjectProvider(ctx, project.ID, broker.ID); err == nil { @@ -4529,12 +4556,6 @@ func (s *Server) handleProjectRoutes(w http.ResponseWriter, r *http.Request) { return } - // Check for nested /import-harness-configs path - if subPath == "import-harness-configs" { - s.handleProjectImportHarnessConfigs(w, r, projectID) - return - } - // Check for nested /dav/ path (WebDAV endpoint for project workspace sync) if strings.HasPrefix(subPath, "dav") { davPath := strings.TrimPrefix(subPath, "dav") @@ -5245,7 +5266,7 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request, id string // Clean up project-scoped harness configs (best-effort), including storage files. s.deleteProjectHarnessConfigs(ctx, id) - // For hub-managed and shared-workspace projects, notify provider brokers to clean up + // For hub-native and shared-workspace projects, notify provider brokers to clean up // their local project directories. This must run before DeleteProject because // the cascade deletes the project_providers we need to enumerate. if project.GitRemote == "" || project.IsSharedWorkspace() { @@ -5257,7 +5278,7 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request, id string return } - // For hub-managed and shared-workspace projects, remove the filesystem directory. + // For hub-native and shared-workspace projects, remove the filesystem directory. if (project.GitRemote == "" || project.IsSharedWorkspace()) && project.Slug != "" { if projectPath, err := hubManagedProjectPath(project.Slug); err == nil { if err := util.RemoveAllSafe(projectPath); err != nil { @@ -8592,24 +8613,6 @@ func (s *Server) getHarnessConfigFromTemplate(template *store.Template, fallback return fallback } -// lookupHarnessConfigRecord resolves a harness-config reference (name or slug) -// to its Hub record, checking project scope first then global — the same -// precedence the broker uses for on-disk lookup. Returns nil if not found. -func (s *Server) lookupHarnessConfigRecord(ctx context.Context, projectID, ref string) *store.HarnessConfig { - if ref == "" { - return nil - } - if projectID != "" { - if hc, err := s.store.GetHarnessConfigBySlug(ctx, ref, store.HarnessConfigScopeProject, projectID); err == nil && hc != nil { - return hc - } - } - if hc, err := s.store.GetHarnessConfigBySlug(ctx, ref, store.HarnessConfigScopeGlobal, ""); err == nil && hc != nil { - return hc - } - return nil -} - // buildAppliedConfig constructs an AgentAppliedConfig from a CreateAgentRequest. // When req.Config is a ScionConfig, its fields are extracted into the applied config // and the full ScionConfig is preserved as InlineConfig for threading to the broker. @@ -8735,25 +8738,6 @@ func (s *Server) populateAgentConfig(ctx context.Context, agent *store.Agent, pr } } - // Resolve the harness-config name to a Hub record so the broker can hydrate - // it from the configured storage backend, mirroring template hydration. - // Without this a remote broker can only use harness-configs that happen to - // exist on its local filesystem (see resource-storage-refactor §4/§7.3 step 4). - hcRef := agent.AppliedConfig.HarnessConfig - if hcRef == "" && resolvedTemplate != nil { - hcRef = s.getHarnessConfigFromTemplate(resolvedTemplate, "") - } - if hcRef != "" { - projectID := "" - if project != nil { - projectID = project.ID - } - if hc := s.lookupHarnessConfigRecord(ctx, projectID, hcRef); hc != nil { - agent.AppliedConfig.HarnessConfigID = hc.ID - agent.AppliedConfig.HarnessConfigHash = hc.ContentHash - } - } - // Merge hub-level telemetry config as lowest-priority default. // Only applies when no per-agent or template telemetry config is set. s.mu.RLock() @@ -8833,12 +8817,10 @@ func (s *Server) createNotifySubscription(ctx context.Context, agentID, projectI // already exists when a create/start request arrives. // // Phases: -// 0. Resume from suspended (suspended): recover broker, dispatch start, update in-place → started -// 1. Resume from stopped (stopped + Resume flag): recover broker, dispatch start, update in-place → started -// 2. Stale cleanup (running/stopped/error + not resume + not provision-only): dispatch delete, remove from DB → deleted -// 3. Env-gather re-provisioning (provisioning + GatherEnv): dispatch delete, remove from DB → deleted -// 4. Restart (created/provisioning/pending + not provision-only): recover broker ID, update config, dispatch start → started -// 5. Otherwise: none (caller decides what to do) +// 1. Stale cleanup (running/stopped/error + not provision-only): dispatch delete, remove from DB → deleted +// 2. Env-gather re-provisioning (provisioning + GatherEnv): dispatch delete, remove from DB → deleted +// 3. Restart (created/provisioning/pending + not provision-only): recover broker ID, update config, dispatch start → started +// 4. Otherwise: none (caller decides what to do) func (s *Server) handleExistingAgent( ctx context.Context, w http.ResponseWriter, @@ -8904,50 +8886,6 @@ func (s *Server) handleExistingAgent( return existingAgentStarted } - // Stopped agents resumed in-place when the caller explicitly requests resume. - // This preserves the agent ID, metadata, and template association. The broker - // will recreate the container but the hub-level record stays the same. - if !req.ProvisionOnly && req.Resume && existingAgent.Phase == string(state.PhaseStopped) { - if existingAgent.RuntimeBrokerID == "" && runtimeBrokerID != "" { - existingAgent.RuntimeBrokerID = runtimeBrokerID - } - - dispatcher := s.GetDispatcher() - if dispatcher == nil || existingAgent.RuntimeBrokerID == "" { - writeError(w, http.StatusBadRequest, ErrCodeValidationError, - "cannot resume agent: no runtime broker available", nil) - return existingAgentErrored - } - - if existingAgent.AppliedConfig == nil { - existingAgent.AppliedConfig = &store.AgentAppliedConfig{} - } - if req.Task != "" { - existingAgent.AppliedConfig.Task = req.Task - existingAgent.AppliedConfig.Attach = req.Attach - } - - if err := dispatcher.DispatchAgentStart(ctx, existingAgent, req.Task); err != nil { - RuntimeError(w, "Failed to resume stopped agent: "+err.Error()) - return existingAgentErrored - } - - existingAgent.Phase = string(state.PhaseRunning) - if err := s.store.UpdateAgent(ctx, existingAgent); err != nil { - s.agentLifecycleLog.Warn("Failed to update agent status after resume from stopped", "agent_id", existingAgent.ID, "error", err) - } - - if req.Notify { - s.createNotifySubscription(ctx, existingAgent.ID, existingAgent.ProjectID, notifySubscriberType, notifySubscriberID, createdBy) - } - - s.enrichAgent(ctx, existingAgent, project, nil) - writeJSON(w, http.StatusOK, CreateAgentResponse{ - Agent: existingAgent, - }) - return existingAgentStarted - } - // Phase 1: Stale cleanup — agent is running/stopped/error and caller wants a real start. // The old agent is deleted so a fresh one can be created with a new ID. if !req.ProvisionOnly && @@ -9078,7 +9016,7 @@ func (s *Server) resolveRuntimeBroker(ctx context.Context, w http.ResponseWriter "totalProviders", len(allProviders), "onlineProviders", len(availableBrokers), "defaultBroker", project.DefaultRuntimeBrokerID, - "isHubManaged", project.GitRemote == "") + "isHubNative", project.GitRemote == "") // Convert to summary for error responses, marking and prioritizing the default broker brokerSummaries := make([]RuntimeBrokerSummary, 0, len(availableBrokers)) @@ -9372,25 +9310,13 @@ func (s *Server) handleProjectImportTemplates(w http.ResponseWriter, r *http.Req return } - kind := s.templateImportKind() - var run func(progress importProgressFunc) ([]string, error) + var imported []string if req.WorkspacePath != "" { - run = func(progress importProgressFunc) ([]string, error) { - return s.importFromWorkspace(ctx, project, req.WorkspacePath, store.TemplateScopeProject, kind, progress) - } + imported, err = s.importTemplatesFromWorkspace(ctx, project, req.WorkspacePath) } else { - sourceURL := config.NormalizeTemplateSourceURL(req.SourceURL) - run = func(progress importProgressFunc) ([]string, error) { - return s.importFromRemote(ctx, projectID, sourceURL, store.TemplateScopeProject, kind, progress) - } + req.SourceURL = config.NormalizeTemplateSourceURL(req.SourceURL) + imported, err = s.importTemplatesFromRemote(ctx, projectID, req.SourceURL) } - - if importAcceptsNDJSON(r) { - s.streamImport(w, run) - return - } - - imported, err := run(nil) if err != nil { writeError(w, http.StatusBadRequest, "import_failed", err.Error(), nil) return @@ -9401,128 +9327,6 @@ func (s *Server) handleProjectImportTemplates(w http.ResponseWriter, r *http.Req Count: len(imported), }) } - -// ============================================================================ -// Project Harness-Config Import -// ============================================================================ - -// ImportHarnessConfigsRequest is the request body for direct harness-config -// import. Exactly one of SourceURL or WorkspacePath should be provided. -type ImportHarnessConfigsRequest struct { - SourceURL string `json:"sourceUrl"` - WorkspacePath string `json:"workspacePath"` -} - -// ImportHarnessConfigsResponse is returned after a direct harness-config import -// completes. -type ImportHarnessConfigsResponse struct { - HarnessConfigs []string `json:"harnessConfigs"` - Count int `json:"count"` -} - -// handleProjectImportHarnessConfigs imports harness-configs directly from a -// remote URL or the project workspace into the project's harness-config store, -// mirroring handleProjectImportTemplates. -func (s *Server) handleProjectImportHarnessConfigs(w http.ResponseWriter, r *http.Request, projectID string) { - if r.Method != http.MethodPost { - MethodNotAllowed(w) - return - } - - ctx := r.Context() - - // Authorize the caller - if agentIdent := GetAgentIdentityFromContext(ctx); agentIdent != nil { - if !agentIdent.HasScope(ScopeAgentCreate) { - writeError(w, http.StatusForbidden, ErrCodeForbidden, "Missing required scope: project:agent:create", nil) - return - } - if projectID != agentIdent.ProjectID() { - writeError(w, http.StatusForbidden, ErrCodeForbidden, "Agents can only import harness-configs within their own project", nil) - return - } - } else if userIdent := GetUserIdentityFromContext(ctx); userIdent != nil { - decision := s.authzService.CheckAccess(ctx, userIdent, Resource{ - Type: "agent", - ParentType: "project", - ParentID: projectID, - }, ActionCreate) - if !decision.Allowed { - writeError(w, http.StatusForbidden, ErrCodeForbidden, - "You don't have permission to import harness-configs in this project", nil) - return - } - } else { - writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) - return - } - - var req ImportHarnessConfigsRequest - if err := readJSON(r, &req); err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil) - return - } - - if req.SourceURL != "" && req.WorkspacePath != "" { - writeError(w, http.StatusBadRequest, "invalid_request", "Exactly one of sourceUrl or workspacePath must be provided", nil) - return - } - - if req.SourceURL == "" && req.WorkspacePath == "" { - // Default workspace path when neither is provided - req.WorkspacePath = "/.scion/harness-configs" - } - - // Verify project exists - project, err := s.store.GetProject(ctx, projectID) - if err != nil { - if err == store.ErrNotFound { - NotFound(w, "Project") - return - } - writeErrorFromErr(w, err, "") - return - } - - if s.GetStorage() == nil { - writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Harness-config storage is not configured", nil) - return - } - - kind := s.harnessConfigImportKind() - var run func(progress importProgressFunc) ([]string, error) - if req.WorkspacePath != "" { - run = func(progress importProgressFunc) ([]string, error) { - return s.importFromWorkspace(ctx, project, req.WorkspacePath, store.HarnessConfigScopeProject, kind, progress) - } - } else { - sourceURL := config.NormalizeTemplateSourceURL(req.SourceURL) - run = func(progress importProgressFunc) ([]string, error) { - return s.importFromRemote(ctx, projectID, sourceURL, store.HarnessConfigScopeProject, kind, progress) - } - } - - if importAcceptsNDJSON(r) { - s.streamImport(w, run) - return - } - - imported, err := run(nil) - if err != nil { - writeError(w, http.StatusBadRequest, "import_failed", err.Error(), nil) - return - } - - writeJSON(w, http.StatusOK, ImportHarnessConfigsResponse{ - HarnessConfigs: imported, - Count: len(imported), - }) -} - -// ============================================================================ -// Unified Resource Import (kind/scope-generic) -// ============================================================================ - // ImportResourcesRequest is the body for the unified import endpoint // (POST /api/v1/resources/import). It imports a single kind of resource from a // remote source URL into the given scope. diff --git a/pkg/hub/handlers_broker_inbound.go b/pkg/hub/handlers_broker_inbound.go index c8da35b10..ab9e9fa72 100644 --- a/pkg/hub/handlers_broker_inbound.go +++ b/pkg/hub/handlers_broker_inbound.go @@ -15,6 +15,7 @@ package hub import ( + "errors" "fmt" "net/http" "strings" @@ -103,7 +104,11 @@ func (s *Server) handleBrokerInbound(w http.ResponseWriter, r *http.Request) { return } - if err := dispatcher.DispatchAgentMessage(r.Context(), agent, req.Message.Msg, req.Message.Urgent, req.Message); err != nil { + if err := dispatcher.DispatchAgentMessage(r.Context(), agent, req.Message.Msg, req.Message.Urgent, req.Message); errors.Is(err, ErrMessageDeferred) { + s.signalDeferredMessage(r.Context(), agent.RuntimeBrokerID, agent.ID) + w.WriteHeader(http.StatusAccepted) + return + } else if err != nil { log.Error("Failed to dispatch inbound message", "agent_id", agent.ID, "agent_slug", agentSlug, "error", err) writeError(w, http.StatusBadGateway, ErrCodeRuntimeError, diff --git a/pkg/hub/httpdispatcher.go b/pkg/hub/httpdispatcher.go index bf94ed812..83662f1d0 100644 --- a/pkg/hub/httpdispatcher.go +++ b/pkg/hub/httpdispatcher.go @@ -17,6 +17,8 @@ package hub import ( "context" + "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -24,9 +26,12 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/messages" + "github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics" "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/store" + "go.opentelemetry.io/otel/attribute" "github.com/go-jose/go-jose/v4/jwt" + "github.com/google/uuid" ) // HTTPRuntimeBrokerClient is an HTTP-based implementation of RuntimeBrokerClient. @@ -132,6 +137,15 @@ type HTTPAgentDispatcher struct { devAuthToken string // Dev auth token to inject into agent env (dev-auth mode only) debug bool log *slog.Logger + + // Cross-node dispatch deps (B4-2). When events + commandBus are non-nil + // and client.StartAgent/StopAgent/RestartAgent returns ErrLifecycleDeferred, + // the dispatcher writes durable intent + signals the owning node + waits + // for the terminal phase transition. Nil = cross-node dispatch disabled + // (single-node / SQLite mode: all brokers are local). + events EventPublisher + commandBus CommandBus + dispatchMetrics dispatchmetrics.Recorder } // NewHTTPAgentDispatcher creates a new HTTP-based agent dispatcher. @@ -191,6 +205,20 @@ func (d *HTTPAgentDispatcher) SetGitHubAppMinter(m GitHubAppTokenMinter) { d.githubAppMinter = m } +// SetCrossNodeDeps wires the event publisher and command bus needed for +// cross-node lifecycle dispatch (B4-2). When both are set and a lifecycle +// op returns ErrLifecycleDeferred, the dispatcher writes durable intent, +// signals the owning node, and waits for the terminal phase. +func (d *HTTPAgentDispatcher) SetCrossNodeDeps(events EventPublisher, bus CommandBus) { + d.events = events + d.commandBus = bus +} + +// SetDispatchMetrics wires the dispatch metrics recorder (B5-2). +func (d *HTTPAgentDispatcher) SetDispatchMetrics(rec dispatchmetrics.Recorder) { + d.dispatchMetrics = rec +} + // getBrokerEndpoint retrieves the endpoint URL for a runtime broker. // Returns an empty string without error when no endpoint is configured, // which is normal for brokers that connect via WebSocket control channel. @@ -282,7 +310,7 @@ func (d *HTTPAgentDispatcher) buildCreateRequest(ctx context.Context, agent *sto workspace := agent.AppliedConfig.Workspace gitClone := agent.AppliedConfig.GitClone // When the broker has a local provider path for this project, clear - // the hub-managed workspace path — the broker will derive its own + // the hub-native workspace path — the broker will derive its own // workspace location from the project path. However, keep GitClone // config: all hub-linked projects with a git remote use clone-based // provisioning (HTTPS + GitHub token) rather than worktree-based, @@ -300,21 +328,19 @@ func (d *HTTPAgentDispatcher) buildCreateRequest(ctx context.Context, agent *sto } } req.Config = &RemoteAgentConfig{ - Template: agent.Template, - Image: agent.AppliedConfig.Image, - HarnessConfig: agent.AppliedConfig.HarnessConfig, - HarnessAuth: agent.AppliedConfig.HarnessAuth, - Task: agent.AppliedConfig.Task, - Workspace: workspace, - Profile: agent.AppliedConfig.Profile, - Branch: agent.AppliedConfig.Branch, - TemplateID: agent.AppliedConfig.TemplateID, - TemplateHash: agent.AppliedConfig.TemplateHash, - HarnessConfigID: agent.AppliedConfig.HarnessConfigID, - HarnessConfigHash: agent.AppliedConfig.HarnessConfigHash, - GitClone: gitClone, - SharedWorkspace: projectInfo.sharedWorkspace, - GCPIdentity: remoteGCPIdentity, + Template: agent.Template, + Image: agent.AppliedConfig.Image, + HarnessConfig: agent.AppliedConfig.HarnessConfig, + HarnessAuth: agent.AppliedConfig.HarnessAuth, + Task: agent.AppliedConfig.Task, + Workspace: workspace, + Profile: agent.AppliedConfig.Profile, + Branch: agent.AppliedConfig.Branch, + TemplateID: agent.AppliedConfig.TemplateID, + TemplateHash: agent.AppliedConfig.TemplateHash, + GitClone: gitClone, + SharedWorkspace: projectInfo.sharedWorkspace, + GCPIdentity: remoteGCPIdentity, } req.ResolvedEnv = agent.AppliedConfig.Env @@ -504,7 +530,7 @@ func (d *HTTPAgentDispatcher) resolveDispatchProjectPath(ctx context.Context, ag func (d *HTTPAgentDispatcher) resolveDispatchProjectInfo(ctx context.Context, agent *store.Agent) projectDispatchInfo { // Look up the local path for this project on the target runtime broker. - // A provider LocalPath (linked project) takes precedence over hub-managed + // A provider LocalPath (linked project) takes precedence over hub-native // slug resolution, even for projects without a git remote. Only when there // is no provider path and no git remote do we fall back to projectSlug so // the broker resolves the conventional ~/.scion/projects/ path. @@ -537,7 +563,7 @@ func (d *HTTPAgentDispatcher) resolveDispatchProjectInfo(ctx context.Context, ag } } // If no provider path was found, let the broker resolve the path via - // slug. This applies to both hub-managed projects (no git remote) and + // slug. This applies to both hub-native projects (no git remote) and // git-anchored projects — the broker needs a project identity to create // agent directories under ~/.scion/projects// rather than falling // back to the global project. @@ -697,6 +723,9 @@ func (d *HTTPAgentDispatcher) DispatchAgentCreateWithGather(ctx context.Context, "agent_id", agent.ID, "agent", agent.Name, "brokerElapsed", time.Since(brokerCallStart).String(), "totalElapsed", time.Since(dispatchStart).String()) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredCreateWithGather(ctx, agent) + } if err != nil { return nil, err } @@ -711,6 +740,22 @@ func (d *HTTPAgentDispatcher) DispatchAgentCreateWithGather(ctx context.Context, return nil, nil } +// deferredCreateWithGather handles a cross-node create-with-gather via durable dispatch. +func (d *HTTPAgentDispatcher) deferredCreateWithGather(ctx context.Context, agent *store.Agent) (*RemoteEnvRequirementsResponse, error) { + result, err := d.deferredDataOpResult(ctx, agent, "create", &CreateWithGatherDispatchArgs{}) + if err != nil { + return nil, err + } + if result.Result == "" { + return nil, nil + } + var cr CreateWithGatherResult + if err := json.Unmarshal([]byte(result.Result), &cr); err != nil { + return nil, fmt.Errorf("unmarshal create result: %w", err) + } + return cr.EnvRequirements, nil +} + // DispatchFinalizeEnv sends gathered env vars to the broker to complete agent creation. func (d *HTTPAgentDispatcher) DispatchFinalizeEnv(ctx context.Context, agent *store.Agent, env map[string]string) error { if err := requireRuntimeBrokerAssigned(agent); err != nil { @@ -723,6 +768,9 @@ func (d *HTTPAgentDispatcher) DispatchFinalizeEnv(ctx context.Context, agent *st } resp, err := d.client.FinalizeEnv(ctx, agent.RuntimeBrokerID, endpoint, agent.ID, env) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredFinalizeEnv(ctx, agent, env) + } if err != nil { return err } @@ -733,6 +781,11 @@ func (d *HTTPAgentDispatcher) DispatchFinalizeEnv(ctx context.Context, agent *st return nil } +// deferredFinalizeEnv handles a cross-node finalize_env via durable dispatch. +func (d *HTTPAgentDispatcher) deferredFinalizeEnv(ctx context.Context, agent *store.Agent, env map[string]string) error { + return d.deferredDataOp(ctx, agent, "finalize_env", &FinalizeEnvDispatchArgs{Env: env}) +} + // resolveEnvFromStorage queries Hub env var storage for all applicable scopes // and returns a merged map with precedence: user > project > global. func (d *HTTPAgentDispatcher) resolveEnvFromStorage(ctx context.Context, agent *store.Agent) (map[string]string, error) { @@ -1066,6 +1119,11 @@ func (d *HTTPAgentDispatcher) DispatchAgentStart(ctx context.Context, agent *sto } resp, err := d.client.StartAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID, task, projectPath, projectSlug, harnessConfig, resolvedEnv, resolvedSecrets, inlineConfig, projectInfo.sharedDirs, projectInfo.sharedWorkspace) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredStart(ctx, agent, &StartDispatchArgs{ + Task: task, + }) + } if err != nil { return err } @@ -1087,7 +1145,11 @@ func (d *HTTPAgentDispatcher) DispatchAgentStop(ctx context.Context, agent *stor return err } - return d.client.StopAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID) + err = d.client.StopAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredStop(ctx, agent) + } + return err } // DispatchAgentRestart restarts an agent on the runtime broker. @@ -1141,7 +1203,11 @@ func (d *HTTPAgentDispatcher) DispatchAgentRestart(ctx context.Context, agent *s } } - return d.client.RestartAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID, resolvedEnv) + err = d.client.RestartAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID, resolvedEnv) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredRestart(ctx, agent) + } + return err } // DispatchAgentDelete deletes an agent from the runtime broker. @@ -1155,7 +1221,11 @@ func (d *HTTPAgentDispatcher) DispatchAgentDelete(ctx context.Context, agent *st return err } - return d.client.DeleteAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID, deleteFiles, removeBranch, softDelete, deletedAt) + err = d.client.DeleteAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID, deleteFiles, removeBranch, softDelete, deletedAt) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredDelete(ctx, agent, deleteFiles, removeBranch, softDelete, deletedAt) + } + return err } // DispatchAgentMessage sends a message to an agent on the runtime broker. @@ -1211,7 +1281,205 @@ func (d *HTTPAgentDispatcher) DispatchCheckAgentPrompt(ctx context.Context, agen return false, err } - return d.client.CheckAgentPrompt(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID) + hasPrompt, err := d.client.CheckAgentPrompt(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID) + if errors.Is(err, ErrLifecycleDeferred) { + return d.deferredCheckPrompt(ctx, agent) + } + return hasPrompt, err +} + +// deferredCheckPrompt handles a cross-node check_prompt via durable dispatch. +func (d *HTTPAgentDispatcher) deferredCheckPrompt(ctx context.Context, agent *store.Agent) (bool, error) { + result, err := d.deferredDataOpResult(ctx, agent, "check_prompt", &CheckPromptDispatchArgs{}) + if err != nil { + return false, err + } + var cr CheckPromptResult + if result.Result != "" { + if err := json.Unmarshal([]byte(result.Result), &cr); err != nil { + return false, fmt.Errorf("unmarshal check_prompt result: %w", err) + } + } + return cr.HasPrompt, nil +} + +// ============================================================================= +// Cross-node lifecycle dispatch (B4-2) +// ============================================================================= + +// isStartTerminal returns true for terminal phases of a start/restart op. +func isStartTerminal(phase string) bool { return phase == "running" || phase == "error" } + +// isStopTerminal returns true for terminal phases of a stop op. +func isStopTerminal(phase string) bool { return phase == "stopped" || phase == "error" } + +// deferredStart handles a cross-node agent start: subscribe → write intent → +// signal → wait for the terminal phase. Called when client.StartAgent returns +// ErrLifecycleDeferred (broker not locally connected). +func (d *HTTPAgentDispatcher) deferredStart(ctx context.Context, agent *store.Agent, args *StartDispatchArgs) error { + return d.deferredLifecycle(ctx, agent, "start", args, isStartTerminal) +} + +// deferredStop handles a cross-node agent stop. +func (d *HTTPAgentDispatcher) deferredStop(ctx context.Context, agent *store.Agent) error { + return d.deferredLifecycle(ctx, agent, "stop", &StopDispatchArgs{}, isStopTerminal) +} + +// deferredRestart handles a cross-node agent restart. +func (d *HTTPAgentDispatcher) deferredRestart(ctx context.Context, agent *store.Agent) error { + return d.deferredLifecycle(ctx, agent, "restart", &RestartDispatchArgs{}, isStartTerminal) +} + +// deferredDelete handles a cross-node agent delete: subscribe → write intent → +// signal → wait for the dispatch row to reach terminal state. Delete is +// idempotent: 404 from the owner is treated as success. +func (d *HTTPAgentDispatcher) deferredDelete(ctx context.Context, agent *store.Agent, deleteFiles, removeBranch, softDelete bool, deletedAt time.Time) error { + args := &DeleteDispatchArgs{ + DeleteFiles: deleteFiles, + RemoveBranch: removeBranch, + SoftDelete: softDelete, + DeletedAt: deletedAt, + } + return d.deferredDataOp(ctx, agent, "delete", args) +} + +// deferredDataOp is the common flow for cross-node ops that return a result +// via the dispatch row (delete, finalize_env, check_prompt, create): +// 1. Subscribe to broker.dispatch..done BEFORE writing intent +// 2. InsertBrokerDispatch with serialized args +// 3. Best-effort SignalBrokerCmd +// 4. waitForDispatchDone (reads result from the DB row — authoritative) +func (d *HTTPAgentDispatcher) deferredDataOp( + ctx context.Context, + agent *store.Agent, + op string, + args interface{}, +) error { + _, err := d.deferredDataOpResult(ctx, agent, op, args) + return err +} + +// deferredDataOpResult is like deferredDataOp but returns the completed +// dispatch row so callers can read the result JSON. +func (d *HTTPAgentDispatcher) deferredDataOpResult( + ctx context.Context, + agent *store.Agent, + op string, + args interface{}, +) (*store.BrokerDispatch, error) { + if d.events == nil || d.commandBus == nil { + return nil, fmt.Errorf("cross-node dispatch not available: events or command bus not configured") + } + + dispatchID := uuid.NewString() + + // 1. Subscribe BEFORE writing intent so we don't miss events. + eventCh, unsub := d.events.Subscribe("broker.dispatch." + dispatchID + ".done") + + // 2. Serialize args and insert the durable intent row. + argsJSON, err := MarshalDispatchArgs(args) + if err != nil { + unsub() + return nil, fmt.Errorf("marshal dispatch args: %w", err) + } + + dispatch := &store.BrokerDispatch{ + ID: dispatchID, + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + AgentSlug: agent.Slug, + ProjectID: agent.ProjectID, + Op: op, + Args: argsJSON, + } + if err := d.store.InsertBrokerDispatch(ctx, dispatch); err != nil { + unsub() + return nil, fmt.Errorf("insert dispatch intent: %w", err) + } + if rec := d.dispatchMetrics; rec != nil { + rec.IncPublished(ctx, 1, attribute.String("op", op)) + } + + // 3. Best-effort signal. + if err := d.commandBus.SignalBrokerCmd(ctx, agent.RuntimeBrokerID); err != nil { + d.log.Warn("deferredDataOp: signal failed (durable intent is backstop)", + "op", op, "brokerID", agent.RuntimeBrokerID, "error", err) + } + + // 4. Wait for completion — reads result from the DB row (authoritative). + result, err := waitForDispatchDone(ctx, eventCh, unsub, d.store, dispatchID) + if err != nil { + return nil, err + } + if result.State == store.DispatchStateFailed { + return nil, fmt.Errorf("dispatch %s failed: %s", op, result.Error) + } + return result, nil +} + +// deferredLifecycle is the common flow for cross-node start/stop/restart: +// 1. Subscribe to agent..status BEFORE writing intent (no missed events) +// 2. InsertBrokerDispatch with serialized resolved args +// 3. Best-effort SignalBrokerCmd (the row is durable; reconnect-drain backstop) +// 4. waitForAgentTransition with the op's terminal set +// 5. Return nil on success-terminal, ErrDispatchFailed on timeout, wrapped +// error on error-terminal +func (d *HTTPAgentDispatcher) deferredLifecycle( + ctx context.Context, + agent *store.Agent, + op string, + args interface{}, + terminal func(string) bool, +) error { + if d.events == nil || d.commandBus == nil { + return fmt.Errorf("cross-node dispatch not available: events or command bus not configured") + } + + // 1. Subscribe BEFORE writing intent so we don't miss events. + eventCh, unsub := d.events.Subscribe("agent." + agent.ID + ".status") + + // 2. Serialize args and insert the durable intent row. + argsJSON, err := MarshalDispatchArgs(args) + if err != nil { + unsub() + return fmt.Errorf("marshal dispatch args: %w", err) + } + + dispatch := &store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: agent.RuntimeBrokerID, + AgentID: agent.ID, + AgentSlug: agent.Slug, + ProjectID: agent.ProjectID, + Op: op, + Args: argsJSON, + } + if err := d.store.InsertBrokerDispatch(ctx, dispatch); err != nil { + unsub() + return fmt.Errorf("insert dispatch intent: %w", err) + } + if rec := d.dispatchMetrics; rec != nil { + rec.IncPublished(ctx, 1, attribute.String("op", op)) + } + + // 3. Best-effort signal — the row is the durable intent; reconnect-drain + // is the backstop if the signal is missed or no node owns the broker. + if err := d.commandBus.SignalBrokerCmd(ctx, agent.RuntimeBrokerID); err != nil { + d.log.Warn("deferredLifecycle: signal failed (durable intent is backstop)", + "op", op, "brokerID", agent.RuntimeBrokerID, "error", err) + } + + // 4. Wait for terminal phase. + phase, err := waitForAgentTransition(ctx, eventCh, unsub, terminal) + if err != nil { + return err + } + + // 5. Map terminal phase. + if phase == "error" { + return fmt.Errorf("agent entered error phase during %s", op) + } + return nil } // resolveSecrets queries secrets from all applicable scopes and merges them diff --git a/pkg/hub/messagebroker.go b/pkg/hub/messagebroker.go index ac863a8e6..ae8940207 100644 --- a/pkg/hub/messagebroker.go +++ b/pkg/hub/messagebroker.go @@ -17,6 +17,7 @@ package hub import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -43,17 +44,18 @@ const brokerCallbackTimeout = 30 * time.Second // - Manages subscriptions based on agent lifecycle events (created/deleted) // - Handles broadcast fan-out from a single broker publish to individual agent deliveries type MessageBrokerProxy struct { - bus eventbus.EventBus - store store.Store - events EventPublisher - getDispatcher func() AgentDispatcher - log *slog.Logger - messageLog *slog.Logger + bus eventbus.EventBus + store store.Store + events EventPublisher + getDispatcher func() AgentDispatcher + signalDeferred func(ctx context.Context, brokerID, agentID string) // NOTIFY wakeup for deferred messages + log *slog.Logger + messageLog *slog.Logger mu sync.Mutex subscriptions map[string][]eventbus.Subscription // projectID -> active subscriptions pluginSubscriptions map[string]eventbus.Subscription // pattern -> plugin-initiated subscription - subscribedTopics map[string]bool // dedup guard for project-level subscriptions + subscribedTopics map[string]bool // dedup guard for project-level subscriptions stopCh chan struct{} stopOnce sync.Once wg sync.WaitGroup @@ -80,6 +82,12 @@ func NewMessageBrokerProxy( } } +// SetSignalDeferred injects the callback used to emit a NOTIFY wakeup when a +// message dispatch is deferred (broker not locally connected). +func (p *MessageBrokerProxy) SetSignalDeferred(fn func(ctx context.Context, brokerID, agentID string)) { + p.signalDeferred = fn +} + // Start subscribes to agent lifecycle events and sets up broker subscriptions // for existing running agents. func (p *MessageBrokerProxy) Start() { @@ -243,7 +251,7 @@ func (p *MessageBrokerProxy) PublishUserMessage(ctx context.Context, projectID, return p.bus.Publish(ctx, topic, msg) } -// PublishToGroup fans out a message to a parsed group of recipients, delegating +// PublishToGroup fans out a message to a parsed set of recipients, delegating // to PublishMessage for agents and PublishUserMessage for users. func (p *MessageBrokerProxy) PublishToGroup(ctx context.Context, projectID string, recipients []messages.GroupRecipient, msg *messages.StructuredMessage) map[string]error { errs := make(map[string]error, len(recipients)) @@ -442,8 +450,6 @@ func (p *MessageBrokerProxy) deliverToUser(ctx context.Context, projectID, topic Urgent: msg.Urgent, Broadcasted: msg.Broadcasted, AgentID: agentID, - Channel: msg.Channel, - ThreadID: msg.ThreadID, CreatedAt: time.Now(), } if err := p.store.CreateMessage(ctx, storeMsg); err != nil { @@ -507,13 +513,8 @@ func (p *MessageBrokerProxy) deliverToAgent(ctx context.Context, projectID, agen return } - if err := dispatcher.DispatchAgentMessage(ctx, agent, msg.Msg, msg.Urgent, msg); err != nil { - p.log.Error("Failed to dispatch broker message to agent", - "agentSlug", agentSlug, "error", err) - return - } - - // Persist to message store (write-through; non-fatal if store fails). + // Persist to message store first so the row is durable intent for + // cross-node dispatch (dispatch_state defaults to pending). storeMsg := &store.Message{ ID: api.NewUUID(), ProjectID: projectID, @@ -526,12 +527,30 @@ func (p *MessageBrokerProxy) deliverToAgent(ctx context.Context, projectID, agen Urgent: msg.Urgent, Broadcasted: msg.Broadcasted, AgentID: agent.ID, - Channel: msg.Channel, - ThreadID: msg.ThreadID, CreatedAt: time.Now(), } if err := p.store.CreateMessage(ctx, storeMsg); err != nil { p.log.Error("Failed to persist broker message to store", "agentSlug", agentSlug, "error", err) + // Without a durable row, a deferred signal has nothing for the + // owning node to reconcile — abort dispatch entirely. + return + } + + if err := dispatcher.DispatchAgentMessage(ctx, agent, msg.Msg, msg.Urgent, msg); errors.Is(err, ErrMessageDeferred) { + if p.signalDeferred != nil { + p.signalDeferred(ctx, agent.RuntimeBrokerID, agent.ID) + } + return + } else if err != nil { + p.log.Error("Failed to dispatch broker message to agent", + "agentSlug", agentSlug, "error", err) + return + } + + // Mark the message as dispatched so reconcileBroker does not + // re-deliver it on the next broker reconnect. + if _, err := p.store.MarkMessageDispatched(ctx, storeMsg.ID); err != nil { + p.log.Error("Failed to mark broker message dispatched", "id", storeMsg.ID, "error", err) } // Log to dedicated message audit log @@ -596,8 +615,8 @@ func (p *MessageBrokerProxy) fanOutGlobal(ctx context.Context, msg *messages.Str } } -// ListChannels returns the names of registered bus channels. Returns nil if -// the underlying bus does not support channel listing. +// ListChannels returns the named bus channels when using a FanOutEventBus, +// or nil for single-bus configurations. Used by the message-channels API. func (p *MessageBrokerProxy) ListChannels() []eventbus.BusChannel { if fb, ok := p.bus.(*eventbus.FanOutEventBus); ok { return fb.BusChannels() diff --git a/pkg/hub/notifications.go b/pkg/hub/notifications.go index a79fcd3ba..4a94ee35f 100644 --- a/pkg/hub/notifications.go +++ b/pkg/hub/notifications.go @@ -17,6 +17,7 @@ package hub import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -35,6 +36,7 @@ type NotificationDispatcher struct { store store.Store events EventPublisher getDispatcher func() AgentDispatcher // lazy getter; dispatcher may be set after startup + signalDeferred func(ctx context.Context, brokerID, agentID string) // NOTIFY wakeup for deferred messages log *slog.Logger messageLog *slog.Logger // dedicated message audit logger (nil = disabled) channelRegistry *ChannelRegistry // external notification channels (nil = disabled) @@ -358,7 +360,13 @@ func (nd *NotificationDispatcher) dispatchToAgent(ctx context.Context, sub *stor structuredMsg.RecipientID = subscriber.ID structuredMsg.Status = strings.ToUpper(notif.Status) - if err := dispatcher.DispatchAgentMessage(ctx, subscriber, notif.Message, false, structuredMsg); err != nil { + if err := dispatcher.DispatchAgentMessage(ctx, subscriber, notif.Message, false, structuredMsg); errors.Is(err, ErrMessageDeferred) { + nd.log.Info("Notification deferred for cross-node delivery", + "subscriberID", sub.SubscriberID, "brokerID", subscriber.RuntimeBrokerID) + if nd.signalDeferred != nil { + nd.signalDeferred(ctx, subscriber.RuntimeBrokerID, subscriber.ID) + } + } else if err != nil { nd.log.Error("Failed to dispatch notification to agent", "subscriberID", sub.SubscriberID, "error", err) } else { diff --git a/pkg/hub/reaper.go b/pkg/hub/reaper.go new file mode 100644 index 000000000..e8bafd55a --- /dev/null +++ b/pkg/hub/reaper.go @@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "log/slog" + "time" +) + +// Staleness thresholds for the broker-affinity reaper (B5-1, design §7.1). +// +// affinityStaleAge: 2× the defaultAffinityFreshness (90s) used by the routing +// layer in broker_routing.go — a broker that hasn't heartbeated in 3 minutes is +// certainly dead and its affinity is safe to clear. +// +// dispatchStuckAge: 3× the dispatchRollingTimeout (90s) from dispatch_wait.go — +// gives the rolling-timeout wait ample time to fail organically before the +// reaper force-transitions the row. +const ( + affinityStaleAge = 2 * defaultAffinityFreshness // 180s + dispatchStuckAge = 3 * dispatchRollingTimeout // 270s + dispatchMaxRetries = 3 +) + +// brokerAffinityReapHandler returns a recurring handler that clears stale broker +// affinity and re-drives (or fails) stuck dispatches. Registered as a singleton +// so at most one replica runs it per tick. +func (s *Server) brokerAffinityReapHandler() func(ctx context.Context) { + return func(ctx context.Context) { + now := time.Now() + + cleared, err := s.store.ReapStaleBrokerAffinity(ctx, now.Add(-affinityStaleAge)) + if err != nil { + slog.Error("Scheduler: broker affinity reap failed", "error", err) + return + } + + requeued, failed, err := s.store.ReapStuckDispatch(ctx, now.Add(-dispatchStuckAge), dispatchMaxRetries) + if err != nil { + slog.Error("Scheduler: stuck dispatch reap failed", "error", err) + return + } + + if cleared > 0 || requeued > 0 || failed > 0 { + slog.Info("Scheduler: broker affinity reap complete", + "affinity_cleared", cleared, + "dispatch_requeued", requeued, + "dispatch_failed", failed) + } + } +} diff --git a/pkg/hub/reconcile.go b/pkg/hub/reconcile.go new file mode 100644 index 000000000..0c6466579 --- /dev/null +++ b/pkg/hub/reconcile.go @@ -0,0 +1,351 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" + "go.opentelemetry.io/otel/attribute" +) + +// ReconcileBroker is the exported entry point used by the command-bus signal +// handler (B2-4) to drain durable dispatch intent for a broker this node owns. +func (s *Server) ReconcileBroker(ctx context.Context, brokerID string) { + s.reconcileBroker(ctx, brokerID) +} + +// reconcileBroker drains durable dispatch intent for a broker this node owns: +// pending broker_dispatch rows and pending messages, each CAS-claimed so exactly +// one node executes a given item (design §5.3, §2.0.1). It is the durability +// backstop behind BOTH the command-bus NOTIFY signal and reconnect +// (markBrokerOnline) — so a missed signal or a down owner only delays, never +// loses, a command. Idempotent and safe to run concurrently: the store CAS +// (ClaimBrokerDispatch / MarkMessageDispatched) gates double-execution. +// +// Callers must already hold the broker's control-channel socket (markBrokerOnline +// runs on the accepting node; the command bus filters by ownsLocally), since the +// op executors deliver over the local tunnel. +func (s *Server) reconcileBroker(ctx context.Context, brokerID string) { + if s == nil || s.store == nil || brokerID == "" { + return + } + drainStart := time.Now() + defer func() { + if rec := s.dispatchMetrics; rec != nil { + rec.RecordReconcileDrainDuration(ctx, float64(time.Since(drainStart).Milliseconds())) + } + }() + + // 1. Lifecycle / create-time dispatch intents. + dispatches, err := s.store.ListPendingDispatch(ctx, brokerID) + if err != nil { + s.agentLifecycleLog.Error("reconcile: list pending dispatch failed", "brokerID", brokerID, "error", err) + } + for i := range dispatches { + d := dispatches[i] + claimed, err := s.store.ClaimBrokerDispatch(ctx, d.ID, s.instanceID) + if err != nil { + s.agentLifecycleLog.Error("reconcile: claim dispatch failed", "id", d.ID, "error", err) + continue + } + if !claimed { + continue // another node/drain owns this intent (exactly-once) + } + opAttr := attribute.String("op", d.Op) + if rec := s.dispatchMetrics; rec != nil { + rec.IncClaimed(ctx, 1, opAttr) + } + result, execErr := s.execDispatch(ctx, d) + if execErr != nil { + s.agentLifecycleLog.Warn("reconcile: dispatch op failed", "id", d.ID, "op", d.Op, "error", execErr) + if err := s.store.FailBrokerDispatch(ctx, d.ID, execErr.Error()); err != nil { + s.agentLifecycleLog.Error("reconcile: fail dispatch failed", "id", d.ID, "error", err) + } + if rec := s.dispatchMetrics; rec != nil { + rec.IncFailed(ctx, 1, opAttr) + } + if s.events != nil { + s.events.PublishDispatchDone(ctx, d.ID) + } + continue + } + if err := s.store.CompleteBrokerDispatch(ctx, d.ID, result); err != nil { + s.agentLifecycleLog.Error("reconcile: complete dispatch failed", "id", d.ID, "error", err) + } + if rec := s.dispatchMetrics; rec != nil { + rec.IncDone(ctx, 1, opAttr) + latencyMs := float64(time.Since(d.CreatedAt).Milliseconds()) + rec.RecordDispatchLatency(ctx, latencyMs, opAttr) + } + // Emit a slim completion event so originators waiting on + // waitForDispatchDone wake up (design §6.3). + if s.events != nil { + s.events.PublishDispatchDone(ctx, d.ID) + } + } + + // 2. Pending messages destined for agents on this broker. + msgs, err := s.store.ListPendingMessages(ctx, brokerID) + if err != nil { + s.agentLifecycleLog.Error("reconcile: list pending messages failed", "brokerID", brokerID, "error", err) + return + } + for i := range msgs { + m := msgs[i] + dispatched, err := s.store.MarkMessageDispatched(ctx, m.ID) + if err != nil { + s.agentLifecycleLog.Error("reconcile: mark message dispatched failed", "id", m.ID, "error", err) + continue + } + if !dispatched { + continue // another drain already took it (dedupe) + } + if rec := s.dispatchMetrics; rec != nil { + rec.IncMessageDispatched(ctx, 1) + } + if err := s.deliverMsg(ctx, &m); err != nil { + // At-least-once: the row is already marked dispatched; a delivery + // failure is surfaced by the pending-message sweep (B5-2). Phase 3 + // (B3-3) supplies the real local tunnel + failure handling. + s.agentLifecycleLog.Warn("reconcile: message delivery failed", "id", m.ID, "error", err) + } + } +} + +// executeDispatch runs a claimed dispatch intent's op via the LOCAL broker +// tunnel and returns its result JSON. The lifecycle cases (start/stop/restart) +// deserialize args from the dispatch row and call the local dispatcher, which +// delivers over the in-memory control-channel socket. Unknown ops fail cleanly +// (and are retryable). +func (s *Server) executeDispatch(ctx context.Context, d store.BrokerDispatch) (string, error) { + switch d.Op { + case "start": + return s.execDispatchStart(ctx, d) + case "stop": + return s.execDispatchStop(ctx, d) + case "restart": + return s.execDispatchRestart(ctx, d) + case "delete": + return s.execDispatchDelete(ctx, d) + case "check_prompt": + return s.execDispatchCheckPrompt(ctx, d) + case "finalize_env": + return s.execDispatchFinalizeEnv(ctx, d) + case "create": + return s.execDispatchCreate(ctx, d) + default: + return "", fmt.Errorf("broker dispatch op %q not yet wired on this node", d.Op) + } +} + +func (s *Server) execDispatchStart(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + var task string + if d.Args != "" { + args, err := UnmarshalStartArgs(d.Args) + if err != nil { + return "", fmt.Errorf("unmarshal start args: %w", err) + } + task = args.Task + } + if err := dispatcher.DispatchAgentStart(ctx, agent, task); err != nil { + return "", fmt.Errorf("dispatch start: %w", err) + } + return "", nil +} + +func (s *Server) execDispatchStop(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + if err := dispatcher.DispatchAgentStop(ctx, agent); err != nil { + return "", fmt.Errorf("dispatch stop: %w", err) + } + return "", nil +} + +func (s *Server) execDispatchRestart(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + if err := dispatcher.DispatchAgentRestart(ctx, agent); err != nil { + return "", fmt.Errorf("dispatch restart: %w", err) + } + return "", nil +} + +func (s *Server) execDispatchDelete(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + var deleteFiles, removeBranch, softDelete bool + var deletedAt time.Time + if d.Args != "" { + args, err := UnmarshalDeleteArgs(d.Args) + if err != nil { + return "", fmt.Errorf("unmarshal delete args: %w", err) + } + deleteFiles = args.DeleteFiles + removeBranch = args.RemoveBranch + softDelete = args.SoftDelete + deletedAt = args.DeletedAt + } + if err := dispatcher.DispatchAgentDelete(ctx, agent, deleteFiles, removeBranch, softDelete, deletedAt); err != nil { + return "", fmt.Errorf("dispatch delete: %w", err) + } + return "", nil +} + +func (s *Server) execDispatchCheckPrompt(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + hasPrompt, err := dispatcher.DispatchCheckAgentPrompt(ctx, agent) + if err != nil { + return "", fmt.Errorf("dispatch check_prompt: %w", err) + } + result, err := json.Marshal(CheckPromptResult{HasPrompt: hasPrompt}) + if err != nil { + return "", fmt.Errorf("marshal check_prompt result: %w", err) + } + return string(result), nil +} + +func (s *Server) execDispatchFinalizeEnv(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + var env map[string]string + if d.Args != "" { + args, err := UnmarshalFinalizeEnvArgs(d.Args) + if err != nil { + return "", fmt.Errorf("unmarshal finalize_env args: %w", err) + } + env = args.Env + } + if err := dispatcher.DispatchFinalizeEnv(ctx, agent, env); err != nil { + return "", fmt.Errorf("dispatch finalize_env: %w", err) + } + result, err := json.Marshal(FinalizeEnvResult{Success: true}) + if err != nil { + return "", fmt.Errorf("marshal finalize_env result: %w", err) + } + return string(result), nil +} + +func (s *Server) execDispatchCreate(ctx context.Context, d store.BrokerDispatch) (string, error) { + agent, err := s.resolveDispatchAgent(ctx, d) + if err != nil { + return "", err + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return "", fmt.Errorf("no dispatcher available") + } + envReqs, err := dispatcher.DispatchAgentCreateWithGather(ctx, agent) + if err != nil { + return "", fmt.Errorf("dispatch create: %w", err) + } + cr := CreateWithGatherResult{EnvRequirements: envReqs} + result, err := json.Marshal(cr) + if err != nil { + return "", fmt.Errorf("marshal create result: %w", err) + } + return string(result), nil +} + +// resolveDispatchAgent loads the agent from the store by slug (used as the +// identifier in the dispatch row's AgentSlug field, matching the runtime +// broker's slug-based addressing). +func (s *Server) resolveDispatchAgent(ctx context.Context, d store.BrokerDispatch) (*store.Agent, error) { + if d.AgentID != "" { + agent, err := s.store.GetAgent(ctx, d.AgentID) + if err != nil { + return nil, fmt.Errorf("resolve agent %s: %w", d.AgentID, err) + } + return agent, nil + } + return nil, fmt.Errorf("dispatch row has no agent ID") +} + +// deliverMessage tunnels a reconciled message to its agent over the LOCAL +// control channel — the same path DispatchAgentMessage uses for a locally- +// connected broker. reconcileBroker has already CAS-marked the message +// dispatched before calling this, so just deliver. +func (s *Server) deliverMessage(ctx context.Context, m *store.Message) error { + if m == nil || m.AgentID == "" { + return fmt.Errorf("message has no agent ID") + } + agent, err := s.store.GetAgent(ctx, m.AgentID) + if err != nil { + return fmt.Errorf("resolve agent %s: %w", m.AgentID, err) + } + if agent.RuntimeBrokerID == "" { + return fmt.Errorf("agent %s has no runtime broker", m.AgentID) + } + dispatcher := s.GetDispatcher() + if dispatcher == nil { + return fmt.Errorf("no dispatcher available for message delivery") + } + return dispatcher.DispatchAgentMessage(ctx, agent, m.Msg, m.Urgent, nil) +} + +// signalDeferredMessage emits a best-effort NOTIFY wakeup so the broker's +// owning node drains the pending message. Called when route() returns +// routeForward or routeUndeliverable (the message row is already durable). +func (s *Server) signalDeferredMessage(ctx context.Context, brokerID, agentID string) { + if s.commandBus != nil { + if err := s.commandBus.SignalBrokerCmd(ctx, brokerID); err != nil { + slog.Warn("failed to signal deferred message", "brokerID", brokerID, "agentID", agentID, "error", err) + } + } +} diff --git a/pkg/hub/reconcile_test.go b/pkg/hub/reconcile_test.go new file mode 100644 index 000000000..0df283e54 --- /dev/null +++ b/pkg/hub/reconcile_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !no_sqlite + +package hub + +import ( + "context" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/messages" + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/GoogleCloudPlatform/scion/pkg/store/entadapter" + "github.com/GoogleCloudPlatform/scion/pkg/store/enttest" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newReconcileServer builds a minimal Server wired only with what reconcileBroker +// needs, plus overridable executor seams. +func newReconcileServer(st store.Store, exec func(context.Context, store.BrokerDispatch) (string, error), deliver func(context.Context, *store.Message) error) *Server { + return &Server{ + store: st, + instanceID: "hub-" + uuid.NewString()[:8], + agentLifecycleLog: slog.Default(), + execDispatch: exec, + deliverMsg: deliver, + } +} + +func TestReconcileBroker_DrainsDispatchOnce(t *testing.T) { + ctx := context.Background() + cs := entadapter.NewCompositeStore(enttest.NewClient(t)) + var execN int32 + s := newReconcileServer(cs, + func(context.Context, store.BrokerDispatch) (string, error) { atomic.AddInt32(&execN, 1); return `{"ok":true}`, nil }, + func(context.Context, *store.Message) error { return nil }) + + broker := uuid.NewString() + d := &store.BrokerDispatch{ID: uuid.NewString(), BrokerID: broker, Op: "start"} + require.NoError(t, cs.InsertBrokerDispatch(ctx, d)) + + s.reconcileBroker(ctx, broker) + + assert.Equal(t, int32(1), atomic.LoadInt32(&execN), "executor runs once") + pending, err := cs.ListPendingDispatch(ctx, broker) + require.NoError(t, err) + assert.Empty(t, pending, "drained dispatch is no longer pending") +} + +func TestReconcileBroker_ConcurrentDrainsExecuteOnce(t *testing.T) { + ctx := context.Background() + cs := entadapter.NewCompositeStore(enttest.NewClient(t)) + var execN int32 + s := newReconcileServer(cs, + func(context.Context, store.BrokerDispatch) (string, error) { atomic.AddInt32(&execN, 1); return "", nil }, + func(context.Context, *store.Message) error { return nil }) + + broker := uuid.NewString() + require.NoError(t, cs.InsertBrokerDispatch(ctx, &store.BrokerDispatch{ID: uuid.NewString(), BrokerID: broker, Op: "start"})) + + const racers = 6 + var wg sync.WaitGroup + wg.Add(racers) + for i := 0; i < racers; i++ { + go func() { defer wg.Done(); s.reconcileBroker(ctx, broker) }() + } + wg.Wait() + + assert.Equal(t, int32(1), atomic.LoadInt32(&execN), "concurrent drains execute the intent exactly once") +} + +func TestReconcileBroker_FailedOpMarkedFailed(t *testing.T) { + ctx := context.Background() + cs := entadapter.NewCompositeStore(enttest.NewClient(t)) + s := newReconcileServer(cs, + func(context.Context, store.BrokerDispatch) (string, error) { return "", assertErr{} }, + func(context.Context, *store.Message) error { return nil }) + + broker := uuid.NewString() + d := &store.BrokerDispatch{ID: uuid.NewString(), BrokerID: broker, Op: "start"} + require.NoError(t, cs.InsertBrokerDispatch(ctx, d)) + + s.reconcileBroker(ctx, broker) + + pending, err := cs.ListPendingDispatch(ctx, broker) + require.NoError(t, err) + assert.Empty(t, pending, "a failed op leaves no pending row (it is marked failed, not retried in-loop)") +} + +func TestReconcileBroker_DrainsPendingMessageOnce(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + var deliverN int32 + s := newReconcileServer(cs, + func(context.Context, store.BrokerDispatch) (string, error) { return "", nil }, + func(context.Context, *store.Message) error { atomic.AddInt32(&deliverN, 1); return nil }) + + broker := uuid.NewString() + proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString()} + require.NoError(t, cs.CreateProject(ctx, proj)) + agent, err := client.Agent.Create(). + SetSlug("a-" + uuid.NewString()[:8]).SetName("a"). + SetProjectID(uuid.MustParse(proj.ID)).SetRuntimeBrokerID(broker). + Save(ctx) + require.NoError(t, err) + + msg := &store.Message{ID: uuid.NewString(), ProjectID: proj.ID, Sender: "user:x", Recipient: "agent:a", Msg: "hi", AgentID: agent.ID.String()} + require.NoError(t, cs.CreateMessage(ctx, msg)) + + s.reconcileBroker(ctx, broker) + + assert.Equal(t, int32(1), atomic.LoadInt32(&deliverN), "pending message delivered once") + got, err := cs.GetMessage(ctx, msg.ID) + require.NoError(t, err) + assert.Equal(t, store.MessageDispatchDispatched, got.DispatchState) +} + +// TestDeliverMessage_TunnelsViaDispatcher verifies that deliverMessage resolves +// the agent from the store and dispatches via the local AgentDispatcher. +func TestDeliverMessage_TunnelsViaDispatcher(t *testing.T) { + ctx := context.Background() + client := enttest.NewClient(t) + cs := entadapter.NewCompositeStore(client) + + proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString()} + require.NoError(t, cs.CreateProject(ctx, proj)) + + brokerID := uuid.NewString() + agent, err := client.Agent.Create(). + SetSlug("a-" + uuid.NewString()[:8]).SetName("deliver-test"). + SetProjectID(uuid.MustParse(proj.ID)).SetRuntimeBrokerID(brokerID). + Save(ctx) + require.NoError(t, err) + + var dispatched atomic.Int32 + var lastMsg string + fakeDispatcher := &reconcileTestDispatcher{ + onMessage: func(a *store.Agent, msg string) error { + dispatched.Add(1) + lastMsg = msg + return nil + }, + } + + srv := &Server{ + store: cs, + instanceID: "hub-test", + agentLifecycleLog: slog.Default(), + } + srv.SetDispatcher(fakeDispatcher) + srv.deliverMsg = srv.deliverMessage + + m := &store.Message{ + ID: uuid.NewString(), + AgentID: agent.ID.String(), + Msg: "hello from reconcile", + Urgent: true, + } + + err = srv.deliverMsg(ctx, m) + require.NoError(t, err) + assert.Equal(t, int32(1), dispatched.Load(), "message dispatched once") + assert.Equal(t, "hello from reconcile", lastMsg) +} + +// TestDeliverMessage_MissingAgent returns an error when the agent doesn't exist. +func TestDeliverMessage_MissingAgent(t *testing.T) { + ctx := context.Background() + cs := entadapter.NewCompositeStore(enttest.NewClient(t)) + srv := &Server{ + store: cs, + instanceID: "hub-test", + agentLifecycleLog: slog.Default(), + } + srv.deliverMsg = srv.deliverMessage + + m := &store.Message{ID: uuid.NewString(), AgentID: uuid.NewString(), Msg: "test"} + err := srv.deliverMsg(ctx, m) + assert.Error(t, err) + assert.Contains(t, err.Error(), "resolve agent") +} + +// reconcileTestDispatcher is a minimal AgentDispatcher for deliverMessage tests. +type reconcileTestDispatcher struct { + onMessage func(agent *store.Agent, msg string) error +} + +func (d *reconcileTestDispatcher) DispatchAgentCreate(context.Context, *store.Agent) error { return nil } +func (d *reconcileTestDispatcher) DispatchAgentProvision(context.Context, *store.Agent) error { + return nil +} +func (d *reconcileTestDispatcher) DispatchAgentStart(context.Context, *store.Agent, string) error { + return nil +} +func (d *reconcileTestDispatcher) DispatchAgentStop(context.Context, *store.Agent) error { return nil } +func (d *reconcileTestDispatcher) DispatchAgentRestart(context.Context, *store.Agent) error { + return nil +} +func (d *reconcileTestDispatcher) DispatchAgentDelete(_ context.Context, _ *store.Agent, _, _, _ bool, _ time.Time) error { + return nil +} +func (d *reconcileTestDispatcher) DispatchAgentMessage(_ context.Context, agent *store.Agent, msg string, _ bool, _ *messages.StructuredMessage) error { + if d.onMessage != nil { + return d.onMessage(agent, msg) + } + return nil +} +func (d *reconcileTestDispatcher) DispatchAgentLogs(context.Context, *store.Agent, int) (string, error) { + return "", nil +} +func (d *reconcileTestDispatcher) DispatchAgentExec(context.Context, *store.Agent, []string, int) (string, int, error) { + return "", 0, nil +} +func (d *reconcileTestDispatcher) DispatchCheckAgentPrompt(context.Context, *store.Agent) (bool, error) { + return false, nil +} +func (d *reconcileTestDispatcher) DispatchAgentCreateWithGather(context.Context, *store.Agent) (*RemoteEnvRequirementsResponse, error) { + return nil, nil +} +func (d *reconcileTestDispatcher) DispatchFinalizeEnv(context.Context, *store.Agent, map[string]string) error { + return nil +} + +type assertErr struct{} + +func (assertErr) Error() string { return "boom" } diff --git a/pkg/hub/server.go b/pkg/hub/server.go index e1184323f..aa989a0d3 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -28,6 +28,7 @@ import ( "log/slog" "net" "net/http" + "os" "strings" "sync" "time" @@ -37,6 +38,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/hub/githubapp" "github.com/GoogleCloudPlatform/scion/pkg/messages" "github.com/GoogleCloudPlatform/scion/pkg/observability/dbmetrics" + "github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics" "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" @@ -77,6 +79,14 @@ type ServerConfig struct { // UserTokenConfig holds configuration for user JWT tokens. // If SigningKey is empty, a random key is generated. UserTokenConfig UserTokenConfig + // SharedSigningSecret is the deployment-wide secret (the same value every + // replica receives via --session-secret / SESSION_SECRET) from which the + // agent and user JWT signing keys are derived deterministically. When set, + // every replica derives identical signing keys regardless of its + // host-derived HubID, so a JWT minted by one replica validates on any + // other replica behind the load balancer. When empty, signing keys fall + // back to per-hub storage in the secret backend / store. + SharedSigningSecret string // TrustedProxies is a list of trusted proxy IPs/CIDRs for forwarded headers. TrustedProxies []string // Debug enables verbose debug logging. @@ -261,7 +271,7 @@ type RuntimeBrokerClient interface { // brokerID is used for HMAC authentication lookup. // task is an optional task string to pass to the agent on start. // projectPath is the local filesystem path to the project on the broker. - // projectSlug is the project slug for hub-managed projects (no local provider path). + // projectSlug is the project slug for hub-native projects (no local provider path). // resolvedEnv contains environment variables resolved from Hub storage (API keys, etc.). // harnessConfig is the harness config name to use for the agent (e.g. "claude", "gemini"). // resolvedSecrets contains type-aware secrets (including file-type) for auth resolution. @@ -317,7 +327,7 @@ type RuntimeBrokerClient interface { // Returns the command output, exit code, and any error. ExecAgent(ctx context.Context, brokerID, brokerEndpoint, agentID, projectID string, command []string, timeout int) (string, int, error) - // CleanupProject asks a broker to remove its local hub-managed project directory. + // CleanupProject asks a broker to remove its local hub-native project directory. // brokerID is used for HMAC authentication lookup. // 404 responses are tolerated for idempotency. CleanupProject(ctx context.Context, brokerID, brokerEndpoint, projectSlug string) error @@ -365,7 +375,7 @@ type RemoteCreateAgentRequest struct { // Only populated when GatherEnv is true. EnvSources map[string]string `json:"envSources,omitempty"` - // ProjectSlug is the project slug for hub-managed projects. + // ProjectSlug is the project slug for hub-native projects. // When set, the broker creates the workspace at ~/.scion/projects// // instead of the default worktree-based path. ProjectSlug string `json:"projectSlug,omitempty"` @@ -414,15 +424,6 @@ type RemoteAgentConfig struct { // If the cached template's hash matches, it can be used without re-downloading. TemplateHash string `json:"templateHash,omitempty"` - // HarnessConfigID is the Hub harness-config ID for hydration on the broker. - // When set, the broker fetches the harness-config from the Hub's storage - // backend rather than requiring it on the broker's local filesystem. - HarnessConfigID string `json:"harnessConfigId,omitempty"` - - // HarnessConfigHash is the content hash of the harness-config for cache - // validation, mirroring TemplateHash. - HarnessConfigHash string `json:"harnessConfigHash,omitempty"` - // GitClone specifies git clone parameters for git-anchored projects. // When set, the runtime broker skips workspace mounting and injects env vars // so sciontool can clone the repo inside the container. @@ -509,9 +510,15 @@ type Server struct { controlChannel *ControlChannelManager // WebSocket control channel for runtime brokers authzService *AuthzService // Authorization service for policy evaluation events EventPublisher // Event publisher for real-time SSE updates + commandBus CommandBus // Inter-node dispatch signal bus (nil-safe; nil = no-op) notificationDispatcher *NotificationDispatcher // Notification dispatcher for agent status events + // reconcile op executors (seams): default to executeDispatch/deliverMessage; + // Phase 3/4 supply the real local-tunnel ops; tests override for exactly-once. + execDispatch func(ctx context.Context, d store.BrokerDispatch) (string, error) + deliverMsg func(ctx context.Context, m *store.Message) error maintenance *MaintenanceState // Runtime maintenance mode state hubID string // Unique hub instance ID for secret namespacing + instanceID string // Unique per-process ID (uuid); affinity key for broker dispatch embeddedBrokerID string // Broker ID when running in hub+broker combo mode scheduler *Scheduler // Unified scheduler for recurring tasks cleanupOnce sync.Once // Ensures CleanupResources runs only once @@ -541,6 +548,10 @@ type Server struct { // connection-pool sampler started in StartBackgroundServices. dbMetrics dbmetrics.Recorder + // Broker dispatch metrics recorder (B5-2). Defaults to a disabled no-op + // recorder; SetDispatchMetrics wires a real exporter. + dispatchMetrics dispatchmetrics.Recorder + // stopPoolSampler stops the DB pool-stats sampling goroutine on shutdown. stopPoolSampler func() @@ -569,6 +580,16 @@ type Server struct { githubAppRateLimit *githubapp.RateLimitInfo } +func newInstanceID() string { + if podName := os.Getenv("POD_NAME"); podName != "" { + return podName + "-" + uuid.NewString() + } + return uuid.NewString() +} + +// InstanceID returns the per-process unique identifier for this hub instance. +func (s *Server) InstanceID() string { return s.instanceID } + // New creates a new Hub API server. func New(cfg ServerConfig, s store.Store) (*Server, error) { // Apply defaults for zero-value fields that have meaningful defaults. @@ -585,6 +606,7 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { events: noopEventPublisher{}, maintenance: NewMaintenanceState(cfg.AdminMode, cfg.MaintenanceMessage), hubID: cfg.HubID, + instanceID: newInstanceID(), // Subsystem loggers agentLifecycleLog: logging.Subsystem("hub.agent-lifecycle"), @@ -678,6 +700,10 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { } // Initialize audit logger (used by broker auth and invite system) + // Default reconcile-drain op executors (Phase 3/4 supply the real local ops). + srv.execDispatch = srv.executeDispatch + srv.deliverMsg = srv.deliverMessage + srv.auditLogger = NewLogAuditLogger("[Hub Audit]", cfg.Debug) // Initialize broker auth service if enabled @@ -696,10 +722,27 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { RequestTimeout: 120 * time.Second, Debug: cfg.Debug, }, logging.Subsystem("hub.control-channel")) - // Set disconnect callback to mark broker offline when WebSocket drops - srv.controlChannel.SetOnDisconnect(func(brokerID string) { + // Set disconnect callback to mark broker offline when WebSocket drops. + // Compare-and-clear affinity first: only stamp offline if this hub instance + + // session still owns the broker. If affinity has moved (broker flapped to + // another replica or re-dialed with a newer session), this is a stale + // disconnect and we must NOT clobber the live owner's online status. + srv.controlChannel.SetOnDisconnect(func(brokerID, sessionID string) { ctx := context.Background() - slog.Info("Broker disconnected, marking offline", "brokerID", brokerID) + + cleared, err := s.ReleaseRuntimeBrokerConnection(ctx, brokerID, srv.instanceID, sessionID) + if err != nil { + slog.Error("Failed to release broker affinity on disconnect", "brokerID", brokerID, "sessionID", sessionID, "error", err) + return + } + if !cleared { + // Another replica (or a newer session on this replica) already owns + // the socket. Skip the offline stamp to avoid clobbering it. + slog.Info("broker reconnected elsewhere; skipping offline stamp", "brokerID", brokerID, "staleSession", sessionID) + return + } + + slog.Info("Broker disconnected, marking offline", "brokerID", brokerID, "sessionID", sessionID) if err := s.UpdateRuntimeBrokerHeartbeat(ctx, brokerID, store.BrokerStatusOffline); err != nil { slog.Error("Failed to mark broker offline", "brokerID", brokerID, "error", err) @@ -779,6 +822,18 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { return srv, nil } +// deriveSharedSigningKey deterministically derives a 32-byte HS256 signing key +// from the deployment's shared signing secret and the logical key name. The key +// name (e.g. "user_signing_key", "agent_signing_key") provides domain +// separation so the user and agent keys differ even though both originate from +// the same shared secret. Every replica configured with the same shared secret +// derives identical keys, which is what lets a JWT minted by one replica be +// validated by another. +func deriveSharedSigningKey(secret, keyName string) []byte { + sum := sha256.Sum256([]byte("scion-hub-signing-key:" + keyName + ":" + secret)) + return sum[:] +} + // ensureSigningKey ensures a signing key exists, loading it if it does // or generating and saving it if it doesn't. // @@ -798,6 +853,27 @@ func (s *Server) ensureSigningKey(ctx context.Context, keyName string, existingK return existingKey, nil } + // When a deployment-wide shared signing secret is configured (the same + // secret every replica receives via --session-secret / SESSION_SECRET), + // derive the signing key deterministically from it. This makes the key + // identical on every replica regardless of the host-derived hub ID, so a + // JWT minted by one replica validates on any other. It mirrors the web + // session cookie store (commit 0515e2a8), whose keys are derived from the + // same shared secret, and is what lets the hub scale horizontally behind a + // load balancer without operators having to pin a matching HubID on each + // replica. Per-host secret-backend storage (below) is bypassed entirely. + if s.config.SharedSigningSecret != "" { + key := deriveSharedSigningKey(s.config.SharedSigningSecret, keyName) + fp := sha256.Sum256(key) + slog.Info("ensureSigningKey: derived from shared signing secret", + "key", keyName, + "source", "shared_secret", + "key_len", len(key), + "sha256_prefix", hex.EncodeToString(fp[:8]), + ) + return key, nil + } + hubID := s.hubID hasSecretBackend := s.secretBackend != nil _, isGCPBackend := s.secretBackend.(*secret.GCPBackend) @@ -1268,6 +1344,13 @@ func (s *Server) SetDBMetrics(rec dbmetrics.Recorder) { s.dbMetrics = rec } +// SetDispatchMetrics wires the broker-dispatch metrics recorder (B5-2). +func (s *Server) SetDispatchMetrics(rec dispatchmetrics.Recorder) { + s.mu.Lock() + defer s.mu.Unlock() + s.dispatchMetrics = rec +} + // GetMaintenanceState returns the runtime maintenance state. func (s *Server) GetMaintenanceState() *MaintenanceState { return s.maintenance @@ -1301,6 +1384,24 @@ func (s *Server) SetEventPublisher(ep EventPublisher) { s.events = ep } +// SetCommandBus sets the inter-node dispatch signal bus. Nil is safe (treated +// as no-op). Called from the server-foreground init path after backend selection. +func (s *Server) SetCommandBus(cb CommandBus) { + s.mu.Lock() + defer s.mu.Unlock() + s.commandBus = cb + if pgBus, ok := cb.(*PostgresCommandBus); ok { + pgBus.SetOnReconnect(func() { + if rec := s.dispatchMetrics; rec != nil { + rec.IncCmdBusReconnects(context.Background(), 1) + } + }) + } +} + +// CommandBus returns the configured command bus, or nil. +func (s *Server) CommandBus() CommandBus { return s.commandBus } + // StartNotificationDispatcher creates and starts the notification dispatcher // if a subscription-capable EventPublisher is available. It uses a lazy getter for the // AgentDispatcher so it works even if SetDispatcher is called later. @@ -1321,6 +1422,7 @@ func (s *Server) StartNotificationDispatcher() { nd := NewNotificationDispatcher(s.store, s.events, s.GetDispatcher, logging.Subsystem("hub.notifications")) nd.messageLog = s.dedicatedMessageLog nd.channelRegistry = s.channelRegistry + nd.signalDeferred = s.signalDeferredMessage s.notificationDispatcher = nd s.notificationDispatcher.Start() } @@ -1344,6 +1446,7 @@ func (s *Server) StartMessageBroker(b eventbus.EventBus) { proxy := NewMessageBrokerProxy(b, s.store, s.events, s.GetDispatcher, logging.Subsystem("hub.broker")) proxy.messageLog = s.dedicatedMessageLog + proxy.SetSignalDeferred(s.signalDeferredMessage) s.messageBrokerProxy = proxy proxy.Start() @@ -1372,7 +1475,9 @@ func (s *Server) CreateAuthenticatedDispatcher() *HTTPAgentDispatcher { // Wrap with hybrid client that prefers control channel var client RuntimeBrokerClient if s.controlChannel != nil { - client = NewHybridBrokerClient(s.controlChannel, httpClient, &hmacBrokerSigner{store: s.store}, s.config.Debug) + hbc := NewHybridBrokerClient(s.controlChannel, httpClient, &hmacBrokerSigner{store: s.store}, s.config.Debug) + hbc.SetAffinityLookup(StoreAffinityLookup(s.store, 0)) + client = hbc } else { client = httpClient } @@ -1416,6 +1521,16 @@ func (s *Server) CreateAuthenticatedDispatcher() *HTTPAgentDispatcher { dispatcher.SetGitHubAppMinter(s) } + // Wire cross-node lifecycle dispatch deps (B4-2) so the dispatcher + // can handle ErrLifecycleDeferred from route-gated Start/Stop/Restart + // by writing durable intent, signaling the owning node, and waiting + // for the terminal phase. In SQLite mode events/commandBus are no-ops, + // and route() always returns routeLocal, so this never triggers. + dispatcher.SetCrossNodeDeps(s.events, s.commandBus) + if s.dispatchMetrics != nil { + dispatcher.SetDispatchMetrics(s.dispatchMetrics) + } + return dispatcher } @@ -1604,12 +1719,16 @@ func (s *Server) messageEventHandler() EventHandler { structuredMsg.Plain = payload.Plain structuredMsg.Urgent = payload.Interrupt - if err := dispatcher.DispatchAgentMessage(ctx, agent, payload.Message, payload.Interrupt, structuredMsg); err != nil { + if err := dispatcher.DispatchAgentMessage(ctx, agent, payload.Message, payload.Interrupt, structuredMsg); errors.Is(err, ErrMessageDeferred) { + s.signalDeferredMessage(ctx, agent.RuntimeBrokerID, agent.ID) + slog.Info("Scheduler: message deferred for cross-node delivery", + "eventID", evt.ID, "agent_id", agent.ID, "agentName", agent.Name) + } else if err != nil { return fmt.Errorf("failed to dispatch message to agent %s: %w", agent.Name, err) + } else { + slog.Info("Scheduler: message delivered to agent", + "eventID", evt.ID, "agent_id", agent.ID, "agentName", agent.Name) } - - slog.Info("Scheduler: message delivered to agent", - "eventID", evt.ID, "agent_id", agent.ID, "agentName", agent.Name) return nil } } @@ -1872,6 +1991,8 @@ func (s *Server) StartBackgroundServices(ctx context.Context) { s.scheduler.RegisterEventHandler("message", s.messageEventHandler()) s.scheduler.RegisterEventHandler("dispatch_agent", s.dispatchAgentEventHandler()) s.scheduler.RegisterRecurringSingleton("schedule-evaluator", 1, store.LockScheduleEvaluator, s.evaluateSchedulesHandler()) + s.scheduler.RegisterRecurringSingleton("broker-affinity-reap", 1, store.LockBrokerAffinityReap, s.brokerAffinityReapHandler()) + s.scheduler.RegisterRecurringSingleton("broker-message-sweep", 1, store.LockBrokerMessageSweep, s.brokerMessageSweepHandler()) // Register GitHub App health check if the app is configured s.mu.RLock() @@ -1985,6 +2106,9 @@ func (s *Server) Shutdown(ctx context.Context) error { if s.events != nil { s.events.Close() } + if s.commandBus != nil { + s.commandBus.Close() + } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -2024,6 +2148,9 @@ func (s *Server) CleanupResources(ctx context.Context) error { if s.events != nil { s.events.Close() } + if s.commandBus != nil { + s.commandBus.Close() + } if s.logQueryService != nil { s.logQueryService.Close() } @@ -2086,9 +2213,6 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("/api/v1/harness-configs", s.handleHarnessConfigs) s.mux.HandleFunc("/api/v1/harness-configs/", s.handleHarnessConfigByID) - // Unified, kind/scope-generic resource import (templates + harness-configs). - s.mux.HandleFunc("/api/v1/resources/import", s.handleResourcesImport) - s.mux.HandleFunc("/api/v1/users", s.handleUsers) s.mux.HandleFunc("/api/v1/users/", s.handleUserByID) @@ -2113,9 +2237,6 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("/api/v1/brokers/join", s.handleBrokerJoin) s.mux.HandleFunc("/api/v1/brokers/", s.handleBrokerByIDRoutes) - // Message channel listing - s.mux.HandleFunc("/api/v1/message-channels", s.handleMessageChannels) - // Broker plugin inbound message delivery s.mux.HandleFunc("/api/v1/broker/inbound", s.handleBrokerInbound) @@ -2449,31 +2570,35 @@ func (s *Server) handleRuntimeBrokerConnect(w http.ResponseWriter, r *http.Reque } // Use the broker ID from header - if err := s.controlChannel.HandleUpgrade(w, r, brokerID); err != nil { + sessionID, err := s.controlChannel.HandleUpgrade(w, r, brokerID) + if err != nil { slog.Error("Upgrade failed for broker", "brokerID", brokerID, "error", err) // Error already written by upgrader return } - s.markBrokerOnline(brokerID) + s.markBrokerOnline(brokerID, sessionID) return } // Use authenticated broker identity - if err := s.controlChannel.HandleUpgrade(w, r, broker.ID()); err != nil { + sessionID, err := s.controlChannel.HandleUpgrade(w, r, broker.ID()) + if err != nil { slog.Error("Upgrade failed for broker", "brokerID", broker.ID(), "error", err) // Error already written by upgrader return } - s.markBrokerOnline(broker.ID()) + s.markBrokerOnline(broker.ID(), sessionID) } // markBrokerOnline updates broker and provider statuses to online after a successful WebSocket connection. -func (s *Server) markBrokerOnline(brokerID string) { +// It claims broker affinity for this hub instance + the connection's sessionID, +// which also bumps status->online and refreshes the heartbeat in one CAS write. +func (s *Server) markBrokerOnline(brokerID, sessionID string) { ctx := context.Background() - slog.Info("Broker connected, marking online", "brokerID", brokerID) + slog.Info("Broker connected, marking online", "brokerID", brokerID, "sessionID", sessionID, "instanceID", s.instanceID) - if err := s.store.UpdateRuntimeBrokerHeartbeat(ctx, brokerID, store.BrokerStatusOnline); err != nil { - slog.Error("Failed to mark broker online", "brokerID", brokerID, "error", err) + if err := s.store.ClaimRuntimeBrokerConnection(ctx, brokerID, s.instanceID, sessionID); err != nil { + slog.Error("Failed to claim broker connection", "brokerID", brokerID, "error", err) } providers, err := s.store.GetBrokerProjects(ctx, brokerID) @@ -2498,6 +2623,12 @@ func (s *Server) markBrokerOnline(brokerID string) { brokerName = broker.Name } s.events.PublishBrokerConnected(ctx, brokerID, brokerName, projectIDs) + + // Durability backstop (design §5.3): the moment this node owns the socket, + // drain any durable dispatch intent that accumulated while the broker was + // offline or owned elsewhere. Async so it never blocks the connect path; + // idempotent + CAS-gated so concurrent drains execute each item once. + go s.reconcileBroker(context.Background(), brokerID) } // isWebSocketUpgrade checks if the request is a WebSocket upgrade request. diff --git a/pkg/hub/server_instanceid_test.go b/pkg/hub/server_instanceid_test.go new file mode 100644 index 000000000..987b23e7c --- /dev/null +++ b/pkg/hub/server_instanceid_test.go @@ -0,0 +1,41 @@ +package hub + +import ( + "testing" +) + +func TestNewInstanceID_NonEmpty(t *testing.T) { + id := newInstanceID() + if id == "" { + t.Fatal("newInstanceID() returned empty string") + } +} + +func TestNewInstanceID_Unique(t *testing.T) { + ids := make(map[string]struct{}, 100) + for i := 0; i < 100; i++ { + id := newInstanceID() + if _, exists := ids[id]; exists { + t.Fatalf("duplicate instanceID on call %d: %s", i, id) + } + ids[id] = struct{}{} + } +} + +func TestInstanceID_AccessorMatchesField(t *testing.T) { + s := &Server{instanceID: newInstanceID()} + if s.InstanceID() == "" { + t.Fatal("InstanceID() returned empty string") + } + if s.InstanceID() != s.instanceID { + t.Fatal("InstanceID() does not match instanceID field") + } +} + +func TestInstanceID_TwoServersDistinct(t *testing.T) { + s1 := &Server{instanceID: newInstanceID()} + s2 := &Server{instanceID: newInstanceID()} + if s1.InstanceID() == s2.InstanceID() { + t.Fatalf("two Servers share the same InstanceID: %s", s1.InstanceID()) + } +} diff --git a/pkg/hub/signing_key_shared_test.go b/pkg/hub/signing_key_shared_test.go new file mode 100644 index 000000000..b3849f025 --- /dev/null +++ b/pkg/hub/signing_key_shared_test.go @@ -0,0 +1,126 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "bytes" + "context" + "testing" +) + +// TestDeriveSharedSigningKey_Deterministic verifies that the derivation is +// stable for a given (secret, keyName) pair and domain-separated across key +// names and secrets. +func TestDeriveSharedSigningKey_Deterministic(t *testing.T) { + const secret = "shared-deployment-secret" + + userA := deriveSharedSigningKey(secret, SecretKeyUserSigningKey) + userB := deriveSharedSigningKey(secret, SecretKeyUserSigningKey) + if !bytes.Equal(userA, userB) { + t.Fatal("same secret + key name must derive identical keys") + } + if len(userA) != 32 { + t.Fatalf("expected a 32-byte key, got %d bytes", len(userA)) + } + + // Domain separation: user vs agent key must differ. + agent := deriveSharedSigningKey(secret, SecretKeyAgentSigningKey) + if bytes.Equal(userA, agent) { + t.Fatal("user and agent keys derived from the same secret must differ") + } + + // A different secret must produce a different key. + other := deriveSharedSigningKey("a-different-secret", SecretKeyUserSigningKey) + if bytes.Equal(userA, other) { + t.Fatal("different secrets must derive different keys") + } +} + +// TestEnsureSigningKey_SharedSecretReplicaPortable is the regression test for +// the cross-replica "session_expired" login loop: two replicas with DIFFERENT +// host-derived hub IDs but the SAME shared signing secret must resolve +// identical signing keys, so a user JWT minted by one replica validates on the +// other. A replica with a different shared secret must NOT be able to validate +// the token. +func TestEnsureSigningKey_SharedSecretReplicaPortable(t *testing.T) { + const sharedSecret = "the-load-balancer-shared-secret" + ctx := context.Background() + + // Two replicas of one logical hub, distinct hub IDs (sha256(hostname)). + replicaA := &Server{hubID: "ca39430276ee", config: ServerConfig{SharedSigningSecret: sharedSecret}} + replicaB := &Server{hubID: "9662ebe99da4", config: ServerConfig{SharedSigningSecret: sharedSecret}} + + // ensureSigningKey returns before touching the store/secret backend when a + // shared secret is set, so a nil store is fine here. + keyA, err := replicaA.ensureSigningKey(ctx, SecretKeyUserSigningKey, nil) + if err != nil { + t.Fatalf("replicaA ensureSigningKey: %v", err) + } + keyB, err := replicaB.ensureSigningKey(ctx, SecretKeyUserSigningKey, nil) + if err != nil { + t.Fatalf("replicaB ensureSigningKey: %v", err) + } + if !bytes.Equal(keyA, keyB) { + t.Fatal("replicas sharing a signing secret must derive identical keys despite different hub IDs") + } + + // Mint a user token on replica A; it must validate on replica B. + svcA, err := NewUserTokenService(UserTokenConfig{SigningKey: keyA}) + if err != nil { + t.Fatalf("NewUserTokenService A: %v", err) + } + svcB, err := NewUserTokenService(UserTokenConfig{SigningKey: keyB}) + if err != nil { + t.Fatalf("NewUserTokenService B: %v", err) + } + + accessToken, _, _, err := svcA.GenerateTokenPair("uid-1", "user@example.com", "User", "admin", ClientTypeWeb) + if err != nil { + t.Fatalf("GenerateTokenPair: %v", err) + } + if _, err := svcB.ValidateUserToken(accessToken); err != nil { + t.Fatalf("token minted on replica A must validate on replica B, got: %v", err) + } + + // Negative: a replica with a different shared secret cannot validate it. + replicaC := &Server{hubID: "ca39430276ee", config: ServerConfig{SharedSigningSecret: "a-totally-different-secret"}} + keyC, err := replicaC.ensureSigningKey(ctx, SecretKeyUserSigningKey, nil) + if err != nil { + t.Fatalf("replicaC ensureSigningKey: %v", err) + } + svcC, err := NewUserTokenService(UserTokenConfig{SigningKey: keyC}) + if err != nil { + t.Fatalf("NewUserTokenService C: %v", err) + } + if _, err := svcC.ValidateUserToken(accessToken); err == nil { + t.Fatal("token must NOT validate under a different shared secret") + } +} + +// TestEnsureSigningKey_PreConfiguredKeyTakesPrecedence verifies that an +// explicitly supplied key still wins over shared-secret derivation, preserving +// existing behavior for callers that pass a key directly. +func TestEnsureSigningKey_PreConfiguredKeyTakesPrecedence(t *testing.T) { + explicit := bytes.Repeat([]byte{0xAB}, 32) + s := &Server{hubID: "host1", config: ServerConfig{SharedSigningSecret: "ignored-because-explicit-key-given"}} + + got, err := s.ensureSigningKey(context.Background(), SecretKeyUserSigningKey, explicit) + if err != nil { + t.Fatalf("ensureSigningKey: %v", err) + } + if !bytes.Equal(got, explicit) { + t.Fatal("a pre-configured key must take precedence over shared-secret derivation") + } +} diff --git a/pkg/hub/sweep.go b/pkg/hub/sweep.go new file mode 100644 index 000000000..dbf0e12bf --- /dev/null +++ b/pkg/hub/sweep.go @@ -0,0 +1,45 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "time" +) + +const stuckMessageThreshold = 5 * time.Minute + +// brokerMessageSweepHandler returns a handler that counts messages still in +// dispatch_state='pending' beyond the stuck threshold and logs/emits metrics. +// Registered as a RecurringSingleton guarded by LockBrokerMessageSweep (B5-2). +func (s *Server) brokerMessageSweepHandler() func(ctx context.Context) { + return func(ctx context.Context) { + cutoff := time.Now().Add(-stuckMessageThreshold) + count, err := s.store.CountStuckPendingMessages(ctx, cutoff) + if err != nil { + s.agentLifecycleLog.Error("sweep: count stuck pending messages failed", "error", err) + return + } + + if count > 0 { + s.agentLifecycleLog.Warn("sweep: stuck pending messages detected", + "count", count, "threshold", stuckMessageThreshold.String()) + } + + if rec := s.dispatchMetrics; rec != nil { + rec.ObserveMessageStuck(ctx, int64(count)) + } + } +} diff --git a/pkg/hub/web.go b/pkg/hub/web.go index e867486e3..a7fdc896c 100644 --- a/pkg/hub/web.go +++ b/pkg/hub/web.go @@ -17,6 +17,7 @@ package hub import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -143,7 +144,7 @@ type WebServer struct { assets fs.FS // embedded or nil assetsDisk string // filesystem override path, or "" shellTmpl *template.Template - sessionStore *sessions.FilesystemStore + sessionStore *sessions.CookieStore oauthService *OAuthService store store.Store userTokenSvc *UserTokenService @@ -420,28 +421,44 @@ func NewWebServer(cfg WebServerConfig) *WebServer { slog.Warn("No session secret configured, using random key (sessions will not persist across restarts)") } - // Use a filesystem-backed session store so that only a small session ID - // is sent as a cookie. This avoids the 4 KB cookie size limit that can - // be exceeded when JWT tokens are stored in the session. - sessionDir := filepath.Join(os.TempDir(), "scion-sessions") - if err := os.MkdirAll(sessionDir, 0700); err != nil { - slog.Error("Failed to create session directory", "dir", sessionDir, "error", err) - } - fsStore := sessions.NewFilesystemStore(sessionDir, []byte(sessionKey)) - // Remove the default 4096-byte securecookie encoding limit. The - // FilesystemStore writes session data to disk (not cookies), so the - // browser cookie-size cap is irrelevant. JWT tokens stored in the - // session regularly exceed 4096 bytes after gob+base64 encoding, - // which causes Save() to fail and tokens to be silently dropped. - fsStore.MaxLength(0) - fsStore.Options = &sessions.Options{ + // Use an encrypted, signed cookie session store so that NO session state + // lives on a single replica's local filesystem. This is required for + // horizontal scaling: behind a load balancer the OAuth login and callback + // (and every subsequent API request) can land on different replicas. A + // cookie-backed store keeps the whole session — the OAuth CSRF state token, + // the post-login return path, the user identity, and the Hub access/refresh + // tokens — in the client's signed+encrypted cookie, so any replica sharing + // SESSION_SECRET can read it. + // + // The previous FilesystemStore kept this state on one replica's disk, which + // caused intermittent "state_mismatch" login failures (and silently dropped + // post-login sessions) whenever the LB routed a follow-up request to a + // different replica. The whole session encodes to roughly 2.6 KB today — + // well within the browser's ~4 KB per-cookie cap — so the historical + // "JWT tokens exceed 4096 bytes" concern that motivated the disk store no + // longer applies to the current compact HS256 tokens. + // + // Keys are derived deterministically from the shared SESSION_SECRET so all + // replicas agree: a 32-byte HMAC authentication key and a 32-byte AES-256 + // encryption key, with domain separation so the two keys differ. + cookieStore := sessions.NewCookieStore( + deriveSessionKey(sessionKey, "scion-session-hash"), + deriveSessionKey(sessionKey, "scion-session-block"), + ) + cookieStore.Options = &sessions.Options{ Path: "/", MaxAge: 86400, // 24 hours HttpOnly: true, Secure: strings.HasPrefix(cfg.BaseURL, "https://"), SameSite: http.SameSiteLaxMode, } - ws.sessionStore = fsStore + // Keep securecookie's timestamp window in sync with the cookie MaxAge. We + // intentionally leave the default 4096-byte securecookie length limit in + // force (unlike the disk store, which disabled it): if a session ever grew + // past the browser cookie cap, Save() would return an error we can log + // rather than silently emitting an oversized cookie the browser drops. + cookieStore.MaxAge(cookieStore.Options.MaxAge) + ws.sessionStore = cookieStore // Resolve asset source if cfg.AssetsDir != "" { @@ -471,6 +488,17 @@ func NewWebServer(cfg WebServerConfig) *WebServer { return ws } +// deriveSessionKey deterministically derives a 32-byte key from the shared +// session secret and a label. The label provides domain separation so the +// HMAC authentication key and the AES encryption key differ even though both +// originate from the same SESSION_SECRET. Every replica configured with the +// same secret derives identical keys, which is what lets a session cookie +// minted by one replica be validated and decrypted by another. +func deriveSessionKey(secret, label string) []byte { + sum := sha256.Sum256([]byte(label + ":" + secret)) + return sum[:] +} + // SetMaintenanceState sets the shared runtime maintenance state. func (ws *WebServer) SetMaintenanceState(ms *MaintenanceState) { ws.maintenance = ms diff --git a/pkg/observability/dispatchmetrics/dispatchmetrics.go b/pkg/observability/dispatchmetrics/dispatchmetrics.go new file mode 100644 index 000000000..d2ced25d5 --- /dev/null +++ b/pkg/observability/dispatchmetrics/dispatchmetrics.go @@ -0,0 +1,206 @@ +/* +Copyright 2026 The Scion Authors. +*/ + +// Package dispatchmetrics provides Cloud Monitoring scaffolding for the +// multi-node broker-dispatch observability requirement (B5-2). +// +// It defines OpenTelemetry metric instruments for the dispatch pipeline: +// published/claimed/done/failed counters, intent-to-done latency histogram, +// message dispatched/stuck counters, command-bus reconnects, and reconcile +// drain duration. The package mirrors the dbmetrics pattern: a Recorder +// interface backed by an OTel MeterProvider (or no-op when none is supplied). +package dispatchmetrics + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +const instrumentationName = "github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics" + +const ( + MetricDispatchPublished = "scion.dispatch.published" + MetricDispatchClaimed = "scion.dispatch.claimed" + MetricDispatchDone = "scion.dispatch.done" + MetricDispatchFailed = "scion.dispatch.failed" + MetricDispatchLatency = "scion.dispatch.intent_to_done.duration" + MetricMessageDispatched = "scion.dispatch.message.dispatched" + MetricMessageStuck = "scion.dispatch.message.stuck" + MetricCmdBusReconnects = "scion.dispatch.cmdbus.reconnects" + MetricReconcileDrainDur = "scion.dispatch.reconcile.drain.duration" +) + +// Recorder is the interface callers use to record broker-dispatch metrics. +// All methods are safe to call concurrently and are cheap no-ops when metrics +// are disabled. +type Recorder interface { + IncPublished(ctx context.Context, n int64, attrs ...attribute.KeyValue) + IncClaimed(ctx context.Context, n int64, attrs ...attribute.KeyValue) + IncDone(ctx context.Context, n int64, attrs ...attribute.KeyValue) + IncFailed(ctx context.Context, n int64, attrs ...attribute.KeyValue) + + RecordDispatchLatency(ctx context.Context, ms float64, attrs ...attribute.KeyValue) + + IncMessageDispatched(ctx context.Context, n int64, attrs ...attribute.KeyValue) + ObserveMessageStuck(ctx context.Context, n int64, attrs ...attribute.KeyValue) + + IncCmdBusReconnects(ctx context.Context, n int64, attrs ...attribute.KeyValue) + + RecordReconcileDrainDuration(ctx context.Context, ms float64, attrs ...attribute.KeyValue) + + Enabled() bool +} + +type recorder struct { + enabled bool + + published metric.Int64Counter + claimed metric.Int64Counter + done metric.Int64Counter + failed metric.Int64Counter + latency metric.Float64Histogram + + msgDispatched metric.Int64Counter + msgStuck metric.Int64Gauge + + cmdBusReconn metric.Int64Counter + drainDur metric.Float64Histogram +} + +var _ Recorder = (*recorder)(nil) + +// New creates a Recorder backed by the supplied MeterProvider. If mp is nil, +// a no-op MeterProvider is used and every method becomes a cheap no-op. +func New(mp metric.MeterProvider) (Recorder, error) { + enabled := mp != nil + if mp == nil { + mp = noop.NewMeterProvider() + } + + meter := mp.Meter(instrumentationName) + r := &recorder{enabled: enabled} + var err error + + if r.published, err = meter.Int64Counter( + MetricDispatchPublished, + metric.WithUnit("{dispatch}"), + metric.WithDescription("Number of broker dispatch intents published"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricDispatchPublished, err) + } + + if r.claimed, err = meter.Int64Counter( + MetricDispatchClaimed, + metric.WithUnit("{dispatch}"), + metric.WithDescription("Number of broker dispatch intents claimed"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricDispatchClaimed, err) + } + + if r.done, err = meter.Int64Counter( + MetricDispatchDone, + metric.WithUnit("{dispatch}"), + metric.WithDescription("Number of broker dispatch intents completed"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricDispatchDone, err) + } + + if r.failed, err = meter.Int64Counter( + MetricDispatchFailed, + metric.WithUnit("{dispatch}"), + metric.WithDescription("Number of broker dispatch intents failed"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricDispatchFailed, err) + } + + if r.latency, err = meter.Float64Histogram( + MetricDispatchLatency, + metric.WithUnit("ms"), + metric.WithDescription("Latency from dispatch intent creation to completion"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricDispatchLatency, err) + } + + if r.msgDispatched, err = meter.Int64Counter( + MetricMessageDispatched, + metric.WithUnit("{message}"), + metric.WithDescription("Number of messages dispatched to remote broker"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricMessageDispatched, err) + } + + if r.msgStuck, err = meter.Int64Gauge( + MetricMessageStuck, + metric.WithUnit("{message}"), + metric.WithDescription("Number of messages stuck in pending state beyond threshold"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricMessageStuck, err) + } + + if r.cmdBusReconn, err = meter.Int64Counter( + MetricCmdBusReconnects, + metric.WithUnit("{reconnect}"), + metric.WithDescription("Number of command bus listener reconnects"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricCmdBusReconnects, err) + } + + if r.drainDur, err = meter.Float64Histogram( + MetricReconcileDrainDur, + metric.WithUnit("ms"), + metric.WithDescription("Duration of a reconcile broker drain cycle"), + ); err != nil { + return nil, fmt.Errorf("registering %s: %w", MetricReconcileDrainDur, err) + } + + return r, nil +} + +// NewDisabled returns a Recorder whose calls are all no-ops. +func NewDisabled() Recorder { + r, _ := New(nil) + return r +} + +func (r *recorder) Enabled() bool { return r.enabled } + +func (r *recorder) IncPublished(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.published.Add(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) IncClaimed(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.claimed.Add(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) IncDone(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.done.Add(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) IncFailed(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.failed.Add(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) RecordDispatchLatency(ctx context.Context, ms float64, attrs ...attribute.KeyValue) { + r.latency.Record(ctx, ms, metric.WithAttributes(attrs...)) +} + +func (r *recorder) IncMessageDispatched(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.msgDispatched.Add(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) ObserveMessageStuck(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.msgStuck.Record(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) IncCmdBusReconnects(ctx context.Context, n int64, attrs ...attribute.KeyValue) { + r.cmdBusReconn.Add(ctx, n, metric.WithAttributes(attrs...)) +} + +func (r *recorder) RecordReconcileDrainDuration(ctx context.Context, ms float64, attrs ...attribute.KeyValue) { + r.drainDur.Record(ctx, ms, metric.WithAttributes(attrs...)) +} diff --git a/pkg/observability/dispatchmetrics/dispatchmetrics_test.go b/pkg/observability/dispatchmetrics/dispatchmetrics_test.go new file mode 100644 index 000000000..958338103 --- /dev/null +++ b/pkg/observability/dispatchmetrics/dispatchmetrics_test.go @@ -0,0 +1,116 @@ +/* +Copyright 2026 The Scion Authors. +*/ + +package dispatchmetrics + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestNewDisabledRegisters(t *testing.T) { + r, err := New(nil) + if err != nil { + t.Fatalf("New(nil) returned error: %v", err) + } + if r == nil { + t.Fatal("New(nil) returned nil Recorder") + } + if r.Enabled() { + t.Error("expected Recorder backed by no-op provider to report Enabled()==false") + } +} + +func TestNewDisabledRecordsAreNoops(t *testing.T) { + r := NewDisabled() + ctx := context.Background() + attrs := []attribute.KeyValue{attribute.String("op", "start")} + + r.IncPublished(ctx, 1, attrs...) + r.IncClaimed(ctx, 1, attrs...) + r.IncDone(ctx, 1, attrs...) + r.IncFailed(ctx, 1, attrs...) + r.RecordDispatchLatency(ctx, 42.5, attrs...) + r.IncMessageDispatched(ctx, 1, attrs...) + r.ObserveMessageStuck(ctx, 3, attrs...) + r.IncCmdBusReconnects(ctx, 1) + r.RecordReconcileDrainDuration(ctx, 10.0) +} + +func TestNewWithRealProviderRegisters(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + r, err := New(mp) + if err != nil { + t.Fatalf("New(mp) returned error: %v", err) + } + if !r.Enabled() { + t.Error("expected Recorder backed by real provider to report Enabled()==true") + } +} + +func TestRecordedMetricsAreExported(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = mp.Shutdown(context.Background()) }) + + r, err := New(mp) + if err != nil { + t.Fatalf("New(mp) returned error: %v", err) + } + + ctx := context.Background() + attrs := []attribute.KeyValue{attribute.String("op", "start")} + + r.IncPublished(ctx, 2, attrs...) + r.IncClaimed(ctx, 1, attrs...) + r.IncDone(ctx, 1, attrs...) + r.IncFailed(ctx, 1, attrs...) + r.RecordDispatchLatency(ctx, 42.5, attrs...) + r.IncMessageDispatched(ctx, 1, attrs...) + r.ObserveMessageStuck(ctx, 3, attrs...) + r.IncCmdBusReconnects(ctx, 1) + r.RecordReconcileDrainDuration(ctx, 10.0) + + var rm metricdata.ResourceMetrics + if err := reader.Collect(ctx, &rm); err != nil { + t.Fatalf("collecting metrics: %v", err) + } + + got := collectedNames(&rm) + + want := []string{ + MetricDispatchPublished, + MetricDispatchClaimed, + MetricDispatchDone, + MetricDispatchFailed, + MetricDispatchLatency, + MetricMessageDispatched, + MetricMessageStuck, + MetricCmdBusReconnects, + MetricReconcileDrainDur, + } + + for _, name := range want { + if !got[name] { + t.Errorf("expected metric %q to be exported, but it was not present", name) + } + } +} + +func collectedNames(rm *metricdata.ResourceMetrics) map[string]bool { + names := make(map[string]bool) + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + names[m.Name] = true + } + } + return names +} diff --git a/pkg/store/concurrency.go b/pkg/store/concurrency.go index 454afe632..bb7a3ec7f 100644 --- a/pkg/store/concurrency.go +++ b/pkg/store/concurrency.go @@ -50,6 +50,10 @@ const ( // LockGitHubAppHealthCheck guards the periodic GitHub App installation // health check. LockGitHubAppHealthCheck AdvisoryLockKey = 0x5C100005 + // LockBrokerAffinityReap guards the stale broker-affinity + stuck dispatch reaper. + LockBrokerAffinityReap AdvisoryLockKey = 0x5C100006 + // LockBrokerMessageSweep guards the periodic stuck-pending-message sweep (B5-2). + LockBrokerMessageSweep AdvisoryLockKey = 0x5C100007 ) // AdvisoryLocker is implemented by backends that can take a cluster-wide diff --git a/pkg/store/entadapter/broker_affinity_test.go b/pkg/store/entadapter/broker_affinity_test.go new file mode 100644 index 000000000..6044ee395 --- /dev/null +++ b/pkg/store/entadapter/broker_affinity_test.go @@ -0,0 +1,135 @@ +package entadapter + +import ( + "context" + "testing" + + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newOfflineBroker returns an unclaimed broker (offline, no affinity) so tests +// can observe the claim transition. +func newOfflineBroker(t *testing.T, ps *ProjectStore) *store.RuntimeBroker { + t.Helper() + b := newBroker() + b.Status = store.BrokerStatusOffline + require.NoError(t, ps.CreateRuntimeBroker(context.Background(), b)) + return b +} + +func TestClaimRuntimeBrokerConnection_SetsAffinityAndOnline(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + b := newOfflineBroker(t, ps) + + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1")) + + got, err := ps.GetRuntimeBroker(ctx, b.ID) + require.NoError(t, err) + require.NotNil(t, got.ConnectedHubID) + assert.Equal(t, "hub-1", *got.ConnectedHubID) + require.NotNil(t, got.ConnectedSessionID) + assert.Equal(t, "sess-1", *got.ConnectedSessionID) + require.NotNil(t, got.ConnectedAt) + assert.False(t, got.ConnectedAt.IsZero()) + // Claim bumps status->online + refreshes heartbeat in the same write. + assert.Equal(t, store.BrokerStatusOnline, got.Status) + assert.False(t, got.LastHeartbeat.IsZero()) +} + +func TestClaimRuntimeBrokerConnection_NewestWins(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + b := newOfflineBroker(t, ps) + + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1")) + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hub-2", "sess-2")) + + got, err := ps.GetRuntimeBroker(ctx, b.ID) + require.NoError(t, err) + require.NotNil(t, got.ConnectedHubID) + assert.Equal(t, "hub-2", *got.ConnectedHubID) + require.NotNil(t, got.ConnectedSessionID) + assert.Equal(t, "sess-2", *got.ConnectedSessionID) +} + +func TestReleaseRuntimeBrokerConnection_ClearsWhenOwner(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + b := newOfflineBroker(t, ps) + + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1")) + + cleared, err := ps.ReleaseRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1") + require.NoError(t, err) + assert.True(t, cleared) + + got, err := ps.GetRuntimeBroker(ctx, b.ID) + require.NoError(t, err) + assert.Nil(t, got.ConnectedHubID) + assert.Nil(t, got.ConnectedSessionID) + assert.Nil(t, got.ConnectedAt) + // Release must NOT change status — the caller decides offline based on cleared. + assert.Equal(t, store.BrokerStatusOnline, got.Status) +} + +func TestReleaseRuntimeBrokerConnection_NoOpWhenAffinityMoved(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + b := newOfflineBroker(t, ps) + + // Affinity currently owned by (hub-2, sess-2). + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hub-2", "sess-2")) + + // A stale owner (hub-1, sess-1) tries to release: must be a no-op. + cleared, err := ps.ReleaseRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1") + require.NoError(t, err) + assert.False(t, cleared) + + got, err := ps.GetRuntimeBroker(ctx, b.ID) + require.NoError(t, err) + require.NotNil(t, got.ConnectedHubID) + assert.Equal(t, "hub-2", *got.ConnectedHubID) + require.NotNil(t, got.ConnectedSessionID) + assert.Equal(t, "sess-2", *got.ConnectedSessionID) +} + +func TestReleaseRuntimeBrokerConnection_NoOpWhenUnclaimed(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + b := newOfflineBroker(t, ps) + + cleared, err := ps.ReleaseRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1") + require.NoError(t, err) + assert.False(t, cleared) +} + +// TestBrokerAffinity_FlapAtoB reproduces the design §9.4 disconnect race: a +// broker flaps from hub A to hub B; A's delayed onDisconnect must NOT clobber +// B's live ownership. +func TestBrokerAffinity_FlapAtoB(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + b := newOfflineBroker(t, ps) + + // t0: socket on hub A (session s1). + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hubA", "s1")) + // t2: broker re-dials, lands on hub B (session s2); B claims (newest wins). + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hubB", "s2")) + + // t3: hub A's old socket finally errors -> delayed release for (hubA, s1). + cleared, err := ps.ReleaseRuntimeBrokerConnection(ctx, b.ID, "hubA", "s1") + require.NoError(t, err) + assert.False(t, cleared, "stale owner release must be a no-op") + + // Affinity still names B, status still online (no false offline). + got, err := ps.GetRuntimeBroker(ctx, b.ID) + require.NoError(t, err) + require.NotNil(t, got.ConnectedHubID) + assert.Equal(t, "hubB", *got.ConnectedHubID) + require.NotNil(t, got.ConnectedSessionID) + assert.Equal(t, "s2", *got.ConnectedSessionID) + assert.Equal(t, store.BrokerStatusOnline, got.Status) +} diff --git a/pkg/store/entadapter/broker_dispatch_store_test.go b/pkg/store/entadapter/broker_dispatch_store_test.go new file mode 100644 index 000000000..dc2ef53c1 --- /dev/null +++ b/pkg/store/entadapter/broker_dispatch_store_test.go @@ -0,0 +1,252 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !no_sqlite + +package entadapter + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/ent" + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/GoogleCloudPlatform/scion/pkg/store/enttest" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newDispatch(brokerID, op string) *store.BrokerDispatch { + return &store.BrokerDispatch{ + ID: uuid.NewString(), + BrokerID: brokerID, + Op: op, + } +} + +func TestBrokerDispatch_InsertListPending_OnlyPending(t *testing.T) { + client := enttest.NewClient(t) + s := NewBrokerDispatchStore(client) + ctx := context.Background() + brokerA := uuid.NewString() + brokerB := uuid.NewString() + + d1 := newDispatch(brokerA, "start") + d2 := newDispatch(brokerA, "stop") + dOther := newDispatch(brokerB, "start") + require.NoError(t, s.InsertBrokerDispatch(ctx, d1)) + require.NoError(t, s.InsertBrokerDispatch(ctx, d2)) + require.NoError(t, s.InsertBrokerDispatch(ctx, dOther)) + assert.Equal(t, store.DispatchStatePending, d1.State) + + // Claim d1 -> in_progress; it should drop out of the pending drain. + claimed, err := s.ClaimBrokerDispatch(ctx, d1.ID, "hub-1") + require.NoError(t, err) + assert.True(t, claimed) + + pending, err := s.ListPendingDispatch(ctx, brokerA) + require.NoError(t, err) + require.Len(t, pending, 1) + assert.Equal(t, d2.ID, pending[0].ID, "drain returns only pending rows for the broker") +} + +func TestBrokerDispatch_ClaimOnceThenFalse(t *testing.T) { + client := enttest.NewClient(t) + s := NewBrokerDispatchStore(client) + ctx := context.Background() + + d := newDispatch(uuid.NewString(), "start") + require.NoError(t, s.InsertBrokerDispatch(ctx, d)) + + claimed, err := s.ClaimBrokerDispatch(ctx, d.ID, "hub-1") + require.NoError(t, err) + assert.True(t, claimed) + + again, err := s.ClaimBrokerDispatch(ctx, d.ID, "hub-2") + require.NoError(t, err) + assert.False(t, again, "a second claim of a non-pending row must lose") +} + +func TestBrokerDispatch_ConcurrentClaimSingleWinner(t *testing.T) { + client := enttest.NewClient(t) + s := NewBrokerDispatchStore(client) + ctx := context.Background() + + d := newDispatch(uuid.NewString(), "start") + require.NoError(t, s.InsertBrokerDispatch(ctx, d)) + + const racers = 8 + var wg sync.WaitGroup + var mu sync.Mutex + wins := 0 + wg.Add(racers) + for i := 0; i < racers; i++ { + go func() { + defer wg.Done() + won, err := s.ClaimBrokerDispatch(ctx, d.ID, "hub") + if err == nil && won { + mu.Lock() + wins++ + mu.Unlock() + } + }() + } + wg.Wait() + assert.Equal(t, 1, wins, "exactly one concurrent claim must win (exactly-once execution)") +} + +func TestBrokerDispatch_CompleteAndFail(t *testing.T) { + client := enttest.NewClient(t) + s := NewBrokerDispatchStore(client) + ctx := context.Background() + + d := newDispatch(uuid.NewString(), "check_prompt") + require.NoError(t, s.InsertBrokerDispatch(ctx, d)) + _, err := s.ClaimBrokerDispatch(ctx, d.ID, "hub-1") + require.NoError(t, err) + + require.NoError(t, s.CompleteBrokerDispatch(ctx, d.ID, `{"ok":true}`)) + got, err := client.BrokerDispatch.Get(ctx, uuid.MustParse(d.ID)) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateDone, got.State) + assert.Equal(t, `{"ok":true}`, got.Result) + + d2 := newDispatch(uuid.NewString(), "start") + require.NoError(t, s.InsertBrokerDispatch(ctx, d2)) + _, err = s.ClaimBrokerDispatch(ctx, d2.ID, "hub-1") + require.NoError(t, err) + require.NoError(t, s.FailBrokerDispatch(ctx, d2.ID, "boom")) + got2, err := client.BrokerDispatch.Get(ctx, uuid.MustParse(d2.ID)) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateFailed, got2.State) + assert.Equal(t, "boom", got2.Error) + assert.Equal(t, 1, got2.Attempts, "failure bumps the attempt counter") +} + +func TestMarkMessageDispatched_Dedupe(t *testing.T) { + client := enttest.NewClient(t) + cs := NewCompositeStore(client) + ctx := context.Background() + + msg := &store.Message{ + ID: uuid.NewString(), + ProjectID: uuid.NewString(), + Sender: "user:alice", + Recipient: "agent:bob", + Msg: "hi", + } + require.NoError(t, cs.CreateMessage(ctx, msg)) + assert.Equal(t, store.MessageDispatchPending, msg.DispatchState) + + ok, err := cs.MarkMessageDispatched(ctx, msg.ID) + require.NoError(t, err) + assert.True(t, ok) + + again, err := cs.MarkMessageDispatched(ctx, msg.ID) + require.NoError(t, err) + assert.False(t, again, "second dispatch CAS must dedupe") + + got, err := cs.GetMessage(ctx, msg.ID) + require.NoError(t, err) + assert.Equal(t, store.MessageDispatchDispatched, got.DispatchState) + require.NotNil(t, got.DispatchedAt) +} + +func TestListPendingMessages_ByBrokerAgent(t *testing.T) { + client := enttest.NewClient(t) + cs := NewCompositeStore(client) + ctx := context.Background() + brokerA := uuid.NewString() + brokerB := uuid.NewString() + + // A project and two agents, one per broker. + proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString()} + require.NoError(t, cs.CreateProject(ctx, proj)) + projUID := uuid.MustParse(proj.ID) + agentA := mustCreateAgent(t, client, projUID, brokerA) + agentB := mustCreateAgent(t, client, projUID, brokerB) + + // Pending message to agentA (on brokerA), and one to agentB (on brokerB). + msgA := &store.Message{ID: uuid.NewString(), ProjectID: proj.ID, Sender: "user:x", Recipient: "agent:a", Msg: "for A", AgentID: agentA} + msgB := &store.Message{ID: uuid.NewString(), ProjectID: proj.ID, Sender: "user:x", Recipient: "agent:b", Msg: "for B", AgentID: agentB} + require.NoError(t, cs.CreateMessage(ctx, msgA)) + require.NoError(t, cs.CreateMessage(ctx, msgB)) + + pending, err := cs.ListPendingMessages(ctx, brokerA) + require.NoError(t, err) + require.Len(t, pending, 1) + assert.Equal(t, msgA.ID, pending[0].ID, "only the message for an agent on brokerA") + + // Once dispatched, it drops out of the pending set. + _, err = cs.MarkMessageDispatched(ctx, msgA.ID) + require.NoError(t, err) + pending, err = cs.ListPendingMessages(ctx, brokerA) + require.NoError(t, err) + assert.Empty(t, pending) +} + +func TestCountStuckPendingMessages(t *testing.T) { + client := enttest.NewClient(t) + cs := NewCompositeStore(client) + ctx := context.Background() + + proj := &store.Project{ + ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], + Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString(), + } + require.NoError(t, cs.CreateProject(ctx, proj)) + + // A message created 10 minutes ago (stuck). + oldMsg := &store.Message{ + ID: uuid.NewString(), ProjectID: proj.ID, + Sender: "user:x", Recipient: "agent:a", Msg: "old", + CreatedAt: time.Now().Add(-10 * time.Minute), + } + require.NoError(t, cs.CreateMessage(ctx, oldMsg)) + assert.Equal(t, store.MessageDispatchPending, oldMsg.DispatchState) + + // A message created just now (not stuck). + newMsg := &store.Message{ + ID: uuid.NewString(), ProjectID: proj.ID, + Sender: "user:x", Recipient: "agent:b", Msg: "new", + } + require.NoError(t, cs.CreateMessage(ctx, newMsg)) + + cutoff := time.Now().Add(-5 * time.Minute) + count, err := cs.CountStuckPendingMessages(ctx, cutoff) + require.NoError(t, err) + assert.Equal(t, 1, count, "only the old message is stuck") + + // Dispatch the old message — it should no longer be stuck. + _, err = cs.MarkMessageDispatched(ctx, oldMsg.ID) + require.NoError(t, err) + count, err = cs.CountStuckPendingMessages(ctx, cutoff) + require.NoError(t, err) + assert.Equal(t, 0, count, "dispatched message is not stuck") +} + +func mustCreateAgent(t *testing.T, client *ent.Client, projectID uuid.UUID, brokerID string) string { + t.Helper() + a, err := client.Agent.Create(). + SetSlug("agent-" + uuid.NewString()[:8]). + SetName("agent"). + SetProjectID(projectID). + SetRuntimeBrokerID(brokerID). + Save(context.Background()) + require.NoError(t, err) + return a.ID.String() +} diff --git a/pkg/store/entadapter/brokerdispatch_store.go b/pkg/store/entadapter/brokerdispatch_store.go new file mode 100644 index 000000000..4747f066e --- /dev/null +++ b/pkg/store/entadapter/brokerdispatch_store.go @@ -0,0 +1,337 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package entadapter + +import ( + "context" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/ent" + "github.com/GoogleCloudPlatform/scion/pkg/ent/agent" + "github.com/GoogleCloudPlatform/scion/pkg/ent/brokerdispatch" + "github.com/GoogleCloudPlatform/scion/pkg/ent/message" + "github.com/GoogleCloudPlatform/scion/pkg/store" +) + +// BrokerDispatchStore is the Ent-backed store for the broker_dispatch durable +// intent table plus the message dispatch-state CAS helpers. Exactly-once +// execution across nodes is enforced by conditional (compare-and-swap) updates +// on the state column — no SELECT ... FOR UPDATE, correct on SQLite + Postgres. +type BrokerDispatchStore struct { + client *ent.Client +} + +// NewBrokerDispatchStore creates a new Ent-backed BrokerDispatchStore. +func NewBrokerDispatchStore(client *ent.Client) *BrokerDispatchStore { + return &BrokerDispatchStore{client: client} +} + +func entBrokerDispatchToStore(e *ent.BrokerDispatch) store.BrokerDispatch { + d := store.BrokerDispatch{ + ID: e.ID.String(), + BrokerID: e.BrokerID.String(), + AgentSlug: e.AgentSlug, + Op: e.Op, + Args: e.Args, + State: e.State, + Result: e.Result, + ClaimedBy: e.ClaimedBy, + Attempts: e.Attempts, + Error: e.Error, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } + if e.AgentID != nil { + d.AgentID = e.AgentID.String() + } + if e.ProjectID != nil { + d.ProjectID = e.ProjectID.String() + } + if e.DeadlineAt != nil { + d.DeadlineAt = e.DeadlineAt + } + return d +} + +// InsertBrokerDispatch persists a new durable dispatch intent. State defaults to +// pending. The generated id and timestamps are written back into d. +func (s *BrokerDispatchStore) InsertBrokerDispatch(ctx context.Context, d *store.BrokerDispatch) error { + if d.BrokerID == "" || d.Op == "" { + return store.ErrInvalidInput + } + brokerUID, err := parseUUID(d.BrokerID) + if err != nil { + return err + } + + create := s.client.BrokerDispatch.Create(). + SetBrokerID(brokerUID). + SetOp(d.Op) + + if d.ID != "" { + uid, err := parseUUID(d.ID) + if err != nil { + return err + } + create.SetID(uid) + } + if d.AgentID != "" { + agentUID, err := parseUUID(d.AgentID) + if err != nil { + return err + } + create.SetAgentID(agentUID) + } + if d.AgentSlug != "" { + create.SetAgentSlug(d.AgentSlug) + } + if d.ProjectID != "" { + projUID, err := parseUUID(d.ProjectID) + if err != nil { + return err + } + create.SetProjectID(projUID) + } + if d.Args != "" { + create.SetArgs(d.Args) + } + if d.State != "" { + create.SetState(d.State) + } + if d.DeadlineAt != nil { + create.SetDeadlineAt(*d.DeadlineAt) + } + + created, err := create.Save(ctx) + if err != nil { + return mapError(err) + } + d.ID = created.ID.String() + d.State = created.State + d.CreatedAt = created.CreatedAt + d.UpdatedAt = created.UpdatedAt + return nil +} + +// ClaimBrokerDispatch atomically transitions a dispatch from pending to +// in_progress, recording the claiming hub instance. It is a CAS keyed on +// state='pending', so exactly one node wins for a given row (design §7). Returns +// claimed=false if the row was not pending (already claimed/done/failed/absent). +func (s *BrokerDispatchStore) ClaimBrokerDispatch(ctx context.Context, id, hubInstanceID string) (bool, error) { + uid, err := parseUUID(id) + if err != nil { + return false, err + } + affected, err := s.client.BrokerDispatch.Update(). + Where(brokerdispatch.IDEQ(uid), brokerdispatch.StateEQ(store.DispatchStatePending)). + SetState(store.DispatchStateInProgress). + SetClaimedBy(hubInstanceID). + SetUpdatedAt(time.Now()). + Save(ctx) + if err != nil { + return false, mapError(err) + } + return affected == 1, nil +} + +// CompleteBrokerDispatch marks a dispatch done and records its result JSON. +// The update is guarded by state=in_progress (CAS) so a done or failed +// dispatch cannot be flipped by a stale or duplicate completion call. +func (s *BrokerDispatchStore) CompleteBrokerDispatch(ctx context.Context, id, result string) error { + uid, err := parseUUID(id) + if err != nil { + return err + } + upd := s.client.BrokerDispatch.Update(). + Where(brokerdispatch.IDEQ(uid), brokerdispatch.StateEQ(store.DispatchStateInProgress)). + SetState(store.DispatchStateDone). + SetUpdatedAt(time.Now()) + if result != "" { + upd.SetResult(result) + } + affected, err := upd.Save(ctx) + if err != nil { + return mapError(err) + } + if affected == 0 { + return store.ErrNotFound + } + return nil +} + +// FailBrokerDispatch marks a dispatch failed, records the error, and bumps the +// attempt counter (so a reaper/retry can bound re-drives). The update is +// guarded by state=in_progress (CAS) so a completed or already-failed dispatch +// cannot be overwritten by a stale failure call. +func (s *BrokerDispatchStore) FailBrokerDispatch(ctx context.Context, id, errMsg string) error { + uid, err := parseUUID(id) + if err != nil { + return err + } + affected, err := s.client.BrokerDispatch.Update(). + Where(brokerdispatch.IDEQ(uid), brokerdispatch.StateEQ(store.DispatchStateInProgress)). + SetState(store.DispatchStateFailed). + SetError(errMsg). + AddAttempts(1). + SetUpdatedAt(time.Now()). + Save(ctx) + if err != nil { + return mapError(err) + } + if affected == 0 { + return store.ErrNotFound + } + return nil +} + +// GetBrokerDispatch returns a single dispatch row by ID. Used by the originator +// to read the result/state after the owner completes the dispatch. +func (s *BrokerDispatchStore) GetBrokerDispatch(ctx context.Context, id string) (*store.BrokerDispatch, error) { + uid, err := parseUUID(id) + if err != nil { + return nil, err + } + row, err := s.client.BrokerDispatch.Get(ctx, uid) + if err != nil { + return nil, mapError(err) + } + d := entBrokerDispatchToStore(row) + return &d, nil +} + +// ListPendingDispatch returns the pending dispatch intents for a broker, oldest +// first — the reconcile-drain query (design §5.3). +func (s *BrokerDispatchStore) ListPendingDispatch(ctx context.Context, brokerID string) ([]store.BrokerDispatch, error) { + brokerUID, err := parseUUID(brokerID) + if err != nil { + return nil, err + } + rows, err := s.client.BrokerDispatch.Query(). + Where(brokerdispatch.BrokerIDEQ(brokerUID), brokerdispatch.StateEQ(store.DispatchStatePending)). + Order(ent.Asc(brokerdispatch.FieldCreatedAt)). + All(ctx) + if err != nil { + return nil, mapError(err) + } + out := make([]store.BrokerDispatch, 0, len(rows)) + for _, r := range rows { + out = append(out, entBrokerDispatchToStore(r)) + } + return out, nil +} + +// MarkMessageDispatched CAS-flips a message from dispatch_state=pending to +// dispatched and stamps dispatched_at. Returns dispatched=false if the row was +// not pending (already dispatched/failed/absent) — dedupes concurrent drains. +func (s *BrokerDispatchStore) MarkMessageDispatched(ctx context.Context, id string) (bool, error) { + uid, err := parseUUID(id) + if err != nil { + return false, err + } + affected, err := s.client.Message.Update(). + Where(message.IDEQ(uid), message.DispatchStateEQ(store.MessageDispatchPending)). + SetDispatchState(store.MessageDispatchDispatched). + SetDispatchedAt(time.Now()). + Save(ctx) + if err != nil { + return false, mapError(err) + } + return affected == 1, nil +} + +// CountStuckPendingMessages returns the number of messages still in +// dispatch_state='pending' whose created timestamp is before the given cutoff. +func (s *BrokerDispatchStore) CountStuckPendingMessages(ctx context.Context, before time.Time) (int, error) { + n, err := s.client.Message.Query(). + Where(message.DispatchStateEQ(store.MessageDispatchPending), message.CreatedLT(before)). + Count(ctx) + if err != nil { + return 0, mapError(err) + } + return n, nil +} + +// ListPendingMessages returns messages still pending delivery whose target agent +// lives on the given broker (messages have no broker_id; the association is via +// the recipient agent's runtime_broker_id). +func (s *BrokerDispatchStore) ListPendingMessages(ctx context.Context, brokerID string) ([]store.Message, error) { + agents, err := s.client.Agent.Query(). + Where(agent.RuntimeBrokerIDEQ(brokerID)). + All(ctx) + if err != nil { + return nil, mapError(err) + } + if len(agents) == 0 { + return nil, nil + } + agentIDs := make([]string, 0, len(agents)) + for _, a := range agents { + agentIDs = append(agentIDs, a.ID.String()) + } + rows, err := s.client.Message.Query(). + Where(message.AgentIDIn(agentIDs...), message.DispatchStateEQ(store.MessageDispatchPending)). + Order(ent.Asc(message.FieldCreated)). + All(ctx) + if err != nil { + return nil, mapError(err) + } + out := make([]store.Message, 0, len(rows)) + for _, r := range rows { + out = append(out, *entMessageToStore(r)) + } + return out, nil +} + +// ReapStuckDispatch re-drives or fails in_progress dispatches that have gone +// stale. Dispatches with attempts < maxAttempts are reset to pending; those at +// or above the limit are marked failed. +func (s *BrokerDispatchStore) ReapStuckDispatch(ctx context.Context, stuckBefore time.Time, maxAttempts int) (requeued, failed int, err error) { + now := time.Now() + + stuckPred := brokerdispatch.And( + brokerdispatch.StateEQ(store.DispatchStateInProgress), + brokerdispatch.Or( + brokerdispatch.UpdatedAtLT(stuckBefore), + brokerdispatch.And( + brokerdispatch.DeadlineAtNotNil(), + brokerdispatch.DeadlineAtLT(now), + ), + ), + ) + + requeued, err = s.client.BrokerDispatch.Update(). + Where(stuckPred, brokerdispatch.AttemptsLT(maxAttempts)). + SetState(store.DispatchStatePending). + ClearClaimedBy(). + AddAttempts(1). + SetUpdatedAt(now). + Save(ctx) + if err != nil { + return 0, 0, mapError(err) + } + + failed, err = s.client.BrokerDispatch.Update(). + Where(stuckPred, brokerdispatch.AttemptsGTE(maxAttempts)). + SetState(store.DispatchStateFailed). + SetError("reaper: max attempts exceeded"). + AddAttempts(1). + SetUpdatedAt(now). + Save(ctx) + if err != nil { + return requeued, 0, mapError(err) + } + + return requeued, failed, nil +} diff --git a/pkg/store/entadapter/composite.go b/pkg/store/entadapter/composite.go index 195e6825d..4b10cad3c 100644 --- a/pkg/store/entadapter/composite.go +++ b/pkg/store/entadapter/composite.go @@ -51,6 +51,7 @@ type CompositeStore struct { *AllowListStore *GroupStore *PolicyStore + *BrokerDispatchStore client *ent.Client } @@ -77,9 +78,10 @@ func NewCompositeStore(client *ent.Client) *CompositeStore { ExternalStore: NewExternalStore(client), BrokerSecretStore: NewBrokerSecretStore(client), AllowListStore: NewAllowListStore(client), - GroupStore: NewGroupStore(client), - PolicyStore: NewPolicyStore(client), - client: client, + GroupStore: NewGroupStore(client), + PolicyStore: NewPolicyStore(client), + BrokerDispatchStore: NewBrokerDispatchStore(client), + client: client, } } diff --git a/pkg/store/entadapter/message_store.go b/pkg/store/entadapter/message_store.go index 1743e1d74..72111b1b0 100644 --- a/pkg/store/entadapter/message_store.go +++ b/pkg/store/entadapter/message_store.go @@ -74,9 +74,11 @@ func entMessageToStore(e *ent.Message) *store.Message { Urgent: e.Urgent, Broadcasted: e.Broadcasted, Read: e.Read, - AgentID: e.AgentID, - GroupID: e.GroupID, - CreatedAt: e.Created, + AgentID: e.AgentID, + GroupID: e.GroupID, + CreatedAt: e.Created, + DispatchState: e.DispatchState, + DispatchedAt: e.DispatchedAt, } } @@ -112,6 +114,12 @@ func (s *MessageStore) CreateMessage(ctx context.Context, msg *store.Message) er if msg.Type == "" { create.SetType("instruction") } + if msg.DispatchState != "" { + create.SetDispatchState(msg.DispatchState) + } + if msg.DispatchedAt != nil { + create.SetDispatchedAt(*msg.DispatchedAt) + } if !msg.CreatedAt.IsZero() { create.SetCreated(msg.CreatedAt) } @@ -122,6 +130,7 @@ func (s *MessageStore) CreateMessage(ctx context.Context, msg *store.Message) er } msg.CreatedAt = created.Created msg.Type = created.Type + msg.DispatchState = created.DispatchState // Design-in: announce the new message for LISTEN/NOTIFY subscribers. // Best-effort — a publish failure must not fail the write that succeeded. diff --git a/pkg/store/entadapter/project_store.go b/pkg/store/entadapter/project_store.go index 7a7e1a79c..056d64089 100644 --- a/pkg/store/entadapter/project_store.go +++ b/pkg/store/entadapter/project_store.go @@ -539,6 +539,9 @@ func entBrokerToStore(b *ent.RuntimeBroker) *store.RuntimeBroker { if b.LastHeartbeat != nil { sb.LastHeartbeat = *b.LastHeartbeat } + sb.ConnectedHubID = b.ConnectedHubID + sb.ConnectedSessionID = b.ConnectedSessionID + sb.ConnectedAt = b.ConnectedAt unmarshalRawJSON(b.Capabilities, &sb.Capabilities) // Profiles are persisted in the "runtimes" column (legacy naming). unmarshalRawJSON(b.Runtimes, &sb.Profiles) @@ -580,6 +583,15 @@ func (s *ProjectStore) CreateRuntimeBroker(ctx context.Context, b *store.Runtime if b.CreatedBy != "" { create.SetCreatedBy(b.CreatedBy) } + if b.ConnectedHubID != nil { + create.SetConnectedHubID(*b.ConnectedHubID) + } + if b.ConnectedSessionID != nil { + create.SetConnectedSessionID(*b.ConnectedSessionID) + } + if b.ConnectedAt != nil { + create.SetConnectedAt(*b.ConnectedAt) + } created, err := create.Save(ctx) if err != nil { @@ -637,7 +649,7 @@ func (s *ProjectStore) UpdateRuntimeBroker(ctx context.Context, b *store.Runtime return mapError(err) } - affected, err := s.client.RuntimeBroker.Update(). + update := s.client.RuntimeBroker.Update(). Where(runtimebroker.IDEQ(uid), runtimebroker.LockVersionEQ(cur.LockVersion)). SetName(b.Name). SetSlug(b.Slug). @@ -652,8 +664,23 @@ func (s *ProjectStore) UpdateRuntimeBroker(ctx context.Context, b *store.Runtime SetEndpoint(b.Endpoint). SetAutoProvide(b.AutoProvide). SetUpdated(now). - AddLockVersion(1). - Save(ctx) + AddLockVersion(1) + if b.ConnectedHubID != nil { + update.SetConnectedHubID(*b.ConnectedHubID) + } else { + update.ClearConnectedHubID() + } + if b.ConnectedSessionID != nil { + update.SetConnectedSessionID(*b.ConnectedSessionID) + } else { + update.ClearConnectedSessionID() + } + if b.ConnectedAt != nil { + update.SetConnectedAt(*b.ConnectedAt) + } else { + update.ClearConnectedAt() + } + affected, err := update.Save(ctx) if err != nil { return mapError(err) } @@ -781,6 +808,108 @@ func (s *ProjectStore) UpdateRuntimeBrokerHeartbeat(ctx context.Context, id stri return store.ErrVersionConflict } +// ClaimRuntimeBrokerConnection records this hub instance as the owner of the +// broker's live control-channel socket. The newest connection wins +// (unconditional claim — mirrors a fresh socket replacing an old one): it sets +// the affinity columns and, in the same CAS write, bumps status to online and +// refreshes last_heartbeat. Uses the lock_version optimistic-concurrency loop, +// like UpdateRuntimeBrokerHeartbeat. +func (s *ProjectStore) ClaimRuntimeBrokerConnection(ctx context.Context, brokerID, hubInstanceID, sessionID string) error { + uid, err := parseUUID(brokerID) + if err != nil { + return err + } + + now := time.Now() + for attempt := 0; attempt < maxCASRetries; attempt++ { + cur, err := s.client.RuntimeBroker.Get(ctx, uid) + if err != nil { + return mapError(err) + } + affected, err := s.client.RuntimeBroker.Update(). + Where(runtimebroker.IDEQ(uid), runtimebroker.LockVersionEQ(cur.LockVersion)). + SetConnectedHubID(hubInstanceID). + SetConnectedSessionID(sessionID). + SetConnectedAt(now). + SetStatus(store.BrokerStatusOnline). + SetLastHeartbeat(now). + SetUpdated(now). + AddLockVersion(1). + Save(ctx) + if err != nil { + return mapError(err) + } + if affected == 1 { + return nil + } + } + return store.ErrVersionConflict +} + +// ReleaseRuntimeBrokerConnection clears the broker's affinity ONLY IF it still +// names (hubInstanceID, sessionID) — a compare-and-clear that fixes the +// disconnect-race: a delayed disconnect from a stale owner/session must not +// clobber a live owner. Returns cleared=true when this caller owned the +// affinity and it was cleared; cleared=false (no-op) when affinity has already +// moved (or was already clear). Does not change status — the caller decides +// whether to stamp offline based on cleared. +func (s *ProjectStore) ReleaseRuntimeBrokerConnection(ctx context.Context, brokerID, hubInstanceID, sessionID string) (bool, error) { + uid, err := parseUUID(brokerID) + if err != nil { + return false, err + } + + now := time.Now() + for attempt := 0; attempt < maxCASRetries; attempt++ { + cur, err := s.client.RuntimeBroker.Get(ctx, uid) + if err != nil { + return false, mapError(err) + } + // Compare: only clear if affinity still names this exact (hub, session). + if cur.ConnectedHubID == nil || *cur.ConnectedHubID != hubInstanceID || + cur.ConnectedSessionID == nil || *cur.ConnectedSessionID != sessionID { + return false, nil + } + affected, err := s.client.RuntimeBroker.Update(). + Where(runtimebroker.IDEQ(uid), runtimebroker.LockVersionEQ(cur.LockVersion)). + ClearConnectedHubID(). + ClearConnectedSessionID(). + ClearConnectedAt(). + SetUpdated(now). + AddLockVersion(1). + Save(ctx) + if err != nil { + return false, mapError(err) + } + if affected == 1 { + return true, nil + } + // affected==0: lock_version moved under us; re-read and re-evaluate the + // compare on the next iteration (affinity may have moved away). + } + return false, store.ErrVersionConflict +} + +// ReapStaleBrokerAffinity clears affinity (connected_hub_id/connected_session_id/ +// connected_at) for brokers that still claim affinity but whose last_heartbeat +// is older than staleBefore. Does not change broker status. +func (s *ProjectStore) ReapStaleBrokerAffinity(ctx context.Context, staleBefore time.Time) (int, error) { + affected, err := s.client.RuntimeBroker.Update(). + Where( + runtimebroker.ConnectedHubIDNotNil(), + runtimebroker.LastHeartbeatLT(staleBefore), + ). + ClearConnectedHubID(). + ClearConnectedSessionID(). + ClearConnectedAt(). + SetUpdated(time.Now()). + Save(ctx) + if err != nil { + return 0, mapError(err) + } + return affected, nil +} + // ============================================================================= // ProjectProvider (project_contributors) operations // ============================================================================= diff --git a/pkg/store/entadapter/reaper_test.go b/pkg/store/entadapter/reaper_test.go new file mode 100644 index 000000000..b388c18bf --- /dev/null +++ b/pkg/store/entadapter/reaper_test.go @@ -0,0 +1,218 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !no_sqlite + +package entadapter + +import ( + "context" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/GoogleCloudPlatform/scion/pkg/store/enttest" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ReapStaleBrokerAffinity +// --------------------------------------------------------------------------- + +func TestReapStaleBrokerAffinity_ClearsStaleOnly(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + + // Broker with stale heartbeat + affinity → should be reaped. + stale := newBroker() + stale.Status = store.BrokerStatusOnline + require.NoError(t, ps.CreateRuntimeBroker(ctx, stale)) + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, stale.ID, "hub-old", "sess-old")) + // Backdate heartbeat to make it stale. + _, err := ps.client.RuntimeBroker.UpdateOneID(uuid.MustParse(stale.ID)). + SetLastHeartbeat(time.Now().Add(-10 * time.Minute)).Save(ctx) + require.NoError(t, err) + + // Broker with fresh heartbeat + affinity → should NOT be reaped. + fresh := newBroker() + fresh.Status = store.BrokerStatusOnline + require.NoError(t, ps.CreateRuntimeBroker(ctx, fresh)) + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, fresh.ID, "hub-alive", "sess-alive")) + + // Broker with no affinity (NULL connected_hub_id) → should NOT be reaped. + noAffinity := newBroker() + noAffinity.Status = store.BrokerStatusOffline + require.NoError(t, ps.CreateRuntimeBroker(ctx, noAffinity)) + + cleared, err := ps.ReapStaleBrokerAffinity(ctx, time.Now().Add(-3*time.Minute)) + require.NoError(t, err) + assert.Equal(t, 1, cleared) + + // Verify stale broker's affinity was cleared. + got, err := ps.GetRuntimeBroker(ctx, stale.ID) + require.NoError(t, err) + assert.Nil(t, got.ConnectedHubID) + assert.Nil(t, got.ConnectedSessionID) + assert.Nil(t, got.ConnectedAt) + + // Verify fresh broker's affinity is intact. + got, err = ps.GetRuntimeBroker(ctx, fresh.ID) + require.NoError(t, err) + require.NotNil(t, got.ConnectedHubID) + assert.Equal(t, "hub-alive", *got.ConnectedHubID) + + // Verify no-affinity broker is untouched. + got, err = ps.GetRuntimeBroker(ctx, noAffinity.ID) + require.NoError(t, err) + assert.Nil(t, got.ConnectedHubID) +} + +func TestReapStaleBrokerAffinity_NothingToReap(t *testing.T) { + ps := newTestProjectStore(t) + ctx := context.Background() + + b := newBroker() + require.NoError(t, ps.CreateRuntimeBroker(ctx, b)) + require.NoError(t, ps.ClaimRuntimeBrokerConnection(ctx, b.ID, "hub-1", "sess-1")) + + cleared, err := ps.ReapStaleBrokerAffinity(ctx, time.Now().Add(-10*time.Minute)) + require.NoError(t, err) + assert.Equal(t, 0, cleared) +} + +// --------------------------------------------------------------------------- +// ReapStuckDispatch +// --------------------------------------------------------------------------- + +func TestReapStuckDispatch_RedrivesBelowMax(t *testing.T) { + client := enttest.NewClient(t) + ds := NewBrokerDispatchStore(client) + ctx := context.Background() + brokerID := uuid.NewString() + + d := newDispatch(brokerID, "start") + require.NoError(t, ds.InsertBrokerDispatch(ctx, d)) + claimed, err := ds.ClaimBrokerDispatch(ctx, d.ID, "hub-1") + require.NoError(t, err) + require.True(t, claimed) + + // Backdate updated_at to make it stuck. + _, err = client.BrokerDispatch.UpdateOneID(uuid.MustParse(d.ID)). + SetUpdatedAt(time.Now().Add(-10 * time.Minute)).Save(ctx) + require.NoError(t, err) + + requeued, failed, err := ds.ReapStuckDispatch(ctx, time.Now().Add(-5*time.Minute), 3) + require.NoError(t, err) + assert.Equal(t, 1, requeued) + assert.Equal(t, 0, failed) + + got, err := ds.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStatePending, got.State) + assert.Equal(t, "", got.ClaimedBy) + assert.Equal(t, 1, got.Attempts) +} + +func TestReapStuckDispatch_FailsAtMaxAttempts(t *testing.T) { + client := enttest.NewClient(t) + ds := NewBrokerDispatchStore(client) + ctx := context.Background() + brokerID := uuid.NewString() + + d := newDispatch(brokerID, "stop") + d.State = store.DispatchStateInProgress + require.NoError(t, ds.InsertBrokerDispatch(ctx, d)) + + // Set attempts to maxAttempts. + _, err := client.BrokerDispatch.UpdateOneID(uuid.MustParse(d.ID)). + SetAttempts(3). + SetUpdatedAt(time.Now().Add(-10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + requeued, failed, err := ds.ReapStuckDispatch(ctx, time.Now().Add(-5*time.Minute), 3) + require.NoError(t, err) + assert.Equal(t, 0, requeued) + assert.Equal(t, 1, failed) + + got, err := ds.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStateFailed, got.State) + assert.Contains(t, got.Error, "max attempts exceeded") +} + +func TestReapStuckDispatch_LeavesFreshAndTerminal(t *testing.T) { + client := enttest.NewClient(t) + ds := NewBrokerDispatchStore(client) + ctx := context.Background() + brokerID := uuid.NewString() + + // Fresh in_progress dispatch (updated recently) → should NOT be reaped. + fresh := newDispatch(brokerID, "start") + require.NoError(t, ds.InsertBrokerDispatch(ctx, fresh)) + claimed, err := ds.ClaimBrokerDispatch(ctx, fresh.ID, "hub-1") + require.NoError(t, err) + require.True(t, claimed) + + // Done dispatch → should NOT be reaped. + done := newDispatch(brokerID, "stop") + require.NoError(t, ds.InsertBrokerDispatch(ctx, done)) + _, err = ds.ClaimBrokerDispatch(ctx, done.ID, "hub-1") + require.NoError(t, err) + require.NoError(t, ds.CompleteBrokerDispatch(ctx, done.ID, `{"ok":true}`)) + + // Pending dispatch → should NOT be reaped (only in_progress is targeted). + pending := newDispatch(brokerID, "restart") + require.NoError(t, ds.InsertBrokerDispatch(ctx, pending)) + + requeued, failed, err := ds.ReapStuckDispatch(ctx, time.Now().Add(-5*time.Minute), 3) + require.NoError(t, err) + assert.Equal(t, 0, requeued) + assert.Equal(t, 0, failed) + + // Verify states are unchanged. + got, _ := ds.GetBrokerDispatch(ctx, fresh.ID) + assert.Equal(t, store.DispatchStateInProgress, got.State) + + got, _ = ds.GetBrokerDispatch(ctx, done.ID) + assert.Equal(t, store.DispatchStateDone, got.State) + + got, _ = ds.GetBrokerDispatch(ctx, pending.ID) + assert.Equal(t, store.DispatchStatePending, got.State) +} + +func TestReapStuckDispatch_PastDeadline(t *testing.T) { + client := enttest.NewClient(t) + ds := NewBrokerDispatchStore(client) + ctx := context.Background() + brokerID := uuid.NewString() + + d := newDispatch(brokerID, "start") + pastDeadline := time.Now().Add(-1 * time.Minute) + d.DeadlineAt = &pastDeadline + d.State = store.DispatchStateInProgress + require.NoError(t, ds.InsertBrokerDispatch(ctx, d)) + + // updated_at is recent (within threshold), but deadline_at is past. + requeued, failed, err := ds.ReapStuckDispatch(ctx, time.Now().Add(-10*time.Minute), 3) + require.NoError(t, err) + assert.Equal(t, 1, requeued) + assert.Equal(t, 0, failed) + + got, err := ds.GetBrokerDispatch(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, store.DispatchStatePending, got.State) +} diff --git a/pkg/store/models.go b/pkg/store/models.go index 7007ef2de..5c0bc2e22 100644 --- a/pkg/store/models.go +++ b/pkg/store/models.go @@ -308,6 +308,11 @@ type RuntimeBroker struct { Labels map[string]string `json:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` + // Affinity — which hub instance currently holds the control-channel socket + ConnectedHubID *string `json:"connectedHubId,omitempty"` + ConnectedSessionID *string `json:"connectedSessionId,omitempty"` + ConnectedAt *time.Time `json:"connectedAt,omitempty"` + // Network endpoint (for direct HTTP mode) Endpoint string `json:"endpoint,omitempty"` @@ -747,6 +752,42 @@ const ( BrokerStatusDegraded = "degraded" ) +// BrokerDispatch is the durable intent for a lifecycle/create-time command +// targeted at a broker (design §5.2). The socket-holding node reconciles it: +// CAS-claim (pending->in_progress) → run the local tunnel op → mark done/failed. +type BrokerDispatch struct { + ID string `json:"id"` + BrokerID string `json:"brokerId"` + AgentID string `json:"agentId,omitempty"` // empty for project-scoped ops + AgentSlug string `json:"agentSlug,omitempty"` + ProjectID string `json:"projectId,omitempty"` // empty if unknown/none + Op string `json:"op"` // start|stop|restart|delete|finalize_env|check_prompt|create|message + Args string `json:"args,omitempty"` // JSON + State string `json:"state"` // pending|in_progress|done|failed + Result string `json:"result,omitempty"` // JSON + ClaimedBy string `json:"claimedBy,omitempty"` // hub instanceID that reconciled it + Attempts int `json:"attempts"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeadlineAt *time.Time `json:"deadlineAt,omitempty"` +} + +// BrokerDispatch.State values. +const ( + DispatchStatePending = "pending" + DispatchStateInProgress = "in_progress" + DispatchStateDone = "done" + DispatchStateFailed = "failed" +) + +// Message.DispatchState values (the message row is its own dispatch intent). +const ( + MessageDispatchPending = "pending" + MessageDispatchDispatched = "dispatched" + MessageDispatchFailed = "failed" +) + // ============================================================================= // Notifications (Agent Status Notification System) // ============================================================================= @@ -1350,6 +1391,11 @@ type Message struct { Channel string `json:"channel,omitempty"` ThreadID string `json:"threadId,omitempty"` CreatedAt time.Time `json:"createdAt"` + // DispatchState tracks cross-node delivery of the message to the broker: + // pending|dispatched|failed. The message row is its own durable dispatch + // intent (design §5.2/§6.1). + DispatchState string `json:"dispatchState,omitempty"` + DispatchedAt *time.Time `json:"dispatchedAt,omitempty"` } // MarshalJSON implements custom marshaling to support legacy groveId field. diff --git a/pkg/store/store.go b/pkg/store/store.go index 22e93e471..6f357f624 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -49,6 +49,9 @@ type Store interface { // RuntimeBroker operations RuntimeBrokerStore + // BrokerDispatch operations (multi-node command dispatch) + BrokerDispatchStore + // Template operations TemplateStore @@ -302,6 +305,27 @@ type RuntimeBrokerStore interface { // UpdateRuntimeBrokerHeartbeat updates the last heartbeat and status. UpdateRuntimeBrokerHeartbeat(ctx context.Context, id string, status string) error + + // ClaimRuntimeBrokerConnection records this hub instance as the owner of the + // broker's live control-channel socket. The newest connection wins + // (unconditional claim): it sets connected_hub_id/connected_session_id/ + // connected_at and, in the same write, bumps status to online and refreshes + // last_heartbeat. + ClaimRuntimeBrokerConnection(ctx context.Context, brokerID, hubInstanceID, sessionID string) error + + // ReleaseRuntimeBrokerConnection clears the broker's affinity ONLY IF it still + // names (hubInstanceID, sessionID) — a compare-and-clear. It returns + // cleared=true when this caller owned the affinity and it was cleared; it + // returns cleared=false (a no-op) when affinity has already moved to another + // hub/session, in which case the caller MUST NOT stamp the broker offline. + // It does not change status (the caller decides offline based on cleared). + ReleaseRuntimeBrokerConnection(ctx context.Context, brokerID, hubInstanceID, sessionID string) (cleared bool, err error) + + // ReapStaleBrokerAffinity clears connected_hub_id/connected_session_id/ + // connected_at for brokers whose last_heartbeat is older than staleBefore + // and whose connected_hub_id is not NULL (i.e. they still claim affinity). + // Returns the number of rows cleared. Does not change broker status. + ReapStaleBrokerAffinity(ctx context.Context, staleBefore time.Time) (cleared int, err error) } // RuntimeBrokerFilter defines criteria for filtering runtime brokers. @@ -312,6 +336,49 @@ type RuntimeBrokerFilter struct { AutoProvide *bool // Filter by auto-provide flag (nil = no filter) } +// BrokerDispatchStore defines persistence for the durable broker-dispatch intent +// table and the message dispatch-state CAS helpers (multi-node command dispatch). +type BrokerDispatchStore interface { + // InsertBrokerDispatch persists a new dispatch intent (state defaults pending). + InsertBrokerDispatch(ctx context.Context, d *BrokerDispatch) error + + // ClaimBrokerDispatch CAS-transitions a dispatch pending->in_progress for the + // given hub instance. Returns claimed=false if it was not pending (so exactly + // one node executes a given intent). + ClaimBrokerDispatch(ctx context.Context, id, hubInstanceID string) (claimed bool, err error) + + // CompleteBrokerDispatch marks a dispatch done with an optional result JSON. + CompleteBrokerDispatch(ctx context.Context, id, result string) error + + // FailBrokerDispatch marks a dispatch failed, records the error, bumps attempts. + FailBrokerDispatch(ctx context.Context, id, errMsg string) error + + // GetBrokerDispatch returns a single dispatch row by ID (used by the + // originator to read the result after the owner completes it). + GetBrokerDispatch(ctx context.Context, id string) (*BrokerDispatch, error) + + // ListPendingDispatch returns pending intents for a broker (drain query). + ListPendingDispatch(ctx context.Context, brokerID string) ([]BrokerDispatch, error) + + // MarkMessageDispatched CAS-flips a message pending->dispatched (dedupes drains). + MarkMessageDispatched(ctx context.Context, id string) (dispatched bool, err error) + + // ListPendingMessages returns pending messages whose target agent is on the broker. + ListPendingMessages(ctx context.Context, brokerID string) ([]Message, error) + + // ReapStuckDispatch re-drives or fails in_progress dispatches that have gone + // stale (updated_at < stuckBefore). Dispatches with attempts < maxAttempts + // are reset to pending (re-driven); those at or above the limit are failed. + // Returns counts of re-driven and failed rows. + ReapStuckDispatch(ctx context.Context, stuckBefore time.Time, maxAttempts int) (requeued, failed int, err error) + + // CountStuckPendingMessages returns the number of messages still in + // dispatch_state='pending' whose created timestamp is before the given + // cutoff. Used by the stuck-message sweep (B5-2) to surface messages that + // have not been dispatched within the expected window. + CountStuckPendingMessages(ctx context.Context, before time.Time) (int, error) +} + // TemplateStore defines template persistence operations. type TemplateStore interface { // CreateTemplate creates a new template record. diff --git a/scratch/apitest/main.go b/scratch/apitest/main.go new file mode 100644 index 000000000..336e61306 --- /dev/null +++ b/scratch/apitest/main.go @@ -0,0 +1,237 @@ +// Command apitest drives API-level multi-hub integration/stress traffic against +// two running Scion hubs that share one CloudSQL Postgres instance. It validates +// the connection-pool / keepalive fixes and multi-replica behavior through the +// real HTTP API. Run it ON a hub VM so it reaches both hubs over the fast +// internal network. Not part of the product. +// +// Env: +// A_BASE, B_BASE base URLs (e.g. http://localhost:8080, http://10.128.15.241:8080) +// A_TOK, B_TOK admin bearer tokens (per-hub signing keys) +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" +) + +type hub struct { + name string + base string + tok string +} + +var client = &http.Client{Timeout: 35 * time.Second} + +func req(h hub, method, path string, body any) (int, []byte, time.Duration) { + var rdr io.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } + r, _ := http.NewRequest(method, h.base+path, rdr) + r.Header.Set("Authorization", "Bearer "+h.tok) + if body != nil { + r.Header.Set("Content-Type", "application/json") + } + start := time.Now() + resp, err := client.Do(r) + d := time.Since(start) + if err != nil { + return 0, []byte(err.Error()), d + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + return resp.StatusCode, rb, d +} + +func pct(ds []time.Duration, p float64) time.Duration { + if len(ds) == 0 { + return 0 + } + sort.Slice(ds, func(i, j int) bool { return ds[i] < ds[j] }) + i := int(float64(len(ds)) * p) + if i >= len(ds) { + i = len(ds) - 1 + } + return ds[i] +} + +func main() { + A := hub{"A", os.Getenv("A_BASE"), os.Getenv("A_TOK")} + B := hub{"B", os.Getenv("B_BASE"), os.Getenv("B_TOK")} + hubs := []hub{A, B} + + // ---- Phase 1: concurrent CRUD storm across both hubs ---- + fmt.Println("== Phase 1: concurrent project CRUD storm (both hubs) ==") + const workers, iters = 24, 30 + var ok, fail, stalls int64 + latMu := sync.Mutex{} + lat := map[string][]time.Duration{"A": {}, "B": {}} + var wg sync.WaitGroup + t0 := time.Now() + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + h := hubs[w%2] + for i := 0; i < iters; i++ { + name := fmt.Sprintf("stress-%d-%d-%s", w, i, uuid.NewString()[:8]) + st, body, d := req(h, "POST", "/api/v1/projects", map[string]string{"name": name}) + if d > 2*time.Second { + atomic.AddInt64(&stalls, 1) + } + if st != 201 && st != 200 { + atomic.AddInt64(&fail, 1) + if i == 0 { + fmt.Printf(" [%s] create failed st=%d body=%.120s\n", h.name, st, body) + } + continue + } + var pr struct { + ID string `json:"id"` + } + json.Unmarshal(body, &pr) + req(h, "GET", "/api/v1/projects/"+pr.ID, nil) + req(h, "GET", "/api/v1/projects?limit=5", nil) + dst, _, dd := req(h, "DELETE", "/api/v1/projects/"+pr.ID, nil) + if dd > 2*time.Second { + atomic.AddInt64(&stalls, 1) + } + if dst >= 200 && dst < 300 { + atomic.AddInt64(&ok, 1) + } else { + atomic.AddInt64(&fail, 1) + } + latMu.Lock() + lat[h.name] = append(lat[h.name], d) + latMu.Unlock() + } + }(w) + } + wg.Wait() + dur := time.Since(t0) + total := int64(workers * iters) + fmt.Printf(" full CRUD cycles ok=%d fail=%d of %d in %s (%.0f cycles/s), stalls(>2s)=%d\n", + ok, fail, total, dur.Truncate(time.Millisecond), float64(total)/dur.Seconds(), stalls) + for _, n := range []string{"A", "B"} { + fmt.Printf(" hub %s create-latency p50=%s p95=%s max=%s (n=%d)\n", + n, pct(lat[n], 0.5), pct(lat[n], 0.95), pct(lat[n], 1.0), len(lat[n])) + } + + // ---- Phase 2: cross-replica read-after-write (create A, read B) ---- + fmt.Println("== Phase 2: cross-replica read-after-write (create on A, GET on B) ==") + const rw = 40 + var immediate, delayed, miss int + for i := 0; i < rw; i++ { + name := "raw-" + uuid.NewString()[:10] + st, body, _ := req(A, "POST", "/api/v1/projects", map[string]string{"name": name}) + if st != 201 && st != 200 { + miss++ + continue + } + var pr struct { + ID string `json:"id"` + } + json.Unmarshal(body, &pr) + got := false + for attempt := 0; attempt < 10; attempt++ { + s2, _, _ := req(B, "GET", "/api/v1/projects/"+pr.ID, nil) + if s2 == 200 { + if attempt == 0 { + immediate++ + } else { + delayed++ + } + got = true + break + } + time.Sleep(50 * time.Millisecond) + } + if !got { + miss++ + } + req(A, "DELETE", "/api/v1/projects/"+pr.ID, nil) + } + fmt.Printf(" read-after-write: immediate=%d delayed=%d miss=%d of %d\n", immediate, delayed, miss, rw) + + // ---- Phase 3: conflict -> HTTP 409 (concurrent duplicate-ID creates) ---- + fmt.Println("== Phase 3: concurrent duplicate-ID create -> expect exactly one 201, rest 409 ==") + const rounds = 25 + var created, conflict, other int + for i := 0; i < rounds; i++ { + id := uuid.NewString() + name := "dup-" + id[:8] + var c201, c409, cother int64 + var w2 sync.WaitGroup + // 4 concurrent creators (2 per hub) racing on the same explicit ID. + for k := 0; k < 4; k++ { + w2.Add(1) + go func(k int) { + defer w2.Done() + h := hubs[k%2] + st, _, _ := req(h, "POST", "/api/v1/projects", map[string]any{"id": id, "name": name}) + switch { + case st == 201 || st == 200: + atomic.AddInt64(&c201, 1) + case st == 409: + atomic.AddInt64(&c409, 1) + default: + atomic.AddInt64(&cother, 1) + } + }(k) + } + w2.Wait() + created += int(c201) + conflict += int(c409) + other += int(cother) + req(A, "DELETE", "/api/v1/projects/"+id, nil) + } + fmt.Printf(" over %d rounds (4 racers each): 201=%d 409=%d other=%d (ideal: 201==%d, 409==%d)\n", + rounds, created, conflict, other, rounds, rounds*3) + + // ---- Phase 4: idle-then-burst (the stale-connection scenario) ---- + idleStr := os.Getenv("IDLE_SECONDS") + idle := 75 + fmt.Sscanf(idleStr, "%d", &idle) + fmt.Printf("== Phase 4: idle %ds then burst (validates keepalive/idle-recycle fix) ==\n", idle) + for _, h := range hubs { // warm the pools + for i := 0; i < 5; i++ { + req(h, "GET", "/api/v1/projects?limit=1", nil) + } + } + fmt.Printf(" pools warm; sleeping %ds to force idle...\n", idle) + time.Sleep(time.Duration(idle) * time.Second) + for _, h := range hubs { + var first time.Duration + var maxd time.Duration + for i := 0; i < 10; i++ { + st, _, d := req(h, "GET", "/api/v1/projects?limit=1", nil) + if i == 0 { + first = d + } + if d > maxd { + maxd = d + } + if st != 200 { + fmt.Printf(" [%s] burst req %d unexpected st=%d\n", h.name, i, st) + } + } + verdict := "OK" + if first > 2*time.Second { + verdict = "STALL (likely dead idle conn)" + } + fmt.Printf(" hub %s post-idle first-request=%s max=%s -> %s\n", + h.name, first.Truncate(time.Millisecond), maxd.Truncate(time.Millisecond), verdict) + } + fmt.Println("== done ==") +} diff --git a/scratch/dbdiag/main.go b/scratch/dbdiag/main.go new file mode 100644 index 000000000..8cc7d92fc --- /dev/null +++ b/scratch/dbdiag/main.go @@ -0,0 +1,42 @@ +// Command dbdiag prints CloudSQL connection usage for diagnosing pool +// saturation. Not part of the product. +package main + +import ( + "context" + "fmt" + "os" + + "github.com/jackc/pgx/v5" +) + +func main() { + ctx := context.Background() + conn, err := pgx.Connect(ctx, os.Getenv("PG_DSN")) + if err != nil { + fmt.Fprintln(os.Stderr, "connect:", err) + os.Exit(1) + } + defer conn.Close(ctx) + + var maxc, used int + conn.QueryRow(ctx, "SHOW max_connections").Scan(&maxc) + conn.QueryRow(ctx, "SELECT count(*) FROM pg_stat_activity WHERE datname='scion_test'").Scan(&used) + fmt.Printf("max_connections=%d total_on_scion_test=%d\n", maxc, used) + + rows, _ := conn.Query(ctx, `SELECT COALESCE(application_name,'(none)'), state, count(*) + FROM pg_stat_activity WHERE datname='scion_test' + GROUP BY 1,2 ORDER BY 3 DESC`) + defer rows.Close() + fmt.Printf("%-32s %-20s %s\n", "application_name", "state", "count") + for rows.Next() { + var app, state string + var n int + rows.Scan(&app, &state, &n) + fmt.Printf("%-32s %-20s %d\n", app, state, n) + } + // Advisory locks currently held. + var locks int + conn.QueryRow(ctx, "SELECT count(*) FROM pg_locks WHERE locktype='advisory'").Scan(&locks) + fmt.Printf("advisory_locks_held=%d\n", locks) +} diff --git a/scratch/dbdiag2/main.go b/scratch/dbdiag2/main.go new file mode 100644 index 000000000..274fb1173 --- /dev/null +++ b/scratch/dbdiag2/main.go @@ -0,0 +1,17 @@ +package main +import ("context";"fmt";"os";"time";"github.com/jackc/pgx/v5") +func main(){ + ctx:=context.Background() + c,_:=pgx.Connect(ctx,os.Getenv("PG_DSN")); defer c.Close(ctx) + for i:=0;i<14;i++{ + rows,_:=c.Query(ctx,`SELECT client_addr::text, state, count(*) FROM pg_stat_activity WHERE datname='scion_test' AND client_addr IS NOT NULL GROUP BY 1,2 ORDER BY 1,2`) + m:=map[string]int{} + for rows.Next(){var a,s string;var n int;rows.Scan(&a,&s,&n);m[a+"/"+s]=n} + rows.Close() + var locks,waiting int + c.QueryRow(ctx,"SELECT count(*) FROM pg_locks WHERE locktype='advisory'").Scan(&locks) + c.QueryRow(ctx,"SELECT count(*) FROM pg_stat_activity WHERE wait_event_type='Client' AND datname='scion_test'").Scan(&waiting) + fmt.Printf("t+%2ds locks=%d %v\n",i*5,locks,m) + time.Sleep(5*time.Second) + } +} diff --git a/scratch/minttoken/main.go b/scratch/minttoken/main.go new file mode 100644 index 000000000..705957868 --- /dev/null +++ b/scratch/minttoken/main.go @@ -0,0 +1,61 @@ +// Command minttoken mints a user access-token JWT for API-level integration +// testing against the running hubs. It looks up an existing (preferably admin) +// user in the shared Postgres DB and signs a token with the per-hub signing key +// read from Secret Manager. Not part of the product; used only for test driving. +package main + +import ( + "context" + "encoding/base64" + "fmt" + "os" + + "github.com/jackc/pgx/v5" + + "github.com/GoogleCloudPlatform/scion/pkg/hub" +) + +func main() { + dsn := os.Getenv("PG_DSN") + keyB64 := os.Getenv("SIGNING_KEY_B64") + if dsn == "" || keyB64 == "" { + fmt.Fprintln(os.Stderr, "PG_DSN and SIGNING_KEY_B64 required") + os.Exit(1) + } + key, err := base64.StdEncoding.DecodeString(keyB64) + if err != nil { + fmt.Fprintln(os.Stderr, "decode key:", err) + os.Exit(1) + } + + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + fmt.Fprintln(os.Stderr, "db connect:", err) + os.Exit(1) + } + defer conn.Close(ctx) + + var id, email, displayName, role string + // Prefer an admin; fall back to any user. + err = conn.QueryRow(ctx, `SELECT id::text, email, display_name, role FROM users + ORDER BY (role = 'admin') DESC, created ASC LIMIT 1`).Scan(&id, &email, &displayName, &role) + if err != nil { + fmt.Fprintln(os.Stderr, "user lookup:", err) + os.Exit(1) + } + + svc, err := hub.NewUserTokenService(hub.UserTokenConfig{SigningKey: key}) + if err != nil { + fmt.Fprintln(os.Stderr, "token service:", err) + os.Exit(1) + } + // CLI client type → long (30-day) validity so the token outlives the test run. + token, _, err := svc.GenerateAccessToken(id, email, displayName, role, hub.ClientTypeCLI) + if err != nil { + fmt.Fprintln(os.Stderr, "mint:", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "user=%s email=%s role=%s\n", id, email, role) + fmt.Println(token) +} diff --git a/scratch/nm2-test-pod-a.yaml b/scratch/nm2-test-pod-a.yaml new file mode 100644 index 000000000..cab1f7892 --- /dev/null +++ b/scratch/nm2-test-pod-a.yaml @@ -0,0 +1,76 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nm2-test-agent-a + namespace: scion-agents + labels: + test: nm2-scenario-a + scion.dev/project-id: test-project-alpha +spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + initContainers: + - name: workspace-provision + image: alpine/git:latest + command: + - sh + - -c + - | + set -e + SENTINEL="/workspace/.scion-provisioned" + if [ -f "$SENTINEL" ]; then + echo "PROVISION: sentinel found, skipping clone" + exit 0 + fi + echo "PROVISION: cloning workspace..." + git clone --depth 1 https://github.com/ptone/scion.git /workspace + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SENTINEL" + echo "PROVISION: clone complete, sentinel written" + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-alpha/workspace + resources: + requests: + cpu: 250m + memory: 512Mi + containers: + - name: agent + image: busybox:1.36 + command: + - sh + - -c + - | + echo "=== WORKSPACE CONTENTS ===" + ls -la /workspace/ + echo "=== SENTINEL CHECK ===" + cat /workspace/.scion-provisioned 2>/dev/null && echo "SENTINEL: present" || echo "SENTINEL: missing" + echo "=== ISOLATION CHECK ===" + echo "Attempting to access parent dir..." + ls /workspace/../ 2>&1 || echo "ISOLATION: cannot traverse up" + echo "=== WORKSPACE MOUNT INFO ===" + mount | grep workspace || echo "mount info unavailable in busybox" + echo "=== UID/GID CHECK ===" + id + echo "=== TEST COMPLETE ===" + sleep 30 + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-alpha/workspace + resources: + requests: + cpu: 250m + memory: 256Mi + volumes: + - name: workspace + persistentVolumeClaim: + claimName: scion-workspaces + restartPolicy: Never + tolerations: + - key: "kubernetes.io/arch" + operator: "Equal" + value: "amd64" + effect: "NoSchedule" diff --git a/scratch/nm2-test-pod-b1.yaml b/scratch/nm2-test-pod-b1.yaml new file mode 100644 index 000000000..fc29aa3a4 --- /dev/null +++ b/scratch/nm2-test-pod-b1.yaml @@ -0,0 +1,71 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nm2-test-agent-b1 + namespace: scion-agents + labels: + test: nm2-scenario-b + scion.dev/project-id: test-project-beta +spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + initContainers: + - name: workspace-provision + image: alpine/git:latest + command: + - sh + - -c + - | + set -e + SENTINEL="/workspace/.scion-provisioned" + if [ -f "$SENTINEL" ]; then + echo "PROVISION: sentinel found at $(cat $SENTINEL), skipping clone" + exit 0 + fi + echo "PROVISION: cloning workspace for project-beta (agent b1)..." + git clone --depth 1 https://github.com/ptone/scion.git /workspace + echo "b1:$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SENTINEL" + echo "PROVISION: clone complete, sentinel written by b1" + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-beta/workspace + resources: + requests: + cpu: 250m + memory: 512Mi + containers: + - name: agent + image: busybox:1.36 + command: + - sh + - -c + - | + echo "=== AGENT B1 WORKSPACE ===" + ls -la /workspace/ + echo "=== SENTINEL ===" + cat /workspace/.scion-provisioned + echo "=== go.mod (identity check) ===" + head -3 /workspace/go.mod 2>/dev/null || echo "go.mod not found" + echo "=== TEST COMPLETE (b1) ===" + sleep 60 + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-beta/workspace + resources: + requests: + cpu: 250m + memory: 256Mi + volumes: + - name: workspace + persistentVolumeClaim: + claimName: scion-workspaces + restartPolicy: Never + tolerations: + - key: "kubernetes.io/arch" + operator: "Equal" + value: "amd64" + effect: "NoSchedule" diff --git a/scratch/nm2-test-pod-b2.yaml b/scratch/nm2-test-pod-b2.yaml new file mode 100644 index 000000000..e2b3647ed --- /dev/null +++ b/scratch/nm2-test-pod-b2.yaml @@ -0,0 +1,71 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nm2-test-agent-b2 + namespace: scion-agents + labels: + test: nm2-scenario-b + scion.dev/project-id: test-project-beta +spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + initContainers: + - name: workspace-provision + image: alpine/git:latest + command: + - sh + - -c + - | + set -e + SENTINEL="/workspace/.scion-provisioned" + if [ -f "$SENTINEL" ]; then + echo "PROVISION: sentinel found at $(cat $SENTINEL), skipping clone" + exit 0 + fi + echo "PROVISION: cloning workspace for project-beta (agent b2)..." + git clone --depth 1 https://github.com/ptone/scion.git /workspace + echo "b2:$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SENTINEL" + echo "PROVISION: clone complete, sentinel written by b2" + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-beta/workspace + resources: + requests: + cpu: 250m + memory: 512Mi + containers: + - name: agent + image: busybox:1.36 + command: + - sh + - -c + - | + echo "=== AGENT B2 WORKSPACE ===" + ls -la /workspace/ + echo "=== SENTINEL ===" + cat /workspace/.scion-provisioned + echo "=== go.mod (identity check) ===" + head -3 /workspace/go.mod 2>/dev/null || echo "go.mod not found" + echo "=== TEST COMPLETE (b2) ===" + sleep 60 + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-beta/workspace + resources: + requests: + cpu: 250m + memory: 256Mi + volumes: + - name: workspace + persistentVolumeClaim: + claimName: scion-workspaces + restartPolicy: Never + tolerations: + - key: "kubernetes.io/arch" + operator: "Equal" + value: "amd64" + effect: "NoSchedule" diff --git a/scratch/nm2-test-pod-e.yaml b/scratch/nm2-test-pod-e.yaml new file mode 100644 index 000000000..856f923fc --- /dev/null +++ b/scratch/nm2-test-pod-e.yaml @@ -0,0 +1,97 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nm2-test-agent-e + namespace: scion-agents + labels: + test: nm2-scenario-e + scion.dev/project-id: test-project-epsilon +spec: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + initContainers: + - name: workspace-provision + image: alpine/git:latest + command: + - sh + - -c + - | + set -e + SENTINEL="/workspace/.scion-provisioned" + if [ -f "$SENTINEL" ]; then + echo "PROVISION: sentinel found, skipping clone" + exit 0 + fi + echo "PROVISION: cloning workspace..." + git clone --depth 1 https://github.com/ptone/scion.git /workspace + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SENTINEL" + echo "PROVISION: clone complete" + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-epsilon/workspace + resources: + requests: + cpu: 250m + memory: 512Mi + - name: shared-dir-provision + image: busybox:1.36 + command: + - sh + - -c + - | + echo "SHARED-DIR: ensuring directory exists..." + mkdir -p /shared/test-data + echo "shared-dir-test-content" > /shared/test-data/readme.txt + ls -la /shared/ + echo "SHARED-DIR: provisioned" + volumeMounts: + - name: shared-dir-0 + mountPath: /shared + subPath: projects/test-project-epsilon/shared-dirs/test-data + resources: + requests: + cpu: 250m + memory: 128Mi + containers: + - name: agent + image: busybox:1.36 + command: + - sh + - -c + - | + echo "=== WORKSPACE ===" + ls -la /workspace/ | head -10 + echo "=== SHARED DIR (/scion-volumes/test-data) ===" + ls -la /scion-volumes/test-data/ + cat /scion-volumes/test-data/readme.txt + echo "=== MOUNT VERIFICATION ===" + echo "Workspace and shared dir are on same PVC with different subPaths" + echo "=== TEST COMPLETE (e) ===" + sleep 30 + volumeMounts: + - name: workspace + mountPath: /workspace + subPath: projects/test-project-epsilon/workspace + - name: shared-dir-0 + mountPath: /scion-volumes/test-data + subPath: projects/test-project-epsilon/shared-dirs/test-data + resources: + requests: + cpu: 250m + memory: 256Mi + volumes: + - name: workspace + persistentVolumeClaim: + claimName: scion-workspaces + - name: shared-dir-0 + persistentVolumeClaim: + claimName: scion-workspaces + restartPolicy: Never + tolerations: + - key: "kubernetes.io/arch" + operator: "Equal" + value: "amd64" + effect: "NoSchedule" diff --git a/scratch/scion-nfs-pv.yaml b/scratch/scion-nfs-pv.yaml new file mode 100644 index 000000000..0fed9b6a9 --- /dev/null +++ b/scratch/scion-nfs-pv.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: scion-agents +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: scion-workspaces +spec: + capacity: + storage: 1Ti + accessModes: [ReadWriteMany] + nfs: + server: 10.45.255.170 + path: /scion_share + mountOptions: [vers=3, hard, nconnect=4] + persistentVolumeReclaimPolicy: Retain +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: scion-workspaces + namespace: scion-agents +spec: + accessModes: [ReadWriteMany] + storageClassName: "" + volumeName: scion-workspaces + resources: + requests: + storage: 1Ti diff --git a/shared-dirs/scratchpad/coordinator.md b/shared-dirs/scratchpad/coordinator.md new file mode 100644 index 000000000..de67981d2 --- /dev/null +++ b/shared-dirs/scratchpad/coordinator.md @@ -0,0 +1,75 @@ +# Coordinator Agent Workflow Instructions + +## Role +You are a coordinator agent. Your primary role is to manage agents using the Scion CLI and communicate with the user via `scion message`. You do not implement code yourself. You are acting as the product manager and are here to ensure that the project is completed completely and at high quality. + + +## Communication +- Always communicate with the user via `scion message --non-interactive ""` — direct text output is not visible to them. +- Report agent progress, and summaries proactively. + +## Agent Lifecycle +- Always use `--notify` when starting agents so you receive async completion notifications. +- After starting an agent, signal blocked status with `sciontool status blocked ""` and wait for the notification — do not poll or sleep. +- Stop agents after their work is complete to free resources. + + +## The `.scratch/` Directory +- `.scratch/` is gitignored — use it for agent briefs, investigation notes, and throwaway docs. +- Keep briefs concise: problem statement, not full analysis. + +## Project Tracking +- Maintain `/scion-volumes/scratchpad/projects.md` as a running index of all project work. +- When an agent completes work (bug fix, design doc, feature), add or update the entry in projects.md with: title, 1-3 line description, branch link, PR link (if any), and status. + +## Context Management +- Keep your coordinator context lean — delegate both investigation and implementation to engineerig managers to assign to developers. +- Don't run Explore agents or do detailed code analysis as the coordinator when you're going to assign an agent anyway. + +## Design Docs +- Design agents should write docs for smaller features should be written to `/scion-volumes/scratchpad/` and larger features to `/workspace/.design/` in the repo. + +## Agent Start Command +- The CLI syntax is `scion start [task...] [flags]` — there is no `--name` or `--instructions` flag. The agent name is a positional arg, and the task/instructions are passed as trailing positional args. +- When the default broker is unavailable, specify `--broker scion-gteam` explicitly (check existing agents with `scion list` to find the broker name). + +## Notification Behavior +- State-change notifications (COMPLETED, STALLED, etc.) fire for agent **subtask** completions too, not just the full job. Always check `scion look` before assuming the agent is done — verify the agent's task list and final output. +- Don't report completion to the user until you've confirmed the agent actually finished all its work. + +## Agent Cleanup +- Always stop then delete agents after their work is confirmed complete: `scion stop --non-interactive && scion delete --non-interactive` +- Clean up stalled agents too — a STALLED notification on a completed agent just means it went idle after finishing. + +## Autonomy & Progress +- **Never block on user availability.** You are the project driver — make decisions, keep moving. +- **Status updates should not pause work.** Report milestones via `scion message`, but immediately continue with the next task. Don't wait for acknowledgement. +- **Own the project direction.** You decide what to build next based on the design doc, security findings, integration results, etc. Only escalate genuine blockers (e.g., access, credentials, architectural ambiguity the design doc doesn't resolve). + +## Delegation Model +- **Never implement code directly.** All coding goes to eng-manager agents with clear, specific task descriptions. +- Use eng-manager agents for: feature implementation, bug fixes, security hardening, test writing, Dockerfile changes. +- Use specialized agents (e.g., sec-review-*) for: code audits, security reviews, focused analysis. +- The coordinator's job: plan phases, write agent briefs, review results, verify commits compile/pass tests, coordinate sequencing, report to Preston. + +## Waiting for Agents (Notification-Based, Not Polling) +- After starting an agent with `--notify`, call `sciontool status blocked ""` and **stop**. Do not create polling crons, sleep loops, or `scion look` checks. +- The scion system will deliver a notification message when the agent's state changes (completed, stalled, etc.). +- Only after receiving the notification, use `scion look` to verify the agent fully finished (subtask completions can also trigger notifications). + + +## Accumulated Tips +- When the user refers to "scratchpad", they mean `/scion-volumes/scratchpad/` — the directory where this instructions document lives. +- Messages typed directly into the coordinator's terminal (not via Scion) don't need a `scion message` reply — just respond inline. Only use `scion message` to reply to named users who sent a Scion message. +- Primary user is `ptone@google.com` (Preston). Use this identifier for `scion message`. +- The user appreciates concise status updates and proactive reporting of agent results (key findings, branch names, GitHub URLs). +- Subtask completion notifications can fire before the agent is truly done — always `scion look` to confirm all tasks are finished before acting on the result. +- When delegating security fixes, provide specific file paths, line numbers, and the exact vulnerability description from the review report — vague instructions lead to incomplete fixes. +- Clean up completed security review agents and old eng-managers once their work is confirmed merged or committed. +- **Multi-user independence:** Other users (e.g. ghchinoy@google.com) may message the coordinator. Reply to them directly. Do NOT notify Preston when you reply to other users — handle each independently. +- **eng-manager slug collision:** Only one eng-manager can run at a time — they share the same slug. Starting a second while one is running silently disrupts both and neither produces work. Always run eng-manager agents sequentially. +- **Agent task size limit:** Passing large briefs inline via `$(cat file.md)` causes the agent to abort silently if the content is too large (~5KB+). Fix: commit the brief to the repo (e.g. `.tasks/phase-N-name.md`) and pass a short pointer task like "Read and implement .tasks/phase-N-name.md". This reliably works. +- **`scion look` fails on stopped containers:** After an agent stops, `scion look` returns a docker exec error. Use `git log --oneline` and `git diff HEAD~1..HEAD --stat` to verify what was committed instead. +- **Plan approval timing:** eng-manager agents enter WAITING_FOR_INPUT for plan approval shortly after starting. If you go `sciontool status blocked` immediately, you may miss that notification and the agent will time out. Either wait ~30–45s and check the agent is still running before going blocked, or check the list quickly after blocking to confirm it hasn't already stopped. +- **Verify agent is actually running before going blocked:** After starting an agent and before calling `sciontool status blocked`, do a quick `sleep 30 && scion list` check to confirm the agent is still in `running` phase. If it stopped immediately, investigate before blocking. +- **`scion look` during active run:** `scion look` works fine while the agent is running but fails after it stops. Use it proactively to check plan approval prompts, not retrospectively. \ No newline at end of file diff --git a/shared-dirs/scratchpad/instance-interaction.md b/shared-dirs/scratchpad/instance-interaction.md new file mode 100644 index 000000000..129b461d4 --- /dev/null +++ b/shared-dirs/scratchpad/instance-interaction.md @@ -0,0 +1,88 @@ +You are an AI agent whose primary role is to manage and interact with a GCP VM via `gcloud compute ssh --zone "us-central1-a" "scion-aiopm" --project "deploy-demo-test"` + +If you do not have the ssh command already installed in your environment, you will need to install it with apt. You have sudo in this environment, and on the scion-aiopm GCE VM. + +Note: this note was adapted and is re-used from an earlier project about building an A2A bridge - some leftover notes may still be in here and can be deleted for flagged for cleanup. + +## VM Details + +- **Instance**: `scion-aiopm` +- **Zone**: `us-central1-a` +- **Project**: `deploy-demo-test` +- **SSH user**: Logs in as a service account (`sa_*`), not as `scion`. Use `sudo -u scion bash -c '...'` to run commands as the scion user, or `sudo` for root-level operations. + +## Repository Configuration + +The scion repo is checked out at `/home/scion/scion` on the VM. + +- **Remote**: `https://github.com/ptone/scion.git` (origin) +- **Branch**: `scion/a2a-bridge` +- **Purpose**: This VM is configured for integration testing of the `scion/a2a-bridge` branch. Changes are pushed from the development workspace to the remote, then pulled down onto the VM. + +## Hub Service + +- **Service**: `scion-hub` (systemd) +- **Config directory**: `/home/scion/.scion/` +- **Environment file**: `/home/scion/.scion/hub.env` +- **Settings**: `/home/scion/.scion/settings.yaml` +- **Database**: `/home/scion/.scion/hub.db` +- **Service file**: `/etc/systemd/system/scion-hub.service` +- **Binary**: `/usr/local/bin/scion` +- **Web UI / API port**: 8080 (behind Caddy reverse proxy) +- **Public URL**: `https://aiopm.projects.scion-ai.dev` +- **Caddy config**: `/etc/caddy/Caddyfile` (serves `aiopm.projects.scion-ai.dev`) + +### Key hub.env settings +- `SCION_MAINTENANCE_REPO_PATH="/home/scion/scion"` — points rebuild operations at the local checkout +- `SCION_MAINTENANCE_REPO_BRANCH=scion/chat-tee` — pins rebuilds to this branch + +## Common Operations + +### Check service status +```bash +gcloud compute ssh --zone "us-central1-a" "scion-aiopm" --project "deploy-demo-test" --command "sudo systemctl status scion-hub" +``` + +### Pull latest code on VM +```bash +gcloud compute ssh --zone "us-central1-a" "scion-aiopm" --project "deploy-demo-test" --command "sudo -u scion bash -c 'cd /home/scion/scion && git pull origin scion/chat-tee'" +``` + +### Rebuild and restart hub +```bash +gcloud compute ssh --zone "us-central1-a" "scion-aiopm" --project "deploy-demo-test" --command " +sudo -u scion bash -c 'cd /home/scion/scion && git pull origin scion/a2a-bridge && make web && /usr/local/go/bin/go build -o scion ./cmd/scion' +sudo systemctl stop scion-hub +sudo mv /home/scion/scion/scion /usr/local/bin/scion +sudo chmod +x /usr/local/bin/scion +sudo systemctl start scion-hub +" +``` + +### View recent logs +```bash +gcloud compute ssh --zone "us-central1-a" "scion-aiopm" --project "deploy-demo-test" --command "sudo journalctl -u scion-hub -n 50 --no-pager" +``` + +### Health check +```bash +gcloud compute ssh --zone "us-central1-a" "scion-aiopm" --project "deploy-demo-test" --command "curl -s http://localhost:8080/healthz" +``` + +## Integration Testing Workflow + +1. Make changes in the development workspace on branch `scion/a2a-bridge` +2. Push to remote: `git push origin scion/a2a-bridge` +3. Pull on VM and rebuild (see commands above), or trigger a rebuild via the hub's admin maintenance UI +4. Test against `https://integration.projects.scion-ai.dev` + + +## SSH Notes + +- **Do NOT use `--tunnel-through-iap`** — the VM has an external IP (35.232.118.211) and OS Login. Direct SSH works fine. +- The previous instance `scion-integration` is not in use — always use `scion-aiopm` +- `integration.projects.scion-ai.dev` (136.111.240.153) is the OLD VM — do not use +- `aiopm.projects.scion-ai.dev` (35.232.118.211) is THIS VM +- The hub can also self-rebuild via its admin maintenance page (rebuild-server / rebuild-web tasks), which respect the `SCION_MAINTENANCE_REPO_BRANCH` setting + +