diff --git a/api/serving/v1alpha1/llmservice_types.go b/api/serving/v1alpha1/llmservice_types.go index b6d5e76..57ab46f 100644 --- a/api/serving/v1alpha1/llmservice_types.go +++ b/api/serving/v1alpha1/llmservice_types.go @@ -118,6 +118,13 @@ const ( // newest chart the repository holds. Any later new value does it again. AnnotationReResolve = "serving.modelsphere.dev/re-resolve" + // AnnotationSkipSchemaValidation, set to "true", installs and upgrades + // without checking the merged values against the chart's + // values.schema.json, as helm's --skip-schema-validation does. Any other + // value leaves the check on. Not part of the applied hash: turning it on + // re-applies only a release whose last apply did not succeed. + AnnotationSkipSchemaValidation = "serving.modelsphere.dev/skip-schema-validation" + // SwissAnnotationPrefix selects annotations copied into each history entry. // The operator otherwise ignores them. SwissAnnotationPrefix = "swiss.modelsphere.dev/" diff --git a/docs/llmservice.md b/docs/llmservice.md index b5a0035..b89d5d7 100644 --- a/docs/llmservice.md +++ b/docs/llmservice.md @@ -208,6 +208,7 @@ A layer with no `values`, or `values: null`, contributes nothing. | `serving.modelsphere.dev/force-delete-after` | Overrides `spec.deletion.forceDeleteAfter`. `0s` force-deletes a terminating pod on the next pass. | | `serving.modelsphere.dev/force-conflicts: ""` | Force field-manager conflicts on the apply for that generation only. Any other value is ignored. Recorded as `forceConflicts` in `status.history`. | | `serving.modelsphere.dev/re-resolve: ""` | Drop the pinned chart version once and resolve `spec.chart.version` to the newest version in range. See [Chart version](#chart-version). | +| `serving.modelsphere.dev/skip-schema-validation: "true"` | Install and upgrade without checking the merged values against the chart's `values.schema.json` (helm's `--skip-schema-validation`), for every apply while set. Any other value leaves the check on. An escape hatch for values the schema refuses. Not part of the applied hash: turning it on re-applies only a release whose last apply did not succeed. | | `swiss.modelsphere.dev/*` | Copied into each history entry and ControllerRevision. Otherwise ignored. | Annotation changes trigger a reconcile, as do generation changes and deletion. Status-only updates do not. diff --git a/internal/controller/serving/llmservice_cases_test.go b/internal/controller/serving/llmservice_cases_test.go index 14e9818..2eb8faa 100644 --- a/internal/controller/serving/llmservice_cases_test.go +++ b/internal/controller/serving/llmservice_cases_test.go @@ -167,6 +167,7 @@ type expectSpec struct { HelmTouched *int `json:"helmTouched,omitempty"` ListCalls *int `json:"listCalls,omitempty"` LastForce *bool `json:"lastForce,omitempty"` + LastSkipSchema *bool `json:"lastSkipSchemaValidation,omitempty"` GetsUnchanged *bool `json:"getsUnchanged,omitempty"` UpgradesUnchanged *bool `json:"upgradesUnchanged,omitempty"` Phase string `json:"phase,omitempty"` @@ -922,6 +923,7 @@ func (c *caseRun) checkHelm(exp *expectSpec, before helmSnap) { Expect(c.h.gets + c.h.installs + c.h.upgrades).To(Equal(*exp.HelmTouched)) } expectBool(c.h.lastForce, exp.LastForce) + expectBool(c.h.lastSkipSchema, exp.LastSkipSchema) if exp.GetsUnchanged != nil && *exp.GetsUnchanged { Expect(c.h.gets).To(Equal(before.gets)) } diff --git a/internal/controller/serving/llmservice_controller.go b/internal/controller/serving/llmservice_controller.go index 5a97654..3f56e28 100644 --- a/internal/controller/serving/llmservice_controller.go +++ b/internal/controller/serving/llmservice_controller.go @@ -401,9 +401,10 @@ func (r *LLMServiceReconciler) apply(ctx context.Context, obj *servingv1alpha1.L } ref := helm.ChartRef{Repo: obj.Spec.Chart.Repo, Name: obj.Spec.Chart.Name, Version: ver} opts := helm.ApplyOpts{ - ForceConflicts: obj.Annotations[servingv1alpha1.AnnotationForceConflicts] == strconv.FormatInt(obj.Generation, 10), - Credentials: creds, - Labels: releaseLabels(obj), + ForceConflicts: obj.Annotations[servingv1alpha1.AnnotationForceConflicts] == strconv.FormatInt(obj.Generation, 10), + SkipSchemaValidation: obj.Annotations[servingv1alpha1.AnnotationSkipSchemaValidation] == "true", + Credentials: creds, + Labels: releaseLabels(obj), } var ( rel *helm.Release diff --git a/internal/controller/serving/llmservice_controller_test.go b/internal/controller/serving/llmservice_controller_test.go index c69e874..3e17dbd 100644 --- a/internal/controller/serving/llmservice_controller_test.go +++ b/internal/controller/serving/llmservice_controller_test.go @@ -91,6 +91,7 @@ type fakeHelm struct { upgradeErr error getErr error lastForce bool + lastSkipSchema bool // keepFailed stores a failed release when Install returns installErr. keepFailed bool // hold blocks Install and Upgrade until closed. entered is signaled first. @@ -170,6 +171,7 @@ func (f *fakeHelm) gate() { func (f *fakeHelm) Install(_ context.Context, ns, name string, ch helm.ChartRef, values map[string]any, opts helm.ApplyOpts) (*helm.Release, error) { f.installs++ f.lastForce = opts.ForceConflicts + f.lastSkipSchema = opts.SkipSchemaValidation f.gate() if f.installErr != nil { if f.keepFailed { @@ -192,6 +194,7 @@ func (f *fakeHelm) Install(_ context.Context, ns, name string, ch helm.ChartRef, func (f *fakeHelm) Upgrade(_ context.Context, ns, name string, ch helm.ChartRef, values map[string]any, opts helm.ApplyOpts) (*helm.Release, error) { f.upgrades++ f.lastForce = opts.ForceConflicts + f.lastSkipSchema = opts.SkipSchemaValidation f.gate() if f.upgradeErr != nil { return nil, f.upgradeErr diff --git a/internal/controller/serving/testdata/controller/applies-again-once-skip-schema-validation-is-turned-on.yaml b/internal/controller/serving/testdata/controller/applies-again-once-skip-schema-validation-is-turned-on.yaml new file mode 100644 index 0000000..3aca99e --- /dev/null +++ b/internal/controller/serving/testdata/controller/applies-again-once-skip-schema-validation-is-turned-on.yaml @@ -0,0 +1,24 @@ +name: applies-again-once-skip-schema-validation-is-turned-on +description: a release the chart's schema refused applies once the skip-schema-validation annotation is set, with no spec change +steps: + - action: setHelm + installErr: "values don't meet the specifications of the schema(s)" + - action: create + version: "0.8.6" + - action: reconcile + expect: + errorContains: schema + installs: 1 + lastSkipSchemaValidation: false + - action: setHelm + installErr: "" + - action: update + annotations: + serving.modelsphere.dev/skip-schema-validation: "true" + expect: + generation: 1 + - action: reconcile + expect: + installs: 2 + lastSkipSchemaValidation: true + phase: Applied diff --git a/internal/controller/serving/testdata/controller/passes-skip-schema-validation-to-helm.yaml b/internal/controller/serving/testdata/controller/passes-skip-schema-validation-to-helm.yaml new file mode 100644 index 0000000..088f2b7 --- /dev/null +++ b/internal/controller/serving/testdata/controller/passes-skip-schema-validation-to-helm.yaml @@ -0,0 +1,24 @@ +name: passes-skip-schema-validation-to-helm +description: passes the skip-schema-validation annotation to the install and the upgrade it is set for; only "true" turns it on +steps: + - action: create + version: "0.8.6" + annotations: + serving.modelsphere.dev/skip-schema-validation: "true" + - action: reconcile + expect: + installs: 1 + lastSkipSchemaValidation: true + phase: Applied + - action: update + annotations: + serving.modelsphere.dev/skip-schema-validation: "yes" + layers: + - name: site + values: '{"replicaCount":2}' + expect: + generation: 2 + - action: reconcile + expect: + upgrades: 1 + lastSkipSchemaValidation: false diff --git a/internal/helm/helm.go b/internal/helm/helm.go index cd9e224..f9281a3 100644 --- a/internal/helm/helm.go +++ b/internal/helm/helm.go @@ -75,7 +75,10 @@ type Credentials struct { // ApplyOpts controls one install or upgrade. type ApplyOpts struct { ForceConflicts bool - Credentials *Credentials + // SkipSchemaValidation leaves the values unchecked against the chart's + // values.schema.json. + SkipSchemaValidation bool + Credentials *Credentials // Labels are stored on the release by Install and Upgrade. Labels map[string]string } @@ -180,6 +183,7 @@ func (c *Client) Install(ctx context.Context, ns, releaseName string, ch ChartRe inst.Namespace = ns inst.ServerSideApply = true inst.ForceConflicts = opts.ForceConflicts + inst.SkipSchemaValidation = opts.SkipSchemaValidation inst.WaitStrategy = kube.HookOnlyStrategy inst.Timeout = ApplyTimeout inst.Labels = maps.Clone(opts.Labels) @@ -209,6 +213,7 @@ func (c *Client) Upgrade(ctx context.Context, ns, releaseName string, ch ChartRe up.Namespace = ns up.ServerSideApply = "true" up.ForceConflicts = opts.ForceConflicts + up.SkipSchemaValidation = opts.SkipSchemaValidation up.WaitStrategy = kube.HookOnlyStrategy up.Timeout = ApplyTimeout up.Labels = maps.Clone(opts.Labels)