diff --git a/.openshift-tests-extension/openshift_payload_cluster-version-operator.json b/.openshift-tests-extension/openshift_payload_cluster-version-operator.json index 93c1add6a2..9fecf93c23 100644 --- a/.openshift-tests-extension/openshift_payload_cluster-version-operator.json +++ b/.openshift-tests-extension/openshift_payload_cluster-version-operator.json @@ -96,5 +96,18 @@ "source": "openshift:payload:cluster-version-operator", "lifecycle": "blocking", "environmentSelector": {} + }, + { + "name": "[Jira:\"Cluster Version Operator\"] cluster-version-operator should create proposals", + "labels": { + "Lifecycle:informing": {}, + "Serial": {} + }, + "resources": { + "isolation": {} + }, + "source": "openshift:payload:cluster-version-operator", + "lifecycle": "informing", + "environmentSelector": {} } ] \ No newline at end of file diff --git a/go.mod b/go.mod index 14e1b82497..30165efa9c 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kube-aggregator v0.35.1 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + sigs.k8s.io/controller-runtime v0.22.2 sigs.k8s.io/kustomize/kyaml v0.21.1 sigs.k8s.io/yaml v1.6.0 ) @@ -44,6 +45,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.4.2 // indirect @@ -100,7 +102,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/component-base v0.35.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - sigs.k8s.io/controller-runtime v0.22.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 87876ad318..ac67b7d5b9 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -25,6 +27,8 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= @@ -167,6 +171,10 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/pkg/cvo/availableupdates.go b/pkg/cvo/availableupdates.go index 9f1f88e9c9..76345f5e87 100644 --- a/pkg/cvo/availableupdates.go +++ b/pkg/cvo/availableupdates.go @@ -182,6 +182,10 @@ func (optr *Operator) syncAvailableUpdates(ctx context.Context, config *configv1 // queue optr.sync() to update ClusterVersion status optr.queue.Add(queueKey) + if optr.shouldEnableProposalController() { + // queue optr.proposalController.Sync() to manage proposals + optr.proposalController.Queue().Add(optr.proposalController.QueueKey()) + } return nil } diff --git a/pkg/cvo/availableupdates_test.go b/pkg/cvo/availableupdates_test.go index 77d3c2a2b6..844609643a 100644 --- a/pkg/cvo/availableupdates_test.go +++ b/pkg/cvo/availableupdates_test.go @@ -14,6 +14,7 @@ import ( "github.com/blang/semver/v4" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "sigs.k8s.io/controller-runtime/pkg/client/fake" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -29,6 +30,7 @@ import ( "github.com/openshift/cluster-version-operator/pkg/clusterconditions/always" "github.com/openshift/cluster-version-operator/pkg/clusterconditions/mock" "github.com/openshift/cluster-version-operator/pkg/featuregates" + "github.com/openshift/cluster-version-operator/pkg/proposal" "github.com/openshift/cluster-version-operator/pkg/risk" riskmock "github.com/openshift/cluster-version-operator/pkg/risk/mock" ) @@ -242,6 +244,15 @@ func TestSyncAvailableUpdates(t *testing.T) { expectedAvailableUpdates.RiskConditions = map[string][]metav1.Condition{"FourFiveSix": {{Type: "Applies", Status: "True", Reason: "Match"}}} optr.enabledCVOFeatureGates = featuregates.DefaultCvoGates("version") + optr.proposalController = proposal.NewController(func() ([]configv1.Release, []configv1.ConditionalUpdate, error) { + return nil, nil, nil + }, fake.NewClientBuilder().Build(), func(_ string) (*configv1.ClusterVersion, error) { + return &configv1.ClusterVersion{}, nil + }, func(name, namespace string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{}, nil + }, func() string { + return optr.release.Version + }) err := optr.syncAvailableUpdates(context.Background(), cvFixture) if err != nil { @@ -331,6 +342,15 @@ func TestSyncAvailableUpdates_ConditionalUpdateRecommendedConditions(t *testing. tc.modifyCV(cv, fixture.expectedConditionalUpdates[0]) optr.enabledCVOFeatureGates = featuregates.DefaultCvoGates("version") + optr.proposalController = proposal.NewController(func() ([]configv1.Release, []configv1.ConditionalUpdate, error) { + return nil, nil, nil + }, fake.NewClientBuilder().Build(), func(_ string) (*configv1.ClusterVersion, error) { + return &configv1.ClusterVersion{}, nil + }, func(name, namespace string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{}, nil + }, func() string { + return optr.release.Version + }) err := optr.syncAvailableUpdates(context.Background(), cv) if err != nil { @@ -767,6 +787,15 @@ func TestSyncAvailableUpdatesDesiredUpdate(t *testing.T) { cv := cvFixture.DeepCopy() cv.Spec.DesiredUpdate = tt.args.desiredUpdate optr.enabledCVOFeatureGates = featuregates.DefaultCvoGates("version") + optr.proposalController = proposal.NewController(func() ([]configv1.Release, []configv1.ConditionalUpdate, error) { + return nil, nil, nil + }, fake.NewClientBuilder().Build(), func(_ string) (*configv1.ClusterVersion, error) { + return &configv1.ClusterVersion{}, nil + }, func(name, namespace string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{}, nil + }, func() string { + return optr.release.Version + }) if err := optr.syncAvailableUpdates(context.Background(), cv); err != nil { t.Fatalf("syncAvailableUpdates() unexpected error: %v", err) } diff --git a/pkg/cvo/cvo.go b/pkg/cvo/cvo.go index e9eb1d3f3a..5e100c1565 100644 --- a/pkg/cvo/cvo.go +++ b/pkg/cvo/cvo.go @@ -8,6 +8,8 @@ import ( "sync" "time" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -53,6 +55,7 @@ import ( "github.com/openshift/cluster-version-operator/pkg/payload" "github.com/openshift/cluster-version-operator/pkg/payload/precondition" preconditioncv "github.com/openshift/cluster-version-operator/pkg/payload/precondition/clusterversion" + "github.com/openshift/cluster-version-operator/pkg/proposal" "github.com/openshift/cluster-version-operator/pkg/risk" "github.com/openshift/cluster-version-operator/pkg/risk/alert" ) @@ -213,6 +216,9 @@ type Operator struct { // risks holds update-risk source (in-cluster alerts, etc.) // that will be aggregated into conditional update risks. risks risk.Source + + // proposalController, if enabled, watches available and conditionals updates and manage proposals for them + proposalController *proposal.Controller } // New returns a new cluster version operator. @@ -242,6 +248,7 @@ func New( featureSet configv1.FeatureSet, cvoGates featuregates.CvoGateChecker, startingEnabledManifestFeatureGates sets.Set[string], + rtClient runtimeclient.Client, ) (*Operator, error) { eventBroadcaster := record.NewBroadcaster() eventBroadcaster.StartLogging(klog.Infof) @@ -317,6 +324,18 @@ func New( optr.configuration = configuration.NewClusterVersionOperatorConfiguration(operatorClient, operatorInformerFactory) + optr.proposalController = proposal.NewController(func() ([]configv1.Release, []configv1.ConditionalUpdate, error) { + availableUpdates := optr.getAvailableUpdates() + if availableUpdates == nil { + return nil, nil, nil + } + return availableUpdates.Updates, availableUpdates.ConditionalUpdates, nil + }, rtClient, cvInformer.Lister().Get, func(name, namespace string) (*corev1.ConfigMap, error) { + return cmConfigManagedInformer.Lister().ConfigMaps(namespace).Get(name) + }, func() string { + return optr.release.Version + }) + return optr, nil } @@ -470,6 +489,7 @@ func (optr *Operator) Run(runContext context.Context, shutdownContext context.Co defer optr.availableUpdatesQueue.ShutDown() defer optr.upgradeableQueue.ShutDown() defer optr.configuration.Queue().ShutDown() + defer optr.proposalController.Queue().ShutDown() stopCh := runContext.Done() klog.Infof("Starting ClusterVersionOperator with minimum reconcile period %s", optr.minimumUpdateCheckInterval) @@ -525,6 +545,19 @@ func (optr *Operator) Run(runContext context.Context, shutdownContext context.Co klog.Infof("The ClusterVersionOperatorConfiguration feature gate is disabled or HyperShift is detected; the configuration sync routine will not run.") } + if optr.shouldEnableProposalController() { + resultChannelCount++ + go func() { + defer utilruntime.HandleCrash() + wait.UntilWithContext(runContext, func(runContext context.Context) { + optr.worker(runContext, optr.proposalController.Queue(), optr.proposalController.Sync) + }, time.Second) + resultChannel <- asyncResult{name: "proposal controller"} + }() + } else { + klog.Infof("The proposal controller is disabled.") + } + resultChannelCount++ go func() { defer utilruntime.HandleCrash() @@ -595,6 +628,7 @@ func (optr *Operator) Run(runContext context.Context, shutdownContext context.Co optr.availableUpdatesQueue.ShutDown() optr.upgradeableQueue.ShutDown() optr.configuration.Queue().ShutDown() + optr.proposalController.Queue().ShutDown() } } @@ -1191,3 +1225,8 @@ func (optr *Operator) shouldReconcileAcceptRisks() bool { // HyperShift will be supported later if needed return optr.enabledCVOFeatureGates.AcceptRisks() && !optr.hypershift } + +// shouldEnableProposalController returns whether the CVO should enable the proposal controller +func (optr *Operator) shouldEnableProposalController() bool { + return optr.enabledCVOFeatureGates.Proposal() +} diff --git a/pkg/cvo/cvo_test.go b/pkg/cvo/cvo_test.go index 9d7a3aeccb..96cf244ebb 100644 --- a/pkg/cvo/cvo_test.go +++ b/pkg/cvo/cvo_test.go @@ -17,6 +17,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/google/go-cmp/cmp" "github.com/google/uuid" + ctrlruntimefake "sigs.k8s.io/controller-runtime/pkg/client/fake" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -46,6 +47,7 @@ import ( "github.com/openshift/cluster-version-operator/pkg/featuregates" "github.com/openshift/cluster-version-operator/pkg/internal" "github.com/openshift/cluster-version-operator/pkg/payload" + "github.com/openshift/cluster-version-operator/pkg/proposal" ) var ( @@ -2758,6 +2760,15 @@ func TestOperator_availableUpdatesSync(t *testing.T) { old := optr.availableUpdates ctx := context.Background() + optr.proposalController = proposal.NewController(func() ([]configv1.Release, []configv1.ConditionalUpdate, error) { + return nil, nil, nil + }, ctrlruntimefake.NewClientBuilder().Build(), func(_ string) (*configv1.ClusterVersion, error) { + return &configv1.ClusterVersion{}, nil + }, func(name, namespace string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{}, nil + }, func() string { + return optr.release.Version + }) err := optr.availableUpdatesSync(ctx, optr.queueKey()) if err != nil && tt.wantErr == nil { t.Fatalf("Operator.sync() unexpected error: %v", err) diff --git a/pkg/cvo/status.go b/pkg/cvo/status.go index 1687e8d060..6ebfc2030d 100644 --- a/pkg/cvo/status.go +++ b/pkg/cvo/status.go @@ -182,6 +182,13 @@ func (optr *Operator) syncStatus(ctx context.Context, original, config *configv1 if klog.V(6).Enabled() { klog.Infof("Apply config: %s", cmp.Diff(original, config)) } + if optr.shouldEnableProposalController() { + if len(config.Status.History) < len(original.Status.History) { + klog.V(internal.Normal).Infof("Reconciling proposals because ClusterVersion.status.history got pruned") + // queue optr.proposalController.Sync() to manage proposals + optr.proposalController.Queue().Add(optr.proposalController.QueueKey()) + } + } updated, err := applyClusterVersionStatus(ctx, optr.client.ConfigV1(), config, original) optr.rememberLastUpdate(updated) return err diff --git a/pkg/cvo/status_test.go b/pkg/cvo/status_test.go index 36a9dbf562..5f879d41f2 100644 --- a/pkg/cvo/status_test.go +++ b/pkg/cvo/status_test.go @@ -108,7 +108,7 @@ func TestOperator_syncFailingStatus(t *testing.T) { Desired: configv1.Release{ Version: "4.0.1", Image: "image/image:v4.0.1", - URL: configv1.URL("https://example.com/v4.0.1"), + URL: "https://example.com/v4.0.1", }, VersionHash: "", Conditions: []configv1.ClusterOperatorStatusCondition{ @@ -149,7 +149,7 @@ func TestOperator_syncFailingStatus(t *testing.T) { Desired: configv1.Release{ Version: "4.0.1", Image: "image/image:v4.0.1", - URL: configv1.URL("https://example.com/v4.0.1"), + URL: "https://example.com/v4.0.1", }, VersionHash: "", Conditions: []configv1.ClusterOperatorStatusCondition{ @@ -206,6 +206,7 @@ type fakeRiFlags struct { statusReleaseArchitecture bool cvoConfiguration bool acceptRisks bool + proposal bool } func (f fakeRiFlags) DesiredVersion() string { @@ -228,6 +229,10 @@ func (f fakeRiFlags) AcceptRisks() bool { return f.acceptRisks } +func (f fakeRiFlags) Proposal() bool { + return f.proposal +} + func TestUpdateClusterVersionStatus_FilteringMultipleErrorsForFailingCondition(t *testing.T) { ignoreLastTransitionTime := cmpopts.IgnoreFields(configv1.ClusterOperatorStatusCondition{}, "LastTransitionTime") type args struct { @@ -1084,7 +1089,7 @@ func Test_conditionalUpdateWithRiskNamesAndRiskConditions(t *testing.T) { desiredImage: "not-important", availableUpdates: &availableUpdates{ RiskConditions: map[string][]metav1.Condition{ - "TestAlert": []metav1.Condition{{ + "TestAlert": {{ Type: "Applies", Status: "True", Reason: "Alert:firing", diff --git a/pkg/featuregates/featurechangestopper_test.go b/pkg/featuregates/featurechangestopper_test.go index eecea760b7..4901d48fb1 100644 --- a/pkg/featuregates/featurechangestopper_test.go +++ b/pkg/featuregates/featurechangestopper_test.go @@ -98,6 +98,7 @@ func TestTechPreviewChangeStopper(t *testing.T) { fg.Status = configv1.FeatureGateStatus{} tt.startingCvoFeatureGates = CvoGates{unknownVersion: true} } + tt.startingCvoFeatureGates.proposal = fg.Spec.FeatureSet == configv1.TechPreviewNoUpgrade client := fakeconfigv1client.NewClientset(fg) diff --git a/pkg/featuregates/featuregates.go b/pkg/featuregates/featuregates.go index 6abc52c343..5849837cb4 100644 --- a/pkg/featuregates/featuregates.go +++ b/pkg/featuregates/featuregates.go @@ -45,6 +45,9 @@ type CvoGateChecker interface { // AcceptRisks controls whether the CVO reconciles spec.desiredUpdate.acceptRisks. AcceptRisks() bool + + // Proposal controls whether the CVO makes proposals for updates. + Proposal() bool } // CvoGates contains flags that control CVO functionality gated by product feature gates. The @@ -60,6 +63,7 @@ type CvoGates struct { statusReleaseArchitecture bool cvoConfiguration bool acceptRisks bool + proposal bool } func (c CvoGates) DesiredVersion() string { @@ -82,6 +86,10 @@ func (c CvoGates) AcceptRisks() bool { return c.acceptRisks } +func (c CvoGates) Proposal() bool { + return c.proposal +} + // DefaultCvoGates apply when actual features for given version are unknown func DefaultCvoGates(version string) CvoGates { return CvoGates{ @@ -90,6 +98,7 @@ func DefaultCvoGates(version string) CvoGates { statusReleaseArchitecture: false, cvoConfiguration: false, acceptRisks: false, + proposal: false, } } @@ -98,6 +107,9 @@ func DefaultCvoGates(version string) CvoGates { func CvoGatesFromFeatureGate(gate *configv1.FeatureGate, version string) CvoGates { enabledGates := DefaultCvoGates(version) + // We do not have a specific gate for the Proposal feature and use the TechPreviewNoUpgrade instead + enabledGates.proposal = gate.Spec.FeatureSet == configv1.TechPreviewNoUpgrade + for _, g := range gate.Status.FeatureGates { if g.Version != version { diff --git a/pkg/internal/constants.go b/pkg/internal/constants.go index eac0f36af3..568e038dbc 100644 --- a/pkg/internal/constants.go +++ b/pkg/internal/constants.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" + "github.com/blang/semver/v4" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/klog/v2" @@ -131,3 +133,24 @@ func IsAlertConditionReason(reason string) bool { func AlertConditionMessage(alertName, severity, state, impact, details string) string { return fmt.Sprintf("%s alert %s %s, %s. %s", severity, alertName, state, impact, details) } + +const ( + UpdateTypeMajor = "Major" + UpdateTypeMinor = "Minor" + UpdateTypePatch = "Patch" + UpdateTypeUnknown = "Unknown" +) + +// UpdateType returns the type of the update from the source to the target versions +func UpdateType(source, target semver.Version) string { + if source.Major < target.Major { + return UpdateTypeMajor + } + if source.Major == target.Major && source.Minor < target.Minor { + return UpdateTypeMinor + } + if source.LT(target) { + return UpdateTypePatch + } + return UpdateTypeUnknown +} diff --git a/pkg/payload/precondition/clusterversion/upgradeable.go b/pkg/payload/precondition/clusterversion/upgradeable.go index 74d4934915..f6ddb52921 100644 --- a/pkg/payload/precondition/clusterversion/upgradeable.go +++ b/pkg/payload/precondition/clusterversion/upgradeable.go @@ -150,11 +150,8 @@ func majorOrMinorUpdateFrom(status configv1.ClusterVersionStatus, currentVersion } if cond := resourcemerge.FindOperatorStatusCondition(status.Conditions, configv1.OperatorProgressing); cond != nil && cond.Status == configv1.ConditionTrue { - if v.Major < currentVersion.Major { - return completedVersion, "Major" - } - if v.Major == currentVersion.Major && v.Minor < currentVersion.Minor { - return completedVersion, "Minor" + if ut := internal.UpdateType(v, currentVersion); ut == internal.UpdateTypeMajor || ut == internal.UpdateTypeMinor { + return completedVersion, ut } } return "", "" diff --git a/pkg/proposal/api/v1alpha1/agent_types.go b/pkg/proposal/api/v1alpha1/agent_types.go new file mode 100644 index 0000000000..298b14d4df --- /dev/null +++ b/pkg/proposal/api/v1alpha1/agent_types.go @@ -0,0 +1,356 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// OutputFieldType is the JSON type of an output field in the agent's +// structured output schema. The operator merges these fields into the +// base output schema that every agent produces (diagnosis, proposal, +// verification plan, RBAC request), allowing adapters to request +// domain-specific structured data from the agent. +// +kubebuilder:validation:Enum=string;number;boolean;array;object +type OutputFieldType string + +const ( + OutputFieldTypeString OutputFieldType = "string" + OutputFieldTypeNumber OutputFieldType = "number" + OutputFieldTypeBoolean OutputFieldType = "boolean" + OutputFieldTypeArray OutputFieldType = "array" + OutputFieldTypeObject OutputFieldType = "object" +) + +// OutputField defines a top-level field in the agent's structured output. +// These fields are merged into the base output schema that the operator sends +// to the agent. Use outputFields to request adapter-specific structured data +// (e.g., an ACS adapter might add a "violationId" string field). +// +// Supports up to two levels of nesting: top-level fields can contain object +// properties or array items, and those can contain one more level of nesting. +// +// Example — adding an ACS violation ID and affected images to the output: +// +// outputFields: +// - name: violationId +// type: string +// description: "The ACS violation ID that triggered this proposal" +// required: true +// - name: affectedImages +// type: array +// description: "Container images flagged by the violation" +// items: +// type: string +// +// +kubebuilder:validation:XValidation:rule="self.type == 'array' ? has(self.items) : true",message="items is required when type is array" +// +kubebuilder:validation:XValidation:rule="self.type == 'object' ? has(self.properties) : true",message="properties is required when type is object" +// +kubebuilder:validation:XValidation:rule="has(self.enum) ? self.type == 'string' : true",message="enum is only valid for string fields" +type OutputField struct { + // name is the field name in the output JSON. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern="^[a-zA-Z][a-zA-Z0-9_]*$" + Name string `json:"name"` + + // type is the JSON type of this field. + // +kubebuilder:validation:Required + Type OutputFieldType `json:"type"` + + // description explains the purpose of this field (passed to the LLM). + // +optional + Description string `json:"description,omitempty"` + + // required indicates whether the agent must populate this field. + // +optional + Required bool `json:"required,omitempty"` + + // enum constrains string fields to a set of allowed values. + // +optional + Enum []string `json:"enum,omitempty"` + + // items defines the element schema when type is array. + // +optional + Items *OutputFieldItems `json:"items,omitempty"` + + // properties defines nested fields when type is object. + // +optional + // +listType=map + // +listMapKey=name + Properties []OutputSubField `json:"properties,omitempty"` +} + +// OutputSubField defines a nested field (one level deep) within an OutputField +// of type "object" or within array items of type "object". At this depth, +// array items are restricted to primitive types (string, number, boolean). +// +kubebuilder:validation:XValidation:rule="self.type == 'array' ? has(self.items) : true",message="items is required when type is array" +// +kubebuilder:validation:XValidation:rule="has(self.enum) ? self.type == 'string' : true",message="enum is only valid for string fields" +type OutputSubField struct { + // name is the field name in the output JSON. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern="^[a-zA-Z][a-zA-Z0-9_]*$" + Name string `json:"name"` + + // type is the JSON type of this field. + // +kubebuilder:validation:Required + Type OutputFieldType `json:"type"` + + // description explains the purpose of this field (passed to the LLM). + // +optional + Description string `json:"description,omitempty"` + + // required indicates whether the agent must populate this field. + // +optional + Required bool `json:"required,omitempty"` + + // enum constrains string fields to a set of allowed values. + // +optional + Enum []string `json:"enum,omitempty"` + + // items defines the element schema when type is array (primitive types only at this depth). + // +optional + Items *OutputSubFieldItems `json:"items,omitempty"` +} + +// OutputFieldItems defines the schema for array elements at the top level. +// Supports primitive types and objects (with nested OutputSubField properties). +type OutputFieldItems struct { + // type is the JSON type of array elements. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=string;number;boolean;object + Type OutputFieldType `json:"type"` + + // properties defines fields for object-typed array elements. + // +optional + // +listType=map + // +listMapKey=name + Properties []OutputSubField `json:"properties,omitempty"` +} + +// OutputSubFieldItems defines the schema for array elements at the nested +// level. Only primitive types (string, number, boolean) are allowed here +// to prevent unbounded schema depth. +type OutputSubFieldItems struct { + // type is the JSON type of array elements. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=string;number;boolean + Type OutputFieldType `json:"type"` +} + +// NOTE: MCPHeaderSourceType, MCPHeaderValueSource, MCPHeader, and MCPServerConfig +// are defined in olsconfig_types.go and shared by both OLSConfig and Agent. + +// SkillsSource defines an OCI image containing skills and optionally which +// paths within that image to mount. Skills are mounted as Kubernetes image +// volumes in the agent's sandbox pod. +// +// When paths is omitted, the entire image is mounted. When paths is specified, +// only those directories are mounted (each as a separate subPath volumeMount), +// allowing selective composition of skills from large shared images. +// +// Example — mount all skills from a custom image: +// +// skills: +// - image: quay.io/my-org/my-skills:latest +// +// Example — selectively mount two skills from a shared image: +// +// skills: +// - image: registry.ci.openshift.org/ocp/5.0:agentic-skills +// paths: +// - /skills/prometheus +// - /skills/cluster-update/update-advisor +type SkillsSource struct { + // image is the OCI image reference containing skills. + // The operator mounts this as a Kubernetes image volume (requires K8s 1.34+). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Image string `json:"image"` + + // paths restricts which directories from the image are mounted. + // Each path is mounted as a separate subPath volumeMount into the agent's + // skills directory. The last segment of each path becomes the mount name + // (e.g., "/skills/prometheus" mounts as "prometheus"). + // + // When omitted, the entire image is mounted as a single volume. + // +optional + Paths []string `json:"paths,omitempty"` +} + +// AgentSpec defines the desired state of Agent. +type AgentSpec struct { + // llmRef references a cluster-scoped LlmProvider CR that supplies the + // LLM backend for this agent. The operator resolves this reference at + // reconcile time and configures the sandbox pod with the provider's + // credentials and model. + // +kubebuilder:validation:Required + LLMRef corev1.LocalObjectReference `json:"llmRef"` + + // skills defines one or more OCI images containing skills to mount + // in the agent's sandbox pod. Each entry specifies an image and optionally + // which paths within that image to mount. The operator creates Kubernetes + // image volumes (requires K8s 1.34+) and mounts them into the agent's + // skills directory. + // + // Multiple entries allow composing skills from different images: + // + // skills: + // - image: registry.ci.openshift.org/ocp/5.0:agentic-skills + // paths: + // - /skills/prometheus + // - /skills/cluster-update/update-advisor + // - image: quay.io/my-org/custom-skills:latest + // + // +kubebuilder:validation:MinItems=1 + Skills []SkillsSource `json:"skills"` + + // mcpServers defines external MCP (Model Context Protocol) servers the + // agent can connect to for additional tools and context beyond its + // built-in skills. Each server is identified by name and URL. + // +optional + // +listType=map + // +listMapKey=name + MCPServers []MCPServerConfig `json:"mcpServers,omitempty"` + + // systemPromptRef references a ConfigMap containing the system prompt. + // The ConfigMap must have a key named "prompt" with the prompt text. + // The system prompt shapes the agent's behavior for its role (analysis, + // execution, or verification). When omitted, the agent uses a default + // prompt appropriate for its workflow step. + // +optional + SystemPromptRef *corev1.LocalObjectReference `json:"systemPromptRef,omitempty"` + + // outputFields defines additional structured output fields beyond the + // base schema that every agent produces (diagnosis, proposal, RBAC, + // verification plan). Use this to request domain-specific structured + // data from the agent (e.g., an ACS violation ID, affected images). + // Mutually exclusive with rawOutputSchema. + // +optional + // +listType=map + // +listMapKey=name + OutputFields []OutputField `json:"outputFields,omitempty"` + + // rawOutputSchema is an escape hatch that replaces the entire output + // schema with a raw JSON Schema object. Use this when outputFields + // cannot express the schema you need (e.g., deeply nested structures, + // conditional fields). Mutually exclusive with outputFields. + // +optional + RawOutputSchema *apiextensionsv1.JSON `json:"rawOutputSchema,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="LLM",type=string,JSONPath=`.spec.llmRef.name` +// +kubebuilder:printcolumn:name="Skills Image",type=string,JSONPath=`.spec.skills[0].image`,priority=1 +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Agent defines a complete agent configuration: which LLM to use, what +// skills to mount, optional MCP servers, and what system prompt to follow. +// It is the second link in the CRD chain (LlmProvider -> Agent -> Workflow +// -> Proposal) and is referenced by Workflow steps via agentRef. +// +// Agent is cluster-scoped. You typically create a few agents with different +// capabilities and assign them to workflow steps. For example, an analysis +// agent might use a capable model with broad diagnostic skills, while an +// execution agent uses a fast model with targeted remediation skills. +// +// Example — an analysis agent with selective skills and a system prompt: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Agent +// metadata: +// name: analyzer +// spec: +// llmRef: +// name: smart +// skills: +// - image: registry.ci.openshift.org/ocp/5.0:agentic-skills +// paths: +// - /skills/prometheus +// - /skills/cluster-ops +// - /skills/rbac-security +// systemPromptRef: +// name: analysis-prompt +// +// Example — an execution agent with a fast model: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Agent +// metadata: +// name: executor +// spec: +// llmRef: +// name: fast +// skills: +// - image: registry.ci.openshift.org/ocp/5.0:agentic-skills +// paths: +// - /skills/cluster-ops +// systemPromptRef: +// name: execution-prompt +// +// Example — an agent with MCP servers for extended tooling: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Agent +// metadata: +// name: analyzer-with-mcp +// spec: +// llmRef: +// name: smart +// skills: +// - image: registry.ci.openshift.org/ocp/5.0:agentic-skills +// mcpServers: +// - name: openshift +// url: https://mcp.openshift-lightspeed.svc:8443/sse +// timeout: 10 +// headers: +// - name: Authorization +// valueFrom: +// type: kubernetes +// - name: pagerduty +// url: https://mcp-pagerduty.example.com/sse +// headers: +// - name: X-API-Key +// valueFrom: +// type: secret +// secretRef: +// name: pagerduty-api-key +// systemPromptRef: +// name: analysis-prompt +type Agent struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec AgentSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// AgentList contains a list of Agent. +type AgentList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Agent `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Agent{}, &AgentList{}) +} diff --git a/pkg/proposal/api/v1alpha1/groupversion_info.go b/pkg/proposal/api/v1alpha1/groupversion_info.go new file mode 100644 index 0000000000..a595e86fdc --- /dev/null +++ b/pkg/proposal/api/v1alpha1/groupversion_info.go @@ -0,0 +1,37 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1alpha1 contains API Schema definitions for the agentic v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=agentic.openshift.io +package v1alpha1 + +import ( + "sigs.k8s.io/controller-runtime/pkg/scheme" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "agentic.openshift.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/pkg/proposal/api/v1alpha1/llmprovider_types.go b/pkg/proposal/api/v1alpha1/llmprovider_types.go new file mode 100644 index 0000000000..087f20a23c --- /dev/null +++ b/pkg/proposal/api/v1alpha1/llmprovider_types.go @@ -0,0 +1,142 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// LLMProviderType identifies the hosting backend for an LLM provider. +// +// Each backend has different authentication requirements and API endpoints: +// - "anthropic" — Direct Anthropic API. Secret needs ANTHROPIC_API_KEY. +// - "vertex" — Google Cloud Vertex AI. Secret needs service account JSON +// (GOOGLE_APPLICATION_CREDENTIALS) plus GCP_PROJECT and GCP_REGION. +// - "openai" — OpenAI-compatible API. Secret needs OPENAI_API_KEY. +// - "azure_openai" — Azure OpenAI Service. Secret needs AZURE_OPENAI_API_KEY, +// AZURE_OPENAI_ENDPOINT, and optionally AZURE_OPENAI_API_VERSION. +// - "bedrock" — AWS Bedrock. Secret needs AWS_ACCESS_KEY_ID, +// AWS_SECRET_ACCESS_KEY, and AWS_REGION. +type LLMProviderType string + +const ( + // LLMProviderAnthropic uses the Anthropic API directly. + LLMProviderAnthropic LLMProviderType = "anthropic" + // LLMProviderVertex uses Google Cloud Vertex AI as the LLM backend. + LLMProviderVertex LLMProviderType = "vertex" + // LLMProviderOpenAI uses an OpenAI-compatible API endpoint. + LLMProviderOpenAI LLMProviderType = "openai" + // LLMProviderAzureOpenAI uses the Azure OpenAI Service. + LLMProviderAzureOpenAI LLMProviderType = "azure_openai" + // LLMProviderBedrock uses AWS Bedrock. + LLMProviderBedrock LLMProviderType = "bedrock" +) + +// LlmProviderSpec defines the desired state of LlmProvider. +type LlmProviderSpec struct { + // type is the LLM provider backend (e.g., "vertex", "anthropic", "bedrock"). + // See LLMProviderType for the authentication requirements of each backend. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=anthropic;vertex;openai;azure_openai;bedrock + Type LLMProviderType `json:"type"` + + // credentialsSecretRef references a Secret in the operator's namespace + // containing the provider credentials. The required keys depend on the + // provider type (see LLMProviderType for details). The operator reads this + // secret and injects the credentials into agent sandbox pods at runtime. + // +kubebuilder:validation:Required + CredentialsSecretRef corev1.LocalObjectReference `json:"credentialsSecretRef"` + + // model is the LLM model identifier as recognized by the provider + // (e.g., "claude-opus-4-6", "claude-haiku-4-5", "gpt-4o"). + // Different agents can reference different LlmProviders to use different + // models for different tasks (e.g., a capable model for analysis, + // a fast model for execution). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Model string `json:"model"` + + // url is an optional override for the provider API endpoint. + // Most providers have well-known endpoints that the operator resolves + // automatically, so this is only needed for custom deployments or proxies. + // +optional + // +kubebuilder:validation:Pattern=`^https?://.*$` + URL string `json:"url,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +// +kubebuilder:printcolumn:name="Model",type=string,JSONPath=`.spec.model` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// LlmProvider defines an LLM provider configuration. It is the first link in +// the CRD chain (LlmProvider -> Agent -> Workflow -> Proposal) and is +// referenced by Agent resources via spec.llmRef. +// +// LlmProvider is cluster-scoped, meaning a single provider can be shared by +// agents across all namespaces. The operator uses the credentials and model +// to configure the LLM client inside agent sandbox pods. +// +// Typically you create a small number of providers representing different +// capability/cost tiers (e.g., "smart" for complex analysis, "fast" for +// routine execution) and then reference them from multiple Agent resources. +// +// Example — a high-capability provider for analysis tasks: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: LlmProvider +// metadata: +// name: smart +// spec: +// type: vertex +// model: claude-opus-4-6 +// credentialsSecretRef: +// name: llm-credentials +// +// Example — a fast, cost-efficient provider for execution tasks: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: LlmProvider +// metadata: +// name: fast +// spec: +// type: vertex +// model: claude-haiku-4-5 +// credentialsSecretRef: +// name: llm-credentials +type LlmProvider struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec LlmProviderSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// LlmProviderList contains a list of LlmProvider. +type LlmProviderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LlmProvider `json:"items"` +} + +func init() { + SchemeBuilder.Register(&LlmProvider{}, &LlmProviderList{}) +} diff --git a/pkg/proposal/api/v1alpha1/olsconfig_types.go b/pkg/proposal/api/v1alpha1/olsconfig_types.go new file mode 100644 index 0000000000..14690bdebc --- /dev/null +++ b/pkg/proposal/api/v1alpha1/olsconfig_types.go @@ -0,0 +1,871 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + resource "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1 "github.com/openshift/api/config/v1" +) + +// OLSConfigSpec defines the desired state of OLSConfig +type OLSConfigSpec struct { + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="LLM Settings" + LLMConfig LLMSpec `json:"llm"` + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="OLS Settings" + OLSConfig OLSSpec `json:"ols"` + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="OLS Data Collector Settings" + OLSDataCollectorConfig OLSDataCollectorSpec `json:"olsDataCollector,omitempty"` + // MCP Server settings + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="MCP Server Settings" + MCPServers []MCPServerConfig `json:"mcpServers,omitempty"` + // Skills configuration for agent SDK capability modes. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Skills Settings" + Skills *SkillsConfig `json:"skills,omitempty"` + // Sandbox configuration for execution modes (design, deploy, remediate). + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Sandbox Settings" + Sandbox *SandboxConfig `json:"sandbox,omitempty"` + // AlertManager webhook configuration for automated remediation proposals. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="AlertManager Settings" + AlertManager *AlertManagerConfig `json:"alertManager,omitempty"` + // Escalation configuration for filing support cases when remediation fails. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Escalation Settings" + // +kubebuilder:validation:XValidation:message="targetRepo is required when escalation is enabled",rule="!self.enabled || has(self.targetRepo)" + Escalation *EscalationConfig `json:"escalation,omitempty"` + // Feature Gates holds list of features to be enabled explicitly, otherwise they are disabled by default. + // possible values: MCPServer, ToolFiltering + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Feature Gates" + FeatureGates []FeatureGate `json:"featureGates,omitempty"` +} + +// +kubebuilder:validation:Enum=MCPServer;ToolFiltering +type FeatureGate string + +// OLSConfigStatus defines the observed state of OLS deployment. +type OLSConfigStatus struct { + // Conditions represent the state of individual components + // Always populated after first reconciliation + // +operator-sdk:csv:customresourcedefinitions:type=status + Conditions []metav1.Condition `json:"conditions"` + + // OverallStatus provides a high-level summary of the entire system's health. + // Aggregates all component conditions into a single status value. + // - Ready: All components are healthy + // - NotReady: At least one component is not ready (check conditions for details) + // Always set after first reconciliation + // +optional + // +kubebuilder:validation:Enum=Ready;NotReady + // +operator-sdk:csv:customresourcedefinitions:type=status + OverallStatus OverallStatus `json:"overallStatus,omitempty"` + + // DiagnosticInfo provides detailed troubleshooting information when deployments fail. + // Each entry contains pod-level error details for a specific component. + // This array is automatically populated when deployments fail and cleared when they recover. + // Only present during deployment failures. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=status + DiagnosticInfo []PodDiagnostic `json:"diagnosticInfo,omitempty"` +} + +// PodDiagnostic describes a pod-level issue +type PodDiagnostic struct { + // FailedComponent identifies which component this diagnostic relates to, + // using the same type as the Conditions field (e.g., "ApiReady", "CacheReady") + // This allows easy correlation between condition status and diagnostic details. + FailedComponent string `json:"failedComponent"` + + // PodName is the name of the pod with issues + PodName string `json:"podName"` + + // ContainerName is the container within the pod that failed + // Empty if the issue is at the pod level (e.g., scheduling) + // +optional + ContainerName string `json:"containerName,omitempty"` + + // Reason is the failure reason + // Examples: ImagePullBackOff, CrashLoopBackOff, Unschedulable, OOMKilled + Reason string `json:"reason"` + + // Message provides detailed error information from Kubernetes + Message string `json:"message"` + + // ExitCode for terminated containers (only set for container failures) + // +optional + ExitCode *int32 `json:"exitCode,omitempty"` + + // Type indicates the diagnostic type + // +kubebuilder:validation:Enum=ContainerWaiting;ContainerTerminated;PodScheduling;PodCondition + Type DiagnosticType `json:"type"` + + // LastUpdated is the timestamp when this diagnostic was collected + LastUpdated metav1.Time `json:"lastUpdated"` +} + +// DiagnosticType categorizes the type of diagnostic +// +kubebuilder:validation:Enum=ContainerWaiting;ContainerTerminated;PodScheduling;PodCondition +type DiagnosticType string + +const ( + DiagnosticTypeContainerWaiting DiagnosticType = "ContainerWaiting" + DiagnosticTypeContainerTerminated DiagnosticType = "ContainerTerminated" + DiagnosticTypePodScheduling DiagnosticType = "PodScheduling" + DiagnosticTypePodCondition DiagnosticType = "PodCondition" +) + +// DeploymentStatus represents the status of a deployment check +type DeploymentStatus string + +const ( + DeploymentStatusReady DeploymentStatus = "Ready" + DeploymentStatusProgressing DeploymentStatus = "Progressing" + DeploymentStatusFailed DeploymentStatus = "Failed" +) + +// OverallStatus represents the aggregate status of the entire system +type OverallStatus string + +const ( + OverallStatusReady OverallStatus = "Ready" + OverallStatusNotReady OverallStatus = "NotReady" +) + +// LogLevel defines the logging level for components +// +kubebuilder:validation:Enum=DEBUG;INFO;WARNING;ERROR;CRITICAL +type LogLevel string + +const ( + // LogLevelDebug enables debug-level logging (most verbose) + LogLevelDebug LogLevel = "DEBUG" + + // LogLevelInfo enables info-level logging (default) + LogLevelInfo LogLevel = "INFO" + + // LogLevelWarning enables warning-level logging + LogLevelWarning LogLevel = "WARNING" + + // LogLevelError enables error-level logging + LogLevelError LogLevel = "ERROR" + + // LogLevelCritical enables critical-level logging (least verbose) + LogLevelCritical LogLevel = "CRITICAL" +) + +// LLMSpec defines the desired state of the large language model (LLM). +type LLMSpec struct { + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Providers" + Providers []ProviderSpec `json:"providers"` +} + +// OLSSpec defines the desired state of OLS deployment. +type OLSSpec struct { + // Conversation cache settings + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=2,displayName="Conversation Cache" + ConversationCache ConversationCacheSpec `json:"conversationCache,omitempty"` + // OLS deployment settings + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=1,displayName="Deployment" + DeploymentConfig DeploymentConfig `json:"deployment,omitempty"` + // Log level. Valid options are DEBUG, INFO, WARNING, ERROR and CRITICAL. Default: "INFO". + // +kubebuilder:default=INFO + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Log level" + LogLevel LogLevel `json:"logLevel,omitempty"` + // Default model for usage + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Default Model",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:text"} + DefaultModel string `json:"defaultModel"` + // Default provider for usage + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Default Provider",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:text"} + DefaultProvider string `json:"defaultProvider"` + // Query filters + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Query Filters" + QueryFilters []QueryFiltersSpec `json:"queryFilters,omitempty"` + // User data collection switches + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="User Data Collection" + UserDataCollection UserDataCollectionSpec `json:"userDataCollection,omitempty"` + // TLS configuration of the Lightspeed backend's HTTPS endpoint + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="TLS Configuration" + TLSConfig *TLSConfig `json:"tlsConfig,omitempty"` + // Additional CA certificates for TLS communication between OLS service and LLM Provider + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Additional CA Configmap",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + AdditionalCAConfigMapRef *corev1.LocalObjectReference `json:"additionalCAConfigMapRef,omitempty"` + // TLS Security Profile used by API endpoints + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="TLS Security Profile",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + TLSSecurityProfile *configv1.TLSSecurityProfile `json:"tlsSecurityProfile,omitempty"` + // Enable introspection features + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Introspection Enabled",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + IntrospectionEnabled bool `json:"introspectionEnabled,omitempty"` + // Proxy settings for connecting to external servers, such as LLM providers. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Proxy Settings",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + // +kubebuilder:validation:Optional + ProxyConfig *ProxyConfig `json:"proxyConfig,omitempty"` + // RAG databases + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="RAG Databases",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + RAG []RAGSpec `json:"rag,omitempty"` + // LLM Token Quota Configuration + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="LLM Token Quota Configuration" + QuotaHandlersConfig *QuotaHandlersConfig `json:"quotaHandlersConfig,omitempty"` + // Persistent Storage Configuration + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Persistent Storage Configuration",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + Storage *Storage `json:"storage,omitempty"` + // Only use BYOK RAG sources, ignore the OpenShift documentation RAG + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Only use BYOK RAG sources",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + ByokRAGOnly bool `json:"byokRAGOnly,omitempty"` + // Custom system prompt for LLM queries. If not specified, uses the default OpenShift Lightspeed prompt. + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Query System Prompt",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + QuerySystemPrompt string `json:"querySystemPrompt,omitempty"` + // Maximum number of iterations for agent execution. Default: 5 + // +kubebuilder:default=5 + // +kubebuilder:validation:Minimum=1 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Max Iterations",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:number"} + MaxIterations int `json:"maxIterations,omitempty"` + // Pull secrets for BYOK RAG images from image registries requiring authentication + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Image Pull Secrets" + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + // Tool filtering configuration for hybrid RAG retrieval. If not specified, all tools are used. + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Tool Filtering Configuration",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ToolFilteringConfig *ToolFilteringConfig `json:"toolFilteringConfig,omitempty"` + // Tool execution approval configuration. Controls whether tool calls require user approval before execution. + // ⚠️ WARNING: This feature is not yet fully supported in the current OLS backend version. + // The operator will generate the configuration, but tool approval behavior may not function as expected. + // Please verify backend support before enabling. + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Tools Approval Configuration",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + ToolsApprovalConfig *ToolsApprovalConfig `json:"toolsApprovalConfig,omitempty"` +} + +// Persistent Storage Configuration +type Storage struct { + // Size of the requested volume + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Size of the Requested Volume" + // +kubebuilder:validation:Optional + Size resource.Quantity `json:"size,omitempty"` + // Storage class of the requested volume + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Storage Class of the Requested Volume" + Class string `json:"class,omitempty"` +} + +// RAGSpec defines how to retrieve RAG databases. +type RAGSpec struct { + // The path to the RAG database inside of the container image + // +kubebuilder:default="/rag/vector_db" + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Index Path in the Image" + IndexPath string `json:"indexPath,omitempty"` + // The Index ID of the RAG database. Only needed if there are multiple indices in the database. + // +kubebuilder:default="" + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Index ID" + IndexID string `json:"indexID,omitempty"` + // The URL of the container image to use as a RAG source + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Image" + Image string `json:"image"` +} + +// QuotaHandlersConfig defines the token quota configuration +type QuotaHandlersConfig struct { + // Token quota limiters + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Token Quota Limiters",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + LimitersConfig []LimiterConfig `json:"limitersConfig,omitempty"` + // Enable token history + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Enable Token History",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:booleanSwitch"} + EnableTokenHistory bool `json:"enableTokenHistory,omitempty"` +} + +// LimiterConfig defines settings for a token quota limiter +type LimiterConfig struct { + // Name of the limiter + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Limiter Name" + Name string `json:"name"` + // Type of the limiter + // +kubebuilder:validation:Enum=cluster_limiter;user_limiter + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Limiter Type. Accepted Values: cluster_limiter, user_limiter." + Type string `json:"type"` + // Initial value of the token quota + // +kubebuilder:validation:Minimum=0 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Initial Token Quota" + InitialQuota int `json:"initialQuota"` + // Token quota increase step + // +kubebuilder:validation:Minimum=0 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Token Quota Increase Step" + QuotaIncrease int `json:"quotaIncrease"` + // Period of time the token quota is for + // Examples: "1 hour", "30 minutes", "2 days", "1 h", "30 min", "2 d" + // Accepts singular (e.g., "1 second") or plural (e.g., "2 seconds") forms + // Supported units: second(s), minute(s), hour(s), day(s), month(s), year(s) or s, min, h, d, m, y + // +kubebuilder:validation:Pattern=`^(1\s+(second|minute|hour|day|month|year|s|min|h|d|m|y)|([2-9][0-9]*|[1-9][0-9]{2,})\s+(seconds|minutes|hours|days|months|years|s|min|h|d|m|y))$` + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Period of Time the Token Quota Is For" + Period string `json:"period"` +} + +// DeploymentConfig defines the schema for overriding deployment of OLS instance. +type DeploymentConfig struct { + // API container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="API Deployment" + APIContainer Config `json:"api,omitempty"` + // Data Collector container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Data Collector Container" + DataCollectorContainer ContainerConfig `json:"dataCollector,omitempty"` + // MCP server container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="MCP Server Container" + MCPServerContainer ContainerConfig `json:"mcpServer,omitempty"` + // Llama Stack container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Llama Stack Container" + LlamaStackContainer ContainerConfig `json:"llamaStack,omitempty"` + // Console container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Console Deployment" + ConsoleContainer Config `json:"console,omitempty"` + // Database container settings. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Database Deployment" + DatabaseContainer Config `json:"database,omitempty"` +} + +// Config defines pod configuration using standard Kubernetes types +type Config struct { + // Defines the number of desired OLS pods. Default: "1" + // Note: Replicas can only be changed for APIContainer. For PostgreSQL and Console containers, + // the number of replicas will always be set to 1. + // +kubebuilder:default=1 + // +kubebuilder:validation:Minimum=0 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Number of replicas",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:podCount"} + Replicas *int32 `json:"replicas,omitempty"` + // Resource requirements (CPU, memory) + // Uses standard corev1.ResourceRequirements + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + + // Tolerations for pod scheduling + // Uses standard corev1.Toleration + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Node selector constraints + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + + // Affinity rules (can be added without API version bump) + // Uses standard corev1.Affinity + Affinity *corev1.Affinity `json:"affinity,omitempty"` + + // Topology spread constraints (can be added without API version bump) + // Uses standard corev1.TopologySpreadConstraint + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` +} + +// ContainerConfig defines container configuration using standard Kubernetes types +type ContainerConfig struct { + // Resource requirements (CPU, memory) + // Uses standard corev1.ResourceRequirements + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` +} + +// +kubebuilder:validation:Enum=postgres +type CacheType string + +const ( + Postgres CacheType = "postgres" +) + +// ConversationCacheSpec defines the desired state of OLS conversation cache. +type ConversationCacheSpec struct { + // Conversation cache type. Default: "postgres" + // +kubebuilder:default=postgres + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Cache Type" + Type CacheType `json:"type,omitempty"` + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="PostgreSQL Settings" + Postgres PostgresSpec `json:"postgres,omitempty"` +} + +// PostgresSpec defines the desired state of Postgres. +type PostgresSpec struct { + // Postgres sharedbuffers + // +kubebuilder:validation:XIntOrString + // +kubebuilder:default="256MB" + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Shared Buffer Size" + SharedBuffers string `json:"sharedBuffers,omitempty"` + // Postgres maxconnections. Default: "2000" + // +kubebuilder:default=2000 + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=262143 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Maximum Connections" + MaxConnections int `json:"maxConnections,omitempty"` +} + +// QueryFiltersSpec defines filters to manipulate questions/queries. +type QueryFiltersSpec struct { + // Filter name. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Filter Name" + Name string `json:"name,omitempty"` + // Filter pattern. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="The pattern to replace" + Pattern string `json:"pattern,omitempty"` + // Replacement for the matched pattern. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Replace With" + ReplaceWith string `json:"replaceWith,omitempty"` +} + +// ModelParametersSpec +type ModelParametersSpec struct { + // Max tokens for response. The default is 2048 tokens. + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Max Tokens For Response" + MaxTokensForResponse int `json:"maxTokensForResponse,omitempty"` +} + +// ModelSpec defines the LLM model to use and its parameters. +type ModelSpec struct { + // Model name + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Name" + Name string `json:"name"` + // Model API URL + // +kubebuilder:validation:Pattern=`^https?://.*$` + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="URL" + URL string `json:"url,omitempty"` + // Defines the model's context window size, in tokens. The default is 128k tokens. + // +kubebuilder:validation:Minimum=1024 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Context Window Size" + ContextWindowSize uint `json:"contextWindowSize,omitempty"` + // Model API parameters + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Parameters" + Parameters ModelParametersSpec `json:"parameters,omitempty"` +} + +// ProviderSpec defines the desired state of LLM provider. +// +kubebuilder:validation:XValidation:message="'deploymentName' must be specified for 'azure_openai' provider",rule="self.type != \"azure_openai\" || self.deploymentName != \"\"" +// +kubebuilder:validation:XValidation:message="'projectID' must be specified for 'watsonx' provider",rule="self.type != \"watsonx\" || self.projectID != \"\"" +type ProviderSpec struct { + // Provider name + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=1,displayName="Name" + Name string `json:"name"` + // Provider API URL + // +kubebuilder:validation:Pattern=`^https?://.*$` + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=2,displayName="URL" + URL string `json:"url,omitempty"` + // The name of the secret object that stores API provider credentials + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=3,displayName="Credential Secret" + CredentialsSecretRef corev1.LocalObjectReference `json:"credentialsSecretRef"` + // List of models from the provider + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Models" + Models []ModelSpec `json:"models"` + // Provider type + // +kubebuilder:validation:Required + // +required + // +kubebuilder:validation:Enum=anthropic_vertex;azure_openai;bam;openai;watsonx;rhoai_vllm;rhelai_vllm;fake_provider + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Provider Type" + Type string `json:"type"` + // Deployment name for Azure OpenAI provider + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Azure Deployment Name" + AzureDeploymentName string `json:"deploymentName,omitempty"` + // API Version for Azure OpenAI provider + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Azure OpenAI API Version" + APIVersion string `json:"apiVersion,omitempty"` + // Watsonx Project ID + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Watsonx Project ID" + WatsonProjectID string `json:"projectID,omitempty"` + // Fake Provider MCP Tool Call + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Fake Provider MCP Tool Call" + FakeProviderMCPToolCall bool `json:"fakeProviderMCPToolCall,omitempty"` + // TLS Security Profile used by connection to provider + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="TLS Security Profile",xDescriptors={"urn:alm:descriptor:com.tectonic.ui:advanced"} + TLSSecurityProfile *configv1.TLSSecurityProfile `json:"tlsSecurityProfile,omitempty"` +} + +// UserDataCollectionSpec defines how we collect user data. +type UserDataCollectionSpec struct { + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Do Not Collect User Feedback" + FeedbackDisabled bool `json:"feedbackDisabled,omitempty"` + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Do Not Collect Transcripts" + TranscriptsDisabled bool `json:"transcriptsDisabled,omitempty"` +} + +// OLSDataCollectorSpec defines allowed OLS data collector configuration. +type OLSDataCollectorSpec struct { + // Log level. Valid options are DEBUG, INFO, WARNING, ERROR and CRITICAL. Default: "INFO". + // +kubebuilder:default=INFO + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Log level" + LogLevel LogLevel `json:"logLevel,omitempty"` +} + +type TLSConfig struct { + // KeyCertSecretRef references a Secret containing TLS certificate and key. + // The Secret must contain the following keys: + // - tls.crt: Server certificate (PEM format) - REQUIRED + // - tls.key: Private key (PEM format) - REQUIRED + // - ca.crt: CA certificate for console proxy trust (PEM format) - OPTIONAL + // + // If ca.crt is not provided, the OpenShift Console proxy will use the default system trust store. + // + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="TLS Certificate Secret Reference" + // +optional + KeyCertSecretRef corev1.LocalObjectReference `json:"keyCertSecretRef,omitempty"` +} + +// ProxyConfig defines the proxy settings for connecting to external servers, such as LLM providers. +type ProxyConfig struct { + // Proxy URL, e.g. https://proxy.example.com:8080 + // If not specified, the cluster wide proxy will be used, through env var "https_proxy". + // +kubebuilder:validation:Pattern=`^https?://.*$` + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Proxy URL" + ProxyURL string `json:"proxyURL,omitempty"` + // The configmap holding proxy CA certificate + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Proxy CA Certificate" + ProxyCACertificateRef *corev1.LocalObjectReference `json:"proxyCACertificate,omitempty"` +} + +// ToolFilteringConfig defines configuration for tool filtering using hybrid RAG retrieval. +// If this config is present, tool filtering is enabled. If absent, all tools are used. +// The embedding model is not exposed as it's handled by the container image. +// +kubebuilder:validation:XValidation:rule="self.alpha >= 0.0 && self.alpha <= 1.0",message="alpha must be between 0.0 and 1.0" +// +kubebuilder:validation:XValidation:rule="self.threshold >= 0.0 && self.threshold <= 1.0",message="threshold must be between 0.0 and 1.0" +type ToolFilteringConfig struct { + // Weight for dense vs sparse retrieval (1.0 = full dense, 0.0 = full sparse) + // +kubebuilder:default=0.8 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Alpha Weight" + Alpha float64 `json:"alpha,omitempty"` + + // Number of tools to retrieve + // +kubebuilder:default=10 + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=50 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Top K" + TopK int `json:"topK,omitempty"` + + // Minimum similarity threshold for filtering results + // +kubebuilder:default=0.01 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Similarity Threshold" + Threshold float64 `json:"threshold,omitempty"` +} + +// ApprovalType defines the approval strategy for tool execution +// +kubebuilder:validation:Enum=never;always;tool_annotations +type ApprovalType string + +const ( + // ApprovalTypeNever - all tools execute without approval + ApprovalTypeNever ApprovalType = "never" + // ApprovalTypeAlways - all tool calls require approval + ApprovalTypeAlways ApprovalType = "always" + // ApprovalTypeToolAnnotations - approval based on per-tool annotations + ApprovalTypeToolAnnotations ApprovalType = "tool_annotations" +) + +// ToolsApprovalConfig defines configuration for tool execution approval. +// Controls whether tool calls require user approval before execution. +type ToolsApprovalConfig struct { + // Approval strategy for tool execution. + // 'never' - tools execute without approval + // 'always' - all tool calls require approval + // 'tool_annotations' - approval based on per-tool annotations + // +kubebuilder:default=never + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Approval Type" + ApprovalType ApprovalType `json:"approvalType,omitempty"` + + // Timeout in seconds for waiting for user approval + // +kubebuilder:default=600 + // +kubebuilder:validation:Minimum=1 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Approval Timeout (seconds)" + ApprovalTimeout int `json:"approvalTimeout,omitempty"` +} + +// MCPHeaderSourceType defines the type of header value source +// +enum +type MCPHeaderSourceType string + +const ( + // MCPHeaderSourceTypeSecret uses a value from a Kubernetes secret + MCPHeaderSourceTypeSecret MCPHeaderSourceType = "secret" + // MCPHeaderSourceTypeKubernetes uses the Kubernetes service account token + MCPHeaderSourceTypeKubernetes MCPHeaderSourceType = "kubernetes" + // MCPHeaderSourceTypeClient uses the client token from the incoming request + MCPHeaderSourceTypeClient MCPHeaderSourceType = "client" +) + +// MCPHeaderValueSource defines where the header value comes from. +// Uses a discriminated union pattern following KEP-1027. +// The Type field determines which of the other fields should be set. +// Secrets must exist in the operator's namespace. +// +// Examples: +// +// # Use a secret: +// valueFrom: +// type: secret +// secretRef: +// name: my-mcp-secret +// +// # Use Kubernetes service account token: +// valueFrom: +// type: kubernetes +// +// # Pass through client token: +// valueFrom: +// type: client +// +// +kubebuilder:validation:XValidation:rule="self.type == 'secret' ? has(self.secretRef) && size(self.secretRef.name) > 0 : true",message="secretRef with non-empty name is required when type is 'secret'" +// +kubebuilder:validation:XValidation:rule="self.type != 'secret' ? !has(self.secretRef) : true",message="secretRef must not be set when type is 'kubernetes' or 'client'" +type MCPHeaderValueSource struct { + // Type specifies the source type for the header value + // +unionDiscriminator + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=secret;kubernetes;client + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Source Type" + Type MCPHeaderSourceType `json:"type"` + + // Reference to a secret containing the header value. + // Required when Type is "secret". + // The secret must exist in the operator's namespace. + // +unionMember + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Secret Reference" + SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` +} + +// MCPHeader defines a header to send to the MCP server +type MCPHeader struct { + // Name of the header (e.g., "Authorization", "X-API-Key") + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9-]+$` + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Header Name" + Name string `json:"name"` + + // Source of the header value + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Value Source" + ValueFrom MCPHeaderValueSource `json:"valueFrom"` +} + +// MCPServerConfig defines the streamlined configuration for an MCP server +// This configuration only supports HTTP/HTTPS transport +type MCPServerConfig struct { + // Name of the MCP server + // +kubebuilder:validation:Required + // +required + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Name" + Name string `json:"name"` + + // URL of the MCP server (HTTP/HTTPS) + // +kubebuilder:validation:Required + // +required + // +kubebuilder:validation:Pattern=`^https?://.*$` + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="URL" + URL string `json:"url"` + + // Timeout for the MCP server in seconds, default is 5 + // +kubebuilder:default=5 + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Timeout (seconds)" + Timeout int `json:"timeout,omitempty"` + + // Headers to send to the MCP server + // Each header can reference a secret or use a special source (kubernetes token, client token) + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,displayName="Headers" + Headers []MCPHeader `json:"headers,omitempty"` +} + +// SkillsConfig defines how agent skills are delivered to the service pods. +// Skills are markdown files packaged as an OCI image and mounted via the +// Kubernetes image volume source (requires K8s 1.34+ / OCP 4.20+). +type SkillsConfig struct { + // image is the OCI image reference containing skills markdown files. + // Example: "quay.io/openshift-lightspeed/skills:latest" + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Image string `json:"image"` + + // pullPolicy defines when to pull the skills image. + // +kubebuilder:default=IfNotPresent + // +kubebuilder:validation:Enum=Always;IfNotPresent;Never + // +optional + PullPolicy string `json:"pullPolicy,omitempty"` + + // mountPath is the directory where skills are mounted inside the service pod. + // +kubebuilder:default="/app/skills" + // +optional + MountPath string `json:"mountPath,omitempty"` +} + +// SandboxConfig configures the sandbox runtime for proposal execution. +// Agent configuration (skills, LLM, prompts) is defined via Agent CRs. +// Workflow configuration (which agent per step) is defined via Workflow CRs. +type SandboxConfig struct { + // baseTemplate is the name of the SandboxTemplate to use as the base for + // all derived per-agent templates. The operator reads this template and + // creates variants with the agent's skills image and mode. + // +optional + BaseTemplate string `json:"baseTemplate,omitempty"` + + // namespace is the namespace where SandboxClaims are created. + // Defaults to the operator's namespace. + // +optional + Namespace string `json:"namespace,omitempty"` + + // maxAttempts is the maximum retry attempts before auto-escalation. + // +kubebuilder:default=3 + // +kubebuilder:validation:Minimum=1 + // +optional + MaxAttempts int `json:"maxAttempts,omitempty"` + + // policy configures the RBAC security model enforcement layers. + // +optional + Policy *PolicyConfig `json:"policy,omitempty"` +} + +// PolicyRuleRef identifies a set of API resources for policy configuration. +type PolicyRuleRef struct { + // apiGroups are the API groups to match. + APIGroups []string `json:"apiGroups"` + // resources are the resources to match. + Resources []string `json:"resources"` +} + +// PolicyConfig configures the RBAC security model enforcement layers. +type PolicyConfig struct { + // maxExecutionRules are the admin-configured ceiling for namespace-scoped + // RBAC rules the operator will grant to execution sandboxes. + // +optional + MaxExecutionRules []rbacv1.PolicyRule `json:"maxExecutionRules,omitempty"` + + // maxClusterRules are the admin-configured ceiling for cluster-scoped + // RBAC rules the operator will grant to execution sandboxes. + // +optional + MaxClusterRules []rbacv1.PolicyRule `json:"maxClusterRules,omitempty"` + + // additionalProtectedNamespaces are extra namespaces (beyond the hardcoded + // set) where execution write access is forbidden. Supports glob patterns. + // +optional + AdditionalProtectedNamespaces []string `json:"additionalProtectedNamespaces,omitempty"` + + // analysisRole is the name of a pre-created ClusterRole for read-only + // analysis access. If set, analysis sandboxes are bound to this ClusterRole. + // +optional + AnalysisRole string `json:"analysisRole,omitempty"` + + // excludeFromRead lists resources excluded from analysis read access. + // Default excludes secrets to prevent credential leakage to the LLM. + // +optional + ExcludeFromRead []PolicyRuleRef `json:"excludeFromRead,omitempty"` +} + +// AlertManagerConfig configures the Alertmanager webhook receiver that +// auto-creates Proposal CRs with type=remediate. +type AlertManagerConfig struct { + // enabled controls whether the AlertManager webhook listener is active. + // +kubebuilder:default=false + Enabled bool `json:"enabled"` + + // port is the port for the webhook HTTP listener. + // +kubebuilder:default=9095 + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +optional + Port int32 `json:"port,omitempty"` + + // namespace is the namespace where remediation proposals are created. + // Defaults to the operator's namespace. + // +optional + Namespace string `json:"namespace,omitempty"` + + // cooldownMinutes is the minimum time (in minutes) after a proposal is created + // before a new proposal can be created for the same alert. Prevents rapid + // re-creation when alerts re-fire shortly after remediation. + // +kubebuilder:default=15 + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1440 + // +optional + CooldownMinutes int32 `json:"cooldownMinutes,omitempty"` +} + +// EscalationConfig configures the escalation flow for filing support cases +// when automated remediation fails or a platform bug is identified. +type EscalationConfig struct { + // enabled controls whether escalation mode is available. + // +kubebuilder:default=false + Enabled bool `json:"enabled"` + + // targetRepo is the GitHub repository where issues are filed. + // Format: "owner/repo" (e.g., "openshift-lightspeed/support-cases"). + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$` + // +optional + TargetRepo string `json:"targetRepo,omitempty"` + + // backend selects where escalation cases are filed. + // +kubebuilder:default="github" + // +kubebuilder:validation:Enum=github + // +optional + Backend string `json:"backend,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:validation:XValidation:rule="self.metadata.name == 'cluster'",message=".metadata.name must be 'cluster'" +// Red Hat OpenShift Lightspeed instance. OLSConfig is the Schema for the olsconfigs API +type OLSConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // +kubebuilder:validation:Required + // +required + Spec OLSConfigSpec `json:"spec"` + Status OLSConfigStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// OLSConfigList contains a list of OLSConfig +type OLSConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OLSConfig `json:"items"` +} + +func init() { + SchemeBuilder.Register(&OLSConfig{}, &OLSConfigList{}) +} diff --git a/pkg/proposal/api/v1alpha1/outputfield_deepcopy.go b/pkg/proposal/api/v1alpha1/outputfield_deepcopy.go new file mode 100644 index 0000000000..d1a45234b8 --- /dev/null +++ b/pkg/proposal/api/v1alpha1/outputfield_deepcopy.go @@ -0,0 +1,107 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +// Manual DeepCopy for output field types. +// controller-gen does not reliably generate deepcopy for these types +// when scanning multiple package paths simultaneously. + +func (in *OutputField) DeepCopyInto(out *OutputField) { + *out = *in + if in.Enum != nil { + in, out := &in.Enum, &out.Enum + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = new(OutputFieldItems) + (*in).DeepCopyInto(*out) + } + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make([]OutputSubField, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +func (in *OutputField) DeepCopy() *OutputField { + if in == nil { + return nil + } + out := new(OutputField) + in.DeepCopyInto(out) + return out +} + +func (in *OutputSubField) DeepCopyInto(out *OutputSubField) { + *out = *in + if in.Enum != nil { + in, out := &in.Enum, &out.Enum + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = new(OutputSubFieldItems) + **out = **in + } +} + +func (in *OutputSubField) DeepCopy() *OutputSubField { + if in == nil { + return nil + } + out := new(OutputSubField) + in.DeepCopyInto(out) + return out +} + +func (in *OutputFieldItems) DeepCopyInto(out *OutputFieldItems) { + *out = *in + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make([]OutputSubField, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +func (in *OutputFieldItems) DeepCopy() *OutputFieldItems { + if in == nil { + return nil + } + out := new(OutputFieldItems) + in.DeepCopyInto(out) + return out +} + +func (in *OutputSubFieldItems) DeepCopyInto(out *OutputSubFieldItems) { + *out = *in +} + +func (in *OutputSubFieldItems) DeepCopy() *OutputSubFieldItems { + if in == nil { + return nil + } + out := new(OutputSubFieldItems) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/proposal/api/v1alpha1/proposal_types.go b/pkg/proposal/api/v1alpha1/proposal_types.go new file mode 100644 index 0000000000..9388a3f584 --- /dev/null +++ b/pkg/proposal/api/v1alpha1/proposal_types.go @@ -0,0 +1,747 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ProposalPhase represents the current phase of the proposal lifecycle. +// +// The full lifecycle is: +// +// Pending -> Analyzing -> Proposed -> [user approves] -> Approved -> Executing -> Verifying -> Completed +// [user denies] -> Denied +// [exec skipped] -> AwaitingSync (advisory/gitops) +// [failure] -> Failed -> [retry] -> Pending (enriched context) +// [max retries] -> Escalated (child proposal created) +// +// The operator is the sole writer of this field. Users influence transitions +// indirectly by approving or denying proposals. +// +// +kubebuilder:validation:Enum=Pending;Analyzing;Proposed;Approved;Denied;Executing;AwaitingSync;Verifying;Completed;Failed;Escalated +type ProposalPhase string + +const ( + // ProposalPhasePending is the initial phase. The operator picks up the + // proposal and prepares to launch the analysis sandbox. On retries, + // the proposal returns to Pending with enriched context from previous + // attempts. + ProposalPhasePending ProposalPhase = "Pending" + // ProposalPhaseAnalyzing means the analysis agent is running in a + // sandbox pod, examining cluster state and producing a diagnosis, + // remediation plan, and RBAC request. + ProposalPhaseAnalyzing ProposalPhase = "Analyzing" + // ProposalPhaseProposed means analysis is complete and the proposal + // is waiting for user approval. The user can approve (to proceed with + // execution), deny, or escalate. + ProposalPhaseProposed ProposalPhase = "Proposed" + // ProposalPhaseApproved means the user approved the proposal. The + // operator creates execution RBAC (ServiceAccount, Role, RoleBinding) + // and launches the execution sandbox. + ProposalPhaseApproved ProposalPhase = "Approved" + // ProposalPhaseDenied means the user rejected the proposal. This is + // a terminal phase. + ProposalPhaseDenied ProposalPhase = "Denied" + // ProposalPhaseExecuting means the execution agent is running in a + // sandbox pod, carrying out the approved remediation plan. + ProposalPhaseExecuting ProposalPhase = "Executing" + // ProposalPhaseAwaitingSync means execution was skipped (advisory-only + // or gitops workflow). The user is expected to apply changes manually + // or via GitOps, then mark the proposal as synced. + ProposalPhaseAwaitingSync ProposalPhase = "AwaitingSync" + // ProposalPhaseVerifying means the verification agent is running, + // checking whether the remediation was successful. + ProposalPhaseVerifying ProposalPhase = "Verifying" + // ProposalPhaseCompleted means all steps finished successfully. + // This is a terminal phase. + ProposalPhaseCompleted ProposalPhase = "Completed" + // ProposalPhaseFailed means a step failed. If retries remain, the + // operator resets the proposal to Pending with failure context. If + // maxAttempts is reached, it transitions to Escalated instead. + ProposalPhaseFailed ProposalPhase = "Failed" + // ProposalPhaseEscalated means the proposal exhausted all retry + // attempts. The operator creates a child proposal (with parentRef + // pointing back) containing the full failure history for + // higher-privilege or human-assisted remediation. + ProposalPhaseEscalated ProposalPhase = "Escalated" +) + +// StepPhase represents the phase of a single step (analysis, execution, or +// verification) within a proposal's status. Set by the operator. +// +kubebuilder:validation:Enum=Pending;Running;Completed;Failed;Skipped +type StepPhase string + +const ( + // StepPhasePending means the step has not started yet. + StepPhasePending StepPhase = "Pending" + // StepPhaseRunning means the agent sandbox is active and processing. + StepPhaseRunning StepPhase = "Running" + // StepPhaseCompleted means the step finished successfully. + StepPhaseCompleted StepPhase = "Completed" + // StepPhaseFailed means the step encountered an error. + StepPhaseFailed StepPhase = "Failed" + // StepPhaseSkipped means the step was skipped (per workflow or override). + StepPhaseSkipped StepPhase = "Skipped" +) + +// SandboxPhase identifies which workflow step a sandbox pod is running for. +// Used in PreviousAttempt to record which phase failed, and internally by the +// operator for sandbox lifecycle management. +// +kubebuilder:validation:Enum=analysis;execution;verification +type SandboxPhase string + +const ( + // SandboxPhaseAnalysis is the analysis step sandbox. + SandboxPhaseAnalysis SandboxPhase = "analysis" + // SandboxPhaseExecution is the execution step sandbox. + SandboxPhaseExecution SandboxPhase = "execution" + // SandboxPhaseVerification is the verification step sandbox. + SandboxPhaseVerification SandboxPhase = "verification" +) + +// Condition types for Proposal. These follow the standard metav1.Condition +// pattern and are set by the operator to provide fine-grained observability +// beyond the top-level phase. Each condition has a type, status (True/False), +// reason, and message. +const ( + // ProposalConditionAnalyzed is set to True when analysis completes + // successfully, False when analysis fails. + ProposalConditionAnalyzed string = "Analyzed" + // ProposalConditionApproved is set to True when the user approves, + // False when denied. + ProposalConditionApproved string = "Approved" + // ProposalConditionExecuted is set to True when execution completes + // successfully, False when execution fails. + ProposalConditionExecuted string = "Executed" + // ProposalConditionVerified is set to True when verification passes, + // False when verification fails. + ProposalConditionVerified string = "Verified" + // ProposalConditionEscalated is set to True when the proposal has been + // escalated (max retries exhausted). + ProposalConditionEscalated string = "Escalated" +) + +// DiagnosisResult contains the root cause analysis from the analysis agent. +// This is populated by the agent during the Analyzing phase and stored in +// the AnalysisStepStatus as part of a RemediationOption. Users see this +// in the console UI when reviewing the proposal. +type DiagnosisResult struct { + // summary is a human-readable diagnosis summary explaining the problem, + // its symptoms, and the agent's findings. + Summary string `json:"summary"` + // confidence is the agent's self-assessed confidence in its diagnosis. + // Higher confidence generally correlates with clearer symptoms and + // more deterministic root causes. + // +kubebuilder:validation:Enum=low;medium;high + Confidence string `json:"confidence"` + // rootCause is a concise one-line description of the identified root + // cause (e.g., "OOMKilled due to memory limit of 256Mi"). + RootCause string `json:"rootCause"` +} + +// ProposedAction describes a single discrete action the analysis agent +// recommends as part of its remediation plan. Actions are displayed to +// the user in the Proposed phase for review before approval. +type ProposedAction struct { + // type is the action category (e.g., "patch", "scale", "restart", + // "create", "delete", "rollout"). + Type string `json:"type"` + // description is a human-readable explanation of what this action + // will do (e.g., "Increase memory limit from 256Mi to 512Mi"). + Description string `json:"description"` +} + +// ProposalResult contains the remediation plan from the analysis agent. +// This is part of a RemediationOption and is presented to the user in the +// Proposed phase. The risk and reversibility assessments help users make +// informed approval decisions. +type ProposalResult struct { + // description is a human-readable summary of the overall remediation + // approach. + Description string `json:"description"` + // actions is the ordered list of discrete actions the agent proposes. + Actions []ProposedAction `json:"actions"` + // risk is the agent's assessment of how risky the remediation is. + // Critical-risk proposals typically require explicit human review. + // +kubebuilder:validation:Enum=low;medium;high;critical + Risk string `json:"risk"` + // reversible indicates whether the remediation can be rolled back + // if something goes wrong. The rollback plan is in the + // VerificationPlan. + Reversible bool `json:"reversible"` + // estimatedImpact describes the expected impact of the remediation + // on the system (e.g., "Brief pod restart, ~30s downtime"). + // +optional + EstimatedImpact string `json:"estimatedImpact,omitempty"` +} + +// VerificationStep describes a single verification check that the +// verification agent should run after execution. Populated by the +// analysis agent as part of the RemediationOption. +type VerificationStep struct { + // name is a short identifier for this check (e.g., "pod-running"). + Name string `json:"name"` + // command is the command or API call to run for this check + // (e.g., "oc get pod -n production -l app=web -o jsonpath='{.items[0].status.phase}'"). + Command string `json:"command"` + // expected is the expected output or condition + // (e.g., "Running", "ready=true"). + Expected string `json:"expected"` + // type categorizes the check (e.g., "command", "metric", "condition"). + Type string `json:"type"` +} + +// RollbackPlan describes how to undo the remediation if verification fails +// or the remediation causes unexpected issues. Populated by the analysis agent. +type RollbackPlan struct { + // description is a human-readable explanation of the rollback strategy. + Description string `json:"description"` + // command is the rollback command or steps to execute. + Command string `json:"command"` +} + +// VerificationPlan describes the complete verification strategy for a +// remediation, including individual checks and a rollback plan. Populated +// by the analysis agent as part of a RemediationOption and used by the +// verification agent (if not skipped) to validate the remediation. +type VerificationPlan struct { + // description is a human-readable summary of the verification approach. + Description string `json:"description"` + // steps is the ordered list of verification checks to run. + // +optional + Steps []VerificationStep `json:"steps,omitempty"` + // rollbackPlan describes how to undo the remediation if verification + // fails. Displayed to the user and available to the verification agent. + // +optional + RollbackPlan RollbackPlan `json:"rollbackPlan,omitempty"` +} + +// RBACRule describes a single RBAC permission that the analysis agent +// requests for the execution phase. The operator's policy engine validates +// these requests against a 6-layer defense model before creating the +// actual Role/ClusterRole bindings. Each rule must include a justification +// so that users and policy can audit why the permission is needed. +type RBACRule struct { + // namespace is the target namespace for namespace-scoped rules. + // Must match one of the proposal's targetNamespaces. Ignored for + // cluster-scoped rules. + // +optional + Namespace string `json:"namespace,omitempty"` + // apiGroups are the API groups for this rule (e.g., "", "apps", "batch"). + APIGroups []string `json:"apiGroups"` + // resources are the resource types (e.g., "pods", "deployments"). + Resources []string `json:"resources"` + // resourceNames restricts the rule to specific named resources. + // When empty, the rule applies to all resources of the given type. + // +optional + ResourceNames []string `json:"resourceNames,omitempty"` + // verbs are the allowed operations (e.g., "get", "patch", "delete"). + Verbs []string `json:"verbs"` + // justification explains why this permission is needed for the + // remediation (e.g., "Need to patch deployment to increase memory limit"). + // Required for audit and policy enforcement. + Justification string `json:"justification"` +} + +// RBACResult contains the RBAC permissions requested by the analysis agent +// for the execution phase. The operator creates a dedicated ServiceAccount +// per proposal and binds these permissions via Role (namespace-scoped) or +// ClusterRole (cluster-scoped) before launching the execution sandbox. +// All RBAC resources are cleaned up after the proposal reaches a terminal phase. +type RBACResult struct { + // namespaceScoped are rules that will be applied via Role + RoleBinding + // in the proposal's target namespaces. These are the most common rules. + // +optional + NamespaceScoped []RBACRule `json:"namespaceScoped,omitempty"` + // clusterScoped are rules that will be applied via ClusterRole + + // ClusterRoleBinding. Used when the agent needs cross-namespace or + // non-namespaced resource access (e.g., reading nodes, CRDs). + // +optional + ClusterScoped []RBACRule `json:"clusterScoped,omitempty"` +} + +// RemediationOption represents a single remediation approach produced by +// the analysis agent. The agent may return multiple options, each with +// its own diagnosis, remediation plan, verification strategy, and RBAC +// requirements. The user selects one option during the Proposed phase +// (recorded in AnalysisStepStatus.selectedOption), and the operator uses +// that option's RBAC and plan for the execution phase. +// +// The components field is an extensibility point for adapter-specific UI +// data. For example, an ACS adapter might include violation details or +// affected deployment information as components that the console plugin +// renders with custom components. +type RemediationOption struct { + // title is a short human-readable name for this option + // (e.g., "Increase memory limit", "Restart with backoff"). + // +kubebuilder:validation:MinLength=1 + Title string `json:"title"` + // summary is an optional one-line summary for collapsed views in the + // console UI. + // +optional + Summary string `json:"summary,omitempty"` + // diagnosis contains the root cause analysis specific to this option. + Diagnosis DiagnosisResult `json:"diagnosis"` + // proposal contains the remediation plan for this option. + Proposal ProposalResult `json:"proposal"` + // verification contains the verification plan. Omitted when + // verification is skipped in the workflow. + // +optional + Verification *VerificationPlan `json:"verification,omitempty"` + // rbac contains the RBAC permissions the execution agent will need. + // The operator's policy engine validates these before creating the + // actual Kubernetes RBAC resources. Omitted for advisory-only options. + // +optional + RBAC *RBACResult `json:"rbac,omitempty"` + // components contains optional adapter-defined structured data for + // custom console UI rendering. Each entry is a raw JSON object. + // +optional + Components []apiextensionsv1.JSON `json:"components,omitempty"` +} + +// ExecutionAction describes a single action taken by the execution agent +// during the Executing phase. These are recorded in ExecutionStepStatus +// to provide an audit trail of what the agent actually did. +type ExecutionAction struct { + // type is the action category (e.g., "patch", "scale", "restart"). + Type string `json:"type"` + // description is what the agent did + // (e.g., "Patched deployment/web to set memory limit to 512Mi"). + Description string `json:"description"` + // success indicates whether this individual action succeeded. + Success bool `json:"success"` + // output is the command output or API response from the action. + // +optional + Output string `json:"output,omitempty"` + // error is the error message if the action failed. + // +optional + Error string `json:"error,omitempty"` +} + +// ExecutionVerification is a lightweight inline verification that the +// execution agent performs immediately after completing its actions, +// before the formal verification step. This gives early signal on whether +// the remediation worked. In trust-mode workflows (verification skipped), +// this is the only verification that occurs. +type ExecutionVerification struct { + // conditionImproved indicates whether the target condition improved + // after the remediation (e.g., pod is no longer CrashLoopBackOff). + ConditionImproved bool `json:"conditionImproved"` + // summary is a human-readable summary of the inline verification. + Summary string `json:"summary"` +} + +// VerifyCheck is a single verification check result from the verification +// agent. Each check corresponds to a VerificationStep from the analysis +// agent's verification plan. +type VerifyCheck struct { + // name is the check identifier, matching the VerificationStep name. + Name string `json:"name"` + // source is what performed the check (e.g., "oc", "promql", "curl"). + Source string `json:"source"` + // value is the actual observed value (e.g., "Running", "3 replicas"). + Value string `json:"value"` + // passed indicates whether the check's observed value matches + // the expected value. + Passed bool `json:"passed"` +} + +// SandboxInfo tracks the sandbox pod used for a workflow step. The operator +// creates a sandbox pod for each active step (analysis, execution, +// verification) and records the claim details here. This enables the +// console UI to stream sandbox pod logs in real time. +type SandboxInfo struct { + // claimName is the name of the SandboxClaim resource that owns the + // sandbox pod. + // +optional + ClaimName string `json:"claimName,omitempty"` + // namespace is the namespace where the SandboxClaim and its pod live. + // +optional + Namespace string `json:"namespace,omitempty"` + // startedAt is when the sandbox pod was created. + // +optional + StartedAt *metav1.Time `json:"startedAt,omitempty"` + // completedAt is when the sandbox pod finished (success or failure). + // +optional + CompletedAt *metav1.Time `json:"completedAt,omitempty"` +} + +// AnalysisStepStatus is the observed state of the analysis step. +// All fields are populated by the operator based on the analysis agent's +// output. The options field is the most important -- it contains the +// remediation options the user chooses from in the Proposed phase. +type AnalysisStepStatus struct { + // phase is the step phase. + // +optional + Phase StepPhase `json:"phase,omitempty"` + // startedAt is when the step started. + // +optional + StartedAt *metav1.Time `json:"startedAt,omitempty"` + // completedAt is when the step completed. + // +optional + CompletedAt *metav1.Time `json:"completedAt,omitempty"` + // conditions for this step. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + // sandbox tracks the sandbox used. + // +optional + Sandbox SandboxInfo `json:"sandbox,omitempty"` + // options contains one or more remediation options returned by the + // analysis agent. Each option has its own diagnosis, plan, verification + // strategy, and RBAC requirements. The user reviews these in the + // Proposed phase and selects one to approve. + // +optional + Options []RemediationOption `json:"options,omitempty"` + // selectedOption is the 0-based index into the options array that the + // user approved. Set when the user approves the proposal. The operator + // uses this to determine which option's RBAC and plan to use for + // execution. + // +optional + // +kubebuilder:validation:Minimum=0 + SelectedOption *int `json:"selectedOption,omitempty"` + // components contains optional adapter-specific UI components that + // apply to the analysis step as a whole (not to a specific option). + // +optional + Components []apiextensionsv1.JSON `json:"components,omitempty"` +} + +// ExecutionStepStatus is the observed state of the execution step. +// Populated by the operator from the execution agent's output. Contains +// an audit trail of every action the agent took and whether each succeeded. +type ExecutionStepStatus struct { + // phase is the step phase. + // +optional + Phase StepPhase `json:"phase,omitempty"` + // startedAt is when the step started. + // +optional + StartedAt *metav1.Time `json:"startedAt,omitempty"` + // completedAt is when the step completed. + // +optional + CompletedAt *metav1.Time `json:"completedAt,omitempty"` + // conditions for this step. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + // sandbox tracks the sandbox used. + // +optional + Sandbox SandboxInfo `json:"sandbox,omitempty"` + // success indicates whether execution completed successfully. + // +optional + Success *bool `json:"success,omitempty"` + // actionsTaken lists what the agent did. + // +optional + ActionsTaken []ExecutionAction `json:"actionsTaken,omitempty"` + // verification is the inline verification from the execution agent. + // +optional + Verification *ExecutionVerification `json:"verification,omitempty"` + // components contains optional adapter-defined structured data. + // +optional + Components []apiextensionsv1.JSON `json:"components,omitempty"` +} + +// VerificationStepStatus is the observed state of the verification step. +// Populated by the operator from the verification agent's output. Contains +// individual check results and an overall success/failure assessment. +type VerificationStepStatus struct { + // phase is the step phase. + // +optional + Phase StepPhase `json:"phase,omitempty"` + // startedAt is when the step started. + // +optional + StartedAt *metav1.Time `json:"startedAt,omitempty"` + // completedAt is when the step completed. + // +optional + CompletedAt *metav1.Time `json:"completedAt,omitempty"` + // conditions for this step. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + // sandbox tracks the sandbox used. + // +optional + Sandbox SandboxInfo `json:"sandbox,omitempty"` + // success indicates whether verification passed. + // +optional + Success *bool `json:"success,omitempty"` + // checks contains individual verification check results. + // +optional + Checks []VerifyCheck `json:"checks,omitempty"` + // summary is a human-readable verification summary. + // +optional + Summary string `json:"summary,omitempty"` + // components contains optional adapter-defined structured data. + // +optional + Components []apiextensionsv1.JSON `json:"components,omitempty"` +} + +// StepsStatus contains the per-step observed state for all three workflow +// steps. Each step status is populated independently as the proposal +// progresses through its lifecycle. All fields are set by the operator. +type StepsStatus struct { + // analysis is the observed state of the analysis step. + // +optional + Analysis AnalysisStepStatus `json:"analysis,omitempty"` + // execution is the observed state of the execution step. + // +optional + Execution ExecutionStepStatus `json:"execution,omitempty"` + // verification is the observed state of the verification step. + // +optional + Verification VerificationStepStatus `json:"verification,omitempty"` +} + +// WorkflowStepOverride allows overriding a single step of the referenced +// workflow without creating a new Workflow CR. Each field is optional -- +// only the fields you set are overridden; everything else comes from the +// Workflow. +type WorkflowStepOverride struct { + // skip overrides the skip flag for this step. When set to true, the step + // is skipped regardless of the Workflow's setting. When set to false, + // the step runs even if the Workflow says skip. + // +optional + Skip *bool `json:"skip,omitempty"` + // agentRef overrides the agent used for this step. Allows using a + // different agent for a specific proposal without changing the Workflow. + // +optional + AgentRef *corev1.LocalObjectReference `json:"agentRef,omitempty"` +} + +// WorkflowOverride allows per-proposal overrides of the referenced workflow. +// This is useful for one-off customizations: for example, using a +// remediation workflow but skipping execution to make it advisory-only, +// or swapping in a specialized agent for a specific proposal. +// +// Example — skip execution on a remediation workflow to make it advisory: +// +// workflowOverride: +// execution: +// skip: true +// verification: +// skip: true +// +// Example — use a specialized ACS analyzer agent for one proposal: +// +// workflowOverride: +// analysis: +// agentRef: +// name: acs-analyzer +type WorkflowOverride struct { + // analysis overrides for the analysis step. + // +optional + Analysis *WorkflowStepOverride `json:"analysis,omitempty"` + // execution overrides for the execution step. + // +optional + Execution *WorkflowStepOverride `json:"execution,omitempty"` + // verification overrides for the verification step. + // +optional + Verification *WorkflowStepOverride `json:"verification,omitempty"` +} + +// PreviousAttempt captures the state of a failed attempt. When a proposal +// fails and retries, the operator records the failure context here so that +// the analysis agent on the next attempt can learn from previous failures. +// If maxAttempts is reached, the full history of PreviousAttempts is +// included in the escalation child proposal. +type PreviousAttempt struct { + // attempt is the 1-based attempt number that failed. + Attempt int `json:"attempt"` + // failedPhase is which step failed (analysis, execution, or verification). + // +optional + FailedPhase SandboxPhase `json:"failedPhase,omitempty"` + // failureReason is the error message or explanation from the failed step. + // +optional + FailureReason string `json:"failureReason,omitempty"` +} + +// ProposalSpec defines the desired state of Proposal. This is the user-facing +// (or adapter-facing) configuration -- everything the operator needs to start +// processing the proposal. +type ProposalSpec struct { + // request is the user's original request, alert description, or a + // description of what triggered this proposal. This text is passed to + // the analysis agent as the primary input. For adapter-created proposals, + // this typically contains the alert summary and relevant details. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Request string `json:"request"` + + // workflowRef references a cluster-scoped Workflow CR that defines + // which agents handle each step (analysis, execution, verification) + // and which steps are skipped. This is the primary routing mechanism. + // +kubebuilder:validation:Required + WorkflowRef corev1.LocalObjectReference `json:"workflowRef"` + + // targetNamespaces are the Kubernetes namespace(s) this proposal + // operates on. The operator uses these to scope RBAC (creating Roles + // and RoleBindings only in these namespaces) and to pass context to + // the analysis agent. When empty, the proposal operates at the + // cluster level only. + // +optional + TargetNamespaces []string `json:"targetNamespaces,omitempty"` + + // workflowOverride allows per-proposal overrides of the referenced + // workflow without creating a new Workflow CR. Useful for one-off + // customizations like skipping execution on a normally full-lifecycle + // workflow, or swapping in a specialized agent. + // +optional + WorkflowOverride *WorkflowOverride `json:"workflowOverride,omitempty"` + + // parentRef references the parent proposal in an escalation chain. + // Set automatically by the operator when creating a child proposal + // after maxAttempts is exhausted. The child proposal inherits the + // full failure history from its parent. The child is also owned by + // the parent via Kubernetes owner references for garbage collection. + // +optional + ParentRef *corev1.LocalObjectReference `json:"parentRef,omitempty"` + + // maxAttempts overrides the global retry limit for this proposal. + // When a step fails, the operator resets the proposal to Pending + // with enriched context (up to maxAttempts times). After that, the + // proposal transitions to Escalated. Set to 0 to disable retries. + // When omitted, the operator's global default is used. + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=20 + MaxAttempts *int `json:"maxAttempts,omitempty"` +} + +// ProposalStatus defines the observed state of Proposal. All fields are +// set by the operator -- users should not modify status fields directly. +// The status provides complete observability into the proposal's progress, +// including per-step results, retry history, and standard Kubernetes conditions. +type ProposalStatus struct { + // phase is the current phase of the proposal lifecycle. + // See ProposalPhase for the full state machine. + // +kubebuilder:default=Pending + Phase ProposalPhase `json:"phase"` + + // attempt is the current attempt number (1-based). Incremented each + // time the proposal is retried after a failure. Starts at 1 for the + // first attempt. + // +optional + Attempt int `json:"attempt,omitempty"` + + // steps contains the per-step observed state (analysis, execution, + // verification). Each step independently tracks its phase, timing, + // sandbox info, and results. + // +optional + Steps StepsStatus `json:"steps,omitempty"` + + // previousAttempts contains the failure history from earlier attempts. + // Each entry records which phase failed and why, giving the analysis + // agent on the next attempt context to avoid repeating the same mistake. + // +optional + PreviousAttempts []PreviousAttempt `json:"previousAttempts,omitempty"` + + // conditions represent the latest available observations using the + // standard Kubernetes condition pattern. Condition types include: + // Analyzed, Approved, Executed, Verified, and Escalated. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Workflow",type=string,JSONPath=`.spec.workflowRef.name` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Request",type=string,JSONPath=`.spec.request`,priority=1 +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Proposal represents a unit of work managed by the agentic platform. It is +// the final link in the CRD chain (LlmProvider -> Agent -> Workflow -> +// Proposal) and the primary resource users and adapters interact with. +// +// A Proposal references a Workflow that defines which agents handle each +// step, and tracks the full lifecycle from initial request through analysis, +// user approval, execution, and verification. Proposals are created by +// adapters (AlertManager webhook, ACS violation webhook, manual creation) +// or by the operator itself (escalation child proposals). +// +// Proposal is cluster-scoped. The operator watches for new Proposals and +// drives them through the lifecycle automatically. Users interact with +// proposals in the Proposed phase to approve, deny, or escalate. +// +// Example — a remediation proposal targeting a specific namespace: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Proposal +// metadata: +// name: fix-crashloop +// spec: +// request: | +// Pod web-frontend-5d4b8c6f-x9k2m in namespace production is in +// CrashLoopBackOff. Last restart reason: OOMKilled. Container memory +// limit is 256Mi. +// workflowRef: +// name: remediation +// targetNamespaces: +// - production +// +// Example — advisory-only via workflowOverride (reuses a remediation workflow +// but skips execution so the user applies changes manually): +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Proposal +// metadata: +// name: one-off-advisory +// spec: +// request: "Review the nginx deployment in staging for security best practices" +// workflowRef: +// name: remediation +// targetNamespaces: +// - staging +// workflowOverride: +// execution: +// skip: true +// verification: +// skip: true +// +// Example — an upgrade proposal with limited retries: +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Proposal +// metadata: +// name: upgrade-4-22 +// spec: +// request: "Analyze and plan upgrade from OpenShift 4.21 to 4.22" +// workflowRef: +// name: upgrade +// maxAttempts: 2 +type Proposal struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + Spec ProposalSpec `json:"spec"` + + // +optional + Status ProposalStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ProposalList contains a list of Proposal. +type ProposalList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Proposal `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Proposal{}, &ProposalList{}) +} diff --git a/pkg/proposal/api/v1alpha1/workflow_types.go b/pkg/proposal/api/v1alpha1/workflow_types.go new file mode 100644 index 0000000000..1cc5891bb1 --- /dev/null +++ b/pkg/proposal/api/v1alpha1/workflow_types.go @@ -0,0 +1,183 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WorkflowStep defines the configuration for a single step in a workflow. +// Each step either references an Agent to execute it, or is skipped entirely. +// +// Skipping a step changes the proposal lifecycle: +// - Skip analysis: Not recommended. Analysis produces the diagnosis and +// remediation plan that drive downstream steps. +// - Skip execution: The proposal transitions to AwaitingSync after approval, +// making it advisory-only. The user is expected to apply changes manually +// or via GitOps. Useful for gitops-remediation and advisory-only workflows. +// - Skip verification: The proposal completes immediately after execution +// without a verification check. Useful for trust-mode workflows where +// the execution agent's inline verification is sufficient. +type WorkflowStep struct { + // agentRef references a cluster-scoped Agent CR to use for this step. + // The operator resolves this reference and launches a sandbox pod with + // the agent's LLM, skills, and system prompt to process the step. + // Required when skip is false; must be omitted or nil when skip is true. + // +optional + AgentRef *corev1.LocalObjectReference `json:"agentRef,omitempty"` + + // skip skips this step entirely. When true, agentRef is not needed and + // the operator advances the proposal past this step automatically. + // See WorkflowStep documentation for the effect of skipping each step. + // +optional + Skip bool `json:"skip,omitempty"` +} + +// WorkflowSpec defines the desired state of Workflow. +// +// A workflow is a 3-step pipeline template. The steps always run in order: +// analysis -> execution -> verification. Between analysis and execution, +// the proposal pauses in the Proposed phase for user approval (unless the +// operator is configured for auto-approve). +type WorkflowSpec struct { + // analysis defines the analysis step. The analysis agent examines the + // cluster state, produces a diagnosis (root cause, confidence), a + // remediation proposal (actions, risk, reversibility), a verification + // plan, and RBAC permissions needed for execution. + // +kubebuilder:validation:Required + Analysis WorkflowStep `json:"analysis"` + + // execution defines the execution step. The execution agent carries out + // the approved remediation plan using the RBAC permissions granted by the + // operator. When skipped, the proposal enters AwaitingSync for manual + // or GitOps-driven application. + // +kubebuilder:validation:Required + Execution WorkflowStep `json:"execution"` + + // verification defines the verification step. The verification agent + // checks whether the remediation was successful by running the + // verification plan produced during analysis. When skipped, the proposal + // completes immediately after execution. + // +kubebuilder:validation:Required + Verification WorkflowStep `json:"verification"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Analysis Agent",type=string,JSONPath=`.spec.analysis.agentRef.name` +// +kubebuilder:printcolumn:name="Exec Skip",type=boolean,JSONPath=`.spec.execution.skip` +// +kubebuilder:printcolumn:name="Verify Skip",type=boolean,JSONPath=`.spec.verification.skip` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Workflow defines a reusable 3-step pipeline template that controls which +// agents handle analysis, execution, and verification, and whether any steps +// are skipped. It is the third link in the CRD chain (LlmProvider -> Agent -> +// Workflow -> Proposal) and is referenced by Proposal resources via +// spec.workflowRef. +// +// Workflow is cluster-scoped. You create workflows representing different +// operational patterns and then reference them from proposals. Per-proposal +// overrides (WorkflowOverride in the Proposal spec) allow customizing +// individual steps without creating a new Workflow. +// +// Example — full remediation (analyze, execute, verify): +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Workflow +// metadata: +// name: remediation +// spec: +// analysis: +// agentRef: +// name: analyzer +// execution: +// agentRef: +// name: executor +// verification: +// agentRef: +// name: verifier +// +// Example — advisory-only (analyze only, no execution or verification): +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Workflow +// metadata: +// name: advisory-only +// spec: +// analysis: +// agentRef: +// name: analyzer +// execution: +// skip: true +// verification: +// skip: true +// +// Example — gitops-remediation (analyze, skip execution, verify after user applies via git): +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Workflow +// metadata: +// name: gitops-remediation +// spec: +// analysis: +// agentRef: +// name: analyzer +// execution: +// skip: true +// verification: +// agentRef: +// name: verifier +// +// Example — trust-mode (analyze, execute, skip verification): +// +// apiVersion: agentic.openshift.io/v1alpha1 +// kind: Workflow +// metadata: +// name: trust-mode +// spec: +// analysis: +// agentRef: +// name: analyzer +// execution: +// agentRef: +// name: executor +// verification: +// skip: true +type Workflow struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self.analysis.skip || (has(self.analysis.agentRef) && self.analysis.agentRef.name != '')",message="agentRef is required when analysis is not skipped" + // +kubebuilder:validation:XValidation:rule="self.execution.skip || (has(self.execution.agentRef) && self.execution.agentRef.name != '')",message="agentRef is required when execution is not skipped" + // +kubebuilder:validation:XValidation:rule="self.verification.skip || (has(self.verification.agentRef) && self.verification.agentRef.name != '')",message="agentRef is required when verification is not skipped" + Spec WorkflowSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// WorkflowList contains a list of Workflow. +type WorkflowList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Workflow `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Workflow{}, &WorkflowList{}) +} diff --git a/pkg/proposal/api/v1alpha1/zz_generated.deepcopy.go b/pkg/proposal/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..d9fb7c2cab --- /dev/null +++ b/pkg/proposal/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,1755 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + + configv1 "github.com/openshift/api/config/v1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Agent) DeepCopyInto(out *Agent) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Agent. +func (in *Agent) DeepCopy() *Agent { + if in == nil { + return nil + } + out := new(Agent) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Agent) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentList) DeepCopyInto(out *AgentList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Agent, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentList. +func (in *AgentList) DeepCopy() *AgentList { + if in == nil { + return nil + } + out := new(AgentList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AgentList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { + *out = *in + out.LLMRef = in.LLMRef + if in.Skills != nil { + in, out := &in.Skills, &out.Skills + *out = make([]SkillsSource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.MCPServers != nil { + in, out := &in.MCPServers, &out.MCPServers + *out = make([]MCPServerConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SystemPromptRef != nil { + in, out := &in.SystemPromptRef, &out.SystemPromptRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.OutputFields != nil { + in, out := &in.OutputFields, &out.OutputFields + *out = make([]OutputField, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.RawOutputSchema != nil { + in, out := &in.RawOutputSchema, &out.RawOutputSchema + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. +func (in *AgentSpec) DeepCopy() *AgentSpec { + if in == nil { + return nil + } + out := new(AgentSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AlertManagerConfig) DeepCopyInto(out *AlertManagerConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AlertManagerConfig. +func (in *AlertManagerConfig) DeepCopy() *AlertManagerConfig { + if in == nil { + return nil + } + out := new(AlertManagerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AnalysisStepStatus) DeepCopyInto(out *AnalysisStepStatus) { + *out = *in + if in.StartedAt != nil { + in, out := &in.StartedAt, &out.StartedAt + *out = (*in).DeepCopy() + } + if in.CompletedAt != nil { + in, out := &in.CompletedAt, &out.CompletedAt + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.Sandbox.DeepCopyInto(&out.Sandbox) + if in.Options != nil { + in, out := &in.Options, &out.Options + *out = make([]RemediationOption, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SelectedOption != nil { + in, out := &in.SelectedOption, &out.SelectedOption + *out = new(int) + **out = **in + } + if in.Components != nil { + in, out := &in.Components, &out.Components + *out = make([]apiextensionsv1.JSON, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnalysisStepStatus. +func (in *AnalysisStepStatus) DeepCopy() *AnalysisStepStatus { + if in == nil { + return nil + } + out := new(AnalysisStepStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Config) DeepCopyInto(out *Config) { + *out = *in + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(v1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]v1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(v1.Affinity) + (*in).DeepCopyInto(*out) + } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Config. +func (in *Config) DeepCopy() *Config { + if in == nil { + return nil + } + out := new(Config) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContainerConfig) DeepCopyInto(out *ContainerConfig) { + *out = *in + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(v1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerConfig. +func (in *ContainerConfig) DeepCopy() *ContainerConfig { + if in == nil { + return nil + } + out := new(ContainerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConversationCacheSpec) DeepCopyInto(out *ConversationCacheSpec) { + *out = *in + out.Postgres = in.Postgres +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConversationCacheSpec. +func (in *ConversationCacheSpec) DeepCopy() *ConversationCacheSpec { + if in == nil { + return nil + } + out := new(ConversationCacheSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeploymentConfig) DeepCopyInto(out *DeploymentConfig) { + *out = *in + in.APIContainer.DeepCopyInto(&out.APIContainer) + in.DataCollectorContainer.DeepCopyInto(&out.DataCollectorContainer) + in.MCPServerContainer.DeepCopyInto(&out.MCPServerContainer) + in.LlamaStackContainer.DeepCopyInto(&out.LlamaStackContainer) + in.ConsoleContainer.DeepCopyInto(&out.ConsoleContainer) + in.DatabaseContainer.DeepCopyInto(&out.DatabaseContainer) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentConfig. +func (in *DeploymentConfig) DeepCopy() *DeploymentConfig { + if in == nil { + return nil + } + out := new(DeploymentConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DiagnosisResult) DeepCopyInto(out *DiagnosisResult) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiagnosisResult. +func (in *DiagnosisResult) DeepCopy() *DiagnosisResult { + if in == nil { + return nil + } + out := new(DiagnosisResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EscalationConfig) DeepCopyInto(out *EscalationConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EscalationConfig. +func (in *EscalationConfig) DeepCopy() *EscalationConfig { + if in == nil { + return nil + } + out := new(EscalationConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecutionAction) DeepCopyInto(out *ExecutionAction) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionAction. +func (in *ExecutionAction) DeepCopy() *ExecutionAction { + if in == nil { + return nil + } + out := new(ExecutionAction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecutionStepStatus) DeepCopyInto(out *ExecutionStepStatus) { + *out = *in + if in.StartedAt != nil { + in, out := &in.StartedAt, &out.StartedAt + *out = (*in).DeepCopy() + } + if in.CompletedAt != nil { + in, out := &in.CompletedAt, &out.CompletedAt + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.Sandbox.DeepCopyInto(&out.Sandbox) + if in.Success != nil { + in, out := &in.Success, &out.Success + *out = new(bool) + **out = **in + } + if in.ActionsTaken != nil { + in, out := &in.ActionsTaken, &out.ActionsTaken + *out = make([]ExecutionAction, len(*in)) + copy(*out, *in) + } + if in.Verification != nil { + in, out := &in.Verification, &out.Verification + *out = new(ExecutionVerification) + **out = **in + } + if in.Components != nil { + in, out := &in.Components, &out.Components + *out = make([]apiextensionsv1.JSON, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionStepStatus. +func (in *ExecutionStepStatus) DeepCopy() *ExecutionStepStatus { + if in == nil { + return nil + } + out := new(ExecutionStepStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecutionVerification) DeepCopyInto(out *ExecutionVerification) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionVerification. +func (in *ExecutionVerification) DeepCopy() *ExecutionVerification { + if in == nil { + return nil + } + out := new(ExecutionVerification) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLMSpec) DeepCopyInto(out *LLMSpec) { + *out = *in + if in.Providers != nil { + in, out := &in.Providers, &out.Providers + *out = make([]ProviderSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLMSpec. +func (in *LLMSpec) DeepCopy() *LLMSpec { + if in == nil { + return nil + } + out := new(LLMSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LimiterConfig) DeepCopyInto(out *LimiterConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LimiterConfig. +func (in *LimiterConfig) DeepCopy() *LimiterConfig { + if in == nil { + return nil + } + out := new(LimiterConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LlmProvider) DeepCopyInto(out *LlmProvider) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LlmProvider. +func (in *LlmProvider) DeepCopy() *LlmProvider { + if in == nil { + return nil + } + out := new(LlmProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LlmProvider) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LlmProviderList) DeepCopyInto(out *LlmProviderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LlmProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LlmProviderList. +func (in *LlmProviderList) DeepCopy() *LlmProviderList { + if in == nil { + return nil + } + out := new(LlmProviderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LlmProviderList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LlmProviderSpec) DeepCopyInto(out *LlmProviderSpec) { + *out = *in + out.CredentialsSecretRef = in.CredentialsSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LlmProviderSpec. +func (in *LlmProviderSpec) DeepCopy() *LlmProviderSpec { + if in == nil { + return nil + } + out := new(LlmProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPHeader) DeepCopyInto(out *MCPHeader) { + *out = *in + in.ValueFrom.DeepCopyInto(&out.ValueFrom) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPHeader. +func (in *MCPHeader) DeepCopy() *MCPHeader { + if in == nil { + return nil + } + out := new(MCPHeader) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPHeaderValueSource) DeepCopyInto(out *MCPHeaderValueSource) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPHeaderValueSource. +func (in *MCPHeaderValueSource) DeepCopy() *MCPHeaderValueSource { + if in == nil { + return nil + } + out := new(MCPHeaderValueSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerConfig) DeepCopyInto(out *MCPServerConfig) { + *out = *in + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = make([]MCPHeader, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerConfig. +func (in *MCPServerConfig) DeepCopy() *MCPServerConfig { + if in == nil { + return nil + } + out := new(MCPServerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelParametersSpec) DeepCopyInto(out *ModelParametersSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelParametersSpec. +func (in *ModelParametersSpec) DeepCopy() *ModelParametersSpec { + if in == nil { + return nil + } + out := new(ModelParametersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelSpec) DeepCopyInto(out *ModelSpec) { + *out = *in + out.Parameters = in.Parameters +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelSpec. +func (in *ModelSpec) DeepCopy() *ModelSpec { + if in == nil { + return nil + } + out := new(ModelSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OLSConfig) DeepCopyInto(out *OLSConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OLSConfig. +func (in *OLSConfig) DeepCopy() *OLSConfig { + if in == nil { + return nil + } + out := new(OLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OLSConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OLSConfigList) DeepCopyInto(out *OLSConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]OLSConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OLSConfigList. +func (in *OLSConfigList) DeepCopy() *OLSConfigList { + if in == nil { + return nil + } + out := new(OLSConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OLSConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OLSConfigSpec) DeepCopyInto(out *OLSConfigSpec) { + *out = *in + in.LLMConfig.DeepCopyInto(&out.LLMConfig) + in.OLSConfig.DeepCopyInto(&out.OLSConfig) + out.OLSDataCollectorConfig = in.OLSDataCollectorConfig + if in.MCPServers != nil { + in, out := &in.MCPServers, &out.MCPServers + *out = make([]MCPServerConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Skills != nil { + in, out := &in.Skills, &out.Skills + *out = new(SkillsConfig) + **out = **in + } + if in.Sandbox != nil { + in, out := &in.Sandbox, &out.Sandbox + *out = new(SandboxConfig) + (*in).DeepCopyInto(*out) + } + if in.AlertManager != nil { + in, out := &in.AlertManager, &out.AlertManager + *out = new(AlertManagerConfig) + **out = **in + } + if in.Escalation != nil { + in, out := &in.Escalation, &out.Escalation + *out = new(EscalationConfig) + **out = **in + } + if in.FeatureGates != nil { + in, out := &in.FeatureGates, &out.FeatureGates + *out = make([]FeatureGate, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OLSConfigSpec. +func (in *OLSConfigSpec) DeepCopy() *OLSConfigSpec { + if in == nil { + return nil + } + out := new(OLSConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OLSConfigStatus) DeepCopyInto(out *OLSConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.DiagnosticInfo != nil { + in, out := &in.DiagnosticInfo, &out.DiagnosticInfo + *out = make([]PodDiagnostic, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OLSConfigStatus. +func (in *OLSConfigStatus) DeepCopy() *OLSConfigStatus { + if in == nil { + return nil + } + out := new(OLSConfigStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OLSDataCollectorSpec) DeepCopyInto(out *OLSDataCollectorSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OLSDataCollectorSpec. +func (in *OLSDataCollectorSpec) DeepCopy() *OLSDataCollectorSpec { + if in == nil { + return nil + } + out := new(OLSDataCollectorSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OLSSpec) DeepCopyInto(out *OLSSpec) { + *out = *in + out.ConversationCache = in.ConversationCache + in.DeploymentConfig.DeepCopyInto(&out.DeploymentConfig) + if in.QueryFilters != nil { + in, out := &in.QueryFilters, &out.QueryFilters + *out = make([]QueryFiltersSpec, len(*in)) + copy(*out, *in) + } + out.UserDataCollection = in.UserDataCollection + if in.TLSConfig != nil { + in, out := &in.TLSConfig, &out.TLSConfig + *out = new(TLSConfig) + **out = **in + } + if in.AdditionalCAConfigMapRef != nil { + in, out := &in.AdditionalCAConfigMapRef, &out.AdditionalCAConfigMapRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.TLSSecurityProfile != nil { + in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile + *out = new(configv1.TLSSecurityProfile) + (*in).DeepCopyInto(*out) + } + if in.ProxyConfig != nil { + in, out := &in.ProxyConfig, &out.ProxyConfig + *out = new(ProxyConfig) + (*in).DeepCopyInto(*out) + } + if in.RAG != nil { + in, out := &in.RAG, &out.RAG + *out = make([]RAGSpec, len(*in)) + copy(*out, *in) + } + if in.QuotaHandlersConfig != nil { + in, out := &in.QuotaHandlersConfig, &out.QuotaHandlersConfig + *out = new(QuotaHandlersConfig) + (*in).DeepCopyInto(*out) + } + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = new(Storage) + (*in).DeepCopyInto(*out) + } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]v1.LocalObjectReference, len(*in)) + copy(*out, *in) + } + if in.ToolFilteringConfig != nil { + in, out := &in.ToolFilteringConfig, &out.ToolFilteringConfig + *out = new(ToolFilteringConfig) + **out = **in + } + if in.ToolsApprovalConfig != nil { + in, out := &in.ToolsApprovalConfig, &out.ToolsApprovalConfig + *out = new(ToolsApprovalConfig) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OLSSpec. +func (in *OLSSpec) DeepCopy() *OLSSpec { + if in == nil { + return nil + } + out := new(OLSSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodDiagnostic) DeepCopyInto(out *PodDiagnostic) { + *out = *in + if in.ExitCode != nil { + in, out := &in.ExitCode, &out.ExitCode + *out = new(int32) + **out = **in + } + in.LastUpdated.DeepCopyInto(&out.LastUpdated) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodDiagnostic. +func (in *PodDiagnostic) DeepCopy() *PodDiagnostic { + if in == nil { + return nil + } + out := new(PodDiagnostic) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyConfig) DeepCopyInto(out *PolicyConfig) { + *out = *in + if in.MaxExecutionRules != nil { + in, out := &in.MaxExecutionRules, &out.MaxExecutionRules + *out = make([]rbacv1.PolicyRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.MaxClusterRules != nil { + in, out := &in.MaxClusterRules, &out.MaxClusterRules + *out = make([]rbacv1.PolicyRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AdditionalProtectedNamespaces != nil { + in, out := &in.AdditionalProtectedNamespaces, &out.AdditionalProtectedNamespaces + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExcludeFromRead != nil { + in, out := &in.ExcludeFromRead, &out.ExcludeFromRead + *out = make([]PolicyRuleRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyConfig. +func (in *PolicyConfig) DeepCopy() *PolicyConfig { + if in == nil { + return nil + } + out := new(PolicyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyRuleRef) DeepCopyInto(out *PolicyRuleRef) { + *out = *in + if in.APIGroups != nil { + in, out := &in.APIGroups, &out.APIGroups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyRuleRef. +func (in *PolicyRuleRef) DeepCopy() *PolicyRuleRef { + if in == nil { + return nil + } + out := new(PolicyRuleRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSpec. +func (in *PostgresSpec) DeepCopy() *PostgresSpec { + if in == nil { + return nil + } + out := new(PostgresSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PreviousAttempt) DeepCopyInto(out *PreviousAttempt) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreviousAttempt. +func (in *PreviousAttempt) DeepCopy() *PreviousAttempt { + if in == nil { + return nil + } + out := new(PreviousAttempt) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Proposal) DeepCopyInto(out *Proposal) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Proposal. +func (in *Proposal) DeepCopy() *Proposal { + if in == nil { + return nil + } + out := new(Proposal) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Proposal) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProposalList) DeepCopyInto(out *ProposalList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Proposal, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProposalList. +func (in *ProposalList) DeepCopy() *ProposalList { + if in == nil { + return nil + } + out := new(ProposalList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProposalList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProposalResult) DeepCopyInto(out *ProposalResult) { + *out = *in + if in.Actions != nil { + in, out := &in.Actions, &out.Actions + *out = make([]ProposedAction, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProposalResult. +func (in *ProposalResult) DeepCopy() *ProposalResult { + if in == nil { + return nil + } + out := new(ProposalResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProposalSpec) DeepCopyInto(out *ProposalSpec) { + *out = *in + out.WorkflowRef = in.WorkflowRef + if in.TargetNamespaces != nil { + in, out := &in.TargetNamespaces, &out.TargetNamespaces + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.WorkflowOverride != nil { + in, out := &in.WorkflowOverride, &out.WorkflowOverride + *out = new(WorkflowOverride) + (*in).DeepCopyInto(*out) + } + if in.ParentRef != nil { + in, out := &in.ParentRef, &out.ParentRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.MaxAttempts != nil { + in, out := &in.MaxAttempts, &out.MaxAttempts + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProposalSpec. +func (in *ProposalSpec) DeepCopy() *ProposalSpec { + if in == nil { + return nil + } + out := new(ProposalSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProposalStatus) DeepCopyInto(out *ProposalStatus) { + *out = *in + in.Steps.DeepCopyInto(&out.Steps) + if in.PreviousAttempts != nil { + in, out := &in.PreviousAttempts, &out.PreviousAttempts + *out = make([]PreviousAttempt, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProposalStatus. +func (in *ProposalStatus) DeepCopy() *ProposalStatus { + if in == nil { + return nil + } + out := new(ProposalStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProposedAction) DeepCopyInto(out *ProposedAction) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProposedAction. +func (in *ProposedAction) DeepCopy() *ProposedAction { + if in == nil { + return nil + } + out := new(ProposedAction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderSpec) DeepCopyInto(out *ProviderSpec) { + *out = *in + out.CredentialsSecretRef = in.CredentialsSecretRef + if in.Models != nil { + in, out := &in.Models, &out.Models + *out = make([]ModelSpec, len(*in)) + copy(*out, *in) + } + if in.TLSSecurityProfile != nil { + in, out := &in.TLSSecurityProfile, &out.TLSSecurityProfile + *out = new(configv1.TLSSecurityProfile) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderSpec. +func (in *ProviderSpec) DeepCopy() *ProviderSpec { + if in == nil { + return nil + } + out := new(ProviderSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProxyConfig) DeepCopyInto(out *ProxyConfig) { + *out = *in + if in.ProxyCACertificateRef != nil { + in, out := &in.ProxyCACertificateRef, &out.ProxyCACertificateRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyConfig. +func (in *ProxyConfig) DeepCopy() *ProxyConfig { + if in == nil { + return nil + } + out := new(ProxyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QueryFiltersSpec) DeepCopyInto(out *QueryFiltersSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QueryFiltersSpec. +func (in *QueryFiltersSpec) DeepCopy() *QueryFiltersSpec { + if in == nil { + return nil + } + out := new(QueryFiltersSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *QuotaHandlersConfig) DeepCopyInto(out *QuotaHandlersConfig) { + *out = *in + if in.LimitersConfig != nil { + in, out := &in.LimitersConfig, &out.LimitersConfig + *out = make([]LimiterConfig, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new QuotaHandlersConfig. +func (in *QuotaHandlersConfig) DeepCopy() *QuotaHandlersConfig { + if in == nil { + return nil + } + out := new(QuotaHandlersConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RAGSpec) DeepCopyInto(out *RAGSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RAGSpec. +func (in *RAGSpec) DeepCopy() *RAGSpec { + if in == nil { + return nil + } + out := new(RAGSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RBACResult) DeepCopyInto(out *RBACResult) { + *out = *in + if in.NamespaceScoped != nil { + in, out := &in.NamespaceScoped, &out.NamespaceScoped + *out = make([]RBACRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ClusterScoped != nil { + in, out := &in.ClusterScoped, &out.ClusterScoped + *out = make([]RBACRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RBACResult. +func (in *RBACResult) DeepCopy() *RBACResult { + if in == nil { + return nil + } + out := new(RBACResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RBACRule) DeepCopyInto(out *RBACRule) { + *out = *in + if in.APIGroups != nil { + in, out := &in.APIGroups, &out.APIGroups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ResourceNames != nil { + in, out := &in.ResourceNames, &out.ResourceNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Verbs != nil { + in, out := &in.Verbs, &out.Verbs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RBACRule. +func (in *RBACRule) DeepCopy() *RBACRule { + if in == nil { + return nil + } + out := new(RBACRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemediationOption) DeepCopyInto(out *RemediationOption) { + *out = *in + out.Diagnosis = in.Diagnosis + in.Proposal.DeepCopyInto(&out.Proposal) + if in.Verification != nil { + in, out := &in.Verification, &out.Verification + *out = new(VerificationPlan) + (*in).DeepCopyInto(*out) + } + if in.RBAC != nil { + in, out := &in.RBAC, &out.RBAC + *out = new(RBACResult) + (*in).DeepCopyInto(*out) + } + if in.Components != nil { + in, out := &in.Components, &out.Components + *out = make([]apiextensionsv1.JSON, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemediationOption. +func (in *RemediationOption) DeepCopy() *RemediationOption { + if in == nil { + return nil + } + out := new(RemediationOption) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RollbackPlan) DeepCopyInto(out *RollbackPlan) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RollbackPlan. +func (in *RollbackPlan) DeepCopy() *RollbackPlan { + if in == nil { + return nil + } + out := new(RollbackPlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SandboxConfig) DeepCopyInto(out *SandboxConfig) { + *out = *in + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = new(PolicyConfig) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxConfig. +func (in *SandboxConfig) DeepCopy() *SandboxConfig { + if in == nil { + return nil + } + out := new(SandboxConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SandboxInfo) DeepCopyInto(out *SandboxInfo) { + *out = *in + if in.StartedAt != nil { + in, out := &in.StartedAt, &out.StartedAt + *out = (*in).DeepCopy() + } + if in.CompletedAt != nil { + in, out := &in.CompletedAt, &out.CompletedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxInfo. +func (in *SandboxInfo) DeepCopy() *SandboxInfo { + if in == nil { + return nil + } + out := new(SandboxInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SkillsConfig) DeepCopyInto(out *SkillsConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SkillsConfig. +func (in *SkillsConfig) DeepCopy() *SkillsConfig { + if in == nil { + return nil + } + out := new(SkillsConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SkillsSource) DeepCopyInto(out *SkillsSource) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SkillsSource. +func (in *SkillsSource) DeepCopy() *SkillsSource { + if in == nil { + return nil + } + out := new(SkillsSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StepsStatus) DeepCopyInto(out *StepsStatus) { + *out = *in + in.Analysis.DeepCopyInto(&out.Analysis) + in.Execution.DeepCopyInto(&out.Execution) + in.Verification.DeepCopyInto(&out.Verification) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StepsStatus. +func (in *StepsStatus) DeepCopy() *StepsStatus { + if in == nil { + return nil + } + out := new(StepsStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Storage) DeepCopyInto(out *Storage) { + *out = *in + out.Size = in.Size.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Storage. +func (in *Storage) DeepCopy() *Storage { + if in == nil { + return nil + } + out := new(Storage) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLSConfig) DeepCopyInto(out *TLSConfig) { + *out = *in + out.KeyCertSecretRef = in.KeyCertSecretRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSConfig. +func (in *TLSConfig) DeepCopy() *TLSConfig { + if in == nil { + return nil + } + out := new(TLSConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolFilteringConfig) DeepCopyInto(out *ToolFilteringConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolFilteringConfig. +func (in *ToolFilteringConfig) DeepCopy() *ToolFilteringConfig { + if in == nil { + return nil + } + out := new(ToolFilteringConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ToolsApprovalConfig) DeepCopyInto(out *ToolsApprovalConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ToolsApprovalConfig. +func (in *ToolsApprovalConfig) DeepCopy() *ToolsApprovalConfig { + if in == nil { + return nil + } + out := new(ToolsApprovalConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserDataCollectionSpec) DeepCopyInto(out *UserDataCollectionSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserDataCollectionSpec. +func (in *UserDataCollectionSpec) DeepCopy() *UserDataCollectionSpec { + if in == nil { + return nil + } + out := new(UserDataCollectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationPlan) DeepCopyInto(out *VerificationPlan) { + *out = *in + if in.Steps != nil { + in, out := &in.Steps, &out.Steps + *out = make([]VerificationStep, len(*in)) + copy(*out, *in) + } + out.RollbackPlan = in.RollbackPlan +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationPlan. +func (in *VerificationPlan) DeepCopy() *VerificationPlan { + if in == nil { + return nil + } + out := new(VerificationPlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationStep) DeepCopyInto(out *VerificationStep) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationStep. +func (in *VerificationStep) DeepCopy() *VerificationStep { + if in == nil { + return nil + } + out := new(VerificationStep) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationStepStatus) DeepCopyInto(out *VerificationStepStatus) { + *out = *in + if in.StartedAt != nil { + in, out := &in.StartedAt, &out.StartedAt + *out = (*in).DeepCopy() + } + if in.CompletedAt != nil { + in, out := &in.CompletedAt, &out.CompletedAt + *out = (*in).DeepCopy() + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.Sandbox.DeepCopyInto(&out.Sandbox) + if in.Success != nil { + in, out := &in.Success, &out.Success + *out = new(bool) + **out = **in + } + if in.Checks != nil { + in, out := &in.Checks, &out.Checks + *out = make([]VerifyCheck, len(*in)) + copy(*out, *in) + } + if in.Components != nil { + in, out := &in.Components, &out.Components + *out = make([]apiextensionsv1.JSON, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationStepStatus. +func (in *VerificationStepStatus) DeepCopy() *VerificationStepStatus { + if in == nil { + return nil + } + out := new(VerificationStepStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerifyCheck) DeepCopyInto(out *VerifyCheck) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerifyCheck. +func (in *VerifyCheck) DeepCopy() *VerifyCheck { + if in == nil { + return nil + } + out := new(VerifyCheck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Workflow) DeepCopyInto(out *Workflow) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Workflow. +func (in *Workflow) DeepCopy() *Workflow { + if in == nil { + return nil + } + out := new(Workflow) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Workflow) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowList) DeepCopyInto(out *WorkflowList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Workflow, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowList. +func (in *WorkflowList) DeepCopy() *WorkflowList { + if in == nil { + return nil + } + out := new(WorkflowList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkflowList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowOverride) DeepCopyInto(out *WorkflowOverride) { + *out = *in + if in.Analysis != nil { + in, out := &in.Analysis, &out.Analysis + *out = new(WorkflowStepOverride) + (*in).DeepCopyInto(*out) + } + if in.Execution != nil { + in, out := &in.Execution, &out.Execution + *out = new(WorkflowStepOverride) + (*in).DeepCopyInto(*out) + } + if in.Verification != nil { + in, out := &in.Verification, &out.Verification + *out = new(WorkflowStepOverride) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowOverride. +func (in *WorkflowOverride) DeepCopy() *WorkflowOverride { + if in == nil { + return nil + } + out := new(WorkflowOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowSpec) DeepCopyInto(out *WorkflowSpec) { + *out = *in + in.Analysis.DeepCopyInto(&out.Analysis) + in.Execution.DeepCopyInto(&out.Execution) + in.Verification.DeepCopyInto(&out.Verification) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowSpec. +func (in *WorkflowSpec) DeepCopy() *WorkflowSpec { + if in == nil { + return nil + } + out := new(WorkflowSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowStep) DeepCopyInto(out *WorkflowStep) { + *out = *in + if in.AgentRef != nil { + in, out := &in.AgentRef, &out.AgentRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowStep. +func (in *WorkflowStep) DeepCopy() *WorkflowStep { + if in == nil { + return nil + } + out := new(WorkflowStep) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowStepOverride) DeepCopyInto(out *WorkflowStepOverride) { + *out = *in + if in.Skip != nil { + in, out := &in.Skip, &out.Skip + *out = new(bool) + **out = **in + } + if in.AgentRef != nil { + in, out := &in.AgentRef, &out.AgentRef + *out = new(v1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowStepOverride. +func (in *WorkflowStepOverride) DeepCopy() *WorkflowStepOverride { + if in == nil { + return nil + } + out := new(WorkflowStepOverride) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/proposal/controller.go b/pkg/proposal/controller.go new file mode 100644 index 0000000000..a5888a92cc --- /dev/null +++ b/pkg/proposal/controller.go @@ -0,0 +1,456 @@ +package proposal + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/blang/semver/v4" + "k8s.io/apimachinery/pkg/util/sets" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kutilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog/v2" + "k8s.io/utils/ptr" + + configv1 "github.com/openshift/api/config/v1" + + i "github.com/openshift/cluster-version-operator/pkg/internal" + proposalv1alpha1 "github.com/openshift/cluster-version-operator/pkg/proposal/api/v1alpha1" +) + +type Controller struct { + queueKey string + queue workqueue.TypedRateLimitingInterface[any] + updatesGetterFunc updatesGetterFunc + client ctrlruntimeclient.Client + cvGetterFunc cvGetterFunc + configMapGetterFunc configMapGetterFunc + getCurrentVersionFunc getCurrentVersionFunc + config Config +} + +const controllerName = "proposal-lifecycle-controller" + +type updatesGetterFunc func() ([]configv1.Release, []configv1.ConditionalUpdate, error) + +type cvGetterFunc func(name string) (*configv1.ClusterVersion, error) + +type getCurrentVersionFunc func() string + +type configMapGetterFunc func(name, namespace string) (*corev1.ConfigMap, error) + +// NewController returns Controller to manage Proposals. +// It monitors available and conditional updates, and creates a LightspeedProposal for every target version of them. +// It expires (and replace) any previous LightspeedProposals owned by the CVO after 24h. +// It deletes any CVO-owned LightspeedProposals (without replacement) that are associated with target releases +// that are no longer supported next-hop options (e.g. because a channel change or cluster update), but preserves +// LightspeedProposals associated with versions in the ClusterVersion status.history (history already has its own +// garbage-collection). +func NewController( + updatesGetterFunc updatesGetterFunc, + client ctrlruntimeclient.Client, + cvGetterFunc cvGetterFunc, + configMapGetterFunc configMapGetterFunc, + getCurrentVersionFunc getCurrentVersionFunc, +) *Controller { + return &Controller{ + queueKey: fmt.Sprintf("ClusterVersionOperator/%s", controllerName), + queue: workqueue.NewTypedRateLimitingQueueWithConfig[any]( + workqueue.DefaultTypedControllerRateLimiter[any](), + workqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName}), + updatesGetterFunc: updatesGetterFunc, + client: client, + cvGetterFunc: cvGetterFunc, + configMapGetterFunc: configMapGetterFunc, + getCurrentVersionFunc: getCurrentVersionFunc, + config: DefaultConfig(), + } +} + +// Config holds configuration for proposal creation. +type Config struct { + Namespace string + Workflow string + PromptConfigMap string // ConfigMap name containing the system prompt +} + +// DefaultConfig returns the default configuration, checking env vars for overrides. +func DefaultConfig() Config { + return Config{ + Namespace: envOrDefault("LIGHTSPEED_PROPOSAL_NAMESPACE", "openshift-lightspeed"), + Workflow: envOrDefault("LIGHTSPEED_PROPOSAL_WORKFLOW", "ota-advisory"), + PromptConfigMap: envOrDefault("LIGHTSPEED_PROMPT_CONFIGMAP", "ota-advisory-prompt"), + } +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func (c *Controller) Queue() workqueue.TypedRateLimitingInterface[any] { + return c.queue +} + +func (c *Controller) QueueKey() string { + return c.queueKey +} + +func (c *Controller) Sync(ctx context.Context, key string) error { + startTime := time.Now() + klog.V(i.Normal).Infof("Started syncing CVO configuration %q", key) + defer func() { + klog.V(i.Normal).Infof("Finished syncing CVO configuration (%v)", time.Since(startTime)) + }() + + updates, conditionalUpdates, err := c.updatesGetterFunc() + if err != nil { + klog.Errorf("Error getting available updates: %v", err) + return err + } + klog.V(i.Debug).Infof("Got available updates: %#v", updates) + klog.V(i.Debug).Infof("Got conditional updates: %#v", conditionalUpdates) + + if len(updates) == 0 && len(conditionalUpdates) == 0 { + return nil + } + + cv, err := c.cvGetterFunc(i.DefaultClusterVersionName) + if err != nil { + klog.V(i.Normal).Infof("Failed to get ClusterVersion %s: %v", i.DefaultClusterVersionName, err) + return fmt.Errorf("failed to get ClusterVersion %s: %w", i.DefaultClusterVersionName, err) + } + + var prompt string + promptConfigMap, err := c.configMapGetterFunc(c.config.Namespace, c.config.PromptConfigMap) + if err != nil { + klog.V(i.Normal).Infof("Failed to get prompt ConfigMap %s/%s: %v", c.config.Namespace, c.config.PromptConfigMap, err) + return fmt.Errorf("failed to get prompt ConfigMap %s/%s: %w", c.config.Namespace, c.config.PromptConfigMap, err) + } + promptKey := "prompt" + if v, ok := promptConfigMap.Data[promptKey]; ok { + prompt = v + } else { + klog.V(i.Normal).Infof("ConfigMap %s/%s has no key %s in data", c.config.Namespace, c.config.PromptConfigMap, promptKey) + // raise error? + } + + currentVersion := c.getCurrentVersionFunc() + + var errs []error + if err := deleteProposals(ctx, c.client, updates, conditionalUpdates, cv.Status.History, currentVersion); err != nil { + errs = append(errs, err) + } + + // TODO: + readinessJSON := "todo-readinessJSON" + proposals, err := getProposals(updates, conditionalUpdates, c.config.Namespace, currentVersion, cv.Spec.Channel, c.config.Workflow, prompt, readinessJSON) + if err != nil { + klog.V(i.Normal).Infof("Getting proposals hit an error: %v", err) + } + + for _, proposal := range proposals { + existing := &proposalv1alpha1.Proposal{} + err := c.client.Get(ctx, ctrlruntimeclient.ObjectKey{Name: proposal.Name, Namespace: proposal.Namespace}, existing) + if err != nil { + if !kerrors.IsNotFound(err) { + klog.V(i.Normal).Infof("Failed to get proposal %s/%s: %v", proposal.Namespace, proposal.Name, err) + errs = append(errs, err) + continue + } + } else { + if !ownedByCVO(existing) { + klog.V(i.Normal).Infof("Ignored proposal %s/%s not owned by CVO", proposal.Namespace, proposal.Name) + continue + } + if expired(existing) { + if err := deleteProposal(ctx, c.client, existing, "expired"); err != nil { + errs = append(errs, err) + continue + } + } else { + klog.V(i.Debug).Infof("The existing proposal %s/%s is not expired", proposal.Namespace, proposal.Name) + continue + } + } + + if c.client.Create(ctx, proposal) != nil { + if !kerrors.IsAlreadyExists(err) { + klog.V(i.Normal).Infof("Failed to create proposal %s/%s: %v", proposal.Namespace, proposal.Name, err) + errs = append(errs, err) + } else { + klog.V(i.Debug).Infof("The proposal %s/%s existed already", proposal.Namespace, proposal.Name) + } + } else { + klog.V(i.Debug).Infof("Created proposal %s/%s", proposal.Namespace, proposal.Name) + } + } + + return kutilerrors.NewAggregate(errs) +} + +func ownedByCVO(p *proposalv1alpha1.Proposal) bool { + if p == nil { + return false + } + return p.Labels[labelKeySource] == labelValueSource +} + +func expired(p *proposalv1alpha1.Proposal) bool { + if p == nil { + return false + } + return time.Now().After(p.CreationTimestamp.Add(proposalExpiration)) +} + +func deleteProposals(ctx context.Context, client ctrlruntimeclient.Client, availableUpdates []configv1.Release, conditionalUpdates []configv1.ConditionalUpdate, history []configv1.UpdateHistory, currentVersion string) error { + targets := sets.New[string]() + for _, update := range availableUpdates { + targets.Insert(labelValueFromVersion(update.Version)) + } + for _, update := range conditionalUpdates { + targets.Insert(labelValueFromVersion(update.Release.Version)) + } + associatedWithHistory := sets.New[string]() + for _, h := range history { + associatedWithHistory.Insert(labelValueFromVersion(h.Version)) + } + + list := &proposalv1alpha1.ProposalList{} + if err := client.List(ctx, list, ctrlruntimeclient.MatchingLabels(map[string]string{labelKeySource: labelValueSource})); err != nil { + return fmt.Errorf("failed to list proposals: %w", err) + } + var errs []error + for _, proposal := range list.Items { + if !ownedByCVO(&proposal) { + klog.V(i.Debug).Infof("Keeping proposal %s/%s not owned by CVO", proposal.Namespace, proposal.Name) + continue + } + cv, cvOk := proposal.Labels[labelKeyCurrentVersion] + tv, tvOk := proposal.Labels[labelKeyTargetVersion] + if cvOk && tvOk && cv == currentVersion && targets.Has(tv) { + klog.V(i.Debug).Infof("Keeping relevant proposal %s/%s from %s to %s", proposal.Namespace, proposal.Name, cv, tv) + continue + } + if tvOk && associatedWithHistory.Has(tv) { + klog.V(i.Debug).Infof("Keeping proposal %s/%s for a version %s associated with history", proposal.Namespace, proposal.Name, tv) + continue + } + err := deleteProposal(ctx, client, &proposal, "irrelevant") + if err != nil { + errs = append(errs, err) + } + } + + return kutilerrors.NewAggregate(errs) +} + +func deleteProposal(ctx context.Context, client ctrlruntimeclient.Client, proposal *proposalv1alpha1.Proposal, adjective string) error { + if proposal == nil { + return nil + } + klog.V(i.Normal).Infof("Deleting %s proposal %s/%s ...", adjective, proposal.Namespace, proposal.Name) + err := client.Delete(ctx, proposal) + if err == nil { + klog.V(i.Normal).Infof("Deleted %s proposal %s/%s", adjective, proposal.Namespace, proposal.Name) + return nil + } + + if !kerrors.IsNotFound(err) { + klog.V(i.Normal).Infof("Failed to delete %s proposal %s/%s: %v", adjective, proposal.Namespace, proposal.Name, err) + return err + } + + klog.V(i.Normal).Infof("Failed to delete not-found proposal %s/%s", proposal.Namespace, proposal.Name) + return nil +} + +func getProposals( + availableUpdates []configv1.Release, + conditionalUpdates []configv1.ConditionalUpdate, + namespace string, + currentVersion, channel, + workflowRefName string, + systemPrompt string, + readinessJSON string, +) ([]*proposalv1alpha1.Proposal, error) { + var errs []error + var proposals []*proposalv1alpha1.Proposal + for _, au := range availableUpdates { + targetVersion := au.Version + if proposal, err := getProposal(namespace, currentVersion, targetVersion, channel, updateKindRecommended, workflowRefName, systemPrompt, readinessJSON, availableUpdates); err != nil { + errs = append(errs, err) + continue + } else { + proposals = append(proposals, proposal) + } + } + + for _, cu := range conditionalUpdates { + targetVersion := cu.Release.Version + if proposal, err := getProposal(namespace, currentVersion, targetVersion, channel, updateKindConditional, workflowRefName, systemPrompt, readinessJSON, availableUpdates); err != nil { + errs = append(errs, err) + continue + } else { + proposals = append(proposals, proposal) + } + } + + return proposals, kutilerrors.NewAggregate(errs) +} + +func getProposal(namespace, currentVersion, targetVersion, channel, updateKind, workflowRefName, systemPrompt, readinessJSON string, availableUpdates []configv1.Release) (*proposalv1alpha1.Proposal, error) { + + var errs []error + for _, v := range []string{currentVersion, targetVersion} { + if _, err := semver.Parse(v); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return nil, kutilerrors.NewAggregate(errs) + } + + name := proposalName(currentVersion, targetVersion) + updateType := classifyUpdate(currentVersion, targetVersion) + request := buildRequest(systemPrompt, currentVersion, targetVersion, channel, updateType, updateKind, availableUpdates, readinessJSON) + return &proposalv1alpha1.Proposal{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + labelKeySource: labelValueSource, + labelKeyCurrentVersion: labelValueFromVersion(currentVersion), + labelKeyTargetVersion: labelValueFromVersion(targetVersion), + "agentic.openshift.io/update-type": updateType, + }, + }, + Spec: proposalv1alpha1.ProposalSpec{ + Request: request, + WorkflowRef: corev1.LocalObjectReference{ + Name: workflowRefName, + }, + MaxAttempts: ptr.To(2), + }, + }, nil +} + +// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set +func labelValueFromVersion(version string) string { + return trim63(version) +} + +func trim63(value string) string { + if len(value) > 63 { + return value[:60] + "xxx" + } + return value +} + +// proposalName generates a deterministic proposal name from the version pair. +func proposalName(current, target string) string { + return toDNS1035(fmt.Sprintf("ota-%s-to-%s", current, target)) +} + +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names +func toDNS1035(name string) string { + // Convert to lowercase + ret := strings.ToLower(name) + + // Replace any non-alphanumeric character with a hyphen + reg := regexp.MustCompile(`[^a-z0-9]+`) + ret = reg.ReplaceAllString(ret, "-") + + // Trim hyphens from the ends + ret = strings.Trim(ret, "-") + + return trim63(ret) +} + +const ( + labelKeySource = "agentic.openshift.io/source" + labelValueSource = "cluster-version-operator" + labelKeyCurrentVersion = "agentic.openshift.io/current-version" + labelKeyTargetVersion = "agentic.openshift.io/target-version" + + updateKindRecommended = "Recommended" + updateKindConditional = "Conditional" + + proposalExpiration = 24 * time.Hour +) + +// classifyUpdate returns "z-stream" if major.minor match, otherwise "minor". +func classifyUpdate(current, target string) string { + cv, cerr := semver.Parse(current) + tv, terr := semver.Parse(target) + if cerr != nil || terr != nil { + return i.UpdateTypeUnknown + } + return i.UpdateType(cv, tv) +} + +// buildRequest constructs the proposal request with system prompt, metadata, and readiness data. +func buildRequest(systemPrompt, current, target, channel, updateType, targetType string, + updates []configv1.Release, readinessJSON string) string { + + var b strings.Builder + + if systemPrompt != "" { + b.WriteString(systemPrompt) + b.WriteString("\n\n---\n\n") + } + + _, _ = fmt.Fprintf(&b, "Current version: OCP %s\n", current) + _, _ = fmt.Fprintf(&b, "Target version: OCP %s\n", target) + _, _ = fmt.Fprintf(&b, "Channel: %s\n", channel) + _, _ = fmt.Fprintf(&b, "Update type: %s\n", updateType) + _, _ = fmt.Fprintf(&b, "Update path: %s\n\n", targetType) + + if targetType == updateKindConditional { + b.WriteString("WARNING: This target version is available as a CONDITIONAL update.\n") + b.WriteString("OSUS has flagged known risks that may apply to this cluster.\n") + b.WriteString("The assessment MUST evaluate each conditional risk against cluster state.\n\n") + } + + if len(updates) > 1 { + b.WriteString("Other recommended versions available:\n") + count := 0 + for _, u := range updates { + if u.Version != target { + if u.URL != "" { + _, _ = fmt.Fprintf(&b, " - %s (errata: %s)\n", u.Version, u.URL) + } else { + _, _ = fmt.Fprintf(&b, " - %s\n", u.Version) + } + count++ + if count >= 5 { + remaining := len(updates) - count - 1 + if remaining > 0 { + _, _ = fmt.Fprintf(&b, " ... and %d more\n", remaining) + } + break + } + } + } + b.WriteString("\n") + } + + if readinessJSON != "" { + b.WriteString("## Cluster Readiness Data\n\n") + b.WriteString("```json\n") + b.WriteString(readinessJSON) + b.WriteString("\n```\n") + } + + return b.String() +} diff --git a/pkg/proposal/controller_test.go b/pkg/proposal/controller_test.go new file mode 100644 index 0000000000..7f4598c8bd --- /dev/null +++ b/pkg/proposal/controller_test.go @@ -0,0 +1,200 @@ +package proposal + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + + configv1 "github.com/openshift/api/config/v1" + + proposalv1alpha1 "github.com/openshift/cluster-version-operator/pkg/proposal/api/v1alpha1" +) + +func init() { + err := proposalv1alpha1.AddToScheme(scheme.Scheme) + if err != nil { + panic(err) + } +} + +func TestController_Sync(t *testing.T) { + tests := []struct { + name string + updatesGetterFunc updatesGetterFunc + client ctrlruntimeclient.Client + cvGetterFunc cvGetterFunc + expected error + verifyFunc func(client ctrlruntimeclient.Client) error + }{ + { + name: "basic case", + updatesGetterFunc: func() ([]configv1.Release, []configv1.ConditionalUpdate, error) { + return []configv1.Release{ + { + Version: "5.0.0-ec.0", + }, + }, nil, nil + }, + cvGetterFunc: func(_ string) (*configv1.ClusterVersion, error) { + return &configv1.ClusterVersion{}, nil + }, + client: fake.NewClientBuilder().Build(), + verifyFunc: func(client ctrlruntimeclient.Client) error { + proposals := &proposalv1alpha1.ProposalList{} + if err := client.List(context.Background(), proposals); err != nil { + return err + } + if len(proposals.Items) == 0 { + return fmt.Errorf("expected proposals, none") + } + return nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewController(tt.updatesGetterFunc, tt.client, tt.cvGetterFunc, func(name, namespace string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{}, nil + }, func() string { + return "5.0.0-ec.0" + }) + actual := c.Sync(context.Background(), tt.name) + if diff := cmp.Diff(tt.expected, actual, cmp.Transformer("Error", func(e error) string { + if e == nil { + return "" + } + return e.Error() + })); diff != "" { + t.Errorf("unexpected error (-want +got):\n%s", diff) + } + if tt.verifyFunc != nil { + if err := tt.verifyFunc(tt.client); err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} + +func TestClassifyUpdate(t *testing.T) { + tests := []struct { + name string + current string + target string + expected string + }{ + {name: "z-stream", current: "4.15.1", target: "4.15.3", expected: "Patch"}, + {name: "minor", current: "4.15.1", target: "4.16.0", expected: "Minor"}, + {name: "major", current: "4.15.1", target: "5.0.0", expected: "Major"}, + {name: "invalid current", current: "bad", target: "4.15.0", expected: "Unknown"}, + {name: "invalid target", current: "4.15.0", target: "bad", expected: "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyUpdate(tt.current, tt.target) + if got != tt.expected { + t.Errorf("classifyUpdate(%q, %q) = %q, want %q", tt.current, tt.target, got, tt.expected) + } + }) + } +} + +func TestProposalName(t *testing.T) { + tests := []struct { + current string + target string + expected string + }{ + {"4.15.1", "4.15.3", "ota-4-15-1-to-4-15-3"}, + {"4.15.1", "4.16.0", "ota-4-15-1-to-4-16-0"}, + } + + for _, tt := range tests { + t.Run(tt.current+"->"+tt.target, func(t *testing.T) { + got := proposalName(tt.current, tt.target) + if got != tt.expected { + t.Errorf("proposalName(%q, %q) = %q, want %q", tt.current, tt.target, got, tt.expected) + } + }) + } +} + +func Test_labelValueFromVersion(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"4.15.1", "4.15.1"}, + {"4.15.1-a-very-long-version-string-that-is-too-long-long-version-string-that-is-too-long", "4.15.1-a-very-long-version-string-that-is-too-long-long-versxxx"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := labelValueFromVersion(tt.input) + if got != tt.expected { + t.Errorf("sanitize(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + updates := []configv1.Release{ + {Version: "4.16.0", URL: "https://example.com/errata/1"}, + {Version: "4.16.1", URL: "https://example.com/errata/2"}, + } + + t.Run("recommended target", func(t *testing.T) { + request := buildRequest("", "4.15.3", "4.16.0", "stable-4.16", "minor", "recommended", updates, "") + if !strings.Contains(request, "Current version: OCP 4.15.3") { + t.Error("request should contain current version") + } + if !strings.Contains(request, "Target version: OCP 4.16.0") { + t.Error("request should contain target version") + } + if !strings.Contains(request, "Update type: minor") { + t.Error("request should contain update type") + } + if !strings.Contains(request, "Update path: recommended") { + t.Error("request should contain update path") + } + if strings.Contains(request, "WARNING") { + t.Error("recommended target should not have warning") + } + if !strings.Contains(request, "Other recommended versions available:") { + t.Error("should list other versions when more than one update") + } + if !strings.Contains(request, "4.16.1") { + t.Error("should list alternative version") + } + }) + + t.Run("conditional target", func(t *testing.T) { + request := buildRequest("", "4.15.3", "4.16.0", "stable-4.16", "minor", "Conditional", updates, "") + if !strings.Contains(request, "WARNING") { + t.Error("conditional target should have warning") + } + if !strings.Contains(request, "CONDITIONAL update") { + t.Error("conditional target should mention CONDITIONAL") + } + }) + + t.Run("readiness JSON embedded", func(t *testing.T) { + request := buildRequest("", "4.15.3", "4.16.0", "stable-4.16", "minor", "Recommended", updates, `{"checks":{},"meta":{}}`) + if !strings.Contains(request, "## Cluster Readiness Data") { + t.Error("request should contain readiness data header") + } + if !strings.Contains(request, `{"checks":{},"meta":{}}`) { + t.Error("request should contain readiness JSON") + } + }) +} diff --git a/pkg/start/start.go b/pkg/start/start.go index b8401cb678..5995f21ec1 100644 --- a/pkg/start/start.go +++ b/pkg/start/start.go @@ -13,6 +13,7 @@ import ( "time" "github.com/google/uuid" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -47,6 +48,7 @@ import ( "github.com/openshift/cluster-version-operator/pkg/featuregates" "github.com/openshift/cluster-version-operator/pkg/internal" "github.com/openshift/cluster-version-operator/pkg/payload" + proposalv1alpha1 "github.com/openshift/cluster-version-operator/pkg/proposal/api/v1alpha1" ) const ( @@ -505,6 +507,14 @@ func (cb *ClientBuilder) OperatorClientOrDie(name string, configFns ...func(*res return operatorclientset.NewForConfigOrDie(rest.AddUserAgent(cb.RestConfig(configFns...), name)) } +func (cb *ClientBuilder) RuntimeControllerClientOrDie(name string, configFns ...func(*rest.Config)) runtimeclient.Client { + c, err := runtimeclient.New(rest.AddUserAgent(cb.RestConfig(configFns...), name), runtimeclient.Options{}) + if err != nil { + panic(err) + } + return c +} + func newClientBuilder(kubeconfig string) (*ClientBuilder, error) { clientCfg := clientcmd.NewDefaultClientConfigLoadingRules() clientCfg.ExplicitPath = kubeconfig @@ -577,6 +587,13 @@ type Context struct { OperatorInformerFactory operatorinformers.SharedInformerFactory } +func addSchemes() error { + if err := proposalv1alpha1.AddToScheme(scheme.Scheme); err != nil { + return fmt.Errorf("failed to add proposalv1alpha1 to scheme: %w", err) + } + return nil +} + // NewControllerContext initializes the default Context for the current Options. It does // not start any background processes. func (o *Options) NewControllerContext( @@ -602,6 +619,11 @@ func (o *Options) NewControllerContext( cvoKubeClient := cb.KubeClientOrDie(o.Namespace, useProtobuf) o.PromQLTarget.KubeClient = cvoKubeClient + if err := addSchemes(); err != nil { + return nil, err + } + rtClient := cb.RuntimeControllerClientOrDie("runtime-controller-client") + cvo, err := cvo.New( o.NodeName, o.Namespace, o.Name, @@ -628,6 +650,7 @@ func (o *Options) NewControllerContext( startingFeatureSet, startingCvoGates, startingEnabledManifestFeatureGates, + rtClient, ) if err != nil { return nil, err diff --git a/test/cvo/proposal.go b/test/cvo/proposal.go index 13333fac54..c8db0571b1 100644 --- a/test/cvo/proposal.go +++ b/test/cvo/proposal.go @@ -2,26 +2,48 @@ package cvo import ( "context" + "time" g "github.com/onsi/ginkgo/v2" o "github.com/onsi/gomega" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + oteginkgo "github.com/openshift-eng/openshift-tests-extension/pkg/ginkgo" + configv1 "github.com/openshift/api/config/v1" + configv1client "github.com/openshift/client-go/config/clientset/versioned/typed/config/v1" + + "github.com/openshift/cluster-version-operator/pkg/external" + proposalv1alpha1 "github.com/openshift/cluster-version-operator/pkg/proposal/api/v1alpha1" "github.com/openshift/cluster-version-operator/test/util" ) +func init() { + err := proposalv1alpha1.AddToScheme(scheme.Scheme) + if err != nil { + panic(err) + } +} + var _ = g.Describe(`[Jira:"Cluster Version Operator"] cluster-version-operator`, func() { var ( - c *rest.Config - err error - - ctx = context.Background() + c *rest.Config + configClient *configv1client.ConfigV1Client apiExtensionsClient apiextensionsclientset.Interface + rtClient ctrlruntimeclient.Client + err error + + ctx = context.Background() + needRecover bool + backup configv1.ClusterVersionSpec ) g.BeforeEach(func() { @@ -31,9 +53,32 @@ var _ = g.Describe(`[Jira:"Cluster Version Operator"] cluster-version-operator`, o.Expect(util.SkipIfHypershift(ctx, c)).To(o.BeNil()) o.Expect(util.SkipIfMicroshift(ctx, c)).To(o.BeNil()) + configClient, err = configv1client.NewForConfig(c) + o.Expect(err).To(o.BeNil()) + apiExtensionsClient, err = apiextensionsclientset.NewForConfig(c) o.Expect(err).To(o.BeNil()) + rtClient, err = ctrlruntimeclient.New(config.GetConfigOrDie(), ctrlruntimeclient.Options{}) + o.Expect(err).To(o.BeNil()) + + cv, err := configClient.ClusterVersions().Get(ctx, external.DefaultClusterVersionName, metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + if du := cv.Spec.DesiredUpdate; du != nil { + logger.WithValues("AcceptRisks", du.AcceptRisks).Info("Accept risks before testing") + o.Expect(du.AcceptRisks).To(o.BeEmpty(), "found accept risks") + } + backup = *cv.Spec.DeepCopy() + }) + + g.AfterEach(func() { + if needRecover { + cv, err := configClient.ClusterVersions().Get(ctx, external.DefaultClusterVersionName, metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + cv.Spec = backup + _, err = configClient.ClusterVersions().Update(ctx, cv, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + } }) g.It("should install light speed CRDs correctly", func() { @@ -46,4 +91,31 @@ var _ = g.Describe(`[Jira:"Cluster Version Operator"] cluster-version-operator`, } } }) + + g.It("should create proposals", g.Label("Serial"), oteginkgo.Informing(), func() { + o.Expect(util.SkipIfNetworkRestricted(ctx, c, util.FauxinnatiAPIURL)).To(o.BeNil()) + util.SkipIfNotTechPreviewNoUpgrade(ctx, c) + + cv, err := configClient.ClusterVersions().Get(ctx, external.DefaultClusterVersionName, metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Using fauxinnati as the upstream and its simple channel") + cv.Spec.Upstream = util.FauxinnatiAPIURL + cv.Spec.Channel = "simple" + + _, err = configClient.ClusterVersions().Update(ctx, cv, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + needRecover = true + + g.By("Checking if the proposal are created") + o.Expect(wait.PollUntilContextTimeout(ctx, 30*time.Second, 5*time.Minute, true, func(ctx context.Context) (done bool, err error) { + proposals := proposalv1alpha1.ProposalList{} + err = rtClient.List(ctx, &proposals, ctrlruntimeclient.InNamespace(external.DefaultCVONamespace)) + o.Expect(err).NotTo(o.HaveOccurred()) + if len(proposals.Items) == 0 { + return false, nil + } + return true, nil + })).NotTo(o.HaveOccurred(), "no proposals found") + }) }) diff --git a/vendor/github.com/evanphx/json-patch/v5/LICENSE b/vendor/github.com/evanphx/json-patch/v5/LICENSE new file mode 100644 index 0000000000..df76d7d771 --- /dev/null +++ b/vendor/github.com/evanphx/json-patch/v5/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2014, Evan Phoenix +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of the Evan Phoenix nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/evanphx/json-patch/v5/errors.go b/vendor/github.com/evanphx/json-patch/v5/errors.go new file mode 100644 index 0000000000..75304b4437 --- /dev/null +++ b/vendor/github.com/evanphx/json-patch/v5/errors.go @@ -0,0 +1,38 @@ +package jsonpatch + +import "fmt" + +// AccumulatedCopySizeError is an error type returned when the accumulated size +// increase caused by copy operations in a patch operation has exceeded the +// limit. +type AccumulatedCopySizeError struct { + limit int64 + accumulated int64 +} + +// NewAccumulatedCopySizeError returns an AccumulatedCopySizeError. +func NewAccumulatedCopySizeError(l, a int64) *AccumulatedCopySizeError { + return &AccumulatedCopySizeError{limit: l, accumulated: a} +} + +// Error implements the error interface. +func (a *AccumulatedCopySizeError) Error() string { + return fmt.Sprintf("Unable to complete the copy, the accumulated size increase of copy is %d, exceeding the limit %d", a.accumulated, a.limit) +} + +// ArraySizeError is an error type returned when the array size has exceeded +// the limit. +type ArraySizeError struct { + limit int + size int +} + +// NewArraySizeError returns an ArraySizeError. +func NewArraySizeError(l, s int) *ArraySizeError { + return &ArraySizeError{limit: l, size: s} +} + +// Error implements the error interface. +func (a *ArraySizeError) Error() string { + return fmt.Sprintf("Unable to create array of size %d, limit is %d", a.size, a.limit) +} diff --git a/vendor/github.com/evanphx/json-patch/v5/internal/json/decode.go b/vendor/github.com/evanphx/json-patch/v5/internal/json/decode.go new file mode 100644 index 0000000000..e9bb0efe77 --- /dev/null +++ b/vendor/github.com/evanphx/json-patch/v5/internal/json/decode.go @@ -0,0 +1,1385 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Represents JSON data structure using native Go types: booleans, floats, +// strings, arrays, and maps. + +package json + +import ( + "encoding" + "encoding/base64" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "unicode" + "unicode/utf16" + "unicode/utf8" +) + +// Unmarshal parses the JSON-encoded data and stores the result +// in the value pointed to by v. If v is nil or not a pointer, +// Unmarshal returns an InvalidUnmarshalError. +// +// Unmarshal uses the inverse of the encodings that +// Marshal uses, allocating maps, slices, and pointers as necessary, +// with the following additional rules: +// +// To unmarshal JSON into a pointer, Unmarshal first handles the case of +// the JSON being the JSON literal null. In that case, Unmarshal sets +// the pointer to nil. Otherwise, Unmarshal unmarshals the JSON into +// the value pointed at by the pointer. If the pointer is nil, Unmarshal +// allocates a new value for it to point to. +// +// To unmarshal JSON into a value implementing the Unmarshaler interface, +// Unmarshal calls that value's UnmarshalJSON method, including +// when the input is a JSON null. +// Otherwise, if the value implements encoding.TextUnmarshaler +// and the input is a JSON quoted string, Unmarshal calls that value's +// UnmarshalText method with the unquoted form of the string. +// +// To unmarshal JSON into a struct, Unmarshal matches incoming object +// keys to the keys used by Marshal (either the struct field name or its tag), +// preferring an exact match but also accepting a case-insensitive match. By +// default, object keys which don't have a corresponding struct field are +// ignored (see Decoder.DisallowUnknownFields for an alternative). +// +// To unmarshal JSON into an interface value, +// Unmarshal stores one of these in the interface value: +// +// bool, for JSON booleans +// float64, for JSON numbers +// string, for JSON strings +// []interface{}, for JSON arrays +// map[string]interface{}, for JSON objects +// nil for JSON null +// +// To unmarshal a JSON array into a slice, Unmarshal resets the slice length +// to zero and then appends each element to the slice. +// As a special case, to unmarshal an empty JSON array into a slice, +// Unmarshal replaces the slice with a new empty slice. +// +// To unmarshal a JSON array into a Go array, Unmarshal decodes +// JSON array elements into corresponding Go array elements. +// If the Go array is smaller than the JSON array, +// the additional JSON array elements are discarded. +// If the JSON array is smaller than the Go array, +// the additional Go array elements are set to zero values. +// +// To unmarshal a JSON object into a map, Unmarshal first establishes a map to +// use. If the map is nil, Unmarshal allocates a new map. Otherwise Unmarshal +// reuses the existing map, keeping existing entries. Unmarshal then stores +// key-value pairs from the JSON object into the map. The map's key type must +// either be any string type, an integer, implement json.Unmarshaler, or +// implement encoding.TextUnmarshaler. +// +// If the JSON-encoded data contain a syntax error, Unmarshal returns a SyntaxError. +// +// If a JSON value is not appropriate for a given target type, +// or if a JSON number overflows the target type, Unmarshal +// skips that field and completes the unmarshaling as best it can. +// If no more serious errors are encountered, Unmarshal returns +// an UnmarshalTypeError describing the earliest such error. In any +// case, it's not guaranteed that all the remaining fields following +// the problematic one will be unmarshaled into the target object. +// +// The JSON null value unmarshals into an interface, map, pointer, or slice +// by setting that Go value to nil. Because null is often used in JSON to mean +// “not present,” unmarshaling a JSON null into any other Go type has no effect +// on the value and produces no error. +// +// When unmarshaling quoted strings, invalid UTF-8 or +// invalid UTF-16 surrogate pairs are not treated as an error. +// Instead, they are replaced by the Unicode replacement +// character U+FFFD. +func Unmarshal(data []byte, v any) error { + // Check for well-formedness. + // Avoids filling out half a data structure + // before discovering a JSON syntax error. + d := ds.Get().(*decodeState) + defer ds.Put(d) + //var d decodeState + d.useNumber = true + err := checkValid(data, &d.scan) + if err != nil { + return err + } + + d.init(data) + return d.unmarshal(v) +} + +var ds = sync.Pool{ + New: func() any { + return new(decodeState) + }, +} + +func UnmarshalWithKeys(data []byte, v any) ([]string, error) { + // Check for well-formedness. + // Avoids filling out half a data structure + // before discovering a JSON syntax error. + + d := ds.Get().(*decodeState) + defer ds.Put(d) + //var d decodeState + d.useNumber = true + err := checkValid(data, &d.scan) + if err != nil { + return nil, err + } + + d.init(data) + err = d.unmarshal(v) + if err != nil { + return nil, err + } + + return d.lastKeys, nil +} + +func UnmarshalValid(data []byte, v any) error { + // Check for well-formedness. + // Avoids filling out half a data structure + // before discovering a JSON syntax error. + d := ds.Get().(*decodeState) + defer ds.Put(d) + //var d decodeState + d.useNumber = true + + d.init(data) + return d.unmarshal(v) +} + +func UnmarshalValidWithKeys(data []byte, v any) ([]string, error) { + // Check for well-formedness. + // Avoids filling out half a data structure + // before discovering a JSON syntax error. + + d := ds.Get().(*decodeState) + defer ds.Put(d) + //var d decodeState + d.useNumber = true + + d.init(data) + err := d.unmarshal(v) + if err != nil { + return nil, err + } + + return d.lastKeys, nil +} + +// Unmarshaler is the interface implemented by types +// that can unmarshal a JSON description of themselves. +// The input can be assumed to be a valid encoding of +// a JSON value. UnmarshalJSON must copy the JSON data +// if it wishes to retain the data after returning. +// +// By convention, to approximate the behavior of Unmarshal itself, +// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op. +type Unmarshaler interface { + UnmarshalJSON([]byte) error +} + +// An UnmarshalTypeError describes a JSON value that was +// not appropriate for a value of a specific Go type. +type UnmarshalTypeError struct { + Value string // description of JSON value - "bool", "array", "number -5" + Type reflect.Type // type of Go value it could not be assigned to + Offset int64 // error occurred after reading Offset bytes + Struct string // name of the struct type containing the field + Field string // the full path from root node to the field +} + +func (e *UnmarshalTypeError) Error() string { + if e.Struct != "" || e.Field != "" { + return "json: cannot unmarshal " + e.Value + " into Go struct field " + e.Struct + "." + e.Field + " of type " + e.Type.String() + } + return "json: cannot unmarshal " + e.Value + " into Go value of type " + e.Type.String() +} + +// An UnmarshalFieldError describes a JSON object key that +// led to an unexported (and therefore unwritable) struct field. +// +// Deprecated: No longer used; kept for compatibility. +type UnmarshalFieldError struct { + Key string + Type reflect.Type + Field reflect.StructField +} + +func (e *UnmarshalFieldError) Error() string { + return "json: cannot unmarshal object key " + strconv.Quote(e.Key) + " into unexported field " + e.Field.Name + " of type " + e.Type.String() +} + +// An InvalidUnmarshalError describes an invalid argument passed to Unmarshal. +// (The argument to Unmarshal must be a non-nil pointer.) +type InvalidUnmarshalError struct { + Type reflect.Type +} + +func (e *InvalidUnmarshalError) Error() string { + if e.Type == nil { + return "json: Unmarshal(nil)" + } + + if e.Type.Kind() != reflect.Pointer { + return "json: Unmarshal(non-pointer " + e.Type.String() + ")" + } + return "json: Unmarshal(nil " + e.Type.String() + ")" +} + +func (d *decodeState) unmarshal(v any) error { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return &InvalidUnmarshalError{reflect.TypeOf(v)} + } + + d.scan.reset() + d.scanWhile(scanSkipSpace) + // We decode rv not rv.Elem because the Unmarshaler interface + // test must be applied at the top level of the value. + err := d.value(rv) + if err != nil { + return d.addErrorContext(err) + } + return d.savedError +} + +// A Number represents a JSON number literal. +type Number string + +// String returns the literal text of the number. +func (n Number) String() string { return string(n) } + +// Float64 returns the number as a float64. +func (n Number) Float64() (float64, error) { + return strconv.ParseFloat(string(n), 64) +} + +// Int64 returns the number as an int64. +func (n Number) Int64() (int64, error) { + return strconv.ParseInt(string(n), 10, 64) +} + +// An errorContext provides context for type errors during decoding. +type errorContext struct { + Struct reflect.Type + FieldStack []string +} + +// decodeState represents the state while decoding a JSON value. +type decodeState struct { + data []byte + off int // next read offset in data + opcode int // last read result + scan scanner + errorContext *errorContext + savedError error + useNumber bool + disallowUnknownFields bool + lastKeys []string +} + +// readIndex returns the position of the last byte read. +func (d *decodeState) readIndex() int { + return d.off - 1 +} + +// phasePanicMsg is used as a panic message when we end up with something that +// shouldn't happen. It can indicate a bug in the JSON decoder, or that +// something is editing the data slice while the decoder executes. +const phasePanicMsg = "JSON decoder out of sync - data changing underfoot?" + +func (d *decodeState) init(data []byte) *decodeState { + d.data = data + d.off = 0 + d.savedError = nil + if d.errorContext != nil { + d.errorContext.Struct = nil + // Reuse the allocated space for the FieldStack slice. + d.errorContext.FieldStack = d.errorContext.FieldStack[:0] + } + return d +} + +// saveError saves the first err it is called with, +// for reporting at the end of the unmarshal. +func (d *decodeState) saveError(err error) { + if d.savedError == nil { + d.savedError = d.addErrorContext(err) + } +} + +// addErrorContext returns a new error enhanced with information from d.errorContext +func (d *decodeState) addErrorContext(err error) error { + if d.errorContext != nil && (d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0) { + switch err := err.(type) { + case *UnmarshalTypeError: + err.Struct = d.errorContext.Struct.Name() + err.Field = strings.Join(d.errorContext.FieldStack, ".") + } + } + return err +} + +// skip scans to the end of what was started. +func (d *decodeState) skip() { + s, data, i := &d.scan, d.data, d.off + depth := len(s.parseState) + for { + op := s.step(s, data[i]) + i++ + if len(s.parseState) < depth { + d.off = i + d.opcode = op + return + } + } +} + +// scanNext processes the byte at d.data[d.off]. +func (d *decodeState) scanNext() { + if d.off < len(d.data) { + d.opcode = d.scan.step(&d.scan, d.data[d.off]) + d.off++ + } else { + d.opcode = d.scan.eof() + d.off = len(d.data) + 1 // mark processed EOF with len+1 + } +} + +// scanWhile processes bytes in d.data[d.off:] until it +// receives a scan code not equal to op. +func (d *decodeState) scanWhile(op int) { + s, data, i := &d.scan, d.data, d.off + for i < len(data) { + newOp := s.step(s, data[i]) + i++ + if newOp != op { + d.opcode = newOp + d.off = i + return + } + } + + d.off = len(data) + 1 // mark processed EOF with len+1 + d.opcode = d.scan.eof() +} + +// rescanLiteral is similar to scanWhile(scanContinue), but it specialises the +// common case where we're decoding a literal. The decoder scans the input +// twice, once for syntax errors and to check the length of the value, and the +// second to perform the decoding. +// +// Only in the second step do we use decodeState to tokenize literals, so we +// know there aren't any syntax errors. We can take advantage of that knowledge, +// and scan a literal's bytes much more quickly. +func (d *decodeState) rescanLiteral() { + data, i := d.data, d.off +Switch: + switch data[i-1] { + case '"': // string + for ; i < len(data); i++ { + switch data[i] { + case '\\': + i++ // escaped char + case '"': + i++ // tokenize the closing quote too + break Switch + } + } + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-': // number + for ; i < len(data); i++ { + switch data[i] { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + '.', 'e', 'E', '+', '-': + default: + break Switch + } + } + case 't': // true + i += len("rue") + case 'f': // false + i += len("alse") + case 'n': // null + i += len("ull") + } + if i < len(data) { + d.opcode = stateEndValue(&d.scan, data[i]) + } else { + d.opcode = scanEnd + } + d.off = i + 1 +} + +// value consumes a JSON value from d.data[d.off-1:], decoding into v, and +// reads the following byte ahead. If v is invalid, the value is discarded. +// The first byte of the value has been read already. +func (d *decodeState) value(v reflect.Value) error { + switch d.opcode { + default: + panic(phasePanicMsg) + + case scanBeginArray: + if v.IsValid() { + if err := d.array(v); err != nil { + return err + } + } else { + d.skip() + } + d.scanNext() + + case scanBeginObject: + if v.IsValid() { + if err := d.object(v); err != nil { + return err + } + } else { + d.skip() + } + d.scanNext() + + case scanBeginLiteral: + // All bytes inside literal return scanContinue op code. + start := d.readIndex() + d.rescanLiteral() + + if v.IsValid() { + if err := d.literalStore(d.data[start:d.readIndex()], v, false); err != nil { + return err + } + } + } + return nil +} + +type unquotedValue struct{} + +// valueQuoted is like value but decodes a +// quoted string literal or literal null into an interface value. +// If it finds anything other than a quoted string literal or null, +// valueQuoted returns unquotedValue{}. +func (d *decodeState) valueQuoted() any { + switch d.opcode { + default: + panic(phasePanicMsg) + + case scanBeginArray, scanBeginObject: + d.skip() + d.scanNext() + + case scanBeginLiteral: + v := d.literalInterface() + switch v.(type) { + case nil, string: + return v + } + } + return unquotedValue{} +} + +// indirect walks down v allocating pointers as needed, +// until it gets to a non-pointer. +// If it encounters an Unmarshaler, indirect stops and returns that. +// If decodingNull is true, indirect stops at the first settable pointer so it +// can be set to nil. +func indirect(v reflect.Value, decodingNull bool) (Unmarshaler, encoding.TextUnmarshaler, reflect.Value) { + // Issue #24153 indicates that it is generally not a guaranteed property + // that you may round-trip a reflect.Value by calling Value.Addr().Elem() + // and expect the value to still be settable for values derived from + // unexported embedded struct fields. + // + // The logic below effectively does this when it first addresses the value + // (to satisfy possible pointer methods) and continues to dereference + // subsequent pointers as necessary. + // + // After the first round-trip, we set v back to the original value to + // preserve the original RW flags contained in reflect.Value. + v0 := v + haveAddr := false + + // If v is a named type and is addressable, + // start with its address, so that if the type has pointer methods, + // we find them. + if v.Kind() != reflect.Pointer && v.Type().Name() != "" && v.CanAddr() { + haveAddr = true + v = v.Addr() + } + for { + // Load value from interface, but only if the result will be + // usefully addressable. + if v.Kind() == reflect.Interface && !v.IsNil() { + e := v.Elem() + if e.Kind() == reflect.Pointer && !e.IsNil() && (!decodingNull || e.Elem().Kind() == reflect.Pointer) { + haveAddr = false + v = e + continue + } + } + + if v.Kind() != reflect.Pointer { + break + } + + if decodingNull && v.CanSet() { + break + } + + // Prevent infinite loop if v is an interface pointing to its own address: + // var v interface{} + // v = &v + if v.Elem().Kind() == reflect.Interface && v.Elem().Elem() == v { + v = v.Elem() + break + } + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + if v.Type().NumMethod() > 0 && v.CanInterface() { + if u, ok := v.Interface().(Unmarshaler); ok { + return u, nil, reflect.Value{} + } + if !decodingNull { + if u, ok := v.Interface().(encoding.TextUnmarshaler); ok { + return nil, u, reflect.Value{} + } + } + } + + if haveAddr { + v = v0 // restore original value after round-trip Value.Addr().Elem() + haveAddr = false + } else { + v = v.Elem() + } + } + return nil, nil, v +} + +// array consumes an array from d.data[d.off-1:], decoding into v. +// The first byte of the array ('[') has been read already. +func (d *decodeState) array(v reflect.Value) error { + // Check for unmarshaler. + u, ut, pv := indirect(v, false) + if u != nil { + start := d.readIndex() + d.skip() + return u.UnmarshalJSON(d.data[start:d.off]) + } + if ut != nil { + d.saveError(&UnmarshalTypeError{Value: "array", Type: v.Type(), Offset: int64(d.off)}) + d.skip() + return nil + } + v = pv + + // Check type of target. + switch v.Kind() { + case reflect.Interface: + if v.NumMethod() == 0 { + // Decoding into nil interface? Switch to non-reflect code. + ai := d.arrayInterface() + v.Set(reflect.ValueOf(ai)) + return nil + } + // Otherwise it's invalid. + fallthrough + default: + d.saveError(&UnmarshalTypeError{Value: "array", Type: v.Type(), Offset: int64(d.off)}) + d.skip() + return nil + case reflect.Array, reflect.Slice: + break + } + + i := 0 + for { + // Look ahead for ] - can only happen on first iteration. + d.scanWhile(scanSkipSpace) + if d.opcode == scanEndArray { + break + } + + // Get element of array, growing if necessary. + if v.Kind() == reflect.Slice { + // Grow slice if necessary + if i >= v.Cap() { + newcap := v.Cap() + v.Cap()/2 + if newcap < 4 { + newcap = 4 + } + newv := reflect.MakeSlice(v.Type(), v.Len(), newcap) + reflect.Copy(newv, v) + v.Set(newv) + } + if i >= v.Len() { + v.SetLen(i + 1) + } + } + + if i < v.Len() { + // Decode into element. + if err := d.value(v.Index(i)); err != nil { + return err + } + } else { + // Ran out of fixed array: skip. + if err := d.value(reflect.Value{}); err != nil { + return err + } + } + i++ + + // Next token must be , or ]. + if d.opcode == scanSkipSpace { + d.scanWhile(scanSkipSpace) + } + if d.opcode == scanEndArray { + break + } + if d.opcode != scanArrayValue { + panic(phasePanicMsg) + } + } + + if i < v.Len() { + if v.Kind() == reflect.Array { + // Array. Zero the rest. + z := reflect.Zero(v.Type().Elem()) + for ; i < v.Len(); i++ { + v.Index(i).Set(z) + } + } else { + v.SetLen(i) + } + } + if i == 0 && v.Kind() == reflect.Slice { + v.Set(reflect.MakeSlice(v.Type(), 0, 0)) + } + return nil +} + +var nullLiteral = []byte("null") +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + +// object consumes an object from d.data[d.off-1:], decoding into v. +// The first byte ('{') of the object has been read already. +func (d *decodeState) object(v reflect.Value) error { + // Check for unmarshaler. + u, ut, pv := indirect(v, false) + if u != nil { + start := d.readIndex() + d.skip() + return u.UnmarshalJSON(d.data[start:d.off]) + } + if ut != nil { + d.saveError(&UnmarshalTypeError{Value: "object", Type: v.Type(), Offset: int64(d.off)}) + d.skip() + return nil + } + v = pv + t := v.Type() + + // Decoding into nil interface? Switch to non-reflect code. + if v.Kind() == reflect.Interface && v.NumMethod() == 0 { + oi := d.objectInterface() + v.Set(reflect.ValueOf(oi)) + return nil + } + + var fields structFields + + // Check type of target: + // struct or + // map[T1]T2 where T1 is string, an integer type, + // or an encoding.TextUnmarshaler + switch v.Kind() { + case reflect.Map: + // Map key must either have string kind, have an integer kind, + // or be an encoding.TextUnmarshaler. + switch t.Key().Kind() { + case reflect.String, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + default: + if !reflect.PointerTo(t.Key()).Implements(textUnmarshalerType) { + d.saveError(&UnmarshalTypeError{Value: "object", Type: t, Offset: int64(d.off)}) + d.skip() + return nil + } + } + if v.IsNil() { + v.Set(reflect.MakeMap(t)) + } + case reflect.Struct: + fields = cachedTypeFields(t) + // ok + default: + d.saveError(&UnmarshalTypeError{Value: "object", Type: t, Offset: int64(d.off)}) + d.skip() + return nil + } + + var mapElem reflect.Value + var origErrorContext errorContext + if d.errorContext != nil { + origErrorContext = *d.errorContext + } + + var keys []string + + for { + // Read opening " of string key or closing }. + d.scanWhile(scanSkipSpace) + if d.opcode == scanEndObject { + // closing } - can only happen on first iteration. + break + } + if d.opcode != scanBeginLiteral { + panic(phasePanicMsg) + } + + // Read key. + start := d.readIndex() + d.rescanLiteral() + item := d.data[start:d.readIndex()] + key, ok := unquoteBytes(item) + if !ok { + panic(phasePanicMsg) + } + + keys = append(keys, string(key)) + + // Figure out field corresponding to key. + var subv reflect.Value + destring := false // whether the value is wrapped in a string to be decoded first + + if v.Kind() == reflect.Map { + elemType := t.Elem() + if !mapElem.IsValid() { + mapElem = reflect.New(elemType).Elem() + } else { + mapElem.Set(reflect.Zero(elemType)) + } + subv = mapElem + } else { + var f *field + if i, ok := fields.nameIndex[string(key)]; ok { + // Found an exact name match. + f = &fields.list[i] + } else { + // Fall back to the expensive case-insensitive + // linear search. + for i := range fields.list { + ff := &fields.list[i] + if ff.equalFold(ff.nameBytes, key) { + f = ff + break + } + } + } + if f != nil { + subv = v + destring = f.quoted + for _, i := range f.index { + if subv.Kind() == reflect.Pointer { + if subv.IsNil() { + // If a struct embeds a pointer to an unexported type, + // it is not possible to set a newly allocated value + // since the field is unexported. + // + // See https://golang.org/issue/21357 + if !subv.CanSet() { + d.saveError(fmt.Errorf("json: cannot set embedded pointer to unexported struct: %v", subv.Type().Elem())) + // Invalidate subv to ensure d.value(subv) skips over + // the JSON value without assigning it to subv. + subv = reflect.Value{} + destring = false + break + } + subv.Set(reflect.New(subv.Type().Elem())) + } + subv = subv.Elem() + } + subv = subv.Field(i) + } + if d.errorContext == nil { + d.errorContext = new(errorContext) + } + d.errorContext.FieldStack = append(d.errorContext.FieldStack, f.name) + d.errorContext.Struct = t + } else if d.disallowUnknownFields { + d.saveError(fmt.Errorf("json: unknown field %q", key)) + } + } + + // Read : before value. + if d.opcode == scanSkipSpace { + d.scanWhile(scanSkipSpace) + } + if d.opcode != scanObjectKey { + panic(phasePanicMsg) + } + d.scanWhile(scanSkipSpace) + + if destring { + switch qv := d.valueQuoted().(type) { + case nil: + if err := d.literalStore(nullLiteral, subv, false); err != nil { + return err + } + case string: + if err := d.literalStore([]byte(qv), subv, true); err != nil { + return err + } + default: + d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal unquoted value into %v", subv.Type())) + } + } else { + if err := d.value(subv); err != nil { + return err + } + } + + // Write value back to map; + // if using struct, subv points into struct already. + if v.Kind() == reflect.Map { + kt := t.Key() + var kv reflect.Value + switch { + case reflect.PointerTo(kt).Implements(textUnmarshalerType): + kv = reflect.New(kt) + if err := d.literalStore(item, kv, true); err != nil { + return err + } + kv = kv.Elem() + case kt.Kind() == reflect.String: + kv = reflect.ValueOf(key).Convert(kt) + default: + switch kt.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s := string(key) + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || reflect.Zero(kt).OverflowInt(n) { + d.saveError(&UnmarshalTypeError{Value: "number " + s, Type: kt, Offset: int64(start + 1)}) + break + } + kv = reflect.ValueOf(n).Convert(kt) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s := string(key) + n, err := strconv.ParseUint(s, 10, 64) + if err != nil || reflect.Zero(kt).OverflowUint(n) { + d.saveError(&UnmarshalTypeError{Value: "number " + s, Type: kt, Offset: int64(start + 1)}) + break + } + kv = reflect.ValueOf(n).Convert(kt) + default: + panic("json: Unexpected key type") // should never occur + } + } + if kv.IsValid() { + v.SetMapIndex(kv, subv) + } + } + + // Next token must be , or }. + if d.opcode == scanSkipSpace { + d.scanWhile(scanSkipSpace) + } + if d.errorContext != nil { + // Reset errorContext to its original state. + // Keep the same underlying array for FieldStack, to reuse the + // space and avoid unnecessary allocs. + d.errorContext.FieldStack = d.errorContext.FieldStack[:len(origErrorContext.FieldStack)] + d.errorContext.Struct = origErrorContext.Struct + } + if d.opcode == scanEndObject { + break + } + if d.opcode != scanObjectValue { + panic(phasePanicMsg) + } + } + + if v.Kind() == reflect.Map { + d.lastKeys = keys + } + return nil +} + +// convertNumber converts the number literal s to a float64 or a Number +// depending on the setting of d.useNumber. +func (d *decodeState) convertNumber(s string) (any, error) { + if d.useNumber { + return Number(s), nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil, &UnmarshalTypeError{Value: "number " + s, Type: reflect.TypeOf(0.0), Offset: int64(d.off)} + } + return f, nil +} + +var numberType = reflect.TypeOf(Number("")) + +// literalStore decodes a literal stored in item into v. +// +// fromQuoted indicates whether this literal came from unwrapping a +// string from the ",string" struct tag option. this is used only to +// produce more helpful error messages. +func (d *decodeState) literalStore(item []byte, v reflect.Value, fromQuoted bool) error { + // Check for unmarshaler. + if len(item) == 0 { + //Empty string given + d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type())) + return nil + } + isNull := item[0] == 'n' // null + u, ut, pv := indirect(v, isNull) + if u != nil { + return u.UnmarshalJSON(item) + } + if ut != nil { + if item[0] != '"' { + if fromQuoted { + d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type())) + return nil + } + val := "number" + switch item[0] { + case 'n': + val = "null" + case 't', 'f': + val = "bool" + } + d.saveError(&UnmarshalTypeError{Value: val, Type: v.Type(), Offset: int64(d.readIndex())}) + return nil + } + s, ok := unquoteBytes(item) + if !ok { + if fromQuoted { + return fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type()) + } + panic(phasePanicMsg) + } + return ut.UnmarshalText(s) + } + + v = pv + + switch c := item[0]; c { + case 'n': // null + // The main parser checks that only true and false can reach here, + // but if this was a quoted string input, it could be anything. + if fromQuoted && string(item) != "null" { + d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type())) + break + } + switch v.Kind() { + case reflect.Interface, reflect.Pointer, reflect.Map, reflect.Slice: + v.Set(reflect.Zero(v.Type())) + // otherwise, ignore null for primitives/string + } + case 't', 'f': // true, false + value := item[0] == 't' + // The main parser checks that only true and false can reach here, + // but if this was a quoted string input, it could be anything. + if fromQuoted && string(item) != "true" && string(item) != "false" { + d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type())) + break + } + switch v.Kind() { + default: + if fromQuoted { + d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type())) + } else { + d.saveError(&UnmarshalTypeError{Value: "bool", Type: v.Type(), Offset: int64(d.readIndex())}) + } + case reflect.Bool: + v.SetBool(value) + case reflect.Interface: + if v.NumMethod() == 0 { + v.Set(reflect.ValueOf(value)) + } else { + d.saveError(&UnmarshalTypeError{Value: "bool", Type: v.Type(), Offset: int64(d.readIndex())}) + } + } + + case '"': // string + s, ok := unquoteBytes(item) + if !ok { + if fromQuoted { + return fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type()) + } + panic(phasePanicMsg) + } + switch v.Kind() { + default: + d.saveError(&UnmarshalTypeError{Value: "string", Type: v.Type(), Offset: int64(d.readIndex())}) + case reflect.Slice: + if v.Type().Elem().Kind() != reflect.Uint8 { + d.saveError(&UnmarshalTypeError{Value: "string", Type: v.Type(), Offset: int64(d.readIndex())}) + break + } + b := make([]byte, base64.StdEncoding.DecodedLen(len(s))) + n, err := base64.StdEncoding.Decode(b, s) + if err != nil { + d.saveError(err) + break + } + v.SetBytes(b[:n]) + case reflect.String: + if v.Type() == numberType && !isValidNumber(string(s)) { + return fmt.Errorf("json: invalid number literal, trying to unmarshal %q into Number", item) + } + v.SetString(string(s)) + case reflect.Interface: + if v.NumMethod() == 0 { + v.Set(reflect.ValueOf(string(s))) + } else { + d.saveError(&UnmarshalTypeError{Value: "string", Type: v.Type(), Offset: int64(d.readIndex())}) + } + } + + default: // number + if c != '-' && (c < '0' || c > '9') { + if fromQuoted { + return fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type()) + } + panic(phasePanicMsg) + } + s := string(item) + switch v.Kind() { + default: + if v.Kind() == reflect.String && v.Type() == numberType { + // s must be a valid number, because it's + // already been tokenized. + v.SetString(s) + break + } + if fromQuoted { + return fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type()) + } + d.saveError(&UnmarshalTypeError{Value: "number", Type: v.Type(), Offset: int64(d.readIndex())}) + case reflect.Interface: + n, err := d.convertNumber(s) + if err != nil { + d.saveError(err) + break + } + if v.NumMethod() != 0 { + d.saveError(&UnmarshalTypeError{Value: "number", Type: v.Type(), Offset: int64(d.readIndex())}) + break + } + v.Set(reflect.ValueOf(n)) + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || v.OverflowInt(n) { + d.saveError(&UnmarshalTypeError{Value: "number " + s, Type: v.Type(), Offset: int64(d.readIndex())}) + break + } + v.SetInt(n) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + n, err := strconv.ParseUint(s, 10, 64) + if err != nil || v.OverflowUint(n) { + d.saveError(&UnmarshalTypeError{Value: "number " + s, Type: v.Type(), Offset: int64(d.readIndex())}) + break + } + v.SetUint(n) + + case reflect.Float32, reflect.Float64: + n, err := strconv.ParseFloat(s, v.Type().Bits()) + if err != nil || v.OverflowFloat(n) { + d.saveError(&UnmarshalTypeError{Value: "number " + s, Type: v.Type(), Offset: int64(d.readIndex())}) + break + } + v.SetFloat(n) + } + } + return nil +} + +// The xxxInterface routines build up a value to be stored +// in an empty interface. They are not strictly necessary, +// but they avoid the weight of reflection in this common case. + +// valueInterface is like value but returns interface{} +func (d *decodeState) valueInterface() (val any) { + switch d.opcode { + default: + panic(phasePanicMsg) + case scanBeginArray: + val = d.arrayInterface() + d.scanNext() + case scanBeginObject: + val = d.objectInterface() + d.scanNext() + case scanBeginLiteral: + val = d.literalInterface() + } + return +} + +// arrayInterface is like array but returns []interface{}. +func (d *decodeState) arrayInterface() []any { + var v = make([]any, 0) + for { + // Look ahead for ] - can only happen on first iteration. + d.scanWhile(scanSkipSpace) + if d.opcode == scanEndArray { + break + } + + v = append(v, d.valueInterface()) + + // Next token must be , or ]. + if d.opcode == scanSkipSpace { + d.scanWhile(scanSkipSpace) + } + if d.opcode == scanEndArray { + break + } + if d.opcode != scanArrayValue { + panic(phasePanicMsg) + } + } + return v +} + +// objectInterface is like object but returns map[string]interface{}. +func (d *decodeState) objectInterface() map[string]any { + m := make(map[string]any) + for { + // Read opening " of string key or closing }. + d.scanWhile(scanSkipSpace) + if d.opcode == scanEndObject { + // closing } - can only happen on first iteration. + break + } + if d.opcode != scanBeginLiteral { + panic(phasePanicMsg) + } + + // Read string key. + start := d.readIndex() + d.rescanLiteral() + item := d.data[start:d.readIndex()] + key, ok := unquote(item) + if !ok { + panic(phasePanicMsg) + } + + // Read : before value. + if d.opcode == scanSkipSpace { + d.scanWhile(scanSkipSpace) + } + if d.opcode != scanObjectKey { + panic(phasePanicMsg) + } + d.scanWhile(scanSkipSpace) + + // Read value. + m[key] = d.valueInterface() + + // Next token must be , or }. + if d.opcode == scanSkipSpace { + d.scanWhile(scanSkipSpace) + } + if d.opcode == scanEndObject { + break + } + if d.opcode != scanObjectValue { + panic(phasePanicMsg) + } + } + return m +} + +// literalInterface consumes and returns a literal from d.data[d.off-1:] and +// it reads the following byte ahead. The first byte of the literal has been +// read already (that's how the caller knows it's a literal). +func (d *decodeState) literalInterface() any { + // All bytes inside literal return scanContinue op code. + start := d.readIndex() + d.rescanLiteral() + + item := d.data[start:d.readIndex()] + + switch c := item[0]; c { + case 'n': // null + return nil + + case 't', 'f': // true, false + return c == 't' + + case '"': // string + s, ok := unquote(item) + if !ok { + panic(phasePanicMsg) + } + return s + + default: // number + if c != '-' && (c < '0' || c > '9') { + panic(phasePanicMsg) + } + n, err := d.convertNumber(string(item)) + if err != nil { + d.saveError(err) + } + return n + } +} + +// getu4 decodes \uXXXX from the beginning of s, returning the hex value, +// or it returns -1. +func getu4(s []byte) rune { + if len(s) < 6 || s[0] != '\\' || s[1] != 'u' { + return -1 + } + var r rune + for _, c := range s[2:6] { + switch { + case '0' <= c && c <= '9': + c = c - '0' + case 'a' <= c && c <= 'f': + c = c - 'a' + 10 + case 'A' <= c && c <= 'F': + c = c - 'A' + 10 + default: + return -1 + } + r = r*16 + rune(c) + } + return r +} + +// unquote converts a quoted JSON string literal s into an actual string t. +// The rules are different than for Go, so cannot use strconv.Unquote. +func unquote(s []byte) (t string, ok bool) { + s, ok = unquoteBytes(s) + t = string(s) + return +} + +func unquoteBytes(s []byte) (t []byte, ok bool) { + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return + } + s = s[1 : len(s)-1] + + // Check for unusual characters. If there are none, + // then no unquoting is needed, so return a slice of the + // original bytes. + r := 0 + for r < len(s) { + c := s[r] + if c == '\\' || c == '"' || c < ' ' { + break + } + if c < utf8.RuneSelf { + r++ + continue + } + rr, size := utf8.DecodeRune(s[r:]) + if rr == utf8.RuneError && size == 1 { + break + } + r += size + } + if r == len(s) { + return s, true + } + + b := make([]byte, len(s)+2*utf8.UTFMax) + w := copy(b, s[0:r]) + for r < len(s) { + // Out of room? Can only happen if s is full of + // malformed UTF-8 and we're replacing each + // byte with RuneError. + if w >= len(b)-2*utf8.UTFMax { + nb := make([]byte, (len(b)+utf8.UTFMax)*2) + copy(nb, b[0:w]) + b = nb + } + switch c := s[r]; { + case c == '\\': + r++ + if r >= len(s) { + return + } + switch s[r] { + default: + return + case '"', '\\', '/', '\'': + b[w] = s[r] + r++ + w++ + case 'b': + b[w] = '\b' + r++ + w++ + case 'f': + b[w] = '\f' + r++ + w++ + case 'n': + b[w] = '\n' + r++ + w++ + case 'r': + b[w] = '\r' + r++ + w++ + case 't': + b[w] = '\t' + r++ + w++ + case 'u': + r-- + rr := getu4(s[r:]) + if rr < 0 { + return + } + r += 6 + if utf16.IsSurrogate(rr) { + rr1 := getu4(s[r:]) + if dec := utf16.DecodeRune(rr, rr1); dec != unicode.ReplacementChar { + // A valid pair; consume. + r += 6 + w += utf8.EncodeRune(b[w:], dec) + break + } + // Invalid surrogate; fall back to replacement rune. + rr = unicode.ReplacementChar + } + w += utf8.EncodeRune(b[w:], rr) + } + + // Quote, control characters are invalid. + case c == '"', c < ' ': + return + + // ASCII + case c < utf8.RuneSelf: + b[w] = c + r++ + w++ + + // Coerce to well-formed UTF-8. + default: + rr, size := utf8.DecodeRune(s[r:]) + r += size + w += utf8.EncodeRune(b[w:], rr) + } + } + return b[0:w], true +} diff --git a/vendor/github.com/evanphx/json-patch/v5/internal/json/encode.go b/vendor/github.com/evanphx/json-patch/v5/internal/json/encode.go new file mode 100644 index 0000000000..2e6eca4487 --- /dev/null +++ b/vendor/github.com/evanphx/json-patch/v5/internal/json/encode.go @@ -0,0 +1,1486 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package json implements encoding and decoding of JSON as defined in +// RFC 7159. The mapping between JSON and Go values is described +// in the documentation for the Marshal and Unmarshal functions. +// +// See "JSON and Go" for an introduction to this package: +// https://golang.org/doc/articles/json_and_go.html +package json + +import ( + "bytes" + "encoding" + "encoding/base64" + "fmt" + "math" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "unicode" + "unicode/utf8" +) + +// Marshal returns the JSON encoding of v. +// +// Marshal traverses the value v recursively. +// If an encountered value implements the Marshaler interface +// and is not a nil pointer, Marshal calls its MarshalJSON method +// to produce JSON. If no MarshalJSON method is present but the +// value implements encoding.TextMarshaler instead, Marshal calls +// its MarshalText method and encodes the result as a JSON string. +// The nil pointer exception is not strictly necessary +// but mimics a similar, necessary exception in the behavior of +// UnmarshalJSON. +// +// Otherwise, Marshal uses the following type-dependent default encodings: +// +// Boolean values encode as JSON booleans. +// +// Floating point, integer, and Number values encode as JSON numbers. +// +// String values encode as JSON strings coerced to valid UTF-8, +// replacing invalid bytes with the Unicode replacement rune. +// So that the JSON will be safe to embed inside HTML