Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @external-secrets/maintainers
* @Workable/systems
119 changes: 76 additions & 43 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
name: CI
name: External Secrets Workable CI

on:
push:
tags:
- workable-*
pull_request:
branches:
- main
- release-*
pull_request: {}
- workable-*

env:
# Common versions
GOLANGCI_VERSION: 'v1.64.6'
KUBERNETES_VERSION: '1.31.x'

# Sonar
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

permissions:
contents: read

Expand Down Expand Up @@ -126,6 +124,7 @@ jobs:
make test

- name: Publish Unit Test Coverage
if: false
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
Expand All @@ -134,41 +133,75 @@ jobs:
file: ./cover.out

publish-artifacts:
needs: detect-noop
if: needs.detect-noop.outputs.noop != 'true'
uses: ./.github/workflows/publish.yml
needs: [lint, check-diff, unit-tests]
if: ${{ needs.detect-noop.outputs.noop != 'true' && startsWith(github.ref, 'refs/tags/workable-') }}
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 ppc64le"
build-platform: "linux/amd64,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 }}
username: ${{ github.actor }}
secrets:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IS_FORK: ${{ secrets.GHCR_USERNAME }} # this is just a secret to verify it is a fork or not, no other utility
id-token: write
contents: read
runs-on: ubuntu-latest
environment: Workable
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- 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@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.1
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:
REGISTRY: us-docker.pkg.dev/sre-artifacts-20e4/gcr.io
run: |
docker tag Workable/external-secrets:${{ steps.container-info.outputs.image-tag }} \
${{ env.REGISTRY }}/external-secrets:${{ steps.container-info.outputs.image-tag }}
docker push ${{ env.REGISTRY }}/external-secrets:${{ steps.container-info.outputs.image-tag }}

# DISTRIBUTION OF STAGING IMAGE
- name: Login to staging registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: us-docker.pkg.dev
username: _json_key
password: ${{ secrets.STAGING_GCR_SA }}

- name: Push image to staging registry
env:
REGISTRY: us-docker.pkg.dev/staging-artifacts-786a/gcr.io
run: |
docker tag Workable/external-secrets:${{ steps.container-info.outputs.image-tag }} \
${{ env.REGISTRY }}/external-secrets:${{ steps.container-info.outputs.image-tag }}
docker push ${{ env.REGISTRY }}/external-secrets:${{ steps.container-info.outputs.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
run: |
docker tag Workable/external-secrets:${{ steps.container-info.outputs.image-tag }} \
${{ env.REGISTRY }}/external-secrets:${{ steps.container-info.outputs.image-tag }}
docker push ${{ env.REGISTRY }}/external-secrets:${{ steps.container-info.outputs.image-tag }}
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ 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
@test -z "$$(git status --porcelain)" || (echo "$$(git status --porcelain)" && $(FAIL))
@$(OK) branch is clean

update-deps:
Expand Down Expand Up @@ -196,7 +195,7 @@ helm.schema.plugin:

helm.schema.update: helm.schema.plugin
@$(INFO) Generating values.schema.json
@helm schema -input $(HELM_DIR)/values.yaml -output $(HELM_DIR)/values.schema.json
@helm schema --values $(HELM_DIR)/values.yaml --output $(HELM_DIR)/values.schema.json
@$(OK) Generated values.schema.json

helm.generate:
Expand Down
6 changes: 6 additions & 0 deletions pkg/provider/vault/client_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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)
}

Expand Down
76 changes: 76 additions & 0 deletions pkg/provider/vault/symlink.go
Original file line number Diff line number Diff line change
@@ -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<Path>.*)#`
// Key can be any alphanumeric character and stops with the first @.
vaultSymlinkSecret = `(?P<Secret>\w+)`
// Version is optional and will match any number after @.
vaultSymlinkVersion = `(@(?P<Version>\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
}
81 changes: 81 additions & 0 deletions pkg/provider/vault/symlink_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading