diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ba295c411e1..c0455e60995 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,67 +1 @@ -# External Secrets CODEOWNERS -# -# Maps repository paths to GitHub teams for review. Syntax reference: -# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -# -# Order matters: the LAST matching pattern wins. The project-wide default -# therefore has to come first, with narrower paths after it. -# -# Teams need explicit write access to the repository, and a team that does not -# exist makes its line a no-op, so keep this file in step with org teams. - -# --- Project-wide default (must stay first) --- -* @external-secrets/maintainers @external-secrets/interim-maintainers - -# --- CI / Infrastructure --- -.github/ @external-secrets/ci-reviewers -hack/ @external-secrets/ci-reviewers @external-secrets/testing-reviewers - -# --- Testing --- -tests/ @external-secrets/testing-reviewers -e2e/ @external-secrets/testing-reviewers - -# --- Documentation --- -docs/ @external-secrets/docs-maintainers - -# --- Core Controllers --- -apis/ @external-secrets/core-reviewers -pkg/controllers/ @external-secrets/core-reviewers - -# --- Providers --- -providers/v1/ @external-secrets/providers-reviewers -providers/v1/akeyless/ @external-secrets/provider-akeyless-reviewers -providers/v1/aws/ @external-secrets/provider-aws-reviewers -providers/v1/azure/ @external-secrets/provider-azure-reviewers -providers/v1/barbican/ @external-secrets/provider-barbican-reviewers -providers/v1/beyondtrust/ @external-secrets/provider-beyondtrust-reviewers -providers/v1/bitwarden/ @external-secrets/provider-bitwarden-reviewers -providers/v1/chef/ @external-secrets/provider-chef-reviewers -providers/v1/cloudru/ @external-secrets/provider-cloudru-reviewers -providers/v1/conjur/ @external-secrets/provider-conjur-reviewers -providers/v1/delinea/ @external-secrets/provider-delinea-reviewers -providers/v1/doppler/ @external-secrets/provider-doppler-reviewers -providers/v1/fake/ @external-secrets/provider-fake-reviewers -providers/v1/fortanix/ @external-secrets/provider-fortanix-reviewers -providers/v1/gcp/ @external-secrets/provider-gcp-reviewers -providers/v1/github/ @external-secrets/provider-github-reviewers -providers/v1/gitlab/ @external-secrets/provider-gitlab-reviewers -providers/v1/ibm/ @external-secrets/provider-ibm-reviewers -providers/v1/infisical/ @external-secrets/provider-infisical-reviewers -providers/v1/keepersecurity/ @external-secrets/provider-keepersecurity-reviewers -providers/v1/kubernetes/ @external-secrets/provider-kubernetes-reviewers -providers/v1/onboardbase/ @external-secrets/provider-onboardbase-reviewers -providers/v1/onepassword/ @external-secrets/provider-onepassword-reviewers -providers/v1/onepasswordsdk/ @external-secrets/provider-onepasswordsdk-reviewers -providers/v1/openbao/ @external-secrets/provider-openbao-reviewers -providers/v1/oracle/ @external-secrets/provider-oracle-reviewers -providers/v1/passbolt/ @external-secrets/provider-passbolt-reviewers -providers/v1/passworddepot/ @external-secrets/provider-passworddepot-reviewers -providers/v1/previder/ @external-secrets/provider-previder-reviewers -providers/v1/pulumi/ @external-secrets/provider-pulumi-reviewers -providers/v1/scaleway/ @external-secrets/provider-scaleway-reviewers -providers/v1/secretserver/ @external-secrets/provider-secretserver-reviewers -providers/v1/senhasegura/ @external-secrets/provider-senhasegura-reviewers -providers/v1/vault/ @external-secrets/provider-vault-reviewers -providers/v1/webhook/ @external-secrets/provider-webhook-reviewers -providers/v1/yandex/ @external-secrets/provider-yandex-reviewers - +* @Workable/systems diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86716e5d5a4..e92d7e5bc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,12 @@ -name: CI +name: External Secrets Workable CI on: push: + tags: + - workable-* + pull_request: branches: - - main - pull_request: {} + - workable-* env: # Common versions @@ -34,9 +36,10 @@ jobs: do_not_skip: '["workflow_dispatch", "schedule", "push"]' concurrent_skipping: false - license-check: + lint: permissions: - contents: read + contents: read # for actions/checkout to fetch code + pull-requests: read # for golangci/golangci-lint-action to fetch pull requests runs-on: ubuntu-latest needs: detect-noop if: needs.detect-noop.outputs.noop != 'true' && github.ref != 'refs/heads/main' @@ -49,8 +52,24 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Check License Headers - uses: apache/skywalking-eyes/header@61275cc80d0798a405cb070f7d3a8aaf7cf2c2c1 # v0.8.0 + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + id: setup-go + with: + go-version-file: "go.mod" + cache: false + + - name: Download Go modules + if: ${{ steps.setup-go.outputs.cache-hit != 'true' }} + run: go mod download + + - name: Lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.12.2 # matches github.com/golangci/golangci-lint/v2 in hack/tools/go.mod + skip-pkg-cache: true + skip-build-cache: true check-diff: runs-on: ubuntu-latest @@ -127,47 +146,78 @@ jobs: run: | make test - - name: Publish Unit Test Coverage - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + publish-artifacts: + needs: [lint, check-diff, unit-tests] + if: ${{ needs.detect-noop.outputs.noop != 'true' && startsWith(github.ref, 'refs/tags/workable-') }} + permissions: + id-token: write + contents: read + runs-on: ubuntu-latest + environment: Workable + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Get image tag + id: container-info + run: | + echo "image-tag=${GITHUB_REF#refs/tags/workable-}" >> $GITHUB_OUTPUT + + - name: Build image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: Dockerfile.standalone + push: false + tags: Workable/external-secrets:${{ steps.container-info.outputs.image-tag }} + provenance: false + + # DISTRIBUTION OF SRE IMAGE + - name: Login to sre registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: us-docker.pkg.dev + username: _json_key + password: ${{ secrets.SRE_GCR_SA }} + + - name: Push image to sre registry env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + REGISTRY: us-docker.pkg.dev/sre-artifacts-20e4/gcr.io + IMAGE_TAG: ${{ steps.container-info.outputs.image-tag }} + run: | + docker tag "Workable/external-secrets:${IMAGE_TAG}" "${REGISTRY}/external-secrets:${IMAGE_TAG}" + docker push "${REGISTRY}/external-secrets:${IMAGE_TAG}" + + # DISTRIBUTION OF STAGING IMAGE + - name: Login to staging registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: - flags: unittests - file: ./cover.out + registry: us-docker.pkg.dev + username: _json_key + password: ${{ secrets.STAGING_GCR_SA }} - publish-artifacts: - needs: detect-noop - if: needs.detect-noop.outputs.noop != 'true' - uses: ./.github/workflows/publish.yml - permissions: - contents: read #actions/checkout - packages: write #for publishing artifacts - id-token: write #for keyless sign - strategy: - matrix: - include: - - dockerfile: "Dockerfile" - build-args: "CGO_ENABLED=0" - build-arch: "amd64 arm64 s390x ppc64le" - build-platform: "linux/amd64,linux/arm64,linux/s390x,linux/ppc64le" - tag-suffix: "" # distroless - - dockerfile: "Dockerfile.ubi" - build-args: "CGO_ENABLED=0" - build-arch: "amd64 arm64 ppc64le" - build-platform: "linux/amd64,linux/arm64,linux/ppc64le" - tag-suffix: "-ubi" - - dockerfile: "Dockerfile.ubi" - build-args: "CGO_ENABLED=0 GOEXPERIMENT=boringcrypto" - build-arch: "amd64 arm64 ppc64le" - build-platform: "linux/amd64,linux/arm64,linux/ppc64le" - tag-suffix: "-ubi-boringssl" - with: - dockerfile: ${{ matrix.dockerfile }} - tag-suffix: ${{ matrix.tag-suffix }} - image-name: ghcr.io/${{ github.repository }} - build-platform: ${{ matrix.build-platform }} - build-args: ${{ matrix.build-args }} - build-arch: ${{ matrix.build-arch }} - ref: ${{ github.ref }} - secrets: - IS_FORK: ${{ secrets.GHCR_USERNAME }} # this is just a secret to verify it is a fork or not, no other utility + - name: Push image to staging registry + env: + REGISTRY: us-docker.pkg.dev/staging-artifacts-786a/gcr.io + IMAGE_TAG: ${{ steps.container-info.outputs.image-tag }} + run: | + docker tag "Workable/external-secrets:${IMAGE_TAG}" "${REGISTRY}/external-secrets:${IMAGE_TAG}" + docker push "${REGISTRY}/external-secrets:${IMAGE_TAG}" + + # DISTRIBUTION OF PRODUCTION IMAGE + - name: Login to production registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: us-docker.pkg.dev + username: _json_key + password: ${{ secrets.PRODUCTION_GCR_SA }} + + - name: Push image to production registry + env: + REGISTRY: us-docker.pkg.dev/production-artifacts-0b0d/gcr.io + IMAGE_TAG: ${{ steps.container-info.outputs.image-tag }} + run: | + docker tag "Workable/external-secrets:${IMAGE_TAG}" "${REGISTRY}/external-secrets:${IMAGE_TAG}" + docker push "${REGISTRY}/external-secrets:${IMAGE_TAG}" diff --git a/Dockerfile.standalone b/Dockerfile.standalone index 34bf15ceba8..33b0efb337c 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -1,16 +1,19 @@ # This version of Dockerfile is for building without external dependencies. # Build a multi-platform image e.g. `docker buildx build --push --platform linux/arm64,linux/amd64 --tag external-secrets:dev --file Dockerfile.standalone .` +# Providers are gated behind Go build tags (see pkg/register/.go and AGENTS.md); pass +# --build-arg PROVIDER=[,...] to build a slimmer binary with only the providers you need. FROM golang:1.27.0-alpine@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder # Add metadata LABEL maintainer="cncf-externalsecretsop-maintainers@lists.cncf.io" \ description="External Secrets Operator is a Kubernetes operator that integrates external secret management systems" ARG TARGETOS ARG TARGETARCH +ARG PROVIDER=all_providers ENV CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} WORKDIR /app COPY . /app/ RUN go mod download -RUN go build -o external-secrets main.go +RUN go build -tags ${PROVIDER} -o external-secrets main.go FROM gcr.io/distroless/static@sha256:f2ea2709ac8db56323cbd7d014277f32cb572d9ea124b0076f7aafe5980678fe AS app COPY --from=builder /app/external-secrets /bin/external-secrets diff --git a/Makefile b/Makefile index a9c8ccefb9f..d78dff200e8 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ FAIL = (echo ${TIME} ${RED}[FAIL]${CNone} && false) # ==================================================================================== # Conformance -reviewable: generate docs manifests helm.generate helm.schema.update helm.docs lint license.check helm.test.update test.crds.update tf.fmt ## Ensure a PR is ready for review. +reviewable: generate docs manifests helm.generate helm.schema.update helm.docs lint helm.test.update test.crds.update tf.fmt ## Ensure a PR is ready for review. @GOWORK=off go -C hack/tools mod tidy # Tools and their deps are not to be included in project's GOWORK or in the project go.mod/sum. We consider them external. @GOWORK=off go -C hack/tools/gen-crd-api-reference-docs mod tidy @go mod tidy @@ -84,16 +84,11 @@ reviewable: generate docs manifests helm.generate helm.schema.update helm.docs l check-diff: reviewable ## Ensure branch is clean. @$(INFO) checking that branch is clean - @status="$$(git status --porcelain)" && test -z "$$status" || (printf '%s\n' "$$status" && $(FAIL)) @$(OK) branch is clean update-deps: ## Update dependencies across all modules (root, apis, runtime, e2e, providers, generators) @./hack/update-deps.sh -.PHONY: license.check -license.check: - $(DOCKER) run --rm -u $(shell id -u) -v $(shell pwd):/github/workspace apache/skywalking-eyes:0.6.0 header check - # ==================================================================================== # Golang diff --git a/providers/v1/vault/client_get.go b/providers/v1/vault/client_get.go index 8c39217e744..7d3a38321ef 100644 --- a/providers/v1/vault/client_get.go +++ b/providers/v1/vault/client_get.go @@ -74,6 +74,12 @@ func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemot } } + // Replace symlinks + data, err = c.resolveSymlink(ctx, data) + if err != nil { + return nil, err + } + return getSecretValue(data, ref.Property) } diff --git a/providers/v1/vault/symlink.go b/providers/v1/vault/symlink.go new file mode 100644 index 00000000000..85bb7d580b1 --- /dev/null +++ b/providers/v1/vault/symlink.go @@ -0,0 +1,76 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vault + +import ( + "context" + "regexp" + "strings" +) + +const ( + // Symlink must start with the prefix vault:// to be valid. + vaultSymlink = `vault://` + // Path can be anything and matches the last # as a separator of key. + vaultSymlinkPath = `(?P.*)#` + // Key can be any alphanumeric character and stops with the first @. + vaultSymlinkSecret = `(?P\w+)` + // Version is optional and will match any number after @. + vaultSymlinkVersion = `(@(?P\d+)?)?` + vaultSymlinkPattern = vaultSymlink + vaultSymlinkPath + vaultSymlinkSecret + vaultSymlinkVersion +) + +// isSymlink tests if secret can be converted to string and if it matches the symlink pattern. +func isSymlink(secret any) bool { + if s, ok := secret.(string); ok { + return strings.HasPrefix(s, vaultSymlink) + } + + return false +} + +// extractSymlinkParts extract capture group items of regex to a map. +func extractSymlinkParts(secret any) (paramsMap map[string]string) { + r := regexp.MustCompile(vaultSymlinkPattern) + match := r.FindStringSubmatch(secret.(string)) + paramsMap = make(map[string]string) + + for i, name := range r.SubexpNames() { + if i > 0 && i <= len(match) { + paramsMap[name] = match[i] + } + } + + return paramsMap +} + +// resolveSymlink test if the data passed has symlinks and resolve them. +func (c *client) resolveSymlink(ctx context.Context, data map[string]any) (map[string]any, error) { + for key, secret := range data { + for isSymlink(secret) { + symlink := extractSymlinkParts(secret) + + s, err := c.readSecret(ctx, symlink["Path"], symlink["Version"]) + if err != nil { + return nil, err + } + + secret = s[symlink["Secret"]] + data[key] = secret + } + } + + return data, nil +} diff --git a/providers/v1/vault/symlink_test.go b/providers/v1/vault/symlink_test.go new file mode 100644 index 00000000000..782204815af --- /dev/null +++ b/providers/v1/vault/symlink_test.go @@ -0,0 +1,81 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vault + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestIsSymlink(t *testing.T) { + cases := map[string]struct { + got string + want bool + }{ + "ShouldResolveSymlink": { + got: "vault://test", + want: true, + }, + "ShouldNotResolveSymlink": { + got: "test", + want: false, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if diff := cmp.Diff(tc.want, isSymlink(tc.got), EquateErrors()); diff != "" { + t.Errorf("\nvault.isSymlink(...): -want error, +got error:\n%s", diff) + } + }) + } +} + +func TestExtractSymlinkParts(t *testing.T) { + cases := map[string]struct { + pattern string + expectedPath string + expectedSecret string + expectedVersion string + }{ + "ShouldExtractPathAndSecret": { + pattern: "vault://test#KEY", + expectedPath: "test", + expectedSecret: "KEY", + expectedVersion: "", + }, + "ShouldExtractPathAndSecretAndVersion": { + pattern: "vault://test#KEY@21", + expectedPath: "test", + expectedSecret: "KEY", + expectedVersion: "21", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if diff := cmp.Diff(tc.expectedPath, extractSymlinkParts(tc.pattern)["Path"], EquateErrors()); diff != "" { + t.Errorf("\nvault.extractSymlinkParts(...): -want error, +got error:\n%s", diff) + } + if diff := cmp.Diff(tc.expectedSecret, extractSymlinkParts(tc.pattern)["Secret"], EquateErrors()); diff != "" { + t.Errorf("\nvault.extractSymlinkParts(...): -want error, +got error:\n%s", diff) + } + if diff := cmp.Diff(tc.expectedVersion, extractSymlinkParts(tc.pattern)["Version"], EquateErrors()); diff != "" { + t.Errorf("\nvault.extractSymlinkParts(...): -want error, +got error:\n%s", diff) + } + }) + } +}