├── .dockerignore ├── .github ├── CODEOWNERS ├── dependabot.yml ├── dependency-review-config.yml └── workflows │ ├── build.yml │ ├── chart.yaml │ └── dependency-review.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── Dockerfile.local ├── Dockerfile.wolfi ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── README.md ├── charts └── wasmcloud-operator │ ├── .helmignore │ ├── Chart.yaml │ ├── ct.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── cluster-role.yaml │ ├── deployment.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml │ └── values.yaml ├── crates └── types │ ├── Cargo.toml │ └── src │ ├── lib.rs │ └── v1alpha1 │ ├── mod.rs │ └── wasmcloud_host_config.rs ├── deploy ├── base │ ├── deployment.yaml │ ├── kustomization.yaml │ └── namespace.yaml └── local │ ├── kustomization.yaml │ └── local-registry.yaml ├── examples ├── full-config │ └── wasmcloud-annotated.yaml └── quickstart │ ├── README.md │ ├── hello-world-application.yaml │ ├── nats-values.yaml │ ├── wadm-values.yaml │ └── wasmcloud-host.yaml ├── hack └── run-kind-cluster.sh └── src ├── config.rs ├── controller.rs ├── crdgen.rs ├── discovery.rs ├── docker_secret.rs ├── header.rs ├── lib.rs ├── main.rs ├── openapi.rs ├── resources ├── application.rs └── mod.rs ├── router.rs ├── services.rs └── table.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | target/ 5 | Dockerfile 6 | Makefile 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # wasmCloud operator maintainers 2 | * @wasmCloud/operator-maintainers -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/dependency-review-config.yml: -------------------------------------------------------------------------------- 1 | # For more details on the available options, see: 2 | # https://github.com/actions/dependency-review-action?tab=readme-ov-file#configuration-options 3 | fail-on-severity: critical 4 | 5 | comment-summary-in-pr: always 6 | 7 | show-openssf-scorecard: true 8 | 9 | warn-on-openssf-scorecard-level: 3 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | tags: 7 | - "v*" 8 | pull_request: 9 | branches: 10 | - "main" 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | check: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Lint 21 | run: | 22 | cargo clippy -- --no-deps 23 | - name: Test 24 | run: | 25 | cargo test 26 | build: 27 | needs: 28 | - check 29 | strategy: 30 | matrix: 31 | arch: ["x86_64", "aarch64"] 32 | runs-on: ubuntu-latest 33 | if: startswith(github.ref, 'refs/tags/v') # Only run on tag push 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: goto-bus-stop/setup-zig@v2 37 | 38 | - name: Add musl targets 39 | run: | 40 | rustup target add ${{ matrix.arch }}-unknown-linux-musl 41 | 42 | - name: Install cargo-zigbuild 43 | run: | 44 | cargo install cargo-zigbuild 45 | 46 | - name: Build 47 | run: | 48 | cargo zigbuild --release --target ${{matrix.arch}}-unknown-linux-musl 49 | 50 | - name: Store artifact 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: wasmcloud-operator-${{matrix.arch}} 54 | path: target/${{matrix.arch}}-unknown-linux-musl/release/wasmcloud-operator 55 | 56 | release: 57 | needs: 58 | - build 59 | runs-on: ubuntu-latest 60 | permissions: 61 | contents: read 62 | packages: write 63 | if: startswith(github.ref, 'refs/tags/v') # Only run on tag push 64 | steps: 65 | - uses: actions/checkout@v4 66 | 67 | - name: Set up QEMU 68 | uses: docker/setup-qemu-action@v3 69 | 70 | - name: Set up Docker Buildx 71 | uses: docker/setup-buildx-action@v3 72 | 73 | - name: Log in to the Container registry 74 | uses: docker/login-action@v3 75 | with: 76 | registry: ${{ env.REGISTRY }} 77 | username: ${{ github.repository_owner }} 78 | password: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | - name: Extract metadata (tags, labels) for Docker 81 | id: meta 82 | uses: docker/metadata-action@v5 83 | with: 84 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 85 | tags: | 86 | type=sha,prefix= 87 | type=semver,pattern={{version}} 88 | 89 | - name: Extract metadata (tags, labels) for Docker 90 | id: meta_wolfi 91 | uses: docker/metadata-action@v5 92 | with: 93 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 94 | tags: | 95 | type=sha,prefix=,suffix=-wolfi 96 | type=semver,pattern={{version}},suffix=-wolfi 97 | 98 | - name: Load artifacts 99 | uses: actions/download-artifact@v4 100 | with: 101 | path: artifacts 102 | 103 | - name: Fix permissions and architectures 104 | run: | 105 | mv artifacts/wasmcloud-operator-x86_64/wasmcloud-operator artifacts/wasmcloud-operator-amd64 106 | mv artifacts/wasmcloud-operator-aarch64/wasmcloud-operator artifacts/wasmcloud-operator-arm64 107 | chmod +x artifacts/wasmcloud-operator* 108 | 109 | - name: Build and push Docker image 110 | uses: docker/build-push-action@v6 111 | with: 112 | push: true 113 | context: . 114 | tags: ${{ steps.meta.outputs.tags }} 115 | labels: ${{ steps.meta.outputs.labels }} 116 | platforms: linux/amd64,linux/arm64 117 | build-args: "BIN_PATH=artifacts/wasmcloud-operator" 118 | 119 | - name: Build and push Docker image (wolfi) 120 | uses: docker/build-push-action@v6 121 | with: 122 | push: true 123 | context: . 124 | file: './Dockerfile.wolfi' 125 | tags: ${{ steps.meta_wolfi.outputs.tags }} 126 | labels: ${{ steps.meta_wolfi.outputs.labels }} 127 | platforms: linux/amd64,linux/arm64 128 | build-args: "BIN_PATH=artifacts/wasmcloud-operator" 129 | -------------------------------------------------------------------------------- /.github/workflows/chart.yaml: -------------------------------------------------------------------------------- 1 | name: chart 2 | 3 | env: 4 | HELM_VERSION: v3.14.0 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'chart-v[0-9].[0-9]+.[0-9]+' 10 | pull_request: 11 | paths: 12 | - 'charts/**' 13 | - '.github/workflows/chart.yml' 14 | 15 | jobs: 16 | validate: 17 | runs-on: ubuntu-22.04 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Fetch main branch for chart-testing 25 | run: | 26 | git fetch origin main:main 27 | 28 | - name: Set up Helm 29 | uses: azure/setup-helm@v4 30 | with: 31 | version: ${{ env.HELM_VERSION }} 32 | 33 | # Used by helm chart-testing below 34 | - name: Set up Python 35 | uses: actions/setup-python@v5.4.0 36 | with: 37 | python-version: '3.12.2' 38 | 39 | - name: Set up chart-testing 40 | uses: helm/chart-testing-action@v2.7.0 41 | with: 42 | version: v3.10.1 43 | yamllint_version: 1.35.1 44 | yamale_version: 5.0.0 45 | 46 | - name: Run chart-testing (lint) 47 | run: | 48 | ct lint --config charts/wasmcloud-operator/ct.yaml 49 | 50 | - name: Create kind cluster 51 | uses: helm/kind-action@v1.12.0 52 | with: 53 | version: "v0.22.0" 54 | 55 | - name: Run chart-testing (install) 56 | run: | 57 | ct install --config charts/wasmcloud-operator/ct.yaml 58 | 59 | publish: 60 | if: ${{ startsWith(github.ref, 'refs/tags/chart-v') }} 61 | runs-on: ubuntu-22.04 62 | needs: validate 63 | permissions: 64 | packages: write 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | 69 | - name: Set up Helm 70 | uses: azure/setup-helm@v4 71 | with: 72 | version: ${{ env.HELM_VERSION }} 73 | 74 | - name: Package 75 | run: | 76 | helm package charts/wasmcloud-operator -d .helm-charts 77 | 78 | - name: Login to GHCR 79 | uses: docker/login-action@v3 80 | with: 81 | registry: ghcr.io 82 | username: ${{ github.repository_owner }} 83 | password: ${{ secrets.GITHUB_TOKEN }} 84 | 85 | - name: Lowercase the organization name for ghcr.io 86 | run: | 87 | echo "GHCR_REPO_NAMESPACE=${GITHUB_REPOSITORY_OWNER,,}" >>${GITHUB_ENV} 88 | 89 | - name: Publish 90 | run: | 91 | for chart in .helm-charts/*; do 92 | if [ -z "${chart:-}" ]; then 93 | break 94 | fi 95 | helm push "${chart}" "oci://ghcr.io/${{ env.GHCR_REPO_NAMESPACE }}/charts" 96 | done 97 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | 10 | jobs: 11 | dependency-review: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 'Checkout Repository' 15 | uses: actions/checkout@v4 16 | - name: Dependency Review 17 | uses: actions/dependency-review-action@v4 18 | with: 19 | config-file: './.github/dependency-review-config.yml' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasmcloud-operator" 3 | version = "0.5.0" 4 | edition = "2021" 5 | 6 | [[bin]] 7 | doc = false 8 | name = "wasmcloud-operator" 9 | path = "src/main.rs" 10 | 11 | [[bin]] 12 | doc = false 13 | name = "crdgen" 14 | path = "src/crdgen.rs" 15 | 16 | [lib] 17 | name = "controller" 18 | path = "src/lib.rs" 19 | 20 | [workspace.package] 21 | edition = "2021" 22 | 23 | [dependencies] 24 | async-nats = { workspace = true } 25 | axum = { workspace = true } 26 | axum-server = { workspace = true } 27 | anyhow = { workspace = true } 28 | ctrlc = { workspace = true } 29 | cloudevents-sdk = { workspace = true } 30 | config = { workspace = true } 31 | futures = { workspace = true } 32 | handlebars = { workspace = true } 33 | json-patch = { workspace = true } 34 | k8s-openapi = { workspace = true, features = ["v1_28", "schemars"] } 35 | kube = { workspace = true, features = ["runtime", "derive", "default"] } 36 | opentelemetry = { workspace = true } 37 | opentelemetry_sdk = { workspace = true } 38 | opentelemetry-otlp = { workspace = true } 39 | rcgen = { workspace = true } 40 | schemars = { workspace = true } 41 | secrecy = { workspace = true } 42 | serde = { workspace = true } 43 | serde_json = { workspace = true } 44 | serde_yaml = { workspace = true } 45 | thiserror = { workspace = true } 46 | time = { workspace = true } 47 | tokio = { workspace = true } 48 | tokio-util = { workspace = true } 49 | tracing = { workspace = true } 50 | tracing-opentelemetry = { workspace = true } 51 | tracing-subscriber = { workspace = true } 52 | utoipa = { workspace = true } 53 | uuid = { workspace = true } 54 | wadm = { workspace = true } 55 | wadm-client = { workspace = true } 56 | wadm-types = { workspace = true } 57 | wasmcloud-operator-types = { workspace = true } 58 | 59 | [workspace.dependencies] 60 | async-nats = "0.33" 61 | axum = { version = "0.6", features = ["headers"] } 62 | axum-server = { version = "0.4", features = ["tls-rustls"] } 63 | anyhow = "1" 64 | config = { version = "0.14", default-features = false, features = [ 65 | "convert-case", 66 | "async", 67 | ] } 68 | cloudevents-sdk = "0.7" 69 | ctrlc = "3" 70 | futures = "0.3" 71 | handlebars = "5.1" 72 | json-patch = "1.4.0" 73 | k8s-openapi = { version = "0.20", default-features = false } 74 | kube = { version = "0.87", default-features = false } 75 | opentelemetry = { version = "0.21", default-features = false } 76 | opentelemetry_sdk = { version = "0.21", features = [ 77 | "metrics", 78 | "trace", 79 | "rt-tokio", 80 | ] } 81 | opentelemetry-otlp = { version = "0.14", features = ["tokio"] } 82 | rcgen = "0.11" 83 | schemars = "0.8" 84 | secrecy = "0.8" 85 | serde = "1" 86 | serde_json = "1" 87 | serde_yaml = "0.9" 88 | thiserror = "1" 89 | time = "0.3" 90 | tokio = { version = "1", features = ["full"] } 91 | tokio-util = { version = "0.7", features = ["rt"] } 92 | tracing = "0.1" 93 | tracing-opentelemetry = "0.22" 94 | tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 95 | utoipa = { version = "4.1", features = ["axum_extras"] } 96 | uuid = { version = "1", features = ["v5"] } 97 | wadm = "0.13.0" 98 | wadm-client = "0.2.0" 99 | wadm-types = "0.2.0" 100 | wasmcloud-operator-types = { version = "*", path = "./crates/types" } 101 | 102 | [workspace] 103 | members = ["crates/*"] 104 | resolver = "2" 105 | 106 | [profile.release] 107 | strip = true 108 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM gcr.io/distroless/cc-debian12 3 | ARG BIN_PATH 4 | ARG TARGETARCH 5 | 6 | COPY ${BIN_PATH}-${TARGETARCH} /usr/local/bin/wasmcloud-operator 7 | ENTRYPOINT ["/usr/local/bin/wasmcloud-operator"] 8 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM rust:1.77-bookworm as builder 3 | 4 | WORKDIR /app 5 | COPY . . 6 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 7 | --mount=type=cache,target=/app/target \ 8 | cargo build --release && cp target/release/wasmcloud-operator . 9 | 10 | FROM gcr.io/distroless/cc-debian12 11 | COPY --from=builder /app/wasmcloud-operator /usr/local/bin/ 12 | ENTRYPOINT ["/usr/local/bin/wasmcloud-operator"] 13 | -------------------------------------------------------------------------------- /Dockerfile.wolfi: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM chainguard/wolfi-base:latest 3 | ARG BIN_PATH 4 | ARG TARGETARCH 5 | 6 | COPY ${BIN_PATH}-${TARGETARCH} /usr/local/bin/wasmcloud-operator 7 | ENTRYPOINT ["/usr/local/bin/wasmcloud-operator"] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 wasmCloud Maintainers 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # MAINTAINERS 2 | 3 | The following individuals are responsible for reviewing code, managing issues, and ensuring the overall quality of `wasmcloud-operator`. 4 | 5 | ## @wasmCloud/operator-maintainers 6 | 7 | Name: Joonas Bergius 8 | GitHub: @joonas 9 | Organization: Cosmonic 10 | 11 | Name: Dan Norris 12 | GitHub: @protochron 13 | Organization: Cosmonic 14 | 15 | Name: Taylor Thomas 16 | GitHub: @thomastaylor312 17 | Organization: Cosmonic 18 | 19 | Name: Lucas Fontes 20 | GitHub: @lxfontes 21 | Organization: Cosmonic 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | repo := ghcr.io/wasmcloud/wasmcloud-operator 2 | version := $(shell git rev-parse --short HEAD) 3 | platforms := linux/amd64,linux/arm64 4 | 5 | .PHONY: build-dev-image build-image buildx-image 6 | build-image: 7 | docker build -t $(repo):$(version) . 8 | 9 | buildx-image: 10 | docker buildx build --platform $(platforms) -t $(repo):$(version) --load . 11 | 12 | build-dev-image: 13 | docker build -t $(repo):$(version)-dev -f Dockerfile.local . 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wasmcloud-operator 2 | 3 | An operator for managing a set of [wasmCloud hosts](https://github.com/wasmCloud/wasmCloud/) running on Kubernetes and 4 | manage [wasmCloud applications using wadm](https://github.com/wasmcloud/wadm). 5 | The goal is to easily be able to run WasmCloud hosts on a Kubernetes cluster. 6 | 7 | ## WasmCloudHostConfig Custom Resource Definition (CRD) 8 | 9 | The WasmCloudHostConfig CRD describes the desired state of a set of wasmCloud 10 | hosts connected to the same lattice. 11 | 12 | ```yaml 13 | apiVersion: k8s.wasmcloud.dev/v1alpha1 14 | kind: WasmCloudHostConfig 15 | metadata: 16 | name: my-wasmcloud-cluster 17 | spec: 18 | # The number of wasmCloud host pods to run 19 | hostReplicas: 2 20 | # The lattice to connect the hosts to 21 | lattice: default 22 | # Additional labels to apply to the host other than the defaults set in the operator 23 | hostLabels: 24 | some-label: value 25 | # The address to connect to nats 26 | natsAddress: nats://nats.default.svc.cluster.local 27 | # Which wasmCloud version to use 28 | version: 1.0.4 29 | # Enable the following to run the wasmCloud hosts as a DaemonSet 30 | #daemonset: true 31 | # The name of the image pull secret to use with wasmCloud hosts so that they 32 | # can authenticate to a private registry to pull components. 33 | # registryCredentialsSecret: my-registry-secret 34 | ``` 35 | 36 | The CRD requires a Kubernetes Secret with the following keys: 37 | 38 | ```yaml 39 | apiVersion: v1 40 | kind: Secret 41 | metadata: 42 | name: my-wasmcloud-cluster 43 | #data: 44 | # Only required if using a NATS creds file 45 | # nats.creds: 46 | ``` 47 | 48 | The operator will fail to provision the wasmCloud Deployment if any of these 49 | secrets are missing! 50 | 51 | #### Customizing the images used for wasmCloud host and NATS leaf 52 | 53 | If you would like to customize the registry or image that gets used to provision the wasmCloud hosts and the NATS leaf that runs alongside them, you can specify the following options in the above `WasmCloudHostConfig` CRD. 54 | 55 | For wasmCloud Host, use the `image` field: 56 | 57 | ```yaml 58 | apiVersion: k8s.wasmcloud.dev/v1alpha1 59 | kind: WasmCloudHostConfig 60 | metadata: 61 | name: my-wasmcloud-cluster 62 | spec: 63 | # other config options omitted 64 | image: registry.example.com/wasmcloud:1.0.2 65 | ``` 66 | 67 | For the NATS leaf, use the `natsImageLeaf` field: 68 | 69 | ```yaml 70 | apiVersion: k8s.wasmcloud.dev/v1alpha1 71 | kind: WasmCloudHostConfig 72 | metadata: 73 | name: my-wasmcloud-cluster 74 | spec: 75 | # other config options omitted 76 | natsLeafImage: registry.example.com/nats:2.10.16 77 | ``` 78 | 79 | ### Image Pull Secrets 80 | 81 | You can also specify an image pull secret to use use with the wasmCloud hosts 82 | so that they can pull components from a private registry. This secret needs to 83 | be in the same namespace as the WasmCloudHostConfig CRD and must be a 84 | `kubernetes.io/dockerconfigjson` type secret. See the [Kubernetes 85 | documentation](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#registry-secret-existing-credentials) 86 | for more information on how to provision that secret. 87 | 88 | Once it is created, you can reference it in the WasmCloudHostConfig CRD by 89 | setting the `registryCredentialsSecret` field to the name of the secret. 90 | 91 | ## Deploying the operator 92 | 93 | A wasmCloud cluster requires a few things to run: 94 | 95 | - A NATS cluster with Jetstream enabled 96 | - WADM connected to the NATS cluster in order to support applications 97 | 98 | If you are running locally, you can use the following commands to start a 99 | NATS cluster and WADM in your Kubernetes cluster. 100 | 101 | ### Running NATS 102 | 103 | Use the upstream NATS Helm chart to start a cluster with the following 104 | values.yaml file: 105 | 106 | ```yaml 107 | config: 108 | cluster: 109 | enabled: true 110 | replicas: 3 111 | leafnodes: 112 | enabled: true 113 | jetstream: 114 | enabled: true 115 | fileStore: 116 | pvc: 117 | size: 10Gi 118 | merge: 119 | domain: default 120 | ``` 121 | 122 | ```sh 123 | helm repo add nats https://nats-io.github.io/k8s/helm/charts/ 124 | helm upgrade --install -f values.yaml nats nats/nats 125 | ``` 126 | 127 | ### Running Wadm 128 | 129 | You can run Wadm in your Kubernetes cluster using our Helm chart. For a minimal deployment using the 130 | NATS server deployed above, all you need in your `values.yaml` file is: 131 | 132 | ```yaml 133 | wadm: 134 | config: 135 | nats: 136 | server: "nats.default.svc.cluster.local:4222" 137 | ``` 138 | 139 | You can deploy Wadm using your values file and Helm: 140 | 141 | ```sh 142 | helm install wadm -f wadm-values.yaml --version 0.2.0 oci://ghcr.io/wasmcloud/charts/wadm 143 | ``` 144 | 145 | ### Start the operator 146 | 147 | ```sh 148 | kubectl kustomize deploy/base | kubectl apply -f - 149 | ``` 150 | 151 | ## Automatically Syncing Kubernetes Services 152 | 153 | The operator automatically creates Kubernetes Services for wasmCloud 154 | applications. Right now this is limited only to applications that deploy the 155 | wasmCloud httpserver component using a `daemonscaler`, but additional support 156 | for `spreadscalers` will be added in the future. 157 | 158 | If you specify host label selectors on the `daemonscaler` then the operator 159 | will honor those labels and will only create a service for the pods that match 160 | those label selectors. 161 | 162 | ## Argo CD Health Check 163 | 164 | Argo CD provides a way to define a [custom health 165 | check](https://argo-cd.readthedocs.io/en/stable/operator-manual/health/#custom-health-checks) 166 | that it then runs against a given resource to determine whether or not the 167 | resource is in healthy state. 168 | 169 | For this purpose, we specifically expose a `status.phase` field, which exposes 170 | the underlying status information from wadm. 171 | 172 | With the following ConfigMap, a custom health check can be added to an existing 173 | Argo CD installation for tracking the health of wadm applications. 174 | 175 | ```yaml 176 | --- 177 | apiVersion: v1 178 | kind: ConfigMap 179 | metadata: 180 | name: argocd-cm 181 | namespace: argocd 182 | labels: 183 | app.kubernetes.io/name: argocd-cm 184 | app.kubernetes.io/part-of: argocd 185 | data: 186 | resource.customizations: | 187 | core.oam.dev/Application: 188 | health.lua: | 189 | hs = {} 190 | hs.status = "Progressing" 191 | hs.message = "Reconciling application state" 192 | if obj.status ~= nil and obj.status.phase ~= nil then 193 | if obj.status.phase == "Deployed" then 194 | hs.status = "Healthy" 195 | hs.message = "Application is ready" 196 | end 197 | if obj.status.phase == "Reconciling" then 198 | hs.status = "Progressing" 199 | hs.message = "Application has been deployed" 200 | end 201 | if obj.status.phase == "Failed" then 202 | hs.status = "Degraded" 203 | hs.message = "Application failed to deploy" 204 | end 205 | if obj.status.phase == "Undeployed" then 206 | hs.status = "Suspended" 207 | hs.message = "Application is undeployed" 208 | end 209 | end 210 | return hs 211 | ``` 212 | 213 | ## Testing 214 | 215 | - Make sure you have a Kubernetes cluster running locally. Some good options 216 | include [Kind](https://kind.sigs.k8s.io/) or Docker Desktop. 217 | - `RUST_LOG=info cargo run` 218 | 219 | ## Types crate 220 | 221 | This repo stores the types for any CRDs used by the operator in a separate 222 | crate (`wasmcloud-operator-types`) so that they can be reused in other projects. 223 | -------------------------------------------------------------------------------- /charts/wasmcloud-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/wasmcloud-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: wasmcloud-operator 3 | description: A Helm chart for deploying the wasmcloud-operator on Kubernetes 4 | 5 | type: application 6 | 7 | # This is the chart version. This version number should be incremented each time you make changes 8 | # to the chart and its templates, including the app version. 9 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 10 | version: 0.1.6 11 | 12 | # This is the version number of the application being deployed. This version number should be 13 | # incremented each time you make changes to the application. Versions are not expected to 14 | # follow Semantic Versioning. They should reflect the version the application is using. 15 | # It is recommended to use it with quotes. 16 | appVersion: "0.4.0" 17 | -------------------------------------------------------------------------------- /charts/wasmcloud-operator/ct.yaml: -------------------------------------------------------------------------------- 1 | validate-maintainers: false 2 | target-branch: main # TODO: Remove this once chart-testing 3.10.1+ is released 3 | helm-extra-args: --timeout 60s -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/NOTES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wasmCloud/wasmcloud-operator/bc2d7bbea2f15931090a0d30e925035b8b9c88bf/charts/wasmcloud-operator/templates/NOTES.txt -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "wasmcloud-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 "wasmcloud-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 "wasmcloud-operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Namespace that's used for setting up cluster role bindings and such 35 | */}} 36 | {{- define "wasmcloud-operator.namespace" -}} 37 | {{- default "default" .Release.Namespace }} 38 | {{- end }} 39 | 40 | {{/* 41 | Common labels 42 | */}} 43 | {{- define "wasmcloud-operator.labels" -}} 44 | helm.sh/chart: {{ include "wasmcloud-operator.chart" . }} 45 | {{ include "wasmcloud-operator.selectorLabels" . }} 46 | app.kubernetes.io/component: operator 47 | {{- if .Chart.AppVersion }} 48 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 49 | {{- end }} 50 | app.kubernetes.io/managed-by: {{ .Release.Service }} 51 | app.kubernetes.io/part-of: wasmcloud-operator 52 | {{- with .Values.additionalLabels }} 53 | {{ . | toYaml }} 54 | {{- end }} 55 | {{- end }} 56 | 57 | {{/* 58 | Selector labels 59 | */}} 60 | {{- define "wasmcloud-operator.selectorLabels" -}} 61 | app.kubernetes.io/name: {{ include "wasmcloud-operator.name" . }} 62 | app.kubernetes.io/instance: {{ .Release.Name }} 63 | {{- end }} 64 | 65 | {{/* 66 | Create the name of the service account to use 67 | */}} 68 | {{- define "wasmcloud-operator.service-account" -}} 69 | {{- if .Values.serviceAccount.create }} 70 | {{- default (include "wasmcloud-operator.fullname" .) .Values.serviceAccount.name }} 71 | {{- else }} 72 | {{- default "default" .Values.serviceAccount.name }} 73 | {{- end }} 74 | {{- end }} 75 | 76 | {{/* 77 | Create the name of the cluster role to use 78 | */}} 79 | {{- define "wasmcloud-operator.cluster-role" -}} 80 | {{- if .Values.serviceAccount.create }} 81 | {{- default (include "wasmcloud-operator.fullname" .) .Values.serviceAccount.name }} 82 | {{- else }} 83 | {{- default "default" .Values.serviceAccount.name }} 84 | {{- end }} 85 | {{- end }} 86 | 87 | {{/* 88 | Create the name of the cluster role binding to use 89 | */}} 90 | {{- define "wasmcloud-operator.cluster-role-binding" -}} 91 | {{- if .Values.serviceAccount.create }} 92 | {{- default (include "wasmcloud-operator.fullname" .) .Values.serviceAccount.name }} 93 | {{- else }} 94 | {{- default "default" .Values.serviceAccount.name }} 95 | {{- end }} 96 | {{- end }} 97 | -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/cluster-role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "wasmcloud-operator.cluster-role" . }} 5 | labels: 6 | {{- include "wasmcloud-operator.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - secrets 12 | - services 13 | - configmaps 14 | - serviceaccounts 15 | - pods 16 | verbs: 17 | - get 18 | - list 19 | - watch 20 | - create 21 | - delete 22 | - patch 23 | - update 24 | - apiGroups: 25 | - apps 26 | resources: 27 | - deployments 28 | - daemonsets 29 | verbs: 30 | - get 31 | - list 32 | - watch 33 | - create 34 | - delete 35 | - patch 36 | - update 37 | - apiGroups: 38 | - rbac.authorization.k8s.io 39 | resources: 40 | - rolebindings 41 | - roles 42 | verbs: 43 | - get 44 | - list 45 | - watch 46 | - create 47 | - delete 48 | - patch 49 | - apiGroups: 50 | - apiextensions.k8s.io 51 | resources: 52 | - customresourcedefinitions 53 | verbs: 54 | - get 55 | - list 56 | - watch 57 | - create 58 | - delete 59 | - patch 60 | - apiGroups: 61 | - apiregistration.k8s.io 62 | resources: 63 | - apiservices 64 | verbs: 65 | - create 66 | - delete 67 | - get 68 | - list 69 | - patch 70 | - update 71 | - apiGroups: 72 | - discovery.k8s.io 73 | resources: 74 | - endpointslices 75 | verbs: 76 | - create 77 | - delete 78 | - get 79 | - list 80 | - patch 81 | - update 82 | - apiGroups: 83 | - k8s.wasmcloud.dev 84 | resources: 85 | - wasmcloudhostconfigs 86 | - wasmcloudhostconfigs/status 87 | verbs: 88 | - "*" 89 | --- 90 | apiVersion: rbac.authorization.k8s.io/v1 91 | kind: ClusterRoleBinding 92 | metadata: 93 | name: {{ include "wasmcloud-operator.cluster-role-binding" . }} 94 | labels: 95 | {{- include "wasmcloud-operator.labels" . | nindent 4 }} 96 | roleRef: 97 | apiGroup: rbac.authorization.k8s.io 98 | kind: ClusterRole 99 | name: {{ include "wasmcloud-operator.cluster-role" . }} 100 | subjects: 101 | - apiGroup: "" 102 | kind: ServiceAccount 103 | name: {{ include "wasmcloud-operator.service-account" . }} 104 | namespace: {{ include "wasmcloud-operator.namespace" . }} 105 | --- 106 | apiVersion: rbac.authorization.k8s.io/v1 107 | kind: ClusterRoleBinding 108 | metadata: 109 | name: {{ include "wasmcloud-operator.cluster-role-binding" . }}-delegator 110 | roleRef: 111 | apiGroup: rbac.authorization.k8s.io 112 | kind: ClusterRole 113 | name: system:auth-delegator 114 | subjects: 115 | - apiGroup: "" 116 | kind: ServiceAccount 117 | name: {{ include "wasmcloud-operator.service-account" . }} 118 | namespace: {{ include "wasmcloud-operator.namespace" . }} -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "wasmcloud-operator.fullname" . }} 5 | labels: 6 | {{- include "wasmcloud-operator.labels" . | nindent 4 }} 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | {{- include "wasmcloud-operator.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "wasmcloud-operator.labels" . | nindent 8 }} 20 | {{- with .Values.podLabels }} 21 | {{- toYaml . | nindent 8 }} 22 | {{- end }} 23 | spec: 24 | {{- with .Values.imagePullSecrets }} 25 | imagePullSecrets: 26 | {{- toYaml . | nindent 8 }} 27 | {{- end }} 28 | serviceAccountName: {{ include "wasmcloud-operator.service-account" . }} 29 | securityContext: 30 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 31 | containers: 32 | - name: {{ .Chart.Name }} 33 | securityContext: 34 | {{- toYaml .Values.securityContext | nindent 12 }} 35 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 36 | imagePullPolicy: {{ .Values.image.pullPolicy }} 37 | env: 38 | - name: RUST_LOG 39 | value: info,async_nats=error 40 | - name: POD_NAMESPACE 41 | valueFrom: 42 | fieldRef: 43 | fieldPath: metadata.namespace 44 | ports: 45 | - name: https 46 | containerPort: {{ .Values.service.port }} 47 | protocol: TCP 48 | resources: 49 | {{- toYaml .Values.resources | nindent 12 }} 50 | {{- with .Values.nodeSelector }} 51 | nodeSelector: 52 | {{- toYaml . | nindent 8 }} 53 | {{- end }} 54 | {{- with .Values.affinity }} 55 | affinity: 56 | {{- toYaml . | nindent 8 }} 57 | {{- end }} 58 | {{- with .Values.tolerations }} 59 | tolerations: 60 | {{- toYaml . | nindent 8 }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "wasmcloud-operator.fullname" . }} 5 | labels: 6 | {{- include "wasmcloud-operator.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: https 12 | protocol: TCP 13 | name: https 14 | selector: 15 | {{- include "wasmcloud-operator.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "wasmcloud-operator.service-account" . }} 6 | labels: 7 | {{- include "wasmcloud-operator.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /charts/wasmcloud-operator/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "wasmcloud-operator.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "wasmcloud-operator.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: alpine 13 | command: ['wget'] 14 | args: ['--no-check-certificate', 'https://{{ include "wasmcloud-operator.fullname" . }}:{{ .Values.service.port }}/apis/core.oam.dev/v1beta1'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /charts/wasmcloud-operator/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for wasmcloud-operator. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | image: 6 | repository: ghcr.io/wasmcloud/wasmcloud-operator 7 | pullPolicy: IfNotPresent 8 | # Overrides the image tag whose default is the chart appVersion. 9 | tag: "" 10 | 11 | imagePullSecrets: [] 12 | nameOverride: "" 13 | fullnameOverride: "" 14 | 15 | additionalLabels: {} 16 | # app: wasmcloud-operator 17 | 18 | serviceAccount: 19 | # Specifies whether a service account should be created 20 | create: true 21 | # Automatically mount a ServiceAccount's API credentials? 22 | automount: true 23 | # Annotations to add to the service account 24 | annotations: {} 25 | # The name of the service account to use. 26 | # If not set and create is true, a name is generated using the fullname template 27 | name: "" 28 | 29 | podAnnotations: {} 30 | podLabels: {} 31 | 32 | podSecurityContext: {} 33 | # fsGroup: 2000 34 | 35 | securityContext: {} 36 | # capabilities: 37 | # drop: 38 | # - ALL 39 | # readOnlyRootFilesystem: true 40 | # runAsNonRoot: true 41 | # runAsUser: 1000 42 | 43 | service: 44 | type: ClusterIP 45 | port: 8443 46 | 47 | resources: {} 48 | # We usually recommend not to specify default resources and to leave this as a conscious 49 | # choice for the user. This also increases chances charts run on environments with little 50 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 51 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 52 | # limits: 53 | # cpu: 100m 54 | # memory: 128Mi 55 | # requests: 56 | # cpu: 100m 57 | # memory: 128Mi 58 | 59 | nodeSelector: {} 60 | 61 | tolerations: [] 62 | 63 | affinity: {} 64 | -------------------------------------------------------------------------------- /crates/types/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasmcloud-operator-types" 3 | version = "0.1.9" 4 | edition = "2021" 5 | 6 | [package.metadata.cargo-machete] 7 | # NOTE: This exists because kube-derive needs it, and for reasons I don't 8 | # fully understand, it's not coming through kube-derive's own depedendencies. 9 | ignored = ["serde_json"] 10 | 11 | [dependencies] 12 | k8s-openapi = { workspace = true } 13 | kube = { workspace = true, features = ["derive"] } 14 | schemars = { workspace = true } 15 | serde = { workspace = true } 16 | serde_json = { workspace = true } -------------------------------------------------------------------------------- /crates/types/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod v1alpha1; 2 | -------------------------------------------------------------------------------- /crates/types/src/v1alpha1/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod wasmcloud_host_config; 2 | pub use wasmcloud_host_config::*; 3 | -------------------------------------------------------------------------------- /crates/types/src/v1alpha1/wasmcloud_host_config.rs: -------------------------------------------------------------------------------- 1 | use k8s_openapi::api::core::v1::{Container, PodSpec, ResourceRequirements, Volume}; 2 | use kube::CustomResource; 3 | use schemars::{gen::SchemaGenerator, schema::Schema, JsonSchema}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::{BTreeMap, BTreeSet}; 6 | 7 | #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] 8 | #[cfg_attr(test, derive(Default))] 9 | #[kube( 10 | kind = "WasmCloudHostConfig", 11 | group = "k8s.wasmcloud.dev", 12 | version = "v1alpha1", 13 | shortname = "whc", 14 | namespaced, 15 | status = "WasmCloudHostConfigStatus", 16 | printcolumn = r#"{"name":"App Count", "type":"integer", "jsonPath":".status.app_count"}"# 17 | )] 18 | #[serde(rename_all = "camelCase")] 19 | pub struct WasmCloudHostConfigSpec { 20 | /// The number of replicas to use for the wasmCloud host Deployment. 21 | #[serde(default = "default_host_replicas")] 22 | pub host_replicas: u32, 23 | /// DEPRECATED: A list of cluster issuers to use when provisioning hosts. See 24 | /// https://wasmcloud.com/docs/deployment/security/zero-trust-invocations for more information. 25 | #[deprecated(since = "0.3.1", note = "Removed in wasmcloud 1.0.0")] 26 | pub issuers: Option>, 27 | /// The lattice to use for these hosts. 28 | pub lattice: String, 29 | /// An optional set of labels to apply to these hosts. 30 | pub host_labels: Option>, 31 | /// The version of the wasmCloud host to deploy. 32 | pub version: String, 33 | /// The image to use for the wasmCloud host. 34 | /// If not provided, the default image for the version will be used. 35 | /// Also if provided, the version field will be ignored. 36 | pub image: Option, 37 | /// The image to use for the NATS leaf that is deployed alongside the wasmCloud host. 38 | /// If not provided, the default upstream image will be used. 39 | /// If provided, it should be fully qualified by including the image tag. 40 | pub nats_leaf_image: Option, 41 | /// Optional. The name of a secret containing a set of NATS credentials under 'nats.creds' key. 42 | pub secret_name: Option, 43 | /// Enable structured logging for host logs. 44 | pub enable_structured_logging: Option, 45 | /// Name of a secret containing the registry credentials 46 | pub registry_credentials_secret: Option, 47 | /// The control topic prefix to use for the host. 48 | pub control_topic_prefix: Option, 49 | /// The leaf node domain to use for the NATS sidecar. Defaults to "leaf". 50 | #[serde(default = "default_leaf_node_domain")] 51 | pub leaf_node_domain: String, 52 | /// Enable the config service for this host. 53 | #[serde(default)] 54 | pub config_service_enabled: bool, 55 | /// The address of the NATS server to connect to. Defaults to "nats://nats.default.svc.cluster.local". 56 | #[serde(default = "default_nats_address")] 57 | pub nats_address: String, 58 | /// The port of the NATS server to connect to. Defaults to 4222. 59 | #[serde(default = "default_nats_port")] 60 | pub nats_client_port: u16, 61 | /// The port of the NATS server to connect to for leaf node connections. Defaults to 7422. 62 | #[serde(default = "default_nats_leafnode_port")] 63 | pub nats_leafnode_port: u16, 64 | /// The Jetstream domain to use for the NATS sidecar. Defaults to "default". 65 | #[serde(default = "default_jetstream_domain")] 66 | pub jetstream_domain: String, 67 | /// Allow the host to deploy using the latest tag on OCI components or providers 68 | #[serde(default)] 69 | pub allow_latest: bool, 70 | /// Allow the host to pull artifacts from OCI registries insecurely 71 | #[serde(default)] 72 | pub allowed_insecure: Option>, 73 | /// The log level to use for the host. Defaults to "INFO". 74 | #[serde(default = "default_log_level")] 75 | pub log_level: String, 76 | pub policy_service: Option, 77 | /// Kubernetes scheduling options for the wasmCloud host. 78 | pub scheduling_options: Option, 79 | /// Observability options for configuring the OpenTelemetry integration 80 | pub observability: Option, 81 | /// Certificates: Authorities, client certificates 82 | pub certificates: Option, 83 | /// wasmCloud secrets topic prefix, must not be empty if set. 84 | pub secrets_topic_prefix: Option, 85 | /// Maximum memory in bytes that components can use. 86 | pub max_linear_memory_bytes: Option, 87 | } 88 | 89 | #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] 90 | #[serde(rename_all = "camelCase")] 91 | pub struct PolicyService { 92 | pub topic: Option, 93 | pub timeout_ms: Option, 94 | pub changes_topic: Option, 95 | } 96 | 97 | #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] 98 | #[serde(rename_all = "camelCase")] 99 | pub struct KubernetesSchedulingOptions { 100 | /// Run hosts as a DaemonSet instead of a Deployment. 101 | #[serde(default)] 102 | pub daemonset: bool, 103 | /// Kubernetes resources to allocate for the host. See 104 | /// https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ for valid 105 | /// values to use here. 106 | pub resources: Option, 107 | /// Any other pod template spec options to set for the underlying wasmCloud host pods. 108 | #[schemars(schema_with = "pod_schema")] 109 | #[serde(default, skip_serializing_if = "Option::is_none")] 110 | pub pod_template_additions: Option, 111 | /// Allow for customization of either the wasmcloud or nats leaf container inside of the wasmCloud host pod. 112 | pub container_template_additions: Option, 113 | } 114 | 115 | #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] 116 | #[serde(rename_all = "camelCase")] 117 | pub struct ContainerTemplateAdditions { 118 | #[schemars(schema_with = "container_schema")] 119 | #[serde(default, skip_serializing_if = "Option::is_none")] 120 | pub nats: Option, 121 | #[schemars(schema_with = "container_schema")] 122 | #[serde(default, skip_serializing_if = "Option::is_none")] 123 | pub wasmcloud: Option, 124 | } 125 | 126 | #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] 127 | #[serde(rename_all = "camelCase")] 128 | pub struct ObservabilityConfiguration { 129 | #[serde(default)] 130 | pub enable: bool, 131 | pub endpoint: String, 132 | pub protocol: Option, 133 | pub logs: Option, 134 | pub metrics: Option, 135 | pub traces: Option, 136 | } 137 | 138 | #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] 139 | #[serde(rename_all = "camelCase")] 140 | pub enum OtelProtocol { 141 | Grpc, 142 | Http, 143 | } 144 | 145 | impl std::fmt::Display for OtelProtocol { 146 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 147 | write!( 148 | f, 149 | "{}", 150 | match self { 151 | OtelProtocol::Grpc => "grpc", 152 | OtelProtocol::Http => "http", 153 | } 154 | ) 155 | } 156 | } 157 | 158 | #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] 159 | #[serde(rename_all = "camelCase")] 160 | pub struct OtelSignalConfiguration { 161 | pub enable: Option, 162 | pub endpoint: Option, 163 | } 164 | 165 | /// This is a workaround for the fact that we can't override the PodSpec schema to make containers 166 | /// an optional field. It generates the OpenAPI schema for the PodSpec type the same way that 167 | /// kube.rs does while dropping any required fields. 168 | fn pod_schema(_gen: &mut SchemaGenerator) -> Schema { 169 | let gen = schemars::gen::SchemaSettings::openapi3() 170 | .with(|s| { 171 | s.inline_subschemas = true; 172 | s.meta_schema = None; 173 | }) 174 | .with_visitor(kube::core::schema::StructuralSchemaRewriter) 175 | .into_generator(); 176 | let mut val = gen.into_root_schema_for::(); 177 | // Drop `containers` as a required field, along with any others. 178 | val.schema.object.as_mut().unwrap().required = BTreeSet::new(); 179 | val.schema.into() 180 | } 181 | 182 | /// This is a workaround for the fact that we can't override the Container schema to make name 183 | /// an optional field. It generates the OpenAPI schema for the Container type the same way that 184 | /// kube.rs does while dropping any required fields. 185 | fn container_schema(_gen: &mut SchemaGenerator) -> Schema { 186 | let gen = schemars::gen::SchemaSettings::openapi3() 187 | .with(|s| { 188 | s.inline_subschemas = true; 189 | s.meta_schema = None; 190 | }) 191 | .with_visitor(kube::core::schema::StructuralSchemaRewriter) 192 | .into_generator(); 193 | let mut val = gen.into_root_schema_for::(); 194 | // Drop `name` as a required field as it will be filled in from container 195 | // definition coming the controller that this configuration gets merged into. 196 | val.schema.object.as_mut().unwrap().required = BTreeSet::new(); 197 | val.schema.into() 198 | } 199 | 200 | fn default_host_replicas() -> u32 { 201 | 1 202 | } 203 | 204 | fn default_jetstream_domain() -> String { 205 | "default".to_string() 206 | } 207 | 208 | fn default_nats_address() -> String { 209 | "nats://nats.default.svc.cluster.local".to_string() 210 | } 211 | 212 | fn default_leaf_node_domain() -> String { 213 | "leaf".to_string() 214 | } 215 | 216 | fn default_log_level() -> String { 217 | "INFO".to_string() 218 | } 219 | 220 | fn default_nats_port() -> u16 { 221 | 4222 222 | } 223 | 224 | fn default_nats_leafnode_port() -> u16 { 225 | 7422 226 | } 227 | 228 | #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] 229 | pub struct WasmCloudHostCertificates { 230 | pub authorities: Option>, 231 | } 232 | 233 | #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)] 234 | pub struct WasmCloudHostConfigResources { 235 | pub nats: Option, 236 | pub wasmcloud: Option, 237 | } 238 | 239 | #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] 240 | pub struct WasmCloudHostConfigStatus { 241 | pub apps: Vec, 242 | pub app_count: u32, 243 | } 244 | 245 | #[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] 246 | pub struct AppStatus { 247 | pub name: String, 248 | pub version: String, 249 | } 250 | -------------------------------------------------------------------------------- /deploy/base/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: wasmcloud-operator 6 | name: wasmcloud-operator 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: wasmcloud-operator 11 | template: 12 | metadata: 13 | labels: 14 | app: wasmcloud-operator 15 | spec: 16 | serviceAccountName: wasmcloud-operator 17 | containers: 18 | - image: ghcr.io/wasmcloud/wasmcloud-operator:0.5.0 19 | imagePullPolicy: Always 20 | name: wasmcloud-operator 21 | ports: 22 | - containerPort: 8443 23 | name: https 24 | protocol: TCP 25 | env: 26 | - name: RUST_LOG 27 | value: info 28 | - name: POD_NAMESPACE 29 | valueFrom: 30 | fieldRef: 31 | fieldPath: metadata.namespace 32 | --- 33 | apiVersion: v1 34 | kind: ServiceAccount 35 | metadata: 36 | name: wasmcloud-operator 37 | labels: 38 | app: wasmcloud-operator 39 | --- 40 | apiVersion: rbac.authorization.k8s.io/v1 41 | kind: ClusterRole 42 | metadata: 43 | name: wasmcloud-operator 44 | rules: 45 | - apiGroups: 46 | - "" 47 | resources: 48 | - secrets 49 | - services 50 | - configmaps 51 | - serviceaccounts 52 | - pods 53 | verbs: 54 | - get 55 | - list 56 | - watch 57 | - create 58 | - delete 59 | - patch 60 | - update 61 | - apiGroups: 62 | - apps 63 | resources: 64 | - deployments 65 | - daemonsets 66 | verbs: 67 | - get 68 | - list 69 | - watch 70 | - create 71 | - delete 72 | - patch 73 | - update 74 | - apiGroups: 75 | - rbac.authorization.k8s.io 76 | resources: 77 | - rolebindings 78 | - roles 79 | verbs: 80 | - get 81 | - list 82 | - watch 83 | - create 84 | - delete 85 | - patch 86 | - apiGroups: 87 | - apiextensions.k8s.io 88 | resources: 89 | - customresourcedefinitions 90 | verbs: 91 | - get 92 | - list 93 | - watch 94 | - create 95 | - delete 96 | - patch 97 | - apiGroups: 98 | - apiregistration.k8s.io 99 | resources: 100 | - apiservices 101 | verbs: 102 | - create 103 | - delete 104 | - get 105 | - list 106 | - patch 107 | - update 108 | - apiGroups: 109 | - discovery.k8s.io 110 | resources: 111 | - endpointslices 112 | verbs: 113 | - create 114 | - delete 115 | - get 116 | - list 117 | - patch 118 | - update 119 | - apiGroups: 120 | - k8s.wasmcloud.dev 121 | resources: 122 | - wasmcloudhostconfigs 123 | - wasmcloudhostconfigs/status 124 | verbs: 125 | - "*" 126 | --- 127 | apiVersion: rbac.authorization.k8s.io/v1 128 | kind: ClusterRoleBinding 129 | metadata: 130 | name: wasmcloud-operator 131 | roleRef: 132 | apiGroup: rbac.authorization.k8s.io 133 | kind: ClusterRole 134 | name: wasmcloud-operator 135 | subjects: 136 | - apiGroup: "" 137 | kind: ServiceAccount 138 | name: wasmcloud-operator 139 | namespace: default 140 | --- 141 | apiVersion: rbac.authorization.k8s.io/v1 142 | kind: ClusterRoleBinding 143 | metadata: 144 | name: wasmcloud-operator-delegator 145 | roleRef: 146 | apiGroup: rbac.authorization.k8s.io 147 | kind: ClusterRole 148 | name: system:auth-delegator 149 | subjects: 150 | - apiGroup: "" 151 | kind: ServiceAccount 152 | name: wasmcloud-operator 153 | namespace: default 154 | --- 155 | apiVersion: v1 156 | kind: Service 157 | metadata: 158 | labels: 159 | app: wasmcloud-operator 160 | name: wasmcloud-operator 161 | spec: 162 | ports: 163 | - name: https 164 | port: 8443 165 | protocol: TCP 166 | targetPort: https 167 | selector: 168 | app: wasmcloud-operator 169 | type: ClusterIP 170 | -------------------------------------------------------------------------------- /deploy/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - namespace.yaml 5 | - deployment.yaml 6 | namespace: wasmcloud-operator 7 | -------------------------------------------------------------------------------- /deploy/base/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: wasmcloud-operator 5 | -------------------------------------------------------------------------------- /deploy/local/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - ../base 5 | patches: 6 | - path: local-registry.yaml 7 | target: 8 | kind: Deployment 9 | -------------------------------------------------------------------------------- /deploy/local/local-registry.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /spec/template/spec/containers/0/image 3 | value: localhost:5001/wasmcloud-operator:latest 4 | - op: replace 5 | path: /spec/template/spec/containers/0/env/0 6 | value: 7 | name: RUST_LOG 8 | value: info,controller::services=debug,async_nats=warn,controller::controller=debug 9 | -------------------------------------------------------------------------------- /examples/full-config/wasmcloud-annotated.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: k8s.wasmcloud.dev/v1alpha1 2 | kind: WasmCloudHostConfig 3 | metadata: 4 | name: my-wasmcloud-cluster 5 | namespace: default 6 | spec: 7 | # Optional: Number of hosts (pods). Defaults to 1. 8 | hostReplicas: 1 9 | # Required: The lattice to connect the hosts to. 10 | lattice: default 11 | # Optional: Additional labels to apply to the host other than the defaults set in the controller. 12 | hostLabels: 13 | test: value 14 | cluster: kind 15 | # Required: Which wasmCloud version to use. 16 | version: "1.0.4" 17 | # Optional: The image to use for the wasmCloud host. 18 | # If provided, the 'version' field will be ignored. 19 | image: "registry/wasmcloud:tag" 20 | # Optional: The image to use for the NATS leaf that is deployed alongside the wasmCloud host. 21 | # If not provided, the default upstream image will be used. 22 | natsLeafImage: "registry/nats:tag" 23 | # Optional. The name of a secret containing a set of NATS credentials under 'nats.creds' key. 24 | secretName: "wasmcloud-host-nats-secret" 25 | # Optional: Enable structured logging for host logs. Defaults to "false". 26 | enableStructuredLogging: true 27 | # Optional: The name of a secret containing the registry credentials. 28 | # See https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#create-a-secret-by-providing-credentials-on-the-command-line 29 | registryCredentialsSecret: "wasmcloud-pull-secret" 30 | # Optional: The control topic prefix to use for the host. Defaults to "wasmbus.ctl" 31 | controlTopicPrefix: "wasmbus.custom-ctl" 32 | # Optional: The leaf node domain to use for the NATS sidecar. Defaults to "leaf". 33 | leafNodeDomain: "custom-leaf" 34 | # Optional: Enable the config service for this host. Defaults to "false". 35 | # Makes wasmCloud host issue requests to a config service on startup. 36 | configServiceEnabled: true 37 | # Optional: The log level to use for the host. Defaults to "INFO". 38 | logLevel: INFO 39 | # Optional: The address of the NATS server to connect to. Defaults to "nats://nats.default.svc.cluster.local". 40 | natsAddress: nats://nats.default.svc.cluster.local 41 | # Optional: Allow the host to deploy using the latest tag on OCI components or providers. Defaults to "false". 42 | allowLatest: true 43 | # Optional: Allow the host to pull artifacts from OCI registries insecurely. 44 | allowedInsecure: 45 | - "localhost:5001" 46 | - "kind-registry:5000" 47 | # Optional: Policy service configuration. 48 | policyService: 49 | # If provided, enables policy checks on start actions and component invocations. 50 | topic: "wasmcloud.policy" 51 | # If provided, allows the host to subscribe to updates on past policy decisions. Requires 'topic' above to be set. 52 | changesTopic: "wasmcloud.policy.changes" 53 | # If provided, allows setting a custom timeout for requesting policy decisions. Defaults to 1000. Requires 'topic' to be set. 54 | timeoutMs: 10000 55 | # Optional: Observability options for configuring the OpenTelemetry integration. 56 | observability: 57 | # NOTE: Enables all signals (logs/metrics/traces) at once. Set it to 'false' and enable each signal individually in case you don't need all of them. 58 | enable: true 59 | endpoint: "otel-collector.svc" 60 | # Either 'grpc' or 'http' 61 | protocol: "http" 62 | logs: 63 | enable: false 64 | endpoint: "logs-specific-otel-collector.svc" 65 | metrics: 66 | enable: false 67 | endpoint: "metrics-specific-otel-collector.svc" 68 | traces: 69 | enable: false 70 | endpoint: "traces-specific-otel-collector.svc" 71 | # Optional: Subject prefix that will be used by the host to query for wasmCloud Secrets. 72 | # See https://wasmcloud.com/docs/concepts/secrets for more context 73 | secretsTopicPrefix: "wasmcloud.secrets" 74 | # Optional: The maximum amount of memory bytes that a component can allocate. 75 | maxLinearMemoryBytes: 20000000 76 | # Optional: Additional options to control how the underlying wasmCloud hosts are scheduled in Kubernetes. 77 | # This includes setting resource requirements for the nats and wasmCloud host 78 | # containers along with any additional pot template settings. 79 | schedulingOptions: 80 | # Optional: Enable the following to run the wasmCloud hosts as a DaemonSet. Defaults to "false". 81 | daemonset: true 82 | # Optional: Set the resource requirements for the nats and wasmCloud host containers. 83 | # See https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ for valid values 84 | resources: 85 | nats: 86 | requests: 87 | cpu: "1" 88 | wasmCloudHost: 89 | requests: 90 | cpu: "1" 91 | # Optional: Any additional pod template settings to apply to the wasmCloud host pods. 92 | # See https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#podspec-v1-core for all valid options. 93 | # Note that you *cannot* set the `containers` field here as it is managed by the controller. 94 | podTemplateAdditions: 95 | spec: 96 | nodeSelector: 97 | kubernetes.io/os: linux 98 | -------------------------------------------------------------------------------- /examples/quickstart/README.md: -------------------------------------------------------------------------------- 1 | # Example setup 2 | 3 | This example shows the bare minimum requirements to deploy applications on wasmCloud. 4 | 5 | It relies on the Kubernetes `default` namespace for simplicity. 6 | 7 | ## Install [NATS](https://github.com/nats-io/nats-server) 8 | 9 | ```bash 10 | helm repo add nats https://nats-io.github.io/k8s/helm/charts/ 11 | helm upgrade --install -f nats-values.yaml nats nats/nats 12 | ``` 13 | 14 | Validate installation with: 15 | 16 | ```bash 17 | # make sure pods are ready 18 | kubectl rollout status deploy,sts -l app.kubernetes.io/instance=nats 19 | ``` 20 | 21 | ## Install wasmCloud Application Deployment Manager - [wadm](https://github.com/wasmCloud/wadm) 22 | 23 | ```sh 24 | helm install wadm -f wadm-values.yaml oci://ghcr.io/wasmcloud/charts/wadm 25 | ``` 26 | 27 | Validate installation with: 28 | 29 | ```bash 30 | # make sure pods are ready 31 | kubectl rollout status deploy -l app.kubernetes.io/instance=wadm 32 | ``` 33 | 34 | ## Install the operator 35 | 36 | ```sh 37 | kubectl apply -k ../../deploy/base 38 | ``` 39 | 40 | Validate installation with: 41 | 42 | ```bash 43 | # make sure pods are ready 44 | kubectl rollout status deploy -l app=wasmcloud-operator -n wasmcloud-operator 45 | # apiservice should be available 46 | kubectl get apiservices.apiregistration.k8s.io v1beta1.core.oam.dev 47 | ``` 48 | 49 | ## Create wasmcloud cluster 50 | 51 | ```bash 52 | kubectl apply -f wasmcloud-host.yaml 53 | ``` 54 | 55 | Check wasmCloud host status with: 56 | 57 | ```bash 58 | kubectl describe wasmcloudhostconfig wasmcloud-host 59 | ``` 60 | 61 | ## Managing applications using kubectl 62 | 63 | Install the rust hello world application: 64 | 65 | ```bash 66 | kubectl apply -f hello-world-application.yaml 67 | ``` 68 | 69 | Check application status with: 70 | 71 | ```bash 72 | kubectl get applications 73 | ``` 74 | 75 | ## Managing applications with wash 76 | 77 | Port forward into the NATS cluster. 4222 = NATS Service, 4223 = NATS Websockets 78 | 79 | ```bash 80 | kubectl port-forward svc/nats 4222:4222 4223:4223 81 | ``` 82 | 83 | In another shell: 84 | 85 | ```bash 86 | wash app list 87 | ``` 88 | -------------------------------------------------------------------------------- /examples/quickstart/hello-world-application.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: core.oam.dev/v1beta1 2 | kind: Application 3 | metadata: 4 | name: hello-world 5 | annotations: 6 | version: v0.0.1 7 | description: "HTTP hello world demo in Rust, using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)" 8 | wasmcloud.dev/authors: wasmCloud team 9 | wasmcloud.dev/source-url: https://github.com/wasmCloud/wasmCloud/blob/main/examples/rusg/components/http-hello-world/wadm.yaml 10 | wasmcloud.dev/readme-md-url: https://github.com/wasmCloud/wasmCloud/blob/main/examples/rusg/components/http-hello-world/README.md 11 | wasmcloud.dev/homepage: https://github.com/wasmCloud/wasmCloud/tree/main/examples/rusg/components/http-hello-world 12 | wasmcloud.dev/categories: | 13 | http,http-server,rust,hello-world,example 14 | spec: 15 | components: 16 | - name: http-component 17 | type: component 18 | properties: 19 | image: ghcr.io/wasmcloud/components/http-hello-world-rust:0.1.0 20 | traits: 21 | # Govern the spread/scheduling of the component 22 | - type: daemonscaler 23 | properties: 24 | replicas: 100 25 | 26 | # Add a capability provider that enables HTTP access 27 | - name: httpserver 28 | type: capability 29 | properties: 30 | image: ghcr.io/wasmcloud/http-server:0.21.0 31 | traits: 32 | # Establish a unidirectional link from this http server provider (the "source") 33 | # to the `http-component` component (the "target") so the component can handle incoming HTTP requests. 34 | # 35 | # The source (this provider) is configured such that the HTTP server listens on 0.0.0.0:8000. 36 | # When running the application on Kubernetes with the wasmCloud operator, you can change the 37 | # port but the address must be 0.0.0.0. 38 | - type: link 39 | properties: 40 | target: http-component 41 | namespace: wasi 42 | package: http 43 | interfaces: [incoming-handler] 44 | source_config: 45 | - name: default-http 46 | properties: 47 | address: 0.0.0.0:8000 48 | # When running the application on Kubernetes with the wasmCloud operator, 49 | # the operator automatically creates a Kubernetes service for applications that use 50 | # the httpserver provider with a daemonscaler. 51 | - type: daemonscaler 52 | properties: 53 | replicas: 1 54 | -------------------------------------------------------------------------------- /examples/quickstart/nats-values.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | cluster: 3 | enabled: true 4 | replicas: 3 5 | leafnodes: 6 | enabled: true 7 | websocket: 8 | enabled: true 9 | port: 4223 10 | jetstream: 11 | enabled: true 12 | fileStore: 13 | pvc: 14 | size: 10Gi 15 | merge: 16 | domain: default 17 | -------------------------------------------------------------------------------- /examples/quickstart/wadm-values.yaml: -------------------------------------------------------------------------------- 1 | wadm: 2 | image: 3 | tag: v0.12.2 4 | config: 5 | nats: 6 | server: "nats.default.svc.cluster.local:4222" 7 | -------------------------------------------------------------------------------- /examples/quickstart/wasmcloud-host.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: k8s.wasmcloud.dev/v1alpha1 2 | kind: WasmCloudHostConfig 3 | metadata: 4 | name: wasmcloud-host 5 | spec: 6 | lattice: default 7 | version: "1.0.4" 8 | -------------------------------------------------------------------------------- /hack/run-kind-cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -o errexit 3 | 4 | # 1. Create registry container unless it already exists 5 | reg_name='kind-registry' 6 | reg_port='5001' 7 | if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then 8 | docker run \ 9 | -d --restart=always -p "127.0.0.1:${reg_port}:5000" --name "${reg_name}" \ 10 | registry:2 11 | fi 12 | 13 | # 2. Create kind cluster with containerd registry config dir enabled 14 | # TODO: kind will eventually enable this by default and this patch will 15 | # be unnecessary. 16 | # 17 | # See: 18 | # https://github.com/kubernetes-sigs/kind/issues/2875 19 | # https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration 20 | # See: https://github.com/containerd/containerd/blob/main/docs/hosts.md 21 | cat < u16 { 12 | 1 13 | } 14 | -------------------------------------------------------------------------------- /src/crdgen.rs: -------------------------------------------------------------------------------- 1 | use kube::CustomResourceExt; 2 | use wasmcloud_operator_types::v1alpha1::WasmCloudHostConfig; 3 | 4 | fn main() { 5 | print!( 6 | "{}", 7 | serde_yaml::to_string(&WasmCloudHostConfig::crd()).unwrap() 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/discovery.rs: -------------------------------------------------------------------------------- 1 | use kube::core::{GroupVersionKind, ListMeta, ObjectMeta}; 2 | use serde::Serialize; 3 | 4 | /// DiscoveryFreshness is an enum defining whether the Discovery document published by an apiservice is up to date (fresh). 5 | #[derive(Clone, Debug, Serialize)] 6 | pub enum DiscoveryFreshness { 7 | Current, 8 | Stale, 9 | } 10 | 11 | /// ResourceScope is an enum defining the different scopes available to a resource. 12 | #[derive(Clone, Debug, Serialize)] 13 | pub enum ResourceScope { 14 | Cluster, 15 | Namespaced, 16 | } 17 | 18 | /// APIGroupDiscoveryList is a resource containing a list of APIGroupDiscovery. 19 | /// This is one of the types able to be returned from the /api and /apis endpoint and contains an aggregated 20 | /// list of API resources (built-ins, Custom Resource Definitions, resources from aggregated servers) 21 | /// that a cluster supports. 22 | // Based on https://github.com/kubernetes/kubernetes/blob/ec5096fa869b801d6eb1bf019819287ca61edc4d/pkg/apis/apidiscovery/types.go#L25-L37 23 | #[derive(Clone, Debug, Serialize)] 24 | #[serde(rename_all = "camelCase")] 25 | pub struct APIGroupDiscoveryList { 26 | pub kind: String, 27 | pub api_version: String, 28 | // ResourceVersion will not be set, because this does not have a replayable ordering among multiple apiservers. 29 | // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata 30 | pub metadata: ListMeta, 31 | // items is the list of groups for discovery. The groups are listed in priority order. 32 | pub items: Vec, 33 | } 34 | 35 | impl Default for APIGroupDiscoveryList { 36 | fn default() -> Self { 37 | Self { 38 | api_version: "apidiscovery.k8s.io/v2beta1".to_string(), 39 | kind: "APIGroupDiscoveryList".to_string(), 40 | metadata: ListMeta::default(), 41 | items: vec![], 42 | } 43 | } 44 | } 45 | 46 | /// APIGroupDiscovery holds information about which resources are being served for all version of the API Group. 47 | /// It contains a list of APIVersionDiscovery that holds a list of APIResourceDiscovery types served for a version. 48 | /// Versions are in descending order of preference, with the first version being the preferred entry. 49 | // Based on https://github.com/kubernetes/kubernetes/blob/ec5096fa869b801d6eb1bf019819287ca61edc4d/pkg/apis/apidiscovery/types.go#L41-L58 50 | #[derive(Clone, Debug, Serialize)] 51 | #[serde(rename_all = "camelCase")] 52 | pub struct APIGroupDiscovery { 53 | pub kind: Option, 54 | pub api_version: Option, 55 | // Standard object's metadata. 56 | // The only field completed will be name. For instance, resourceVersion will be empty. 57 | // name is the name of the API group whose discovery information is presented here. 58 | // name is allowed to be "" to represent the legacy, ungroupified resources. 59 | // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata 60 | pub metadata: ObjectMeta, 61 | // versions are the versions supported in this group. They are sorted in descending order of preference, 62 | // with the preferred version being the first entry. 63 | pub versions: Vec, // `json:"versions,omitempty" protobuf:"bytes,2,rep,name=versions"` 64 | } 65 | 66 | /// APIVersionDiscovery holds a list of APIResourceDiscovery types that are served for a particular version within an API Group. 67 | // Based on https://github.com/kubernetes/kubernetes/blob/ec5096fa869b801d6eb1bf019819287ca61edc4d/pkg/apis/apidiscovery/types.go#L60-L77 68 | #[derive(Clone, Debug, Serialize)] 69 | pub struct APIVersionDiscovery { 70 | // version is the name of the version within a group version. 71 | pub version: String, 72 | // resources is a list of APIResourceDiscovery objects for the corresponding group version. 73 | pub resources: Vec, 74 | // freshness marks whether a group version's discovery document is up to date. 75 | // "Current" indicates the discovery document was recently 76 | // refreshed. "Stale" indicates the discovery document could not 77 | // be retrieved and the returned discovery document may be 78 | // significantly out of date. Clients that require the latest 79 | // version of the discovery information be retrieved before 80 | // performing an operation should not use the aggregated document 81 | // and instead retrieve the necessary version docs directly. 82 | pub freshness: DiscoveryFreshness, 83 | } 84 | 85 | /// APIResourceDiscovery provides information about an API resource for discovery. 86 | // Based on https://github.com/kubernetes/kubernetes/blob/ec5096fa869b801d6eb1bf019819287ca61edc4d/pkg/apis/apidiscovery/types.go#L79-L113 87 | #[derive(Clone, Debug, Serialize)] 88 | #[serde(rename_all = "camelCase")] 89 | pub struct APIResourceDiscovery { 90 | // resource is the plural name of the resource. This is used in the URL path and is the unique identifier 91 | // for this resource across all versions in the API group. 92 | // Resources with non-empty groups are located at /apis/// 93 | // Resources with empty groups are located at /api/v1/ 94 | pub resource: String, 95 | // responseKind describes the group, version, and kind of the serialization schema for the object type this endpoint typically returns. 96 | // APIs may return other objects types at their discretion, such as error conditions, requests for alternate representations, or other operation specific behavior. 97 | // This value will be null or empty if an APIService reports subresources but supports no operations on the parent resource 98 | pub response_kind: GroupVersionKind, 99 | // scope indicates the scope of a resource, either Cluster or Namespaced 100 | pub scope: ResourceScope, 101 | // singularResource is the singular name of the resource. This allows clients to handle plural and singular opaquely. 102 | // For many clients the singular form of the resource will be more understandable to users reading messages and should be used when integrating the name of the resource into a sentence. 103 | // The command line tool kubectl, for example, allows use of the singular resource name in place of plurals. 104 | // The singular form of a resource should always be an optional element - when in doubt use the canonical resource name. 105 | pub singular_resource: String, 106 | // verbs is a list of supported API operation types (this includes 107 | // but is not limited to get, list, watch, create, update, patch, 108 | // delete, deletecollection, and proxy). 109 | pub verbs: Vec, 110 | // shortNames is a list of suggested short names of the resource. 111 | pub short_names: Vec, 112 | // categories is a list of the grouped resources this resource belongs to (e.g. 'all'). 113 | // Clients may use this to simplify acting on multiple resource types at once. 114 | pub categories: Vec, 115 | // subresources is a list of subresources provided by this resource. Subresources are located at /apis////name-of-instance/ 116 | pub subresources: Vec, 117 | } 118 | 119 | /// APISubresourceDiscovery provides information about an API subresource for discovery. 120 | // Based on https://github.com/kubernetes/kubernetes/blob/ec5096fa869b801d6eb1bf019819287ca61edc4d/pkg/apis/apidiscovery/types.go#L131C1-L156 121 | #[derive(Clone, Debug, Serialize)] 122 | #[serde(rename_all = "camelCase")] 123 | pub struct APISubresourceDiscovery { 124 | // subresource is the name of the subresource. This is used in the URL path and is the unique identifier 125 | // for this resource across all versions. 126 | pub subresource: String, 127 | // responseKind describes the group, version, and kind of the serialization schema for the object type this endpoint typically returns. 128 | // Some subresources do not return normal resources, these will have null or empty return types. 129 | pub response_kind: GroupVersionKind, 130 | // acceptedTypes describes the kinds that this endpoint accepts. 131 | // Subresources may accept the standard content types or define 132 | // custom negotiation schemes. The list may not be exhaustive for 133 | // all operations. 134 | pub accepted_types: Vec, 135 | // verbs is a list of supported API operation types (this includes 136 | // but is not limited to get, list, watch, create, update, patch, 137 | // delete, deletecollection, and proxy). Subresources may define 138 | // custom verbs outside the standard Kubernetes verb set. Clients 139 | // should expect the behavior of standard verbs to align with 140 | // Kubernetes interaction conventions. 141 | pub verbs: Vec, 142 | } 143 | -------------------------------------------------------------------------------- /src/docker_secret.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use k8s_openapi::api::core::v1::Secret; 3 | use secrecy::SecretString; 4 | use serde::Deserialize; 5 | use std::collections::HashMap; 6 | 7 | #[derive(Deserialize, Debug)] 8 | pub struct DockerConfigJson { 9 | pub auths: HashMap, 10 | } 11 | 12 | #[derive(Deserialize, Debug)] 13 | pub struct DockerConfigJsonAuth { 14 | pub username: String, 15 | pub password: SecretString, 16 | pub auth: String, 17 | } 18 | 19 | impl DockerConfigJson { 20 | pub fn from_secret(secret: Secret) -> Result { 21 | if let Some(data) = secret.data.clone() { 22 | let bytes = data 23 | .get(".dockerconfigjson") 24 | .ok_or(anyhow!("No .dockerconfigjson in secret"))?; 25 | let b = bytes.clone(); 26 | 27 | match std::str::from_utf8(&b.0) { 28 | Ok(s) => Ok(serde_json::from_str(s)?), 29 | Err(e) => Err(anyhow!("Error decoding secret: {}", e)), 30 | } 31 | } else { 32 | Err(anyhow!("No data in secret")) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/header.rs: -------------------------------------------------------------------------------- 1 | use axum::headers::{Error, Header, HeaderName, HeaderValue}; 2 | use axum::http::header::ACCEPT; 3 | use std::fmt; 4 | 5 | #[derive(Debug)] 6 | pub enum As { 7 | APIGroupDiscoveryList, 8 | PartialObjectMetadataList, 9 | Table, 10 | NotSpecified, 11 | } 12 | 13 | impl fmt::Display for As { 14 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 15 | match self { 16 | As::APIGroupDiscoveryList => write!(f, "APIGroupDiscoveryList"), 17 | As::PartialObjectMetadataList => write!(f, "PartialObjectMetadataList"), 18 | As::Table => write!(f, "Table"), 19 | As::NotSpecified => write!(f, "NotSpecified"), 20 | } 21 | } 22 | } 23 | 24 | impl From for As { 25 | fn from(accept: Accept) -> Self { 26 | accept.0 27 | } 28 | } 29 | 30 | #[derive(Debug)] 31 | pub struct Accept(As); 32 | 33 | impl std::fmt::Display for Accept { 34 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { 35 | match self.0 { 36 | As::APIGroupDiscoveryList => write!(f, "APIGroupDiscoveryList"), 37 | As::PartialObjectMetadataList => write!(f, "PartialObjectMetadataList"), 38 | As::Table => write!(f, "Table"), 39 | As::NotSpecified => write!(f, "NotSpecified"), 40 | } 41 | } 42 | } 43 | 44 | // Parses Accept headers from kube-apiserver/aggregator and turns them into Enum values. 45 | // Some examples include: 46 | // * application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList 47 | // * application/json;as=Table;g=meta.k8s.io;v=v1 48 | impl Header for Accept { 49 | fn name() -> &'static HeaderName { 50 | &ACCEPT 51 | } 52 | fn decode<'i, I>(values: &mut I) -> Result 53 | where 54 | I: Iterator, 55 | { 56 | let header_value = values.next(); 57 | // We need to return a default rather than error here, because without it 58 | // kube-aggregator will freak out about the responses, for some reason. 59 | if header_value.is_none() { 60 | return Ok(Accept(As::NotSpecified)); 61 | } 62 | 63 | let value = header_value.unwrap().to_str().unwrap_or("n/a"); 64 | 65 | let parts: Vec<&str> = value 66 | .split(';') 67 | .filter(|p| p.starts_with("as=")) 68 | .map(|p| p.strip_prefix("as=").unwrap_or("")) 69 | .collect(); 70 | 71 | let header = match parts.into_iter().nth(0) { 72 | Some("APIGroupDiscoveryList") => Accept(As::APIGroupDiscoveryList), 73 | Some("PartialObjectMetadataList") => Accept(As::PartialObjectMetadataList), 74 | Some("Table") => Accept(As::Table), 75 | None => Accept(As::NotSpecified), 76 | // This exists to satisfy rust-analyzer, we should probably try to 77 | // figure out if a new type was added that we need to be responding to. 78 | Some(&_) => Accept(As::NotSpecified), 79 | }; 80 | Ok(header) 81 | } 82 | fn encode(&self, values: &mut E) 83 | where 84 | E: Extend, 85 | { 86 | let s = self.0.to_string(); 87 | let value = HeaderValue::from_static(s.leak()); 88 | values.extend(std::iter::once(value)); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | body, 3 | http::StatusCode, 4 | response::{IntoResponse, Response}, 5 | }; 6 | use handlebars::RenderError; 7 | use thiserror::Error; 8 | 9 | #[derive(Error, Debug)] 10 | pub enum Error { 11 | #[error("SerializationError: {0}")] 12 | SerializationError(#[source] serde_json::Error), 13 | 14 | #[error("Kube Error: {0}")] 15 | KubeError(#[from] kube::Error), 16 | 17 | #[error("Finalizer Error: {0}")] 18 | // NB: awkward type because finalizer::Error embeds the reconciler error (which is this) 19 | // so boxing this error to break cycles 20 | FinalizerError(#[source] Box>), 21 | 22 | #[error("IllegalDocument")] 23 | IllegalDocument, 24 | 25 | #[error("NATS error: {0}")] 26 | NatsError(String), 27 | 28 | #[error("Request error: {0}")] 29 | RequestError(String), 30 | 31 | #[error("Error retrieving secrets: {0}")] 32 | SecretError(String), 33 | 34 | #[error("Certificate error: {0}")] 35 | CertificateError(String), 36 | 37 | #[error("Error rendering template: {0}")] 38 | RenderError(#[from] RenderError), 39 | } 40 | pub type Result = std::result::Result; 41 | 42 | impl IntoResponse for Error { 43 | fn into_response(self) -> Response { 44 | let mut resp = Response::new(body::boxed(self.to_string())); 45 | *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; 46 | resp 47 | } 48 | } 49 | 50 | pub mod config; 51 | pub mod controller; 52 | pub mod discovery; 53 | pub mod docker_secret; 54 | pub mod header; 55 | pub(crate) mod openapi; 56 | pub mod resources; 57 | pub mod router; 58 | pub(crate) mod services; 59 | pub(crate) mod table; 60 | 61 | pub use crate::controller::*; 62 | pub use crate::resources::application::{delete_application, get_application, list_applications}; 63 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use axum_server::{tls_rustls::RustlsConfig, Handle}; 3 | use controller::{config::OperatorConfig, State}; 4 | 5 | use config::Config; 6 | use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; 7 | use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; 8 | use k8s_openapi::kube_aggregator::pkg::apis::apiregistration::v1::{ 9 | APIService, APIServiceSpec, ServiceReference, 10 | }; 11 | use kube::{ 12 | api::{Api, Patch, PatchParams, PostParams}, 13 | client::Client, 14 | CustomResourceExt, 15 | }; 16 | use opentelemetry::KeyValue; 17 | use opentelemetry_sdk::{ 18 | trace::{RandomIdGenerator, Sampler}, 19 | Resource, 20 | }; 21 | use std::io::IsTerminal; 22 | use std::net::SocketAddr; 23 | use std::time::Duration; 24 | use tracing::{error, info}; 25 | use tracing_subscriber::layer::SubscriberExt; 26 | 27 | use wasmcloud_operator_types::v1alpha1::WasmCloudHostConfig; 28 | 29 | #[tokio::main] 30 | async fn main() -> Result<()> { 31 | let args = std::env::args().collect::>(); 32 | if args.iter().any(|arg| arg == "-V" || arg == "--version") { 33 | let version = version(); 34 | println!("{} {version}", env!("CARGO_BIN_NAME")); 35 | std::process::exit(0); 36 | } 37 | 38 | let tracing_enabled = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok(); 39 | configure_tracing(tracing_enabled).map_err(|e| { 40 | error!("Failed to configure tracing: {}", e); 41 | e 42 | })?; 43 | info!("Starting operator"); 44 | 45 | let cfg = Config::builder() 46 | .add_source(config::Environment::with_prefix("WASMCLOUD_OPERATOR")) 47 | .build() 48 | .map_err(|e| anyhow!("Failed to build config: {}", e))?; 49 | let config: OperatorConfig = cfg 50 | .try_deserialize() 51 | .map_err(|e| anyhow!("Failed to parse config: {}", e))?; 52 | 53 | let client = Client::try_default().await?; 54 | install_crd(&client).await?; 55 | 56 | let state = State::new(config); 57 | let ctl = controller::run(state.clone()); 58 | let router = controller::router::setup(state.clone()); 59 | 60 | let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; 61 | let tls_config = RustlsConfig::from_der( 62 | vec![cert.serialize_der()?], 63 | cert.serialize_private_key_der(), 64 | ) 65 | .await?; 66 | 67 | let handle = Handle::new(); 68 | let addr = SocketAddr::from(([0, 0, 0, 0], 8443)); 69 | let server = axum_server::bind_rustls(addr, tls_config) 70 | .handle(handle.clone()) 71 | .serve(router.into_make_service()); 72 | tokio::spawn(async move { 73 | info!("Starting apiserver"); 74 | let res = server.await; 75 | if let Err(e) = res { 76 | error!("Error running apiserver: {}", e); 77 | } 78 | }); 79 | 80 | ctl.await?; 81 | handle.graceful_shutdown(Some(Duration::from_secs(3))); 82 | info!("Controller finished"); 83 | Ok(()) 84 | } 85 | 86 | fn configure_tracing(enabled: bool) -> anyhow::Result<()> { 87 | let tracer = opentelemetry_otlp::new_pipeline() 88 | .tracing() 89 | .with_exporter(opentelemetry_otlp::new_exporter().tonic()) 90 | .with_trace_config( 91 | opentelemetry_sdk::trace::config() 92 | .with_sampler(Sampler::AlwaysOn) 93 | .with_id_generator(RandomIdGenerator::default()) 94 | .with_max_attributes_per_span(32) 95 | .with_max_events_per_span(32) 96 | .with_resource(Resource::new(vec![KeyValue::new( 97 | "service.name", 98 | "wasmcloud-operator", 99 | )])), 100 | ) 101 | .install_simple()?; 102 | 103 | let env_filter_layer = tracing_subscriber::EnvFilter::from_default_env(); 104 | let log_layer = tracing_subscriber::fmt::layer() 105 | .with_writer(std::io::stderr) 106 | .with_ansi(std::io::stderr().is_terminal()); 107 | 108 | let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); 109 | 110 | if enabled { 111 | let subscriber = tracing_subscriber::Registry::default() 112 | .with(env_filter_layer) 113 | .with(log_layer) 114 | .with(otel_layer); 115 | 116 | let _ = tracing::subscriber::set_global_default(subscriber); 117 | } else { 118 | let subscriber = tracing_subscriber::Registry::default() 119 | .with(env_filter_layer) 120 | .with(log_layer); 121 | 122 | let _ = tracing::subscriber::set_global_default(subscriber); 123 | } 124 | Ok(()) 125 | } 126 | 127 | // Initialize the CRD in the cluster 128 | async fn install_crd(client: &Client) -> anyhow::Result<()> { 129 | let crds = Api::::all(client.clone()); 130 | let crd = &WasmCloudHostConfig::crd(); 131 | let registrations = Api::::all(client.clone()); 132 | 133 | let crd_name = crd.metadata.name.as_ref().unwrap(); 134 | // TODO(protochron) validate that the crd is upd to date and patch if needed 135 | // This doesn't work for some reason with status subresources if they change, probably because 136 | // they're not actually embedded in the struct 137 | if let Ok(old_crd) = crds.get(crd_name.as_str()).await { 138 | if old_crd != *crd { 139 | info!("Updating CRD"); 140 | crds.patch( 141 | crd_name.as_str(), 142 | // https://github.com/kubernetes/client-go/issues/1036 143 | // regarding +yaml: https://kubernetes.io/docs/reference/using-api/server-side-apply/#serialization 144 | &PatchParams::apply("application/apply-patch+yaml").force(), 145 | &Patch::Apply(crd), 146 | ) 147 | .await?; 148 | } 149 | } else { 150 | crds.create(&PostParams::default(), crd) 151 | .await 152 | .map_err(|e| anyhow!("failed to create crd: {e}"))?; 153 | } 154 | 155 | let namespace = std::env::var("POD_NAMESPACE").unwrap_or("default".to_string()); 156 | let mut registration = APIService { 157 | metadata: ObjectMeta { 158 | name: Some("v1beta1.core.oam.dev".to_string()), 159 | ..Default::default() 160 | }, 161 | spec: Some(APIServiceSpec { 162 | group: Some("core.oam.dev".to_string()), 163 | group_priority_minimum: 2100, 164 | insecure_skip_tls_verify: Some(true), 165 | version_priority: 100, 166 | version: Some("v1beta1".to_string()), 167 | service: Some(ServiceReference { 168 | name: Some("wasmcloud-operator".to_string()), 169 | namespace: Some(namespace), 170 | port: Some(8443), 171 | }), 172 | ..Default::default() 173 | }), 174 | ..Default::default() 175 | }; 176 | 177 | let old_reg = registrations.get("v1beta1.core.oam.dev").await; 178 | if let Ok(old) = old_reg { 179 | info!("Updating APIService"); 180 | let resource_version = old.metadata.resource_version.unwrap(); 181 | registration.metadata.resource_version = Some(resource_version); 182 | 183 | // Wholesale replace because we're terrible people and don't care at all about existing OAM 184 | // managment controllers 185 | registrations 186 | .replace( 187 | "v1beta1.core.oam.dev", 188 | &PostParams::default(), 189 | ®istration, 190 | ) 191 | .await?; 192 | } else { 193 | info!("Creating APIService"); 194 | registrations 195 | .create(&PostParams::default(), ®istration) 196 | .await 197 | .map_err(|e| anyhow!("failed to create registration: {e}"))?; 198 | }; 199 | 200 | Ok(()) 201 | } 202 | 203 | fn version() -> &'static str { 204 | option_env!("CARGO_VERSION_INFO").unwrap_or(env!("CARGO_PKG_VERSION")) 205 | } 206 | -------------------------------------------------------------------------------- /src/openapi.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashMap}; 2 | 3 | use axum::{ 4 | http::{HeaderMap, StatusCode, Uri}, 5 | response::IntoResponse, 6 | routing::get, 7 | Json, Router, 8 | }; 9 | use serde::{Deserialize, Serialize}; 10 | use tracing::debug; 11 | use utoipa::{OpenApi, ToSchema}; 12 | 13 | /* TODO: 14 | * - Add full support for Kubernetes' OpenAPI spec: https://github.com/kubernetes/kubernetes/tree/master/api/openapi-spec 15 | * - Add namespace and labels support: 16 | * - Without namespaces we get an "InvalidSpecError": https://github.com/argoproj/argo-cd/blob/a761a495f16d76c0a8e50359eda50f605e329aba/controller/state.go#L694-L696 17 | */ 18 | 19 | /// An OAM manifest 20 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 21 | #[schema(as = dev::oam::core::v1beta1::Application)] 22 | #[serde(rename = "Application")] 23 | pub struct Application { 24 | /// The OAM version of the manifest 25 | #[serde(rename = "apiVersion")] 26 | pub api_version: String, 27 | /// The kind or type of manifest described by the spec 28 | pub kind: String, 29 | /// Metadata describing the manifest 30 | pub metadata: Metadata, 31 | /// The specification for this manifest 32 | pub spec: Specification, 33 | } 34 | 35 | /// The metadata describing the manifest 36 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 37 | #[schema(as = dev::oam::core::v1beta1::Metadata)] 38 | pub struct Metadata { 39 | /// The name of the manifest. This should be unique 40 | pub name: String, 41 | // This is to satisfy ArgoCD's validation. 42 | pub namespace: String, 43 | /// Optional data for annotating this manifest 44 | #[serde(skip_serializing_if = "BTreeMap::is_empty")] 45 | pub annotations: BTreeMap, 46 | // This is to satisfy ArgoCD's validation. 47 | pub labels: BTreeMap, 48 | } 49 | 50 | /// A representation of an OAM specification 51 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 52 | #[schema(as = dev::oam::core::v1beta1::Specification)] 53 | pub struct Specification { 54 | /// The list of components for describing an application 55 | pub components: Vec, 56 | 57 | /// The list of policies describing an application. This is for providing application-wide 58 | /// setting such as configuration for a secrets backend, how to render Kubernetes services, 59 | /// etc. It can be omitted if no policies are needed for an application. 60 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 61 | pub policies: Vec, 62 | } 63 | 64 | /// A policy definition 65 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 66 | #[schema(as = dev::oam::core::v1beta1::Policy)] 67 | pub struct Policy { 68 | /// The name of this policy 69 | pub name: String, 70 | /// The properties for this policy 71 | pub properties: BTreeMap, 72 | /// The type of the policy 73 | #[serde(rename = "type")] 74 | pub policy_type: String, 75 | } 76 | 77 | /// A component definition 78 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 79 | #[schema(as = dev::oam::core::v1beta1::Component)] 80 | pub struct Component { 81 | /// The name of this component 82 | pub name: String, 83 | /// The type of component 84 | /// The properties for this component 85 | // NOTE(thomastaylor312): It would probably be better for us to implement a custom deserialze 86 | // and serialize that combines this and the component type. This is good enough for first draft 87 | #[serde(flatten)] 88 | pub properties: Properties, 89 | /// A list of various traits assigned to this component 90 | #[serde(skip_serializing_if = "Option::is_none")] 91 | pub traits: Option>, 92 | } 93 | 94 | /// Properties that can be defined for a component 95 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 96 | #[schema(as = dev::oam::core::v1beta1::Properties)] 97 | #[serde(tag = "type")] 98 | pub enum Properties { 99 | #[serde(rename = "actor")] 100 | Actor { properties: ActorProperties }, 101 | #[serde(rename = "capability")] 102 | Capability { properties: CapabilityProperties }, 103 | } 104 | 105 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 106 | #[schema(as = dev::oam::core::v1beta1::ActorProperties)] 107 | pub struct ActorProperties { 108 | /// The image reference to use 109 | pub image: String, 110 | } 111 | 112 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 113 | #[schema(as = dev::oam::core::v1beta1::CapabilityProperties)] 114 | pub struct CapabilityProperties { 115 | /// The image reference to use 116 | pub image: String, 117 | /// The contract ID of this capability 118 | pub contract: String, 119 | /// An optional link name to use for this capability 120 | #[serde(skip_serializing_if = "Option::is_none")] 121 | pub link_name: Option, 122 | /// Optional config to pass to the provider. This can be either a raw string encoded config, or 123 | /// a JSON or YAML object 124 | #[serde(skip_serializing_if = "Option::is_none")] 125 | pub config: Option, 126 | } 127 | 128 | /// Right now providers can technically use any config format they want, although most use JSON. 129 | /// This enum takes that into account and allows either type of data to be passed 130 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 131 | #[schema(as = dev::oam::core::v1beta1::CapabilityConfig)] 132 | pub enum CapabilityConfig { 133 | Json(serde_json::Value), 134 | Opaque(String), 135 | } 136 | 137 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 138 | #[schema(as = dev::oam::core::v1beta1::Trait)] 139 | pub struct Trait { 140 | /// The type of trait specified. This should be a unique string for the type of scaler. As we 141 | /// plan on supporting custom scalers, these traits are not enumerated 142 | #[serde(rename = "type")] 143 | pub trait_type: String, 144 | /// The properties of this trait 145 | pub properties: TraitProperty, 146 | } 147 | 148 | /// Properties for defining traits 149 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 150 | #[schema(as = dev::oam::core::v1beta1::TraitProperty)] 151 | #[serde(untagged)] 152 | pub enum TraitProperty { 153 | Linkdef(LinkdefProperty), 154 | SpreadScaler(SpreadScalerProperty), 155 | // TODO(thomastaylor312): This is still broken right now with deserializing. If the incoming 156 | // type specifies replicas, it matches with spreadscaler first. So we need to implement a custom 157 | // parser here 158 | Custom(serde_json::Value), 159 | } 160 | 161 | /// Properties for linkdefs 162 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 163 | #[schema(as = dev::oam::core::v1beta1::LinkdefProperty)] 164 | pub struct LinkdefProperty { 165 | /// The target this linkdef applies to. This should be the name of an actor component 166 | pub target: String, 167 | /// Values to use for this linkdef 168 | #[serde(skip_serializing_if = "Option::is_none")] 169 | pub values: Option>, 170 | } 171 | 172 | /// Properties for spread scalers 173 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 174 | #[schema(as = dev::oam::core::v1beta1::SpreadScalerProperty)] 175 | pub struct SpreadScalerProperty { 176 | /// Number of replicas to scale 177 | pub replicas: usize, 178 | /// Requirements for spreading throse replicas 179 | #[serde(default)] 180 | pub spread: Vec, 181 | } 182 | 183 | /// Configuration for various spreading requirements 184 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] 185 | #[schema(as = dev::oam::core::v1beta1::Spread)] 186 | pub struct Spread { 187 | /// The name of this spread requirement 188 | pub name: String, 189 | /// An arbitrary map of labels to match on for scaling requirements 190 | #[serde(skip_serializing_if = "BTreeMap::is_empty")] 191 | pub requirements: BTreeMap, 192 | /// An optional weight for this spread. Higher weights are given more precedence 193 | #[serde(skip_serializing_if = "Option::is_none")] 194 | pub weight: Option, 195 | } 196 | 197 | #[derive(OpenApi)] 198 | #[openapi( 199 | components(schemas( 200 | ActorProperties, 201 | Application, 202 | CapabilityConfig, 203 | CapabilityProperties, 204 | Component, 205 | LinkdefProperty, 206 | Metadata, 207 | Policy, 208 | Properties, 209 | Specification, 210 | Spread, 211 | SpreadScalerProperty, 212 | Trait, 213 | TraitProperty, 214 | )), 215 | info( 216 | description = "The OAM Application API provides a way to manage applications in a Kubernetes cluster." 217 | ), 218 | paths( 219 | crate::router::api_resources, 220 | crate::resources::application::create_application, 221 | crate::resources::application::list_applications, 222 | crate::resources::application::get_application, 223 | crate::resources::application::patch_application, 224 | crate::resources::application::delete_application, 225 | ) 226 | )] 227 | pub struct ApiDoc; 228 | 229 | #[derive(Serialize)] 230 | struct OpenApiV3Discovery { 231 | paths: HashMap, 232 | } 233 | 234 | #[derive(Serialize)] 235 | struct OpenApiV3DiscoveryGroupVersion { 236 | #[serde(rename = "serverRelativeURL")] 237 | server_relative_url: String, 238 | } 239 | 240 | impl OpenApiV3Discovery { 241 | pub fn new() -> Self { 242 | let mut paths = HashMap::new(); 243 | paths.insert( 244 | "apis/core.oam.dev/v1beta1".to_string(), 245 | OpenApiV3DiscoveryGroupVersion { 246 | server_relative_url: "/openapi/v3/apis/core.oam.dev/v1beta1".to_string(), 247 | }, 248 | ); 249 | Self { paths } 250 | } 251 | } 252 | 253 | pub fn router() -> Router { 254 | Router::new() 255 | .route("/v3", get(openapi_v3)) 256 | .route("/v3/apis/core.oam.dev/v1beta1", get(openapi_v3_details)) 257 | .route("/v2", get(openapi_v2)) 258 | .fallback(fallback) 259 | } 260 | 261 | async fn fallback(headers: HeaderMap, uri: Uri) -> (StatusCode, String) { 262 | debug!("openapi fallback: uri={uri}"); 263 | for (hk, hv) in headers.iter() { 264 | debug!("hk={hk}, hv={}", hv.to_str().unwrap()) 265 | } 266 | (StatusCode::NOT_FOUND, format!("No route for {uri}")) 267 | } 268 | 269 | async fn openapi_v3() -> Json { 270 | //let doc = ApiDoc::openapi().to_json().unwrap(); 271 | let root = OpenApiV3Discovery::new(); 272 | Json(root) 273 | } 274 | 275 | async fn openapi_v3_details() -> Json { 276 | // TODO add actual OAM docs in this 277 | // We may need to copy/paste the OAM spec from wadm here to add the right annotations or at 278 | // least use type annotation. 279 | let doc = ApiDoc::openapi(); 280 | Json(serde_json::to_value(doc).unwrap()) 281 | } 282 | 283 | async fn openapi_v2() -> impl IntoResponse { 284 | OPENAPI_V2_SPEC_JSON.into_response() 285 | } 286 | 287 | const OPENAPI_V2_SPEC_JSON: &str = r##" 288 | { 289 | "swagger": "2.0", 290 | "info": { 291 | "title": "wasmcloud-operator", 292 | "description": "The OAM Application API provides a way to manage applications in a Kubernetes cluster.", 293 | "license": { 294 | "name": "Apache 2.0" 295 | }, 296 | "version": "0.4.0" 297 | }, 298 | "paths": { 299 | "/apis/core.oam.dev/v1beta1": { 300 | "get": { 301 | "parameters": [], 302 | "responses": {}, 303 | "tags": [ 304 | "crate::router" 305 | ], 306 | "operationId": "api_resources" 307 | } 308 | }, 309 | "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications": { 310 | "get": { 311 | "parameters": [ 312 | { 313 | "in": "path", 314 | "name": "namespace", 315 | "required": true, 316 | "type": "string" 317 | } 318 | ], 319 | "responses": {}, 320 | "tags": [ 321 | "crate::resources::application" 322 | ], 323 | "operationId": "list_applications", 324 | "x-kubernetes-group-version-kind": [ 325 | { 326 | "group": "core.oam.dev", 327 | "kind": "Application", 328 | "version": "v1beta1" 329 | } 330 | ] 331 | }, 332 | "post": { 333 | "parameters": [ 334 | { 335 | "in": "path", 336 | "name": "namespace", 337 | "required": true, 338 | "type": "string" 339 | }, 340 | { 341 | "description": "", 342 | "in": "body", 343 | "name": "body", 344 | "required": true, 345 | "schema": { 346 | "type": "string", 347 | "format": "binary" 348 | } 349 | } 350 | ], 351 | "responses": {}, 352 | "tags": [ 353 | "crate::resources::application" 354 | ], 355 | "operationId": "create_application", 356 | "consumes": [ 357 | "application/octet-stream" 358 | ], 359 | "x-kubernetes-group-version-kind": [ 360 | { 361 | "group": "core.oam.dev", 362 | "kind": "Application", 363 | "version": "v1beta1" 364 | } 365 | ] 366 | } 367 | }, 368 | "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications/{name}": { 369 | "delete": { 370 | "parameters": [ 371 | { 372 | "in": "path", 373 | "name": "namespace", 374 | "required": true, 375 | "type": "string" 376 | }, 377 | { 378 | "in": "path", 379 | "name": "name", 380 | "required": true, 381 | "type": "string" 382 | } 383 | ], 384 | "responses": {}, 385 | "tags": [ 386 | "crate::resources::application" 387 | ], 388 | "operationId": "delete_application", 389 | "x-kubernetes-group-version-kind": [ 390 | { 391 | "group": "core.oam.dev", 392 | "kind": "Application", 393 | "version": "v1beta1" 394 | } 395 | ] 396 | }, 397 | "get": { 398 | "parameters": [ 399 | { 400 | "in": "path", 401 | "name": "namespace", 402 | "required": true, 403 | "type": "string" 404 | }, 405 | { 406 | "in": "path", 407 | "name": "name", 408 | "required": true, 409 | "type": "string" 410 | } 411 | ], 412 | "responses": {}, 413 | "tags": [ 414 | "crate::resources::application" 415 | ], 416 | "operationId": "get_application", 417 | "x-kubernetes-group-version-kind": [ 418 | { 419 | "group": "core.oam.dev", 420 | "kind": "Application", 421 | "version": "v1beta1" 422 | } 423 | ] 424 | }, 425 | "patch": { 426 | "parameters": [ 427 | { 428 | "in": "path", 429 | "name": "namespace", 430 | "required": true, 431 | "type": "string" 432 | }, 433 | { 434 | "in": "path", 435 | "name": "name", 436 | "required": true, 437 | "type": "string" 438 | }, 439 | { 440 | "description": "", 441 | "in": "body", 442 | "name": "body", 443 | "required": true, 444 | "schema": { 445 | "type": "string", 446 | "format": "binary" 447 | } 448 | } 449 | ], 450 | "responses": {}, 451 | "tags": [ 452 | "crate::resources::application" 453 | ], 454 | "operationId": "patch_application", 455 | "consumes": [ 456 | "application/octet-stream" 457 | ], 458 | "x-kubernetes-group-version-kind": [ 459 | { 460 | "group": "core.oam.dev", 461 | "kind": "Application", 462 | "version": "v1beta1" 463 | } 464 | ] 465 | } 466 | } 467 | }, 468 | "definitions": { 469 | "dev.oam.core.v1beta1.ActorProperties": { 470 | "properties": { 471 | "image": { 472 | "description": "The image reference to use", 473 | "type": "string" 474 | } 475 | }, 476 | "required": [ 477 | "image" 478 | ], 479 | "type": "object", 480 | "x-kubernetes-group-version-kind": [ 481 | { 482 | "group": "core.oam.dev", 483 | "kind": "ActorProperties", 484 | "version": "v1beta1" 485 | } 486 | ] 487 | }, 488 | "dev.oam.core.v1beta1.Application": { 489 | "description": "An OAM manifest", 490 | "properties": { 491 | "apiVersion": { 492 | "description": "The OAM version of the manifest", 493 | "type": "string" 494 | }, 495 | "kind": { 496 | "description": "The kind or type of manifest described by the spec", 497 | "type": "string" 498 | }, 499 | "metadata": { 500 | "$ref": "#/definitions/dev.oam.core.v1beta1.Metadata" 501 | }, 502 | "spec": { 503 | "$ref": "#/definitions/dev.oam.core.v1beta1.Specification" 504 | } 505 | }, 506 | "required": [ 507 | "apiVersion", 508 | "kind", 509 | "metadata", 510 | "spec" 511 | ], 512 | "type": "object", 513 | "x-kubernetes-group-version-kind": [ 514 | { 515 | "group": "core.oam.dev", 516 | "kind": "Application", 517 | "version": "v1beta1" 518 | } 519 | ] 520 | }, 521 | "dev.oam.core.v1beta1.CapabilityConfig": { 522 | "description": "Right now providers can technically use any config format they want, although most use JSON.\nThis enum takes that into account and allows either type of data to be passed", 523 | "oneOf": [ 524 | { 525 | "properties": { 526 | "Json": {} 527 | }, 528 | "required": [ 529 | "Json" 530 | ], 531 | "type": "object" 532 | }, 533 | { 534 | "properties": { 535 | "Opaque": { 536 | "type": "string" 537 | } 538 | }, 539 | "required": [ 540 | "Opaque" 541 | ], 542 | "type": "object" 543 | } 544 | ], 545 | "x-kubernetes-group-version-kind": [ 546 | { 547 | "group": "core.oam.dev", 548 | "kind": "CapabilityConfig", 549 | "version": "v1beta1" 550 | } 551 | ] 552 | }, 553 | "dev.oam.core.v1beta1.CapabilityProperties": { 554 | "properties": { 555 | "config": { 556 | "allOf": [ 557 | { 558 | "$ref": "#/definitions/dev.oam.core.v1beta1.CapabilityConfig" 559 | } 560 | ], 561 | "nullable": true 562 | }, 563 | "contract": { 564 | "description": "The contract ID of this capability", 565 | "type": "string" 566 | }, 567 | "image": { 568 | "description": "The image reference to use", 569 | "type": "string" 570 | }, 571 | "link_name": { 572 | "description": "An optional link name to use for this capability", 573 | "nullable": true, 574 | "type": "string" 575 | } 576 | }, 577 | "required": [ 578 | "image", 579 | "contract" 580 | ], 581 | "type": "object", 582 | "x-kubernetes-group-version-kind": [ 583 | { 584 | "group": "core.oam.dev", 585 | "kind": "CapabilityProperties", 586 | "version": "v1beta1" 587 | } 588 | ] 589 | }, 590 | "dev.oam.core.v1beta1.Component": { 591 | "allOf": [ 592 | { 593 | "$ref": "#/definitions/dev.oam.core.v1beta1.Properties" 594 | }, 595 | { 596 | "properties": { 597 | "name": { 598 | "description": "The name of this component", 599 | "type": "string" 600 | }, 601 | "traits": { 602 | "description": "A list of various traits assigned to this component", 603 | "items": { 604 | "$ref": "#/definitions/dev.oam.core.v1beta1.Trait" 605 | }, 606 | "nullable": true, 607 | "type": "array" 608 | } 609 | }, 610 | "required": [ 611 | "name" 612 | ], 613 | "type": "object" 614 | } 615 | ], 616 | "description": "A component definition", 617 | "x-kubernetes-group-version-kind": [ 618 | { 619 | "group": "core.oam.dev", 620 | "kind": "Component", 621 | "version": "v1beta1" 622 | } 623 | ] 624 | }, 625 | "dev.oam.core.v1beta1.LinkdefProperty": { 626 | "description": "Properties for linkdefs", 627 | "properties": { 628 | "target": { 629 | "description": "The target this linkdef applies to. This should be the name of an actor component", 630 | "type": "string" 631 | }, 632 | "values": { 633 | "additionalProperties": { 634 | "type": "string" 635 | }, 636 | "description": "Values to use for this linkdef", 637 | "nullable": true, 638 | "type": "object" 639 | } 640 | }, 641 | "required": [ 642 | "target" 643 | ], 644 | "type": "object", 645 | "x-kubernetes-group-version-kind": [ 646 | { 647 | "group": "core.oam.dev", 648 | "kind": "LinkdefProperty", 649 | "version": "v1beta1" 650 | } 651 | ] 652 | }, 653 | "dev.oam.core.v1beta1.Metadata": { 654 | "description": "The metadata describing the manifest", 655 | "properties": { 656 | "annotations": { 657 | "additionalProperties": { 658 | "type": "string" 659 | }, 660 | "description": "Optional data for annotating this manifest", 661 | "type": "object" 662 | }, 663 | "labels": { 664 | "additionalProperties": { 665 | "type": "string" 666 | }, 667 | "type": "object" 668 | }, 669 | "name": { 670 | "description": "The name of the manifest. This should be unique", 671 | "type": "string" 672 | }, 673 | "namespace": { 674 | "type": "string" 675 | } 676 | }, 677 | "required": [ 678 | "name", 679 | "namespace", 680 | "labels" 681 | ], 682 | "type": "object", 683 | "x-kubernetes-group-version-kind": [ 684 | { 685 | "group": "core.oam.dev", 686 | "kind": "Metadata", 687 | "version": "v1beta1" 688 | } 689 | ] 690 | }, 691 | "dev.oam.core.v1beta1.Policy": { 692 | "description": "A policy definition", 693 | "properties": { 694 | "name": { 695 | "description": "The name of this policy", 696 | "type": "string" 697 | }, 698 | "properties": { 699 | "additionalProperties": { 700 | "type": "string" 701 | }, 702 | "description": "The properties for this policy", 703 | "type": "object" 704 | }, 705 | "type": { 706 | "description": "The type of the policy", 707 | "type": "string" 708 | } 709 | }, 710 | "required": [ 711 | "name", 712 | "properties", 713 | "type" 714 | ], 715 | "type": "object", 716 | "x-kubernetes-group-version-kind": [ 717 | { 718 | "group": "core.oam.dev", 719 | "kind": "Policy", 720 | "version": "v1beta1" 721 | } 722 | ] 723 | }, 724 | "dev.oam.core.v1beta1.Properties": { 725 | "description": "Properties that can be defined for a component", 726 | "discriminator": { 727 | "propertyName": "type" 728 | }, 729 | "oneOf": [ 730 | { 731 | "properties": { 732 | "properties": { 733 | "$ref": "#/definitions/dev.oam.core.v1beta1.ActorProperties" 734 | }, 735 | "type": { 736 | "enum": [ 737 | "actor" 738 | ], 739 | "type": "string" 740 | } 741 | }, 742 | "required": [ 743 | "properties", 744 | "type" 745 | ], 746 | "type": "object" 747 | }, 748 | { 749 | "properties": { 750 | "properties": { 751 | "$ref": "#/definitions/dev.oam.core.v1beta1.CapabilityProperties" 752 | }, 753 | "type": { 754 | "enum": [ 755 | "capability" 756 | ], 757 | "type": "string" 758 | } 759 | }, 760 | "required": [ 761 | "properties", 762 | "type" 763 | ], 764 | "type": "object" 765 | } 766 | ], 767 | "x-kubernetes-group-version-kind": [ 768 | { 769 | "group": "core.oam.dev", 770 | "kind": "Properties", 771 | "version": "v1beta1" 772 | } 773 | ] 774 | }, 775 | "dev.oam.core.v1beta1.Specification": { 776 | "description": "A representation of an OAM specification", 777 | "properties": { 778 | "components": { 779 | "description": "The list of components for describing an application", 780 | "items": { 781 | "$ref": "#/definitions/dev.oam.core.v1beta1.Component" 782 | }, 783 | "type": "array" 784 | }, 785 | "policies": { 786 | "description": "The list of policies describing an application. This is for providing application-wide\nsetting such as configuration for a secrets backend, how to render Kubernetes services,\netc. It can be omitted if no policies are needed for an application.", 787 | "items": { 788 | "$ref": "#/definitions/dev.oam.core.v1beta1.Policy" 789 | }, 790 | "type": "array" 791 | } 792 | }, 793 | "required": [ 794 | "components" 795 | ], 796 | "type": "object", 797 | "x-kubernetes-group-version-kind": [ 798 | { 799 | "group": "core.oam.dev", 800 | "kind": "Specification", 801 | "version": "v1beta1" 802 | } 803 | ] 804 | }, 805 | "dev.oam.core.v1beta1.Spread": { 806 | "description": "Configuration for various spreading requirements", 807 | "properties": { 808 | "name": { 809 | "description": "The name of this spread requirement", 810 | "type": "string" 811 | }, 812 | "requirements": { 813 | "additionalProperties": { 814 | "type": "string" 815 | }, 816 | "description": "An arbitrary map of labels to match on for scaling requirements", 817 | "type": "object" 818 | }, 819 | "weight": { 820 | "description": "An optional weight for this spread. Higher weights are given more precedence", 821 | "minimum": 0, 822 | "nullable": true, 823 | "type": "integer" 824 | } 825 | }, 826 | "required": [ 827 | "name" 828 | ], 829 | "type": "object", 830 | "x-kubernetes-group-version-kind": [ 831 | { 832 | "group": "core.oam.dev", 833 | "kind": "Spread", 834 | "version": "v1beta1" 835 | } 836 | ] 837 | }, 838 | "dev.oam.core.v1beta1.SpreadScalerProperty": { 839 | "description": "Properties for spread scalers", 840 | "properties": { 841 | "replicas": { 842 | "description": "Number of replicas to scale", 843 | "minimum": 0, 844 | "type": "integer" 845 | }, 846 | "spread": { 847 | "description": "Requirements for spreading throse replicas", 848 | "items": { 849 | "$ref": "#/definitions/dev.oam.core.v1beta1.Spread" 850 | }, 851 | "type": "array" 852 | } 853 | }, 854 | "required": [ 855 | "replicas" 856 | ], 857 | "type": "object", 858 | "x-kubernetes-group-version-kind": [ 859 | { 860 | "group": "core.oam.dev", 861 | "kind": "SpreadScalerProperty", 862 | "version": "v1beta1" 863 | } 864 | ] 865 | }, 866 | "dev.oam.core.v1beta1.Trait": { 867 | "properties": { 868 | "properties": { 869 | "$ref": "#/definitions/dev.oam.core.v1beta1.TraitProperty" 870 | }, 871 | "type": { 872 | "description": "The type of trait specified. This should be a unique string for the type of scaler. As we\nplan on supporting custom scalers, these traits are not enumerated", 873 | "type": "string" 874 | } 875 | }, 876 | "required": [ 877 | "type", 878 | "properties" 879 | ], 880 | "type": "object", 881 | "x-kubernetes-group-version-kind": [ 882 | { 883 | "group": "core.oam.dev", 884 | "kind": "Trait", 885 | "version": "v1beta1" 886 | } 887 | ] 888 | }, 889 | "dev.oam.core.v1beta1.TraitProperty": { 890 | "description": "Properties for defining traits", 891 | "oneOf": [ 892 | { 893 | "$ref": "#/definitions/dev.oam.core.v1beta1.LinkdefProperty" 894 | }, 895 | { 896 | "$ref": "#/definitions/dev.oam.core.v1beta1.SpreadScalerProperty" 897 | }, 898 | {} 899 | ], 900 | "x-kubernetes-group-version-kind": [ 901 | { 902 | "group": "core.oam.dev", 903 | "kind": "TraitProperty", 904 | "version": "v1beta1" 905 | } 906 | ] 907 | } 908 | }, 909 | "x-components": {} 910 | } 911 | 912 | "##; 913 | -------------------------------------------------------------------------------- /src/resources/application.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashMap, HashSet}; 2 | use std::sync::Arc; 3 | 4 | use anyhow::{anyhow, Error}; 5 | use async_nats::{Client as NatsClient, ConnectError, ConnectOptions}; 6 | use axum::{ 7 | body::Bytes, 8 | extract::{Path, State as AxumState}, 9 | http::StatusCode, 10 | response::{IntoResponse, Json, Response}, 11 | TypedHeader, 12 | }; 13 | use kube::{ 14 | api::{Api, ListParams}, 15 | client::Client as KubeClient, 16 | core::{ListMeta, ObjectMeta}, 17 | }; 18 | use secrecy::{ExposeSecret, SecretString}; 19 | use serde::Serialize; 20 | use serde_json::{json, Value}; 21 | use tokio::sync::RwLock; 22 | use tracing::error; 23 | use uuid::Uuid; 24 | use wadm_client::{error::ClientError, Client as WadmClient}; 25 | use wadm_types::{ 26 | api::{ModelSummary, Status, StatusType}, 27 | Manifest, 28 | }; 29 | 30 | use wasmcloud_operator_types::v1alpha1::WasmCloudHostConfig; 31 | 32 | use crate::{ 33 | controller::State, 34 | header::{Accept, As}, 35 | router::{internal_error, not_found_error}, 36 | table::{TableColumnDefinition, TableRow}, 37 | NameNamespace, 38 | }; 39 | 40 | /* TODO: 41 | * - Add a way to store Kubernetes **Namespace** the App belongs to. 42 | * - Possibly using annotations that are set automatically when app is deployed into the cluster. 43 | * - Add a way to store app.kubernetes.io/name label and other Recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/ 44 | * - Current hack results in a "SharedResourceWarning" warning in the UI: https://github.com/argoproj/argo-cd/blob/a761a495f16d76c0a8e50359eda50f605e329aba/controller/state.go#L529-L537 45 | * - Add a way to support all of the Argo resource tracking methods: https://argo-cd.readthedocs.io/en/stable/user-guide/resource_tracking/ 46 | */ 47 | 48 | const GROUP_VERSION: &str = "core.oam.dev/v1beta1"; 49 | const KUBECTL_LAST_APPLIED_CONFIG_ANNOTATION: &str = 50 | "kubectl.kubernetes.io/last-applied-configuration"; 51 | 52 | pub struct AppError(Error); 53 | 54 | impl IntoResponse for AppError { 55 | fn into_response(self) -> Response { 56 | ( 57 | StatusCode::INTERNAL_SERVER_ERROR, 58 | format!("Error: {}", self.0), 59 | ) 60 | .into_response() 61 | } 62 | } 63 | 64 | impl From for AppError 65 | where 66 | E: Into, 67 | { 68 | fn from(e: E) -> Self { 69 | Self(e.into()) 70 | } 71 | } 72 | 73 | #[derive(Debug, Clone, Serialize)] 74 | #[serde(rename_all = "camelCase")] 75 | pub struct Application { 76 | pub api_version: String, 77 | pub kind: String, 78 | pub metadata: ObjectMeta, 79 | } 80 | 81 | impl Application { 82 | pub fn new(name: String) -> Self { 83 | Self { 84 | api_version: "v1beta1".to_string(), 85 | kind: "Application".to_string(), 86 | metadata: ObjectMeta { 87 | name: Some(name), 88 | ..Default::default() 89 | }, 90 | } 91 | } 92 | } 93 | 94 | #[derive(Debug, Clone, Serialize)] 95 | #[serde(rename_all = "camelCase")] 96 | pub struct ApplicationList { 97 | api_version: String, 98 | kind: String, 99 | items: Vec, 100 | metadata: ListMeta, 101 | } 102 | 103 | impl ApplicationList { 104 | pub fn new() -> Self { 105 | Self { 106 | api_version: GROUP_VERSION.to_string(), 107 | kind: "ApplicationList".to_string(), 108 | items: vec![], 109 | metadata: ListMeta::default(), 110 | } 111 | } 112 | } 113 | 114 | impl Default for ApplicationList { 115 | fn default() -> Self { 116 | Self::new() 117 | } 118 | } 119 | 120 | impl From> for ApplicationList { 121 | fn from(partials: Vec) -> Self { 122 | let mut al = ApplicationList::default(); 123 | // TODO(joonas): Let's figure out a better way to do this shall we? 124 | let v = serde_json::to_value(&partials).unwrap(); 125 | let resource_version = Uuid::new_v5(&Uuid::NAMESPACE_OID, v.to_string().as_bytes()); 126 | al.metadata.resource_version = Some(resource_version.to_string()); 127 | al.items = partials; 128 | al 129 | } 130 | } 131 | 132 | #[derive(Debug, Clone, Serialize, Default)] 133 | #[serde(rename_all = "camelCase")] 134 | pub struct ApplicationPartial { 135 | metadata: ObjectMeta, 136 | spec: BTreeMap, 137 | status: BTreeMap, 138 | } 139 | 140 | impl From for ApplicationPartial { 141 | fn from(summary: ModelSummary) -> Self { 142 | let ns = format!("{}/{}", summary.name, summary.version); 143 | let uid = Uuid::new_v5(&Uuid::NAMESPACE_OID, ns.as_bytes()); 144 | Self { 145 | metadata: ObjectMeta { 146 | name: Some(summary.name), 147 | // TODO(joonas): Infer this, or make it something that can be set later. 148 | namespace: Some("default".to_string()), 149 | resource_version: Some(uid.to_string()), 150 | uid: Some(uid.to_string()), 151 | ..Default::default() 152 | }, 153 | ..Default::default() 154 | } 155 | } 156 | } 157 | 158 | // Definition: https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Table 159 | // Based on https://github.com/kubernetes-sigs/metrics-server/blob/master/pkg/api/table.go 160 | #[derive(Debug, Clone, Serialize)] 161 | #[serde(rename_all = "camelCase")] 162 | pub struct ApplicationTable { 163 | api_version: String, 164 | kind: String, 165 | column_definitions: Vec, 166 | rows: Vec, 167 | metadata: ListMeta, 168 | } 169 | 170 | impl Default for ApplicationTable { 171 | fn default() -> Self { 172 | Self::new() 173 | } 174 | } 175 | 176 | impl ApplicationTable { 177 | pub fn new() -> Self { 178 | Self { 179 | api_version: "meta.k8s.io/v1".to_string(), 180 | kind: "Table".to_string(), 181 | column_definitions: vec![ 182 | TableColumnDefinition { 183 | name: "Application".to_string(), 184 | kind: "string".to_string(), 185 | description: "Name of the Application".to_string(), 186 | priority: 0, 187 | format: "name".to_string(), 188 | }, 189 | TableColumnDefinition { 190 | name: "Deployed Version".to_string(), 191 | kind: "string".to_string(), 192 | description: "Currently deployed version of the Application".to_string(), 193 | priority: 0, 194 | ..Default::default() 195 | }, 196 | TableColumnDefinition { 197 | name: "Latest Version".to_string(), 198 | kind: "string".to_string(), 199 | description: "Latest available version of the Application".to_string(), 200 | priority: 0, 201 | ..Default::default() 202 | }, 203 | TableColumnDefinition { 204 | name: "Status".to_string(), 205 | kind: "string".to_string(), 206 | description: "Current status of the Application".to_string(), 207 | priority: 0, 208 | ..Default::default() 209 | }, 210 | ], 211 | rows: vec![], 212 | metadata: ListMeta::default(), 213 | } 214 | } 215 | } 216 | 217 | impl From> for ApplicationTable { 218 | fn from(summaries: Vec) -> Self { 219 | let mut table = Self::default(); 220 | let rows = summaries 221 | .into_iter() 222 | .map(|i| TableRow { 223 | cells: vec![ 224 | i.name, 225 | i.deployed_version.unwrap_or("N/A".to_string()), 226 | i.version, 227 | match i.status { 228 | StatusType::Undeployed => "Undeployed".to_string(), 229 | StatusType::Reconciling => "Reconciling".to_string(), 230 | StatusType::Deployed => "Deployed".to_string(), 231 | StatusType::Failed => "Failed".to_string(), 232 | }, 233 | ], 234 | }) 235 | .collect(); 236 | 237 | table.rows = rows; 238 | table 239 | } 240 | } 241 | 242 | impl From> for ApplicationTable { 243 | fn from(manifests: Vec) -> Self { 244 | let mut table = Self::default(); 245 | let rows = manifests 246 | .into_iter() 247 | .map(|cm| TableRow { 248 | cells: vec![ 249 | cm.name(), 250 | cm.deployed_version(), 251 | cm.latest_version(), 252 | cm.status(), 253 | ], 254 | }) 255 | .collect(); 256 | 257 | table.rows = rows; 258 | table 259 | } 260 | } 261 | 262 | struct CombinedManifest { 263 | manifest: Manifest, 264 | status: Status, 265 | } 266 | 267 | impl CombinedManifest { 268 | pub(crate) fn new(manifest: Manifest, status: Status) -> Self { 269 | Self { manifest, status } 270 | } 271 | 272 | pub(crate) fn name(&self) -> String { 273 | self.manifest.metadata.name.to_owned() 274 | } 275 | 276 | pub(crate) fn deployed_version(&self) -> String { 277 | match self.manifest.metadata.annotations.get("version") { 278 | Some(v) => v.to_owned(), 279 | None => "N/A".to_string(), 280 | } 281 | } 282 | 283 | pub(crate) fn latest_version(&self) -> String { 284 | self.status.version.to_owned() 285 | } 286 | 287 | pub(crate) fn status(&self) -> String { 288 | match self.status.info.status_type { 289 | StatusType::Undeployed => "Undeployed", 290 | StatusType::Reconciling => "Reconciling", 291 | StatusType::Deployed => "Deployed", 292 | StatusType::Failed => "Failed", 293 | } 294 | .to_string() 295 | } 296 | } 297 | 298 | #[utoipa::path( 299 | post, 300 | path = "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications" 301 | )] 302 | pub async fn create_application( 303 | Path(namespace): Path, 304 | AxumState(state): AxumState, 305 | body: Bytes, 306 | ) -> impl IntoResponse { 307 | let kube_client = match KubeClient::try_default().await { 308 | Ok(c) => c, 309 | Err(e) => return internal_error(anyhow!("unable to initialize kubernetes client: {}", e)), 310 | }; 311 | let configs: Api = Api::namespaced(kube_client, &namespace); 312 | let cfgs = match configs.list(&ListParams::default()).await { 313 | Ok(objs) => objs, 314 | Err(e) => return internal_error(anyhow!("Unable to list cosmonic host configs: {}", e)), 315 | }; 316 | 317 | // TODO(joonas): Remove this once we move to pulling NATS creds+secrets from lattice instead of hosts. 318 | let (nats_client, lattice_id) = 319 | match get_lattice_connection(cfgs.into_iter(), state, namespace).await { 320 | Ok(data) => data, 321 | Err(resp) => return resp, 322 | }; 323 | 324 | let wadm_client = WadmClient::from_nats_client(&lattice_id, None, nats_client); 325 | 326 | let manifest: Manifest = match serde_json::from_slice(&body) { 327 | Ok(v) => v, 328 | Err(e) => return internal_error(anyhow!("unable to decode the patch: {}", e)), 329 | }; 330 | 331 | let (application_name, _application_version) = 332 | match wadm_client.put_and_deploy_manifest(manifest).await { 333 | Ok(application_bits) => application_bits, 334 | Err(e) => return internal_error(anyhow!("could not deploy app: {}", e)), 335 | }; 336 | 337 | Json(Application::new(application_name)).into_response() 338 | } 339 | 340 | #[utoipa::path(get, path = "/apis/core.oam.dev/v1beta1/applications")] 341 | pub async fn list_all_applications( 342 | TypedHeader(accept): TypedHeader, 343 | AxumState(state): AxumState, 344 | ) -> impl IntoResponse { 345 | // TODO(joonas): Use lattices (or perhaps Controller specific/special creds) for instanciating NATS client. 346 | // TODO(joonas): Add watch support to stop Argo from spamming this endpoint every second. 347 | 348 | let kube_client = match KubeClient::try_default().await { 349 | Ok(c) => c, 350 | Err(e) => return internal_error(anyhow!("unable to initialize kubernetes client: {}", e)), 351 | }; 352 | 353 | let configs: Api = Api::all(kube_client); 354 | let cfgs = match configs.list(&ListParams::default()).await { 355 | Ok(objs) => objs, 356 | Err(e) => return internal_error(anyhow!("Unable to list cosmonic host configs: {}", e)), 357 | }; 358 | 359 | let mut apps = Vec::new(); 360 | let mut lattices = HashSet::new(); 361 | for cfg in cfgs { 362 | let name = cfg.metadata.name.unwrap().clone(); 363 | let lattice_id = cfg.spec.lattice.clone(); 364 | let namespace = cfg.metadata.namespace.unwrap().clone(); 365 | let nst = NameNamespace::new(name, namespace.clone()); 366 | let map = state.nats_creds.read().await; 367 | let secret = map.get(&nst); 368 | // Prevent listing applications within a given lattice more than once 369 | if !lattices.contains(&lattice_id) { 370 | let result = match list_apps( 371 | &cfg.spec.nats_address, 372 | &cfg.spec.nats_client_port, 373 | secret, 374 | lattice_id.clone(), 375 | ) 376 | .await 377 | { 378 | Ok(apps) => apps, 379 | Err(e) => return internal_error(anyhow!("unable to list applications: {}", e)), 380 | }; 381 | apps.extend(result); 382 | lattices.insert(lattice_id); 383 | } 384 | } 385 | 386 | // We're trying to match the appopriate response based on what Kubernetes/kubectl asked for. 387 | match accept.into() { 388 | As::Table => Json(ApplicationTable::from(apps)).into_response(), 389 | As::NotSpecified => { 390 | let partials: Vec = apps 391 | .iter() 392 | .map(|a| ApplicationPartial::from(a.clone())) 393 | .collect(); 394 | 395 | Json(ApplicationList::from(partials)).into_response() 396 | } 397 | // TODO(joonas): Add better error handling here 398 | _ => Json("").into_response(), 399 | } 400 | } 401 | 402 | #[utoipa::path( 403 | get, 404 | path = "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications" 405 | )] 406 | pub async fn list_applications( 407 | TypedHeader(accept): TypedHeader, 408 | Path(namespace): Path, 409 | AxumState(state): AxumState, 410 | ) -> impl IntoResponse { 411 | let kube_client = match KubeClient::try_default().await { 412 | Ok(c) => c, 413 | Err(e) => return internal_error(anyhow!("unable to initialize kubernetes client: {}", e)), 414 | }; 415 | let configs: Api = Api::namespaced(kube_client, &namespace); 416 | let cfgs = match configs.list(&ListParams::default()).await { 417 | Ok(objs) => objs, 418 | Err(e) => return internal_error(anyhow!("Unable to list cosmonic host configs: {}", e)), 419 | }; 420 | 421 | let mut apps = Vec::new(); 422 | let mut lattices = HashSet::new(); 423 | for cfg in cfgs { 424 | let name = cfg.metadata.name.unwrap().clone(); 425 | let lattice_id = cfg.spec.lattice.clone(); 426 | let nst = NameNamespace::new(name, namespace.clone()); 427 | let map = state.nats_creds.read().await; 428 | let secret = map.get(&nst); 429 | // This is to check that we don't list a lattice more than once 430 | if !lattices.contains(&lattice_id) { 431 | let result = match list_apps( 432 | &cfg.spec.nats_address, 433 | &cfg.spec.nats_client_port, 434 | secret, 435 | lattice_id.clone(), 436 | ) 437 | .await 438 | { 439 | Ok(apps) => apps, 440 | Err(e) => return internal_error(anyhow!("unable to list applications: {}", e)), 441 | }; 442 | apps.extend(result); 443 | lattices.insert(lattice_id); 444 | } 445 | } 446 | 447 | // We're trying to match the appopriate response based on what Kubernetes/kubectl asked for. 448 | match accept.into() { 449 | As::Table => Json(ApplicationTable::from(apps)).into_response(), 450 | As::NotSpecified => { 451 | let partials: Vec = apps 452 | .iter() 453 | .map(|a| ApplicationPartial::from(a.clone())) 454 | .collect(); 455 | Json(ApplicationList::from(partials)).into_response() 456 | } 457 | // TODO(joonas): Add better error handling here 458 | _ => Json("").into_response(), 459 | } 460 | } 461 | 462 | pub async fn list_apps( 463 | cluster_url: &str, 464 | port: &u16, 465 | creds: Option<&SecretString>, 466 | lattice_id: String, 467 | ) -> Result, Error> { 468 | let addr = format!("{}:{}", cluster_url, port); 469 | let nats_client = match creds { 470 | Some(creds) => { 471 | ConnectOptions::with_credentials(creds.expose_secret())? 472 | .connect(addr) 473 | .await? 474 | } 475 | None => ConnectOptions::new().connect(addr).await?, 476 | }; 477 | let wadm_client = WadmClient::from_nats_client(&lattice_id, None, nats_client); 478 | Ok(wadm_client.list_manifests().await?) 479 | } 480 | 481 | pub async fn get_nats_client( 482 | cluster_url: &str, 483 | port: &u16, 484 | nats_creds: Arc>>, 485 | namespace: NameNamespace, 486 | ) -> Result { 487 | let addr = format!("{}:{}", cluster_url, port); 488 | let creds = nats_creds.read().await; 489 | match creds.get(&namespace) { 490 | Some(creds) => { 491 | let creds = creds.expose_secret(); 492 | ConnectOptions::with_credentials(creds) 493 | .expect("unable to create nats client") 494 | .connect(addr) 495 | .await 496 | } 497 | None => ConnectOptions::new().connect(addr).await, 498 | } 499 | } 500 | 501 | #[utoipa::path( 502 | get, 503 | path = "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications/{name}" 504 | )] 505 | pub async fn get_application( 506 | TypedHeader(accept): TypedHeader, 507 | Path((namespace, name)): Path<(String, String)>, 508 | AxumState(state): AxumState, 509 | ) -> impl IntoResponse { 510 | let kube_client = match KubeClient::try_default().await { 511 | Ok(c) => c, 512 | Err(e) => return internal_error(anyhow!("unable to initialize kubernetes client: {}", e)), 513 | }; 514 | 515 | let configs: Api = Api::namespaced(kube_client, &namespace); 516 | let cfgs = match configs.list(&ListParams::default()).await { 517 | Ok(objs) => objs, 518 | Err(e) => return internal_error(anyhow!("unable to list cosmonic host configs: {}", e)), 519 | }; 520 | 521 | // TODO(joonas): Remove this once we move to pulling NATS creds+secrets from lattice instead of hosts. 522 | let (nats_client, lattice_id) = 523 | match get_lattice_connection(cfgs.into_iter(), state, namespace.clone()).await { 524 | Ok(data) => data, 525 | Err(resp) => return resp, 526 | }; 527 | let wadm_client = WadmClient::from_nats_client(&lattice_id, None, nats_client); 528 | 529 | let manifest = match wadm_client.get_manifest(&name, None).await { 530 | Ok(m) => m, 531 | Err(e) => match e { 532 | ClientError::NotFound(_) => { 533 | return not_found_error(anyhow!("applications \"{}\" not found", name)) 534 | } 535 | _ => return internal_error(anyhow!("unable to request app from wadm: {}", e)), 536 | }, 537 | }; 538 | let status = match wadm_client.get_manifest_status(&name).await { 539 | Ok(s) => s, 540 | Err(e) => match e { 541 | ClientError::NotFound(_) => { 542 | return not_found_error(anyhow!("applications \"{}\" not found", name)) 543 | } 544 | _ => return internal_error(anyhow!("unable to request app status from wadm: {}", e)), 545 | }, 546 | }; 547 | 548 | match accept.into() { 549 | As::Table => { 550 | let combined_manifest = CombinedManifest::new(manifest, status); 551 | Json(ApplicationTable::from(vec![combined_manifest])).into_response() 552 | } 553 | As::NotSpecified => { 554 | // TODO(joonas): This is a terrible hack, but for now it's what we need to do to satisfy Argo/Kubernetes since WADM doesn't support this metadata. 555 | let mut manifest_value = serde_json::to_value(&manifest).unwrap(); 556 | // TODO(joonas): We should add lattice id to this as well, but we need it in every place where the application is listed. 557 | let ns = format!("{}/{}", &name, &manifest.version()); 558 | let uid = Uuid::new_v5(&Uuid::NAMESPACE_OID, ns.as_bytes()); 559 | manifest_value["metadata"]["uid"] = json!(uid.to_string()); 560 | manifest_value["metadata"]["resourceVersion"] = json!(uid.to_string()); 561 | manifest_value["metadata"]["namespace"] = json!(namespace); 562 | manifest_value["metadata"]["labels"] = json!({ 563 | "app.kubernetes.io/instance": &name 564 | }); 565 | // TODO(joonas): refactor status and the metadata inputs into a struct we could just serialize 566 | // The custom health check we provide for Argo will handle the case where status is missing, so this is fine for now. 567 | let phase = match status.info.status_type { 568 | StatusType::Undeployed => "Undeployed", 569 | StatusType::Reconciling => "Reconciling", 570 | StatusType::Deployed => "Deployed", 571 | StatusType::Failed => "Failed", 572 | }; 573 | manifest_value["status"] = json!({ 574 | "phase": phase, 575 | }); 576 | Json(manifest_value).into_response() 577 | } 578 | // TODO(joonas): Add better error handling here 579 | t => internal_error(anyhow!("unknown type: {}", t)), 580 | } 581 | } 582 | 583 | #[utoipa::path( 584 | patch, 585 | path = "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications/{name}" 586 | )] 587 | pub async fn patch_application( 588 | Path((namespace, name)): Path<(String, String)>, 589 | AxumState(state): AxumState, 590 | body: Bytes, 591 | ) -> impl IntoResponse { 592 | let kube_client = match KubeClient::try_default().await { 593 | Ok(c) => c, 594 | Err(e) => return internal_error(anyhow!("unable to initialize kubernetes client: {}", e)), 595 | }; 596 | let configs: Api = Api::namespaced(kube_client, &namespace); 597 | let cfgs = match configs.list(&ListParams::default()).await { 598 | Ok(objs) => objs, 599 | Err(e) => return internal_error(anyhow!("unable to list cosmonic host configs: {}", e)), 600 | }; 601 | 602 | // TODO(joonas): Remove this once we move to pulling NATS creds+secrets from lattice instead of hosts. 603 | let (nats_client, lattice_id) = 604 | match get_lattice_connection(cfgs.into_iter(), state, namespace).await { 605 | Ok(data) => data, 606 | Err(resp) => return resp, 607 | }; 608 | let wadm_client = WadmClient::from_nats_client(&lattice_id, None, nats_client); 609 | let current_manifest = match wadm_client.get_manifest(&name, None).await { 610 | Ok(m) => m, 611 | Err(e) => match e { 612 | ClientError::NotFound(_) => { 613 | return not_found_error(anyhow!("applications \"{}\" not found", name)) 614 | } 615 | _ => return internal_error(anyhow!("unable to request app from wadm: {}", e)), 616 | }, 617 | }; 618 | 619 | let mut current = serde_json::to_value(current_manifest).unwrap(); 620 | // Parse the Kubernetes-provided RFC 7386 patch 621 | let patch = match serde_json::from_slice::(&body) { 622 | Ok(p) => p, 623 | Err(e) => return internal_error(anyhow!("unable to decode the patch: {}", e)), 624 | }; 625 | 626 | // Remove kubectl.kubernetes.io/last-applied-configuration annotation before 627 | // we compare against the patch, otherwise we'll always end up creating a new version. 628 | let last_applied_configuration = current 629 | .get_mut("metadata") 630 | .and_then(|metadata| metadata.get_mut("annotations")) 631 | .and_then(|annotations| annotations.as_object_mut()) 632 | .and_then(|annotations| annotations.remove(KUBECTL_LAST_APPLIED_CONFIG_ANNOTATION)); 633 | 634 | // TODO(joonas): This doesn't quite work as intended at the moment, 635 | // there are some differences in terms like replicas vs. instances: 636 | // * Add(AddOperation { path: "/spec/components/0/traits/0/properties/replicas", value: Number(1) }), 637 | // * Remove(RemoveOperation { path: "/spec/components/0/traits/0/properties/instances" }), 638 | // 639 | // which cause the server to always patch. Also, top-level entries such 640 | // as apiVersion, kind and metadata are always removed. 641 | // 642 | // let diff = json_patch::diff(¤t, &patch); 643 | // if diff.is_empty() { 644 | // // If there's nothing to patch, return early. 645 | // return Json(()).into_response(); 646 | // }; 647 | 648 | // Remove current version so that either a new version is generated, 649 | // or the one set in the incoming patch gets used. 650 | if let Some(annotations) = current 651 | .get_mut("metadata") 652 | .and_then(|metadata| metadata.get_mut("annotations")) 653 | .and_then(|annotations| annotations.as_object_mut()) 654 | { 655 | annotations.remove("version"); 656 | } 657 | 658 | // Attempt to patch the currently running version 659 | json_patch::merge(&mut current, &patch); 660 | 661 | // Re-insert "kubectl.kubernetes.io/last-applied-configuration" if one was set 662 | if let Some(last_applied_config) = last_applied_configuration { 663 | if let Some(annotations) = current 664 | .get_mut("metadata") 665 | .and_then(|metadata| metadata.get_mut("annotations")) 666 | .and_then(|annotations| annotations.as_object_mut()) 667 | { 668 | annotations.insert( 669 | KUBECTL_LAST_APPLIED_CONFIG_ANNOTATION.to_string(), 670 | last_applied_config, 671 | ); 672 | } 673 | } 674 | 675 | let updated_manifest = match serde_json::from_value::(current) { 676 | Ok(m) => m, 677 | Err(e) => return internal_error(anyhow!("unable to patch the application: {}", e)), 678 | }; 679 | 680 | match wadm_client.put_and_deploy_manifest(updated_manifest).await { 681 | Ok((app_name, _)) => Json(Application::new(app_name)).into_response(), 682 | Err(e) => match e { 683 | ClientError::NotFound(_) => { 684 | not_found_error(anyhow!("applications \"{}\" not found", &name)) 685 | } 686 | _ => internal_error(anyhow!("could not update application: {}", e)), 687 | }, 688 | } 689 | } 690 | 691 | #[utoipa::path( 692 | delete, 693 | path = "/apis/core.oam.dev/v1beta1/namespaces/{namespace}/applications/{name}" 694 | )] 695 | pub async fn delete_application( 696 | Path((namespace, name)): Path<(String, String)>, 697 | AxumState(state): AxumState, 698 | ) -> impl IntoResponse { 699 | let kube_client = match KubeClient::try_default().await { 700 | Ok(c) => c, 701 | Err(e) => return internal_error(anyhow!("unable to initialize kubernetes client: {}", e)), 702 | }; 703 | 704 | let configs: Api = Api::namespaced(kube_client, &namespace); 705 | let cfgs = match configs.list(&ListParams::default()).await { 706 | Ok(objs) => objs, 707 | Err(e) => return internal_error(anyhow!("unable to list cosmonic host configs: {}", e)), 708 | }; 709 | 710 | // TODO(joonas): Remove this once we move to pulling NATS creds+secrets from lattice instead of hosts. 711 | let (nats_client, lattice_id) = 712 | match get_lattice_connection(cfgs.into_iter(), state, namespace).await { 713 | Ok(data) => data, 714 | Err(resp) => return resp, 715 | }; 716 | 717 | let wadm_client = WadmClient::from_nats_client(&lattice_id, None, nats_client); 718 | match wadm_client.delete_manifest(&name, None).await { 719 | Ok(_) => Json(Application::new(name)).into_response(), 720 | Err(e) => match e { 721 | ClientError::NotFound(_) => not_found_error(anyhow!("apps \"{}\" not found", name)), 722 | _ => internal_error(anyhow!("could not delete app: {}", e)), 723 | }, 724 | } 725 | } 726 | 727 | async fn get_lattice_connection( 728 | cfgs: impl Iterator, 729 | state: State, 730 | namespace: String, 731 | ) -> Result<(NatsClient, String), Response> { 732 | let connection_data = 733 | cfgs.map(|cfg| (cfg, namespace.clone())) 734 | .filter_map(|(cfg, namespace)| { 735 | let cluster_url = cfg.spec.nats_address; 736 | let lattice_id = cfg.spec.lattice; 737 | let lattice_name = cfg.metadata.name?; 738 | let nst: NameNamespace = NameNamespace::new(lattice_name, namespace); 739 | let port = cfg.spec.nats_client_port; 740 | Some((cluster_url, nst, lattice_id, port)) 741 | }); 742 | 743 | for (cluster_url, ns, lattice_id, port) in connection_data { 744 | match get_nats_client(&cluster_url, &port, state.nats_creds.clone(), ns).await { 745 | Ok(c) => return Ok((c, lattice_id)), 746 | Err(e) => { 747 | error!(err = %e, %lattice_id, "error connecting to nats"); 748 | continue; 749 | } 750 | }; 751 | } 752 | 753 | // If we get here, we couldn't get a NATS client, so return an error 754 | Err(internal_error(anyhow!("unable to initialize nats client"))) 755 | } 756 | -------------------------------------------------------------------------------- /src/resources/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | -------------------------------------------------------------------------------- /src/router.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use anyhow::{anyhow, Error}; 4 | use axum::{ 5 | http::StatusCode, 6 | response::{IntoResponse, Response}, 7 | routing::{delete, get, patch, post}, 8 | Json, Router, TypedHeader, 9 | }; 10 | use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ 11 | APIGroup, APIGroupList, APIResource, APIResourceList, GroupVersionForDiscovery, ObjectMeta, 12 | Status as StatusKind, 13 | }; 14 | use kube::core::{GroupVersionKind, ListMeta}; 15 | use serde::Serialize; 16 | use tracing::error; 17 | 18 | use crate::{ 19 | discovery::{ 20 | APIGroupDiscovery, APIGroupDiscoveryList, APIResourceDiscovery, APIVersionDiscovery, 21 | DiscoveryFreshness, ResourceScope, 22 | }, 23 | header::{Accept, As}, 24 | openapi, 25 | resources::application::{ 26 | create_application, delete_application, get_application, list_all_applications, 27 | list_applications, patch_application, 28 | }, 29 | State, 30 | }; 31 | 32 | pub fn setup(state: State) -> Router { 33 | let openapi_router = openapi::router(); 34 | Router::new() 35 | .route("/apis/core.oam.dev/v1beta1", get(api_resources)) 36 | .route( 37 | "/apis/core.oam.dev/v1beta1/applications", 38 | get(list_all_applications), 39 | ) 40 | .route( 41 | "/apis/core.oam.dev/v1beta1/namespaces/:namespace/applications", 42 | get(list_applications), 43 | ) 44 | .route( 45 | "/apis/core.oam.dev/v1beta1/namespaces/:namespace/applications", 46 | post(create_application), 47 | ) 48 | .route( 49 | "/apis/core.oam.dev/v1beta1/namespaces/:namespace/applications/:name", 50 | get(get_application), 51 | ) 52 | .route( 53 | "/apis/core.oam.dev/v1beta1/namespaces/:namespace/applications/:name", 54 | patch(patch_application), 55 | ) 56 | .route( 57 | "/apis/core.oam.dev/v1beta1/namespaces/:namespace/applications/:name", 58 | delete(delete_application), 59 | ) 60 | .with_state(state.clone()) 61 | .route("/apis", get(api_groups)) 62 | .route("/api", get(api_groups)) 63 | .route("/health", get(health)) 64 | .nest("/openapi", openapi_router) 65 | } 66 | 67 | #[utoipa::path(get, path = "/apis/core.oam.dev/v1beta1")] 68 | async fn api_resources() -> Json { 69 | let resources = APIResourceList { 70 | group_version: "core.oam.dev/v1beta1".to_string(), 71 | resources: vec![APIResource { 72 | group: Some("core.oam.dev".to_string()), 73 | kind: "Application".to_string(), 74 | name: "applications".to_string(), 75 | namespaced: true, 76 | short_names: Some(vec!["app".to_string()]), 77 | singular_name: "application".to_string(), 78 | categories: Some(vec!["oam".to_string()]), 79 | verbs: vec![ 80 | "create".to_string(), 81 | "get".to_string(), 82 | "list".to_string(), 83 | "patch".to_string(), 84 | "watch".to_string(), 85 | ], 86 | ..Default::default() 87 | }], 88 | }; 89 | Json(resources) 90 | } 91 | 92 | async fn api_groups(TypedHeader(accept): TypedHeader) -> impl IntoResponse { 93 | match accept.into() { 94 | // This is to support KEP-3352 95 | As::APIGroupDiscoveryList => Json(APIGroupDiscoveryList { 96 | items: vec![APIGroupDiscovery { 97 | metadata: ObjectMeta { 98 | name: Some("core.oam.dev".to_string()), 99 | ..Default::default() 100 | }, 101 | versions: vec![APIVersionDiscovery { 102 | version: "v1beta1".to_string(), 103 | resources: vec![APIResourceDiscovery { 104 | resource: "applications".to_string(), 105 | response_kind: GroupVersionKind { 106 | group: "core.oam.dev".to_string(), 107 | version: "v1beta1".to_string(), 108 | kind: "Application".to_string(), 109 | }, 110 | scope: ResourceScope::Namespaced, 111 | singular_resource: "application".to_string(), 112 | verbs: vec![ 113 | "create".to_string(), 114 | "get".to_string(), 115 | "list".to_string(), 116 | "patch".to_string(), 117 | "watch".to_string(), 118 | ], 119 | short_names: vec!["app".to_string()], 120 | categories: vec!["oam".to_string()], 121 | // TODO(joonas): Add status resource here once we have support for it. 122 | subresources: vec![], 123 | }], 124 | freshness: DiscoveryFreshness::Current, 125 | }], 126 | kind: None, 127 | api_version: None, 128 | }], 129 | ..Default::default() 130 | }) 131 | .into_response(), 132 | // This is to support the "legacy" 'Accept: application/accept' requests. 133 | As::NotSpecified => Json(APIGroupList { 134 | groups: vec![APIGroup { 135 | name: "core.oam.dev".to_string(), 136 | preferred_version: Some(GroupVersionForDiscovery { 137 | group_version: "core.oam.dev/v1beta1".to_string(), 138 | version: "v1beta1".to_string(), 139 | }), 140 | versions: vec![GroupVersionForDiscovery { 141 | group_version: "core.oam.dev/v1beta1".to_string(), 142 | version: "v1beta1".to_string(), 143 | }], 144 | ..Default::default() 145 | }], 146 | }) 147 | .into_response(), 148 | t => internal_error(anyhow!("unknown type request: {}", t)), 149 | } 150 | } 151 | 152 | async fn health() -> &'static str { 153 | "healthy" 154 | } 155 | 156 | /// PartialObjectMetadataList contains a list of objects containing only their metadata 157 | // Based on https://github.com/kubernetes/kubernetes/blob/022d50fe3a1bdf2386395da7c266fede0c110040/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go#L1469-L1480 158 | #[derive(Clone, Debug, Serialize)] 159 | #[serde(rename_all = "camelCase")] 160 | struct PartialObjectMetadataList { 161 | api_version: String, 162 | kind: String, 163 | // Standard list metadata. 164 | // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 165 | metadata: ListMeta, 166 | // items contains each of the included items. 167 | items: Vec, 168 | } 169 | 170 | impl Default for PartialObjectMetadataList { 171 | fn default() -> Self { 172 | Self { 173 | api_version: "meta.k8s.io/v1".to_string(), 174 | kind: "PartialObjectMetadataList".to_string(), 175 | metadata: ListMeta::default(), 176 | items: vec![], 177 | } 178 | } 179 | } 180 | 181 | /// PartialObjectMetadata is a generic representation of any object with ObjectMeta. It allows clients 182 | /// to get access to a particular ObjectMeta schema without knowing the details of the version. 183 | // https://github.com/kubernetes/kubernetes/blob/022d50fe3a1bdf2386395da7c266fede0c110040/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go#L1458-L1467 184 | #[derive(Clone, Debug, Serialize)] 185 | struct PartialObjectMetadata { 186 | api_version: String, 187 | kind: String, 188 | // Standard object's metadata. 189 | // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata 190 | metadata: ObjectMeta, 191 | } 192 | 193 | /// Helper for mapping any error into a `500 Internal Server Error` response. 194 | #[allow(clippy::needless_pass_by_value)] 195 | pub(crate) fn internal_error(err: E) -> Response 196 | where 197 | E: Into + Display, 198 | { 199 | error!(%err); 200 | ( 201 | StatusCode::INTERNAL_SERVER_ERROR, 202 | Json(StatusKind { 203 | code: Some(StatusCode::INTERNAL_SERVER_ERROR.as_u16() as i32), 204 | details: None, 205 | message: Some(err.to_string()), 206 | metadata: ListMeta::default(), 207 | reason: Some("StatusInternalServerError".to_string()), 208 | status: Some("Failure".to_string()), 209 | }), 210 | ) 211 | .into_response() 212 | } 213 | 214 | /// Helper for mapping any error into a `404 Not Found` response. 215 | #[allow(clippy::needless_pass_by_value)] 216 | pub(crate) fn not_found_error(err: E) -> Response 217 | where 218 | E: Into + Display, 219 | { 220 | error!(%err); 221 | ( 222 | StatusCode::NOT_FOUND, 223 | Json(StatusKind { 224 | code: Some(StatusCode::NOT_FOUND.as_u16() as i32), 225 | details: None, 226 | message: Some(err.to_string()), 227 | metadata: ListMeta::default(), 228 | reason: Some("NotFound".to_string()), 229 | status: Some("Failure".to_string()), 230 | }), 231 | ) 232 | .into_response() 233 | } 234 | -------------------------------------------------------------------------------- /src/services.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashMap, HashSet}; 2 | use std::net::SocketAddr; 3 | use std::sync::Arc; 4 | 5 | use anyhow::Result; 6 | use async_nats::{ 7 | jetstream, 8 | jetstream::{ 9 | consumer::{pull::Config, Consumer}, 10 | stream::{Config as StreamConfig, RetentionPolicy, Source, StorageType, SubjectTransform}, 11 | AckKind, 12 | }, 13 | Client, 14 | }; 15 | use cloudevents::{AttributesReader, Event as CloudEvent}; 16 | use futures::StreamExt; 17 | use k8s_openapi::api::core::v1::{Pod, Service, ServicePort, ServiceSpec}; 18 | use k8s_openapi::api::discovery::v1::{Endpoint, EndpointConditions, EndpointPort, EndpointSlice}; 19 | use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; 20 | use kube::{ 21 | api::{Api, DeleteParams, ListParams, Patch, PatchParams}, 22 | client::Client as KubeClient, 23 | Resource, 24 | }; 25 | use tokio::sync::{mpsc, RwLock}; 26 | use tokio_util::sync::CancellationToken; 27 | use tracing::{debug, error, warn}; 28 | use wadm::events::{Event, ManifestPublished, ManifestUnpublished}; 29 | use wadm_client::Client as WadmClient; 30 | use wadm_types::{api::ModelSummary, Component, Manifest, Properties, Trait, TraitProperty}; 31 | use wasmcloud_operator_types::v1alpha1::WasmCloudHostConfig; 32 | 33 | use crate::controller::{ 34 | common_labels, CLUSTER_CONFIG_FINALIZER, SERVICE_FINALIZER, 35 | WASMCLOUD_OPERATOR_HOST_LABEL_PREFIX, WASMCLOUD_OPERATOR_MANAGED_BY_LABEL_REQUIREMENT, 36 | }; 37 | 38 | const CONSUMER_PREFIX: &str = "wasmcloud_operator_service"; 39 | // This should probably be exposed by wadm somewhere 40 | const WADM_EVENT_STREAM_NAME: &str = "wadm_events"; 41 | const OPERATOR_STREAM_NAME: &str = "wasmcloud_operator_events"; 42 | const OPERATOR_STREAM_SUBJECT: &str = "wasmcloud_operator_events.*.>"; 43 | 44 | /// Commands that can be sent to the watcher to trigger an update or removal of a service. 45 | #[derive(Clone, Debug)] 46 | enum WatcherCommand { 47 | UpsertService(ServiceParams), 48 | RemoveService { 49 | name: String, 50 | namespaces: HashSet, 51 | }, 52 | RemoveServices { 53 | namespaces: HashSet, 54 | }, 55 | } 56 | 57 | /// Parameters for creating or updating a service in the cluster. 58 | #[derive(Clone, Debug)] 59 | pub struct ServiceParams { 60 | name: String, 61 | namespaces: HashSet, 62 | lattice_id: String, 63 | port: u16, 64 | host_labels: Option>, 65 | } 66 | 67 | /// Watches for new services to be created in the cluster for a partcular lattice and creates or 68 | /// updates them as necessary. 69 | #[derive(Clone, Debug)] 70 | pub struct Watcher { 71 | namespaces: HashSet, 72 | lattice_id: String, 73 | nats_client: Client, 74 | shutdown: CancellationToken, 75 | consumer: Consumer, 76 | tx: mpsc::UnboundedSender, 77 | } 78 | 79 | impl Drop for Watcher { 80 | fn drop(&mut self) { 81 | self.shutdown.cancel(); 82 | } 83 | } 84 | 85 | impl Watcher { 86 | /// Creates a new watcher for a particular lattice. 87 | fn new( 88 | namespace: String, 89 | lattice_id: String, 90 | nats_client: Client, 91 | consumer: Consumer, 92 | tx: mpsc::UnboundedSender, 93 | ) -> Self { 94 | let watcher = Self { 95 | namespaces: HashSet::from([namespace]), 96 | nats_client, 97 | lattice_id: lattice_id.clone(), 98 | consumer, 99 | shutdown: CancellationToken::new(), 100 | tx, 101 | }; 102 | 103 | // TODO is there a better way to handle this? 104 | let watcher_dup = watcher.clone(); 105 | tokio::spawn(async move { 106 | tokio::select! { 107 | _ = watcher_dup.shutdown.cancelled() => { 108 | debug!(%lattice_id, "Service watcher shutting down for lattice"); 109 | } 110 | _ = watcher_dup.watch_events(&watcher_dup.consumer) => { 111 | error!(%lattice_id, "Service watcher for lattice has stopped"); 112 | } 113 | } 114 | }); 115 | 116 | watcher 117 | } 118 | 119 | /// Watches for new events on the mirrored wadm_events stream and processes them. 120 | async fn watch_events(&self, consumer: &Consumer) -> Result<()> { 121 | let mut messages = consumer.stream().messages().await?; 122 | while let Some(message) = messages.next().await { 123 | if let Ok(message) = message { 124 | match self.handle_event(message.clone()) { 125 | Ok(_) => message 126 | .ack() 127 | .await 128 | .map_err(|e| { 129 | error!(err=%e, "Error acking message"); 130 | e 131 | }) 132 | .ok(), 133 | Err(_) => message 134 | .ack_with(AckKind::Nak(None)) 135 | .await 136 | .map_err(|e| { 137 | error!(err=%e, "Error nacking message"); 138 | e 139 | }) 140 | .ok(), 141 | }; 142 | } 143 | } 144 | Ok(()) 145 | } 146 | 147 | /// Handles a new event from the consumer. 148 | fn handle_event(&self, message: async_nats::jetstream::Message) -> Result<()> { 149 | let event = serde_json::from_slice::(&message.payload) 150 | .map_err(|e| anyhow::anyhow!("Error parsing cloudevent: {}", e))?; 151 | let evt = match Event::try_from(event.clone()) { 152 | Ok(evt) => evt, 153 | Err(e) => { 154 | warn!( 155 | err=%e, 156 | event_type=%event.ty(), 157 | "Error converting cloudevent to wadm event", 158 | ); 159 | return Ok(()); 160 | } 161 | }; 162 | match evt { 163 | Event::ManifestPublished(mp) => { 164 | let name = mp.manifest.metadata.name.clone(); 165 | self.handle_manifest_published(mp).map_err(|e| { 166 | error!(lattice_id = %self.lattice_id, manifest = name, "Error handling manifest published event: {}", e); 167 | e 168 | })?; 169 | } 170 | Event::ManifestUnpublished(mu) => { 171 | let name = mu.name.clone(); 172 | self.handle_manifest_unpublished(mu).map_err(|e| { 173 | error!(lattice_id = %self.lattice_id, manifest = name, "Error handling manifest unpublished event: {}", e); 174 | e 175 | })?; 176 | } 177 | _ => {} 178 | } 179 | Ok(()) 180 | } 181 | 182 | /// Handles a manifest published event. 183 | fn handle_manifest_published(&self, mp: ManifestPublished) -> Result<()> { 184 | debug!(manifest=?mp, "Handling manifest published event"); 185 | let manifest = mp.manifest; 186 | if let Some(httpserver_service) = http_server_component(&manifest) { 187 | if let Ok(addr) = httpserver_service.address.parse::() { 188 | debug!(manifest = %manifest.metadata.name, "Upserting service for manifest"); 189 | self.tx 190 | .send(WatcherCommand::UpsertService(ServiceParams { 191 | name: manifest.metadata.name.clone(), 192 | lattice_id: self.lattice_id.clone(), 193 | port: addr.port(), 194 | namespaces: self.namespaces.clone(), 195 | host_labels: httpserver_service.labels, 196 | })) 197 | .map_err(|e| anyhow::anyhow!("Error sending command to watcher: {}", e))?; 198 | } else { 199 | error!( 200 | address = httpserver_service.address, 201 | "Invalid address in manifest" 202 | ); 203 | } 204 | } 205 | Ok(()) 206 | } 207 | 208 | /// Handles a manifest unpublished event. 209 | fn handle_manifest_unpublished(&self, mu: ManifestUnpublished) -> Result<()> { 210 | self.tx 211 | .send(WatcherCommand::RemoveService { 212 | name: mu.name, 213 | namespaces: self.namespaces.clone(), 214 | }) 215 | .map_err(|e| anyhow::anyhow!("Error sending command to watcher: {}", e))?; 216 | Ok(()) 217 | } 218 | } 219 | 220 | /// Waits for commands to update or remove services based on manifest deploy/undeploy events in 221 | /// underlying lattices. 222 | /// Each lattice is managed by a [`Watcher`] which listens for events relayed by a NATS consumer and 223 | /// issues commands to create or update services in the cluster. 224 | pub struct ServiceWatcher { 225 | watchers: Arc>>, 226 | sender: mpsc::UnboundedSender, 227 | stream_replicas: u16, 228 | } 229 | 230 | impl ServiceWatcher { 231 | /// Creates a new service watcher. 232 | pub fn new(k8s_client: KubeClient, stream_replicas: u16) -> Self { 233 | let (tx, mut rx) = mpsc::unbounded_channel::(); 234 | 235 | let client = k8s_client.clone(); 236 | tokio::spawn(async move { 237 | while let Some(cmd) = rx.recv().await { 238 | match cmd { 239 | WatcherCommand::UpsertService(params) => { 240 | create_or_update_service(client.clone(), ¶ms, None) 241 | .await 242 | .map_err(|e| error!(err=%e, "Error creating/updating service")) 243 | .ok(); 244 | } 245 | WatcherCommand::RemoveService { name, namespaces } => { 246 | for namespace in namespaces { 247 | delete_service(client.clone(), &namespace, name.as_str()) 248 | .await 249 | .map_err(|e| error!(err=%e, %namespace, "Error deleting service")) 250 | .ok(); 251 | } 252 | } 253 | WatcherCommand::RemoveServices { namespaces } => { 254 | for namespace in namespaces { 255 | delete_services(client.clone(), namespace.as_str()) 256 | .await 257 | .map_err(|e| error!(err=%e, %namespace, "Error deleting service")) 258 | .ok(); 259 | } 260 | } 261 | } 262 | } 263 | }); 264 | 265 | Self { 266 | watchers: Arc::new(RwLock::new(HashMap::new())), 267 | sender: tx, 268 | stream_replicas, 269 | } 270 | } 271 | 272 | /// Reconciles services for a set of apps in a lattice. 273 | /// This intended to be called by the controller whenever it reconciles state. 274 | pub async fn reconcile_services(&self, apps: Vec, lattice_id: String) { 275 | if let Some(watcher) = self.watchers.read().await.get(lattice_id.as_str()) { 276 | let wadm_client = 277 | WadmClient::from_nats_client(&lattice_id, None, watcher.nats_client.clone()); 278 | for app in apps { 279 | if app.deployed_version.is_none() { 280 | continue; 281 | } 282 | match wadm_client 283 | .get_manifest(app.name.as_str(), app.deployed_version.as_deref()) 284 | .await 285 | { 286 | Ok(manifest) => { 287 | let _ = watcher.handle_manifest_published(ManifestPublished { 288 | manifest, 289 | }).map_err(|e| error!(err = %e, %lattice_id, app = %app.name, "failed to trigger service reconciliation for app")); 290 | } 291 | Err(e) => warn!(err=%e, "Unable to retrieve model"), 292 | }; 293 | } 294 | }; 295 | } 296 | 297 | /// Create a new [`Watcher`] for a lattice. 298 | /// It will return early if a [`Watcher`] already exists for the lattice. 299 | pub async fn watch(&self, client: Client, namespace: String, lattice_id: String) -> Result<()> { 300 | // If we're already watching this lattice then return early 301 | // TODO is there an easy way to do this with a read lock? 302 | let mut watchers = self.watchers.write().await; 303 | if let Some(watcher) = watchers.get_mut(lattice_id.as_str()) { 304 | watcher.namespaces.insert(namespace); 305 | return Ok(()); 306 | } 307 | 308 | let js = jetstream::new(client.clone()); 309 | 310 | // Should we also be doing this when we first create the ServiceWatcher? 311 | let stream = js 312 | .get_or_create_stream(StreamConfig { 313 | name: OPERATOR_STREAM_NAME.to_string(), 314 | description: Some( 315 | "Stream for wadm events consumed by the wasmCloud K8s Operator".to_string(), 316 | ), 317 | max_age: wadm::DEFAULT_EXPIRY_TIME, 318 | retention: RetentionPolicy::WorkQueue, 319 | storage: StorageType::File, 320 | allow_rollup: false, 321 | num_replicas: self.stream_replicas as usize, 322 | mirror: Some(Source { 323 | name: WADM_EVENT_STREAM_NAME.to_string(), 324 | subject_transforms: vec![SubjectTransform { 325 | source: wadm::DEFAULT_WADM_EVENTS_TOPIC.to_string(), 326 | destination: OPERATOR_STREAM_SUBJECT.replacen('*', "{{wildcard(1)}}", 1), 327 | }], 328 | ..Default::default() 329 | }), 330 | ..Default::default() 331 | }) 332 | .await?; 333 | 334 | let consumer_name = format!("{CONSUMER_PREFIX}-{}", lattice_id.clone()); 335 | let consumer = stream 336 | .get_or_create_consumer( 337 | consumer_name.as_str(), 338 | Config { 339 | durable_name: Some(consumer_name.clone()), 340 | description: Some("Consumer created by the wasmCloud K8s Operator to watch for new service endpoints in wadm manifests".to_string()), 341 | ack_policy: jetstream::consumer::AckPolicy::Explicit, 342 | ack_wait: std::time::Duration::from_secs(2), 343 | max_deliver: 3, 344 | deliver_policy: async_nats::jetstream::consumer::DeliverPolicy::All, 345 | filter_subject: OPERATOR_STREAM_SUBJECT.replacen('*', &lattice_id, 1), 346 | ..Default::default() 347 | }, 348 | ) 349 | .await?; 350 | 351 | let watcher = Watcher::new( 352 | namespace, 353 | lattice_id.clone(), 354 | client.clone(), 355 | consumer, 356 | self.sender.clone(), 357 | ); 358 | watchers.insert(lattice_id.clone(), watcher); 359 | Ok(()) 360 | } 361 | 362 | /// Stops watching a lattice by stopping the underlying [`Watcher`] if no namespaces require it. 363 | pub async fn stop_watch(&self, lattice_id: String, namespace: String) -> Result<()> { 364 | let mut watchers = self.watchers.write().await; 365 | if let Some(watcher) = watchers.get_mut(lattice_id.as_str()) { 366 | watcher.namespaces.remove(namespace.as_str()); 367 | if watcher.namespaces.is_empty() { 368 | watchers.remove(lattice_id.as_str()); 369 | } 370 | 371 | self.sender 372 | .send(WatcherCommand::RemoveServices { 373 | namespaces: HashSet::from([namespace]), 374 | }) 375 | .map_err(|e| anyhow::anyhow!("Error sending command to watcher: {}", e))?; 376 | } 377 | Ok(()) 378 | } 379 | } 380 | 381 | /// Creates or updates a service in the cluster based on the provided parameters. 382 | pub async fn create_or_update_service( 383 | k8s_client: KubeClient, 384 | params: &ServiceParams, 385 | owner_ref: Option, 386 | ) -> Result<()> { 387 | let mut labels = common_labels(); 388 | labels.extend(BTreeMap::from([( 389 | "app.kubernetes.io/name".to_string(), 390 | params.name.to_string(), 391 | )])); 392 | let mut selector = BTreeMap::new(); 393 | let mut create_endpoints = false; 394 | if let Some(host_labels) = ¶ms.host_labels { 395 | selector.insert( 396 | "app.kubernetes.io/name".to_string(), 397 | "wasmcloud".to_string(), 398 | ); 399 | selector.extend( 400 | host_labels 401 | .iter() 402 | .map(|(k, v)| (format_service_selector(k), v.clone())), 403 | ); 404 | } else { 405 | create_endpoints = true; 406 | } 407 | 408 | for namespace in params.namespaces.iter() { 409 | let api = Api::::namespaced(k8s_client.clone(), namespace); 410 | 411 | let mut svc = Service { 412 | metadata: kube::api::ObjectMeta { 413 | name: Some(params.name.clone()), 414 | labels: Some(labels.clone()), 415 | finalizers: Some(vec![SERVICE_FINALIZER.to_string()]), 416 | namespace: Some(namespace.clone()), 417 | ..Default::default() 418 | }, 419 | spec: Some(ServiceSpec { 420 | selector: Some(selector.clone()), 421 | ports: Some(vec![ServicePort { 422 | name: Some("http".to_string()), 423 | port: params.port as i32, 424 | protocol: Some("TCP".to_string()), 425 | ..Default::default() 426 | }]), 427 | ..Default::default() 428 | }), 429 | ..Default::default() 430 | }; 431 | 432 | if let Some(owner_ref) = &owner_ref { 433 | svc.metadata.owner_references = Some(vec![owner_ref.clone()]); 434 | } 435 | 436 | debug!(service =? svc, %namespace, "Creating/updating service"); 437 | 438 | let svc = api 439 | .patch( 440 | params.name.as_str(), 441 | &PatchParams::apply(SERVICE_FINALIZER), 442 | &Patch::Apply(svc), 443 | ) 444 | .await 445 | .map_err(|e| { 446 | error!(err = %e, "Error creating/updating service"); 447 | e 448 | })?; 449 | 450 | if create_endpoints { 451 | let crds = 452 | Api::::namespaced(k8s_client.clone(), namespace.as_str()); 453 | let pods = Api::::namespaced(k8s_client.clone(), namespace.as_str()); 454 | let endpoints = 455 | Api::::namespaced(k8s_client.clone(), namespace.as_str()); 456 | 457 | let configs = crds.list(&ListParams::default()).await?; 458 | let mut ips = vec![]; 459 | for cfg in configs { 460 | if cfg.spec.lattice == params.lattice_id { 461 | let name = cfg.metadata.name.unwrap(); 462 | let pods = pods 463 | .list(&ListParams { 464 | label_selector: Some(format!( 465 | "app.kubernetes.io/name=wasmcloud,app.kubernetes.io/instance={name}" 466 | )), 467 | ..Default::default() 468 | }) 469 | .await?; 470 | let pod_ips = pods 471 | .into_iter() 472 | .filter_map(|pod| { 473 | pod.status.and_then(|status| { 474 | if status.phase == Some("Running".to_string()) { 475 | status.pod_ips 476 | } else { 477 | None 478 | } 479 | }) 480 | }) 481 | .flatten(); 482 | ips.extend(pod_ips); 483 | } 484 | } 485 | 486 | // Create an EndpointSlice if we're working with a daemonscaler without label requirements. 487 | // This means we need to manually map the endpoints to each wasmCloud host belonging to the 488 | // lattice in this namespace. 489 | // TODO: This can actually span namespaces, same with the label requirements so should we 490 | // be querying _all_ CRDs to find all available pods? 491 | if !ips.is_empty() { 492 | let mut labels = labels.clone(); 493 | labels.insert( 494 | "kubernetes.io/service-name".to_string(), 495 | params.name.clone(), 496 | ); 497 | let endpoint_slice = EndpointSlice { 498 | metadata: kube::api::ObjectMeta { 499 | name: Some(params.name.clone()), 500 | labels: Some(labels.clone()), 501 | // SAFETY: This should be safe according to the kube.rs docs, which specifiy 502 | // that anything created through the apiserver should have a populated field 503 | // here. 504 | owner_references: Some(vec![svc.controller_owner_ref(&()).unwrap()]), 505 | ..Default::default() 506 | }, 507 | // TODO is there a way to figure this out automatically? Maybe based on the number 508 | // of IPs that come back or what they are 509 | address_type: "IPv4".to_string(), 510 | endpoints: ips 511 | .iter() 512 | .filter_map(|ip| { 513 | ip.ip.as_ref().map(|i| Endpoint { 514 | addresses: vec![i.clone()], 515 | conditions: Some(EndpointConditions { 516 | ready: Some(true), 517 | serving: Some(true), 518 | terminating: None, 519 | }), 520 | hostname: None, 521 | target_ref: None, 522 | ..Default::default() 523 | }) 524 | }) 525 | .collect(), 526 | ports: Some(vec![EndpointPort { 527 | name: Some("http".to_string()), 528 | port: Some(params.port as i32), 529 | protocol: Some("TCP".to_string()), 530 | app_protocol: None, 531 | }]), 532 | }; 533 | // TODO this should probably do the usual get/patch or get/replce bit since I don't 534 | // think this is fully syncing endpoints when pods are deleted. Also we should update 535 | // this based on pod status since we may end up having stale IPs 536 | endpoints 537 | .patch( 538 | params.name.as_str(), 539 | &PatchParams::apply(CLUSTER_CONFIG_FINALIZER), 540 | &Patch::Apply(endpoint_slice), 541 | ) 542 | .await 543 | .map_err(|e| { 544 | error!("Error creating endpoint slice: {}", e); 545 | e 546 | })?; 547 | } 548 | }; 549 | } 550 | 551 | debug!("Created/updated service"); 552 | Ok(()) 553 | } 554 | 555 | #[derive(Default)] 556 | pub struct HttpServerComponent { 557 | labels: Option>, 558 | address: String, 559 | } 560 | 561 | /// Finds the httpserver component in a manifest and returns the details needed to create a service 562 | fn http_server_component(manifest: &Manifest) -> Option { 563 | let components: Vec<&Component> = manifest 564 | .components() 565 | // filter just for the wasmCloud httpserver for now. This should actually just filter for 566 | // the http capability 567 | .filter(|c| { 568 | if let Properties::Capability { properties } = &c.properties { 569 | if properties 570 | .image 571 | .starts_with("ghcr.io/wasmcloud/http-server") 572 | { 573 | return true; 574 | } 575 | } 576 | false 577 | }) 578 | .collect(); 579 | 580 | let scalers: Vec<&Trait> = components 581 | .iter() 582 | .filter_map(|c| { 583 | if let Some(t) = &c.traits { 584 | for trait_ in t { 585 | if trait_.trait_type == "daemonscaler" { 586 | return Some(trait_); 587 | } 588 | } 589 | }; 590 | None 591 | }) 592 | .collect(); 593 | 594 | // Right now we only support daemonscalers, so if we don't find any then we have nothing to do 595 | if scalers.is_empty() { 596 | return None; 597 | } 598 | 599 | let links: Vec<&Trait> = components 600 | .iter() 601 | .filter_map(|c| { 602 | if let Some(t) = &c.traits { 603 | for trait_ in t { 604 | if trait_.trait_type == "link" { 605 | return Some(trait_); 606 | } 607 | } 608 | }; 609 | None 610 | }) 611 | .collect(); 612 | 613 | let mut details = HttpServerComponent::default(); 614 | let mut should_create_service = false; 615 | for l in links { 616 | match &l.properties { 617 | TraitProperty::Link(props) => { 618 | if props.namespace == "wasi" 619 | && props.package == "http" 620 | && props.interfaces.contains(&"incoming-handler".to_string()) 621 | && props.source.is_some() 622 | { 623 | let source = props.source.as_ref().unwrap(); 624 | for cp in source.config.iter() { 625 | if let Some(addr) = cp.properties.as_ref().and_then(|p| p.get("address")) { 626 | details.address.clone_from(addr); 627 | should_create_service = true; 628 | } 629 | } 630 | } 631 | } 632 | TraitProperty::SpreadScaler(scaler) => { 633 | for spread in scaler.spread.iter() { 634 | spread.requirements.iter().for_each(|(k, v)| { 635 | details 636 | .labels 637 | .get_or_insert_with(HashMap::new) 638 | .insert(k.clone(), v.clone()); 639 | }); 640 | } 641 | } 642 | _ => {} 643 | } 644 | } 645 | 646 | if should_create_service { 647 | return Some(details); 648 | } 649 | None 650 | } 651 | 652 | /// Deletes a service in the cluster. 653 | async fn delete_service(k8s_client: KubeClient, namespace: &str, name: &str) -> Result<()> { 654 | debug!(namespace = namespace, name = name, "Deleting service"); 655 | let api = Api::::namespaced(k8s_client.clone(), namespace); 656 | // Remove the finalizer so that the service can be deleted 657 | let mut svc = api.get(name).await?; 658 | svc.metadata.finalizers = None; 659 | svc.metadata.managed_fields = None; 660 | api.patch( 661 | name, 662 | &PatchParams::apply(SERVICE_FINALIZER).force(), 663 | &Patch::Apply(svc), 664 | ) 665 | .await 666 | .map_err(|e| { 667 | error!("Error removing finalizer from service: {}", e); 668 | e 669 | })?; 670 | 671 | api.delete(name, &DeleteParams::default()).await?; 672 | Ok(()) 673 | } 674 | 675 | async fn delete_services(k8s_client: KubeClient, namespace: &str) -> Result<()> { 676 | let api = Api::::namespaced(k8s_client.clone(), namespace); 677 | let services = api 678 | .list(&ListParams { 679 | label_selector: Some(WASMCLOUD_OPERATOR_MANAGED_BY_LABEL_REQUIREMENT.to_string()), 680 | ..Default::default() 681 | }) 682 | .await?; 683 | for svc in services { 684 | let name = svc.metadata.name.unwrap(); 685 | delete_service(k8s_client.clone(), namespace, name.as_str()).await?; 686 | } 687 | Ok(()) 688 | } 689 | 690 | /// Formats a service selector for a given name. 691 | fn format_service_selector(name: &str) -> String { 692 | format!("{WASMCLOUD_OPERATOR_HOST_LABEL_PREFIX}/{}", name) 693 | } 694 | 695 | #[cfg(test)] 696 | mod test { 697 | use super::*; 698 | 699 | #[test] 700 | fn test_daemonscaler_should_return() { 701 | let manifest = r#" 702 | apiVersion: core.oam.dev/v1beta1 703 | kind: Application 704 | metadata: 705 | name: rust-http-hello-world 706 | annotations: 707 | version: v0.0.1 708 | description: "HTTP hello world demo in Rust, using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)" 709 | experimental: "true" 710 | spec: 711 | components: 712 | - name: http-hello-world 713 | type: component 714 | properties: 715 | image: wasmcloud.azurecr.io/http-hello-world:0.1.0 716 | id: helloworld 717 | traits: 718 | # Govern the spread/scheduling of the actor 719 | - type: spreadscaler 720 | properties: 721 | replicas: 5000 722 | # Add a capability provider that mediates HTTP access 723 | - name: httpserver 724 | type: capability 725 | properties: 726 | image: ghcr.io/wasmcloud/http-server:0.20.0 727 | id: httpserver 728 | traits: 729 | # Link the HTTP server, and inform it to listen on port 8080 730 | # on the local machine 731 | - type: link 732 | properties: 733 | target: http-hello-world 734 | namespace: wasi 735 | package: http 736 | interfaces: [incoming-handler] 737 | source_config: 738 | - name: default-http 739 | properties: 740 | address: 0.0.0.0:8080 741 | - type: daemonscaler 742 | properties: 743 | replicas: 1 744 | "#; 745 | let m = serde_yaml::from_str::(manifest).unwrap(); 746 | let component = http_server_component(&m); 747 | assert!(component.is_some()); 748 | 749 | let manifest = r#" 750 | apiVersion: core.oam.dev/v1beta1 751 | kind: Application 752 | metadata: 753 | name: rust-http-hello-world 754 | annotations: 755 | version: v0.0.1 756 | description: "HTTP hello world demo in Rust, using the WebAssembly Component Model and WebAssembly Interfaces Types (WIT)" 757 | experimental: "true" 758 | spec: 759 | components: 760 | - name: http-hello-world 761 | type: component 762 | properties: 763 | image: wasmcloud.azurecr.io/http-hello-world:0.1.0 764 | id: helloworld 765 | traits: 766 | # Govern the spread/scheduling of the actor 767 | - type: spreadscaler 768 | properties: 769 | replicas: 5000 770 | # Add a capability provider that mediates HTTP access 771 | - name: httpserver 772 | type: capability 773 | properties: 774 | image: ghcr.io/wasmcloud/http-server:0.20.0 775 | id: httpserver 776 | traits: 777 | # Link the HTTP server, and inform it to listen on port 8080 778 | # on the local machine 779 | - type: link 780 | properties: 781 | target: http-hello-world 782 | namespace: wasi 783 | package: http 784 | interfaces: [incoming-handler] 785 | source_config: 786 | - name: default-http 787 | properties: 788 | address: 0.0.0.0:8080 789 | "#; 790 | let m = serde_yaml::from_str::(manifest).unwrap(); 791 | let component = http_server_component(&m); 792 | assert!(component.is_none()); 793 | } 794 | } 795 | -------------------------------------------------------------------------------- /src/table.rs: -------------------------------------------------------------------------------- 1 | use serde::{self, Serialize}; 2 | 3 | // Based on https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Table 4 | // TODO(joonas): should we have a Table struct that can be used by multiple resource types? 5 | // pub struct Table { 6 | // ... 7 | //} 8 | 9 | // Based on https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#TableColumnDefinition 10 | #[derive(Debug, Clone, Serialize)] 11 | pub struct TableColumnDefinition { 12 | // name is a human readable name for the column. 13 | pub name: String, 14 | // type is an OpenAPI type definition for this column, such as number, integer, string, or 15 | // array. 16 | // See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more. 17 | #[serde(rename = "type")] 18 | pub kind: String, 19 | // format is an optional OpenAPI type modifier for this column. A format modifies the type and 20 | // imposes additional rules, like date or time formatting for a string. The 'name' format is applied 21 | // to the primary identifier column which has type 'string' to assist in clients identifying column 22 | // is the resource name. 23 | // See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for more. 24 | pub format: String, 25 | // description is a human readable description of this column. 26 | pub description: String, 27 | // priority is an integer defining the relative importance of this column compared to others. Lower 28 | // numbers are considered higher priority. Columns that may be omitted in limited space scenarios 29 | // should be given a higher priority. 30 | /// NOTE: Set priority to 0 if you want things to show up in the non `-o wide` view, and anything above 31 | /// if you don't mind the value being hidden behind `-o wide` flag in kubectl get . 32 | pub priority: u8, 33 | } 34 | 35 | impl Default for TableColumnDefinition { 36 | fn default() -> Self { 37 | Self { 38 | name: "".to_string(), 39 | kind: "".to_string(), 40 | format: "".to_string(), 41 | description: "".to_string(), 42 | priority: 0, 43 | } 44 | } 45 | } 46 | 47 | // Based on https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#TableRow 48 | #[derive(Debug, Clone, Serialize)] 49 | pub struct TableRow { 50 | // TODO(joonas): Support more than strings here 51 | // cells will be as wide as the column definitions array and may contain strings, numbers (float64 or 52 | // int64), booleans, simple maps, lists, or null. See the type field of the column definition for a 53 | // more detailed description. 54 | pub cells: Vec, 55 | } 56 | --------------------------------------------------------------------------------