diff --git a/api/core/v1alpha1/catalog_types.go b/api/core/v1alpha1/catalog_types.go index 3ebf012f..46922299 100644 --- a/api/core/v1alpha1/catalog_types.go +++ b/api/core/v1alpha1/catalog_types.go @@ -70,6 +70,7 @@ type CatalogList struct { } // CatalogSpec defines the desired state of Catalog +// +kubebuilder:validation:XValidation:rule="!has(self.source.image.pollInterval) || (self.source.image.pollInterval == \"\") || (self.source.image.ref.find('@sha256:') == \"\")",message="cannot specify PollInterval while using digest-based image" type CatalogSpec struct { // Source is the source of a Catalog that contains Operators' metadata in the FBC format // https://olm.operatorframework.io/docs/reference/file-based-catalogs/#docs @@ -81,11 +82,15 @@ type CatalogStatus struct { // Conditions store the status conditions of the Catalog instances Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` - ResolvedSource *CatalogSource `json:"resolvedSource,omitempty"` - Phase string `json:"phase,omitempty"` + ResolvedSource *ResolvedCatalogSource `json:"resolvedSource,omitempty"` + Phase string `json:"phase,omitempty"` // ContentURL is a cluster-internal address that on-cluster components // can read the content of a catalog from ContentURL string `json:"contentURL,omitempty"` + // observedGeneration is the most recent generation observed for this Catalog. It corresponds to the + // Catalog's generation, which is updated on mutation by the API Server. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty" protobuf:"varint,1,opt,name=observedGeneration"` } // CatalogSource contains the sourcing information for a Catalog @@ -93,7 +98,25 @@ type CatalogSource struct { // Type defines the kind of Catalog content being sourced. Type SourceType `json:"type"` // Image is the catalog image that backs the content of this catalog. - Image *ImageSource `json:"image,omitempty"` + Image *ImageSource `json:"image"` +} + +// ResolvedCatalogSource contains the information about a sourced Catalog +type ResolvedCatalogSource struct { + // Type defines the kind of Catalog content that was sourced. + Type SourceType `json:"type"` + // Image is the catalog image that backs the content of this catalog. + Image *ResolvedImageSource `json:"image"` +} + +// ResolvedImageSource contains information about the sourced Catalog +type ResolvedImageSource struct { + // Ref contains the reference to a container image containing Catalog contents. + Ref string `json:"ref"` + // ResolvedRef contains the resolved sha256 image ref containing Catalog contents. + ResolvedRef string `json:"resolvedRef"` + // LastPollAtempt is the time when the source resolved was last polled for new content. + LastPollAttempt *metav1.Time `json:"lastPollAttempt"` } // ImageSource contains information required for sourcing a Catalog from an OCI image @@ -102,6 +125,10 @@ type ImageSource struct { Ref string `json:"ref"` // PullSecret contains the name of the image pull secret in the namespace that catalogd is deployed. PullSecret string `json:"pullSecret,omitempty"` + // PollInterval indicates the interval at which the image source should be polled for new content, + // specified as a duration (e.g., "5m", "1h", "24h", "etc".). Note that PollInterval may not be + // specified for a catalog image referenced by a sha256 digest. + PollInterval *metav1.Duration `json:"pollInterval,omitempty"` } func init() { diff --git a/api/core/v1alpha1/catalog_types_test.go b/api/core/v1alpha1/catalog_types_test.go new file mode 100644 index 00000000..8eb9ca7f --- /dev/null +++ b/api/core/v1alpha1/catalog_types_test.go @@ -0,0 +1,142 @@ +package v1alpha1 + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + celconfig "k8s.io/apiserver/pkg/apis/cel" + "sigs.k8s.io/yaml" +) + +func TestPollIntervalCELValidationRules(t *testing.T) { + validators := fieldValidatorsFromFile(t, "../../../config/crd/bases/catalogd.operatorframework.io_catalogs.yaml") + pth := "openAPIV3Schema.properties.spec" + validator, found := validators["v1alpha1"][pth] + assert.True(t, found) + + for name, tc := range map[string]struct { + spec CatalogSpec + wantErrs []string + }{ + "digest based image ref, poll interval not allowed, poll interval specified": { + spec: CatalogSpec{ + Source: CatalogSource{ + Type: SourceTypeImage, + Image: &ImageSource{ + Ref: "docker.io/test-image@sha256:asdf98234sd", + PollInterval: &metav1.Duration{Duration: time.Minute}, + }, + }, + }, + wantErrs: []string{ + "openAPIV3Schema.properties.spec: Invalid value: \"object\": cannot specify PollInterval while using digest-based image", + }, + }, + "digest based image ref, poll interval not allowed, poll interval not specified": { + spec: CatalogSpec{ + Source: CatalogSource{ + Type: SourceTypeImage, + Image: &ImageSource{ + Ref: "docker.io/example/test-catalog@sha256:asdf123", + }, + }, + }, + wantErrs: []string{}, + }, + } { + t.Run(name, func(t *testing.T) { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&tc.spec) + require.NoError(t, err) + errs := validator(obj, nil) + require.Equal(t, len(tc.wantErrs), len(errs)) + for i := range tc.wantErrs { + got := errs[i].Error() + assert.Equal(t, tc.wantErrs[i], got) + } + }) + } +} + +// fieldValidatorsFromFile extracts the CEL validators by version and JSONPath from a CRD file and returns +// a validator func for testing against samples. +func fieldValidatorsFromFile(t *testing.T, crdFilePath string) map[string]map[string]CELValidateFunc { + data, err := os.ReadFile(crdFilePath) + require.NoError(t, err) + + var crd apiextensionsv1.CustomResourceDefinition + err = yaml.Unmarshal(data, &crd) + require.NoError(t, err) + + ret := map[string]map[string]CELValidateFunc{} + for _, v := range crd.Spec.Versions { + var internalSchema apiextensions.JSONSchemaProps + err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v.Schema.OpenAPIV3Schema, &internalSchema, nil) + require.NoError(t, err, "failed to convert JSONSchemaProps for version %s: %v", v.Name, err) + structuralSchema, err := schema.NewStructural(&internalSchema) + require.NoError(t, err, "failed to create StructuralSchema for version %s: %v", v.Name, err) + + versionVals, err := findCEL(structuralSchema, true, field.NewPath("openAPIV3Schema")) + require.NoError(t, err, "failed to find CEL for version %s: %v", v.Name, err) + ret[v.Name] = versionVals + } + return ret +} + +// CELValidateFunc tests a sample object against a CEL validator. +type CELValidateFunc func(obj, old interface{}) field.ErrorList + +func findCEL(s *schema.Structural, root bool, pth *field.Path) (map[string]CELValidateFunc, error) { + ret := map[string]CELValidateFunc{} + + if len(s.XValidations) > 0 { + s := *s + pth := *pth + ret[pth.String()] = func(obj, old interface{}) field.ErrorList { + errs, _ := cel.NewValidator(&s, root, celconfig.PerCallLimit).Validate(context.TODO(), &pth, &s, obj, old, celconfig.RuntimeCELCostBudget) + return errs + } + } + + for k, v := range s.Properties { + v := v + sub, err := findCEL(&v, false, pth.Child("properties").Child(k)) + if err != nil { + return nil, err + } + + for pth, val := range sub { + ret[pth] = val + } + } + if s.Items != nil { + sub, err := findCEL(s.Items, false, pth.Child("items")) + if err != nil { + return nil, err + } + for pth, val := range sub { + ret[pth] = val + } + } + if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil { + sub, err := findCEL(s.AdditionalProperties.Structural, false, pth.Child("additionalProperties")) + if err != nil { + return nil, err + } + for pth, val := range sub { + ret[pth] = val + } + } + + return ret, nil +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 6d85fd21..32075510 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -91,7 +91,7 @@ func (in *CatalogSource) DeepCopyInto(out *CatalogSource) { if in.Image != nil { in, out := &in.Image, &out.Image *out = new(ImageSource) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -133,7 +133,7 @@ func (in *CatalogStatus) DeepCopyInto(out *CatalogStatus) { } if in.ResolvedSource != nil { in, out := &in.ResolvedSource, &out.ResolvedSource - *out = new(CatalogSource) + *out = new(ResolvedCatalogSource) (*in).DeepCopyInto(*out) } } @@ -151,6 +151,11 @@ func (in *CatalogStatus) DeepCopy() *CatalogStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ImageSource) DeepCopyInto(out *ImageSource) { *out = *in + if in.PollInterval != nil { + in, out := &in.PollInterval, &out.PollInterval + *out = new(v1.Duration) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageSource. @@ -162,3 +167,42 @@ func (in *ImageSource) DeepCopy() *ImageSource { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedCatalogSource) DeepCopyInto(out *ResolvedCatalogSource) { + *out = *in + if in.Image != nil { + in, out := &in.Image, &out.Image + *out = new(ResolvedImageSource) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedCatalogSource. +func (in *ResolvedCatalogSource) DeepCopy() *ResolvedCatalogSource { + if in == nil { + return nil + } + out := new(ResolvedCatalogSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedImageSource) DeepCopyInto(out *ResolvedImageSource) { + *out = *in + if in.LastPollAttempt != nil { + in, out := &in.LastPollAttempt, &out.LastPollAttempt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedImageSource. +func (in *ResolvedImageSource) DeepCopy() *ResolvedImageSource { + if in == nil { + return nil + } + out := new(ResolvedImageSource) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/catalogd.operatorframework.io_catalogs.yaml b/config/crd/bases/catalogd.operatorframework.io_catalogs.yaml index b2c87681..f8dca03b 100644 --- a/config/crd/bases/catalogd.operatorframework.io_catalogs.yaml +++ b/config/crd/bases/catalogd.operatorframework.io_catalogs.yaml @@ -49,6 +49,13 @@ spec: description: Image is the catalog image that backs the content of this catalog. properties: + pollInterval: + description: PollInterval indicates the interval at which + the image source should be polled for new content, specified + as a duration (e.g., "5m", "1h", "24h", "etc".). Note that + PollInterval may not be specified for a catalog image referenced + by a sha256 digest. + type: string pullSecret: description: PullSecret contains the name of the image pull secret in the namespace that catalogd is deployed. @@ -64,11 +71,16 @@ spec: description: Type defines the kind of Catalog content being sourced. type: string required: + - image - type type: object required: - source type: object + x-kubernetes-validations: + - message: cannot specify PollInterval while using digest-based image + rule: '!has(self.source.image.pollInterval) || (self.source.image.pollInterval + == "") || (self.source.image.ref.find(''@sha256:'') == "")' status: description: CatalogStatus defines the observed state of Catalog properties: @@ -146,31 +158,46 @@ spec: description: ContentURL is a cluster-internal address that on-cluster components can read the content of a catalog from type: string + observedGeneration: + description: observedGeneration is the most recent generation observed + for this Catalog. It corresponds to the Catalog's generation, which + is updated on mutation by the API Server. + format: int64 + type: integer phase: type: string resolvedSource: - description: CatalogSource contains the sourcing information for a - Catalog + description: ResolvedCatalogSource contains the information about + a sourced Catalog properties: image: description: Image is the catalog image that backs the content of this catalog. properties: - pullSecret: - description: PullSecret contains the name of the image pull - secret in the namespace that catalogd is deployed. + lastPollAttempt: + description: LastPollAtempt is the time when the source resolved + was last polled for new content. + format: date-time type: string ref: description: Ref contains the reference to a container image containing Catalog contents. type: string + resolvedRef: + description: ResolvedRef contains the resolved sha256 image + ref containing Catalog contents. + type: string required: + - lastPollAttempt - ref + - resolvedRef type: object type: - description: Type defines the kind of Catalog content being sourced. + description: Type defines the kind of Catalog content that was + sourced. type: string required: + - image - type type: object type: object diff --git a/config/samples/core_v1alpha1_catalog.yaml b/config/samples/core_v1alpha1_catalog.yaml index 1895a90a..0158ab20 100644 --- a/config/samples/core_v1alpha1_catalog.yaml +++ b/config/samples/core_v1alpha1_catalog.yaml @@ -6,4 +6,5 @@ spec: source: type: image image: + pollInterval: 24h ref: quay.io/operatorhubio/catalog:latest diff --git a/go.mod b/go.mod index 29fa85c8..5397a400 100644 --- a/go.mod +++ b/go.mod @@ -17,14 +17,17 @@ require ( github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.3 k8s.io/api v0.27.2 + k8s.io/apiextensions-apiserver v0.27.2 k8s.io/apimachinery v0.27.2 + k8s.io/apiserver v0.27.2 k8s.io/client-go v0.27.2 k8s.io/component-base v0.27.2 sigs.k8s.io/controller-runtime v0.15.0 + sigs.k8s.io/yaml v1.3.0 ) require ( - cloud.google.com/go/compute v1.18.0 // indirect + cloud.google.com/go/compute v1.19.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect @@ -38,6 +41,8 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/hcsshim v0.9.8 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect + github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect + github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/aws/aws-sdk-go-v2 v1.17.5 // indirect github.com/aws/aws-sdk-go-v2/config v1.18.15 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.13.15 // indirect @@ -81,6 +86,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/cel-go v0.15.3 // indirect github.com/google/gnostic v0.6.9 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect @@ -97,6 +103,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -109,12 +116,14 @@ require ( github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect github.com/sirupsen/logrus v1.9.2 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/vbatts/tar-split v0.11.2 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.14.0 // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect @@ -126,16 +135,15 @@ require ( golang.org/x/tools v0.9.1 // indirect gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20230525154841-bd750badd5c6 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.27.2 // indirect k8s.io/klog/v2 v2.90.1 // indirect k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f // indirect k8s.io/utils v0.0.0-20230308161112-d77c459e9343 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index 95b1db94..3ac6db59 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZ cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY= -cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= +cloud.google.com/go/compute v1.19.0 h1:+9zda3WGgW1ZSTlVppLCYFIr48Pa35q1uG2N1itbCEQ= +cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= @@ -101,8 +101,11 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df h1:7RFfzj4SSt6nnvCPbCqijJi1nWCd+TqAT3bYCStRC18= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go-v2 v1.17.5 h1:TzCUW1Nq4H8Xscph5M/skINUitxM5UBAyvm2s7XBzL4= @@ -463,6 +466,8 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.15.3 h1:W1wIeGuEs81+lBVU+cQRg1hkRT58Q6bNxvM5yn008S8= +github.com/google/cel-go v0.15.3/go.mod h1:YzWEoI07MC/a/wj9in8GeVatqfypkldgBlwXh9bCwqY= github.com/google/gnostic v0.6.9 h1:ZK/5VhkoX835RikCHpSUJV9a+S3e1zLh59YnyWeBW+0= github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -619,6 +624,8 @@ github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= @@ -784,6 +791,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -894,6 +902,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -1179,6 +1189,8 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20230525154841-bd750badd5c6 h1:62QuyPXKEkZpjZesyj5K5jABl6MnSnWl+vNuT5oz90E= +google.golang.org/genproto v0.0.0-20230525154841-bd750badd5c6/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -1276,6 +1288,8 @@ k8s.io/apimachinery v0.27.2/go.mod h1:XNfZ6xklnMCOGGFNqXG7bUrQCoR04dh/E7FprV6pb+ k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU= k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM= k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q= +k8s.io/apiserver v0.27.2 h1:p+tjwrcQEZDrEorCZV2/qE8osGTINPuS5ZNqWAvKm5E= +k8s.io/apiserver v0.27.2/go.mod h1:EsOf39d75rMivgvvwjJ3OW/u9n1/BmUMK5otEOJrb1Y= k8s.io/client-go v0.20.1/go.mod h1:/zcHdt1TeWSd5HoUe6elJmHSQ6uLLgp4bIJHVEuy+/Y= k8s.io/client-go v0.20.4/go.mod h1:LiMv25ND1gLUdBeYxBIwKpkSC5IsozMMmOOeSJboP+k= k8s.io/client-go v0.20.6/go.mod h1:nNQMnOvEUEsOzRRFIIkdmYOjAZrC8bgq0ExboWSU1I0= diff --git a/internal/source/image_registry_client.go b/internal/source/image_registry_client.go index bc55f26b..ddc8c970 100644 --- a/internal/source/image_registry_client.go +++ b/internal/source/image_registry_client.go @@ -9,12 +9,14 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/containerd/containerd/archive" "github.com/google/go-containerregistry/pkg/authn/k8schain" gcrkube "github.com/google/go-containerregistry/pkg/authn/kubernetes" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/log" catalogdv1alpha1 "github.com/operator-framework/catalogd/api/core/v1alpha1" @@ -116,11 +118,12 @@ func (i *ImageRegistry) Cleanup(_ context.Context, catalog *catalogdv1alpha1.Cat func unpackedResult(fsys fs.FS, catalog *catalogdv1alpha1.Catalog, ref string) *Result { return &Result{ FS: fsys, - ResolvedSource: &catalogdv1alpha1.CatalogSource{ + ResolvedSource: &catalogdv1alpha1.ResolvedCatalogSource{ Type: catalogdv1alpha1.SourceTypeImage, - Image: &catalogdv1alpha1.ImageSource{ - Ref: ref, - PullSecret: catalog.Spec.Source.Image.PullSecret, + Image: &catalogdv1alpha1.ResolvedImageSource{ + Ref: catalog.Spec.Source.Image.Ref, + ResolvedRef: ref, + LastPollAttempt: &metav1.Time{Time: time.Now()}, }, }, State: StateUnpacked, diff --git a/internal/source/image_registry_client_test.go b/internal/source/image_registry_client_test.go index 76480da5..dfa215d3 100644 --- a/internal/source/image_registry_client_test.go +++ b/internal/source/image_registry_client_test.go @@ -358,7 +358,7 @@ func TestImageRegistry(t *testing.T) { rs, err := imgReg.Unpack(ctx, tt.catalog) if !tt.wantErr { assert.NoError(t, err) - assert.Equal(t, fmt.Sprintf("%s@sha256:%s", imgName.Context().Name(), digest.Hex), rs.ResolvedSource.Image.Ref) + assert.Equal(t, fmt.Sprintf("%s@sha256:%s", imgName.Context().Name(), digest.Hex), rs.ResolvedSource.Image.ResolvedRef) assert.Equal(t, source.StateUnpacked, rs.State) assert.DirExists(t, filepath.Join(testCache, tt.catalog.Name, digest.Hex)) entries, err := os.ReadDir(filepath.Join(testCache, tt.catalog.Name)) diff --git a/internal/source/unpacker.go b/internal/source/unpacker.go index 854f378c..3059a38e 100644 --- a/internal/source/unpacker.go +++ b/internal/source/unpacker.go @@ -49,7 +49,7 @@ type Result struct { // For example, resolved image sources should reference a container image // digest rather than an image tag, and git sources should reference a // commit hash rather than a branch or tag. - ResolvedSource *catalogdv1alpha1.CatalogSource + ResolvedSource *catalogdv1alpha1.ResolvedCatalogSource // State is the current state of unpacking the catalog content. State State diff --git a/pkg/controllers/core/catalog_controller.go b/pkg/controllers/core/catalog_controller.go index 303ea6ff..2a5a8577 100644 --- a/pkg/controllers/core/catalog_controller.go +++ b/pkg/controllers/core/catalog_controller.go @@ -20,12 +20,14 @@ import ( "context" // #nosec "errors" "fmt" + "time" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apimacherrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/wait" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -37,7 +39,12 @@ import ( "github.com/operator-framework/catalogd/pkg/storage" ) -const fbcDeletionFinalizer = "catalogd.operatorframework.io/delete-server-cache" +const ( + fbcDeletionFinalizer = "catalogd.operatorframework.io/delete-server-cache" + // CatalogSources are polled if PollInterval is mentioned, in intervals of wait.Jitter(pollDuration, maxFactor) + // wait.Jitter returns a time.Duration between pollDuration and pollDuration + maxFactor * pollDuration. + requeueJitterMaxFactor = 0.01 +) // CatalogReconciler reconciles a Catalog object type CatalogReconciler struct { @@ -125,6 +132,23 @@ func (r *CatalogReconciler) reconcile(ctx context.Context, catalog *v1alpha1.Cat controllerutil.RemoveFinalizer(catalog, fbcDeletionFinalizer) return ctrl.Result{}, nil } + // if ResolvedSource is not nil, it indicates that this is not the first time we're + // unpacking this catalog. + if catalog.Status.ResolvedSource != nil { + // if pollInterval is nil, and the spec.Source.Image.Ref was not changed, no + // need to unpack again + if catalog.Spec.Source.Image.PollInterval == nil && catalog.Spec.Source.Image.Ref == catalog.Status.ResolvedSource.Image.Ref { + return ctrl.Result{}, nil + } + // If we have unpacked before, don't resolved and unpack again until poll duration has lapsed. + // However, unpack if spec.Source.Image.Ref has changed. + // Only unpack if spec.Source.Image.Ref has changed(ignore eg a new label being added) + if catalog.Generation == catalog.Status.ObservedGeneration && + time.Now().Before(catalog.Status.ResolvedSource.Image.LastPollAttempt.Add(catalog.Spec.Source.Image.PollInterval.Duration)) && + catalog.Spec.Source.Image.Ref == catalog.Status.ResolvedSource.Image.Ref { + return ctrl.Result{}, nil + } + } unpackResult, err := r.Unpacker.Unpack(ctx, catalog) if err != nil { return ctrl.Result{}, updateStatusUnpackFailing(&catalog.Status, fmt.Errorf("source bundle content: %v", err)) @@ -148,8 +172,15 @@ func (r *CatalogReconciler) reconcile(ctx context.Context, catalog *v1alpha1.Cat } contentURL = r.Storage.ContentURL(catalog.Name) - updateStatusUnpacked(&catalog.Status, unpackResult, contentURL) - return ctrl.Result{}, nil + updateStatusUnpacked(&catalog.Status, unpackResult, contentURL, catalog.Generation) + var requeueAfter time.Duration + switch catalog.Spec.Source.Type { + case v1alpha1.SourceTypeImage: + if catalog.Spec.Source.Image.PollInterval != nil { + requeueAfter = wait.Jitter(catalog.Spec.Source.Image.PollInterval.Duration, requeueJitterMaxFactor) + } + } + return ctrl.Result{RequeueAfter: requeueAfter}, nil default: return ctrl.Result{}, updateStatusUnpackFailing(&catalog.Status, fmt.Errorf("unknown unpack state %q: %v", unpackResult.State, err)) } @@ -177,10 +208,11 @@ func updateStatusUnpacking(status *v1alpha1.CatalogStatus, result *source.Result }) } -func updateStatusUnpacked(status *v1alpha1.CatalogStatus, result *source.Result, contentURL string) { +func updateStatusUnpacked(status *v1alpha1.CatalogStatus, result *source.Result, contentURL string, generation int64) { status.ResolvedSource = result.ResolvedSource status.ContentURL = contentURL status.Phase = v1alpha1.PhaseUnpacked + status.ObservedGeneration = generation meta.SetStatusCondition(&status.Conditions, metav1.Condition{ Type: v1alpha1.TypeUnpacked, Status: metav1.ConditionTrue, diff --git a/pkg/controllers/core/catalog_controller_test.go b/pkg/controllers/core/catalog_controller_test.go index 31fe413b..167a3a88 100644 --- a/pkg/controllers/core/catalog_controller_test.go +++ b/pkg/controllers/core/catalog_controller_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" ctrl "sigs.k8s.io/controller-runtime" "github.com/google/go-cmp/cmp/cmpopts" @@ -539,9 +540,69 @@ func TestCatalogdControllerReconcile(t *testing.T) { } else { assert.Error(t, err) } - diff := cmp.Diff(tt.expectedCatalog, tt.catalog, cmpopts.IgnoreFields(metav1.Condition{}, "Message", "LastTransitionTime")) assert.Empty(t, diff, "comparing the expected Catalog") }) } } + +func TestPolling(t *testing.T) { + for name, tc := range map[string]struct { + catalog *catalogdv1alpha1.Catalog + expectedRequeueAfter time.Duration + }{ + "Catalog with tag based image ref without any poll interval specified, requeueAfter set to 0, ie polling disabled": { + catalog: &catalogdv1alpha1.Catalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + Finalizers: []string{fbcDeletionFinalizer}, + }, + Spec: catalogdv1alpha1.CatalogSpec{ + Source: catalogdv1alpha1.CatalogSource{ + Type: "image", + Image: &catalogdv1alpha1.ImageSource{ + Ref: "someimage:latest", + }, + }, + }, + }, + expectedRequeueAfter: time.Second * 0, + }, + "Catalog with tag based image ref with poll interval specified, requeueAfter set to wait.jitter(pollInterval)": { + catalog: &catalogdv1alpha1.Catalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + Finalizers: []string{fbcDeletionFinalizer}, + }, + Spec: catalogdv1alpha1.CatalogSpec{ + Source: catalogdv1alpha1.CatalogSource{ + Type: "image", + Image: &catalogdv1alpha1.ImageSource{ + Ref: "someimage:latest", + PollInterval: &metav1.Duration{Duration: time.Minute * 5}, + }, + }, + }, + }, + expectedRequeueAfter: time.Minute * 5, + }, + } { + t.Run(name, func(t *testing.T) { + reconciler := &CatalogReconciler{ + Client: nil, + Unpacker: source.NewUnpacker( + map[catalogdv1alpha1.SourceType]source.Unpacker{ + catalogdv1alpha1.SourceTypeImage: &MockSource{result: &source.Result{ + State: source.StateUnpacked, + FS: &fstest.MapFS{}, + }}, + }, + ), + Storage: &MockStore{}, + } + res, _ := reconciler.reconcile(context.Background(), tc.catalog) + assert.GreaterOrEqual(t, res.RequeueAfter, tc.expectedRequeueAfter) + assert.LessOrEqual(t, res.RequeueAfter, wait.Jitter(res.RequeueAfter, requeueJitterMaxFactor)) + }) + } +}