Skip to content

Commit 7c30369

Browse files
authored
feat(runtime): add resolved runtime policy support (#192)
* feat(runtime): add resolved runtime policy support * fix(runtime): harden resolved runtime policy enforcement * fix(runtime): stabilize runtime policy rollouts * fix(runtime): preserve stable workload selectors * fix(runtime): reserve runtime policy for services
1 parent f769251 commit 7c30369

18 files changed

+1570
-18
lines changed

api/create_admission.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
163163
requestContext.InstanceClassID = selectedClass.ID
164164
}
165165
serviceAccountResolved := false
166+
runtimePolicyResolved := false
166167
resolver, response, err := s.extensions.resolve(
167168
ctx,
168169
extensionOperationPresetCreateResolve,
@@ -190,6 +191,7 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
190191
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
191192
}
192193
serviceAccountResolved = mutationResult.serviceAccountResolved
194+
runtimePolicyResolved = mutationResult.runtimePolicyResolved
193195
switch response.Status {
194196
case "", extensionStatusResolved:
195197
case extensionStatusUnresolved:
@@ -211,6 +213,10 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
211213
err := fmt.Errorf("instance class %q requires resolver-produced field %q", selectedClass.ID, requiredResolvedFieldServiceAccountName)
212214
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
213215
}
216+
if selectedClass.requiresResolvedField(requiredResolvedFieldRuntimePolicy) && normalizeSpritzRuntimePolicy(body.Spec.RuntimePolicy) != nil && !runtimePolicyResolved {
217+
err := fmt.Errorf("instance class %q requires resolver-produced field %q", selectedClass.ID, requiredResolvedFieldRuntimePolicy)
218+
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
219+
}
214220
if err := selectedClass.validateResolvedCreate(body); err != nil {
215221
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
216222
}
@@ -220,6 +226,7 @@ func (s *server) resolveCreateAdmission(ctx context.Context, principal principal
220226

221227
type presetCreateMutationResult struct {
222228
serviceAccountResolved bool
229+
runtimePolicyResolved bool
223230
}
224231

225232
func applyPresetCreateResolverMutations(body *createRequest, response extensionResolverResponseEnvelope) (presetCreateMutationResult, error) {
@@ -236,6 +243,20 @@ func applyPresetCreateResolverMutations(body *createRequest, response extensionR
236243
body.Spec.ServiceAccountName = resolvedServiceAccount
237244
result.serviceAccountResolved = true
238245
}
246+
resolvedRuntimePolicy := normalizeSpritzRuntimePolicy(
247+
response.Mutations.Spec.RuntimePolicy,
248+
)
249+
mergedRuntimePolicy, err := mergeSpritzRuntimePolicyStrict(
250+
body.Spec.RuntimePolicy,
251+
resolvedRuntimePolicy,
252+
)
253+
if err != nil {
254+
return presetCreateMutationResult{}, err
255+
}
256+
body.Spec.RuntimePolicy = mergedRuntimePolicy
257+
if resolvedRuntimePolicy != nil {
258+
result.runtimePolicyResolved = true
259+
}
239260
mergedAgentRef, err := mergeSpritzAgentRefStrict(body.Spec.AgentRef, response.Mutations.Spec.AgentRef)
240261
if err != nil {
241262
return presetCreateMutationResult{}, err

api/create_admission_test.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,116 @@ func TestCreateSpritzRejectsMissingRequiredResolvedFieldFromInstanceClass(t *tes
293293
}
294294
}
295295

296+
func TestCreateSpritzStoresResolvedRuntimePolicy(t *testing.T) {
297+
s := newCreateSpritzTestServer(t)
298+
var presetReceived map[string]any
299+
resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
300+
defer r.Body.Close()
301+
if err := json.NewDecoder(r.Body).Decode(&presetReceived); err != nil {
302+
t.Fatalf("failed to decode resolver request: %v", err)
303+
}
304+
w.Header().Set("Content-Type", "application/json")
305+
_ = json.NewEncoder(w).Encode(map[string]any{
306+
"status": "resolved",
307+
"mutations": map[string]any{
308+
"spec": map[string]any{
309+
"serviceAccountName": "dev-agent-ag-123",
310+
"runtimePolicy": map[string]string{
311+
"networkProfile": "dev-cluster-only",
312+
"mountProfile": "dev-default",
313+
"exposureProfile": "internal-acp",
314+
"revision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
315+
},
316+
},
317+
},
318+
})
319+
}))
320+
defer resolver.Close()
321+
322+
s.presets = presetCatalog{
323+
byID: []runtimePreset{{
324+
ID: "devbox",
325+
Name: "Devbox",
326+
Image: "example.com/devbox:latest",
327+
NamePrefix: "devbox",
328+
InstanceClass: "dev-runtime",
329+
}},
330+
}
331+
s.instanceClasses = instanceClassCatalog{
332+
byID: map[string]instanceClass{
333+
"dev-runtime": {
334+
ID: "dev-runtime",
335+
Version: "v1",
336+
Creation: instanceClassCreationPolicy{
337+
RequireOwner: true,
338+
RequiredResolvedFields: []string{
339+
requiredResolvedFieldRuntimePolicy,
340+
requiredResolvedFieldServiceAccountName,
341+
},
342+
},
343+
},
344+
},
345+
}
346+
s.extensions = extensionRegistry{
347+
resolvers: []configuredResolver{{
348+
id: "runtime-binding",
349+
extensionType: extensionTypeResolver,
350+
operation: extensionOperationPresetCreateResolve,
351+
match: extensionMatchRule{
352+
presetIDs: map[string]struct{}{"devbox": {}},
353+
},
354+
transport: configuredHTTPTransport{
355+
url: resolver.URL,
356+
timeout: time.Second,
357+
},
358+
}},
359+
}
360+
361+
e := echo.New()
362+
secured := e.Group("", s.authMiddleware())
363+
secured.POST("/api/spritzes", s.createSpritz)
364+
365+
body := []byte(`{
366+
"name":"devbox-lake",
367+
"presetId":"devbox",
368+
"spec":{}
369+
}`)
370+
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
371+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
372+
req.Header.Set("X-Spritz-User-Id", "user-1")
373+
rec := httptest.NewRecorder()
374+
375+
e.ServeHTTP(rec, req)
376+
377+
if rec.Code != http.StatusCreated {
378+
t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String())
379+
}
380+
contextPayload, ok := presetReceived["context"].(map[string]any)
381+
if !ok {
382+
t.Fatalf("expected resolver context payload, got %#v", presetReceived["context"])
383+
}
384+
if contextPayload["instanceClassId"] != "dev-runtime" {
385+
t.Fatalf("expected resolver instanceClassId dev-runtime, got %#v", contextPayload["instanceClassId"])
386+
}
387+
388+
stored := &spritzv1.Spritz{}
389+
if err := s.client.Get(context.Background(), client.ObjectKey{Name: "devbox-lake", Namespace: s.namespace}, stored); err != nil {
390+
t.Fatalf("expected created spritz resource: %v", err)
391+
}
392+
if stored.Spec.ServiceAccountName != "dev-agent-ag-123" {
393+
t.Fatalf("expected resolved service account name, got %q", stored.Spec.ServiceAccountName)
394+
}
395+
if stored.Spec.RuntimePolicy == nil {
396+
t.Fatal("expected resolved runtimePolicy to be stored")
397+
}
398+
if stored.Spec.RuntimePolicy.NetworkProfile != "dev-cluster-only" {
399+
t.Fatalf("expected runtime policy networkProfile, got %#v", stored.Spec.RuntimePolicy)
400+
}
401+
if stored.Spec.RuntimePolicy.Revision != "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
402+
t.Fatalf("expected runtime policy revision, got %#v", stored.Spec.RuntimePolicy)
403+
}
404+
}
405+
296406
func TestCreateSpritzProvisionerPresetResolverReplaysWithResolvedBinding(t *testing.T) {
297407
s := newCreateSpritzTestServer(t)
298408
configureProvisionerTestServer(s)
@@ -628,6 +738,159 @@ func TestCreateSpritzProvisionerRejectsManualServiceAccountForResolverRequiredFi
628738
}
629739
}
630740

741+
func TestCreateSpritzProvisionerRejectsManualRuntimePolicyForResolverRequiredField(t *testing.T) {
742+
s := newCreateSpritzTestServer(t)
743+
configureProvisionerTestServer(s)
744+
s.presets = presetCatalog{
745+
byID: []runtimePreset{{
746+
ID: "devbox",
747+
Name: "Devbox",
748+
Image: "example.com/devbox:latest",
749+
NamePrefix: "devbox",
750+
InstanceClass: "dev-runtime",
751+
}},
752+
}
753+
s.provisioners.allowedPresetIDs = map[string]struct{}{"devbox": {}}
754+
s.instanceClasses = instanceClassCatalog{
755+
byID: map[string]instanceClass{
756+
"dev-runtime": {
757+
ID: "dev-runtime",
758+
Version: "v1",
759+
Creation: instanceClassCreationPolicy{
760+
RequireOwner: true,
761+
RequiredResolvedFields: []string{
762+
requiredResolvedFieldRuntimePolicy,
763+
},
764+
},
765+
},
766+
},
767+
}
768+
769+
e := echo.New()
770+
secured := e.Group("", s.authMiddleware())
771+
secured.POST("/api/spritzes", s.createSpritz)
772+
773+
body := []byte(`{
774+
"presetId":"devbox",
775+
"ownerId":"user-123",
776+
"idempotencyKey":"manual-runtime-policy",
777+
"spec":{
778+
"runtimePolicy":{
779+
"networkProfile":"dev-cluster-only",
780+
"mountProfile":"dev-default",
781+
"exposureProfile":"internal-acp",
782+
"revision":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
783+
}
784+
}
785+
}`)
786+
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
787+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
788+
req.Header.Set("X-Spritz-User-Id", "zenobot")
789+
req.Header.Set("X-Spritz-Principal-Type", "service")
790+
req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner")
791+
rec := httptest.NewRecorder()
792+
793+
e.ServeHTTP(rec, req)
794+
795+
if rec.Code != http.StatusBadRequest {
796+
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
797+
}
798+
if !strings.Contains(rec.Body.String(), "resolver-produced field") {
799+
t.Fatalf("expected resolver-produced field error, got %s", rec.Body.String())
800+
}
801+
}
802+
803+
func TestCreateSpritzProvisionerRejectsManualRuntimePolicyWhenResolverOnlySetsServiceAccount(t *testing.T) {
804+
s := newCreateSpritzTestServer(t)
805+
configureProvisionerTestServer(s)
806+
resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
807+
defer r.Body.Close()
808+
w.Header().Set("Content-Type", "application/json")
809+
_ = json.NewEncoder(w).Encode(map[string]any{
810+
"status": "resolved",
811+
"mutations": map[string]any{
812+
"spec": map[string]string{
813+
"serviceAccountName": "dev-agent-ag-123",
814+
},
815+
},
816+
})
817+
}))
818+
defer resolver.Close()
819+
820+
s.presets = presetCatalog{
821+
byID: []runtimePreset{{
822+
ID: "devbox",
823+
Name: "Devbox",
824+
Image: "example.com/devbox:latest",
825+
NamePrefix: "devbox",
826+
InstanceClass: "dev-runtime",
827+
}},
828+
}
829+
s.provisioners.allowedPresetIDs = map[string]struct{}{"devbox": {}}
830+
s.instanceClasses = instanceClassCatalog{
831+
byID: map[string]instanceClass{
832+
"dev-runtime": {
833+
ID: "dev-runtime",
834+
Version: "v1",
835+
Creation: instanceClassCreationPolicy{
836+
RequireOwner: true,
837+
RequiredResolvedFields: []string{
838+
requiredResolvedFieldRuntimePolicy,
839+
requiredResolvedFieldServiceAccountName,
840+
},
841+
},
842+
},
843+
},
844+
}
845+
s.extensions = extensionRegistry{
846+
resolvers: []configuredResolver{{
847+
id: "runtime-binding",
848+
extensionType: extensionTypeResolver,
849+
operation: extensionOperationPresetCreateResolve,
850+
match: extensionMatchRule{
851+
presetIDs: map[string]struct{}{"devbox": {}},
852+
},
853+
transport: configuredHTTPTransport{
854+
url: resolver.URL,
855+
timeout: time.Second,
856+
},
857+
}},
858+
}
859+
860+
e := echo.New()
861+
secured := e.Group("", s.authMiddleware())
862+
secured.POST("/api/spritzes", s.createSpritz)
863+
864+
body := []byte(`{
865+
"presetId":"devbox",
866+
"ownerId":"user-123",
867+
"idempotencyKey":"manual-runtime-policy-with-resolver",
868+
"spec":{
869+
"runtimePolicy":{
870+
"networkProfile":"dev-cluster-only",
871+
"mountProfile":"dev-default",
872+
"exposureProfile":"internal-acp",
873+
"revision":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
874+
}
875+
}
876+
}`)
877+
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
878+
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
879+
req.Header.Set("X-Spritz-User-Id", "zenobot")
880+
req.Header.Set("X-Spritz-Principal-Type", "service")
881+
req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner")
882+
rec := httptest.NewRecorder()
883+
884+
e.ServeHTTP(rec, req)
885+
886+
if rec.Code != http.StatusBadRequest {
887+
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
888+
}
889+
if !strings.Contains(rec.Body.String(), requiredResolvedFieldRuntimePolicy) {
890+
t.Fatalf("expected runtimePolicy resolver-produced field error, got %s", rec.Body.String())
891+
}
892+
}
893+
631894
func TestProvisionerRestoreStoredPayloadRestoresResolverMetadata(t *testing.T) {
632895
tx := &provisionerCreateTransaction{body: &createRequest{}}
633896
raw, err := createResolvedProvisionerPayload(createRequest{

api/create_request_normalization.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ func (s *server) normalizeCreateRequest(_ context.Context, principal principal,
9999
if strings.TrimSpace(body.Spec.ServiceAccountName) != "" && !principalCanUseProvisionerFlow(principal) {
100100
return nil, newCreateRequestError(http.StatusForbidden, errors.New("spec.serviceAccountName is reserved for provisioner use"))
101101
}
102+
if normalizeSpritzRuntimePolicy(body.Spec.RuntimePolicy) != nil && !principal.isService() {
103+
return nil, newCreateRequestError(http.StatusForbidden, errors.New("spec.runtimePolicy is reserved for provisioner use"))
104+
}
102105
if !principal.isService() {
103106
if err := validateReservedCreateAnnotations(body.Annotations); err != nil {
104107
return nil, newCreateRequestError(http.StatusForbidden, err)
@@ -215,6 +218,10 @@ func validateCreateSpec(spec *spritzv1.SpritzSpec) error {
215218
if err := validateSpritzAgentRef(spec.AgentRef); err != nil {
216219
return err
217220
}
221+
spec.RuntimePolicy = normalizeSpritzRuntimePolicy(spec.RuntimePolicy)
222+
if err := validateSpritzRuntimePolicy(spec.RuntimePolicy); err != nil {
223+
return err
224+
}
218225
spec.ProfileOverrides = normalizeSpritzAgentProfile(spec.ProfileOverrides)
219226
if len(spec.SharedMounts) > 0 {
220227
normalized, err := normalizeSharedMounts(spec.SharedMounts)

api/extensions.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,9 @@ type extensionResolverMutations struct {
122122
}
123123

124124
type extensionResolverSpecMutation struct {
125-
ServiceAccountName string `json:"serviceAccountName,omitempty"`
126-
AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"`
125+
ServiceAccountName string `json:"serviceAccountName,omitempty"`
126+
AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"`
127+
RuntimePolicy *spritzv1.SpritzRuntimePolicy `json:"runtimePolicy,omitempty"`
127128
}
128129

129130
type configuredResolver struct {

api/instance_classes.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const (
1313
instanceClassAnnotationKey = "spritz.sh/instance-class"
1414
instanceClassVersionAnnotationKey = "spritz.sh/instance-class-version"
1515
requiredResolvedFieldServiceAccountName = "serviceAccountName"
16+
requiredResolvedFieldRuntimePolicy = "runtimePolicy"
1617
)
1718

1819
type instanceClass struct {
@@ -96,6 +97,8 @@ func normalizeRequiredResolvedField(raw string) string {
9697
switch strings.TrimSpace(raw) {
9798
case requiredResolvedFieldServiceAccountName:
9899
return requiredResolvedFieldServiceAccountName
100+
case requiredResolvedFieldRuntimePolicy:
101+
return requiredResolvedFieldRuntimePolicy
99102
default:
100103
return ""
101104
}
@@ -140,6 +143,10 @@ func (c instanceClass) validateResolvedCreate(body *createRequest) error {
140143
if strings.TrimSpace(body.Spec.ServiceAccountName) == "" {
141144
return fmt.Errorf("instance class %q requires resolved field %q", c.ID, field)
142145
}
146+
case requiredResolvedFieldRuntimePolicy:
147+
if normalizeSpritzRuntimePolicy(body.Spec.RuntimePolicy) == nil {
148+
return fmt.Errorf("instance class %q requires resolved field %q", c.ID, field)
149+
}
143150
default:
144151
return fmt.Errorf("instance class %q references unsupported resolved field %q", c.ID, field)
145152
}

0 commit comments

Comments
 (0)