├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ ├── docker-push.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── README.md ├── api └── swagger.yaml ├── cmd └── clouddriver │ └── clouddriver.go ├── configs ├── README.md └── krakend.json ├── deployments ├── spin-clouddriver.yaml ├── spin-doctor-service.yaml ├── spin-doctor.yaml ├── spin-go-clouddriver.yaml ├── spin-router-service.yaml └── spin-router.yaml ├── go.mod ├── go.sum ├── internal ├── api │ ├── core │ │ ├── applications.go │ │ ├── applications_test.go │ │ ├── artifacts.go │ │ ├── artifacts_test.go │ │ ├── controller.go │ │ ├── core_suite_test.go │ │ ├── core_test.go │ │ ├── credentials.go │ │ ├── credentials_test.go │ │ ├── features.go │ │ ├── instances.go │ │ ├── instances_test.go │ │ ├── kubernetes │ │ │ ├── cleanup.go │ │ │ ├── cleanup_test.go │ │ │ ├── controller.go │ │ │ ├── delete.go │ │ │ ├── delete_test.go │ │ │ ├── deploy.go │ │ │ ├── deploy_test.go │ │ │ ├── disable.go │ │ │ ├── disable_test.go │ │ │ ├── enable.go │ │ │ ├── enable_test.go │ │ │ ├── kubernetes_suite_test.go │ │ │ ├── kubernetes_test.go │ │ │ ├── ops.go │ │ │ ├── patch.go │ │ │ ├── patch_test.go │ │ │ ├── restart.go │ │ │ ├── restart_test.go │ │ │ ├── rollback.go │ │ │ ├── rollback_test.go │ │ │ ├── runjob.go │ │ │ ├── runjob_test.go │ │ │ ├── scale.go │ │ │ └── scale_test.go │ │ ├── manifest.go │ │ ├── manifest_test.go │ │ ├── ok.go │ │ ├── ok_test.go │ │ ├── ops.go │ │ ├── ops_test.go │ │ ├── payload_test.go │ │ ├── project.go │ │ ├── project_test.go │ │ ├── search.go │ │ ├── search_test.go │ │ ├── security_groups.go │ │ ├── task.go │ │ ├── task_test.go │ │ └── test │ │ │ ├── expected-git-repo-master-subpath.tar.gz │ │ │ ├── expected-git-repo-master.tar.gz │ │ │ ├── expected-git-repo-test.tar.gz │ │ │ ├── git-repo-master.tar.gz │ │ │ └── git-repo-test.tar.gz │ ├── server.go │ └── v1 │ │ ├── controller.go │ │ ├── payload_test.go │ │ ├── provider.go │ │ ├── provider_test.go │ │ ├── resource.go │ │ ├── resource_test.go │ │ ├── v1_suite_test.go │ │ └── v1_test.go ├── artifact │ ├── artifact_suite_test.go │ ├── artifactfakes │ │ └── fake_credentials_controller.go │ ├── controller.go │ ├── controller_test.go │ └── test │ │ ├── credentials │ │ └── test-keyfile.json │ │ ├── custom-object.json │ │ ├── docker-image.json │ │ ├── embedded-base64.json │ │ ├── front50-pipeline-template.json │ │ ├── gcs-object-credentials.json │ │ ├── gcs-object.json │ │ ├── git-repo-token.json │ │ ├── git-repo.json │ │ ├── github-enterprise-file.json │ │ ├── github-file.json │ │ ├── helm-chart-basic-auth.json │ │ ├── helm-chart.json │ │ ├── http-file.json │ │ └── kubernetes.json ├── controller.go ├── controller_test.go ├── fiat │ ├── client.go │ ├── client_test.go │ ├── fiat_suite_test.go │ └── fiatfakes │ │ └── fake_client.go ├── front50 │ ├── client.go │ ├── client_test.go │ ├── front50_suite_test.go │ └── front50fakes │ │ └── fake_client.go ├── helm │ ├── client.go │ ├── client_test.go │ ├── helm_suite_test.go │ └── helmfakes │ │ └── fake_client.go ├── internal_suite_test.go ├── kubernetes │ ├── annotation.go │ ├── annotation_test.go │ ├── artifact.go │ ├── artifact_test.go │ ├── cached │ │ ├── disk │ │ │ ├── cached_discovery.go │ │ │ ├── cached_discovery_test.go │ │ │ ├── disk_suite_test.go │ │ │ ├── diskfakes │ │ │ │ └── fake_cache_round_tripper.go │ │ │ ├── round_tripper.go │ │ │ └── round_tripper_test.go │ │ └── memory │ │ │ ├── cache.go │ │ │ ├── cache_test.go │ │ │ ├── memory_suite_test.go │ │ │ ├── round_tripper.go │ │ │ └── round_tripper_test.go │ ├── client.go │ ├── clientset.go │ ├── cluster.go │ ├── cluster_test.go │ ├── controller.go │ ├── controller_test.go │ ├── custom_kind.go │ ├── custom_kind_test.go │ ├── daemonset.go │ ├── daemonset_test.go │ ├── deployment.go │ ├── deployment_test.go │ ├── filter.go │ ├── filter_test.go │ ├── horizontalpodautoscaler.go │ ├── horizontalpodautoscaler_test.go │ ├── job.go │ ├── job_test.go │ ├── kubernetes_suite_test.go │ ├── kubernetesfakes │ │ ├── fake_client.go │ │ ├── fake_clientset.go │ │ └── fake_controller.go │ ├── label.go │ ├── label_selector.go │ ├── label_selector_test.go │ ├── label_test.go │ ├── manifest │ │ └── status.go │ ├── namespace.go │ ├── namespace_test.go │ ├── patcher │ │ └── patcher.go │ ├── pod.go │ ├── pod_test.go │ ├── provider.go │ ├── provider_test.go │ ├── replicaset.go │ ├── replicaset_test.go │ ├── resource.go │ ├── sort.go │ ├── sort_test.go │ ├── statefulset.go │ ├── statefulset_test.go │ ├── status.go │ ├── strategy.go │ ├── strategy_test.go │ ├── traffic.go │ ├── traffic_test.go │ ├── unstructured.go │ ├── unstructured_test.go │ ├── version.go │ └── version_test.go ├── map.go ├── map_test.go ├── middleware │ ├── auth.go │ ├── auth_test.go │ ├── cachecontrol.go │ ├── cachecontrol_test.go │ ├── controller.go │ ├── error.go │ ├── log.go │ ├── middleware_suite_test.go │ ├── task.go │ ├── vary.go │ └── vary_test.go ├── sql │ ├── client.go │ ├── client_test.go │ ├── sql_suite_test.go │ └── sqlfakes │ │ └── fake_client.go ├── time.go └── time_test.go └── pkg ├── artifact.go ├── credentials.go ├── error.go ├── logger.go ├── permissions.go └── task.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @alice485 @pjberry16 @guido9j -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | branches: [master] 9 | workflow_call: 10 | 11 | jobs: 12 | build: 13 | name: build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version-file: go.mod 23 | 24 | - name: Get dependencies 25 | run: | 26 | go get -v -t -d ./... 27 | if [ -f Gopkg.toml ]; then 28 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 29 | dep ensure 30 | fi 31 | 32 | - name: Verify dependencies 33 | run: go mod verify 34 | 35 | - name: lint 36 | uses: golangci/golangci-lint-action@v6 37 | with: 38 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 39 | version: v1.58 40 | 41 | - name: Build 42 | run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v cmd/clouddriver/clouddriver.go 43 | 44 | - name: Test 45 | run: go test -v ./... 46 | env: 47 | GOOGLE_APPLICATION_CREDENTIALS: test/credentials/test-keyfile.json 48 | 49 | - uses: actions/upload-artifact@v4 50 | with: 51 | name: build 52 | path: clouddriver 53 | -------------------------------------------------------------------------------- /.github/workflows/docker-push.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | on: 3 | workflow_call: 4 | 5 | env: 6 | CONTAINER_REGISTRY: ghcr.io 7 | IMAGE_NAME: ${{ github.repository }} 8 | 9 | jobs: 10 | push: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - uses: actions/download-artifact@v4 18 | with: 19 | name: build 20 | 21 | - name: Add executable permissions 22 | run: | 23 | chmod +x clouddriver 24 | 25 | - name: Set release info 26 | run: | 27 | event_type=${{ github.event.action }} 28 | release_version=$(echo ${{ github.ref_name }} | sed 's/v//g' | sed 's/+/-/g') 29 | echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV 30 | if [[ $event_type == "released" ]]; then 31 | echo "TAGS=${{ env.CONTAINER_REGISTRY }}/${{ env.IMAGE_NAME }}:$release_version,${{ env.CONTAINER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_ENV 32 | else 33 | echo "TAGS=${{ env.CONTAINER_REGISTRY }}/${{ env.IMAGE_NAME }}:$release_version" >> $GITHUB_ENV 34 | fi 35 | 36 | - name: Login 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ${{ env.CONTAINER_REGISTRY }} 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Set up QEMU 44 | uses: docker/setup-qemu-action@v3 45 | 46 | - name: Set up docker context for buildx 47 | id: buildx-context 48 | run: | 49 | docker context create builders 50 | 51 | - name: Set up docker buildx 52 | uses: docker/setup-buildx-action@v2 53 | with: 54 | endpoint: builders 55 | driver-opts: | 56 | image=moby/buildkit:master 57 | 58 | - name: Build and push 59 | id: docker_build 60 | uses: docker/build-push-action@v5 61 | with: 62 | context: . 63 | push: true 64 | tags: ${{ env.TAGS }} 65 | labels: | 66 | org.opencontainers.image.source=https://github.com/${{ env.IMAGE_NAME }} 67 | org.opencontainers.image.version=${{ env.RELEASE_VERSION }} 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [prereleased, released] 6 | 7 | jobs: 8 | build: 9 | uses: ./.github/workflows/build.yml 10 | 11 | docker: 12 | needs: build 13 | uses: ./.github/workflows/docker-push.yml 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /clouddriver 2 | /test/ 3 | *.coverprofile 4 | clouddriver.db 5 | .idea/ 6 | .DS_Store 7 | vendor/ 8 | .vscode/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | concurrency: 8 3 | issues-exit-code: 2 4 | tests: false 5 | timeout: 200s 6 | 7 | output: 8 | formats: 9 | - format: colored-line-number 10 | 11 | issues: 12 | max-issues-per-linter: 0 13 | max-same-issues: 0 14 | exclude-rules: 15 | - path: _test\.go 16 | linters: 17 | - gochecknoinits 18 | 19 | linters: 20 | disable-all: true 21 | enable: 22 | - bodyclose 23 | - dogsled 24 | - exportloopref 25 | - gocritic 26 | - gofmt 27 | - goimports 28 | - gosimple 29 | - govet 30 | - ineffassign 31 | - misspell 32 | - nakedret 33 | - prealloc 34 | - typecheck 35 | - unconvert 36 | - unparam 37 | - unused 38 | - whitespace 39 | - wsl 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions Welcome 2 | 3 | First off, thank you for considering contributing to Go Clouddriver! 4 | 5 | If you're just looking for quick feedback for an idea or proposal, feel free to open an 6 | [issue](https://github.com/homedepot/go-clouddriver/issues/new). 7 | 8 | Follow the [contribution workflow](#contribution-workflow) for submitting your 9 | changes to the Go Clouddriver codebase. 10 | 11 | ## Contribution Workflow 12 | 13 | The Go Clouddriver repository uses the “fork-and-pull” development model. Follow these steps if 14 | you want to merge your changes to the Go Clouddriver repository: 15 | 16 | 1. Within your fork of 17 | [Go Clouddriver](https://github.com/homedepot/go-clouddriver), create a 18 | branch for your contribution. Use a meaningful name. 19 | 2. Create your contribution, meeting all 20 | [contribution quality standards](#contribution-quality-standards) 21 | 3. [Create a pull request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) 22 | against the master branch of the Go Clouddriver repository in the Homedepot org. Make sure to set the base repository as 23 | `homedepot/go-clouddriver`. 24 | 4. Add a reviewer to your pull request. Work with your reviewer to address any comments and obtain an approval. 25 | To update your pull request amend existing commits whenever applicable and 26 | then push the new changes to your pull request branch. 27 | 5. Once the pull request is approved, one of the [maintainers](MAINTAINERS.md) will merge it. 28 | 29 | ## Contribution Quality Standards 30 | 31 | Your contribution needs to meet the following standards: 32 | 33 | - Separate each **logical change** into its own commit. 34 | - Add a descriptive message for each commit. Follow 35 | [commit message best practices](https://github.com/erlang/otp/wiki/writing-good-commit-messages). 36 | - Document your pull requests. Include the reasoning behind each change, and 37 | the testing done. 38 | - Acknowledge the [Apache 2.0 license](LICENSE). 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | RUN apk add --no-cache ca-certificates curl 3 | COPY clouddriver /usr/local/bin 4 | CMD ["/usr/local/bin/clouddriver"] 5 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | This document contains a list of maintainers of this repo. For information on contributing see [CONTRIBUTING.md](CONTRIBUTING.md). 3 | 4 | | Name | GitHub | 5 | |------|--------| 6 | | James Guido | [@guido9j](https://github.com/guido9j) | 7 | | Parag Patel | [@iteaguy](https://github.com/iteaguy) | 8 | | Alice Garcia | [@alice485](https://github.com/alice485) | 9 | | Don Benjamin | [@theassyrian](https://github.com/theassyrian) | 10 | | Patrick Berry | [@pjberry16](https://github.com/pjberry16) | 11 | | Ryan Johnson | [@ryanjohnsontv](https://github.com/ryanjohnsontv) | 12 | | Abel Rodriguez | [@rodrig67](https://github.com/rodrig67) | 13 | | Rodrigo Tovar | [@tovar-rodrigo](https://github.com/tovar-rodrigo) | 14 | | Jose Anguiano | [@JoseAngui](https://github.com/JoseAngui) | 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: all 2 | 3 | all: clean lint build test 4 | 5 | build: 6 | go build cmd/clouddriver/clouddriver.go 7 | 8 | clean: 9 | go clean 10 | -rm ./clouddriver 11 | 12 | lint: 13 | golangci-lint run 14 | 15 | run: clean lint build test 16 | ./clouddriver 17 | 18 | test: 19 | ginkgo -r 20 | 21 | tools: 22 | go get github.com/onsi/ginkgo/v2/ginkgo 23 | go get github.com/onsi/gomega/... 24 | 25 | vendor: 26 | go mod vendor 27 | 28 | .PHONY: all clean build lint run test tools 29 | -------------------------------------------------------------------------------- /configs/README.md: -------------------------------------------------------------------------------- 1 | # configs 2 | 3 | KrakenD configuration is stored here. It currently supports the following providers. Any additional providers will follow the fork & pull model for contributions to this repository. 4 | 5 | | Provider | Supported | 6 | |----------|:-------------:| 7 | | Kubernetes | ✔️ | 8 | | Appengine | ✔️ | 9 | -------------------------------------------------------------------------------- /deployments/spin-clouddriver.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: spin 6 | cluster: spin-go-clouddriver 7 | name: spin-go-clouddriver 8 | namespace: spinnaker 9 | spec: 10 | ports: 11 | - port: 7002 12 | protocol: TCP 13 | targetPort: 7002 14 | selector: 15 | app: spin 16 | cluster: spin-go-clouddriver 17 | sessionAffinity: None 18 | type: ClusterIP 19 | 20 | -------------------------------------------------------------------------------- /deployments/spin-doctor-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: spin-doctor 6 | name: spin-doctor 7 | namespace: spinnaker 8 | spec: 9 | ports: 10 | - name: http 11 | port: 7002 12 | protocol: TCP 13 | targetPort: http 14 | selector: 15 | app: spin-doctor 16 | -------------------------------------------------------------------------------- /deployments/spin-doctor.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: spin-doctor 6 | name: spin-doctor 7 | namespace: spinnaker 8 | spec: 9 | progressDeadlineSeconds: 600 10 | replicas: 2 11 | revisionHistoryLimit: 10 12 | selector: 13 | matchLabels: 14 | app: spin-doctor 15 | strategy: 16 | rollingUpdate: 17 | maxSurge: 25% 18 | maxUnavailable: 25% 19 | type: RollingUpdate 20 | template: 21 | metadata: 22 | labels: 23 | app: spin-doctor 24 | spec: 25 | containers: 26 | - args: 27 | - -d 28 | - run 29 | - -c 30 | - /vault/secrets/krakend.json 31 | image: devopsfaith/krakend:1.4.1 32 | imagePullPolicy: IfNotPresent 33 | livenessProbe: 34 | failureThreshold: 3 35 | httpGet: 36 | path: /healthz 37 | port: http 38 | scheme: HTTP 39 | initialDelaySeconds: 15 40 | periodSeconds: 20 41 | successThreshold: 1 42 | timeoutSeconds: 1 43 | name: spin-doctor 44 | ports: 45 | - containerPort: 8000 46 | name: http 47 | protocol: TCP 48 | - containerPort: 9091 49 | name: metrics 50 | protocol: TCP 51 | readinessProbe: 52 | failureThreshold: 3 53 | httpGet: 54 | path: /healthz 55 | port: http 56 | scheme: HTTP 57 | initialDelaySeconds: 5 58 | periodSeconds: 10 59 | successThreshold: 1 60 | timeoutSeconds: 1 61 | resources: 62 | requests: 63 | cpu: "3" 64 | memory: 4Gi 65 | -------------------------------------------------------------------------------- /deployments/spin-go-clouddriver.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | creationTimestamp: "2021-11-15T19:54:11Z" 5 | generation: 35 6 | labels: 7 | app: spin 8 | cluster: spin-go-clouddriver 9 | name: spin-go-clouddriver 10 | namespace: spinnaker 11 | spec: 12 | progressDeadlineSeconds: 600 13 | replicas: 2 14 | revisionHistoryLimit: 10 15 | selector: 16 | matchLabels: 17 | app: spin 18 | cluster: spin-go-clouddriver 19 | strategy: 20 | rollingUpdate: 21 | maxSurge: 25% 22 | maxUnavailable: 25% 23 | type: RollingUpdate 24 | template: 25 | metadata: 26 | labels: 27 | app: spin 28 | cluster: spin-go-clouddriver 29 | spec: 30 | containers: 31 | - args: 32 | - clouddriver 33 | command: 34 | - /bin/sh 35 | - -c 36 | env: 37 | - name: ARCADE_API_KEY 38 | value: 39 | - name: ARTIFACTS_CREDENTIALS_CONFIG_DIR 40 | value: 41 | - name: DB_HOST 42 | value: 43 | - name: DB_NAME 44 | value: 45 | - name: DB_PASS 46 | value: 47 | - name: DB_USER 48 | value: 49 | - name: GIN_MODE 50 | value: release 51 | - name: KUBERNETES_USE_DISK_CACHE 52 | value: 53 | - name: VERBOSE_REQUEST_LOGGING 54 | value: 55 | image: docker.io/oshomedepot/go-clouddriver:1.0.0 56 | name: clouddriver 57 | ports: 58 | - containerPort: 7002 59 | protocol: TCP 60 | readinessProbe: 61 | exec: 62 | command: 63 | - wget 64 | - --no-check-certificate 65 | - --spider 66 | - -q 67 | - http://localhost:7002/health 68 | failureThreshold: 3 69 | periodSeconds: 10 70 | successThreshold: 1 71 | timeoutSeconds: 1 72 | resources: 73 | requests: 74 | cpu: "3" 75 | memory: 4Gi 76 | volumeMounts: 77 | - mountPath: /var/kube/cache 78 | name: kube-cache-volume 79 | - args: 80 | - arcade 81 | command: 82 | - /bin/sh 83 | - -c 84 | env: 85 | - name: ARCADE_API_KEY 86 | value: 87 | image: docker.io/oshomedepot/arcade:1.0.2 88 | name: arcade 89 | ports: 90 | - containerPort: 1982 91 | protocol: TCP 92 | volumeMounts: 93 | - mountPath: /secret/arcade/providers 94 | name: arcade-providers-volume 95 | readOnly: true 96 | terminationGracePeriodSeconds: 720 97 | volumes: 98 | - name: arcade-providers-volume 99 | secret: 100 | defaultMode: 420 101 | secretName: arcade-providers 102 | - emptyDir: {} 103 | name: kube-cache-volume 104 | 105 | -------------------------------------------------------------------------------- /deployments/spin-router-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: spin-router 6 | name: spin-router 7 | namespace: spinnaker 8 | spec: 9 | ports: 10 | - nodePort: 31493 11 | port: 3000 12 | protocol: TCP 13 | targetPort: 3000 14 | selector: 15 | app: spin-router 16 | -------------------------------------------------------------------------------- /deployments/spin-router.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: spin-router 6 | name: spin-router 7 | namespace: spinnaker 8 | spec: 9 | progressDeadlineSeconds: 600 10 | replicas: 2 11 | revisionHistoryLimit: 10 12 | selector: 13 | matchLabels: 14 | app: spin-router 15 | strategy: 16 | rollingUpdate: 17 | maxSurge: 25% 18 | maxUnavailable: 25% 19 | type: RollingUpdate 20 | template: 21 | metadata: 22 | labels: 23 | app: spin-router 24 | spec: 25 | containers: 26 | - image: billiford/spin-router:v0.8.0 27 | livenessProbe: 28 | failureThreshold: 3 29 | httpGet: 30 | path: /healthz 31 | port: 3000 32 | scheme: HTTP 33 | initialDelaySeconds: 15 34 | periodSeconds: 20 35 | successThreshold: 1 36 | timeoutSeconds: 1 37 | name: spin-router 38 | ports: 39 | - containerPort: 3000 40 | protocol: TCP 41 | readinessProbe: 42 | failureThreshold: 3 43 | httpGet: 44 | path: /healthz 45 | port: 3000 46 | scheme: HTTP 47 | initialDelaySeconds: 5 48 | periodSeconds: 10 49 | successThreshold: 1 50 | timeoutSeconds: 1 51 | resources: 52 | requests: 53 | cpu: 100m 54 | memory: 256Mi 55 | terminationGracePeriodSeconds: 30 56 | -------------------------------------------------------------------------------- /internal/api/core/controller.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "github.com/homedepot/go-clouddriver/internal" 5 | ) 6 | 7 | // Controller holds all non request-scoped objects. 8 | type Controller struct { 9 | *internal.Controller 10 | } 11 | -------------------------------------------------------------------------------- /internal/api/core/core_suite_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestCore(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Core Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/api/core/features.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type Stages []Stage 10 | 11 | type Stage struct { 12 | Enabled bool `json:"enabled"` 13 | Name string `json:"name"` 14 | } 15 | 16 | var stages = []string{ 17 | "resizeServerGroup", 18 | "runJob", 19 | "undoRolloutManifest", 20 | "rollingRestartManifest", 21 | "pauseRolloutManifest", 22 | "enableManifest", 23 | "scaleManifest", 24 | "disableManifest", 25 | "patchManifest", 26 | "resumeRolloutManifest", 27 | "deleteManifest", 28 | "deployManifest", 29 | "cleanupArtifacts", 30 | "upsertLoadBalancer", 31 | "enableServerGroup", 32 | "createServerGroup", 33 | "deleteLoadBalancer", 34 | "upsertScalingPolicy", 35 | "terminateInstances", 36 | "stopServerGroup", 37 | "disableServerGroup", 38 | "startServerGroup", 39 | "destroyServerGroup", 40 | } 41 | 42 | // Expected response: 43 | // 44 | // [ 45 | // 46 | // { 47 | // "enabled": true, 48 | // "name": "resizeServerGroup" 49 | // }, 50 | // { 51 | // "enabled": true, 52 | // "name": "runJob" 53 | // }, 54 | // { 55 | // "enabled": true, 56 | // "name": "undoRolloutManifest" 57 | // }, 58 | // { 59 | // "enabled": true, 60 | // "name": "rollingRestartManifest" 61 | // }, 62 | // { 63 | // "enabled": true, 64 | // "name": "pauseRolloutManifest" 65 | // }, 66 | // { 67 | // "enabled": true, 68 | // "name": "enableManifest" 69 | // }, 70 | // { 71 | // "enabled": true, 72 | // "name": "scaleManifest" 73 | // }, 74 | // { 75 | // "enabled": true, 76 | // "name": "disableManifest" 77 | // }, 78 | // { 79 | // "enabled": true, 80 | // "name": "patchManifest" 81 | // }, 82 | // { 83 | // "enabled": true, 84 | // "name": "resumeRolloutManifest" 85 | // }, 86 | // { 87 | // "enabled": true, 88 | // "name": "deleteManifest" 89 | // }, 90 | // { 91 | // "enabled": true, 92 | // "name": "deployManifest" 93 | // }, 94 | // { 95 | // "enabled": true, 96 | // "name": "cleanupArtifacts" 97 | // }, 98 | // { 99 | // "enabled": true, 100 | // "name": "upsertLoadBalancer" 101 | // }, 102 | // { 103 | // "enabled": true, 104 | // "name": "enableServerGroup" 105 | // }, 106 | // { 107 | // "enabled": true, 108 | // "name": "createServerGroup" 109 | // }, 110 | // { 111 | // "enabled": true, 112 | // "name": "deleteLoadBalancer" 113 | // }, 114 | // { 115 | // "enabled": true, 116 | // "name": "upsertScalingPolicy" 117 | // }, 118 | // { 119 | // "enabled": true, 120 | // "name": "terminateInstances" 121 | // }, 122 | // { 123 | // "enabled": true, 124 | // "name": "stopServerGroup" 125 | // }, 126 | // { 127 | // "enabled": true, 128 | // "name": "disableServerGroup" 129 | // }, 130 | // { 131 | // "enabled": true, 132 | // "name": "startServerGroup" 133 | // }, 134 | // { 135 | // "enabled": true, 136 | // "name": "destroyServerGroup" 137 | // } 138 | // 139 | // ] 140 | func ListStages(c *gin.Context) { 141 | response := Stages{} 142 | 143 | for _, stage := range stages { 144 | s := Stage{ 145 | Enabled: false, 146 | Name: stage, 147 | } 148 | response = append(response, s) 149 | } 150 | 151 | c.JSON(http.StatusOK, response) 152 | } 153 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/cleanup.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sort" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/google/uuid" 10 | "github.com/homedepot/go-clouddriver/internal" 11 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 12 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 16 | ) 17 | 18 | func (cc *Controller) CleanupArtifacts(c *gin.Context, ca CleanupArtifactsRequest) { 19 | app := c.GetHeader("X-Spinnaker-Application") 20 | taskID := clouddriver.TaskIDFromContext(c) 21 | 22 | for _, manifest := range ca.Manifests { 23 | u, err := kubernetes.ToUnstructured(manifest) 24 | if err != nil { 25 | clouddriver.Error(c, http.StatusBadRequest, err) 26 | return 27 | } 28 | 29 | provider, err := cc.KubernetesProvider(ca.Account) 30 | if err != nil { 31 | clouddriver.Error(c, http.StatusBadRequest, err) 32 | return 33 | } 34 | 35 | gvr, err := provider.Client.GVRForKind(u.GetKind()) 36 | if err != nil { 37 | clouddriver.Error(c, http.StatusInternalServerError, err) 38 | return 39 | } 40 | 41 | namespace := u.GetNamespace() 42 | 43 | // Preserve backwards compatibility 44 | if len(provider.Namespaces) == 1 { 45 | namespace = provider.Namespaces[0] 46 | } 47 | 48 | err = provider.ValidateNamespaceAccess(namespace) 49 | if err != nil { 50 | clouddriver.Log(err) 51 | continue 52 | } 53 | 54 | // Grab the cluster of this resource from its annotations. 55 | cluster := clusterAnnotation(u) 56 | // Handle max version history. Source code here: 57 | // https://github.com/spinnaker/clouddriver/blob/master/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/op/artifact/KubernetesCleanupArtifactsOperation.java#L102 58 | maxVersionHistory, err := kubernetes.MaxVersionHistory(u) 59 | if err == nil && maxVersionHistory > 0 && cluster != "" { 60 | // Only list resources that are managed by Spinnaker. 61 | lo := metav1.ListOptions{ 62 | LabelSelector: kubernetes.DefaultLabelSelector(), 63 | } 64 | 65 | ul, err := provider.Client.ListResourcesByKindAndNamespace(u.GetKind(), namespace, lo) 66 | if err != nil { 67 | clouddriver.Error(c, http.StatusInternalServerError, 68 | fmt.Errorf("error listing resources to cleanup for max version history (kind: %s, name: %s, namespace: %s): %v", 69 | u.GetKind(), u.GetName(), namespace, err)) 70 | 71 | return 72 | } 73 | 74 | artifacts := kubernetes.FilterOnAnnotation(ul.Items, 75 | kubernetes.AnnotationSpinnakerMonikerCluster, cluster) 76 | if maxVersionHistory < len(artifacts) { 77 | // Sort on creation timestamp oldest to newest. 78 | sort.Slice(artifacts, func(i, j int) bool { 79 | return artifacts[i].GetCreationTimestamp().String() < artifacts[j].GetCreationTimestamp().String() 80 | }) 81 | 82 | artifactsToDelete := artifacts[0 : len(artifacts)-maxVersionHistory] 83 | for _, a := range artifactsToDelete { 84 | // Delete the resource and any dependants in the foreground. 85 | pp := v1.DeletePropagationForeground 86 | do := metav1.DeleteOptions{ 87 | PropagationPolicy: &pp, 88 | } 89 | 90 | err = provider.Client.DeleteResourceByKindAndNameAndNamespace(a.GetKind(), a.GetName(), namespace, do) 91 | if err != nil { 92 | clouddriver.Error(c, http.StatusInternalServerError, 93 | fmt.Errorf("error deleting resource to cleanup for max version history (kind: %s, name: %s, namespace: %s): %v", 94 | a.GetKind(), a.GetName(), namespace, err)) 95 | 96 | return 97 | } 98 | } 99 | } 100 | } 101 | 102 | kr := kubernetes.Resource{ 103 | AccountName: ca.Account, 104 | ID: uuid.New().String(), 105 | TaskID: taskID, 106 | TaskType: clouddriver.TaskTypeCleanup, 107 | Timestamp: internal.CurrentTimeUTC(), 108 | APIGroup: gvr.Group, 109 | Name: u.GetName(), 110 | Namespace: namespace, 111 | Resource: gvr.Resource, 112 | Version: gvr.Version, 113 | Kind: u.GetKind(), 114 | SpinnakerApp: app, 115 | Cluster: cluster, 116 | } 117 | 118 | err = cc.SQLClient.CreateKubernetesResource(kr) 119 | if err != nil { 120 | clouddriver.Error(c, http.StatusInternalServerError, err) 121 | return 122 | } 123 | } 124 | } 125 | 126 | // clusterAnnotation returns the value of the annotation 127 | // 'moniker.spinnaker.io/cluster' if it exists, otherwise 128 | // it returns an empty string. 129 | func clusterAnnotation(u unstructured.Unstructured) string { 130 | cluster := "" 131 | 132 | annotations := u.GetAnnotations() 133 | if annotations != nil { 134 | if value, ok := annotations[kubernetes.AnnotationSpinnakerMonikerCluster]; ok { 135 | cluster = value 136 | } 137 | } 138 | 139 | return cluster 140 | } 141 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/controller.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import "github.com/homedepot/go-clouddriver/internal" 4 | 5 | // Controller holds all non request-scoped objects. 6 | type Controller struct { 7 | *internal.Controller 8 | } 9 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/enable.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/google/uuid" 12 | "github.com/homedepot/go-clouddriver/internal" 13 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 14 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 15 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 18 | ) 19 | 20 | // Enable takes in manifest coordinates and grabs the list of load balancers behind which 21 | // it needs to be fronted from the annotation `traffic.spinnaker.io/load-balancers`. 22 | // It loops through these load balancers, adding any selectors from the load balancer's 23 | // labels and patching the target resource using the JSON patch strategy. It then 24 | // patches the labels of all pods that this manifest owns. 25 | func (cc *Controller) Enable(c *gin.Context, dm EnableManifestRequest) { 26 | taskID := clouddriver.TaskIDFromContext(c) 27 | namespace := dm.Location 28 | 29 | provider, err := cc.KubernetesProvider(dm.Account) 30 | if err != nil { 31 | clouddriver.Error(c, http.StatusBadRequest, err) 32 | return 33 | } 34 | 35 | // Preserve backwards compatibility 36 | if len(provider.Namespaces) == 1 { 37 | namespace = provider.Namespaces[0] 38 | } 39 | 40 | // ManifestName is the kind and name of the manifest, including any version, like 41 | // 'ReplicaSet test-rs-v001'. 42 | a := strings.Split(dm.ManifestName, " ") 43 | if len(a) != 2 { 44 | clouddriver.Error(c, http.StatusBadRequest, errInvalidManifestName) 45 | return 46 | } 47 | 48 | kind := a[0] 49 | name := a[1] 50 | 51 | err = provider.ValidateKindStatus(kind) 52 | if err != nil { 53 | clouddriver.Error(c, http.StatusBadRequest, err) 54 | return 55 | } 56 | 57 | err = provider.ValidateNamespaceAccess(namespace) 58 | if err != nil { 59 | clouddriver.Error(c, http.StatusBadRequest, err) 60 | return 61 | } 62 | 63 | // Grab the target manifest. 64 | target, err := provider.Client.Get(kind, name, namespace) 65 | if err != nil { 66 | if k8serrors.IsNotFound(err) { 67 | clouddriver.Error(c, http.StatusNotFound, fmt.Errorf("resource %s %s does not exist", kind, name)) 68 | return 69 | } 70 | 71 | clouddriver.Error(c, http.StatusInternalServerError, fmt.Errorf("error getting resource (kind: %s, name: %s, namespace: %s): %v", 72 | kind, name, namespace, err)) 73 | 74 | return 75 | } 76 | 77 | loadBalancers, err := kubernetes.LoadBalancers(*target) 78 | if err != nil { 79 | clouddriver.Error(c, http.StatusBadRequest, err) 80 | return 81 | } 82 | 83 | var pods []*unstructured.Unstructured 84 | // If the target manifest has load balancers and pods, list pods, grab those that has the owner UID 85 | // of the target manifest, and patch those pods. 86 | if len(loadBalancers) > 0 && hasPods(target) { 87 | // Declare server side filtering options. 88 | lo := metav1.ListOptions{ 89 | FieldSelector: "metadata.namespace=" + namespace, 90 | LabelSelector: kubernetes.DefaultLabelSelector(), 91 | } 92 | // Declare a context with timeout. 93 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*internal.DefaultListTimeoutSeconds) 94 | defer cancel() 95 | // List resources with the context. 96 | ul, err := provider.Client.ListResourceWithContext(ctx, "pods", lo) 97 | if err != nil { 98 | clouddriver.Error(c, http.StatusInternalServerError, err) 99 | return 100 | } 101 | // Loop through all pods, finding all that are owned by the target manifest. 102 | for _, u := range ul.Items { 103 | for _, ownerReference := range u.GetOwnerReferences() { 104 | if ownerReference.UID == target.GetUID() { 105 | // Create a copy of the unstructured object since we access by reference. 106 | u := u 107 | pods = append(pods, &u) 108 | } 109 | } 110 | } 111 | } 112 | 113 | for _, loadBalancer := range loadBalancers { 114 | lb, err := getLoadBalancer(provider.Client, loadBalancer, namespace) 115 | if err != nil { 116 | clouddriver.Error(c, http.StatusInternalServerError, err) 117 | return 118 | } 119 | 120 | err = attachDetach(provider.Client, lb, target, "add") 121 | if err != nil { 122 | clouddriver.Error(c, http.StatusInternalServerError, err) 123 | return 124 | } 125 | 126 | // Patch all pods. 127 | for _, pod := range pods { 128 | err = attachDetach(provider.Client, lb, pod, "add") 129 | if err != nil { 130 | clouddriver.Error(c, http.StatusInternalServerError, err) 131 | return 132 | } 133 | } 134 | } 135 | 136 | // Just create one entry for a successful attachment to load balancers. 137 | kr := kubernetes.Resource{ 138 | TaskType: clouddriver.TaskTypeNoOp, 139 | AccountName: dm.Account, 140 | SpinnakerApp: dm.App, 141 | ID: uuid.New().String(), 142 | TaskID: taskID, 143 | Name: name, 144 | Namespace: namespace, 145 | Kind: kind, 146 | } 147 | 148 | err = cc.SQLClient.CreateKubernetesResource(kr) 149 | if err != nil { 150 | clouddriver.Error(c, http.StatusInternalServerError, err) 151 | return 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/kubernetes_suite_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestKubernetes(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Kubernetes HTTP Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/patch.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 12 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | "k8s.io/apimachinery/pkg/types" 15 | ) 16 | 17 | func (cc *Controller) Patch(c *gin.Context, pm PatchManifestRequest) { 18 | taskID := clouddriver.TaskIDFromContext(c) 19 | namespace := pm.Location 20 | 21 | provider, err := cc.KubernetesProvider(pm.Account) 22 | if err != nil { 23 | clouddriver.Error(c, http.StatusBadRequest, err) 24 | return 25 | } 26 | 27 | // Preserve backwards compatibility 28 | if len(provider.Namespaces) == 1 { 29 | namespace = provider.Namespaces[0] 30 | } 31 | 32 | b, err := json.Marshal(pm.PatchBody) 33 | if err != nil { 34 | clouddriver.Error(c, http.StatusBadRequest, err) 35 | return 36 | } 37 | 38 | // Manifest name is *really* the Spinnaker cluster - i.e. "deployment test-deployment", so we 39 | // need to split on a whitespace and get the actual name of the manifest. 40 | kind := "" 41 | name := pm.ManifestName 42 | 43 | a := strings.Split(pm.ManifestName, " ") 44 | if len(a) > 1 { 45 | kind = a[0] 46 | name = a[1] 47 | } 48 | 49 | err = provider.ValidateKindStatus(kind) 50 | if err != nil { 51 | clouddriver.Error(c, http.StatusBadRequest, err) 52 | return 53 | } 54 | 55 | err = provider.ValidateNamespaceAccess(namespace) 56 | if err != nil { 57 | clouddriver.Error(c, http.StatusBadRequest, err) 58 | return 59 | } 60 | 61 | // Only bind artifacts for "strategic" or "merge" strategy. 62 | // 63 | // See https://spinnaker.io/docs/guides/user/kubernetes-v2/patch-manifest/#override-artifacts 64 | if pm.Options.MergeStrategy == "strategic" || 65 | pm.Options.MergeStrategy == "merge" { 66 | m := map[string]interface{}{} 67 | if err := json.Unmarshal(b, &m); err != nil { 68 | clouddriver.Error(c, http.StatusBadRequest, err) 69 | return 70 | } 71 | 72 | u := unstructured.Unstructured{ 73 | Object: m, 74 | } 75 | kubernetes.BindArtifacts(&u, pm.AllArtifacts, pm.Account) 76 | 77 | b, err = json.Marshal(&u.Object) 78 | if err != nil { 79 | clouddriver.Error(c, http.StatusInternalServerError, err) 80 | return 81 | } 82 | } 83 | 84 | // Merge strategy can be "strategic", "json", or "merge". 85 | var strategy types.PatchType 86 | 87 | switch pm.Options.MergeStrategy { 88 | case "strategic": 89 | strategy = types.StrategicMergePatchType 90 | case "json": 91 | strategy = types.JSONPatchType 92 | case "merge": 93 | strategy = types.MergePatchType 94 | default: 95 | clouddriver.Error(c, http.StatusBadRequest, 96 | fmt.Errorf("invalid merge strategy %s", pm.Options.MergeStrategy)) 97 | return 98 | } 99 | 100 | meta, _, err := provider.Client.PatchUsingStrategy(kind, name, namespace, b, strategy) 101 | if err != nil { 102 | clouddriver.Error(c, http.StatusInternalServerError, err) 103 | return 104 | } 105 | 106 | // TODO Record the applied patch in the kubernetes.io/change-cause annotation. If the annotation already exists, the contents are replaced. 107 | // if pm.Options.Record { 108 | // } 109 | 110 | kr := kubernetes.Resource{ 111 | AccountName: pm.Account, 112 | ID: uuid.New().String(), 113 | TaskID: taskID, 114 | APIGroup: meta.Group, 115 | Name: meta.Name, 116 | Namespace: meta.Namespace, 117 | Resource: meta.Resource, 118 | Version: meta.Version, 119 | Kind: meta.Kind, 120 | SpinnakerApp: pm.App, 121 | } 122 | 123 | err = cc.SQLClient.CreateKubernetesResource(kr) 124 | if err != nil { 125 | clouddriver.Error(c, http.StatusInternalServerError, err) 126 | return 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/restart.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 12 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 13 | ) 14 | 15 | // RollingRestart performs a `kubectl rollout restart` by setting an annotation on a pod template 16 | // to the current time in RFC3339. 17 | func (cc *Controller) RollingRestart(c *gin.Context, rr RollingRestartManifestRequest) { 18 | app := c.GetHeader("X-Spinnaker-Application") 19 | taskID := clouddriver.TaskIDFromContext(c) 20 | namespace := rr.Location 21 | 22 | provider, err := cc.KubernetesProvider(rr.Account) 23 | if err != nil { 24 | clouddriver.Error(c, http.StatusBadRequest, err) 25 | return 26 | } 27 | 28 | // Preserve backwards compatibility 29 | if len(provider.Namespaces) == 1 { 30 | namespace = provider.Namespaces[0] 31 | } 32 | 33 | a := strings.Split(rr.ManifestName, " ") 34 | kind := a[0] 35 | name := a[1] 36 | 37 | err = provider.ValidateKindStatus(kind) 38 | if err != nil { 39 | clouddriver.Error(c, http.StatusBadRequest, err) 40 | return 41 | } 42 | 43 | err = provider.ValidateNamespaceAccess(namespace) 44 | if err != nil { 45 | clouddriver.Error(c, http.StatusBadRequest, err) 46 | return 47 | } 48 | 49 | u, err := provider.Client.Get(kind, name, namespace) 50 | if err != nil { 51 | clouddriver.Error(c, http.StatusInternalServerError, err) 52 | return 53 | } 54 | 55 | var meta kubernetes.Metadata 56 | 57 | switch strings.ToLower(kind) { 58 | case "deployment": 59 | // Add annotation to pod spec: 60 | // kubectl.kubernetes.io/restartedAt: "2020-08-21T03:56:27Z" 61 | err = kubernetes.AnnotateTemplate(u, "clouddriver.spinnaker.io/restartedAt", 62 | time.Now().In(time.UTC).Format(time.RFC3339)) 63 | if err != nil { 64 | clouddriver.Error(c, http.StatusInternalServerError, err) 65 | return 66 | } 67 | 68 | meta, err = provider.Client.Apply(u) 69 | if err != nil { 70 | clouddriver.Error(c, http.StatusInternalServerError, err) 71 | return 72 | } 73 | 74 | default: 75 | clouddriver.Error(c, http.StatusBadRequest, fmt.Errorf("restarting kind %s not currently supported", kind)) 76 | return 77 | } 78 | 79 | kr := kubernetes.Resource{ 80 | AccountName: rr.Account, 81 | ID: uuid.New().String(), 82 | TaskID: taskID, 83 | APIGroup: meta.Group, 84 | Name: meta.Name, 85 | Namespace: meta.Namespace, 86 | Resource: meta.Resource, 87 | Version: meta.Version, 88 | Kind: meta.Kind, 89 | SpinnakerApp: app, 90 | } 91 | 92 | err = cc.SQLClient.CreateKubernetesResource(kr) 93 | if err != nil { 94 | clouddriver.Error(c, http.StatusInternalServerError, err) 95 | return 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/restart_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("RollingRestart", func() { 13 | BeforeEach(func() { 14 | setup() 15 | }) 16 | 17 | JustBeforeEach(func() { 18 | kubernetesController.RollingRestart(c, rollingRestartManifestRequest) 19 | }) 20 | 21 | When("getting the provider returns an error", func() { 22 | BeforeEach(func() { 23 | fakeSQLClient.GetKubernetesProviderReturns(kubernetes.Provider{}, errors.New("error getting provider")) 24 | }) 25 | 26 | It("returns an error", func() { 27 | Expect(c.Writer.Status()).To(Equal(http.StatusBadRequest)) 28 | Expect(c.Errors.Last().Error()).To(Equal("internal: error getting kubernetes provider spin-cluster-account: error getting provider")) 29 | }) 30 | }) 31 | 32 | When("getting the manifest returns an error", func() { 33 | BeforeEach(func() { 34 | fakeKubeClient.GetReturns(nil, errors.New("error getting manifest")) 35 | }) 36 | 37 | It("returns an error", func() { 38 | Expect(c.Writer.Status()).To(Equal(http.StatusInternalServerError)) 39 | Expect(c.Errors.Last().Error()).To(Equal("error getting manifest")) 40 | }) 41 | }) 42 | 43 | When("applying the manifest returns an error", func() { 44 | BeforeEach(func() { 45 | fakeKubeClient.ApplyReturns(kubernetes.Metadata{}, errors.New("error applying manifest")) 46 | }) 47 | 48 | It("returns an error", func() { 49 | Expect(c.Writer.Status()).To(Equal(http.StatusInternalServerError)) 50 | Expect(c.Errors.Last().Error()).To(Equal("error applying manifest")) 51 | }) 52 | }) 53 | 54 | When("creating the resource returns an error", func() { 55 | BeforeEach(func() { 56 | fakeSQLClient.CreateKubernetesResourceReturns(errors.New("error creating resource")) 57 | }) 58 | 59 | It("returns an error", func() { 60 | Expect(c.Writer.Status()).To(Equal(http.StatusInternalServerError)) 61 | Expect(c.Errors.Last().Error()).To(Equal("error creating resource")) 62 | }) 63 | }) 64 | 65 | When("the kind is not supported to restarted", func() { 66 | BeforeEach(func() { 67 | rollingRestartManifestRequest.ManifestName = "not-supported-kind test-name" 68 | }) 69 | 70 | It("returns an error", func() { 71 | Expect(c.Writer.Status()).To(Equal(http.StatusBadRequest)) 72 | Expect(c.Errors.Last().Error()).To(Equal("restarting kind not-supported-kind not currently supported")) 73 | }) 74 | }) 75 | 76 | When("it succeeds", func() { 77 | It("succeeds", func() { 78 | Expect(c.Writer.Status()).To(Equal(http.StatusOK)) 79 | }) 80 | }) 81 | 82 | When("Using a namespace-scoped provider", func() { 83 | BeforeEach(func() { 84 | fakeSQLClient.GetKubernetesProviderReturns(namespaceScopedProvider, nil) 85 | }) 86 | 87 | When("the kind is not supported", func() { 88 | BeforeEach(func() { 89 | rollingRestartManifestRequest.ManifestName = "namespace someNamespace" 90 | }) 91 | 92 | It("returns an error", func() { 93 | Expect(c.Writer.Status()).To(Equal(http.StatusBadRequest)) 94 | Expect(c.Errors.Last().Error()).To(Equal("namespace-scoped account not allowed to access cluster-scoped kind: 'namespace'")) 95 | }) 96 | }) 97 | 98 | When("the kind is supported", func() { 99 | It("succeeds", func() { 100 | Expect(c.Writer.Status()).To(Equal(http.StatusOK)) 101 | _, _, namespace := fakeKubeClient.GetArgsForCall(0) 102 | Expect(namespace).To(Equal("provider-namespace")) 103 | }) 104 | }) 105 | }) 106 | 107 | When("Using a multiple namespace-scoped provider", func() { 108 | BeforeEach(func() { 109 | fakeSQLClient.GetKubernetesProviderReturns(multipleNamespaceScopedProvider, nil) 110 | }) 111 | 112 | When("the kind is not supported", func() { 113 | BeforeEach(func() { 114 | rollingRestartManifestRequest.ManifestName = "namespace someNamespace" 115 | }) 116 | 117 | It("returns an error", func() { 118 | Expect(c.Writer.Status()).To(Equal(http.StatusBadRequest)) 119 | Expect(c.Errors.Last().Error()).To(Equal("namespace-scoped account not allowed to access cluster-scoped kind: 'namespace'")) 120 | }) 121 | }) 122 | 123 | When("the kind is supported", func() { 124 | BeforeEach(func() { 125 | rollingRestartManifestRequest.Location = "provider-namespace" 126 | }) 127 | It("succeeds", func() { 128 | Expect(c.Writer.Status()).To(Equal(http.StatusOK)) 129 | _, _, namespace := fakeKubeClient.GetArgsForCall(0) 130 | Expect(namespace).To(Equal("provider-namespace")) 131 | }) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/runjob.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/google/uuid" 8 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 9 | kube "github.com/homedepot/go-clouddriver/internal/kubernetes" 10 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 11 | "k8s.io/apimachinery/pkg/util/rand" 12 | ) 13 | 14 | const randNameNumber = 5 15 | 16 | func (cc *Controller) RunJob(c *gin.Context, rj RunJobRequest) { 17 | taskID := clouddriver.TaskIDFromContext(c) 18 | 19 | provider, err := cc.KubernetesProvider(rj.Account) 20 | if err != nil { 21 | clouddriver.Error(c, http.StatusBadRequest, err) 22 | return 23 | } 24 | 25 | u, err := kube.ToUnstructured(rj.Manifest) 26 | if err != nil { 27 | clouddriver.Error(c, http.StatusInternalServerError, err) 28 | return 29 | } 30 | 31 | namespace := "" 32 | 33 | // Preserve backwards compatibility 34 | if len(provider.Namespaces) == 1 { 35 | namespace = provider.Namespaces[0] 36 | } 37 | 38 | kubernetes.SetNamespaceOnManifest(&u, namespace) 39 | 40 | err = provider.ValidateNamespaceAccess(u.GetNamespace()) // pass in the current manifest's namespace 41 | if err != nil { 42 | clouddriver.Error(c, http.StatusBadRequest, err) 43 | return 44 | } 45 | 46 | err = kube.AddSpinnakerAnnotations(&u, rj.Application) 47 | if err != nil { 48 | clouddriver.Error(c, http.StatusInternalServerError, err) 49 | return 50 | } 51 | 52 | err = kube.AddSpinnakerLabels(&u, rj.Application) 53 | if err != nil { 54 | clouddriver.Error(c, http.StatusInternalServerError, err) 55 | return 56 | } 57 | 58 | name := u.GetName() 59 | generateName := u.GetGenerateName() 60 | 61 | if name == "" && generateName != "" { 62 | u.SetName(generateName + rand.String(randNameNumber)) 63 | } 64 | 65 | kubernetes.BindArtifacts(&u, append(rj.RequiredArtifacts, rj.OptionalArtifacts...), rj.Account) 66 | 67 | meta := kubernetes.Metadata{} 68 | if kubernetes.Replace(u) { 69 | meta, err = provider.Client.Replace(&u) 70 | } else { 71 | meta, err = provider.Client.Apply(&u) 72 | } 73 | 74 | if err != nil { 75 | clouddriver.Error(c, http.StatusInternalServerError, err) 76 | return 77 | } 78 | 79 | kr := kubernetes.Resource{ 80 | AccountName: rj.Account, 81 | ID: uuid.New().String(), 82 | TaskID: taskID, 83 | APIGroup: meta.Group, 84 | Name: meta.Name, 85 | Namespace: meta.Namespace, 86 | Resource: meta.Resource, 87 | Version: meta.Version, 88 | Kind: "job", 89 | SpinnakerApp: rj.Application, 90 | } 91 | 92 | err = cc.SQLClient.CreateKubernetesResource(kr) 93 | if err != nil { 94 | clouddriver.Error(c, http.StatusInternalServerError, err) 95 | return 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/api/core/kubernetes/scale.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 12 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | ) 15 | 16 | func (cc *Controller) Scale(c *gin.Context, sm ScaleManifestRequest) { 17 | app := c.GetHeader("X-Spinnaker-Application") 18 | taskID := clouddriver.TaskIDFromContext(c) 19 | namespace := sm.Location 20 | 21 | provider, err := cc.KubernetesProvider(sm.Account) 22 | if err != nil { 23 | clouddriver.Error(c, http.StatusBadRequest, err) 24 | return 25 | } 26 | 27 | // Preserve backwards compatibility 28 | if len(provider.Namespaces) == 1 { 29 | namespace = provider.Namespaces[0] 30 | } 31 | 32 | a := strings.Split(sm.ManifestName, " ") 33 | kind := a[0] 34 | name := a[1] 35 | 36 | err = provider.ValidateKindStatus(kind) 37 | if err != nil { 38 | clouddriver.Error(c, http.StatusBadRequest, err) 39 | return 40 | } 41 | 42 | err = provider.ValidateNamespaceAccess(namespace) 43 | if err != nil { 44 | clouddriver.Error(c, http.StatusBadRequest, err) 45 | return 46 | } 47 | 48 | u, err := provider.Client.Get(kind, name, namespace) 49 | if err != nil { 50 | clouddriver.Error(c, http.StatusInternalServerError, err) 51 | return 52 | } 53 | 54 | var meta kubernetes.Metadata 55 | 56 | switch strings.ToLower(kind) { 57 | case "deployment", "replicaset", "statefulset": 58 | r, err := strconv.Atoi(sm.Replicas) 59 | if err != nil { 60 | clouddriver.Error(c, http.StatusBadRequest, err) 61 | return 62 | } 63 | 64 | err = unstructured.SetNestedField(u.Object, int64(r), "spec", "replicas") 65 | if err != nil { 66 | clouddriver.Error(c, http.StatusBadRequest, err) 67 | return 68 | } 69 | 70 | meta, err = provider.Client.Apply(u) 71 | if err != nil { 72 | clouddriver.Error(c, http.StatusInternalServerError, err) 73 | return 74 | } 75 | default: 76 | clouddriver.Error(c, http.StatusBadRequest, 77 | fmt.Errorf("scaling kind %s not currently supported", kind)) 78 | return 79 | } 80 | 81 | kr := kubernetes.Resource{ 82 | AccountName: sm.Account, 83 | ID: uuid.New().String(), 84 | TaskID: taskID, 85 | APIGroup: meta.Group, 86 | Name: meta.Name, 87 | Namespace: meta.Namespace, 88 | Resource: meta.Resource, 89 | Version: meta.Version, 90 | Kind: meta.Kind, 91 | SpinnakerApp: app, 92 | } 93 | 94 | err = cc.SQLClient.CreateKubernetesResource(kr) 95 | if err != nil { 96 | clouddriver.Error(c, http.StatusInternalServerError, err) 97 | return 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/api/core/ok.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func OK(*gin.Context) {} 6 | -------------------------------------------------------------------------------- /internal/api/core/ok_test.go: -------------------------------------------------------------------------------- 1 | package core_test 2 | 3 | import ( 4 | // . "github.com/homedepot/go-clouddriver/internal/api/v0" 5 | 6 | "net/http" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | var _ = Describe("Ok", func() { 13 | BeforeEach(func() { 14 | setup() 15 | uri = svr.URL + "/health" 16 | createRequest(http.MethodGet) 17 | }) 18 | 19 | AfterEach(func() { 20 | svr.Close() 21 | }) 22 | 23 | JustBeforeEach(func() { 24 | doRequest() 25 | }) 26 | 27 | It("returns OK", func() { 28 | Expect(res.StatusCode).To(Equal(http.StatusOK)) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /internal/api/core/ops.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gin-gonic/gin/binding" 8 | "github.com/homedepot/go-clouddriver/internal/api/core/kubernetes" 9 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 10 | ) 11 | 12 | // CreateKubernetesOperation is the main function that starts a kubernetes operation. 13 | // 14 | // Kubernetes operations are things like deploy/delete manifest or perform 15 | // a rolling restart. Spinnaker sends *all* of these types of events to the 16 | // same endpoint (/kubernetes/ops), so we have to unmarshal and check which 17 | // kind of operation we are performing. 18 | // 19 | // The actual actions have been moved to the kubernetes subfolder to make 20 | // this function a bit more readable. 21 | func (cc *Controller) CreateKubernetesOperation(c *gin.Context) { 22 | // All operations are bound to a task ID and stored in the database. 23 | ko := kubernetes.Operations{} 24 | taskID := clouddriver.TaskIDFromContext(c) 25 | 26 | if err := c.ShouldBindBodyWith(&ko, binding.JSON); err != nil { 27 | clouddriver.Error(c, http.StatusBadRequest, err) 28 | return 29 | } 30 | 31 | kc := kubernetes.Controller{ 32 | Controller: cc.Controller, 33 | } 34 | // Loop through each request in the kubernetes operations and perform 35 | // each requested action. 36 | for _, req := range ko { 37 | if req.DeployManifest != nil { 38 | kc.Deploy(c, *req.DeployManifest) 39 | } 40 | 41 | if req.DeleteManifest != nil { 42 | kc.Delete(c, *req.DeleteManifest) 43 | } 44 | 45 | if req.DisableManifest != nil { 46 | kc.Disable(c, *req.DisableManifest) 47 | } 48 | 49 | if req.EnableManifest != nil { 50 | kc.Enable(c, *req.EnableManifest) 51 | } 52 | 53 | if req.ScaleManifest != nil { 54 | kc.Scale(c, *req.ScaleManifest) 55 | } 56 | 57 | if req.CleanupArtifacts != nil { 58 | kc.CleanupArtifacts(c, *req.CleanupArtifacts) 59 | } 60 | 61 | if req.RollingRestartManifest != nil { 62 | kc.RollingRestart(c, *req.RollingRestartManifest) 63 | } 64 | 65 | if req.RunJob != nil { 66 | kc.RunJob(c, *req.RunJob) 67 | } 68 | 69 | if req.UndoRolloutManifest != nil { 70 | kc.Rollback(c, *req.UndoRolloutManifest) 71 | } 72 | 73 | if req.PatchManifest != nil { 74 | kc.Patch(c, *req.PatchManifest) 75 | } 76 | 77 | if c.Errors != nil && len(c.Errors) > 0 { 78 | return 79 | } 80 | } 81 | 82 | or := kubernetes.OperationsResponse{ 83 | ID: taskID, 84 | ResourceURI: "/task/" + taskID, 85 | } 86 | c.JSON(http.StatusOK, or) 87 | } 88 | -------------------------------------------------------------------------------- /internal/api/core/security_groups.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // I'm not sure what this endpoint is supposed to do or what Spinnaker does with it, 10 | // but here is an example of the response. I found that returning an empty object "{}" 11 | // satisfies Deck. 12 | // 13 | // { 14 | // "label": "securityGroups", 15 | // "name": "applications", 16 | // "profiles": [ 17 | // "smoketests" 18 | // ], 19 | // "propertySources": [ 20 | // { 21 | // "name": "vault:spinnaker", 22 | // "source": { 23 | // "kubernetes.accounts[0].cacheIntervalSeconds": 3600, 24 | // "kubernetes.accounts[0].cacheThreads": 4, 25 | // "kubernetes.accounts[0].cachingPolicies": "", 26 | // "kubernetes.accounts[0].checkPermissionsOnStartup": false, 27 | // "kubernetes.accounts[0].configureImagePullSecrets": true, 28 | // "kubernetes.accounts[0].context": "...", 29 | // "kubernetes.accounts[0].dockerRegistries[0].accountName": "docker-registry", 30 | // "kubernetes.accounts[0].dockerRegistries[0].namespaces": "", 31 | // "kubernetes.accounts[0].kubeconfigContents": "...", 32 | // "kubernetes.accounts[0].liveManifestCalls": true, 33 | // "kubernetes.accounts[0].metrics": false, 34 | // "kubernetes.accounts[0].name": "...", 35 | // "kubernetes.accounts[0].namespaces": "", 36 | // "kubernetes.accounts[0].oAuthScopes": "", 37 | // "kubernetes.accounts[0].omitKinds[0]": "podPreset", 38 | // "kubernetes.accounts[0].omitNamespaces[0]": "kube-public", 39 | // "kubernetes.accounts[0].omitNamespaces[1]": "kube-node-lease", 40 | // "kubernetes.accounts[0].onlySpinnakerManaged": true, 41 | // "kubernetes.accounts[0].permissions.READ[0]": "...", 42 | // "kubernetes.accounts[0].permissions.READ[1]": "...", 43 | // "kubernetes.accounts[0].permissions.WRITE[0]": "...", 44 | // "kubernetes.accounts[0].providerVersion": "V2", 45 | // "kubernetes.accounts[0].requiredGroupMembership": "", 46 | // } 47 | // } 48 | // ] 49 | // } 50 | func ListSecurityGroups(c *gin.Context) { 51 | var empty struct{} 52 | 53 | c.JSON(http.StatusOK, empty) 54 | } 55 | -------------------------------------------------------------------------------- /internal/api/core/task.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/homedepot/go-clouddriver/internal/artifact" 12 | "github.com/homedepot/go-clouddriver/internal/kubernetes" 13 | "github.com/iancoleman/strcase" 14 | ) 15 | 16 | // GetTask gets a task - currently only associated with kubernetes 'tasks'. 17 | func (cc *Controller) GetTask(c *gin.Context) { 18 | id := c.Param("id") 19 | task := clouddriver.NewDefaultTask(id) 20 | manifests := []map[string]interface{}{} 21 | 22 | resources, err := cc.SQLClient.ListKubernetesResourcesByTaskID(id) 23 | if err != nil { 24 | task.Status.Failed = true 25 | task.Status.Retryable = true 26 | task.Status.Status = fmt.Sprintf("Error listing resources for task (id: %s): %v", id, err) 27 | c.JSON(http.StatusInternalServerError, task) 28 | 29 | return 30 | } 31 | 32 | if len(resources) == 0 { 33 | task.Status.Failed = true 34 | task.Status.Status = fmt.Sprintf("Task not found (id: %s)", id) 35 | c.JSON(http.StatusNotFound, task) 36 | 37 | return 38 | } 39 | 40 | accountName := resources[0].AccountName 41 | 42 | provider, err := cc.KubernetesProvider(accountName) 43 | if err != nil { 44 | task.Status.Failed = true 45 | task.Status.Retryable = true 46 | task.Status.Status = fmt.Sprintf("Error getting kubernetes provider %s for task (id: %s): %v", 47 | accountName, id, err) 48 | c.JSON(http.StatusInternalServerError, task) 49 | 50 | return 51 | } 52 | 53 | for _, r := range resources { 54 | // Ignore getting the manifest if task type is "cleanup" or "noop". 55 | if strings.EqualFold(r.TaskType, clouddriver.TaskTypeCleanup) || 56 | strings.EqualFold(r.TaskType, clouddriver.TaskTypeNoOp) { 57 | manifests = append(manifests, map[string]interface{}{}) 58 | 59 | continue 60 | } 61 | 62 | result, err := provider.Client.Get(r.Resource, r.Name, r.Namespace) 63 | if err != nil { 64 | // If the task type is "delete" and the resource was not found, 65 | // append an empty manifest and continue. 66 | if strings.EqualFold(r.TaskType, clouddriver.TaskTypeDelete) && 67 | strings.HasSuffix(err.Error(), "not found") { 68 | manifests = append(manifests, map[string]interface{}{}) 69 | 70 | continue 71 | } 72 | 73 | task.Status.Failed = true 74 | task.Status.Retryable = true 75 | task.Status.Status = fmt.Sprintf("Error getting resource for task (task ID: %s, kind: %s, name: %s, namespace: %s): %v", 76 | id, r.Resource, r.Name, r.Namespace, err) 77 | c.JSON(http.StatusInternalServerError, task) 78 | 79 | return 80 | } else if strings.EqualFold(r.TaskType, clouddriver.TaskTypeDelete) { 81 | task.Status.Complete = false 82 | task.Status.Completed = false 83 | task.Status.Status = "Orchestration in progress." 84 | } 85 | 86 | manifests = append(manifests, result.Object) 87 | } 88 | 89 | mnr := buildMapOfNamespaceToResource(resources) 90 | // Refactor bound artifact to get the list of bound artifacts as not all created artifacts need to be bound. 91 | createdArtifacts := buildCreatedArtifacts(resources) 92 | ro := clouddriver.TaskResultObject{ 93 | BoundArtifacts: createdArtifacts, 94 | DeployedNamesByLocation: mnr, 95 | CreatedArtifacts: createdArtifacts, 96 | Manifests: manifests, 97 | ManifestNamesByNamespace: mnr, 98 | ManifestNamesByNamespaceToRefresh: mnr, 99 | } 100 | 101 | task.ResultObjects = []clouddriver.TaskResultObject{ro} 102 | 103 | c.JSON(http.StatusOK, task) 104 | } 105 | 106 | func buildCreatedArtifacts(resources []kubernetes.Resource) []clouddriver.Artifact { 107 | var ( 108 | artifactVersion string 109 | lastIndex int 110 | ) 111 | 112 | cas := []clouddriver.Artifact{} 113 | 114 | for _, resource := range resources { 115 | artifactVersion = "" 116 | lastIndex = strings.LastIndex(resource.Name, "-v") 117 | 118 | if lastIndex != -1 { 119 | artifactVersion = resource.Name[lastIndex+1:] 120 | } 121 | 122 | ca := clouddriver.Artifact{ 123 | CustomKind: false, 124 | Location: resource.Namespace, 125 | Metadata: clouddriver.ArtifactMetadata{ 126 | Account: resource.AccountName, 127 | }, 128 | Name: resource.ArtifactName, 129 | Reference: resource.Name, 130 | Type: artifact.Type("kubernetes/" + strcase.ToLowerCamel(resource.Kind)), 131 | Version: artifactVersion, 132 | } 133 | cas = append(cas, ca) 134 | } 135 | 136 | return cas 137 | } 138 | 139 | func buildMapOfNamespaceToResource(resources []kubernetes.Resource) map[string][]string { 140 | m := map[string][]string{} 141 | 142 | for _, resource := range resources { 143 | if _, ok := m[resource.Namespace]; !ok { 144 | m[resource.Namespace] = []string{} 145 | } 146 | 147 | a := m[resource.Namespace] 148 | a = append(a, fmt.Sprintf("%s %s", resource.Kind, resource.Name)) 149 | m[resource.Namespace] = a 150 | } 151 | 152 | return m 153 | } 154 | -------------------------------------------------------------------------------- /internal/api/core/test/expected-git-repo-master-subpath.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homedepot/go-clouddriver/d1c33abe8387abbdd3b4a953ca96dda6f91ee61f/internal/api/core/test/expected-git-repo-master-subpath.tar.gz -------------------------------------------------------------------------------- /internal/api/core/test/expected-git-repo-master.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homedepot/go-clouddriver/d1c33abe8387abbdd3b4a953ca96dda6f91ee61f/internal/api/core/test/expected-git-repo-master.tar.gz -------------------------------------------------------------------------------- /internal/api/core/test/expected-git-repo-test.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homedepot/go-clouddriver/d1c33abe8387abbdd3b4a953ca96dda6f91ee61f/internal/api/core/test/expected-git-repo-test.tar.gz -------------------------------------------------------------------------------- /internal/api/core/test/git-repo-master.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homedepot/go-clouddriver/d1c33abe8387abbdd3b4a953ca96dda6f91ee61f/internal/api/core/test/git-repo-master.tar.gz -------------------------------------------------------------------------------- /internal/api/core/test/git-repo-test.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homedepot/go-clouddriver/d1c33abe8387abbdd3b4a953ca96dda6f91ee61f/internal/api/core/test/git-repo-test.tar.gz -------------------------------------------------------------------------------- /internal/api/v1/controller.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import "github.com/homedepot/go-clouddriver/internal" 4 | 5 | // Controller holds all non request-scoped objects. 6 | type Controller struct { 7 | *internal.Controller 8 | } 9 | -------------------------------------------------------------------------------- /internal/api/v1/v1_suite_test.go: -------------------------------------------------------------------------------- 1 | package v1_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestV1(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "V1 Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/artifact/artifact_suite_test.go: -------------------------------------------------------------------------------- 1 | package artifact_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestArtifact(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Artifact Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/artifact/test/credentials/test-keyfile.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "test", 4 | "private_key_id": "1234567890", 5 | "private_key": "", 6 | "client_email": "test@test.iam.gserviceaccount.com", 7 | "client_id": "1234567890", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/cd-jenkins%40np-platforms-cd-thd.iam.gserviceaccount.com" 12 | } -------------------------------------------------------------------------------- /internal/artifact/test/custom-object.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-artifact", 3 | "types": [ 4 | "custom/object" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/docker-image.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-registry", 3 | "types": [ 4 | "docker/image" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/embedded-base64.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "embedded-artifact", 3 | "types": [ 4 | "embedded/base64" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/front50-pipeline-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front50ArtifactCredentials", 3 | "types": [ 4 | "front50/pipelineTemplate" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/gcs-object-credentials.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gcs-test", 3 | "types": [ 4 | "gcs/object" 5 | ], 6 | "jsonPath": "test/credentials/test-keyfile.json" 7 | } 8 | -------------------------------------------------------------------------------- /internal/artifact/test/gcs-object.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gcs-spinnaker", 3 | "types": [ 4 | "gcs/object" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/git-repo-token.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghe-spinnaker", 3 | "token": "fake-token", 4 | "types": [ 5 | "git/repo" 6 | ] 7 | } -------------------------------------------------------------------------------- /internal/artifact/test/git-repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-spinnaker", 3 | "types": [ 4 | "git/repo" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/github-enterprise-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseURL": "https://github.example.com", 3 | "enterprise": true, 4 | "token": "some-token", 5 | "name": "github.example.com", 6 | "types": [ 7 | "github/file" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /internal/artifact/test/github-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github.com", 3 | "types": [ 4 | "github/file" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/helm-chart-basic-auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helm-basic-auth", 3 | "types": [ 4 | "helm/chart" 5 | ], 6 | "repository": "https://kubernetes-charts.storage.googleapis.com", 7 | "username": "fake-user", 8 | "password": "fakk-password" 9 | } 10 | -------------------------------------------------------------------------------- /internal/artifact/test/helm-chart.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helm-test", 3 | "types": [ 4 | "helm/chart" 5 | ], 6 | "repository": "https://kubernetes-charts.storage.googleapis.com" 7 | } 8 | -------------------------------------------------------------------------------- /internal/artifact/test/http-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http", 3 | "types": [ 4 | "http/file" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /internal/artifact/test/kubernetes.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kubernetes", 3 | "types": [ 4 | "kubernetes/configMap", 5 | "kubernetes/deployment", 6 | "kubernetes/replicaSet", 7 | "kubernetes/secret" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /internal/fiat/client.go: -------------------------------------------------------------------------------- 1 | package fiat 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | const ( 11 | defaultFiatURL = "http://spin-fiat.spinnaker:7003" 12 | ) 13 | 14 | //go:generate counterfeiter . Client 15 | type Client interface { 16 | Authorize(account string) (Response, error) 17 | } 18 | 19 | func NewClient(url string) Client { 20 | return &client{ 21 | url: url, 22 | } 23 | } 24 | 25 | func NewDefaultClient() Client { 26 | return NewClient(defaultFiatURL) 27 | } 28 | 29 | type client struct { 30 | url string 31 | } 32 | 33 | type Response struct { 34 | Name string `json:"name"` 35 | Accounts []Account `json:"accounts"` 36 | Applications []Application `json:"applications"` 37 | ServiceAccounts []ServiceAccount `json:"serviceAccounts"` 38 | Roles []Role `json:"roles"` 39 | BuildServices []interface{} `json:"buildServices"` 40 | ExtensionResources struct { 41 | } `json:"extensionResources"` 42 | Admin bool `json:"admin"` 43 | LegacyFallback bool `json:"legacyFallback"` 44 | AllowAccessToUnknownApplications bool `json:"allowAccessToUnknownApplications"` 45 | } 46 | 47 | type Account struct { 48 | Name string `json:"name"` 49 | Authorizations []string `json:"authorizations"` 50 | } 51 | 52 | type Application struct { 53 | Name string `json:"name"` 54 | Authorizations []string `json:"authorizations"` 55 | } 56 | 57 | type ServiceAccount struct { 58 | Name string `json:"name"` 59 | MemberOf []string `json:"memberOf"` 60 | } 61 | 62 | type Role struct { 63 | Name string `json:"name"` 64 | Source string `json:"source"` 65 | } 66 | 67 | func (c *client) Authorize(account string) (Response, error) { 68 | req, err := http.NewRequest(http.MethodGet, c.url+"/authorize/"+account, nil) 69 | if err != nil { 70 | return Response{}, err 71 | } 72 | 73 | res, err := http.DefaultClient.Do(req) 74 | if err != nil { 75 | return Response{}, err 76 | } 77 | defer res.Body.Close() 78 | 79 | if res.StatusCode < 200 || res.StatusCode > 399 { 80 | return Response{}, fmt.Errorf("user authorization error: %s", res.Status) 81 | } 82 | 83 | b, err := io.ReadAll(res.Body) 84 | if err != nil { 85 | return Response{}, err 86 | } 87 | 88 | response := Response{} 89 | 90 | err = json.Unmarshal(b, &response) 91 | if err != nil { 92 | return response, err 93 | } 94 | 95 | return response, nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/fiat/client_test.go: -------------------------------------------------------------------------------- 1 | package fiat_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | . "github.com/homedepot/go-clouddriver/internal/fiat" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "github.com/onsi/gomega/ghttp" 11 | ) 12 | 13 | var _ = Describe("Client", func() { 14 | var ( 15 | server *ghttp.Server 16 | client Client 17 | err error 18 | response, expectedResponse Response 19 | fakeResponse string 20 | ) 21 | 22 | BeforeEach(func() { 23 | server = ghttp.NewServer() 24 | client = NewClient(server.URL()) 25 | }) 26 | 27 | AfterEach(func() { 28 | server.Close() 29 | }) 30 | 31 | Describe("#NewDefaultClient", func() { 32 | BeforeEach(func() { 33 | client = NewDefaultClient() 34 | }) 35 | 36 | It("succeeds", func() { 37 | }) 38 | }) 39 | 40 | Describe("#Authorize", func() { 41 | JustBeforeEach(func() { 42 | response, err = client.Authorize("fakeAccount") 43 | }) 44 | 45 | When("the uri is invalid", func() { 46 | BeforeEach(func() { 47 | client = NewClient("::haha") 48 | }) 49 | 50 | It("returns an error", func() { 51 | Expect(err).ToNot(BeNil()) 52 | Expect(response).To(Equal(Response{})) 53 | }) 54 | }) 55 | 56 | When("the server is not reachable", func() { 57 | BeforeEach(func() { 58 | server.Close() 59 | }) 60 | 61 | It("returns an error", func() { 62 | Expect(err).ToNot(BeNil()) 63 | }) 64 | }) 65 | 66 | When("the response is not 2XX", func() { 67 | BeforeEach(func() { 68 | server.AppendHandlers( 69 | ghttp.RespondWith(http.StatusInternalServerError, nil), 70 | ) 71 | }) 72 | 73 | It("returns an error", func() { 74 | Expect(err).ToNot(BeNil()) 75 | Expect(err.Error()).To(Equal("user authorization error: 500 Internal Server Error")) 76 | }) 77 | }) 78 | 79 | When("the server returns bad data", func() { 80 | BeforeEach(func() { 81 | server.AppendHandlers( 82 | ghttp.RespondWith(http.StatusOK, ";{["), 83 | ) 84 | }) 85 | 86 | It("returns an error", func() { 87 | Expect(err).ToNot(BeNil()) 88 | Expect(err.Error()).To(Equal("invalid character ';' looking for beginning of value")) 89 | }) 90 | }) 91 | 92 | When("it succeeds", func() { 93 | BeforeEach(func() { 94 | fakeResponse = `{ 95 | "name" : "test_group", 96 | "accounts" : [ { 97 | "name" : "gke_github-replication-sandbox_us-central1_sandbox-us-central1-agent_smoketest-dev", 98 | "authorizations" : [ "READ", "WRITE", "EXECUTE", "CREATE" ] 99 | }, { 100 | "name" : "spin-cluster-account", 101 | "authorizations" : [ "READ", "WRITE", "EXECUTE", "CREATE" ] 102 | } ], 103 | "serviceAccounts" : [ { 104 | "name" : "gg_cloud_gcp_spinnaker_admins_member", 105 | "memberOf" : [ "gg_cloud_gcp_spinnaker_admins" ] 106 | } ], 107 | "roles" : [ { 108 | "name" : "gg_cloud_gcp_spinnaker_admins", 109 | "source" : "EXTERNAL" 110 | }, { 111 | "name" : "test_group", 112 | "source" : "EXTERNAL" 113 | } ], 114 | "buildServices" : [ ], 115 | "extensionResources" : { }, 116 | "admin" : false, 117 | "legacyFallback" : false, 118 | "allowAccessToUnknownApplications" : false 119 | } 120 | ` 121 | 122 | server.AppendHandlers(ghttp.CombineHandlers( 123 | ghttp.VerifyRequest(http.MethodGet, "/authorize/fakeAccount"), 124 | ghttp.RespondWith(http.StatusOK, fakeResponse), 125 | )) 126 | }) 127 | 128 | It("succeeds", func() { 129 | byt := []byte(fakeResponse) 130 | err := json.Unmarshal(byt, &expectedResponse) 131 | Expect(err).To(BeNil()) 132 | Expect(expectedResponse).To(Equal(response)) 133 | }) 134 | }) 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /internal/fiat/fiat_suite_test.go: -------------------------------------------------------------------------------- 1 | package fiat_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestFiat(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Fiat Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/fiat/fiatfakes/fake_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fiatfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/homedepot/go-clouddriver/internal/fiat" 8 | ) 9 | 10 | type FakeClient struct { 11 | AuthorizeStub func(string) (fiat.Response, error) 12 | authorizeMutex sync.RWMutex 13 | authorizeArgsForCall []struct { 14 | arg1 string 15 | } 16 | authorizeReturns struct { 17 | result1 fiat.Response 18 | result2 error 19 | } 20 | authorizeReturnsOnCall map[int]struct { 21 | result1 fiat.Response 22 | result2 error 23 | } 24 | invocations map[string][][]interface{} 25 | invocationsMutex sync.RWMutex 26 | } 27 | 28 | func (fake *FakeClient) Authorize(arg1 string) (fiat.Response, error) { 29 | fake.authorizeMutex.Lock() 30 | ret, specificReturn := fake.authorizeReturnsOnCall[len(fake.authorizeArgsForCall)] 31 | fake.authorizeArgsForCall = append(fake.authorizeArgsForCall, struct { 32 | arg1 string 33 | }{arg1}) 34 | stub := fake.AuthorizeStub 35 | fakeReturns := fake.authorizeReturns 36 | fake.recordInvocation("Authorize", []interface{}{arg1}) 37 | fake.authorizeMutex.Unlock() 38 | if stub != nil { 39 | return stub(arg1) 40 | } 41 | if specificReturn { 42 | return ret.result1, ret.result2 43 | } 44 | return fakeReturns.result1, fakeReturns.result2 45 | } 46 | 47 | func (fake *FakeClient) AuthorizeCallCount() int { 48 | fake.authorizeMutex.RLock() 49 | defer fake.authorizeMutex.RUnlock() 50 | return len(fake.authorizeArgsForCall) 51 | } 52 | 53 | func (fake *FakeClient) AuthorizeCalls(stub func(string) (fiat.Response, error)) { 54 | fake.authorizeMutex.Lock() 55 | defer fake.authorizeMutex.Unlock() 56 | fake.AuthorizeStub = stub 57 | } 58 | 59 | func (fake *FakeClient) AuthorizeArgsForCall(i int) string { 60 | fake.authorizeMutex.RLock() 61 | defer fake.authorizeMutex.RUnlock() 62 | argsForCall := fake.authorizeArgsForCall[i] 63 | return argsForCall.arg1 64 | } 65 | 66 | func (fake *FakeClient) AuthorizeReturns(result1 fiat.Response, result2 error) { 67 | fake.authorizeMutex.Lock() 68 | defer fake.authorizeMutex.Unlock() 69 | fake.AuthorizeStub = nil 70 | fake.authorizeReturns = struct { 71 | result1 fiat.Response 72 | result2 error 73 | }{result1, result2} 74 | } 75 | 76 | func (fake *FakeClient) AuthorizeReturnsOnCall(i int, result1 fiat.Response, result2 error) { 77 | fake.authorizeMutex.Lock() 78 | defer fake.authorizeMutex.Unlock() 79 | fake.AuthorizeStub = nil 80 | if fake.authorizeReturnsOnCall == nil { 81 | fake.authorizeReturnsOnCall = make(map[int]struct { 82 | result1 fiat.Response 83 | result2 error 84 | }) 85 | } 86 | fake.authorizeReturnsOnCall[i] = struct { 87 | result1 fiat.Response 88 | result2 error 89 | }{result1, result2} 90 | } 91 | 92 | func (fake *FakeClient) Invocations() map[string][][]interface{} { 93 | fake.invocationsMutex.RLock() 94 | defer fake.invocationsMutex.RUnlock() 95 | fake.authorizeMutex.RLock() 96 | defer fake.authorizeMutex.RUnlock() 97 | copiedInvocations := map[string][][]interface{}{} 98 | for key, value := range fake.invocations { 99 | copiedInvocations[key] = value 100 | } 101 | return copiedInvocations 102 | } 103 | 104 | func (fake *FakeClient) recordInvocation(key string, args []interface{}) { 105 | fake.invocationsMutex.Lock() 106 | defer fake.invocationsMutex.Unlock() 107 | if fake.invocations == nil { 108 | fake.invocations = map[string][][]interface{}{} 109 | } 110 | if fake.invocations[key] == nil { 111 | fake.invocations[key] = [][]interface{}{} 112 | } 113 | fake.invocations[key] = append(fake.invocations[key], args) 114 | } 115 | 116 | var _ fiat.Client = new(FakeClient) 117 | -------------------------------------------------------------------------------- /internal/front50/client.go: -------------------------------------------------------------------------------- 1 | package front50 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | const ( 11 | defaultFront50Url = "http://spin-front50.spinnaker:8080" 12 | ) 13 | 14 | //go:generate counterfeiter . Client 15 | type Client interface { 16 | Project(project string) (Response, error) 17 | } 18 | 19 | func NewClient(url string) Client { 20 | return &client{ 21 | url: url, 22 | } 23 | } 24 | 25 | func NewDefaultClient() Client { 26 | return NewClient(defaultFront50Url) 27 | } 28 | 29 | type client struct { 30 | url string 31 | } 32 | 33 | type Response struct { 34 | ID string `json:"id"` 35 | Name string `json:"name"` 36 | Email string `json:"email"` 37 | Config Config `json:"config"` 38 | UpdateTs int64 `json:"updateTs"` 39 | CreateTs int64 `json:"createTs"` 40 | LastModifiedBy string `json:"lastModifiedBy"` 41 | } 42 | 43 | type Config struct { 44 | PipelineConfigs []PipelineConfig `json:"pipelineConfigs"` 45 | Applications []string `json:"applications"` 46 | Clusters []Cluster `json:"clusters"` 47 | } 48 | 49 | type PipelineConfig struct { 50 | Application string `json:"application"` 51 | PipelineConfigID string `json:"pipelineConfigId"` 52 | } 53 | 54 | type Cluster struct { 55 | Account string `json:"account"` 56 | Stack string `json:"stack"` 57 | Detail string `json:"detail"` 58 | Applications []string `json:"applications"` 59 | } 60 | 61 | // Project gets the Spinnaker project from the front50 service. 62 | // 63 | // See https://github.com/spinnaker/front50/blob/master/front50-web/src/main/java/com/netflix/spinnaker/front50/controllers/v2/ProjectsController.java 64 | func (c *client) Project(project string) (Response, error) { 65 | req, err := http.NewRequest(http.MethodGet, c.url+"/v2/projects/"+project, nil) 66 | if err != nil { 67 | return Response{}, err 68 | } 69 | 70 | res, err := http.DefaultClient.Do(req) 71 | if err != nil { 72 | return Response{}, err 73 | } 74 | defer res.Body.Close() 75 | 76 | if res.StatusCode < 200 || res.StatusCode > 399 { 77 | return Response{}, fmt.Errorf("user authorization error: %s", res.Status) 78 | } 79 | 80 | b, err := io.ReadAll(res.Body) 81 | if err != nil { 82 | return Response{}, err 83 | } 84 | 85 | response := Response{} 86 | 87 | err = json.Unmarshal(b, &response) 88 | if err != nil { 89 | return response, err 90 | } 91 | 92 | return response, nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/front50/client_test.go: -------------------------------------------------------------------------------- 1 | package front50_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | . "github.com/homedepot/go-clouddriver/internal/front50" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "github.com/onsi/gomega/ghttp" 11 | ) 12 | 13 | var _ = Describe("Client", func() { 14 | var ( 15 | server *ghttp.Server 16 | client Client 17 | err error 18 | response, expectedResponse Response 19 | fakeResponse string 20 | ) 21 | 22 | BeforeEach(func() { 23 | server = ghttp.NewServer() 24 | client = NewClient(server.URL()) 25 | }) 26 | 27 | AfterEach(func() { 28 | server.Close() 29 | }) 30 | 31 | Describe("#NewDefaultClient", func() { 32 | BeforeEach(func() { 33 | client = NewDefaultClient() 34 | }) 35 | 36 | It("succeeds", func() { 37 | }) 38 | }) 39 | 40 | Describe("#Project", func() { 41 | JustBeforeEach(func() { 42 | response, err = client.Project("fakeProjectID") 43 | }) 44 | 45 | When("the uri is invalid", func() { 46 | BeforeEach(func() { 47 | client = NewClient("::haha") 48 | }) 49 | 50 | It("returns an error", func() { 51 | Expect(err).ToNot(BeNil()) 52 | Expect(response).To(Equal(Response{})) 53 | }) 54 | }) 55 | 56 | When("the server is not reachable", func() { 57 | BeforeEach(func() { 58 | server.Close() 59 | }) 60 | 61 | It("returns an error", func() { 62 | Expect(err).ToNot(BeNil()) 63 | }) 64 | }) 65 | 66 | When("the response is not 2XX", func() { 67 | BeforeEach(func() { 68 | server.AppendHandlers( 69 | ghttp.RespondWith(http.StatusInternalServerError, nil), 70 | ) 71 | }) 72 | 73 | It("returns an error", func() { 74 | Expect(err).ToNot(BeNil()) 75 | Expect(err.Error()).To(Equal("user authorization error: 500 Internal Server Error")) 76 | }) 77 | }) 78 | 79 | When("the server returns bad data", func() { 80 | BeforeEach(func() { 81 | server.AppendHandlers( 82 | ghttp.RespondWith(http.StatusOK, ";{["), 83 | ) 84 | }) 85 | 86 | It("returns an error", func() { 87 | Expect(err).ToNot(BeNil()) 88 | Expect(err.Error()).To(Equal("invalid character ';' looking for beginning of value")) 89 | }) 90 | }) 91 | 92 | When("it succeeds", func() { 93 | BeforeEach(func() { 94 | fakeResponse = `{ 95 | "id": "048de9a7-7b57-4097-8444-e44682d9dcfc", 96 | "name": "spinnaker", 97 | "email": "david_m_rogers@homedepot.com", 98 | "config": { 99 | "pipelineConfigs": [ 100 | { 101 | "application": "billy", 102 | "pipelineConfigId": "b1bb2476-388b-47b9-8730-9617cccfe458" 103 | } 104 | ], 105 | "applications": [ 106 | "smoketests" 107 | ], 108 | "clusters": [ 109 | { 110 | "account": "gae_np-te-cd-tools-np", 111 | "stack": "*", 112 | "detail": "*", 113 | "applications": null 114 | }, 115 | { 116 | "account": "gke_np-te-cd-tools_us-central1_smoketests_sandbox-us-central1-np", 117 | "stack": "*", 118 | "detail": "*", 119 | "applications": null 120 | }, 121 | { 122 | "account": "gke_np-te-cd-tools_us-central1_sandbox-us-central1-np", 123 | "stack": "*", 124 | "detail": "*", 125 | "applications": null 126 | } 127 | ] 128 | }, 129 | "updateTs": 1635962823717, 130 | "createTs": 1587655303067, 131 | "lastModifiedBy": "DXR05" 132 | }` 133 | 134 | server.AppendHandlers(ghttp.CombineHandlers( 135 | ghttp.VerifyRequest(http.MethodGet, "/v2/projects/fakeProjectID"), 136 | ghttp.RespondWith(http.StatusOK, fakeResponse), 137 | )) 138 | }) 139 | 140 | It("succeeds", func() { 141 | byt := []byte(fakeResponse) 142 | err := json.Unmarshal(byt, &expectedResponse) 143 | Expect(err).To(BeNil()) 144 | Expect(expectedResponse).To(Equal(response)) 145 | }) 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /internal/front50/front50_suite_test.go: -------------------------------------------------------------------------------- 1 | package front50_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestFront50(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Front50 Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/front50/front50fakes/fake_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package front50fakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/homedepot/go-clouddriver/internal/front50" 8 | ) 9 | 10 | type FakeClient struct { 11 | ProjectStub func(string) (front50.Response, error) 12 | projectMutex sync.RWMutex 13 | projectArgsForCall []struct { 14 | arg1 string 15 | } 16 | projectReturns struct { 17 | result1 front50.Response 18 | result2 error 19 | } 20 | projectReturnsOnCall map[int]struct { 21 | result1 front50.Response 22 | result2 error 23 | } 24 | invocations map[string][][]interface{} 25 | invocationsMutex sync.RWMutex 26 | } 27 | 28 | func (fake *FakeClient) Project(arg1 string) (front50.Response, error) { 29 | fake.projectMutex.Lock() 30 | ret, specificReturn := fake.projectReturnsOnCall[len(fake.projectArgsForCall)] 31 | fake.projectArgsForCall = append(fake.projectArgsForCall, struct { 32 | arg1 string 33 | }{arg1}) 34 | stub := fake.ProjectStub 35 | fakeReturns := fake.projectReturns 36 | fake.recordInvocation("Project", []interface{}{arg1}) 37 | fake.projectMutex.Unlock() 38 | if stub != nil { 39 | return stub(arg1) 40 | } 41 | if specificReturn { 42 | return ret.result1, ret.result2 43 | } 44 | return fakeReturns.result1, fakeReturns.result2 45 | } 46 | 47 | func (fake *FakeClient) ProjectCallCount() int { 48 | fake.projectMutex.RLock() 49 | defer fake.projectMutex.RUnlock() 50 | return len(fake.projectArgsForCall) 51 | } 52 | 53 | func (fake *FakeClient) ProjectCalls(stub func(string) (front50.Response, error)) { 54 | fake.projectMutex.Lock() 55 | defer fake.projectMutex.Unlock() 56 | fake.ProjectStub = stub 57 | } 58 | 59 | func (fake *FakeClient) ProjectArgsForCall(i int) string { 60 | fake.projectMutex.RLock() 61 | defer fake.projectMutex.RUnlock() 62 | argsForCall := fake.projectArgsForCall[i] 63 | return argsForCall.arg1 64 | } 65 | 66 | func (fake *FakeClient) ProjectReturns(result1 front50.Response, result2 error) { 67 | fake.projectMutex.Lock() 68 | defer fake.projectMutex.Unlock() 69 | fake.ProjectStub = nil 70 | fake.projectReturns = struct { 71 | result1 front50.Response 72 | result2 error 73 | }{result1, result2} 74 | } 75 | 76 | func (fake *FakeClient) ProjectReturnsOnCall(i int, result1 front50.Response, result2 error) { 77 | fake.projectMutex.Lock() 78 | defer fake.projectMutex.Unlock() 79 | fake.ProjectStub = nil 80 | if fake.projectReturnsOnCall == nil { 81 | fake.projectReturnsOnCall = make(map[int]struct { 82 | result1 front50.Response 83 | result2 error 84 | }) 85 | } 86 | fake.projectReturnsOnCall[i] = struct { 87 | result1 front50.Response 88 | result2 error 89 | }{result1, result2} 90 | } 91 | 92 | func (fake *FakeClient) Invocations() map[string][][]interface{} { 93 | fake.invocationsMutex.RLock() 94 | defer fake.invocationsMutex.RUnlock() 95 | fake.projectMutex.RLock() 96 | defer fake.projectMutex.RUnlock() 97 | copiedInvocations := map[string][][]interface{}{} 98 | for key, value := range fake.invocations { 99 | copiedInvocations[key] = value 100 | } 101 | return copiedInvocations 102 | } 103 | 104 | func (fake *FakeClient) recordInvocation(key string, args []interface{}) { 105 | fake.invocationsMutex.Lock() 106 | defer fake.invocationsMutex.Unlock() 107 | if fake.invocations == nil { 108 | fake.invocations = map[string][][]interface{}{} 109 | } 110 | if fake.invocations[key] == nil { 111 | fake.invocations[key] = [][]interface{}{} 112 | } 113 | fake.invocations[key] = append(fake.invocations[key], args) 114 | } 115 | 116 | var _ front50.Client = new(FakeClient) 117 | -------------------------------------------------------------------------------- /internal/helm/client.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "sync" 9 | 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | var ( 14 | errUnableToFindResource = errors.New("unable to find resource") 15 | ) 16 | 17 | type Index struct { 18 | APIVersion string `json:"apiVersion" yaml:"apiVersion"` 19 | Entries map[string][]Resource `json:"entries" yaml:"entries"` 20 | } 21 | 22 | type Resource struct { 23 | APIVersion string `json:"apiVersion" yaml:"apiVersion"` 24 | AppVersion string `json:"appVersion" yaml:"appVersion"` 25 | // Created time.Time `json:"created"` 26 | Description string `json:"description" yaml:"description"` 27 | Digest string `json:"digest" yaml:"digest"` 28 | Home string `json:"home" yaml:"home"` 29 | // Maintainers []struct { 30 | // Email string `json:"email"` 31 | // Name string `json:"name"` 32 | // } `json:"maintainers"` 33 | Name string `json:"name" yaml:"name"` 34 | Urls []string `json:"urls" yaml:"urls"` 35 | Version string `json:"version" yaml:"version"` 36 | } 37 | 38 | //go:generate counterfeiter . Client 39 | type Client interface { 40 | GetIndex() (Index, error) 41 | WithUsernameAndPassword(string, string) 42 | GetChart(string, string) ([]byte, error) 43 | } 44 | 45 | var ( 46 | etag string 47 | cache Index 48 | mux sync.Mutex 49 | ) 50 | 51 | func NewClient(url string) Client { 52 | return &client{url: url} 53 | } 54 | 55 | type client struct { 56 | url string 57 | username string 58 | password string 59 | } 60 | 61 | func (c *client) WithUsernameAndPassword(username, password string) { 62 | c.username = username 63 | c.password = password 64 | } 65 | 66 | func (c *client) GetIndex() (Index, error) { 67 | i := Index{} 68 | 69 | req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/index.yaml", c.url), nil) 70 | if err != nil { 71 | return i, err 72 | } 73 | 74 | req.Header.Add("If-None-Match", etag) 75 | 76 | if c.username != "" && c.password != "" { 77 | req.SetBasicAuth(c.username, c.password) 78 | } 79 | 80 | res, err := http.DefaultClient.Do(req) 81 | if err != nil { 82 | return i, err 83 | } 84 | defer res.Body.Close() 85 | 86 | if res.StatusCode == http.StatusNotModified { 87 | mux.Lock() 88 | defer mux.Unlock() 89 | 90 | return cache, nil 91 | } 92 | 93 | if res.StatusCode < 200 || res.StatusCode > 399 { 94 | return i, errors.New("error getting helm index: " + res.Status) 95 | } 96 | 97 | b, err := io.ReadAll(res.Body) 98 | if err != nil { 99 | return i, err 100 | } 101 | 102 | err = yaml.Unmarshal(b, &i) 103 | if err != nil { 104 | return i, err 105 | } 106 | 107 | mux.Lock() 108 | defer mux.Unlock() 109 | 110 | cache = i 111 | etag = res.Header.Get("etag") 112 | 113 | return i, nil 114 | } 115 | 116 | func (c *client) GetChart(name, version string) ([]byte, error) { 117 | var ( 118 | err error 119 | b []byte 120 | ) 121 | 122 | resource, err := c.findResource(name, version) 123 | if err != nil { 124 | return b, fmt.Errorf("helm: unable to find chart %s-%s: %w", name, version, err) 125 | } 126 | 127 | if len(resource.Urls) == 0 { 128 | return b, fmt.Errorf("helm: no resource urls defined for chart %s-%s", name, version) 129 | } 130 | 131 | // Loop through all the resource's URLs to get the chart. 132 | for _, url := range resource.Urls { 133 | req, e := http.NewRequest(http.MethodGet, url, nil) 134 | if e != nil { 135 | err = e 136 | 137 | continue 138 | } 139 | // Set credentials to pull the chart. 140 | if c.username != "" && c.password != "" { 141 | req.SetBasicAuth(c.username, c.password) 142 | } 143 | 144 | res, e := http.DefaultClient.Do(req) 145 | if e != nil { 146 | err = e 147 | 148 | continue 149 | } 150 | defer res.Body.Close() 151 | 152 | if res.StatusCode < 200 || res.StatusCode > 399 { 153 | err = errors.New("helm: error getting chart: " + res.Status) 154 | 155 | continue 156 | } 157 | 158 | b, err = io.ReadAll(res.Body) 159 | if err != nil { 160 | continue 161 | } 162 | 163 | break 164 | } 165 | 166 | return b, err 167 | } 168 | 169 | // findResource resets the helm index's cache then gets the resource 170 | // from the cache by name and version. 171 | // 172 | // If it is unable to find the resource it returns an error. 173 | func (c *client) findResource(name, version string) (Resource, error) { 174 | // Refresh the cached index. 175 | _, err := c.GetIndex() 176 | if err != nil { 177 | return Resource{}, err 178 | } 179 | 180 | // Lock since we are accessing the cached index. 181 | mux.Lock() 182 | defer mux.Unlock() 183 | 184 | if _, ok := cache.Entries[name]; ok { 185 | resources := cache.Entries[name] 186 | for _, resource := range resources { 187 | if resource.Version == version { 188 | return resource, nil 189 | } 190 | } 191 | } 192 | 193 | return Resource{}, errUnableToFindResource 194 | } 195 | -------------------------------------------------------------------------------- /internal/helm/helm_suite_test.go: -------------------------------------------------------------------------------- 1 | package helm_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestHelm(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Helm Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/internal_suite_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestInternal(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Internal Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/disk/disk_suite_test.go: -------------------------------------------------------------------------------- 1 | package disk_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestDisk(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Disk Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/disk/diskfakes/fake_cache_round_tripper.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package diskfakes 3 | 4 | import ( 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/homedepot/go-clouddriver/internal/kubernetes/cached/disk" 9 | ) 10 | 11 | type FakeCacheRoundTripper struct { 12 | CancelRequestStub func(*http.Request) 13 | cancelRequestMutex sync.RWMutex 14 | cancelRequestArgsForCall []struct { 15 | arg1 *http.Request 16 | } 17 | RoundTripStub func(*http.Request) (*http.Response, error) 18 | roundTripMutex sync.RWMutex 19 | roundTripArgsForCall []struct { 20 | arg1 *http.Request 21 | } 22 | roundTripReturns struct { 23 | result1 *http.Response 24 | result2 error 25 | } 26 | roundTripReturnsOnCall map[int]struct { 27 | result1 *http.Response 28 | result2 error 29 | } 30 | invocations map[string][][]interface{} 31 | invocationsMutex sync.RWMutex 32 | } 33 | 34 | func (fake *FakeCacheRoundTripper) CancelRequest(arg1 *http.Request) { 35 | fake.cancelRequestMutex.Lock() 36 | fake.cancelRequestArgsForCall = append(fake.cancelRequestArgsForCall, struct { 37 | arg1 *http.Request 38 | }{arg1}) 39 | stub := fake.CancelRequestStub 40 | fake.recordInvocation("CancelRequest", []interface{}{arg1}) 41 | fake.cancelRequestMutex.Unlock() 42 | if stub != nil { 43 | fake.CancelRequestStub(arg1) 44 | } 45 | } 46 | 47 | func (fake *FakeCacheRoundTripper) CancelRequestCallCount() int { 48 | fake.cancelRequestMutex.RLock() 49 | defer fake.cancelRequestMutex.RUnlock() 50 | return len(fake.cancelRequestArgsForCall) 51 | } 52 | 53 | func (fake *FakeCacheRoundTripper) CancelRequestCalls(stub func(*http.Request)) { 54 | fake.cancelRequestMutex.Lock() 55 | defer fake.cancelRequestMutex.Unlock() 56 | fake.CancelRequestStub = stub 57 | } 58 | 59 | func (fake *FakeCacheRoundTripper) CancelRequestArgsForCall(i int) *http.Request { 60 | fake.cancelRequestMutex.RLock() 61 | defer fake.cancelRequestMutex.RUnlock() 62 | argsForCall := fake.cancelRequestArgsForCall[i] 63 | return argsForCall.arg1 64 | } 65 | 66 | func (fake *FakeCacheRoundTripper) RoundTrip(arg1 *http.Request) (*http.Response, error) { 67 | fake.roundTripMutex.Lock() 68 | ret, specificReturn := fake.roundTripReturnsOnCall[len(fake.roundTripArgsForCall)] 69 | fake.roundTripArgsForCall = append(fake.roundTripArgsForCall, struct { 70 | arg1 *http.Request 71 | }{arg1}) 72 | stub := fake.RoundTripStub 73 | fakeReturns := fake.roundTripReturns 74 | fake.recordInvocation("RoundTrip", []interface{}{arg1}) 75 | fake.roundTripMutex.Unlock() 76 | if stub != nil { 77 | return stub(arg1) 78 | } 79 | if specificReturn { 80 | return ret.result1, ret.result2 81 | } 82 | return fakeReturns.result1, fakeReturns.result2 83 | } 84 | 85 | func (fake *FakeCacheRoundTripper) RoundTripCallCount() int { 86 | fake.roundTripMutex.RLock() 87 | defer fake.roundTripMutex.RUnlock() 88 | return len(fake.roundTripArgsForCall) 89 | } 90 | 91 | func (fake *FakeCacheRoundTripper) RoundTripCalls(stub func(*http.Request) (*http.Response, error)) { 92 | fake.roundTripMutex.Lock() 93 | defer fake.roundTripMutex.Unlock() 94 | fake.RoundTripStub = stub 95 | } 96 | 97 | func (fake *FakeCacheRoundTripper) RoundTripArgsForCall(i int) *http.Request { 98 | fake.roundTripMutex.RLock() 99 | defer fake.roundTripMutex.RUnlock() 100 | argsForCall := fake.roundTripArgsForCall[i] 101 | return argsForCall.arg1 102 | } 103 | 104 | func (fake *FakeCacheRoundTripper) RoundTripReturns(result1 *http.Response, result2 error) { 105 | fake.roundTripMutex.Lock() 106 | defer fake.roundTripMutex.Unlock() 107 | fake.RoundTripStub = nil 108 | fake.roundTripReturns = struct { 109 | result1 *http.Response 110 | result2 error 111 | }{result1, result2} 112 | } 113 | 114 | func (fake *FakeCacheRoundTripper) RoundTripReturnsOnCall(i int, result1 *http.Response, result2 error) { 115 | fake.roundTripMutex.Lock() 116 | defer fake.roundTripMutex.Unlock() 117 | fake.RoundTripStub = nil 118 | if fake.roundTripReturnsOnCall == nil { 119 | fake.roundTripReturnsOnCall = make(map[int]struct { 120 | result1 *http.Response 121 | result2 error 122 | }) 123 | } 124 | fake.roundTripReturnsOnCall[i] = struct { 125 | result1 *http.Response 126 | result2 error 127 | }{result1, result2} 128 | } 129 | 130 | func (fake *FakeCacheRoundTripper) Invocations() map[string][][]interface{} { 131 | fake.invocationsMutex.RLock() 132 | defer fake.invocationsMutex.RUnlock() 133 | fake.cancelRequestMutex.RLock() 134 | defer fake.cancelRequestMutex.RUnlock() 135 | fake.roundTripMutex.RLock() 136 | defer fake.roundTripMutex.RUnlock() 137 | copiedInvocations := map[string][][]interface{}{} 138 | for key, value := range fake.invocations { 139 | copiedInvocations[key] = value 140 | } 141 | return copiedInvocations 142 | } 143 | 144 | func (fake *FakeCacheRoundTripper) recordInvocation(key string, args []interface{}) { 145 | fake.invocationsMutex.Lock() 146 | defer fake.invocationsMutex.Unlock() 147 | if fake.invocations == nil { 148 | fake.invocations = map[string][][]interface{}{} 149 | } 150 | if fake.invocations[key] == nil { 151 | fake.invocations[key] = [][]interface{}{} 152 | } 153 | fake.invocations[key] = append(fake.invocations[key], args) 154 | } 155 | 156 | var _ disk.CacheRoundTripper = new(FakeCacheRoundTripper) 157 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/disk/round_tripper.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 The Kubernetes Authors. 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 disk 18 | 19 | import ( 20 | "net/http" 21 | "os" 22 | "path/filepath" 23 | 24 | "github.com/gregjones/httpcache" 25 | "github.com/gregjones/httpcache/diskcache" 26 | "github.com/peterbourgon/diskv" 27 | "k8s.io/klog/v2" 28 | ) 29 | 30 | //go:generate counterfeiter . CacheRoundTripper 31 | type CacheRoundTripper interface { 32 | RoundTrip(req *http.Request) (*http.Response, error) 33 | CancelRequest(req *http.Request) 34 | } 35 | 36 | type cacheRoundTripper struct { 37 | rt *httpcache.Transport 38 | } 39 | 40 | func NewCacheRoundTripper(cachDir string, rt http.RoundTripper) http.RoundTripper { 41 | return newCacheRoundTripper(cachDir, rt) 42 | } 43 | 44 | // newCacheRoundTripper creates a roundtripper that reads the ETag on 45 | // response headers and send the If-None-Match header on subsequent 46 | // corresponding requests. 47 | func newCacheRoundTripper(cacheDir string, rt http.RoundTripper) http.RoundTripper { 48 | d := diskv.New(diskv.Options{ 49 | PathPerm: os.FileMode(0750), 50 | FilePerm: os.FileMode(0660), 51 | BasePath: cacheDir, 52 | TempDir: filepath.Join(cacheDir, ".diskv-temp"), 53 | }) 54 | t := httpcache.NewTransport(diskcache.NewWithDiskv(d)) 55 | t.Transport = rt 56 | 57 | return &cacheRoundTripper{rt: t} 58 | } 59 | 60 | func (rt *cacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 61 | return rt.rt.RoundTrip(req) 62 | } 63 | 64 | func (rt *cacheRoundTripper) CancelRequest(req *http.Request) { 65 | type canceler interface { 66 | CancelRequest(*http.Request) 67 | } 68 | 69 | if cr, ok := rt.rt.Transport.(canceler); ok { 70 | cr.CancelRequest(req) 71 | } else { 72 | klog.Errorf("CancelRequest not implemented by %T", rt.rt.Transport) 73 | } 74 | } 75 | 76 | func (rt *cacheRoundTripper) WrappedRoundTripper() http.RoundTripper { return rt.rt.Transport } 77 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/disk/round_tripper_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 The Kubernetes Authors. 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 disk_test 18 | 19 | import ( 20 | "bytes" 21 | "fmt" 22 | "io" 23 | "net/http" 24 | "net/url" 25 | "os" 26 | "path/filepath" 27 | 28 | . "github.com/homedepot/go-clouddriver/internal/kubernetes/cached/disk" 29 | . "github.com/onsi/ginkgo/v2" 30 | . "github.com/onsi/gomega" 31 | ) 32 | 33 | // copied from k8s.io/client-go/transport/round_trippers_test.go 34 | type testRoundTripper struct { 35 | Request *http.Request 36 | Response *http.Response 37 | Err error 38 | } 39 | 40 | func (rt *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 41 | rt.Request = req 42 | return rt.Response, rt.Err 43 | } 44 | 45 | var _ = Describe("CachedDiscovery", func() { 46 | var ( 47 | rt *testRoundTripper 48 | cacheDir string 49 | err error 50 | cache http.RoundTripper 51 | req *http.Request 52 | resp *http.Response 53 | content []byte 54 | ) 55 | 56 | BeforeEach(func() { 57 | rt = &testRoundTripper{} 58 | cacheDir, err = os.MkdirTemp("", "cache-rt") 59 | Expect(err).To(BeNil()) 60 | }) 61 | 62 | JustBeforeEach(func() { 63 | cache = NewCacheRoundTripper(cacheDir, rt) 64 | }) 65 | 66 | AfterEach(func() { 67 | os.RemoveAll(cacheDir) 68 | if resp != nil { 69 | resp.Body.Close() 70 | } 71 | }) 72 | 73 | Describe("#RoundTrip", func() { 74 | BeforeEach(func() { 75 | req = &http.Request{ 76 | Method: http.MethodGet, 77 | URL: &url.URL{Host: "localhost"}, 78 | } 79 | }) 80 | 81 | JustBeforeEach(func() { 82 | resp, err = cache.RoundTrip(req) 83 | Expect(err).To(BeNil()) 84 | content, err = io.ReadAll(resp.Body) 85 | Expect(err).To(BeNil()) 86 | }) 87 | 88 | When("the data is not cached", func() { 89 | BeforeEach(func() { 90 | rt.Response = &http.Response{ 91 | Header: http.Header{"ETag": []string{`"123456"`}}, 92 | Body: io.NopCloser(bytes.NewReader([]byte("Content"))), 93 | StatusCode: http.StatusOK, 94 | } 95 | }) 96 | 97 | It("succeeds", func() { 98 | Expect(string(content)).To(Equal("Content")) 99 | }) 100 | }) 101 | 102 | When("the data is cached", func() { 103 | BeforeEach(func() { 104 | rt.Response = &http.Response{ 105 | StatusCode: http.StatusNotModified, 106 | Body: io.NopCloser(bytes.NewReader([]byte("Other Content"))), 107 | } 108 | }) 109 | 110 | It("succeeds", func() { 111 | Expect(string(content)).To(Equal("Other Content")) 112 | }) 113 | }) 114 | 115 | When("the cache directory is removed", func() { 116 | BeforeEach(func() { 117 | os.RemoveAll(cacheDir) 118 | rt.Response = &http.Response{ 119 | Header: http.Header{"ETag": []string{`"123456"`}}, 120 | Body: io.NopCloser(bytes.NewReader([]byte("Content"))), 121 | StatusCode: http.StatusOK, 122 | } 123 | }) 124 | 125 | It("creates the cache directories and files with the correct permissions", func() { 126 | err = filepath.Walk(cacheDir, func(path string, info os.FileInfo, err error) error { 127 | if err != nil { 128 | return err 129 | } 130 | if info.IsDir() { 131 | if info.Mode().Perm() != os.FileMode(0750) { 132 | return fmt.Errorf("directory perm incorrect expected %d got %d", os.FileMode(0750), info.Mode().Perm()) 133 | } 134 | } else { 135 | if info.Mode().Perm() != os.FileMode(0660) { 136 | return fmt.Errorf("file perm incorrect expected %d got %d", os.FileMode(0660), info.Mode().Perm()) 137 | } 138 | } 139 | return nil 140 | }) 141 | Expect(err).To(BeNil()) 142 | }) 143 | }) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/memory/memory_suite_test.go: -------------------------------------------------------------------------------- 1 | package memory_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestMemory(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Memory Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/memory/round_tripper.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 The Kubernetes Authors. 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 memory 18 | 19 | import ( 20 | "net/http" 21 | 22 | "github.com/gregjones/httpcache" 23 | "k8s.io/klog/v2" 24 | ) 25 | 26 | //go:generate counterfeiter . CacheRoundTripper 27 | type MemCacheRoundTripper interface { 28 | RoundTrip(req *http.Request) (*http.Response, error) 29 | CancelRequest(req *http.Request) 30 | } 31 | 32 | type memCacheRoundTripper struct { 33 | rt *httpcache.Transport 34 | } 35 | 36 | func NewMemCacheRoundTripper(rt http.RoundTripper, c *httpcache.MemoryCache) http.RoundTripper { 37 | return newMemCacheRoundTripper(rt, c) 38 | } 39 | 40 | // newMemCacheRoundTripper creates a roundtripper that reads the ETag on 41 | // response headers and send the If-None-Match header on subsequent 42 | // corresponding requests. 43 | func newMemCacheRoundTripper(rt http.RoundTripper, c *httpcache.MemoryCache) http.RoundTripper { 44 | t := httpcache.NewTransport(c) 45 | t.Transport = rt 46 | 47 | return &memCacheRoundTripper{rt: t} 48 | } 49 | 50 | func (rt *memCacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 51 | return rt.rt.RoundTrip(req) 52 | } 53 | 54 | func (rt *memCacheRoundTripper) CancelRequest(req *http.Request) { 55 | type canceler interface { 56 | CancelRequest(*http.Request) 57 | } 58 | 59 | if cr, ok := rt.rt.Transport.(canceler); ok { 60 | cr.CancelRequest(req) 61 | } else { 62 | klog.Errorf("CancelRequest not implemented by %T", rt.rt.Transport) 63 | } 64 | } 65 | 66 | func (rt *memCacheRoundTripper) WrappedRoundTripper() http.RoundTripper { return rt.rt.Transport } 67 | -------------------------------------------------------------------------------- /internal/kubernetes/cached/memory/round_tripper_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2017 The Kubernetes Authors. 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 memory_test 18 | 19 | import ( 20 | "bytes" 21 | "io" 22 | "net/http" 23 | "net/url" 24 | 25 | "github.com/gregjones/httpcache" 26 | . "github.com/homedepot/go-clouddriver/internal/kubernetes/cached/memory" 27 | . "github.com/onsi/ginkgo/v2" 28 | . "github.com/onsi/gomega" 29 | ) 30 | 31 | // copied from k8s.io/client-go/transport/round_trippers_test.go 32 | type testRoundTripper struct { 33 | Request *http.Request 34 | Response *http.Response 35 | Err error 36 | } 37 | 38 | func (rt *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 39 | rt.Request = req 40 | return rt.Response, rt.Err 41 | } 42 | 43 | var _ = Describe("CachedDiscovery", func() { 44 | var ( 45 | rt *testRoundTripper 46 | err error 47 | cache http.RoundTripper 48 | httpMemCache *httpcache.MemoryCache 49 | req *http.Request 50 | resp *http.Response 51 | content []byte 52 | ) 53 | 54 | BeforeEach(func() { 55 | rt = &testRoundTripper{} 56 | httpMemCache = httpcache.NewMemoryCache() 57 | }) 58 | 59 | JustBeforeEach(func() { 60 | cache = NewMemCacheRoundTripper(rt, httpMemCache) 61 | }) 62 | 63 | AfterEach(func() { 64 | if resp != nil { 65 | resp.Body.Close() 66 | } 67 | }) 68 | 69 | Describe("#RoundTrip", func() { 70 | BeforeEach(func() { 71 | req = &http.Request{ 72 | Method: http.MethodGet, 73 | URL: &url.URL{Host: "localhost"}, 74 | } 75 | }) 76 | 77 | JustBeforeEach(func() { 78 | resp, err = cache.RoundTrip(req) 79 | Expect(err).To(BeNil()) 80 | content, err = io.ReadAll(resp.Body) 81 | Expect(err).To(BeNil()) 82 | }) 83 | 84 | When("the data is not cached", func() { 85 | BeforeEach(func() { 86 | rt.Response = &http.Response{ 87 | Header: http.Header{"ETag": []string{`"123456"`}}, 88 | Body: io.NopCloser(bytes.NewReader([]byte("Content"))), 89 | StatusCode: http.StatusOK, 90 | } 91 | }) 92 | 93 | It("succeeds", func() { 94 | Expect(string(content)).To(Equal("Content")) 95 | }) 96 | }) 97 | 98 | When("the data is cached", func() { 99 | BeforeEach(func() { 100 | rt.Response = &http.Response{ 101 | StatusCode: http.StatusNotModified, 102 | Body: io.NopCloser(bytes.NewReader([]byte("Other Content"))), 103 | } 104 | }) 105 | 106 | It("succeeds", func() { 107 | Expect(string(content)).To(Equal("Other Content")) 108 | }) 109 | }) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /internal/kubernetes/clientset.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "unicode" 9 | 10 | v1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/client-go/kubernetes" 13 | ) 14 | 15 | var ( 16 | // defaultTailLines sets the logs to get the previous 17 | // 10000 lines. 18 | defaultTailLines = int64(10000) 19 | ) 20 | 21 | //go:generate counterfeiter . Clientset 22 | type Clientset interface { 23 | PodLogs(string, string, string) (string, error) 24 | Events(context.Context, string, string, string) ([]v1.Event, error) 25 | } 26 | 27 | type clientset struct { 28 | clientset *kubernetes.Clientset 29 | } 30 | 31 | // PodLogs returns logs for a given container in a given pod in a given 32 | // namespace. 33 | func (c *clientset) PodLogs(name, namespace, container string) (string, error) { 34 | podLogOptions := v1.PodLogOptions{ 35 | Container: container, 36 | TailLines: &defaultTailLines, 37 | } 38 | 39 | logs := c.clientset.CoreV1(). 40 | Pods(namespace). 41 | GetLogs(name, &podLogOptions) 42 | 43 | stream, err := logs.Stream(context.Background()) 44 | if err != nil { 45 | return "", err 46 | } 47 | defer stream.Close() 48 | 49 | buf := new(bytes.Buffer) 50 | 51 | _, err = io.Copy(buf, stream) 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | return buf.String(), nil 57 | } 58 | 59 | // Events returns events for a given kind, name, and namespace. 60 | func (c *clientset) Events(ctx context.Context, kind, name, namespace string) ([]v1.Event, error) { 61 | lo := metav1.ListOptions{ 62 | FieldSelector: fmt.Sprintf("involvedObject.kind=%s,involvedObject.name=%s", uppercaseFirst(kind), name), 63 | } 64 | 65 | events, err := c.clientset.CoreV1().Events(namespace).List(ctx, lo) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return events.Items, nil 71 | } 72 | 73 | // uppercaseFirst uppercases the first letter of a string. 74 | func uppercaseFirst(str string) string { 75 | for i, v := range str { 76 | return string(unicode.ToUpper(v)) + str[i+1:] 77 | } 78 | 79 | return "" 80 | } 81 | -------------------------------------------------------------------------------- /internal/kubernetes/cluster.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | // Generate the cluster that a kind is a part of. 10 | // A Kubernetes cluster is of kind deployment, statefulSet, replicaSet, ingress, service, and daemonSet 11 | // so only generate a cluster for these kinds. 12 | func Cluster(kind, name string) string { 13 | cluster := "" 14 | 15 | if strings.EqualFold(kind, "deployment") || 16 | strings.EqualFold(kind, "statefulSet") || 17 | strings.EqualFold(kind, "replicaSet") || 18 | strings.EqualFold(kind, "ingress") || 19 | strings.EqualFold(kind, "service") || 20 | strings.EqualFold(kind, "daemonSet") { 21 | cluster = fmt.Sprintf("%s %s", lowercaseFirst(kind), name) 22 | } 23 | 24 | return cluster 25 | } 26 | 27 | func lowercaseFirst(str string) string { 28 | for i, v := range str { 29 | return string(unicode.ToLower(v)) + str[i+1:] 30 | } 31 | 32 | return "" 33 | } 34 | -------------------------------------------------------------------------------- /internal/kubernetes/cluster_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("Cluster", func() { 10 | Describe("#Cluster", func() { 11 | var ( 12 | kind string 13 | cluster string 14 | ) 15 | 16 | JustBeforeEach(func() { 17 | cluster = Cluster(kind, "test-name") 18 | }) 19 | 20 | When("the kind is daemonSet", func() { 21 | BeforeEach(func() { 22 | kind = "DaemonSet" 23 | }) 24 | 25 | It("sets the cluster", func() { 26 | Expect(cluster).To(Equal("daemonSet test-name")) 27 | }) 28 | }) 29 | 30 | When("the kind is deployment", func() { 31 | BeforeEach(func() { 32 | kind = "Deployment" 33 | }) 34 | 35 | It("sets the cluster", func() { 36 | Expect(cluster).To(Equal("deployment test-name")) 37 | }) 38 | }) 39 | 40 | When("the kind is ingress", func() { 41 | BeforeEach(func() { 42 | kind = "Ingress" 43 | }) 44 | 45 | It("sets the cluster", func() { 46 | Expect(cluster).To(Equal("ingress test-name")) 47 | }) 48 | }) 49 | 50 | When("the kind is replicaSet", func() { 51 | BeforeEach(func() { 52 | kind = "ReplicaSet" 53 | }) 54 | 55 | It("sets the cluster", func() { 56 | Expect(cluster).To(Equal("replicaSet test-name")) 57 | }) 58 | }) 59 | 60 | When("the kind is service", func() { 61 | BeforeEach(func() { 62 | kind = "Service" 63 | }) 64 | 65 | It("sets the cluster", func() { 66 | Expect(cluster).To(Equal("service test-name")) 67 | }) 68 | }) 69 | 70 | When("the kind is statefulSet", func() { 71 | BeforeEach(func() { 72 | kind = "StatefulSet" 73 | }) 74 | 75 | It("sets the cluster", func() { 76 | Expect(cluster).To(Equal("statefulSet test-name")) 77 | }) 78 | }) 79 | 80 | When("the kind is not a cluster type", func() { 81 | BeforeEach(func() { 82 | kind = "Pod" 83 | }) 84 | 85 | It("does not set the cluster", func() { 86 | Expect(cluster).To(BeEmpty()) 87 | }) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /internal/kubernetes/controller.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "path/filepath" 5 | "regexp" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/homedepot/go-clouddriver/internal/kubernetes/cached/disk" 11 | "github.com/homedepot/go-clouddriver/internal/kubernetes/cached/memory" 12 | "k8s.io/client-go/dynamic" 13 | "k8s.io/client-go/kubernetes" 14 | "k8s.io/client-go/rest" 15 | "k8s.io/client-go/restmapper" 16 | ) 17 | 18 | var ( 19 | useDiskCache bool 20 | ) 21 | 22 | // Controller holds the ability to generate a new 23 | // dynamic kubernetes client. 24 | // 25 | //go:generate counterfeiter . Controller 26 | type Controller interface { 27 | NewClient(*rest.Config) (Client, error) 28 | NewClientset(*rest.Config) (Clientset, error) 29 | } 30 | 31 | // NewController returns an instance of Controller. 32 | func NewController() Controller { 33 | return &controller{} 34 | } 35 | 36 | type controller struct{} 37 | 38 | // UseDiskCache sets the controller to generate clients that use 39 | // disk cache instead of memory cache for discovery and HTTP responses. 40 | func UseDiskCache() { 41 | useDiskCache = true 42 | } 43 | 44 | // NewClient returns a new dynamic Kubernetes client. By default it returns 45 | // a client that uses in-memory cache store, unless `useDiskCache` is set 46 | // to true. This is where the client stores and references its discovery of 47 | // the Kubernetes API server. 48 | func (c *controller) NewClient(config *rest.Config) (Client, error) { 49 | var ( 50 | client Client 51 | err error 52 | ) 53 | 54 | if useDiskCache { 55 | client, err = newClientWithDefaultDiskCache(config) 56 | } else { 57 | client, err = newClientWithMemoryCache(config) 58 | } 59 | 60 | return client, err 61 | } 62 | 63 | // NewClientset returns a new kubernetes Clientset wrapper. 64 | func (c *controller) NewClientset(config *rest.Config) (Clientset, error) { 65 | cs, err := kubernetes.NewForConfig(config) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return &clientset{ 71 | clientset: cs, 72 | }, nil 73 | } 74 | 75 | const ( 76 | // Default cache directory. 77 | cacheDir = "/var/kube/cache" 78 | defaultTimeout = 180 * time.Second 79 | ttl = 10 * time.Minute 80 | ) 81 | 82 | var ( 83 | mux sync.Mutex 84 | memCaches = map[string]*memory.Cache{} 85 | ) 86 | 87 | func newClientWithMemoryCache(config *rest.Config) (Client, error) { 88 | // If the timeout is not set, set it to the default timeout. 89 | if config.Timeout == 0 { 90 | config.Timeout = defaultTimeout 91 | } 92 | 93 | dynamicClient, err := dynamic.NewForConfig(config) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | mc, err := memCacheClientForConfig(config) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | mapper := restmapper.NewDeferredDiscoveryRESTMapper(mc) 104 | kubeClient := &client{ 105 | c: dynamicClient, 106 | config: config, 107 | mapper: mapper, 108 | } 109 | 110 | return kubeClient, nil 111 | } 112 | 113 | func memCacheClientForConfig(inConfig *rest.Config) (memory.CachedDiscoveryClient, error) { 114 | mux.Lock() 115 | defer mux.Unlock() 116 | 117 | config := inConfig 118 | if _, ok := memCaches[config.Host]; !ok { 119 | memCaches[config.Host] = memory.NewCache(ttl) 120 | } 121 | 122 | memCache := memCaches[config.Host] 123 | 124 | return memCache.NewClientForConfig(config) 125 | } 126 | 127 | func newClientWithDefaultDiskCache(config *rest.Config) (Client, error) { 128 | // If the timeout is not set, set it to the default timeout. 129 | if config.Timeout == 0 { 130 | config.Timeout = defaultTimeout 131 | } 132 | 133 | dynamicClient, err := dynamic.NewForConfig(config) 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | // Some code to define this take from 139 | // https://github.com/kubernetes/cli-runtime/blob/master/pkg/genericclioptions/config_flags.go#L215 140 | httpCacheDir := filepath.Join(cacheDir, "http") 141 | discoveryCacheDir := computeDiscoverCacheDir(filepath.Join(cacheDir, "discovery"), config.Host) 142 | 143 | // DiscoveryClient queries API server about the resources 144 | cdc, err := disk.NewCachedDiscoveryClientForConfig(config, discoveryCacheDir, httpCacheDir, ttl) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | mapper := restmapper.NewDeferredDiscoveryRESTMapper(cdc) 150 | kubeClient := &client{ 151 | c: dynamicClient, 152 | config: config, 153 | mapper: mapper, 154 | } 155 | 156 | return kubeClient, nil 157 | } 158 | 159 | // overlyCautiousIllegalFileCharacters matches characters that *might* not be supported. 160 | // Windows is really restrictive, so this is really restrictive. 161 | var overlyCautiousIllegalFileCharacters = regexp.MustCompile(`[^(\w/\.)]`) 162 | 163 | // computeDiscoverCacheDir takes the parentDir and the host and comes up with a "usually non-colliding" name. 164 | func computeDiscoverCacheDir(parentDir, host string) string { 165 | // strip the optional scheme from host if its there: 166 | schemelessHost := strings.Replace(strings.Replace(host, "https://", "", 1), "http://", "", 1) 167 | // now do a simple collapse of non-AZ09 characters. Collisions are possible but unlikely. 168 | // Even if we do collide the problem is short lived 169 | safeHost := overlyCautiousIllegalFileCharacters.ReplaceAllString(schemelessHost, "_") 170 | 171 | return filepath.Join(parentDir, safeHost) 172 | } 173 | -------------------------------------------------------------------------------- /internal/kubernetes/custom_kind.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 11 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | ) 14 | 15 | var ( 16 | once sync.Once 17 | configFile map[string]CustomKindConfig 18 | ) 19 | 20 | // CustomKindConfig describes the structure of each item in the custom kinds config file, see example below: 21 | // 22 | // { 23 | // "myCustomKind": { 24 | // "statusChecks": [ 25 | // { 26 | // "fieldPath": "field1.field2", 27 | // "comparedValue": true, 28 | // "operator": "EQ" 29 | // } 30 | // ] 31 | // } 32 | // } 33 | type CustomKindConfig struct { 34 | StatusChecks []StatusCheck `json:"statusChecks"` 35 | } 36 | 37 | type StatusCheck struct { 38 | // The path to the field within the manifest's status object that the status check should evaluate, 39 | // use dot notation for nested fields 40 | FieldPath string `json:"fieldPath"` 41 | ComparedValue interface{} `json:"comparedValue"` 42 | // Specifies how to compare the actual value and the compared value; 43 | // the status check passes if the comparison evaluates to true and fails otherwise. 44 | // Currently only supports EQ and NE 45 | Operator string `json:"operator"` 46 | } 47 | 48 | type CustomKind struct { 49 | CustomKindConfig 50 | manifest *unstructured.Unstructured 51 | } 52 | 53 | func NewCustomKind(kind string, m map[string]interface{}) *CustomKind { 54 | manifest, err := ToUnstructured(m) 55 | if err != nil { 56 | clouddriver.Log(fmt.Errorf("error creating unstructured object from manifest: %v", err)) 57 | } 58 | 59 | configData := getCustomKindConfig(kind) 60 | 61 | return &CustomKind{manifest: &manifest, CustomKindConfig: configData} 62 | } 63 | 64 | func (k *CustomKind) Object() *unstructured.Unstructured { 65 | return k.manifest 66 | } 67 | 68 | func (k *CustomKind) Status() manifest.Status { 69 | s := manifest.DefaultStatus 70 | 71 | unstructuredContent := k.manifest.UnstructuredContent() 72 | if _, ok := unstructuredContent["status"]; !ok { 73 | return s 74 | } 75 | 76 | statusData := unstructuredContent["status"].(map[string]interface{}) 77 | 78 | for _, statusCheck := range k.StatusChecks { 79 | statusValue := getStatusValue(statusData, statusCheck.FieldPath) 80 | if statusValue == nil { 81 | continue 82 | } 83 | 84 | if !evaluateStatusCheck(statusValue, statusCheck.ComparedValue, statusCheck.Operator) { 85 | s.Stable.State = false 86 | s.Failed.State = true 87 | s.Failed.Message = fmt.Sprintf("Field status.%s was %v", statusCheck.FieldPath, statusValue) 88 | 89 | return s 90 | } 91 | } 92 | 93 | return s 94 | } 95 | 96 | func getCustomKindConfig(kind string) CustomKindConfig { 97 | customKindsConfigPath := os.Getenv("CUSTOM_KINDS_CONFIG_PATH") 98 | if customKindsConfigPath == "" { 99 | return CustomKindConfig{} 100 | } 101 | 102 | if configFile == nil { 103 | once.Do(func() { 104 | allConfigs := make(map[string]CustomKindConfig) 105 | configBytes, err := os.ReadFile(customKindsConfigPath) 106 | 107 | if err != nil { 108 | clouddriver.Log(fmt.Errorf("error reading custom kinds config file at %s: %v", 109 | customKindsConfigPath, err)) 110 | } 111 | 112 | if err := json.Unmarshal(configBytes, &allConfigs); err != nil { 113 | clouddriver.Log(fmt.Errorf("error setting up custom kinds config: %v", err)) 114 | } 115 | 116 | configFile = allConfigs 117 | }) 118 | } 119 | 120 | config, ok := configFile[kind] 121 | if !ok { 122 | return CustomKindConfig{} 123 | } 124 | 125 | return config 126 | } 127 | 128 | func getStatusValue(statusMap map[string]interface{}, fieldPath string) interface{} { 129 | fields := strings.Split(fieldPath, ".") 130 | if len(fields) == 0 { 131 | return nil 132 | } 133 | 134 | currField := fields[0] 135 | 136 | if len(fields) == 1 { 137 | val, exists := statusMap[currField] 138 | if !exists { 139 | return nil 140 | } 141 | 142 | return val 143 | } 144 | 145 | remainingFields := fields[1:] 146 | 147 | val, exists := statusMap[currField] 148 | if !exists { 149 | return nil 150 | } 151 | 152 | // recursively traverses the status object until we reach the field we're looking for 153 | return getStatusValue(val.(map[string]interface{}), strings.Join(remainingFields, ".")) 154 | } 155 | 156 | func evaluateStatusCheck(actual, compared interface{}, operator string) bool { 157 | // we can add more operators if necessary 158 | switch strings.ToLower(operator) { 159 | case "eq": 160 | return actual == compared 161 | case "ne": 162 | return actual != compared 163 | default: 164 | return true 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /internal/kubernetes/custom_kind_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 8 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Custom Kind", Ordered, func() { 14 | var ( 15 | customKind *CustomKind 16 | fakeCustomKindConfig map[string]CustomKindConfig 17 | ) 18 | 19 | BeforeAll(func() { 20 | fakeCustomKindConfig = map[string]CustomKindConfig{ 21 | "test": { 22 | StatusChecks: []StatusCheck{ 23 | { 24 | FieldPath: "phase.type", 25 | ComparedValue: "Error", 26 | Operator: "NE", 27 | }, 28 | }, 29 | }, 30 | } 31 | customKindsConfigPath := "./customKindsConfig.json" 32 | os.Setenv("CUSTOM_KINDS_CONFIG_PATH", customKindsConfigPath) 33 | f, err := os.Create(customKindsConfigPath) 34 | Expect(err).To(BeNil()) 35 | err = json.NewEncoder(f).Encode(fakeCustomKindConfig) 36 | Expect(err).To(BeNil()) 37 | f.Close() 38 | }) 39 | 40 | AfterAll(func() { 41 | os.Remove(os.Getenv("CUSTOM_KINDS_CONFIG_PATH")) 42 | }) 43 | 44 | Describe("#Object", func() { 45 | BeforeEach(func() { 46 | fakeManifest := map[string]interface{}{"status": "1", "kind": "test"} 47 | customKind = NewCustomKind("test", fakeManifest) 48 | }) 49 | 50 | When("it succeeds", func() { 51 | It("succeeds", func() { 52 | Expect(customKind.Object()).ToNot(BeNil()) 53 | }) 54 | }) 55 | }) 56 | 57 | Describe("#Status", func() { 58 | var s manifest.Status 59 | 60 | JustBeforeEach(func() { 61 | s = customKind.Status() 62 | }) 63 | 64 | When("there is no configuration for the kind", func() { 65 | BeforeEach(func() { 66 | status := map[string]interface{}{"ready": false} 67 | fakeManifest := map[string]interface{}{"status": status, "kind": "doesnotexist"} 68 | customKind = NewCustomKind("doesnotexist", fakeManifest) 69 | }) 70 | 71 | It("returns default status", func() { 72 | Expect(s).To(Equal(manifest.DefaultStatus)) 73 | }) 74 | }) 75 | 76 | When("the status check fails", func() { 77 | BeforeEach(func() { 78 | status := map[string]interface{}{"phase": map[string]interface{}{"type": "Error", "reason": "some reason"}} 79 | fakeManifest := map[string]interface{}{"status": status, "kind": "test"} 80 | customKind = NewCustomKind("test", fakeManifest) 81 | }) 82 | 83 | It("returns status failed", func() { 84 | Expect(s.Failed.State).To(BeTrue()) 85 | Expect(s.Failed.Message).To(Equal("Field status.phase.type was Error")) 86 | }) 87 | }) 88 | 89 | When("the expected field does not exist", func() { 90 | BeforeEach(func() { 91 | fakeManifest := map[string]interface{}{"randomField": "1", "kind": "test"} 92 | customKind = NewCustomKind("test", fakeManifest) 93 | }) 94 | 95 | It("returns default status", func() { 96 | Expect(s).To(Equal(manifest.DefaultStatus)) 97 | }) 98 | }) 99 | 100 | When("the status check succeeds", func() { 101 | BeforeEach(func() { 102 | status := map[string]interface{}{"phase": map[string]interface{}{"type": "some type"}} 103 | fakeManifest := map[string]interface{}{"status": status, "kind": "test"} 104 | customKind = NewCustomKind("test", fakeManifest) 105 | }) 106 | 107 | It("returns the expected status", func() { 108 | Expect(s.Stable.State).To(BeTrue()) 109 | Expect(s.Failed.State).To(BeFalse()) 110 | }) 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /internal/kubernetes/daemonset.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 9 | v1 "k8s.io/api/apps/v1" 10 | ) 11 | 12 | func NewDaemonSet(m map[string]interface{}) *DaemonSet { 13 | ds := &v1.DaemonSet{} 14 | b, _ := json.Marshal(m) 15 | _ = json.Unmarshal(b, &ds) 16 | 17 | return &DaemonSet{ds: ds} 18 | } 19 | 20 | type DaemonSet struct { 21 | ds *v1.DaemonSet 22 | } 23 | 24 | func (ds *DaemonSet) Object() *v1.DaemonSet { 25 | return ds.ds 26 | } 27 | 28 | func (ds *DaemonSet) Status() manifest.Status { 29 | s := manifest.DefaultStatus 30 | 31 | if reflect.DeepEqual(ds.ds.Status, v1.DaemonSetStatus{}) { 32 | s = manifest.NoneReported 33 | 34 | return s 35 | } 36 | 37 | if strings.EqualFold(string(ds.ds.Spec.UpdateStrategy.Type), "rollingupdate") { 38 | return s 39 | } 40 | 41 | if ds.ds.ObjectMeta.Generation != ds.ds.Status.ObservedGeneration { 42 | s.Stable.State = false 43 | s.Stable.Message = "Waiting for status generation to match updated object generation" 44 | 45 | return s 46 | } 47 | 48 | desiredReplicas := ds.ds.Status.DesiredNumberScheduled 49 | 50 | { 51 | scheduledReplicas := ds.ds.Status.CurrentNumberScheduled 52 | if scheduledReplicas < desiredReplicas { 53 | s.Stable.State = false 54 | s.Stable.Message = "Waiting for all replicas to be scheduled" 55 | 56 | return s 57 | } 58 | } 59 | 60 | { 61 | updatedReplicas := ds.ds.Status.UpdatedNumberScheduled 62 | if updatedReplicas < desiredReplicas { 63 | s.Stable.State = false 64 | s.Stable.Message = "Waiting for all updated replicas to be scheduled" 65 | 66 | return s 67 | } 68 | } 69 | 70 | { 71 | availableReplicas := ds.ds.Status.NumberAvailable 72 | if availableReplicas < desiredReplicas { 73 | s.Stable.State = false 74 | s.Stable.Message = "Waiting for all replicas to be available" 75 | 76 | return s 77 | } 78 | } 79 | 80 | { 81 | readyReplicas := ds.ds.Status.NumberReady 82 | if readyReplicas < desiredReplicas { 83 | s.Stable.State = false 84 | s.Stable.Message = "Waiting for all replicas to be ready" 85 | 86 | return s 87 | } 88 | } 89 | 90 | return s 91 | } 92 | -------------------------------------------------------------------------------- /internal/kubernetes/daemonset_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 5 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | v1 "k8s.io/api/apps/v1" 9 | ) 10 | 11 | var _ = Describe("DaemonSet", func() { 12 | var ( 13 | daemonSet *DaemonSet 14 | ) 15 | 16 | BeforeEach(func() { 17 | ds := map[string]interface{}{} 18 | daemonSet = NewDaemonSet(ds) 19 | }) 20 | 21 | Describe("#Status", func() { 22 | var s manifest.Status 23 | 24 | BeforeEach(func() { 25 | daemonSet.Object().Status.DesiredNumberScheduled = 4 26 | }) 27 | 28 | JustBeforeEach(func() { 29 | s = daemonSet.Status() 30 | }) 31 | 32 | When("there is no status", func() { 33 | BeforeEach(func() { 34 | ds := map[string]interface{}{} 35 | daemonSet = NewDaemonSet(ds) 36 | }) 37 | 38 | It("returns status unstable and unavailable", func() { 39 | Expect(s.Stable.State).To(BeFalse()) 40 | Expect(s.Stable.Message).To(Equal("No status reported yet")) 41 | Expect(s.Available.State).To(BeFalse()) 42 | Expect(s.Available.Message).To(Equal("No availability reported")) 43 | 44 | }) 45 | }) 46 | 47 | When("the update stategy is rolling update", func() { 48 | BeforeEach(func() { 49 | o := daemonSet.Object() 50 | o.Spec.UpdateStrategy = v1.DaemonSetUpdateStrategy{ 51 | Type: v1.RollingUpdateDaemonSetStrategyType, 52 | } 53 | }) 54 | 55 | It("returns status stable and available", func() { 56 | Expect(s.Stable.State).To(BeTrue()) 57 | Expect(s.Available.State).To(BeTrue()) 58 | }) 59 | }) 60 | 61 | When("the generations do not match", func() { 62 | BeforeEach(func() { 63 | o := daemonSet.Object() 64 | o.ObjectMeta.Generation = 100 65 | o.Status.ObservedGeneration = 99 66 | }) 67 | 68 | It("returns status unstable", func() { 69 | Expect(s.Stable.State).To(BeFalse()) 70 | Expect(s.Stable.Message).To(Equal("Waiting for status generation to match updated object generation")) 71 | }) 72 | }) 73 | 74 | When("scheduled replicas is less than desired", func() { 75 | BeforeEach(func() { 76 | o := daemonSet.Object() 77 | o.Status.CurrentNumberScheduled = int32(2) 78 | }) 79 | 80 | It("returns the expected status", func() { 81 | Expect(s.Stable.State).To(BeFalse()) 82 | Expect(s.Stable.Message).To(Equal("Waiting for all replicas to be scheduled")) 83 | }) 84 | }) 85 | 86 | When("updated replicas is less than desired", func() { 87 | BeforeEach(func() { 88 | o := daemonSet.Object() 89 | o.Status.CurrentNumberScheduled = int32(4) 90 | o.Status.UpdatedNumberScheduled = int32(2) 91 | }) 92 | 93 | It("returns the expected status", func() { 94 | Expect(s.Stable.State).To(BeFalse()) 95 | Expect(s.Stable.Message).To(Equal("Waiting for all updated replicas to be scheduled")) 96 | }) 97 | }) 98 | 99 | When("there are less available replicas than desireds replicas", func() { 100 | BeforeEach(func() { 101 | o := daemonSet.Object() 102 | o.Status.CurrentNumberScheduled = int32(4) 103 | o.Status.UpdatedNumberScheduled = int32(4) 104 | o.Status.NumberAvailable = int32(2) 105 | }) 106 | 107 | It("returns the expected status", func() { 108 | Expect(s.Stable.State).To(BeFalse()) 109 | Expect(s.Stable.Message).To(Equal("Waiting for all replicas to be available")) 110 | }) 111 | }) 112 | 113 | When("there are less ready replicas than desireds replicas", func() { 114 | BeforeEach(func() { 115 | o := daemonSet.Object() 116 | o.Status.CurrentNumberScheduled = int32(4) 117 | o.Status.UpdatedNumberScheduled = int32(4) 118 | o.Status.NumberAvailable = int32(4) 119 | o.Status.NumberReady = int32(2) 120 | }) 121 | 122 | It("returns the expected status", func() { 123 | Expect(s.Stable.State).To(BeFalse()) 124 | Expect(s.Stable.Message).To(Equal("Waiting for all replicas to be ready")) 125 | }) 126 | }) 127 | 128 | When("it succeeds", func() { 129 | BeforeEach(func() { 130 | o := daemonSet.Object() 131 | o.Status.CurrentNumberScheduled = int32(4) 132 | o.Status.UpdatedNumberScheduled = int32(4) 133 | o.Status.NumberAvailable = int32(4) 134 | o.Status.NumberReady = int32(4) 135 | }) 136 | 137 | It("returns the expected status", func() { 138 | Expect(s.Stable.State).To(BeTrue()) 139 | }) 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /internal/kubernetes/deployment.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 8 | v1 "k8s.io/api/apps/v1" 9 | ) 10 | 11 | func NewDeployment(m map[string]interface{}) *Deployment { 12 | d := &v1.Deployment{} 13 | b, _ := json.Marshal(m) 14 | _ = json.Unmarshal(b, &d) 15 | 16 | return &Deployment{d: d} 17 | } 18 | 19 | type Deployment struct { 20 | d *v1.Deployment 21 | } 22 | 23 | func (d *Deployment) Object() *v1.Deployment { 24 | return d.d 25 | } 26 | 27 | func (d *Deployment) Status() manifest.Status { 28 | s := manifest.DefaultStatus 29 | 30 | if d.d.ObjectMeta.Generation != d.d.Status.ObservedGeneration { 31 | s.Stable.State = false 32 | s.Stable.Message = "Waiting for status generation to match updated object generation" 33 | 34 | return s 35 | } 36 | 37 | conditions := d.d.Status.Conditions 38 | for _, condition := range conditions { 39 | if strings.EqualFold(condition.Reason, "deploymentpaused") { 40 | s.Paused.State = true 41 | } 42 | 43 | if strings.EqualFold(string(condition.Type), "available") && 44 | strings.EqualFold(string(condition.Status), "false") { 45 | s.Available.State = false 46 | s.Available.Message = condition.Reason 47 | s.Stable.State = false 48 | s.Stable.Message = condition.Reason 49 | } 50 | 51 | if strings.EqualFold(string(condition.Type), "progressing") && 52 | strings.EqualFold(condition.Reason, "progressdeadlineexceeded") { 53 | s.Failed.State = true 54 | s.Failed.Message = condition.Message 55 | } 56 | } 57 | 58 | desiredReplicas := int32(0) 59 | 60 | if d.d.Spec.Replicas != nil { 61 | desiredReplicas = *d.d.Spec.Replicas 62 | } 63 | 64 | { 65 | updatedReplicas := d.d.Status.UpdatedReplicas 66 | if updatedReplicas < desiredReplicas { 67 | s.Stable.State = false 68 | s.Stable.Message = "Waiting for all replicas to be updated" 69 | 70 | return s 71 | } 72 | 73 | statusReplicas := d.d.Status.Replicas 74 | if statusReplicas > updatedReplicas { 75 | s.Stable.State = false 76 | s.Stable.Message = "Waiting for old replicas to finish termination" 77 | 78 | return s 79 | } 80 | } 81 | 82 | { 83 | availableReplicas := d.d.Status.AvailableReplicas 84 | if availableReplicas < desiredReplicas { 85 | s.Stable.State = false 86 | s.Stable.Message = "Waiting for all replicas to be available" 87 | 88 | return s 89 | } 90 | } 91 | 92 | { 93 | readyReplicas := d.d.Status.ReadyReplicas 94 | if readyReplicas < desiredReplicas { 95 | s.Stable.State = false 96 | s.Stable.Message = "Waiting for all replicas to be ready" 97 | 98 | return s 99 | } 100 | } 101 | 102 | return s 103 | } 104 | -------------------------------------------------------------------------------- /internal/kubernetes/filter.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "strings" 5 | 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | ) 8 | 9 | // FilterOnAnnotations takes a slice of unstructured and returns 10 | // the filtered slice based on a given annotation key and value. 11 | func FilterOnAnnotation(items []unstructured.Unstructured, 12 | annotationKey, annotationValue string) []unstructured.Unstructured { 13 | filtered := []unstructured.Unstructured{} 14 | 15 | for _, item := range items { 16 | annotations := item.GetAnnotations() 17 | if annotations != nil && 18 | strings.EqualFold(annotations[annotationKey], annotationValue) { 19 | filtered = append(filtered, item) 20 | } 21 | } 22 | 23 | return filtered 24 | } 25 | 26 | // FilterOnLabelExists takes a slice of unstructured and returns 27 | // a filtered slice where a given label exists in each 28 | // of the unstructured objects. 29 | func FilterOnLabelExists(items []unstructured.Unstructured, 30 | label string) []unstructured.Unstructured { 31 | filtered := []unstructured.Unstructured{} 32 | 33 | for _, item := range items { 34 | labels := item.GetLabels() 35 | if labels != nil { 36 | if _, ok := labels[label]; ok { 37 | filtered = append(filtered, item) 38 | } 39 | } 40 | } 41 | 42 | return filtered 43 | } 44 | -------------------------------------------------------------------------------- /internal/kubernetes/horizontalpodautoscaler.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 8 | v1 "k8s.io/api/autoscaling/v1" 9 | ) 10 | 11 | func NewHorizontalPodAutoscaler(m map[string]interface{}) *HorizontalPodAutoscaler { 12 | hpa := &v1.HorizontalPodAutoscaler{} 13 | b, _ := json.Marshal(m) 14 | _ = json.Unmarshal(b, &hpa) 15 | 16 | return &HorizontalPodAutoscaler{hpa: hpa} 17 | } 18 | 19 | type HorizontalPodAutoscaler struct { 20 | hpa *v1.HorizontalPodAutoscaler 21 | } 22 | 23 | func (hpa *HorizontalPodAutoscaler) Object() *v1.HorizontalPodAutoscaler { 24 | return hpa.hpa 25 | } 26 | 27 | func (hpa *HorizontalPodAutoscaler) Status() manifest.Status { 28 | s := manifest.DefaultStatus 29 | 30 | hpaStatus := hpa.hpa.Status 31 | if hpaStatus.DesiredReplicas > hpaStatus.CurrentReplicas { 32 | s.Stable.State = false 33 | s.Stable.Message = fmt.Sprintf("Waiting for HPA to complete a scale up, current: %d desired: %d", hpaStatus.CurrentReplicas, hpaStatus.DesiredReplicas) 34 | s.Available.State = false 35 | s.Available.Message = fmt.Sprintf("Waiting for HPA to complete a scale up, current: %d desired: %d", hpaStatus.CurrentReplicas, hpaStatus.DesiredReplicas) 36 | } 37 | 38 | if hpaStatus.DesiredReplicas < hpaStatus.CurrentReplicas { 39 | s.Stable.State = false 40 | s.Stable.Message = fmt.Sprintf("Waiting for HPA to complete a scale down, current: %d desired: %d", hpaStatus.CurrentReplicas, hpaStatus.DesiredReplicas) 41 | s.Available.State = false 42 | s.Available.Message = fmt.Sprintf("Waiting for HPA to complete a scale down, current: %d desired: %d", hpaStatus.CurrentReplicas, hpaStatus.DesiredReplicas) 43 | } 44 | 45 | return s 46 | } 47 | -------------------------------------------------------------------------------- /internal/kubernetes/horizontalpodautoscaler_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 8 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 9 | ) 10 | 11 | var _ = Describe("HorizontalPodAutoscaler", func() { 12 | var ( 13 | hpa *HorizontalPodAutoscaler 14 | ) 15 | 16 | BeforeEach(func() { 17 | hpa = NewHorizontalPodAutoscaler(map[string]interface{}{}) 18 | }) 19 | 20 | Describe("#Status", func() { 21 | var s manifest.Status 22 | 23 | JustBeforeEach(func() { 24 | s = hpa.Status() 25 | }) 26 | 27 | When("DesiredReplicas > CurrentReplicas", func() { 28 | BeforeEach(func() { 29 | o := hpa.Object() 30 | o.Status.DesiredReplicas = 5 31 | o.Status.CurrentReplicas = 3 32 | }) 33 | 34 | It("returns expected status", func() { 35 | Expect(s.Stable.State).To(BeFalse()) 36 | Expect(s.Stable.Message).To(Equal("Waiting for HPA to complete a scale up, current: 3 desired: 5")) 37 | Expect(s.Available.State).To(BeFalse()) 38 | Expect(s.Available.Message).To(Equal("Waiting for HPA to complete a scale up, current: 3 desired: 5")) 39 | }) 40 | }) 41 | 42 | When("DesiredReplicas < CurrentReplicas", func() { 43 | BeforeEach(func() { 44 | o := hpa.Object() 45 | o.Status.DesiredReplicas = 3 46 | o.Status.CurrentReplicas = 5 47 | }) 48 | 49 | It("returns expected status", func() { 50 | Expect(s.Stable.State).To(BeFalse()) 51 | Expect(s.Stable.Message).To(Equal("Waiting for HPA to complete a scale down, current: 5 desired: 3")) 52 | Expect(s.Available.State).To(BeFalse()) 53 | Expect(s.Available.Message).To(Equal("Waiting for HPA to complete a scale down, current: 5 desired: 3")) 54 | }) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /internal/kubernetes/job.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 7 | v1 "k8s.io/api/batch/v1" 8 | ) 9 | 10 | func NewJob(m map[string]interface{}) *Job { 11 | j := &v1.Job{} 12 | b, _ := json.Marshal(m) 13 | _ = json.Unmarshal(b, &j) 14 | 15 | return &Job{j: j} 16 | } 17 | 18 | type Job struct { 19 | j *v1.Job 20 | } 21 | 22 | func (j *Job) Object() *v1.Job { 23 | return j.j 24 | } 25 | 26 | // Calculated at https://github.com/spinnaker/clouddriver/blob/master/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/model/KubernetesJobStatus.java#L71 27 | func (j *Job) State() string { 28 | obj := j.Object() 29 | status := obj.Status 30 | 31 | if status.CompletionTime == nil { 32 | return "Running" 33 | } 34 | 35 | completions := int32(1) 36 | if obj.Spec.Completions != nil { 37 | completions = *obj.Spec.Completions 38 | } 39 | 40 | succeeded := status.Succeeded 41 | 42 | if succeeded < completions { 43 | conditions := status.Conditions 44 | failed := false 45 | 46 | for _, condition := range conditions { 47 | if condition.Type == v1.JobFailed { 48 | failed = true 49 | break 50 | } 51 | } 52 | 53 | if failed { 54 | return "Failed" 55 | } 56 | 57 | return "Running" 58 | } 59 | 60 | return "Succeeded" 61 | } 62 | 63 | func (j *Job) Status() manifest.Status { 64 | s := manifest.DefaultStatus 65 | 66 | completions := int32(1) 67 | spec := j.j.Spec 68 | status := j.j.Status 69 | 70 | if spec.Completions != nil { 71 | completions = *spec.Completions 72 | } 73 | 74 | succeeded := status.Succeeded 75 | if succeeded < completions { 76 | conditions := status.Conditions 77 | for _, condition := range conditions { 78 | if condition.Type == v1.JobFailed { 79 | s.Failed.State = true 80 | s.Failed.Message = condition.Message 81 | 82 | return s 83 | } 84 | } 85 | 86 | s.Stable.State = false 87 | s.Stable.Message = "Waiting for jobs to finish" 88 | } 89 | 90 | return s 91 | } 92 | -------------------------------------------------------------------------------- /internal/kubernetes/job_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | v1 "k8s.io/api/batch/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | 9 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 10 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 11 | ) 12 | 13 | var _ = Describe("Job", func() { 14 | var ( 15 | job *Job 16 | ) 17 | 18 | BeforeEach(func() { 19 | j := map[string]interface{}{} 20 | job = NewJob(j) 21 | }) 22 | 23 | Describe("#State", func() { 24 | var s string 25 | 26 | JustBeforeEach(func() { 27 | s = job.State() 28 | }) 29 | 30 | When("the job has not completed", func() { 31 | BeforeEach(func() { 32 | o := job.Object() 33 | o.Status.CompletionTime = nil 34 | }) 35 | 36 | It("returns expected state", func() { 37 | Expect(s).To(Equal("Running")) 38 | }) 39 | }) 40 | 41 | When("the job has failed", func() { 42 | BeforeEach(func() { 43 | completions := int32(1) 44 | o := job.Object() 45 | o.Status.CompletionTime = &metav1.Time{} 46 | o.Spec.Completions = &completions 47 | o.Status.Conditions = []v1.JobCondition{ 48 | { 49 | Type: "Failed", 50 | }, 51 | } 52 | }) 53 | 54 | It("returns expected state", func() { 55 | Expect(s).To(Equal("Failed")) 56 | }) 57 | }) 58 | 59 | When("the job is partially successful", func() { 60 | BeforeEach(func() { 61 | completions := int32(1) 62 | o := job.Object() 63 | o.Status.CompletionTime = &metav1.Time{} 64 | o.Spec.Completions = &completions 65 | }) 66 | 67 | It("returns expected state", func() { 68 | Expect(s).To(Equal("Running")) 69 | }) 70 | }) 71 | 72 | When("the job succeeded", func() { 73 | BeforeEach(func() { 74 | completions := int32(1) 75 | o := job.Object() 76 | o.Status.CompletionTime = &metav1.Time{} 77 | o.Status.Succeeded = int32(1) 78 | o.Spec.Completions = &completions 79 | }) 80 | 81 | It("returns expected state", func() { 82 | Expect(s).To(Equal("Succeeded")) 83 | }) 84 | }) 85 | }) 86 | 87 | Describe("#Status", func() { 88 | var s manifest.Status 89 | 90 | BeforeEach(func() { 91 | completions := int32(1) 92 | o := job.Object() 93 | o.Status.Succeeded = 1 94 | o.Spec.Completions = &completions 95 | }) 96 | 97 | JustBeforeEach(func() { 98 | s = job.Status() 99 | }) 100 | 101 | Context("succeeded pods is less than completions", func() { 102 | BeforeEach(func() { 103 | o := job.Object() 104 | completions := int32(2) 105 | o.Status.Succeeded = 1 106 | o.Spec.Completions = &completions 107 | }) 108 | 109 | When("there is a failed condition", func() { 110 | BeforeEach(func() { 111 | o := job.Object() 112 | o.Status.Conditions = []v1.JobCondition{ 113 | { 114 | Type: v1.JobFailed, 115 | Message: "Some failure message", 116 | }, 117 | } 118 | }) 119 | 120 | It("returns status failed", func() { 121 | Expect(s.Failed.State).To(BeTrue()) 122 | Expect(s.Failed.Message).To(Equal("Some failure message")) 123 | }) 124 | }) 125 | 126 | When("the job is not finished", func() { 127 | It("returns the expected status", func() { 128 | Expect(s.Stable.State).To(BeFalse()) 129 | Expect(s.Stable.Message).To(Equal("Waiting for jobs to finish")) 130 | }) 131 | }) 132 | }) 133 | 134 | When("it succeeeds", func() { 135 | It("succeeds", func() { 136 | Expect(s.Stable.State).To(BeTrue()) 137 | Expect(s.Available.State).To(BeTrue()) 138 | }) 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /internal/kubernetes/kubernetes_suite_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestKubernetes(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Kubernetes Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/kubernetes/label.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "strings" 5 | 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | ) 8 | 9 | const ( 10 | // https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ 11 | LabelKubernetesName = `app.kubernetes.io/name` 12 | LabelKubernetesManagedBy = `app.kubernetes.io/managed-by` 13 | LabelSpinnakerMonikerSequence = `moniker.spinnaker.io/sequence` 14 | ) 15 | 16 | // AddSpinnakerLabels labels a given unstructured Kubernetes resource 17 | // with Spinnaker defined labels. 18 | func AddSpinnakerLabels(u *unstructured.Unstructured, application string) error { 19 | // Add reserved labels. Do not overwrite the "qpp.kubernetes.io/name" label 20 | // as this could affect label selectors. 21 | // 22 | // https://spinnaker.io/reference/providers/kubernetes-v2/#reserved-labels 23 | // https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ 24 | label(u, LabelKubernetesManagedBy, spinnaker) 25 | labelIfNotExists(u, LabelKubernetesName, application) 26 | 27 | if strings.EqualFold(u.GetKind(), "deployment") || 28 | strings.EqualFold(u.GetKind(), "replicaset") || 29 | strings.EqualFold(u.GetKind(), "daemonset") { 30 | err := labelTemplate(u, LabelKubernetesManagedBy, spinnaker) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | err = labelTemplateIfNotExists(u, LabelKubernetesName, application) 36 | if err != nil { 37 | return err 38 | } 39 | } 40 | 41 | return nil 42 | } 43 | 44 | // AddSpinnakerVersionLabels adds the `moniker.spinnaker.io/sequence` label 45 | // to the manifest to identify the version number of that resource. 46 | func AddSpinnakerVersionLabels(u *unstructured.Unstructured, version SpinnakerVersion) error { 47 | label(u, LabelSpinnakerMonikerSequence, version.Short) 48 | 49 | if strings.EqualFold(u.GetKind(), "deployment") || 50 | strings.EqualFold(u.GetKind(), "replicaset") || 51 | strings.EqualFold(u.GetKind(), "statefulset") || 52 | strings.EqualFold(u.GetKind(), "daemonset") { 53 | err := labelTemplate(u, LabelSpinnakerMonikerSequence, version.Short) 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func label(u *unstructured.Unstructured, key, value string) { 63 | labels := u.GetLabels() 64 | if labels == nil { 65 | labels = map[string]string{} 66 | } 67 | 68 | labels[key] = value 69 | u.SetLabels(labels) 70 | } 71 | 72 | func labelTemplate(u *unstructured.Unstructured, key, value string) error { 73 | templateLabels, found, err := unstructured.NestedStringMap(u.Object, "spec", "template", "metadata", "labels") 74 | if err != nil { 75 | return err 76 | } 77 | 78 | if !found { 79 | templateLabels = map[string]string{} 80 | } 81 | 82 | templateLabels[key] = value 83 | 84 | err = unstructured.SetNestedStringMap(u.Object, templateLabels, "spec", "template", "metadata", "labels") 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func labelIfNotExists(u *unstructured.Unstructured, key, value string) { 93 | labels := u.GetLabels() 94 | if labels == nil { 95 | labels = map[string]string{} 96 | } 97 | 98 | if _, ok := labels[key]; !ok { 99 | labels[key] = value 100 | } 101 | 102 | u.SetLabels(labels) 103 | } 104 | 105 | func labelTemplateIfNotExists(u *unstructured.Unstructured, key, value string) error { 106 | templateLabels, found, err := unstructured.NestedStringMap(u.Object, "spec", "template", "metadata", "labels") 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if !found { 112 | templateLabels = map[string]string{} 113 | } 114 | 115 | if templateLabels[key] != "" { 116 | return nil 117 | } 118 | 119 | templateLabels[key] = value 120 | 121 | err = unstructured.SetNestedStringMap(u.Object, templateLabels, "spec", "template", "metadata", "labels") 122 | if err != nil { 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /internal/kubernetes/label_selector.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/labels" 9 | "k8s.io/apimachinery/pkg/selection" 10 | ) 11 | 12 | func NewRequirement(op string, key string, values []string) (*labels.Requirement, error) { 13 | switch strings.ToLower(op) { 14 | case "contains": 15 | return labels.NewRequirement(key, selection.In, values) 16 | case "not_contains": 17 | return labels.NewRequirement(key, selection.NotIn, values) 18 | case "equals": 19 | return labels.NewRequirement(key, selection.Equals, values) 20 | case "not_equals": 21 | return labels.NewRequirement(key, selection.NotEquals, values) 22 | case "exists": 23 | return labels.NewRequirement(key, selection.Exists, values) 24 | case "not_exists": 25 | return labels.NewRequirement(key, selection.DoesNotExist, values) 26 | default: 27 | return nil, fmt.Errorf("operator '%v' is not recognized", op) 28 | } 29 | } 30 | 31 | // DefaultLabelSelector returns the label selector 32 | // `app.kubernetes.io/managed-by in (spinnaker,spinnaker-operator)`, 33 | // which allows us to list all resources with a label selector 34 | // managed by Spinnaker or Spinnaker Operator. 35 | func DefaultLabelSelector() string { 36 | labelSelector := metav1.LabelSelector{ 37 | MatchExpressions: []metav1.LabelSelectorRequirement{ 38 | { 39 | Key: LabelKubernetesManagedBy, 40 | Operator: metav1.LabelSelectorOpIn, 41 | Values: []string{"spinnaker", "spinnaker-operator"}, 42 | }, 43 | }, 44 | } 45 | // Since this is a defined label Selector we can ignore the error. 46 | ls, _ := metav1.LabelSelectorAsSelector(&labelSelector) 47 | 48 | return ls.String() 49 | } 50 | -------------------------------------------------------------------------------- /internal/kubernetes/manifest/status.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | var DefaultStatus = Status{ 4 | Available: StatusAvailable{ 5 | State: true, 6 | }, 7 | Failed: StatusFailed{ 8 | State: false, 9 | }, 10 | Paused: StatusPaused{ 11 | State: false, 12 | }, 13 | Stable: StatusStable{ 14 | State: true, 15 | }, 16 | } 17 | 18 | var NoneReported = Status{ 19 | Available: StatusAvailable{ 20 | State: false, 21 | Message: "No availability reported", 22 | }, 23 | Failed: StatusFailed{ 24 | State: false, 25 | }, 26 | Paused: StatusPaused{ 27 | State: false, 28 | }, 29 | Stable: StatusStable{ 30 | State: false, 31 | Message: "No status reported yet", 32 | }, 33 | } 34 | 35 | type Status struct { 36 | Available StatusAvailable `json:"available"` 37 | Failed StatusFailed `json:"failed"` 38 | Paused StatusPaused `json:"paused"` 39 | Stable StatusStable `json:"stable"` 40 | } 41 | 42 | type StatusAvailable struct { 43 | State bool `json:"state"` 44 | Message string `json:"message"` 45 | } 46 | 47 | type StatusFailed struct { 48 | State bool `json:"state"` 49 | Message string `json:"message"` 50 | } 51 | 52 | type StatusPaused struct { 53 | State bool `json:"state"` 54 | Message string `json:"message"` 55 | } 56 | 57 | type StatusStable struct { 58 | State bool `json:"state"` 59 | Message string `json:"message"` 60 | } 61 | -------------------------------------------------------------------------------- /internal/kubernetes/namespace.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "strings" 5 | 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | ) 8 | 9 | // SetNamespaceOnManifest updates the namespace on the given manifest. 10 | // 11 | // If no namespace is set on the manifest and no namespace override is passed 12 | // in then we set the namespace to 'default'. 13 | // 14 | // If namespaceOverride is empty it will NOT override the namespace set 15 | // on the manifest. 16 | // 17 | // We only override the namespace if the manifest is NOT cluster scoped 18 | // (i.e. a ClusterRole) and namespaceOverride is NOT an empty string. 19 | func SetNamespaceOnManifest(u *unstructured.Unstructured, namespaceOverride string) { 20 | if namespaceOverride == "" { 21 | setDefaultNamespaceIfScopedAndNoneSet(u) 22 | } else { 23 | setNamespaceIfScoped(namespaceOverride, u) 24 | } 25 | } 26 | 27 | func setDefaultNamespaceIfScopedAndNoneSet(u *unstructured.Unstructured) { 28 | namespace := u.GetNamespace() 29 | if isNamespaceScoped(u.GetKind()) && namespace == "" { 30 | namespace = "default" 31 | u.SetNamespace(namespace) 32 | } 33 | } 34 | 35 | func setNamespaceIfScoped(namespace string, u *unstructured.Unstructured) { 36 | if isNamespaceScoped(u.GetKind()) { 37 | u.SetNamespace(namespace) 38 | } 39 | } 40 | 41 | // isNamespaceScoped returns true if the kind is namespace-scoped. 42 | // 43 | // Cluster-scoped kinds are: 44 | // - apiService 45 | // - clusterRole 46 | // - clusterRoleBinding 47 | // - customResourceDefinition 48 | // - mutatingWebhookConfiguration 49 | // - namespace 50 | // - persistentVolume 51 | // - podSecurityPolicy 52 | // - storageClass 53 | // - validatingWebhookConfiguration 54 | // 55 | // See https://github.com/spinnaker/clouddriver/blob/58ab154b0ec0d62772201b5b319af349498a4e3f/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/description/manifest/KubernetesKindProperties.java#L31 56 | // for clouddriver OSS namespace-scoped kinds. 57 | func isNamespaceScoped(kind string) bool { 58 | namespaceScoped := true 59 | 60 | for _, value := range clusterScopedKinds { 61 | if strings.EqualFold(value, kind) { 62 | namespaceScoped = false 63 | 64 | break 65 | } 66 | } 67 | 68 | return namespaceScoped 69 | } 70 | -------------------------------------------------------------------------------- /internal/kubernetes/pod.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 7 | v1 "k8s.io/api/core/v1" 8 | ) 9 | 10 | func NewPod(m map[string]interface{}) *Pod { 11 | p := &v1.Pod{} 12 | b, _ := json.Marshal(m) 13 | _ = json.Unmarshal(b, &p) 14 | 15 | return &Pod{p: p} 16 | } 17 | 18 | type Pod struct { 19 | p *v1.Pod 20 | } 21 | 22 | func (p *Pod) Object() *v1.Pod { 23 | return p.p 24 | } 25 | 26 | func (p *Pod) Status() manifest.Status { 27 | s := manifest.DefaultStatus 28 | 29 | if p.p.Status.Phase == v1.PodPending || 30 | p.p.Status.Phase == v1.PodFailed || 31 | p.p.Status.Phase == v1.PodUnknown { 32 | s.Stable.State = false 33 | s.Stable.Message = "Pod is " + string(p.p.Status.Phase) 34 | s.Available.State = false 35 | s.Available.Message = "Pod is " + string(p.p.Status.Phase) 36 | } 37 | 38 | return s 39 | } 40 | -------------------------------------------------------------------------------- /internal/kubernetes/pod_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | v1 "k8s.io/api/core/v1" 7 | 8 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 9 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 10 | ) 11 | 12 | var _ = Describe("Pod", func() { 13 | var ( 14 | pod *Pod 15 | ) 16 | 17 | BeforeEach(func() { 18 | p := map[string]interface{}{} 19 | pod = NewPod(p) 20 | }) 21 | 22 | Describe("#Status", func() { 23 | var s manifest.Status 24 | 25 | JustBeforeEach(func() { 26 | s = pod.Status() 27 | }) 28 | 29 | When("pod phase is pending", func() { 30 | BeforeEach(func() { 31 | o := pod.Object() 32 | o.Status.Phase = v1.PodPending 33 | }) 34 | 35 | It("returns expected status", func() { 36 | Expect(s.Stable.State).To(BeFalse()) 37 | Expect(s.Stable.Message).To(Equal("Pod is Pending")) 38 | Expect(s.Available.State).To(BeFalse()) 39 | Expect(s.Available.Message).To(Equal("Pod is Pending")) 40 | }) 41 | }) 42 | 43 | When("pod phase is failed", func() { 44 | BeforeEach(func() { 45 | o := pod.Object() 46 | o.Status.Phase = v1.PodFailed 47 | }) 48 | 49 | It("returns expected status", func() { 50 | Expect(s.Stable.State).To(BeFalse()) 51 | Expect(s.Stable.Message).To(Equal("Pod is Failed")) 52 | Expect(s.Available.State).To(BeFalse()) 53 | Expect(s.Available.Message).To(Equal("Pod is Failed")) 54 | }) 55 | }) 56 | 57 | When("pod phase is unknown", func() { 58 | BeforeEach(func() { 59 | o := pod.Object() 60 | o.Status.Phase = v1.PodUnknown 61 | }) 62 | 63 | It("returns expected status", func() { 64 | Expect(s.Stable.State).To(BeFalse()) 65 | Expect(s.Stable.Message).To(Equal("Pod is Unknown")) 66 | Expect(s.Available.State).To(BeFalse()) 67 | Expect(s.Available.Message).To(Equal("Pod is Unknown")) 68 | }) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /internal/kubernetes/provider.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "k8s.io/kubectl/pkg/util/slice" 8 | ) 9 | 10 | var ( 11 | clusterScopedKinds = []string{ 12 | "apiService", 13 | "clusterRole", 14 | "clusterRoleBinding", 15 | "customResourceDefinition", 16 | "mutatingWebhookConfiguration", 17 | "namespace", 18 | "persistentVolume", 19 | "podSecurityPolicy", 20 | "storageClass", 21 | "validatingWebhookConfiguration", 22 | } 23 | ) 24 | 25 | type Provider struct { 26 | Name string `json:"name" gorm:"primary_key"` 27 | Host string `json:"host"` 28 | CAData string `json:"caData" gorm:"type:text"` 29 | BearerToken string `json:"bearerToken,omitempty" gorm:"size:2048"` 30 | TokenProvider string `json:"tokenProvider,omitempty" gorm:"size:128;not null;default:'google'"` 31 | Namespace *string `json:"namespace,omitempty" gorm:"size:253"` 32 | Namespaces []string `json:"namespaces,omitempty" gorm:"-"` 33 | Permissions ProviderPermissions `json:"permissions" gorm:"-"` 34 | // Providers can hold instances of clients. 35 | Client Client `json:"-" gorm:"-"` 36 | Clientset Clientset `json:"-" gorm:"-"` 37 | } 38 | 39 | type ProviderPermissions struct { 40 | Read []string `json:"read" gorm:"-"` 41 | Write []string `json:"write" gorm:"-"` 42 | } 43 | 44 | func (Provider) TableName() string { 45 | return "kubernetes_providers" 46 | } 47 | 48 | type ProviderNamespaces struct { 49 | // ID string `json:"-" gorm:"primary_key"` 50 | AccountName string `json:"accountName" gorm:"index:account_name_namespace_idx,unique"` 51 | Namespace string `json:"namespace,omitempty" gorm:"index:account_name_namespace_idx,unique"` 52 | } 53 | 54 | func (ProviderNamespaces) TableName() string { 55 | return "kubernetes_providers_namespaces" 56 | } 57 | 58 | // ValidateKindStatus verifies that this provider can access the given kind. 59 | // This begins to support `omitKinds`, but only in the context of namespace-scoped 60 | // providers. 61 | // 62 | // When a provider is limited to namespace, then it cannot access these kinds: 63 | // - apiService 64 | // - clusterRole 65 | // - clusterRoleBinding 66 | // - customResourceDefinition 67 | // - mutatingWebhookConfiguration 68 | // - namespace 69 | // - persistentVolume 70 | // - podSecurityPolicy 71 | // - storageClass 72 | // - validatingWebhookConfiguration 73 | // 74 | // See https://github.com/spinnaker/clouddriver/blob/58ab154b0ec0d62772201b5b319af349498a4e3f/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/description/manifest/KubernetesKindProperties.java#L31 75 | // for clouddriver OSS namespace-scoped kinds. 76 | func (p *Provider) ValidateKindStatus(kind string) error { 77 | if p.Namespace == nil && len(p.Namespaces) == 0 { 78 | return nil 79 | } 80 | 81 | for _, value := range clusterScopedKinds { 82 | if strings.EqualFold(value, kind) { 83 | return fmt.Errorf("namespace-scoped account not allowed to access cluster-scoped kind: '%s'", kind) 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | 90 | // ValidateNamespaceAccess verifies that this provider can access the given namespace 91 | func (p *Provider) ValidateNamespaceAccess(namespace string) error { 92 | namespace = strings.TrimSpace(namespace) 93 | 94 | if namespace == "" { 95 | namespace = "default" 96 | } 97 | 98 | if len(p.Namespaces) > 0 && !slice.ContainsString(p.Namespaces, namespace, nil) { 99 | return fmt.Errorf("namespace-scoped account not allowed to access forbidden namespace: '%s'", namespace) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | // WithClient sets the kubernetes client for this provider. 106 | func (p *Provider) WithClient(client Client) { 107 | p.Client = client 108 | } 109 | 110 | // WithClientset sets the kubernetes clientset for this provider. 111 | func (p *Provider) WithClientset(clientset Clientset) { 112 | p.Clientset = clientset 113 | } 114 | -------------------------------------------------------------------------------- /internal/kubernetes/provider_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("Provider", func() { 10 | var ( 11 | provider Provider 12 | kind string 13 | namespace string = "test-namespace" 14 | ) 15 | 16 | Context("#ValidateKindStatus", func() { 17 | BeforeEach(func() { 18 | provider = Provider{} 19 | kind = "Deployment" 20 | }) 21 | 22 | JustBeforeEach(func() { 23 | err = provider.ValidateKindStatus(kind) 24 | }) 25 | 26 | When("Provider is namespace-scoped", func() { 27 | BeforeEach(func() { 28 | provider.Namespaces = []string{namespace} 29 | }) 30 | 31 | When("kind is not allowed", func() { 32 | BeforeEach(func() { 33 | kind = "Namespace" 34 | }) 35 | 36 | It("errors", func() { 37 | Expect(err).ToNot(BeNil()) 38 | Expect(err.Error()).To(Equal("namespace-scoped account not allowed to access cluster-scoped kind: 'Namespace'")) 39 | }) 40 | }) 41 | 42 | When("kind is allowed", func() { 43 | It("succeeds", func() { 44 | Expect(err).To(BeNil()) 45 | }) 46 | }) 47 | }) 48 | 49 | When("Provider is cluster-scoped", func() { 50 | When("kind is not allowed", func() { 51 | BeforeEach(func() { 52 | kind = "Namespace" 53 | }) 54 | 55 | It("succeeds", func() { 56 | Expect(err).To(BeNil()) 57 | }) 58 | }) 59 | 60 | When("kind is allowed", func() { 61 | It("succeeds", func() { 62 | Expect(err).To(BeNil()) 63 | }) 64 | }) 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /internal/kubernetes/replicaset.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 7 | v1 "k8s.io/api/apps/v1" 8 | ) 9 | 10 | func NewReplicaSet(m map[string]interface{}) *ReplicaSet { 11 | r := &v1.ReplicaSet{} 12 | b, _ := json.Marshal(m) 13 | _ = json.Unmarshal(b, &r) 14 | 15 | return &ReplicaSet{rs: r} 16 | } 17 | 18 | type ReplicaSet struct { 19 | rs *v1.ReplicaSet 20 | } 21 | 22 | func (rs *ReplicaSet) Object() *v1.ReplicaSet { 23 | return rs.rs 24 | } 25 | 26 | func (rs *ReplicaSet) Status() manifest.Status { 27 | s := manifest.DefaultStatus 28 | r := rs.rs 29 | 30 | desired := int32(0) 31 | fullyLabeled := r.Status.FullyLabeledReplicas 32 | available := r.Status.AvailableReplicas 33 | ready := r.Status.ReadyReplicas 34 | 35 | if r.Spec.Replicas != nil { 36 | desired = *r.Spec.Replicas 37 | } 38 | 39 | if desired > fullyLabeled { 40 | s.Stable.State = false 41 | s.Stable.Message = "Waiting for all replicas to be fully-labeled" 42 | 43 | return s 44 | } 45 | 46 | if desired > ready { 47 | s.Stable.State = false 48 | s.Stable.Message = "Waiting for all replicas to be ready" 49 | 50 | return s 51 | } 52 | 53 | if desired > available { 54 | s.Stable.State = false 55 | s.Stable.Message = "Waiting for all replicas to be available" 56 | 57 | return s 58 | } 59 | 60 | if r.ObjectMeta.Generation != r.Status.ObservedGeneration { 61 | s.Stable.State = false 62 | s.Stable.Message = "Waiting for replicaset spec update to be observed" 63 | 64 | return s 65 | } 66 | 67 | return s 68 | } 69 | -------------------------------------------------------------------------------- /internal/kubernetes/replicaset_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 5 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | v1 "k8s.io/api/apps/v1" 9 | ) 10 | 11 | var _ = Describe("Replicaset", func() { 12 | var ( 13 | rs *ReplicaSet 14 | ) 15 | 16 | BeforeEach(func() { 17 | r := map[string]interface{}{} 18 | rs = NewReplicaSet(r) 19 | }) 20 | 21 | Describe("#Object", func() { 22 | var r *v1.ReplicaSet 23 | 24 | BeforeEach(func() { 25 | r = rs.Object() 26 | }) 27 | 28 | When("it succeeds", func() { 29 | It("succeeds", func() { 30 | Expect(r).ToNot(BeNil()) 31 | }) 32 | }) 33 | }) 34 | 35 | Describe("#Status", func() { 36 | var s manifest.Status 37 | 38 | BeforeEach(func() { 39 | replicas := int32(4) 40 | rs.Object().Spec.Replicas = &replicas 41 | }) 42 | 43 | JustBeforeEach(func() { 44 | s = rs.Status() 45 | }) 46 | 47 | When("there are more desired replicas than fully labeled replicas", func() { 48 | It("returns status unstable", func() { 49 | Expect(s.Stable.State).To(BeFalse()) 50 | Expect(s.Stable.Message).To(Equal("Waiting for all replicas to be fully-labeled")) 51 | }) 52 | }) 53 | 54 | When("there are more desired replicas than ready replicas", func() { 55 | BeforeEach(func() { 56 | o := rs.Object() 57 | o.Status.FullyLabeledReplicas = int32(4) 58 | }) 59 | 60 | It("returns status unstable", func() { 61 | Expect(s.Stable.State).To(BeFalse()) 62 | Expect(s.Stable.Message).To(Equal("Waiting for all replicas to be ready")) 63 | }) 64 | }) 65 | 66 | When("there are more desired replicas than available", func() { 67 | BeforeEach(func() { 68 | o := rs.Object() 69 | o.Status.FullyLabeledReplicas = int32(4) 70 | o.Status.ReadyReplicas = int32(4) 71 | }) 72 | 73 | It("returns status unstable", func() { 74 | Expect(s.Stable.State).To(BeFalse()) 75 | Expect(s.Stable.Message).To(Equal("Waiting for all replicas to be available")) 76 | }) 77 | }) 78 | 79 | When("the generations do not match", func() { 80 | BeforeEach(func() { 81 | o := rs.Object() 82 | o.Status.FullyLabeledReplicas = int32(4) 83 | o.Status.ReadyReplicas = int32(4) 84 | o.Status.AvailableReplicas = int32(4) 85 | o.ObjectMeta.Generation = 100 86 | o.Status.ObservedGeneration = 99 87 | }) 88 | 89 | It("returns status unstable", func() { 90 | Expect(s.Stable.State).To(BeFalse()) 91 | Expect(s.Stable.Message).To(Equal("Waiting for replicaset spec update to be observed")) 92 | }) 93 | }) 94 | 95 | When("it succeeds", func() { 96 | BeforeEach(func() { 97 | o := rs.Object() 98 | o.Status.FullyLabeledReplicas = int32(4) 99 | o.Status.ReadyReplicas = int32(4) 100 | o.Status.AvailableReplicas = int32(4) 101 | o.ObjectMeta.Generation = 100 102 | o.Status.ObservedGeneration = 100 103 | }) 104 | 105 | It("returns status unstable", func() { 106 | Expect(s.Stable.State).To(BeTrue()) 107 | }) 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /internal/kubernetes/resource.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import "time" 4 | 5 | type Resource struct { 6 | AccountName string `json:"accountName" gorm:"index:account_name_kind_name_spinnaker_app_idx,priority:1"` 7 | ID string `json:"id" gorm:"primary_key"` 8 | Timestamp time.Time `json:"timestamp,omitempty" gorm:"type:timestamp;DEFAULT:current_timestamp"` 9 | TaskID string `json:"taskId" gorm:"index:task_id_idx"` 10 | TaskType string `json:"-"` 11 | APIGroup string `json:"apiGroup"` 12 | Name string `json:"name" gorm:"index:account_name_kind_name_spinnaker_app_idx,priority:3"` 13 | ArtifactName string `json:"-"` 14 | Namespace string `json:"namespace"` 15 | Resource string `json:"resource"` 16 | Version string `json:"version"` 17 | Kind string `json:"kind" gorm:"index:account_name_kind_name_spinnaker_app_idx,priority:2;index:kind_idx"` 18 | SpinnakerApp string `json:"spinnakerApp" gorm:"index:account_name_kind_name_spinnaker_app_idx,priority:4"` 19 | Cluster string `json:"-"` 20 | } 21 | 22 | func (Resource) TableName() string { 23 | return "kubernetes_resources" 24 | } 25 | -------------------------------------------------------------------------------- /internal/kubernetes/statefulset.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 9 | v1 "k8s.io/api/apps/v1" 10 | ) 11 | 12 | func NewStatefulSet(m map[string]interface{}) *StatefulSet { 13 | s := &v1.StatefulSet{} 14 | b, _ := json.Marshal(m) 15 | _ = json.Unmarshal(b, &s) 16 | 17 | return &StatefulSet{ss: s} 18 | } 19 | 20 | type StatefulSet struct { 21 | ss *v1.StatefulSet 22 | } 23 | 24 | func (ss *StatefulSet) Object() *v1.StatefulSet { 25 | return ss.ss 26 | } 27 | 28 | func (ss *StatefulSet) Status() manifest.Status { 29 | s := manifest.DefaultStatus 30 | x := ss.ss 31 | 32 | if strings.EqualFold(string(x.Spec.UpdateStrategy.Type), "ondelete") { 33 | return s 34 | } 35 | 36 | if reflect.DeepEqual(x.Status, v1.StatefulSetStatus{}) { 37 | s = manifest.NoneReported 38 | return s 39 | } 40 | 41 | if x.ObjectMeta.Generation != x.Status.ObservedGeneration { 42 | s.Stable.State = false 43 | s.Stable.Message = "Waiting for status generation to match updated object generation" 44 | 45 | return s 46 | } 47 | 48 | desired := int32(0) 49 | if x.Spec.Replicas != nil { 50 | desired = *x.Spec.Replicas 51 | } 52 | 53 | existing := x.Status.Replicas 54 | if desired > existing { 55 | s.Stable.State = false 56 | s.Stable.Message = "Waiting for at least the desired replica count to be met" 57 | 58 | return s 59 | } 60 | 61 | ready := x.Status.ReadyReplicas 62 | if desired > ready { 63 | s.Stable.State = false 64 | s.Stable.Message = "Waiting for all updated replicas to be ready" 65 | 66 | return s 67 | } 68 | 69 | updType := string(x.Spec.UpdateStrategy.Type) 70 | rollUpd := x.Spec.UpdateStrategy.RollingUpdate 71 | updated := x.Status.UpdatedReplicas 72 | 73 | if strings.EqualFold(updType, "rollingupdate") && rollUpd != nil { 74 | partition := rollUpd.Partition 75 | if partition != nil && (updated < (existing - *partition)) { 76 | s.Stable.State = false 77 | s.Stable.Message = "Waiting for partitioned rollout to finish" 78 | 79 | return s 80 | } 81 | 82 | s.Stable.State = true 83 | s.Stable.Message = "Partitioned roll out complete" 84 | 85 | return s 86 | } 87 | 88 | current := x.Status.CurrentReplicas 89 | if desired > current { 90 | s.Stable.State = false 91 | s.Stable.Message = "Waiting for all updated replicas to be scheduled" 92 | 93 | return s 94 | } 95 | 96 | updateRev := x.Status.UpdateRevision 97 | currentRev := x.Status.CurrentRevision 98 | 99 | if currentRev != updateRev { 100 | s.Stable.State = false 101 | s.Stable.Message = "Waiting for the updated revision to match the current revision" 102 | 103 | return s 104 | } 105 | 106 | return s 107 | } 108 | -------------------------------------------------------------------------------- /internal/kubernetes/statefulset_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 5 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | v1 "k8s.io/api/apps/v1" 9 | ) 10 | 11 | var _ = Describe("Statefulset", func() { 12 | var ( 13 | ss *StatefulSet 14 | ) 15 | 16 | BeforeEach(func() { 17 | s := map[string]interface{}{} 18 | ss = NewStatefulSet(s) 19 | }) 20 | 21 | Describe("#Object", func() { 22 | var s *v1.StatefulSet 23 | 24 | BeforeEach(func() { 25 | s = ss.Object() 26 | }) 27 | 28 | When("it succeeds", func() { 29 | It("succeeds", func() { 30 | Expect(s).ToNot(BeNil()) 31 | }) 32 | }) 33 | }) 34 | 35 | Describe("#Status", func() { 36 | var s manifest.Status 37 | 38 | BeforeEach(func() { 39 | replicas := int32(4) 40 | o := ss.Object() 41 | o.Spec.Replicas = &replicas 42 | o.Status.Replicas = replicas 43 | o.Status.ReadyReplicas = replicas 44 | o.Status.UpdatedReplicas = replicas 45 | o.Status.CurrentReplicas = replicas 46 | }) 47 | 48 | JustBeforeEach(func() { 49 | s = ss.Status() 50 | }) 51 | 52 | When("the update strategy type is OnDelete", func() { 53 | BeforeEach(func() { 54 | o := ss.Object() 55 | o.Spec.UpdateStrategy.Type = "OnDelete" 56 | }) 57 | 58 | It("returns the expected status", func() { 59 | Expect(s).To(Equal(manifest.DefaultStatus)) 60 | }) 61 | }) 62 | 63 | When("there is no reported status", func() { 64 | BeforeEach(func() { 65 | o := ss.Object() 66 | o.Status = v1.StatefulSetStatus{} 67 | }) 68 | 69 | It("returns the expected status", func() { 70 | Expect(s).To(Equal(manifest.NoneReported)) 71 | }) 72 | }) 73 | 74 | When("the generation does not match", func() { 75 | BeforeEach(func() { 76 | o := ss.Object() 77 | o.ObjectMeta.Generation = int64(99) 78 | o.Status.ObservedGeneration = int64(100) 79 | }) 80 | 81 | It("returns the expected status", func() { 82 | Expect(s.Stable.State).To(BeFalse()) 83 | Expect(s.Stable.Message).To(Equal("Waiting for status generation to match updated object generation")) 84 | }) 85 | }) 86 | 87 | When("there are more desired replicas than existing replicas", func() { 88 | BeforeEach(func() { 89 | o := ss.Object() 90 | o.Status.Replicas = 3 91 | }) 92 | 93 | It("returns the expected status", func() { 94 | Expect(s.Stable.State).To(BeFalse()) 95 | Expect(s.Stable.Message).To(Equal("Waiting for at least the desired replica count to be met")) 96 | }) 97 | }) 98 | 99 | When("there are more desired replicas than ready replicas", func() { 100 | BeforeEach(func() { 101 | o := ss.Object() 102 | o.Status.ReadyReplicas = int32(3) 103 | }) 104 | 105 | It("returns the expected status", func() { 106 | Expect(s.Stable.State).To(BeFalse()) 107 | Expect(s.Stable.Message).To(Equal("Waiting for all updated replicas to be ready")) 108 | }) 109 | }) 110 | 111 | Context("when the update type is a rolling update", func() { 112 | BeforeEach(func() { 113 | o := ss.Object() 114 | o.Spec.UpdateStrategy.Type = "RollingUpdate" 115 | rollingUpdate := v1.RollingUpdateStatefulSetStrategy{} 116 | o.Spec.UpdateStrategy.RollingUpdate = &rollingUpdate 117 | }) 118 | 119 | When("the partitioned rollout has not finished", func() { 120 | BeforeEach(func() { 121 | partition := int32(2) 122 | o := ss.Object() 123 | o.Status.UpdatedReplicas = 1 124 | o.Status.CurrentReplicas = 3 125 | o.Spec.UpdateStrategy.RollingUpdate.Partition = &partition 126 | }) 127 | 128 | It("returns the expected status", func() { 129 | Expect(s.Stable.State).To(BeFalse()) 130 | Expect(s.Stable.Message).To(Equal("Waiting for partitioned rollout to finish")) 131 | }) 132 | }) 133 | 134 | When("the partitioned rollout has finished", func() { 135 | BeforeEach(func() { 136 | partition := int32(2) 137 | o := ss.Object() 138 | o.Status.UpdatedReplicas = 2 139 | o.Spec.UpdateStrategy.RollingUpdate.Partition = &partition 140 | }) 141 | 142 | It("returns the expected status", func() { 143 | Expect(s.Stable.State).To(BeTrue()) 144 | Expect(s.Stable.Message).To(Equal("Partitioned roll out complete")) 145 | }) 146 | }) 147 | }) 148 | 149 | When("the desired replicas is more than the current replicas", func() { 150 | BeforeEach(func() { 151 | o := ss.Object() 152 | o.Status.CurrentReplicas = 2 153 | }) 154 | 155 | It("returns the expected status", func() { 156 | Expect(s.Stable.State).To(BeFalse()) 157 | Expect(s.Stable.Message).To(Equal("Waiting for all updated replicas to be scheduled")) 158 | }) 159 | }) 160 | 161 | When("the current revision is not equal to the update revision", func() { 162 | BeforeEach(func() { 163 | o := ss.Object() 164 | o.Status.UpdateRevision = "100" 165 | o.Status.CurrentRevision = "99" 166 | }) 167 | 168 | It("returns the expected status", func() { 169 | Expect(s.Stable.State).To(BeFalse()) 170 | Expect(s.Stable.Message).To(Equal("Waiting for the updated revision to match the current revision")) 171 | }) 172 | }) 173 | 174 | When("it succeeds", func() { 175 | It("succeeds", func() { 176 | Expect(s).To(Equal(manifest.DefaultStatus)) 177 | }) 178 | }) 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /internal/kubernetes/status.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/homedepot/go-clouddriver/internal/kubernetes/manifest" 7 | ) 8 | 9 | // Status definitions of kinds can be found at 10 | // https://github.com/spinnaker/clouddriver/tree/master/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/op/handler 11 | func GetStatus(kind string, m map[string]interface{}) manifest.Status { 12 | var status manifest.Status 13 | 14 | switch strings.ToLower(kind) { 15 | case "daemonset": 16 | status = NewDaemonSet(m).Status() 17 | case "deployment": 18 | status = NewDeployment(m).Status() 19 | case "horizontalpodautoscaler": 20 | status = NewHorizontalPodAutoscaler(m).Status() 21 | case "job": 22 | status = NewJob(m).Status() 23 | case "pod": 24 | status = NewPod(m).Status() 25 | case "replicaset": 26 | status = NewReplicaSet(m).Status() 27 | case "statefulset": 28 | status = NewStatefulSet(m).Status() 29 | default: 30 | status = NewCustomKind(kind, m).Status() 31 | } 32 | 33 | return status 34 | } 35 | -------------------------------------------------------------------------------- /internal/kubernetes/strategy.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | ) 9 | 10 | const ( 11 | AnnotationSpinnakerMaxVersionHistory = "strategy.spinnaker.io/max-version-history" 12 | AnnotationSpinnakerRecreate = "strategy.spinnaker.io/recreate" 13 | AnnotationSpinnakerReplaced = "strategy.spinnaker.io/replace" 14 | AnnotationSpinnakerUseSourceCapacity = "strategy.spinnaker.io/use-source-capacity" 15 | ) 16 | 17 | // MaxVersionHistory returns the value as an int of the annotation 18 | // `strategy.spinnaker.io/max-version-history` of the given Kubernetes 19 | // unstructured resource, or 0 if annotation is not present. 20 | // 21 | // See https://spinnaker.io/docs/reference/providers/kubernetes-v2/#strategy for more info. 22 | func MaxVersionHistory(u unstructured.Unstructured) (maxVersionHistory int, err error) { 23 | maxVersionHistory = 0 24 | 25 | annotations := u.GetAnnotations() 26 | if annotations != nil { 27 | if value, ok := annotations[AnnotationSpinnakerMaxVersionHistory]; ok { 28 | maxVersionHistory, err = strconv.Atoi(value) 29 | if err != nil { 30 | return 31 | } 32 | } 33 | } 34 | 35 | return 36 | } 37 | 38 | // Recreate returns true if the given Kubernetes unstructured resource 39 | // has the annotation `strategy.spinnaker.io/recreate` set to "true". 40 | // 41 | // See https://spinnaker.io/docs/reference/providers/kubernetes-v2/#strategy for more info. 42 | func Recreate(u unstructured.Unstructured) bool { 43 | annotations := u.GetAnnotations() 44 | if annotations != nil { 45 | if value, ok := annotations[AnnotationSpinnakerRecreate]; ok { 46 | return value == "true" 47 | } 48 | } 49 | 50 | return false 51 | } 52 | 53 | // Replace returns true if the given Kubernetes unstructured resource 54 | // has the annotation `strategy.spinnaker.io/replace` set to "true". 55 | // 56 | // See https://spinnaker.io/docs/reference/providers/kubernetes-v2/#strategy for more info. 57 | func Replace(u unstructured.Unstructured) bool { 58 | annotations := u.GetAnnotations() 59 | if annotations != nil { 60 | if value, ok := annotations[AnnotationSpinnakerReplaced]; ok { 61 | return value == "true" 62 | } 63 | } 64 | 65 | return false 66 | } 67 | 68 | // UseSourceCapacity returns true is a given Kubernetes unstructured resource 69 | // has the annotation `strategy.spinnaker.io/use-source-capacity` set to "true" 70 | // and it is of kind Deployment, ReplicaSet, or StatefulSet. 71 | // 72 | // See https://spinnaker.io/docs/reference/providers/kubernetes-v2/#strategy for more info. 73 | func UseSourceCapacity(u unstructured.Unstructured) bool { 74 | switch strings.ToLower(u.GetKind()) { 75 | case "deployment", "replicaset", "statefulset": 76 | annotations := u.GetAnnotations() 77 | if annotations != nil { 78 | if value, ok := annotations[AnnotationSpinnakerUseSourceCapacity]; ok { 79 | return value == "true" 80 | } 81 | } 82 | } 83 | 84 | return false 85 | } 86 | -------------------------------------------------------------------------------- /internal/kubernetes/traffic.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | ) 9 | 10 | const ( 11 | AnnotationSpinnakerTrafficLoadBalancers = "traffic.spinnaker.io/load-balancers" 12 | ) 13 | 14 | // LoadBalancers returns a slice of load balancers from the annotation 15 | // `traffic.spinnaker.io/load-balancers`. It errors if this annotation 16 | // is not a string slice format like '["service my-service", "service my-service2"]'. 17 | // 18 | // See https://spinnaker.io/docs/reference/providers/kubernetes-v2/#traffic for more info. 19 | func LoadBalancers(u unstructured.Unstructured) ([]string, error) { 20 | var lbs []string 21 | 22 | annotations := u.GetAnnotations() 23 | if annotations != nil { 24 | if value, ok := annotations[AnnotationSpinnakerTrafficLoadBalancers]; ok { 25 | err := json.Unmarshal([]byte(value), &lbs) 26 | if err != nil { 27 | return nil, 28 | fmt.Errorf("error unmarshaling annotation 'traffic.spinnaker.io/load-balancers' "+ 29 | "for resource (kind: %s, name: %s, namespace: %s) into string slice: %v", 30 | u.GetKind(), 31 | u.GetName(), 32 | u.GetNamespace(), 33 | err) 34 | } 35 | } 36 | } 37 | 38 | return lbs, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/kubernetes/traffic_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | ) 9 | 10 | var _ = Describe("Traffic", func() { 11 | var ( 12 | err error 13 | fakeResource unstructured.Unstructured 14 | lbs []string 15 | ) 16 | 17 | Context("#LoadBalancers", func() { 18 | JustBeforeEach(func() { 19 | lbs, err = LoadBalancers(fakeResource) 20 | }) 21 | 22 | When("annotation is missing", func() { 23 | BeforeEach(func() { 24 | fakeResource = unstructured.Unstructured{ 25 | Object: map[string]interface{}{ 26 | "kind": "ReplicaSet", 27 | }, 28 | } 29 | }) 30 | 31 | It("returns no load balancers", func() { 32 | Expect(err).To(BeNil()) 33 | Expect(lbs).To(HaveLen(0)) 34 | }) 35 | }) 36 | 37 | When("the annotation is not an array", func() { 38 | BeforeEach(func() { 39 | fakeResource = unstructured.Unstructured{ 40 | Object: map[string]interface{}{ 41 | "kind": "ReplicaSet", 42 | "metadata": map[string]interface{}{ 43 | "name": "test-name", 44 | "namespace": "test-namespace", 45 | "annotations": map[string]interface{}{ 46 | "traffic.spinnaker.io/load-balancers": "string", 47 | }, 48 | }, 49 | }, 50 | } 51 | }) 52 | 53 | It("errors", func() { 54 | Expect(err).ToNot(BeNil()) 55 | Expect(err.Error()).To(Equal("error unmarshaling annotation 'traffic.spinnaker.io/load-balancers' for resource (kind: ReplicaSet, name: test-name, namespace: test-namespace) into string slice: invalid character 's' looking for beginning of value")) 56 | }) 57 | }) 58 | 59 | When("it succeeds", func() { 60 | BeforeEach(func() { 61 | fakeResource = unstructured.Unstructured{ 62 | Object: map[string]interface{}{ 63 | "kind": "ReplicaSet", 64 | "metadata": map[string]interface{}{ 65 | "name": "test-name", 66 | "namespace": "test-namespace", 67 | "annotations": map[string]interface{}{ 68 | "traffic.spinnaker.io/load-balancers": "[\"service test-lb-service\",\"service test-lb-service2\"]", 69 | }, 70 | }, 71 | }, 72 | } 73 | }) 74 | 75 | It("succeeds", func() { 76 | Expect(err).To(BeNil()) 77 | Expect(lbs).To(HaveLen(2)) 78 | Expect(lbs[0]).To(Equal("service test-lb-service")) 79 | Expect(lbs[1]).To(Equal("service test-lb-service2")) 80 | }) 81 | }) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /internal/kubernetes/unstructured.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | "k8s.io/apimachinery/pkg/runtime" 9 | "k8s.io/cli-runtime/pkg/resource" 10 | ) 11 | 12 | // ToUnstructured converts a map[string]interface{} to a kubernetes unstructured.Unstructured 13 | // object. An unstructured's "Object" field is technically just a map[string]interface{}, 14 | // so we could do the following: 15 | // 16 | // return unstructured.Unstructured{ Object: manifest } 17 | // 18 | // and not have this function return an error, but we miss out on some validation that 19 | // happens during `kubectl`, such as when you attempt to apply a bad manifest, like something 20 | // without "kind" specified. Example: 21 | // 22 | // $ k apply -f bad.yaml --validate=false 23 | // error: unable to decode "bad.yaml": Object 'Kind' is missing in '{}' 24 | // 25 | // If we decide to not cycle through the encoding/decoding process, these errors 26 | // will be deferred to when the manifest gets applied, and will fail with some error like 27 | // 28 | // error applying manifest (kind: , apiVersion: v1, name: bad-gke): no matches for kind "" in version "apps/v1" 29 | // 30 | // For now, we are not deferring the error. 31 | func ToUnstructured(manifest map[string]interface{}) (unstructured.Unstructured, error) { 32 | b, err := json.Marshal(manifest) 33 | if err != nil { 34 | return unstructured.Unstructured{}, err 35 | } 36 | 37 | obj, _, err := unstructured.UnstructuredJSONScheme.Decode(b, nil, nil) 38 | if err != nil { 39 | return unstructured.Unstructured{}, err 40 | } 41 | // Convert the runtime.Object to unstructured.Unstructured. 42 | m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) 43 | if err != nil { 44 | return unstructured.Unstructured{}, err 45 | } 46 | 47 | u := unstructured.Unstructured{ 48 | Object: m, 49 | } 50 | 51 | // Attempt to get annotations as map[string]string 52 | // If no errors then nothing else needs to be done 53 | if _, _, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations"); err == nil { 54 | return u, nil 55 | } 56 | 57 | // Attempt to get annotations as map[string]interface{} 58 | annotationsMap, exists, err := unstructured.NestedMap(u.Object, "metadata", "annotations") 59 | if err != nil || !exists { 60 | return u, err 61 | } 62 | 63 | // If annotations exist in manifest and are map[string]interface, convert to map[string]string 64 | annotations := make(map[string]string, len(annotationsMap)) 65 | 66 | for k, v := range annotationsMap { 67 | annotations[k] = fmt.Sprintf("%v", v) 68 | } 69 | 70 | u.SetAnnotations(annotations) 71 | 72 | return u, nil 73 | } 74 | 75 | func SetDefaultNamespaceIfScopedAndNoneSet(u *unstructured.Unstructured, helper *resource.Helper) { 76 | namespace := u.GetNamespace() 77 | if helper.NamespaceScoped && namespace == "" { 78 | namespace = "default" 79 | u.SetNamespace(namespace) 80 | } 81 | } 82 | 83 | func SetNamespaceIfScoped(namespace string, u *unstructured.Unstructured, helper *resource.Helper) { 84 | if helper.NamespaceScoped { 85 | u.SetNamespace(namespace) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/kubernetes/unstructured_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 7 | "k8s.io/cli-runtime/pkg/resource" 8 | 9 | . "github.com/homedepot/go-clouddriver/internal/kubernetes" 10 | ) 11 | 12 | var _ = Describe("Unstructured", func() { 13 | var ( 14 | m map[string]interface{} 15 | err error 16 | ) 17 | 18 | Describe("#ToUnstructured", func() { 19 | var u unstructured.Unstructured 20 | 21 | BeforeEach(func() { 22 | m = map[string]interface{}{ 23 | "kind": "Namespace", 24 | "apiVersion": "v1", 25 | } 26 | }) 27 | 28 | JustBeforeEach(func() { 29 | u, err = ToUnstructured(m) 30 | }) 31 | 32 | When("object kind is missing", func() { 33 | BeforeEach(func() { 34 | m = map[string]interface{}{} 35 | }) 36 | 37 | It("returns an error", func() { 38 | Expect(err).ToNot(BeNil()) 39 | Expect(err.Error()).To(Equal("Object 'Kind' is missing in '{}'")) 40 | }) 41 | }) 42 | 43 | When("it succeeds", func() { 44 | It("succeeds", func() { 45 | Expect(err).To(BeNil()) 46 | Expect(u).ToNot(BeNil()) 47 | }) 48 | }) 49 | }) 50 | 51 | Describe("#SetDefaultNamespaceIfScopedAndNoneSet", func() { 52 | var ( 53 | u unstructured.Unstructured 54 | helper *resource.Helper 55 | ) 56 | 57 | BeforeEach(func() { 58 | m = map[string]interface{}{ 59 | "kind": "Pod", 60 | "apiVersion": "v1", 61 | "metadata": map[string]interface{}{}, 62 | } 63 | helper = &resource.Helper{ 64 | NamespaceScoped: false, 65 | } 66 | u, err = ToUnstructured(m) 67 | Expect(err).To(BeNil()) 68 | }) 69 | 70 | JustBeforeEach(func() { 71 | SetDefaultNamespaceIfScopedAndNoneSet(&u, helper) 72 | }) 73 | 74 | When("it is scoped", func() { 75 | BeforeEach(func() { 76 | helper.NamespaceScoped = true 77 | }) 78 | 79 | It("sets the default namespace", func() { 80 | n := u.GetNamespace() 81 | Expect(n).To(Equal("default")) 82 | }) 83 | }) 84 | 85 | When("it is not scoped", func() { 86 | BeforeEach(func() { 87 | helper.NamespaceScoped = false 88 | }) 89 | 90 | It("does not set the namespace", func() { 91 | n := u.GetNamespace() 92 | Expect(n).To(BeEmpty()) 93 | }) 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /internal/kubernetes/version.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strconv" 8 | "strings" 9 | 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | ) 12 | 13 | const ( 14 | // https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ 15 | AnnotationSpinnakerArtifactVersion = `artifact.spinnaker.io/version` 16 | AnnotationSpinnakerMonikerSequence = `moniker.spinnaker.io/sequence` 17 | // Maximum latest version before cycling back to v000. 18 | maxLatestVersion = 999 19 | ) 20 | 21 | var ( 22 | // Regular expresion to match trailing '-v###' 23 | matchSpinnakerVersionRegexp = regexp.MustCompile("-v[0-9]{3}[0-9]*$") 24 | ) 25 | 26 | type SpinnakerVersion struct { 27 | Long string 28 | Short string 29 | } 30 | 31 | // GetCurrentVersion returns the latest "Spinnaker version" from an unstructured 32 | // list of Kubernetes resources. 33 | func GetCurrentVersion(ul *unstructured.UnstructuredList, kind, name string) string { 34 | currentVersion := "-1" 35 | cluster := "" 36 | 37 | if ul == nil || len(ul.Items) == 0 { 38 | return currentVersion 39 | } 40 | 41 | // Filter out all unassociated objects based on the 42 | // moniker.spinnaker.io/cluster annotation. 43 | cluster = kind + " " + name 44 | results := FilterOnAnnotation(ul.Items, AnnotationSpinnakerMonikerCluster, cluster) 45 | // Filter out empty moniker.spinnaker.io/sequence labels 46 | results = FilterOnLabelExists(results, LabelSpinnakerMonikerSequence) 47 | if len(results) == 0 { 48 | return currentVersion 49 | } 50 | 51 | // For now, we sort on creation timestamp to grab the manifest. 52 | sort.Slice(results, func(i, j int) bool { 53 | return results[i].GetCreationTimestamp().String() > results[j].GetCreationTimestamp().String() 54 | }) 55 | 56 | annotations := results[0].GetAnnotations() 57 | currentVersion = annotations[AnnotationSpinnakerMonikerSequence] 58 | 59 | return currentVersion 60 | } 61 | 62 | // IsVersioned returns true is a given Kubernetes unstructured resource 63 | // is "versioned". A resource is version if its annotation 64 | // `strategy.spinnaker.io/versioned` is set to "true" or if it is of kind 65 | // Pod, ReplicaSet, ConfigMap, or Secret. 66 | // 67 | // See https://spinnaker.io/reference/providers/kubernetes-v2/#workloads for more info. 68 | func IsVersioned(u unstructured.Unstructured) bool { 69 | annotations := u.GetAnnotations() 70 | if annotations != nil { 71 | if _, ok := annotations[AnnotationSpinnakerStrategyVersioned]; ok { 72 | return annotations[AnnotationSpinnakerStrategyVersioned] == "true" 73 | } 74 | } 75 | 76 | kind := strings.ToLower(u.GetKind()) 77 | if strings.EqualFold(kind, "pod") || 78 | strings.EqualFold(kind, "replicaSet") || 79 | strings.EqualFold(kind, "ConfigMap") || 80 | strings.EqualFold(kind, "Secret") { 81 | return true 82 | } 83 | 84 | return false 85 | } 86 | 87 | func IncrementVersion(currentVersion string) SpinnakerVersion { 88 | currentVersionInt, _ := strconv.Atoi(currentVersion) 89 | latestVersionInt := currentVersionInt + 1 90 | 91 | if latestVersionInt > maxLatestVersion { 92 | latestVersionInt = 0 93 | } 94 | 95 | latestVersionShortFormat := strconv.Itoa(latestVersionInt) 96 | latestVersionLongFormat := "" 97 | latestVersionLongFormat = fmt.Sprintf("v%03d", latestVersionInt) 98 | 99 | return SpinnakerVersion{ 100 | Short: latestVersionShortFormat, 101 | Long: latestVersionLongFormat, 102 | } 103 | } 104 | 105 | // NameWithVersion removes the Spinnaker version (trailing '-v###) 106 | // from the name. 107 | func NameWithoutVersion(name string) string { 108 | return matchSpinnakerVersionRegexp.ReplaceAllString(name, "") 109 | } 110 | -------------------------------------------------------------------------------- /internal/map.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // DeleteNilVaules returns the given map with all keys 9 | // with nil values removed. 10 | func DeleteNilValues(m map[string]interface{}) map[string]interface{} { 11 | newMap := make(map[string]interface{}) 12 | 13 | for k, v := range m { 14 | if v != nil { 15 | if isMap(v) { 16 | newMap[k] = DeleteNilValues(v.(map[string]interface{})) 17 | } else { 18 | newMap[k] = v 19 | } 20 | } 21 | } 22 | 23 | return newMap 24 | } 25 | 26 | // isMap returns true if the argument is of type map 27 | // 28 | // see https://stackoverflow.com/questions/20759803/how-to-check-variable-type-is-map-in-go-language 29 | func isMap(x interface{}) bool { 30 | t := fmt.Sprintf("%T", x) 31 | return strings.HasPrefix(t, "map[") 32 | } 33 | -------------------------------------------------------------------------------- /internal/map_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | . "github.com/homedepot/go-clouddriver/internal" 5 | . "github.com/onsi/ginkgo/v2" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("Map", func() { 10 | 11 | var m, actual map[string]interface{} 12 | 13 | Describe("#DeleteNilValues", func() { 14 | 15 | JustBeforeEach(func() { 16 | actual = DeleteNilValues(m) 17 | }) 18 | 19 | When("the map is empty", func() { 20 | BeforeEach(func() { 21 | m = map[string]interface{}{} 22 | }) 23 | 24 | It("returns an empty map", func() { 25 | Expect(actual).To(Equal(m)) 26 | }) 27 | }) 28 | 29 | When("the map has no nil-valued keys", func() { 30 | BeforeEach(func() { 31 | m = map[string]interface{}{ 32 | "a": 1, 33 | "b": true, 34 | "c": "three", 35 | } 36 | }) 37 | 38 | It("returns the map unchanged", func() { 39 | Expect(actual).To(Equal(m)) 40 | }) 41 | }) 42 | 43 | When("the map has nil-valued keys", func() { 44 | BeforeEach(func() { 45 | m = map[string]interface{}{ 46 | "a": 1, 47 | "b": nil, 48 | "c": "three", 49 | } 50 | }) 51 | 52 | It("succeeds", func() { 53 | expected := map[string]interface{}{ 54 | "a": 1, 55 | "c": "three", 56 | } 57 | Expect(actual).To(Equal(expected)) 58 | }) 59 | }) 60 | 61 | When("the map has nested nil-valued keys", func() { 62 | BeforeEach(func() { 63 | m = map[string]interface{}{ 64 | "a": 1, 65 | "b": "nil", 66 | "c": "three", 67 | "d": map[string]interface{}{ 68 | "e": nil, 69 | "f": "f", 70 | "g": map[string]interface{}{ 71 | "h": nil, 72 | }, 73 | }, 74 | } 75 | }) 76 | 77 | It("succeeds", func() { 78 | expected := map[string]interface{}{ 79 | "a": 1, 80 | "b": "nil", 81 | "c": "three", 82 | "d": map[string]interface{}{ 83 | "f": "f", 84 | "g": map[string]interface{}{}, 85 | }, 86 | } 87 | Expect(actual).To(Equal(expected)) 88 | }) 89 | }) 90 | }) 91 | }) 92 | -------------------------------------------------------------------------------- /internal/middleware/cachecontrol.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // CacheControl sets the 'Cache-Control' header to the defined 10 | // max-age. 11 | func CacheControl(maxAge int) gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | c.Writer.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) 14 | c.Next() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/middleware/cachecontrol_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | 7 | "github.com/gin-gonic/gin" 8 | . "github.com/homedepot/go-clouddriver/internal/middleware" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("CacheControl", func() { 14 | var ( 15 | c *gin.Context 16 | r *http.Request 17 | err error 18 | ) 19 | 20 | BeforeEach(func() { 21 | gin.SetMode(gin.ReleaseMode) 22 | c, _ = gin.CreateTestContext(httptest.NewRecorder()) 23 | r, err = http.NewRequest(http.MethodGet, "", nil) 24 | Expect(err).To(BeNil()) 25 | c.Request = r 26 | }) 27 | 28 | JustBeforeEach(func() { 29 | mw := CacheControl(30) 30 | mw(c) 31 | }) 32 | 33 | It("sets the Cache-Control header", func() { 34 | Expect(c.Writer.Header().Get("Cache-Control")).To(Equal("public, max-age=30")) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /internal/middleware/controller.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/homedepot/go-clouddriver/internal" 4 | 5 | type Controller struct { 6 | *internal.Controller 7 | } 8 | -------------------------------------------------------------------------------- /internal/middleware/error.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 8 | ) 9 | 10 | func HandleError() gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | c.Next() // execute all the handlers 13 | 14 | // If an error occurred during handling the request, write the error as a JSON response. 15 | err := c.Errors.ByType(gin.ErrorTypePublic).Last() 16 | if err != nil { 17 | statusCode := c.Writer.Status() 18 | text := http.StatusText(statusCode) 19 | 20 | if statusCode >= http.StatusInternalServerError { 21 | meta := clouddriver.Meta(err) 22 | clouddriver.Log(err, meta) 23 | text += " (error ID: " + meta.GUID + ")" 24 | } 25 | 26 | ce := clouddriver.NewError( 27 | text, 28 | err.Error(), 29 | statusCode, 30 | ) 31 | 32 | c.JSON(c.Writer.Status(), ce) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/middleware/log.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "log" 11 | "time" 12 | 13 | "github.com/fatih/color" 14 | "github.com/gin-gonic/gin" 15 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 16 | ) 17 | 18 | var ( 19 | bold = color.New(color.FgWhite, color.Bold).SprintFunc() 20 | ) 21 | 22 | // A verbose request/response logger. 23 | func LogRequest() gin.HandlerFunc { 24 | return func(c *gin.Context) { 25 | var ( 26 | err error 27 | buf bytes.Buffer 28 | ) 29 | 30 | clone := c.Request.Clone(context.TODO()) 31 | 32 | _, err = buf.ReadFrom(c.Request.Body) 33 | c.Request.Body = io.NopCloser(&buf) 34 | clone.Body = io.NopCloser(bytes.NewReader(buf.Bytes())) 35 | 36 | if err != nil { 37 | clouddriver.Log(err) 38 | } else { 39 | b, _ := io.ReadAll(clone.Body) 40 | buffer := &bytes.Buffer{} 41 | 42 | buffer.WriteString(bold("REQUEST: ["+time.Now().In(time.UTC).Format(time.RFC3339)) + bold("]")) 43 | buffer.WriteByte('\n') 44 | buffer.WriteString(fmt.Sprintf("%s %s %s", clone.Method, clone.URL, clone.Proto)) 45 | buffer.WriteByte('\n') 46 | buffer.WriteString(fmt.Sprintf("Host: %s", clone.Host)) 47 | buffer.WriteByte('\n') 48 | buffer.WriteString(fmt.Sprintf("Accept: %s", clone.Header.Get("Accept"))) 49 | buffer.WriteByte('\n') 50 | buffer.WriteString(fmt.Sprintf("User-Agent: %s", clone.Header.Get("User-Agent"))) 51 | buffer.WriteByte('\n') 52 | buffer.WriteString(fmt.Sprintf("Headers: %s", clone.Header)) 53 | buffer.WriteByte('\n') 54 | 55 | if len(b) > 0 { 56 | j, err := json.MarshalIndent(b, "", " ") 57 | if err != nil { 58 | log.Fatal("[MIDDLEWARE] failed to generate json", err) 59 | } else { 60 | body, _ := base64.StdEncoding.DecodeString(string(j[1 : len(j)-1])) 61 | buffer.Write(body) 62 | buffer.WriteByte('\n') 63 | } 64 | } 65 | 66 | fmt.Println(buffer.String()) 67 | } 68 | 69 | c.Next() // execute all the handlers 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/middleware/middleware_suite_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestMiddleware(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Middleware Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/middleware/task.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/google/uuid" 6 | clouddriver "github.com/homedepot/go-clouddriver/pkg" 7 | ) 8 | 9 | // TaskID attaches a unique GUID to the context. 10 | func TaskID() gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | c.Set(clouddriver.TaskIDKey, uuid.New().String()) 13 | c.Next() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/middleware/vary.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | // Vary sets the 'Vary' header to the defined headers. 10 | func Vary(headers ...string) gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | c.Writer.Header().Set("Vary", strings.Join(headers, ", ")) 13 | c.Next() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/middleware/vary_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | 7 | "github.com/gin-gonic/gin" 8 | . "github.com/homedepot/go-clouddriver/internal/middleware" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Vary", func() { 14 | var ( 15 | c *gin.Context 16 | r *http.Request 17 | err error 18 | ) 19 | 20 | BeforeEach(func() { 21 | gin.SetMode(gin.ReleaseMode) 22 | c, _ = gin.CreateTestContext(httptest.NewRecorder()) 23 | r, err = http.NewRequest(http.MethodGet, "", nil) 24 | Expect(err).To(BeNil()) 25 | c.Request = r 26 | }) 27 | 28 | JustBeforeEach(func() { 29 | mw := Vary("header1", "header2") 30 | mw(c) 31 | }) 32 | 33 | It("sets the Vary header", func() { 34 | Expect(c.Writer.Header().Get("Vary")).To(Equal("header1, header2")) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /internal/sql/sql_suite_test.go: -------------------------------------------------------------------------------- 1 | package sql_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestSql(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Sql Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/time.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func CurrentTimeUTC() time.Time { 8 | utc := time.Now().UTC().Format("2006-01-02T15:04:05.999Z") 9 | t, _ := time.Parse("2006-01-02T15:04:05.999Z", utc) 10 | 11 | return t 12 | } 13 | -------------------------------------------------------------------------------- /internal/time_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/homedepot/go-clouddriver/internal" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var _ = Describe("Time", func() { 12 | It("formats and sets the time to UTC", func() { 13 | now := time.Now().UTC().Format("2006-01-02T15:04:05.999Z") 14 | t, _ := time.Parse("2006-01-02T15:04:05.999Z", now) 15 | utc := CurrentTimeUTC() 16 | 17 | Expect(utc).To(BeTemporally("~", t)) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /pkg/artifact.go: -------------------------------------------------------------------------------- 1 | package clouddriver 2 | 3 | import "github.com/homedepot/go-clouddriver/internal/artifact" 4 | 5 | type Artifact struct { 6 | CustomKind bool `json:"customKind"` 7 | Location string `json:"location,omitempty"` 8 | Metadata ArtifactMetadata `json:"metadata"` 9 | Name string `json:"name"` 10 | Reference string `json:"reference"` 11 | Type artifact.Type `json:"type"` 12 | Version string `json:"version,omitempty"` 13 | } 14 | 15 | type ArtifactMetadata struct { 16 | Account string `json:"account,omitempty"` 17 | } 18 | -------------------------------------------------------------------------------- /pkg/credentials.go: -------------------------------------------------------------------------------- 1 | package clouddriver 2 | 3 | type Credentials struct { 4 | AccountType string `json:"accountType"` 5 | CacheThreads int `json:"cacheThreads"` 6 | ChallengeDestructiveActions bool `json:"challengeDestructiveActions"` 7 | CloudProvider string `json:"cloudProvider"` 8 | DockerRegistries []interface{} `json:"dockerRegistries"` 9 | Enabled bool `json:"enabled"` 10 | Environment string `json:"environment"` 11 | Name string `json:"name"` 12 | Namespaces []string `json:"namespaces"` 13 | Permissions struct { 14 | READ []string `json:"READ"` 15 | WRITE []string `json:"WRITE"` 16 | } `json:"permissions"` 17 | PrimaryAccount bool `json:"primaryAccount"` 18 | ProviderVersion string `json:"providerVersion"` 19 | RequiredGroupMembership []interface{} `json:"requiredGroupMembership"` 20 | Skin string `json:"skin"` 21 | SpinnakerKindMap map[string]string `json:"spinnakerKindMap"` 22 | Type string `json:"type"` 23 | } 24 | -------------------------------------------------------------------------------- /pkg/error.go: -------------------------------------------------------------------------------- 1 | package clouddriver 2 | 3 | import ( 4 | "runtime" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | // This represents a clouddriver error. 12 | type ErrorResponse struct { 13 | Error string `json:"error"` 14 | Message string `json:"message"` 15 | Status int `json:"status"` 16 | Timestamp int64 `json:"timestamp"` 17 | } 18 | 19 | type ErrorMeta struct { 20 | FuncName string 21 | FileName string 22 | GUID string 23 | LineNum int 24 | } 25 | 26 | // Example. 27 | // 28 | // { 29 | // "error":"Forbidden", 30 | // "message":"Access denied to account spin-cluster-account - required authorization: READ", 31 | // "status":403, 32 | // "timestamp":1597608027851 33 | // } 34 | func NewError(err, message string, status int) ErrorResponse { 35 | return ErrorResponse{ 36 | Error: err, 37 | Message: message, 38 | Status: status, 39 | Timestamp: time.Now().UnixNano() / 1000000, 40 | } 41 | } 42 | 43 | // Error attaches a given Go error to a gin context and sets its type to public. 44 | func Error(c *gin.Context, status int, err error) { 45 | pc, fn, ln, _ := runtime.Caller(1) 46 | m := ErrorMeta{ 47 | FuncName: runtime.FuncForPC(pc).Name(), 48 | FileName: fn, 49 | GUID: uuid.New().String(), 50 | LineNum: ln, 51 | } 52 | 53 | c.Status(status) 54 | _ = c.Error(err).SetType(gin.ErrorTypePublic).SetMeta(m) 55 | } 56 | 57 | func Meta(msg *gin.Error) ErrorMeta { 58 | return msg.Meta.(ErrorMeta) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/logger.go: -------------------------------------------------------------------------------- 1 | package clouddriver 2 | 3 | import ( 4 | "log" 5 | "runtime" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const ( 12 | white = "\033[90;47m" 13 | reset = "\033[0m" 14 | ) 15 | 16 | // Log logs a given error. It checks if meta was passed in. If 17 | // no meta was passed in, it defines the meta as the function and 18 | // line number of what called the Log func. 19 | // 20 | // See https://stackoverflow.com/questions/24809287/how-do-you-get-a-golang-program-to-print-the-line-number-of-the-error-it-just-ca 21 | func Log(err error, meta ...ErrorMeta) { 22 | if len(meta) == 0 { 23 | // notice that we're using 1, so it will actually log the where 24 | // the error happened, 0 = this function, we don't want that. 25 | pc, fn, ln, _ := runtime.Caller(1) 26 | m := ErrorMeta{ 27 | FuncName: runtime.FuncForPC(pc).Name(), 28 | FileName: fn, 29 | GUID: uuid.New().String(), 30 | LineNum: ln, 31 | } 32 | meta = append(meta, m) 33 | } 34 | 35 | m := meta[0] 36 | 37 | log.SetFlags(0) 38 | log.Printf("[CLOUDDRIVER] %v |%s %s %s| %s | %s | %s:%d | %v\n", 39 | time.Now().In(time.UTC).Format("2006/01/02 - 15:04:05"), 40 | white, "LOG", reset, 41 | m.GUID, 42 | m.FuncName, 43 | m.FileName, 44 | m.LineNum, 45 | err, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/permissions.go: -------------------------------------------------------------------------------- 1 | package clouddriver 2 | 3 | type Permissions struct { 4 | READ []string `json:"READ"` 5 | WRITE []string `json:"WRITE"` 6 | } 7 | 8 | type ReadPermission struct { 9 | ID string `json:"-" gorm:"primary_key"` 10 | AccountName string `json:"accountName" gorm:"index:account_name_idx"` 11 | ReadGroup string `json:"readGroup"` 12 | } 13 | 14 | func (ReadPermission) TableName() string { 15 | return "provider_read_permissions" 16 | } 17 | 18 | type WritePermission struct { 19 | ID string `json:"-" gorm:"primary_key"` 20 | AccountName string `json:"accountName" gorm:"index:account_name_idx"` 21 | WriteGroup string `json:"writeGroup"` 22 | } 23 | 24 | func (WritePermission) TableName() string { 25 | return "provider_write_permissions" 26 | } 27 | -------------------------------------------------------------------------------- /pkg/task.go: -------------------------------------------------------------------------------- 1 | package clouddriver 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | const ( 8 | TaskIDKey = `TaskID` 9 | TaskTypeCleanup = `cleanup` 10 | TaskTypeDelete = `delete` 11 | TaskTypeNoOp = `noop` 12 | ) 13 | 14 | func NewDefaultTask(id string) Task { 15 | return Task{ 16 | ID: id, 17 | ResultObjects: []TaskResultObject{}, 18 | Status: TaskStatus{ 19 | Complete: true, 20 | Completed: true, 21 | Failed: false, 22 | Phase: "ORCHESTRATION", 23 | Retryable: false, 24 | Status: "Orchestration completed.", 25 | }, 26 | } 27 | } 28 | 29 | func TaskIDFromContext(c *gin.Context) string { 30 | return c.MustGet(TaskIDKey).(string) 31 | } 32 | 33 | type Task struct { 34 | ID string `json:"id"` 35 | // SagaIds []interface{} `json:"sagaIds"` 36 | // History []struct { 37 | // Phase string `json:"phase"` 38 | // Status string `json:"status"` 39 | // } `json:"history"` 40 | // OwnerIDClouddriverSQL string `json:"ownerId$clouddriver_sql"` 41 | // RequestIDClouddriverSQL string `json:"requestId$clouddriver_sql"` 42 | // Retryable bool `json:"retryable"` 43 | // StartTimeMsClouddriverSQL int64 `json:"startTimeMs$clouddriver_sql"` 44 | ResultObjects []TaskResultObject `json:"resultObjects"` 45 | Status TaskStatus `json:"status"` 46 | } 47 | 48 | type TaskStatus struct { 49 | Complete bool `json:"complete"` 50 | Completed bool `json:"completed"` 51 | Failed bool `json:"failed"` 52 | Phase string `json:"phase"` 53 | Retryable bool `json:"retryable"` 54 | Status string `json:"status"` 55 | } 56 | 57 | type TaskResultObject struct { 58 | BoundArtifacts []Artifact `json:"boundArtifacts"` 59 | CreatedArtifacts []Artifact `json:"createdArtifacts"` 60 | DeployedNamesByLocation map[string][]string `json:"deployedNamesByLocation"` 61 | ManifestNamesByNamespace map[string][]string `json:"manifestNamesByNamespace"` 62 | ManifestNamesByNamespaceToRefresh map[string][]string `json:"manifestNamesByNamespaceToRefresh"` 63 | Manifests []map[string]interface{} `json:"manifests"` 64 | } 65 | --------------------------------------------------------------------------------