From 1abd633324df47af038e09b54df7244e6c7c7046 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Sun, 13 Jul 2025 21:42:35 +0200 Subject: [PATCH 1/3] feat: switch to kstatus-compatible status conditions --- api/v1/conditions.go | 4 ++-- internal/tokenmanager/token_secret.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/v1/conditions.go b/api/v1/conditions.go index 7986442..82762c7 100644 --- a/api/v1/conditions.go +++ b/api/v1/conditions.go @@ -1,6 +1,6 @@ package v1 const ( - // ConditionTypeAvailable represents the status of the Secret reconciliation - ConditionTypeAvailable = "Available" + // ConditionTypeReady is used to signal whether a reconciliation has completed successfully. + ConditionTypeReady = "Ready" ) diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 8dadc2d..545a05b 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -83,7 +83,7 @@ func NewTokenSecret(ctx context.Context, key types.NamespacedName, owner tokenMa s.log.Info("initializing token status conditions") condition := metav1.Condition{ - Type: githubv1.ConditionTypeAvailable, + Type: githubv1.ConditionTypeReady, Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: "Starting reconciliation", @@ -152,7 +152,7 @@ func (s *tokenSecret) Reconcile() (result reconcile.Result, err error) { // Secret was found, so update it if !metav1.IsControlledBy(secret, s.owner) { condition := metav1.Condition{ - Type: githubv1.ConditionTypeAvailable, + Type: githubv1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: "Failed", Message: "Secret already exists", @@ -217,7 +217,7 @@ func (s *tokenSecret) CreateSecret() error { } condition := metav1.Condition{ - Type: githubv1.ConditionTypeAvailable, + Type: githubv1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: "Creating", Message: "Creating Secret", @@ -263,7 +263,7 @@ func (s *tokenSecret) UpdateSecret() error { s.Data = s.SecretData(installationToken.GetToken()) condition := metav1.Condition{ - Type: githubv1.ConditionTypeAvailable, + Type: githubv1.ConditionTypeReady, Status: metav1.ConditionUnknown, Reason: "Updating", Message: "Updating Secret", @@ -300,7 +300,7 @@ func (s *tokenSecret) DeleteSecret(key types.NamespacedName) error { log := s.log.WithValues("func", "DeleteSecret") condition := metav1.Condition{ - Type: githubv1.ConditionTypeAvailable, + Type: githubv1.ConditionTypeReady, Status: metav1.ConditionFalse, Reason: "Reconciling", Message: "Deleting old Secret", From afb30bbfda71cf3c5eda2bbf8e2a9832286df207 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Sun, 13 Jul 2025 21:48:07 +0200 Subject: [PATCH 2/3] test: add full e2e tests --- .gitignore | 3 + Makefile | 7 +- go.mod | 8 +- .../controller/clustertoken_controller.go | 14 +- internal/controller/token_controller.go | 14 +- internal/ghapp/config.go | 4 +- internal/ghapp/ghapp.go | 4 +- internal/tokenmanager/token_secret.go | 4 +- test/e2e/e2e_helpers_test.go | 385 +++++++++++++++++ test/e2e/e2e_suite_test.go | 7 +- test/e2e/e2e_test.go | 401 +++++++++++++++--- test/utils/utils.go | 140 ------ 12 files changed, 755 insertions(+), 236 deletions(-) create mode 100644 test/e2e/e2e_helpers_test.go delete mode 100644 test/utils/utils.go diff --git a/.gitignore b/.gitignore index 7a7feec..4a906be 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ bin/* Dockerfile.cross +# Secrets +test/e2e/values.yaml + # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index 5b35eb3..7c18376 100644 --- a/Makefile +++ b/Makefile @@ -150,8 +150,8 @@ cleanup-test-e2e: # Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. .PHONY: test-e2e -test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. - KIND_CLUSTER=$(KIND_CLUSTER) go test ./test/e2e/ -v -ginkgo.v +test-e2e: fmt vet setup-test-e2e ## Run the e2e tests. Expected an isolated environment using Kind. + KUBE_CONTEXT=kind-$(KIND_CLUSTER) go test ./test/e2e/ -tags=e2e -v -ginkgo.v $(MAKE) cleanup-test-e2e .PHONY: lint @@ -213,7 +213,7 @@ build-installer: manifests generate kustomize ## Generate a consolidated YAML wi .PHONY: ko-build ko-build: ## Build the manager image using ko. KO_DOCKER_REPO=$(IMAGE_TAG_BASE) \ - ko build --bare --platform=$(PLATFORMS) --image-label org.opencontainers.image.source=$(IMAGE_SOURCE) --tags "latest,$(VERSION)" --push ./cmd/manager + $(KO) build --bare --platform=$(PLATFORMS) --image-label org.opencontainers.image.source=$(IMAGE_SOURCE) --tags "latest,$(VERSION)" --push ./cmd/manager ##@ Deployment @@ -249,6 +249,7 @@ $(LOCALBIN): ## Tool Binaries KUBECTL ?= kubectl KIND ?= kind +KO ?= ko KUSTOMIZE ?= $(LOCALBIN)/kustomize CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen ENVTEST ?= $(LOCALBIN)/setup-envtest diff --git a/go.mod b/go.mod index 6f56cf2..2b14c61 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/isometry/github-token-manager -go 1.24.0 - -toolchain go1.24.5 +go 1.24.5 require ( github.com/go-logr/logr v1.4.3 @@ -12,10 +10,12 @@ require ( github.com/onsi/gomega v1.37.0 github.com/spf13/viper v1.20.1 go.uber.org/automaxprocs v1.6.0 + golang.org/x/oauth2 v0.30.0 k8s.io/api v0.33.2 k8s.io/apimachinery v0.33.2 k8s.io/client-go v0.33.2 sigs.k8s.io/controller-runtime v0.21.0 + sigs.k8s.io/yaml v1.4.0 ) require ( @@ -127,7 +127,6 @@ require ( golang.org/x/crypto v0.40.0 // indirect golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/term v0.33.0 // indirect @@ -154,5 +153,4 @@ require ( sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/internal/controller/clustertoken_controller.go b/internal/controller/clustertoken_controller.go index 0f63c71..a7344f2 100644 --- a/internal/controller/clustertoken_controller.go +++ b/internal/controller/clustertoken_controller.go @@ -55,12 +55,12 @@ type ClusterTokenReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile func (r *ClusterTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { - log := log.FromContext(ctx) + logger := log.FromContext(ctx) if app == nil { app, err = ghapp.NewGHApp(ctx) if err != nil { - log.Error(err, "failed to load GitHub App credentials") + logger.Error(err, "failed to load GitHub App credentials") return ctrl.Result{RequeueAfter: time.Minute}, err } } @@ -70,26 +70,26 @@ func (r *ClusterTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request options := []tm.Option{ tm.WithReconciler(r), tm.WithGHApp(app), - tm.WithLogger(log), + tm.WithLogger(logger), } tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, options...) if err != nil { - log.Error(err, "failed to create ClusterToken reconciler") + logger.Error(err, "failed to create ClusterToken reconciler") return ctrl.Result{}, err } if tokenSecret == nil { - log.Info("ClusterToken not found, skipping reconciliation") + logger.Info("ClusterToken not found, skipping reconciliation") return ctrl.Result{}, nil } result, err = tokenSecret.Reconcile() if err != nil { - log.Error(err, "failed to reconcile ClusterToken") + logger.Error(err, "failed to reconcile ClusterToken") return result, err } - log.Info("reconciled", "requeueAfter", result.RequeueAfter) + logger.Info("reconciled", "requeueAfter", result.RequeueAfter) return result, nil } diff --git a/internal/controller/token_controller.go b/internal/controller/token_controller.go index 716a32b..1896694 100644 --- a/internal/controller/token_controller.go +++ b/internal/controller/token_controller.go @@ -55,12 +55,12 @@ type TokenReconciler struct { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.3/pkg/reconcile func (r *TokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { - log := log.FromContext(ctx) + logger := log.FromContext(ctx) if app == nil { app, err = ghapp.NewGHApp(ctx) if err != nil { - log.Error(err, "failed to load GitHub App credentials") + logger.Error(err, "failed to load GitHub App credentials") return ctrl.Result{RequeueAfter: time.Minute}, err } } @@ -69,26 +69,26 @@ func (r *TokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (resu options := []tm.Option{ tm.WithReconciler(r), tm.WithGHApp(app), - tm.WithLogger(log), + tm.WithLogger(logger), } tokenSecret, err := tm.NewTokenSecret(ctx, req.NamespacedName, token, options...) if err != nil { - log.Error(err, "failed to create Token reconciler") + logger.Error(err, "failed to create Token reconciler") return ctrl.Result{}, err } if tokenSecret == nil { - log.Info("Token not found, skipping reconciliation") + logger.Info("Token not found, skipping reconciliation") return ctrl.Result{}, nil } result, err = tokenSecret.Reconcile() if err != nil { - log.Error(err, "failed to reconcile Token") + logger.Error(err, "failed to reconcile Token") return result, err } - log.Info("reconciled", "requeueAfter", result.RequeueAfter) + logger.Info("reconciled", "requeueAfter", result.RequeueAfter) return result, nil } diff --git a/internal/ghapp/config.go b/internal/ghapp/config.go index 80a625e..14d2c34 100644 --- a/internal/ghapp/config.go +++ b/internal/ghapp/config.go @@ -41,7 +41,7 @@ func (c *OperatorConfig) GetKey() string { const TokenValidity = time.Hour func LoadConfig(ctx context.Context) (*OperatorConfig, error) { - log := log.FromContext(ctx) + logger := log.FromContext(ctx) viper.AutomaticEnv() viper.SetEnvPrefix("GTM") @@ -60,7 +60,7 @@ func LoadConfig(ctx context.Context) (*OperatorConfig, error) { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("error reading configuration file: %w", err) } else { - log.Info("no configuration file found, continuing with environment variables only") + logger.Info("no configuration file found, continuing with environment variables only") } } diff --git a/internal/ghapp/ghapp.go b/internal/ghapp/ghapp.go index 2dc4e18..2905466 100644 --- a/internal/ghapp/ghapp.go +++ b/internal/ghapp/ghapp.go @@ -9,14 +9,14 @@ import ( ) func NewGHApp(ctx context.Context) (ghait.GHAIT, error) { - log := log.FromContext(ctx) + logger := log.FromContext(ctx) cfg, err := LoadConfig(ctx) if err != nil { return nil, fmt.Errorf("configuration: %w", err) } - log.Info("loaded configuration", "config", cfg) + logger.Info("loaded configuration", "config", cfg) ghapp, err := ghait.NewGHAIT(ctx, cfg) if err != nil { diff --git a/internal/tokenmanager/token_secret.go b/internal/tokenmanager/token_secret.go index 545a05b..e9e6d43 100644 --- a/internal/tokenmanager/token_secret.go +++ b/internal/tokenmanager/token_secret.go @@ -43,9 +43,9 @@ func WithReconciler(reconciler tokenReconciler) Option { } } -func WithGHApp(ghait ghait.GHAIT) Option { +func WithGHApp(g ghait.GHAIT) Option { return func(s *tokenSecret) { - s.ghait = ghait + s.ghait = g } } diff --git a/test/e2e/e2e_helpers_test.go b/test/e2e/e2e_helpers_test.go new file mode 100644 index 0000000..603fe94 --- /dev/null +++ b/test/e2e/e2e_helpers_test.go @@ -0,0 +1,385 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2024 Robin Breathe. + +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 e2e_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/google/go-github/v73/github" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" //nolint:staticcheck + "golang.org/x/oauth2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + gtmv1 "github.com/isometry/github-token-manager/api/v1" + "github.com/isometry/github-token-manager/internal/tokenmanager" +) + +type clientContext struct { + client client.Client + context context.Context +} + +func newClientContext(ctx context.Context, k8sClient client.Client) *clientContext { + return &clientContext{ + client: k8sClient, + context: ctx, + } +} + +// waitForPod waits for a pod to be created and enter the running state +func (c *clientContext) waitForPod(inNamespace string, matchingLabels map[string]string) { + Eventually(func(g Gomega) { + podList := &corev1.PodList{} + g.Expect( + c.client.List(c.context, podList, + client.InNamespace(inNamespace), + client.MatchingLabels(matchingLabels), + ), + ).NotTo(HaveOccurred()) + g.Expect(podList.Items).To(HaveLen(1)) + g.Expect(podList.Items[0].Status).ToNot(BeNil()) + g.Expect(podList.Items[0].Status.Phase).To(Equal(corev1.PodRunning)) + }).Within(podReadyTimeout).Should(Succeed()) +} + +// waitForTokenReconciliation waits for a Token resource to reach status Ready=True +func (c *clientContext) waitForTokenReconciliation(name, namespace string) { + Eventually(func(g Gomega) { + tokenObj := >mv1.Token{} + g.Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, tokenObj), + ).NotTo(HaveOccurred()) + g.Expect(tokenObj.Status.Conditions).To(HaveLen(1)) + g.Expect(tokenObj.Status.Conditions[0].Type).To(Equal(gtmv1.ConditionTypeReady)) + g.Expect(tokenObj.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + }).Within(reconciliationTimeout).Should(Succeed()) +} + +// waitForClusterTokenReconciliation waits for a ClusterToken resource to reach status Ready=True +func (c *clientContext) waitForClusterTokenReconciliation(name string) { + Eventually(func(g Gomega) { + clusterTokenObj := >mv1.ClusterToken{} + g.Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: name, + }, clusterTokenObj), + ).NotTo(HaveOccurred()) + g.Expect(clusterTokenObj.Status.Conditions).To(HaveLen(1)) + g.Expect(clusterTokenObj.Status.Conditions[0].Type).To(Equal(gtmv1.ConditionTypeReady)) + g.Expect(clusterTokenObj.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + }).Within(reconciliationTimeout).Should(Succeed()) +} + +// checkManagedSecret waits for a secret to be created and returns its initial token value +func (c *clientContext) checkManagedSecret( + name, namespace string, //nolint:unparam + secretType corev1.SecretType, +) (secretValue string) { + secret := &corev1.Secret{} + Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, secret), + ).NotTo(HaveOccurred()) + + Expect(secret.Labels).To(HaveKey("app.kubernetes.io/created-by")) + Expect(secret.Labels["app.kubernetes.io/created-by"]).To(Equal("github-token-manager")) + + switch secretType { + case tokenmanager.SecretTypeBasicAuth: + Expect(secret.Type).To(Equal(tokenmanager.SecretTypeBasicAuth)) + Expect(secret.Data).To(HaveKey("username")) + Expect(secret.Data["username"]).ToNot(BeEmpty()) + Expect(secret.Data).To(HaveKey("password")) + Expect(secret.Data["password"]).ToNot(BeEmpty()) + secretValue = string(secret.Data["password"]) + case tokenmanager.SecretTypeToken: + Expect(secret.Type).To(Equal(tokenmanager.SecretTypeToken)) + Expect(secret.Data).To(HaveKey("token")) + Expect(secret.Data["token"]).ToNot(BeEmpty()) + secretValue = string(secret.Data["token"]) + } + + return secretValue +} + +// checkManagedSecretRotation waits for a token to be refreshed and returns the refreshed token +func (c *clientContext) checkManagedSecretRotation( + secretName, namespace string, //nolint:unparam + secretType corev1.SecretType, + oldSecretValue string, +) (newSecretValue string) { + Eventually(func(g Gomega) { + secret := &corev1.Secret{} + g.Expect( + c.client.Get(c.context, client.ObjectKey{ + Name: secretName, + Namespace: namespace, + }, secret), + ).NotTo(HaveOccurred()) + + switch secretType { + case tokenmanager.SecretTypeBasicAuth: + g.Expect(secret.Data).To(HaveKey("password")) + g.Expect(secret.Data["password"]).ToNot(BeEmpty()) + newSecretValue = string(secret.Data["password"]) + case tokenmanager.SecretTypeToken: + g.Expect(secret.Data).To(HaveKey("token")) + g.Expect(secret.Data["token"]).ToNot(BeEmpty()) + newSecretValue = string(secret.Data["token"]) + } + + g.Expect(newSecretValue).To(Not(Equal(oldSecretValue))) + }).Within(secretCheckTimeout).ProbeEvery(secretCheckInterval).Should(Succeed()) + + return newSecretValue +} + +// createNamespace creates a namespace for testing +func (c *clientContext) createNamespace(name string) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + return c.client.Create(c.context, ns) +} + +// deleteNamespace deletes a namespace +func (c *clientContext) deleteNamespace(name string) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + return c.client.Delete(c.context, ns) +} + +// createToken creates a standard Token resource for testing +func (c *clientContext) createToken( + name, namespace, secretName string, + isBasicAuth bool, + refreshInterval time.Duration, +) error { + token := >mv1.Token{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "github.as-code.io/v1", + Kind: "Token", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: gtmv1.TokenSpec{ + RefreshInterval: metav1.Duration{Duration: refreshInterval}, + Secret: gtmv1.TokenSecretSpec{ + Name: secretName, + BasicAuth: isBasicAuth, + }, + Repositories: []string{ + testRepositoryName, + }, + Permissions: >mv1.Permissions{ + Contents: &readPermission, + Metadata: &readPermission, + }, + }, + } + + return c.client.Create(c.context, token) +} + +// deleteToken deletes a Token resource +func (c *clientContext) deleteToken(name, namespace string) error { + token := >mv1.Token{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + return c.client.Delete(c.context, token) +} + +// createClusterToken creates a standard ClusterToken resource for testing +func (c *clientContext) createClusterToken( + name, secretName, targetNamespace string, + isBasicAuth bool, + refreshInterval time.Duration, +) error { + clusterToken := >mv1.ClusterToken{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "github.as-code.io/v1", + Kind: "ClusterToken", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: gtmv1.ClusterTokenSpec{ + RefreshInterval: metav1.Duration{Duration: refreshInterval}, + Secret: gtmv1.ClusterTokenSecretSpec{ + Name: secretName, + Namespace: targetNamespace, + BasicAuth: isBasicAuth, + }, + Repositories: []string{ + testRepositoryName, + }, + Permissions: >mv1.Permissions{ + Contents: &readPermission, + }, + }, + } + return c.client.Create(c.context, clusterToken) +} + +// deleteClusterToken deletes a ClusterToken resource +func (c *clientContext) deleteClusterToken(name string) error { + clusterToken := >mv1.ClusterToken{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + return c.client.Delete(c.context, clusterToken) +} + +// runCommand executes the provided command within this context +func runCommand(cmd *exec.Cmd) ([]byte, error) { + dir, _ := getProjectDir() + cmd.Dir = dir + + cmd.Env = append(os.Environ(), "GO111MODULE=on") + command := strings.Join(cmd.Args, " ") + _, _ = fmt.Fprintf(ginkgo.GinkgoWriter, "running: %s\n", command) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + stderrStr := stderr.String() + if stderrStr != "" { + return stdout.Bytes(), fmt.Errorf("%s failed with error: (%v) stderr: %s", command, err, stderrStr) + } + return stdout.Bytes(), fmt.Errorf("%s failed with error: (%v)", command, err) + } + + return stdout.Bytes(), nil +} + +// loadImageToKindCluster loads a local docker image to the kind cluster +func loadImageToKindCluster(name, cluster string) error { + kindOptions := []string{"load", "docker-image", name, "--name", cluster} + cmd := exec.Command("kind", kindOptions...) + _, err := runCommand(cmd) + return err +} + +// getProjectDir will return the directory where the project is +func getProjectDir() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found") + } + dir = parent + } +} + +// generateTestKey generates a new RSA private key and returns it as a PEM-encoded string +func generateTestKey() (string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", err + } + + privateKeyDER := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyDER, + }) + + return string(privateKeyPEM), nil +} + +// checkToken validates that a GitHub token can read content from the specified repository +// by making a simple API call to get the repository's README. +// Returns nil if the token is valid, error otherwise. +func checkToken(repository, token string) error { + ctx := context.Background() + + // Create OAuth2 token source + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + // Create GitHub client + client := github.NewClient(tc) + + // Parse repository string (format: "owner/repo") + parts := strings.Split(repository, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repository format, expected 'owner/repo', got '%s'", repository) + } + owner, repo := parts[0], parts[1] + + // Test the /repos/OWNER/REPO/readme endpoint to validate content read permissions + _, _, err := client.Repositories.GetReadme(ctx, owner, repo, nil) + if err != nil { + return fmt.Errorf("failed to validate token for repository %s (readme endpoint): %w", repository, err) + } + + return nil +} + +// newTokenValidator creates a new GitHub token validator for the specified repository. +// Returns a function that can be used to validate tokens. +func newTokenValidator(repository string) func(string) error { + return func(token string) error { + return checkToken(repository, token) + } +} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index b80de56..2f01544 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -1,3 +1,6 @@ +//go:build e2e +// +build e2e + /* Copyright 2024 Robin Breathe. @@ -14,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package e2e +package e2e_test import ( "fmt" @@ -27,6 +30,6 @@ import ( // Run e2e tests using the Ginkgo runner. func TestE2E(t *testing.T) { RegisterFailHandler(Fail) - fmt.Fprintf(GinkgoWriter, "Starting github-token-manager suite\n") + _, _ = fmt.Fprintf(GinkgoWriter, "Starting github-token-manager suite\n") RunSpecs(t, "e2e suite") } diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 68899c8..d320b21 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -1,3 +1,6 @@ +//go:build e2e +// +build e2e + /* Copyright 2024 Robin Breathe. @@ -14,96 +17,362 @@ See the License for the specific language governing permissions and limitations under the License. */ -package e2e +package e2e_test import ( + "cmp" + "context" "fmt" + "os" "os/exec" + "path/filepath" + "strings" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + gtmv1 "github.com/isometry/github-token-manager/api/v1" + "github.com/isometry/github-token-manager/internal/tokenmanager" +) + +const ( + // Operator namespace and configuration + operatorNamespace = "github-token-manager" + testRepositoryOwner = "isometry" + testRepositoryName = "github-token-manager" + testRepository = testRepositoryOwner + "/" + testRepositoryName + + // Test timeouts and intervals + podReadyTimeout = 2 * time.Minute + reconciliationTimeout = 10 * time.Second + tokenRefreshInterval = 10 * time.Second + secretCheckTimeout = 2 * tokenRefreshInterval + secretCheckInterval = 1 * time.Second - "github.com/isometry/github-token-manager/test/utils" + // Test namespace + targetNamespace = "test-tokens" + + // Resource names + testToken1 = "token-1" + testToken2 = "token-2" + testClusterToken1 = "cluster-token-1" + testClusterToken2 = "cluster-token-2" + + // Secret names + testSecret1 = "secret-1" + testSecret2 = "secret-2" + testSecret3 = "secret-3" + testSecret4 = "secret-4" ) -const namespace = "github-token-manager-system" +// Permission strings (var to allow taking address) +var readPermission = "read" + +var _ = Describe("GitHub Token Manager", Ordered, func() { + var kubeContext, testImage, testRepo, testTag string + var hasAppCredentials bool + var k8sClient client.Client + ctx := context.Background() + var clientCtx *clientContext + checkToken := newTokenValidator(testRepository) -var _ = Describe("controller", Ordered, func() { BeforeAll(func() { - By("creating manager namespace") - cmd := exec.Command("kubectl", "create", "ns", namespace) - _, _ = utils.Run(cmd) + scheme := runtime.NewScheme() + Expect(clientgoscheme.AddToScheme(scheme)).To(Succeed()) + Expect(gtmv1.AddToScheme(scheme)).To(Succeed()) + + // Get Kind cluster name from environment + kubeContext = os.Getenv("KUBE_CONTEXT") + Expect(kubeContext).NotTo(BeEmpty()) + + // Build kubeconfig for the specific Kind cluster context + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + kubeconfig, err := clientcmd.LoadFromFile(loadingRules.GetDefaultFilename()) + Expect(err).NotTo(HaveOccurred()) + + config, err := clientcmd.NewDefaultClientConfig(*kubeconfig, &clientcmd.ConfigOverrides{ + CurrentContext: kubeContext, + }).ClientConfig() + Expect(err).NotTo(HaveOccurred()) + + k8sClient, err = client.New(config, client.Options{Scheme: scheme}) + Expect(err).NotTo(HaveOccurred()) + + clientCtx = newClientContext(ctx, k8sClient) + + By("creating token namespace") + Expect(clientCtx.createNamespace(targetNamespace)).To(Succeed()) }) - AfterAll(func() { - By("removing manager namespace") - cmd := exec.Command("kubectl", "delete", "ns", namespace) - _, _ = utils.Run(cmd) + Context("Container Image", func() { + It("builds successfully", func() { + By("building the manager image") + cmd := exec.Command("ko", "build", "--local", "./cmd/manager") + output, err := runCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + testImage = strings.TrimSpace(string(output)) + imageParts := strings.SplitN(testImage, ":", 2) + testRepo = imageParts[0] + testTag = imageParts[1] + + if strings.HasPrefix(kubeContext, "kind-") { + By("loading the manager image to Kind") + Expect(loadImageToKindCluster(testImage, strings.TrimPrefix(kubeContext, "kind-"))).To(Succeed()) + } + }) }) - Context("Operator", func() { - It("should run successfully", func() { - var controllerPodName string - var err error - - // projectimage stores the name of the image used in the example - var projectimage = "example.com/github-token-manager:v0.0.1" - - By("building the manager(Operator) image") - cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectimage)) - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("loading the the manager(Operator) image on Kind") - err = utils.LoadImageToKindClusterWithName(projectimage) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("installing CRDs") - cmd = exec.Command("make", "install") - _, err = utils.Run(cmd) - - By("deploying the manager") - cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectimage)) - _, err = utils.Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred()) - - By("validating that the manager pod is running as expected") - verifyControllerUp := func() error { - // Get pod name - - cmd = exec.Command("kubectl", "get", - "pods", "-l", "control-plane=manager", - "-o", "go-template={{ range .items }}"+ - "{{ if not .metadata.deletionTimestamp }}"+ - "{{ .metadata.name }}"+ - "{{ \"\\n\" }}{{ end }}{{ end }}", - "-n", namespace, + Context("Helm Chart", func() { + It("installs cleanly", func() { + ctx = context.Background() + + By("checking for valid GitHub App credentials") + projectDir, err := getProjectDir() + Expect(err).NotTo(HaveOccurred()) + valuesPath := filepath.Join(projectDir, "test", "e2e", "values.yaml") + + chartPath := filepath.Join(projectDir, "deploy", "charts", "github-token-manager") + valuesArgs := []string{ + "helm", "upgrade", "--install", "github-token-manager", chartPath, + "--namespace", operatorNamespace, + "--create-namespace", + fmt.Sprintf("--set=manager.repository=%s", testRepo), + fmt.Sprintf("--set=manager.tag=%s", testTag), + } + + gtmAppId := os.Getenv("GTM_APP_ID") + gtmInstallationId := os.Getenv("GTM_INSTALLATION_ID") + gtmProvider := cmp.Or(os.Getenv("GTM_PROVIDER"), "file") + gtmKey := os.Getenv("GTM_KEY") + + // Check for credentials in the following priority order: + // 1. Local values.yaml file + // 2. Environment variables (for CI/GitHub Actions) + if _, err := os.Stat(valuesPath); err == nil { + hasAppCredentials = true + GinkgoWriter.Printf("Using GitHub App configuration values from %q\n", valuesPath) + valuesArgs = append(valuesArgs, + fmt.Sprintf("--values=%s", valuesPath), ) + } else if gtmAppId != "" && gtmInstallationId != "" && gtmKey != "" { + hasAppCredentials = true + GinkgoWriter.Println("Using GitHub App credentials from the environment") - podOutput, err := utils.Run(cmd) - ExpectWithOffset(2, err).NotTo(HaveOccurred()) - podNames := utils.GetNonEmptyLines(string(podOutput)) - if len(podNames) != 1 { - return fmt.Errorf("expect 1 controller pods running, but got %d", len(podNames)) + // Create temporary values file from environment variables + envValuesPath := filepath.Join(projectDir, "test", "e2e", "values.env.yaml") + + // Structure matching the Helm values schema + values := map[string]any{ + "config": map[string]string{ + "app_id": gtmAppId, + "installation_id": gtmInstallationId, + "provider": gtmProvider, + "key": gtmKey, + }, } - controllerPodName = podNames[0] - ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("manager")) - // Validate pod status - cmd = exec.Command("kubectl", "get", - "pods", controllerPodName, "-o", "jsonpath={.status.phase}", - "-n", namespace, + valuesYAML, err := yaml.Marshal(values) + Expect(err).NotTo(HaveOccurred()) + + Expect(os.WriteFile(envValuesPath, valuesYAML, 0600)).To(Succeed()) + defer func() { + _ = os.Remove(envValuesPath) + }() + + valuesArgs = append(valuesArgs, + fmt.Sprintf("--values=%s", envValuesPath), ) - status, err := utils.Run(cmd) - ExpectWithOffset(2, err).NotTo(HaveOccurred()) - if string(status) != "Running" { - return fmt.Errorf("controller pod in %s status", status) - } - return nil + } else { + GinkgoWriter.Printf("No real config found at %q or in the environment, generaging dumbie config\n", valuesPath) + // Use test config values + privateKey, err := generateTestKey() + Expect(err).NotTo(HaveOccurred()) + valuesArgs = append(valuesArgs, + "--set=config.app_id=123456", + "--set=config.installation_id=789012", + "--set=config.provider=file", + fmt.Sprintf("--set-string=config.key=%s", privateKey), + ) + } + + By("installing operator using Helm chart") + cmd := exec.Command(valuesArgs[0], valuesArgs[1:]...) + _, err = runCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for manager pod to be ready") + clientCtx.waitForPod( + operatorNamespace, + map[string]string{ + "app.kubernetes.io/name": "github-token-manager", + "app.kubernetes.io/instance": "github-token-manager", + }, + ) + }) + }) + + Context("Token CR", func() { + It("manages Secrets of type github.as-code.io/token", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") + } + + By("creating a Token resource with basicAuth=false") + Expect(clientCtx.createToken(testToken1, targetNamespace, testSecret1, false, tokenRefreshInterval)).To(Succeed()) + + By("waiting for Token reconciliation") + clientCtx.waitForTokenReconciliation(testToken1, targetNamespace) + + By("checking managed Secret is created correctly") + initialSecretToken := clientCtx.checkManagedSecret(testSecret1, targetNamespace, tokenmanager.SecretTypeToken) + + By("checking that the managed Secret token value is valid") + Expect(checkToken(initialSecretToken)).To(Succeed()) + + By("checking managed Secret token values are rotated") + rotatedSecretToken := clientCtx.checkManagedSecretRotation( + testSecret1, + targetNamespace, + tokenmanager.SecretTypeToken, + initialSecretToken, + ) + + By("checking that the rotated Secret token value is valid") + Expect(checkToken(rotatedSecretToken)).To(Succeed()) + + By("deleting the Token resource") + Expect(clientCtx.deleteToken(testToken1, targetNamespace)).To(Succeed()) + }) + + It("manages Secrets of type github.as-code.io/basic-auth", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") } - EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed()) + By("creating a Token resource with basicAuth=true") + Expect(clientCtx.createToken(testToken2, targetNamespace, testSecret2, true, tokenRefreshInterval)).To(Succeed()) + + By("waiting for Token reconciliation") + clientCtx.waitForTokenReconciliation(testToken2, targetNamespace) + + By("checking managed Secret is created correctly") + initialSecretToken := clientCtx.checkManagedSecret(testSecret2, targetNamespace, tokenmanager.SecretTypeBasicAuth) + + By("checking that the managed Secret token value is valid") + Expect(checkToken(initialSecretToken)).To(Succeed()) + + By("checking managed Secret token values are rotated") + rotatedSecretToken := clientCtx.checkManagedSecretRotation( + testSecret2, + targetNamespace, + tokenmanager.SecretTypeBasicAuth, + initialSecretToken, + ) + + By("checking that the rotated Secret token value is valid") + Expect(checkToken(rotatedSecretToken)).To(Succeed()) + + By("deleting the Token resource") + Expect(clientCtx.deleteToken(testToken2, targetNamespace)).To(Succeed()) }) }) + + Context("ClusterToken CR", func() { + It("manages Secrets of type github.as-code.io/token", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") + } + + By("creating a ClusterToken resource with basicAuth=false") + Expect(clientCtx.createClusterToken( + testClusterToken1, + testSecret3, + targetNamespace, + false, + tokenRefreshInterval, + )).To(Succeed()) + + By("waiting for ClusterToken reconciliation") + clientCtx.waitForClusterTokenReconciliation(testClusterToken1) + + By("checking managed Secret is created correctly") + initialToken := clientCtx.checkManagedSecret(testSecret3, targetNamespace, tokenmanager.SecretTypeToken) + + By("checking that the managed Secret token value is valid") + Expect(checkToken(initialToken)).To(Succeed()) + + By("checking managed Secret token values are rotated") + refreshedTokenValue := clientCtx.checkManagedSecretRotation( + testSecret3, + targetNamespace, + tokenmanager.SecretTypeToken, + initialToken, + ) + + By("checking that the rotated Secret token value is valid") + Expect(checkToken(refreshedTokenValue)).To(Succeed()) + + By("deleting the ClusterToken resource") + Expect(clientCtx.deleteClusterToken(testClusterToken1)).To(Succeed()) + }) + + It("manages Secrets of type github.as-code.io/basic-auth", func() { + if !hasAppCredentials { + Skip("skipping tests - no valid GitHub App configuration provided") + } + + By("creating a ClusterToken resource with basicAuth=true") + Expect(clientCtx.createClusterToken( + testClusterToken2, + testSecret4, + targetNamespace, + true, + tokenRefreshInterval, + )).To(Succeed()) + + By("waiting for ClusterToken reconciliation") + clientCtx.waitForClusterTokenReconciliation(testClusterToken2) + + By("checking managed Secret is created correctly") + initialTokenValue := clientCtx.checkManagedSecret(testSecret4, targetNamespace, tokenmanager.SecretTypeBasicAuth) + + By("checking that the managed Secret token value is valid") + Expect(checkToken(initialTokenValue)).To(Succeed()) + + By("checking managed Secret token values are rotated") + updatedTokenValue := clientCtx.checkManagedSecretRotation( + testSecret4, + targetNamespace, + tokenmanager.SecretTypeBasicAuth, + initialTokenValue, + ) + + By("checking that the rotated Secret token value is valid") + Expect(checkToken(updatedTokenValue)).To(Succeed()) + + By("deleting the ClusterToken resource") + Expect(clientCtx.deleteClusterToken(testClusterToken2)).To(Succeed()) + }) + }) + + Context("Helm Chart", func() { + It("should uninstall without error", func() { + By("uninstalling Helm chart") + cmd := exec.Command("helm", "uninstall", "github-token-manager", "--namespace", operatorNamespace) + _, err := runCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + + }) + }) + + AfterAll(func() { + Expect(clientCtx.deleteNamespace(operatorNamespace)).To(Succeed()) + Expect(clientCtx.deleteNamespace(targetNamespace)).To(Succeed()) + }) }) diff --git a/test/utils/utils.go b/test/utils/utils.go deleted file mode 100644 index c2aa8ea..0000000 --- a/test/utils/utils.go +++ /dev/null @@ -1,140 +0,0 @@ -/* -Copyright 2024 Robin Breathe. - -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 utils - -import ( - "fmt" - "os" - "os/exec" - "strings" - - . "github.com/onsi/ginkgo/v2" //nolint:revive -) - -const ( - prometheusOperatorVersion = "v0.68.0" - prometheusOperatorURL = "https://github.com/prometheus-operator/prometheus-operator/" + - "releases/download/%s/bundle.yaml" - - certmanagerVersion = "v1.5.3" - certmanagerURLTmpl = "https://github.com/jetstack/cert-manager/releases/download/%s/cert-manager.yaml" -) - -func warnError(err error) { - fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) -} - -// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. -func InstallPrometheusOperator() error { - url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) - cmd := exec.Command("kubectl", "create", "-f", url) - _, err := Run(cmd) - return err -} - -// Run executes the provided command within this context -func Run(cmd *exec.Cmd) ([]byte, error) { - dir, _ := GetProjectDir() - cmd.Dir = dir - - if err := os.Chdir(cmd.Dir); err != nil { - fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) - } - - cmd.Env = append(os.Environ(), "GO111MODULE=on") - command := strings.Join(cmd.Args, " ") - fmt.Fprintf(GinkgoWriter, "running: %s\n", command) - output, err := cmd.CombinedOutput() - if err != nil { - return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) - } - - return output, nil -} - -// UninstallPrometheusOperator uninstalls the prometheus -func UninstallPrometheusOperator() { - url := fmt.Sprintf(prometheusOperatorURL, prometheusOperatorVersion) - cmd := exec.Command("kubectl", "delete", "-f", url) - if _, err := Run(cmd); err != nil { - warnError(err) - } -} - -// UninstallCertManager uninstalls the cert manager -func UninstallCertManager() { - url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) - cmd := exec.Command("kubectl", "delete", "-f", url) - if _, err := Run(cmd); err != nil { - warnError(err) - } -} - -// InstallCertManager installs the cert manager bundle. -func InstallCertManager() error { - url := fmt.Sprintf(certmanagerURLTmpl, certmanagerVersion) - cmd := exec.Command("kubectl", "apply", "-f", url) - if _, err := Run(cmd); err != nil { - return err - } - // Wait for cert-manager-webhook to be ready, which can take time if cert-manager - // was re-installed after uninstalling on a cluster. - cmd = exec.Command("kubectl", "wait", "deployment.apps/cert-manager-webhook", - "--for", "condition=Available", - "--namespace", "cert-manager", - "--timeout", "5m", - ) - - _, err := Run(cmd) - return err -} - -// LoadImageToKindCluster loads a local docker image to the kind cluster -func LoadImageToKindClusterWithName(name string) error { - cluster := "kind" - if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { - cluster = v - } - kindOptions := []string{"load", "docker-image", name, "--name", cluster} - cmd := exec.Command("kind", kindOptions...) - _, err := Run(cmd) - return err -} - -// GetNonEmptyLines converts given command output string into individual objects -// according to line breakers, and ignores the empty elements in it. -func GetNonEmptyLines(output string) []string { - var res []string - elements := strings.Split(output, "\n") - for _, element := range elements { - if element != "" { - res = append(res, element) - } - } - - return res -} - -// GetProjectDir will return the directory where the project is -func GetProjectDir() (string, error) { - wd, err := os.Getwd() - if err != nil { - return wd, err - } - wd = strings.ReplaceAll(wd, "/test/e2e", "") - return wd, nil -} From bc24235ad56a54c30471cf5f28095dcdf34e5077 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Tue, 22 Jul 2025 13:20:23 +0200 Subject: [PATCH 3/3] ci: add e2e workflow --- .github/workflows/e2e.yaml | 45 ++++++++++++++++++++++++++++++++++++++ README.md | 7 +++--- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/e2e.yaml diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..4fdf458 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,45 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + e2e: + name: E2E Tests + runs-on: ubuntu-latest + + env: + KIND_CLUSTER: e2e + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup go + uses: actions/setup-go@v5 + + - name: Setup ko + uses: ko-build/setup-ko@v0.9 + + - name: Setup helm + uses: azure/setup-helm@v4 + + - name: Setup kind + uses: helm/kind-action@v1 + with: + cluster_name: ${{ env.KIND_CLUSTER }} + + - name: Run E2E tests + env: + GTM_APP_ID: ${{ vars.GTM_APP_ID }} + GTM_INSTALLATION_ID: ${{ vars.GTM_INSTALLATION_ID }} + GTM_PROVIDER: file + GTM_KEY: ${{ secrets.GTM_KEY }} + run: | + make test-e2e diff --git a/README.md b/README.md index d32d1f5..868ad50 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![CodeQL](https://github.com/isometry/github-token-manager/actions/workflows/codeql.yaml/badge.svg)](https://github.com/isometry/github-token-manager/actions/workflows/codeql.yaml) +[![E2E](https://github.com/isometry/github-token-manager/actions/workflows/e2e.yaml/badge.svg)](https://github.com/isometry/github-token-manager/actions/workflows/e2e.yaml) [![Publish](https://github.com/isometry/github-token-manager/actions/workflows/publish.yaml/badge.svg)](https://github.com/isometry/github-token-manager/actions/workflows/publish.yaml) [![Go Report Card](https://goreportcard.com/badge/github.com/isometry/github-token-manager)](https://goreportcard.com/report/github.com/isometry/github-token-manager) @@ -158,8 +159,8 @@ More information can be found via the [Kubebuilder Documentation](https://book.k make ko-build IMG=/github-token-manager:tag ``` -**NOTE:** This image ought to be published in the personal registry you specified. -And it is required to have access to pull the image from the working environment. +**NOTE:** This image ought to be published in the personal registry you specified. +And it is required to have access to pull the image from the working environment. Make sure you have the proper permission to the registry if the above commands don’t work. #### Install the CRDs into the cluster: @@ -174,7 +175,7 @@ make install make deploy IMG=/github-token-manager:tag ``` -> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin +> **NOTE**: If you encounter RBAC errors, you may need to grant yourself cluster-admin privileges or be logged in as admin. ### To Uninstall