feat(agent): add environment agent support, remove direct SP management - #37
feat(agent): add environment agent support, remove direct SP management#37gabriel-farache wants to merge 9 commits into
Conversation
8e4fe9b to
3a42161
Compare
|
Do not merged until the agent is ready |
PR Summary by QodoAdd environment agent provisioning via NATS; remove direct SP provider management
AI Description
Diagram
High-Level Assessment
Files changed (79)
|
Code Review by Qodo
1.
|
|
This PR removes Flagging a downstream collision: Is this migration coordinated with |
|
/agentic_review |
@jordigilh This PR will be ready to merge only once the PR on https://github.com/dcm-project/environment-agent will all be merged and that the agent behaviour will have been validated standalone, before that, we can't merge this PR as there would be no agent ready to consume and reply |
|
Code review by qodo was updated up to the latest commit 9ac411a |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 1d8fb4d |
…tion Three PR dcm-project#37 review gaps closed in the agent API, all confirmed against .ai/reviews/2026-08-07_pr37-thread-triage-v2.md: - Add `security: [bearerAuth]` to the agent OpenAPI spec so the existing request-validation middleware actually authenticates /agents calls, matching the catalog/policy/resource-manager APIs instead of silently skipping auth for this one domain. - Change `cost` from an unvalidated `number`/`float64` to a required string enum (low/medium-low/medium/medium-high/high). The real environment-agent already sends cost as one of these strings; the old float schema meant every real registration either failed OpenAPI validation or silently mismatched. No new Go-side validation is added since the OpenAPI middleware is the single point of defense for enum membership. - Replace the offset-only pagination stub with real opaque page_token/ next_page_token cursor pagination in the agent store, mirroring the existing internal/policy/store pagination pattern. Regenerated via oapi-codegen v2.7.0 (pinned to match this repo's predominant generated-code version rather than whatever is newest on PATH) to keep the codegen diff limited to the schema changes above. Assisted by: Cursor - Claude Sonnet 5 Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: gabriel-farache <gfarache@redhat.com>
Closes two related PR dcm-project#37 review threads (evaluation.go:136 and constraints.go:80): a routing policy with no agent_constraints could select any agent name at all, since ValidateAgent only ever checked allow-list/pattern constraints and never compared the decision against the request's own available_agents. Extend ValidateAgent to reject a selected_agent outside availableAgents independent of policy-declared constraints, and wire it in ahead of the existing allow-list checks. This requires plumbing structured agent info (name + environment) instead of bare names end-to-end: AgentLister.ListReadyAgents now returns []AgentInfo, threaded through PlacementService -> the policy client adapter -> EvaluationRequest.AvailableAgents. The OPA input's available_agents is now a list of {name, environment} objects rather than bare strings, and ValidateAgentEnvironment is called right after ValidateAgent succeeds using the matched agent's environment - this was previously impossible to wire up since no agent metadata reached the evaluation service at all. Assisted by: Cursor - Claude Sonnet 5 Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: gabriel-farache <gfarache@redhat.com>
…ore rehydration comments Three PR dcm-project#37 follow-ups to the placement self-healing path: Extract internal/placement/agent as a Client adapter around the agent store's ListReady, mirroring the existing policy/sprm adapter pattern, and replace the ad hoc agentNameLister wired up in run.go. Renamed agentLister/WithAgentLister to agentClient/WithAgentClient throughout PlacementService for the same consistency reason. The adapter depends on a narrow local readyLister interface (just ListReady), not the full agent store interface, keeping its compile-time surface honest about what it actually uses. Restored two "Step 3"/"Step 4" flow comments in RehydrateResource that had been dropped; git blame confirmed the apparent duplicate wording was original author intent, not a stray copy-paste, so both were kept as-is. ReEvaluateWithExclude now also proactively reassigns run-siblings still pointed at an excluded agent (previously only the primary resource was re-evaluated, leaving siblings to wait out their own independent sweep timeout) via a shared reassignOne helper. That sibling addition surfaced a real cross-replica race in the CAS this whole self-heal path already depended on: ReassignAndReset's CAS checked status but not agent_name, so two concurrent healers (e.g. a sweep-claimed primary heal and a sibling heal triggered from a different resource in the same run) could both pass a status-only check and both publish a create to a different agent for the same instance. A first attempt at fixing this added an agent_name CAS to ReassignAndReset, but threaded it by having ReassignAgent re-derive "expected current agent" from a fresh Get() at CAS time - which just reflects the latest writer's value and silently defeats the CAS against exactly the race it's meant to catch. Fixed by threading expectedCurrentAgent from the caller's own pre-reassignment observation (the resource's or sibling's agent_name at decision time) through PlacementService.reassignOne, sprm.Client, and InstanceService.ReassignAgent down to the CAS, so a stale observation is correctly rejected instead of overwritten. Validated with two rounds of independent multi-model review (Opus, Grok, Gemini, Codex) rotated across self-heal correctness, the handler split, the agent adapter, and subsystem-test-coverage angles, with each model reviewing angles it hadn't covered in the prior round. The CAS race was independently caught by two different models in round 2 after round 1 had judged the status-only CAS sufficient. Assisted by: Cursor - Sonnet 5 Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: gabriel-farache <gfarache@redhat.com>
9e04268 to
2f32c37
Compare
…-heal PR dcm-project#37 review thread r3761347505 flagged that sibling reassignment during self-heal (reassignExcludedSiblings in placement.go) had no end-to-end proof, only unit coverage. Adds four subsystem tests against the real NATS-driven sweep and ReassignAndReset CAS: a cancelled run-sibling stuck on the excluded agent gets proactively reassigned; a provisioning sibling is left untouched (CAS-ineligible); a sibling on a different, non-excluded agent is left alone; and a single-resource instance is marked failed once retries are exhausted. The cancelled-sibling design (rather than leaving it pending) is deliberate: a still-pending sibling would be independently healed by sweepPending in the same tick, making the test pass even without reassignExcludedSiblings. Cancelled instances are never re-scanned by either sweep, so movement is only possible via that code path. Also tunes AGENT_PENDING_REQUEST_MAX_RETRIES down to 1 in the sp subsystem compose file so the retry-exhaustion test reaches "failed" in ~10-15s instead of ~25-30s, verified not to affect existing self-heal tests (they resolve on their first retry attempt). Assisted by: Claude Code - Sonnet 5 Signed-off-by: gabriel-farache <gfarache@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
jordigilh
left a comment
There was a problem hiding this comment.
Re-reviewed after bf449ad. All 4 open threads from my side are addressed with real subsystem coverage (retry-exhaustion path, and 3 sibling-reassignment scenarios against the real NATS sweep + CAS) — resolved them. Also ran a broadened 68-linter sweep (dupl/goconst/gocyclo/err113/forcetypeassert/gosec/mnd/etc., generated code excluded) scoped to this PR's diff: 0 issues.
Approving. Two non-blocking-for-review items before merge though:
- DCO is failing:
a7e583cis missingSigned-off-by. auth-subsystem / blackboxshowscancelled(stalled at Keycloak startup, timed out) — passed on2f32c37two commits back and this commit only touchestest/subsystem/sp/*, so looks like infra flake, not a regression. Worth a re-run before merge.
Introduce agent-based resource provisioning via NATS and remove all direct Service Provider (SP) management, completing the transition to an agent-only architecture. Each resource in a run is now routed to an agent (policy.SelectedAgent) rather than a provider, with per-resource SPRM provisioning using AgentName. - Agent domain (internal/agent/): store, service, HTTP handler (split into handler/convert/errors, matching the SP pattern), health monitor, OpenAPI spec and generated server/client/types - NATS integration (internal/sp/messaging/, internal/sp/consumer/, internal/sp/pending/): CloudEvents publish/consume and sweep-driven pending/queued retries and self-heal, including a CAS fix for a race that let two concurrent healers reassign the same instance to different agents, and proactive reassignment of run-siblings stuck on an excluded agent - Policy: ServiceTypes and Cost threaded into agent evaluation so Rego can see agent capability, with a hard capability pre-filter and explicit rejection when no capable or all-excluded agents remain (previously silent/fail-open) - Placement: agent-aware routing in CreateRun/RehydrateResource/ ReEvaluateWithExclude; ProviderName replaced by AgentName across the store model, DTO, and ListRun filter; fail closed instead of open when listing ready agents errors - SP resource manager: CreateInstance publishes to NATS instead of calling provider HTTP, with AgentName on ServiceTypeInstance and a matching agent_name filter on ListInstances - Removed all provider-specific code (internal/sp/*/provider, api/sp/*/provider, pkg/sp/client/provider) - Subsystem coverage for NATS response events, self-heal sweep re-routing, and sibling reassignment during self-heal (PR dcm-project#37 review thread r3761347505) Assisted by: Cursor - Sonnet 5 Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: gabriel-farache <gfarache@redhat.com>
Resource lifecycle events (create/queue/cancel/delete) had audit gaps: Publisher never logged publish outcomes, and several response-consumer and sweep transitions were silent on success, so a resource's history couldn't be reconstructed from logs alone. Add centralized publish logging in Publisher.publish, success-path transition logs across response_consumer.go and sweep.go, and unify resource_id/ce_type log keys to instance_id/event_type for consistent querying. Validated with two rounds of independent multi-model review (Opus, Grok, Gemini, Codex) across audit-completeness and code-quality/ operational-safety angles. Round findings closed: missing fields on failure/stale paths, a data race in the test log-capture harness (confirmed via -race), and incomplete test assertions. Assisted by: Cursor - Sonnet 4.5 Signed-off-by: gabriel-farache <gfarache@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
@jordigilh I'll rewrite commit history to fix DCO and the CI will re-run so I hope all will pass |
bf449ad to
3611cfa
Compare
|
dcm-project/shared-workflows#32 to try to debug why the failing CI cancels and is not progressing |
The blackbox job's log-collection step only ran on failure(), so a timeout/cancellation (as seen in dcm-project/control-plane#37) left no diagnostic output about which service hung. Switch it to always() so it runs (with GitHub's ~5min cancellation grace period) even when the job is cancelled, and have it dump container status, health check history, and compose logs. Also pin podman-compose to 1.6.0 instead of installing unpinned, to remove version drift as a variable. Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: gabriel-farache <gfarache@redhat.com>
…agnostics` (shared-workflows#32) so a 25m timeout/cancel still dumps podman ps, health, and per-container logs. Main still skips Collect logs on cancel. Will revert to @main once dcm-project#32 is merged (or when we have enough signal). Not intended to ship. Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
| func (s *ServiceTypeInstanceStore) MarkDeletionCompleteFromAgent(ctx context.Context, id string, agentName string) error { | ||
| result := s.db.WithContext(ctx). | ||
| Model(&model.ServiceTypeInstance{}). | ||
| Where("id = ? AND agent_name = ?", id, agentName). |
There was a problem hiding this comment.
TOCTOU: a completed deletion can be silently regressed to FAILED
MarkDeletionCompleteFromAgent (and MarkDeletionComplete above it) correctly update deletion_status unconditionally to DELETED once the DB accepts the row — good, that part is atomic and race-free on its own.
The problem is MarkDeletionFailed (a few lines above, unchanged by this PR):
func (s *ServiceTypeInstanceStore) MarkDeletionFailed(ctx context.Context, id string) error {
result := s.db.WithContext(ctx).
Model(&model.ServiceTypeInstance{}).
Where("id = ?", id).
Update("deletion_status", DeletionStatusFailed)
...
}Unlike every other transition here, MarkDeletionFailed has no guard on the current deletion_status — it will happily stamp FAILED over a row that a concurrent goroutine just marked DELETED.
Concrete race, now made easier to hit by this PR's new agent-scoped completion path:
- Deletion is in flight; some retry/backoff loop is about to time out and call
MarkDeletionFailed(id). - Concurrently, the CE-driven handler receives the real completion event and calls
MarkDeletionCompleteFromAgent(id, agentName)→ row is nowDELETED. - Step 1's
MarkDeletionFailedcall, still in flight, lands after step 2 and overwritesdeletion_statusback toFAILED— with no error, sinceRowsAffectedis 1 either way.
Net effect: an instance that's actually deleted (and possibly already GC'd downstream) reports FAILED forever, since nothing re-checks deletion_status afterward.
Suggested fix: give MarkDeletionFailed the same guard pattern used elsewhere — e.g. Where("id = ? AND deletion_status NOT IN (?)", id, []string{"DELETED"}) — and treat RowsAffected == 0 as "already resolved, not an error" the same way MarkDeletionComplete/MarkDeletionCompleteFromAgent treat it via ErrInstanceNotFound.
| } | ||
|
|
||
| return backoff.Retry(ctx, operation, s.retryOpts...) | ||
| return backoff.Retry(ctx, operation, s.retryOptsFunc()...) |
There was a problem hiding this comment.
Minor: duplicate-key errors from Create aren't classified as ConflictError
Not a correctness bug (the DB's primary key still prevents any actual duplicate row/corruption), but worth flagging alongside the other TOCTOU-adjacent findings on this file: Create returns whatever error GORM/the driver surfaces for a duplicate-ID insert as-is, unclassified.
func (s *ServiceTypeInstanceStore) Create(ctx context.Context, instance model.ServiceTypeInstance) (*model.ServiceTypeInstance, error) {
operation := func() (*model.ServiceTypeInstance, error) {
if err := s.db.WithContext(ctx).Clauses(clause.Returning{}).Create(&instance).Error; err != nil {
return nil, err
}
return &instance, nil
}
return backoff.Retry(ctx, operation, s.retryOptsFunc()...)
}Two consequences:
- Callers get a generic
500instead of a409 Conflictfor what's actually a client-side "this ID already exists" error. backoff.Retrywill retry a unique-constraint violation several times before giving up, since nothing marks it non-retryable — pure wasted latency/DB load, since retrying can't ever make a duplicate-key error succeed.
Suggested fix: detect the duplicate-key case (e.g. errors.Is/driver-specific code check, same idea as apierrors.IsAlreadyExists used in the k8s-based SPs) and either return a ConflictError directly or mark it non-retryable for backoff.Retry, mirroring how MarkDeletionFailed's sibling methods already special-case "not found" via ErrInstanceNotFound.
| jobs: | ||
| auth-subsystem: | ||
| uses: dcm-project/shared-workflows/.github/workflows/black-box.yaml@main | ||
| uses: dcm-project/shared-workflows/.github/workflows/black-box.yaml@fix/blackbox-timeout-diagnostics |
There was a problem hiding this comment.
Pre-merge blocker: all 4 subsystem CI jobs are pinned to a diagnostic branch, not main
This line (and the identical pin on policy-subsystem, catalog-subsystem, sp-subsystem below it) points at dcm-project/shared-workflows/.github/workflows/black-box.yaml@fix/blackbox-timeout-diagnostics instead of @main. The commit that introduced this (0ad5a1a) is itself titled Tmp debug: subsystem black-box jobs now use @fix/blackbox-timeout-diagnostics, so this looks like debugging scaffolding rather than an intended permanent change.
Merging with this in place means every subsystem job on main (and release/v*) depends on someone else's WIP branch in shared-workflows — if that branch is force-pushed, rebased, or deleted after merge, this repo's CI breaks with no local signal why. Please revert these 4 lines back to @main (or to whatever tag/SHA shared-workflows lands the timeout fix on) before merging.
Flagging this as the one outstanding item from re-auditing the diff since the last approval — everything else since then (bf449ad's new subsystem tests, the setup_test.go fixture-embedding change) checked out fine.
jenniferubah
left a comment
There was a problem hiding this comment.
All my comments are for follow up smaller scoped PRs as this PR is already too large.
…meout-diagnostics`" This reverts commit 0ad5a1a.
MarkDeletionFailed now skips rows already DELETED so a late cleanup timeout cannot overwrite a completed agent ack. Assisted-By: Claude (Anthropic) Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
Unique-constraint hits on Create are no longer retried and surface as 409 instead of a generic 500 after backoff. Assisted-By: Claude (Anthropic) Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
Put back entry/list Debug and resolve-ID Warn/Error logs dropped in the agent rewrite. Assisted-By: Claude (Anthropic) Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
Temporary. Revert to @main after that PR merges. Assisted-By: Claude (Anthropic) Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
Signed-off-by: Gloria Ciavarrini <gciavarrini@redhat.com>
Summary
Replaces direct HTTP-based Service Provider (SP) provisioning with agent-based provisioning over NATS/CloudEvents, completing the move to an agent-only architecture. Each resource is now routed to an agent selected by policy (
SelectedAgent) instead of a provider, and instance creation is dispatched asynchronously to that agent instead of calling a provider's HTTP API directly.What changed
internal/agent/): registration, health monitoring, service-type matching, OpenAPI spec + generated client/server.internal/sp/messaging,internal/sp/consumer,internal/sp/pending): publishes create/delete/cancel CloudEvents to the selected agent's topic, consumes agent responses via JetStream, and retries pending/queued requests on timeout.CreateRun,RehydrateResource,ReEvaluateWithExclude;AgentNamereplaces provider selection onResourceandServiceTypeInstance.internal/sp/*/provider,api/sp/*/provider,pkg/sp/client/provider), thePENDING_PROVIDERstatus, and provider-specific error types.ON DELETE RESTRICT) enforce thatResource/ServiceTypeInstanceagent references stay valid.Test plan
go build ./...go test ./... -count=1make lint— 0 issuesspsubsystem test suite (agent-routed create/get/list/delete, including deferred and acknowledged hard-delete)Made with Cursor