├── test └── e2e │ ├── suite │ ├── suite.go │ ├── validation │ │ └── validation.go │ └── issuers │ │ └── issuers.go │ ├── util │ └── util.go │ ├── e2e_test.go │ ├── framework │ ├── config │ │ └── config.go │ ├── framework.go │ └── helper │ │ └── helper.go │ └── e2e.go ├── PROJECT ├── deploy └── charts │ └── google-cas-issuer │ ├── templates │ ├── serviceaccount.yaml │ ├── role.yaml │ ├── rolebinding.yaml │ ├── _helpers.tpl │ ├── clusterrolebinding.yaml │ ├── clusterrole.yaml │ ├── deployment.yaml │ └── crds │ │ ├── cas-issuer.jetstack.io_googlecasissuers.yaml │ │ └── cas-issuer.jetstack.io_googlecasclusterissuers.yaml │ ├── Chart.yaml │ ├── values.yaml │ └── README.md ├── hack ├── tools │ ├── tools.go │ └── go.mod ├── boilerplate.go.txt ├── update-helm-docs.sh ├── verify-helm-docs.sh ├── ci │ └── run-e2e.sh └── casutil │ ├── certificates.go │ ├── casutil.go │ ├── capool.go │ └── ca.go ├── .gitignore ├── .github └── workflows │ ├── unit_tests.yml │ ├── docker.yml │ ├── e2e_tests.yml │ ├── pr_e2e_tests.yml │ └── release.yml ├── main.go ├── Dockerfile ├── api └── v1beta1 │ ├── groupversion_info.go │ ├── googlecasclusterissuer_types.go │ ├── googlecasissuer_types.go │ └── zz_generated.deepcopy.go ├── cmd ├── cmd.go └── root.go ├── pkg ├── controller │ ├── certificaterequest │ │ ├── certificaterequest_controller_test.go │ │ └── certificaterequest_controller.go │ └── issuer │ │ ├── googlecasissuer_controller_test.go │ │ └── googlecasissuer_controller.go └── cas │ ├── cas_test.go │ └── cas.go ├── Makefile ├── go.mod ├── README.md └── LICENSE.txt /test/e2e/suite/suite.go: -------------------------------------------------------------------------------- 1 | package suite 2 | 3 | import ( 4 | _ "github.com/jetstack/google-cas-issuer/test/e2e/suite/issuers" 5 | _ "github.com/jetstack/google-cas-issuer/test/e2e/suite/validation" 6 | ) 7 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: jetstack.io 2 | repo: github.com/jetstack/google-cas-issuer 3 | resources: 4 | - group: issuers 5 | kind: GoogleCASIssuer 6 | version: v1alpha1 7 | - group: issuers 8 | kind: GoogleCASClusterIssuer 9 | version: v1alpha1 10 | version: "2" 11 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 5 | labels: 6 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 7 | annotations: 8 | {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} 9 | -------------------------------------------------------------------------------- /hack/tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | // This file exists to force 'go mod' to fetch tool dependencies 4 | // See: https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 5 | 6 | package bin 7 | 8 | import ( 9 | _ "github.com/norwoodj/helm-docs/cmd/helm-docs" 10 | _ "github.com/onsi/ginkgo/v2/ginkgo" 11 | _ "sigs.k8s.io/controller-tools/cmd/controller-gen" 12 | _ "sigs.k8s.io/kind" 13 | ) 14 | -------------------------------------------------------------------------------- /test/e2e/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | var letters = []rune("abcdefghijklmnopqrstuvwxyz") 9 | 10 | func RandomString(length int) string { 11 | testRand := rand.New(rand.NewSource(time.Now().UnixNano())) 12 | randomString := make([]rune, length) 13 | for i := range randomString { 14 | randomString[i] = letters[testRand.Intn(len(letters))] 15 | } 16 | return string(randomString) 17 | } 18 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | 3 | name: cert-manager-google-cas-issuer 4 | type: application 5 | description: A Helm chart for jetstack/google-cas-issuer 6 | 7 | home: https://github.com/jetstack/google-cas-issuer 8 | maintainers: 9 | - name: jetstack 10 | email: cert-manager-maintainers@jetstack.io 11 | url: https://platform.jetstack.io 12 | sources: 13 | - https://github.com/jetstack/google-cas-issuer 14 | 15 | appVersion: v0.6.2 16 | version: v0.6.2 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Kubernetes Generated files - skip generated files, except for vendored files 17 | 18 | !vendor/**/zz_generated.* 19 | 20 | # editor and IDE paraphernalia 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | 26 | kubeconfig.yaml 27 | _artifacts 28 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/role.yaml: -------------------------------------------------------------------------------- 1 | kind: Role 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 5 | labels: 6 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 7 | rules: 8 | - apiGroups: ["coordination.k8s.io"] 9 | resources: ["leases"] 10 | verbs: ["create"] 11 | - apiGroups: ["coordination.k8s.io"] 12 | resources: ["leases"] 13 | verbs: ["get", "update"] 14 | resourceNames: ["cm-google-cas-issuer"] 15 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/rolebinding.yaml: -------------------------------------------------------------------------------- 1 | kind: RoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 5 | labels: 6 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 7 | roleRef: 8 | apiGroup: rbac.authorization.k8s.io 9 | kind: Role 10 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 11 | subjects: 12 | - kind: ServiceAccount 13 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 14 | namespace: {{ .Release.Namespace }} 15 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs unpriviledged unit tests. 2 | name: unit-tests 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | # At 03:23 on every 3rd day 12 | - cron: "23 3 */3 * *" 13 | 14 | jobs: 15 | run_unit_tests: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | steps: 20 | - name: Install Go 21 | uses: actions/setup-go@v3 22 | with: 23 | go-version: '^1.19' 24 | 25 | - name: Checkout 26 | uses: actions/checkout@v3 27 | 28 | - name: Run unit tests 29 | run: make test 30 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "github.com/jetstack/google-cas-issuer/cmd" 21 | ) 22 | 23 | func main() { 24 | cmd.Execute() 25 | } 26 | -------------------------------------------------------------------------------- /test/e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "github.com/onsi/ginkgo/v2" 10 | "github.com/onsi/ginkgo/v2/reporters" 11 | "github.com/onsi/gomega" 12 | "k8s.io/apimachinery/pkg/util/wait" 13 | 14 | _ "github.com/jetstack/google-cas-issuer/test/e2e/suite" 15 | ) 16 | 17 | func init() { 18 | wait.ForeverTestTimeout = time.Second * 60 19 | } 20 | 21 | func TestE2E(t *testing.T) { 22 | gomega.RegisterFailHandler(ginkgo.Fail) 23 | 24 | junitPath := "../../_artifacts" 25 | if path := os.Getenv("ARTIFACTS"); path != "" { 26 | junitPath = path 27 | } 28 | 29 | junitReporter := reporters.NewJUnitReporter(filepath.Join( 30 | junitPath, 31 | "junit-go-e2e.xml", 32 | )) 33 | ginkgo.RunSpecsWithDefaultAndCustomReporters(t, "jetstack google-cas-issuer e2e suite", []ginkgo.Reporter{junitReporter}) 34 | } 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.19 as builder 3 | 4 | WORKDIR /workspace 5 | # Copy the Go Modules manifests 6 | COPY go.mod go.mod 7 | COPY go.sum go.sum 8 | # cache deps before building and copying source so that we don't need to re-download as much 9 | # and so that source changes don't invalidate our downloaded layer 10 | RUN go mod download 11 | 12 | # Copy the go source 13 | COPY main.go main.go 14 | COPY api/ api/ 15 | COPY cmd/ cmd/ 16 | COPY pkg/ pkg/ 17 | 18 | # Build 19 | RUN CGO_ENABLED=0 go build -v -o google-cas-issuer main.go 20 | 21 | # Use distroless as minimal base image to package the manager binary 22 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 23 | FROM gcr.io/distroless/static-debian10 24 | WORKDIR / 25 | COPY --from=builder /workspace/google-cas-issuer . 26 | USER nonroot:nonroot 27 | 28 | ENTRYPOINT ["/google-cas-issuer"] 29 | -------------------------------------------------------------------------------- /hack/update-helm-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2021 The cert-manager Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -o errexit 18 | set -o nounset 19 | set -o pipefail 20 | 21 | KUBE_ROOT=$(dirname "${BASH_SOURCE}")/.. 22 | 23 | HELM_DOCS_BIN="${KUBE_ROOT}/bin/helm-docs" 24 | 25 | $HELM_DOCS_BIN ${KUBE_ROOT}/deploy/charts/google-cas-issuer 26 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | multiarch-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v2 15 | - 16 | name: Set up QEMU 17 | uses: docker/setup-qemu-action@v1 18 | - 19 | name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v1 21 | - 22 | name: Login to quay.io 23 | uses: docker/login-action@v1 24 | with: 25 | registry: quay.io 26 | username: ${{ secrets.QUAY_USERNAME }} 27 | password: ${{ secrets.QUAY_PASSWORD }} 28 | - 29 | name: Build and push 30 | uses: docker/build-push-action@v2 31 | with: 32 | context: . 33 | file: ./Dockerfile 34 | platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le 35 | push: true 36 | tags: | 37 | quay.io/jetstack/cert-manager-google-cas-issuer:latest 38 | quay.io/jetstack/cert-manager-google-cas-issuer:dev 39 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "cert-manager-google-cas-issuer.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create chart name and version as used by the chart label. 11 | */}} 12 | {{- define "cert-manager-google-cas-issuer.chart" -}} 13 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 14 | {{- end -}} 15 | 16 | {{/* 17 | Common labels 18 | */}} 19 | {{- define "cert-manager-google-cas-issuer.labels" -}} 20 | app.kubernetes.io/name: {{ include "cert-manager-google-cas-issuer.name" . }} 21 | helm.sh/chart: {{ include "cert-manager-google-cas-issuer.chart" . }} 22 | app.kubernetes.io/instance: {{ .Release.Name }} 23 | {{- if .Chart.AppVersion }} 24 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 25 | {{- end }} 26 | app.kubernetes.io/managed-by: {{ .Release.Service }} 27 | {{- if .Values.commonLabels}} 28 | {{ toYaml .Values.commonLabels }} 29 | {{- end }} 30 | {{- end -}} 31 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/clusterrolebinding.yaml: -------------------------------------------------------------------------------- 1 | kind: ClusterRoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | labels: 5 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 6 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 7 | roleRef: 8 | apiGroup: rbac.authorization.k8s.io 9 | kind: ClusterRole 10 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 11 | subjects: 12 | - kind: ServiceAccount 13 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 14 | namespace: {{ .Release.Namespace }} 15 | --- 16 | {{- if .Values.app.approval.enabled }} 17 | kind: ClusterRoleBinding 18 | apiVersion: rbac.authorization.k8s.io/v1 19 | metadata: 20 | labels: 21 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 22 | name: {{ include "cert-manager-google-cas-issuer.name" . }}:approval 23 | roleRef: 24 | apiGroup: rbac.authorization.k8s.io 25 | kind: ClusterRole 26 | name: {{ include "cert-manager-google-cas-issuer.name" . }}:approval 27 | subjects: 28 | {{ .Values.app.approval.subjects | toYaml | indent 2}} 29 | {{- end }} 30 | -------------------------------------------------------------------------------- /hack/verify-helm-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2021 The cert-manager Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -o errexit 18 | set -o nounset 19 | set -o pipefail 20 | 21 | KUBE_ROOT=$(dirname "${BASH_SOURCE}")/.. 22 | 23 | HELM_DOCS_BIN="${KUBE_ROOT}/bin/helm-docs" 24 | 25 | TEMP_FILE=$(mktemp) 26 | trap '{ rm -f -- "$TEMP_FILE"; }' EXIT 27 | 28 | $HELM_DOCS_BIN ${KUBE_ROOT}/deploy/charts/google-cas-issuer -d -l error > ${TEMP_FILE} 29 | 30 | if ! cmp -s "${KUBE_ROOT}/deploy/charts/google-cas-issuer/README.md" "${TEMP_FILE}"; then 31 | echo "Helm chart README.md is out of date." 32 | echo "Please run 'make update-helm-docs'." 33 | exit 1 34 | fi 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/e2e_tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs priviledged e2e tests for trusted sources. 2 | name: e2e-tests 3 | on: 4 | push: 5 | branches: 6 | - main 7 | schedule: 8 | # At 03:23 on every 3rd day 9 | - cron: "23 3 */3 * *" 10 | 11 | jobs: 12 | run_e2e_tests: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | id-token: write 17 | steps: 18 | - name: Install Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: '^1.19' 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | 26 | - id: 'auth' 27 | name: 'Authenticate to Google Cloud' 28 | uses: 'google-github-actions/auth@v0' 29 | with: 30 | workload_identity_provider: 'projects/874174494201/locations/global/workloadIdentityPools/google-cas-issuer-e2e/providers/google-cas-issuer-e2e' 31 | service_account: 'google-cas-issuer-e2e@jetstack-cas.iam.gserviceaccount.com' 32 | 33 | - name: Run e2e tests 34 | run: make e2e 35 | env: 36 | TEST_GOOGLE_APPLICATION_CREDENTIALS: ${{ steps.auth.outputs.credentials_file_path }} 37 | 38 | - uses: actions/upload-artifact@v3 39 | if: always() 40 | with: 41 | name: e2e-logs 42 | path: _artifacts/e2e/logs 43 | -------------------------------------------------------------------------------- /api/v1beta1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package v1beta1 contains API Schema definitions for the issuers v1beta1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=cas-issuer.jetstack.io 20 | package v1beta1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects 29 | GroupVersion = schema.GroupVersion{Group: "cas-issuer.jetstack.io", Version: "v1beta1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /test/e2e/framework/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | 7 | utilerrors "k8s.io/apimachinery/pkg/util/errors" 8 | ) 9 | 10 | type Config struct { 11 | KubeConfigPath string 12 | 13 | RepoRoot string 14 | Namespace string 15 | 16 | Project string 17 | Location string 18 | CaPoolId string 19 | } 20 | 21 | var ( 22 | sharedConfig = &Config{} 23 | ) 24 | 25 | func SetConfig(config *Config) { 26 | sharedConfig = config 27 | } 28 | 29 | func GetConfig() *Config { 30 | return sharedConfig 31 | } 32 | 33 | func (c *Config) AddFlags(fs *flag.FlagSet) { 34 | fs.StringVar(&c.KubeConfigPath, "kubeconfig", "", "path to Kubeconfig") 35 | fs.StringVar(&c.Project, "project", "", "GCP project name") 36 | fs.StringVar(&c.Location, "location", "", "GCP project location") 37 | fs.StringVar(&c.CaPoolId, "capoolid", "", "CA pool ID") 38 | } 39 | 40 | func (c *Config) Validate() error { 41 | var errs []error 42 | 43 | if c.KubeConfigPath == "" { 44 | errs = append(errs, errors.New("--kubeconfig not set")) 45 | } 46 | if c.Project == "" { 47 | errs = append(errs, errors.New("--project not set")) 48 | } 49 | if c.Location == "" { 50 | errs = append(errs, errors.New("--location not set")) 51 | } 52 | if c.CaPoolId == "" { 53 | errs = append(errs, errors.New("--capoolid not set")) 54 | } 55 | if c.Namespace == "" { 56 | errs = append(errs, errors.New("no namespace name set")) 57 | } 58 | 59 | return utilerrors.NewAggregate(errs) 60 | } 61 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cmd 18 | 19 | import ( 20 | "math/rand" 21 | "os" 22 | "strings" 23 | "time" 24 | 25 | "github.com/spf13/cobra" 26 | "github.com/spf13/viper" 27 | "k8s.io/apimachinery/pkg/runtime" 28 | ctrl "sigs.k8s.io/controller-runtime" 29 | ) 30 | 31 | var ( 32 | scheme = runtime.NewScheme() 33 | setupLog = ctrl.Log.WithName("setup") 34 | ) 35 | 36 | // Execute is the main entrypoint 37 | func Execute() { 38 | if err := rootCmd.Execute(); err != nil { 39 | setupLog.Error(err, "error in root cmd") 40 | os.Exit(1) 41 | } 42 | } 43 | 44 | func init() { 45 | // Check environment variables for config 46 | cobra.OnInitialize(flagsFromEnv) 47 | 48 | // initialise psuedorandom numbers for cert IDs 49 | rand.Seed(time.Now().UnixNano()) 50 | } 51 | 52 | // flagsFromEnv allows flags to be set from environment variables. 53 | // for example --metrics-addr can be set with GOOGLE_CAS_ISSUER_METRICS_ADDR 54 | func flagsFromEnv() { 55 | viper.SetEnvPrefix("google_cas_issuer") 56 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 57 | viper.AutomaticEnv() 58 | } 59 | -------------------------------------------------------------------------------- /hack/ci/run-e2e.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | 5 | REPO_ROOT="${REPO_ROOT:-$(dirname "${BASH_SOURCE}")/../..}" 6 | BINDIR="${BINDIR:-${REPO_ROOT}/bin}" 7 | IMG_REPO="${IMG_REPO:-quay.io/jetstack/cert-manager-google-cas-issuer}" 8 | IMG_TAG="${IMG_TAG:-$(git rev-parse HEAD)}" 9 | E2E_LOG_DIR="${E2E_LOG_DIR:-${REPO_ROOT}/_artifacts/e2e/logs}" 10 | 11 | export PATH="${BINDIR}:${PATH}" 12 | 13 | cd $REPO_ROOT 14 | KUBECONFIG=$(pwd)/kubeconfig.yaml 15 | 16 | function export_logs_delete { 17 | echo "Exporting e2e test logs" 18 | rm -rf ${E2E_LOG_DIR} 19 | mkdir -p ${E2E_LOG_DIR} 20 | kubectl --kubeconfig $KUBECONFIG cluster-info dump --all-namespaces --output-directory ${E2E_LOG_DIR}/kubectl-cluster-info-dump --output yaml 21 | kind export logs --name casissuer-e2e ${E2E_LOG_DIR} 22 | kind delete cluster --name casissuer-e2e 23 | } 24 | 25 | kind version 26 | kind create cluster --name casissuer-e2e 27 | kind export kubeconfig --name casissuer-e2e --kubeconfig $KUBECONFIG 28 | kind load docker-image --name casissuer-e2e $IMG_REPO:$IMG_TAG 29 | kubectl --kubeconfig $KUBECONFIG apply -f https://github.com/jetstack/cert-manager/releases/download/v1.9.1/cert-manager.yaml 30 | helm upgrade -i cert-manager-google-cas-issuer ./deploy/charts/google-cas-issuer -n cert-manager \ 31 | --set image.repository=$IMG_REPO \ 32 | --set image.tag=$IMG_TAG \ 33 | --set image.pullPolicy=Never 34 | timeout 5m bash -c "until kubectl --kubeconfig $KUBECONFIG --timeout=120s wait --for=condition=Ready pods --all --namespace kube-system; do sleep 1; done" 35 | timeout 5m bash -c "until kubectl --kubeconfig $KUBECONFIG --timeout=120s wait --for=condition=Ready pods --all --namespace cert-manager; do sleep 1; done" 36 | trap export_logs_delete EXIT 37 | ginkgo -nodes 1 test/e2e/ -- --kubeconfig $KUBECONFIG --project jetstack-cas --location europe-west1 --capoolid issuer-e2e 38 | -------------------------------------------------------------------------------- /hack/casutil/certificates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | privateca "cloud.google.com/go/security/privateca/apiv1" 5 | "context" 6 | "fmt" 7 | "github.com/olekukonko/tablewriter" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | "google.golang.org/api/iterator" 11 | privatecaapi "google.golang.org/genproto/googleapis/cloud/security/privateca/v1" 12 | "os" 13 | "path" 14 | "time" 15 | ) 16 | 17 | var ( 18 | listCerts = &cobra.Command{ 19 | Use: "certs", 20 | Short: "Lists certificates in a pool", 21 | Args: cobra.ExactArgs(0), 22 | Run: func(cmd *cobra.Command, args []string) { 23 | p, err := cmd.Flags().GetString("pool") 24 | fatalIf(err) 25 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 26 | fatalIf(err) 27 | defer c.Close() 28 | it := c.ListCertificates(context.Background(), &privatecaapi.ListCertificatesRequest{ 29 | Parent: fmt.Sprintf( 30 | "projects/%s/locations/%s/caPools/%s", 31 | viper.GetString("project"), 32 | viper.GetString("location"), 33 | p, 34 | ), 35 | }) 36 | var data [][]string 37 | for { 38 | resp, err := it.Next() 39 | if err == iterator.Done { 40 | break 41 | } 42 | fatalIf(err) 43 | name := path.Base(resp.Name) 44 | cn := resp.CertificateDescription.SubjectDescription.Subject.CommonName 45 | sans := resp.CertificateDescription.SubjectDescription.SubjectAltName.String() 46 | created := resp.CreateTime.AsTime().Local().Truncate(time.Second).String() 47 | lifetime := resp.Lifetime.AsDuration().String() 48 | data = append(data, []string{name, cn, sans, created, lifetime}) 49 | } 50 | t := tablewriter.NewWriter(os.Stdout) 51 | t.SetHeader([]string{"Name", "CN", "SANs", "Created", "Lifetime"}) 52 | for _, d := range data { 53 | t.Append(d) 54 | } 55 | t.Render() 56 | }, 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /test/e2e/e2e.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/cli-runtime/pkg/genericclioptions" 12 | "k8s.io/client-go/kubernetes" 13 | "k8s.io/klog/v2" 14 | 15 | "github.com/jetstack/google-cas-issuer/test/e2e/framework/config" 16 | "github.com/jetstack/google-cas-issuer/test/e2e/util" 17 | ) 18 | 19 | var ( 20 | cfg = config.GetConfig() 21 | kubeClientSet kubernetes.Interface 22 | ) 23 | 24 | func init() { 25 | cfg.AddFlags(flag.CommandLine) 26 | } 27 | 28 | var _ = SynchronizedBeforeSuite(func() []byte { 29 | var err error 30 | cfg.RepoRoot, err = os.Getwd() 31 | if err != nil { 32 | klog.Fatal(err) 33 | } 34 | 35 | cfg.Namespace = "casissuer-e2e-" + util.RandomString(5) 36 | 37 | if err := cfg.Validate(); err != nil { 38 | klog.Fatalf("Invalid test config: %s", err) 39 | } 40 | 41 | clientConfigFlags := genericclioptions.NewConfigFlags(true) 42 | clientConfigFlags.KubeConfig = &cfg.KubeConfigPath 43 | config, err := clientConfigFlags.ToRESTConfig() 44 | if err != nil { 45 | klog.Fatalf("Invalid kube config: %s", err) 46 | } 47 | kubeClientSet, err = kubernetes.NewForConfig(config) 48 | if err != nil { 49 | klog.Fatalf("Couldn't construct client set: %s", err) 50 | } 51 | _, err = kubeClientSet.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: cfg.Namespace}}, metav1.CreateOptions{}) 52 | if err != nil { 53 | klog.Fatalf("Couldn't create namespace %s: %s", cfg.Namespace, err) 54 | } 55 | return nil 56 | }, func([]byte) { 57 | }) 58 | 59 | var _ = SynchronizedAfterSuite(func() {}, 60 | func() { 61 | if kubeClientSet == nil { 62 | return 63 | } 64 | _ = kubeClientSet.CoreV1().Namespaces().Delete(context.TODO(), cfg.Namespace, metav1.DeleteOptions{}) 65 | }, 66 | ) 67 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 6 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - events 12 | verbs: 13 | - create 14 | - patch 15 | - apiGroups: 16 | - "" 17 | resources: 18 | - secrets 19 | verbs: 20 | - get 21 | - list 22 | - watch 23 | - apiGroups: 24 | - cas-issuer.jetstack.io 25 | resources: 26 | - googlecasclusterissuers 27 | verbs: 28 | - create 29 | - delete 30 | - get 31 | - list 32 | - patch 33 | - update 34 | - watch 35 | - apiGroups: 36 | - cas-issuer.jetstack.io 37 | resources: 38 | - googlecasclusterissuers/status 39 | verbs: 40 | - get 41 | - patch 42 | - update 43 | - apiGroups: 44 | - cas-issuer.jetstack.io 45 | resources: 46 | - googlecasissuers 47 | verbs: 48 | - create 49 | - delete 50 | - get 51 | - list 52 | - patch 53 | - update 54 | - watch 55 | - apiGroups: 56 | - cas-issuer.jetstack.io 57 | resources: 58 | - googlecasissuers/status 59 | verbs: 60 | - get 61 | - patch 62 | - update 63 | - apiGroups: 64 | - cert-manager.io 65 | resources: 66 | - certificaterequests 67 | verbs: 68 | - get 69 | - list 70 | - update 71 | - watch 72 | - apiGroups: 73 | - cert-manager.io 74 | resources: 75 | - certificaterequests/status 76 | verbs: 77 | - get 78 | - patch 79 | - update 80 | --- 81 | {{- if .Values.app.approval.enabled }} 82 | apiVersion: rbac.authorization.k8s.io/v1 83 | kind: ClusterRole 84 | metadata: 85 | labels: 86 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 87 | name: {{ include "cert-manager-google-cas-issuer.name" . }}:approval 88 | rules: 89 | - apiGroups: 90 | - cert-manager.io 91 | resourceNames: 92 | - googlecasclusterissuers.cas-issuer.jetstack.io/* 93 | - googlecasissuers.cas-issuer.jetstack.io/* 94 | resources: 95 | - signers 96 | verbs: 97 | - approve 98 | {{- end }} 99 | -------------------------------------------------------------------------------- /.github/workflows/pr_e2e_tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs priviledged e2e tests for untrusted sources (incomming PRs). 2 | name: pr-e2e-tests 3 | on: 4 | pull_request_target: 5 | types: [labeled] 6 | branches: 7 | - main 8 | 9 | jobs: 10 | run_e2e_tests: 11 | # IMPORTANT: we require the ok-to-test label before running the test! 12 | if: contains(github.event.pull_request.labels.*.name, 'ok-to-test') 13 | 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | id-token: write 18 | steps: 19 | - name: Install Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: '^1.19' 23 | 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | with: 27 | # INSECURE, but ok thanks to ok-to-test label. 28 | fetch-depth: 0 29 | ref: ${{ github.event.pull_request.head.ref }} 30 | repository: ${{ github.event.pull_request.head.repo.full_name }} 31 | 32 | - id: 'auth' 33 | name: 'Authenticate to Google Cloud' 34 | uses: 'google-github-actions/auth@v0' 35 | with: 36 | workload_identity_provider: 'projects/874174494201/locations/global/workloadIdentityPools/google-cas-issuer-e2e/providers/google-cas-issuer-e2e' 37 | service_account: 'google-cas-issuer-e2e@jetstack-cas.iam.gserviceaccount.com' 38 | 39 | - name: Run e2e tests 40 | run: make e2e 41 | env: 42 | TEST_GOOGLE_APPLICATION_CREDENTIALS: ${{ steps.auth.outputs.credentials_file_path }} 43 | 44 | - uses: actions/upload-artifact@v3 45 | if: always() 46 | with: 47 | name: e2e-logs 48 | path: _artifacts/e2e/logs 49 | 50 | # remove the ok-to-test label after the e2e test completed 51 | remove-ok-to-test: 52 | name: Remove ok-to-test label 53 | needs: 54 | - run_e2e_tests 55 | runs-on: ubuntu-latest 56 | if: contains(github.event.pull_request.labels.*.name, 'ok-to-test') && always() 57 | steps: 58 | - name: Remove Label 59 | uses: actions-ecosystem/action-remove-labels@v1 60 | with: 61 | labels: 'ok-to-test' 62 | fail_on_error: 'false' 63 | -------------------------------------------------------------------------------- /pkg/controller/certificaterequest/certificaterequest_controller_test.go: -------------------------------------------------------------------------------- 1 | package certificaterequest 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 8 | cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 9 | "github.com/stretchr/testify/assert" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | func TestSanitiseCertificateRequest(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | inputSpec *cmapi.CertificateRequestSpec 17 | expectedSpec *cmapi.CertificateRequestSpec 18 | expectedError error 19 | }{ 20 | { 21 | name: "nil duration should be replaced with default duration", 22 | inputSpec: &cmapi.CertificateRequestSpec{ 23 | Duration: nil, 24 | IssuerRef: cmmeta.ObjectReference{}, 25 | Request: []byte("invalid"), 26 | IsCA: false, 27 | Usages: nil, 28 | }, 29 | expectedSpec: &cmapi.CertificateRequestSpec{ 30 | Duration: &metav1.Duration{Duration: cmapi.DefaultCertificateDuration}, 31 | IssuerRef: cmmeta.ObjectReference{}, 32 | Request: []byte("invalid"), 33 | IsCA: false, 34 | Usages: nil, 35 | }, 36 | expectedError: nil, 37 | }, 38 | { 39 | name: "very short duration should be replaced with minimum default duration", 40 | inputSpec: &cmapi.CertificateRequestSpec{ 41 | Duration: &metav1.Duration{Duration: time.Minute}, 42 | IssuerRef: cmmeta.ObjectReference{}, 43 | Request: []byte("invalid"), 44 | IsCA: false, 45 | Usages: nil, 46 | }, 47 | expectedSpec: &cmapi.CertificateRequestSpec{ 48 | Duration: &metav1.Duration{Duration: cmapi.MinimumCertificateDuration}, 49 | IssuerRef: cmmeta.ObjectReference{}, 50 | Request: []byte("invalid"), 51 | IsCA: false, 52 | Usages: nil, 53 | }, 54 | expectedError: nil, 55 | }, 56 | } 57 | 58 | for _, tt := range tests { 59 | err := sanitiseCertificateRequestSpec(tt.inputSpec) 60 | assert.Equal(t, tt.expectedSpec, tt.inputSpec, "%s failed", tt.name) 61 | assert.Equal(t, tt.expectedError, err, "%s failed", tt.name) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /api/v1beta1/googlecasclusterissuer_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1beta1 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | ) 22 | 23 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 24 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 25 | 26 | // +kubebuilder:object:root=true 27 | // +kubebuilder:resource:scope=Cluster 28 | // +kubebuilder:printcolumn:name="ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" 29 | // +kubebuilder:printcolumn:name="reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason" 30 | // +kubebuilder:printcolumn:name="message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message" 31 | // +kubebuilder:subresource:status 32 | // GoogleCASClusterIssuer is the Schema for the googlecasclusterissuers API 33 | type GoogleCASClusterIssuer struct { 34 | metav1.TypeMeta `json:",inline"` 35 | metav1.ObjectMeta `json:"metadata,omitempty"` 36 | 37 | Spec GoogleCASIssuerSpec `json:"spec,omitempty"` 38 | Status GoogleCASIssuerStatus `json:"status,omitempty"` 39 | } 40 | 41 | // +kubebuilder:object:root=true 42 | // GoogleCASClusterIssuerList contains a list of GoogleCASClusterIssuer 43 | type GoogleCASClusterIssuerList struct { 44 | metav1.TypeMeta `json:",inline"` 45 | metav1.ListMeta `json:"metadata,omitempty"` 46 | Items []GoogleCASClusterIssuer `json:"items"` 47 | } 48 | 49 | func init() { 50 | SchemeBuilder.Register(&GoogleCASClusterIssuer{}, &GoogleCASClusterIssuerList{}) 51 | } 52 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "cert-manager-google-cas-issuer.name" . }} 5 | labels: 6 | {{ include "cert-manager-google-cas-issuer.labels" . | indent 4 }} 7 | {{- with .Values.deploymentAnnotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | spec: 12 | replicas: {{ .Values.replicaCount }} 13 | selector: 14 | matchLabels: 15 | app: {{ include "cert-manager-google-cas-issuer.name" . }} 16 | template: 17 | metadata: 18 | labels: 19 | app: {{ include "cert-manager-google-cas-issuer.name" . }} 20 | {{- include "cert-manager-google-cas-issuer.labels" . | nindent 8 }} 21 | {{- with .Values.podLabels }} 22 | {{- toYaml . | nindent 8 }} 23 | {{- end }} 24 | {{- with .Values.podAnnotations }} 25 | annotations: 26 | {{- toYaml . | nindent 8 }} 27 | {{- end }} 28 | spec: 29 | {{- with .Values.imagePullSecrets }} 30 | imagePullSecrets: 31 | {{- toYaml . | nindent 8 }} 32 | {{- end }} 33 | serviceAccountName: {{ include "cert-manager-google-cas-issuer.name" . }} 34 | {{- with .Values.priorityClassName }} 35 | priorityClassName: {{ . | quote }} 36 | {{- end }} 37 | containers: 38 | - name: {{ include "cert-manager-google-cas-issuer.name" . }} 39 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 40 | imagePullPolicy: {{ .Values.image.pullPolicy }} 41 | ports: 42 | - containerPort: {{ .Values.app.metrics.port }} 43 | command: ["/google-cas-issuer"] 44 | 45 | args: 46 | - --enable-leader-election 47 | - --log-level={{.Values.app.logLevel}} 48 | - --metrics-addr=:{{.Values.app.metrics.port}} 49 | {{- with .Values.resources }} 50 | resources: 51 | {{- toYaml . | nindent 10 }} 52 | {{- end }} 53 | 54 | {{- with .Values.nodeSelector }} 55 | nodeSelector: 56 | {{- toYaml . | nindent 8 }} 57 | {{- end }} 58 | {{- with .Values.affinity }} 59 | affinity: 60 | {{- toYaml . | nindent 8 }} 61 | {{- end }} 62 | {{- with .Values.tolerations }} 63 | tolerations: 64 | {{- toYaml . | nindent 8 }} 65 | {{- end }} 66 | -------------------------------------------------------------------------------- /test/e2e/suite/validation/validation.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "k8s.io/apimachinery/pkg/runtime/serializer/yaml" 11 | 12 | "github.com/jetstack/google-cas-issuer/test/e2e/framework" 13 | ) 14 | 15 | var _ = framework.CasesDescribe("validation", func() { 16 | f := framework.NewDefaultFramework("validation") 17 | It("Has valid kubeconfig", func() { 18 | By("using the provided kubeconfig to list namespaces") 19 | _, err := f.KubeClientSet.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) 20 | Expect(err).NotTo(HaveOccurred()) 21 | }) 22 | 23 | It("Has cert-manager CRDs installed", func() { 24 | By("using the provided CM clientset to get clusterIssuers") 25 | _, err := f.CMClientSet.CertmanagerV1().ClusterIssuers().List(context.TODO(), metav1.ListOptions{}) 26 | Expect(err).NotTo(HaveOccurred()) 27 | }) 28 | 29 | It("Has the google-cas-issuer CRDs installed", func() { 30 | By("using the dynamic client to create a google-cas-issuer") 31 | casYAML := `apiVersion: cas-issuer.jetstack.io/v1beta1 32 | kind: GoogleCASIssuer 33 | metadata: 34 | name: googlecasissuer-sample 35 | namespace: default 36 | spec: 37 | project: project-name 38 | location: europe-west1 39 | caPoolId: some-pool 40 | ` 41 | dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) 42 | apiObject := &unstructured.Unstructured{} 43 | _, gvk, err := dec.Decode([]byte(casYAML), nil, apiObject) 44 | Expect(err).NotTo(HaveOccurred()) 45 | mapping, err := f.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) 46 | Expect(err).NotTo(HaveOccurred()) 47 | 48 | dr := f.DynamicClientSet.Resource(mapping.Resource).Namespace(apiObject.GetNamespace()) 49 | 50 | // Similar to `kubectl create` 51 | _, err = dr.Create(context.TODO(), apiObject, metav1.CreateOptions{}) 52 | Expect(err).NotTo(HaveOccurred()) 53 | 54 | // Similar to `kubectl get` 55 | _, err = dr.Get(context.TODO(), apiObject.GetName(), metav1.GetOptions{}) 56 | Expect(err).NotTo(HaveOccurred()) 57 | 58 | // Similar to `kubectl delete` 59 | err = dr.Delete(context.TODO(), apiObject.GetName(), metav1.DeleteOptions{}) 60 | Expect(err).NotTo(HaveOccurred()) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /hack/casutil/casutil.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var ( 13 | root = &cobra.Command{ 14 | Use: "casutil", 15 | Short: "Utility for Interacting with the Google CAS v1 API", 16 | } 17 | create = &cobra.Command{ 18 | Use: "create", 19 | Short: "create CAs and CA pools", 20 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 21 | showConfig() 22 | }, 23 | } 24 | list = &cobra.Command{ 25 | Use: "list", 26 | Short: "List pools or CAs in a pool", 27 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 28 | showConfig() 29 | }, 30 | } 31 | delete = &cobra.Command{ 32 | Use: "delete", 33 | Short: "Delete pools or CAs in a pool", 34 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 35 | showConfig() 36 | }, 37 | } 38 | enable = &cobra.Command{ 39 | Use: "enable", 40 | Short: "Enable CAs", 41 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 42 | showConfig() 43 | }, 44 | } 45 | ) 46 | 47 | func main() { 48 | root.Execute() 49 | } 50 | 51 | func init() { 52 | cobra.OnInitialize(flagsFromEnv) 53 | root.PersistentFlags().StringP("project", "p", "", "The GCP project in which CAS operations are performed") 54 | root.PersistentFlags().StringP("location", "l", "", "The GCP location") 55 | 56 | createCA.Flags().String("pool", "", "The pool to create the CA in") 57 | listCA.Flags().String("pool", "", "The pool to list CAs in") 58 | deleteCA.Flags().String("pool", "", "The pool to list CAs in") 59 | enableCA.Flags().String("pool", "", "The pool to enable CAs in") 60 | 61 | listCerts.Flags().String("pool", "", "The pool to list CAs in") 62 | 63 | create.AddCommand(createPool, createCA) 64 | list.AddCommand(listPool, listCA, listCerts) 65 | delete.AddCommand(deletePool, deleteCA) 66 | enable.AddCommand(enableCA) 67 | root.AddCommand(create, list, delete, enable) 68 | } 69 | 70 | func fatalIf(err error) { 71 | if err != nil { 72 | fmt.Fprintf(os.Stderr, "%s\n", err.Error()) 73 | os.Exit(1) 74 | } 75 | } 76 | 77 | func flagsFromEnv() { 78 | viper.SetEnvPrefix("casutil") 79 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 80 | viper.AutomaticEnv() 81 | } 82 | 83 | func showConfig() { 84 | fmt.Fprintf(os.Stderr, 85 | "Using project: %s\n"+ 86 | "Using location: %s\n", 87 | viper.GetString("project"), 88 | viper.GetString("location"), 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /test/e2e/framework/framework.go: -------------------------------------------------------------------------------- 1 | package framework 2 | 3 | import ( 4 | cmversioned "github.com/cert-manager/cert-manager/pkg/client/clientset/versioned" 5 | "github.com/jetstack/google-cas-issuer/test/e2e/framework/config" 6 | "github.com/jetstack/google-cas-issuer/test/e2e/framework/helper" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "k8s.io/cli-runtime/pkg/genericclioptions" 10 | "k8s.io/client-go/discovery" 11 | memory "k8s.io/client-go/discovery/cached" 12 | "k8s.io/client-go/dynamic" 13 | "k8s.io/client-go/kubernetes" 14 | "k8s.io/client-go/restmapper" 15 | ) 16 | 17 | type Framework struct { 18 | BaseName string 19 | 20 | KubeClientSet kubernetes.Interface 21 | CMClientSet cmversioned.Interface 22 | DynamicClientSet dynamic.Interface 23 | 24 | DiscoveryClient discovery.DiscoveryInterface 25 | Mapper *restmapper.DeferredDiscoveryRESTMapper 26 | 27 | config *config.Config 28 | helper *helper.Helper 29 | } 30 | 31 | func NewDefaultFramework(baseName string) *Framework { 32 | return NewFramework(baseName, config.GetConfig()) 33 | } 34 | 35 | func NewFramework(baseName string, config *config.Config) *Framework { 36 | f := &Framework{ 37 | BaseName: baseName, 38 | config: config, 39 | } 40 | 41 | JustBeforeEach(f.BeforeEach) 42 | 43 | return f 44 | } 45 | 46 | func (f *Framework) BeforeEach() { 47 | By("Creating a kubernetes client") 48 | clientConfigFlags := genericclioptions.NewConfigFlags(true) 49 | clientConfigFlags.KubeConfig = &f.config.KubeConfigPath 50 | config, err := clientConfigFlags.ToRESTConfig() 51 | Expect(err).NotTo(HaveOccurred()) 52 | 53 | f.KubeClientSet, err = kubernetes.NewForConfig(config) 54 | Expect(err).NotTo(HaveOccurred()) 55 | 56 | By("Creating a cert-manager client") 57 | f.CMClientSet, err = cmversioned.NewForConfig(config) 58 | Expect(err).NotTo(HaveOccurred()) 59 | 60 | By("Creating a dynamic client") 61 | f.DynamicClientSet, err = dynamic.NewForConfig(config) 62 | Expect(err).NotTo(HaveOccurred()) 63 | 64 | By("Creating a discovery client") 65 | f.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(config) 66 | Expect(err).NotTo(HaveOccurred()) 67 | 68 | f.Mapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(f.DiscoveryClient)) 69 | 70 | f.helper = helper.NewHelper(f.CMClientSet, f.KubeClientSet, f.DynamicClientSet) 71 | } 72 | 73 | func (f *Framework) Helper() *helper.Helper { 74 | return f.helper 75 | } 76 | 77 | func (f *Framework) Config() *config.Config { 78 | return f.config 79 | } 80 | 81 | func CasesDescribe(text string, body func()) bool { 82 | return Describe("[jetstack google-cas-issuer] "+text, body) 83 | } 84 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Image URL to use all building/pushing image targets 2 | IMG ?= quay.io/jetstack/cert-manager-google-cas-issuer:$(shell git rev-parse HEAD) 3 | 4 | BINDIR ?= $(CURDIR)/bin 5 | ARCH=$(shell go env GOARCH) 6 | OS=$(shell go env GOOS) 7 | 8 | ARTIFACTS_DIR ?= _artifacts 9 | HELM_VERSION ?= 3.9.4 10 | CRDS_DIR=$(CURDIR)/deploy/charts/google-cas-issuer/templates/crds/ 11 | 12 | all: google-cas-issuer 13 | 14 | .PHONY: clean 15 | clean: ## clean up created files 16 | rm -rf $(BINDIR) $(ARTIFACTS_DIR) 17 | 18 | # Run tests 19 | test: generate fmt vet helm-docs manifests 20 | go test ./api/... ./pkg/... ./cmd/... -coverprofile cover.out 21 | 22 | .PHONY: e2e 23 | e2e: depend docker-build 24 | ./hack/ci/run-e2e.sh 25 | 26 | # Build google-cas-issuer binary 27 | google-cas-issuer: generate fmt vet 28 | go build -o bin/google-cas-issuer main.go 29 | 30 | # Run against the configured Kubernetes cluster in ~/.kube/config 31 | run: generate fmt vet manifests 32 | go run ./main.go --log-level=5 33 | 34 | # Generate CRDs 35 | manifests: depend 36 | $(CONTROLLER_GEN) crd schemapatch:manifests=$(CRDS_DIR) output:dir=$(CRDS_DIR) paths=./api/... 37 | 38 | # Run go fmt against code 39 | fmt: 40 | go fmt ./... 41 | 42 | # Run go vet against code 43 | vet: 44 | go vet ./... 45 | 46 | .PHONY: helm-docs 47 | helm-docs: $(BINDIR)/helm-docs # verify helm-docs 48 | ./hack/verify-helm-docs.sh 49 | 50 | .PHONY: update-helm-docs 51 | update-helm-docs: $(BINDIR)/helm-docs # update helm-docs 52 | ./hack/update-helm-docs.sh 53 | 54 | # Generate code 55 | generate: depend 56 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 57 | 58 | # Build the docker image 59 | docker-build: test 60 | docker build . -t ${IMG} 61 | 62 | # Push the docker image 63 | docker-push: 64 | docker push ${IMG} 65 | 66 | .PHONY: depend 67 | depend: $(BINDIR) $(BINDIR)/kind $(BINDIR)/helm $(BINDIR)/kubectl $(BINDIR)/ginkgo $(BINDIR)/controller-gen $(BINDIR)/helm-docs 68 | 69 | $(BINDIR): 70 | mkdir -p ./bin 71 | 72 | $(BINDIR)/controller-gen: 73 | cd hack/tools && go build -o $@ sigs.k8s.io/controller-tools/cmd/controller-gen 74 | CONTROLLER_GEN=$(BINDIR)/controller-gen 75 | 76 | $(BINDIR)/kind: 77 | cd hack/tools && go build -o $@ sigs.k8s.io/kind 78 | KIND=$(BINDIR)/kind 79 | 80 | $(BINDIR)/ginkgo: 81 | cd hack/tools && go build -o $@ github.com/onsi/ginkgo/v2/ginkgo 82 | GINKGO=$(BINDIR)/ginkgo 83 | 84 | # find or download kubectl 85 | $(BINDIR)/kubectl: 86 | curl -o $@ -LO "https://storage.googleapis.com/kubernetes-release/release/$(shell curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/$(OS)/$(ARCH)/kubectl" 87 | chmod +x $@ 88 | KUBECTL=$(BINDIR)/kubectl 89 | 90 | $(BINDIR)/helm: 91 | curl -o $(BINDIR)/helm.tar.gz -LO "https://get.helm.sh/helm-v$(HELM_VERSION)-$(OS)-$(ARCH).tar.gz" 92 | tar -C $(BINDIR) -xzf $(BINDIR)/helm.tar.gz 93 | cp $(BINDIR)/$(OS)-$(ARCH)/helm $(BINDIR)/helm 94 | rm -r $(BINDIR)/$(OS)-$(ARCH) $(BINDIR)/helm.tar.gz 95 | HELM=$(BINDIR)/helm 96 | 97 | $(BINDIR)/helm-docs: 98 | cd hack/tools && go build -o $(BINDIR)/helm-docs github.com/norwoodj/helm-docs/cmd/helm-docs 99 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/values.yaml: -------------------------------------------------------------------------------- 1 | # -- Number of replicas of google-cas-issuer to run. 2 | replicaCount: 1 3 | 4 | image: 5 | # -- Target image repository. 6 | repository: quay.io/jetstack/cert-manager-google-cas-issuer 7 | # -- Target image version tag. 8 | tag: 0.6.2 9 | # -- Kubernetes imagePullPolicy on Deployment. 10 | pullPolicy: IfNotPresent 11 | 12 | # -- Optional secrets used for pulling the google-cas-issuer container image. 13 | imagePullSecrets: [] 14 | 15 | # -- Labels to apply to all resources 16 | commonLabels: {} 17 | 18 | serviceAccount: 19 | # -- Optional annotations to add to the service account 20 | annotations: {} 21 | 22 | app: 23 | # -- Verbosity of google-cas-issuer logging. 24 | logLevel: 1 # 1-5 25 | 26 | # -- Handle RBAC permissions for approving Google CAS issuer 27 | # CertificateRequests. 28 | approval: 29 | # -- enabled determines whether the ClusterRole and ClusterRoleBinding for 30 | # approval is created. You will want to disable this if you are managing 31 | # approval RBAC elsewhere from this chart, for example if you create them 32 | # separately for all installed issuers. 33 | enabled: true 34 | # -- subjects is the subject that the approval RBAC permissions will be 35 | # bound to. Here we are binding them to cert-manager's ServiceAccount so 36 | # that the default approve all approver has the permissions to do so. You 37 | # will want to change this subject to approver-policy's ServiceAccount if 38 | # using that project (recommended). 39 | # https://cert-manager.io/docs/projects/approver-policy 40 | # name: cert-manager-approver-policy 41 | # namespace: cert-manager 42 | subjects: 43 | - kind: ServiceAccount 44 | name: cert-manager 45 | namespace: cert-manager 46 | 47 | # metrics controls exposing google-cas-issuer metrics. 48 | metrics: 49 | # -- Port for exposing Prometheus metrics on 0.0.0.0 on path '/metrics'. 50 | port: 9402 51 | 52 | # -- Optional additional annotations to add to the google-cas-issuer Deployment 53 | deploymentAnnotations: {} 54 | 55 | # -- Optional additional annotations to add to the google-cas-issuer Pods 56 | podAnnotations: {} 57 | 58 | # -- Optional additional labels to add to the google-cas-issuer Pods 59 | podLabels: {} 60 | 61 | # -- Kubernetes pod resource requests/limits for google-cas-issuer. 62 | resources: {} 63 | # limits: 64 | # cpu: 100m 65 | # memory: 128Mi 66 | # requests: 67 | # cpu: 100m 68 | # memory: 128Mi 69 | 70 | # -- Kubernetes node selector: node labels for pod assignment 71 | nodeSelector: {} 72 | # -- Allow scheduling of DaemonSet on linux nodes only 73 | # kubernetes.io/os: linux 74 | 75 | # -- Kubernetes affinity: constraints for pod assignment 76 | affinity: {} 77 | # nodeAffinity: 78 | # requiredDuringSchedulingIgnoredDuringExecution: 79 | # nodeSelectorTerms: 80 | # - matchExpressions: 81 | # - key: foo.bar.com/role 82 | # operator: In 83 | # values: 84 | # - master 85 | 86 | # -- Kubernetes pod tolerations for google-cas-issuer 87 | tolerations: [] 88 | # -- Allow scheduling of DaemonSet on all nodes 89 | # - operator: "Exists" 90 | 91 | # -- Optional priority class to be used for the google-cas-issuer pods. 92 | priorityClassName: "" 93 | -------------------------------------------------------------------------------- /hack/tools/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jetstack/google-cas-issuer/hack/tools 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/norwoodj/helm-docs v1.11.0 7 | github.com/onsi/ginkgo/v2 v2.1.4 8 | sigs.k8s.io/controller-tools v0.9.2 9 | sigs.k8s.io/kind v0.14.0 10 | ) 11 | 12 | require ( 13 | github.com/BurntSushi/toml v1.0.0 // indirect 14 | github.com/Masterminds/goutils v1.1.1 // indirect 15 | github.com/Masterminds/semver/v3 v3.1.1 // indirect 16 | github.com/Masterminds/sprig/v3 v3.2.2 // indirect 17 | github.com/alessio/shellescape v1.4.1 // indirect 18 | github.com/evanphx/json-patch/v5 v5.6.0 // indirect 19 | github.com/fatih/color v1.12.0 // indirect 20 | github.com/fsnotify/fsnotify v1.4.9 // indirect 21 | github.com/go-logr/logr v1.2.0 // indirect 22 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect 23 | github.com/gobuffalo/flect v0.2.5 // indirect 24 | github.com/gobwas/glob v0.2.3 // indirect 25 | github.com/gogo/protobuf v1.3.2 // indirect 26 | github.com/google/gofuzz v1.1.0 // indirect 27 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect 28 | github.com/google/uuid v1.1.2 // indirect 29 | github.com/hashicorp/hcl v1.0.0 // indirect 30 | github.com/huandu/xstrings v1.3.1 // indirect 31 | github.com/imdario/mergo v0.3.11 // indirect 32 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 33 | github.com/json-iterator/go v1.1.12 // indirect 34 | github.com/magiconair/properties v1.8.1 // indirect 35 | github.com/mattn/go-colorable v0.1.8 // indirect 36 | github.com/mattn/go-isatty v0.0.14 // indirect 37 | github.com/mitchellh/copystructure v1.0.0 // indirect 38 | github.com/mitchellh/mapstructure v1.4.1 // indirect 39 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 41 | github.com/modern-go/reflect2 v1.0.2 // indirect 42 | github.com/pelletier/go-toml v1.9.4 // indirect 43 | github.com/pkg/errors v0.9.1 // indirect 44 | github.com/shopspring/decimal v1.2.0 // indirect 45 | github.com/sirupsen/logrus v1.8.1 // indirect 46 | github.com/spf13/afero v1.6.0 // indirect 47 | github.com/spf13/cast v1.3.1 // indirect 48 | github.com/spf13/cobra v1.4.0 // indirect 49 | github.com/spf13/jwalterweatherman v1.0.0 // indirect 50 | github.com/spf13/pflag v1.0.5 // indirect 51 | github.com/spf13/viper v1.7.0 // indirect 52 | github.com/subosito/gotenv v1.2.0 // indirect 53 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect 54 | golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect 55 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect 56 | golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f // indirect 57 | golang.org/x/text v0.3.7 // indirect 58 | golang.org/x/tools v0.1.10 // indirect 59 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 60 | gopkg.in/inf.v0 v0.9.1 // indirect 61 | gopkg.in/ini.v1 v1.51.0 // indirect 62 | gopkg.in/yaml.v2 v2.4.0 // indirect 63 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 64 | k8s.io/api v0.24.0 // indirect 65 | k8s.io/apiextensions-apiserver v0.24.0 // indirect 66 | k8s.io/apimachinery v0.24.0 // indirect 67 | k8s.io/helm v2.14.3+incompatible // indirect 68 | k8s.io/klog/v2 v2.60.1 // indirect 69 | k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect 70 | sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect 71 | sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect 72 | sigs.k8s.io/yaml v1.3.0 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/README.md: -------------------------------------------------------------------------------- 1 | # cert-manager-google-cas-issuer 2 | 3 | ![Version: v0.6.2](https://img.shields.io/badge/Version-v0.6.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v0.6.2](https://img.shields.io/badge/AppVersion-v0.6.2-informational?style=flat-square) 4 | 5 | A Helm chart for jetstack/google-cas-issuer 6 | 7 | **Homepage:** 8 | 9 | ## Maintainers 10 | 11 | | Name | Email | Url | 12 | | ---- | ------ | --- | 13 | | jetstack | | | 14 | 15 | ## Source Code 16 | 17 | * 18 | 19 | ## Values 20 | 21 | | Key | Type | Default | Description | 22 | |-----|------|---------|-------------| 23 | | affinity | object | `{}` | Kubernetes affinity: constraints for pod assignment | 24 | | app.approval | object | `{"enabled":true,"subjects":[{"kind":"ServiceAccount","name":"cert-manager","namespace":"cert-manager"}]}` | Handle RBAC permissions for approving Google CAS issuer CertificateRequests. | 25 | | app.approval.enabled | bool | `true` | enabled determines whether the ClusterRole and ClusterRoleBinding for approval is created. You will want to disable this if you are managing approval RBAC elsewhere from this chart, for example if you create them separately for all installed issuers. | 26 | | app.approval.subjects | list | `[{"kind":"ServiceAccount","name":"cert-manager","namespace":"cert-manager"}]` | subjects is the subject that the approval RBAC permissions will be bound to. Here we are binding them to cert-manager's ServiceAccount so that the default approve all approver has the permissions to do so. You will want to change this subject to approver-policy's ServiceAccount if using that project (recommended). https://cert-manager.io/docs/projects/approver-policy name: cert-manager-approver-policy namespace: cert-manager | 27 | | app.logLevel | int | `1` | Verbosity of google-cas-issuer logging. | 28 | | app.metrics.port | int | `9402` | Port for exposing Prometheus metrics on 0.0.0.0 on path '/metrics'. | 29 | | commonLabels | object | `{}` | Labels to apply to all resources | 30 | | deploymentAnnotations | object | `{}` | Optional additional annotations to add to the google-cas-issuer Deployment | 31 | | image.pullPolicy | string | `"IfNotPresent"` | Kubernetes imagePullPolicy on Deployment. | 32 | | image.repository | string | `"quay.io/jetstack/cert-manager-google-cas-issuer"` | Target image repository. | 33 | | image.tag | string | `"0.6.2"` | Target image version tag. | 34 | | imagePullSecrets | list | `[]` | Optional secrets used for pulling the google-cas-issuer container image. | 35 | | nodeSelector | object | `{}` | Kubernetes node selector: node labels for pod assignment | 36 | | podAnnotations | object | `{}` | Optional additional annotations to add to the google-cas-issuer Pods | 37 | | podLabels | object | `{}` | Optional additional labels to add to the google-cas-issuer Pods | 38 | | priorityClassName | string | `""` | Optional priority class to be used for the google-cas-issuer pods. | 39 | | replicaCount | int | `1` | Number of replicas of google-cas-issuer to run. | 40 | | resources | object | `{}` | Kubernetes pod resource requests/limits for google-cas-issuer. | 41 | | serviceAccount.annotations | object | `{}` | Optional annotations to add to the service account | 42 | | tolerations | list | `[]` | Kubernetes pod tolerations for google-cas-issuer | 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | docker_build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | name: Checkout 12 | uses: actions/checkout@v2 13 | - 14 | name: Docker meta 15 | id: docker_meta 16 | uses: crazy-max/ghaction-docker-meta@v1 17 | with: 18 | images: quay.io/jetstack/cert-manager-google-cas-issuer 19 | tag-semver: | 20 | {{version}} 21 | {{major}}.{{minor}} 22 | - 23 | name: Set up QEMU 24 | uses: docker/setup-qemu-action@v1 25 | - 26 | name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v1 28 | - 29 | name: Login to quay.io 30 | uses: docker/login-action@v2 31 | with: 32 | registry: quay.io 33 | username: ${{ secrets.QUAY_USERNAME }} 34 | password: ${{ secrets.QUAY_PASSWORD }} 35 | - 36 | name: Build and push 37 | uses: docker/build-push-action@v2 38 | if: ${{ !env.ACT }} 39 | with: 40 | context: . 41 | file: ./Dockerfile 42 | platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le 43 | push: true 44 | tags: ${{ steps.docker_meta.outputs.tags }} 45 | 46 | 47 | prepare_release: 48 | runs-on: ubuntu-latest 49 | container: golang:1.19 50 | steps: 51 | - 52 | name: extract version 53 | id: extract_version 54 | run: /bin/bash -c 'echo ::set-output name=VERSION::$(echo ${GITHUB_REF##*/} | cut -c2-)' 55 | - 56 | name: install controller-gen 57 | run: go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.5.0 58 | - 59 | name: checkout repository 60 | uses: actions/checkout@v2 61 | - 62 | name: install helm 63 | shell: bash 64 | run: make depend 65 | - 66 | name: generate deployment manifests 67 | id: deploy 68 | shell: bash 69 | run: | 70 | ./bin/helm template deploy/charts/google-cas-issuer --set image.tag=${GITHUB_REF##*/} | tee google-cas-issuer-${GITHUB_REF##*/}.yaml 71 | - 72 | name: create release 73 | id: create_release 74 | uses: actions/create-release@v1 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | with: 78 | tag_name: ${{ github.ref }} 79 | release_name: Release ${{ github.ref }} 80 | draft: true 81 | prerelease: false 82 | body: | 83 | Docker images are available at `quay.io/jetstack/cert-manager-google-cas-issuer:${{ steps.extract_version.outputs.VERSION }}` 84 | 85 | One-line install: `kubectl apply -f https://github.com/jetstack/google-cas-issuer/releases/download/v${{ steps.extract_version.outputs.VERSION }}/google-cas-issuer-v${{ steps.extract_version.outputs.VERSION }}.yaml` 86 | - name: Upload Release Asset 87 | id: upload-release-asset 88 | uses: actions/upload-release-asset@v1 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | with: 92 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 93 | asset_path: ./google-cas-issuer-v${{ steps.extract_version.outputs.VERSION }}.yaml 94 | asset_name: google-cas-issuer-v${{ steps.extract_version.outputs.VERSION }}.yaml 95 | asset_content_type: application/x-yaml 96 | 97 | -------------------------------------------------------------------------------- /pkg/controller/issuer/googlecasissuer_controller_test.go: -------------------------------------------------------------------------------- 1 | package issuer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | issuersv1beta1 "github.com/jetstack/google-cas-issuer/api/v1beta1" 9 | ) 10 | 11 | func TestSetReadyCondition(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | inputStatus *issuersv1beta1.GoogleCASIssuerStatus 15 | inputConditionStatus issuersv1beta1.ConditionStatus 16 | inputReason string 17 | inputMessage string 18 | expectedStatus *issuersv1beta1.GoogleCASIssuerStatus 19 | }{ 20 | { 21 | name: "Status with nil condition should be set", 22 | inputStatus: &issuersv1beta1.GoogleCASIssuerStatus{Conditions: nil}, 23 | inputConditionStatus: issuersv1beta1.ConditionTrue, 24 | inputReason: "Test Ready Reason", 25 | inputMessage: "Test Ready Message", 26 | expectedStatus: &issuersv1beta1.GoogleCASIssuerStatus{ 27 | Conditions: []issuersv1beta1.GoogleCASIssuerCondition{{ 28 | Type: issuersv1beta1.IssuerConditionReady, 29 | Status: issuersv1beta1.ConditionTrue, 30 | LastTransitionTime: nil, 31 | Reason: "Test Ready Reason", 32 | Message: "Test Ready Message", 33 | }}, 34 | }, 35 | }, 36 | { 37 | name: "Status can transition from Ready to Not Ready", 38 | inputStatus: &issuersv1beta1.GoogleCASIssuerStatus{ 39 | Conditions: []issuersv1beta1.GoogleCASIssuerCondition{ 40 | { 41 | Type: issuersv1beta1.IssuerConditionReady, 42 | Status: issuersv1beta1.ConditionTrue, 43 | LastTransitionTime: nil, 44 | Reason: "I was Ready before", 45 | Message: "Test Ready Message", 46 | }, 47 | }, 48 | }, 49 | inputConditionStatus: issuersv1beta1.ConditionFalse, 50 | inputReason: "I'm not ready now reason", 51 | inputMessage: "I'm not ready now message", 52 | expectedStatus: &issuersv1beta1.GoogleCASIssuerStatus{ 53 | Conditions: []issuersv1beta1.GoogleCASIssuerCondition{{ 54 | Type: issuersv1beta1.IssuerConditionReady, 55 | Status: issuersv1beta1.ConditionFalse, 56 | LastTransitionTime: nil, 57 | Reason: "I'm not ready now reason", 58 | Message: "I'm not ready now message", 59 | }}, 60 | }, 61 | }, 62 | { 63 | name: "Status can transition from Not Ready to Ready", 64 | inputStatus: &issuersv1beta1.GoogleCASIssuerStatus{ 65 | Conditions: []issuersv1beta1.GoogleCASIssuerCondition{ 66 | { 67 | Type: issuersv1beta1.IssuerConditionReady, 68 | Status: issuersv1beta1.ConditionFalse, 69 | LastTransitionTime: nil, 70 | Reason: "I was not ready before", 71 | Message: "Test Ready Message", 72 | }, 73 | }, 74 | }, 75 | inputConditionStatus: issuersv1beta1.ConditionTrue, 76 | inputReason: "I'm ready now reason", 77 | inputMessage: "I'm ready now message", 78 | expectedStatus: &issuersv1beta1.GoogleCASIssuerStatus{ 79 | Conditions: []issuersv1beta1.GoogleCASIssuerCondition{{ 80 | Type: issuersv1beta1.IssuerConditionReady, 81 | Status: issuersv1beta1.ConditionTrue, 82 | LastTransitionTime: nil, 83 | Reason: "I'm ready now reason", 84 | Message: "I'm ready now message", 85 | }}, 86 | }, 87 | }, 88 | } 89 | for _, tt := range tests { 90 | status := tt.inputStatus.DeepCopy() 91 | setReadyCondition(status, tt.inputConditionStatus, tt.inputReason, tt.inputMessage) 92 | // ignore time.now 93 | for i := range status.Conditions { 94 | status.Conditions[i].LastTransitionTime = nil 95 | } 96 | assert.Equal(t, tt.expectedStatus, status, "%s failed", tt.name) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /hack/casutil/capool.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | privateca "cloud.google.com/go/security/privateca/apiv1" 5 | "context" 6 | "fmt" 7 | "github.com/google/uuid" 8 | "github.com/olekukonko/tablewriter" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "google.golang.org/api/iterator" 12 | privatecaapi "google.golang.org/genproto/googleapis/cloud/security/privateca/v1" 13 | "os" 14 | "path" 15 | ) 16 | 17 | var ( 18 | createPool = &cobra.Command{ 19 | Use: "pool ", 20 | Short: "Create CA pool", 21 | Run: func(cmd *cobra.Command, args []string) { 22 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 23 | fatalIf(err) 24 | defer c.Close() 25 | 26 | op, err := c.CreateCaPool(context.Background(), &privatecaapi.CreateCaPoolRequest{ 27 | Parent: fmt.Sprintf( 28 | "projects/%s/locations/%s", 29 | viper.GetString("project"), 30 | viper.GetString("location"), 31 | ), 32 | CaPoolId: args[0], 33 | CaPool: &privatecaapi.CaPool{ 34 | Tier: privatecaapi.CaPool_ENTERPRISE, 35 | IssuancePolicy: nil, 36 | PublishingOptions: nil, 37 | Labels: nil, 38 | }, 39 | RequestId: uuid.New().String(), 40 | }) 41 | fatalIf(err) 42 | 43 | resp, err := op.Wait(context.Background()) 44 | fatalIf(err) 45 | 46 | fmt.Printf("Created pool %s\n", resp.Name) 47 | }, 48 | Args: cobra.ExactArgs(1), 49 | } 50 | listPool = &cobra.Command{ 51 | Use: "pools", 52 | Short: "list all pools in project / location", 53 | Args: cobra.ExactArgs(0), 54 | Run: func(cmd *cobra.Command, args []string) { 55 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 56 | fatalIf(err) 57 | defer c.Close() 58 | it := c.ListCaPools(context.Background(), &privatecaapi.ListCaPoolsRequest{ 59 | Parent: fmt.Sprintf( 60 | "projects/%s/locations/%s", 61 | viper.GetString("project"), 62 | viper.GetString("location"), 63 | ), 64 | }) 65 | var data [][]string 66 | for { 67 | resp, err := it.Next() 68 | if err == iterator.Done { 69 | break 70 | } 71 | fatalIf(err) 72 | var tier string 73 | switch resp.Tier { 74 | case privatecaapi.CaPool_TIER_UNSPECIFIED: 75 | tier = "Unspecified" 76 | case privatecaapi.CaPool_ENTERPRISE: 77 | tier = "Enterprise" 78 | case privatecaapi.CaPool_DEVOPS: 79 | tier = "Devops" 80 | default: 81 | tier = fmt.Sprintf("", resp.Tier) 82 | } 83 | name := path.Base(resp.Name) 84 | data = append(data, []string{name, tier}) 85 | } 86 | t := tablewriter.NewWriter(os.Stdout) 87 | t.SetHeader([]string{"Name", "Tier"}) 88 | for _, d := range data { 89 | t.Append(d) 90 | } 91 | t.Render() 92 | }, 93 | } 94 | deletePool = &cobra.Command{ 95 | Use: "pool ", 96 | Short: "delete CA pool", 97 | Args: cobra.ExactArgs(1), 98 | Run: func(cmd *cobra.Command, args []string) { 99 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 100 | fatalIf(err) 101 | defer c.Close() 102 | op, err := c.DeleteCaPool(context.Background(), &privatecaapi.DeleteCaPoolRequest{ 103 | Name: fmt.Sprintf( 104 | "projects/%s/locations/%s/caPools/%s", 105 | viper.GetString("project"), 106 | viper.GetString("location"), 107 | args[0], 108 | ), 109 | RequestId: uuid.New().String(), 110 | }) 111 | fatalIf(err) 112 | err = op.Wait(context.Background()) 113 | // Seems this always errors with 114 | // mismatched message type: got "google.protobuf.Empty" want "google.cloud.security.privateca.v1.CaPool" 115 | // but the call succeeds? 116 | fatalIf(err) 117 | fmt.Printf("deleted projects/%s/locations/%s/caPools/%s\n", viper.GetString("project"), 118 | viper.GetString("location"), args[0]) 119 | }, 120 | } 121 | ) 122 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cmd 18 | 19 | import ( 20 | "flag" 21 | 22 | cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 23 | "github.com/spf13/cobra" 24 | "github.com/spf13/viper" 25 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 26 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 27 | "k8s.io/klog/v2" 28 | "k8s.io/klog/v2/klogr" 29 | ctrl "sigs.k8s.io/controller-runtime" 30 | 31 | issuersv1beta1 "github.com/jetstack/google-cas-issuer/api/v1beta1" 32 | "github.com/jetstack/google-cas-issuer/pkg/controller/certificaterequest" 33 | "github.com/jetstack/google-cas-issuer/pkg/controller/issuer" 34 | ) 35 | 36 | var ( 37 | rootCmd = &cobra.Command{ 38 | Use: "google-cas-issuer", 39 | Short: "An external issuer for cert-manager that signs certificates with Google CAS", 40 | Long: "An external issuer for cert-manager that signs certificates with Google CAS.", 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | return root() 43 | }, 44 | } 45 | ) 46 | 47 | func init() { 48 | // Issuer flags 49 | rootCmd.PersistentFlags().String("metrics-addr", ":8080", "The address the metric endpoint binds to.") 50 | rootCmd.PersistentFlags().Bool("enable-leader-election", false, "Enable leader election for controller manager.") 51 | rootCmd.PersistentFlags().String("leader-election-id", "cm-google-cas-issuer", "The ID of the leader election lock that the controller should attempt to acquire.") 52 | rootCmd.PersistentFlags().String("cluster-resource-namespace", "cert-manager", "The namespace for secrets in which cluster-scoped resources are found.") 53 | rootCmd.PersistentFlags().Bool("disable-approval-check", false, "Don't check whether a CertificateRequest is approved before signing. For compatibility with cert-manager sigs.k8s.io/gateway-api v0.4.3 116 | -------------------------------------------------------------------------------- /api/v1beta1/googlecasissuer_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1beta1 18 | 19 | import ( 20 | cmmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 25 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 26 | 27 | // GoogleCASIssuerSpec defines the desired state of GoogleCASIssuer 28 | type GoogleCASIssuerSpec struct { 29 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 30 | // Important: Run "make" to regenerate code after modifying this file 31 | 32 | // Project is the Google Cloud Project ID 33 | Project string `json:"project,omitempty"` 34 | 35 | // Location is the Google Cloud Project Location 36 | Location string `json:"location,omitempty"` 37 | 38 | // CaPoolId is the id of the CA pool to issue certificates from 39 | CaPoolId string `json:"caPoolId,omitempty"` 40 | 41 | // CertificateAuthorityId is specific certificate authority to 42 | // use to sign. Omit in order to load balance across all CAs 43 | // in the pool 44 | // +optional 45 | CertificateAuthorityId string `json:"certificateAuthorityId,omitempty"` 46 | 47 | // Credentials is a reference to a Kubernetes Secret Key that contains Google Service Account Credentials 48 | // +optional 49 | Credentials cmmetav1.SecretKeySelector `json:"credentials,omitempty"` 50 | 51 | // CertificateTemplate is specific certificate template to 52 | // use. Omit to not specify a template 53 | // +optional 54 | CertificateTemplate string `json:"certificateTemplate,omitempty"` 55 | } 56 | 57 | // GoogleCASIssuerStatus defines the observed state of GoogleCASIssuer 58 | type GoogleCASIssuerStatus struct { 59 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 60 | // Important: Run "make" to regenerate code after modifying this file 61 | 62 | // +optional 63 | Conditions []GoogleCASIssuerCondition `json:"conditions,omitempty"` 64 | } 65 | 66 | // +kubebuilder:object:root=true 67 | // +kubebuilder:printcolumn:name="ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" 68 | // +kubebuilder:printcolumn:name="reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason" 69 | // +kubebuilder:printcolumn:name="message",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message" 70 | // +kubebuilder:subresource:status 71 | // GoogleCASIssuer is the Schema for the googlecasissuers API 72 | type GoogleCASIssuer struct { 73 | metav1.TypeMeta `json:",inline"` 74 | metav1.ObjectMeta `json:"metadata,omitempty"` 75 | 76 | Spec GoogleCASIssuerSpec `json:"spec,omitempty"` 77 | Status GoogleCASIssuerStatus `json:"status,omitempty"` 78 | } 79 | 80 | // +kubebuilder:object:root=true 81 | // GoogleCASIssuerList contains a list of GoogleCASIssuer 82 | type GoogleCASIssuerList struct { 83 | metav1.TypeMeta `json:",inline"` 84 | metav1.ListMeta `json:"metadata,omitempty"` 85 | Items []GoogleCASIssuer `json:"items"` 86 | } 87 | 88 | // +kubebuilder:validation:Enum=Ready 89 | type GoogleCASIssuerConditionType string 90 | 91 | const ( 92 | // IssuerConditionReady indicates that a CAS Issuer is ready for use. 93 | // This is defined as: 94 | IssuerConditionReady GoogleCASIssuerConditionType = "Ready" 95 | ) 96 | 97 | // ConditionStatus represents a condition's status. 98 | // +kubebuilder:validation:Enum=True;False;Unknown 99 | type ConditionStatus string 100 | 101 | // These are valid condition statuses. "ConditionTrue" means a resource is in 102 | // the condition; "ConditionFalse" means a resource is not in the condition; 103 | // "ConditionUnknown" means kubernetes can't decide if a resource is in the 104 | // condition or not. In the future, we could add other intermediate 105 | // conditions, e.g. ConditionDegraded. 106 | const ( 107 | // ConditionTrue represents the fact that a given condition is true 108 | ConditionTrue ConditionStatus = "True" 109 | 110 | // ConditionFalse represents the fact that a given condition is false 111 | ConditionFalse ConditionStatus = "False" 112 | 113 | // ConditionUnknown represents the fact that a given condition is unknown 114 | ConditionUnknown ConditionStatus = "Unknown" 115 | ) 116 | 117 | // IssuerCondition contains condition information for a CAS Issuer. 118 | type GoogleCASIssuerCondition struct { 119 | // Type of the condition, currently ('Ready'). 120 | Type GoogleCASIssuerConditionType `json:"type"` 121 | 122 | // Status of the condition, one of ('True', 'False', 'Unknown'). 123 | // +kubebuilder:validation:Enum=True;False;Unknown 124 | Status ConditionStatus `json:"status"` 125 | 126 | // LastTransitionTime is the timestamp corresponding to the last status 127 | // change of this condition. 128 | // +optional 129 | LastTransitionTime *metav1.Time `json:"lastTransitionTime,omitempty"` 130 | 131 | // Reason is a brief machine readable explanation for the condition's last 132 | // transition. 133 | // +optional 134 | Reason string `json:"reason,omitempty"` 135 | 136 | // Message is a human readable description of the details of the last 137 | // transition, complementing reason. 138 | // +optional 139 | Message string `json:"message,omitempty"` 140 | } 141 | 142 | func init() { 143 | SchemeBuilder.Register(&GoogleCASIssuer{}, &GoogleCASIssuerList{}) 144 | } 145 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/crds/cas-issuer.jetstack.io_googlecasissuers.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | controller-gen.kubebuilder.io/version: v0.9.2 6 | creationTimestamp: null 7 | name: googlecasissuers.cas-issuer.jetstack.io 8 | spec: 9 | group: cas-issuer.jetstack.io 10 | names: 11 | kind: GoogleCASIssuer 12 | listKind: GoogleCASIssuerList 13 | plural: googlecasissuers 14 | singular: googlecasissuer 15 | scope: Namespaced 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .status.conditions[?(@.type=='Ready')].status 19 | name: ready 20 | type: string 21 | - jsonPath: .status.conditions[?(@.type=='Ready')].reason 22 | name: reason 23 | type: string 24 | - jsonPath: .status.conditions[?(@.type=='Ready')].message 25 | name: message 26 | type: string 27 | name: v1beta1 28 | schema: 29 | openAPIV3Schema: 30 | description: GoogleCASIssuer is the Schema for the googlecasissuers API 31 | type: object 32 | properties: 33 | apiVersion: 34 | description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 35 | type: string 36 | kind: 37 | description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 38 | type: string 39 | metadata: 40 | type: object 41 | spec: 42 | description: GoogleCASIssuerSpec defines the desired state of GoogleCASIssuer 43 | type: object 44 | properties: 45 | caPoolId: 46 | description: CaPoolId is the id of the CA pool to issue certificates from 47 | type: string 48 | certificateAuthorityId: 49 | description: CertificateAuthorityId is specific certificate authority to use to sign. Omit in order to load balance across all CAs in the pool 50 | type: string 51 | certificateTemplate: 52 | description: CertificateTemplate is specific certificate template to use. Omit to not specify a template 53 | type: string 54 | credentials: 55 | description: Credentials is a reference to a Kubernetes Secret Key that contains Google Service Account Credentials 56 | type: object 57 | required: 58 | - name 59 | properties: 60 | key: 61 | description: The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be defaulted, in others it may be required. 62 | type: string 63 | name: 64 | description: 'Name of the resource being referred to. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' 65 | type: string 66 | location: 67 | description: Location is the Google Cloud Project Location 68 | type: string 69 | project: 70 | description: Project is the Google Cloud Project ID 71 | type: string 72 | status: 73 | description: GoogleCASIssuerStatus defines the observed state of GoogleCASIssuer 74 | type: object 75 | properties: 76 | conditions: 77 | type: array 78 | items: 79 | description: IssuerCondition contains condition information for a CAS Issuer. 80 | type: object 81 | required: 82 | - status 83 | - type 84 | properties: 85 | lastTransitionTime: 86 | description: LastTransitionTime is the timestamp corresponding to the last status change of this condition. 87 | type: string 88 | format: date-time 89 | message: 90 | description: Message is a human readable description of the details of the last transition, complementing reason. 91 | type: string 92 | reason: 93 | description: Reason is a brief machine readable explanation for the condition's last transition. 94 | type: string 95 | status: 96 | description: Status of the condition, one of ('True', 'False', 'Unknown'). 97 | type: string 98 | allOf: 99 | - enum: 100 | - "True" 101 | - "False" 102 | - Unknown 103 | - enum: 104 | - "True" 105 | - "False" 106 | - Unknown 107 | type: 108 | description: Type of the condition, currently ('Ready'). 109 | type: string 110 | enum: 111 | - Ready 112 | served: true 113 | storage: true 114 | subresources: 115 | status: {} 116 | -------------------------------------------------------------------------------- /deploy/charts/google-cas-issuer/templates/crds/cas-issuer.jetstack.io_googlecasclusterissuers.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | controller-gen.kubebuilder.io/version: v0.9.2 6 | creationTimestamp: null 7 | name: googlecasclusterissuers.cas-issuer.jetstack.io 8 | spec: 9 | group: cas-issuer.jetstack.io 10 | names: 11 | kind: GoogleCASClusterIssuer 12 | listKind: GoogleCASClusterIssuerList 13 | plural: googlecasclusterissuers 14 | singular: googlecasclusterissuer 15 | scope: Cluster 16 | versions: 17 | - additionalPrinterColumns: 18 | - jsonPath: .status.conditions[?(@.type=='Ready')].status 19 | name: ready 20 | type: string 21 | - jsonPath: .status.conditions[?(@.type=='Ready')].reason 22 | name: reason 23 | type: string 24 | - jsonPath: .status.conditions[?(@.type=='Ready')].message 25 | name: message 26 | type: string 27 | name: v1beta1 28 | schema: 29 | openAPIV3Schema: 30 | description: GoogleCASClusterIssuer is the Schema for the googlecasclusterissuers API 31 | type: object 32 | properties: 33 | apiVersion: 34 | description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 35 | type: string 36 | kind: 37 | description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 38 | type: string 39 | metadata: 40 | type: object 41 | spec: 42 | description: GoogleCASIssuerSpec defines the desired state of GoogleCASIssuer 43 | type: object 44 | properties: 45 | caPoolId: 46 | description: CaPoolId is the id of the CA pool to issue certificates from 47 | type: string 48 | certificateAuthorityId: 49 | description: CertificateAuthorityId is specific certificate authority to use to sign. Omit in order to load balance across all CAs in the pool 50 | type: string 51 | certificateTemplate: 52 | description: CertificateTemplate is specific certificate template to use. Omit to not specify a template 53 | type: string 54 | credentials: 55 | description: Credentials is a reference to a Kubernetes Secret Key that contains Google Service Account Credentials 56 | type: object 57 | required: 58 | - name 59 | properties: 60 | key: 61 | description: The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be defaulted, in others it may be required. 62 | type: string 63 | name: 64 | description: 'Name of the resource being referred to. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' 65 | type: string 66 | location: 67 | description: Location is the Google Cloud Project Location 68 | type: string 69 | project: 70 | description: Project is the Google Cloud Project ID 71 | type: string 72 | status: 73 | description: GoogleCASIssuerStatus defines the observed state of GoogleCASIssuer 74 | type: object 75 | properties: 76 | conditions: 77 | type: array 78 | items: 79 | description: IssuerCondition contains condition information for a CAS Issuer. 80 | type: object 81 | required: 82 | - status 83 | - type 84 | properties: 85 | lastTransitionTime: 86 | description: LastTransitionTime is the timestamp corresponding to the last status change of this condition. 87 | type: string 88 | format: date-time 89 | message: 90 | description: Message is a human readable description of the details of the last transition, complementing reason. 91 | type: string 92 | reason: 93 | description: Reason is a brief machine readable explanation for the condition's last transition. 94 | type: string 95 | status: 96 | description: Status of the condition, one of ('True', 'False', 'Unknown'). 97 | type: string 98 | allOf: 99 | - enum: 100 | - "True" 101 | - "False" 102 | - Unknown 103 | - enum: 104 | - "True" 105 | - "False" 106 | - Unknown 107 | type: 108 | description: Type of the condition, currently ('Ready'). 109 | type: string 110 | enum: 111 | - Ready 112 | served: true 113 | storage: true 114 | subresources: 115 | status: {} 116 | -------------------------------------------------------------------------------- /pkg/controller/issuer/googlecasissuer_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package issuer 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "github.com/go-logr/logr" 25 | "github.com/jetstack/google-cas-issuer/pkg/cas" 26 | "github.com/spf13/viper" 27 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 28 | "k8s.io/apimachinery/pkg/runtime" 29 | "k8s.io/client-go/tools/record" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/client" 32 | 33 | issuersv1beta1 "github.com/jetstack/google-cas-issuer/api/v1beta1" 34 | ) 35 | 36 | const ( 37 | eventTypeWarning = "Warning" 38 | eventTypeNormal = "Normal" 39 | 40 | reasonCASClientOK = "CASClientOK" 41 | reasonIssuerMisconfigured = "IssuerMisconfigured" 42 | ) 43 | 44 | // GoogleCASIssuerReconciler reconciles a GoogleCASIssuer object 45 | type GoogleCASIssuerReconciler struct { 46 | // GoogleCASIssuer or GoogleCASClusterIssuer 47 | Kind string 48 | 49 | client.Client 50 | Log logr.Logger 51 | Recorder record.EventRecorder 52 | Scheme *runtime.Scheme 53 | } 54 | 55 | // +kubebuilder:rbac:groups=cas-issuer.jetstack.io,resources=googlecasissuers,verbs=get;list;watch;create;update;patch;delete 56 | // +kubebuilder:rbac:groups=cas-issuer.jetstack.io,resources=googlecasissuers/status,verbs=get;update;patch 57 | // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch 58 | // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch 59 | // +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;create;update 60 | func (r *GoogleCASIssuerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { 61 | log := r.Log.WithValues(r.Kind, req.NamespacedName) 62 | issuer, err := r.getIssuer() 63 | if err != nil { 64 | log.Error(err, "invalid issuer type seen - ignoring") 65 | return ctrl.Result{}, nil 66 | } 67 | 68 | if err := r.Get(ctx, req.NamespacedName, issuer); err != nil { 69 | if err := client.IgnoreNotFound(err); err != nil { 70 | log.Error(err, "failed to retrieve incoming Issuer resource") 71 | return ctrl.Result{}, err 72 | } 73 | return ctrl.Result{}, nil 74 | } 75 | 76 | spec, status, err := getIssuerSpecStatus(issuer) 77 | if err != nil { 78 | log.Error(err, "issuer is of unexpected type, ignoring") 79 | return ctrl.Result{}, nil 80 | } 81 | 82 | // Always attempt to update the Ready condition 83 | defer func() { 84 | if err != nil { 85 | setReadyCondition(status, issuersv1beta1.ConditionFalse, "issuer failed to reconcile", err.Error()) 86 | } 87 | // If the Issuer is deleted mid-reconcile, ignore it 88 | if updateErr := client.IgnoreNotFound(r.Status().Update(ctx, issuer)); updateErr != nil { 89 | log.Info("Couldn't update ready condition", "err", err) 90 | result = ctrl.Result{} 91 | } 92 | }() 93 | 94 | ns := req.NamespacedName.Namespace 95 | if len(ns) == 0 { 96 | ns = viper.GetString("cluster-resource-namespace") 97 | } 98 | 99 | _, err = cas.NewSigner(ctx, spec, r.Client, ns) 100 | 101 | if err != nil { 102 | log.Info("Issuer is misconfigured", "info", err.Error()) 103 | setReadyCondition(status, issuersv1beta1.ConditionFalse, reasonIssuerMisconfigured, err.Error()) 104 | r.Recorder.Event(issuer, eventTypeWarning, reasonIssuerMisconfigured, err.Error()) 105 | return ctrl.Result{RequeueAfter: 10 * time.Second}, nil 106 | } 107 | 108 | log.Info("reconciled issuer", "kind", issuer.GetObjectKind()) 109 | msg := "Successfully constructed CAS client" 110 | setReadyCondition(status, issuersv1beta1.ConditionTrue, reasonCASClientOK, msg) 111 | r.Recorder.Event(issuer, eventTypeNormal, reasonCASClientOK, msg) 112 | return ctrl.Result{}, nil 113 | } 114 | 115 | func (r *GoogleCASIssuerReconciler) SetupWithManager(mgr ctrl.Manager) error { 116 | issuer, err := r.getIssuer() 117 | if err != nil { 118 | return err 119 | } 120 | return ctrl.NewControllerManagedBy(mgr). 121 | For(issuer). 122 | Complete(r) 123 | } 124 | 125 | // convert a k8s.io/apimachinery/pkg/runtime.Object into a sigs.k8s.io/controller-runtime/pkg/client.Object 126 | func (r *GoogleCASIssuerReconciler) getIssuer() (client.Object, error) { 127 | issuer, err := r.Scheme.New(issuersv1beta1.GroupVersion.WithKind(r.Kind)) 128 | if err != nil { 129 | return nil, err 130 | } 131 | switch t := issuer.(type) { 132 | case *issuersv1beta1.GoogleCASIssuer: 133 | return t, nil 134 | case *issuersv1beta1.GoogleCASClusterIssuer: 135 | return t, nil 136 | default: 137 | return nil, fmt.Errorf("unsupported kind %s", r.Kind) 138 | } 139 | } 140 | 141 | func getIssuerSpecStatus(object client.Object) (*issuersv1beta1.GoogleCASIssuerSpec, *issuersv1beta1.GoogleCASIssuerStatus, error) { 142 | switch t := object.(type) { 143 | case *issuersv1beta1.GoogleCASIssuer: 144 | return &t.Spec, &t.Status, nil 145 | case *issuersv1beta1.GoogleCASClusterIssuer: 146 | return &t.Spec, &t.Status, nil 147 | default: 148 | return nil, nil, fmt.Errorf("unexpected type %T", t) 149 | } 150 | } 151 | 152 | func setReadyCondition(status *issuersv1beta1.GoogleCASIssuerStatus, conditionStatus issuersv1beta1.ConditionStatus, reason, message string) { 153 | var ready *issuersv1beta1.GoogleCASIssuerCondition 154 | for _, c := range status.Conditions { 155 | if c.Type == issuersv1beta1.IssuerConditionReady { 156 | ready = &c 157 | break 158 | } 159 | } 160 | if ready == nil { 161 | ready = &issuersv1beta1.GoogleCASIssuerCondition{Type: issuersv1beta1.IssuerConditionReady} 162 | } 163 | if ready.Status != conditionStatus { 164 | ready.Status = conditionStatus 165 | now := metav1.Now() 166 | ready.LastTransitionTime = &now 167 | } 168 | ready.Reason = reason 169 | ready.Message = message 170 | 171 | for i, c := range status.Conditions { 172 | if c.Type == issuersv1beta1.IssuerConditionReady { 173 | status.Conditions[i] = *ready 174 | return 175 | } 176 | } 177 | 178 | status.Conditions = append(status.Conditions, *ready) 179 | } 180 | -------------------------------------------------------------------------------- /pkg/cas/cas_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package cas 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "github.com/stretchr/testify/assert" 24 | "testing" 25 | 26 | "google.golang.org/genproto/googleapis/cloud/security/privateca/v1" 27 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 28 | 29 | "github.com/jetstack/google-cas-issuer/api/v1beta1" 30 | ) 31 | 32 | func TestNewSigner(t *testing.T) { 33 | spec := &v1beta1.GoogleCASIssuerSpec{ 34 | CaPoolId: "test-pool", 35 | Project: "test-project", 36 | Location: "test-location", 37 | } 38 | ctx := context.Background() 39 | namespace := "test" 40 | client := fake.NewFakeClient() 41 | res, err := newSignerNoSelftest(ctx, spec, client, namespace) 42 | if err != nil { 43 | t.Errorf("NewSigner returned an error: %s", err.Error()) 44 | } 45 | if got, want := res.parent, fmt.Sprintf("projects/%s/locations/%s/caPools/%s", spec.Project, spec.Location, spec.CaPoolId); got != want { 46 | t.Errorf("Wrong parent: %s != %s", got, want) 47 | } 48 | if got, want := res.namespace, namespace; got != want { 49 | t.Errorf("Wrong namespace: %s != %s", got, want) 50 | } 51 | } 52 | 53 | func TestNewSignerMissingPoolId(t *testing.T) { 54 | spec := &v1beta1.GoogleCASIssuerSpec{ 55 | CaPoolId: "", 56 | } 57 | ctx := context.Background() 58 | namespace := "test" 59 | client := fake.NewFakeClient() 60 | _, err := newSignerNoSelftest(ctx, spec, client, namespace) 61 | if err == nil { 62 | t.Error("NewSigner didn't return an error") 63 | } 64 | if got, want := err.Error(), "must specify a CaPoolId"; got != want { 65 | t.Errorf("Wrong error: %s != %s", got, want) 66 | } 67 | } 68 | 69 | func TestExtractCertAndCA(t *testing.T) { 70 | type expected struct { 71 | cert []byte 72 | ca []byte 73 | err error 74 | } 75 | const rootCA = `-----BEGIN CERTIFICATE----- 76 | MIICbjCCAhWgAwIBAgIRAIx1PjG13lEQB1ZqNm7c5sswCgYIKoZIzj0EAwIwgaEx 77 | HjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE7MDkGA1UECwwyamFrZXhr 78 | c0AwMFdLU01BQzYyLjFwZXJjZW50Lm5ldHdvcmsgKEpha2UgU2FuZGVycykxQjBA 79 | BgNVBAMMOW1rY2VydCBqYWtleGtzQDAwV0tTTUFDNjIuMXBlcmNlbnQubmV0d29y 80 | ayAoSmFrZSBTYW5kZXJzKTAeFw0yMTA2MTQxMjU1NDNaFw0yMzA5MTQxMjU1NDNa 81 | MGYxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE7MDkG 82 | A1UECwwyamFrZXhrc0AwMFdLU01BQzYyLjFwZXJjZW50Lm5ldHdvcmsgKEpha2Ug 83 | U2FuZGVycykwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARbbosQ+SfKKj3dalEF 84 | J7/sESpINBiOVpwN+3AICP0oRnjX3fEWYvCTp7j4h3Hww4Tz1RNYCN8VsvV2BU9y 85 | ndTIo2gwZjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYD 86 | VR0jBBgwFoAUbSUVuAPENX7tpcK0/pj0jqMHBxswHgYDVR0RBBcwFYITY2FzLWUy 87 | ZS5qZXRzdGFjay5pbzAKBggqhkjOPQQDAgNHADBEAiBEzu5o0PIB9d5dAZJHF8re 88 | /M30rr/PDo8eagMZBEfUuAIgI8OcOearnlofAz5AS94axOyIJXIH/H+4dNKCXkAV 89 | V94= 90 | -----END CERTIFICATE-----` 91 | 92 | testData := []struct { 93 | name string 94 | input *privateca.Certificate 95 | expected expected 96 | }{ 97 | { 98 | name: "nil input returns an error without panicking", 99 | input: nil, 100 | expected: expected{ 101 | nil, nil, errors.New("extractCertAndCA: certificate response is nil"), 102 | }, 103 | }, 104 | { 105 | name: "cert signed directly by a CA returns single leaf, single root", 106 | input: &privateca.Certificate{ 107 | PemCertificate: `-----BEGIN CERTIFICATE----- 108 | MIIBtjCCAVwCCQDkGWfHQC96wTAJBgcqhkjOPQQBMGYxJzAlBgNVBAoTHm1rY2Vy 109 | dCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE7MDkGA1UECwwyamFrZXhrc0AwMFdL 110 | U01BQzYyLjFwZXJjZW50Lm5ldHdvcmsgKEpha2UgU2FuZGVycykwHhcNMjEwNjE0 111 | MTMwMzU4WhcNMzEwNDIzMTMwMzU4WjBEMQswCQYDVQQGEwJHQjERMA8GA1UECgwI 112 | SmV0c3RhY2sxIjAgBgNVBAMMGWxlYWYxLmNhcy1lMmUuamV0c3RhY2suaW8wdjAQ 113 | BgcqhkjOPQIBBgUrgQQAIgNiAAQ3NFaJEUbrkM8+sVcbFUnzTttaOPo/deMcuMFB 114 | kDRfJ7+G4H+VRMSm4oTXpUXSbr7cAppCvB+ePHh3qkIpeNq66oA2bUK4j8l78DPo 115 | 0H0S96Qz8bBHEBWtSAnCO7wymp4wCQYHKoZIzj0EAQNJADBGAiEA28LfGB4MQu1F 116 | Db+mNOgU61RUz2JhH6b0MnL//0RYd/4CIQDAWWj5Mo0qSpUtcZ+yJKYnN4w+hKYo 117 | z5B9C4cjanJ67w== 118 | -----END CERTIFICATE-----`, 119 | PemCertificateChain: []string{rootCA}, 120 | }, 121 | expected: expected{ 122 | []byte(`-----BEGIN CERTIFICATE----- 123 | MIIBtjCCAVwCCQDkGWfHQC96wTAJBgcqhkjOPQQBMGYxJzAlBgNVBAoTHm1rY2Vy 124 | dCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE7MDkGA1UECwwyamFrZXhrc0AwMFdL 125 | U01BQzYyLjFwZXJjZW50Lm5ldHdvcmsgKEpha2UgU2FuZGVycykwHhcNMjEwNjE0 126 | MTMwMzU4WhcNMzEwNDIzMTMwMzU4WjBEMQswCQYDVQQGEwJHQjERMA8GA1UECgwI 127 | SmV0c3RhY2sxIjAgBgNVBAMMGWxlYWYxLmNhcy1lMmUuamV0c3RhY2suaW8wdjAQ 128 | BgcqhkjOPQIBBgUrgQQAIgNiAAQ3NFaJEUbrkM8+sVcbFUnzTttaOPo/deMcuMFB 129 | kDRfJ7+G4H+VRMSm4oTXpUXSbr7cAppCvB+ePHh3qkIpeNq66oA2bUK4j8l78DPo 130 | 0H0S96Qz8bBHEBWtSAnCO7wymp4wCQYHKoZIzj0EAQNJADBGAiEA28LfGB4MQu1F 131 | Db+mNOgU61RUz2JhH6b0MnL//0RYd/4CIQDAWWj5Mo0qSpUtcZ+yJKYnN4w+hKYo 132 | z5B9C4cjanJ67w== 133 | -----END CERTIFICATE----- 134 | `), []byte(rootCA + "\n"), nil, 135 | }, 136 | }, 137 | { 138 | name: "the bottom most certificate ends up in the CA field (trivially)", 139 | input: &privateca.Certificate{ 140 | PemCertificate: `-----BEGIN CERTIFICATE----- 141 | leaf 142 | -----END CERTIFICATE-----`, 143 | PemCertificateChain: []string{`-----BEGIN CERTIFICATE----- 144 | intermediate2 145 | -----END CERTIFICATE-----`, `-----BEGIN CERTIFICATE----- 146 | intermediate1 147 | -----END CERTIFICATE-----`, `-----BEGIN CERTIFICATE----- 148 | root 149 | -----END CERTIFICATE-----`}, 150 | }, 151 | expected: expected{ 152 | []byte(`-----BEGIN CERTIFICATE----- 153 | leaf 154 | -----END CERTIFICATE----- 155 | -----BEGIN CERTIFICATE----- 156 | intermediate2 157 | -----END CERTIFICATE----- 158 | -----BEGIN CERTIFICATE----- 159 | intermediate1 160 | -----END CERTIFICATE----- 161 | `), 162 | []byte(`-----BEGIN CERTIFICATE----- 163 | root 164 | -----END CERTIFICATE----- 165 | `), 166 | nil, 167 | }, 168 | }, 169 | } 170 | 171 | for _, tt := range testData { 172 | cert, ca, err := extractCertAndCA(tt.input) 173 | assert.Equalf(t, tt.expected.cert, cert, "Test %s failed", tt.name) 174 | assert.Equalf(t, tt.expected.ca, ca, "Test %s failed", tt.name) 175 | assert.Equalf(t, tt.expected.err, err, "Test %s failed", tt.name) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /pkg/cas/cas.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package cas is a wrapper for the Google Cloud Private certificate authority service API 18 | package cas 19 | 20 | import ( 21 | "bytes" 22 | "context" 23 | "errors" 24 | "fmt" 25 | "math/rand" 26 | "strings" 27 | "time" 28 | 29 | privateca "cloud.google.com/go/security/privateca/apiv1" 30 | "github.com/golang/protobuf/ptypes/duration" 31 | "github.com/google/uuid" 32 | "google.golang.org/api/option" 33 | casapi "google.golang.org/genproto/googleapis/cloud/security/privateca/v1" 34 | corev1 "k8s.io/api/core/v1" 35 | "k8s.io/apimachinery/pkg/types" 36 | "sigs.k8s.io/controller-runtime/pkg/client" 37 | 38 | "github.com/jetstack/google-cas-issuer/api/v1beta1" 39 | ) 40 | 41 | // A Signer is an abstraction of a certificate authority 42 | type Signer interface { 43 | // Sign signs a CSR and returns a cert and chain 44 | Sign(csr []byte, expiry time.Duration) (cert []byte, ca []byte, err error) 45 | } 46 | 47 | type casSigner struct { 48 | // parent is the Google cloud project ID in the format "projects/*/locations/*" 49 | parent string 50 | // spec is a reference to the issuer Spec 51 | spec *v1beta1.GoogleCASIssuerSpec 52 | // namespace is the namespace to look for secrets in 53 | namespace string 54 | 55 | client client.Client 56 | ctx context.Context 57 | } 58 | 59 | func (c *casSigner) Sign(csr []byte, expiry time.Duration) (cert []byte, ca []byte, err error) { 60 | casClient, err := c.createCasClient() 61 | if err != nil { 62 | return nil, nil, err 63 | } 64 | defer casClient.Close() 65 | createCertificateRequest := &casapi.CreateCertificateRequest{ 66 | Parent: c.parent, 67 | // Should this use the certificate request name? 68 | CertificateId: fmt.Sprintf("cert-manager-%d", rand.Int()), 69 | Certificate: &casapi.Certificate{ 70 | CertificateConfig: &casapi.Certificate_PemCsr{ 71 | PemCsr: string(csr), 72 | }, 73 | Lifetime: &duration.Duration{ 74 | Seconds: expiry.Milliseconds() / 1000, 75 | Nanos: 0, 76 | }, 77 | CertificateTemplate: c.spec.CertificateTemplate, 78 | }, 79 | RequestId: uuid.New().String(), 80 | IssuingCertificateAuthorityId: c.spec.CertificateAuthorityId, 81 | } 82 | createCertResp, err := casClient.CreateCertificate(c.ctx, createCertificateRequest) 83 | if err != nil { 84 | return nil, nil, fmt.Errorf("casClient.CreateCertificate failed: %w", err) 85 | } 86 | return extractCertAndCA(createCertResp) 87 | } 88 | 89 | func NewSigner(ctx context.Context, spec *v1beta1.GoogleCASIssuerSpec, client client.Client, namespace string) (Signer, error) { 90 | c, err := newSignerNoSelftest(ctx, spec, client, namespace) 91 | if err != nil { 92 | return c, err 93 | } 94 | casClient, err := c.createCasClient() 95 | if err != nil { 96 | return nil, err 97 | } 98 | casClient.Close() 99 | return c, nil 100 | } 101 | 102 | // newSignerNoSelftest creates a Signer without doing a self-check, useful for tests 103 | func newSignerNoSelftest(ctx context.Context, spec *v1beta1.GoogleCASIssuerSpec, client client.Client, namespace string) (*casSigner, error) { 104 | if spec.CaPoolId == "" { 105 | return nil, fmt.Errorf("must specify a CaPoolId") 106 | } 107 | c := &casSigner{ 108 | parent: fmt.Sprintf("projects/%s/locations/%s/caPools/%s", spec.Project, spec.Location, spec.CaPoolId), 109 | spec: spec, 110 | client: client, 111 | ctx: ctx, 112 | namespace: namespace, 113 | } 114 | return c, nil 115 | } 116 | 117 | func (c *casSigner) createCasClient() (*privateca.CertificateAuthorityClient, error) { 118 | var casClient *privateca.CertificateAuthorityClient 119 | 120 | if len(c.spec.Credentials.Name) > 0 && len(c.spec.Credentials.Key) > 0 { 121 | secretNamespaceName := types.NamespacedName{ 122 | Name: c.spec.Credentials.Name, 123 | Namespace: c.namespace, 124 | } 125 | var secret corev1.Secret 126 | if err := c.client.Get(c.ctx, secretNamespaceName, &secret); err != nil { 127 | return nil, err 128 | } 129 | credentials, exists := secret.Data[c.spec.Credentials.Key] 130 | if !exists { 131 | return nil, fmt.Errorf("no credentials found in secret %s under %s", secretNamespaceName, c.spec.Credentials.Key) 132 | } 133 | c, err := privateca.NewCertificateAuthorityClient(c.ctx, option.WithCredentialsJSON(credentials)) 134 | if err != nil { 135 | return nil, fmt.Errorf("failed to build certificate authority client: %w", err) 136 | } 137 | casClient = c 138 | } else { 139 | // Using implicit credentials, e.g. with Google cloud service accounts 140 | c, err := privateca.NewCertificateAuthorityClient(c.ctx) 141 | if err != nil { 142 | return nil, err 143 | } 144 | casClient = c 145 | } 146 | return casClient, nil 147 | } 148 | 149 | // extractCertAndCA takes a response from the Google CAS API and formats it into a format 150 | // expected by cert-manager. A Certificate contains the leaf in the PemCertificate field 151 | // and the rest of the chain down to the root in the PemCertificateChain. cert-manager 152 | // expects the leaf and all intermediates in the certificate field, stacked in PEM format 153 | // with the root in the CA field. 154 | // 155 | // Additionally, for each PEM block, all whitespace is trimmed and a single new line is 156 | // appended, in case software consuming the resulting secret writes the PEM blocks 157 | // directly into a config file without parsing them. 158 | func extractCertAndCA(resp *casapi.Certificate) (cert []byte, ca []byte, err error) { 159 | if resp == nil { 160 | return nil, nil, errors.New("extractCertAndCA: certificate response is nil") 161 | } 162 | certBuf := &bytes.Buffer{} 163 | 164 | // Write the leaf to the buffer 165 | certBuf.WriteString(strings.TrimSpace(resp.PemCertificate)) 166 | certBuf.WriteRune('\n') 167 | 168 | // Write any remaining certificates except for the root-most one 169 | for _, c := range resp.PemCertificateChain[:len(resp.PemCertificateChain)-1] { 170 | certBuf.WriteString(strings.TrimSpace(c)) 171 | certBuf.WriteRune('\n') 172 | } 173 | 174 | // Return the root-most certificate in the CA field. 175 | return certBuf.Bytes(), []byte( 176 | strings.TrimSpace( 177 | resp.PemCertificateChain[len(resp.PemCertificateChain)-1], 178 | ) + "\n"), nil 179 | } 180 | -------------------------------------------------------------------------------- /api/v1beta1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | /* 5 | Copyright 2021 Jetstack Ltd. 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | */ 19 | 20 | // Code generated by controller-gen. DO NOT EDIT. 21 | 22 | package v1beta1 23 | 24 | import ( 25 | runtime "k8s.io/apimachinery/pkg/runtime" 26 | ) 27 | 28 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 29 | func (in *GoogleCASClusterIssuer) DeepCopyInto(out *GoogleCASClusterIssuer) { 30 | *out = *in 31 | out.TypeMeta = in.TypeMeta 32 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 33 | out.Spec = in.Spec 34 | in.Status.DeepCopyInto(&out.Status) 35 | } 36 | 37 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASClusterIssuer. 38 | func (in *GoogleCASClusterIssuer) DeepCopy() *GoogleCASClusterIssuer { 39 | if in == nil { 40 | return nil 41 | } 42 | out := new(GoogleCASClusterIssuer) 43 | in.DeepCopyInto(out) 44 | return out 45 | } 46 | 47 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 48 | func (in *GoogleCASClusterIssuer) DeepCopyObject() runtime.Object { 49 | if c := in.DeepCopy(); c != nil { 50 | return c 51 | } 52 | return nil 53 | } 54 | 55 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 56 | func (in *GoogleCASClusterIssuerList) DeepCopyInto(out *GoogleCASClusterIssuerList) { 57 | *out = *in 58 | out.TypeMeta = in.TypeMeta 59 | in.ListMeta.DeepCopyInto(&out.ListMeta) 60 | if in.Items != nil { 61 | in, out := &in.Items, &out.Items 62 | *out = make([]GoogleCASClusterIssuer, len(*in)) 63 | for i := range *in { 64 | (*in)[i].DeepCopyInto(&(*out)[i]) 65 | } 66 | } 67 | } 68 | 69 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASClusterIssuerList. 70 | func (in *GoogleCASClusterIssuerList) DeepCopy() *GoogleCASClusterIssuerList { 71 | if in == nil { 72 | return nil 73 | } 74 | out := new(GoogleCASClusterIssuerList) 75 | in.DeepCopyInto(out) 76 | return out 77 | } 78 | 79 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 80 | func (in *GoogleCASClusterIssuerList) DeepCopyObject() runtime.Object { 81 | if c := in.DeepCopy(); c != nil { 82 | return c 83 | } 84 | return nil 85 | } 86 | 87 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 88 | func (in *GoogleCASIssuer) DeepCopyInto(out *GoogleCASIssuer) { 89 | *out = *in 90 | out.TypeMeta = in.TypeMeta 91 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 92 | out.Spec = in.Spec 93 | in.Status.DeepCopyInto(&out.Status) 94 | } 95 | 96 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASIssuer. 97 | func (in *GoogleCASIssuer) DeepCopy() *GoogleCASIssuer { 98 | if in == nil { 99 | return nil 100 | } 101 | out := new(GoogleCASIssuer) 102 | in.DeepCopyInto(out) 103 | return out 104 | } 105 | 106 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 107 | func (in *GoogleCASIssuer) DeepCopyObject() runtime.Object { 108 | if c := in.DeepCopy(); c != nil { 109 | return c 110 | } 111 | return nil 112 | } 113 | 114 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 115 | func (in *GoogleCASIssuerCondition) DeepCopyInto(out *GoogleCASIssuerCondition) { 116 | *out = *in 117 | if in.LastTransitionTime != nil { 118 | in, out := &in.LastTransitionTime, &out.LastTransitionTime 119 | *out = (*in).DeepCopy() 120 | } 121 | } 122 | 123 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASIssuerCondition. 124 | func (in *GoogleCASIssuerCondition) DeepCopy() *GoogleCASIssuerCondition { 125 | if in == nil { 126 | return nil 127 | } 128 | out := new(GoogleCASIssuerCondition) 129 | in.DeepCopyInto(out) 130 | return out 131 | } 132 | 133 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 134 | func (in *GoogleCASIssuerList) DeepCopyInto(out *GoogleCASIssuerList) { 135 | *out = *in 136 | out.TypeMeta = in.TypeMeta 137 | in.ListMeta.DeepCopyInto(&out.ListMeta) 138 | if in.Items != nil { 139 | in, out := &in.Items, &out.Items 140 | *out = make([]GoogleCASIssuer, len(*in)) 141 | for i := range *in { 142 | (*in)[i].DeepCopyInto(&(*out)[i]) 143 | } 144 | } 145 | } 146 | 147 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASIssuerList. 148 | func (in *GoogleCASIssuerList) DeepCopy() *GoogleCASIssuerList { 149 | if in == nil { 150 | return nil 151 | } 152 | out := new(GoogleCASIssuerList) 153 | in.DeepCopyInto(out) 154 | return out 155 | } 156 | 157 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 158 | func (in *GoogleCASIssuerList) DeepCopyObject() runtime.Object { 159 | if c := in.DeepCopy(); c != nil { 160 | return c 161 | } 162 | return nil 163 | } 164 | 165 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 166 | func (in *GoogleCASIssuerSpec) DeepCopyInto(out *GoogleCASIssuerSpec) { 167 | *out = *in 168 | out.Credentials = in.Credentials 169 | } 170 | 171 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASIssuerSpec. 172 | func (in *GoogleCASIssuerSpec) DeepCopy() *GoogleCASIssuerSpec { 173 | if in == nil { 174 | return nil 175 | } 176 | out := new(GoogleCASIssuerSpec) 177 | in.DeepCopyInto(out) 178 | return out 179 | } 180 | 181 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 182 | func (in *GoogleCASIssuerStatus) DeepCopyInto(out *GoogleCASIssuerStatus) { 183 | *out = *in 184 | if in.Conditions != nil { 185 | in, out := &in.Conditions, &out.Conditions 186 | *out = make([]GoogleCASIssuerCondition, len(*in)) 187 | for i := range *in { 188 | (*in)[i].DeepCopyInto(&(*out)[i]) 189 | } 190 | } 191 | } 192 | 193 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoogleCASIssuerStatus. 194 | func (in *GoogleCASIssuerStatus) DeepCopy() *GoogleCASIssuerStatus { 195 | if in == nil { 196 | return nil 197 | } 198 | out := new(GoogleCASIssuerStatus) 199 | in.DeepCopyInto(out) 200 | return out 201 | } 202 | -------------------------------------------------------------------------------- /hack/casutil/ca.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | privateca "cloud.google.com/go/security/privateca/apiv1" 5 | "context" 6 | "fmt" 7 | "github.com/google/uuid" 8 | "github.com/olekukonko/tablewriter" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "google.golang.org/api/iterator" 12 | privatecaapi "google.golang.org/genproto/googleapis/cloud/security/privateca/v1" 13 | "google.golang.org/protobuf/types/known/durationpb" 14 | "os" 15 | "path" 16 | ) 17 | 18 | var ( 19 | createCA = &cobra.Command{ 20 | Use: "ca ", 21 | Short: "Create CA in a pool", 22 | Args: cobra.ExactArgs(1), 23 | Run: func(cmd *cobra.Command, args []string) { 24 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 25 | fatalIf(err) 26 | defer c.Close() 27 | p, err := cmd.Flags().GetString("pool") 28 | fatalIf(err) 29 | op, err := c.CreateCertificateAuthority(context.Background(), &privatecaapi.CreateCertificateAuthorityRequest{ 30 | Parent: fmt.Sprintf( 31 | "projects/%s/locations/%s/caPools/%s", 32 | viper.GetString("project"), 33 | viper.GetString("location"), 34 | p, 35 | ), 36 | CertificateAuthorityId: args[0], 37 | CertificateAuthority: &privatecaapi.CertificateAuthority{ 38 | Type: privatecaapi.CertificateAuthority_SELF_SIGNED, 39 | Config: &privatecaapi.CertificateConfig{ 40 | SubjectConfig: &privatecaapi.CertificateConfig_SubjectConfig{ 41 | Subject: &privatecaapi.Subject{ 42 | CommonName: args[0], 43 | CountryCode: "GB", 44 | Organization: "Jetstack", 45 | OrganizationalUnit: "Product", 46 | Locality: "", 47 | Province: "", 48 | StreetAddress: "", 49 | PostalCode: "", 50 | }, 51 | SubjectAltName: &privatecaapi.SubjectAltNames{ 52 | DnsNames: args, 53 | Uris: nil, 54 | EmailAddresses: nil, 55 | IpAddresses: nil, 56 | CustomSans: nil, 57 | }, 58 | }, 59 | X509Config: &privatecaapi.X509Parameters{ 60 | KeyUsage: &privatecaapi.KeyUsage{ 61 | BaseKeyUsage: &privatecaapi.KeyUsage_KeyUsageOptions{ 62 | DigitalSignature: true, 63 | ContentCommitment: true, 64 | KeyEncipherment: false, 65 | DataEncipherment: false, 66 | KeyAgreement: true, 67 | CertSign: true, 68 | CrlSign: true, 69 | EncipherOnly: false, 70 | DecipherOnly: false, 71 | }, 72 | ExtendedKeyUsage: &privatecaapi.KeyUsage_ExtendedKeyUsageOptions{ 73 | ServerAuth: true, 74 | ClientAuth: true, 75 | CodeSigning: false, 76 | EmailProtection: false, 77 | TimeStamping: false, 78 | OcspSigning: true, 79 | }, 80 | UnknownExtendedKeyUsages: nil, 81 | }, 82 | CaOptions: &privatecaapi.X509Parameters_CaOptions{ 83 | IsCa: truePointer(), 84 | MaxIssuerPathLength: twoPointer(), 85 | }, 86 | }, 87 | }, 88 | Lifetime: &durationpb.Duration{ 89 | // 10 Years 90 | Seconds: 315569520, 91 | Nanos: 0, 92 | }, 93 | KeySpec: &privatecaapi.CertificateAuthority_KeyVersionSpec{ 94 | KeyVersion: &privatecaapi.CertificateAuthority_KeyVersionSpec_CloudKmsKeyVersion{ 95 | // TODO: make keyrings configurable 96 | CloudKmsKeyVersion: fmt.Sprintf( 97 | "projects/%s/locations/%s/keyRings/kr1/cryptoKeys/k1/cryptoKeyVersions/1", 98 | viper.GetString("project"), 99 | viper.GetString("location"), 100 | ), 101 | }, 102 | }, 103 | }, 104 | RequestId: uuid.New().String(), 105 | }) 106 | fatalIf(err) 107 | resp, err := op.Wait(context.Background()) 108 | fatalIf(err) 109 | fmt.Printf("Created %s\n", resp.Name) 110 | }, 111 | } 112 | listCA = &cobra.Command{ 113 | Use: "cas", 114 | Short: "List all CAs in pool", 115 | Args: cobra.ExactArgs(0), 116 | Run: func(cmd *cobra.Command, args []string) { 117 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 118 | fatalIf(err) 119 | defer c.Close() 120 | p, err := cmd.Flags().GetString("pool") 121 | fatalIf(err) 122 | it := c.ListCertificateAuthorities(context.Background(), &privatecaapi.ListCertificateAuthoritiesRequest{ 123 | Parent: fmt.Sprintf( 124 | "projects/%s/locations/%s/caPools/%s", 125 | viper.GetString("project"), 126 | viper.GetString("location"), 127 | p, 128 | ), 129 | }) 130 | var data [][]string 131 | for { 132 | resp, err := it.Next() 133 | if err == iterator.Done { 134 | break 135 | } 136 | fatalIf(err) 137 | name := path.Base(resp.Name) 138 | var tier string 139 | switch resp.Tier { 140 | case privatecaapi.CaPool_TIER_UNSPECIFIED: 141 | tier = "Unspecified" 142 | case privatecaapi.CaPool_ENTERPRISE: 143 | tier = "Enterprise" 144 | case privatecaapi.CaPool_DEVOPS: 145 | tier = "Devops" 146 | default: 147 | tier = fmt.Sprintf("", resp.Tier.String()) 148 | } 149 | state := resp.State.String() 150 | data = append(data, []string{name, tier, state}) 151 | } 152 | t := tablewriter.NewWriter(os.Stdout) 153 | t.SetHeader([]string{"Name", "Tier", "State"}) 154 | for _, d := range data { 155 | t.Append(d) 156 | } 157 | t.Render() 158 | }, 159 | } 160 | deleteCA = &cobra.Command{ 161 | Use: "ca ", 162 | Short: "Delete a CA in a pool", 163 | Args: cobra.ExactArgs(1), 164 | Run: func(cmd *cobra.Command, args []string) { 165 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 166 | fatalIf(err) 167 | defer c.Close() 168 | p, err := cmd.Flags().GetString("pool") 169 | fatalIf(err) 170 | op, err := c.DeleteCertificateAuthority(context.Background(), &privatecaapi.DeleteCertificateAuthorityRequest{ 171 | Name: fmt.Sprintf( 172 | "projects/%s/locations/%s/caPools/%s/certificateAuthorities/%s", 173 | viper.GetString("project"), 174 | viper.GetString("location"), 175 | p, 176 | args[0], 177 | ), 178 | RequestId: uuid.New().String(), 179 | IgnoreActiveCertificates: true, 180 | }) 181 | fatalIf(err) 182 | resp, err := op.Wait(context.Background()) 183 | fatalIf(err) 184 | fmt.Printf("deleted %s\n", resp.Name) 185 | }, 186 | } 187 | enableCA = &cobra.Command{ 188 | Use: "ca ", 189 | Short: "Enables a CA in a Pool", 190 | Args: cobra.ExactArgs(1), 191 | Run: func(cmd *cobra.Command, args []string) { 192 | c, err := privateca.NewCertificateAuthorityClient(context.Background()) 193 | fatalIf(err) 194 | defer c.Close() 195 | p, err := cmd.Flags().GetString("pool") 196 | fatalIf(err) 197 | op, err := c.EnableCertificateAuthority(context.Background(), &privatecaapi.EnableCertificateAuthorityRequest{ 198 | Name: fmt.Sprintf( 199 | "projects/%s/locations/%s/caPools/%s/certificateAuthorities/%s", 200 | viper.GetString("project"), 201 | viper.GetString("location"), 202 | p, 203 | args[0], 204 | ), 205 | RequestId: uuid.New().String(), 206 | }) 207 | fatalIf(err) 208 | resp, err := op.Wait(context.Background()) 209 | fatalIf(err) 210 | fmt.Printf("Enabled %s\n", resp.Name) 211 | }, 212 | } 213 | ) 214 | 215 | func truePointer() *bool { 216 | t := true 217 | return &t 218 | } 219 | 220 | func twoPointer() *int32 { 221 | two := int32(2) 222 | return &two 223 | } 224 | -------------------------------------------------------------------------------- /test/e2e/suite/issuers/issuers.go: -------------------------------------------------------------------------------- 1 | package issuers 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "os" 8 | "text/template" 9 | "time" 10 | 11 | certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 12 | cmmetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | corev1 "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 18 | "k8s.io/apimachinery/pkg/runtime/serializer/yaml" 19 | 20 | "github.com/jetstack/google-cas-issuer/test/e2e/framework" 21 | "github.com/jetstack/google-cas-issuer/test/e2e/framework/config" 22 | "github.com/jetstack/google-cas-issuer/test/e2e/util" 23 | ) 24 | 25 | const ( 26 | issuerYAML string = `apiVersion: cas-issuer.jetstack.io/v1beta1 27 | kind: GoogleCASIssuer 28 | metadata: 29 | name: {{ .Name }} 30 | namespace: {{ .Namespace }} 31 | spec: 32 | project: {{ .Project }} 33 | location: {{ .Location }} 34 | caPoolId: {{ .Pool }} 35 | credentials: 36 | name: {{ .SecretName }} 37 | key: "{{ .SecretKey }}" 38 | ` 39 | 40 | clusterIssuerYaml string = `apiVersion: cas-issuer.jetstack.io/v1beta1 41 | kind: GoogleCASClusterIssuer 42 | metadata: 43 | name: {{ .Name }} 44 | spec: 45 | project: {{ .Project }} 46 | location: {{ .Location }} 47 | caPoolId: {{ .Pool }} 48 | credentials: 49 | name: {{ .SecretName }} 50 | key: "{{ .SecretKey }}"` 51 | ) 52 | 53 | type templateConfig struct { 54 | Name string 55 | Namespace string 56 | Project string 57 | Location string 58 | Pool string 59 | SecretName string 60 | SecretKey string 61 | } 62 | 63 | var _ = framework.CasesDescribe("issuers", func() { 64 | f := framework.NewDefaultFramework("issuer") 65 | cfg := config.GetConfig() 66 | It("Tests Issuer functionality", func() { 67 | By("Creating Google Cloud Credentials Secret") 68 | data, err := os.ReadFile(os.Getenv("TEST_GOOGLE_APPLICATION_CREDENTIALS")) 69 | Expect(err).NotTo(HaveOccurred()) 70 | secret, err := f.KubeClientSet.CoreV1().Secrets(cfg.Namespace).Create( 71 | context.TODO(), 72 | &corev1.Secret{ 73 | ObjectMeta: metav1.ObjectMeta{ 74 | GenerateName: "google-credentials-", 75 | Namespace: cfg.Namespace, 76 | }, 77 | Type: corev1.SecretTypeOpaque, 78 | Data: map[string][]byte{ 79 | "google.json": data, 80 | }, 81 | }, 82 | metav1.CreateOptions{}, 83 | ) 84 | Expect(err).NotTo(HaveOccurred()) 85 | 86 | By("Constructing a random issuer") 87 | t := &templateConfig{ 88 | Name: "issuer-" + util.RandomString(5), 89 | Namespace: cfg.Namespace, 90 | Project: cfg.Project, 91 | Location: cfg.Location, 92 | Pool: cfg.CaPoolId, 93 | SecretName: secret.Name, 94 | SecretKey: "google.json", 95 | } 96 | buf := &bytes.Buffer{} 97 | err = template.Must(template.New("issuer").Parse(issuerYAML)).Execute(buf, t) 98 | Expect(err).NotTo(HaveOccurred()) 99 | 100 | By("Creating dynamic object") 101 | dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) 102 | apiObject := &unstructured.Unstructured{} 103 | _, gvk, err := dec.Decode(buf.Bytes(), nil, apiObject) 104 | Expect(err).NotTo(HaveOccurred()) 105 | mapping, err := f.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) 106 | Expect(err).NotTo(HaveOccurred()) 107 | 108 | dr := f.DynamicClientSet.Resource(mapping.Resource) 109 | 110 | By("Creating issuer " + t.Namespace + "/" + t.Name) 111 | _, err = dr.Namespace(t.Namespace).Create(context.TODO(), apiObject, metav1.CreateOptions{}) 112 | Expect(err).NotTo(HaveOccurred()) 113 | 114 | By("Waiting for issuer to become ready") 115 | err = f.Helper().WaitForUnstructuredReady(dr, t.Name, t.Namespace, 10*time.Second) 116 | Expect(err).NotTo(HaveOccurred()) 117 | 118 | By("Creating a certificate") 119 | certName := "casissuer-e2e-" + util.RandomString(5) 120 | _, err = f.CMClientSet.CertmanagerV1().Certificates(cfg.Namespace).Create(context.TODO(), &certmanagerv1.Certificate{ 121 | ObjectMeta: metav1.ObjectMeta{ 122 | Name: certName, 123 | Namespace: cfg.Namespace, 124 | }, 125 | Spec: certmanagerv1.CertificateSpec{ 126 | SecretName: certName, 127 | CommonName: certName, 128 | DNSNames: []string{certName, "e2etests.invalid"}, 129 | Duration: &metav1.Duration{Duration: 24 * time.Hour}, 130 | RenewBefore: &metav1.Duration{Duration: 8 * time.Hour}, 131 | IssuerRef: cmmetav1.ObjectReference{ 132 | Name: t.Name, 133 | Kind: gvk.Kind, 134 | Group: gvk.Group, 135 | }, 136 | }, 137 | }, metav1.CreateOptions{}) 138 | Expect(err).NotTo(HaveOccurred()) 139 | 140 | By("Waiting for certificate to become ready") 141 | _, err = f.Helper().WaitForCertificateReady(cfg.Namespace, certName, 10*time.Second) 142 | Expect(err).NotTo(HaveOccurred()) 143 | 144 | By("Verifying chain and CA") 145 | err = f.Helper().VerifyCMCertificate(cfg.Namespace, certName) 146 | Expect(err).NotTo(HaveOccurred()) 147 | }) 148 | 149 | It("Tests ClusterIssuer functionality", func() { 150 | By("Creating Google Cloud Credentials Secret") 151 | data, err := os.ReadFile(os.Getenv("TEST_GOOGLE_APPLICATION_CREDENTIALS")) 152 | Expect(err).NotTo(HaveOccurred()) 153 | secret, err := f.KubeClientSet.CoreV1().Secrets("cert-manager").Create( 154 | context.TODO(), 155 | &corev1.Secret{ 156 | ObjectMeta: metav1.ObjectMeta{ 157 | GenerateName: "google-credentials-", 158 | Namespace: "cert-manager", 159 | }, 160 | Type: corev1.SecretTypeOpaque, 161 | Data: map[string][]byte{ 162 | "google.json": data, 163 | }, 164 | }, 165 | metav1.CreateOptions{}, 166 | ) 167 | Expect(err).NotTo(HaveOccurred()) 168 | 169 | By("Constructing a random cluster issuer") 170 | t := &templateConfig{ 171 | Name: "clusterissuer-" + util.RandomString(5), 172 | Project: cfg.Project, 173 | Location: cfg.Location, 174 | Pool: cfg.CaPoolId, 175 | SecretName: secret.Name, 176 | SecretKey: "google.json", 177 | } 178 | buf := &bytes.Buffer{} 179 | err = template.Must(template.New("clusterissuer").Parse(clusterIssuerYaml)).Execute(buf, t) 180 | Expect(err).NotTo(HaveOccurred()) 181 | 182 | By("Creating dynamic object") 183 | dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) 184 | apiObject := &unstructured.Unstructured{} 185 | _, gvk, err := dec.Decode(buf.Bytes(), nil, apiObject) 186 | Expect(err).NotTo(HaveOccurred()) 187 | mapping, err := f.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) 188 | Expect(err).NotTo(HaveOccurred()) 189 | 190 | dr := f.DynamicClientSet.Resource(mapping.Resource) 191 | 192 | By("Creating clusterissuer " + t.Name) 193 | _, err = dr.Create(context.TODO(), apiObject, metav1.CreateOptions{}) 194 | Expect(err).NotTo(HaveOccurred()) 195 | 196 | By("Waiting for issuer to become ready") 197 | err = f.Helper().WaitForUnstructuredReady(dr, t.Name, "", 10*time.Second) 198 | Expect(err).NotTo(HaveOccurred()) 199 | 200 | By("Creating a certificate") 201 | certName := "casissuer-e2e-" + util.RandomString(5) 202 | cert, err := f.CMClientSet.CertmanagerV1().Certificates(cfg.Namespace).Create(context.TODO(), &certmanagerv1.Certificate{ 203 | ObjectMeta: metav1.ObjectMeta{ 204 | Name: certName, 205 | Namespace: cfg.Namespace, 206 | }, 207 | Spec: certmanagerv1.CertificateSpec{ 208 | SecretName: certName, 209 | CommonName: certName, 210 | DNSNames: []string{certName, "e2etests.invalid"}, 211 | Duration: &metav1.Duration{Duration: 24 * time.Hour}, 212 | RenewBefore: &metav1.Duration{Duration: 8 * time.Hour}, 213 | IssuerRef: cmmetav1.ObjectReference{ 214 | Name: t.Name, 215 | Kind: gvk.Kind, 216 | Group: gvk.Group, 217 | }, 218 | }, 219 | }, metav1.CreateOptions{}) 220 | Expect(err).NotTo(HaveOccurred()) 221 | 222 | By("Waiting for certificate to become ready") 223 | _, err = f.Helper().WaitForCertificateReady(cert.ObjectMeta.Namespace, cert.ObjectMeta.Name, 10*time.Second) 224 | Expect(err).NotTo(HaveOccurred()) 225 | 226 | By("Verifying chain and CA") 227 | err = f.Helper().VerifyCMCertificate(cert.ObjectMeta.Namespace, cert.ObjectMeta.Name) 228 | Expect(err).NotTo(HaveOccurred()) 229 | }) 230 | }) 231 | -------------------------------------------------------------------------------- /test/e2e/framework/helper/helper.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Jetstack Ltd. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package helper 18 | 19 | import ( 20 | "context" 21 | "crypto/tls" 22 | "crypto/x509" 23 | "encoding/pem" 24 | "errors" 25 | "fmt" 26 | "time" 27 | 28 | apiutil "github.com/cert-manager/cert-manager/pkg/api/util" 29 | cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" 30 | cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" 31 | cmversioned "github.com/cert-manager/cert-manager/pkg/client/clientset/versioned" 32 | corev1 "k8s.io/api/core/v1" 33 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 34 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 35 | "k8s.io/apimachinery/pkg/util/wait" 36 | "k8s.io/client-go/dynamic" 37 | "k8s.io/client-go/kubernetes" 38 | ) 39 | 40 | type Helper struct { 41 | cmClient cmversioned.Interface 42 | kubeClient kubernetes.Interface 43 | dynamicClient dynamic.Interface 44 | } 45 | 46 | func NewHelper(cmclient cmversioned.Interface, kubeclient kubernetes.Interface, dynamicClient dynamic.Interface) *Helper { 47 | return &Helper{ 48 | cmClient: cmclient, 49 | kubeClient: kubeclient, 50 | dynamicClient: dynamicClient, 51 | } 52 | } 53 | 54 | // WaitForCertificateReady waits for the certificate resource to enter a Ready 55 | // state. 56 | func (h *Helper) WaitForCertificateReady(ns, name string, timeout time.Duration) (*cmapi.Certificate, error) { 57 | var certificate *cmapi.Certificate 58 | 59 | err := wait.PollImmediate(time.Second, timeout, 60 | func() (bool, error) { 61 | var err error 62 | certificate, err = h.cmClient.CertmanagerV1().Certificates(ns).Get(context.TODO(), name, metav1.GetOptions{}) 63 | if err != nil { 64 | return false, fmt.Errorf("error getting Certificate %s: %v", name, err) 65 | } 66 | isReady := apiutil.CertificateHasCondition(certificate, cmapi.CertificateCondition{ 67 | Type: cmapi.CertificateConditionReady, 68 | Status: cmmeta.ConditionTrue, 69 | }) 70 | if !isReady { 71 | return false, nil 72 | } 73 | return true, nil 74 | }, 75 | ) 76 | 77 | // return certificate even when error to use for debugging 78 | return certificate, err 79 | } 80 | 81 | // WaitForPodsReady waits for all pods in a namespace to become ready 82 | func (h *Helper) WaitForPodsReady(ns string, timeout time.Duration) error { 83 | podsList, err := h.kubeClient.CoreV1().Pods(ns).List(context.TODO(), metav1.ListOptions{}) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | for _, pod := range podsList.Items { 89 | err := wait.PollImmediate(time.Second, timeout, 90 | func() (bool, error) { 91 | var err error 92 | pod, err := h.kubeClient.CoreV1().Pods(ns).Get(context.TODO(), pod.Name, metav1.GetOptions{}) 93 | if err != nil { 94 | return false, fmt.Errorf("error getting Pod %q: %v", pod.Name, err) 95 | } 96 | for _, c := range pod.Status.Conditions { 97 | if c.Type == corev1.PodReady { 98 | return c.Status == corev1.ConditionTrue, nil 99 | } 100 | } 101 | 102 | return false, nil 103 | }, 104 | ) 105 | 106 | if err != nil { 107 | return fmt.Errorf("failed to wait for pod %q to become ready: %s", 108 | pod.Name, err) 109 | } 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // WaitForUnstructuredReady waits for an unstructured.Unstructured object to become ready 116 | func (h *Helper) WaitForUnstructuredReady(dr dynamic.NamespaceableResourceInterface, name, namespace string, timeout time.Duration) error { 117 | err := wait.PollImmediate(time.Second, timeout, func() (done bool, err error) { 118 | var obj *unstructured.Unstructured 119 | if len(namespace) > 0 { 120 | obj, err = dr.Namespace(namespace).Get(context.TODO(), name, metav1.GetOptions{}) 121 | } else { 122 | obj, err = dr.Get(context.TODO(), name, metav1.GetOptions{}) 123 | } 124 | if err != nil { 125 | return true, err 126 | } 127 | status, found, err := unstructured.NestedMap(obj.Object, "status") 128 | if err != nil { 129 | return false, err 130 | } 131 | if !found { 132 | return false, nil 133 | } 134 | conditions, ok := status["conditions"].([]interface{}) 135 | if !ok { 136 | return false, errors.New(".status.conditions is not []interface{}") 137 | } 138 | for _, c := range conditions { 139 | cond, ok := c.(map[string]interface{}) 140 | if !ok { 141 | return false, errors.New(".status.conditions doesn't contain a map") 142 | } 143 | if cond["type"].(string) != "Ready" { 144 | continue 145 | } 146 | if cond["status"].(string) == "True" { 147 | return true, nil 148 | } else { 149 | reasonMessage := "Issuer is not ready: " 150 | reason, found := cond["reason"] 151 | if found { 152 | reasonMessage = reasonMessage + " " + reason.(string) 153 | } 154 | message, found := cond["message"] 155 | if found { 156 | reasonMessage = reasonMessage + " " + message.(string) 157 | } 158 | return false, errors.New(reasonMessage) 159 | } 160 | } 161 | return false, nil 162 | }) 163 | 164 | return err 165 | } 166 | 167 | func (h *Helper) VerifyCMCertificate(namespace, name string) error { 168 | certificate, err := h.cmClient.CertmanagerV1().Certificates(namespace).Get(context.TODO(), name, metav1.GetOptions{}) 169 | if err != nil { 170 | return fmt.Errorf("couldn't get certificate %s/%s: %w", namespace, name, err) 171 | } 172 | 173 | if certificate == nil { 174 | return errors.New("certificate is nil") 175 | } 176 | secret, err := h.kubeClient.CoreV1().Secrets(certificate.ObjectMeta.Namespace).Get(context.TODO(), certificate.Spec.SecretName, metav1.GetOptions{}) 177 | if err != nil { 178 | return fmt.Errorf("couldn't retrieve secret %s/%s: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, err) 179 | } 180 | 181 | caCrt, found := secret.Data["ca.crt"] 182 | if !found { 183 | return fmt.Errorf("ca.crt not found in secret %s/%s", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName) 184 | } 185 | tlsCrt, found := secret.Data["tls.crt"] 186 | if !found { 187 | return fmt.Errorf("tls.crt not found in secret %s/%s", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName) 188 | } 189 | tlsKey, found := secret.Data["tls.key"] 190 | if !found { 191 | return fmt.Errorf("tls.key not found in secret %s/%s", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName) 192 | } 193 | 194 | cert, err := tls.X509KeyPair(tlsCrt, tlsKey) 195 | if err != nil { 196 | return fmt.Errorf("certificate in secret %s/%s is invalid: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, err) 197 | } 198 | 199 | caBlock, rest := pem.Decode(caCrt) 200 | if len(rest) > 0 { 201 | return fmt.Errorf("ca in secret %s/%s has more than one PEM block or no valid PEM was found", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName) 202 | } 203 | if caBlock == nil { 204 | return fmt.Errorf("while parsing ca.crt, no ca found in secret %s/%s", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName) 205 | } 206 | 207 | ca, err := x509.ParseCertificate(caBlock.Bytes) 208 | if err != nil { 209 | return fmt.Errorf("ca in secret %s/%s is invalid: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, err) 210 | } 211 | 212 | // each cert in a chain must certify the one preceding it 213 | for i := 0; i < len(cert.Certificate)-1; i++ { 214 | certifier, err := x509.ParseCertificate(cert.Certificate[i+1]) 215 | if err != nil { 216 | return fmt.Errorf("tls.crt in secret %s/%s has invalid cert at %d in chain: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, i+1, err) 217 | } 218 | certified, err := x509.ParseCertificate(cert.Certificate[i]) 219 | if err != nil { 220 | return fmt.Errorf("tls.crt in secret %s/%s has invalid cert at %d in chain: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, i, err) 221 | } 222 | pool := x509.NewCertPool() 223 | pool.AddCert(certifier) 224 | 225 | if _, err = certified.Verify(x509.VerifyOptions{ 226 | Roots: pool, 227 | CurrentTime: time.Now(), 228 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, 229 | }); err != nil { 230 | return fmt.Errorf("tls.crt in secret %s/%s: cert at %d in chain couldn't be verified: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, i, err) 231 | } 232 | } 233 | // verify last in chain against root 234 | certified, err := x509.ParseCertificate(cert.Certificate[len(cert.Certificate)-1]) 235 | if err != nil { 236 | return fmt.Errorf("tls.cert in secret %s/%s has invalid cert at %d in chain: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, len(cert.Certificate)-1, err) 237 | } 238 | pool := x509.NewCertPool() 239 | pool.AddCert(ca) 240 | _, err = certified.Verify(x509.VerifyOptions{ 241 | Roots: pool, 242 | CurrentTime: time.Now(), 243 | KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, 244 | }) 245 | if err != nil { 246 | return fmt.Errorf("ca.crt in secret %s/%s doesn't validate tls.crt: %w", certificate.ObjectMeta.Namespace, certificate.Spec.SecretName, err) 247 | } 248 | 249 | return nil 250 | } 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Certificate Authority Service Issuer for cert-manager 2 | 3 | This repository contains an [external Issuer](https://cert-manager.io/docs/contributing/external-issuers/) 4 | for cert-manager that issues certificates using [Google Cloud 5 | Certificate Authority Service (CAS)](https://cloud.google.com/certificate-authority-service/), using managed private CAs to issue certificates. 6 | 7 | ## Getting started 8 | 9 | ### Prerequisites 10 | 11 | #### CAS-enabled GCP project 12 | 13 | Enable the Certificate Authority API (`privateca.googleapis.com`) in your GCP project by following the 14 | [official documentation](https://cloud.google.com/certificate-authority-service/docs/quickstart). 15 | 16 | #### CAS-managed Certificate Authorities 17 | 18 | You can create a ca pool containing a certificate authority in your current Google project with: 19 | 20 | ```shell 21 | gcloud privateca pools create my-pool --location us-east1 22 | gcloud privateca roots create my-ca --pool my-pool --key-algorithm "ec-p384-sha384" --subject="CN=my-root,O=my-ca,OU=my-ou" --max-chain-length=2 --location us-east1 23 | ``` 24 | 25 | You should also enable the root CA you just created when prompted by `gcloud`. 26 | 27 | > It is recommended to create subordinate CAs for signing leaf 28 | > certificates. See the [official 29 | > documentation](https://cloud.google.com/certificate-authority-service/docs/creating-certificate-authorities). 30 | 31 | #### cert-manager 32 | 33 | If not already running in the cluster, install cert-manager by following the [official documentation](https://cert-manager.io/docs/installation/kubernetes/). 34 | 35 | ### Installing Google CAS Issuer for cert-manager 36 | 37 | ```shell 38 | helm repo add jetstack https://charts.jetstack.io --force-update 39 | helm upgrade -i cert-manager-google-cas-issuer jetstack/cert-manager-google-cas-issuer -n cert-manager --wait 40 | ``` 41 | 42 | Or alternatively, assuming that you have installed cert-manager in the `cert-manager` namespace, you can use a single kubectl 43 | command to install Google CAS Issuer. 44 | Visit the [GitHub releases](https://github.com/jetstack/google-cas-issuer/releases), select the latest release 45 | and copy the command, e.g. 46 | 47 | ```shell 48 | kubectl apply -f https://github.com/jetstack/google-cas-issuer/releases/download/v0.6.1/google-cas-issuer-v0.6.1.yaml 49 | ``` 50 | 51 | You can then skip to the [Setting up Google Cloud IAM](#setting-up-google-cloud-iam) section. 52 | 53 | ##### Build and push the controller image 54 | 55 | **Note**: you can skip this step if using the public images at [quay.io](https://quay.io/repository/jetstack/cert-manager-google-cas-issuer?tag=latest&tab=tags). 56 | 57 | Build the docker image: 58 | 59 | ```shell 60 | make docker-build 61 | ``` 62 | 63 | Push the docker image or load it into kind for testing 64 | 65 | ```shell 66 | make docker-push || kind load docker-image quay.io/jetstack/cert-manager-google-cas-issuer:latest 67 | ``` 68 | 69 | #### Deploy the controller 70 | 71 | Deploy the issuer controller: 72 | 73 | ```shell 74 | cat <