├── .github └── workflows │ ├── build-publish-mcr.yaml │ ├── codeql.yml │ ├── k8scompat.yaml │ ├── smoke.yaml │ └── unit.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── api └── v1 │ ├── composition.go │ ├── composition_test.go │ ├── config │ └── crd │ │ ├── eno.azure.io_compositions.yaml │ │ ├── eno.azure.io_resourceslices.yaml │ │ ├── eno.azure.io_symphonies.yaml │ │ └── eno.azure.io_synthesizers.yaml │ ├── docsconfig.yaml │ ├── env.go │ ├── inputs.go │ ├── resourceslice.go │ ├── resourceslice_test.go │ ├── symphony.go │ ├── synthesizer.go │ ├── types.go │ └── zz_generated.deepcopy.go ├── cmd ├── eno-controller │ └── main.go └── eno-reconciler │ └── main.go ├── dev ├── build-linux.sh ├── build.sh └── deploy.yaml ├── docker ├── eno-controller │ └── Dockerfile └── eno-reconciler │ └── Dockerfile ├── docs ├── api.md ├── reconciliation.md ├── symphony.md ├── synthesis.md └── synthesizer-api.md ├── examples ├── 01-minimal │ └── example.yaml ├── 02-go-synthesizer │ ├── Dockerfile │ ├── build.sh │ ├── example.yaml │ └── main.go ├── 03-helm-shim │ ├── Dockerfile │ ├── build.sh │ ├── chart │ │ ├── Chart.yaml │ │ └── templates │ │ │ └── configmap.yaml │ ├── example.yaml │ ├── go.mod │ ├── go.sum │ └── main.go ├── 04-readiness │ └── example.yaml ├── 05-crd │ ├── Dockerfile │ ├── build.sh │ ├── config │ │ └── crd │ │ │ └── example.azure.io_examples.yaml │ ├── crd.go │ ├── example.yaml │ ├── main.go │ └── zz_generated.deepcopy.go ├── 100-minimal-symphony │ └── example.yaml ├── 101-patch │ └── example.yaml ├── 102-delete-patch │ └── example.yaml └── 103-ignore-side-effects │ └── example.yaml ├── go.mod ├── go.sum ├── go.work ├── go.work.sum ├── hack ├── build-k8s-matrix.sh ├── download-k8s.sh └── smoke-test.sh ├── internal ├── controllers │ ├── composition │ │ ├── controller.go │ │ └── controller_test.go │ ├── liveness │ │ ├── namespace.go │ │ └── namespace_test.go │ ├── reconciliation │ │ ├── controller.go │ │ ├── controller_test.go │ │ ├── crud_test.go │ │ ├── edgecase_test.go │ │ ├── fixtures │ │ │ ├── crd-runtimetest-extra-property.yaml │ │ │ ├── crd-runtimetest.yaml │ │ │ ├── helmchart │ │ │ │ ├── Chart.yaml │ │ │ │ └── templates │ │ │ │ │ └── configmap.yaml │ │ │ └── v1 │ │ │ │ ├── config │ │ │ │ ├── crd │ │ │ │ │ └── enotest.azure.io_testresources.yaml │ │ │ │ └── enotest.azure.io_testresources-old.yaml │ │ │ │ ├── types.go │ │ │ │ └── zz_generated.deepcopy.go │ │ ├── helm_test.go │ │ ├── helpers_test.go │ │ ├── merge_test.go │ │ ├── metrics.go │ │ ├── ordering_test.go │ │ ├── patch_test.go │ │ ├── reconstitution.go │ │ ├── rollout_test.go │ │ ├── status_test.go │ │ └── symphony_test.go │ ├── resourceslice │ │ ├── integration_test.go │ │ ├── slice.go │ │ ├── slice_test.go │ │ ├── slicecleanup.go │ │ └── slicecleanup_test.go │ ├── scheduling │ │ ├── controller.go │ │ ├── controller_test.go │ │ ├── metrics.go │ │ ├── metrics_test.go │ │ ├── op.go │ │ └── op_test.go │ ├── symphony │ │ ├── controller.go │ │ └── controller_test.go │ ├── synthesis │ │ ├── gc.go │ │ ├── gc_fuzz_test.go │ │ ├── gc_test.go │ │ ├── integration_test.go │ │ ├── lifecycle.go │ │ ├── lifecycle_test.go │ │ ├── metrics.go │ │ ├── pod.go │ │ └── pod_test.go │ └── watch │ │ ├── integration_test.go │ │ ├── kind.go │ │ ├── kind_test.go │ │ ├── pruning.go │ │ └── watch.go ├── execution │ ├── executor.go │ ├── executor_test.go │ ├── handler.go │ └── handler_test.go ├── flowcontrol │ ├── metrics.go │ ├── writebuffer.go │ └── writebuffer_test.go ├── inputs │ ├── inputs.go │ └── inputs_test.go ├── k8s │ └── kubeconfig.go ├── manager │ ├── indices.go │ ├── manager.go │ ├── manager_test.go │ └── options.go ├── readiness │ ├── readiness.go │ └── readiness_test.go ├── resource │ ├── cache.go │ ├── cache_test.go │ ├── fixtures │ │ ├── openapi.json │ │ ├── tree-builder-both-crd-and-cr-and-readiness-groups.json │ │ ├── tree-builder-both-crd-and-cr-conflict.json │ │ ├── tree-builder-crd-and-cr.json │ │ ├── tree-builder-empty.json │ │ ├── tree-builder-several-overlapping-groups.json │ │ ├── tree-builder-several-readiness-groups.json │ │ └── tree-builder-single-basic-resource.json │ ├── resource.go │ ├── resource_test.go │ ├── slicing.go │ ├── slicing_test.go │ ├── tree.go │ └── tree_test.go └── testutil │ ├── statespace │ ├── statespace.go │ └── statespace_test.go │ └── testutil.go └── pkg ├── function ├── fixtures │ ├── invalid.yaml │ └── valid.yaml ├── fs.go ├── fs_test.go ├── inputs.go ├── inputs_test.go ├── main.go ├── main_test.go ├── outputs.go └── outputs_test.go ├── functiontest ├── fixtures │ ├── 1.yaml │ ├── 2.yml │ └── 3.json ├── snapshots │ └── 1.yaml ├── testing.go └── testing_test.go ├── helmshim ├── fixtures │ ├── basic-chart │ │ ├── Chart.yaml │ │ └── templates │ │ │ ├── cm.yaml │ │ │ ├── skipped-but-with-fancy-comment.yaml │ │ │ └── unknown.yaml │ └── hook-chart │ │ ├── Chart.yaml │ │ └── templates │ │ ├── cm.yaml │ │ └── unknown.yaml ├── go.mod ├── go.sum ├── helm.go ├── helm_test.go └── options.go └── krm └── functions └── api └── v1 ├── doc.go ├── register.go ├── resource_list.go ├── result.go ├── swagger.yaml └── zz_generated.deepcopy.go /.github/workflows/build-publish-mcr.yaml: -------------------------------------------------------------------------------- 1 | # This Github Action will build and publish images to Azure Container Registry(ACR), from where the published images will be 2 | # automatically pushed to the trusted registry, Microsoft Container Registry(MCR). 3 | name: Building and Pushing to MCR 4 | on: 5 | release: 6 | types: [published] 7 | 8 | permissions: 9 | id-token: write # This is required for requesting the JWT 10 | contents: write # release changes require contents write 11 | 12 | env: 13 | REGISTRY_REPO: unlisted/aks/eno 14 | 15 | jobs: 16 | prepare-variables: 17 | runs-on: ubuntu-latest 18 | outputs: 19 | release_tag: ${{ github.event.release.tag_name }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: 'Set output variables' 25 | id: vars 26 | run: | 27 | # set the image version 28 | RELEASE_TAG=${{ inputs.releaseTag }} 29 | if [ -z "$RELEASE_TAG" ]; then 30 | RELEASE_TAG=`git describe --tags $(git rev-list --tags --max-count=1)` 31 | echo "The user input release tag is empty, will use the latest tag $RELEASE_TAG." 32 | fi 33 | echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT 34 | 35 | # NOTE: As exporting a variable from a secret is not possible, the shared variable registry obtained 36 | # from AZURE_REGISTRY secret is not exported from here. 37 | 38 | publish-manifest: 39 | runs-on: ubuntu-latest 40 | needs: prepare-variables 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | ref: ${{ needs.prepare-variables.outputs.release_tag }} 45 | - name: Build and push deployment manifest 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | REGISTRY: "mcr.microsoft.com/aks/eno" 49 | TAG: ${{ needs.prepare-variables.outputs.release_tag }} 50 | DISABLE_SSA: "false" 51 | run: | 52 | cat ./api/v1/config/crd/* > manifest.yaml 53 | cat ./dev/deploy.yaml | envsubst >> manifest.yaml 54 | gh release upload ${{ needs.prepare-variables.outputs.release_tag }} manifest.yaml 55 | 56 | publish-images: 57 | runs-on: 58 | labels: [self-hosted, "1ES.Pool=1es-aks-eno-pool-ubuntu"] 59 | needs: prepare-variables 60 | steps: 61 | - uses: actions/checkout@v4 62 | with: 63 | ref: ${{ needs.prepare-variables.outputs.release_tag }} 64 | - name: 'Login the ACR' 65 | run: | 66 | az login --identity 67 | az acr login -n ${{ secrets.AZURE_REGISTRY }} 68 | - name: Build and publish eno-controller 69 | run: | 70 | make docker-build-eno-controller 71 | env: 72 | ENO_CONTROLLER_IMAGE_VERSION: ${{ needs.prepare-variables.outputs.release_tag }} 73 | REGISTRY: ${{ secrets.AZURE_REGISTRY }}/${{ env.REGISTRY_REPO}} 74 | - name: Build and publish eno-reconciler 75 | run: | 76 | make docker-build-eno-reconciler 77 | env: 78 | ENO_RECONCILER_IMAGE_VERSION: ${{ needs.prepare-variables.outputs.release_tag }} 79 | REGISTRY: ${{ secrets.AZURE_REGISTRY }}/${{ env.REGISTRY_REPO}} -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '19 13 * * 6' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: go 47 | build-mode: autobuild 48 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 59 | 60 | # Add any setup steps before running the `github/codeql-action/init` action. 61 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 62 | # or others). This is typically only required for manual builds. 63 | # - name: Setup runtime (example) 64 | # uses: actions/setup-example@v1 65 | 66 | # Initializes the CodeQL tools for scanning. 67 | - name: Initialize CodeQL 68 | uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 69 | with: 70 | languages: ${{ matrix.language }} 71 | build-mode: ${{ matrix.build-mode }} 72 | # If you wish to specify custom queries, you can do so here or in a config file. 73 | # By default, queries listed here will override any specified in a config file. 74 | # Prefix the list here with "+" to use these queries and those in the config file. 75 | 76 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 77 | # queries: security-extended,security-and-quality 78 | 79 | # If the analyze step fails for one of the languages you are analyzing with 80 | # "We were unable to automatically build your code", modify the matrix above 81 | # to set the build mode to "manual" for that language. Then modify this step 82 | # to build your code. 83 | # ℹ️ Command-line programs to run using the OS shell. 84 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 85 | - if: matrix.build-mode == 'manual' 86 | shell: bash 87 | run: | 88 | echo 'If you are using a "manual" build mode for one or more of the' \ 89 | 'languages you are analyzing, replace this with the commands to build' \ 90 | 'your code, for example:' 91 | echo ' make bootstrap' 92 | echo ' make release' 93 | exit 1 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | -------------------------------------------------------------------------------- /.github/workflows/k8scompat.yaml: -------------------------------------------------------------------------------- 1 | name: Kubernetes Version Compatibility Tests 2 | on: 3 | push: 4 | pull_request: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | schedule: 9 | - cron: 0 0 * * * 10 | 11 | jobs: 12 | buildMatrix: 13 | name: Prepare Matrix 14 | runs-on: ubuntu-latest 15 | outputs: 16 | matrix: ${{ steps.matrixbuild.outputs.matrix }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | 23 | - name: Generate test matrix 24 | id: matrixbuild 25 | run: echo "matrix=$(./hack/build-k8s-matrix.sh)" >> $GITHUB_OUTPUT 26 | 27 | - name: Build the test binary 28 | id: testbuild 29 | run: go test -c -o eno-tests ./internal/controllers/reconciliation 30 | 31 | - name: Upload test artifacts 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: test-artifacts 35 | retention-days: 1 36 | path: eno-tests 37 | 38 | test: 39 | name: Kubernetes 1.${{ matrix.downstreamApiserverMinorVersion }} 40 | needs: buildMatrix 41 | runs-on: ubuntu-latest 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | downstreamApiserverMinorVersion: ${{ fromJson(needs.buildMatrix.outputs.matrix) }} 46 | steps: 47 | - uses: actions/checkout@v4 48 | 49 | - name: Download test artifacts 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: test-artifacts 53 | 54 | - name: Download kubebuilder assets 55 | run: | 56 | echo "UPSTREAM_KUBEBUILDER_ASSETS=$(./hack/download-k8s.sh)" >> $GITHUB_ENV 57 | echo "DOWNSTREAM_KUBEBUILDER_ASSETS=$(./hack/download-k8s.sh ${{ matrix.downstreamApiserverMinorVersion }})" >> $GITHUB_ENV 58 | 59 | - name: Run tests 60 | run: | 61 | chmod +x eno-tests 62 | cd internal/controllers/reconciliation 63 | ../../../eno-tests -test.v 64 | env: 65 | DOWNSTREAM_VERSION_MINOR: "${{ matrix.downstreamApiserverMinorVersion }}" 66 | -------------------------------------------------------------------------------- /.github/workflows/smoke.yaml: -------------------------------------------------------------------------------- 1 | name: Smoke Tests 2 | on: 3 | push: 4 | pull_request: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | schedule: 9 | - cron: 0 0 * * * 10 | 11 | jobs: 12 | test: 13 | name: Apply Examples (${{ matrix.config.name }}) 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | config: 20 | - name: Default 21 | disable_ssa: "false" 22 | - name: Disable SSA 23 | disable_ssa: "true" 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Create Kind cluster 29 | uses: helm/kind-action@v1 30 | 31 | - name: Wait for apiserver 32 | run: | 33 | kind export kubeconfig --name chart-testing 34 | while true; do 35 | kubectl api-resources 36 | if [[ $? -eq 0 ]]; then 37 | break 38 | else 39 | sleep 1 40 | fi 41 | done 42 | 43 | - name: Build Eno images 44 | env: 45 | REGISTRY: localhost 46 | SKIP_PUSH: "yes" 47 | DISABLE_SSA: "${{ matrix.config.disable_ssa }}" 48 | run: | 49 | echo "--- building eno..." 50 | ./dev/build.sh 51 | 52 | - name: Build Example images 53 | env: 54 | REGISTRY: localhost 55 | SKIP_PUSH: "yes" 56 | run: | 57 | for i in ./examples/*/build.sh; do 58 | echo "--- running $i..." 59 | $i 60 | done 61 | 62 | - name: Load images into Kind cluster 63 | run: | 64 | for image in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep localhost); do 65 | echo "--- pushing $image" 66 | kind load docker-image --name chart-testing $image 67 | done 68 | 69 | - name: Run tests 70 | timeout-minutes: 3 71 | run: ./hack/smoke-test.sh 72 | -------------------------------------------------------------------------------- /.github/workflows/unit.yaml: -------------------------------------------------------------------------------- 1 | name: Go Unit Tests 2 | on: 3 | push: 4 | pull_request: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | schedule: 9 | - cron: 0 0 * * * 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version-file: go.mod 21 | 22 | - name: Download k8s 23 | run: echo "KUBEBUILDER_ASSETS=$(./hack/download-k8s.sh)" >> $GITHUB_ENV 24 | 25 | - name: Run tests 26 | run: go test -v ./... 27 | 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .k8sexamples/03-helm-shim/helm-shim 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Eno 2 | 3 | Thank you for your interest in contributing to Eno! This document provides an overview of the project's architecture, components, and development workflow to help you get started. 4 | 5 | ## Project Overview 6 | 7 | Eno is a Kubernetes operator that enables declarative management of complex resources through synthesis and reconciliation processes. The project consists of several controllers that work together to manage the lifecycle of compositions, synthesizers, and resources. 8 | 9 | ## Repository Structure 10 | 11 | The repository is structured as follows: 12 | 13 | - `api/` - Contains the API definitions for CRDs (Custom Resource Definitions) 14 | - `cmd/` - Contains the entry points for the eno-controller and eno-reconciler binaries 15 | - `docker/` - Docker build configurations 16 | - `docs/` - Project documentation including API reference 17 | - `examples/` - Example usage patterns and resources 18 | - `hack/` - Development and tooling scripts 19 | - `internal/` - Core implementation code: 20 | - `controllers/` - All controllers that implement the application's behavior 21 | - `execution/` - Code for executing synthesizers 22 | - `manager/` - Controller manager infrastructure 23 | - `resource/` - Resource handling and manipulation 24 | - `testutil/` - Testing utilities and helpers 25 | - `pkg/` - Public packages that can be imported by external projects 26 | 27 | ## Architecture 28 | 29 | Eno implements a distributed control plane model with several key components: 30 | 31 | 1. **Synthesizers**: These convert high-level declarative definitions into concrete resources. 32 | 2. **Compositions**: These define what to synthesize and provide bindings to resources. 33 | 3. **ResourceSlices**: Groups of resources generated by the synthesis process. 34 | 4. **Reconciliation Controller**: Ensures resources defined in ResourceSlices are correctly reconciled in the cluster. 35 | 5. **Scheduling Controller**: Schedules and manages synthesis operations. 36 | 37 | ## Controllers Overview 38 | 39 | The following controllers form the core of the system: 40 | 41 | - **Composition Controller**: Manages the composition lifecycle and triggers synthesis. 42 | - **Reconciliation Controller**: Ensures synthesized resources match their desired state in the cluster. 43 | - **Scheduling Controller**: Schedules and manages synthesis operations. 44 | - **Resource Slice Controller**: Processes resource slices generated by synthesizers. 45 | - **Symphony Controller**: Coordinates multiple compositions with variations. 46 | - **Watch Controller**: Watches for changes in resources that might affect compositions. 47 | 48 | ## Development Workflow 49 | 50 | ### Prerequisites 51 | 52 | - Go 1.23+ 53 | - Kubernetes environment for testing (can use kind, minikube, etc.) 54 | 55 | ### Setup Environment 56 | 57 | Use the Makefile target to set up the test environment: 58 | 59 | ```bash 60 | make setup-testenv 61 | ``` 62 | 63 | This will download the controller-runtime test environment binaries needed for running tests. 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifndef TAG 2 | TAG ?= $(shell git rev-parse --short=7 HEAD) 3 | endif 4 | 5 | ENO_CONTROLLER_IMAGE_VERSION ?= $(TAG) 6 | ENO_CONTROLLER_IMAGE_NAME ?= eno-controller 7 | ENO_RECONCILER_IMAGE_VERSION ?= $(TAG) 8 | ENO_RECONCILER_IMAGE_NAME ?= eno-reconciler 9 | 10 | .PHONY: docker-build-eno-controller 11 | docker-build-eno-controller: 12 | docker build \ 13 | --file docker/$(ENO_CONTROLLER_IMAGE_NAME)/Dockerfile \ 14 | --tag $(REGISTRY)/$(ENO_CONTROLLER_IMAGE_NAME):$(ENO_CONTROLLER_IMAGE_VERSION) . 15 | docker push $(REGISTRY)/$(ENO_CONTROLLER_IMAGE_NAME):$(ENO_CONTROLLER_IMAGE_VERSION) 16 | 17 | .PHONY: docker-build-eno-reconciler 18 | docker-build-eno-reconciler: 19 | docker build \ 20 | --file docker/$(ENO_RECONCILER_IMAGE_NAME)/Dockerfile \ 21 | --tag $(REGISTRY)/$(ENO_RECONCILER_IMAGE_NAME):$(ENO_RECONCILER_IMAGE_VERSION) . 22 | docker push $(REGISTRY)/$(ENO_RECONCILER_IMAGE_NAME):$(ENO_RECONCILER_IMAGE_VERSION) 23 | 24 | # Setup controller-runtime test environment binaries 25 | .PHONY: setup-testenv 26 | setup-testenv: 27 | @echo "Installing controller-runtime testenv binaries..." 28 | @go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest use -p path 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # TODO: The maintainer of this repo has not yet edited this file 2 | 3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? 4 | 5 | - **No CSS support:** Fill out this template with information about how to file issues and get help. 6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps. 7 | - **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide. 8 | 9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.* 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 17 | feature request as a new Issue. 18 | 19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 22 | 23 | ## Microsoft Support Policy 24 | 25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 26 | -------------------------------------------------------------------------------- /api/v1/config/crd/eno.azure.io_resourceslices.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.3 7 | name: resourceslices.eno.azure.io 8 | spec: 9 | group: eno.azure.io 10 | names: 11 | kind: ResourceSlice 12 | listKind: ResourceSliceList 13 | plural: resourceslices 14 | singular: resourceslice 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | properties: 21 | apiVersion: 22 | description: |- 23 | APIVersion defines the versioned schema of this representation of an object. 24 | Servers should convert recognized schemas to the latest internal value, and 25 | may reject unrecognized values. 26 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 27 | type: string 28 | kind: 29 | description: |- 30 | Kind is a string value representing the REST resource this object represents. 31 | Servers may infer this from the endpoint the client submits requests to. 32 | Cannot be updated. 33 | In CamelCase. 34 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 35 | type: string 36 | metadata: 37 | type: object 38 | spec: 39 | properties: 40 | resources: 41 | items: 42 | properties: 43 | deleted: 44 | description: Deleted is true when this manifest represents a 45 | "tombstone" - a resource that should no longer exist. 46 | type: boolean 47 | manifest: 48 | type: string 49 | type: object 50 | type: array 51 | synthesisUUID: 52 | type: string 53 | type: object 54 | status: 55 | properties: 56 | resources: 57 | description: Elements of resources correspond in index to those in 58 | spec.resources at the observed generation. 59 | items: 60 | properties: 61 | deleted: 62 | type: boolean 63 | ready: 64 | format: date-time 65 | type: string 66 | reconciled: 67 | type: boolean 68 | type: object 69 | type: array 70 | type: object 71 | type: object 72 | served: true 73 | storage: true 74 | subresources: 75 | status: {} 76 | -------------------------------------------------------------------------------- /api/v1/docsconfig.yaml: -------------------------------------------------------------------------------- 1 | processor: 2 | ignoreTypes: 3 | - ".*List$" 4 | - "ResourceSlice" 5 | - "InputResource" 6 | ignoreFields: 7 | - "TypeMeta$" 8 | 9 | render: 10 | kubernetesVersion: 1.22 11 | -------------------------------------------------------------------------------- /api/v1/env.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | type EnvVar struct { 4 | // +required 5 | // +kubebuilder:validation:MaxLength:=100 6 | Name string `json:"name"` 7 | Value string `json:"value,omitempty"` 8 | } 9 | -------------------------------------------------------------------------------- /api/v1/inputs.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 4 | 5 | // NewInput is used to create an `Input` with TypeMeta populated. 6 | // This is required because `Input` is not a CRD, but we still want 7 | // proper encoding/decoding via the Unstructured codec. 8 | func NewInput(key string, res InputResource) Input { 9 | return Input{ 10 | TypeMeta: metav1.TypeMeta{ 11 | APIVersion: SchemeGroupVersion.String(), 12 | Kind: "Input", 13 | }, 14 | Key: key, 15 | Resource: res, 16 | } 17 | } 18 | 19 | // Input is passed to Synthesis Pods at runtime and represents a bound ref. 20 | type Input struct { 21 | metav1.TypeMeta `json:",inline"` 22 | Key string `json:"key"` 23 | Resource InputResource `json:"resource"` 24 | } 25 | 26 | type InputResource struct { 27 | Name string `json:"name"` 28 | Namespace string `json:"namespace,omitempty"` 29 | Kind string `json:"kind"` 30 | Group string `json:"group"` 31 | } 32 | 33 | // Bindings map a specific Kubernetes resource to a ref exposed by a synthesizer. 34 | // Compositions use bindings to populate inputs supported by their synthesizer. 35 | type Binding struct { 36 | // Key determines which ref this binding binds to. Opaque. 37 | Key string `json:"key"` 38 | 39 | Resource ResourceBinding `json:"resource"` 40 | } 41 | 42 | // A reference to a specific resource name and optionally namespace. 43 | type ResourceBinding struct { 44 | Name string `json:"name"` 45 | Namespace string `json:"namespace,omitempty"` 46 | } 47 | 48 | // Ref defines a synthesizer input. 49 | // Inputs are typed using the Kubernetes API - they are just normal Kubernetes resources. 50 | // The consumer (synthesizer) specifies the resource's kind/group, 51 | // while the producer (composition) specifies a specific resource name/namespace. 52 | // 53 | // Compositions that use the synthesizer will be re-synthesized when the resource bound to this ref changes. 54 | // Re-synthesis happens automatically while honoring the globally configured cooldown period. 55 | type Ref struct { 56 | // Key corresponds to bindings to this ref. 57 | Key string `json:"key"` 58 | 59 | Resource ResourceRef `json:"resource"` 60 | 61 | // Allows control over re-synthesis when inputs changed. 62 | // A non-deferred input will trigger a synthesis immediately, whereas a 63 | // deferred input will respect the cooldown period. 64 | Defer bool `json:"defer,omitempty"` 65 | } 66 | 67 | // A reference to a resource kind/group. 68 | type ResourceRef struct { 69 | Group string `json:"group,omitempty"` 70 | Version string `json:"version,omitempty"` 71 | Kind string `json:"kind"` 72 | 73 | // If set, name and namespace form an "implicit binding", i.e. a ref that is bound to 74 | // a specific resource without a corresponding binding on the composition resource. 75 | // The implied binding takes precedence over a corresponding binding from the composition. 76 | Name string `json:"name,omitempty"` 77 | Namespace string `json:"namespace,omitempty"` 78 | } 79 | -------------------------------------------------------------------------------- /api/v1/resourceslice.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 4 | 5 | // +kubebuilder:object:root=true 6 | type ResourceSliceList struct { 7 | metav1.TypeMeta `json:",inline"` 8 | metav1.ListMeta `json:"metadata,omitempty"` 9 | Items []ResourceSlice `json:"items"` 10 | } 11 | 12 | // +kubebuilder:object:root=true 13 | // +kubebuilder:subresource:status 14 | type ResourceSlice struct { 15 | metav1.TypeMeta `json:",inline"` 16 | metav1.ObjectMeta `json:"metadata,omitempty"` 17 | 18 | Spec ResourceSliceSpec `json:"spec,omitempty"` 19 | Status ResourceSliceStatus `json:"status,omitempty"` 20 | } 21 | 22 | type ResourceSliceSpec struct { 23 | SynthesisUUID string `json:"synthesisUUID,omitempty"` 24 | Resources []Manifest `json:"resources,omitempty"` 25 | } 26 | 27 | type Manifest struct { 28 | Manifest string `json:"manifest,omitempty"` 29 | 30 | // Deleted is true when this manifest represents a "tombstone" - a resource that should no longer exist. 31 | Deleted bool `json:"deleted,omitempty"` 32 | } 33 | 34 | type ResourceSliceStatus struct { 35 | // Elements of resources correspond in index to those in spec.resources at the observed generation. 36 | Resources []ResourceState `json:"resources,omitempty"` 37 | } 38 | 39 | type ResourceState struct { 40 | Reconciled bool `json:"reconciled,omitempty"` 41 | Ready *metav1.Time `json:"ready,omitempty"` 42 | Deleted bool `json:"deleted,omitempty"` 43 | } 44 | 45 | func (r *ResourceState) Equal(rr *ResourceState) bool { 46 | if r == nil { 47 | return rr == nil 48 | } 49 | if rr == nil { 50 | return false 51 | } 52 | if r.Reconciled != rr.Reconciled || r.Deleted != rr.Deleted { 53 | return false 54 | } 55 | if r.Ready == nil { 56 | return rr.Ready == nil 57 | } 58 | if rr.Ready == nil { 59 | return r.Ready == nil 60 | } 61 | return r.Ready.Equal(rr.Ready) 62 | } 63 | 64 | type ResourceSliceRef struct { 65 | Name string `json:"name,omitempty"` 66 | } 67 | -------------------------------------------------------------------------------- /api/v1/resourceslice_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/utils/ptr" 10 | ) 11 | 12 | func TestResourceStateEqual(t *testing.T) { 13 | var tests = []struct { 14 | Name string 15 | Expected bool 16 | A, B *ResourceState 17 | }{ 18 | { 19 | Name: "nil", 20 | Expected: true, 21 | }, 22 | { 23 | Name: "empty", 24 | Expected: true, 25 | A: &ResourceState{}, 26 | B: &ResourceState{}, 27 | }, 28 | { 29 | Name: "nil", 30 | A: &ResourceState{}, 31 | }, 32 | { 33 | Name: "full", 34 | Expected: true, 35 | A: &ResourceState{ 36 | Reconciled: true, 37 | Ready: &metav1.Time{}, 38 | Deleted: true, 39 | }, 40 | B: &ResourceState{ 41 | Reconciled: true, 42 | Ready: &metav1.Time{}, 43 | Deleted: true, 44 | }, 45 | }, 46 | { 47 | Name: "ready-mismatch", 48 | Expected: false, 49 | A: &ResourceState{ 50 | Reconciled: true, 51 | Ready: &metav1.Time{}, 52 | Deleted: true, 53 | }, 54 | B: &ResourceState{ 55 | Reconciled: true, 56 | Ready: ptr.To(metav1.NewTime(time.Now().Add(time.Second))), 57 | Deleted: true, 58 | }, 59 | }, 60 | { 61 | Name: "ready-mismatch", 62 | Expected: false, 63 | A: &ResourceState{ 64 | Reconciled: true, 65 | Ready: &metav1.Time{}, 66 | Deleted: true, 67 | }, 68 | B: &ResourceState{ 69 | Reconciled: true, 70 | Deleted: true, 71 | }, 72 | }, 73 | { 74 | Name: "reconciled-mismatch", 75 | Expected: false, 76 | A: &ResourceState{ 77 | Reconciled: true, 78 | Ready: &metav1.Time{}, 79 | Deleted: true, 80 | }, 81 | B: &ResourceState{ 82 | Reconciled: false, 83 | Ready: &metav1.Time{}, 84 | Deleted: true, 85 | }, 86 | }, 87 | { 88 | Name: "deleted-mismatch", 89 | Expected: false, 90 | A: &ResourceState{ 91 | Reconciled: true, 92 | Ready: &metav1.Time{}, 93 | Deleted: true, 94 | }, 95 | B: &ResourceState{ 96 | Reconciled: true, 97 | Ready: &metav1.Time{}, 98 | Deleted: false, 99 | }, 100 | }, 101 | } 102 | 103 | for _, tt := range tests { 104 | t.Run(tt.Name, func(t *testing.T) { 105 | assert.Equal(t, tt.Expected, tt.A.Equal(tt.B), "a->b") 106 | assert.Equal(t, tt.Expected, tt.B.Equal(tt.A), "b->a") 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /api/v1/symphony.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 4 | 5 | // +kubebuilder:object:root=true 6 | type SymphonyList struct { 7 | metav1.TypeMeta `json:",inline"` 8 | metav1.ListMeta `json:"metadata,omitempty"` 9 | Items []Symphony `json:"items"` 10 | } 11 | 12 | // Symphony is a set of variations on a composition. 13 | // Useful for creating several compositions that use a common set of bindings but different synthesizers. 14 | // 15 | // This pattern is highly opinionated for use-cases in which a single "unit of management" 16 | // includes multiple distinct components. For example: deploying many instances of an application that 17 | // is comprised of several components (Wordpress, etc.). 18 | // 19 | // +kubebuilder:object:root=true 20 | // +kubebuilder:subresource:status 21 | type Symphony struct { 22 | metav1.TypeMeta `json:",inline"` 23 | metav1.ObjectMeta `json:"metadata,omitempty"` 24 | 25 | Spec SymphonySpec `json:"spec,omitempty"` 26 | Status SymphonyStatus `json:"status,omitempty"` 27 | } 28 | 29 | type SymphonySpec struct { 30 | // Each variation will result in the creation of a composition. 31 | // Synthesizer refs must be unique across variations. 32 | // Removing a variation will cause the composition to be deleted! 33 | Variations []Variation `json:"variations,omitempty"` 34 | 35 | // Bindings are inherited by all compositions managed by this symphony. 36 | Bindings []Binding `json:"bindings,omitempty"` 37 | 38 | // SynthesisEnv 39 | // Copied opaquely into the compositions managed by this symphony. 40 | // +kubebuilder:validation:MaxItems:=50 41 | SynthesisEnv []EnvVar `json:"synthesisEnv,omitempty"` // deprecated synthesis env should always be variation scoped. 42 | } 43 | 44 | type SymphonyStatus struct { 45 | ObservedGeneration int64 `json:"observedGeneration,omitempty"` 46 | Synthesized *metav1.Time `json:"synthesized,omitempty"` 47 | Reconciled *metav1.Time `json:"reconciled,omitempty"` 48 | Ready *metav1.Time `json:"ready,omitempty"` 49 | } 50 | 51 | type Variation struct { 52 | // Used to populate the composition's metadata.labels. 53 | Labels map[string]string `json:"labels,omitempty"` 54 | 55 | // Used to populate the composition's medatada.annotations. 56 | Annotations map[string]string `json:"annotations,omitempty"` 57 | 58 | // Used to populate the composition's spec.synthesizer. 59 | Synthesizer SynthesizerRef `json:"synthesizer,omitempty"` 60 | 61 | // Variation-specific bindings get merged with Symphony bindings and take 62 | // precedence over them. 63 | Bindings []Binding `json:"bindings,omitempty"` 64 | 65 | // SynthesisEnv 66 | // Copied opaquely into the compositions that's derived from this variation. 67 | // It gets merged with the Symhony environment and takes precedence over it. 68 | // +kubebuilder:validation:MaxItems:=25 69 | SynthesisEnv []EnvVar `json:"synthesisEnv,omitempty"` 70 | } 71 | -------------------------------------------------------------------------------- /api/v1/synthesizer.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | // +kubebuilder:object:root=true 9 | type SynthesizerList struct { 10 | metav1.TypeMeta `json:",inline"` 11 | metav1.ListMeta `json:"metadata,omitempty"` 12 | Items []Synthesizer `json:"items"` 13 | } 14 | 15 | // Synthesizers are any process that can run in a Kubernetes container that implements the [KRM Functions Specification](https://github.com/kubernetes-sigs/kustomize/blob/master/cmd/config/docs/api-conventions/functions-spec.md). 16 | // 17 | // Synthesizer processes are given some metadata about the composition they are synthesizing, and are expected 18 | // to return a set of Kubernetes resources. Essentially they generate the desired state for a set of Kubernetes resources. 19 | // 20 | // +kubebuilder:object:root=true 21 | // +kubebuilder:subresource:status 22 | // +kubebuilder:resource:scope=Cluster 23 | // +kubebuilder:printcolumn:name="Image",type=string,JSONPath=`.spec.image` 24 | type Synthesizer struct { 25 | metav1.TypeMeta `json:",inline"` 26 | metav1.ObjectMeta `json:"metadata,omitempty"` 27 | 28 | Spec SynthesizerSpec `json:"spec,omitempty"` 29 | Status SynthesizerStatus `json:"status,omitempty"` 30 | } 31 | 32 | // +kubebuilder:validation:XValidation:rule="duration(self.execTimeout) <= duration(self.podTimeout)",message="podTimeout must be greater than execTimeout" 33 | type SynthesizerSpec struct { 34 | // Copied opaquely into the container's image property. 35 | Image string `json:"image,omitempty"` 36 | 37 | // Copied opaquely into the container's command property. 38 | // 39 | // +kubebuilder:default={"synthesize"} 40 | Command []string `json:"command,omitempty"` 41 | 42 | // Timeout for each execution of the synthesizer command. 43 | // 44 | // +kubebuilder:default="10s" 45 | ExecTimeout *metav1.Duration `json:"execTimeout,omitempty"` 46 | 47 | // Pods are recreated after they've existed for at least the pod timeout interval. 48 | // This helps close the loop in failure modes where a pod may be considered ready but not actually able to run. 49 | // 50 | // +kubebuilder:default="2m" 51 | PodTimeout *metav1.Duration `json:"podTimeout,omitempty"` 52 | 53 | // Refs define the Synthesizer's input schema without binding it to specific 54 | // resources. 55 | Refs []Ref `json:"refs,omitempty"` 56 | 57 | // PodOverrides sets values in the pods used to execute this synthesizer. 58 | PodOverrides PodOverrides `json:"podOverrides,omitempty"` 59 | } 60 | 61 | type PodOverrides struct { 62 | Labels map[string]string `json:"labels,omitempty"` 63 | Annotations map[string]string `json:"annotations,omitempty"` 64 | Resources corev1.ResourceRequirements `json:"resources,omitempty"` 65 | Affinity *corev1.Affinity `json:"affinity,omitempty"` 66 | } 67 | 68 | type SynthesizerStatus struct { 69 | } 70 | 71 | type SynthesizerRef struct { 72 | Name string `json:"name,omitempty"` 73 | } 74 | -------------------------------------------------------------------------------- /api/v1/types.go: -------------------------------------------------------------------------------- 1 | // +kubebuilder:object:generate=true 2 | // +groupName=eno.azure.io 3 | package v1 4 | 5 | import ( 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | "sigs.k8s.io/controller-runtime/pkg/scheme" 8 | ) 9 | 10 | //go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen object crd rbac:roleName=resourceprovider paths=./... 11 | 12 | // Requires https://github.com/elastic/crd-ref-docs 13 | // 14 | //go:generate crd-ref-docs --source-path=./ --config=docsconfig.yaml --renderer=markdown --output-path=../../docs/api.md 15 | 16 | var ( 17 | SchemeGroupVersion = schema.GroupVersion{Group: "eno.azure.io", Version: "v1"} 18 | SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} 19 | ) 20 | 21 | func init() { 22 | SchemeBuilder.Register(&SynthesizerList{}, &Synthesizer{}) 23 | SchemeBuilder.Register(&CompositionList{}, &Composition{}) 24 | SchemeBuilder.Register(&SymphonyList{}, &Symphony{}) 25 | SchemeBuilder.Register(&ResourceSliceList{}, &ResourceSlice{}) 26 | } 27 | -------------------------------------------------------------------------------- /cmd/eno-reconciler/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/go-logr/zapr" 10 | "go.uber.org/zap" 11 | "go.uber.org/zap/zapcore" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/labels" 14 | "k8s.io/client-go/rest" 15 | ctrl "sigs.k8s.io/controller-runtime" 16 | 17 | "github.com/Azure/eno/internal/controllers/liveness" 18 | "github.com/Azure/eno/internal/controllers/reconciliation" 19 | "github.com/Azure/eno/internal/flowcontrol" 20 | "github.com/Azure/eno/internal/k8s" 21 | "github.com/Azure/eno/internal/manager" 22 | ) 23 | 24 | func main() { 25 | if err := run(); err != nil { 26 | fmt.Fprintf(os.Stderr, "error: %s\n", err) 27 | os.Exit(1) 28 | } 29 | } 30 | 31 | func run() error { 32 | ctx := ctrl.SetupSignalHandler() 33 | var ( 34 | debugLogging bool 35 | remoteKubeconfigFile string 36 | remoteQPS float64 37 | compositionSelector string 38 | compositionNamespace string 39 | namespaceCreationGracePeriod time.Duration 40 | namespaceCleanup bool 41 | 42 | mgrOpts = &manager.Options{ 43 | Rest: ctrl.GetConfigOrDie(), 44 | } 45 | 46 | recOpts = reconciliation.Options{} 47 | ) 48 | flag.BoolVar(&debugLogging, "debug", true, "Enable debug logging") 49 | flag.StringVar(&remoteKubeconfigFile, "remote-kubeconfig", "", "Path to the kubeconfig of the apiserver where the resources will be reconciled. The config from the environment is used if this is not provided") 50 | flag.Float64Var(&remoteQPS, "remote-qps", 50, "Max requests per second to the remote apiserver") 51 | flag.DurationVar(&recOpts.Timeout, "timeout", time.Minute, "Per-resource reconciliation timeout. Avoids cases where client retries/timeouts are configured poorly and the loop gets blocked") 52 | flag.DurationVar(&recOpts.ReadinessPollInterval, "readiness-poll-interval", time.Second*5, "Interval at which non-ready resources will be checked for readiness") 53 | flag.DurationVar(&recOpts.MinReconcileInterval, "min-reconcile-interval", time.Second, "Minimum value of eno.azure.com/reconcile-interval that will be honored by the controller") 54 | flag.BoolVar(&recOpts.DisableServerSideApply, "disable-ssa", false, "Use non-strategic three-way merge patches instead of server-side apply") 55 | flag.StringVar(&compositionSelector, "composition-label-selector", labels.Everything().String(), "Optional label selector for compositions to be reconciled") 56 | flag.StringVar(&compositionNamespace, "composition-namespace", metav1.NamespaceAll, "Optional namespace to limit compositions that will be reconciled") 57 | flag.DurationVar(&namespaceCreationGracePeriod, "ns-creation-grace-period", time.Second, "A namespace is assumed to be missing if it doesn't exist once one of its resources has existed for this long") 58 | flag.BoolVar(&namespaceCleanup, "namespace-cleanup", true, "Clean up orphaned resources caused by namespace force-deletions") 59 | mgrOpts.Bind(flag.CommandLine) 60 | flag.Parse() 61 | 62 | zapCfg := zap.NewProductionConfig() 63 | if debugLogging { 64 | zapCfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) 65 | } 66 | zl, err := zapCfg.Build() 67 | if err != nil { 68 | return err 69 | } 70 | logger := zapr.NewLogger(zl) 71 | 72 | mgrOpts.CompositionNamespace = compositionNamespace 73 | if compositionSelector != "" { 74 | var err error 75 | mgrOpts.CompositionSelector, err = labels.Parse(compositionSelector) 76 | if err != nil { 77 | return fmt.Errorf("invalid composition label selector: %w", err) 78 | } 79 | } else { 80 | mgrOpts.CompositionSelector = labels.Everything() 81 | } 82 | 83 | mgrOpts.Rest.UserAgent = "eno-reconciler" 84 | mgr, err := manager.NewReconciler(logger, mgrOpts) 85 | if err != nil { 86 | return fmt.Errorf("constructing manager: %w", err) 87 | } 88 | 89 | if namespaceCleanup { 90 | err = liveness.NewNamespaceController(mgr, 5, namespaceCreationGracePeriod) 91 | if err != nil { 92 | return fmt.Errorf("constructing namespace liveness controller: %w", err) 93 | } 94 | } 95 | 96 | remoteConfig := rest.CopyConfig(mgr.GetConfig()) 97 | if remoteKubeconfigFile != "" { 98 | if remoteConfig, err = k8s.GetRESTConfig(remoteKubeconfigFile); err != nil { 99 | return err 100 | } 101 | } 102 | if remoteQPS >= 0 { 103 | remoteConfig.QPS = float32(remoteQPS) 104 | } 105 | 106 | // Burst of 1 allows the first write to happen immediately, while subsequent writes are debounced/batched at writeBatchInterval. 107 | // This provides quick feedback in cases where only a few resources have changed. 108 | writeBuffer := flowcontrol.NewResourceSliceWriteBufferForManager(mgr) 109 | 110 | recOpts.Manager = mgr 111 | recOpts.WriteBuffer = writeBuffer 112 | recOpts.Downstream = remoteConfig 113 | 114 | err = reconciliation.New(mgr, recOpts) 115 | if err != nil { 116 | return fmt.Errorf("constructing reconciliation controller: %w", err) 117 | } 118 | 119 | return mgr.Start(ctx) 120 | } 121 | -------------------------------------------------------------------------------- /dev/build-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -z "${REGISTRY}" ]]; then 6 | echo "REGISTRY must be set" > /dev/stderr 7 | exit 1 8 | fi 9 | 10 | export TAG="$(date +%s)" 11 | 12 | function build() { 13 | cmd=$(basename $1) 14 | docker build --platform=linux/amd64 --quiet -t "$REGISTRY/$cmd:$TAG" -f "$f/Dockerfile" . 15 | [[ -z "${SKIP_PUSH}" ]] && docker push "$REGISTRY/$cmd:$TAG" 16 | } 17 | 18 | # Build! 19 | for f in docker/*; do 20 | build $f & 21 | done 22 | wait 23 | 24 | echo "Success! built and pushed tag: $TAG" 25 | -------------------------------------------------------------------------------- /dev/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -z "${REGISTRY}" ]]; then 6 | echo "REGISTRY must be set" > /dev/stderr 7 | exit 1 8 | fi 9 | 10 | export TAG="$(date +%s)" 11 | 12 | function build() { 13 | cmd=$(basename $1) 14 | docker build -t "$REGISTRY/$cmd:$TAG" -f "$f/Dockerfile" . 15 | if [[ -z "${SKIP_PUSH}" ]]; then 16 | docker push "$REGISTRY/$cmd:$TAG" 17 | fi 18 | } 19 | 20 | # Build! 21 | for f in docker/*; do 22 | build $f 23 | done 24 | 25 | # Deploy! 26 | export DISABLE_SSA="${DISABLE_SSA:=false}" 27 | cat "$(dirname "$0")/deploy.yaml" | envsubst | kubectl apply -f - -f ./api/v1/config/crd 28 | echo "Success! You're running tag: $TAG" 29 | -------------------------------------------------------------------------------- /dev/deploy.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: eno-controller 6 | labels: 7 | app: eno-controller 8 | spec: 9 | strategy: 10 | type: RollingUpdate 11 | replicas: 2 12 | selector: 13 | matchLabels: 14 | app: eno-controller 15 | template: 16 | metadata: 17 | labels: 18 | app: eno-controller 19 | annotations: 20 | prometheus.io/port: "8080" 21 | prometheus.io/path: /metrics 22 | prometheus.io/scrape: "true" 23 | spec: 24 | serviceAccountName: eno 25 | containers: 26 | - name: eno-controller 27 | image: $REGISTRY/eno-controller:$TAG 28 | args: 29 | - --executor-image=$REGISTRY/eno-controller:$TAG 30 | - --leader-election 31 | - --leader-election-id=controller 32 | - --synthesizer-pod-service-account=eno 33 | resources: 34 | requests: 35 | cpu: 50m 36 | memory: 42Mi 37 | limits: 38 | cpu: 250m 39 | memory: 42Mi 40 | env: 41 | - name: POD_NAMESPACE 42 | valueFrom: 43 | fieldRef: 44 | fieldPath: metadata.namespace 45 | - name: GOMAXPROCS 46 | value: "1" 47 | - name: GOMEMLIMIT 48 | valueFrom: 49 | resourceFieldRef: 50 | resource: limits.memory 51 | readinessProbe: 52 | httpGet: 53 | path: /readyz 54 | port: 8081 55 | scheme: HTTP 56 | livenessProbe: 57 | httpGet: 58 | path: /healthz 59 | port: 8081 60 | scheme: HTTP 61 | 62 | --- 63 | 64 | apiVersion: apps/v1 65 | kind: Deployment 66 | metadata: 67 | name: eno-reconciler 68 | labels: 69 | app: eno-reconciler 70 | spec: 71 | strategy: 72 | type: RollingUpdate 73 | replicas: 2 74 | selector: 75 | matchLabels: 76 | app: eno-reconciler 77 | template: 78 | metadata: 79 | labels: 80 | app: eno-reconciler 81 | annotations: 82 | prometheus.io/port: "8080" 83 | prometheus.io/path: /metrics 84 | prometheus.io/scrape: "true" 85 | spec: 86 | serviceAccountName: eno 87 | containers: 88 | - name: eno-reconciler 89 | image: $REGISTRY/eno-reconciler:$TAG 90 | args: 91 | - --leader-election 92 | - --leader-election-id=reconciler 93 | - --disable-ssa=$DISABLE_SSA 94 | env: 95 | - name: POD_NAMESPACE 96 | valueFrom: 97 | fieldRef: 98 | fieldPath: metadata.namespace 99 | - name: GOMAXPROCS 100 | value: "1" 101 | - name: GOMEMLIMIT 102 | valueFrom: 103 | resourceFieldRef: 104 | resource: limits.memory 105 | resources: 106 | requests: 107 | cpu: 50m 108 | memory: 64Mi 109 | limits: 110 | cpu: 1 111 | memory: 256Mi 112 | readinessProbe: 113 | httpGet: 114 | path: /readyz 115 | port: 8081 116 | scheme: HTTP 117 | livenessProbe: 118 | httpGet: 119 | path: /healthz 120 | port: 8081 121 | scheme: HTTP 122 | 123 | --- 124 | 125 | apiVersion: v1 126 | kind: ServiceAccount 127 | metadata: 128 | name: eno 129 | 130 | --- 131 | 132 | apiVersion: rbac.authorization.k8s.io/v1 133 | kind: ClusterRoleBinding 134 | metadata: 135 | name: eno-cluster-admin 136 | roleRef: 137 | apiGroup: rbac.authorization.k8s.io 138 | kind: ClusterRole 139 | name: cluster-admin 140 | subjects: 141 | - kind: ServiceAccount 142 | name: eno 143 | namespace: default 144 | -------------------------------------------------------------------------------- /docker/eno-controller/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/go:1.24 AS builder 2 | WORKDIR /app 3 | 4 | ADD go.mod . 5 | ADD go.sum . 6 | RUN --mount=type=cache,target=/root/.cache/go-build go mod download 7 | 8 | COPY . . 9 | RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags="-s -w" ./cmd/eno-controller 10 | 11 | FROM gcr.io/distroless/static 12 | 13 | # https://github.com/GoogleContainerTools/distroless/blob/16dc4a6a33838006fe956e4c19f049ece9c18a8d/common/variables.bzl#L18 14 | USER 65532:65532 15 | 16 | COPY --from=builder /app/eno-controller /eno-controller 17 | ENTRYPOINT ["/eno-controller"] 18 | -------------------------------------------------------------------------------- /docker/eno-reconciler/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/go:1.24 AS builder 2 | WORKDIR /app 3 | 4 | ADD go.mod . 5 | ADD go.sum . 6 | RUN --mount=type=cache,target=/root/.cache/go-build go mod download 7 | 8 | COPY . . 9 | RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags="-s -w" ./cmd/eno-reconciler 10 | 11 | FROM gcr.io/distroless/static 12 | 13 | # https://github.com/GoogleContainerTools/distroless/blob/16dc4a6a33838006fe956e4c19f049ece9c18a8d/common/variables.bzl#L18 14 | USER 65532:65532 15 | 16 | COPY --from=builder /app/eno-reconciler /eno-reconciler 17 | ENTRYPOINT ["/eno-reconciler"] 18 | -------------------------------------------------------------------------------- /docs/symphony.md: -------------------------------------------------------------------------------- 1 | # Symphony 2 | 3 | Symphonies are higher-order units of configuration that involve multiple synthesizers. 4 | 5 | ```yaml 6 | apiVersion: eno.azure.io/v1 7 | kind: Symphony 8 | metadata: 9 | name: basic-symphony 10 | spec: 11 | variations: 12 | - synthesizer: 13 | name: synth-1 14 | - synthesizer: 15 | name: synth-2 16 | ``` 17 | 18 | This will result in the creation of two compositions owned by the symphony. 19 | Removing a variation will cause the corresponding composition to be deleted. 20 | 21 | ## Bindings 22 | 23 | Compositions that are part of the same symphony can share common bindings. 24 | 25 | > Note: refs require matching bindings but bindings don't require matching refs. So a symphony can set all possible bindings and synthesizers can define a matching ref only if the input is needed. 26 | 27 | ```yaml 28 | apiVersion: eno.azure.io/v1 29 | kind: Symphony 30 | metadata: 31 | name: basic-symphony 32 | spec: 33 | bindings: 34 | - key: foo 35 | resource: 36 | name: test-input 37 | namespace: default 38 | variations: 39 | - synthesizer: 40 | name: synth-1 41 | - synthesizer: 42 | name: synth-2 43 | ``` 44 | 45 | ### Overrides 46 | 47 | Variations can override and append to the inherited bindings. 48 | 49 | If overrides are used for most synthesizers, that's a good sign that the symphony pattern doesn't fit your use-case. 50 | 51 | ```yaml 52 | apiVersion: eno.azure.io/v1 53 | kind: Symphony 54 | metadata: 55 | name: basic-symphony 56 | spec: 57 | bindings: 58 | - key: foo 59 | resource: 60 | name: test-input 61 | namespace: default 62 | 63 | variations: 64 | - synthesizer: 65 | name: synth-1 66 | # Override an existing binding 67 | bindings: 68 | - key: foo 69 | resource: 70 | name: a-different-input 71 | namespace: default 72 | 73 | - synthesizer: 74 | name: synth-2 75 | # Append a second binding 76 | bindings: 77 | - key: bar 78 | resource: 79 | name: a-different-input 80 | namespace: default 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/synthesis.md: -------------------------------------------------------------------------------- 1 | # Synthesis 2 | 3 | Eno uses short-lived synthesizer pods to synthesize compositions. 4 | This process and its results are often referred to as `synthesis` (not necessarily in a [Hegelian sense](https://en.wikipedia.org/wiki/Dialectic)). 5 | 6 | ## Dispatch 7 | 8 | Synthesis will be dispatched in these scenarios unless blocked by one of the conditions described later: 9 | 10 | - The composition has been modified 11 | - The composition's synthesizer has been modified 12 | - Any inputs of the composition have been modified 13 | 14 | ### Deferral 15 | 16 | Changes that may impact many compositions are designated as `deferred`. 17 | This includes synthesizer changes and changes to any inputs bound to refs that set `defer: true`. 18 | 19 | Deferred changes are subject to a global cooldown period to avoid suddenly changing hundreds/thousands/etc. of compositions. 20 | The cooldown period can be configured with `--rollout-cooldown`. 21 | 22 | Compositions can opt-out of any deferred syntheses. 23 | Only composition updates will cause synthesis when this annotation is set. 24 | 25 | ```yaml 26 | annotations: 27 | eno.azure.io/ignore-side-effects: "true" 28 | ``` 29 | 30 | ### Input Lockstep 31 | 32 | Synthesis can be blocked until relevant inputs have the same revision. 33 | This pattern is useful when inputs are coupled in such a way that the synthesizer may behave unexpectedly during state transitions. 34 | 35 | > Note: Inputs that do not set a revision "fail open" i.e. will not block synthesis. 36 | 37 | ```yaml 38 | annotations: 39 | eno.azure.io/revision: "123" 40 | ``` 41 | 42 | It's also possible to block synthesis until an input has "seen" the current synthesizer resource. 43 | This is useful in cases where another controller generates input resources based on some properties or annotations of the synthesizer. 44 | 45 | ```yaml 46 | annotations: 47 | eno.azure.io/synthesizer-generation: "123" # Will block synthesis if < the synthesizer's metadata.generation 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/synthesizer-api.md: -------------------------------------------------------------------------------- 1 | # Synthesizer API 2 | 3 | Synthesizers are container images that implement the [KRM Functions API](https://github.com/kubernetes-sigs/kustomize/blob/master/cmd/config/docs/api-conventions/functions-spec.md). 4 | 5 | 6 | ## SDK 7 | 8 | Eno provides a simple library for writing synthesizers in Go: [github.com/Azure/eno/pkg/function](https://pkg.go.dev/github.com/Azure/eno/pkg/function). 9 | 10 | 11 | ## IO 12 | 13 | Synthesizers communicate with Eno through stdin/stdout. 14 | 15 | ### Inputs 16 | 17 | Input resources are provided to the synthesizer through a json object streamed over stdin. 18 | 19 | Example: 20 | 21 | ```json 22 | { 23 | "apiVersion":"config.kubernetes.io/v1", 24 | "kind":"ResourceList", 25 | "items": [{ 26 | "apiVersion": "v1", 27 | "kind": "ConfigMap", 28 | "metadata": { 29 | "name": "my-app-config", 30 | "annotations": { 31 | "eno.azure.io/input-key": "value-from-synthesizer-ref" 32 | } 33 | } 34 | }] 35 | } 36 | ``` 37 | 38 | ### Outputs 39 | 40 | The results of a synthesizer run should be returned through stdout using the same schema as the inputs: 41 | 42 | ```json 43 | { 44 | "apiVersion":"config.kubernetes.io/v1", 45 | "kind":"ResourceList", 46 | "items": [{ 47 | "apiVersion": "apps/v1", 48 | "kind": "Deployment", 49 | // ... 50 | }] 51 | } 52 | ``` 53 | 54 | The first error result is visible when listing compositions. 55 | 56 | ```json 57 | { 58 | "apiVersion":"config.kubernetes.io/v1", 59 | "kind":"ResourceList", 60 | "results": [{ 61 | "severity": "error", 62 | "message": "The system is down, the system is down" 63 | }] 64 | } 65 | ``` 66 | 67 | For example: 68 | 69 | ```bash 70 | $ kubectl get compositions 71 | NAME SYNTHESIZER AGE STATUS ERROR 72 | example error-example 10s NotReady The system is down, the system is down 73 | ``` 74 | 75 | ### Logging 76 | 77 | The synthesizer process's `stderr` is piped to the synthesizer container it's running in so any typical log forwarding infra can be used. 78 | -------------------------------------------------------------------------------- /examples/01-minimal/example.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: eno.azure.io/v1 2 | kind: Synthesizer 3 | metadata: 4 | name: minimal-example 5 | spec: 6 | image: docker.io/ubuntu:latest 7 | command: 8 | - /bin/bash 9 | - -c 10 | - | 11 | echo ' 12 | { 13 | "apiVersion":"config.kubernetes.io/v1", 14 | "kind":"ResourceList", 15 | "items":[ 16 | { 17 | "apiVersion":"v1", 18 | "data":{"someKey":"someVal"}, 19 | "kind":"ConfigMap", 20 | "metadata":{ 21 | "name":"some-config", 22 | "namespace": "default" 23 | } 24 | } 25 | ] 26 | }' 27 | --- 28 | 29 | apiVersion: eno.azure.io/v1 30 | kind: Composition 31 | metadata: 32 | name: minimal-example 33 | namespace: default 34 | spec: 35 | synthesizer: 36 | name: minimal-example 37 | -------------------------------------------------------------------------------- /examples/02-go-synthesizer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/go:1.24 AS builder 2 | WORKDIR /app 3 | 4 | ADD go.mod . 5 | ADD go.sum . 6 | RUN --mount=type=cache,target=/root/.cache/go-build go mod download 7 | 8 | COPY . . 9 | RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -o go-synthesizer ./examples/02-go-synthesizer 10 | 11 | FROM scratch 12 | 13 | # https://github.com/GoogleContainerTools/distroless/blob/16dc4a6a33838006fe956e4c19f049ece9c18a8d/common/variables.bzl#L18 14 | USER 65532:65532 15 | 16 | COPY --from=builder /app/go-synthesizer /bin/synthesize 17 | -------------------------------------------------------------------------------- /examples/02-go-synthesizer/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -z "${REGISTRY}" ]]; then 6 | echo "REGISTRY must be set" > /dev/stderr 7 | exit 1 8 | fi 9 | 10 | TAG="$(date +%s)" 11 | export IMAGE="$REGISTRY/example-go-synthesizer:$TAG" 12 | 13 | docker build --quiet -t ${IMAGE} -f "examples/02-go-synthesizer/Dockerfile" . 14 | [[ -z "${SKIP_PUSH}" ]] && docker push ${IMAGE} 15 | 16 | kubectl apply -f - < /dev/stderr 7 | exit 1 8 | fi 9 | 10 | TAG="$(date +%s)" 11 | export IMAGE="$REGISTRY/example-helm-shim:$TAG" 12 | 13 | docker build --quiet -t ${IMAGE} -f "examples/03-helm-shim/Dockerfile" . 14 | [[ -z "${SKIP_PUSH}" ]] && docker push ${IMAGE} 15 | 16 | kubectl apply -f - < /dev/stderr 7 | exit 1 8 | fi 9 | 10 | TAG="$(date +%s)" 11 | export IMAGE="$REGISTRY/crd-synthesizer:$TAG" 12 | 13 | docker build --quiet -t ${IMAGE} -f "examples/05-crd/Dockerfile" . 14 | [[ -z "${SKIP_PUSH}" ]] && docker push ${IMAGE} 15 | 16 | kubectl apply -f - < github.com/imdario/mergo v0.3.16 87 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.24.0 2 | 3 | toolchain go1.24.3 4 | 5 | use ( 6 | . 7 | ./examples/03-helm-shim 8 | ./pkg/helmshim 9 | ) 10 | -------------------------------------------------------------------------------- /hack/build-k8s-matrix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | start_minor=10 6 | latest=$(curl -sL https://dl.k8s.io/release/stable.txt) # e.g. "v1.33.1" 7 | latest_minor=$(echo "$latest" | cut -d. -f2) 8 | seq $start_minor $latest_minor | jq --raw-input --slurp -c 'split("\n") | map(select(. != ""))' 9 | -------------------------------------------------------------------------------- /hack/download-k8s.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | etcd_release="v3.6.0" 6 | minor=$1 7 | if [ ! -z "$minor" ]; then 8 | version=$(curl -sL https://dl.k8s.io/release/stable-1.${minor}.txt) 9 | else 10 | version=$(curl -sL https://dl.k8s.io/release/stable.txt) 11 | fi 12 | 13 | function download_apiserver() { 14 | target=".k8s/${version}/kube-apiserver" 15 | if [ -f "$target" ]; then 16 | echo "kube-apiserver ${version} already exists, skipping download." > /dev/stderr 17 | return 18 | fi 19 | 20 | echo "downloading kube-apiserver ${version}..." > /dev/stderr 21 | curl -sL -o "$target" "https://dl.k8s.io/release/${version}/bin/linux/amd64/kube-apiserver" 22 | echo "finished downloading kube-apiserver ${version}..." > /dev/stderr 23 | chmod +x "$target" 24 | } 25 | 26 | function download_etcd() { 27 | if [ ! -f ".k8s/etcd-${etcd_release}-linux-amd64/etcd" ]; then 28 | echo "downloading etcd ${etcd_release}..." > /dev/stderr 29 | curl -sL "https://github.com/etcd-io/etcd/releases/download/${etcd_release}/etcd-${etcd_release}-linux-amd64.tar.gz" | tar -zx -C ".k8s" 30 | fi 31 | } 32 | 33 | dir=".k8s/${version}" 34 | mkdir -p "$dir" 35 | download_apiserver & 36 | download_etcd & 37 | wait 38 | cp ".k8s/etcd-${etcd_release}-linux-amd64/etcd" "$dir" 39 | echo "$(pwd)/$dir" 40 | -------------------------------------------------------------------------------- /hack/smoke-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Apply examples 6 | for file in ./examples/*/example.yaml; do 7 | kubectl apply -f $file 8 | done 9 | 10 | set +e 11 | 12 | # Tail the controller logs 13 | function watch_logs() { 14 | while true; do 15 | kubectl logs -f -l $1 16 | sleep 1 17 | done 18 | } 19 | watch_logs app=eno-controller & 20 | watch_logs app=eno-reconciler & 21 | 22 | # Wait for the composition to be reconciled 23 | while true; do 24 | output=$(kubectl get compositions --no-headers) 25 | echo $output 26 | 27 | if echo "$output" | grep -qv "Ready"; then 28 | sleep 1 29 | else 30 | break 31 | fi 32 | done 33 | 34 | set -e 35 | 36 | # Delete the example and wait for cleanup 37 | kubectl delete composition --all --wait=true --timeout=1m 38 | -------------------------------------------------------------------------------- /internal/controllers/liveness/namespace_test.go: -------------------------------------------------------------------------------- 1 | package liveness 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | apiv1 "github.com/Azure/eno/api/v1" 8 | "github.com/Azure/eno/internal/testutil" 9 | "github.com/stretchr/testify/require" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/api/errors" 12 | "k8s.io/apimachinery/pkg/runtime/schema" 13 | "k8s.io/apimachinery/pkg/runtime/serializer" 14 | "k8s.io/client-go/rest" 15 | "k8s.io/client-go/util/retry" 16 | "k8s.io/kubectl/pkg/scheme" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | ) 19 | 20 | func TestMissingNamespace(t *testing.T) { 21 | t.Run("symphony", func(t *testing.T) { 22 | sym := &apiv1.Symphony{} 23 | sym.Name = "test-symphony" 24 | sym.Finalizers = []string{"eno.azure.io/cleanup"} 25 | testMissingNamespace(t, sym) 26 | }) 27 | 28 | t.Run("composition", func(t *testing.T) { 29 | comp := &apiv1.Composition{} 30 | comp.Name = "test-composition" 31 | comp.Finalizers = []string{"eno.azure.io/cleanup"} 32 | testMissingNamespace(t, comp) 33 | }) 34 | 35 | t.Run("resourceSlice", func(t *testing.T) { 36 | rs := &apiv1.ResourceSlice{} 37 | rs.Name = "test-resource-slice" 38 | rs.Finalizers = []string{"eno.azure.io/cleanup"} 39 | testMissingNamespace(t, rs) 40 | }) 41 | } 42 | 43 | func testMissingNamespace(t *testing.T, orphan client.Object) { 44 | ns := &corev1.Namespace{} 45 | ns.Name = "test" 46 | 47 | ctx := testutil.NewContext(t) 48 | mgr := testutil.NewManager(t, testutil.WithCompositionNamespace(ns.Name)) 49 | cli := mgr.GetClient() 50 | 51 | require.NoError(t, NewNamespaceController(mgr.Manager, 2, time.Second)) 52 | mgr.Start(t) 53 | 54 | require.NoError(t, cli.Create(ctx, ns)) 55 | orphan.SetNamespace(ns.Name) 56 | require.NoError(t, cli.Create(ctx, orphan)) 57 | 58 | // Wait for the orphan resource to hit the cache, otherwise the namespace might be deleted first 59 | testutil.Eventually(t, func() bool { 60 | err := cli.Get(ctx, client.ObjectKeyFromObject(orphan), orphan) 61 | if err != nil { 62 | t.Logf("error while getting orphan resource: %s", err) 63 | return false 64 | } 65 | return true 66 | }) 67 | 68 | // Force delete the namespace 69 | require.NoError(t, cli.Delete(ctx, ns)) 70 | 71 | conf := rest.CopyConfig(mgr.RestConfig) 72 | conf.GroupVersion = &schema.GroupVersion{Version: "v1"} 73 | conf.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{CodecFactory: scheme.Codecs} 74 | rc, err := rest.RESTClientFor(conf) 75 | require.NoError(t, err) 76 | 77 | err = retry.RetryOnConflict(testutil.Backoff, func() error { 78 | cli.Get(ctx, client.ObjectKeyFromObject(ns), ns) 79 | ns.Spec.Finalizers = nil 80 | 81 | _, err = rc.Put(). 82 | AbsPath("/api/v1/namespaces", ns.Name, "/finalize"). 83 | Body(ns). 84 | Do(ctx).Raw() 85 | return err 86 | }) 87 | require.NoError(t, err) 88 | 89 | // The namespace should be completely gone 90 | testutil.Eventually(t, func() bool { 91 | return errors.IsNotFound(cli.Get(ctx, client.ObjectKeyFromObject(ns), ns)) 92 | }) 93 | 94 | // But we should still be able to eventually remove the orphan's finalizer 95 | testutil.Eventually(t, func() bool { 96 | orphan.SetFinalizers(nil) 97 | err = cli.Update(ctx, orphan) 98 | if err != nil { 99 | t.Logf("error while removing finalizer from orphan: %s", err) 100 | } 101 | 102 | missing := errors.IsNotFound(cli.Get(ctx, client.ObjectKeyFromObject(orphan), orphan)) 103 | if !missing { 104 | t.Logf("orphan'd resource still exists") 105 | } 106 | return missing 107 | }) 108 | 109 | // Namespace should end up being deleted 110 | testutil.Eventually(t, func() bool { 111 | cli.Get(ctx, client.ObjectKeyFromObject(ns), ns) 112 | return ns.DeletionTimestamp != nil 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/controller_test.go: -------------------------------------------------------------------------------- 1 | package reconciliation 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | apiv1 "github.com/Azure/eno/api/v1" 8 | "github.com/Azure/eno/internal/resource" 9 | "github.com/Azure/eno/internal/testutil" 10 | "github.com/go-logr/logr" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | corev1 "k8s.io/api/core/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | func TestRequeue(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | comp *apiv1.Composition 22 | resource *resource.Resource 23 | ready *metav1.Time 24 | minReconcile time.Duration 25 | expectedResult time.Duration 26 | }{ 27 | { 28 | name: "resource is not ready, requeue after readiness poll interval", 29 | comp: &apiv1.Composition{}, 30 | resource: &resource.Resource{ 31 | ReconcileInterval: nil, 32 | }, 33 | ready: nil, 34 | minReconcile: 10 * time.Second, 35 | expectedResult: 10 * time.Second, 36 | }, 37 | { 38 | name: "resource is deleted, no requeue", 39 | comp: &apiv1.Composition{}, 40 | resource: &resource.Resource{ 41 | ReconcileInterval: nil, 42 | }, 43 | ready: &metav1.Time{}, 44 | minReconcile: 10 * time.Second, 45 | expectedResult: 0, 46 | }, 47 | { 48 | name: "resource has reconcile interval less than minReconcileInterval", 49 | comp: &apiv1.Composition{}, 50 | resource: &resource.Resource{ 51 | ReconcileInterval: &metav1.Duration{Duration: 5 * time.Second}, 52 | }, 53 | ready: &metav1.Time{}, 54 | minReconcile: 10 * time.Second, 55 | expectedResult: 10 * time.Second, 56 | }, 57 | { 58 | name: "resource has valid reconcile interval", 59 | comp: &apiv1.Composition{}, 60 | resource: &resource.Resource{ 61 | ReconcileInterval: &metav1.Duration{Duration: 15 * time.Second}, 62 | }, 63 | ready: &metav1.Time{}, 64 | minReconcile: 10 * time.Second, 65 | expectedResult: 15 * time.Second, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | logger := logr.Discard() 72 | c := &Controller{ 73 | readinessPollInterval: 10 * time.Second, 74 | minReconcileInterval: tt.minReconcile, 75 | } 76 | 77 | result, err := c.requeue(logger, tt.comp, tt.resource, tt.ready) 78 | assert.NoError(t, err) 79 | assert.InDelta(t, tt.expectedResult, result.RequeueAfter, float64(2*time.Second)) 80 | }) 81 | } 82 | } 83 | 84 | func TestBuildNonStrategicPatch_NilPrevious(t *testing.T) { 85 | ctx := testutil.NewContext(t) 86 | cli := testutil.NewClient(t) 87 | 88 | // Create 89 | actual := &corev1.ConfigMap{} 90 | actual.Name = "test-configmap" 91 | actual.Namespace = "default" 92 | actual.Data = map[string]string{"original": "value"} 93 | require.NoError(t, cli.Create(ctx, actual)) 94 | 95 | // Patch 96 | expected := actual.DeepCopy() 97 | expected.Data = map[string]string{"added": "value"} 98 | patch := buildNonStrategicPatch(nil) 99 | require.NoError(t, cli.Patch(ctx, expected, patch)) 100 | 101 | // Verify 102 | require.NoError(t, cli.Get(ctx, client.ObjectKeyFromObject(actual), actual)) 103 | assert.Equal(t, map[string]string{ 104 | "added": "value", 105 | "original": "value", 106 | }, actual.Data) 107 | } 108 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/crd-runtimetest-extra-property.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: runtimetests.enotest.azure.io 5 | spec: 6 | group: enotest.azure.io 7 | names: 8 | kind: RuntimeTest 9 | listKind: RuntimeTestList 10 | plural: runtimetests 11 | singular: runtimetest 12 | scope: Namespaced 13 | versions: 14 | - name: v1 15 | schema: 16 | openAPIV3Schema: 17 | properties: 18 | apiVersion: 19 | description: |- 20 | APIVersion defines the versioned schema of this representation of an object. 21 | Servers should convert recognized schemas to the latest internal value, and 22 | may reject unrecognized values. 23 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 24 | type: string 25 | kind: 26 | description: |- 27 | Kind is a string value representing the REST resource this object represents. 28 | Servers may infer this from the endpoint the client submits requests to. 29 | Cannot be updated. 30 | In CamelCase. 31 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 32 | type: string 33 | metadata: 34 | type: object 35 | spec: 36 | properties: 37 | addedValue: 38 | type: integer 39 | values: 40 | items: 41 | properties: 42 | int: 43 | type: integer 44 | type: object 45 | type: array 46 | type: object 47 | status: 48 | type: object 49 | type: object 50 | served: true 51 | storage: true 52 | subresources: 53 | status: {} 54 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/crd-runtimetest.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: runtimetests.enotest.azure.io 5 | spec: 6 | group: enotest.azure.io 7 | names: 8 | kind: RuntimeTest 9 | listKind: RuntimeTestList 10 | plural: runtimetests 11 | singular: runtimetest 12 | scope: Namespaced 13 | versions: 14 | - name: v1 15 | schema: 16 | openAPIV3Schema: 17 | properties: 18 | apiVersion: 19 | description: |- 20 | APIVersion defines the versioned schema of this representation of an object. 21 | Servers should convert recognized schemas to the latest internal value, and 22 | may reject unrecognized values. 23 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 24 | type: string 25 | kind: 26 | description: |- 27 | Kind is a string value representing the REST resource this object represents. 28 | Servers may infer this from the endpoint the client submits requests to. 29 | Cannot be updated. 30 | In CamelCase. 31 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 32 | type: string 33 | metadata: 34 | type: object 35 | spec: 36 | properties: 37 | values: 38 | items: 39 | properties: 40 | int: 41 | type: integer 42 | type: object 43 | type: array 44 | type: object 45 | status: 46 | type: object 47 | type: object 48 | served: true 49 | storage: true 50 | subresources: 51 | status: {} 52 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/helmchart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: foo 3 | description: I can't believe it's not foo! 4 | 5 | type: application 6 | version: 0.1.0 7 | appVersion: "1.16.0" 8 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/helmchart/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: test-obj 5 | annotations: 6 | helm.sh/resource-policy: keep 7 | data: 8 | foo: bar 9 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/v1/config/crd/enotest.azure.io_testresources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: testresources.enotest.azure.io 8 | spec: 9 | group: enotest.azure.io 10 | names: 11 | kind: TestResource 12 | listKind: TestResourceList 13 | plural: testresources 14 | singular: testresource 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | properties: 21 | apiVersion: 22 | description: |- 23 | APIVersion defines the versioned schema of this representation of an object. 24 | Servers should convert recognized schemas to the latest internal value, and 25 | may reject unrecognized values. 26 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 27 | type: string 28 | kind: 29 | description: |- 30 | Kind is a string value representing the REST resource this object represents. 31 | Servers may infer this from the endpoint the client submits requests to. 32 | Cannot be updated. 33 | In CamelCase. 34 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 35 | type: string 36 | metadata: 37 | type: object 38 | spec: 39 | properties: 40 | values: 41 | items: 42 | properties: 43 | int: 44 | type: integer 45 | type: object 46 | type: array 47 | type: object 48 | status: 49 | type: object 50 | type: object 51 | served: true 52 | storage: true 53 | subresources: 54 | status: {} 55 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/v1/config/enotest.azure.io_testresources-old.yaml: -------------------------------------------------------------------------------- 1 | # This is a copy of the generated enotest.azure.io_testresources.yaml to provide backwards compatibility 2 | # with old versions of k8s that don't have apiextensions.k8s.io/v1. It needs to be updated manually when 3 | # regenerating its source. 4 | --- 5 | apiVersion: apiextensions.k8s.io/v1beta1 6 | kind: CustomResourceDefinition 7 | metadata: 8 | name: testresources.enotest.azure.io 9 | spec: 10 | group: enotest.azure.io 11 | version: v1 12 | names: 13 | kind: TestResource 14 | listKind: TestResourceList 15 | plural: testresources 16 | singular: testresource 17 | scope: Namespaced 18 | validation: 19 | openAPIV3Schema: 20 | properties: 21 | apiVersion: 22 | description: 'APIVersion defines the versioned schema of this representation 23 | of an object. Servers should convert recognized schemas to the latest 24 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 25 | type: string 26 | kind: 27 | description: 'Kind is a string value representing the REST resource this 28 | object represents. Servers may infer this from the endpoint the client 29 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 30 | type: string 31 | metadata: 32 | type: object 33 | spec: 34 | properties: 35 | values: 36 | items: 37 | properties: 38 | int: 39 | type: integer 40 | type: object 41 | type: array 42 | type: object 43 | status: 44 | type: object 45 | type: object 46 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/v1/types.go: -------------------------------------------------------------------------------- 1 | // +kubebuilder:object:generate=true 2 | // +groupName=enotest.azure.io 3 | package v1 4 | 5 | import ( 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "sigs.k8s.io/controller-runtime/pkg/scheme" 9 | ) 10 | 11 | // When re-generating also update any *-old.yaml files (see their comments for details) 12 | //go:generate controller-gen object crd rbac:roleName=resourceprovider paths=./... 13 | 14 | var ( 15 | SchemeGroupVersion = schema.GroupVersion{Group: "enotest.azure.io", Version: "v1"} 16 | SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} 17 | ) 18 | 19 | func init() { 20 | SchemeBuilder.Register(&TestResourceList{}, &TestResource{}) 21 | } 22 | 23 | // +kubebuilder:object:root=true 24 | type TestResourceList struct { 25 | metav1.TypeMeta `json:",inline"` 26 | metav1.ListMeta `json:"metadata,omitempty"` 27 | Items []TestResource `json:"items"` 28 | } 29 | 30 | // +kubebuilder:object:root=true 31 | // +kubebuilder:subresource:status 32 | type TestResource struct { 33 | metav1.TypeMeta `json:",inline"` 34 | metav1.ObjectMeta `json:"metadata,omitempty"` 35 | 36 | Spec TestResourceSpec `json:"spec,omitempty"` 37 | Status TestResourceStatus `json:"status,omitempty"` 38 | } 39 | 40 | type TestResourceSpec struct { 41 | Values []*TestValue `json:"values,omitempty"` 42 | } 43 | 44 | type TestValue struct { 45 | Int int `json:"int,omitempty"` 46 | } 47 | 48 | type TestResourceStatus struct { 49 | } 50 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/fixtures/v1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | // Code generated by controller-gen. DO NOT EDIT. 4 | 5 | package v1 6 | 7 | import ( 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | ) 10 | 11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 12 | func (in *TestResource) DeepCopyInto(out *TestResource) { 13 | *out = *in 14 | out.TypeMeta = in.TypeMeta 15 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 16 | in.Spec.DeepCopyInto(&out.Spec) 17 | out.Status = in.Status 18 | } 19 | 20 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResource. 21 | func (in *TestResource) DeepCopy() *TestResource { 22 | if in == nil { 23 | return nil 24 | } 25 | out := new(TestResource) 26 | in.DeepCopyInto(out) 27 | return out 28 | } 29 | 30 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 31 | func (in *TestResource) DeepCopyObject() runtime.Object { 32 | if c := in.DeepCopy(); c != nil { 33 | return c 34 | } 35 | return nil 36 | } 37 | 38 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 39 | func (in *TestResourceList) DeepCopyInto(out *TestResourceList) { 40 | *out = *in 41 | out.TypeMeta = in.TypeMeta 42 | in.ListMeta.DeepCopyInto(&out.ListMeta) 43 | if in.Items != nil { 44 | in, out := &in.Items, &out.Items 45 | *out = make([]TestResource, len(*in)) 46 | for i := range *in { 47 | (*in)[i].DeepCopyInto(&(*out)[i]) 48 | } 49 | } 50 | } 51 | 52 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceList. 53 | func (in *TestResourceList) DeepCopy() *TestResourceList { 54 | if in == nil { 55 | return nil 56 | } 57 | out := new(TestResourceList) 58 | in.DeepCopyInto(out) 59 | return out 60 | } 61 | 62 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 63 | func (in *TestResourceList) DeepCopyObject() runtime.Object { 64 | if c := in.DeepCopy(); c != nil { 65 | return c 66 | } 67 | return nil 68 | } 69 | 70 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 71 | func (in *TestResourceSpec) DeepCopyInto(out *TestResourceSpec) { 72 | *out = *in 73 | if in.Values != nil { 74 | in, out := &in.Values, &out.Values 75 | *out = make([]*TestValue, len(*in)) 76 | for i := range *in { 77 | if (*in)[i] != nil { 78 | in, out := &(*in)[i], &(*out)[i] 79 | *out = new(TestValue) 80 | **out = **in 81 | } 82 | } 83 | } 84 | } 85 | 86 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceSpec. 87 | func (in *TestResourceSpec) DeepCopy() *TestResourceSpec { 88 | if in == nil { 89 | return nil 90 | } 91 | out := new(TestResourceSpec) 92 | in.DeepCopyInto(out) 93 | return out 94 | } 95 | 96 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 97 | func (in *TestResourceStatus) DeepCopyInto(out *TestResourceStatus) { 98 | *out = *in 99 | } 100 | 101 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceStatus. 102 | func (in *TestResourceStatus) DeepCopy() *TestResourceStatus { 103 | if in == nil { 104 | return nil 105 | } 106 | out := new(TestResourceStatus) 107 | in.DeepCopyInto(out) 108 | return out 109 | } 110 | 111 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 112 | func (in *TestValue) DeepCopyInto(out *TestValue) { 113 | *out = *in 114 | } 115 | 116 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestValue. 117 | func (in *TestValue) DeepCopy() *TestValue { 118 | if in == nil { 119 | return nil 120 | } 121 | out := new(TestValue) 122 | in.DeepCopyInto(out) 123 | return out 124 | } 125 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/helpers_test.go: -------------------------------------------------------------------------------- 1 | package reconciliation 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | apiv1 "github.com/Azure/eno/api/v1" 9 | "github.com/Azure/eno/internal/controllers/composition" 10 | "github.com/Azure/eno/internal/controllers/liveness" 11 | "github.com/Azure/eno/internal/controllers/resourceslice" 12 | "github.com/Azure/eno/internal/controllers/scheduling" 13 | "github.com/Azure/eno/internal/controllers/symphony" 14 | "github.com/Azure/eno/internal/controllers/synthesis" 15 | "github.com/Azure/eno/internal/controllers/watch" 16 | "github.com/Azure/eno/internal/flowcontrol" 17 | "github.com/Azure/eno/internal/resource" 18 | "github.com/Azure/eno/internal/testutil" 19 | "github.com/stretchr/testify/require" 20 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 21 | "k8s.io/client-go/rest" 22 | "k8s.io/client-go/util/workqueue" 23 | "sigs.k8s.io/controller-runtime/pkg/client" 24 | ) 25 | 26 | func registerControllers(t *testing.T, mgr *testutil.Manager) { 27 | require.NoError(t, synthesis.NewPodLifecycleController(mgr.Manager, defaultConf)) 28 | require.NoError(t, synthesis.NewPodGC(mgr.Manager, time.Second)) 29 | require.NoError(t, scheduling.NewController(mgr.Manager, 10, time.Millisecond, time.Second)) 30 | require.NoError(t, liveness.NewNamespaceController(mgr.Manager, 3, time.Second)) 31 | require.NoError(t, watch.NewController(mgr.Manager)) 32 | require.NoError(t, resourceslice.NewController(mgr.Manager)) 33 | require.NoError(t, resourceslice.NewCleanupController(mgr.Manager)) 34 | require.NoError(t, composition.NewController(mgr.Manager)) 35 | require.NoError(t, symphony.NewController(mgr.Manager)) 36 | } 37 | 38 | func writeGenericComposition(t *testing.T, client client.Client) (*apiv1.Synthesizer, *apiv1.Composition) { 39 | return writeComposition(t, client, false) 40 | } 41 | 42 | func writeComposition(t *testing.T, client client.Client, orphan bool) (*apiv1.Synthesizer, *apiv1.Composition) { 43 | syn := &apiv1.Synthesizer{} 44 | syn.Name = "test-syn" 45 | syn.Spec.Image = "create" 46 | require.NoError(t, client.Create(context.Background(), syn)) 47 | 48 | comp := &apiv1.Composition{} 49 | comp.Name = "test-comp" 50 | comp.Namespace = "default" 51 | comp.Spec.Synthesizer.Name = syn.Name 52 | if orphan { 53 | comp.Annotations = map[string]string{"eno.azure.io/deletion-strategy": "orphan"} 54 | } 55 | require.NoError(t, client.Create(context.Background(), comp)) 56 | 57 | return syn, comp 58 | } 59 | 60 | func setupTestSubject(t *testing.T, mgr *testutil.Manager) { 61 | setupTestSubjectForOptions(t, mgr, Options{ 62 | Manager: mgr.Manager, 63 | Timeout: time.Minute, 64 | ReadinessPollInterval: time.Hour, 65 | DisableServerSideApply: mgr.NoSsaSupport, 66 | }) 67 | } 68 | 69 | func setupTestSubjectForOptions(t *testing.T, mgr *testutil.Manager, opts Options) { 70 | opts.WriteBuffer = flowcontrol.NewResourceSliceWriteBufferForManager(mgr.Manager) 71 | 72 | var cache resource.Cache 73 | rateLimiter := workqueue.DefaultTypedItemBasedRateLimiter[resource.Request]() 74 | queue := workqueue.NewTypedRateLimitingQueue(rateLimiter) 75 | cache.SetQueue(queue) 76 | 77 | opts.Downstream = rest.CopyConfig(mgr.DownstreamRestConfig) 78 | opts.Downstream.QPS = 200 // minimal throttling for the tests 79 | 80 | err := New(mgr.Manager, opts) 81 | require.NoError(t, err) 82 | } 83 | 84 | func mapToResource(t *testing.T, res map[string]any) (*unstructured.Unstructured, *resource.Resource) { 85 | obj := &unstructured.Unstructured{Object: res} 86 | js, err := obj.MarshalJSON() 87 | require.NoError(t, err) 88 | 89 | slice := &apiv1.ResourceSlice{} 90 | slice.Spec.Resources = []apiv1.Manifest{{Manifest: string(js)}} 91 | rr, err := resource.NewResource(context.Background(), slice, 0) 92 | require.NoError(t, err) 93 | 94 | return obj, rr 95 | } 96 | 97 | func requireSSA(t *testing.T, mgr *testutil.Manager) { 98 | if mgr.DownstreamVersion > 0 && mgr.DownstreamVersion < 16 { 99 | t.Skipf("skipping test because it requires server-side apply which isn't supported on k8s 1.%d", mgr.DownstreamVersion) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/controllers/reconciliation/metrics.go: -------------------------------------------------------------------------------- 1 | package reconciliation 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "sigs.k8s.io/controller-runtime/pkg/metrics" 6 | ) 7 | 8 | var ( 9 | reconciliationLatency = prometheus.NewHistogram( 10 | prometheus.HistogramOpts{ 11 | Name: "eno_reconciliation_duration_seconds", 12 | Help: "Samples latency of the entire reconciliation process", 13 | Buckets: []float64{0.1, 0.5, 0.75, 1.0, 3.0, 6.0, 11.0, 20.0, 30.0, 40.0}, 14 | }, 15 | ) 16 | 17 | reconciliationActions = prometheus.NewCounterVec( 18 | prometheus.CounterOpts{ 19 | Name: "eno_reconciliation_actions_total", 20 | Help: "Attempts to reconcile managed resources into the desired state, partitioned by action i.e. create, patch, delete", 21 | }, []string{"action"}, 22 | ) 23 | ) 24 | 25 | func init() { 26 | metrics.Registry.MustRegister(reconciliationLatency, reconciliationActions) 27 | } 28 | -------------------------------------------------------------------------------- /internal/controllers/resourceslice/integration_test.go: -------------------------------------------------------------------------------- 1 | package resourceslice 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | apiv1 "github.com/Azure/eno/api/v1" 8 | "github.com/Azure/eno/internal/testutil" 9 | "github.com/stretchr/testify/require" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/client-go/util/retry" 13 | "k8s.io/utils/ptr" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 16 | ) 17 | 18 | func TestResourceSliceLifecycle(t *testing.T) { 19 | ctx := testutil.NewContext(t) 20 | mgr := testutil.NewManager(t) 21 | cli := mgr.GetClient() 22 | 23 | require.NoError(t, NewCleanupController(mgr.Manager)) 24 | require.NoError(t, NewController(mgr.Manager)) 25 | mgr.Start(t) 26 | 27 | comp := &apiv1.Composition{} 28 | comp.Name = "test-1" 29 | comp.Namespace = "default" 30 | require.NoError(t, cli.Create(ctx, comp)) 31 | 32 | testutil.Eventually(t, func() bool { 33 | return cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) == nil // wait for the informer 34 | }) 35 | 36 | retry.RetryOnConflict(retry.DefaultBackoff, func() error { 37 | cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) 38 | comp.Status.InFlightSynthesis = &apiv1.Synthesis{UUID: "test-uuid"} 39 | return cli.Status().Update(ctx, comp) 40 | }) 41 | 42 | slice := &apiv1.ResourceSlice{} 43 | slice.Name = "test-1" 44 | slice.Namespace = comp.Namespace 45 | slice.Spec.SynthesisUUID = "test-uuid" 46 | slice.Spec.Resources = []apiv1.Manifest{{Manifest: `{}`}} 47 | require.NoError(t, controllerutil.SetControllerReference(comp, slice, cli.Scheme())) 48 | require.NoError(t, cli.Create(ctx, slice)) 49 | 50 | err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { 51 | cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) 52 | comp.Status.InFlightSynthesis = nil 53 | comp.Status.CurrentSynthesis = &apiv1.Synthesis{ 54 | UUID: "test-uuid", 55 | Synthesized: ptr.To(metav1.Now()), 56 | ResourceSlices: []*apiv1.ResourceSliceRef{{Name: slice.Name}}, 57 | } 58 | return cli.Status().Update(ctx, comp) 59 | }) 60 | require.NoError(t, err) 61 | 62 | testutil.Eventually(t, func() bool { 63 | return cli.Get(ctx, client.ObjectKeyFromObject(slice), slice) == nil // wait for the informer 64 | }) 65 | 66 | // The status of the resources in the slice should be aggregated into the composition 67 | err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { 68 | cli.Get(ctx, client.ObjectKeyFromObject(slice), slice) 69 | slice.Status.Resources = []apiv1.ResourceState{ 70 | {Reconciled: true, Ready: ptr.To(metav1.NewTime(time.Now().Add(time.Minute)))}, 71 | } 72 | return cli.Status().Update(ctx, slice) 73 | }) 74 | require.NoError(t, err) 75 | 76 | testutil.Eventually(t, func() bool { 77 | cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) 78 | return comp.Status.CurrentSynthesis != nil && 79 | comp.Status.CurrentSynthesis.Reconciled != nil && 80 | comp.Status.CurrentSynthesis.Ready != nil 81 | }) 82 | 83 | // Unused slices are deleted and missing slices cause resynthesis 84 | err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { 85 | cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) 86 | comp.Status.CurrentSynthesis = &apiv1.Synthesis{ 87 | UUID: "test-uuid", 88 | Synthesized: ptr.To(metav1.Now()), 89 | ResourceSlices: []*apiv1.ResourceSliceRef{{Name: "another-slice"}}, 90 | } 91 | return cli.Status().Update(ctx, comp) 92 | }) 93 | require.NoError(t, err) 94 | 95 | testutil.Eventually(t, func() bool { 96 | return errors.IsNotFound(cli.Get(ctx, client.ObjectKeyFromObject(slice), slice)) 97 | }) 98 | testutil.Eventually(t, func() bool { 99 | cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) 100 | return comp.ShouldForceResynthesis() 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /internal/controllers/scheduling/metrics.go: -------------------------------------------------------------------------------- 1 | package scheduling 2 | 3 | import ( 4 | "time" 5 | 6 | apiv1 "github.com/Azure/eno/api/v1" 7 | "github.com/prometheus/client_golang/prometheus" 8 | "sigs.k8s.io/controller-runtime/pkg/metrics" 9 | ) 10 | 11 | var ( 12 | freeSynthesisSlots = prometheus.NewGauge( 13 | prometheus.GaugeOpts{ 14 | Name: "eno_free_synthesis_slots", 15 | Help: "Count of how many syntheses could be dispatched concurrently", 16 | }, 17 | ) 18 | 19 | schedulingLatency = prometheus.NewHistogram( 20 | prometheus.HistogramOpts{ 21 | Name: "eno_scheduling_latency_seconds", 22 | Help: "Latency of scheduling operations", 23 | Buckets: []float64{0.1, 0.25, 0.5, 1, 5}, 24 | }, 25 | ) 26 | 27 | stuckReconciling = prometheus.NewGaugeVec( 28 | prometheus.GaugeOpts{ 29 | Name: "eno_compositions_stuck_reconciling_total", 30 | Help: "Number of compositions that have not been reconciled since a period after their current synthesis was initialized", 31 | }, []string{"synthesizer"}, 32 | ) 33 | ) 34 | 35 | func init() { 36 | metrics.Registry.MustRegister(freeSynthesisSlots, schedulingLatency, stuckReconciling) 37 | } 38 | 39 | func missedReconciliation(comp *apiv1.Composition, threshold time.Duration) bool { 40 | syn := comp.Status.CurrentSynthesis 41 | return comp.DeletionTimestamp == nil && syn != nil && syn.Reconciled == nil && syn.Initialized != nil && time.Since(syn.Initialized.Time) > threshold 42 | } 43 | -------------------------------------------------------------------------------- /internal/controllers/scheduling/metrics_test.go: -------------------------------------------------------------------------------- 1 | package scheduling 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | apiv1 "github.com/Azure/eno/api/v1" 8 | "github.com/stretchr/testify/assert" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | func TestMissedReconciliation(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | comp *apiv1.Composition 16 | expected bool 17 | }{ 18 | { 19 | name: "No Current Synthesis", 20 | comp: &apiv1.Composition{ 21 | Status: apiv1.CompositionStatus{ 22 | CurrentSynthesis: nil, 23 | }, 24 | }, 25 | expected: false, 26 | }, 27 | { 28 | name: "Synthesis Reconciled", 29 | comp: &apiv1.Composition{ 30 | Status: apiv1.CompositionStatus{ 31 | CurrentSynthesis: &apiv1.Synthesis{ 32 | Reconciled: &metav1.Time{Time: time.Now()}, 33 | }, 34 | }, 35 | }, 36 | expected: false, 37 | }, 38 | { 39 | name: "Synthesis Not Initialized", 40 | comp: &apiv1.Composition{ 41 | Status: apiv1.CompositionStatus{ 42 | CurrentSynthesis: &apiv1.Synthesis{ 43 | Initialized: nil, 44 | }, 45 | }, 46 | }, 47 | expected: false, 48 | }, 49 | { 50 | name: "Synthesis Missed Reconciliation", 51 | comp: &apiv1.Composition{ 52 | Status: apiv1.CompositionStatus{ 53 | CurrentSynthesis: &apiv1.Synthesis{ 54 | Initialized: &metav1.Time{Time: time.Now().Add(-2 * time.Hour)}, 55 | }, 56 | }, 57 | }, 58 | expected: true, 59 | }, 60 | { 61 | name: "Synthesis Within Threshold", 62 | comp: &apiv1.Composition{ 63 | Status: apiv1.CompositionStatus{ 64 | CurrentSynthesis: &apiv1.Synthesis{ 65 | Initialized: &metav1.Time{Time: time.Now().Add(-30 * time.Minute)}, 66 | }, 67 | }, 68 | }, 69 | expected: false, 70 | }, 71 | { 72 | name: "Composition Being Deleted", 73 | comp: &apiv1.Composition{ 74 | ObjectMeta: metav1.ObjectMeta{ 75 | DeletionTimestamp: &metav1.Time{Time: time.Now()}, 76 | }, 77 | Status: apiv1.CompositionStatus{ 78 | CurrentSynthesis: &apiv1.Synthesis{ 79 | Initialized: &metav1.Time{Time: time.Now().Add(-2 * time.Hour)}, 80 | }, 81 | }, 82 | }, 83 | expected: false, 84 | }, 85 | } 86 | 87 | for _, tt := range tests { 88 | t.Run(tt.name, func(t *testing.T) { 89 | result := missedReconciliation(tt.comp, time.Hour) 90 | assert.Equal(t, tt.expected, result) 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/controllers/synthesis/gc_test.go: -------------------------------------------------------------------------------- 1 | package synthesis 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | apiv1 "github.com/Azure/eno/api/v1" 8 | "github.com/Azure/eno/internal/testutil" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | corev1 "k8s.io/api/core/v1" 12 | "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/client-go/util/retry" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | func TestPodGCMissingSynthesis(t *testing.T) { 19 | ctx := testutil.NewContext(t) 20 | mgr := testutil.NewManager(t) 21 | cli := mgr.GetClient() 22 | 23 | require.NoError(t, NewPodGC(mgr.Manager, 0)) 24 | mgr.Start(t) 25 | 26 | synth := &apiv1.Synthesizer{} 27 | synth.Name = "test-syn" 28 | synth.Spec.Image = "test-syn-image" 29 | require.NoError(t, cli.Create(ctx, synth)) 30 | 31 | comp := &apiv1.Composition{} 32 | comp.Name = "test-comp" 33 | comp.Namespace = "default" 34 | comp.Spec.Synthesizer.Name = synth.Name 35 | require.NoError(t, cli.Create(ctx, comp)) 36 | 37 | comp.Status.InFlightSynthesis = &apiv1.Synthesis{UUID: "anything"} 38 | pod := newPod(minimalTestConfig, comp, synth) 39 | require.NoError(t, cli.Create(ctx, pod)) 40 | 41 | testutil.Eventually(t, func() bool { 42 | return errors.IsNotFound(mgr.GetAPIReader().Get(ctx, client.ObjectKeyFromObject(pod), pod)) 43 | }) 44 | } 45 | 46 | func TestPodGCContainerCreationTimeout(t *testing.T) { 47 | ctx := testutil.NewContext(t) 48 | mgr := testutil.NewManager(t) 49 | cli := mgr.GetClient() 50 | 51 | require.NoError(t, NewPodGC(mgr.Manager, time.Millisecond*10)) 52 | mgr.Start(t) 53 | 54 | synth := &apiv1.Synthesizer{} 55 | synth.Name = "test-syn" 56 | synth.Spec.Image = "test-syn-image" 57 | synth.Spec.PodTimeout = &metav1.Duration{Duration: time.Hour} 58 | require.NoError(t, cli.Create(ctx, synth)) 59 | 60 | comp := &apiv1.Composition{} 61 | comp.Name = "test-comp" 62 | comp.Namespace = "default" 63 | comp.Spec.Synthesizer.Name = synth.Name 64 | require.NoError(t, cli.Create(ctx, comp)) 65 | 66 | err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { 67 | cli.Get(ctx, client.ObjectKeyFromObject(comp), comp) 68 | comp.Status.InFlightSynthesis = &apiv1.Synthesis{UUID: "anything"} 69 | return cli.Status().Update(ctx, comp) 70 | }) 71 | require.NoError(t, err) 72 | 73 | pod := newPod(minimalTestConfig, comp, synth) 74 | require.NoError(t, cli.Create(ctx, pod)) 75 | 76 | err = retry.RetryOnConflict(retry.DefaultBackoff, func() error { 77 | cli.Get(ctx, client.ObjectKeyFromObject(pod), pod) 78 | pod.Status.Conditions = []corev1.PodCondition{{Type: corev1.PodScheduled, Status: corev1.ConditionTrue}} 79 | return cli.Status().Update(ctx, pod) 80 | }) 81 | require.NoError(t, err) 82 | 83 | testutil.Eventually(t, func() bool { 84 | return errors.IsNotFound(mgr.GetAPIReader().Get(ctx, client.ObjectKeyFromObject(pod), pod)) 85 | }) 86 | } 87 | 88 | func TestTimeWaitingForKubelet(t *testing.T) { 89 | now := time.Now() 90 | tests := []struct { 91 | Name string 92 | Pod *corev1.Pod 93 | Now time.Time 94 | Expected time.Duration 95 | }{ 96 | { 97 | Name: "Pod with container statuses", 98 | Pod: &corev1.Pod{ 99 | Status: corev1.PodStatus{ 100 | ContainerStatuses: []corev1.ContainerStatus{ 101 | {}, 102 | }, 103 | }, 104 | }, 105 | Now: now, 106 | Expected: 0, 107 | }, 108 | { 109 | Name: "Pod not scheduled", 110 | Pod: &corev1.Pod{ 111 | Status: corev1.PodStatus{ 112 | Conditions: []corev1.PodCondition{ 113 | { 114 | Type: corev1.PodScheduled, 115 | Status: corev1.ConditionFalse, 116 | }, 117 | }, 118 | }, 119 | }, 120 | Now: now, 121 | Expected: 0, 122 | }, 123 | { 124 | Name: "Pod scheduled", 125 | Pod: &corev1.Pod{ 126 | Status: corev1.PodStatus{ 127 | Conditions: []corev1.PodCondition{ 128 | { 129 | Type: corev1.PodScheduled, 130 | Status: corev1.ConditionTrue, 131 | LastTransitionTime: metav1.Time{Time: now.Add(-5 * time.Minute)}, 132 | }, 133 | }, 134 | }, 135 | }, 136 | Now: now, 137 | Expected: 5 * time.Minute, 138 | }, 139 | { 140 | Name: "Pod with no conditions", 141 | Pod: &corev1.Pod{ 142 | Status: corev1.PodStatus{ 143 | Conditions: []corev1.PodCondition{}, 144 | }, 145 | }, 146 | Now: now, 147 | Expected: 0, 148 | }, 149 | } 150 | 151 | for _, tt := range tests { 152 | t.Run(tt.Name, func(t *testing.T) { 153 | result := timeWaitingForKubelet(tt.Pod, tt.Now) 154 | assert.Equal(t, tt.Expected, result) 155 | }) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /internal/controllers/synthesis/metrics.go: -------------------------------------------------------------------------------- 1 | package synthesis 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "sigs.k8s.io/controller-runtime/pkg/metrics" 6 | ) 7 | 8 | var ( 9 | sytheses = prometheus.NewCounter( 10 | prometheus.CounterOpts{ 11 | Name: "eno_syntheses_total", 12 | Help: "Initiated synthesis operations", 13 | }, 14 | ) 15 | ) 16 | 17 | func init() { 18 | metrics.Registry.MustRegister(sytheses) 19 | } 20 | -------------------------------------------------------------------------------- /internal/controllers/watch/pruning.go: -------------------------------------------------------------------------------- 1 | package watch 2 | 3 | import ( 4 | "context" 5 | 6 | apiv1 "github.com/Azure/eno/api/v1" 7 | "github.com/go-logr/logr" 8 | ctrl "sigs.k8s.io/controller-runtime" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | ) 11 | 12 | type pruningController struct { 13 | client client.Client 14 | } 15 | 16 | func (c *pruningController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 17 | logger := logr.FromContextOrDiscard(ctx) 18 | 19 | comp := &apiv1.Composition{} 20 | err := c.client.Get(ctx, req.NamespacedName, comp) 21 | if err != nil { 22 | logger.Error(err, "failed to get composition") 23 | return ctrl.Result{}, client.IgnoreNotFound(err) 24 | } 25 | 26 | synth := &apiv1.Synthesizer{} 27 | synth.Name = comp.Spec.Synthesizer.Name 28 | err = c.client.Get(ctx, client.ObjectKeyFromObject(synth), synth) 29 | if client.IgnoreNotFound(err) != nil { 30 | logger.Error(err, "failed to get synthesizer") 31 | return ctrl.Result{}, err 32 | } 33 | 34 | for i, ir := range comp.Status.InputRevisions { 35 | if hasBindingKey(comp, synth, ir.Key) { 36 | continue 37 | } 38 | comp.Status.InputRevisions = append(comp.Status.InputRevisions[:i], comp.Status.InputRevisions[i+1:]...) 39 | err = c.client.Status().Update(ctx, comp) 40 | if err != nil { 41 | logger.Error(err, "failed to update composition status") 42 | return ctrl.Result{}, err 43 | } 44 | 45 | logger.V(1).Info("pruned old input revision from composition status", "compositionName", comp.Name, "compositionNamespace", comp.Namespace, "ref", ir.Key) 46 | return ctrl.Result{}, nil 47 | } 48 | 49 | return ctrl.Result{}, nil 50 | } 51 | 52 | func hasBindingKey(comp *apiv1.Composition, synth *apiv1.Synthesizer, key string) bool { 53 | for _, b := range comp.Spec.Bindings { 54 | if b.Key == key { 55 | return true 56 | } 57 | } 58 | for _, ref := range synth.Spec.Refs { 59 | if ref.Key == key && ref.Resource.Name != "" { 60 | return true // implicit binding 61 | } 62 | } 63 | return false 64 | } 65 | -------------------------------------------------------------------------------- /internal/controllers/watch/watch.go: -------------------------------------------------------------------------------- 1 | package watch 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | 7 | "github.com/go-logr/logr" 8 | ctrl "sigs.k8s.io/controller-runtime" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | 11 | apiv1 "github.com/Azure/eno/api/v1" 12 | "github.com/Azure/eno/internal/manager" 13 | ) 14 | 15 | type WatchController struct { 16 | mgr ctrl.Manager 17 | client client.Client 18 | refControllers map[apiv1.ResourceRef]*KindWatchController 19 | } 20 | 21 | func NewController(mgr ctrl.Manager) error { 22 | err := ctrl.NewControllerManagedBy(mgr). 23 | Named("watchControllerController"). 24 | Watches(&apiv1.Synthesizer{}, manager.SingleEventHandler()). 25 | WithLogConstructor(manager.NewLogConstructor(mgr, "watchController")). 26 | Complete(&WatchController{ 27 | mgr: mgr, 28 | client: mgr.GetClient(), 29 | refControllers: map[apiv1.ResourceRef]*KindWatchController{}, 30 | }) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return ctrl.NewControllerManagedBy(mgr). 36 | For(&apiv1.Composition{}). 37 | WithLogConstructor(manager.NewLogConstructor(mgr, "watchPruningController")). 38 | Complete(&pruningController{ 39 | client: mgr.GetClient(), 40 | }) 41 | } 42 | 43 | func (c *WatchController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 44 | logger := logr.FromContextOrDiscard(ctx) 45 | 46 | synths := &apiv1.SynthesizerList{} 47 | err := c.client.List(ctx, synths) 48 | if err != nil { 49 | logger.Error(err, "failed to list synthesizers") 50 | return ctrl.Result{}, err 51 | } 52 | 53 | // It's important to randomize the order over which we iterate the synths, 54 | // otherwise one bad resource reference can block the loop 55 | rand.Shuffle(len(synths.Items), func(i, j int) { synths.Items[i], synths.Items[j] = synths.Items[j], synths.Items[i] }) 56 | 57 | // Start any missing controllers 58 | synthsByRef := map[apiv1.ResourceRef]struct{}{} 59 | for _, syn := range synths.Items { 60 | if syn.DeletionTimestamp != nil { 61 | continue 62 | } 63 | for _, ref := range syn.Spec.Refs { 64 | ref := ref 65 | synthsByRef[ref.Resource] = struct{}{} 66 | 67 | current := c.refControllers[ref.Resource] 68 | if current != nil { 69 | continue // already running 70 | } 71 | 72 | rc, err := NewKindWatchController(ctx, c, &ref.Resource) 73 | if err != nil { 74 | logger.Error(err, "failed to create kind watch controller", "resource", ref.Resource) 75 | return ctrl.Result{}, err 76 | } 77 | c.refControllers[ref.Resource] = rc 78 | return ctrl.Result{Requeue: true}, nil 79 | } 80 | } 81 | 82 | // Stop controllers that are no longer needed 83 | for ref, rc := range c.refControllers { 84 | if _, ok := synthsByRef[ref]; ok { 85 | continue 86 | } 87 | 88 | rc.Stop(ctx) 89 | delete(c.refControllers, ref) 90 | logger.Error(nil, "stopped and removed controller for resource", "resource", ref) 91 | return ctrl.Result{Requeue: true}, nil 92 | } 93 | 94 | return ctrl.Result{}, nil 95 | } 96 | -------------------------------------------------------------------------------- /internal/execution/handler.go: -------------------------------------------------------------------------------- 1 | package execution 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "os" 8 | "os/exec" 9 | 10 | apiv1 "github.com/Azure/eno/api/v1" 11 | krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" 12 | ) 13 | 14 | type Env struct { 15 | CompositionName string 16 | CompositionNamespace string 17 | SynthesisUUID string 18 | Image string 19 | } 20 | 21 | func LoadEnv() *Env { 22 | return &Env{ 23 | CompositionName: os.Getenv("COMPOSITION_NAME"), 24 | CompositionNamespace: os.Getenv("COMPOSITION_NAMESPACE"), 25 | SynthesisUUID: os.Getenv("SYNTHESIS_UUID"), 26 | Image: os.Getenv("IMAGE"), 27 | } 28 | } 29 | 30 | type SynthesizerHandle func(context.Context, *apiv1.Synthesizer, *krmv1.ResourceList) (*krmv1.ResourceList, error) 31 | 32 | func NewExecHandler() SynthesizerHandle { 33 | return func(ctx context.Context, s *apiv1.Synthesizer, rl *krmv1.ResourceList) (*krmv1.ResourceList, error) { 34 | stdin := &bytes.Buffer{} 35 | stdout := &bytes.Buffer{} 36 | 37 | err := json.NewEncoder(stdin).Encode(rl) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | command := s.Spec.Command 43 | if len(command) == 0 { 44 | command = []string{"synthesize"} 45 | } 46 | 47 | if s.Spec.ExecTimeout != nil { 48 | var cancel context.CancelFunc 49 | ctx, cancel = context.WithTimeout(ctx, s.Spec.ExecTimeout.Duration) 50 | defer cancel() 51 | } 52 | 53 | cmd := exec.CommandContext(ctx, command[0], command[1:]...) 54 | cmd.Stdin = stdin 55 | cmd.Stderr = os.Stdout // logger uses stderr, so use stdout to avoid race condition 56 | cmd.Stdout = stdout 57 | err = cmd.Run() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | output := &krmv1.ResourceList{} 63 | err = json.NewDecoder(stdout).Decode(output) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return output, nil 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/execution/handler_test.go: -------------------------------------------------------------------------------- 1 | package execution 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | apiv1 "github.com/Azure/eno/api/v1" 9 | krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" 10 | "github.com/stretchr/testify/require" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | ) 14 | 15 | func TestExecHandler(t *testing.T) { 16 | handle := NewExecHandler() 17 | 18 | syn := &apiv1.Synthesizer{} 19 | syn.Spec.Command = []string{"/bin/sh", "-c", "cat /dev/stdin > /dev/stdout"} 20 | rl := &krmv1.ResourceList{Items: []*unstructured.Unstructured{{ 21 | Object: map[string]any{ 22 | "apiVersion": "v1", 23 | "kind": "ConfigMap", 24 | "metadata": map[string]string{ 25 | "name": "test", 26 | "namespace": "default", 27 | }, 28 | "data": map[string]string{"foo": "bar"}, 29 | }, 30 | }}} 31 | 32 | out, err := handle(context.Background(), syn, rl) 33 | require.NoError(t, err) 34 | require.Len(t, out.Items, 1) 35 | } 36 | 37 | func TestExecHandlerTimeout(t *testing.T) { 38 | handle := NewExecHandler() 39 | 40 | syn := &apiv1.Synthesizer{} 41 | syn.Spec.Command = []string{"/bin/sh", "-c", "sleep 1"} 42 | syn.Spec.ExecTimeout = &metav1.Duration{Duration: time.Millisecond} 43 | rl := &krmv1.ResourceList{} 44 | 45 | _, err := handle(context.Background(), syn, rl) 46 | require.EqualError(t, err, "signal: killed") 47 | } 48 | 49 | func TestExecHandlerEmpty(t *testing.T) { 50 | handle := NewExecHandler() 51 | 52 | syn := &apiv1.Synthesizer{} 53 | rl := &krmv1.ResourceList{} 54 | 55 | _, err := handle(context.Background(), syn, rl) 56 | require.EqualError(t, err, "exec: \"synthesize\": executable file not found in $PATH") 57 | } 58 | -------------------------------------------------------------------------------- /internal/flowcontrol/metrics.go: -------------------------------------------------------------------------------- 1 | package flowcontrol 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "sigs.k8s.io/controller-runtime/pkg/metrics" 6 | ) 7 | 8 | var ( 9 | sliceStatusUpdates = prometheus.NewCounter( 10 | prometheus.CounterOpts{ 11 | Name: "eno_resource_slice_status_update_total", 12 | Help: "Count of batch updates to resource slice status", 13 | }, 14 | ) 15 | ) 16 | 17 | func init() { 18 | metrics.Registry.MustRegister(sliceStatusUpdates) 19 | } 20 | -------------------------------------------------------------------------------- /internal/inputs/inputs.go: -------------------------------------------------------------------------------- 1 | package inputs 2 | 3 | import ( 4 | "slices" 5 | 6 | apiv1 "github.com/Azure/eno/api/v1" 7 | ) 8 | 9 | // Exist returns true when all of the inputs required by a synthesizer are represented by the given composition's status. 10 | func Exist(syn *apiv1.Synthesizer, c *apiv1.Composition) bool { 11 | for _, ref := range syn.Spec.Refs { 12 | found := slices.ContainsFunc(c.Status.InputRevisions, func(current apiv1.InputRevisions) bool { 13 | return ref.Key == current.Key 14 | }) 15 | if !found { 16 | return false 17 | } 18 | } 19 | return true 20 | } 21 | 22 | // OutOfLockstep returns true when one or more inputs that specify a revision do not match the others. 23 | // It also returns true if any revision is derived from a synthesizer generation older than the provided synthesizer. 24 | func OutOfLockstep(synth *apiv1.Synthesizer, revs []apiv1.InputRevisions) bool { 25 | // First, the the max revision across all bindings 26 | var maxRevision *int 27 | for _, rev := range revs { 28 | if rev.SynthesizerGeneration != nil && *rev.SynthesizerGeneration < synth.Generation { 29 | return true 30 | } 31 | if rev.Revision == nil { 32 | continue 33 | } 34 | if maxRevision == nil { 35 | maxRevision = rev.Revision 36 | continue 37 | } 38 | if *rev.Revision > *maxRevision { 39 | maxRevision = rev.Revision 40 | } 41 | } 42 | if maxRevision == nil { 43 | return false // no inputs declare a revision, so we should assume they're in sync 44 | } 45 | 46 | // Now given the max, make sure all inputs with a revision match it 47 | for _, rev := range revs { 48 | if rev.Revision != nil && *maxRevision != *rev.Revision { 49 | return true 50 | } 51 | } 52 | return false 53 | } 54 | -------------------------------------------------------------------------------- /internal/k8s/kubeconfig.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "k8s.io/client-go/rest" 8 | "k8s.io/client-go/tools/clientcmd" 9 | ) 10 | 11 | // GetRESTConfig is a convenience method to avoid manually opening a file. 12 | func GetRESTConfig(filename string) (*rest.Config, error) { 13 | b, err := os.ReadFile(filename) 14 | if err != nil { 15 | return nil, fmt.Errorf("could not get read Kubeconfig file: %w", err) 16 | } 17 | cfg, err := clientcmd.RESTConfigFromKubeConfig(b) 18 | if err != nil { 19 | return nil, fmt.Errorf("could not get get Kubeconfig from file: %w", err) 20 | } 21 | return cfg, nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/manager/indices.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "path" 5 | 6 | apiv1 "github.com/Azure/eno/api/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | ) 10 | 11 | const ( 12 | IdxCompositionsBySynthesizer = ".spec.synthesizer" 13 | IdxCompositionsBySymphony = ".compositionsBySymphony" 14 | IdxCompositionsByBinding = ".compositionsByBinding" 15 | IdxSynthesizersByRef = ".synthesizersByRef" 16 | ) 17 | 18 | func indexController() client.IndexerFunc { 19 | return func(o client.Object) []string { 20 | owner := metav1.GetControllerOf(o) 21 | if owner == nil { 22 | return nil 23 | } 24 | return []string{owner.Name} 25 | } 26 | } 27 | 28 | func indexResourceBindings() client.IndexerFunc { 29 | return func(o client.Object) []string { 30 | comp, ok := o.(*apiv1.Composition) 31 | if !ok { 32 | return nil 33 | } 34 | 35 | keys := []string{} 36 | for _, binding := range comp.Spec.Bindings { 37 | keys = append(keys, path.Join(comp.Spec.Synthesizer.Name, binding.Resource.Namespace, binding.Resource.Name)) 38 | } 39 | return keys 40 | } 41 | } 42 | 43 | func indexSynthRefs() client.IndexerFunc { 44 | return func(o client.Object) []string { 45 | synth, ok := o.(*apiv1.Synthesizer) 46 | if !ok { 47 | return nil 48 | } 49 | 50 | keys := []string{} 51 | for _, ref := range synth.Spec.Refs { 52 | keys = append(keys, path.Join(ref.Resource.Group, ref.Resource.Version, ref.Resource.Kind)) 53 | } 54 | return keys 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/manager/options.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "time" 7 | 8 | "k8s.io/apimachinery/pkg/labels" 9 | "k8s.io/client-go/rest" 10 | "sigs.k8s.io/controller-runtime/pkg/cache" 11 | "sigs.k8s.io/controller-runtime/pkg/leaderelection" 12 | ) 13 | 14 | type Options struct { 15 | leaderelection.Options 16 | ElectionLeaseDuration time.Duration 17 | ElectionLeaseRenewDeadline time.Duration 18 | ElectionLeaseRetryPeriod time.Duration 19 | 20 | Rest *rest.Config 21 | HealthProbeAddr string 22 | MetricsAddr string 23 | SynthesizerPodNamespace string // set in cmd from synthesis config 24 | qps float64 // flags don't support float32, bind to this value and copy over to Rest.QPS during initialization 25 | 26 | // Only set by cmd in reconciler process 27 | CompositionNamespace string 28 | CompositionSelector labels.Selector 29 | } 30 | 31 | func (o *Options) Bind(set *flag.FlagSet) { 32 | set.StringVar(&o.HealthProbeAddr, "health-probe-addr", ":8081", "Address to serve health probes on") 33 | set.StringVar(&o.MetricsAddr, "metrics-addr", ":8080", "Address to serve Prometheus metrics on") 34 | set.IntVar(&o.Rest.Burst, "burst", 50, "apiserver client rate limiter burst configuration") 35 | set.Float64Var(&o.qps, "qps", 20, "Max requests per second to apiserver") 36 | set.BoolVar(&o.LeaderElection, "leader-election", false, "Enable leader election") 37 | set.StringVar(&o.LeaderElectionNamespace, "leader-election-namespace", os.Getenv("POD_NAMESPACE"), "Determines the namespace in which the leader election resource will be created") 38 | set.StringVar(&o.LeaderElectionResourceLock, "leader-election-resource-lock", "", "Determines which resource lock to use for leader election") 39 | set.StringVar(&o.LeaderElectionID, "leader-election-id", "", "Determines the name of the resource that leader election will use for holding the leader lock") 40 | set.DurationVar(&o.ElectionLeaseDuration, "leader-election-lease-duration", 35*time.Second, "How long before non-leaders will forcibly take leadership") 41 | set.DurationVar(&o.ElectionLeaseRenewDeadline, "leader-election-lease-renew-deadline", 30*time.Second, "Max duration of all retries when leader is updating the election lease") 42 | set.DurationVar(&o.ElectionLeaseRetryPeriod, "leader-election-lease-retry", 4*time.Second, "Interval at which the leader will update the election lease") 43 | } 44 | 45 | func newCacheOptions(ns string, selector labels.Selector) cache.ByObject { 46 | if ns == cache.AllNamespaces { 47 | return cache.ByObject{Label: selector} 48 | } 49 | return cache.ByObject{ 50 | Namespaces: map[string]cache.Config{ 51 | ns: {LabelSelector: selector}, 52 | }, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/readiness/readiness.go: -------------------------------------------------------------------------------- 1 | package readiness 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "time" 8 | 9 | celtypes "github.com/google/cel-go/common/types" 10 | "github.com/google/cel-go/common/types/ref" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | 14 | "github.com/google/cel-go/cel" 15 | ) 16 | 17 | var defaultEnv *cel.Env 18 | 19 | func init() { 20 | initDefaultEnv() 21 | } 22 | 23 | func initDefaultEnv() { 24 | var err error 25 | defaultEnv, err = cel.NewEnv(cel.Variable("self", cel.DynType)) 26 | if err != nil { 27 | panic(fmt.Sprintf("failed to create default CEL environment: %v", err)) 28 | } 29 | } 30 | 31 | // Check represents a parsed readiness check CEL expression. 32 | type Check struct { 33 | Name string 34 | program cel.Program 35 | } 36 | 37 | // ParseCheck parses the given CEL expression in the context of an environment, 38 | // and returns a reusable execution handle. 39 | func ParseCheck(expr string) (*Check, error) { 40 | ast, iss := defaultEnv.Compile(expr) 41 | if iss != nil && iss.Err() != nil { 42 | return nil, iss.Err() 43 | } 44 | prgm, err := defaultEnv.Program(ast, cel.InterruptCheckFrequency(10)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | return &Check{program: prgm}, nil 49 | } 50 | 51 | // Eval executes the compiled check against a given resource. 52 | func (r *Check) Eval(ctx context.Context, resource *unstructured.Unstructured) (*Status, bool) { 53 | if resource == nil { 54 | return nil, false 55 | } 56 | val, _, err := r.program.ContextEval(ctx, map[string]any{"self": resource.Object}) 57 | if err != nil { 58 | return nil, false 59 | } 60 | 61 | // Support matching on condition structs. 62 | // This allows us to grab the transition time instead of just using the current time. 63 | if list, ok := val.Value().([]ref.Val); ok { 64 | for _, ref := range list { 65 | if mp, ok := ref.Value().(map[string]any); ok { 66 | if mp != nil && mp["status"] == "True" && mp["type"] != nil && mp["reason"] != nil { 67 | ts := metav1.Now() 68 | if str, ok := mp["lastTransitionTime"].(string); ok { 69 | parsed, err := time.Parse(time.RFC3339, str) 70 | if err == nil { 71 | ts.Time = parsed 72 | } 73 | } 74 | return &Status{ReadyTime: ts, PreciseTime: err == nil}, true 75 | } 76 | } 77 | } 78 | } 79 | 80 | if val == celtypes.True { 81 | return &Status{ReadyTime: metav1.Now()}, true 82 | } 83 | return nil, false 84 | } 85 | 86 | type Checks []*Check 87 | 88 | // Eval evaluates and prioritizes the set of readiness checks. 89 | // 90 | // - Nil is returned when less than all of the checks are ready 91 | // - If some precise and some inprecise times are given, the precise times are favored 92 | // - Within precise or non-precise times, the max of that group is always used 93 | func (r Checks) Eval(ctx context.Context, resource *unstructured.Unstructured) (*Status, bool) { 94 | var all []*Status 95 | for _, check := range r { 96 | if ready, ok := check.Eval(ctx, resource); ok { 97 | all = append(all, ready) 98 | } 99 | } 100 | if len(all) == 0 || len(all) != len(r) { 101 | return nil, false 102 | } 103 | 104 | sort.Slice(all, func(i, j int) bool { return all[j].ReadyTime.Before(&all[i].ReadyTime) }) 105 | 106 | // Use the max precise time if any are precise 107 | for _, ready := range all { 108 | ready := ready 109 | if !ready.PreciseTime { 110 | continue 111 | } 112 | return ready, true 113 | } 114 | 115 | // We don't have any precise times, fall back to the max 116 | return all[0], true 117 | } 118 | 119 | // EvalOptionally is identical to Eval, except it returns the current time in the status if no checks are set. 120 | func (r Checks) EvalOptionally(ctx context.Context, resource *unstructured.Unstructured) (*Status, bool) { 121 | if len(r) == 0 { 122 | return &Status{ReadyTime: metav1.Now()}, true 123 | } 124 | return r.Eval(ctx, resource) 125 | } 126 | 127 | type Status struct { 128 | ReadyTime metav1.Time 129 | PreciseTime bool // true when time came from a condition, not the controller's metav1.Now 130 | } 131 | -------------------------------------------------------------------------------- /internal/resource/cache.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | apiv1 "github.com/Azure/eno/api/v1" 8 | "github.com/go-logr/logr" 9 | "k8s.io/apimachinery/pkg/types" 10 | "k8s.io/client-go/util/workqueue" 11 | ) 12 | 13 | type Request struct { 14 | Resource Ref 15 | Composition types.NamespacedName 16 | } 17 | 18 | // Cache caches resources indexed and logically grouped by the UUID of the synthesis that produced them. 19 | // Kind of like an informer but optimized for Eno. 20 | type Cache struct { 21 | mut sync.Mutex 22 | queue workqueue.TypedRateLimitingInterface[Request] 23 | syntheses map[string]*tree 24 | synByComp map[types.NamespacedName][]string 25 | } 26 | 27 | func (c *Cache) initUnlocked() { 28 | if c.syntheses == nil { 29 | c.syntheses = map[string]*tree{} 30 | } 31 | if c.synByComp == nil { 32 | c.synByComp = map[types.NamespacedName][]string{} 33 | } 34 | if c.queue == nil { 35 | panic("attempted to use resource cache without a queue") 36 | } 37 | } 38 | 39 | func (c *Cache) SetQueue(queue workqueue.TypedRateLimitingInterface[Request]) { 40 | c.mut.Lock() 41 | defer c.mut.Unlock() 42 | 43 | if c.queue != nil { 44 | panic("attempted to replace queue in resource cache") 45 | } 46 | c.queue = queue 47 | } 48 | 49 | func (c *Cache) Get(ctx context.Context, synthesisUUID string, ref Ref) (res *Resource, visible, found bool) { 50 | c.mut.Lock() 51 | defer c.mut.Unlock() 52 | 53 | syn, ok := c.syntheses[synthesisUUID] 54 | if !ok { 55 | return nil, false, false 56 | } 57 | return syn.Get(ref) 58 | } 59 | 60 | // Visit takes a set of resource slices from the informers and updates the resource status in the cache. 61 | // Return false if the synthesis is not in the cache. 62 | func (c *Cache) Visit(ctx context.Context, comp *apiv1.Composition, synUUID string, items []apiv1.ResourceSlice) bool { 63 | c.mut.Lock() 64 | defer c.mut.Unlock() 65 | c.initUnlocked() 66 | 67 | syn, ok := c.syntheses[synUUID] 68 | if !ok { 69 | return false 70 | } 71 | 72 | compNSN := types.NamespacedName{Name: comp.Name, Namespace: comp.Namespace} 73 | for _, slice := range items { 74 | for i := 0; i < len(slice.Spec.Resources); i++ { 75 | var state apiv1.ResourceState 76 | if len(slice.Status.Resources) > i { 77 | state = slice.Status.Resources[i] 78 | } 79 | ref := ManifestRef{ 80 | Slice: types.NamespacedName{Name: slice.Name, Namespace: slice.Namespace}, 81 | Index: i, 82 | } 83 | syn.UpdateState(comp, ref, &state, func(r Ref) { 84 | c.queue.Add(Request{Resource: r, Composition: compNSN}) 85 | }) 86 | } 87 | } 88 | 89 | return true 90 | } 91 | 92 | // Fill populates the cache with resources from a synthesis. Call Visit first to see if filling the cache is necessary. 93 | // Get the resource slices from the API - not the informers, which prune out the manifests to save memory. 94 | func (c *Cache) Fill(ctx context.Context, comp types.NamespacedName, synUUID string, items []apiv1.ResourceSlice) { 95 | logger := logr.FromContextOrDiscard(ctx) 96 | 97 | var builder treeBuilder 98 | for _, slice := range items { 99 | slice := slice 100 | for i := range slice.Spec.Resources { 101 | res, err := NewResource(ctx, &slice, i) 102 | if err != nil { 103 | // This should be impossible since the synthesis executor process will not produce invalid resources 104 | logger.Error(err, "invalid resource - cannot load into cache", "resourceSliceName", slice.Name, "resourceIndex", i) 105 | return 106 | } 107 | builder.Add(res) 108 | } 109 | } 110 | tree := builder.Build() 111 | 112 | c.mut.Lock() 113 | c.initUnlocked() 114 | c.syntheses[synUUID] = tree 115 | c.synByComp[comp] = append(c.synByComp[comp], synUUID) 116 | c.mut.Unlock() 117 | logger.V(1).Info("resource cache filled", "synthesisUUID", synUUID) 118 | } 119 | 120 | // Purge removes all syntheses from the cache that are not part of the given composition. 121 | // If comp is nil, all syntheses will be purged. 122 | func (c *Cache) Purge(ctx context.Context, compNSN types.NamespacedName, comp *apiv1.Composition) { 123 | logger := logr.FromContextOrDiscard(ctx) 124 | c.mut.Lock() 125 | defer c.mut.Unlock() 126 | c.initUnlocked() 127 | 128 | remainingSyns := []string{} 129 | for _, uuid := range c.synByComp[compNSN] { 130 | if comp != nil && ((comp.Status.CurrentSynthesis != nil && comp.Status.CurrentSynthesis.UUID == uuid) || (comp.Status.PreviousSynthesis != nil && comp.Status.PreviousSynthesis.UUID == uuid)) { 131 | remainingSyns = append(remainingSyns, uuid) 132 | continue // still referenced 133 | } 134 | 135 | logger.V(1).Info("resource cache purged", "synthesisUUID", uuid) 136 | delete(c.syntheses, uuid) 137 | } 138 | 139 | c.synByComp[compNSN] = remainingSyns 140 | } 141 | -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-both-crd-and-cr-and-readiness-groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "(test.group.TestKind)/default/also-not-a-crd": { 3 | "dependencies": [ 4 | "(test.group.TestKind)/default/test-cr" 5 | ], 6 | "dependents": [], 7 | "ready": false, 8 | "reconciled": false 9 | }, 10 | "(test.group.TestKind)/default/not-a-crd": { 11 | "dependencies": [], 12 | "dependents": [ 13 | "(test.group.TestKind)/default/test-crd" 14 | ], 15 | "ready": false, 16 | "reconciled": false 17 | }, 18 | "(test.group.TestKind)/default/test-cr": { 19 | "dependencies": [ 20 | "(test.group.TestKind)/default/test-crd" 21 | ], 22 | "dependents": [ 23 | "(test.group.TestKind)/default/also-not-a-crd" 24 | ], 25 | "ready": false, 26 | "reconciled": false 27 | }, 28 | "(test.group.TestKind)/default/test-crd": { 29 | "dependencies": [ 30 | "(test.group.TestKind)/default/not-a-crd" 31 | ], 32 | "dependents": [ 33 | "(test.group.TestKind)/default/test-cr" 34 | ], 35 | "ready": false, 36 | "reconciled": false 37 | } 38 | } -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-both-crd-and-cr-conflict.json: -------------------------------------------------------------------------------- 1 | { 2 | "(test.group.TestKind)/default/test-cr": { 3 | "dependencies": [ 4 | "(test.group.TestKind)/default/test-crd" 5 | ], 6 | "dependents": [ 7 | "(test.group.TestKind)/default/test-crd" 8 | ], 9 | "ready": false, 10 | "reconciled": false 11 | }, 12 | "(test.group.TestKind)/default/test-crd": { 13 | "dependencies": [ 14 | "(test.group.TestKind)/default/test-cr" 15 | ], 16 | "dependents": [ 17 | "(test.group.TestKind)/default/test-cr" 18 | ], 19 | "ready": false, 20 | "reconciled": false 21 | } 22 | } -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-crd-and-cr.json: -------------------------------------------------------------------------------- 1 | { 2 | "(test.group.TestKind)/default/test-cr": { 3 | "dependencies": [ 4 | "(test.group.TestKind)/default/test-crd" 5 | ], 6 | "dependents": [], 7 | "ready": false, 8 | "reconciled": false 9 | }, 10 | "(test.group.TestKind)/default/test-crd": { 11 | "dependencies": [], 12 | "dependents": [ 13 | "(test.group.TestKind)/default/test-cr" 14 | ], 15 | "ready": false, 16 | "reconciled": false 17 | } 18 | } -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-empty.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-several-overlapping-groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "(test.group.TestKind)/default/test-1": { 3 | "dependencies": [], 4 | "dependents": [ 5 | "(test.group.TestKind)/default/test-2-a", 6 | "(test.group.TestKind)/default/test-2-b" 7 | ], 8 | "ready": false, 9 | "reconciled": false 10 | }, 11 | "(test.group.TestKind)/default/test-2-a": { 12 | "dependencies": [ 13 | "(test.group.TestKind)/default/test-1" 14 | ], 15 | "dependents": [], 16 | "ready": false, 17 | "reconciled": false 18 | }, 19 | "(test.group.TestKind)/default/test-2-b": { 20 | "dependencies": [ 21 | "(test.group.TestKind)/default/test-1" 22 | ], 23 | "dependents": [], 24 | "ready": false, 25 | "reconciled": false 26 | } 27 | } -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-several-readiness-groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "(test.group.TestKind)/default/test-0": { 3 | "dependencies": [ 4 | "(test.group.TestKind)/default/test-negative-2" 5 | ], 6 | "dependents": [ 7 | "(test.group.TestKind)/default/test-1" 8 | ], 9 | "ready": false, 10 | "reconciled": false 11 | }, 12 | "(test.group.TestKind)/default/test-1": { 13 | "dependencies": [ 14 | "(test.group.TestKind)/default/test-0" 15 | ], 16 | "dependents": [ 17 | "(test.group.TestKind)/default/test-4" 18 | ], 19 | "ready": false, 20 | "reconciled": false 21 | }, 22 | "(test.group.TestKind)/default/test-4": { 23 | "dependencies": [ 24 | "(test.group.TestKind)/default/test-1" 25 | ], 26 | "dependents": [], 27 | "ready": false, 28 | "reconciled": false 29 | }, 30 | "(test.group.TestKind)/default/test-negative-2": { 31 | "dependencies": [], 32 | "dependents": [ 33 | "(test.group.TestKind)/default/test-0" 34 | ], 35 | "ready": false, 36 | "reconciled": false 37 | } 38 | } -------------------------------------------------------------------------------- /internal/resource/fixtures/tree-builder-single-basic-resource.json: -------------------------------------------------------------------------------- 1 | { 2 | "(test.group.TestKind)/default/test-resource": { 3 | "dependencies": [], 4 | "dependents": [], 5 | "ready": false, 6 | "reconciled": false 7 | } 8 | } -------------------------------------------------------------------------------- /internal/resource/slicing.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "fmt" 5 | 6 | apiv1 "github.com/Azure/eno/api/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 9 | "k8s.io/apimachinery/pkg/runtime/schema" 10 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 11 | ) 12 | 13 | // Slice builds a new set of resource slices by merging a new set of resources onto an old set of slices. 14 | // - New and updated resources are partitioned across slices per maxJsonBytes 15 | // - Removed resources are converted into "tombstones" i.e. manifests with Deleted == true 16 | func Slice(comp *apiv1.Composition, previous []*apiv1.ResourceSlice, outputs []*unstructured.Unstructured, maxJsonBytes int) ([]*apiv1.ResourceSlice, error) { 17 | refs := map[resourceRef]struct{}{} 18 | manifests := []apiv1.Manifest{} 19 | for i, output := range outputs { 20 | js, err := output.MarshalJSON() 21 | if err != nil { 22 | return nil, reconcile.TerminalError(fmt.Errorf("encoding output %d: %w", i, err)) 23 | } 24 | manifests = append(manifests, apiv1.Manifest{ 25 | Manifest: string(js), 26 | }) 27 | refs[newResourceRef(output)] = struct{}{} 28 | } 29 | 30 | // Build tombstones by diffing the new state against the current state 31 | // Existing tombstones are passed down if they haven't yet been reconciled to avoid orphaning resources 32 | for _, slice := range previous { 33 | for i, res := range slice.Spec.Resources { 34 | res := res 35 | obj := &unstructured.Unstructured{} 36 | err := obj.UnmarshalJSON([]byte(res.Manifest)) 37 | if err != nil { 38 | return nil, reconcile.TerminalError(fmt.Errorf("decoding resource %d of slice %s: %w", i, slice.Name, err)) 39 | } 40 | 41 | if obj.GetObjectKind().GroupVersionKind() == patchGVK { 42 | // Patches can be removed without deleting the resource 43 | continue 44 | } 45 | 46 | // We don't need a tombstone once the deleted resource has been reconciled 47 | if _, ok := refs[newResourceRef(obj)]; ok || ((res.Deleted || slice.DeletionTimestamp != nil) && slice.Status.Resources != nil && slice.Status.Resources[i].Reconciled) { 48 | continue // still exists or has already been deleted 49 | } 50 | 51 | res.Deleted = true 52 | manifests = append(manifests, res) 53 | } 54 | } 55 | 56 | // Build the slice resources 57 | var ( 58 | slices []*apiv1.ResourceSlice 59 | sliceBytes int 60 | slice *apiv1.ResourceSlice 61 | blockOwnerDeletion = true 62 | ) 63 | for _, manifest := range manifests { 64 | if slice == nil || sliceBytes >= maxJsonBytes { 65 | sliceBytes = 0 66 | slice = &apiv1.ResourceSlice{} 67 | slice.GenerateName = comp.Name + "-" 68 | slice.Namespace = comp.Namespace 69 | slice.Finalizers = []string{"eno.azure.io/cleanup"} 70 | slice.OwnerReferences = []metav1.OwnerReference{{ 71 | APIVersion: apiv1.SchemeGroupVersion.Identifier(), 72 | Kind: "Composition", 73 | Name: comp.Name, 74 | UID: comp.UID, 75 | BlockOwnerDeletion: &blockOwnerDeletion, // we need the composition in order to successfully delete its resource slices 76 | Controller: &blockOwnerDeletion, 77 | }} 78 | if comp.Status.CurrentSynthesis != nil { 79 | slice.Spec.SynthesisUUID = comp.Status.CurrentSynthesis.UUID 80 | } 81 | slices = append(slices, slice) 82 | } 83 | sliceBytes += len(manifest.Manifest) 84 | slice.Spec.Resources = append(slice.Spec.Resources, manifest) 85 | } 86 | 87 | return slices, nil 88 | } 89 | 90 | type resourceRef struct { 91 | Name, Namespace, Kind, Group string 92 | } 93 | 94 | func newResourceRef(obj *unstructured.Unstructured) resourceRef { 95 | if obj.GetObjectKind().GroupVersionKind() == patchGVK { 96 | apiVersion, _, _ := unstructured.NestedString(obj.Object, "patch", "apiVersion") 97 | kind, _, _ := unstructured.NestedString(obj.Object, "patch", "kind") 98 | gv, _ := schema.ParseGroupVersion(apiVersion) 99 | return resourceRef{ 100 | Name: obj.GetName(), 101 | Namespace: obj.GetNamespace(), 102 | Kind: kind, 103 | Group: gv.Group, 104 | } 105 | } 106 | 107 | return resourceRef{ 108 | Name: obj.GetName(), 109 | Namespace: obj.GetNamespace(), 110 | Kind: obj.GetKind(), 111 | Group: obj.GroupVersionKind().Group, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/testutil/statespace/statespace.go: -------------------------------------------------------------------------------- 1 | package statespace 2 | 3 | import ( 4 | "math/rand/v2" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | // Model implements a simple fuzz-style test framework. 10 | // Rather than fuzz scalar values like property-based tests, this framework enumerates the subsets of a bounded, high-cardinality state space. 11 | // Essentially, the framework will call the subject with every subset of the known mutations and assert on each invariant. 12 | // Application order of mutations is randomized to avoid order-dependence without fully permuting the space (the implementation is very fast). 13 | type Model[State any, Result any] struct { 14 | initial func() State 15 | subject func(State) Result 16 | transitions []mutation[State] 17 | invariants []*invariant[State, Result] 18 | } 19 | 20 | type invariant[T any, TT any] struct { 21 | Name string 22 | Assert func(T, TT) bool 23 | } 24 | 25 | type mutation[T any] struct { 26 | Name string 27 | Func func(T) T 28 | } 29 | 30 | // Test creates a new model for testing the given subject. 31 | func Test[T any, TT any](fn func(T) TT) *Model[T, TT] { return &Model[T, TT]{subject: fn} } 32 | 33 | func (m *Model[T, TT]) WithInitialState(fn func() T) *Model[T, TT] { 34 | m.initial = fn 35 | return m 36 | } 37 | 38 | // WithMutation appends a function that will be applied to the state while evaluating the model. 39 | func (m *Model[T, TT]) WithMutation(name string, fn func(T) T) *Model[T, TT] { 40 | m.transitions = append(m.transitions, mutation[T]{Name: name, Func: fn}) 41 | return m 42 | } 43 | 44 | // WithInvariant appends a function that will be used to assert on the behavior of the subject for every subset of mutations. 45 | func (m *Model[T, TT]) WithInvariant(name string, fn func(state T, result TT) bool) *Model[T, TT] { 46 | m.invariants = append(m.invariants, &invariant[T, TT]{Name: name, Assert: fn}) 47 | return m 48 | } 49 | 50 | // Evaluate executes the test. 51 | func (m *Model[T, TT]) Evaluate(t *testing.T) { 52 | m.evaluate(t.Errorf) 53 | } 54 | 55 | func (m *Model[T, TT]) evaluate(fail func(msg string, args ...any)) { 56 | var testCases [][]bool 57 | for i := range 1 << len(m.transitions) { 58 | stack := make([]bool, len(m.transitions)) 59 | for j := range m.transitions { 60 | stack[j] = (i>>j)&1 == 1 61 | } 62 | testCases = append(testCases, stack) 63 | } 64 | rand.Shuffle(len(testCases), func(i, j int) { testCases[i], testCases[j] = testCases[j], testCases[i] }) 65 | 66 | for _, bitmap := range testCases { 67 | var state T 68 | if m.initial != nil { 69 | state = m.initial() 70 | } 71 | 72 | // Build the state by applying mutations 73 | for _, i := range rand.Perm(len(bitmap)) { 74 | if bitmap[i] { 75 | state = m.transitions[i].Func(state) 76 | } 77 | } 78 | 79 | var stack []string 80 | rand.Shuffle(len(m.invariants), func(i, j int) { m.invariants[i], m.invariants[j] = m.invariants[j], m.invariants[i] }) 81 | for _, inv := range m.invariants { 82 | if inv.Assert(state, m.subject(state)) { 83 | continue 84 | } 85 | 86 | // Defer building the stack strings to avoid allocating the memory for passing tests 87 | if stack == nil { 88 | for i, enabled := range bitmap { 89 | if enabled { 90 | stack = append(stack, m.transitions[i].Name) 91 | } 92 | } 93 | } 94 | 95 | fail("invariant '%s' failed with mutation stack: [%s]", inv.Name, strings.Join(stack, ", ")) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/testutil/statespace/statespace_test.go: -------------------------------------------------------------------------------- 1 | package statespace 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestBasics(t *testing.T) { 12 | failures := []string{} 13 | subject := func(state int) string { return strconv.Itoa(state) } 14 | 15 | Test(subject). 16 | WithMutation("increment by one", func(state int) int { 17 | return state + 1 18 | }). 19 | WithMutation("increment by 10", func(state int) int { 20 | return state + 10 21 | }). 22 | WithInvariant("fail on initial", func(_ int, result string) bool { 23 | return result != "0" 24 | }). 25 | WithInvariant("never fail", func(_ int, result string) bool { 26 | return result != "" 27 | }). 28 | WithInvariant("fail when 1", func(_ int, result string) bool { 29 | return result != "1" 30 | }). 31 | WithInvariant("fail when 10", func(state int, result string) bool { 32 | return result != "10" 33 | }). 34 | WithInvariant("fail when 11", func(state int, result string) bool { 35 | return result != "11" 36 | }). 37 | evaluate(func(msg string, args ...any) { 38 | failures = append(failures, fmt.Sprintf(msg, args...)) 39 | }) 40 | 41 | assert.ElementsMatch(t, []string{ 42 | "invariant 'fail on initial' failed with mutation stack: []", 43 | "invariant 'fail when 1' failed with mutation stack: [increment by one]", 44 | "invariant 'fail when 10' failed with mutation stack: [increment by 10]", 45 | "invariant 'fail when 11' failed with mutation stack: [increment by one, increment by 10]", 46 | }, failures) 47 | } 48 | 49 | func TestLargeSpace(t *testing.T) { 50 | fn := func(bool) bool { return true } 51 | 52 | m := Test(fn) 53 | for i := 0; i < 1000; i++ { 54 | m.WithMutation("noop", func(state bool) bool { return state }) 55 | } 56 | m.Evaluate(t) // just prove it doesn't deadlock or take too long 57 | } 58 | -------------------------------------------------------------------------------- /pkg/function/fixtures/invalid.yaml: -------------------------------------------------------------------------------- 1 | metadata: 2 | name: example 3 | namespace: default 4 | 5 | --- 6 | 7 | apiVersion: v1 8 | kind: ConfigMap 9 | metadata: 10 | name: example 11 | namespace: default 12 | -------------------------------------------------------------------------------- /pkg/function/fixtures/valid.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: myapi.myapp.io/v1 2 | kind: Example 3 | metadata: 4 | name: example 5 | namespace: default 6 | 7 | --- 8 | 9 | apiVersion: v1 10 | kind: ConfigMap 11 | metadata: 12 | name: example 13 | namespace: default 14 | -------------------------------------------------------------------------------- /pkg/function/fs.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 9 | "k8s.io/apimachinery/pkg/util/yaml" 10 | "sigs.k8s.io/controller-runtime/pkg/client" 11 | ) 12 | 13 | // ReadManifest reads a YAML file from disk and parses each document into an unstructured object. 14 | func ReadManifest(path string) ([]client.Object, error) { 15 | file, err := os.Open(path) 16 | if err != nil { 17 | return nil, fmt.Errorf("opening file: %w", err) 18 | } 19 | defer file.Close() 20 | 21 | var objects []client.Object 22 | decoder := yaml.NewYAMLOrJSONDecoder(file, 1024) 23 | for { 24 | obj := &unstructured.Unstructured{} 25 | if err := decoder.Decode(obj); err != nil { 26 | if err == io.EOF { 27 | return objects, nil 28 | } 29 | return nil, fmt.Errorf("decoding yaml: %w", err) 30 | } 31 | objects = append(objects, obj) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/function/fs_test.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | ) 11 | 12 | func TestReadManifestHappyPath(t *testing.T) { 13 | objects, err := ReadManifest("fixtures/valid.yaml") 14 | require.NoError(t, err) 15 | assert.Equal(t, []client.Object{ 16 | &unstructured.Unstructured{ 17 | Object: map[string]any{ 18 | "apiVersion": "myapi.myapp.io/v1", 19 | "kind": "Example", 20 | "metadata": map[string]interface{}{ 21 | "name": "example", 22 | "namespace": "default", 23 | }, 24 | }, 25 | }, 26 | &unstructured.Unstructured{ 27 | Object: map[string]any{ 28 | "apiVersion": "v1", 29 | "kind": "ConfigMap", 30 | "metadata": map[string]interface{}{ 31 | "name": "example", 32 | "namespace": "default", 33 | }, 34 | }, 35 | }, 36 | }, objects) 37 | } 38 | 39 | func TestReadManifestInvalidYAML(t *testing.T) { 40 | objects, err := ReadManifest("fixtures/invalid.yaml") 41 | require.Error(t, err) 42 | assert.Empty(t, objects) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/function/inputs.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | type InputReader struct { 17 | resources *krmv1.ResourceList 18 | } 19 | 20 | func NewDefaultInputReader() (*InputReader, error) { 21 | return NewInputReader(os.Stdin) 22 | } 23 | 24 | func NewInputReader(r io.Reader) (*InputReader, error) { 25 | rl := krmv1.ResourceList{} 26 | err := json.NewDecoder(r).Decode(&rl) 27 | if err != nil && !errors.Is(err, io.EOF) { 28 | return nil, fmt.Errorf("decoding stdin as krm resource list: %w", err) 29 | } 30 | return &InputReader{ 31 | resources: &rl, 32 | }, nil 33 | } 34 | 35 | func ReadInput[T client.Object](ir *InputReader, key string, out T) error { 36 | var found bool 37 | for _, i := range ir.resources.Items { 38 | i := i 39 | if getKey(i) == key { 40 | err := runtime.DefaultUnstructuredConverter.FromUnstructured(i.Object, out) 41 | if err != nil { 42 | return fmt.Errorf("converting item to Input: %w", err) 43 | } 44 | found = true 45 | break 46 | } 47 | } 48 | if !found { 49 | return fmt.Errorf("input %q was not found", key) 50 | } 51 | return nil 52 | } 53 | 54 | func (i *InputReader) All() map[string]*unstructured.Unstructured { 55 | m := map[string]*unstructured.Unstructured{} 56 | for _, o := range i.resources.Items { 57 | m[getKey(o)] = o 58 | } 59 | return m 60 | } 61 | 62 | func getKey(obj client.Object) string { 63 | if obj.GetAnnotations() == nil { 64 | return "" 65 | } 66 | return obj.GetAnnotations()["eno.azure.io/input-key"] 67 | } 68 | -------------------------------------------------------------------------------- /pkg/function/inputs_test.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | corev1 "k8s.io/api/core/v1" 10 | ) 11 | 12 | func TestInputReader(t *testing.T) { 13 | input := bytes.NewBufferString(`{ "items": [{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": { "name": "test-cm", "annotations": { "eno.azure.io/input-key": "foo" } } }] }`) 14 | r, err := NewInputReader(input) 15 | require.NoError(t, err) 16 | 17 | // Found 18 | cm := &corev1.ConfigMap{} 19 | err = ReadInput(r, "foo", cm) 20 | require.NoError(t, err) 21 | assert.Equal(t, "test-cm", cm.Name) 22 | assert.Equal(t, "test-cm", r.All()["foo"].GetName()) 23 | 24 | // Missing 25 | err = ReadInput(r, "bar", cm) 26 | require.EqualError(t, err, "input \"bar\" was not found") 27 | } 28 | 29 | func TestNewInputReader(t *testing.T) { 30 | t.Run("treat empty input (EOF) as empty resource list", func(t *testing.T) { 31 | input := bytes.NewBufferString("") 32 | r, err := NewInputReader(input) 33 | require.NoError(t, err) 34 | assert.Equal(t, 0, len(r.resources.Items)) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/function/main.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | ) 10 | 11 | // Inputs is satisfied by any struct that defines the inputs required by a SynthFunc. 12 | // Use the `eno_key` struct tag to specify the corresponding ref key for each input. 13 | // Each field must either be a client.Object or a custom type registered with AddCustomInputType. 14 | type Inputs interface{} 15 | 16 | // SynthFunc defines a synthesizer function that takes a set of inputs and returns a list of objects. 17 | type SynthFunc[T Inputs] func(inputs T) ([]client.Object, error) 18 | 19 | // Main is the entrypoint for Eno synthesizer processes written using the framework defined by this package. 20 | func Main[T Inputs](fn SynthFunc[T]) { 21 | ow := NewDefaultOutputWriter() 22 | ir, err := NewDefaultInputReader() 23 | if err != nil { 24 | panic(fmt.Sprintf("failed to create default input reader: %s", err)) 25 | } 26 | 27 | err = main(fn, ir, ow) 28 | if err != nil { 29 | panic(fmt.Sprintf("error while calling synthesizer function: %s", err)) 30 | } 31 | } 32 | 33 | func main[T Inputs](fn SynthFunc[T], ir *InputReader, ow *OutputWriter) error { 34 | var inputs T 35 | v := reflect.ValueOf(&inputs).Elem() 36 | t := v.Type() 37 | 38 | // Read the inputs 39 | for i := 0; i < t.NumField(); i++ { 40 | tagValue := t.Field(i).Tag.Get("eno_key") 41 | if tagValue == "" { 42 | continue 43 | } 44 | 45 | input, err := newInput(ir, v.Field(i)) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | err = ReadInput(ir, tagValue, input.Object) 51 | if err != nil { 52 | ow.AddResult(&krmv1.Result{ 53 | Message: fmt.Sprintf("error while reading input with key %q: %s", tagValue, err), 54 | Severity: krmv1.ResultSeverityError, 55 | }) 56 | return ow.Write() 57 | } 58 | 59 | input.Finalize() 60 | } 61 | 62 | // Call the fn and handle errors through the KRM interface 63 | outputs, err := fn(inputs) 64 | if err != nil { 65 | ow.AddResult(&krmv1.Result{ 66 | Message: err.Error(), 67 | Severity: krmv1.ResultSeverityError, 68 | }) 69 | return ow.Write() 70 | } 71 | 72 | // Write the outputs 73 | for _, out := range outputs { 74 | ow.Add(out) 75 | } 76 | return ow.Write() 77 | } 78 | 79 | var customInputSourceTypes = map[string]reflect.Type{} 80 | var customInputBindings = map[string]func(any) (any, error){} 81 | 82 | // AddCustomInputType allows types that do not implement client.Object to be used as fields of Inputs structs. 83 | func AddCustomInputType[Resource client.Object, Custom any](bind func(Resource) (Custom, error)) { 84 | str := reflect.TypeOf(bind).Out(0).String() 85 | 86 | // Map from custom type name to the underlying k8s input type 87 | var res Resource 88 | customInputSourceTypes[str] = reflect.TypeOf(res) 89 | 90 | // Map from the custom type name to the binding function 91 | customInputBindings[str] = func(in any) (any, error) { 92 | return bind(in.(Resource)) 93 | } 94 | } 95 | 96 | type input struct { 97 | Object client.Object 98 | bindFn func(any) (any, error) 99 | field reflect.Value 100 | } 101 | 102 | func newInput(ir *InputReader, field reflect.Value) (*input, error) { 103 | i := &input{field: field} 104 | 105 | // Allocate values for nil pointers 106 | if field.IsNil() { 107 | if field.Kind() == reflect.Slice { 108 | field.Set(reflect.MakeSlice(field.Type(), 0, 0)) 109 | } else { 110 | field.Set(reflect.New(field.Type().Elem())) 111 | } 112 | } 113 | 114 | // Pass through client.Object types 115 | fieldVal := field.Interface() 116 | if o, ok := fieldVal.(client.Object); ok { 117 | i.Object = o 118 | return i, nil 119 | } 120 | 121 | // Resolve custom input types back to their binding functions 122 | name := field.Type().String() 123 | inputSourceType, ok := customInputSourceTypes[name] 124 | if !ok { 125 | return nil, fmt.Errorf("custom input type %q has not been registered", name) 126 | } 127 | 128 | fieldVal = reflect.New(inputSourceType.Elem()).Interface() 129 | i.Object = fieldVal.(client.Object) 130 | i.bindFn = customInputBindings[name] 131 | return i, nil 132 | } 133 | 134 | func (i *input) Finalize() error { 135 | if i.bindFn == nil { 136 | return nil 137 | } 138 | 139 | bound, err := i.bindFn(i.Object) 140 | if err != nil { 141 | return fmt.Errorf("error while binding custom input of type %T: %s", i.Object, err) 142 | } 143 | 144 | i.field.Set(reflect.ValueOf(bound)) 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /pkg/function/outputs.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/kubectl/pkg/scheme" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | var Scheme = scheme.Scheme 17 | 18 | type OutputWriter struct { 19 | outputs []*unstructured.Unstructured 20 | results []*krmv1.Result 21 | io io.Writer 22 | committed bool 23 | munge MungeFunc 24 | } 25 | 26 | type MungeFunc func(*unstructured.Unstructured) 27 | 28 | func NewDefaultOutputWriter() *OutputWriter { 29 | return NewOutputWriter(os.Stdout, nil) 30 | } 31 | 32 | func NewOutputWriter(w io.Writer, munge MungeFunc) *OutputWriter { 33 | return &OutputWriter{ 34 | outputs: []*unstructured.Unstructured{}, 35 | io: w, 36 | committed: false, 37 | munge: munge, 38 | } 39 | } 40 | 41 | func (w *OutputWriter) AddResult(result *krmv1.Result) { 42 | w.results = append(w.results, result) 43 | } 44 | 45 | func (w *OutputWriter) Add(outs ...client.Object) error { 46 | if w.committed { 47 | return fmt.Errorf("cannot add to a committed output") 48 | } 49 | 50 | // Doing a "filter" to avoid committing nil values. 51 | for _, o := range outs { 52 | if o == nil { 53 | continue 54 | } 55 | 56 | // Resolve GVK if needed 57 | if o.GetObjectKind().GroupVersionKind().Empty() { 58 | gvks, _, err := Scheme.ObjectKinds(o) 59 | if err != nil || len(gvks) == 0 { 60 | return fmt.Errorf("unable to determine GVK for object %s: %w", o.GetName(), err) 61 | } 62 | o.GetObjectKind().SetGroupVersionKind(gvks[0]) 63 | } 64 | 65 | // Encode 66 | obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(o) 67 | if err != nil { 68 | return fmt.Errorf( 69 | "converting %s %s to unstructured: %w", 70 | o.GetName(), 71 | o.GetObjectKind().GroupVersionKind().Kind, 72 | err, 73 | ) 74 | } 75 | u := &unstructured.Unstructured{Object: obj} 76 | if w.munge != nil { 77 | w.munge(u) 78 | } 79 | w.outputs = append(w.outputs, u) 80 | } 81 | return nil 82 | } 83 | 84 | func (w *OutputWriter) Write() error { 85 | rl := &krmv1.ResourceList{ 86 | Kind: krmv1.ResourceListKind, 87 | APIVersion: krmv1.SchemeGroupVersion.String(), 88 | Items: w.outputs, 89 | Results: w.results, 90 | } 91 | 92 | err := json.NewEncoder(w.io).Encode(rl) 93 | if err != nil { 94 | return fmt.Errorf("writing output to stdou: %w", err) 95 | } 96 | 97 | w.committed = true 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/function/outputs_test.go: -------------------------------------------------------------------------------- 1 | package function 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | krmv1 "github.com/Azure/eno/pkg/krm/functions/api/v1" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | ) 13 | 14 | func TestOutputWriter(t *testing.T) { 15 | out := bytes.NewBuffer(nil) 16 | w := NewOutputWriter(out, nil) 17 | 18 | cm := &corev1.ConfigMap{} 19 | cm.Name = "test-cm" 20 | 21 | require.NoError(t, w.Add(nil)) 22 | require.NoError(t, w.Add(cm)) 23 | w.AddResult(&krmv1.Result{Message: "test message", Severity: krmv1.ResultSeverityError}) 24 | assert.Equal(t, 0, out.Len()) 25 | 26 | require.NoError(t, w.Write()) 27 | assert.Equal(t, "{\"apiVersion\":\"config.kubernetes.io/v1\",\"kind\":\"ResourceList\",\"items\":[{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"creationTimestamp\":null,\"name\":\"test-cm\"}}],\"results\":[{\"message\":\"test message\",\"severity\":\"error\"}]}\n", out.String()) 28 | 29 | require.Error(t, w.Add(nil)) 30 | } 31 | 32 | func TestOutputWriterMunge(t *testing.T) { 33 | out := bytes.NewBuffer(nil) 34 | w := NewOutputWriter(out, func(u *unstructured.Unstructured) { 35 | unstructured.SetNestedField(u.Object, "value from munge function", "data", "extra-val") 36 | }) 37 | 38 | cm := &corev1.ConfigMap{} 39 | cm.Name = "test-cm" 40 | 41 | require.NoError(t, w.Add(cm)) 42 | require.NoError(t, w.Write()) 43 | assert.Equal(t, "{\"apiVersion\":\"config.kubernetes.io/v1\",\"kind\":\"ResourceList\",\"items\":[{\"apiVersion\":\"v1\",\"data\":{\"extra-val\":\"value from munge function\"},\"kind\":\"ConfigMap\",\"metadata\":{\"creationTimestamp\":null,\"name\":\"test-cm\"}}]}\n", out.String()) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/functiontest/fixtures/1.yaml: -------------------------------------------------------------------------------- 1 | foo: bar -------------------------------------------------------------------------------- /pkg/functiontest/fixtures/2.yml: -------------------------------------------------------------------------------- 1 | foo: baz -------------------------------------------------------------------------------- /pkg/functiontest/fixtures/3.json: -------------------------------------------------------------------------------- 1 | { 2 | "bar": "baz" 3 | } -------------------------------------------------------------------------------- /pkg/functiontest/snapshots/1.yaml: -------------------------------------------------------------------------------- 1 | - metadata: 2 | creationTimestamp: null 3 | name: test-pod 4 | spec: 5 | containers: null 6 | status: {} 7 | -------------------------------------------------------------------------------- /pkg/functiontest/testing.go: -------------------------------------------------------------------------------- 1 | package functiontest 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math/rand/v2" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "sigs.k8s.io/yaml" 12 | 13 | "github.com/Azure/eno/pkg/function" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | ) 16 | 17 | // Scenario represents a test case for a synthesizer function. 18 | type Scenario[T function.Inputs] struct { 19 | Name string 20 | Inputs T 21 | Assertion Assertion[T] 22 | } 23 | 24 | // Evaluate runs the synthesizer function with the provided scenarios and asserts on the outputs. 25 | func Evaluate[T function.Inputs](t *testing.T, synth function.SynthFunc[T], scenarios ...Scenario[T]) { 26 | for _, s := range scenarios { 27 | t.Run(s.Name, func(t *testing.T) { 28 | outputs, err := synth(s.Inputs) 29 | if err != nil { 30 | t.Fatalf("unexpected error: %v", err) 31 | } 32 | s.Assertion(t, &s, outputs) 33 | }) 34 | } 35 | } 36 | 37 | // LoadScenarios recursively loads yaml and json input fixtures from the specified directory. 38 | func LoadScenarios[T any](t *testing.T, dir string, assertion Assertion[T]) []Scenario[T] { 39 | scenarios := []Scenario[T]{} 40 | walkFiles(t, dir, func(path, name string) error { 41 | data, err := os.ReadFile(path) 42 | if err != nil { 43 | t.Errorf("error while reading fixture %q: %s", path, err) 44 | return nil 45 | } 46 | 47 | var input T 48 | err = yaml.Unmarshal(data, &input) 49 | if err != nil { 50 | t.Errorf("error while parsing fixture %q: %s", path, err) 51 | return nil 52 | } 53 | scenarios = append(scenarios, Scenario[T]{ 54 | Name: name, 55 | Inputs: input, 56 | Assertion: assertion, 57 | }) 58 | return nil 59 | }) 60 | 61 | // Make sure tests aren't coupled to a particular execution order 62 | rand.Shuffle(len(scenarios), func(i, j int) { scenarios[i], scenarios[j] = scenarios[j], scenarios[i] }) 63 | 64 | return scenarios 65 | } 66 | 67 | type Assertion[T function.Inputs] func(t *testing.T, s *Scenario[T], outputs []client.Object) 68 | 69 | // AssertionChain is a helper function to create an assertion that runs multiple assertions in sequence. 70 | func AssertionChain[T function.Inputs](asserts ...Assertion[T]) Assertion[T] { 71 | return func(t *testing.T, s *Scenario[T], outputs []client.Object) { 72 | for i, assert := range asserts { 73 | t.Run(fmt.Sprintf("assertion-%d", i), func(t *testing.T) { 74 | assert(t, s, outputs) 75 | }) 76 | } 77 | } 78 | } 79 | 80 | // LoadSnapshots returns an assertion that will compare the outputs of a synthesizer function 81 | // with the expected outputs stored in snapshot files. 82 | // 83 | // Scenarios that do not have a corresponding snapshot file will be ignored. 84 | // To generate snapshots, set the ENO_GEN_SNAPSHOTS environment variable to a non-empty value. 85 | // 86 | // So, to bootstrap snapshots for a given fixture/scenario: create an empty snapshot file 87 | // that matches the name of the scenario (or fixture if using LoadScenarios), and run the 88 | // tests with ENO_GEN_SNAPSHOTS=true. 89 | func LoadSnapshots[T function.Inputs](t *testing.T, dir string) Assertion[T] { 90 | snapshots := map[string][]byte{} 91 | walkFiles(t, dir, func(path, name string) error { 92 | data, err := os.ReadFile(path) 93 | if err != nil { 94 | t.Errorf("error while reading fixture %q: %s", path, err) 95 | return nil 96 | } 97 | snapshots[name] = data 98 | return nil 99 | }) 100 | 101 | return func(t *testing.T, s *Scenario[T], outputs []client.Object) { 102 | expected, ok := snapshots[s.Name] 103 | if !ok { 104 | return 105 | } 106 | 107 | data, err := yaml.Marshal(&outputs) 108 | if err != nil { 109 | t.Errorf("error while marshalling outputs: %s", err) 110 | } 111 | 112 | if os.Getenv("ENO_GEN_SNAPSHOTS") != "" { 113 | err = os.WriteFile(filepath.Join(dir, s.Name+".yaml"), data, 0644) 114 | if err != nil { 115 | t.Errorf("error while writing snapshot %q: %s", s.Name, err) 116 | } 117 | return 118 | } 119 | 120 | if !bytes.Equal(data, expected) { 121 | t.Errorf("outputs do not match the snapshot - re-run tests with ENO_GEN_SNAPSHOTS=true to update them") 122 | } 123 | } 124 | } 125 | 126 | func walkFiles(t *testing.T, dir string, fn func(path, name string) error) { 127 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 128 | if err != nil || info.IsDir() { 129 | return err 130 | } 131 | ext := filepath.Ext(info.Name()) 132 | if ext != ".yaml" && ext != ".yml" && ext != ".json" { 133 | return nil 134 | } 135 | return fn(path, info.Name()[:len(info.Name())-len(ext)]) 136 | }) 137 | if err != nil { 138 | t.Errorf("error while walking files: %s", err) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /pkg/functiontest/testing_test.go: -------------------------------------------------------------------------------- 1 | package functiontest 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | "testing" 7 | 8 | corev1 "k8s.io/api/core/v1" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | ) 11 | 12 | func TestEvaluateBasics(t *testing.T) { 13 | fn := func(inputs struct{}) ([]client.Object, error) { 14 | output := &corev1.Pod{} 15 | output.Name = "test-pod" 16 | return []client.Object{output}, nil 17 | } 18 | 19 | Evaluate(t, fn, Scenario[struct{}]{ 20 | Name: "example-test", 21 | Inputs: struct{}{}, 22 | Assertion: func(t *testing.T, scen *Scenario[struct{}], outputs []client.Object) { 23 | if len(outputs) != 1 { 24 | t.Errorf("expected 1 output, got %d", len(outputs)) 25 | } 26 | }, 27 | }) 28 | } 29 | 30 | func TestAssertionChain(t *testing.T) { 31 | fn := func(inputs struct{}) ([]client.Object, error) { 32 | output := &corev1.Pod{} 33 | output.Name = "test-pod" 34 | return []client.Object{output}, nil 35 | } 36 | 37 | calls := atomic.Int64{} 38 | Evaluate(t, fn, Scenario[struct{}]{ 39 | Name: "example-test", 40 | Inputs: struct{}{}, 41 | Assertion: AssertionChain( 42 | func(t *testing.T, scen *Scenario[struct{}], outputs []client.Object) { 43 | calls.Add(1) 44 | }, 45 | func(t *testing.T, scen *Scenario[struct{}], outputs []client.Object) { 46 | calls.Add(1) 47 | }, 48 | ), 49 | }) 50 | 51 | if calls.Load() != 2 { 52 | t.Errorf("expected 2 calls, got %d", calls.Load()) 53 | } 54 | } 55 | 56 | func TestLoadScenarios(t *testing.T) { 57 | fn := func(inputs map[string]any) ([]client.Object, error) { 58 | output := &corev1.Pod{} 59 | output.Name = "test-pod" 60 | return []client.Object{output}, nil 61 | } 62 | 63 | var inputs []map[string]any 64 | var lock sync.Mutex 65 | assertion := func(t *testing.T, scen *Scenario[map[string]any], outputs []client.Object) { 66 | lock.Lock() 67 | inputs = append(inputs, scen.Inputs) 68 | lock.Unlock() 69 | } 70 | 71 | scenarios := LoadScenarios(t, "fixtures", assertion) 72 | Evaluate(t, fn, scenarios...) 73 | 74 | if len(inputs) != 3 { 75 | t.Fatalf("expected 3 inputs, got %d", len(inputs)) 76 | } 77 | } 78 | 79 | func TestLoadSnapshots(t *testing.T) { 80 | fn := func(inputs map[string]any) ([]client.Object, error) { 81 | output := &corev1.Pod{} 82 | output.Name = "test-pod" 83 | return []client.Object{output}, nil 84 | } 85 | 86 | assertion := LoadSnapshots[map[string]any](t, "snapshots") 87 | scenarios := LoadScenarios(t, "fixtures", assertion) 88 | Evaluate(t, fn, scenarios...) 89 | } 90 | -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/basic-chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-chart", 3 | "description": "chart to test eno rendering", 4 | "type": "application", 5 | "version": "0.1.0" 6 | } -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/basic-chart/templates/cm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Values.name }} 5 | data: 6 | some: "value" 7 | input: {{ toJson .Values.foo | quote }} 8 | {{- if .Values.foo }} 9 | inputResourceName: {{ .Values.foo.metadata.name }} 10 | {{- end }} 11 | -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/basic-chart/templates/skipped-but-with-fancy-comment.yaml: -------------------------------------------------------------------------------- 1 | # This comment forces the file to be included in the rendered output :) 2 | {{- if false }} 3 | apiVersion: v1 4 | kind: ConfigMap 5 | metadata: 6 | name: another-config-map 7 | data: 8 | some: "value" 9 | {{- end }} -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/basic-chart/templates/unknown.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: somegroup.io/v9001 2 | kind: ATypeNotKnownByTheScheme 3 | metadata: 4 | name: foo -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/hook-chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-hook-chart", 3 | "description": "chart to test eno rendering helm chart with hook", 4 | "type": "application", 5 | "version": "0.1.0" 6 | } -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/hook-chart/templates/cm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ .Values.name }} 5 | annotations: 6 | "helm.sh/hook": "post-install,post-upgrade" 7 | "helm.sh/hook-weight": "1" 8 | "helm.sh/hook-delete-policy": "before-hook-creation" 9 | data: 10 | some: "value" 11 | 12 | -------------------------------------------------------------------------------- /pkg/helmshim/fixtures/hook-chart/templates/unknown.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: somegroup.io/v9001 2 | kind: ATypeNotKnownByTheScheme 3 | metadata: 4 | name: foo 5 | -------------------------------------------------------------------------------- /pkg/helmshim/helm.go: -------------------------------------------------------------------------------- 1 | package helmshim 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | 11 | "github.com/Azure/eno/pkg/function" 12 | "helm.sh/helm/v3/pkg/action" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | "k8s.io/apimachinery/pkg/util/yaml" 15 | ) 16 | 17 | var ( 18 | ErrChartNotFound = errors.New("the requested chart could not be loaded") 19 | ErrRenderAction = errors.New("the chart could not be rendered with the given values") 20 | ErrCannotParseChart = errors.New("helm produced a set of manifests that is not parseable") 21 | ErrConstructingValues = errors.New("error while constructing helm values") 22 | ) 23 | 24 | // MustRenderChart is the entrypoint to the Helm shim. 25 | // 26 | // The most basic shim cmd's main func only needs one line: 27 | // > helmshim.MustRenderChart(helmshim.ParseFlags()...) 28 | func MustRenderChart(opts ...RenderOption) { 29 | err := RenderChart(opts...) 30 | if err != nil { 31 | fmt.Fprintf(os.Stderr, "error: %s\n", err) 32 | os.Exit(1) 33 | } 34 | } 35 | 36 | // isNullOrEmptyObject checks if the given unstructured object is equivalent to an empty K8S object. 37 | // This is used when then input helm chart includes an empty target (for example: empty yaml file with comments). 38 | func isNullOrEmptyObject(o *unstructured.Unstructured) bool { 39 | if o == nil { 40 | return true 41 | } 42 | if len(o.Object) > 0 { 43 | // if the object has any fields, it is not null 44 | return false 45 | } 46 | b, err := json.Marshal(o) 47 | if err != nil { 48 | return false 49 | } 50 | return string(b) == "null" || string(b) == "{}" 51 | } 52 | 53 | func RenderChart(opts ...RenderOption) error { 54 | a := action.NewInstall(&action.Configuration{}) 55 | a.ReleaseName = "eno-helm-shim" 56 | a.Namespace = "default" 57 | a.DryRun = true 58 | a.Replace = true 59 | a.ClientOnly = true 60 | a.IncludeCRDs = true 61 | 62 | o := &options{ 63 | Action: a, 64 | ValuesFunc: inputsToValues, 65 | ChartLoader: defaultChartLoader, 66 | } 67 | for _, opt := range opts { 68 | opt.apply(o) 69 | } 70 | 71 | if o.Reader == nil { 72 | var err error 73 | o.Reader, err = function.NewDefaultInputReader() 74 | if err != nil { 75 | return err 76 | } 77 | } 78 | 79 | var usingDefaultWriter bool 80 | if o.Writer == nil { 81 | usingDefaultWriter = true 82 | o.Writer = function.NewDefaultOutputWriter() 83 | } 84 | 85 | c, err := o.ChartLoader() 86 | if err != nil { 87 | return errors.Join(ErrChartNotFound, err) 88 | } 89 | 90 | vals, err := o.ValuesFunc(o.Reader) 91 | if err != nil { 92 | return errors.Join(ErrConstructingValues, err) 93 | } 94 | 95 | rel, err := a.Run(c, vals) 96 | if err != nil { 97 | return errors.Join(ErrRenderAction, err) 98 | } 99 | 100 | b := bytes.NewBufferString(rel.Manifest) 101 | // append manifest from hook 102 | for _, hook := range rel.Hooks { 103 | fmt.Fprintf(b, "---\n# Source: %s\n%s\n", hook.Name, hook.Manifest) 104 | } 105 | 106 | d := yaml.NewYAMLToJSONDecoder(b) 107 | for { 108 | m := &unstructured.Unstructured{} 109 | err = d.Decode(m) 110 | if err == io.EOF { 111 | break 112 | } else if err != nil { 113 | return errors.Join(ErrCannotParseChart, err) 114 | } 115 | if isNullOrEmptyObject(m) { 116 | continue 117 | } 118 | if err := o.Writer.Add(m); err != nil { 119 | return fmt.Errorf("adding object %s to output writer: %w", m, err) 120 | } 121 | } 122 | 123 | if usingDefaultWriter { 124 | return o.Writer.Write() 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func inputsToValues(i *function.InputReader) (map[string]any, error) { 131 | m := map[string]any{} 132 | for k, o := range i.All() { 133 | m[k] = o.Object 134 | } 135 | return m, nil 136 | } 137 | -------------------------------------------------------------------------------- /pkg/helmshim/helm_test.go: -------------------------------------------------------------------------------- 1 | package helmshim 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/Azure/eno/pkg/function" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | ) 12 | 13 | func TestIsNullOrEmptyObject(t *testing.T) { 14 | cases := []struct { 15 | name string 16 | o *unstructured.Unstructured 17 | want bool 18 | }{ 19 | { 20 | name: "nil object", 21 | o: nil, 22 | want: true, 23 | }, 24 | { 25 | name: "empty object with nil object map", 26 | o: &unstructured.Unstructured{}, 27 | want: true, 28 | }, 29 | { 30 | name: "empty object", 31 | o: &unstructured.Unstructured{ 32 | Object: map[string]any{}, 33 | }, 34 | want: true, 35 | }, 36 | { 37 | name: "non-empty object", 38 | o: &unstructured.Unstructured{ 39 | Object: map[string]any{ 40 | "apiVersion": "v1", 41 | "kind": "ConfigMap", 42 | }, 43 | }, 44 | want: false, 45 | }, 46 | } 47 | 48 | for _, c := range cases { 49 | t.Run(c.name, func(t *testing.T) { 50 | got := isNullOrEmptyObject(c.o) 51 | assert.Equal(t, c.want, got) 52 | }) 53 | } 54 | } 55 | 56 | func TestRenderChart(t *testing.T) { 57 | output := bytes.NewBuffer(nil) 58 | o := function.NewOutputWriter(output, nil) 59 | 60 | input := bytes.NewBufferString(`{ "items": [{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": { "name": "test-cm", "annotations": { "eno.azure.io/input-key": "foo" } } }] }`) 61 | i, err := function.NewInputReader(input) 62 | require.NoError(t, err) 63 | err = RenderChart(WithInputReader(i), WithOutputWriter(o), WithChartPath("fixtures/basic-chart")) 64 | require.NoError(t, err) 65 | err = o.Write() 66 | require.NoError(t, err) 67 | assert.Equal(t, "{\"apiVersion\":\"config.kubernetes.io/v1\",\"kind\":\"ResourceList\",\"items\":[{\"apiVersion\":\"v1\",\"data\":{\"input\":\"{\\\"apiVersion\\\":\\\"v1\\\",\\\"kind\\\":\\\"ConfigMap\\\",\\\"metadata\\\":{\\\"annotations\\\":{\\\"eno.azure.io/input-key\\\":\\\"foo\\\"},\\\"name\\\":\\\"test-cm\\\"}}\",\"inputResourceName\":\"test-cm\",\"some\":\"value\"},\"kind\":\"ConfigMap\",\"metadata\":{\"name\":null}},{\"apiVersion\":\"somegroup.io/v9001\",\"kind\":\"ATypeNotKnownByTheScheme\",\"metadata\":{\"name\":\"foo\"}}]}\n", output.String()) 68 | } 69 | 70 | func TestRenderChartWithCustomValues(t *testing.T) { 71 | output := bytes.NewBuffer(nil) 72 | o := function.NewOutputWriter(output, nil) 73 | i, err := function.NewInputReader(bytes.NewBufferString("{}")) 74 | require.NoError(t, err) 75 | 76 | err = RenderChart( 77 | WithChartPath("fixtures/basic-chart"), 78 | WithInputReader(i), 79 | WithOutputWriter(o), 80 | WithValuesFunc(func(ir *function.InputReader) (map[string]any, error) { 81 | return map[string]any{"name": "my-test-cm"}, nil 82 | })) 83 | require.NoError(t, err) 84 | err = o.Write() 85 | require.NoError(t, err) 86 | assert.Equal(t, "{\"apiVersion\":\"config.kubernetes.io/v1\",\"kind\":\"ResourceList\",\"items\":[{\"apiVersion\":\"v1\",\"data\":{\"input\":\"null\",\"some\":\"value\"},\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"my-test-cm\"}},{\"apiVersion\":\"somegroup.io/v9001\",\"kind\":\"ATypeNotKnownByTheScheme\",\"metadata\":{\"name\":\"foo\"}}]}\n", output.String()) 87 | } 88 | 89 | func TestRenderChartWithHelmHook(t *testing.T) { 90 | output := bytes.NewBuffer(nil) 91 | o := function.NewOutputWriter(output, nil) 92 | i, err := function.NewInputReader(bytes.NewBufferString("{}")) 93 | require.NoError(t, err) 94 | 95 | err = RenderChart( 96 | WithChartPath("fixtures/hook-chart"), 97 | WithInputReader(i), 98 | WithOutputWriter(o), 99 | WithValuesFunc(func(ir *function.InputReader) (map[string]any, error) { 100 | return map[string]any{"name": "my-test-cm"}, nil 101 | })) 102 | require.NoError(t, err) 103 | err = o.Write() 104 | require.NoError(t, err) 105 | assert.Equal(t, "{\"apiVersion\":\"config.kubernetes.io/v1\",\"kind\":\"ResourceList\",\"items\":[{\"apiVersion\":\"somegroup.io/v9001\",\"kind\":\"ATypeNotKnownByTheScheme\",\"metadata\":{\"name\":\"foo\"}},{\"apiVersion\":\"v1\",\"data\":{\"some\":\"value\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{\"helm.sh/hook\":\"post-install,post-upgrade\",\"helm.sh/hook-delete-policy\":\"before-hook-creation\",\"helm.sh/hook-weight\":\"1\"},\"name\":\"my-test-cm\"}}]}\n", output.String()) 106 | } 107 | -------------------------------------------------------------------------------- /pkg/helmshim/options.go: -------------------------------------------------------------------------------- 1 | package helmshim 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/Azure/eno/pkg/function" 7 | "helm.sh/helm/v3/pkg/action" 8 | "helm.sh/helm/v3/pkg/chart" 9 | "helm.sh/helm/v3/pkg/chart/loader" 10 | ) 11 | 12 | type ValuesFunc func(*function.InputReader) (map[string]any, error) 13 | 14 | // ChartLoader is the function for loading a helm chart. 15 | type ChartLoader func() (*chart.Chart, error) 16 | 17 | func defaultChartLoader() (*chart.Chart, error) { 18 | return loader.Load("./chart") 19 | } 20 | 21 | type options struct { 22 | Action *action.Install 23 | ValuesFunc ValuesFunc 24 | ChartLoader ChartLoader 25 | Reader *function.InputReader 26 | Writer *function.OutputWriter 27 | } 28 | 29 | type RenderOption func(*options) 30 | 31 | func (ro RenderOption) apply(o *options) { 32 | ro(o) 33 | } 34 | 35 | func WithNamespace(ns string) RenderOption { 36 | return RenderOption(func(o *options) { 37 | if o == nil { 38 | return 39 | } 40 | o.Action.Namespace = ns 41 | }) 42 | } 43 | 44 | func WithValuesFunc(fn ValuesFunc) RenderOption { 45 | return RenderOption(func(o *options) { 46 | if o == nil { 47 | return 48 | } 49 | o.ValuesFunc = fn 50 | }) 51 | } 52 | 53 | func WithChartPath(path string) RenderOption { 54 | return RenderOption(func(o *options) { 55 | if o == nil { 56 | return 57 | } 58 | o.ChartLoader = func() (*chart.Chart, error) { 59 | return loader.Load(path) 60 | } 61 | }) 62 | } 63 | 64 | func WithChartLoader(cl ChartLoader) RenderOption { 65 | return RenderOption(func(o *options) { 66 | if o == nil { 67 | return 68 | } 69 | o.ChartLoader = cl 70 | }) 71 | } 72 | 73 | func WithInputReader(r *function.InputReader) RenderOption { 74 | return RenderOption(func(o *options) { 75 | if o == nil { 76 | return 77 | } 78 | o.Reader = r 79 | }) 80 | } 81 | 82 | func WithOutputWriter(w *function.OutputWriter) RenderOption { 83 | return RenderOption(func(o *options) { 84 | if o == nil { 85 | return 86 | } 87 | o.Writer = w 88 | }) 89 | } 90 | 91 | func WithReleaseName(rn string) RenderOption { 92 | return RenderOption(func(o *options) { 93 | if o == nil { 94 | return 95 | } 96 | o.Action.ReleaseName = rn 97 | }) 98 | } 99 | 100 | func ParseFlags() []RenderOption { 101 | ns := flag.String("ns", "default", "Namespace for the Helm release") 102 | chart := flag.String("chart", ".", "Path to the Helm chart") 103 | flag.Parse() 104 | 105 | return []RenderOption{WithNamespace(*ns), WithChartPath(*chart)} 106 | } 107 | -------------------------------------------------------------------------------- /pkg/krm/functions/api/v1/doc.go: -------------------------------------------------------------------------------- 1 | // +k8s:deepcopy-gen=package 2 | 3 | package v1 4 | -------------------------------------------------------------------------------- /pkg/krm/functions/api/v1/register.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/runtime/schema" 5 | ) 6 | 7 | var SchemeGroupVersion = schema.GroupVersion{Group: "config.kubernetes.io", Version: "v1"} 8 | 9 | const ( 10 | ResourceListKind = "ResourceList" 11 | ) 12 | 13 | func (obj *ResourceList) GetObjectKind() schema.ObjectKind { return obj } 14 | func (obj *ResourceList) SetGroupVersionKind(gvk schema.GroupVersionKind) { 15 | obj.APIVersion, obj.Kind = gvk.ToAPIVersionAndKind() 16 | } 17 | func (obj *ResourceList) GroupVersionKind() schema.GroupVersionKind { 18 | return schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/krm/functions/api/v1/resource_list.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 5 | ) 6 | 7 | // ResourceList ResourceList is the input/output wire format for KRM functions. 8 | // 9 | // swagger:model ResourceList 10 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 11 | type ResourceList struct { 12 | // apiVersion of ResourceList 13 | APIVersion string `json:"apiVersion"` 14 | 15 | // kind of ResourceList i.e. `ResourceList` 16 | Kind string `json:"kind"` 17 | 18 | // [input/output] 19 | // Items is a list of Kubernetes objects: 20 | // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#types-kinds). 21 | // 22 | // A function will read this field in the input ResourceList and populate 23 | // this field in the output ResourceList. 24 | Items []*unstructured.Unstructured `json:"items"` 25 | 26 | // [input] 27 | // FunctionConfig is an optional Kubernetes object for passing arguments to a 28 | // function invocation. 29 | // +optional 30 | FunctionConfig *unstructured.Unstructured `json:"functionConfig,omitempty"` 31 | 32 | // [output] 33 | // Results is an optional list that can be used by function to emit results 34 | // for observability and debugging purposes. 35 | // +optional 36 | Results []*Result `json:"results,omitempty"` 37 | } 38 | -------------------------------------------------------------------------------- /pkg/krm/functions/api/v1/result.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | // Result result 4 | // 5 | // swagger:model Result 6 | type Result struct { 7 | // Message is a human readable message. 8 | Message string `json:"message"` 9 | 10 | // TODO: Missing `Field`, avoiding it for now because interface{} fields are not allowed by deepcopy-gen. 11 | 12 | // file 13 | // +optional 14 | File *ResultFile `json:"file,omitempty"` 15 | 16 | // resource ref 17 | // +optional 18 | ResourceRef *ResultResourceRef `json:"resourceRef,omitempty"` 19 | 20 | // Severity is the severity of a result: 21 | // 22 | // "error": indicates an error result. 23 | // "warning": indicates a warning result. 24 | // "info": indicates an informational result. 25 | // 26 | // Enum: [error warning info] 27 | // +optional 28 | Severity string `json:"severity,omitempty"` 29 | 30 | // Tags is an unstructured key value map stored with a result that may be set 31 | // by external tools to store and retrieve arbitrary metadata. 32 | // +optional 33 | Tags map[string]string `json:"tags,omitempty"` 34 | } 35 | 36 | const ( 37 | 38 | // ResultSeverityError captures enum value "error" 39 | ResultSeverityError string = "error" 40 | 41 | // ResultSeverityWarning captures enum value "warning" 42 | ResultSeverityWarning string = "warning" 43 | 44 | // ResultSeverityInfo captures enum value "info" 45 | ResultSeverityInfo string = "info" 46 | ) 47 | 48 | // ResultFile File references a file containing the resource. 49 | // 50 | // swagger:model ResultFile 51 | type ResultFile struct { 52 | // Path is the OS agnostic, slash-delimited, relative path. 53 | // e.g. `some-dir/some-file.yaml`. 54 | Path string `json:"path"` 55 | 56 | // Index of the object in a multi-object YAML file. 57 | // +optional 58 | Index float64 `json:"index,omitempty"` 59 | } 60 | 61 | // ResultResourceRef ResourceRef is the metadata for referencing a Kubernetes object 62 | // associated with a result. 63 | // 64 | // swagger:model ResultResourceRef 65 | type ResultResourceRef struct { 66 | 67 | // APIVersion refers to the `apiVersion` field of the object manifest. 68 | APIVersion string `json:"apiVersion"` 69 | 70 | // Kind refers to the `kind` field of the object. 71 | Kind string `json:"kind"` 72 | 73 | // Name refers to the `metadata.name` field of the object manifest. 74 | Name string `json:"name"` 75 | 76 | // Namespace refers to the `metadata.namespace` field of the object manifest. 77 | // +optional 78 | Namespace string `json:"namespace,omitempty"` 79 | } 80 | -------------------------------------------------------------------------------- /pkg/krm/functions/api/v1/swagger.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | title: KRM Functions Specification (ResourceList) 4 | version: v1 5 | definitions: 6 | ResourceList: 7 | type: object 8 | description: ResourceList is the input/output wire format for KRM functions. 9 | x-kubernetes-group-version-kind: 10 | - group: config.kubernetes.io 11 | kind: ResourceList 12 | version: v1 13 | required: 14 | - items 15 | properties: 16 | apiVersion: 17 | description: apiVersion of ResourceList 18 | type: string 19 | kind: 20 | description: kind of ResourceList i.e. `ResourceList` 21 | type: string 22 | items: 23 | type: array 24 | description: | 25 | [input/output] 26 | Items is a list of Kubernetes objects: 27 | https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#types-kinds). 28 | 29 | A function will read this field in the input ResourceList and populate 30 | this field in the output ResourceList. 31 | items: 32 | type: object 33 | functionConfig: 34 | type: object 35 | description: | 36 | [input] 37 | FunctionConfig is an optional Kubernetes object for passing arguments to a 38 | function invocation. 39 | results: 40 | type: array 41 | description: | 42 | [output] 43 | Results is an optional list that can be used by function to emit results 44 | for observability and debugging purposes. 45 | items: 46 | "$ref": "#/definitions/Result" 47 | Result: 48 | type: object 49 | required: 50 | - message 51 | properties: 52 | message: 53 | type: string 54 | description: Message is a human readable message. 55 | severity: 56 | type: string 57 | enum: 58 | - error 59 | - warning 60 | - info 61 | default: error 62 | description: | 63 | Severity is the severity of a result: 64 | 65 | "error": indicates an error result. 66 | "warning": indicates a warning result. 67 | "info": indicates an informational result. 68 | resourceRef: 69 | type: object 70 | description: | 71 | ResourceRef is the metadata for referencing a Kubernetes object 72 | associated with a result. 73 | required: 74 | - apiVersion 75 | - kind 76 | - name 77 | properties: 78 | apiVersion: 79 | description: 80 | APIVersion refers to the `apiVersion` field of the object 81 | manifest. 82 | type: string 83 | kind: 84 | description: Kind refers to the `kind` field of the object. 85 | type: string 86 | namespace: 87 | description: 88 | Namespace refers to the `metadata.namespace` field of the object 89 | manifest. 90 | type: string 91 | name: 92 | description: 93 | Name refers to the `metadata.name` field of the object manifest. 94 | type: string 95 | field: 96 | type: object 97 | description: | 98 | Field is the reference to a field in the object. 99 | If defined, `ResourceRef` must also be provided. 100 | required: 101 | - path 102 | properties: 103 | path: 104 | type: string 105 | description: | 106 | Path is the JSON path of the field 107 | e.g. `spec.template.spec.containers[3].resources.limits.cpu` 108 | currentValue: 109 | description: | 110 | CurrrentValue is the current value of the field. 111 | Can be any value - string, number, boolean, array or object. 112 | proposedValue: 113 | description: | 114 | PropposedValue is the proposed value of the field to fix an issue. 115 | Can be any value - string, number, boolean, array or object. 116 | file: 117 | type: object 118 | description: File references a file containing the resource. 119 | required: 120 | - path 121 | properties: 122 | path: 123 | type: string 124 | description: | 125 | Path is the OS agnostic, slash-delimited, relative path. 126 | e.g. `some-dir/some-file.yaml`. 127 | index: 128 | type: number 129 | default: 0 130 | description: Index of the object in a multi-object YAML file. 131 | tags: 132 | type: object 133 | additionalProperties: 134 | type: string 135 | description: | 136 | Tags is an unstructured key value map stored with a result that may be set 137 | by external tools to store and retrieve arbitrary metadata. 138 | paths: {} 139 | -------------------------------------------------------------------------------- /pkg/krm/functions/api/v1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | // Code generated by deepcopy-gen. DO NOT EDIT. 5 | 6 | package v1 7 | 8 | import ( 9 | unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | runtime "k8s.io/apimachinery/pkg/runtime" 11 | ) 12 | 13 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 14 | func (in *ResourceList) DeepCopyInto(out *ResourceList) { 15 | *out = *in 16 | if in.Items != nil { 17 | in, out := &in.Items, &out.Items 18 | *out = make([]*unstructured.Unstructured, len(*in)) 19 | for i := range *in { 20 | if (*in)[i] != nil { 21 | in, out := &(*in)[i], &(*out)[i] 22 | *out = (*in).DeepCopy() 23 | } 24 | } 25 | } 26 | if in.FunctionConfig != nil { 27 | in, out := &in.FunctionConfig, &out.FunctionConfig 28 | *out = (*in).DeepCopy() 29 | } 30 | if in.Results != nil { 31 | in, out := &in.Results, &out.Results 32 | *out = make([]*Result, len(*in)) 33 | for i := range *in { 34 | if (*in)[i] != nil { 35 | in, out := &(*in)[i], &(*out)[i] 36 | *out = new(Result) 37 | (*in).DeepCopyInto(*out) 38 | } 39 | } 40 | } 41 | return 42 | } 43 | 44 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceList. 45 | func (in *ResourceList) DeepCopy() *ResourceList { 46 | if in == nil { 47 | return nil 48 | } 49 | out := new(ResourceList) 50 | in.DeepCopyInto(out) 51 | return out 52 | } 53 | 54 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 55 | func (in *ResourceList) DeepCopyObject() runtime.Object { 56 | if c := in.DeepCopy(); c != nil { 57 | return c 58 | } 59 | return nil 60 | } 61 | 62 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 63 | func (in *Result) DeepCopyInto(out *Result) { 64 | *out = *in 65 | if in.File != nil { 66 | in, out := &in.File, &out.File 67 | *out = new(ResultFile) 68 | **out = **in 69 | } 70 | if in.ResourceRef != nil { 71 | in, out := &in.ResourceRef, &out.ResourceRef 72 | *out = new(ResultResourceRef) 73 | **out = **in 74 | } 75 | if in.Tags != nil { 76 | in, out := &in.Tags, &out.Tags 77 | *out = make(map[string]string, len(*in)) 78 | for key, val := range *in { 79 | (*out)[key] = val 80 | } 81 | } 82 | return 83 | } 84 | 85 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Result. 86 | func (in *Result) DeepCopy() *Result { 87 | if in == nil { 88 | return nil 89 | } 90 | out := new(Result) 91 | in.DeepCopyInto(out) 92 | return out 93 | } 94 | 95 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 96 | func (in *ResultFile) DeepCopyInto(out *ResultFile) { 97 | *out = *in 98 | return 99 | } 100 | 101 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResultFile. 102 | func (in *ResultFile) DeepCopy() *ResultFile { 103 | if in == nil { 104 | return nil 105 | } 106 | out := new(ResultFile) 107 | in.DeepCopyInto(out) 108 | return out 109 | } 110 | 111 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 112 | func (in *ResultResourceRef) DeepCopyInto(out *ResultResourceRef) { 113 | *out = *in 114 | return 115 | } 116 | 117 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResultResourceRef. 118 | func (in *ResultResourceRef) DeepCopy() *ResultResourceRef { 119 | if in == nil { 120 | return nil 121 | } 122 | out := new(ResultResourceRef) 123 | in.DeepCopyInto(out) 124 | return out 125 | } 126 | --------------------------------------------------------------------------------