fix(control-plane): 관리 라우트 갱신 경로를 연결한다
운영 중 provider 선택을 직접 DB 조작 없이 CAS와 projection 동기화를 거쳐 교정할 수 있어야 한다.
This commit is contained in:
parent
8dd0c9c297
commit
f1b7c14610
4 changed files with 63 additions and 3 deletions
|
|
@ -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.
|
||||
|
||||
## 필드 의미
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue