diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..6bb9e8f --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,53 @@ +name: Build & Test + +on: + pull_request: + branches: + - '**' # Triggers on all branches + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Build Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: false + tags: user/repo:latest + platforms: linux/amd64,linux/arm64 + + test: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: '^1.22' + + - name: Run Go tests + run: | + go test ./... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b9f5368 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,54 @@ +name: Build and Push Docker Image + +on: + release: + types: [published] # Triggers on release tagging + +jobs: + build: + runs-on: ubuntu-latest + # This job runs only if the tag_name starts with 'v', this avoids conflicts with helm + # chart releases which have ecr-anywhere-helm-chart-{{ .Version }} as the release name + if: startsWith(github.event.release.tag_name, 'v') + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Convert repository name to lowercase + id: lowercase_repo + run: echo "::set-output name=lower_repo::$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: | + ghcr.io/${{ steps.lowercase_repo.outputs.lower_repo }}:latest + ghcr.io/${{ steps.lowercase_repo.outputs.lower_repo }}:${{ github.event.release.tag_name }} + platforms: linux/amd64,linux/arm64 + + - name: Logout from GitHub Container Registry + run: docker logout ghcr.io diff --git a/.github/workflows/release_helm_chart.yaml b/.github/workflows/release_helm_chart.yaml new file mode 100644 index 0000000..28fab2d --- /dev/null +++ b/.github/workflows/release_helm_chart.yaml @@ -0,0 +1,46 @@ +name: Release Helm Chart + +on: + # Triggers the workflow when Chart.yaml is updated on the main branch + push: + branches: + - main + paths: + - "charts/ecr-anywhere/Chart.yaml" + + # Allows you to manually trigger the workflow from GitHub's UI + workflow_dispatch: + +jobs: + release_helm_chart: + # Permissions required for the job. In this case, write access to the repository contents is needed. + permissions: + contents: write + # Specifies the type of runner that the job will run on. Here, it's the latest version of Ubuntu. + runs-on: ubuntu-latest + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Configures Git with the GitHub actor's name and email to make commits and tags + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + # Runs the chart-releaser action which turns your GitHub project into a self-hosted Helm + # chart repo. It does this – during every push to main – by checking each chart in your + # project, and whenever there's a new chart version, creates a corresponding GitHub release + # named for the chart version, adds Helm chart artifacts to the release, and creates or + # updates an index.yaml file with metadata about those releases, + # which is then hosted on GitHub Pages + - name: Run chart-releaser + uses: helm/chart-releaser-action@v1.6.0 + env: + # GitHub token used by the chart-releaser action + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + # Customizes the name of the chart release + CR_RELEASE_NAME_TEMPLATE: "ecr-anywhere-helm-chart-{{ .Version }}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..325a1d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Build the sidecar-injector binary +FROM golang:1.22 as builder + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum + +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the go source +COPY cmd/ cmd/ +COPY pkg/ pkg/ + +# Build +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${BUILDPLATFORM} go build -a -o ecr-anywhere-webhook ./cmd/webhook +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${BUILDPLATFORM} go build -a -o ecr-anywhere-refresher ./cmd/refresher + +FROM alpine:latest + + +WORKDIR / + +# install binaries +COPY --from=builder /workspace/ecr-anywhere-webhook . +COPY --from=builder /workspace/ecr-anywhere-refresher . + +USER 65532:65532 + +# webhook is the default entrypoint +ENTRYPOINT ["/ecr-anywhere-webhook"] diff --git a/README.md b/README.md index f0cc5f7..98fe661 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,39 @@ -# ecr-anywhere -Pull from private ECR repos... anywhere +# ECR Anywhere + +## Description +ECR Anywhere makes it easy to use container images hosted in private ECR repositories on any Kubernetes cluster, especially those hosted outside of AWS. It works via two components: + + 1) A Mutating Webhook that intercepts create/update verbs on labeled Kubernetes Secrets, injecting fresh ECR credentials which expire in 12 hours. + 2) A CronJob that periodically checks the specially labeled Kubernetes Secrets to see if they need to be refreshed. If they do, an annotation is updated, synchronously triggering a credential refresh by the Mutating Webhook. + +The benefits of this approach are the simplicity in implementation and operations (monitoring/alerting). + +From an operational perspective: + + 1) A properly labeled secret can not be created or updated unless ecr-anywhere is working as expected. There's immediate feedback during operational setup/maintenance. + 2) Any automation issues refreshing credentials are known immediately to operators with basic alerting on CronJob failures/pod failures. + + +## Quick Start + +Setup your values.yaml for the helm chart. Specifically include the AWS credentials using the standard AWS SDK environment variables. The easiest way to issue long lived AWS credentials, the most secure way is to use [AWS OIDC](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) with [Spiffe](https://spiffe.io/). The best reference for AWS SDK environment variables seems to be in the [AWS CLI documentation](https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-envvars.html). + +```yaml + +pod: + container: + env: + - name: AWS_ACCESS_KEY_ID + value: "EXAMPLE" + - name: AWS_SECRET_ACCESS_KEY + value: "EXAMPLE" + - name: AWS_REGION + #important, this must match the region in the image name + value: "us-east-1" +``` + + +```sh +helm install ecr-anywhere ./charts/ecr-anywhere -f values.yaml +``` + diff --git a/charts/ecr-anywhere/.helmignore b/charts/ecr-anywhere/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/charts/ecr-anywhere/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/ecr-anywhere/Chart.yaml b/charts/ecr-anywhere/Chart.yaml new file mode 100644 index 0000000..c450847 --- /dev/null +++ b/charts/ecr-anywhere/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: ecr-anywhere +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 1.0.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.0.0" diff --git a/charts/ecr-anywhere/templates/NOTES.txt b/charts/ecr-anywhere/templates/NOTES.txt new file mode 100644 index 0000000..e69de29 diff --git a/charts/ecr-anywhere/templates/_helpers.tpl b/charts/ecr-anywhere/templates/_helpers.tpl new file mode 100644 index 0000000..bcb54fd --- /dev/null +++ b/charts/ecr-anywhere/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "ecr-anywhere.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "ecr-anywhere.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "ecr-anywhere.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "ecr-anywhere.labels" -}} +helm.sh/chart: {{ include "ecr-anywhere.chart" . }} +{{ include "ecr-anywhere.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "ecr-anywhere.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ecr-anywhere.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "ecr-anywhere.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "ecr-anywhere.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/ecr-anywhere/templates/clusterrole.yaml b/charts/ecr-anywhere/templates/clusterrole.yaml new file mode 100644 index 0000000..a3cb8c6 --- /dev/null +++ b/charts/ecr-anywhere/templates/clusterrole.yaml @@ -0,0 +1,13 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ .Values.name }} + labels: + app: {{ .Values.name }} +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "update"] +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list"] diff --git a/charts/ecr-anywhere/templates/clusterrolebinding.yaml b/charts/ecr-anywhere/templates/clusterrolebinding.yaml new file mode 100644 index 0000000..960f63d --- /dev/null +++ b/charts/ecr-anywhere/templates/clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Values.name }} + labels: + app: {{ .Values.name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Values.name }} +subjects: +- kind: ServiceAccount + name: {{ .Values.name }} + namespace: {{ .Values.namespace }} diff --git a/charts/ecr-anywhere/templates/cronjob.yaml b/charts/ecr-anywhere/templates/cronjob.yaml new file mode 100644 index 0000000..249187a --- /dev/null +++ b/charts/ecr-anywhere/templates/cronjob.yaml @@ -0,0 +1,36 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ .Values.name }} + namespace: {{ .Values.namespace }} + annotations: + labels: + app: {{ .Values.name }} +spec: + # Cron schedule expression (e.g., "0 */12 * * *") + schedule: "{{ .Values.cronjob.schedule }}" + successfulJobsHistoryLimit: {{ .Values.successfulJobsHistoryLimit }} # Default 3 + failedJobsHistoryLimit: {{ .Values.failedJobsHistoryLimit }} # Default 1 + + # Refreshes should be fast, so we can replace the job if it's still running + concurrencyPolicy: Replace + + jobTemplate: + spec: + template: + metadata: + labels: + app: {{ .Values.name }} + annotations: + spec: + serviceAccountName: {{ .Values.serviceAccountName }} + + restartPolicy: {{ .Values.cronjob.restartPolicy }} + {{- if .Values.cronjob.backoffLimit }} + backoffLimit: {{ .Values.cronjob.backoffLimit }} + {{- end }} + + containers: + - name: {{ .Values.name }} + image: {{- printf " %s:%s" .Values.image.repository .Values.image.tag }} + command: ["/ecr-anywhere-refresher"] diff --git a/charts/ecr-anywhere/templates/deployment.yaml b/charts/ecr-anywhere/templates/deployment.yaml new file mode 100644 index 0000000..fe3be3b --- /dev/null +++ b/charts/ecr-anywhere/templates/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.name }} + namespace: {{ .Values.namespace }} + {{- if .Values.deployment.annotations }} + annotations: + {{- toYaml .Values.deployment.annotations | nindent 4 }} + {{- end }} + labels: + app: {{ .Values.name }} +spec: + replicas: {{ .Values.deployment.replicas }} + selector: + matchLabels: + app: {{ .Values.name }} + template: + metadata: + labels: + app: {{ .Values.name }} + {{- if .Values.deployment.pod.annotations }} + annotations: + {{- toYaml .Values.deployment.pod.annotations | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ .Values.serviceAccountName }} + containers: + - name: {{ .Values.name }} + image: {{- printf " %s:%s" .Values.image.repository .Values.image.tag }} + imagePullPolicy: {{ .Values.image.imagePullPolicy }} + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: SERVICE_NAME + value: {{ .Values.name }} + - name: PORT + value: "8443" + - name: CERT_FILE + value: /etc/webhook/certs/tls.crt + - name: KEY_FILE + value: /etc/webhook/certs/tls.key + {{- with .Values.deployment.pod.container.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: tls + mountPath: /etc/webhook/certs + volumes: + - name: tls + secret: + secretName: {{ .Values.tlsSecretName }} diff --git a/charts/ecr-anywhere/templates/mutatingwebhookconfiguration.yaml b/charts/ecr-anywhere/templates/mutatingwebhookconfiguration.yaml new file mode 100644 index 0000000..edb3c6a --- /dev/null +++ b/charts/ecr-anywhere/templates/mutatingwebhookconfiguration.yaml @@ -0,0 +1,44 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: {{ .Values.name }} + {{- if .Values.mutatingWebhookConfiguration.annotations }} + annotations: + {{- toYaml .Values.mutatingWebhookConfiguration.annotations | nindent 4 }} + {{- end }} +webhooks: +- admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + caBundle: {{ .Values.caBundle }} + service: + name: {{ .Values.name }} + namespace: {{ .Values.namespace }} + path: /sync + port: 443 + failurePolicy: Fail + matchPolicy: Equivalent + name: ecr-anywhere.centml.ai + namespaceSelector: + matchLabels: + # must match credentials.go + ecr-anywhere.centml.ai/namespace: "enabled" + objectSelector: + matchLabels: + # must match credentials.go + ecr-anywhere.centml.ai/managed: "true" + reinvocationPolicy: Never + rules: + - apiGroups: + - "" + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - secrets + scope: '*' + sideEffects: None + timeoutSeconds: 10 diff --git a/charts/ecr-anywhere/templates/service.yaml b/charts/ecr-anywhere/templates/service.yaml new file mode 100644 index 0000000..f3fceff --- /dev/null +++ b/charts/ecr-anywhere/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.name }} + namespace: {{ .Values.namespace }} + labels: + app: {{ .Values.name }} +spec: + ports: + - port: 443 + targetPort: 8443 + selector: + app: {{ .Values.name }} diff --git a/charts/ecr-anywhere/templates/serviceaccount.yaml b/charts/ecr-anywhere/templates/serviceaccount.yaml new file mode 100644 index 0000000..becde84 --- /dev/null +++ b/charts/ecr-anywhere/templates/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccountName }} + namespace: {{ .Values.namespace }} + labels: + app: {{ .Values.name }} diff --git a/charts/ecr-anywhere/templates/tests/test-injection.yaml b/charts/ecr-anywhere/templates/tests/test-injection.yaml new file mode 100644 index 0000000..e69de29 diff --git a/charts/ecr-anywhere/values.yaml b/charts/ecr-anywhere/values.yaml new file mode 100644 index 0000000..90b1712 --- /dev/null +++ b/charts/ecr-anywhere/values.yaml @@ -0,0 +1,37 @@ +name: ecr-anywhere + +namespace: ecr-anywhere + +serviceName: ecr-anywhere + +serviceAccountName: ecr-anywhere + +tlsSecretName: ecr-anywhere-tls + +deployment: + annotations: {} + replicas: 1 + pod: + annotations: {} + container: + env: {} + +mutatingWebhookConfiguration: + annotations: {} + +image: + # TODO Temporary personal repo + repository: ghcr.io/centml/ecr-anywhere + tag: v1.0.0 + imagePullPolicy: Always + +cronjob: + # ECR Credentials expire every 12 hours. The cron job runs every hour + # by default and refreshes them if they're going to expire in in the + # next 6 hours. This is to ensure that the credentials are always + # up-to-date, and give some time to respond if something goes wrong. + schedule: "20 * * * *" + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + restartPolicy: OnFailure + backoffLimit: 1 diff --git a/cmd/refresher/main.go b/cmd/refresher/main.go new file mode 100644 index 0000000..3bce1d9 --- /dev/null +++ b/cmd/refresher/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "log" + "os" + + "github.com/centml/platform/ecr-anywhere/pkg/credentials" + "github.com/centml/platform/ecr-anywhere/pkg/loggers" + "github.com/spf13/viper" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func init() { + viper.AutomaticEnv() + + // TODO this can be better + // CENTML_FORCE + viper.SetEnvPrefix("CENTML") + viper.SetDefault("FORCE", false) +} + +func getRestConfig() *rest.Config { + var kcfg *rest.Config + var err error + if viper.GetString("KUBECONFIG") == "" { + // TODO loggers + kcfg, err = rest.InClusterConfig() + if err != nil { + log.Fatalf("Failed to load in-cluster kubeconfig: %v", err) + } + } else { + kcfg, err = clientcmd.BuildConfigFromFlags("", viper.GetString("KUBECONFIG")) + if err != nil { + log.Fatalf("Failed to load kubeconfig: %v", err) + } + } + return kcfg + +} + +func main() { + + kcfg := getRestConfig() + clientset, err := kubernetes.NewForConfig(kcfg) + if err != nil { + log.Fatalf("Failed to create kubernetes clientset: %v", err) + } + + loggers := loggers.NewLoggers(log.New(os.Stderr, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile), nil, log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)) + r := credentials.NewK8sCredentialRefreshRequester(clientset, loggers) + + // TODO stop hardcoding + err = r.RequestRefreshes(true) + if err != nil { + log.Fatalf("Failed to request credential refreshes: %v", err) + } +} diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go new file mode 100644 index 0000000..583a2fc --- /dev/null +++ b/cmd/webhook/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/centml/platform/ecr-anywhere/pkg/credentials" + "github.com/centml/platform/ecr-anywhere/pkg/loggers" + "github.com/centml/platform/ecr-anywhere/pkg/webhook" + "github.com/spf13/viper" +) + +var ( + infoLogger *log.Logger + errorLogger *log.Logger + warnLogger *log.Logger +) + +func init() { + // init loggers + infoLogger = log.New(os.Stderr, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + warnLogger = log.New(os.Stderr, "WARN: ", log.Ldate|log.Ltime|log.Lshortfile) + errorLogger = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) + + viper.AutomaticEnv() + viper.SetDefault("PORT", 8443) + viper.SetDefault("CERT_FILE", "/etc/webhook/certs/tls.crt") + viper.SetDefault("KEY_FILE", "/etc/webhook/certs/tls.key") + +} + +func main() { + + awsCfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + log.Fatal(err) + } + ecrc := ecr.NewFromConfig(awsCfg) + + lgz := loggers.NewLoggers(infoLogger, warnLogger, errorLogger) + + cfg := &webhook.WebhookServerConfig{ + Port: viper.GetInt("PORT"), + CertPEM: viper.GetString("CERT_FILE"), + KeyPEM: viper.GetString("KEY_FILE"), + CredentialInjector: credentials.NewECRCredentialInjector(ecrc, lgz), + Loggers: lgz, + } + whsvr := webhook.NewCredentialWebhookServer(cfg) + + // start webhook server in new go rountine + go func() { + if err := whsvr.Start(); err != nil { + errorLogger.Fatalf("Failed to start webhook server: %v", err) + } + }() + + // listening OS shutdown singal + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + <-signalChan + + infoLogger.Printf("Got OS shutdown signal, shutting down webhook server gracefully...") + whsvr.Stop() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..426ea95 --- /dev/null +++ b/go.mod @@ -0,0 +1,96 @@ +module github.com/centml/platform/ecr-anywhere + +go 1.22.0 + +toolchain go1.22.4 + +require ( + k8s.io/api v0.30.2 + k8s.io/apimachinery v0.30.2 + sigs.k8s.io/yaml v1.3.0 // indirect +) + +require ( + github.com/aws/aws-sdk-go-v2/config v1.27.15 + github.com/aws/aws-sdk-go-v2/service/ecr v1.28.5 + github.com/aws/aws-sdk-go-v2/service/iam v1.32.3 + github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.15 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.9 // indirect + github.com/aws/smithy-go v1.20.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // 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 + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.15.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.22.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/client-go v0.30.2 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c21bcbe --- /dev/null +++ b/go.sum @@ -0,0 +1,370 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= +github.com/aws/aws-sdk-go-v2 v1.27.2/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.15 h1:uNnGLZ+DutuNEkuPh6fwqK7LpEiPmzb7MIMA1mNWEUc= +github.com/aws/aws-sdk-go-v2/config v1.27.15/go.mod h1:7j7Kxx9/7kTmL7z4LlhwQe63MYEE5vkVV6nWg4ZAI8M= +github.com/aws/aws-sdk-go-v2/credentials v1.17.15 h1:YDexlvDRCA8ems2T5IP1xkMtOZ1uLJOCJdTr0igs5zo= +github.com/aws/aws-sdk-go-v2/credentials v1.17.15/go.mod h1:vxHggqW6hFNaeNC0WyXS3VdyjcV0a4KMUY4dKJ96buU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3 h1:dQLK4TjtnlRGb0czOht2CevZ5l6RSyRWAnKeGd7VAFE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3/go.mod h1:TL79f2P6+8Q7dTsILpiVST+AL9lkF6PPGI167Ny0Cjw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 h1:cy8ahBJuhtM8GTTSyOkfy6WVPV1IE+SS5/wfXUYuulw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9/go.mod h1:CZBXGLaJnEZI6EVNcPd7a6B5IC5cA/GkRWtu9fp3S6Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 h1:A4SYk07ef04+vxZToz9LWvAXl9LW0NClpPpMsi31cz0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9/go.mod h1:5jJcHuwDagxN+ErjQ3PU3ocf6Ylc/p9x+BLO/+X4iXw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/ecr v1.28.5 h1:dvvTFXpWSv9+8lTNPl1EPNZL6BCUV6MgVckEMvXaOgk= +github.com/aws/aws-sdk-go-v2/service/ecr v1.28.5/go.mod h1:Ogt6AOZ/sPBlJZpVFJgOK+jGGREuo8DMjNg+O/7gpjI= +github.com/aws/aws-sdk-go-v2/service/iam v1.32.3 h1:F42/2xfjHsC1qKXlDtHpajyNUplYPdn2f2yal6l3o5o= +github.com/aws/aws-sdk-go-v2/service/iam v1.32.3/go.mod h1:0xqsq1/HsAC7+OaRMFUHfFtM5wmuFeX4VlbpxNAc2qY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 h1:Wx0rlZoEJR7JwlSZcHnEa7CNjrSIyVxMFWGAaXy4fJY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9/go.mod h1:aVMHdE0aHO3v+f/iw01fmXV/5DbfQ3Bi9nN7nd9bE9Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.8 h1:Kv1hwNG6jHC/sxMTe5saMjH6t6ZLkgfvVxyEjfWL1ks= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.8/go.mod h1:c1qtZUWtygI6ZdvKppzCSXsDOq5I4luJPZ0Ud3juFCA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.2 h1:nWBZ1xHCF+A7vv9sDzJOq4NWIdzFYm0kH7Pr4OjHYsQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.2/go.mod h1:9lmoVDVLz/yUZwLaQ676TK02fhCu4+PgRSmMaKR1ozk= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.9 h1:Qp6Boy0cGDloOE3zI6XhNLNZgjNS8YmiFQFHe71SaW0= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.9/go.mod h1:0Aqn1MnEuitqfsCNyKsdKLhDUOr4txD/g19EfiUqgws= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v1.2.0 h1:QK40JKJyMdUDz+h+xvCsru/bJhvG0UxvePV0ufL/AcE= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +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.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= +golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.19.15 h1:i22aQYrQ9gaBHEAS9XvyR5ZfrTDAd+Q+JwWM+xIBv30= +k8s.io/api v0.19.15/go.mod h1:rMRWjnIJQmurd/FdLobht6dCSbJQ+UDpyOwPaoFS7lI= +k8s.io/api v0.30.2 h1:+ZhRj+28QT4UOH+BKznu4CBgPWgkXO7XAvMcMl0qKvI= +k8s.io/api v0.30.2/go.mod h1:ULg5g9JvOev2dG0u2hig4Z7tQ2hHIuS+m8MNZ+X6EmI= +k8s.io/apimachinery v0.19.15 h1:P37ni6/yFxRMrqgM75k/vt5xq9vnNiR3rJPTmWXrNho= +k8s.io/apimachinery v0.19.15/go.mod h1:RMyblyny2ZcDQ/oVE+lC31u7XTHUaSXEK2IhgtwGxfc= +k8s.io/apimachinery v0.30.2 h1:fEMcnBj6qkzzPGSVsAZtQThU62SmQ4ZymlXRC5yFSCg= +k8s.io/apimachinery v0.30.2/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.2 h1:sBIVJdojUNPDU/jObC+18tXWcTJVcwyqS9diGdWHk50= +k8s.io/client-go v0.30.2/go.mod h1:JglKSWULm9xlJLx4KCkfLLQ7XwtlbflV6uFFSHTMgVs= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4= +k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 h1:jgGTlFYnhF1PM1Ax/lAlxUPE+KfCIXHaathvJg1C3ak= +k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/structured-merge-diff/v4 v4.1.2 h1:Hr/htKFmJEbtMgS/UD0N+gtgctAqz81t3nu+sPzynno= +sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/pkg/credentials/credentials.go b/pkg/credentials/credentials.go new file mode 100644 index 0000000..cdd8047 --- /dev/null +++ b/pkg/credentials/credentials.go @@ -0,0 +1,330 @@ +package credentials + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/centml/platform/ecr-anywhere/pkg/loggers" + "github.com/centml/platform/ecr-anywhere/pkg/patching" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + // These must match the mutating webhook configuration + managedLabelKey = "ecr-anywhere.centml.ai/managed" + managedLabelValue = "true" + namespaceEnabledLabelKey = "ecr-anywhere.centml.ai/namespace" + namespaceEnabledLabelValue = "enabled" +) + +const ( + expiresAtAnnotationKey = "ecr-anywhere.centml.ai/expires_at" + markForUpdateAnnotationKey = "ecr-anywhere.centml.ai/marked_for_update_at" + updatedAnnotationKey = "ecr-anywhere.centml.ai/updated_at" +) + +const ( + // ECR credentials expire after 12 hours, so we'll refresh + // them after 6 hours to be safe + expiryThreshold = 6 * time.Hour +) + +// DockerConfigJSON represents the structure of the Docker config JSON +type DockerConfigJSON struct { + Auths map[string]DockerAuth `json:"auths"` +} + +// DockerAuth contains the authentication information for a Docker registry +type DockerAuth struct { + Username string `json:"username"` + Password string `json:"password"` + Auth string `json:"auth"` +} + +// ECRClient is an interface for the ECR client, used for mocking +type ECRClient interface { + GetAuthorizationToken(context.Context, *ecr.GetAuthorizationTokenInput, ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) +} + +// CredentialInjector is an interface for injecting credentials +// into a secret. This is used for mocking. +type CredentialInjector interface { + Inject(secret *corev1.Secret) (patching.Operations, error) + InjectionPermitted(ignoredList []string, metadata *metav1.ObjectMeta) bool +} + +// ECRCredentialInjector is a struct that implements the CredentialInjector interface. +// It is used to inject ECR credentials into a secret. +type ecrCredentialInjector struct { + ecrClient ECRClient + *loggers.Loggers +} + +// NewECRCredentialInjector creates a new ECRCredentialInjector object with the specified ECR client and loggers. +func NewECRCredentialInjector(ecrClient ECRClient, loggers *loggers.Loggers) CredentialInjector { + return &ecrCredentialInjector{ + ecrClient: ecrClient, + Loggers: loggers, + } +} + +// InjectionPermitted determines whether a mutation is required for the specified pod and if so +// which mutation to use +func (ic *ecrCredentialInjector) InjectionPermitted(ignoredList []string, metadata *metav1.ObjectMeta) bool { + // skip special kubernete system namespaces + for _, namespace := range ignoredList { + if metadata.Namespace == namespace { + ic.InfoLogger.Printf("Skip mutation for %v for it's in special namespace:%v", metadata.Name, metadata.Namespace) + return false + } + } + + labels := metadata.Labels + if labels == nil { + labels = map[string]string{} + } + ic.InfoLogger.Printf("Labels: %v", labels) + + // Label should be configured in the mutating webhook configuration, but just in case + // we'll check here as well + if labels[managedLabelKey] != managedLabelValue { + ic.InfoLogger.Printf("Interception not permitted for %v/%v due to label %s != %s", + metadata.Namespace, metadata.Name, managedLabelKey, managedLabelValue) + return false + } + + ic.InfoLogger.Printf("Interception for %v/%v has been permitted", metadata.Namespace, metadata.Name) + return true +} + +// Inject injects ECR credentials into the specified secret +func (ecu *ecrCredentialInjector) Inject(secret *corev1.Secret) (patching.Operations, error) { + + // Call the API to get ECR credentials + res, err := ecu.ecrClient.GetAuthorizationToken(context.Background(), &ecr.GetAuthorizationTokenInput{}) + if err != nil { + return nil, fmt.Errorf("failed to get authorization token: %w", err) + } + ecu.InfoLogger.Print("Received authorization data") + + // Obtain token and expiration, not only the first AuthorizationData will be populated, + // since it can be used with any repo the underlying role has permissions for. Support + // was removed for specific repo authorization tokens, they kept the data structure + // the same. + token, exp := *res.AuthorizationData[0].AuthorizationToken, *res.AuthorizationData[0].ExpiresAt + decodedToken, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return nil, fmt.Errorf("failed to decode authorization token: %v", err) + } + + // Split the token into username and password + credentials := strings.SplitN(string(decodedToken), ":", 2) + if len(credentials) != 2 { + return nil, fmt.Errorf("invalid authorization token format") + } + username, password := credentials[0], credentials[1] + + // Create the Docker auth structure + dockerAuth := DockerAuth{ + Username: username, + Password: password, + Auth: token, + } + + // Create the Docker config JSON + dockerConfig := DockerConfigJSON{ + Auths: map[string]DockerAuth{ + *res.AuthorizationData[0].ProxyEndpoint: dockerAuth, + }, + } + + // Marshal the Docker config JSON to a string + dcj, err := json.Marshal(dockerConfig) + if err != nil { + log.Fatalf("failed to marshal Docker config JSON: %v", err) + } + + // Base64 encode the Docker config JSON + dcjb64 := base64.StdEncoding.EncodeToString(dcj) + + var patches patching.Operations + expstr := exp.Format(time.RFC3339) + + // Annotations might not exist for new secrets + if secret.Annotations == nil { + // create empty annotations patch + patches.Add(&patching.Operation{ + Op: "add", + Path: "/metadata/annotations", + Value: map[string]string{}, + }) + // set the annotations to the empty map so don't need to nil check below + secret.Annotations = map[string]string{} + } + + // Add or replace the expiresAt annotation + if _, ok := secret.Annotations[expiresAtAnnotationKey]; !ok { + ecu.InfoLogger.Printf("Adding annotation %s = %s to secret %s/%s", expiresAtAnnotationKey, expstr, secret.Namespace, secret.Name) + patches.Add(&patching.Operation{ + Op: "add", + Path: "/metadata/annotations/" + patchFriendly(expiresAtAnnotationKey), + Value: expstr, + }) + } else { + ecu.InfoLogger.Printf("Replacing annotation %s in secret %s/%s", expiresAtAnnotationKey, secret.Namespace, secret.Name) + patches.Add(&patching.Operation{ + Op: "replace", + Path: "/metadata/annotations/" + patchFriendly(expiresAtAnnotationKey), + Value: exp.Format(time.RFC3339), + }) + } + + if _, ok := secret.Annotations[updatedAnnotationKey]; !ok { + ecu.InfoLogger.Printf("Adding annotation %s = %s to secret %s/%s", updatedAnnotationKey, time.Now().Format(time.RFC3339), secret.Namespace, secret.Name) + patches.Add(&patching.Operation{ + Op: "add", + Path: "/metadata/annotations/" + patchFriendly(updatedAnnotationKey), + Value: time.Now().Format(time.RFC3339), + }) + } else { + ecu.InfoLogger.Printf("Replacing annotation %s in secret %s/%s", updatedAnnotationKey, secret.Namespace, secret.Name) + patches.Add(&patching.Operation{ + Op: "replace", + Path: "/metadata/annotations/" + patchFriendly(updatedAnnotationKey), + Value: time.Now().Format(time.RFC3339), + }) + } + + // Create the patch operation for the secret + ecu.InfoLogger.Printf("Adding patch operation for secret %s/%s", secret.Namespace, secret.Name) + patches.Add(&patching.Operation{ + Op: "replace", + Path: "/data", + Value: map[string]string{ + ".dockerconfigjson": dcjb64, + }, + }) + return patches, nil +} + +// CredentialRefreshRequester is an interface for requesting credential refreshes. +// This is used for mocking. +type CredentialRefreshRequester interface { + RequestRefreshes(force bool) error +} + +// k8sCredentialRefreshRequester is a struct that implements the +// CredentialRefreshRequester interface. +type k8sCredentialRefreshRequester struct { + *loggers.Loggers + clientset kubernetes.Interface +} + +// NewK8sCredentialRefreshRequester creates a new CredentialRefreshRequester +// with the specified Kubernetes clientset and loggers. +func NewK8sCredentialRefreshRequester(clientset kubernetes.Interface, + loggers *loggers.Loggers) CredentialRefreshRequester { + return &k8sCredentialRefreshRequester{ + Loggers: loggers, + clientset: clientset, + } +} + +// RequestRefreshes requests credential refreshes for all secrets that need to +// be updated. It does this by checking the expiration time of each secret and +// then marking it with an annotation if it needs to be updated. The actual +// update is done by the mutating webhook. +func (kcr *k8sCredentialRefreshRequester) RequestRefreshes(force bool) error { + + kcr.InfoLogger.Printf("Looking for credentials to refresh, force=%v\n", force) + + // find labeled namespaces + namespaces, err := kcr.clientset.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", namespaceEnabledLabelKey, namespaceEnabledLabelValue), + }) + if err != nil { + kcr.ErrorLogger.Printf("error listing namespaces: %s\n", err.Error()) + return err + } + + kcr.InfoLogger.Printf("Found %d namespaces with label %s=%s\n", len(namespaces.Items), namespaceEnabledLabelKey, namespaceEnabledLabelValue) + + // if there are any issues below they will be logged and success will be marked false + // but we will continue to process the remaining secrets + success := true + + for _, namespace := range namespaces.Items { + // get all secrets that have the managed label + secrets, err := kcr.clientset.CoreV1().Secrets(namespace.Name).List(context.TODO(), metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", managedLabelKey, managedLabelValue), + }) + if err != nil { + // TODO Maybe just fatal? + kcr.ErrorLogger.Printf("error listing secrets in namespace %s\n", err.Error()) + return err + } + + kcr.InfoLogger.Printf("Found %d secrets with label %s=%s\n", len(secrets.Items), managedLabelKey, managedLabelValue) + + for _, secret := range secrets.Items { + if secret.Annotations == nil { + secret.Annotations = map[string]string{} + } + + if expires, ok := secret.Annotations[expiresAtAnnotationKey]; !ok { + kcr.WarnLogger.Printf("Managed secret %s/%s does not have an expiration annotation\n", secret.Namespace, secret.Name) + setRequestAnnotation(kcr.clientset, &secret) + } else { + + // expires at + expat, err := time.Parse(time.RFC3339, expires) + if err != nil { + kcr.ErrorLogger.Printf("error parsing expiration time for secret %s/%s: %s\n", secret.Namespace, secret.Name, err.Error()) + success = false + continue + } + + // if the secret will expire within the expiryThreshold, update it + if time.Now().After(expat.Add(-1*expiryThreshold)) || force { + kcr.InfoLogger.Printf("Updating secret %s/%s\n", secret.Namespace, secret.Name) + setRequestAnnotation(kcr.clientset, &secret) + } else { + kcr.InfoLogger.Printf("Secret %s/%s is not due for update, it expires at %s\n", secret.Namespace, secret.Name, expat.Format(time.RFC3339)) + } + } + } + } + if !success { + return fmt.Errorf("some secrets failed to update, see logs for details") + } + + return nil +} + +// setRequestAnnotation sets the annotation on the secret to indicate that +// it should be updated +func setRequestAnnotation(clientset kubernetes.Interface, secret *corev1.Secret) { + if secret.Annotations == nil { + secret.Annotations = map[string]string{} + } + + // this sets the annotation to the current time indicating that the secret should be updated + secret.Annotations[markForUpdateAnnotationKey] = time.Now().Format(time.RFC3339) + _, err := clientset.CoreV1().Secrets(secret.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{}) + if err != nil { + fmt.Printf("error updating secret %s/%s: %s\n", secret.Namespace, secret.Name, err.Error()) + } +} + +// patchFriendly replaces any / with ~1 in a string for use in a JSON patch +func patchFriendly(str string) string { + return strings.ReplaceAll(str, "/", "~1") +} diff --git a/pkg/credentials/credentials_test.go b/pkg/credentials/credentials_test.go new file mode 100644 index 0000000..cfd315c --- /dev/null +++ b/pkg/credentials/credentials_test.go @@ -0,0 +1,455 @@ +package credentials + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "log" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/centml/platform/ecr-anywhere/pkg/loggers" + "github.com/centml/platform/ecr-anywhere/pkg/patching" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +// MockECRClient is a mock implementation of the ECRClient interface for testing +type MockECRClient struct { + mock.Mock +} + +// GetAuthorizationToken provides a mock function with given fields: ctx, input, opts +func (m *MockECRClient) GetAuthorizationToken(ctx context.Context, input *ecr.GetAuthorizationTokenInput, opts ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + args := m.Called(ctx, input, opts) + + if args.Get(0) != nil { + return args.Get(0).(*ecr.GetAuthorizationTokenOutput), args.Error(1) + } else { + return nil, args.Error(1) + } +} + +// TestInjectionPermitted tests the InjectionPermitted method of ecrCredentialInjector +func TestInjectionPermitted(t *testing.T) { + + mockECRClient := new(MockECRClient) + dl := log.New(io.Discard, "", 0) + injector := NewECRCredentialInjector(mockECRClient, loggers.NewLoggers(dl, dl, dl)) + + tests := []struct { + name string + ignoredList []string + metadata *metav1.ObjectMeta + expected bool + }{ + { + name: "Injection not permitted for ignored namespace", + ignoredList: []string{"kube-system", "default"}, + metadata: &metav1.ObjectMeta{ + Namespace: "kube-system", + Name: "test-pod", + Labels: map[string]string{managedLabelKey: managedLabelValue}, + }, + expected: false, + }, + { + name: "Injection not permitted for missing managed label", + ignoredList: []string{}, + metadata: &metav1.ObjectMeta{ + Namespace: "default", + Name: "test-pod", + Labels: map[string]string{"some-label": "some-value"}, + }, + expected: false, + }, + { + name: "Injection permitted", + ignoredList: []string{"kube-system"}, + metadata: &metav1.ObjectMeta{ + Namespace: "default", + Name: "test-pod", + Labels: map[string]string{managedLabelKey: managedLabelValue}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := injector.InjectionPermitted(tt.ignoredList, tt.metadata) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestInject tests the Inject method of ecrCredentialInjector +func TestInject(t *testing.T) { + mockECRClient := new(MockECRClient) + + dl := log.New(io.Discard, "", 0) + injector := NewECRCredentialInjector(mockECRClient, loggers.NewLoggers(dl, dl, dl)) + + authorizationToken := base64.StdEncoding.EncodeToString([]byte("username:password")) + badAuthorizationToken := "&" + proxyEndpoint := "https://proxy.example.com" + expirationTime := time.Now().Add(1 * time.Hour) + + anyti := mock.AnythingOfType("*ecr.GetAuthorizationTokenInput") + + tests := []struct { + name string + setupMock func() + secret *corev1.Secret + expectedError string + expectedPatches patching.Operations + }{ + { + name: "GetAuthorizationToken fails", + setupMock: func() { + mockECRClient.On("GetAuthorizationToken", mock.Anything, anyti, mock.Anything).Return(nil, fmt.Errorf("failed to get token")).Once() + }, + secret: &corev1.Secret{}, + expectedError: "failed to get authorization token: failed to get token", + }, + { + name: "Authorization token decoding fails", + setupMock: func() { + + mockECRClient.On("GetAuthorizationToken", mock.Anything, anyti, mock.Anything).Return(&ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []types.AuthorizationData{ + { + AuthorizationToken: &badAuthorizationToken, + ExpiresAt: &expirationTime, + ProxyEndpoint: &proxyEndpoint, + }, + }, + }, nil).Once() + }, + secret: &corev1.Secret{}, + expectedError: "failed to decode authorization token: illegal base64 data at input byte 0", + }, + { + name: "Authorization token format is invalid", + setupMock: func() { + token := base64.StdEncoding.EncodeToString([]byte("invalidtoken")) + mockECRClient.On("GetAuthorizationToken", mock.Anything, anyti, mock.Anything).Return(&ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []types.AuthorizationData{ + { + AuthorizationToken: &token, + ExpiresAt: &expirationTime, + ProxyEndpoint: &proxyEndpoint, + }, + }, + }, nil).Once() + }, + secret: &corev1.Secret{}, + expectedError: "invalid authorization token format", + }, + { + name: "Successful injection - add expiresAt and updated annotations", + setupMock: func() { + mockECRClient.On("GetAuthorizationToken", mock.Anything, anyti, mock.Anything).Return(&ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []types.AuthorizationData{ + { + AuthorizationToken: &authorizationToken, + ExpiresAt: &expirationTime, + ProxyEndpoint: &proxyEndpoint, + }, + }, + }, nil).Once() + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-secret", + }, + }, + expectedError: "", + expectedPatches: patching.Operations{ + &patching.Operation{ + Op: "add", + Path: "/metadata/annotations", + Value: map[string]string{}, + }, + &patching.Operation{ + Op: "add", + Path: "/metadata/annotations/" + patchFriendly(expiresAtAnnotationKey), + Value: expirationTime.Format(time.RFC3339), + }, + &patching.Operation{ + Op: "add", + Path: "/metadata/annotations/" + patchFriendly(updatedAnnotationKey), + Value: time.Now().Format(time.RFC3339), + }, + &patching.Operation{ + Op: "replace", + Path: "/data", + Value: map[string]string{ + ".dockerconfigjson": base64.StdEncoding.EncodeToString( + []byte(fmt.Sprintf( + `{"auths":{"https://proxy.example.com":{"username":"username","password":"password","auth":"%s"}}}`, + authorizationToken, + ))), + }, + }, + }, + }, + { + name: "Successful injection - replace expiresAt and updated annotations", + setupMock: func() { + mockECRClient.On("GetAuthorizationToken", mock.Anything, anyti, mock.Anything).Return(&ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []types.AuthorizationData{ + { + AuthorizationToken: &authorizationToken, + ExpiresAt: &expirationTime, + ProxyEndpoint: &proxyEndpoint, + }, + }, + }, nil).Once() + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "test-secret", + Annotations: map[string]string{ + expiresAtAnnotationKey: expirationTime.Add(-12 * time.Hour).Format(time.RFC3339), + updatedAnnotationKey: time.Now().Add(-12 * time.Hour).Format(time.RFC3339), + }, + }, + }, + expectedError: "", + expectedPatches: patching.Operations{ + &patching.Operation{ + Op: "replace", + Path: "/metadata/annotations/" + patchFriendly(expiresAtAnnotationKey), + Value: expirationTime.Format(time.RFC3339), + }, + &patching.Operation{ + Op: "replace", + Path: "/metadata/annotations/" + patchFriendly(updatedAnnotationKey), + Value: time.Now().Format(time.RFC3339), + }, + &patching.Operation{ + Op: "replace", + Path: "/data", + Value: map[string]string{ + ".dockerconfigjson": base64.StdEncoding.EncodeToString( + []byte(fmt.Sprintf( + `{"auths":{"https://proxy.example.com":{"username":"username","password":"password","auth":"%s"}}}`, + authorizationToken, + ))), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupMock() + + patches, err := injector.Inject(tt.secret) + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedPatches, patches) + } + + mockECRClient.AssertExpectations(t) + }) + } +} + +// TestRequestRefreshes tests the RequestRefreshes method of k8sCredentialRefreshRequester +func TestRequestRefreshes(t *testing.T) { + tests := []struct { + name string + namespaces []corev1.Namespace + secrets map[string][]corev1.Secret + force bool + expectedUpdates int + expectedError bool + }{ + { + name: "No namespaces", + namespaces: []corev1.Namespace{}, + secrets: map[string][]corev1.Secret{}, + force: false, + expectedUpdates: 0, + expectedError: false, + }, + { + name: "Namespace without secrets", + namespaces: []corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "ns1", Labels: map[string]string{namespaceEnabledLabelKey: namespaceEnabledLabelValue}}}, + }, + secrets: map[string][]corev1.Secret{}, + force: false, + expectedUpdates: 0, + expectedError: false, + }, + { + name: "Secrets without expiration annotations", + namespaces: []corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "ns1", Labels: map[string]string{namespaceEnabledLabelKey: namespaceEnabledLabelValue}}}, + }, + secrets: map[string][]corev1.Secret{ + "ns1": { + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Labels: map[string]string{ + managedLabelKey: managedLabelValue, + }, + }, + }, + }, + }, + force: false, + expectedUpdates: 1, + expectedError: false, + }, + { + name: "Secrets with invalid expiration annotations", + namespaces: []corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{ + namespaceEnabledLabelKey: namespaceEnabledLabelValue, + }, + }, + }, + }, + secrets: map[string][]corev1.Secret{ + "ns1": { + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Labels: map[string]string{ + managedLabelKey: managedLabelValue, + }, + Annotations: map[string]string{ + expiresAtAnnotationKey: "invalid", + }, + }, + }, + }, + }, + force: false, + expectedUpdates: 0, + expectedError: true, + }, + { + name: "Secrets due for refresh", + namespaces: []corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", + Labels: map[string]string{ + namespaceEnabledLabelKey: namespaceEnabledLabelValue, + }, + }, + }, + }, + secrets: map[string][]corev1.Secret{ + "ns1": { + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Labels: map[string]string{ + managedLabelKey: managedLabelValue, + }, + Annotations: map[string]string{ + expiresAtAnnotationKey: time.Now().Add(5 * time.Hour).Format(time.RFC3339), + }, + }, + }, + }, + }, + force: false, + expectedUpdates: 1, + expectedError: false, + }, + { + name: "Secrets not due for refresh", + namespaces: []corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{ + Name: "ns1", Labels: map[string]string{namespaceEnabledLabelKey: namespaceEnabledLabelValue}}}, + }, + secrets: map[string][]corev1.Secret{ + "ns1": { + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Labels: map[string]string{ + managedLabelKey: managedLabelValue, + }, + Annotations: map[string]string{ + expiresAtAnnotationKey: time.Now().Add(7 * time.Hour).Format(time.RFC3339), + }, + }, + }, + }, + }, + force: false, + expectedUpdates: 0, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientset := fake.NewSimpleClientset() + + // Create namespaces and secrets in the fake clientset + for _, ns := range tt.namespaces { + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &ns, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("error creating namespace: %v", err) + } + for _, secret := range tt.secrets[ns.Name] { + _, err := clientset.CoreV1().Secrets(ns.Name).Create(context.TODO(), &secret, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("error creating secret: %v", err) + } + } + } + + l := log.New(io.Discard, "", 0) + kcr := NewK8sCredentialRefreshRequester(clientset, loggers.NewLoggers(l, l, l)) + + err := kcr.RequestRefreshes(tt.force) + if (err != nil) != tt.expectedError { + t.Errorf("RequestRefreshes() error = %v, expectedError %v", err, tt.expectedError) + } + + // Verify that the correct number of secrets were updated + updatedCount := 0 + for _, ns := range tt.namespaces { + secrets, err := clientset.CoreV1().Secrets(ns.Name).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("error listing secrets: %v", err) + } + + for _, secret := range secrets.Items { + if _, ok := secret.Annotations[markForUpdateAnnotationKey]; ok { + updatedCount++ + } + } + } + + if updatedCount != tt.expectedUpdates { + t.Errorf("expected %d secrets to be updated, but got %d", tt.expectedUpdates, updatedCount) + } + }) + } +} diff --git a/pkg/loggers/loggers.go b/pkg/loggers/loggers.go new file mode 100644 index 0000000..22deb59 --- /dev/null +++ b/pkg/loggers/loggers.go @@ -0,0 +1,20 @@ +package loggers + +import "log" + +// loggers contains the loggers for info and error messages. +type Loggers struct { + InfoLogger *log.Logger + WarnLogger *log.Logger + ErrorLogger *log.Logger +} + +// NewLoggers creates a new Loggers object with the specified loggers for +// info, warn and error messages. +func NewLoggers(infoLogger, warnLogger, errorLogger *log.Logger) *Loggers { + return &Loggers{ + InfoLogger: infoLogger, + WarnLogger: warnLogger, + ErrorLogger: errorLogger, + } +} diff --git a/pkg/patching/main.go b/pkg/patching/main.go new file mode 100644 index 0000000..8df35da --- /dev/null +++ b/pkg/patching/main.go @@ -0,0 +1,13 @@ +package patching + +type Operation struct { + Op string `json:"op"` + Path string `json:"path"` + Value interface{} `json:"value,omitempty"` +} + +type Operations []*Operation + +func (o *Operations) Add(op *Operation) { + *o = append(*o, op) +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go new file mode 100644 index 0000000..bcf392f --- /dev/null +++ b/pkg/webhook/webhook.go @@ -0,0 +1,218 @@ +package webhook + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/centml/platform/ecr-anywhere/pkg/credentials" + "github.com/centml/platform/ecr-anywhere/pkg/loggers" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +var ( + runtimeScheme = runtime.NewScheme() + codecs = serializer.NewCodecFactory(runtimeScheme) + deserializer = codecs.UniversalDeserializer() + webhookInjectPath = "/sync" +) + +// ignoredNamespaces is a list of namespaces to ignore when processing secrets. +var ignoredNamespaces = []string{ + // kube-system and kube-public are ignored by default + // Note, the webhook also has a namespace label selector and an object label selector + metav1.NamespaceSystem, + metav1.NamespacePublic, +} + +// WebhookServer contains the configuration for the webhook server. It's used as a receiver for various +// methods such as Start and Stop. +type WebhookServer struct { + *loggers.Loggers + credentials.CredentialInjector + server *http.Server + certPEM, keyPEM string +} + +// WebhookServerConfig is the configuration for the webhook server. It contains the port to listen on, +// the path to the certificate and key files, the MultiConfig object containing the sidecar configurations, +// and the loggers for info, warning, and error messages. +type WebhookServerConfig struct { + Port int + CertPEM string + KeyPEM string + Loggers *loggers.Loggers + CredentialInjector credentials.CredentialInjector +} + +// NewCredentialWebhookServer creates a new WebhookServer object with the specified configuration. +func NewCredentialWebhookServer(cfg *WebhookServerConfig) *WebhookServer { + + whsvr := &WebhookServer{ + server: &http.Server{ + Addr: fmt.Sprintf(":%v", cfg.Port), + TLSConfig: &tls.Config{ + // each request we retrieve the certs incase they have been rotated + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(cfg.CertPEM, cfg.KeyPEM) + if err != nil { + return nil, err + } + return &cert, nil + }, + }, + }, + Loggers: cfg.Loggers, + CredentialInjector: cfg.CredentialInjector, + } + + // define http server and server handler + mux := http.NewServeMux() + mux.HandleFunc(webhookInjectPath, whsvr.Serve) + whsvr.server.Handler = mux + + return whsvr +} + +// process handles the admission request and returns the admission response. +func (whs *WebhookServer) process(ar *admissionv1.AdmissionReview) *admissionv1.AdmissionResponse { + req := ar.Request + var secret corev1.Secret + if err := json.Unmarshal(req.Object.Raw, &secret); err != nil { + whs.ErrorLogger.Printf("Could not unmarshal raw object: %v", err) + return &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } + + whs.InfoLogger.Printf("AdmissionReview for Kind=%v, Namespace=%v Name=%v (%v) UID=%v patchOperation=%v UserInfo=%v", + req.Kind, req.Namespace, req.Name, secret.Name, req.UID, req.Operation, req.UserInfo) + + // determine whether to intercept secret or ignore it + icept := whs.InjectionPermitted(ignoredNamespaces, &secret.ObjectMeta) + if !icept { + // ignore + whs.InfoLogger.Printf("Not intercepting %s/%s", secret.Namespace, secret.Name) + return &admissionv1.AdmissionResponse{ + Allowed: true, + } + } + + // inject the credentials OR error out, thus secret creation/updates fail + // if there is an error + patches, err := whs.Inject(&secret) + if err != nil { + whs.ErrorLogger.Printf("Could not process the secret: %s", err.Error()) + return &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } + + pjb, err := json.Marshal(patches) + if err != nil { + whs.ErrorLogger.Printf("Could not marshal patches: %s", err.Error()) + return &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } + + whs.InfoLogger.Printf("populating ECR image pull secret for %s/%s", secret.Namespace, secret.Name) + pt := admissionv1.PatchTypeJSONPatch + return &admissionv1.AdmissionResponse{ + Allowed: true, + Patch: pjb, + PatchType: &pt, + } +} + +// Serve method for webhook `server` +func (whs *WebhookServer) Serve(w http.ResponseWriter, r *http.Request) { + var body []byte + if r.Body != nil { + if data, err := io.ReadAll(r.Body); err == nil { + body = data + } + } + if len(body) == 0 { + whs.ErrorLogger.Println("empty body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + // verify the content type is accurate + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + whs.ErrorLogger.Printf("Content-Type=%s, expect application/json", contentType) + http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType) + return + } + + // decode the admission request + var admissionResponse *admissionv1.AdmissionResponse + ar := admissionv1.AdmissionReview{} + if _, _, err := deserializer.Decode(body, nil, &ar); err != nil { + whs.ErrorLogger.Printf("Can't decode body: %v", err) + admissionResponse = &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } else { + // process the secret sent in, active if appropriatea + admissionResponse = whs.process(&ar) + } + + // encode the admission response + admissionReview := admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + } + + // set the response + if admissionResponse != nil { + admissionReview.Response = admissionResponse + if ar.Request != nil { + admissionReview.Response.UID = ar.Request.UID + } + } + + // encode the response + resp, err := json.Marshal(admissionReview) + if err != nil { + whs.ErrorLogger.Printf("Can't encode response: %v", err) + http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError) + } + + // write the response + whs.InfoLogger.Printf("Ready to write reponse ...") + if _, err := w.Write(resp); err != nil { + whs.ErrorLogger.Printf("Can't write response: %v", err) + http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError) + } +} + +// Start method for webhook server. It blocks until the server is stopped. +func (whs *WebhookServer) Start() error { + whs.InfoLogger.Printf("Starting webhook server...\n") + return whs.server.ListenAndServeTLS(whs.certPEM, whs.keyPEM) +} + +// Stop method for webhook server. It stops the server gracefully. +func (whs *WebhookServer) Stop() { + whs.server.Shutdown(context.Background()) +} diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go new file mode 100644 index 0000000..b3fdad7 --- /dev/null +++ b/pkg/webhook/webhook_test.go @@ -0,0 +1,166 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "io" + "log" + "testing" + + "github.com/centml/platform/ecr-anywhere/pkg/loggers" + "github.com/centml/platform/ecr-anywhere/pkg/patching" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// MockCredentialInjector is a mock implementation of the CredentialInjector interface +type MockCredentialInjector struct { + mock.Mock +} + +// Inject is a mock implementation of the Inject method +func (m *MockCredentialInjector) Inject(secret *corev1.Secret) (patching.Operations, error) { + args := m.Called(secret) + if args.Get(0) != nil { + return args.Get(0).(patching.Operations), args.Error(1) + } + return nil, args.Error(1) +} + +// InjectionPermitted is a mock implementation of the InjectionPermitted method +func (m *MockCredentialInjector) InjectionPermitted(ignoredList []string, metadata *metav1.ObjectMeta) bool { + args := m.Called(ignoredList, metadata) + return args.Bool(0) +} + +// TestWebhookServer_process tests the process method +func TestWebhookServer_process(t *testing.T) { + + mockci := &MockCredentialInjector{} + + whsvr := &WebhookServer{ + Loggers: loggers.NewLoggers(log.New(io.Discard, "", 0), log.New(io.Discard, "", 0), log.New(io.Discard, "", 0)), + CredentialInjector: mockci, + } + + tests := []struct { + name string + admissionReview *admissionv1.AdmissionReview + secret corev1.Secret + setupMocks func() + expectedResponse *admissionv1.AdmissionResponse + }{ + { + name: "unmarshal error", + admissionReview: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: []byte("invalid")}, + }, + }, + setupMocks: func() { + }, + expectedResponse: &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: "invalid character 'i' looking for beginning of value", + }, + }, + }, + { + name: "not intercepting secret", + admissionReview: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: func() []byte { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, + } + b, _ := json.Marshal(s) + return b + }()}, + }, + }, + secret: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, + }, + setupMocks: func() { + mockci.On("InjectionPermitted", ignoredNamespaces, mock.Anything).Return(false).Once() + }, + expectedResponse: &admissionv1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "credential injection error", + admissionReview: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: func() []byte { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, + } + b, _ := json.Marshal(s) + return b + }()}, + }, + }, + secret: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, + }, + setupMocks: func() { + mockci.On("InjectionPermitted", ignoredNamespaces, mock.Anything).Return(true).Once() + mockci.On("Inject", mock.Anything).Return(nil, fmt.Errorf("credential injection error")).Once() + }, + expectedResponse: &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: fmt.Errorf("credential injection error").Error(), + }, + }, + }, + { + name: "successful injection", + admissionReview: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: func() []byte { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, + } + b, _ := json.Marshal(s) + return b + }()}, + }, + }, + secret: corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "test-secret"}, + }, + setupMocks: func() { + mockci.On("InjectionPermitted", ignoredNamespaces, mock.Anything).Return(true).Once() + mockci.On("Inject", mock.Anything).Return(patching.Operations{ + &patching.Operation{ + Op: "add", + Path: "/data", + Value: "value", + }, + }, nil) + }, + expectedResponse: &admissionv1.AdmissionResponse{ + Allowed: true, + Patch: []byte(`[{"op":"add","path":"/data","value":"value"}]`), + PatchType: func() *admissionv1.PatchType { + pt := admissionv1.PatchTypeJSONPatch + return &pt + }(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupMocks() + response := whsvr.process(tt.admissionReview) + assert.Equal(t, tt.expectedResponse, response) + mockci.AssertExpectations(t) + }) + } +}