diff --git a/agent-contract/inner/client-control-plane-wire.md b/agent-contract/inner/client-control-plane-wire.md index 22e6e5ce..40625519 100644 --- a/agent-contract/inner/client-control-plane-wire.md +++ b/agent-contract/inner/client-control-plane-wire.md @@ -40,7 +40,7 @@ Client protobuf wire는 hello-only를 유지하고, post-bootstrap slot write는 - Principal bootstrap is an offline, host-local CLI operation. It returns the raw IOP token once; no WebSocket or remote HTTP bootstrap route exists. - Post-bootstrap slot and route lifecycle uses the dedicated credential HTTPS listener only. The listener presents the configured server certificate, and callers validate its CA and server name. - Every credential request requires `Authorization: Bearer `. Slot create and rotate accept the provider secret only as an `application/octet-stream` body; list, disable, enable, revoke, and route operations never return provider plaintext. -- Slot mutations use an explicit expected revision. A successful mutation rebuilds the secret-free principal projection and synchronizes it to connected managed Edges before the operation is treated as complete. +- Slot mutations and `POST /v1/credentials/routes/{route_id}/update` use an explicit `IOP-Expected-Revision`. Route update accepts the complete slot/alias/profile/upstream/resource-selector binding as JSON and applies it with CAS. A successful mutation rebuilds the secret-free principal projection and synchronizes it to connected managed Edges before the operation is treated as complete. - TLS private keys, the at-rest encryption keyring, the lease issuer private key, and raw principal/provider credentials are external mounts. They are never tracked config or Client wire fields. ## 필드 의미 diff --git a/agent-spec/control/control-plane-operations.md b/agent-spec/control/control-plane-operations.md index 3c598591..2d746ce9 100644 --- a/agent-spec/control/control-plane-operations.md +++ b/agent-spec/control/control-plane-operations.md @@ -54,7 +54,7 @@ Control Plane과 Client가 Edge 운영 상태를 어떻게 관찰하고 명령 | Control Plane server | HTTP health/readiness endpoint, Client proto-socket WebSocket endpoint, Edge proto-socket TCP endpoint를 함께 시작한다. | | Client hello wire | `/client` WebSocket proto-socket에서 `ClientHelloRequest`/`ClientHelloResponse` baseline을 제공한다. | | Edge outbound enrollment | Edge가 Control Plane TCP wire로 outbound 연결하고 `EdgeHelloRequest`를 보낸다. `edge_id`가 비어 있으면 거부된다. | -| credential HTTPS | Dedicated server-authenticated HTTPS exposes principal-authenticated slot and route lifecycle; provider plaintext is accepted only for create/rotate and is sealed before persistence. | +| credential HTTPS | Dedicated server-authenticated HTTPS exposes principal-authenticated slot and route lifecycle, including revision-CAS route binding updates; provider plaintext is accepted only for create/rotate and is sealed before persistence. | | host-local principal bootstrap | Initial principal/token bootstrap remains an offline CLI operation and returns the raw token only once; no remote bootstrap route exists. | | managed CP-Edge security | Credential mode requires CP-Edge mTLS and certificate workload identity agreement with `edge_id`. | | principal projection sync | Accepted hello carries the current secret-free projection, and durable mutations push strictly newer generations to connected managed Edges. | @@ -111,7 +111,7 @@ sequenceDiagram - Edge connector 설정은 `configs/edge.yaml`의 `control_plane` 섹션이다. - Managed Control Plane startup requires the credential HTTPS certificate/key, CP-Edge mTLS certificate/key/CA, durable database, external at-rest encryption keyring, lease issuer key id/private key, and bounded lease TTL/cache. Key and credential contents are mounted files, not tracked YAML values. - The projection contains active token hashes and safe route/slot/profile/model/revision facts only. Lease acquisition re-reads durable generation, route, and slot state, so revoke/disable/rotation and stale revisions fail closed even if a request observed an older snapshot. -- Slot/route mutation success includes projection synchronization. Operator lifecycle is bootstrap, create slot, bind route, rotate by expected revision, disable/enable when reversible suspension is needed, and revoke when permanent invalidation is required. +- Slot/route mutation success includes projection synchronization. Operator lifecycle is bootstrap, create slot, bind or update a route by expected revision, rotate by expected revision, disable/enable when reversible suspension is needed, and revoke when permanent invalidation is required. - Edge registry recent node events와 command audit는 bounded in-memory buffer다. durable audit store가 아니다. - `EdgeNodeSnapshot.connected`는 current dispatch-ready ownership과 같고 accepted/pending connection은 false다. configured offline provider는 `status=unavailable`, `health=offline`, capacity/in-flight/queued/long-context 관련 수치를 0으로 보고한다. - online provider의 in-flight는 Edge provider lease state, queued 값은 Edge queue의 candidate pressure다. current owner의 ready/disconnect 전이 뒤에만 관측 event가 relay되고 stale/rejected close는 live snapshot/event를 바꾸지 않는다. @@ -142,3 +142,4 @@ sequenceDiagram - 2026-07-07: 기능 목록 중심으로 축소하고 주요 흐름을 Mermaid sequence diagram으로 정리. - 2026-07-22: dispatch-ready connectivity와 configured offline provider snapshot, reconnect capacity 복구, current-owner event 의미를 현재 Edge status 구현과 계약 기준으로 동기화. - 2026-08-02: Synchronized credential HTTPS, host-local bootstrap, CP-Edge mTLS identity, active projection refresh, authenticated lease issuance, and durable slot/route lifecycle with current source. +- 2026-08-14: Added the implemented revision-CAS credential HTTPS route binding update to the current operations surface. diff --git a/apps/control-plane/cmd/control-plane/credential_http_handlers.go b/apps/control-plane/cmd/control-plane/credential_http_handlers.go index 1f838532..98c17e06 100644 --- a/apps/control-plane/cmd/control-plane/credential_http_handlers.go +++ b/apps/control-plane/cmd/control-plane/credential_http_handlers.go @@ -193,6 +193,29 @@ func (h credentialRouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request var result any var callErr error switch parts[1] { + case "update": + if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { + http.Error(w, "application/json is required", http.StatusUnsupportedMediaType) + return + } + var payload struct { + SlotID string `json:"slot_id"` + Alias string `json:"alias"` + ProfileID string `json:"profile_id"` + UpstreamModel string `json:"upstream_model"` + ResourceSelector string `json:"resource_selector"` + } + dec := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) + dec.DisallowUnknownFields() + if err := dec.Decode(&payload); err != nil { + http.Error(w, "invalid route request", http.StatusBadRequest) + return + } + result, callErr = h.service.UpdateRoute(r.Context(), token, credentialops.UpdateRouteInput{ + RouteID: parts[0], CurrentRevision: revision, SlotID: payload.SlotID, + Alias: payload.Alias, ProfileID: payload.ProfileID, UpstreamModel: payload.UpstreamModel, + ResourceSelector: payload.ResourceSelector, + }) case "disable": result, callErr = h.service.DisableRoute(r.Context(), token, parts[0], revision) case "enable": diff --git a/apps/control-plane/cmd/control-plane/credential_http_handlers_test.go b/apps/control-plane/cmd/control-plane/credential_http_handlers_test.go index 83a5f198..68b67df8 100644 --- a/apps/control-plane/cmd/control-plane/credential_http_handlers_test.go +++ b/apps/control-plane/cmd/control-plane/credential_http_handlers_test.go @@ -78,6 +78,42 @@ func TestCredentialHTTPRequiresPrincipalAndBoundsSecretBody(t *testing.T) { t.Fatalf("decode created slot: id=%q err=%v", created.ID, err) } + routeBody := `{"slot_id":"` + created.ID + `","alias":"ornith:35b","profile_id":"openai","upstream_model":"ornith:35b","resource_selector":"provider-slow"}` + createRoute := httptest.NewRequest(http.MethodPost, "/v1/credentials/routes", strings.NewReader(routeBody)) + createRoute.Header.Set("Authorization", "Bearer "+first.RawToken) + createRoute.Header.Set("Content-Type", "application/json") + createRouteResponse := httptest.NewRecorder() + mux.ServeHTTP(createRouteResponse, createRoute) + var route struct { + ID string `json:"ID"` + ResourceSelector string `json:"ResourceSelector"` + Revision int64 `json:"Revision"` + } + if err := json.Unmarshal(createRouteResponse.Body.Bytes(), &route); err != nil || createRouteResponse.Code != http.StatusOK || route.ID == "" || route.Revision != 0 { + t.Fatalf("create route: status=%d route=%+v err=%v body=%s", createRouteResponse.Code, route, err, createRouteResponse.Body.String()) + } + + updateBody := `{"slot_id":"` + created.ID + `","alias":"ornith:35b","profile_id":"openai","upstream_model":"ornith:35b","resource_selector":"provider-fast"}` + updateRoute := httptest.NewRequest(http.MethodPost, "/v1/credentials/routes/"+route.ID+"/update", strings.NewReader(updateBody)) + updateRoute.Header.Set("Authorization", "Bearer "+first.RawToken) + updateRoute.Header.Set("Content-Type", "application/json") + updateRoute.Header.Set("IOP-Expected-Revision", "0") + updateRouteResponse := httptest.NewRecorder() + mux.ServeHTTP(updateRouteResponse, updateRoute) + if err := json.Unmarshal(updateRouteResponse.Body.Bytes(), &route); err != nil || updateRouteResponse.Code != http.StatusOK || route.ResourceSelector != "provider-fast" || route.Revision != 1 || mutations != 3 { + t.Fatalf("update route: status=%d route=%+v mutations=%d err=%v body=%s", updateRouteResponse.Code, route, mutations, err, updateRouteResponse.Body.String()) + } + + staleRoute := httptest.NewRequest(http.MethodPost, "/v1/credentials/routes/"+route.ID+"/update", strings.NewReader(updateBody)) + staleRoute.Header.Set("Authorization", "Bearer "+first.RawToken) + staleRoute.Header.Set("Content-Type", "application/json") + staleRoute.Header.Set("IOP-Expected-Revision", "0") + staleRouteResponse := httptest.NewRecorder() + mux.ServeHTTP(staleRouteResponse, staleRoute) + if staleRouteResponse.Code != http.StatusConflict || mutations != 3 { + t.Fatalf("stale update status=%d mutations=%d body=%s", staleRouteResponse.Code, mutations, staleRouteResponse.Body.String()) + } + listOther := httptest.NewRequest(http.MethodGet, "/v1/credentials/slots", nil) listOther.Header.Set("Authorization", "Bearer "+second.RawToken) listOtherResponse := httptest.NewRecorder()