fix(control-plane): 관리 라우트 갱신 경로를 연결한다

운영 중 provider 선택을 직접 DB 조작 없이 CAS와 projection 동기화를 거쳐 교정할 수 있어야 한다.
This commit is contained in:
toki 2026-08-14 00:46:12 +09:00
parent 8dd0c9c297
commit f1b7c14610
4 changed files with 63 additions and 3 deletions

View file

@ -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 <IOP token>`. 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.
## 필드 의미

View file

@ -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.

View file

@ -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":

View file

@ -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()