├── .dockerignore ├── .github ├── release.yml └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── Dockerfile.repo-checker ├── Dockerfile.ui ├── LICENSE ├── Makefile ├── PROJECT ├── README.md ├── Tiltfile ├── api └── v1beta1 │ ├── groupversion_info.go │ ├── website_types.go │ └── zz_generated.deepcopy.go ├── aqua.yaml ├── charts └── website-operator │ ├── .helmignore │ ├── Chart.yaml │ ├── crds │ └── website-crd.yaml │ ├── templates │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── leader-election-rbac.yaml │ ├── manager-config.yaml │ ├── manager-rbac.yaml │ ├── ui-rbac.yaml │ └── ui.yaml │ └── values.yaml ├── checker ├── checker.go └── checker_test.go ├── cluster.yaml ├── cmd ├── repo-checker │ ├── cmd │ │ ├── root.go │ │ └── run.go │ └── main.go ├── website-operator-ui │ ├── cmd │ │ ├── root.go │ │ └── run.go │ └── main.go └── website-operator │ ├── cmd │ ├── root.go │ └── run.go │ └── main.go ├── config ├── certmanager │ ├── certificate.yaml │ ├── kustomization.yaml │ └── kustomizeconfig.yaml ├── crd │ ├── bases │ │ └── website.zoetrope.github.io_websites.yaml │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ └── patches │ │ ├── cainjection_in_websites.yaml │ │ └── webhook_in_websites.yaml ├── default │ ├── kustomization.yaml │ ├── manager_auth_proxy_patch.yaml │ └── manager_config_patch.yaml ├── dev │ ├── kustomization.yaml │ ├── manager.yaml │ └── ui.yaml ├── manager │ ├── controller_manager_config.yaml │ ├── kustomization.yaml │ └── manager.yaml ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── rbac │ ├── auth_proxy_client_clusterrole.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── kustomization.yaml │ ├── leader_election_role.yaml │ ├── leader_election_role_binding.yaml │ ├── manager_role_binding.yaml │ ├── role.yaml │ ├── ui_role.yaml │ ├── ui_role_binding.yaml │ ├── website_editor_role.yaml │ └── website_viewer_role.yaml ├── release │ ├── kustomization.yaml │ ├── manager.yaml │ └── ui.yaml ├── samples │ ├── config.yaml │ ├── docusaurus.yaml │ └── honkit.yaml └── ui │ ├── kustomization.yaml │ ├── service.yaml │ └── ui.yaml ├── constants.go ├── controllers ├── common.go ├── revision_watcher.go ├── suite_test.go ├── website_controller.go └── website_controller_test.go ├── cr.yaml ├── e2e ├── Makefile ├── bootstrap_test.go ├── kind-config.yaml ├── manifests │ ├── manager │ │ ├── after-build-honkit.sh │ │ ├── build-gatsby.sh │ │ ├── build-honkit.sh │ │ ├── build-mkdocs.sh │ │ ├── create-honkit-es-index.sh │ │ ├── httpproxy-es.tmpl │ │ ├── httpproxy.tmpl │ │ ├── kustomization.yaml │ │ ├── manager.yaml │ │ ├── rbac.yaml │ │ └── ui.yaml │ ├── sample │ │ ├── build-secret.yaml │ │ ├── elasticsearch.yaml │ │ ├── honkit-es.yaml │ │ └── kustomization.yaml │ └── website │ │ ├── .ssh │ │ ├── .gitignore │ │ └── config │ │ ├── gatsby.yaml │ │ ├── honkit.yaml │ │ ├── kustomization.yaml │ │ ├── mkdocs.yaml │ │ └── pvc.yaml ├── suite_test.go └── update_test.go ├── go.mod ├── go.sum ├── hack └── boilerplate.go.txt ├── renovate.json5 ├── screenshot.png ├── ui ├── backend │ └── server.go └── frontend │ ├── .env.development │ ├── .gitignore │ ├── .postcssrc │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── app.css │ ├── app.js │ └── index.html │ └── tailwind.config.js └── version.go /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/.git 3 | **/.ssh 4 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | - ci 6 | - documentation 7 | - refactoring 8 | - test 9 | categories: 10 | - title: Features 11 | labels: 12 | - enhancement 13 | - title: Bug Fixes 14 | labels: 15 | - bug 16 | - title: Deprecated 17 | labels: 18 | - deprecate 19 | - title: Removed 20 | labels: 21 | - remove 22 | - title: Security 23 | labels: 24 | - security 25 | - title: Dependencies 26 | labels: 27 | - dependencies 28 | - title: Others 29 | labels: 30 | - "*" 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - 'main' 7 | jobs: 8 | test: 9 | name: Small test 10 | runs-on: ubuntu-24.04 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version-file: go.mod 16 | - uses: aquaproj/aqua-installer@5e54e5cee8a95ee2ce7c04cb993da6dfad13e59c # v3.1.2 17 | with: 18 | aqua_version: v2.50.0 19 | aqua_opts: "" 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | - run: | 23 | cat > ./e2e/manifests/website/.ssh/id_rsa < ./e2e/manifests/website/.ssh/id_rsa <> $GITHUB_ENV 116 | - name: GoReleaser 117 | uses: goreleaser/goreleaser-action@v6 118 | with: 119 | version: v2.3.2 120 | args: --snapshot --skip=publish --clean 121 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | release: 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - uses: docker/setup-qemu-action@v3 15 | - uses: docker/setup-buildx-action@v3 16 | - name: GHCR Login 17 | uses: docker/login-action@v3 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.repository_owner }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version-file: go.mod 25 | - uses: aquaproj/aqua-installer@5e54e5cee8a95ee2ce7c04cb993da6dfad13e59c # v3.1.2 26 | with: 27 | aqua_version: v2.50.0 28 | aqua_opts: "" 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | - name: Set previous release tag for GoReleaser 32 | run: | 33 | export TAG=$(curl -s "https://api.github.com/repos/zoetrope/website-operator/releases/latest" | jq -r .tag_name) 34 | echo "GORELEASER_PREVIOUS_TAG=${TAG}" >> $GITHUB_ENV 35 | - name: GoReleaser 36 | uses: goreleaser/goreleaser-action@v6 37 | with: 38 | version: v2.3.2 39 | args: release --clean 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | chart-release: 43 | runs-on: ubuntu-24.04 44 | needs: release 45 | if: contains(needs.release.result, 'success') 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v4 49 | with: 50 | fetch-depth: 0 51 | - name: Set chart version 52 | run: | 53 | helm repo add website-operator https://zoetrope.github.io/website-operator 54 | helm repo update 55 | 56 | # get the release tag version 57 | tag_version=${GITHUB_REF##*/v} 58 | 59 | # get the latest chart version 60 | chart_version=$(helm search repo website-operator -o json | jq -r 'sort_by(.version) | .[-1].version') 61 | chart_patch_version=${chart_version##*.} 62 | new_patch_version=$(($chart_patch_version+1)) 63 | 64 | # if minor or major version changed, reset new patch version 65 | local_version=$(cat charts/website-operator/Chart.yaml | yq .version | sed "s/0-chart-patch-version-placeholder/$chart_patch_version/g") 66 | [ "$local_version" != "$chart_version" ] && new_patch_version=0 67 | 68 | # replace placeholder with new version 69 | sed --in-place "s/app-version-placeholder/$tag_version/g" charts/website-operator/Chart.yaml 70 | sed --in-place "s/0-chart-patch-version-placeholder/$new_patch_version/g" charts/website-operator/Chart.yaml 71 | sed --in-place "s/app-version-placeholder/$tag_version/g" charts/website-operator/values.yaml 72 | - name: Create release notes 73 | run: | 74 | tag_version=${GITHUB_REF##*/} 75 | cat < ./charts/website-operator/RELEASE.md 76 | Helm chart for Website Operator [$tag_version](https://github.com/zoetrope/website-operator/releases/tag/$tag_version) 77 | 78 | EOF 79 | - name: Configure Git 80 | run: | 81 | git config user.name "$GITHUB_ACTOR" 82 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 83 | - name: Install Helm 84 | uses: azure/setup-helm@v4 85 | - name: Run chart-releaser 86 | uses: helm/chart-releaser-action@v1.7.0 87 | with: 88 | config: cr.yaml 89 | env: 90 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | /.idea 17 | bin 18 | /vendor 19 | build 20 | tmp 21 | testbin/* 22 | tilt_modules 23 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: website-operator 3 | dist: bin/ 4 | release: 5 | skip_upload: true 6 | builds: 7 | - id: website-operator 8 | env: 9 | - CGO_ENABLED=0 10 | main: ./cmd/website-operator 11 | binary: website-operator 12 | goos: 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | ldflags: 18 | - -X github.com/zoetrope/website-operator.Version={{.Version}} 19 | - id: website-operator-ui 20 | env: 21 | - CGO_ENABLED=0 22 | main: ./cmd/website-operator-ui 23 | binary: website-operator-ui 24 | goos: 25 | - linux 26 | goarch: 27 | - amd64 28 | - arm64 29 | ldflags: 30 | - -X github.com/zoetrope/website-operator.Version={{.Version}} 31 | - id: repo-checker 32 | env: 33 | - CGO_ENABLED=0 34 | main: ./cmd/repo-checker 35 | binary: repo-checker 36 | goos: 37 | - linux 38 | goarch: 39 | - amd64 40 | - arm64 41 | ldflags: 42 | - -X github.com/zoetrope/website-operator.Version={{.Version}} 43 | before: 44 | hooks: 45 | - make frontend 46 | dockers: 47 | - image_templates: 48 | - "ghcr.io/zoetrope/website-operator:{{ .Version }}-amd64" 49 | use: buildx 50 | dockerfile: ./Dockerfile 51 | ids: 52 | - website-operator 53 | extra_files: 54 | - LICENSE 55 | build_flag_templates: 56 | - "--platform=linux/amd64" 57 | - "--label=org.opencontainers.image.created={{.Date}}" 58 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 59 | - "--label=org.opencontainers.image.version={{.Version}}" 60 | - image_templates: 61 | - "ghcr.io/zoetrope/website-operator:{{ .Version }}-arm64" 62 | use: buildx 63 | dockerfile: ./Dockerfile 64 | ids: 65 | - website-operator 66 | extra_files: 67 | - LICENSE 68 | build_flag_templates: 69 | - "--platform=linux/arm64" 70 | - "--label=org.opencontainers.image.created={{.Date}}" 71 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 72 | - "--label=org.opencontainers.image.version={{.Version}}" 73 | - image_templates: 74 | - "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}-amd64" 75 | use: buildx 76 | dockerfile: ./Dockerfile.ui 77 | ids: 78 | - website-operator-ui 79 | extra_files: 80 | - LICENSE 81 | - ui/frontend/dist 82 | build_flag_templates: 83 | - "--platform=linux/amd64" 84 | - "--label=org.opencontainers.image.created={{.Date}}" 85 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 86 | - "--label=org.opencontainers.image.version={{.Version}}" 87 | - image_templates: 88 | - "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}-arm64" 89 | use: buildx 90 | dockerfile: ./Dockerfile.ui 91 | ids: 92 | - website-operator-ui 93 | extra_files: 94 | - LICENSE 95 | - ui/frontend/dist 96 | build_flag_templates: 97 | - "--platform=linux/arm64" 98 | - "--label=org.opencontainers.image.created={{.Date}}" 99 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 100 | - "--label=org.opencontainers.image.version={{.Version}}" 101 | - image_templates: 102 | - "ghcr.io/zoetrope/repo-checker:{{ .Version }}-amd64" 103 | use: buildx 104 | dockerfile: ./Dockerfile.repo-checker 105 | ids: 106 | - repo-checker 107 | extra_files: 108 | - LICENSE 109 | build_flag_templates: 110 | - "--platform=linux/amd64" 111 | - "--label=org.opencontainers.image.created={{.Date}}" 112 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 113 | - "--label=org.opencontainers.image.version={{.Version}}" 114 | - image_templates: 115 | - "ghcr.io/zoetrope/repo-checker:{{ .Version }}-arm64" 116 | use: buildx 117 | dockerfile: ./Dockerfile.repo-checker 118 | ids: 119 | - repo-checker 120 | extra_files: 121 | - LICENSE 122 | build_flag_templates: 123 | - "--platform=linux/arm64" 124 | - "--label=org.opencontainers.image.created={{.Date}}" 125 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 126 | - "--label=org.opencontainers.image.version={{.Version}}" 127 | docker_manifests: 128 | - name_template: "ghcr.io/zoetrope/website-operator:{{ .Version }}" 129 | image_templates: 130 | - "ghcr.io/zoetrope/website-operator:{{ .Version }}-amd64" 131 | - "ghcr.io/zoetrope/website-operator:{{ .Version }}-arm64" 132 | - name_template: "ghcr.io/zoetrope/website-operator:{{ .Major }}.{{ .Minor }}" 133 | image_templates: 134 | - "ghcr.io/zoetrope/website-operator:{{ .Version }}-amd64" 135 | - "ghcr.io/zoetrope/website-operator:{{ .Version }}-arm64" 136 | - name_template: "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}" 137 | image_templates: 138 | - "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}-amd64" 139 | - "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}-arm64" 140 | - name_template: "ghcr.io/zoetrope/website-operator-ui:{{ .Major }}.{{ .Minor }}" 141 | image_templates: 142 | - "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}-amd64" 143 | - "ghcr.io/zoetrope/website-operator-ui:{{ .Version }}-arm64" 144 | - name_template: "ghcr.io/zoetrope/repo-checker:{{ .Version }}" 145 | image_templates: 146 | - "ghcr.io/zoetrope/repo-checker:{{ .Version }}-amd64" 147 | - "ghcr.io/zoetrope/repo-checker:{{ .Version }}-arm64" 148 | - name_template: "ghcr.io/zoetrope/repo-checker:{{ .Major }}.{{ .Minor }}" 149 | image_templates: 150 | - "ghcr.io/zoetrope/repo-checker:{{ .Version }}-amd64" 151 | - "ghcr.io/zoetrope/repo-checker:{{ .Version }}-arm64" 152 | checksum: 153 | name_template: 'checksums.txt' 154 | snapshot: 155 | version_template: "dev" 156 | changelog: 157 | use: github-native 158 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/zoetrope/ubuntu:20.04 2 | 3 | LABEL org.opencontainers.image.source https://github.com/zoetrope/website-operator 4 | 5 | COPY website-operator / 6 | 7 | USER 10000:10000 8 | 9 | ENTRYPOINT ["/website-operator"] 10 | -------------------------------------------------------------------------------- /Dockerfile.repo-checker: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/zoetrope/ubuntu:20.04 2 | 3 | LABEL org.opencontainers.image.source https://github.com/zoetrope/website-operator 4 | 5 | COPY repo-checker / 6 | 7 | USER 10000:10000 8 | 9 | ENTRYPOINT ["/repo-checker"] 10 | -------------------------------------------------------------------------------- /Dockerfile.ui: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/zoetrope/ubuntu:20.04 2 | 3 | LABEL org.opencontainers.image.source https://github.com/zoetrope/website-operator 4 | 5 | COPY ui/frontend/dist /dist 6 | COPY website-operator-ui / 7 | 8 | USER 10000:10000 9 | 10 | ENTRYPOINT ["/website-operator-ui"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Akihiro Ikezoe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | TAG ?= latest 3 | CRD_OPTIONS = "crd:crdVersions=v1" 4 | 5 | BIN_DIR := $(shell pwd)/bin 6 | 7 | WEBSITE_OPERATOR = bin/website-operator 8 | REPO_CHECKER = bin/repo-checker 9 | WEBSITE_OPERATOR_UI = bin/website-operator-ui 10 | GO_FILES := $(shell find . -type f -name '*.go') 11 | GOOS := $(shell go env GOOS) 12 | GOARCH := $(shell go env GOARCH) 13 | 14 | 15 | all: $(WEBSITE_OPERATOR) $(REPO_CHECKER) $(WEBSITE_OPERATOR_UI) 16 | 17 | ##@ General 18 | 19 | # The help target prints out all targets with their descriptions organized 20 | # beneath their categories. The categories are represented by '##@' and the 21 | # target descriptions by '##'. The awk commands is responsible for reading the 22 | # entire set of makefiles included in this invocation, looking for lines of the 23 | # file as xyz: ## something, and then pretty-format the target and help. Then, 24 | # if there's a line with ##@ something, that gets pretty-printed as a category. 25 | # More info on the usage of ANSI control characters for terminal formatting: 26 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 27 | # More info on the awk command: 28 | # http://linuxcommand.org/lc3_adv_awk.php 29 | 30 | help: ## Display this help. 31 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 32 | 33 | ##@ Development 34 | 35 | .PHONY: manifests 36 | manifests: ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 37 | controller-gen $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases 38 | kustomize build config/crd | yq e "." - > charts/website-operator/crds/website-crd.yaml 39 | 40 | .PHONY: generate-chart 41 | generate-chart: 42 | kustomize build config/release | helmify -crd-dir charts/website-operator 43 | 44 | .PHONY: generate 45 | generate: ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 46 | controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..." 47 | 48 | .PHONY: install 49 | install: manifests ## Install CRDs into the K8s cluster specified in ~/.kube/config. 50 | kustomize build config/crd | kubectl apply -f - 51 | 52 | .PHONY: uninstall 53 | uninstall: manifests ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 54 | kustomize build config/crd | kubectl delete --ignore-not-found=$(ignore-not-found) -f - 55 | 56 | fmt: ## Run go fmt against code. 57 | go fmt ./... 58 | 59 | vet: ## Run go vet against code. 60 | go vet ./... 61 | 62 | .PHONY: test 63 | test: manifests generate setup-envtest ## fmt vet ## Run tests. 64 | source <($(SETUP_ENVTEST) use -p env); go test -v -count 1 ./... 65 | 66 | .PHONY: dev 67 | dev: 68 | ctlptl apply -f ./cluster.yaml 69 | $(MAKE) -C ./e2e/ setup-cluster 70 | 71 | .PHONY: stop-dev 72 | stop-dev: 73 | ctlptl delete -f ./cluster.yaml 74 | 75 | ##@ Build 76 | 77 | $(WEBSITE_OPERATOR): $(GO_FILES) generate 78 | mkdir -p bin 79 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/website-operator 80 | 81 | $(REPO_CHECKER): $(GO_FILES) 82 | mkdir -p bin 83 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/repo-checker 84 | 85 | $(WEBSITE_OPERATOR_UI): $(GO_FILES) 86 | mkdir -p bin 87 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/website-operator-ui 88 | 89 | .PHONY: frontend 90 | frontend: 91 | cd ui/frontend && npm install && npm run build 92 | 93 | .PHONY: setup 94 | setup: setup-envtest 95 | 96 | SETUP_ENVTEST := $(BIN_DIR)/setup-envtest 97 | .PHONY: setup-envtest 98 | setup-envtest: ## Download setup-envtest locally if necessary 99 | # see https://github.com/kubernetes-sigs/controller-runtime/tree/master/tools/setup-envtest 100 | GOBIN=$(BIN_DIR) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest 101 | 102 | .PHONY: clean 103 | clean: 104 | rm -rf ./bin 105 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: zoetrope.github.io 2 | layout: go.kubebuilder.io/v3 3 | projectName: website-operator 4 | repo: github.com/zoetrope/website-operator 5 | resources: 6 | - api: 7 | crdVersion: v1 8 | controller: true 9 | domain: zoetrope.github.io 10 | group: website 11 | kind: webSite 12 | path: github.com/zoetrope/website-operator/api/v1 13 | version: v1beta1 14 | version: "3" 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub release](https://img.shields.io/github/release/zoetrope/website-operator.svg?maxAge=60)](https://github.com/zoetrope/website-operator/releases) 2 | [![CI](https://github.com/zoetrope/website-operator/actions/workflows/ci.yaml/badge.svg)](https://github.com/zoetrope/website-operator/actions/workflows/ci.yaml) 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/zoetrope/website-operator?tab=overview)](https://pkg.go.dev/github.com/zoetrope/website-operator?tab=overview) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/zoetrope/website-operator)](https://goreportcard.com/report/github.com/zoetrope/website-operator) 5 | [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com/) 6 | 7 | # website-operator 8 | 9 | WebSite Operator allows easy deployment of web sites generated by static site generators such as [HonKit](https://honkit.netlify.app), [MkDocs](https://www.mkdocs.org), [Gatsby](https://www.gatsbyjs.com) and so on. 10 | 11 | ## Installation 12 | 13 | All resources (Namespace, CustomResourceDefinitions, Deployment and RBACs) are included in a single manifest file. 14 | You can just install the manifest as follows: 15 | 16 | ```console 17 | helm repo add website-operator https://zoetrope.github.io/website-operator 18 | helm repo update 19 | helm install --create-namespace --namespace website-operator-system website-operator website-operator/website-operator 20 | ``` 21 | 22 | ## Usage 23 | 24 | First, you need to prepare a repository of the content you want to deploy. 25 | 26 | Here's some examples: 27 | 28 | - [https://github.com/zoetrope/honkit-sample](https://github.com/zoetrope/honkit-sample) 29 | - [https://github.com/gatsbyjs/gatsby-starter-default](https://github.com/gatsbyjs/gatsby-starter-default) 30 | 31 | To deploy a site to a Kubernetes cluster you can use the following CustomResource: 32 | 33 | ```yaml 34 | apiVersion: website.zoetrope.github.io/v1beta1 35 | kind: WebSite 36 | metadata: 37 | name: honkit-sample 38 | namespace: default 39 | spec: 40 | buildImage: ghcr.io/zoetrope/node:18.12.1 41 | buildScript: 42 | rawData: | 43 | #!/bin/bash -ex 44 | cd $HOME 45 | rm -rf $REPO_NAME 46 | git clone $REPO_URL 47 | cd $REPO_NAME 48 | git checkout $REVISION 49 | npm install 50 | npm run build 51 | rm -rf $OUTPUT/* 52 | cp -r _book/* $OUTPUT/ 53 | afterBuildScript: 54 | rawData: | 55 | #!/bin/bash -ex 56 | curl -k -X POST https://elasticsearch:9200/test/_bulk -H 'Content-Type: application/json' --data-binary @index.json 57 | 58 | repoURL: https://github.com/zoetrope/honkit-sample.git 59 | branch: main 60 | ``` 61 | 62 | You can specify the following fields: 63 | 64 | | Name | Required | Description | 65 | | ------------------- | -------- | --------------------------------------------------------------------------------------- | 66 | | buildImage | `true` | The name of a container image to build your site | 67 | | buildScript | `true` | A script to build your site | 68 | | repoURL | `true` | The URL of a repository that holds your site's content | 69 | | branch | `true` | The branch of the repository you want to deploy | 70 | | deployKeySecretName | `false` | The name of a secret resource that holds a deploy key to access your private repository | 71 | | extraResources | `false` | Any extra resources you want to deploy | 72 | | replicas | `false` | The number of nginx instances | 73 | | afterBuildScript | `false` | A script to execute in Job once after build (ex. registering search index) | 74 | 75 | In the build script, you have to copy your built output to `$OUTPUT` directory. 76 | 77 | The following environment variables are available in the build script: 78 | 79 | | Name | Description | 80 | | --------- | -------------------------------------------- | 81 | | HOME | Working directory | 82 | | REPO_NAME | The name of a repository | 83 | | REPO_URL | The URL of a repository | 84 | | REVISION | The revision of a repository you will deploy | 85 | | OUTPUT | The name of a directory to put your output | 86 | 87 | ### Build Script and After Build Script as ConfigMap resource 88 | 89 | You can also define a build script and after build script as ConfigMap resource. 90 | 91 | Prepare a build script like bellow: 92 | 93 | ```bash 94 | #!/bin/bash -ex 95 | cd $HOME 96 | rm -rf $REPO_NAME 97 | git clone $REPO_URL 98 | cd $REPO_NAME 99 | git checkout $REVISION 100 | npm install 101 | npm run build 102 | rm -rf $OUTPUT/* 103 | cp -r _book/* $OUTPUT/ 104 | ``` 105 | 106 | Create a ConfigMap resource in the same namespace as website-operator by the following command: 107 | 108 | ```console 109 | kubectl create -n website-operator-system configmap build-scripts --from-file=/path/to/build.sh 110 | ``` 111 | 112 | You can specify `buildScript` field as follows: 113 | 114 | ```yaml 115 | apiVersion: website.zoetrope.github.io/v1beta1 116 | kind: WebSite 117 | metadata: 118 | name: honkit-sample 119 | namespace: default 120 | spec: 121 | buildImage: ghcr.io/zoetrope/node:18.12.1 122 | buildScript: 123 | configMap: 124 | name: build-scripts 125 | key: build.sh 126 | repoURL: https://github.com/zoetrope/honkit-sample.git 127 | branch: main 128 | ``` 129 | 130 | You can setting `afterBuildScript` by above procedure 131 | 132 | ### Build Images 133 | 134 | The following containers are provided to build your sites. 135 | 136 | - [Ubuntu](https://github.com/users/zoetrope/packages/container/package/ubuntu) 137 | - [Node](https://github.com/users/zoetrope/packages/container/package/node) 138 | - [Python](https://github.com/users/zoetrope/packages/container/package/python) 139 | 140 | If you want to customize a container image to generate your site, I recommend that you create a container image based on [Ubuntu](https://github.com/users/zoetrope/packages/container/package/ubuntu). 141 | 142 | ### Private Repository 143 | 144 | You can use deploy key to deploy a content of your private repository. 145 | 146 | Follow the page to generate keys and configure your repository: 147 | 148 | [Managing deploy keys - GitHub Docs](https://docs.github.com/en/free-pro-team@latest/developers/overview/managing-deploy-keys) 149 | 150 | Prepare a private key file (`id_rsa`) and `config` file like below: 151 | 152 | ```text 153 | Host github.com 154 | HostName github.com 155 | User git 156 | UserKnownHostsFile /dev/null 157 | StrictHostKeyChecking no 158 | ``` 159 | 160 | Create a secret resource in the same namespace as WebSite resource by the following command: 161 | 162 | ```console 163 | kubectl create -n default secret generic your-deploy-key --from-file=id_rsa=/path/to/.ssh/id_rsa --from-file=config=/path/to/.ssh/config 164 | ``` 165 | 166 | You can specify `deployKeySecretName` field as follows: 167 | 168 | ```yaml 169 | apiVersion: website.zoetrope.github.io/v1beta1 170 | kind: WebSite 171 | metadata: 172 | name: mkdocs-sample 173 | namespace: default 174 | spec: 175 | buildImage: ghcr.io/zoetrope/python:3.9.5 176 | buildScript: 177 | configMap: 178 | name: build-scripts 179 | key: build-mkdocs.sh 180 | repoURL: git@github.com:zoetrope/mkdocs-sample.git 181 | branch: main 182 | deployKeySecretName: your-deploy-key 183 | ``` 184 | 185 | ### Extra Resource 186 | 187 | You can deploy extra resources for your site. 188 | For example, it is useful if you want to deploy [Contour](https://github.com/projectcontour/contour)'s HTTPProxy resource to expose your site to a load balancer. 189 | 190 | Prepare a extra resource template in go's [text/template](https://golang.org/pkg/text/template/) format: 191 | 192 | ```gotemplate 193 | apiVersion: projectcontour.io/v1 194 | kind: HTTPProxy 195 | metadata: 196 | name: {{.ResourceName}} 197 | spec: 198 | virtualhost: 199 | fqdn: {{.ResourceName}}.{{.ResourceNamespace}}.example.com 200 | routes: 201 | - conditions: 202 | - prefix: / 203 | services: 204 | - name: {{.ResourceName}} 205 | port: 8080 206 | ``` 207 | 208 | In the template, you can use the following parameters: 209 | 210 | | Name | Description | 211 | | ----------------- | ------------------------------------- | 212 | | ResourceName | The name of the WebSite resource | 213 | | ResourceNamespace | The namespace of the WebSite resource | 214 | 215 | Create a ConfigMap resource in the same namespace as website-operator by the following command: 216 | 217 | ```console 218 | kubectl create -n website-operator-system configmap your-templates --from-file=/path/to/httpproxy.tmpl 219 | ``` 220 | 221 | You can specify `extraResource` field as follows: 222 | 223 | ```yaml 224 | apiVersion: website.zoetrope.github.io/v1beta1 225 | kind: WebSite 226 | metadata: 227 | name: honkit-sample 228 | namespace: default 229 | spec: 230 | buildImage: ghcr.io/zoetrope/node:18.12.1 231 | buildScript: 232 | configMap: 233 | name: build-scripts 234 | key: build-honkit.sh 235 | repoURL: https://github.com/zoetrope/honkit-sample.git 236 | branch: main 237 | extraResources: 238 | - configMap: 239 | name: your-templates 240 | key: httpproxy.tmpl 241 | ``` 242 | 243 | Note: You need to add permission to website-operator to create extra resources. 244 | For example, to create httpproxy resources, you have to add the following RBACs. 245 | 246 | ```yaml 247 | apiVersion: rbac.authorization.k8s.io/v1 248 | kind: ClusterRole 249 | metadata: 250 | name: extra-resources-role 251 | rules: 252 | - apiGroups: 253 | - projectcontour.io 254 | resources: 255 | - httpproxies 256 | verbs: 257 | - create 258 | - delete 259 | - get 260 | - list 261 | - patch 262 | - update 263 | - watch 264 | - apiGroups: 265 | - projectcontour.io 266 | resources: 267 | - httpproxies/status 268 | verbs: 269 | - get 270 | --- 271 | apiVersion: rbac.authorization.k8s.io/v1 272 | kind: ClusterRoleBinding 273 | metadata: 274 | name: extra-resources-rolebinding 275 | roleRef: 276 | apiGroup: rbac.authorization.k8s.io 277 | kind: ClusterRole 278 | name: extra-resources-role 279 | subjects: 280 | - kind: ServiceAccount 281 | name: default 282 | namespace: website-operator-system 283 | ``` 284 | 285 | ## Web UI 286 | 287 | Web UI provides view of status and build log. 288 | 289 | ![Web UI](./screenshot.png) 290 | 291 | ## How to development 292 | 293 | The tools for developing website-operator are managed by [aqua](https://aquaproj.github.io). 294 | Please install aqua as described in the following page: 295 | 296 | https://aquaproj.github.io/docs/reference/install 297 | 298 | Then install the developer tools. 299 | 300 | ```shell 301 | $ cd /path/to/website-operator 302 | $ aqua i -l 303 | ``` 304 | 305 | You can start development with tilt. 306 | 307 | ```shell 308 | $ make dev 309 | $ tilt up 310 | ``` 311 | -------------------------------------------------------------------------------- /Tiltfile: -------------------------------------------------------------------------------- 1 | load('ext://restart_process', 'docker_build_with_restart') 2 | 3 | OPERATOR_DOCKERFILE = '''FROM golang:alpine 4 | WORKDIR / 5 | COPY ./bin/website-operator / 6 | CMD ["/website-operator"] 7 | ''' 8 | 9 | REPOCHECKER_DOCKERFILE = '''FROM golang:alpine 10 | RUN apk update && apk add git 11 | WORKDIR / 12 | COPY ./bin/repo-checker / 13 | CMD ["/repo-checker"] 14 | ''' 15 | 16 | UI_DOCKERFILE = '''FROM golang:alpine 17 | WORKDIR / 18 | COPY ./ui/frontend/dist/* /dist/ 19 | COPY ./bin/website-operator-ui / 20 | CMD ["/website-operator-ui"] 21 | ''' 22 | 23 | # Generate manifests and go files 24 | local_resource('make manifests', "make manifests", deps=["api", "controllers"], ignore=['*/*/zz_generated.deepcopy.go']) 25 | local_resource('make generate', "make generate", deps=["api", "controllers"], ignore=['*/*/zz_generated.deepcopy.go']) 26 | 27 | # Deploy CRD 28 | local_resource( 29 | 'CRD', 'make install', deps=["api"], 30 | ignore=['*/*/zz_generated.deepcopy.go']) 31 | 32 | installed = local("which kubebuilder") 33 | print("kubebuilder is present:", installed) 34 | 35 | DIRNAME = os.path.basename(os. getcwd()) 36 | 37 | watch_settings(ignore=['config/crd/bases/', 'config/rbac/role.yaml', 'config/webhook/manifests.yaml']) 38 | k8s_yaml(kustomize('./config/dev')) 39 | 40 | operator_deps = ['api', 'controllers', 'cmd/website-operator', 'version.go', 'constants.go'] 41 | local_resource('Watch&Compile website-operator', "make bin/website-operator", deps=operator_deps, ignore=['*/*/zz_generated.deepcopy.go']) 42 | 43 | repochecker_deps = ['checker', 'cmd/repo-checker', 'version.go', 'constants.go'] 44 | local_resource('Watch&Compile repo-checker', "make bin/repo-checker", deps=repochecker_deps) 45 | 46 | ui_deps = ['ui', 'cmd/website-operator-ui', 'version.go', 'constants.go'] 47 | local_resource('Watch&Compile website-operator-ui', "make frontend; make bin/website-operator-ui", deps=ui_deps, ignore=['ui/frontend/node_modules', 'ui/frontend/dist', 'ui/frontend/.parcel-cache', 'ui/frontend/package*']) 48 | 49 | local_resource('Sample YAML', 'kubectl apply -f ./config/samples', deps=["./config/samples"], resource_deps=[DIRNAME + "-controller-manager"]) 50 | 51 | docker_build_with_restart('website-operator:dev', '.', 52 | dockerfile_contents=OPERATOR_DOCKERFILE, 53 | entrypoint=['/website-operator'], 54 | only=['./bin/website-operator'], 55 | live_update=[ 56 | sync('./bin/website-operator', '/website-operator'), 57 | ] 58 | ) 59 | 60 | docker_build('repo-checker:dev', '.', 61 | dockerfile_contents=REPOCHECKER_DOCKERFILE, 62 | only=['./bin/repo-checker'], 63 | match_in_env_vars=True 64 | ) 65 | 66 | docker_build_with_restart('website-operator-ui:dev', '.', 67 | dockerfile_contents=UI_DOCKERFILE, 68 | entrypoint=['/website-operator-ui'], 69 | only=['./bin/website-operator-ui', './ui/frontend/dist'], 70 | live_update=[ 71 | sync('./bin/website-operator-ui', '/website-operator-ui'), 72 | sync('./ui/frontend/dist', '/dist'), 73 | ] 74 | ) 75 | 76 | k8s_resource(workload='website-operator-ui', port_forwards='8080:8080') 77 | 78 | -------------------------------------------------------------------------------- /api/v1beta1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | // Package v1beta1 contains API Schema definitions for the website v1beta1 API group 2 | //+kubebuilder:object:generate=true 3 | //+groupName=website.zoetrope.github.io 4 | package v1beta1 5 | 6 | import ( 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "sigs.k8s.io/controller-runtime/pkg/scheme" 9 | ) 10 | 11 | var ( 12 | // GroupVersion is group version used to register these objects 13 | GroupVersion = schema.GroupVersion{Group: "website.zoetrope.github.io", Version: "v1beta1"} 14 | 15 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 16 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 17 | 18 | // AddToScheme adds the types in this group-version to the given scheme. 19 | AddToScheme = SchemeBuilder.AddToScheme 20 | ) 21 | -------------------------------------------------------------------------------- /api/v1beta1/website_types.go: -------------------------------------------------------------------------------- 1 | package v1beta1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 9 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 10 | 11 | // WebSiteSpec defines the desired state of WebSite 12 | type WebSiteSpec struct { 13 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 14 | // Important: Run "make" to regenerate code after modifying this file 15 | 16 | // BuildImage is a container image name that will be used to build the website 17 | // +kubebuiler:validation:Required 18 | BuildImage string `json:"buildImage"` 19 | 20 | // BuildScript is a script to build the website 21 | // +kubebuiler:validation:Required 22 | BuildScript DataSource `json:"buildScript"` 23 | 24 | // BuildSecrets is the list of secrets you can use in a build script 25 | // +optional 26 | BuildSecrets []SecretKey `json:"buildSecrets,omitempty"` 27 | 28 | // ImagePullSecrets is a list of references to secrets in the same namespace to use for pulling the images (buildImage, nginx and repo-checker). 29 | // +optional 30 | ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` 31 | 32 | // RepoURL is the URL of the repository that has contents of the website 33 | // +kubebuiler:validation:Required 34 | RepoURL string `json:"repoURL"` 35 | 36 | // Branch is the branch name of the repository 37 | // +kubebuilder:default=main 38 | // +optional 39 | Branch string `json:"branch"` 40 | 41 | // DeployKeySecretName is the name of the secret resource that contains the deploy key to access the private repository 42 | // +optional 43 | DeployKeySecretName *string `json:"deployKeySecretName,omitempty"` 44 | 45 | // ExtraResources are resources that will be applied after the build step 46 | // +optional 47 | ExtraResources []DataSource `json:"extraResources,omitempty"` 48 | 49 | // Replicas is the number of nginx instances 50 | // +kubebuilder:default=1 51 | // +optional 52 | Replicas int32 `json:"replicas,omitempty"` 53 | 54 | // PodTemplate is a `Pod` template for nginx container. 55 | // +optional 56 | PodTemplate *PodTemplate `json:"podTemplate,omitempty"` 57 | 58 | // VolumeTemplates are `Volume` templates for nginx container. 59 | // +optional 60 | VolumeTemplates []corev1.Volume `json:"volumeTemplates,omitempty"` 61 | 62 | // ServiceTemplate is a `Service` template for nginx. 63 | // +optional 64 | ServiceTemplate *ServiceTemplate `json:"serviceTemplate,omitempty"` 65 | 66 | // AfterBuildScript is a script to execute in Job once after build 67 | // +optional 68 | AfterBuildScript *DataSource `json:"afterBuildScript"` 69 | 70 | // PublicURL is the URL of the website 71 | // +optional 72 | PublicURL string `json:"publicURL,omitempty"` 73 | } 74 | 75 | // SecretKey represents the name and key of a secret resource. 76 | type SecretKey struct { 77 | // Name is the name of the secret resource 78 | Name string `json:"name"` 79 | // Key is the key of the secret resource 80 | Key string `json:"key"` 81 | } 82 | 83 | // DataSource represents the source of data. 84 | // Only one of its members may be specified. 85 | type DataSource struct { 86 | // ConfigMapName is the name of the ConfigMap 87 | // +optional 88 | ConfigMap *ConfigMapSource `json:"configMap,omitempty"` 89 | 90 | // RawData is raw data 91 | // +optional 92 | RawData *string `json:"rawData,omitempty"` 93 | } 94 | 95 | // PodTemplate defines the desired spec and annotations of Pod 96 | type PodTemplate struct { 97 | // Standard object's metadata. Only `annotations` and `labels` are valid. 98 | // +optional 99 | ObjectMeta `json:"metadata,omitempty"` 100 | } 101 | 102 | // ServiceTemplate defines the desired spec and annotations of Service 103 | type ServiceTemplate struct { 104 | // Standard object's metadata. Only `annotations` and `labels` are valid. 105 | // +optional 106 | ObjectMeta `json:"metadata,omitempty"` 107 | } 108 | 109 | // ObjectMeta is metadata of objects. 110 | // This is partially copied from metav1.ObjectMeta. 111 | type ObjectMeta struct { 112 | // Labels is a map of string keys and values. 113 | // +optional 114 | Labels map[string]string `json:"labels,omitempty"` 115 | 116 | // Annotations is a map of string keys and values. 117 | // +optional 118 | Annotations map[string]string `json:"annotations,omitempty"` 119 | } 120 | 121 | type ConfigMapSource struct { 122 | // Name is the name of a configmap resource 123 | // +kubebuiler:validation:Required 124 | Name string `json:"name"` 125 | 126 | // Namespace is the namespace of a configmap resource 127 | // if omitted, it will be the same namespace as the WebSite resource 128 | // +optional 129 | Namespace string `json:"namespace"` 130 | 131 | // Key is the name of a key 132 | // +kubebuiler:validation:Required 133 | Key string `json:"key"` 134 | } 135 | 136 | // WebSiteStatus defines the observed state of WebSite 137 | type WebSiteStatus struct { 138 | // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster 139 | // Important: Run "make" to regenerate code after modifying this file 140 | 141 | // Revision is a revision currently available to the public 142 | Revision string `json:"revision"` 143 | // Ready is the current status 144 | Ready corev1.ConditionStatus `json:"ready"` 145 | } 146 | 147 | //+kubebuilder:object:root=true 148 | //+kubebuilder:subresource:status 149 | //+kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.ready" 150 | //+kubebuilder:printcolumn:name="REVISION",type="string",JSONPath=".status.revision" 151 | 152 | // WebSite is the Schema for the websites API 153 | type WebSite struct { 154 | metav1.TypeMeta `json:",inline"` 155 | metav1.ObjectMeta `json:"metadata,omitempty"` 156 | 157 | Spec WebSiteSpec `json:"spec,omitempty"` 158 | Status WebSiteStatus `json:"status,omitempty"` 159 | } 160 | 161 | //+kubebuilder:object:root=true 162 | 163 | // WebSiteList contains a list of WebSite 164 | type WebSiteList struct { 165 | metav1.TypeMeta `json:",inline"` 166 | metav1.ListMeta `json:"metadata,omitempty"` 167 | Items []WebSite `json:"items"` 168 | } 169 | 170 | func init() { 171 | SchemeBuilder.Register(&WebSite{}, &WebSiteList{}) 172 | } 173 | -------------------------------------------------------------------------------- /api/v1beta1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | // Code generated by controller-gen. DO NOT EDIT. 4 | 5 | package v1beta1 6 | 7 | import ( 8 | "k8s.io/api/core/v1" 9 | runtime "k8s.io/apimachinery/pkg/runtime" 10 | ) 11 | 12 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 13 | func (in *ConfigMapSource) DeepCopyInto(out *ConfigMapSource) { 14 | *out = *in 15 | } 16 | 17 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapSource. 18 | func (in *ConfigMapSource) DeepCopy() *ConfigMapSource { 19 | if in == nil { 20 | return nil 21 | } 22 | out := new(ConfigMapSource) 23 | in.DeepCopyInto(out) 24 | return out 25 | } 26 | 27 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 28 | func (in *DataSource) DeepCopyInto(out *DataSource) { 29 | *out = *in 30 | if in.ConfigMap != nil { 31 | in, out := &in.ConfigMap, &out.ConfigMap 32 | *out = new(ConfigMapSource) 33 | **out = **in 34 | } 35 | if in.RawData != nil { 36 | in, out := &in.RawData, &out.RawData 37 | *out = new(string) 38 | **out = **in 39 | } 40 | } 41 | 42 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSource. 43 | func (in *DataSource) DeepCopy() *DataSource { 44 | if in == nil { 45 | return nil 46 | } 47 | out := new(DataSource) 48 | in.DeepCopyInto(out) 49 | return out 50 | } 51 | 52 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 53 | func (in *ObjectMeta) DeepCopyInto(out *ObjectMeta) { 54 | *out = *in 55 | if in.Labels != nil { 56 | in, out := &in.Labels, &out.Labels 57 | *out = make(map[string]string, len(*in)) 58 | for key, val := range *in { 59 | (*out)[key] = val 60 | } 61 | } 62 | if in.Annotations != nil { 63 | in, out := &in.Annotations, &out.Annotations 64 | *out = make(map[string]string, len(*in)) 65 | for key, val := range *in { 66 | (*out)[key] = val 67 | } 68 | } 69 | } 70 | 71 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectMeta. 72 | func (in *ObjectMeta) DeepCopy() *ObjectMeta { 73 | if in == nil { 74 | return nil 75 | } 76 | out := new(ObjectMeta) 77 | in.DeepCopyInto(out) 78 | return out 79 | } 80 | 81 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 82 | func (in *PodTemplate) DeepCopyInto(out *PodTemplate) { 83 | *out = *in 84 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 85 | } 86 | 87 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodTemplate. 88 | func (in *PodTemplate) DeepCopy() *PodTemplate { 89 | if in == nil { 90 | return nil 91 | } 92 | out := new(PodTemplate) 93 | in.DeepCopyInto(out) 94 | return out 95 | } 96 | 97 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 98 | func (in *SecretKey) DeepCopyInto(out *SecretKey) { 99 | *out = *in 100 | } 101 | 102 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKey. 103 | func (in *SecretKey) DeepCopy() *SecretKey { 104 | if in == nil { 105 | return nil 106 | } 107 | out := new(SecretKey) 108 | in.DeepCopyInto(out) 109 | return out 110 | } 111 | 112 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 113 | func (in *ServiceTemplate) DeepCopyInto(out *ServiceTemplate) { 114 | *out = *in 115 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 116 | } 117 | 118 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceTemplate. 119 | func (in *ServiceTemplate) DeepCopy() *ServiceTemplate { 120 | if in == nil { 121 | return nil 122 | } 123 | out := new(ServiceTemplate) 124 | in.DeepCopyInto(out) 125 | return out 126 | } 127 | 128 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 129 | func (in *WebSite) DeepCopyInto(out *WebSite) { 130 | *out = *in 131 | out.TypeMeta = in.TypeMeta 132 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 133 | in.Spec.DeepCopyInto(&out.Spec) 134 | out.Status = in.Status 135 | } 136 | 137 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebSite. 138 | func (in *WebSite) DeepCopy() *WebSite { 139 | if in == nil { 140 | return nil 141 | } 142 | out := new(WebSite) 143 | in.DeepCopyInto(out) 144 | return out 145 | } 146 | 147 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 148 | func (in *WebSite) DeepCopyObject() runtime.Object { 149 | if c := in.DeepCopy(); c != nil { 150 | return c 151 | } 152 | return nil 153 | } 154 | 155 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 156 | func (in *WebSiteList) DeepCopyInto(out *WebSiteList) { 157 | *out = *in 158 | out.TypeMeta = in.TypeMeta 159 | in.ListMeta.DeepCopyInto(&out.ListMeta) 160 | if in.Items != nil { 161 | in, out := &in.Items, &out.Items 162 | *out = make([]WebSite, len(*in)) 163 | for i := range *in { 164 | (*in)[i].DeepCopyInto(&(*out)[i]) 165 | } 166 | } 167 | } 168 | 169 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebSiteList. 170 | func (in *WebSiteList) DeepCopy() *WebSiteList { 171 | if in == nil { 172 | return nil 173 | } 174 | out := new(WebSiteList) 175 | in.DeepCopyInto(out) 176 | return out 177 | } 178 | 179 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 180 | func (in *WebSiteList) DeepCopyObject() runtime.Object { 181 | if c := in.DeepCopy(); c != nil { 182 | return c 183 | } 184 | return nil 185 | } 186 | 187 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 188 | func (in *WebSiteSpec) DeepCopyInto(out *WebSiteSpec) { 189 | *out = *in 190 | in.BuildScript.DeepCopyInto(&out.BuildScript) 191 | if in.BuildSecrets != nil { 192 | in, out := &in.BuildSecrets, &out.BuildSecrets 193 | *out = make([]SecretKey, len(*in)) 194 | copy(*out, *in) 195 | } 196 | if in.ImagePullSecrets != nil { 197 | in, out := &in.ImagePullSecrets, &out.ImagePullSecrets 198 | *out = make([]v1.LocalObjectReference, len(*in)) 199 | copy(*out, *in) 200 | } 201 | if in.DeployKeySecretName != nil { 202 | in, out := &in.DeployKeySecretName, &out.DeployKeySecretName 203 | *out = new(string) 204 | **out = **in 205 | } 206 | if in.ExtraResources != nil { 207 | in, out := &in.ExtraResources, &out.ExtraResources 208 | *out = make([]DataSource, len(*in)) 209 | for i := range *in { 210 | (*in)[i].DeepCopyInto(&(*out)[i]) 211 | } 212 | } 213 | if in.PodTemplate != nil { 214 | in, out := &in.PodTemplate, &out.PodTemplate 215 | *out = new(PodTemplate) 216 | (*in).DeepCopyInto(*out) 217 | } 218 | if in.VolumeTemplates != nil { 219 | in, out := &in.VolumeTemplates, &out.VolumeTemplates 220 | *out = make([]v1.Volume, len(*in)) 221 | for i := range *in { 222 | (*in)[i].DeepCopyInto(&(*out)[i]) 223 | } 224 | } 225 | if in.ServiceTemplate != nil { 226 | in, out := &in.ServiceTemplate, &out.ServiceTemplate 227 | *out = new(ServiceTemplate) 228 | (*in).DeepCopyInto(*out) 229 | } 230 | if in.AfterBuildScript != nil { 231 | in, out := &in.AfterBuildScript, &out.AfterBuildScript 232 | *out = new(DataSource) 233 | (*in).DeepCopyInto(*out) 234 | } 235 | } 236 | 237 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebSiteSpec. 238 | func (in *WebSiteSpec) DeepCopy() *WebSiteSpec { 239 | if in == nil { 240 | return nil 241 | } 242 | out := new(WebSiteSpec) 243 | in.DeepCopyInto(out) 244 | return out 245 | } 246 | 247 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 248 | func (in *WebSiteStatus) DeepCopyInto(out *WebSiteStatus) { 249 | *out = *in 250 | } 251 | 252 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebSiteStatus. 253 | func (in *WebSiteStatus) DeepCopy() *WebSiteStatus { 254 | if in == nil { 255 | return nil 256 | } 257 | out := new(WebSiteStatus) 258 | in.DeepCopyInto(out) 259 | return out 260 | } 261 | -------------------------------------------------------------------------------- /aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # aqua - Declarative CLI Version Manager 3 | # https://aquaproj.github.io/ 4 | registries: 5 | - type: standard 6 | ref: v4.349.0 # renovate: depName=aquaproj/aqua-registry 7 | packages: 8 | - name: arttor/helmify@v0.4.18 9 | - name: goreleaser/goreleaser@v2.8.2 10 | - name: kubernetes-sigs/controller-tools/controller-gen@v0.16.3 11 | - name: kubernetes-sigs/kind@v0.27.0 12 | - name: kubernetes-sigs/kubebuilder@v4.2.0 13 | - name: kubernetes-sigs/kustomize@kustomize/v5.5.0 14 | - name: kubernetes/kubectl@v1.31.9 15 | - name: mikefarah/yq@v4.45.4 16 | - name: tilt-dev/ctlptl@v0.8.42 17 | - name: tilt-dev/tilt@v0.33.22 18 | -------------------------------------------------------------------------------- /charts/website-operator/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/website-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: website-operator 3 | description: Helm chart for Website Operator 4 | home: https://github.com/zoetrope/website-operator 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | # This is the chart version. This version number should be incremented each time you make changes 15 | # to the chart and its templates, including the app version. 16 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 17 | version: 0.3.0-chart-patch-version-placeholder 18 | # This is the version number of the application being deployed. This version number should be 19 | # incremented each time you make changes to the application. Versions are not expected to 20 | # follow Semantic Versioning. They should reflect the version the application is using. 21 | # It is recommended to use it with quotes. 22 | appVersion: app-version-placeholder 23 | -------------------------------------------------------------------------------- /charts/website-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "website-operator.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "website-operator.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "website-operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "website-operator.labels" -}} 37 | helm.sh/chart: {{ include "website-operator.chart" . }} 38 | {{ include "website-operator.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{- define "website-operator-ui.labels" -}} 46 | helm.sh/chart: {{ include "website-operator.chart" . }} 47 | {{ include "website-operator-ui.selectorLabels" . }} 48 | {{- if .Chart.AppVersion }} 49 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 50 | {{- end }} 51 | app.kubernetes.io/managed-by: {{ .Release.Service }} 52 | {{- end }} 53 | 54 | {{/* 55 | Selector labels 56 | */}} 57 | {{- define "website-operator.selectorLabels" -}} 58 | app.kubernetes.io/name: {{ include "website-operator.name" . }} 59 | app.kubernetes.io/instance: {{ .Release.Name }} 60 | {{- end }} 61 | 62 | {{- define "website-operator-ui.selectorLabels" -}} 63 | app.kubernetes.io/name: {{ include "website-operator.name" . }}-ui 64 | app.kubernetes.io/instance: {{ .Release.Name }} 65 | {{- end }} 66 | 67 | {{/* 68 | Create the name of the service account to use 69 | */}} 70 | {{- define "website-operator.serviceAccountName" -}} 71 | {{- if .Values.serviceAccount.create }} 72 | {{- default (include "website-operator.fullname" .) .Values.serviceAccount.name }} 73 | {{- else }} 74 | {{- default "default" .Values.serviceAccount.name }} 75 | {{- end }} 76 | {{- end }} 77 | -------------------------------------------------------------------------------- /charts/website-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "website-operator.fullname" . }}-controller-manager 5 | labels: 6 | control-plane: controller-manager 7 | {{- include "website-operator.labels" . | nindent 4 }} 8 | spec: 9 | replicas: {{ .Values.controller.replicas }} 10 | selector: 11 | matchLabels: 12 | control-plane: controller-manager 13 | {{- include "website-operator.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | labels: 17 | control-plane: controller-manager 18 | {{- include "website-operator.selectorLabels" . | nindent 8 }} 19 | annotations: 20 | kubectl.kubernetes.io/default-container: manager 21 | spec: 22 | containers: 23 | - args: 24 | - --leader-elect 25 | command: 26 | - /website-operator 27 | env: 28 | - name: POD_NAMESPACE 29 | valueFrom: 30 | fieldRef: 31 | fieldPath: metadata.namespace 32 | - name: KUBERNETES_CLUSTER_DOMAIN 33 | value: {{ .Values.kubernetesClusterDomain }} 34 | image: {{ .Values.controller.image.repository }}:{{ .Values.controller.image.tag 35 | | default .Chart.AppVersion }} 36 | livenessProbe: 37 | httpGet: 38 | path: /healthz 39 | port: 8081 40 | initialDelaySeconds: 15 41 | periodSeconds: 20 42 | name: manager 43 | readinessProbe: 44 | httpGet: 45 | path: /readyz 46 | port: 8081 47 | initialDelaySeconds: 5 48 | periodSeconds: 10 49 | resources: {} 50 | securityContext: 51 | allowPrivilegeEscalation: false 52 | securityContext: 53 | runAsNonRoot: true 54 | terminationGracePeriodSeconds: 10 55 | --- 56 | apiVersion: apps/v1 57 | kind: Deployment 58 | metadata: 59 | name: {{ include "website-operator.fullname" . }}-ui 60 | labels: 61 | {{- include "website-operator-ui.labels" . | nindent 4 }} 62 | spec: 63 | replicas: {{ .Values.ui.replicas }} 64 | selector: 65 | matchLabels: 66 | {{- include "website-operator-ui.selectorLabels" . | nindent 6 }} 67 | template: 68 | metadata: 69 | labels: 70 | {{- include "website-operator-ui.selectorLabels" . | nindent 8 }} 71 | spec: 72 | containers: 73 | - args: 74 | - --allow-cors=false 75 | command: 76 | - /website-operator-ui 77 | env: 78 | - name: POD_NAMESPACE 79 | valueFrom: 80 | fieldRef: 81 | fieldPath: metadata.namespace 82 | - name: KUBERNETES_CLUSTER_DOMAIN 83 | value: {{ .Values.kubernetesClusterDomain }} 84 | image: {{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default 85 | .Chart.AppVersion }} 86 | name: ui 87 | ports: 88 | - containerPort: 8080 89 | name: http 90 | protocol: TCP 91 | resources: {} 92 | terminationGracePeriodSeconds: 10 93 | -------------------------------------------------------------------------------- /charts/website-operator/templates/leader-election-rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: {{ include "website-operator.fullname" . }}-leader-election-role 5 | labels: 6 | {{- include "website-operator.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | - coordination.k8s.io 11 | resources: 12 | - configmaps 13 | - leases 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - "" 24 | resources: 25 | - events 26 | verbs: 27 | - create 28 | - patch 29 | --- 30 | apiVersion: rbac.authorization.k8s.io/v1 31 | kind: RoleBinding 32 | metadata: 33 | name: {{ include "website-operator.fullname" . }}-leader-election-rolebinding 34 | labels: 35 | {{- include "website-operator.labels" . | nindent 4 }} 36 | roleRef: 37 | apiGroup: rbac.authorization.k8s.io 38 | kind: Role 39 | name: '{{ include "website-operator.fullname" . }}-leader-election-role' 40 | subjects: 41 | - kind: ServiceAccount 42 | name: default 43 | namespace: '{{ .Release.Namespace }}' -------------------------------------------------------------------------------- /charts/website-operator/templates/manager-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "website-operator.fullname" . }}-manager-config 5 | labels: 6 | {{- include "website-operator.labels" . | nindent 4 }} 7 | data: 8 | controller_manager_config.yaml: | 9 | apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 10 | health: 11 | healthProbeBindAddress: {{ .Values.controller.config.health.healthProbeBindAddress 12 | | quote }} 13 | kind: ControllerManagerConfig 14 | leaderElection: 15 | leaderElect: {{ .Values.controller.config.leaderElection.leaderElect 16 | }} 17 | resourceName: {{ .Values.controller.config.leaderElection.resourceName 18 | | quote }} 19 | metrics: 20 | bindAddress: {{ .Values.controller.config.metrics.bindAddress 21 | | quote }} 22 | -------------------------------------------------------------------------------- /charts/website-operator/templates/manager-rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "website-operator.fullname" . }}-manager-role 5 | labels: 6 | {{- include "website-operator.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - configmaps 12 | verbs: 13 | - create 14 | - delete 15 | - get 16 | - list 17 | - patch 18 | - update 19 | - watch 20 | - apiGroups: 21 | - "" 22 | resources: 23 | - configmaps/status 24 | verbs: 25 | - get 26 | - apiGroups: 27 | - "" 28 | resources: 29 | - services 30 | verbs: 31 | - create 32 | - delete 33 | - get 34 | - list 35 | - patch 36 | - update 37 | - watch 38 | - apiGroups: 39 | - "" 40 | resources: 41 | - services/status 42 | verbs: 43 | - get 44 | - apiGroups: 45 | - apps 46 | resources: 47 | - deployments 48 | verbs: 49 | - create 50 | - delete 51 | - get 52 | - list 53 | - patch 54 | - update 55 | - watch 56 | - apiGroups: 57 | - apps 58 | resources: 59 | - deployments/status 60 | verbs: 61 | - get 62 | - apiGroups: 63 | - batch 64 | resources: 65 | - jobs 66 | verbs: 67 | - create 68 | - delete 69 | - get 70 | - list 71 | - patch 72 | - update 73 | - watch 74 | - apiGroups: 75 | - batch 76 | resources: 77 | - jobs/status 78 | verbs: 79 | - get 80 | - apiGroups: 81 | - website.zoetrope.github.io 82 | resources: 83 | - websites 84 | verbs: 85 | - create 86 | - delete 87 | - get 88 | - list 89 | - patch 90 | - update 91 | - watch 92 | - apiGroups: 93 | - website.zoetrope.github.io 94 | resources: 95 | - websites/finalizers 96 | verbs: 97 | - update 98 | - apiGroups: 99 | - website.zoetrope.github.io 100 | resources: 101 | - websites/status 102 | verbs: 103 | - get 104 | - patch 105 | - update 106 | --- 107 | apiVersion: rbac.authorization.k8s.io/v1 108 | kind: ClusterRoleBinding 109 | metadata: 110 | name: {{ include "website-operator.fullname" . }}-manager-rolebinding 111 | labels: 112 | {{- include "website-operator.labels" . | nindent 4 }} 113 | roleRef: 114 | apiGroup: rbac.authorization.k8s.io 115 | kind: ClusterRole 116 | name: '{{ include "website-operator.fullname" . }}-manager-role' 117 | subjects: 118 | - kind: ServiceAccount 119 | name: default 120 | namespace: '{{ .Release.Namespace }}' -------------------------------------------------------------------------------- /charts/website-operator/templates/ui-rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "website-operator.fullname" . }}-ui-role 5 | labels: 6 | {{- include "website-operator.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - website.zoetrope.github.io 10 | resources: 11 | - websites 12 | verbs: 13 | - get 14 | - list 15 | - watch 16 | - apiGroups: 17 | - website.zoetrope.github.io 18 | resources: 19 | - websites/status 20 | verbs: 21 | - get 22 | - apiGroups: 23 | - "" 24 | resources: 25 | - pods 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - apiGroups: 31 | - "" 32 | resources: 33 | - pods/log 34 | verbs: 35 | - get 36 | - list 37 | --- 38 | apiVersion: rbac.authorization.k8s.io/v1 39 | kind: ClusterRoleBinding 40 | metadata: 41 | name: {{ include "website-operator.fullname" . }}-ui-rolebinding 42 | labels: 43 | {{- include "website-operator.labels" . | nindent 4 }} 44 | roleRef: 45 | apiGroup: rbac.authorization.k8s.io 46 | kind: ClusterRole 47 | name: '{{ include "website-operator.fullname" . }}-ui-role' 48 | subjects: 49 | - kind: ServiceAccount 50 | name: default 51 | namespace: '{{ .Release.Namespace }}' -------------------------------------------------------------------------------- /charts/website-operator/templates/ui.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "website-operator.fullname" . }}-ui 5 | labels: 6 | {{- include "website-operator-ui.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.ui.service.type }} 9 | selector: 10 | {{- include "website-operator-ui.selectorLabels" . | nindent 4 }} 11 | ports: 12 | {{- .Values.ui.service.ports | toYaml | nindent 2 -}} 13 | -------------------------------------------------------------------------------- /charts/website-operator/values.yaml: -------------------------------------------------------------------------------- 1 | controller: 2 | image: 3 | repository: ghcr.io/zoetrope/website-operator 4 | tag: app-version-placeholder 5 | replicas: 1 6 | config: 7 | health: 8 | healthProbeBindAddress: :8081 9 | leaderElection: 10 | leaderElect: true 11 | resourceName: website-operator 12 | metrics: 13 | bindAddress: 127.0.0.1:8080 14 | ui: 15 | image: 16 | repository: ghcr.io/zoetrope/website-operator-ui 17 | tag: app-version-placeholder 18 | replicas: 1 19 | service: 20 | ports: 21 | - name: web 22 | port: 8080 23 | protocol: TCP 24 | targetPort: 8080 25 | type: ClusterIP 26 | kubernetesClusterDomain: cluster.local 27 | -------------------------------------------------------------------------------- /checker/checker.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "path/filepath" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/cybozu-go/well" 12 | ) 13 | 14 | type RepoChecker struct { 15 | latestRevision string 16 | mu sync.Mutex 17 | 18 | repoURL string 19 | repoBranch string 20 | repoName string 21 | workDir string 22 | interval time.Duration 23 | } 24 | 25 | func NewRepoChecker(repoURL, repoBranch, workDir string, interval time.Duration) *RepoChecker { 26 | items := strings.Split(repoURL, "/") 27 | last := items[len(items)-1] 28 | repoName := strings.TrimSuffix(last, ".git") 29 | 30 | return &RepoChecker{ 31 | repoURL: repoURL, 32 | repoBranch: repoBranch, 33 | repoName: repoName, 34 | workDir: workDir, 35 | interval: interval, 36 | } 37 | } 38 | 39 | func (c *RepoChecker) Clone(ctx context.Context) error { 40 | cmd := well.CommandContext(ctx, "git", "clone", "-b", c.repoBranch, c.repoURL) 41 | cmd.Dir = c.workDir 42 | return cmd.Run() 43 | } 44 | 45 | func (c *RepoChecker) UpdateLatestRevision(ctx context.Context) error { 46 | err := c.fetchRemoteRevision(ctx) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | ticker := time.NewTicker(c.interval) 52 | defer ticker.Stop() 53 | 54 | for { 55 | select { 56 | case <-ctx.Done(): 57 | return ctx.Err() 58 | case <-ticker.C: 59 | err := c.fetchRemoteRevision(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | } 65 | } 66 | 67 | func (c *RepoChecker) LatestRevision() string { 68 | c.mu.Lock() 69 | defer c.mu.Unlock() 70 | return c.latestRevision 71 | } 72 | 73 | func (c *RepoChecker) fetchRemoteRevision(ctx context.Context) error { 74 | cmd := well.CommandContext(ctx, "git", "ls-remote", "origin") 75 | cmd.Dir = filepath.Join(c.workDir, c.repoName) 76 | out, err := cmd.Output() 77 | if err != nil { 78 | return err 79 | } 80 | for _, o := range strings.Split(string(out), "\n") { 81 | fields := strings.Fields(o) 82 | if len(fields) != 2 { 83 | continue 84 | } 85 | ref := strings.TrimSpace(fields[1]) 86 | if ref == "refs/heads/"+c.repoBranch { 87 | c.mu.Lock() 88 | c.latestRevision = strings.TrimSpace(fields[0]) 89 | c.mu.Unlock() 90 | return nil 91 | } 92 | } 93 | return errors.New("cannot found hash") 94 | } 95 | -------------------------------------------------------------------------------- /checker/checker_test.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestPublicRepository(t *testing.T) { 13 | workDir, err := ioutil.TempDir("/tmp", "website-operator-checker-test") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | defer os.RemoveAll(workDir) 18 | 19 | rc := NewRepoChecker("https://github.com/zoetrope/honkit-sample.git", "main", workDir, 5*time.Second) 20 | ctx := context.Background() 21 | err = rc.Clone(ctx) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | _, err = os.Stat(filepath.Join(workDir, rc.repoName, "README.md")) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | rev := rc.LatestRevision() 31 | if rev != "" { 32 | t.Fatal("something wrong") 33 | } 34 | 35 | err = rc.fetchRemoteRevision(ctx) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | rev = rc.LatestRevision() 41 | if rev == "" { 42 | t.Fatal("failed to get latest revision") 43 | } 44 | } 45 | 46 | func TestPrivateRepository(t *testing.T) { 47 | wd, err := os.Getwd() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | err = os.Setenv("GIT_SSH_COMMAND", "ssh -F "+filepath.Join(wd, "..", "e2e", "manifests", "website", ".ssh", "config")+" -i "+filepath.Join(wd, "..", "e2e", "manifests", "website", ".ssh", "id_rsa")) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | 56 | workDir, err := ioutil.TempDir("/tmp", "website-operator-checker-test") 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | defer os.RemoveAll(workDir) 61 | 62 | rc := NewRepoChecker("git@github.com:zoetrope/mkdocs-sample.git", "main", workDir, 5*time.Second) 63 | ctx := context.Background() 64 | err = rc.Clone(ctx) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | _, err = os.Stat(filepath.Join(workDir, rc.repoName, "README.md")) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | 73 | rev := rc.LatestRevision() 74 | if rev != "" { 75 | t.Fatal("something wrong") 76 | } 77 | 78 | err = rc.fetchRemoteRevision(ctx) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | rev = rc.LatestRevision() 84 | if rev == "" { 85 | t.Fatal("failed to get latest revision") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: ctlptl.dev/v1alpha1 2 | kind: Registry 3 | name: ctlptl-registry 4 | port: 5005 5 | --- 6 | apiVersion: ctlptl.dev/v1alpha1 7 | kind: Cluster 8 | name: kind-website-operator-dev 9 | product: kind 10 | kubernetesVersion: v1.31.6 # renovate: kindest/node 11 | registry: ctlptl-registry 12 | -------------------------------------------------------------------------------- /cmd/repo-checker/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/zoetrope/website-operator" 10 | ) 11 | 12 | var config struct { 13 | listenAddr string 14 | repoURL string 15 | repoBranch string 16 | workDir string 17 | interval time.Duration 18 | } 19 | 20 | var rootCmd = &cobra.Command{ 21 | Use: "repo-checker", 22 | Version: website.Version, 23 | Short: "repo-checker periodically checks the latest hash of the target GitHub repository", 24 | Long: `repo-checker periodically checks the latest hash of the target GitHub repository.`, 25 | 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | cmd.SilenceUsage = true 28 | return subMain(cmd.Context()) 29 | }, 30 | } 31 | 32 | // Execute adds all child commands to the root command and sets flags appropriately. 33 | // This is called by main.main(). It only needs to happen once to the rootCmd. 34 | func Execute() { 35 | if err := rootCmd.Execute(); err != nil { 36 | fmt.Println(err) 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | func init() { 42 | fs := rootCmd.Flags() 43 | fs.StringVar(&config.listenAddr, "listen-addr", ":9090", "The address the endpoint binds to") 44 | fs.StringVar(&config.repoURL, "repo-url", "", "The URL of the repository to be checked") 45 | fs.StringVar(&config.repoBranch, "repo-branch", "master", "The branch name of the repository") 46 | fs.StringVar(&config.workDir, "work-dir", "/tmp/repos", "The working directory") 47 | fs.DurationVar(&config.interval, "interval", 10*time.Minute, "The interval to check the repository") 48 | } 49 | -------------------------------------------------------------------------------- /cmd/repo-checker/cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/cybozu-go/well" 9 | "github.com/zoetrope/website-operator/checker" 10 | ) 11 | 12 | func subMain(ctx context.Context) error { 13 | _ = os.RemoveAll(config.workDir) 14 | err := os.MkdirAll(config.workDir, 0755) 15 | if err != nil { 16 | return err 17 | } 18 | rc := checker.NewRepoChecker(config.repoURL, config.repoBranch, config.workDir, config.interval) 19 | err = rc.Clone(ctx) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | well.Go(rc.UpdateLatestRevision) 25 | 26 | http.HandleFunc("/", createHandler(rc)) 27 | serv := &well.HTTPServer{ 28 | Server: &http.Server{ 29 | Addr: config.listenAddr, 30 | Handler: http.DefaultServeMux, 31 | }, 32 | } 33 | 34 | err = serv.ListenAndServe() 35 | if err != nil { 36 | return err 37 | } 38 | err = well.Wait() 39 | 40 | if err != nil && !well.IsSignaled(err) { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func createHandler(rc *checker.RepoChecker) func(http.ResponseWriter, *http.Request) { 48 | return func(w http.ResponseWriter, r *http.Request) { 49 | rev := rc.LatestRevision() 50 | if len(rev) == 0 { 51 | http.Error(w, "revision not found", http.StatusNotFound) 52 | return 53 | } 54 | _, err := w.Write([]byte(rev)) 55 | if err != nil { 56 | http.Error(w, err.Error(), http.StatusInternalServerError) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /cmd/repo-checker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/zoetrope/website-operator/cmd/repo-checker/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /cmd/website-operator-ui/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/zoetrope/website-operator" 9 | ) 10 | 11 | var config struct { 12 | listenAddr string 13 | contentDir string 14 | allowCORS bool 15 | } 16 | 17 | var rootCmd = &cobra.Command{ 18 | Use: "website-operator-ui", 19 | Version: website.Version, 20 | Short: "Web UI for WebSite Operator", 21 | Long: `Web UI for WebSite Operator.`, 22 | 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | cmd.SilenceUsage = true 25 | return subMain() 26 | }, 27 | } 28 | 29 | // Execute adds all child commands to the root command and sets flags appropriately. 30 | // This is called by main.main(). It only needs to happen once to the rootCmd. 31 | func Execute() { 32 | if err := rootCmd.Execute(); err != nil { 33 | fmt.Println(err) 34 | os.Exit(1) 35 | } 36 | } 37 | 38 | func init() { 39 | fs := rootCmd.Flags() 40 | fs.StringVar(&config.listenAddr, "listen-addr", ":8080", "The address the endpoint binds to") 41 | fs.StringVar(&config.contentDir, "content-dir", "/dist", "The path of content files") 42 | fs.BoolVar(&config.allowCORS, "allow-cors", false, "Allow CORS (for development)") 43 | } 44 | -------------------------------------------------------------------------------- /cmd/website-operator-ui/cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/cybozu-go/well" 7 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 8 | "github.com/zoetrope/website-operator/ui/backend" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "k8s.io/client-go/kubernetes" 11 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 12 | ctrl "sigs.k8s.io/controller-runtime" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 15 | ) 16 | 17 | func subMain() error { 18 | ctrl.SetLogger(zap.New(zap.UseDevMode(true))) 19 | 20 | restConfig, err := ctrl.GetConfig() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | scheme := runtime.NewScheme() 26 | err = clientgoscheme.AddToScheme(scheme) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | err = websitev1beta1.AddToScheme(scheme) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | kubeClient, err := client.New(restConfig, client.Options{Scheme: scheme}) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | rawClient, err := kubernetes.NewForConfig(restConfig) 42 | if err != nil { 43 | return err 44 | } 45 | server := backend.NewAPIServer(kubeClient, rawClient, config.allowCORS) 46 | 47 | mux := http.NewServeMux() 48 | mux.Handle("/api/v1/", server) 49 | 50 | fs := http.FileServer(http.Dir(config.contentDir)) 51 | mux.Handle("/", fs) 52 | 53 | s := &well.HTTPServer{ 54 | Server: &http.Server{ 55 | Addr: config.listenAddr, 56 | Handler: mux, 57 | }, 58 | } 59 | err = s.ListenAndServe() 60 | if err != nil { 61 | return err 62 | } 63 | return well.Wait() 64 | } 65 | -------------------------------------------------------------------------------- /cmd/website-operator-ui/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/zoetrope/website-operator/cmd/website-operator-ui/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /cmd/website-operator/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/zoetrope/website-operator" 9 | ) 10 | 11 | var config struct { 12 | metricsAddr string 13 | probeAddr string 14 | enableLeaderElection bool 15 | leaderElectionID string 16 | nginxContainerImage string 17 | repoCheckerContainerImage string 18 | development bool 19 | } 20 | 21 | var rootCmd = &cobra.Command{ 22 | Use: "website-operator", 23 | Version: website.Version, 24 | Short: "WebSite Operator", 25 | Long: `WebSite Operator.`, 26 | 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | cmd.SilenceUsage = true 29 | return subMain() 30 | }, 31 | } 32 | 33 | // Execute adds all child commands to the root command and sets flags appropriately. 34 | // This is called by main.main(). It only needs to happen once to the rootCmd. 35 | func Execute() { 36 | if err := rootCmd.Execute(); err != nil { 37 | fmt.Println(err) 38 | os.Exit(1) 39 | } 40 | } 41 | 42 | func init() { 43 | repochecker := os.Getenv("REPOCHECKER_CONTAINER_IMAGE") 44 | if repochecker == "" { 45 | repochecker = website.DefaultRepoCheckerContainerImage 46 | } 47 | nginx := os.Getenv("NGINX_CONTAINER_IMAGE") 48 | if nginx == "" { 49 | nginx = website.DefaultNginxContainerImage 50 | } 51 | fs := rootCmd.Flags() 52 | fs.StringVar(&config.metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to") 53 | fs.StringVar(&config.probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 54 | fs.StringVar(&config.leaderElectionID, "leader-election-id", "website-operator", "ID for leader election by controller-runtime") 55 | fs.BoolVar(&config.enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.") 56 | fs.StringVar(&config.nginxContainerImage, "nginx-container-image", nginx, "The container image name of nginx") 57 | fs.StringVar(&config.repoCheckerContainerImage, "repochecker-container-image", repochecker, "The container image name of repo-checker") 58 | fs.BoolVar(&config.development, "development", false, "Zap development mode") 59 | } 60 | -------------------------------------------------------------------------------- /cmd/website-operator/cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 7 | // to ensure that exec-entrypoint and run can make use of them. 8 | _ "k8s.io/client-go/plugin/pkg/client/auth" 9 | 10 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 11 | "github.com/zoetrope/website-operator/controllers" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 14 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 15 | ctrl "sigs.k8s.io/controller-runtime" 16 | "sigs.k8s.io/controller-runtime/pkg/healthz" 17 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 18 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 19 | "sigs.k8s.io/controller-runtime/pkg/webhook" 20 | //+kubebuilder:scaffold:imports 21 | ) 22 | 23 | var ( 24 | scheme = runtime.NewScheme() 25 | setupLog = ctrl.Log.WithName("setup") 26 | ) 27 | 28 | func init() { 29 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 30 | 31 | utilruntime.Must(websitev1beta1.AddToScheme(scheme)) 32 | //+kubebuilder:scaffold:scheme 33 | } 34 | 35 | func subMain() error { 36 | opts := zap.Options{ 37 | Development: config.development, 38 | } 39 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 40 | 41 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 42 | Scheme: scheme, 43 | Metrics: metricsserver.Options{ 44 | BindAddress: config.metricsAddr, 45 | }, 46 | WebhookServer: webhook.NewServer(webhook.Options{ 47 | Port: 9443, 48 | }), 49 | HealthProbeBindAddress: config.probeAddr, 50 | LeaderElection: config.enableLeaderElection, 51 | LeaderElectionID: config.leaderElectionID, 52 | }) 53 | if err != nil { 54 | setupLog.Error(err, "unable to start manager") 55 | return err 56 | } 57 | 58 | if err = controllers.NewWebSiteReconciler( 59 | mgr.GetClient(), 60 | ctrl.Log.WithName("controllers").WithName("WebSite"), 61 | mgr.GetScheme(), 62 | config.nginxContainerImage, 63 | config.repoCheckerContainerImage, 64 | os.Getenv("POD_NAMESPACE"), 65 | &controllers.RepoCheckerClient{}, 66 | ).SetupWithManager(mgr); err != nil { 67 | setupLog.Error(err, "unable to create controller", "controller", "WebSite") 68 | return err 69 | } 70 | // +kubebuilder:scaffold:builder 71 | 72 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 73 | setupLog.Error(err, "unable to set up health check") 74 | return err 75 | } 76 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 77 | setupLog.Error(err, "unable to set up ready check") 78 | return err 79 | } 80 | 81 | setupLog.Info("starting manager") 82 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 83 | setupLog.Error(err, "problem running manager") 84 | return err 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /cmd/website-operator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/zoetrope/website-operator/cmd/website-operator/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /config/certmanager/certificate.yaml: -------------------------------------------------------------------------------- 1 | # The following manifests contain a self-signed issuer CR and a certificate CR. 2 | # More document can be found at https://docs.cert-manager.io 3 | # WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. 4 | apiVersion: cert-manager.io/v1 5 | kind: Issuer 6 | metadata: 7 | name: selfsigned-issuer 8 | namespace: system 9 | spec: 10 | selfSigned: {} 11 | --- 12 | apiVersion: cert-manager.io/v1 13 | kind: Certificate 14 | metadata: 15 | name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml 16 | namespace: system 17 | spec: 18 | # $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize 19 | dnsNames: 20 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc 21 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local 22 | issuerRef: 23 | kind: Issuer 24 | name: selfsigned-issuer 25 | secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize 26 | -------------------------------------------------------------------------------- /config/certmanager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - certificate.yaml 3 | 4 | configurations: 5 | - kustomizeconfig.yaml 6 | -------------------------------------------------------------------------------- /config/certmanager/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This configuration is for teaching kustomize how to update name ref and var substitution 2 | nameReference: 3 | - kind: Issuer 4 | group: cert-manager.io 5 | fieldSpecs: 6 | - kind: Certificate 7 | group: cert-manager.io 8 | path: spec/issuerRef/name 9 | 10 | varReference: 11 | - kind: Certificate 12 | group: cert-manager.io 13 | path: spec/commonName 14 | - kind: Certificate 15 | group: cert-manager.io 16 | path: spec/dnsNames 17 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/website.zoetrope.github.io_websites.yaml 6 | #+kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patchesStrategicMerge: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | #- patches/webhook_in_websites.yaml 12 | #+kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | #- patches/cainjection_in_websites.yaml 17 | #+kubebuilder:scaffold:crdkustomizecainjectionpatch 18 | 19 | # the following config is for teaching kustomize how to do kustomization for CRDs. 20 | configurations: 21 | - kustomizeconfig.yaml 22 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | group: apiextensions.k8s.io 14 | path: spec/conversion/webhook/clientConfig/service/namespace 15 | create: false 16 | 17 | varReference: 18 | - path: metadata/annotations 19 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_websites.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | # CRD conversion requires k8s 1.13 or later. 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 8 | name: websites.website.zoetrope.github.io 9 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_websites.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables conversion webhook for the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: websites.website.zoetrope.github.io 6 | spec: 7 | conversion: 8 | strategy: Webhook 9 | webhook: 10 | clientConfig: 11 | service: 12 | namespace: system 13 | name: webhook-service 14 | path: /convert 15 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: website-operator-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: website-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #commonLabels: 13 | # someName: someValue 14 | 15 | bases: 16 | - ../crd 17 | - ../rbac 18 | - ../manager 19 | - ../ui 20 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 21 | # crd/kustomization.yaml 22 | #- ../webhook 23 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 24 | #- ../certmanager 25 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 26 | #- ../prometheus 27 | 28 | patchesStrategicMerge: 29 | # Protect the /metrics endpoint by putting it behind auth. 30 | # If you want your controller-manager to expose the /metrics 31 | # endpoint w/o any authn/z, please comment the following line. 32 | #- manager_auth_proxy_patch.yaml 33 | 34 | # Mount the controller config file for loading manager configurations 35 | # through a ComponentConfig type 36 | #- manager_config_patch.yaml 37 | 38 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 39 | # crd/kustomization.yaml 40 | #- manager_webhook_patch.yaml 41 | 42 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 43 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 44 | # 'CERTMANAGER' needs to be enabled to use ca injection 45 | #- webhookcainjection_patch.yaml 46 | 47 | # the following config is for teaching kustomize how to do var substitution 48 | vars: 49 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 50 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 51 | # objref: 52 | # kind: Certificate 53 | # group: cert-manager.io 54 | # version: v1 55 | # name: serving-cert # this name should match the one in certificate.yaml 56 | # fieldref: 57 | # fieldpath: metadata.namespace 58 | #- name: CERTIFICATE_NAME 59 | # objref: 60 | # kind: Certificate 61 | # group: cert-manager.io 62 | # version: v1 63 | # name: serving-cert # this name should match the one in certificate.yaml 64 | #- name: SERVICE_NAMESPACE # namespace of the service 65 | # objref: 66 | # kind: Service 67 | # version: v1 68 | # name: webhook-service 69 | # fieldref: 70 | # fieldpath: metadata.namespace 71 | #- name: SERVICE_NAME 72 | # objref: 73 | # kind: Service 74 | # version: v1 75 | # name: webhook-service 76 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the 2 | # controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: kube-rbac-proxy 13 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.8.0 14 | args: 15 | - "--secure-listen-address=0.0.0.0:8443" 16 | - "--upstream=http://127.0.0.1:8080/" 17 | - "--logtostderr=true" 18 | - "--v=10" 19 | ports: 20 | - containerPort: 8443 21 | protocol: TCP 22 | name: https 23 | - name: manager 24 | args: 25 | - "--health-probe-bind-address=:8081" 26 | - "--metrics-bind-address=127.0.0.1:8080" 27 | - "--leader-elect" 28 | -------------------------------------------------------------------------------- /config/default/manager_config_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | args: 12 | - "--config=controller_manager_config.yaml" 13 | volumeMounts: 14 | - name: manager-config 15 | mountPath: /controller_manager_config.yaml 16 | subPath: controller_manager_config.yaml 17 | volumes: 18 | - name: manager-config 19 | configMap: 20 | name: manager-config 21 | -------------------------------------------------------------------------------- /config/dev/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../default 3 | patchesStrategicMerge: 4 | - ./manager.yaml 5 | - ./ui.yaml 6 | -------------------------------------------------------------------------------- /config/dev/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | securityContext: null 10 | containers: 11 | - command: 12 | - /website-operator 13 | image: website-operator:dev 14 | args: null 15 | name: manager 16 | securityContext: null 17 | # This is a workaround to tell Tilt the name of container image. 18 | # https://docs.tilt.dev/custom_resource.html 19 | env: 20 | - name: REPOCHECKER_CONTAINER_IMAGE 21 | value: repo-checker:dev 22 | -------------------------------------------------------------------------------- /config/dev/ui.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: ui 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | securityContext: null 10 | containers: 11 | - command: 12 | - /website-operator-ui 13 | image: website-operator-ui:dev 14 | args: null 15 | name: ui 16 | securityContext: null 17 | -------------------------------------------------------------------------------- /config/manager/controller_manager_config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 2 | kind: ControllerManagerConfig 3 | health: 4 | healthProbeBindAddress: :8081 5 | metrics: 6 | bindAddress: 127.0.0.1:8080 7 | leaderElection: 8 | leaderElect: true 9 | resourceName: website-operator 10 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | 4 | generatorOptions: 5 | disableNameSuffixHash: true 6 | 7 | configMapGenerator: 8 | - name: manager-config 9 | files: 10 | - controller_manager_config.yaml 11 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: system 7 | --- 8 | apiVersion: apps/v1 9 | kind: Deployment 10 | metadata: 11 | name: controller-manager 12 | namespace: system 13 | labels: 14 | control-plane: controller-manager 15 | spec: 16 | selector: 17 | matchLabels: 18 | control-plane: controller-manager 19 | replicas: 1 20 | template: 21 | metadata: 22 | annotations: 23 | kubectl.kubernetes.io/default-container: manager 24 | labels: 25 | control-plane: controller-manager 26 | spec: 27 | securityContext: 28 | runAsNonRoot: true 29 | containers: 30 | - command: 31 | - /website-operator 32 | args: 33 | - --leader-elect 34 | - --repochecker-container-image=ghcr.io/zoetrope/repo-checker:dev 35 | image: ghcr.io/zoetrope/website-operator:dev 36 | name: manager 37 | securityContext: 38 | allowPrivilegeEscalation: false 39 | livenessProbe: 40 | httpGet: 41 | path: /healthz 42 | port: 8081 43 | initialDelaySeconds: 15 44 | periodSeconds: 20 45 | readinessProbe: 46 | httpGet: 47 | path: /readyz 48 | port: 8081 49 | initialDelaySeconds: 5 50 | periodSeconds: 10 51 | env: 52 | - name: POD_NAMESPACE 53 | valueFrom: 54 | fieldRef: 55 | fieldPath: metadata.namespace 56 | terminationGracePeriodSeconds: 10 57 | -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Prometheus Monitor Service (Metrics) 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | labels: 7 | control-plane: controller-manager 8 | name: controller-manager-metrics-monitor 9 | namespace: system 10 | spec: 11 | endpoints: 12 | - path: /metrics 13 | port: https 14 | selector: 15 | matchLabels: 16 | control-plane: controller-manager 17 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: ["/metrics"] 7 | verbs: ["get"] 8 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: proxy-role 5 | rules: 6 | - apiGroups: ["authentication.k8s.io"] 7 | resources: 8 | - tokenreviews 9 | verbs: ["create"] 10 | - apiGroups: ["authorization.k8s.io"] 11 | resources: 12 | - subjectaccessreviews 13 | verbs: ["create"] 14 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: proxy-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: proxy-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: controller-manager-metrics-service 7 | namespace: system 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | protocol: TCP 13 | targetPort: https 14 | selector: 15 | control-plane: controller-manager 16 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - role.yaml 3 | - manager_role_binding.yaml 4 | - leader_election_role.yaml 5 | - leader_election_role_binding.yaml 6 | - ui_role.yaml 7 | - ui_role_binding.yaml 8 | # Comment the following 4 lines if you want to disable 9 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 10 | # which protects your /metrics endpoint. 11 | #- auth_proxy_service.yaml 12 | #- auth_proxy_role.yaml 13 | #- auth_proxy_role_binding.yaml 14 | #- auth_proxy_client_clusterrole.yaml 15 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | name: leader-election-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | - coordination.k8s.io 10 | resources: 11 | - configmaps 12 | - leases 13 | verbs: 14 | - get 15 | - list 16 | - watch 17 | - create 18 | - update 19 | - patch 20 | - delete 21 | - apiGroups: 22 | - "" 23 | resources: 24 | - events 25 | verbs: 26 | - create 27 | - patch 28 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: leader-election-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: leader-election-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/manager_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | - services 12 | verbs: 13 | - create 14 | - delete 15 | - get 16 | - list 17 | - patch 18 | - update 19 | - watch 20 | - apiGroups: 21 | - "" 22 | resources: 23 | - configmaps/status 24 | - services/status 25 | verbs: 26 | - get 27 | - apiGroups: 28 | - apps 29 | resources: 30 | - deployments 31 | verbs: 32 | - create 33 | - delete 34 | - get 35 | - list 36 | - patch 37 | - update 38 | - watch 39 | - apiGroups: 40 | - apps 41 | resources: 42 | - deployments/status 43 | verbs: 44 | - get 45 | - apiGroups: 46 | - batch 47 | resources: 48 | - jobs 49 | verbs: 50 | - create 51 | - delete 52 | - get 53 | - list 54 | - patch 55 | - update 56 | - watch 57 | - apiGroups: 58 | - batch 59 | resources: 60 | - jobs/status 61 | verbs: 62 | - get 63 | - apiGroups: 64 | - website.zoetrope.github.io 65 | resources: 66 | - websites 67 | verbs: 68 | - create 69 | - delete 70 | - get 71 | - list 72 | - patch 73 | - update 74 | - watch 75 | - apiGroups: 76 | - website.zoetrope.github.io 77 | resources: 78 | - websites/finalizers 79 | verbs: 80 | - update 81 | - apiGroups: 82 | - website.zoetrope.github.io 83 | resources: 84 | - websites/status 85 | verbs: 86 | - get 87 | - patch 88 | - update 89 | -------------------------------------------------------------------------------- /config/rbac/ui_role.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | creationTimestamp: null 7 | name: ui-role 8 | rules: 9 | - apiGroups: 10 | - website.zoetrope.github.io 11 | resources: 12 | - websites 13 | verbs: 14 | - get 15 | - list 16 | - watch 17 | - apiGroups: 18 | - website.zoetrope.github.io 19 | resources: 20 | - websites/status 21 | verbs: 22 | - get 23 | - apiGroups: 24 | - "" 25 | resources: 26 | - pods 27 | verbs: 28 | - get 29 | - list 30 | - watch 31 | - apiGroups: 32 | - "" 33 | resources: 34 | - pods/log 35 | verbs: 36 | - get 37 | - list 38 | -------------------------------------------------------------------------------- /config/rbac/ui_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: ui-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: ui-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/website_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit websites. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: website-editor-role 6 | rules: 7 | - apiGroups: 8 | - website.zoetrope.github.io 9 | resources: 10 | - websites 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - website.zoetrope.github.io 21 | resources: 22 | - websites/status 23 | verbs: 24 | - get 25 | -------------------------------------------------------------------------------- /config/rbac/website_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view websites. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: website-viewer-role 6 | rules: 7 | - apiGroups: 8 | - website.zoetrope.github.io 9 | resources: 10 | - websites 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - website.zoetrope.github.io 17 | resources: 18 | - websites/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /config/release/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: website-operator-system 4 | resources: 5 | - ../default 6 | patchesStrategicMerge: 7 | - manager.yaml 8 | - ui.yaml 9 | -------------------------------------------------------------------------------- /config/release/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - command: 11 | - /website-operator 12 | args: 13 | - --leader-elect 14 | image: ghcr.io/zoetrope/website-operator:dev 15 | name: manager 16 | -------------------------------------------------------------------------------- /config/release/ui.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: ui 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - command: 11 | - /website-operator-ui 12 | args: 13 | - --allow-cors=false 14 | image: ghcr.io/zoetrope/website-operator-ui:dev 15 | name: ui 16 | -------------------------------------------------------------------------------- /config/samples/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | build-honkit.sh: | 4 | #!/bin/bash -ex 5 | cd $HOME 6 | rm -rf $REPO_NAME 7 | git clone $REPO_URL 8 | cd $REPO_NAME 9 | git checkout $REVISION 10 | 11 | npm install 12 | npm run build 13 | 14 | rm -rf $OUTPUT/* 15 | cp -r _book/* $OUTPUT/ 16 | kind: ConfigMap 17 | metadata: 18 | name: build-scripts 19 | namespace: website-operator-system 20 | -------------------------------------------------------------------------------- /config/samples/docusaurus.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: website.zoetrope.github.io/v1beta1 2 | kind: WebSite 3 | metadata: 4 | name: docusaurus-sample 5 | namespace: default 6 | spec: 7 | buildImage: ghcr.io/zoetrope/node:18.12.1 8 | buildScript: 9 | rawData: | 10 | #!/bin/bash -ex 11 | cd $HOME 12 | rm -rf $REPO_NAME 13 | git clone $REPO_URL 14 | cd $REPO_NAME 15 | git checkout $REVISION 16 | 17 | npm install 18 | npm run build 19 | 20 | rm -rf $OUTPUT/* 21 | cp -r build/* $OUTPUT/ 22 | repoURL: https://github.com/zoetrope/docusaurus-sample.git 23 | branch: main 24 | publicURL: http://localhost:9191 25 | -------------------------------------------------------------------------------- /config/samples/honkit.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: website.zoetrope.github.io/v1beta1 2 | kind: WebSite 3 | metadata: 4 | name: honkit-sample 5 | namespace: default 6 | spec: 7 | buildImage: ghcr.io/zoetrope/node:18.12.1 8 | buildScript: 9 | configMap: 10 | name: build-scripts 11 | key: build-honkit.sh 12 | repoURL: https://github.com/zoetrope/honkit-sample.git 13 | branch: main 14 | publicURL: http://localhost:9090 15 | -------------------------------------------------------------------------------- /config/ui/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ui.yaml 3 | - service.yaml 4 | -------------------------------------------------------------------------------- /config/ui/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: website-operator-ui 6 | name: ui 7 | spec: 8 | ports: 9 | - name: web 10 | port: 8080 11 | protocol: TCP 12 | targetPort: 8080 13 | selector: 14 | app.kubernetes.io/name: website-operator-ui 15 | type: ClusterIP 16 | -------------------------------------------------------------------------------- /config/ui/ui.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: ui 5 | namespace: system 6 | labels: 7 | app.kubernetes.io/name: website-operator-ui 8 | spec: 9 | selector: 10 | matchLabels: 11 | app.kubernetes.io/name: website-operator-ui 12 | replicas: 1 13 | template: 14 | metadata: 15 | labels: 16 | app.kubernetes.io/name: website-operator-ui 17 | spec: 18 | containers: 19 | - command: 20 | - /website-operator-ui 21 | args: 22 | - --allow-cors=true 23 | image: ghcr.io/zoetrope/website-operator-ui:dev 24 | name: ui 25 | ports: 26 | - name: http 27 | containerPort: 8080 28 | protocol: TCP 29 | env: 30 | - name: POD_NAMESPACE 31 | valueFrom: 32 | fieldRef: 33 | fieldPath: metadata.namespace 34 | terminationGracePeriodSeconds: 10 35 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package website 2 | 3 | const ( 4 | DefaultNginxContainerImage = "ghcr.io/zoetrope/nginx:1.22.1" 5 | WebSiteIndexField = ".status.ready" 6 | ) 7 | 8 | var DefaultRepoCheckerContainerImage = "ghcr.io/zoetrope/repo-checker:" + Version 9 | -------------------------------------------------------------------------------- /controllers/common.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/cybozu-go/well" 10 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 11 | ) 12 | 13 | type RevisionClient interface { 14 | GetLatestRevision(ctx context.Context, webSite *websitev1beta1.WebSite) (string, error) 15 | } 16 | 17 | type RepoCheckerClient struct { 18 | } 19 | 20 | func (c RepoCheckerClient) GetLatestRevision(ctx context.Context, webSite *websitev1beta1.WebSite) (string, error) { 21 | repoCheckerHost := fmt.Sprintf("%s%s.%s.svc.cluster.local", webSite.Name, RepoCheckerSuffix, webSite.Namespace) 22 | req, err := http.NewRequestWithContext( 23 | ctx, 24 | http.MethodGet, 25 | fmt.Sprintf("http://%s/", repoCheckerHost), 26 | nil, 27 | ) 28 | if err != nil { 29 | return "", err 30 | } 31 | 32 | cli := &well.HTTPClient{Client: &http.Client{}} 33 | resp, err := cli.Do(req) 34 | if err != nil { 35 | return "", err 36 | } 37 | defer resp.Body.Close() 38 | 39 | if resp.StatusCode == http.StatusNotFound { 40 | return "", errRevisionNotReady 41 | } 42 | 43 | if resp.StatusCode != http.StatusOK { 44 | return "", fmt.Errorf("failed to repo check: %s", resp.Status) 45 | } 46 | 47 | b, err := ioutil.ReadAll(resp.Body) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | return string(b), nil 53 | } 54 | -------------------------------------------------------------------------------- /controllers/revision_watcher.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-logr/logr" 8 | "github.com/zoetrope/website-operator" 9 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 10 | corev1 "k8s.io/api/core/v1" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | "sigs.k8s.io/controller-runtime/pkg/event" 13 | "sigs.k8s.io/controller-runtime/pkg/manager" 14 | ) 15 | 16 | func newRevisionWatcher(client client.Client, log logr.Logger, ch chan<- event.TypedGenericEvent[*websitev1beta1.WebSite], interval time.Duration, revCli RevisionClient) manager.Runnable { 17 | return &revisionWatcher{ 18 | client: client, 19 | log: log, 20 | channel: ch, 21 | interval: interval, 22 | revisionClient: revCli, 23 | } 24 | } 25 | 26 | type revisionWatcher struct { 27 | client client.Client 28 | log logr.Logger 29 | channel chan<- event.TypedGenericEvent[*websitev1beta1.WebSite] 30 | interval time.Duration 31 | revisionClient RevisionClient 32 | } 33 | 34 | // Start implements Runnable.Start 35 | func (w revisionWatcher) Start(ctx context.Context) error { 36 | ticker := time.NewTicker(w.interval) 37 | defer ticker.Stop() 38 | for { 39 | select { 40 | case <-ctx.Done(): 41 | return ctx.Err() 42 | case <-ticker.C: 43 | err := w.revisionChanged(context.Background()) 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | } 49 | } 50 | 51 | func (w revisionWatcher) revisionChanged(ctx context.Context) error { 52 | sites := websitev1beta1.WebSiteList{} 53 | err := w.client.List(ctx, &sites, client.MatchingFields(map[string]string{website.WebSiteIndexField: string(corev1.ConditionTrue)})) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | for _, site := range sites.Items { 59 | latestRev, err := w.revisionClient.GetLatestRevision(ctx, &site) 60 | if err != nil { 61 | w.log.Error(err, "failed to get latest revision") 62 | continue 63 | } 64 | if site.Status.Revision == latestRev { 65 | continue 66 | } 67 | w.log.Info("revisionChanged", "currentRevision", site.Status.Revision, "latestRevision", latestRev) 68 | ev := event.TypedGenericEvent[*websitev1beta1.WebSite]{ 69 | Object: site.DeepCopy(), 70 | } 71 | w.channel <- ev 72 | } 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /controllers/suite_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 11 | "go.uber.org/zap/zapcore" 12 | corev1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 15 | "k8s.io/client-go/rest" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | "sigs.k8s.io/controller-runtime/pkg/envtest" 18 | logf "sigs.k8s.io/controller-runtime/pkg/log" 19 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 20 | //+kubebuilder:scaffold:imports 21 | ) 22 | 23 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 24 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 25 | 26 | var cfg *rest.Config 27 | var k8sClient client.Client 28 | var testEnv *envtest.Environment 29 | var scheme *runtime.Scheme = runtime.NewScheme() 30 | 31 | func TestAPIs(t *testing.T) { 32 | RegisterFailHandler(Fail) 33 | 34 | RunSpecs(t, "Controller Suite") 35 | } 36 | 37 | var _ = BeforeSuite(func() { 38 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.StacktraceLevel(zapcore.DPanicLevel), zap.UseDevMode(true))) 39 | 40 | By("bootstrapping test environment") 41 | testEnv = &envtest.Environment{ 42 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 43 | ErrorIfCRDPathMissing: true, 44 | } 45 | 46 | var err error 47 | cfg, err = testEnv.Start() 48 | Expect(err).NotTo(HaveOccurred()) 49 | Expect(cfg).NotTo(BeNil()) 50 | 51 | err = websitev1beta1.AddToScheme(scheme) 52 | Expect(err).NotTo(HaveOccurred()) 53 | err = clientgoscheme.AddToScheme(scheme) 54 | Expect(err).NotTo(HaveOccurred()) 55 | 56 | //+kubebuilder:scaffold:scheme 57 | 58 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme}) 59 | Expect(err).NotTo(HaveOccurred()) 60 | Expect(k8sClient).NotTo(BeNil()) 61 | 62 | ns := &corev1.Namespace{} 63 | ns.Name = "website-operator-system" 64 | err = k8sClient.Create(context.Background(), ns) 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | ns = &corev1.Namespace{} 68 | ns.Name = "test" 69 | err = k8sClient.Create(context.Background(), ns) 70 | Expect(err).NotTo(HaveOccurred()) 71 | 72 | }) 73 | 74 | var _ = AfterSuite(func() { 75 | By("tearing down the test environment") 76 | err := testEnv.Stop() 77 | Expect(err).NotTo(HaveOccurred()) 78 | }) 79 | 80 | type mockRevisionClient struct { 81 | rev string 82 | } 83 | 84 | func (c mockRevisionClient) GetLatestRevision(ctx context.Context, webSite *websitev1beta1.WebSite) (string, error) { 85 | return c.rev, nil 86 | } 87 | -------------------------------------------------------------------------------- /controllers/website_controller_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | . "github.com/onsi/gomega/gstruct" 11 | "github.com/zoetrope/website-operator" 12 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 13 | appsv1 "k8s.io/api/apps/v1" 14 | batchv1 "k8s.io/api/batch/v1" 15 | corev1 "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/utils/pointer" 18 | "k8s.io/utils/ptr" 19 | ctrl "sigs.k8s.io/controller-runtime" 20 | "sigs.k8s.io/controller-runtime/pkg/client" 21 | "sigs.k8s.io/controller-runtime/pkg/config" 22 | ) 23 | 24 | var _ = Describe("WebSite controller", func() { 25 | 26 | ctx := context.Background() 27 | var stopFunc func() 28 | var mockClient mockRevisionClient 29 | 30 | BeforeEach(func() { 31 | err := k8sClient.DeleteAllOf(ctx, &websitev1beta1.WebSite{}, client.InNamespace("test")) 32 | Expect(err).NotTo(HaveOccurred()) 33 | err = k8sClient.DeleteAllOf(ctx, &corev1.ConfigMap{}, client.InNamespace("test")) 34 | Expect(err).NotTo(HaveOccurred()) 35 | err = k8sClient.DeleteAllOf(ctx, &appsv1.Deployment{}, client.InNamespace("test")) 36 | Expect(err).NotTo(HaveOccurred()) 37 | err = k8sClient.DeleteAllOf(ctx, &batchv1.Job{}, client.InNamespace("test"), client.PropagationPolicy(metav1.DeletePropagationBackground)) 38 | Expect(err).NotTo(HaveOccurred()) 39 | svcs := &corev1.ServiceList{} 40 | err = k8sClient.List(ctx, svcs, client.InNamespace("test")) 41 | Expect(err).NotTo(HaveOccurred()) 42 | for _, svc := range svcs.Items { 43 | err := k8sClient.Delete(ctx, &svc) 44 | Expect(err).NotTo(HaveOccurred()) 45 | } 46 | time.Sleep(100 * time.Millisecond) 47 | 48 | mgr, err := ctrl.NewManager(cfg, ctrl.Options{ 49 | Scheme: scheme, 50 | Controller: config.Controller{ 51 | SkipNameValidation: ptr.To(true), 52 | }, 53 | }) 54 | Expect(err).ToNot(HaveOccurred()) 55 | 56 | mockClient = mockRevisionClient{"rev1"} 57 | err = NewWebSiteReconciler( 58 | k8sClient, 59 | ctrl.Log.WithName("controllers").WithName("WebSite"), 60 | scheme, 61 | website.DefaultNginxContainerImage, 62 | website.DefaultRepoCheckerContainerImage, 63 | "website-operator-system", 64 | &mockClient, 65 | ).SetupWithManager(mgr) 66 | Expect(err).NotTo(HaveOccurred()) 67 | 68 | ctx, cancel := context.WithCancel(ctx) 69 | stopFunc = cancel 70 | go func() { 71 | err := mgr.Start(ctx) 72 | if err != nil { 73 | panic(err) 74 | } 75 | }() 76 | time.Sleep(100 * time.Millisecond) 77 | }) 78 | 79 | AfterEach(func() { 80 | stopFunc() 81 | time.Sleep(100 * time.Millisecond) 82 | }) 83 | 84 | Context("BuildScript", func() { 85 | It("should create buildScript ConfigMap from raw data", func() { 86 | site := newWebSite().withRawBuildScript().build() 87 | err := k8sClient.Create(ctx, site) 88 | Expect(err).NotTo(HaveOccurred()) 89 | 90 | cm := corev1.ConfigMap{} 91 | Eventually(func() error { 92 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite-build-script"}, &cm) 93 | }).Should(Succeed()) 94 | Expect(cm.Data).Should(HaveKey("build.sh")) 95 | }) 96 | 97 | It("should create buildScript ConfigMap from ConfigMap", func() { 98 | buildScript := `#!/bin/bash -ex 99 | cd $HOME 100 | git clone $REPO_URL 101 | cd $REPO_NAME 102 | git checkout $REVISION 103 | npm install && npm run build 104 | cp -r _book/* $OUTPUT/ 105 | ` 106 | bsCm := &corev1.ConfigMap{} 107 | bsCm.Name = "myscript" 108 | bsCm.Namespace = "website-operator-system" 109 | bsCm.Data = map[string]string{ 110 | "script": buildScript, 111 | } 112 | 113 | err := k8sClient.Create(ctx, bsCm) 114 | Expect(err).NotTo(HaveOccurred()) 115 | 116 | site := newWebSite().withConfigMapBuildScript().build() 117 | err = k8sClient.Create(ctx, site) 118 | Expect(err).NotTo(HaveOccurred()) 119 | 120 | cm := corev1.ConfigMap{} 121 | Eventually(func() error { 122 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite-build-script"}, &cm) 123 | }).Should(Succeed()) 124 | Expect(cm.Data).Should(HaveKey("build.sh")) 125 | }) 126 | }) 127 | 128 | Context("RepoChecker", func() { 129 | It("should create RepoChecker Deployment", func() { 130 | site := newWebSite().withRawBuildScript().build() 131 | err := k8sClient.Create(ctx, site) 132 | Expect(err).NotTo(HaveOccurred()) 133 | 134 | dep := appsv1.Deployment{} 135 | Eventually(func() error { 136 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite-repo-checker"}, &dep) 137 | }).Should(Succeed()) 138 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 139 | Expect(dep.Spec.Template.Spec.Containers[0].Name).Should(Equal("repo-checker")) 140 | Expect(dep.Spec.Template.Spec.Containers[0].Command).Should(ContainElement("--repo-branch=main")) 141 | Expect(dep.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 142 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 143 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 144 | }) 145 | 146 | It("should create RepoChecker Deployment with DeployKey", func() { 147 | site := newWebSite().withRawBuildScript().withDeployKey().build() 148 | err := k8sClient.Create(ctx, site) 149 | Expect(err).NotTo(HaveOccurred()) 150 | 151 | dep := appsv1.Deployment{} 152 | Eventually(func() error { 153 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite-repo-checker"}, &dep) 154 | }).Should(Succeed()) 155 | 156 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 157 | Expect(dep.Spec.Template.Spec.Containers[0].Name).Should(Equal("repo-checker")) 158 | Expect(dep.Spec.Template.Spec.Containers[0].Command).Should(ContainElement("--repo-branch=main")) 159 | Expect(dep.Spec.Template.Spec.Containers[0].VolumeMounts).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 160 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 161 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 162 | }) 163 | 164 | It("should create RepoChecker Deployment with ImagePullSecrets", func() { 165 | site := newWebSite().withRawBuildScript().withImagePullSecrets().build() 166 | err := k8sClient.Create(ctx, site) 167 | Expect(err).NotTo(HaveOccurred()) 168 | 169 | dep := appsv1.Deployment{} 170 | Eventually(func() error { 171 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite-repo-checker"}, &dep) 172 | }).Should(Succeed()) 173 | 174 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 175 | Expect(dep.Spec.Template.Spec.Containers[0].Name).Should(Equal("repo-checker")) 176 | Expect(dep.Spec.Template.Spec.Containers[0].Command).Should(ContainElement("--repo-branch=main")) 177 | Expect(dep.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 178 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 179 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("myimagepullsecret")}))) 180 | }) 181 | 182 | It("should create RepoChecker Service", func() { 183 | site := newWebSite().withRawBuildScript().build() 184 | err := k8sClient.Create(ctx, site) 185 | Expect(err).NotTo(HaveOccurred()) 186 | 187 | svc := corev1.Service{} 188 | Eventually(func() error { 189 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite-repo-checker"}, &svc) 190 | }).Should(Succeed()) 191 | }) 192 | }) 193 | 194 | Context("Nginx", func() { 195 | It("should create Nginx Deployment", func() { 196 | site := newWebSite().withRawBuildScript().build() 197 | err := k8sClient.Create(ctx, site) 198 | Expect(err).NotTo(HaveOccurred()) 199 | 200 | dep := appsv1.Deployment{} 201 | Eventually(func() error { 202 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &dep) 203 | }).Should(Succeed()) 204 | Expect(*dep.Spec.Replicas).Should(BeNumerically("==", 1)) 205 | Expect(dep.Spec.Template.Labels).Should(HaveLen(3)) 206 | Expect(dep.Spec.Template.Annotations).Should(HaveLen(1)) 207 | Expect(dep.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 208 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 209 | Expect(dep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) 210 | Expect(dep.Spec.Template.Spec.InitContainers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 211 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 212 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("build-script")}))) 213 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 214 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 215 | }) 216 | 217 | It("should create Nginx Deployment with DeployKey", func() { 218 | site := newWebSite().withRawBuildScript().withDeployKey().build() 219 | err := k8sClient.Create(ctx, site) 220 | Expect(err).NotTo(HaveOccurred()) 221 | 222 | dep := appsv1.Deployment{} 223 | Eventually(func() error { 224 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &dep) 225 | }).Should(Succeed()) 226 | Expect(*dep.Spec.Replicas).Should(BeNumerically("==", 1)) 227 | Expect(dep.Spec.Template.Labels).Should(HaveLen(3)) 228 | Expect(dep.Spec.Template.Annotations).Should(HaveLen(1)) 229 | Expect(dep.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 230 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 231 | Expect(dep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) 232 | Expect(dep.Spec.Template.Spec.InitContainers[0].VolumeMounts).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 233 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 234 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("VAR_KEY")}))) 235 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("build-script")}))) 236 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 237 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 238 | }) 239 | 240 | It("should create Nginx Deployment with ImageSecretes", func() { 241 | site := newWebSite().withRawBuildScript().withImagePullSecrets().build() 242 | err := k8sClient.Create(ctx, site) 243 | Expect(err).NotTo(HaveOccurred()) 244 | 245 | dep := appsv1.Deployment{} 246 | Eventually(func() error { 247 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &dep) 248 | }).Should(Succeed()) 249 | Expect(*dep.Spec.Replicas).Should(BeNumerically("==", 1)) 250 | Expect(dep.Spec.Template.Labels).Should(HaveLen(3)) 251 | Expect(dep.Spec.Template.Annotations).Should(HaveLen(1)) 252 | Expect(dep.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 253 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 254 | Expect(dep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) 255 | Expect(dep.Spec.Template.Spec.InitContainers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 256 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 257 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("VAR_KEY")}))) 258 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("build-script")}))) 259 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 260 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("myimagepullsecret")}))) 261 | }) 262 | 263 | It("should create Nginx Deployment with BuildSecretes", func() { 264 | site := newWebSite().withRawBuildScript().withBuildSecrets().build() 265 | err := k8sClient.Create(ctx, site) 266 | Expect(err).NotTo(HaveOccurred()) 267 | 268 | dep := appsv1.Deployment{} 269 | Eventually(func() error { 270 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &dep) 271 | }).Should(Succeed()) 272 | Expect(*dep.Spec.Replicas).Should(BeNumerically("==", 1)) 273 | Expect(dep.Spec.Template.Labels).Should(HaveLen(3)) 274 | Expect(dep.Spec.Template.Annotations).Should(HaveLen(1)) 275 | Expect(dep.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 276 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 277 | Expect(dep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) 278 | Expect(dep.Spec.Template.Spec.InitContainers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 279 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 280 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("VAR_KEY"), "ValueFrom": Equal(&corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "mybuildsecret"}, Key: "VAR_KEY"}})}))) 281 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("build-script")}))) 282 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 283 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 284 | }) 285 | 286 | It("should create Nginx Deployment with Replicas", func() { 287 | site := newWebSite().withRawBuildScript().withReplicas(3).build() 288 | err := k8sClient.Create(ctx, site) 289 | Expect(err).NotTo(HaveOccurred()) 290 | 291 | dep := appsv1.Deployment{} 292 | Eventually(func() error { 293 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &dep) 294 | }).Should(Succeed()) 295 | Expect(*dep.Spec.Replicas).Should(BeNumerically("==", 3)) 296 | Expect(dep.Spec.Template.Labels).Should(HaveLen(3)) 297 | Expect(dep.Spec.Template.Annotations).Should(HaveLen(1)) 298 | Expect(dep.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 299 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 300 | Expect(dep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) 301 | Expect(dep.Spec.Template.Spec.InitContainers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 302 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 303 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("build-script")}))) 304 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 305 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 306 | }) 307 | 308 | It("should create Nginx Deployment with PodTemplate", func() { 309 | site := newWebSite().withRawBuildScript().withPodTemplate().build() 310 | err := k8sClient.Create(ctx, site) 311 | Expect(err).NotTo(HaveOccurred()) 312 | 313 | dep := appsv1.Deployment{} 314 | Eventually(func() error { 315 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &dep) 316 | }).Should(Succeed()) 317 | Expect(*dep.Spec.Replicas).Should(BeNumerically("==", 1)) 318 | Expect(dep.Spec.Template.Labels).Should(HaveLen(4)) 319 | Expect(dep.Spec.Template.Labels).Should(HaveKey("mylabel")) 320 | Expect(dep.Spec.Template.Annotations).Should(HaveLen(2)) 321 | Expect(dep.Spec.Template.Annotations).Should(HaveKey("myann")) 322 | Expect(dep.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 323 | Expect(dep.Spec.Template.Spec.Containers).Should(HaveLen(1)) 324 | Expect(dep.Spec.Template.Spec.InitContainers).Should(HaveLen(1)) 325 | Expect(dep.Spec.Template.Spec.InitContainers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 326 | Expect(dep.Spec.Template.Spec.InitContainers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 327 | Expect(dep.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("build-script")}))) 328 | Expect(dep.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 329 | Expect(dep.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 330 | }) 331 | 332 | It("should create Nginx Service with ServiceTemplate", func() { 333 | site := newWebSite().withRawBuildScript().build() 334 | err := k8sClient.Create(ctx, site) 335 | Expect(err).NotTo(HaveOccurred()) 336 | 337 | svc := corev1.Service{} 338 | Eventually(func() error { 339 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &svc) 340 | }).Should(Succeed()) 341 | Expect(svc.Labels).Should(HaveLen(2)) 342 | Expect(svc.Annotations).Should(HaveLen(0)) 343 | }) 344 | 345 | It("should create Nginx Service with ServiceTemplate", func() { 346 | site := newWebSite().withRawBuildScript().withServiceTemplate().build() 347 | err := k8sClient.Create(ctx, site) 348 | Expect(err).NotTo(HaveOccurred()) 349 | 350 | svc := corev1.Service{} 351 | Eventually(func() error { 352 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &svc) 353 | }).Should(Succeed()) 354 | Expect(svc.Labels).Should(HaveLen(3)) 355 | Expect(svc.Labels).Should(HaveKey("mylabel")) 356 | Expect(svc.Annotations).Should(HaveLen(1)) 357 | Expect(svc.Annotations).Should(HaveKey("myann")) 358 | }) 359 | }) 360 | 361 | Context("ExtraResources", func() { 362 | It("should create extraResources", func() { 363 | site := newWebSite().withRawBuildScript().withExtraResources().build() 364 | err := k8sClient.Create(ctx, site) 365 | Expect(err).NotTo(HaveOccurred()) 366 | 367 | cm := corev1.ConfigMap{} 368 | cm.Namespace = site.Namespace 369 | cm.Name = "my-templates" 370 | cm.Data = map[string]string{ 371 | "ubuntu": `apiVersion: v1 372 | kind: Pod 373 | metadata: 374 | name: {{.ResourceName}}-ubuntu 375 | namespace: unknown 376 | spec: 377 | containers: 378 | - name: ubuntu 379 | image: ghcr.io/zoetrope/ubuntu:20.04 380 | command: ["/usr/local/bin/pause"] 381 | `, 382 | } 383 | err = k8sClient.Create(ctx, &cm) 384 | Expect(err).ShouldNot(HaveOccurred()) 385 | 386 | pod := corev1.Pod{} 387 | Eventually(func() error { 388 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: site.Namespace, Name: site.Name + "-ubuntu"}, &pod) 389 | }).Should(Succeed()) 390 | }) 391 | }) 392 | 393 | Context("AfterBuildcript", func() { 394 | It("should create afterBuildScript job", func() { 395 | site := newWebSite().withRawBuildScript().withAfterBuildScript().build() 396 | err := k8sClient.Create(ctx, site) 397 | Expect(err).NotTo(HaveOccurred()) 398 | 399 | job := batchv1.Job{} 400 | Eventually(func() error { 401 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &job) 402 | }).Should(Succeed()) 403 | 404 | Expect(job.Spec.Template.Spec.Containers).Should(HaveLen(1)) 405 | Expect(job.Spec.Template.Labels).Should(HaveLen(4)) 406 | Expect(job.Spec.Template.Annotations).Should(HaveLen(1)) 407 | Expect(job.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 408 | Expect(job.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 409 | Expect(job.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 410 | Expect(job.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("after-build-script")}))) 411 | Expect(job.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 412 | Expect(job.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 413 | }) 414 | 415 | It("should create afterBuildscript job with deploy key", func() { 416 | site := newWebSite().withRawBuildScript().withDeployKey().withAfterBuildScript().build() 417 | err := k8sClient.Create(ctx, site) 418 | Expect(err).NotTo(HaveOccurred()) 419 | 420 | job := batchv1.Job{} 421 | Eventually(func() error { 422 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &job) 423 | }).Should(Succeed()) 424 | 425 | Expect(job.Spec.Template.Spec.Containers).Should(HaveLen(1)) 426 | Expect(job.Spec.Template.Labels).Should(HaveLen(4)) 427 | Expect(job.Spec.Template.Annotations).Should(HaveLen(1)) 428 | Expect(job.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 429 | Expect(job.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 430 | Expect(job.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 431 | Expect(job.Spec.Template.Spec.Containers[0].VolumeMounts).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 432 | Expect(job.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("after-build-script")}))) 433 | Expect(job.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 434 | }) 435 | 436 | It("should create afterBuildScirpt job with Image Secrets", func() { 437 | site := newWebSite().withRawBuildScript().withImagePullSecrets().withAfterBuildScript().build() 438 | err := k8sClient.Create(ctx, site) 439 | Expect(err).NotTo(HaveOccurred()) 440 | 441 | job := batchv1.Job{} 442 | Eventually(func() error { 443 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &job) 444 | }).Should(Succeed()) 445 | 446 | Expect(job.Spec.Template.Spec.Containers).Should(HaveLen(1)) 447 | Expect(job.Spec.Template.Labels).Should(HaveLen(4)) 448 | Expect(job.Spec.Template.Annotations).Should(HaveLen(1)) 449 | Expect(job.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 450 | Expect(job.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 451 | Expect(job.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 452 | Expect(job.Spec.Template.Spec.Containers[0].Env).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("VAR_KEY")}))) 453 | Expect(job.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("after-build-script")}))) 454 | Expect(job.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 455 | Expect(job.Spec.Template.Spec.ImagePullSecrets).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("myimagepullsecret")}))) 456 | }) 457 | 458 | It("should create afterBuildscript job with Build Secrets", func() { 459 | site := newWebSite().withRawBuildScript().withBuildSecrets().withAfterBuildScript().build() 460 | err := k8sClient.Create(ctx, site) 461 | Expect(err).NotTo(HaveOccurred()) 462 | 463 | job := batchv1.Job{} 464 | Eventually(func() error { 465 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &job) 466 | }).Should(Succeed()) 467 | 468 | Expect(job.Spec.Template.Spec.Containers).Should(HaveLen(1)) 469 | Expect(job.Spec.Template.Labels).Should(HaveLen(4)) 470 | Expect(job.Spec.Template.Annotations).Should(HaveLen(1)) 471 | Expect(job.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 472 | Expect(job.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 473 | Expect(job.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 474 | Expect(job.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("VAR_KEY"), "ValueFrom": Equal(&corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "mybuildsecret"}, Key: "VAR_KEY"}})}))) 475 | Expect(job.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("after-build-script")}))) 476 | Expect(job.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 477 | Expect(job.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 478 | }) 479 | 480 | It("should recreate afterBuildscript job when job exists and revision is updated ", func() { 481 | site := newWebSite().withRawBuildScript().withAfterBuildScript().build() 482 | err := k8sClient.Create(ctx, site) 483 | Expect(err).NotTo(HaveOccurred()) 484 | 485 | job := batchv1.Job{} 486 | Eventually(func() error { 487 | return k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &job) 488 | }).Should(Succeed()) 489 | 490 | Expect(job.Spec.Template.Spec.Containers).Should(HaveLen(1)) 491 | Expect(job.Spec.Template.Labels).Should(HaveLen(4)) 492 | Expect(job.Spec.Template.Annotations).Should(HaveLen(1)) 493 | Expect(job.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 494 | Expect(job.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev1")}))) 495 | Expect(job.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("after-build-script")}))) 496 | Expect(job.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 497 | Expect(job.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 498 | Expect(job.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 499 | 500 | mockClient.rev = "rev2" 501 | newJob := batchv1.Job{} 502 | Eventually(func() (bool, error) { 503 | err := k8sClient.Get(ctx, client.ObjectKey{Namespace: "test", Name: "mysite"}, &newJob) 504 | if err != nil { 505 | return false, fmt.Errorf("error %v", err) 506 | } 507 | return newJob.ObjectMeta.UID != job.ObjectMeta.UID, nil 508 | }, 60).Should(BeTrue()) 509 | 510 | Expect(newJob.Spec.Template.Spec.Containers).Should(HaveLen(1)) 511 | Expect(job.Spec.Template.Labels).Should(HaveLen(4)) 512 | Expect(newJob.Spec.Template.Annotations).Should(HaveLen(1)) 513 | Expect(newJob.Spec.Template.Annotations).Should(HaveKey(AnnChecksumConfig)) 514 | Expect(newJob.Spec.Template.Spec.Containers[0].Env).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("REVISION"), "Value": Equal("rev2")}))) 515 | Expect(newJob.Spec.Template.Spec.Volumes).Should(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("after-build-script")}))) 516 | Expect(newJob.Spec.Template.Spec.Volumes).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 517 | Expect(newJob.Spec.Template.Spec.Containers[0].VolumeMounts).ShouldNot(ContainElement(MatchFields(IgnoreExtras, Fields{"Name": Equal("deploy-key")}))) 518 | Expect(newJob.Spec.Template.Spec.ImagePullSecrets).Should(BeEmpty()) 519 | }) 520 | }) 521 | }) 522 | 523 | type websiteBuilder struct { 524 | website *websitev1beta1.WebSite 525 | } 526 | 527 | func (b *websiteBuilder) build() *websitev1beta1.WebSite { 528 | return b.website 529 | } 530 | 531 | func newWebSite() *websiteBuilder { 532 | site := &websitev1beta1.WebSite{ 533 | TypeMeta: metav1.TypeMeta{ 534 | Kind: "WebSite", 535 | APIVersion: websitev1beta1.GroupVersion.String(), 536 | }, 537 | ObjectMeta: metav1.ObjectMeta{ 538 | Name: "mysite", 539 | Namespace: "test", 540 | }, 541 | Spec: websitev1beta1.WebSiteSpec{ 542 | BuildImage: "ghcr.io/zoetrope/node:18.12.1", 543 | RepoURL: "https://github.com/zoetrope/honkit-sample.git", 544 | Branch: "main", 545 | }, 546 | } 547 | return &websiteBuilder{site} 548 | } 549 | 550 | func (b *websiteBuilder) withRawBuildScript() *websiteBuilder { 551 | buildScript := `#!/bin/bash -ex 552 | cd $HOME 553 | git clone $REPO_URL 554 | cd $REPO_NAME 555 | git checkout $REVISION 556 | npm install && npm run build 557 | cp -r _book/* $OUTPUT/ 558 | ` 559 | b.website.Spec.BuildScript = websitev1beta1.DataSource{ 560 | RawData: &buildScript, 561 | } 562 | 563 | return b 564 | } 565 | 566 | func (b *websiteBuilder) withAfterBuildScript() *websiteBuilder { 567 | afterBuildScript := ` #!/bin/bash -ex 568 | cd $HOME 569 | rm -rf $REPO_NAME 570 | git clone $REPO_URL 571 | cd $REPO_NAME 572 | git checkout $REVISION 573 | sed -i -e "/host/c\ \"host\": \"http://${RESOURCE_NAME}.${RESOURCE_NAMESPACE}.example.com/es\"," book.js 574 | sed -i -e "/index/c\ \"index\": \"${RESOURCE_NAME}-${REVISION}\"," book.js 575 | npm install 576 | npm run build 577 | curl -X DELETE ${ELASTIC_HOST}/${RESOURCE_NAME}-${REVISION} 578 | curl -X PUT ${ELASTIC_HOST}/${RESOURCE_NAME}-${REVISION} -H 'Content-Type: application/json' -d @mappings.json 579 | curl -X POST ${ELASTIC_HOST}/${RESOURCE_NAME}-${REVISION}/_bulk -H 'Content-Type: application/json' --data-binary @_book/search_index.json 580 | ` 581 | b.website.Spec.AfterBuildScript = &websitev1beta1.DataSource{ 582 | RawData: &afterBuildScript, 583 | } 584 | 585 | return b 586 | } 587 | 588 | func (b *websiteBuilder) withConfigMapBuildScript() *websiteBuilder { 589 | b.website.Spec.BuildScript = websitev1beta1.DataSource{ 590 | ConfigMap: &websitev1beta1.ConfigMapSource{ 591 | Name: "myscript", 592 | Namespace: "website-operator-system", 593 | Key: "script", 594 | }, 595 | } 596 | 597 | return b 598 | } 599 | 600 | func (b *websiteBuilder) withDeployKey() *websiteBuilder { 601 | b.website.Spec.DeployKeySecretName = pointer.StringPtr("mydeploykey") 602 | return b 603 | } 604 | 605 | func (b *websiteBuilder) withImagePullSecrets() *websiteBuilder { 606 | b.website.Spec.ImagePullSecrets = []corev1.LocalObjectReference{ 607 | { 608 | Name: "myimagepullsecret", 609 | }, 610 | } 611 | return b 612 | } 613 | 614 | func (b *websiteBuilder) withBuildSecrets() *websiteBuilder { 615 | b.website.Spec.BuildSecrets = []websitev1beta1.SecretKey{ 616 | { 617 | Name: "mybuildsecret", 618 | Key: "VAR_KEY", 619 | }, 620 | } 621 | return b 622 | } 623 | 624 | func (b *websiteBuilder) withReplicas(rep int32) *websiteBuilder { 625 | b.website.Spec.Replicas = rep 626 | return b 627 | } 628 | 629 | func (b *websiteBuilder) withPodTemplate() *websiteBuilder { 630 | b.website.Spec.PodTemplate = &websitev1beta1.PodTemplate{ 631 | ObjectMeta: websitev1beta1.ObjectMeta{ 632 | Labels: map[string]string{ 633 | "mylabel": "foo", 634 | }, 635 | Annotations: map[string]string{ 636 | "myann": "bar", 637 | }, 638 | }, 639 | } 640 | return b 641 | } 642 | 643 | func (b *websiteBuilder) withServiceTemplate() *websiteBuilder { 644 | b.website.Spec.ServiceTemplate = &websitev1beta1.ServiceTemplate{ 645 | ObjectMeta: websitev1beta1.ObjectMeta{ 646 | Labels: map[string]string{ 647 | "mylabel": "foo", 648 | }, 649 | Annotations: map[string]string{ 650 | "myann": "bar", 651 | }, 652 | }, 653 | } 654 | return b 655 | } 656 | 657 | func (b *websiteBuilder) withExtraResources() *websiteBuilder { 658 | b.website.Spec.ExtraResources = []websitev1beta1.DataSource{ 659 | { 660 | ConfigMap: &websitev1beta1.ConfigMapSource{ 661 | Name: "my-templates", 662 | Namespace: "test", 663 | Key: "ubuntu", 664 | }, 665 | }, 666 | } 667 | return b 668 | } 669 | -------------------------------------------------------------------------------- /cr.yaml: -------------------------------------------------------------------------------- 1 | owner: zoetrope 2 | git-repo: website-operator 3 | release-name-template: "{{ .Name }}-chart-{{ .Version }}" 4 | make-release-latest: false 5 | release-notes-file: RELEASE.md 6 | -------------------------------------------------------------------------------- /e2e/Makefile: -------------------------------------------------------------------------------- 1 | KUBERNETES_VERSION := v1.30.10 # renovate: kindest/node 2 | REGISTRY := ghcr.io/zoetrope/ 3 | KIND_CLUSTER_NAME=website-e2e 4 | 5 | GO_FILES := $(shell find .. -path ../vendor -prune -o -path ../e2e -prune -o -name '*.go' -print) 6 | 7 | launch-kind: 8 | if [ ! "$(shell kind get clusters | grep $(KIND_CLUSTER_NAME))" ]; then \ 9 | kind create cluster --name=$(KIND_CLUSTER_NAME) --config kind-config.yaml --image kindest/node:$(KUBERNETES_VERSION) --wait 180s; \ 10 | fi 11 | 12 | shutdown-kind: 13 | if [ "$(shell kind get clusters | grep $(KIND_CLUSTER_NAME))" ]; then \ 14 | kind delete cluster --name=$(KIND_CLUSTER_NAME) || true; \ 15 | fi 16 | 17 | setup-cluster: 18 | kubectl apply -f https://projectcontour.io/quickstart/contour.yaml 19 | kubectl wait -n projectcontour --for condition=available --all deployments --timeout 180s 20 | 21 | test: launch-kind load-images setup-cluster 22 | kubectl config use-context kind-$(KIND_CLUSTER_NAME) 23 | kustomize build ./manifests/manager | kubectl apply -f - 24 | kubectl wait pod -l control-plane=controller-manager -n website-operator-system --for condition=Ready --timeout 180s 25 | kustomize build ./manifests/website | kubectl apply -f - 26 | env E2E_TEST=1 go test -count=1 -v . -args -ginkgo.v -ginkgo.fail-fast 27 | 28 | .PHONY: load-images 29 | load-images: 30 | cd ../ && goreleaser release --clean --snapshot --skip=publish 31 | ID=$$(docker image inspect --format='{{.ID}}' $(REGISTRY)website-operator:dev-amd64); \ 32 | if [ ! "$$(docker exec -it $(KIND_CLUSTER_NAME)-control-plane ctr --namespace=k8s.io images list | grep $$ID)" ]; then \ 33 | kind load docker-image --name=$(KIND_CLUSTER_NAME) $(REGISTRY)website-operator:dev-amd64; \ 34 | fi 35 | ID=$$(docker image inspect --format='{{.ID}}' $(REGISTRY)repo-checker:dev-amd64); \ 36 | if [ ! "$$(docker exec -it $(KIND_CLUSTER_NAME)-control-plane ctr --namespace=k8s.io images list | grep $$ID)" ]; then \ 37 | kind load docker-image --name=$(KIND_CLUSTER_NAME) $(REGISTRY)repo-checker:dev-amd64; \ 38 | fi 39 | ID=$$(docker image inspect --format='{{.ID}}' $(REGISTRY)website-operator-ui:dev-amd64); \ 40 | if [ ! "$$(docker exec -it $(KIND_CLUSTER_NAME)-control-plane ctr --namespace=k8s.io images list | grep $$ID)" ]; then \ 41 | kind load docker-image --name=$(KIND_CLUSTER_NAME) $(REGISTRY)website-operator-ui:dev-amd64; \ 42 | fi 43 | -------------------------------------------------------------------------------- /e2e/bootstrap_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 12 | appsv1 "k8s.io/api/apps/v1" 13 | batchv1 "k8s.io/api/batch/v1" 14 | corev1 "k8s.io/api/core/v1" 15 | ) 16 | 17 | func testWebSite(name string) { 18 | var site websitev1beta1.WebSite 19 | Eventually(func() error { 20 | err := getResource("default", "website", name, "", &site) 21 | if err != nil { 22 | return err 23 | } 24 | if site.Status.Ready != corev1.ConditionTrue { 25 | return fmt.Errorf("%s should be ready", name) 26 | } 27 | return nil 28 | }, 3*time.Minute).Should(Succeed()) 29 | 30 | var deployment appsv1.Deployment 31 | Eventually(func() error { 32 | err := getResource("default", "deployment", name, "", &deployment) 33 | if err != nil { 34 | return err 35 | } 36 | if deployment.Status.AvailableReplicas != 1 { 37 | return errors.New("should be ready") 38 | } 39 | return nil 40 | }, 5*time.Minute).Should(Succeed()) 41 | 42 | if site.Spec.AfterBuildScript != nil { 43 | job := &batchv1.Job{} 44 | Eventually(func() error { 45 | err := getResource("default", "job", name, "", job) 46 | if err != nil { 47 | return err 48 | } 49 | if job.Status.Succeeded != 1 { 50 | return errors.New("should be ready") 51 | } 52 | return nil 53 | }, 5*time.Minute).Should(Succeed()) 54 | } 55 | 56 | req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1", nil) 57 | Expect(err).ShouldNot(HaveOccurred()) 58 | req.Host = name + ".default.example.com" 59 | client := http.Client{} 60 | Eventually(func() error { 61 | res, err := client.Do(req) 62 | if err != nil { 63 | return err 64 | } 65 | if res.StatusCode != http.StatusOK { 66 | return fmt.Errorf("status should be ok: %s", res.Status) 67 | } 68 | return nil 69 | }, 30*time.Second).Should(Succeed()) 70 | } 71 | 72 | func testBootstrap() { 73 | It("should launch honkit-sample", func() { 74 | testWebSite("honkit-sample") 75 | }) 76 | 77 | It("should launch mkdocs-sample", func() { 78 | testWebSite("mkdocs-sample") 79 | }) 80 | 81 | It("should launch gatsby-sample", func() { 82 | testWebSite("gatsby-sample") 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /e2e/kind-config.yaml: -------------------------------------------------------------------------------- 1 | kind: Cluster 2 | apiVersion: kind.x-k8s.io/v1alpha4 3 | nodes: 4 | - role: control-plane 5 | kubeadmConfigPatches: 6 | - | 7 | kind: InitConfiguration 8 | nodeRegistration: 9 | kubeletExtraArgs: 10 | node-labels: "ingress-ready=true" 11 | extraPortMappings: 12 | - containerPort: 80 13 | hostPort: 80 14 | protocol: TCP 15 | - containerPort: 443 16 | hostPort: 443 17 | protocol: TCP 18 | -------------------------------------------------------------------------------- /e2e/manifests/manager/after-build-honkit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | cd $HOME 3 | rm -rf $REPO_NAME 4 | git clone $REPO_URL 5 | cd $REPO_NAME 6 | git checkout $REVISION 7 | 8 | npm install 9 | npm run build 10 | -------------------------------------------------------------------------------- /e2e/manifests/manager/build-gatsby.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | cd $HOME 3 | rm -rf $REPO_NAME 4 | git clone $REPO_URL 5 | cd $REPO_NAME 6 | git checkout $REVISION 7 | 8 | npm install 9 | npm run build 10 | 11 | rm -rf $OUTPUT/* 12 | cp -r public/* $OUTPUT/ 13 | -------------------------------------------------------------------------------- /e2e/manifests/manager/build-honkit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | cd $HOME 3 | rm -rf $REPO_NAME 4 | git clone $REPO_URL 5 | cd $REPO_NAME 6 | git checkout $REVISION 7 | 8 | npm install 9 | npm run build 10 | 11 | rm -rf $OUTPUT/* 12 | cp -r _book/* $OUTPUT/ 13 | -------------------------------------------------------------------------------- /e2e/manifests/manager/build-mkdocs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | cd $HOME 3 | rm -rf $REPO_NAME 4 | git clone $REPO_URL 5 | cd $REPO_NAME 6 | git checkout $REVISION 7 | 8 | pip3 install -r requirements.txt 9 | export PATH=$PATH:$HOME/.local/bin 10 | mkdocs build 11 | 12 | rm -rf $OUTPUT/* 13 | cp -r site/* $OUTPUT/ 14 | -------------------------------------------------------------------------------- /e2e/manifests/manager/create-honkit-es-index.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | cd $HOME 3 | rm -rf $REPO_NAME 4 | git clone $REPO_URL 5 | cd $REPO_NAME 6 | git checkout $REVISION 7 | 8 | sed -i -e "/host/c\ \"host\": \"http://${RESOURCE_NAME}.${RESOURCE_NAMESPACE}.example.com/es\"," book.js 9 | sed -i -e "/index/c\ \"index\": \"${RESOURCE_NAME}-${REVISION}\"," book.js 10 | 11 | npm install 12 | npm run build 13 | 14 | curl -X DELETE ${ELASTIC_HOST}/${RESOURCE_NAME}-${REVISION} 15 | curl -X PUT ${ELASTIC_HOST}/${RESOURCE_NAME}-${REVISION} -H 'Content-Type: application/json' -d @mappings.json 16 | curl -X POST ${ELASTIC_HOST}/${RESOURCE_NAME}-${REVISION}/_bulk -H 'Content-Type: application/json' --data-binary @_book/search_index.json 17 | -------------------------------------------------------------------------------- /e2e/manifests/manager/httpproxy-es.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: projectcontour.io/v1 2 | kind: HTTPProxy 3 | metadata: 4 | name: {{.ResourceName}} 5 | spec: 6 | virtualhost: 7 | fqdn: {{.ResourceName}}.{{.ResourceNamespace}}.example.com 8 | routes: 9 | - conditions: 10 | - prefix: / 11 | services: 12 | - name: {{.ResourceName}} 13 | port: 8080 14 | - conditions: 15 | - prefix: /es 16 | services: 17 | - name: honkit-es 18 | port: 9200 19 | pathRewritePolicy: 20 | replacePrefix: 21 | - prefix: /es 22 | replacement: / 23 | -------------------------------------------------------------------------------- /e2e/manifests/manager/httpproxy.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: projectcontour.io/v1 2 | kind: HTTPProxy 3 | metadata: 4 | name: {{.ResourceName}} 5 | spec: 6 | virtualhost: 7 | fqdn: {{.ResourceName}}.{{.ResourceNamespace}}.example.com 8 | routes: 9 | - conditions: 10 | - prefix: / 11 | services: 12 | - name: {{.ResourceName}} 13 | port: 8080 14 | -------------------------------------------------------------------------------- /e2e/manifests/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: website-operator-system 4 | resources: 5 | - ../../../config/default 6 | - rbac.yaml 7 | patchesStrategicMerge: 8 | - manager.yaml 9 | - ui.yaml 10 | generatorOptions: 11 | disableNameSuffixHash: true 12 | configMapGenerator: 13 | - name: build-scripts 14 | files: 15 | - build-honkit.sh 16 | - build-mkdocs.sh 17 | - build-gatsby.sh 18 | - name: httpproxy 19 | files: 20 | - httpproxy.tmpl 21 | - httpproxy-es.tmpl 22 | - name: after-build-scripts 23 | files: 24 | - after-build-honkit.sh 25 | - create-honkit-es-index.sh 26 | -------------------------------------------------------------------------------- /e2e/manifests/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | image: ghcr.io/zoetrope/website-operator:dev-amd64 12 | args: 13 | - --repochecker-container-image=ghcr.io/zoetrope/repo-checker:dev-amd64 14 | -------------------------------------------------------------------------------- /e2e/manifests/manager/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: extra-resources-role 5 | rules: 6 | - apiGroups: 7 | - projectcontour.io 8 | resources: 9 | - httpproxies 10 | verbs: 11 | - create 12 | - delete 13 | - get 14 | - list 15 | - patch 16 | - update 17 | - watch 18 | - apiGroups: 19 | - projectcontour.io 20 | resources: 21 | - httpproxies/status 22 | verbs: 23 | - get 24 | --- 25 | apiVersion: rbac.authorization.k8s.io/v1 26 | kind: ClusterRoleBinding 27 | metadata: 28 | name: extra-resources-rolebinding 29 | roleRef: 30 | apiGroup: rbac.authorization.k8s.io 31 | kind: ClusterRole 32 | name: extra-resources-role 33 | subjects: 34 | - kind: ServiceAccount 35 | name: default 36 | namespace: website-operator-system 37 | -------------------------------------------------------------------------------- /e2e/manifests/manager/ui.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: ui 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: ui 11 | image: ghcr.io/zoetrope/website-operator-ui:dev-amd64 12 | -------------------------------------------------------------------------------- /e2e/manifests/sample/build-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: build-secret 5 | namespace: default 6 | type: Opaque 7 | data: 8 | ELASTIC_HOST: aHR0cDovL2hvbmtpdC1lczo5MjAw # http://honkit-es:9200 9 | -------------------------------------------------------------------------------- /e2e/manifests/sample/elasticsearch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: StatefulSet 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: elasticsearch 6 | app.kubernetes.io/instance: honkit-es 7 | name: honkit-es 8 | namespace: default 9 | spec: 10 | replicas: 1 11 | serviceName: honkit-es 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/name: elasticsearch 15 | app.kubernetes.io/instance: honkit-es 16 | template: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/name: elasticsearch 20 | app.kubernetes.io/instance: honkit-es 21 | spec: 22 | containers: 23 | - env: 24 | - name: ES_JAVA_OPTS 25 | value: -Xms2048M -Xmx2048M 26 | - name: discovery.type 27 | value: single-node 28 | image: ghcr.io/zoetrope/elasticsearch:8.5.3 29 | imagePullPolicy: IfNotPresent 30 | name: elasticsearch 31 | ports: 32 | - containerPort: 9200 33 | name: http 34 | protocol: TCP 35 | - containerPort: 9300 36 | name: transport 37 | protocol: TCP 38 | resources: 39 | limits: 40 | cpu: "2" 41 | memory: 4Gi 42 | requests: 43 | cpu: "2" 44 | memory: 4Gi 45 | --- 46 | apiVersion: v1 47 | kind: Service 48 | metadata: 49 | labels: 50 | app.kubernetes.io/name: elasticsearch 51 | app.kubernetes.io/instance: honkit-es 52 | name: honkit-es 53 | namespace: default 54 | spec: 55 | ports: 56 | - port: 9200 57 | name: http 58 | selector: 59 | app.kubernetes.io/name: elasticsearch 60 | app.kubernetes.io/instance: honkit-es 61 | -------------------------------------------------------------------------------- /e2e/manifests/sample/honkit-es.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: website.zoetrope.github.io/v1beta1 2 | kind: WebSite 3 | metadata: 4 | name: honkit-sample-es 5 | namespace: default 6 | spec: 7 | buildImage: ghcr.io/zoetrope/node:18.12.1 8 | buildScript: 9 | configMap: 10 | name: build-scripts 11 | key: build-honkit.sh 12 | buildSecrets: 13 | - name: build-secret 14 | key: ELASTIC_HOST 15 | repoURL: https://github.com/zoetrope/honkit-sample.git 16 | afterBuildScript: 17 | configMap: 18 | name: after-build-scripts 19 | key: create-honkit-es-index.sh 20 | branch: main 21 | extraResources: 22 | - configMap: 23 | name: httpproxy 24 | key: httpproxy-es.tmpl 25 | -------------------------------------------------------------------------------- /e2e/manifests/sample/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - elasticsearch.yaml 5 | - honkit-es.yaml 6 | - build-secret.yaml 7 | -------------------------------------------------------------------------------- /e2e/manifests/website/.ssh/.gitignore: -------------------------------------------------------------------------------- 1 | id_rsa 2 | -------------------------------------------------------------------------------- /e2e/manifests/website/.ssh/config: -------------------------------------------------------------------------------- 1 | Host github.com 2 | HostName github.com 3 | User git 4 | UserKnownHostsFile /dev/null 5 | StrictHostKeyChecking no 6 | -------------------------------------------------------------------------------- /e2e/manifests/website/gatsby.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: website.zoetrope.github.io/v1beta1 2 | kind: WebSite 3 | metadata: 4 | name: gatsby-sample 5 | namespace: default 6 | spec: 7 | buildImage: ghcr.io/zoetrope/node:18.12.1 8 | buildScript: 9 | configMap: 10 | name: build-scripts 11 | key: build-gatsby.sh 12 | repoURL: https://github.com/gatsbyjs/gatsby-starter-default.git 13 | branch: master 14 | extraResources: 15 | - configMap: 16 | name: httpproxy 17 | key: httpproxy.tmpl 18 | -------------------------------------------------------------------------------- /e2e/manifests/website/honkit.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: website.zoetrope.github.io/v1beta1 2 | kind: WebSite 3 | metadata: 4 | name: honkit-sample 5 | namespace: default 6 | spec: 7 | buildImage: ghcr.io/zoetrope/node:18.12.1 8 | buildScript: 9 | configMap: 10 | name: build-scripts 11 | key: build-honkit.sh 12 | repoURL: https://github.com/zoetrope/honkit-sample.git 13 | branch: main 14 | afterBuildScript: 15 | configMap: 16 | name: after-build-scripts 17 | key: after-build-honkit.sh 18 | extraResources: 19 | - configMap: 20 | name: httpproxy 21 | key: httpproxy.tmpl 22 | volumeTemplates: 23 | - name: home 24 | persistentVolumeClaim: 25 | claimName: website-home 26 | -------------------------------------------------------------------------------- /e2e/manifests/website/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: default 4 | resources: 5 | - honkit.yaml 6 | - pvc.yaml 7 | - mkdocs.yaml 8 | - gatsby.yaml 9 | generatorOptions: 10 | disableNameSuffixHash: true 11 | secretGenerator: 12 | - name: mkdocs-deploy-key 13 | files: 14 | - .ssh/id_rsa 15 | - .ssh/config 16 | -------------------------------------------------------------------------------- /e2e/manifests/website/mkdocs.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: website.zoetrope.github.io/v1beta1 2 | kind: WebSite 3 | metadata: 4 | name: mkdocs-sample 5 | namespace: default 6 | spec: 7 | buildImage: ghcr.io/zoetrope/python:3.9.5 8 | buildScript: 9 | configMap: 10 | name: build-scripts 11 | key: build-mkdocs.sh 12 | repoURL: git@github.com:zoetrope/mkdocs-sample.git 13 | branch: main 14 | deployKeySecretName: mkdocs-deploy-key 15 | extraResources: 16 | - configMap: 17 | name: httpproxy 18 | key: httpproxy.tmpl 19 | -------------------------------------------------------------------------------- /e2e/manifests/website/pvc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: website-home 5 | annotations: 6 | volumeType: local 7 | spec: 8 | accessModes: 9 | - ReadWriteOnce 10 | storageClassName: standard 11 | resources: 12 | requests: 13 | storage: 1Gi 14 | -------------------------------------------------------------------------------- /e2e/suite_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "os/exec" 8 | "testing" 9 | "time" 10 | 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | ) 14 | 15 | func TestE2E(t *testing.T) { 16 | if os.Getenv("E2E_TEST") == "" { 17 | t.Skip("Run under e2e/") 18 | } 19 | RegisterFailHandler(Fail) 20 | SetDefaultEventuallyTimeout(20 * time.Second) 21 | SetDefaultEventuallyPollingInterval(1 * time.Second) 22 | RunSpecs(t, "E2E Suite") 23 | } 24 | 25 | func kubectl(input []byte, args ...string) (stdout []byte, err error) { 26 | cmd := exec.Command("kubectl", args...) 27 | if input != nil { 28 | cmd.Stdin = bytes.NewReader(input) 29 | } 30 | 31 | return cmd.Output() 32 | } 33 | 34 | func getResource(ns, resource, name, label string, obj interface{}) error { 35 | var args []string 36 | if ns != "" { 37 | args = append(args, "-n", ns) 38 | } 39 | args = append(args, "get", resource) 40 | if name != "" { 41 | args = append(args, name) 42 | } 43 | if label != "" { 44 | args = append(args, "-l", label) 45 | } 46 | args = append(args, "-o", "json") 47 | data, err := kubectl(nil, args...) 48 | if err != nil { 49 | return err 50 | } 51 | return json.Unmarshal(data, obj) 52 | } 53 | 54 | var _ = Describe("website-operator", func() { 55 | Context("bootstrap", testBootstrap) 56 | Context("update", testUpdate) 57 | }) 58 | -------------------------------------------------------------------------------- /e2e/update_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | websitev1beta1 "github.com/zoetrope/website-operator/api/v1beta1" 12 | appsv1 "k8s.io/api/apps/v1" 13 | ) 14 | 15 | func testUpdate() { 16 | It("should update honkit-sample", func() { 17 | var site websitev1beta1.WebSite 18 | err := getResource("default", "website", "honkit-sample", "", &site) 19 | Expect(err).ShouldNot(HaveOccurred()) 20 | revBeforeUpdate := site.Status.Revision 21 | 22 | _, err = kubectl(nil, "patch", "websites", "honkit-sample", "--type=json", "-p", `[{"op": "replace", "path": "/spec/branch", "value": "new-page"}]`) 23 | Expect(err).ShouldNot(HaveOccurred()) 24 | 25 | Eventually(func() error { 26 | err := getResource("default", "website", "honkit-sample", "", &site) 27 | if err != nil { 28 | return err 29 | } 30 | if site.Status.Revision == revBeforeUpdate { 31 | return errors.New("should be updated") 32 | } 33 | return nil 34 | }, 3*time.Minute).Should(Succeed()) 35 | 36 | var deployment appsv1.Deployment 37 | Eventually(func() error { 38 | err := getResource("default", "deployment", "honkit-sample", "", &deployment) 39 | if err != nil { 40 | return err 41 | } 42 | if deployment.Status.UpdatedReplicas != 1 { 43 | return errors.New("should be updated") 44 | } 45 | return nil 46 | }, 2*time.Minute).Should(Succeed()) 47 | 48 | req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/newpage.html", nil) 49 | Expect(err).ShouldNot(HaveOccurred()) 50 | req.Host = "honkit-sample.default.example.com" 51 | client := http.Client{} 52 | Eventually(func() error { 53 | res, err := client.Do(req) 54 | if err != nil { 55 | return err 56 | } 57 | if res.StatusCode != http.StatusOK { 58 | return fmt.Errorf("failed to update: %d", res.StatusCode) 59 | } 60 | return nil 61 | }, 1*time.Minute).Should(Succeed()) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zoetrope/website-operator 2 | 3 | go 1.22.5 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/cybozu-go/log v1.7.0 9 | github.com/cybozu-go/well v1.11.2 10 | github.com/go-logr/logr v1.4.2 11 | github.com/onsi/ginkgo/v2 v2.22.2 12 | github.com/onsi/gomega v1.36.2 13 | github.com/spf13/cobra v1.8.1 14 | go.uber.org/zap v1.27.0 15 | k8s.io/api v0.31.8 16 | k8s.io/apimachinery v0.31.8 17 | k8s.io/client-go v0.31.8 18 | k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 19 | sigs.k8s.io/controller-runtime v0.19.0 20 | sigs.k8s.io/yaml v1.4.0 21 | ) 22 | 23 | require ( 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 | github.com/cybozu-go/netutil v1.4.8 // indirect 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 28 | github.com/emicklei/go-restful/v3 v3.12.1 // indirect 29 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 30 | github.com/fsnotify/fsnotify v1.7.0 // indirect 31 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 32 | github.com/go-logr/zapr v1.3.0 // indirect 33 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 34 | github.com/go-openapi/jsonreference v0.21.0 // indirect 35 | github.com/go-openapi/swag v0.23.0 // indirect 36 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 37 | github.com/gogo/protobuf v1.3.2 // indirect 38 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 39 | github.com/golang/protobuf v1.5.4 // indirect 40 | github.com/google/gnostic-models v0.6.8 // indirect 41 | github.com/google/go-cmp v0.6.0 // indirect 42 | github.com/google/gofuzz v1.2.0 // indirect 43 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/hashicorp/hcl v1.0.0 // indirect 46 | github.com/imdario/mergo v0.3.16 // indirect 47 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 48 | github.com/josharian/intern v1.0.0 // indirect 49 | github.com/json-iterator/go v1.1.12 // indirect 50 | github.com/klauspost/compress v1.17.10 // indirect 51 | github.com/magiconair/properties v1.8.7 // indirect 52 | github.com/mailru/easyjson v0.7.7 // indirect 53 | github.com/mitchellh/mapstructure v1.5.0 // indirect 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 55 | github.com/modern-go/reflect2 v1.0.2 // indirect 56 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 57 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/prometheus/client_golang v1.20.4 // indirect 60 | github.com/prometheus/client_model v0.6.1 // indirect 61 | github.com/prometheus/common v0.60.0 // indirect 62 | github.com/prometheus/procfs v0.15.1 // indirect 63 | github.com/sagikazarmark/locafero v0.6.0 // indirect 64 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 65 | github.com/sourcegraph/conc v0.3.0 // indirect 66 | github.com/spf13/afero v1.11.0 // indirect 67 | github.com/spf13/cast v1.7.0 // indirect 68 | github.com/spf13/pflag v1.0.5 // indirect 69 | github.com/spf13/viper v1.19.0 // indirect 70 | github.com/subosito/gotenv v1.6.0 // indirect 71 | github.com/vishvananda/netlink v1.3.0 // indirect 72 | github.com/vishvananda/netns v0.0.4 // indirect 73 | github.com/x448/float16 v0.8.4 // indirect 74 | go.uber.org/multierr v1.11.0 // indirect 75 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 76 | golang.org/x/net v0.33.0 // indirect 77 | golang.org/x/oauth2 v0.23.0 // indirect 78 | golang.org/x/sys v0.28.0 // indirect 79 | golang.org/x/term v0.27.0 // indirect 80 | golang.org/x/text v0.21.0 // indirect 81 | golang.org/x/time v0.6.0 // indirect 82 | golang.org/x/tools v0.28.0 // indirect 83 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 84 | google.golang.org/protobuf v1.36.1 // indirect 85 | gopkg.in/inf.v0 v0.9.1 // indirect 86 | gopkg.in/ini.v1 v1.67.0 // indirect 87 | gopkg.in/yaml.v2 v2.4.0 // indirect 88 | gopkg.in/yaml.v3 v3.0.1 // indirect 89 | k8s.io/apiextensions-apiserver v0.31.1 // indirect 90 | k8s.io/klog/v2 v2.130.1 // indirect 91 | k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 // indirect 92 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 93 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 94 | ) 95 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= 2 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/cybozu-go/log v1.7.0 h1:wPTkNDWcnSLLAv1ejFSn07qvYG8ng6U6Gygv04dYW1w= 9 | github.com/cybozu-go/log v1.7.0/go.mod h1:pwWH0DFLY85XgTEI6nqkDAvmGReEBDu2vmlkU7CpudQ= 10 | github.com/cybozu-go/netutil v1.4.8 h1:b71xHNvx8UM/jRklhN8d5yJt+X5plGR5u33RyEcUY0I= 11 | github.com/cybozu-go/netutil v1.4.8/go.mod h1:y+C0GUIxWDWs7v2lZu3php0qOAiHMXvWKC+88C/xNhI= 12 | github.com/cybozu-go/well v1.11.2 h1:gFCEH9uGyWhS2owgYk+BqYflimWwTURlxuTmQdC6Zew= 13 | github.com/cybozu-go/well v1.11.2/go.mod h1:gJiQEDZR+SqB/+/GbrB/hVGFxvg+Nm+D+LxrBAdAcKI= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= 19 | github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 20 | github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= 21 | github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 22 | github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= 23 | github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= 24 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 25 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 26 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 27 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 28 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 29 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 30 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 31 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 32 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 33 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 34 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 35 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 36 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 37 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 38 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 39 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 40 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 41 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 42 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 43 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 44 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 45 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 46 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 47 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 48 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 49 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 50 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 51 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 52 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 53 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 54 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 55 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 56 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 57 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 58 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 59 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 60 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 61 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 62 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 63 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 64 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 65 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 66 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 67 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 68 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 69 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 70 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 71 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 72 | github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0= 73 | github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 74 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 75 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 76 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 77 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 78 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 79 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 80 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 81 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 82 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 83 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 84 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 85 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 86 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 87 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 88 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 89 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 90 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 91 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 92 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 93 | github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= 94 | github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= 95 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 96 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 97 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 98 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 99 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 100 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 101 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 102 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 103 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 104 | github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= 105 | github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 106 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 107 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 108 | github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA= 109 | github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= 110 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 111 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 112 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 113 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 114 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 115 | github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= 116 | github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= 117 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 118 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 119 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 120 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 121 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 122 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 123 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 124 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 125 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 126 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 127 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 128 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 129 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 130 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 131 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 132 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 133 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 134 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 135 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 136 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 137 | github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= 138 | github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= 139 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 140 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 141 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 142 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 143 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 144 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 145 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 146 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 147 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 148 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 149 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 150 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 151 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 152 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 153 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 154 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= 155 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= 156 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 157 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 158 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 159 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 160 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 161 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 162 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 163 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 164 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 165 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 166 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 167 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 168 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 169 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 170 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 171 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 172 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 173 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 174 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 175 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 176 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 177 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 178 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 179 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 180 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 181 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 182 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 183 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 184 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 185 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 186 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 187 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 188 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 189 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 190 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 191 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 192 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 193 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 194 | gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= 195 | gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 196 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 197 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 198 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 199 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 200 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 201 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 202 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 203 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 204 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 205 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 206 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 207 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 208 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 209 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 210 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 211 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 212 | k8s.io/api v0.31.8 h1:d5WuCZpFqpkQ7a4JuxSI0/IQuFWT+dUE3jeptRoZkto= 213 | k8s.io/api v0.31.8/go.mod h1:Sq38Y1MdXkkp4thnHFYgErPgP0jhZ9sTOppFkt14YQ8= 214 | k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40= 215 | k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ= 216 | k8s.io/apimachinery v0.31.8 h1:zRA9bpuLwdVqODPrWaAT9eRVB4GuTYLSRLoO3XrzYUU= 217 | k8s.io/apimachinery v0.31.8/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= 218 | k8s.io/client-go v0.31.8 h1:sMlDa9W+2y3tHo0D+XYeovhOTww7lKiOTTqqyxABcM8= 219 | k8s.io/client-go v0.31.8/go.mod h1:7g9whHSnLT2Eilwpw1Ozdl2vRr2zwwqO5RPBDDkT5xo= 220 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 221 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 222 | k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 h1:1dWzkmJrrprYvjGwh9kEUxmcUV/CtNU8QM7h1FLWQOo= 223 | k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA= 224 | k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg= 225 | k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 226 | sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q= 227 | sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= 228 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 229 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 230 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 231 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 232 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 233 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 234 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zoetrope/website-operator/b69106d08bef632543bbad45df004d6788b425c8/hack/boilerplate.go.txt -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "automerge": true, 3 | "extends": [ 4 | "config:base", 5 | "github>aquaproj/aqua-renovate-config#2.7.5", 6 | ":semanticCommitTypeAll(chore)", 7 | ":timezone(Asia/Tokyo)", 8 | "schedule:weekly" 9 | ], 10 | "labels": [ 11 | "dependencies" 12 | ], 13 | "packageRules": [ 14 | { 15 | "description": "Separate minor and patch updates for Kubernetes packages", 16 | "matchDatasources": [ 17 | "go" 18 | ], 19 | "matchPackagePatterns": [ 20 | "^k8s\\.io\\/.*" 21 | ], 22 | "separateMinorPatch": true 23 | }, 24 | { 25 | "automerge": false, 26 | "dependencyDashboardApproval": true, 27 | "description": "Require approval for Kubernetes packages major and minor updates", 28 | "matchDatasources": [ 29 | "go" 30 | ], 31 | "matchPackagePatterns": [ 32 | "^k8s\\.io\\/.*" 33 | ], 34 | "matchUpdateTypes": [ 35 | "major", 36 | "minor" 37 | ] 38 | }, 39 | { 40 | "description": "Disable major updates for k8s.io/client-go", 41 | "enabled": false, 42 | "matchDatasources": [ 43 | "go" 44 | ], 45 | "matchPackageNames": [ 46 | "k8s.io/client-go" 47 | ], 48 | "matchUpdateTypes": [ 49 | "major" 50 | ] 51 | }, 52 | { 53 | "automerge": false, 54 | "dependencyDashboardApproval": true, 55 | "description": "Require approval for sigs.k8s.io packages", 56 | "matchDatasources": [ 57 | "go" 58 | ], 59 | "matchPackagePatterns": [ 60 | "^sigs\\.k8s\\.io\\/.*" 61 | ] 62 | }, 63 | { 64 | "description": "Separate minor and patch updates for kubectl", 65 | "matchPackageNames": [ 66 | "kubernetes/kubectl" 67 | ], 68 | "separateMinorPatch": true 69 | }, 70 | { 71 | "description": "Disable major and minor update for kubectl", 72 | "enabled": false, 73 | "matchPackageNames": [ 74 | "kubernetes/kubectl" 75 | ], 76 | "matchUpdateTypes": [ 77 | "major", 78 | "minor" 79 | ] 80 | }, 81 | { 82 | "automerge": false, 83 | "description": "Disable automerge for controller-tools", 84 | "matchPackageNames": [ 85 | "kubernetes-sigs/controller-tools" 86 | ] 87 | }, 88 | { 89 | "description": "Separate minor and patch update for Kubernetes", 90 | "matchPackageNames": [ 91 | "kindest/node" 92 | ], 93 | "separateMinorPatch": true 94 | }, 95 | { 96 | "description": "Disable major and minor update for Kubernetes", 97 | "enabled": false, 98 | "matchPackageNames": [ 99 | "kindest/node" 100 | ], 101 | "matchUpdateTypes": [ 102 | "major", 103 | "minor" 104 | ] 105 | } 106 | ], 107 | "postUpdateOptions": [ 108 | "gomodTidy" 109 | ], 110 | "regexManagers": [ 111 | { 112 | "datasourceTemplate": "docker", 113 | "depNameTemplate": "kindest/node", 114 | "fileMatch": [ 115 | "^\\.github\\/workflows\\/.+\\.ya?ml$" 116 | ], 117 | "matchStrings": [ 118 | "- (?.+?) # renovate: kindest\\/node" 119 | ] 120 | }, 121 | { 122 | "datasourceTemplate": "docker", 123 | "depNameTemplate": "kindest/node", 124 | "fileMatch": [ 125 | "^e2e\\/Makefile$" 126 | ], 127 | "matchStrings": [ 128 | "KUBERNETES_VERSION := (?.*?) # renovate: kindest\\/node" 129 | ] 130 | }, 131 | { 132 | "datasourceTemplate": "docker", 133 | "depNameTemplate": "kindest/node", 134 | "fileMatch": [ 135 | "^cluster.yaml$" 136 | ], 137 | "matchStrings": [ 138 | "kubernetesVersion: (?.*?) # renovate: kindest\\/node" 139 | ] 140 | } 141 | ] 142 | } 143 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zoetrope/website-operator/b69106d08bef632543bbad45df004d6788b425c8/screenshot.png -------------------------------------------------------------------------------- /ui/backend/server.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/cybozu-go/log" 10 | "github.com/zoetrope/website-operator/api/v1beta1" 11 | "github.com/zoetrope/website-operator/controllers" 12 | corev1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/labels" 14 | "k8s.io/client-go/kubernetes" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | func NewAPIServer(kubeClient client.Client, rawClient *kubernetes.Clientset, allowCORS bool) http.Handler { 19 | return &apiServer{ 20 | kubeClient: kubeClient, 21 | rawClient: rawClient, 22 | allowCORS: allowCORS, 23 | } 24 | } 25 | 26 | type apiServer struct { 27 | kubeClient client.Client 28 | rawClient *kubernetes.Clientset 29 | allowCORS bool 30 | } 31 | 32 | func (s apiServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 33 | if s.allowCORS { 34 | w.Header().Set("Access-Control-Allow-Origin", "*") 35 | w.Header().Set("Access-Control-Allow-Headers", "*") 36 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 37 | if r.Method == http.MethodOptions { 38 | return 39 | } 40 | } 41 | 42 | p := r.URL.Path[len("/api/v1/"):] 43 | switch { 44 | case r.Method == http.MethodGet && p == "websites": 45 | s.listWebSites(w, r) 46 | case r.Method == http.MethodGet && strings.HasPrefix(p, "logs/"): 47 | s.getBuildLog(w, r) 48 | default: 49 | http.Error(w, "requested resource is not found", http.StatusNotFound) 50 | } 51 | } 52 | 53 | type website struct { 54 | Namespace string `json:"namespace"` 55 | Name string `json:"name"` 56 | Status string `json:"status"` 57 | Revision string `json:"revision"` 58 | RepoURL string `json:"repo"` 59 | PublicURL string `json:"public"` 60 | Branch string `json:"branch"` 61 | } 62 | 63 | func (s apiServer) listWebSites(w http.ResponseWriter, r *http.Request) { 64 | var websites v1beta1.WebSiteList 65 | err := s.kubeClient.List(r.Context(), &websites) 66 | if err != nil { 67 | http.Error(w, err.Error(), http.StatusInternalServerError) 68 | return 69 | } 70 | resp := make([]website, len(websites.Items)) 71 | for i, item := range websites.Items { 72 | status, err := s.getStatus(r, item) 73 | if err != nil { 74 | http.Error(w, err.Error(), http.StatusInternalServerError) 75 | return 76 | } 77 | rev := item.Status.Revision 78 | if len(rev) > 7 { 79 | rev = rev[:7] 80 | } 81 | resp[i] = website{ 82 | Namespace: item.Namespace, 83 | Name: item.Name, 84 | Status: status, 85 | Revision: rev, 86 | RepoURL: item.Spec.RepoURL, 87 | PublicURL: item.Spec.PublicURL, 88 | Branch: item.Spec.Branch, 89 | } 90 | } 91 | 92 | w.Header().Set("Content-Type", "application/json") 93 | w.WriteHeader(http.StatusOK) 94 | err = json.NewEncoder(w).Encode(resp) 95 | if err != nil { 96 | log.Error("failed to output JSON", map[string]interface{}{ 97 | log.FnError: err.Error(), 98 | }) 99 | } 100 | } 101 | 102 | func (s apiServer) getStatus(r *http.Request, website v1beta1.WebSite) (string, error) { 103 | if website.Status.Ready != corev1.ConditionTrue { 104 | return "NotReady", nil 105 | } 106 | 107 | var pods corev1.PodList 108 | err := s.kubeClient.List(r.Context(), &pods, &client.ListOptions{ 109 | LabelSelector: labels.SelectorFromSet(map[string]string{ 110 | "app.kubernetes.io/instance": website.Name, 111 | "app.kubernetes.io/managed-by": "website-operator", 112 | }), 113 | Namespace: website.Namespace, 114 | }) 115 | if err != nil { 116 | return "", err 117 | } 118 | 119 | for _, pod := range pods.Items { 120 | if pod.Status.Phase != corev1.PodRunning { 121 | return string(pod.Status.Phase), nil 122 | } 123 | } 124 | return "Running", nil 125 | } 126 | 127 | func (s apiServer) getBuildLog(w http.ResponseWriter, r *http.Request) { 128 | p := r.URL.Path[len("/api/v1/logs/"):] 129 | params := strings.Split(p, "/") 130 | if len(params) != 2 { 131 | http.Error(w, "invalid parameter", http.StatusBadRequest) 132 | return 133 | } 134 | ns := params[0] 135 | resName := params[1] 136 | 137 | var pods corev1.PodList 138 | err := s.kubeClient.List(r.Context(), &pods, &client.ListOptions{ 139 | LabelSelector: labels.SelectorFromSet(map[string]string{ 140 | "app.kubernetes.io/name": controllers.AppNameNginx, 141 | "app.kubernetes.io/instance": resName, 142 | "app.kubernetes.io/managed-by": "website-operator", 143 | }), 144 | Namespace: ns, 145 | }) 146 | if err != nil { 147 | http.Error(w, err.Error(), http.StatusInternalServerError) 148 | return 149 | } 150 | if len(pods.Items) == 0 { 151 | http.Error(w, "not found", http.StatusNotFound) 152 | return 153 | } 154 | latestPod := pods.Items[0] 155 | creationTimestamp := latestPod.GetCreationTimestamp().Time 156 | for _, pod := range pods.Items { 157 | if pod.GetCreationTimestamp().After(creationTimestamp) { 158 | latestPod = pod 159 | creationTimestamp = pod.GetCreationTimestamp().Time 160 | } 161 | } 162 | 163 | req := s.rawClient.CoreV1().Pods(ns).GetLogs(latestPod.GetName(), &corev1.PodLogOptions{ 164 | Container: "build", 165 | }) 166 | 167 | readCloser, err := req.Stream(r.Context()) 168 | if err != nil { 169 | http.Error(w, err.Error(), http.StatusInternalServerError) 170 | return 171 | } 172 | 173 | defer readCloser.Close() 174 | _, err = io.Copy(w, readCloser) 175 | if err != nil { 176 | http.Error(w, err.Error(), http.StatusInternalServerError) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /ui/frontend/.env.development: -------------------------------------------------------------------------------- 1 | # 2 | # !NOTE! Only used for local development 3 | # 4 | DEV_API_ENDPOINT="http://localhost:9090/api/v1" 5 | -------------------------------------------------------------------------------- /ui/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | /.parcel-cache 2 | /node_modules 3 | /dist 4 | -------------------------------------------------------------------------------- /ui/frontend/.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "postcss-import": true, 4 | "tailwindcss": true, 5 | "postcss-nested": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website-operator-ui", 3 | "version": "0.5.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "parcel ./src/index.html", 7 | "build": "rm -rf dist/* && parcel build ./src/index.html" 8 | }, 9 | "author": "zoetrope", 10 | "license": "MIT", 11 | "dependencies": { 12 | "alpinejs": "3.14.9", 13 | "tailwindcss": "3.4.17" 14 | }, 15 | "devDependencies": { 16 | "autoprefixer": "10.4.21", 17 | "parcel": "2.15.2", 18 | "postcss": "8.5.3", 19 | "postcss-import": "16.1.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ui/frontend/src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /ui/frontend/src/app.js: -------------------------------------------------------------------------------- 1 | import Alpine from 'alpinejs' 2 | 3 | window.Alpine = Alpine 4 | const apiEndpoint = process.env.DEV_API_ENDPOINT || '/api/v1' 5 | 6 | Alpine.data('app', () => ({ 7 | websites: [], 8 | showModal: false, 9 | modalTitle: "", 10 | log: "", 11 | init() { 12 | fetch(apiEndpoint + '/websites') 13 | .then(response => response.json()) 14 | .then(data => { 15 | this.websites = data 16 | }) 17 | .catch(error => { 18 | console.error('failed to fetch websites', error); 19 | }); 20 | }, 21 | getLog(ns, name) { 22 | this.showModal = true 23 | this.modalTitle = ns + "/" + name 24 | fetch(apiEndpoint + '/logs/' + ns + '/' + name) 25 | .then(response => response.text()) 26 | .then(data => { 27 | this.log = data 28 | }) 29 | .catch(error => { 30 | console.error('failed to fetch logs', error); 31 | }); 32 | } 33 | })); 34 | 35 | Alpine.start() 36 | -------------------------------------------------------------------------------- /ui/frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | 32 | 35 | 38 | 41 | 44 | 47 | 50 | 53 | 54 | 55 | 75 |
30 | Name 31 | 33 | Namespace 34 | 36 | Repo 37 | 39 | Branch 40 | 42 | Status 43 | 45 | Revision 46 | 48 | Public 49 | 51 | Edit 52 |
76 |
77 |
78 |
79 |
80 |
81 | 82 |
83 |
84 |
85 | 86 | 91 |
92 | 93 |
94 |
95 |
96 |
97 |
98 | 99 | 100 | -------------------------------------------------------------------------------- /ui/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/*.{html,js}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | } 8 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package website 2 | 3 | var ( 4 | // Version is the WebSite-Operator version 5 | Version = "unset" 6 | ) 7 | --------------------------------------------------------------------------------