diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be987501..d04f6a4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,25 @@ -name: ci +name: release on: push: branches: - "main" - create: tags: - "v*" + workflow_dispatch: pull_request: branches: - "main" - workflow_dispatch: jobs: docker: runs-on: ubuntu-latest + permissions: + contents: write + packages: write + env: + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/cloudflare-operator + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME != '' && format('{0}/cloudflare-operator', secrets.DOCKERHUB_USERNAME) || '' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -24,8 +29,8 @@ jobs: with: # list of Docker images to use as base name for tags images: | - adyanth/cloudflare-operator - ghcr.io/adyanth/cloudflare-operator + ${{ env.GHCR_IMAGE }} + ${{ env.DOCKERHUB_IMAGE }} # generate Docker tags based on the following events/attributes tags: | type=ref,event=branch @@ -40,13 +45,13 @@ jobs: uses: docker/setup-buildx-action@v1 - name: Login to DockerHub uses: docker/login-action@v1 - if: github.event.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + if: (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch') && env.DOCKERHUB_IMAGE != '' with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v1 - if: github.event.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -55,29 +60,51 @@ jobs: uses: docker/build-push-action@v2 with: context: . - platforms: ${{ github.event.ref_type == 'tag' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - push: ${{ github.event.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }} + platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + push: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Docker Hub Description - if: github.event.ref_type == 'tag' || github.event_name == 'workflow_dispatch' + if: (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch') && env.DOCKERHUB_IMAGE != '' uses: peter-evans/dockerhub-description@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: adyanth/cloudflare-operator + repository: ${{ env.DOCKERHUB_IMAGE }} readme-filepath: ./README.md short-description: "Cloudflare Operator Controller Manager" - name: Setup Go 1.24 uses: actions/setup-go@v5 - if: github.event.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/') with: go-version: 1.24 + - name: Resolve installer image base + if: startsWith(github.ref, 'refs/tags/') + run: | + if [[ -n "${DOCKERHUB_IMAGE}" ]]; then + echo "INSTALLER_IMAGE_TAG_BASE=${DOCKERHUB_IMAGE}" >> "${GITHUB_ENV}" + else + echo "INSTALLER_IMAGE_TAG_BASE=${GHCR_IMAGE}" >> "${GITHUB_ENV}" + fi - name: Build installer - if: github.event.ref_type == 'tag' - run: make build-installer - - name: Publish installer - if: github.event.ref_type == 'tag' + if: startsWith(github.ref, 'refs/tags/') + run: make build-installer VERSION="${GITHUB_REF_NAME#v}" IMAGE_TAG_BASE="${INSTALLER_IMAGE_TAG_BASE}" + - name: Create GitHub release + if: startsWith(github.ref, 'refs/tags/') + run: | + release_args=( + "${GITHUB_REF_NAME}" + --title "${GITHUB_REF_NAME}" + --generate-notes + ) + if [[ "${GITHUB_REF_NAME}" == *-* ]]; then + release_args+=(--prerelease) + fi + gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 || gh release create "${release_args[@]}" + env: + GH_TOKEN: ${{ github.token }} + - name: Publish installer assets + if: startsWith(github.ref, 'refs/tags/') run: gh release upload ${{ github.ref_name }} dist/*.yaml env: GH_TOKEN: ${{ github.token }} diff --git a/api/v1alpha1/tunnel_conversion_test.go b/api/v1alpha1/tunnel_conversion_test.go index 0a28dc92..ce1e87a9 100644 --- a/api/v1alpha1/tunnel_conversion_test.go +++ b/api/v1alpha1/tunnel_conversion_test.go @@ -36,6 +36,9 @@ var sampleFilledOldTunnel = v1alpha1.Tunnel{ AccountName: "cfAccount", AccountId: "101", Email: "cf@cf.com", + ApiKeyKey: "key-secret-key", + ApiTokenKey: "token-secret-key", + EmailKey: "email-secret-key", CLOUDFLARE_API_KEY: "key", CLOUDFLARE_API_TOKEN: "token", CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: "file", @@ -86,6 +89,9 @@ var sampleFilledNewTunnel = v1alpha2.Tunnel{ AccountName: sampleFilledOldTunnel.Spec.Cloudflare.AccountName, AccountId: sampleFilledOldTunnel.Spec.Cloudflare.AccountId, Email: sampleFilledOldTunnel.Spec.Cloudflare.Email, + ApiKeyKey: sampleFilledOldTunnel.Spec.Cloudflare.ApiKeyKey, + ApiTokenKey: sampleFilledOldTunnel.Spec.Cloudflare.ApiTokenKey, + EmailKey: sampleFilledOldTunnel.Spec.Cloudflare.EmailKey, CLOUDFLARE_API_KEY: sampleFilledOldTunnel.Spec.Cloudflare.CLOUDFLARE_API_KEY, CLOUDFLARE_API_TOKEN: sampleFilledOldTunnel.Spec.Cloudflare.CLOUDFLARE_API_TOKEN, CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: sampleFilledOldTunnel.Spec.Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE, diff --git a/api/v1alpha1/tunnel_types.go b/api/v1alpha1/tunnel_types.go index 09459314..724e57b9 100644 --- a/api/v1alpha1/tunnel_types.go +++ b/api/v1alpha1/tunnel_types.go @@ -21,6 +21,14 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + DefaultCloudflareAPIKeySecretKey = "CLOUDFLARE_API_KEY" + DefaultCloudflareAPITokenSecretKey = "CLOUDFLARE_API_TOKEN" + DefaultCloudflareEmailSecretKey = "CLOUDFLARE_EMAIL" + DefaultCloudflareTunnelCredentialFileSecretKey = "CLOUDFLARE_TUNNEL_CREDENTIAL_FILE" + DefaultCloudflareTunnelCredentialSecretKey = "CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET" +) + // ExistingTunnel spec needs either a Tunnel Id or a Name to find it on Cloudflare. type ExistingTunnel struct { // +kubebuilder:validation:Optional @@ -64,11 +72,26 @@ type CloudflareDetails struct { // +kubebuilder:validation:Optional // +kubebuilder:default:=CLOUDFLARE_API_KEY // Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + ApiKeyKey string `json:"apiKeyKey,omitempty"` + + // +kubebuilder:validation:Optional + // +kubebuilder:default:=CLOUDFLARE_API_TOKEN + // Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN + ApiTokenKey string `json:"apiTokenKey,omitempty"` + + // +kubebuilder:validation:Optional + // +kubebuilder:default:=CLOUDFLARE_EMAIL + // Key in the secret to use for Cloudflare email when Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + EmailKey string `json:"emailKey,omitempty"` + + // +kubebuilder:validation:Optional + // Deprecated: use apiKeyKey instead. + // Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. // For Delete operations for new tunnels only, or as an alternate to API Token CLOUDFLARE_API_KEY string `json:"CLOUDFLARE_API_KEY,omitempty"` // +kubebuilder:validation:Optional - // +kubebuilder:default:=CLOUDFLARE_API_TOKEN + // Deprecated: use apiTokenKey instead. // Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN CLOUDFLARE_API_TOKEN string `json:"CLOUDFLARE_API_TOKEN,omitempty"` @@ -83,6 +106,46 @@ type CloudflareDetails struct { CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET string `json:"CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET,omitempty"` } +func (in CloudflareDetails) APIKeySecretKey() string { + return resolveSecretKey(in.ApiKeyKey, in.CLOUDFLARE_API_KEY, DefaultCloudflareAPIKeySecretKey) +} + +func (in CloudflareDetails) APITokenSecretKey() string { + return resolveSecretKey(in.ApiTokenKey, in.CLOUDFLARE_API_TOKEN, DefaultCloudflareAPITokenSecretKey) +} + +func (in CloudflareDetails) APIEmailSecretKey() string { + if in.EmailKey != "" { + return in.EmailKey + } + + return DefaultCloudflareEmailSecretKey +} + +func (in CloudflareDetails) TunnelCredentialFileSecretKey() string { + return resolveSecretKey("", in.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE, DefaultCloudflareTunnelCredentialFileSecretKey) +} + +func (in CloudflareDetails) TunnelCredentialSecretSecretKey() string { + return resolveSecretKey("", in.CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET, DefaultCloudflareTunnelCredentialSecretKey) +} + +func resolveSecretKey(preferred string, legacy string, defaultValue string) string { + switch { + case preferred == "": + if legacy != "" { + return legacy + } + return defaultValue + case preferred != defaultValue: + return preferred + case legacy != "" && legacy != defaultValue: + return legacy + default: + return preferred + } +} + // TunnelSpec defines the desired state of Tunnel type TunnelSpec struct { // +kubebuilder:validation:Minimum=0 diff --git a/api/v1alpha2/tunnel_types.go b/api/v1alpha2/tunnel_types.go index 8cf505eb..44da5c0d 100644 --- a/api/v1alpha2/tunnel_types.go +++ b/api/v1alpha2/tunnel_types.go @@ -20,6 +20,14 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + DefaultCloudflareAPIKeySecretKey = "CLOUDFLARE_API_KEY" + DefaultCloudflareAPITokenSecretKey = "CLOUDFLARE_API_TOKEN" + DefaultCloudflareEmailSecretKey = "CLOUDFLARE_EMAIL" + DefaultCloudflareTunnelCredentialFileSecretKey = "CLOUDFLARE_TUNNEL_CREDENTIAL_FILE" + DefaultCloudflareTunnelCredentialSecretKey = "CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET" +) + // ExistingTunnel spec needs either a Tunnel Id or a Name to find it on Cloudflare. type ExistingTunnel struct { // +kubebuilder:validation:Optional @@ -63,11 +71,26 @@ type CloudflareDetails struct { // +kubebuilder:validation:Optional // +kubebuilder:default:=CLOUDFLARE_API_KEY // Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + ApiKeyKey string `json:"apiKeyKey,omitempty"` + + // +kubebuilder:validation:Optional + // +kubebuilder:default:=CLOUDFLARE_API_TOKEN + // Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN + ApiTokenKey string `json:"apiTokenKey,omitempty"` + + // +kubebuilder:validation:Optional + // +kubebuilder:default:=CLOUDFLARE_EMAIL + // Key in the secret to use for Cloudflare email when Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + EmailKey string `json:"emailKey,omitempty"` + + // +kubebuilder:validation:Optional + // Deprecated: use apiKeyKey instead. + // Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. // For Delete operations for new tunnels only, or as an alternate to API Token CLOUDFLARE_API_KEY string `json:"CLOUDFLARE_API_KEY,omitempty"` // +kubebuilder:validation:Optional - // +kubebuilder:default:=CLOUDFLARE_API_TOKEN + // Deprecated: use apiTokenKey instead. // Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN CLOUDFLARE_API_TOKEN string `json:"CLOUDFLARE_API_TOKEN,omitempty"` @@ -82,6 +105,46 @@ type CloudflareDetails struct { CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET string `json:"CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET,omitempty"` } +func (in CloudflareDetails) APIKeySecretKey() string { + return resolveSecretKey(in.ApiKeyKey, in.CLOUDFLARE_API_KEY, DefaultCloudflareAPIKeySecretKey) +} + +func (in CloudflareDetails) APITokenSecretKey() string { + return resolveSecretKey(in.ApiTokenKey, in.CLOUDFLARE_API_TOKEN, DefaultCloudflareAPITokenSecretKey) +} + +func (in CloudflareDetails) APIEmailSecretKey() string { + if in.EmailKey != "" { + return in.EmailKey + } + + return DefaultCloudflareEmailSecretKey +} + +func (in CloudflareDetails) TunnelCredentialFileSecretKey() string { + return resolveSecretKey("", in.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE, DefaultCloudflareTunnelCredentialFileSecretKey) +} + +func (in CloudflareDetails) TunnelCredentialSecretSecretKey() string { + return resolveSecretKey("", in.CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET, DefaultCloudflareTunnelCredentialSecretKey) +} + +func resolveSecretKey(preferred string, legacy string, defaultValue string) string { + switch { + case preferred == "": + if legacy != "" { + return legacy + } + return defaultValue + case preferred != defaultValue: + return preferred + case legacy != "" && legacy != defaultValue: + return legacy + default: + return preferred + } +} + // TunnelSpec defines the desired state of Tunnel type TunnelSpec struct { // Deployment patch for the cloudflared deployment. diff --git a/api/v1alpha2/tunnel_types_test.go b/api/v1alpha2/tunnel_types_test.go new file mode 100644 index 00000000..9998a6d8 --- /dev/null +++ b/api/v1alpha2/tunnel_types_test.go @@ -0,0 +1,25 @@ +package v1alpha2 + +import "testing" + +func TestAPITokenSecretKeyPrefersLegacyOverrideWhenCamelCaseFieldHasDefaultValue(t *testing.T) { + details := CloudflareDetails{ + ApiTokenKey: DefaultCloudflareAPITokenSecretKey, + CLOUDFLARE_API_TOKEN: "legacy-token", + } + + if got := details.APITokenSecretKey(); got != "legacy-token" { + t.Fatalf("expected legacy override to win when apiTokenKey is just the default, got %q", got) + } +} + +func TestAPITokenSecretKeyPrefersExplicitCamelCaseOverride(t *testing.T) { + details := CloudflareDetails{ + ApiTokenKey: "token", + CLOUDFLARE_API_TOKEN: "legacy-token", + } + + if got := details.APITokenSecretKey(); got != "token" { + t.Fatalf("expected explicit camelCase override, got %q", got) + } +} diff --git a/bundle/manifests/networking.cfargotunnel.com_clustertunnels.yaml b/bundle/manifests/networking.cfargotunnel.com_clustertunnels.yaml index 1f4e1852..3b7cdf71 100644 --- a/bundle/manifests/networking.cfargotunnel.com_clustertunnels.yaml +++ b/bundle/manifests/networking.cfargotunnel.com_clustertunnels.yaml @@ -47,15 +47,15 @@ spec: description: Cloudflare Credentials properties: CLOUDFLARE_API_KEY: - default: CLOUDFLARE_API_KEY description: |- + Deprecated: use apiKeyKey instead. Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. For Delete operations for new tunnels only, or as an alternate to API Token type: string CLOUDFLARE_API_TOKEN: - default: CLOUDFLARE_API_TOKEN - description: Key in the secret to use for Cloudflare API token, - defaults to CLOUDFLARE_API_TOKEN + description: |- + Deprecated: use apiTokenKey instead. + Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN type: string CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE @@ -77,6 +77,16 @@ spec: cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. type: string + apiKeyKey: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + type: string + apiTokenKey: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string domain: description: Cloudflare Domain to which this tunnel belongs to type: string @@ -84,6 +94,11 @@ spec: description: Email to use along with API Key for Delete operations for new tunnels only, or as an alternate to API Token type: string + emailKey: + default: CLOUDFLARE_EMAIL + description: Key in the secret to use for Cloudflare email when + Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + type: string secret: description: Secret containing Cloudflare API key/token type: string diff --git a/bundle/manifests/networking.cfargotunnel.com_tunnels.yaml b/bundle/manifests/networking.cfargotunnel.com_tunnels.yaml index 8d8e299d..8cedc832 100644 --- a/bundle/manifests/networking.cfargotunnel.com_tunnels.yaml +++ b/bundle/manifests/networking.cfargotunnel.com_tunnels.yaml @@ -47,15 +47,15 @@ spec: description: Cloudflare Credentials properties: CLOUDFLARE_API_KEY: - default: CLOUDFLARE_API_KEY description: |- + Deprecated: use apiKeyKey instead. Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. For Delete operations for new tunnels only, or as an alternate to API Token type: string CLOUDFLARE_API_TOKEN: - default: CLOUDFLARE_API_TOKEN - description: Key in the secret to use for Cloudflare API token, - defaults to CLOUDFLARE_API_TOKEN + description: |- + Deprecated: use apiTokenKey instead. + Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN type: string CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE @@ -77,6 +77,16 @@ spec: cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. type: string + apiKeyKey: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + type: string + apiTokenKey: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string domain: description: Cloudflare Domain to which this tunnel belongs to type: string @@ -84,6 +94,11 @@ spec: description: Email to use along with API Key for Delete operations for new tunnels only, or as an alternate to API Token type: string + emailKey: + default: CLOUDFLARE_EMAIL + description: Key in the secret to use for Cloudflare email when + Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + type: string secret: description: Secret containing Cloudflare API key/token type: string diff --git a/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml b/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml index b44fc2af..97ed54cb 100644 --- a/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml +++ b/config/crd/bases/networking.cfargotunnel.com_clustertunnels.yaml @@ -51,15 +51,15 @@ spec: description: Cloudflare Credentials properties: CLOUDFLARE_API_KEY: - default: CLOUDFLARE_API_KEY description: |- + Deprecated: use apiKeyKey instead. Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. For Delete operations for new tunnels only, or as an alternate to API Token type: string CLOUDFLARE_API_TOKEN: - default: CLOUDFLARE_API_TOKEN - description: Key in the secret to use for Cloudflare API token, - defaults to CLOUDFLARE_API_TOKEN + description: |- + Deprecated: use apiTokenKey instead. + Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN type: string CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE @@ -81,6 +81,16 @@ spec: cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. type: string + apiKeyKey: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + type: string + apiTokenKey: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string domain: description: Cloudflare Domain to which this tunnel belongs to type: string @@ -88,6 +98,11 @@ spec: description: Email to use along with API Key for Delete operations for new tunnels only, or as an alternate to API Token type: string + emailKey: + default: CLOUDFLARE_EMAIL + description: Key in the secret to use for Cloudflare email when + Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + type: string secret: description: Secret containing Cloudflare API key/token type: string @@ -262,15 +277,15 @@ spec: description: Cloudflare Credentials properties: CLOUDFLARE_API_KEY: - default: CLOUDFLARE_API_KEY description: |- + Deprecated: use apiKeyKey instead. Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. For Delete operations for new tunnels only, or as an alternate to API Token type: string CLOUDFLARE_API_TOKEN: - default: CLOUDFLARE_API_TOKEN - description: Key in the secret to use for Cloudflare API token, - defaults to CLOUDFLARE_API_TOKEN + description: |- + Deprecated: use apiTokenKey instead. + Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN type: string CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE @@ -292,6 +307,16 @@ spec: cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. type: string + apiKeyKey: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + type: string + apiTokenKey: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string domain: description: Cloudflare Domain to which this tunnel belongs to type: string @@ -299,6 +324,11 @@ spec: description: Email to use along with API Key for Delete operations for new tunnels only, or as an alternate to API Token type: string + emailKey: + default: CLOUDFLARE_EMAIL + description: Key in the secret to use for Cloudflare email when + Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + type: string secret: description: Secret containing Cloudflare API key/token type: string diff --git a/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml b/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml index 26f6f241..0a960a2f 100644 --- a/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml +++ b/config/crd/bases/networking.cfargotunnel.com_tunnels.yaml @@ -51,15 +51,15 @@ spec: description: Cloudflare Credentials properties: CLOUDFLARE_API_KEY: - default: CLOUDFLARE_API_KEY description: |- + Deprecated: use apiKeyKey instead. Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. For Delete operations for new tunnels only, or as an alternate to API Token type: string CLOUDFLARE_API_TOKEN: - default: CLOUDFLARE_API_TOKEN - description: Key in the secret to use for Cloudflare API token, - defaults to CLOUDFLARE_API_TOKEN + description: |- + Deprecated: use apiTokenKey instead. + Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN type: string CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE @@ -81,6 +81,16 @@ spec: cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. type: string + apiKeyKey: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + type: string + apiTokenKey: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string domain: description: Cloudflare Domain to which this tunnel belongs to type: string @@ -88,6 +98,11 @@ spec: description: Email to use along with API Key for Delete operations for new tunnels only, or as an alternate to API Token type: string + emailKey: + default: CLOUDFLARE_EMAIL + description: Key in the secret to use for Cloudflare email when + Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + type: string secret: description: Secret containing Cloudflare API key/token type: string @@ -262,15 +277,15 @@ spec: description: Cloudflare Credentials properties: CLOUDFLARE_API_KEY: - default: CLOUDFLARE_API_KEY description: |- + Deprecated: use apiKeyKey instead. Key in the secret to use for Cloudflare API Key, defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. For Delete operations for new tunnels only, or as an alternate to API Token type: string CLOUDFLARE_API_TOKEN: - default: CLOUDFLARE_API_TOKEN - description: Key in the secret to use for Cloudflare API token, - defaults to CLOUDFLARE_API_TOKEN + description: |- + Deprecated: use apiTokenKey instead. + Key in the secret to use for Cloudflare API token, defaults to CLOUDFLARE_API_TOKEN type: string CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: default: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE @@ -292,6 +307,16 @@ spec: cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. type: string + apiKeyKey: + default: CLOUDFLARE_API_KEY + description: Key in the secret to use for Cloudflare API Key, + defaults to CLOUDFLARE_API_KEY. Needs Email also to be provided. + type: string + apiTokenKey: + default: CLOUDFLARE_API_TOKEN + description: Key in the secret to use for Cloudflare API token, + defaults to CLOUDFLARE_API_TOKEN + type: string domain: description: Cloudflare Domain to which this tunnel belongs to type: string @@ -299,6 +324,11 @@ spec: description: Email to use along with API Key for Delete operations for new tunnels only, or as an alternate to API Token type: string + emailKey: + default: CLOUDFLARE_EMAIL + description: Key in the secret to use for Cloudflare email when + Email is not set explicitly, defaults to CLOUDFLARE_EMAIL. + type: string secret: description: Secret containing Cloudflare API key/token type: string diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index a67a884d..46485de0 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -6,7 +6,3 @@ generatorOptions: apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -images: -- name: controller - newName: adyanth/cloudflare-operator - newTag: 0.13.1 diff --git a/config/samples/networking_v1alpha1_clustertunnel.yaml b/config/samples/networking_v1alpha1_clustertunnel.yaml index 60be9827..72b7ac10 100644 --- a/config/samples/networking_v1alpha1_clustertunnel.yaml +++ b/config/samples/networking_v1alpha1_clustertunnel.yaml @@ -13,3 +13,6 @@ spec: email: email@example.com accountName: accountId: + # apiTokenKey: + # apiKeyKey: + # emailKey: diff --git a/config/samples/networking_v1alpha1_tunnel.yaml b/config/samples/networking_v1alpha1_tunnel.yaml index 167bb92c..6d6750d6 100644 --- a/config/samples/networking_v1alpha1_tunnel.yaml +++ b/config/samples/networking_v1alpha1_tunnel.yaml @@ -14,3 +14,6 @@ spec: email: email@example.com accountName: accountId: + # apiTokenKey: + # apiKeyKey: + # emailKey: diff --git a/docs/configuration/tunnel-and-cluster-tunnel.md b/docs/configuration/tunnel-and-cluster-tunnel.md index fc81db63..df6ae168 100644 --- a/docs/configuration/tunnel-and-cluster-tunnel.md +++ b/docs/configuration/tunnel-and-cluster-tunnel.md @@ -21,13 +21,15 @@ spec: accountId: account-id accountName: Account Name domain: example.com # Domain where the tunnel runs - email: admin@example.com # Email ID used to login to Cloudflare + email: admin@example.com # Email ID used to login to Cloudflare. Optional if you store it in the secret under emailKey. # Cloudflare credentials secret, and its key overrides. All the overrides are optional and default to the shown values. secret: cloudflare-secrets ## Key in the secret to use for Cloudflare API token. See getting started for information on scopes - CLOUDFLARE_API_TOKEN: CLOUDFLARE_API_TOKEN + apiTokenKey: CLOUDFLARE_API_TOKEN ## Key in the secret to use for Cloudflare API Key. Needs Email also to be provided. For delete operations on new tunnels only, or as an alternate to API Token - CLOUDFLARE_API_KEY: CLOUDFLARE_API_KEY + apiKeyKey: CLOUDFLARE_API_KEY + ## Key in the secret to use for the Cloudflare email when cloudflare.email is omitted + emailKey: CLOUDFLARE_EMAIL ## Key in the secret to use as credentials.json for an existing tunnel CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: CLOUDFLARE_TUNNEL_CREDENTIAL_FILE ## Key in the secret to use as tunnel secret for an existing tunnel diff --git a/docs/examples/operator-authentication/README.md b/docs/examples/operator-authentication/README.md index 9528b1da..06549325 100644 --- a/docs/examples/operator-authentication/README.md +++ b/docs/examples/operator-authentication/README.md @@ -23,7 +23,7 @@ Cloudflare-operator will use an API token _or_ API Key for each Tunnel/ClusterTu These API tokens can be found under [My Profile > API tokens](https://dash.cloudflare.com/profile/api-tokens) page in the Cloudflare Dashboard. -For the `CLOUDFLARE_API_TOKEN`, create a new "custom" token with the following: +For the secret value referenced by `spec.cloudflare.apiTokenKey` (defaults to `CLOUDFLARE_API_TOKEN`), create a new "custom" token with the following: 1. Permissions * Account > Cloudflare Tunnel > Edit : To create new tunnels @@ -38,7 +38,7 @@ For the `CLOUDFLARE_API_TOKEN`, create a new "custom" token with the following: > It is not recommended to use an API Key due to granting excess permissions. Use an API token instead. -For `CLOUDFLARE_API_KEY`, copy the Global API Key shown at the bottom of [Cloudflare's API token page](https://dash.cloudflare.com/profile/api-tokens). +For the secret value referenced by `spec.cloudflare.apiKeyKey` (defaults to `CLOUDFLARE_API_KEY`), copy the Global API Key shown at the bottom of [Cloudflare's API token page](https://dash.cloudflare.com/profile/api-tokens). ## Secret creation @@ -51,9 +51,19 @@ For `CLOUDFLARE_API_KEY`, copy the Global API Key shown at the bottom of [Cloudf --from-literal CLOUDFLARE_API_TOKEN= ``` +If your secret uses a different key name, keep the Secret as-is and point the Tunnel/ClusterTunnel at it: + +```yaml +spec: + cloudflare: + secret: cloudflare-secrets + apiTokenKey: token +``` + ### Declarative 1. Replace `` in `manifests/secret.yaml` with your cloudflare API token. +1. If your secret sync tool writes a different field name, update your Tunnel/ClusterTunnel with `spec.cloudflare.apiTokenKey`, `apiKeyKey`, or `emailKey` instead of renaming the Secret data key. 1. Deploy the secret ```bash kubectl apply -f manifests/secret.yaml diff --git a/docs/examples/operator-authentication/manifests/secret.yaml b/docs/examples/operator-authentication/manifests/secret.yaml index b174982d..478b1a28 100644 --- a/docs/examples/operator-authentication/manifests/secret.yaml +++ b/docs/examples/operator-authentication/manifests/secret.yaml @@ -7,6 +7,7 @@ type: Opaque stringData: CLOUDFLARE_API_TOKEN: # CLOUDFLARE_API_KEY: # if you use an API key instead of a token, replace this instead +# CLOUDFLARE_EMAIL: # optional if you want to source cloudflare.email from the Secret via spec.cloudflare.emailKey # CREDENTIAL_FILE is used if found, else CREDENTIAL_SECRET is used to build the file. # Either of them is needed when using an existing tunnel diff --git a/internal/controller/generic_tunnel_reconciler.go b/internal/controller/generic_tunnel_reconciler.go index 496f9979..c424c5bf 100644 --- a/internal/controller/generic_tunnel_reconciler.go +++ b/internal/controller/generic_tunnel_reconciler.go @@ -96,12 +96,14 @@ func setupExistingTunnel(r GenericTunnelReconciler) error { r.SetCfAPI(cfAPI) // Read secret for credentials file - cfCredFileB64, okCredFile := r.GetCfSecret().Data[r.GetTunnel().GetSpec().Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE] - cfSecretB64, okSecret := r.GetCfSecret().Data[r.GetTunnel().GetSpec().Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET] + credFileKey := r.GetTunnel().GetSpec().Cloudflare.TunnelCredentialFileSecretKey() + credSecretKey := r.GetTunnel().GetSpec().Cloudflare.TunnelCredentialSecretSecretKey() + cfCredFileB64, okCredFile := r.GetCfSecret().Data[credFileKey] + cfSecretB64, okSecret := r.GetCfSecret().Data[credSecretKey] if !okCredFile && !okSecret { err := fmt.Errorf("neither key not found in secret") - r.GetLog().Error(err, "neither key not found in secret", "secret", r.GetTunnel().GetSpec().Cloudflare.Secret, "key1", r.GetTunnel().GetSpec().Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_FILE, "key2", r.GetTunnel().GetSpec().Cloudflare.CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET) + r.GetLog().Error(err, "neither key not found in secret", "secret", r.GetTunnel().GetSpec().Cloudflare.Secret, "key1", credFileKey, "key2", credSecretKey) r.GetRecorder().Event(r.GetTunnel().GetObject(), corev1.EventTypeWarning, "ErrSpecSecret", "Neither Key found in Secret") return err } diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 81f60145..535b901d 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -68,14 +68,17 @@ func getAPIDetails( return &cf.API{}, &corev1.Secret{}, err } + apiTokenKey := tunnelSpec.Cloudflare.APITokenSecretKey() + apiKeyKey := tunnelSpec.Cloudflare.APIKeySecretKey() + // Read secret for API Token - cfAPITokenB64, okApiToken := cfSecret.Data[tunnelSpec.Cloudflare.CLOUDFLARE_API_TOKEN] + cfAPITokenB64, okAPIToken := cfSecret.Data[apiTokenKey] // Read secret for API Key - cfAPIKeyB64, okApiKey := cfSecret.Data[tunnelSpec.Cloudflare.CLOUDFLARE_API_KEY] + cfAPIKeyB64, okAPIKey := cfSecret.Data[apiKeyKey] - if !(okApiKey || okApiToken) { - err := fmt.Errorf("neither %s not %s found in secret %s, cannot construct client", tunnelSpec.Cloudflare.CLOUDFLARE_API_TOKEN, tunnelSpec.Cloudflare.CLOUDFLARE_API_KEY, tunnelSpec.Cloudflare.Secret) + if !okAPIKey && !okAPIToken { + err := fmt.Errorf("neither %s nor %s found in secret %s, cannot construct client", apiTokenKey, apiKeyKey, tunnelSpec.Cloudflare.Secret) log.Error(err, "key not found in secret") return nil, nil, err } @@ -83,6 +86,11 @@ func getAPIDetails( apiToken := string(cfAPITokenB64) apiKey := string(cfAPIKeyB64) apiEmail := tunnelSpec.Cloudflare.Email + if apiEmail == "" { + if cfAPIEmailB64, ok := cfSecret.Data[tunnelSpec.Cloudflare.APIEmailSecretKey()]; ok { + apiEmail = string(cfAPIEmailB64) + } + } cloudflareClient, err := getCloudflareClient(apiKey, apiEmail, apiToken) if err != nil { diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go new file mode 100644 index 00000000..3a9305d3 --- /dev/null +++ b/internal/controller/utils_test.go @@ -0,0 +1,146 @@ +package controller + +import ( + "context" + "strings" + "testing" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + networkingv1alpha2 "github.com/adyanth/cloudflare-operator/api/v1alpha2" +) + +func TestGetAPIDetailsUsesDefaultSecretKeys(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1 scheme: %v", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cloudflare-secrets", + Namespace: "default", + }, + Data: map[string][]byte{ + networkingv1alpha2.DefaultCloudflareAPITokenSecretKey: []byte("token-value"), + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + spec := networkingv1alpha2.TunnelSpec{ + Cloudflare: networkingv1alpha2.CloudflareDetails{ + Secret: "cloudflare-secrets", + Domain: "example.com", + }, + } + + api, gotSecret, err := getAPIDetails(context.Background(), c, logr.Discard(), spec, networkingv1alpha2.TunnelStatus{}, "default") + if err != nil { + t.Fatalf("get api details: %v", err) + } + if api == nil { + t.Fatal("expected api details") + } + if gotSecret == nil { + t.Fatal("expected secret") + } + if gotSecret.Name != secret.Name { + t.Fatalf("expected secret %q, got %q", secret.Name, gotSecret.Name) + } +} + +func TestGetAPIDetailsUsesCustomAPITokenKey(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1 scheme: %v", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cloudflare-secrets", + Namespace: "default", + }, + Data: map[string][]byte{ + "token": []byte("token-value"), + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + spec := networkingv1alpha2.TunnelSpec{ + Cloudflare: networkingv1alpha2.CloudflareDetails{ + Secret: "cloudflare-secrets", + Domain: "example.com", + ApiTokenKey: "token", + }, + } + + if _, _, err := getAPIDetails(context.Background(), c, logr.Discard(), spec, networkingv1alpha2.TunnelStatus{}, "default"); err != nil { + t.Fatalf("get api details with custom token key: %v", err) + } +} + +func TestGetAPIDetailsErrorsWhenConfiguredKeyMissing(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1 scheme: %v", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cloudflare-secrets", + Namespace: "default", + }, + Data: map[string][]byte{}, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + spec := networkingv1alpha2.TunnelSpec{ + Cloudflare: networkingv1alpha2.CloudflareDetails{ + Secret: "cloudflare-secrets", + Domain: "example.com", + ApiTokenKey: "token", + }, + } + + _, _, err := getAPIDetails(context.Background(), c, logr.Discard(), spec, networkingv1alpha2.TunnelStatus{}, "default") + if err == nil { + t.Fatal("expected error when configured key is missing") + } + if !strings.Contains(err.Error(), "token") { + t.Fatalf("expected error to mention missing configured key, got %q", err) + } +} + +func TestGetAPIDetailsUsesLegacyCustomTokenKey(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1 scheme: %v", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cloudflare-secrets", + Namespace: "default", + }, + Data: map[string][]byte{ + "legacy-token": []byte("token-value"), + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + spec := networkingv1alpha2.TunnelSpec{ + Cloudflare: networkingv1alpha2.CloudflareDetails{ + Secret: "cloudflare-secrets", + Domain: "example.com", + CLOUDFLARE_API_TOKEN: "legacy-token", + }, + } + + if _, _, err := getAPIDetails(context.Background(), c, logr.Discard(), spec, networkingv1alpha2.TunnelStatus{}, "default"); err != nil { + t.Fatalf("get api details with legacy token key: %v", err) + } +} diff --git a/test/manifests/clustercrd.yaml b/test/manifests/clustercrd.yaml index 230f0257..e75f5c7b 100644 --- a/test/manifests/clustercrd.yaml +++ b/test/manifests/clustercrd.yaml @@ -31,6 +31,8 @@ spec: # # accountId and accountName cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. # # accountName: Adyanth LAN # accountId: 99324e29567e6b6e4f18eb440eccf5d7 -# # CLOUDFLARE_API_KEY: +# # apiTokenKey: +# # apiKeyKey: +# # emailKey: # # CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: # # CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET: diff --git a/test/manifests/crd.yaml b/test/manifests/crd.yaml index f93a8c5c..95faf6e9 100644 --- a/test/manifests/crd.yaml +++ b/test/manifests/crd.yaml @@ -31,6 +31,8 @@ spec: # # accountId and accountName cannot be both empty. If both are provided, Account ID is used if valid, else falls back to Account Name. # # accountName: Adyanth LAN # accountId: 99324e29567e6b6e4f18eb440eccf5d7 -# # CLOUDFLARE_API_KEY: +# # apiTokenKey: +# # apiKeyKey: +# # emailKey: # # CLOUDFLARE_TUNNEL_CREDENTIAL_FILE: # # CLOUDFLARE_TUNNEL_CREDENTIAL_SECRET: