Skip to content

Commit 23510b9

Browse files
authored
feat(api): add instance-class create admission (#132)
* docs(api): add runtime delegation policy to instance architecture * feat(api): add instance-class create admission * fix(api): harden create resolver normalization * fix(api): validate resolver and class config * fix(api): require resolver-backed service accounts
1 parent 7eb3746 commit 23510b9

14 files changed

Lines changed: 1986 additions & 53 deletions

api/create_admission.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"sort"
12+
"strings"
13+
14+
spritzv1 "spritz.sh/operator/api/v1"
15+
)
16+
17+
type presetCreateResolveInput struct {
18+
Owner spritzv1.SpritzOwner `json:"owner"`
19+
OwnerRef *ownerRef `json:"ownerRef,omitempty"`
20+
PresetInputs json.RawMessage `json:"presetInputs,omitempty"`
21+
Spec spritzv1.SpritzSpec `json:"spec"`
22+
}
23+
24+
func normalizePresetInputs(raw json.RawMessage) (json.RawMessage, error) {
25+
trimmed := bytes.TrimSpace(raw)
26+
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
27+
return nil, nil
28+
}
29+
decoder := json.NewDecoder(bytes.NewReader(trimmed))
30+
decoder.UseNumber()
31+
var value any
32+
if err := decoder.Decode(&value); err != nil {
33+
return nil, errors.New("presetInputs must be valid JSON")
34+
}
35+
var trailing any
36+
if err := decoder.Decode(&trailing); err != io.EOF {
37+
return nil, errors.New("presetInputs must be valid JSON")
38+
}
39+
objectValue, ok := value.(map[string]any)
40+
if !ok {
41+
return nil, errors.New("presetInputs must be a JSON object")
42+
}
43+
normalized, err := marshalCanonicalPresetInputValue(objectValue)
44+
if err != nil {
45+
return nil, errors.New("presetInputs must be valid JSON")
46+
}
47+
return normalized, nil
48+
}
49+
50+
func marshalCanonicalPresetInputValue(value any) (json.RawMessage, error) {
51+
switch typed := value.(type) {
52+
case map[string]any:
53+
keys := make([]string, 0, len(typed))
54+
for key := range typed {
55+
keys = append(keys, key)
56+
}
57+
sort.Strings(keys)
58+
var buffer bytes.Buffer
59+
buffer.WriteByte('{')
60+
for index, key := range keys {
61+
if index > 0 {
62+
buffer.WriteByte(',')
63+
}
64+
encodedKey, err := json.Marshal(key)
65+
if err != nil {
66+
return nil, err
67+
}
68+
buffer.Write(encodedKey)
69+
buffer.WriteByte(':')
70+
encodedValue, err := marshalCanonicalPresetInputValue(typed[key])
71+
if err != nil {
72+
return nil, err
73+
}
74+
buffer.Write(encodedValue)
75+
}
76+
buffer.WriteByte('}')
77+
return buffer.Bytes(), nil
78+
case []any:
79+
var buffer bytes.Buffer
80+
buffer.WriteByte('[')
81+
for index, item := range typed {
82+
if index > 0 {
83+
buffer.WriteByte(',')
84+
}
85+
encodedValue, err := marshalCanonicalPresetInputValue(item)
86+
if err != nil {
87+
return nil, err
88+
}
89+
buffer.Write(encodedValue)
90+
}
91+
buffer.WriteByte(']')
92+
return buffer.Bytes(), nil
93+
case json.Number:
94+
return []byte(typed.String()), nil
95+
case string, bool, nil:
96+
return json.Marshal(typed)
97+
default:
98+
return json.Marshal(typed)
99+
}
100+
}
101+
102+
func mergeMetadataStrict(existing, resolved map[string]string, fieldName string) (map[string]string, error) {
103+
if len(resolved) == 0 {
104+
return existing, nil
105+
}
106+
if existing == nil {
107+
existing = map[string]string{}
108+
}
109+
for key, value := range resolved {
110+
normalizedKey := strings.TrimSpace(key)
111+
if normalizedKey == "" {
112+
return nil, fmt.Errorf("%s keys must not be empty", fieldName)
113+
}
114+
if current, ok := existing[normalizedKey]; ok && current != value {
115+
return nil, fmt.Errorf("resolver attempted to overwrite %s %q", fieldName, normalizedKey)
116+
}
117+
existing[normalizedKey] = value
118+
}
119+
return existing, nil
120+
}
121+
122+
func (s *server) resolveCreateAdmission(ctx context.Context, principal principal, namespace string, body *createRequest) error {
123+
if body == nil {
124+
return nil
125+
}
126+
if body.PresetInputs != nil && strings.TrimSpace(body.PresetID) == "" {
127+
return newAdmissionError(http.StatusBadRequest, "presetInputs requires presetId", nil, errors.New("presetInputs requires presetId"))
128+
}
129+
130+
var selectedClass *instanceClass
131+
if preset, ok := s.presets.get(body.PresetID); ok && strings.TrimSpace(preset.InstanceClass) != "" {
132+
instanceClass, found := s.instanceClasses.get(preset.InstanceClass)
133+
if !found {
134+
return newAdmissionError(http.StatusInternalServerError, "instance class is not configured", nil, fmt.Errorf("preset %q references unknown instance class %q", preset.ID, preset.InstanceClass))
135+
}
136+
selectedClass = instanceClass
137+
if selectedClass.Version != "" {
138+
annotations, err := mergeMetadataStrict(body.Annotations, map[string]string{
139+
instanceClassAnnotationKey: selectedClass.ID,
140+
instanceClassVersionAnnotationKey: selectedClass.Version,
141+
}, "annotation")
142+
if err != nil {
143+
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
144+
}
145+
body.Annotations = annotations
146+
} else {
147+
annotations, err := mergeMetadataStrict(body.Annotations, map[string]string{
148+
instanceClassAnnotationKey: selectedClass.ID,
149+
}, "annotation")
150+
if err != nil {
151+
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
152+
}
153+
body.Annotations = annotations
154+
}
155+
}
156+
157+
requestContext := extensionRequestContext{
158+
Namespace: namespace,
159+
PresetID: body.PresetID,
160+
}
161+
requestedServiceAccount := strings.TrimSpace(body.Spec.ServiceAccountName)
162+
if selectedClass != nil {
163+
requestContext.InstanceClassID = selectedClass.ID
164+
}
165+
serviceAccountResolved := false
166+
resolver, response, err := s.extensions.resolve(
167+
ctx,
168+
extensionOperationPresetCreateResolve,
169+
principal,
170+
body.RequestID,
171+
requestContext,
172+
presetCreateResolveInput{
173+
Owner: body.Spec.Owner,
174+
OwnerRef: body.OwnerRef,
175+
PresetInputs: body.PresetInputs,
176+
Spec: body.Spec,
177+
},
178+
)
179+
if err != nil {
180+
return newAdmissionError(http.StatusInternalServerError, "create resolver failed", nil, err)
181+
}
182+
if resolver == nil {
183+
if body.PresetInputs != nil {
184+
return newAdmissionError(http.StatusBadRequest, "presetInputs require a matching preset create resolver", nil, errors.New("presetInputs require a matching preset create resolver"))
185+
}
186+
} else {
187+
var mutationResult presetCreateMutationResult
188+
mutationResult, err = applyPresetCreateResolverMutations(body, response)
189+
if err != nil {
190+
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
191+
}
192+
serviceAccountResolved = mutationResult.serviceAccountResolved
193+
switch response.Status {
194+
case "", extensionStatusResolved:
195+
case extensionStatusUnresolved:
196+
return newAdmissionError(http.StatusConflict, "preset inputs are unresolved", map[string]any{"error": "preset_create_unresolved"}, errors.New("preset inputs are unresolved"))
197+
case extensionStatusForbidden:
198+
return newAdmissionError(http.StatusForbidden, "preset create resolution is forbidden", map[string]any{"error": "preset_create_forbidden"}, errors.New("preset create resolution is forbidden"))
199+
case extensionStatusAmbiguous:
200+
return newAdmissionError(http.StatusConflict, "preset inputs are ambiguous", map[string]any{"error": "preset_create_ambiguous"}, errors.New("preset inputs are ambiguous"))
201+
case extensionStatusInvalid:
202+
return newAdmissionError(http.StatusBadRequest, "preset inputs are invalid", map[string]any{"error": "preset_create_invalid"}, errors.New("preset inputs are invalid"))
203+
case extensionStatusUnavailable:
204+
return newAdmissionError(http.StatusServiceUnavailable, "preset create resolution is unavailable", map[string]any{"error": "preset_create_unavailable"}, errors.New("preset create resolution is unavailable"))
205+
default:
206+
return newAdmissionError(http.StatusServiceUnavailable, "preset create resolution returned an unsupported status", nil, fmt.Errorf("unsupported preset create status %q", response.Status))
207+
}
208+
}
209+
if selectedClass != nil {
210+
if selectedClass.requiresResolvedField(requiredResolvedFieldServiceAccountName) && requestedServiceAccount != "" && !serviceAccountResolved {
211+
err := fmt.Errorf("instance class %q requires resolver-produced field %q", selectedClass.ID, requiredResolvedFieldServiceAccountName)
212+
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
213+
}
214+
if err := selectedClass.validateResolvedCreate(body); err != nil {
215+
return newAdmissionError(http.StatusBadRequest, err.Error(), nil, err)
216+
}
217+
}
218+
return nil
219+
}
220+
221+
type presetCreateMutationResult struct {
222+
serviceAccountResolved bool
223+
}
224+
225+
func applyPresetCreateResolverMutations(body *createRequest, response extensionResolverResponseEnvelope) (presetCreateMutationResult, error) {
226+
if body == nil {
227+
return presetCreateMutationResult{}, nil
228+
}
229+
result := presetCreateMutationResult{}
230+
if response.Mutations.Spec != nil {
231+
resolvedServiceAccount := strings.TrimSpace(response.Mutations.Spec.ServiceAccountName)
232+
if resolvedServiceAccount != "" {
233+
if current := strings.TrimSpace(body.Spec.ServiceAccountName); current != "" && current != resolvedServiceAccount {
234+
return presetCreateMutationResult{}, errors.New("preset create resolver attempted to overwrite spec.serviceAccountName")
235+
}
236+
body.Spec.ServiceAccountName = resolvedServiceAccount
237+
result.serviceAccountResolved = true
238+
}
239+
}
240+
annotations, err := mergeMetadataStrict(body.Annotations, response.Mutations.Annotations, "annotation")
241+
if err != nil {
242+
return presetCreateMutationResult{}, err
243+
}
244+
body.Annotations = annotations
245+
labels, err := mergeMetadataStrict(body.Labels, response.Mutations.Labels, "label")
246+
if err != nil {
247+
return presetCreateMutationResult{}, err
248+
}
249+
body.Labels = labels
250+
return result, nil
251+
}

0 commit comments

Comments
 (0)