Skip to content

Add .spec.version #142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 14, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/v1alpha1/operator_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ type OperatorSpec struct {
//+kubebuilder:validation:MaxLength:=48
//+kubebuilder:validation:Pattern:=^[a-z0-9]+(-[a-z0-9]+)*$
PackageName string `json:"packageName"`

//+kubebuilder:validation:MaxLength:=64
//+kubebuilder:validation:Pattern=^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$
//+kubebuilder:Optional
// Version is an optional semver constraint on the package version. If not specified, the latest version available of the package will be installed.
// If specified, the specific version of the package will be installed so long as it is available in any of the content sources available.
// Examples: 1.2.3, 1.0.0-alpha, 1.0.0-rc.1
//
// For more information on semver, please see https://semver.org/
Version string `json:"version,omitempty"`
}

const (
Expand All @@ -38,6 +48,7 @@ const (
ReasonBundleLookupFailed = "BundleLookupFailed"
ReasonInstallationFailed = "InstallationFailed"
ReasonInstallationStatusUnknown = "InstallationStatusUnknown"
ReasonInvalidSpec = "InvalidSpec"
)

func init() {
Expand All @@ -52,6 +63,7 @@ func init() {
ReasonBundleLookupFailed,
ReasonInstallationFailed,
ReasonInstallationStatusUnknown,
ReasonInvalidSpec,
)
}

Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/operators.operatorframework.io_operators.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ spec:
maxLength: 48
pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
type: string
version:
description: "Version is an optional semver constraint on the package
version. If not specified, the latest version available of the package
will be installed. If specified, the specific version of the package
will be installed so long as it is available in any of the content
sources available. Examples: 1.2.3, 1.0.0-alpha, 1.0.0-rc.1 \n For
more information on semver, please see https://semver.org/"
maxLength: 64
pattern: ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$
type: string
required:
- packageName
type: object
Expand Down
1 change: 1 addition & 0 deletions config/samples/operators_v1alpha1_operator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ metadata:
spec:
# TODO(user): Add fields here
packageName: prometheus
version: 3.0.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need/want to add a comment here that this is an invalid version? And should remain an invalid version if prometheus ever gets to version 3.0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry - that shouldn't have been committed - I'll omit it.

75 changes: 75 additions & 0 deletions controllers/admission_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package controllers_test

import (
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

operatorsv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1"
)

func operator(spec operatorsv1alpha1.OperatorSpec) *operatorsv1alpha1.Operator {
return &operatorsv1alpha1.Operator{
ObjectMeta: metav1.ObjectMeta{
Name: "test-operator",
},
Spec: spec,
}
}

var _ = Describe("Operator Spec Validations", func() {
var (
ctx context.Context
cancel context.CancelFunc
)
BeforeEach(func() {
ctx, cancel = context.WithCancel(context.Background())
})
AfterEach(func() {
cancel()
})
It("should fail if the spec is empty", func() {
err := cl.Create(ctx, operator(operatorsv1alpha1.OperatorSpec{}))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("spec.packageName in body should match '^[a-z0-9]+(-[a-z0-9]+)*$'"))
})
It("should fail if package name length is greater than 48 characters", func() {
err := cl.Create(ctx, operator(operatorsv1alpha1.OperatorSpec{
PackageName: "this-is-a-really-long-package-name-that-is-greater-than-48-characters",
}))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Too long: may not be longer than 48"))
})
It("should fail if version is valid semver but length is greater than 64 characters", func() {
err := cl.Create(ctx, operator(operatorsv1alpha1.OperatorSpec{
PackageName: "package",
Version: "1234567890.1234567890.12345678901234567890123456789012345678901234",
}))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Too long: may not be longer than 64"))
})
It("should fail if an invalid semver is given", func() {
invalidSemvers := []string{
"1.2.3.4",
"1.02.3",
"1.2.03",
"1.2.3-beta!",
"1.2.3.alpha",
"1..2.3",
"1.2.3-pre+bad_metadata",
"1.2.-3",
".1.2.3",
}
for _, invalidSemver := range invalidSemvers {
err := cl.Create(ctx, operator(operatorsv1alpha1.OperatorSpec{
PackageName: "package",
Version: invalidSemver,
}))

Expect(err).To(HaveOccurred(), "expected error for invalid semver %q", invalidSemver)
Expect(err.Error()).To(ContainSubstring("spec.version in body should match '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(-(0|[1-9]\\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\\+([0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*))?$'"))
}
})
})
14 changes: 14 additions & 0 deletions controllers/operator_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"

"github.com/operator-framework/operator-controller/controllers/validators"

operatorsv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1"
"github.com/operator-framework/operator-controller/internal/resolution"
"github.com/operator-framework/operator-controller/internal/resolution/variable_sources/bundles_and_dependencies"
Expand Down Expand Up @@ -108,6 +110,18 @@ func checkForUnexpectedFieldChange(a, b operatorsv1alpha1.Operator) bool {

// Helper function to do the actual reconcile
func (r *OperatorReconciler) reconcile(ctx context.Context, op *operatorsv1alpha1.Operator) (ctrl.Result, error) {
// validate spec
if err := validators.ValidateOperatorSpec(op); err != nil {
apimeta.SetStatusCondition(&op.Status.Conditions, metav1.Condition{
Type: operatorsv1alpha1.TypeReady,
Status: metav1.ConditionFalse,
Reason: operatorsv1alpha1.ReasonInvalidSpec,
Message: err.Error(),
ObservedGeneration: op.GetGeneration(),
})
return ctrl.Result{}, nil
}

// run resolution
solution, err := r.Resolver.Resolve(ctx)
if err != nil {
Expand Down
81 changes: 81 additions & 0 deletions controllers/operator_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"k8s.io/utils/pointer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

operatorsv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1"
"github.com/operator-framework/operator-controller/controllers"
Expand Down Expand Up @@ -80,6 +81,38 @@ var _ = Describe("Operator Controller Test", func() {
Expect(cond.Message).To(Equal(fmt.Sprintf("package '%s' not found", pkgName)))
})
})
When("the operator specifies a version that does not exist", func() {
var pkgName string
BeforeEach(func() {
By("initializing cluster state")
pkgName = fmt.Sprintf("exists-%s", rand.String(6))
operator = &operatorsv1alpha1.Operator{
ObjectMeta: metav1.ObjectMeta{Name: opKey.Name},
Spec: operatorsv1alpha1.OperatorSpec{
PackageName: pkgName,
Version: "0.50.0", // this version of the package does not exist
},
}
err := cl.Create(ctx, operator)
Expect(err).NotTo(HaveOccurred())
})
It("sets resolution failure status", func() {
By("running reconcile")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: opKey})
Expect(res).To(Equal(ctrl.Result{}))
Expect(err).To(MatchError(fmt.Sprintf("package '%s' at version '0.50.0' not found", pkgName)))

By("fetching updated operator after reconcile")
Expect(cl.Get(ctx, opKey, operator)).NotTo(HaveOccurred())

By("checking the expected conditions")
cond := apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeReady)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonResolutionFailed))
Expect(cond.Message).To(Equal(fmt.Sprintf("package '%s' at version '0.50.0' not found", pkgName)))
})
})
When("the operator specifies a valid available package", func() {
const pkgName = "prometheus"
BeforeEach(func() {
Expand Down Expand Up @@ -568,6 +601,54 @@ var _ = Describe("Operator Controller Test", func() {
Expect(err).To(Not(HaveOccurred()))
})
})
When("an invalid semver is provided that bypasses the regex validation", func() {
var (
operator *operatorsv1alpha1.Operator
opKey types.NamespacedName
pkgName string
fakeClient client.Client
)
BeforeEach(func() {
opKey = types.NamespacedName{Name: fmt.Sprintf("operator-validation-test-%s", rand.String(8))}

By("injecting creating a client with the bad operator CR")
pkgName = fmt.Sprintf("exists-%s", rand.String(6))
operator = &operatorsv1alpha1.Operator{
ObjectMeta: metav1.ObjectMeta{Name: opKey.Name},
Spec: operatorsv1alpha1.OperatorSpec{
PackageName: pkgName,
Version: "1.2.3-123abc_def", // bad semver that matches the regex on the CR validation
},
}

// this bypasses client/server-side CR validation and allows us to test the reconciler's validation
fakeClient = fake.NewClientBuilder().WithScheme(sch).WithObjects(operator).Build()

By("changing the reconciler client to the fake client")
reconciler.Client = fakeClient
})
AfterEach(func() {
By("changing the reconciler client back to the real client")
reconciler.Client = cl
})

It("should add an invalid spec condition and *not* re-enqueue for reconciliation", func() {
By("running reconcile")
res, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: opKey})
Expect(res).To(Equal(ctrl.Result{}))
Expect(err).ToNot(HaveOccurred())

By("fetching updated operator after reconcile")
Expect(fakeClient.Get(ctx, opKey, operator)).NotTo(HaveOccurred())

By("checking the expected conditions")
cond := apimeta.FindStatusCondition(operator.Status.Conditions, operatorsv1alpha1.TypeReady)
Expect(cond).NotTo(BeNil())
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
Expect(cond.Reason).To(Equal(operatorsv1alpha1.ReasonInvalidSpec))
Expect(cond.Message).To(Equal("invalid .spec.version: Invalid character(s) found in prerelease \"123abc_def\""))
})
})
})

func verifyInvariants(ctx context.Context, op *operatorsv1alpha1.Operator) {
Expand Down
38 changes: 38 additions & 0 deletions controllers/validators/validators.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package validators

import (
"fmt"

"github.com/blang/semver/v4"

operatorsv1alpha1 "github.com/operator-framework/operator-controller/api/v1alpha1"
)

type operatorCRValidatorFunc func(operator *operatorsv1alpha1.Operator) error

// validateSemver validates that the operator's version is a valid SemVer.
// this validation should already be happening at the CRD level. But, it depends
// on a regex that could possibly fail to validate a valid SemVer. This is added as an
// extra measure to ensure a valid spec before the CR is processed for resolution
func validateSemver(operator *operatorsv1alpha1.Operator) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the CRD regex validation fails, will the value even be set? So, is this necessary? Are you concerned about false positives?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For false positives. It's in case a bad semver somehow slips through the regex. It's so complicated and hard to parse I couldn't guarantee that nothing would slip through. So, I thought it might be a good idea to be defensive, even if 99.999% of the time this code won't probably be executed.

Copy link
Contributor Author

@perdasilva perdasilva Apr 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the CRD regex validation fails, will the value even be set?

If I understood the question correctly (and please forgive me if I'm going over stuff you already know), my understanding of the way it works is: there's both client-side and server-side validation of the field value against the regex before it reaches the controller. The intent of putting that the validation is to automatically reject anything that isn't a semver so it doesn't even make it to the controller. But, after looking at the regex, I couldn't get any confidence that it would work 100% of the time. I tried to validate with different negative cases, but I couldn't convince myself that it will definitely catch everything. It could be possible that an invalid spec slips through, in which case it would be set. So, I thought it best to handle that case in code (even if it's unlikely that it will ever be executed). Hopefully, for most of the fields that come along in the API we'll be able to get away with relatively simple regexs and length limits that should be sufficient enough for us use of this controller validation sparingly.

Since I'm rubber ducking this with you now, it occured to me that should a false positive slip through, we'd get a resolution failure on trying to parse the semver. So, maybe I'm being overzealous? Though a part of me thinks that ensuring that the input is clean before reaching the resolver might be worthwhile.

I'm totally open for suggestions here. If we feel we're adding more complexity for relatively little gain, I can remove this 2nd layer of validation. wdyt?

Copy link
Member

@m1kola m1kola Apr 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It indeed feels like belt and braces, but I think I'm in favour of keeping this validation as it prevents us hittng rest of the code in case of discrepancies between the regex and the library (e.g. bugs in library, for example).

Copy link
Contributor

@tmshort tmshort Apr 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming you got the regex from a reliable source, this scenario should never be hit, but better safe than sorry. Even regex's and libraries can have bugs.

if operator.Spec.Version == "" {
return nil
}
if _, err := semver.Parse(operator.Spec.Version); err != nil {
return fmt.Errorf("invalid .spec.version: %w", err)
}
return nil
}

// ValidateOperatorSpec validates the operator spec, e.g. ensuring that .spec.version, if provided, is a valid SemVer
func ValidateOperatorSpec(operator *operatorsv1alpha1.Operator) error {
validators := []operatorCRValidatorFunc{
validateSemver,
}
for _, validator := range validators {
if err := validator(operator); err != nil {
return err
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now it is ok as there is only one field which requires validation. Just curious how you see UX for it once we have more validators - Will we bereturning one error at a time or use some sort of aggregated error (so we surface all issues at once)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great call out. Personally, I think we should try as much as possible to add all of the validation errors as conditions. It would suck to keep going back and forth trying to get it right. What I'm not sure of right now is if it's "acceptable" to add multiple conditions of the same type (I don't see why not, but I don't know what the best practice is here). According to my good friend ChatGPT:

It is generally best to keep the status of a Kubernetes resource concise and clear. If you have multiple conditions of the same type, it might be better to consolidate them into a single condition that provides an accurate representation of the overall state of the resource.

Here are some guidelines to consider when working with conditions in the status of a Kubernetes resource:

Each condition should have a distinct type that describes the aspect of the resource being observed.
Conditions should be used to provide insights into the state of the resource and any issues that might be affecting it.
Use the Reason and Message fields within a condition to provide more detailed information about the state of the resource and any issues.
Update conditions as the state of the resource changes, ensuring that the status stays up-to-date and accurate.
By following these guidelines, you can maintain a clear and concise status that communicates the state of the resource effectively. In cases where you need to represent multiple aspects of the resource's state, it's better to use separate conditions with different types. However, if you have multiple instances of the same type of condition, consider consolidating them into a single condition with a clear reason and message.

It seems to suggest we should try to group everything into a single condition. But that seems ugly at first sight. Another option from the top of my head might be to create a condition for each validation error. That also seems ugly. Personally, I'm in favor of just having multiple conditions of the same type each describing its own reason for failure.

I suggest we leave it as it is for now. I will add a comment and a ticket so we can follow up on this after a more thorough discussion upstream. wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have definitevely seen conditions like ValidSomething true/false with message containing concatenated errors. I think it is very common in OCP. But I also seen condition per issue approach.

IMO - if condition is meant to be consumed by humans - we might just want to concatenate errors. We will later be able to split them into separate conditions. I personally not a fan of scrolling through 10s of conditions: I would rather see all validation errors in one place.

However if we want to programmatically consume these conditions or want to give our users this ability - then it will definitevely be easier to consume if we have conditions for each issue. But in this specific case I do not see a lot of value in this feature (but I'm happy to be proven wrong).

I suggest we leave it as it is for now. I will add a comment and a ticket so we can follow up on this after a more thorough discussion upstream. wdyt?

Agreed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition type needs to be unique in the list of conditions.

Type and reason are part of the API so once we GA, those can't be removed or changed.

My recommendation is generally to keep the number of types and reasons as small as possible based on what clients specifically require.

In this case, I'd start with whatever type we're already using, maybe even use the existing failure reason, and then put the validation errors concatenated in the message.

But +1 to capturing this in a separate issue.

}
}
return nil
}
13 changes: 13 additions & 0 deletions controllers/validators/validators_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package validators

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestValidators(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Validators Suite")
}
63 changes: 63 additions & 0 deletions controllers/validators/validators_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package validators_test

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/operator-framework/operator-controller/api/v1alpha1"
"github.com/operator-framework/operator-controller/controllers/validators"
)

var _ = Describe("Validators", func() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something - why not to use simple unit tests here? Looks like a classic use case for a table driven test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we decided to go for ginkgo for the unit tests. However, I'm starting to think it might have been a mistake. They aren't as easy to execute as go tests, and carry the cruft of the *_suite.go files. From my perspective at the moment, I'm not a huge fan of table driven tests. I think they do have a nice additive property to them and can make it easier to change many tests at once. But from previous experience (which is just anecdotal at best) they carry a higher complexity in trying to understand whats going later on in the project. Especially if you have massive test case structures with huge inputs. On the other hand, singular tests are easier to wrap your head around later on. However, they can carry a higher maintenance burden during refactoring/maintenance. So, I find it to be a tradeoff between readability and and maintainability (which are also interrelated XD). Now with copilot and chatGPT it's easier to write singular tests hahahah. On a serious side, I'm easy either way. I'd say we should do the following:

  • I'll create a github discussion around this so we can decide what we should do (I think it's important to have some consistency)
  • Let's leave it as it is for now, make a join decision, and we can always refactor out ginkgo (at least for the unit tests) in favor of gotests and make a decision on whether we want to do tabular or singular tests and keep that decision as a compass bearing

wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ginkgo supports table driven test fwiw. Maybe not quite as straightforward as a hand-written one, but maybe a reasonable compromise?

Describe("ValidateOperatorSpec", func() {
It("should not return an error for valid SemVer", func() {
operator := &v1alpha1.Operator{
Spec: v1alpha1.OperatorSpec{
Version: "1.2.3",
},
}
err := validators.ValidateOperatorSpec(operator)
Expect(err).NotTo(HaveOccurred())
})

It("should return an error for invalid SemVer", func() {
operator := &v1alpha1.Operator{
Spec: v1alpha1.OperatorSpec{
Version: "invalid-semver",
},
}
err := validators.ValidateOperatorSpec(operator)
Expect(err).To(HaveOccurred())
})

It("should not return an error for empty SemVer", func() {
operator := &v1alpha1.Operator{
Spec: v1alpha1.OperatorSpec{
Version: "",
},
}
err := validators.ValidateOperatorSpec(operator)
Expect(err).NotTo(HaveOccurred())
})

It("should not return an error for valid SemVer with pre-release and metadata", func() {
operator := &v1alpha1.Operator{
Spec: v1alpha1.OperatorSpec{
Version: "1.2.3-alpha.1+metadata",
},
}
err := validators.ValidateOperatorSpec(operator)
Expect(err).NotTo(HaveOccurred())
})

It("should not return an error for valid SemVer with pre-release", func() {
operator := &v1alpha1.Operator{
Spec: v1alpha1.OperatorSpec{
Version: "1.2.3-alpha-beta",
},
}
err := validators.ValidateOperatorSpec(operator)
Expect(err).NotTo(HaveOccurred())
})
})
})
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/operator-framework/deppy v0.0.0-20230125110717-dc02e928470f
github.com/operator-framework/operator-registry v1.26.2
github.com/operator-framework/rukpak v0.11.0
go.uber.org/zap v1.21.0
golang.org/x/net v0.0.0-20220722155237-a158d28d115b
google.golang.org/grpc v1.47.0
k8s.io/api v0.25.0
Expand Down Expand Up @@ -70,7 +71,6 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 // indirect
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
Expand Down
Loading