├── .github ├── CODEOWNERS └── workflows │ ├── build-and-test.yaml │ ├── publish-dev.yaml │ └── publish-release.yaml ├── .gitignore ├── LICENSE ├── README.md ├── crd ├── function-test.yaml ├── resource-function.yaml ├── resource-template.yaml ├── value-function.yaml └── workflow.yaml ├── docs ├── function-test.md ├── glossary.md ├── resource-function.md ├── resource-template.md ├── value-funciton.md └── workflow.md ├── examples ├── compare-directives.koreo ├── dummy-crds.yaml ├── k8s-sa.koreo ├── patch-loop-testing.koreo ├── resource-functions.koreo ├── resource-templates.koreo ├── value-functions.koreo └── workflows.koreo ├── pdm.lock ├── pyproject.toml ├── src └── koreo │ ├── __init__.py │ ├── cache.py │ ├── cel │ ├── encoder.py │ ├── evaluation.py │ ├── functions.py │ ├── prepare.py │ └── structure_extractor.py │ ├── conditions.py │ ├── constants.py │ ├── function_test │ ├── prepare.py │ ├── run.py │ └── structure.py │ ├── predicate_helpers.py │ ├── ref_helpers.py │ ├── registry.py │ ├── resource_function │ ├── prepare.py │ ├── reconcile │ │ ├── __init__.py │ │ ├── kind_lookup.py │ │ └── validate.py │ └── structure.py │ ├── resource_template │ ├── prepare.py │ └── structure.py │ ├── result.py │ ├── schema.py │ ├── value_function │ ├── prepare.py │ ├── reconcile.py │ └── structure.py │ └── workflow │ ├── prepare.py │ ├── reconcile.py │ └── structure.py └── tests ├── __init__.py └── koreo ├── cel ├── test_encoder.py ├── test_evaluation.py ├── test_functions.py └── test_structure.py ├── function_test ├── test_prepare.py └── test_run.py ├── resource_function └── reconcile │ ├── test_kind_lookup.py │ └── test_validate.py ├── resource_template └── test_prepare.py ├── test_cache.py ├── test_conditions.py ├── test_registry.py ├── test_result.py ├── value_function ├── test_prepare.py └── test_reconcile.py └── workflow └── test_reconcile.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @robertkluin @tylertreat @ericlarssen 2 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build-and-test: 11 | name: Build & Test 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: pdm-project/setup-pdm@v4 20 | with: 21 | python-version: "3.13" 22 | cache: true 23 | 24 | - name: Install dependencies 25 | run: pdm install --check 26 | 27 | - name: Run tests 28 | run: pdm run pytest 29 | -------------------------------------------------------------------------------- /.github/workflows/publish-dev.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Dev Tag 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | dev-tag: 7 | description: "Dev tag for the package (e.g., 1.0.0.dev1)" 8 | required: true 9 | 10 | jobs: 11 | publish-dev: 12 | name: Publish Dev Tag to PyPI 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: pdm-project/setup-pdm@v4 21 | with: 22 | python-version: "3.13" 23 | 24 | - name: Install dependencies 25 | run: pdm install --prod 26 | 27 | - name: Build package 28 | run: pdm build 29 | 30 | - name: Publish to PyPI (Pre-Release) 31 | env: 32 | PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 33 | run: pdm publish --username __token__ --password "$PYPI_TOKEN" --pre --version "${{ github.event.inputs.dev-tag }}" 34 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | publish: 10 | name: Publish to PyPI 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: pdm-project/setup-pdm@v4 19 | with: 20 | python-version: "3.13" 21 | 22 | - name: Install dependencies 23 | run: pdm install --prod 24 | 25 | - name: Build package 26 | run: pdm build 27 | 28 | - name: Publish to PyPI 29 | env: 30 | PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 31 | run: pdm publish --username __token__ --password "$PYPI_TOKEN" 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm-project.org/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # koreo 2 | 3 | Koreo is a Platform Engineering toolkit focused on making the Platform 4 | Engineer's life easier so that they can focus on making product 5 | developers lives' easier. 6 | 7 | Koreo makes it easy to make things work well together. 8 | 9 | ## Background and Why 10 | 11 | The original motivation for creating Koreo was configuring modern, dynamic 12 | cloud infrastructure. The initial use case was configuring and managing 13 | scalable, ephemeral, serverless systems. 14 | 15 | Many existing IaC tools play very poorly, or are even dangerous to use, with 16 | dynamic infrastructure. Most infrastructure is dynamic–we just like to imagine 17 | that it is static.­In practice, services crash, restart, need to scale, are 18 | rebalanced, and so forth. Infrastructure might be changed via a UI or CLI and 19 | the updates not made within the IaC leading to drift. Ideally our systems for 20 | managing infrastructure are able to perform the correct actions under changing 21 | conditions. 22 | 23 | There is a pattern for implementing such systems, [Kubernetes 24 | Controllers](https://kubernetes.io/docs/concepts/architecture/controller/). The 25 | underlying concept is using a control loop to continually pull resources closer 26 | to the specified configuration. Effectively it is the automation of what a 27 | human should do: watch the state of the system and if indicated take some 28 | actions to alter the system state moving it closer to the desired state. 29 | 30 | The second observation was that getting any one resource (meaning systems, 31 | services, or APIs) working once is usually straightforward. Integrating 32 | multiple resources together is much, much harder. Making many resources work 33 | together in a way that is repeatable and pleasant to interact with is very 34 | hard. 35 | 36 | In modern software development, the integration of systems is the core 37 | challenge Platform Engineering teams face. Our team has a background of 38 | applying product engineering mindset to infrastructure engineering problems, 39 | we've taken that approach to providing tools for Platform Engineering teams. 40 | 41 | ## Overview 42 | 43 | Koreo is build around two core conceptual primitives: 44 | [Workflows](/docs/workflow.md) and [Functions](/docs/glossary.md#function). An 45 | additional primitive, [`FunctionTest`](/docs/function-test.md), sets Koreo 46 | apart by making testing a first-class construct. 47 | 48 | On their own [Functions](/docs/glossary.md#function) do nothing, but they are 49 | the foundation of the system. The define component-specific control loops in a 50 | well-structured, but powerful way. 51 | 52 | ### Function 53 | 54 | There are two types of Functions: [`ValueFunctions`](/docs/value-funciton.md) 55 | and [`ResourceFunction`](/docs/resource-function.md). 56 | 57 | Functions define a control-loop that follows a specific structure. 58 | [`ValueFunctions`](/docs/value-funciton.md) have the simplest structure: 59 | precondition-checks, input data transformations (computations), and returning a 60 | result. [`ResourceFunction`](/docs/resource-function.md) follows the same 61 | pattern, except they specify an external object they will interact with and the 62 | CRUD actions that should be taken to make that object look as it should. 63 | 64 | [`ValueFunctions`](/docs/value-funciton.md) are "pure" in the functional 65 | programming sense; they are side-effect free. These are designed to perform 66 | computations like validating inputs or reshaping data structures. 67 | 68 | [`ResourceFunctions`](/docs/resource-function.md) interact with the Kubernetes 69 | API. They support reading, creating, updating, and deleting resources. They 70 | offer support for validating inputs, specifying rules for how to manage its 71 | resource, and extracting values for usage by other Functions. 72 | 73 | Engineers may optionally load static resource templates from simple 74 | [`ResourceTemplate`](/docs/resource-template.md) resources. 75 | [`ResourceFunctions`](/docs/resource-function.md) may dynamically compute the 76 | [`ResourceTemplate`](/docs/resource-template.md) to be loaded at runtime. This 77 | provides a simple, but controlled, means of offering different 78 | base-configurations to your end consumers. 79 | 80 | Both [Function](/docs/glossary.md#function) types may `return` values for usage 81 | within other Functions or to be surfaced as [`state`](/docs/glossary.md#state). 82 | This allows for the composition of robust, dynamic resource configurations. 83 | 84 | ### Workflow 85 | 86 | [`Workflows`](/docs/workflow.md) define the relationship between 87 | [Functions](/docs/glossary.md#function) and other 88 | [`Workflows`](/docs/workflow.md) (together known as 89 | [Logic](/docs/glossary.md#logic). Their job is to map, and possibly transform, 90 | the outputs from one piece of [Logic](/docs/glossary.md#logic) into another's 91 | inputs, then return an overall result. 92 | 93 | [`Workflows`](/docs/workflow.md) specify an entry-point, the 94 | [Logic](/docs/glossary.md#logic) to be run, how they should be run, and map 95 | values between the Logic of each step. 96 | 97 | The [Logic](/docs/glossary.md#logic) to be run is specified within "steps". The 98 | [`Workflow`](/docs/workflow.md) entry-point is specified as a special 99 | `configStep`. It is unique in that it receives the triggering values as an 100 | input. This allows context to be passed into a [`Workflow`](/docs/workflow.md), 101 | but discourages tightly coupling steps to the configuration structure. 102 | 103 | Additional [Logic](/docs/glossary.md#logic) is specified as a list of steps. 104 | Each [`Workflow`](/docs/workflow.md) step may provide input values to the 105 | [Logic](/docs/glossary.md#logic) it references, and may map return values from 106 | previous steps into another step's inputs. The input mappings are analyzed to 107 | automatically determine the execution order for Logic, and the steps may run 108 | concurrently where possible. Steps may specify conditions and state to be 109 | surfaced into the trigger-object's status. 110 | 111 | 112 | ### FunctionTest 113 | 114 | Often validating systems is very difficult. To help ensure systems are stable 115 | and predictable, Koreo includes a first-class contract testing construct: 116 | [`FunctionTest`](/docs/function-test.md). Using 117 | [`FunctionTest`](/docs/function-test.md) a developer can easily test happy-path 118 | sequences, test "variant" conditions, and error cases through out the reconcile 119 | loops. This allows for robust testing of error-conditions, detection of loops, 120 | and detection of accidental behavioral changes. 121 | 122 | ## Programming Model 123 | 124 | Koreo is effectively a structured, functional programming language designed for 125 | building and running interacting control-loops. It is designed to make creating 126 | asynchronous, event-driven systems predictable, reliable, and maintainable. 127 | 128 | It is crucial to remember the execution context: 129 | [control-loops](/docs/glossary.md#control-loop). 130 | [`Workflows`](/docs/workflow.md) are run periodically, either in response to 131 | resource changes or based on a timer. That means a 132 | [`Workflow`](/docs/workflow.md)'s Functions will be run repeatedly (over time). 133 | [`ValueFunction`](/docs/value-funciton.md) are pure, running them with the same 134 | inputs should always produce the same outputs. To help ensure stability and 135 | ease of programming, side-effects are isolated to 136 | [`ResourceFunctions`](/docs/resource-function.md). The job of a 137 | [`ResourceFunction`](/docs/resource-function.md) is to ensure the specification 138 | of the resource it manages matches the expected specification. The objects 139 | [`ResourceFunctions`](/docs/resource-function.md) manage are typically 140 | controlled (or used) by another controller, and hence 141 | [`ResourceFunction`](/docs/resource-function.md) acts as the interface to 142 | external systems. 143 | 144 | ### Hot Loading 145 | 146 | Koreo supports restart-free, hot-reloading of [Workflows](/docs/workflow.md), 147 | [Functions](/docs/glossary.md#function), 148 | [ResourceTemplates](/docs/resource-template.md), and 149 | [FunctionTests](/docs/function-test.md). This enables rapid development and 150 | testing of your systems without complex build/deploy processes. 151 | 152 | ### Namespace Priority 153 | Koreo allows for loading Workflows and Functions from namespaces in priority 154 | order. This makes altering behavior for select teams or providing an "release 155 | channels" more straightforward. 156 | 157 | Combined with hot-loading allows for development controllers to monitor testing 158 | / development namespaces to test new versions of your Workflow and Function 159 | code. 160 | 161 | ### Versioning 162 | Versioning may be leveraged via convention, and is strongly encouraged. 163 | Versioning enables resources to be evolved over time without breaking existing 164 | users. 165 | 166 | -------------------------------------------------------------------------------- /crd/resource-template.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: resourcetemplates.koreo.dev 5 | spec: 6 | scope: Namespaced 7 | group: koreo.dev 8 | names: 9 | kind: ResourceTemplate 10 | plural: resourcetemplates 11 | singular: resourcetemplate 12 | versions: 13 | - name: v1beta1 14 | served: true 15 | storage: true 16 | schema: 17 | openAPIV3Schema: 18 | type: object 19 | properties: 20 | spec: 21 | type: object 22 | properties: 23 | context: 24 | type: object 25 | nullable: true 26 | x-kubernetes-preserve-unknown-fields: true 27 | template: 28 | type: object 29 | nullable: false 30 | x-kubernetes-embedded-resource: true 31 | x-kubernetes-preserve-unknown-fields: true 32 | required: [template] 33 | status: 34 | x-kubernetes-preserve-unknown-fields: true 35 | type: object 36 | -------------------------------------------------------------------------------- /crd/value-function.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: valuefunctions.koreo.dev 5 | spec: 6 | scope: Namespaced 7 | group: koreo.dev 8 | names: 9 | kind: ValueFunction 10 | plural: valuefunctions 11 | singular: valuefunction 12 | versions: 13 | - name: v1beta1 14 | served: true 15 | storage: true 16 | schema: 17 | openAPIV3Schema: 18 | type: object 19 | properties: 20 | spec: 21 | type: object 22 | nullable: false 23 | properties: 24 | preconditions: 25 | nullable: false 26 | type: array 27 | maxItems: 20 28 | description: | 29 | Optional set of preconditions which will be evaluated to 30 | determine if the Function can, or should, be run and if not 31 | specifies the outcome. 32 | items: 33 | type: object 34 | properties: 35 | assert: 36 | type: string 37 | nullable: false 38 | x-kubernetes-validations: 39 | - rule: self.startsWith('=') 40 | message: | 41 | assertion must be an expression (start with '=') 42 | description: | 43 | A predicate which must evaluate to `true`, if it does 44 | not the specified outcome is returned. This should be 45 | a Koreo Expression and it may access the function's 46 | `inputs`. 47 | defaultReturn: 48 | type: object 49 | nullable: false 50 | x-kubernetes-preserve-unknown-fields: true 51 | description: A static, default return value. 52 | skip: 53 | type: object 54 | nullable: false 55 | properties: 56 | message: 57 | type: string 58 | nullable: false 59 | required: [message] 60 | description: | 61 | Indicates that the Function did not run due to a 62 | condition, such as a config value. Use message to 63 | indicate why. Note this is not an error. 64 | depSkip: 65 | type: object 66 | nullable: false 67 | properties: 68 | message: 69 | type: string 70 | nullable: false 71 | required: [message] 72 | description: | 73 | Indicates that the Function did not run due to a 74 | dependency not being ready or being skipped. Use 75 | message to indicate why. Note this is not an error. 76 | retry: 77 | type: object 78 | nullable: false 79 | properties: 80 | message: 81 | type: string 82 | nullable: false 83 | delay: 84 | type: integer 85 | nullable: false 86 | required: [message, delay] 87 | description: | 88 | Indicates that a condition is not yet met, so the 89 | function can not (or should not) evaluate yet. Wait 90 | and retry after delay seconds. 91 | permFail: 92 | type: object 93 | nullable: false 94 | properties: 95 | message: 96 | type: string 97 | nullable: false 98 | required: [message] 99 | description: | 100 | Indicates that an unrecoverable error has occurred, 101 | intervention is required to correct this condition. 102 | This will cause Workflows to stop retrying. Use 103 | message to provide information to correct the issue. 104 | required: [assert] 105 | oneOf: 106 | - required: [ok] 107 | - required: [skip] 108 | - required: [depSkip] 109 | - required: [retry] 110 | - required: [permFail] 111 | locals: 112 | type: object 113 | nullable: false 114 | x-kubernetes-preserve-unknown-fields: true 115 | description: | 116 | Constant values or Koreo Expressions which will make 117 | `return` more ergonomic to write. 118 | return: 119 | nullable: false 120 | type: object 121 | x-kubernetes-preserve-unknown-fields: true 122 | description: | 123 | The return value expression for this ValueFunction. It must 124 | be an object composed of constant values or Koreo 125 | Expressions with access to both `inputs` and `locals`. 126 | anyOf: 127 | - required: [preconditions] 128 | - required: [return] 129 | status: 130 | type: object 131 | x-kubernetes-preserve-unknown-fields: true 132 | -------------------------------------------------------------------------------- /docs/glossary.md: -------------------------------------------------------------------------------- 1 | # Koreo Glossary of Terminology 2 | 3 | ## C 4 | 5 | ### Condition 6 | A convention used on Kubernetes resources to communicate status information. 7 | Koreo may optionally set Conditions on a [parent object](#parent-object) based 8 | on the [Outcome](#outcome) from a [step](#step). 9 | 10 | ### Contract Testing 11 | Used to ensure that correctly structured API calls are made based on a set of 12 | inputs. 13 | 14 | ### Control Loop 15 | A control loop observes conditions and state. If the observed conditions or 16 | state do not meet the target state, then the control loop will attempt to bring 17 | them into alignment with the target state by making adjustments. 18 | 19 | ## D 20 | 21 | ### `DepSkip` 22 | An [Outcome](#outcome) that indicates a dependency is not yet ready. It means 23 | the [Logic](#logic) was skipped without an attempt to evaluate. 24 | 25 | ## E 26 | 27 | ### Expression 28 | See [Koreo Expression](#koreo-expression) 29 | 30 | ## F 31 | 32 | ### Function 33 | Refers to a [`ValueFunction`](#valuefunction) or 34 | [`ResourceFunction`](#resourcefunction). 35 | 36 | ### Function Test 37 | Koreo's built in [control loop](#control-loop) friendly testing framework. 38 | Allows for unit-testing style validation in addition to [contract 39 | testing](#contract-testing). 40 | 41 | ### Function Under Test 42 | Refers to a [`ValueFunction`](#valuefunction) or 43 | [`ResourceFunction`](#resourcefunction) that is being tested by a 44 | [`FunctionTest`](#functiontest). 45 | 46 | ## K 47 | 48 | ### Koreo 49 | Arguably the most pleasant to use Kubernetes templating and workflow 50 | orchestration system currently in existence. 51 | 52 | ### Koreo Expression 53 | A simple expression language that is modelled after 54 | [CEL](https://github.com/google/cel-spec/blob/master/doc/langdef.md), provides 55 | capabilities needed for basic logic, arithmetic, string manipulation, and data 56 | reshaping. 57 | 58 | ## L 59 | 60 | ### Logic 61 | Refers to a [Function](#function) or [`Workflow`](#workflow). Most often the 62 | term is used to refer to the [Function](#function) or [`Workflow`](#workflow) 63 | to be run as a [`Workflow`](#workflow) [step](#Step). 64 | 65 | ## M 66 | 67 | ### Managed Resource 68 | A Kubernetes resource that a [`ResourceFunction`](#resourcefunction) is 69 | managing to ensures its specification matches a [Target Resource 70 | Specification](#target-resource-specification) or reads values from (for 71 | `readonly` functions). 72 | 73 | ## O 74 | 75 | ### `Ok` 76 | The [Outcome](#outcome) that indicates a successful evaluation. A return-value 77 | may be present, if expected. 78 | 79 | ### Outcome 80 | Refers to the _return type_ of a [Function](#function) or 81 | [`Workflow`](#workflow), the types are [`Skip`](#skip), [`DepSkip`](#depskip), 82 | [`Retry`](#retry), [`PermFail`](#permfail), and [`Ok`](#ok). 83 | 84 | ## P 85 | 86 | ### Parent Object 87 | A Kubernetes object which is used to trigger [`Workflow`](#workflow) 88 | [reconciliations](#reconcile) and provide configuration to the 89 | [`Workflow`](#workflow) instance. 90 | 91 | ### `PermFail` 92 | An [Outcome](#outcome) that indicates a permanent failure condition that will 93 | require intervention in order to resolve. 94 | 95 | ## R 96 | 97 | ### Reconcile 98 | To run a [control loop](#control-loop) in order to ensure the conditions and 99 | observed state match the desired state. If they do not match, the differences 100 | will be _reconciled_ to bring them into alignment. 101 | 102 | ### `ResourceFunction` 103 | A [Function](#function) which manages or reads values from a [Managed 104 | Resource](#managed-resource). These functions are an interface to external 105 | state, which they may set and load. When managing a resource, they define a 106 | [control loop](#control-loop). 107 | 108 | ### `ResourceTemplate` 109 | Provides a simple means of specifying static values as a base [Target Resource 110 | Specification](#target-resource-specification). A `ResourceTemplate` may be 111 | dynamically loaded by a [`ResourceFunction`](#resourcefunction), allowing for 112 | configuration based template selection. The static values may be overlaid with 113 | values provided to (or computed by) a [`ResourceFunction`](#resourcefunction). 114 | 115 | ### `Retry` 116 | An [Outcome](#outcome) that indicates the [Logic](#logic) should be retried 117 | after a specified delay. Typically this indicates an active waiting status that 118 | is expected to self-resolve over time. 119 | 120 | ## S 121 | 122 | ### `Skip` 123 | An [Outcome](#outcome) that indicates the [Logic](#logic) was skipped without 124 | an attempt to evaluate due to an input or other condition. 125 | 126 | ### State 127 | Some of all of a [Logic's](#logic) return value which will be set on the 128 | [parent object's](#parent-object) `status.state` property. 129 | 130 | 131 | ### Step 132 | A [`Workflow`](#workflow) step specifies [Logic](#logic) to be run, how inputs 133 | from other steps will map into the [Logic's](#logic) inputs, if a 134 | [Condition](#condition) should be reported, and if any [`state`](#state) should 135 | be extracted and returned (either to a calling [`Workflow`](#workflow) or 136 | parent object). 137 | 138 | ## T 139 | 140 | ### Target Resource Specification 141 | The specification that a resource is expected to match after all [Koreo 142 | Expressions](#koreo-expression) have been evaluated and all overlays applied. 143 | The is the fully materialized resource view that will be applied to the 144 | cluster. 145 | 146 | ## V 147 | 148 | ### `ValueFunction` 149 | A pure function which may be used to perform validations, compute values, or 150 | restructure data. 151 | 152 | ## W 153 | 154 | ### `Workflow` 155 | Defines a collection of [Steps](#Step) to be run and manages their execution. 156 | -------------------------------------------------------------------------------- /docs/resource-template.md: -------------------------------------------------------------------------------- 1 | # ResourceTemplate 2 | 3 | In order to make customizations easier, Koreo provides `ResourceTemplate`. 4 | `ResourceTemplate` allows static resources to be defined which 5 | `ResourceFunctions` may then _overlay_ with dynamic values to produce a fully 6 | materialized resource. `ResourceFunctions` can dynamically compute the 7 | `ResourceTemplate` name, making it easy to support a range of use cases and 8 | configurations for a managed resource. By allowing the statically defined 9 | resource to be dynamically loaded, it reduces the need to create complex or 10 | verbose functions. 11 | 12 | For instance, resource templates may be provided for different environments, 13 | for specific resource types, or dynamically supplied configuration values. 14 | Templates are also useful for simple static templates to provide common 15 | configuration, such as regions. This allows the `ResourceFunction` to be 16 | responsible for defining the interface and applying the values, but templates 17 | to supply the bulk of "static" configuration. 18 | 19 | This model makes it easy to turn existing resources into templates, then use a 20 | function only to apply dynamic values. 21 | 22 | 23 | | Full Specification | Description | 24 | | :--------------------------| :-------------------- | 25 | | **`apiVersion`**: `koreo.dev/v1beta1` | Specification version | 26 | | **`kind`**: `ResourceTemplate` | Always `ResourceTemplate` | 27 | | **`metadata`**: | | 28 | | **` name`**: | Name of the `ResourceTemplate`| 29 | | **` namespace`**: | Namespace | 30 | | **`spec`**: | | 31 | | **` template`**: | The Function Under Test. | 32 | 33 | ## Usage 34 | 35 | The following sections elaborate on the key features of `ResourceTemplate` and 36 | their intended uses. 37 | 38 | ### `spec.template`: Static Resource Specification 39 | 40 | The `spec.template` is a static Target Resource Specification. Both 41 | `apiVersion` and `kind` must be provided, but everything else is optional. This 42 | static template will be (optionally) overlaid within the `ResourceFunction`. 43 | The `metadata.name` and `metadata.namespace` properties are _always_ overlaid 44 | by the `ResourceFunction`, so you need not specify them. 45 | 46 | 47 | ## Example ResourceTemplate 48 | 49 | The following `ResourceFunction` demonstrates some of the capabilities. 50 | 51 | ```yaml 52 | apiVersion: koreo.dev/v1beta1 53 | kind: ResourceTemplate 54 | metadata: 55 | # The template will be looked up by its name. 56 | name: docs-template-one.v1 57 | namespace: koreo-demo 58 | spec: 59 | # Template contains the static resource that will be used as the base. 60 | # apiVersion and kind are required. The template is the actual body, or some 61 | # portion thereof, which you'd like to set static values for. 62 | template: 63 | apiVersion: koreo.dev/v1beta1 64 | kind: TestDummy 65 | metadata: 66 | labels: 67 | docs.koreo.dev/example: template-label 68 | spec: 69 | value: one 70 | nested: 71 | - 1 72 | - 2 73 | --- 74 | apiVersion: koreo.dev/v1beta1 75 | kind: ResourceTemplate 76 | metadata: 77 | name: docs-template-two.v1 78 | namespace: koreo-demo 79 | spec: 80 | template: 81 | apiVersion: koreo.dev/v1beta1 82 | kind: TestDummy 83 | metadata: 84 | labels: 85 | docs.koreo.dev/example: template-label 86 | annotations: 87 | docs.koreo.dev/example: template-two 88 | spec: 89 | value: two 90 | structure: 91 | - name: doc 92 | - name: examples 93 | --- 94 | apiVersion: koreo.dev/v1beta1 95 | kind: ResourceFunction 96 | metadata: 97 | name: docs-template-function.v1 98 | namespace: koreo-demo 99 | spec: 100 | locals: 101 | template_name: ="docs-template-" + inputs.template + ".v1" 102 | 103 | # The apiConfig section remains the same. For security purposes, apiVersion, 104 | # kind, name, and namespace will be overlaid onto the template. 105 | apiConfig: 106 | apiVersion: koreo.dev/v1beta1 107 | kind: TestDummy 108 | plural: testdummies 109 | 110 | name: =inputs.metadata.name + "-template-docs" 111 | namespace: =inputs.metadata.namespace 112 | 113 | resourceTemplateRef: 114 | name: =locals.template_name 115 | 116 | overlay: 117 | metadata: =template.metadata.overlay(inputs.metadata) 118 | spec: 119 | value: =inputs.value 120 | addedProperty: =inputs.value * 17 121 | --- 122 | apiVersion: koreo.dev/v1beta1 123 | kind: FunctionTest 124 | metadata: 125 | name: docs-template-function.v1 126 | namespace: koreo-demo 127 | spec: 128 | functionRef: 129 | kind: ResourceFunction 130 | name: docs-template-function.v1 131 | 132 | # Template 'one' will be the base case. 133 | inputs: 134 | template: one 135 | value: 42 136 | metadata: 137 | name: test-demo 138 | namespace: tests 139 | labels: 140 | docs.koreo.dev/from-function: label 141 | 142 | testCases: 143 | - label: Template One 144 | expectResource: 145 | apiVersion: koreo.dev/v1beta1 146 | kind: TestDummy 147 | metadata: 148 | name: test-demo-template-docs 149 | namespace: tests 150 | labels: 151 | docs.koreo.dev/example: template-label 152 | docs.koreo.dev/from-function: label 153 | spec: 154 | value: 42 155 | addedProperty: 714 156 | nested: 157 | - 1 158 | - 2 159 | 160 | - label: Template Two 161 | inputOverrides: 162 | template: two 163 | expectResource: 164 | apiVersion: koreo.dev/v1beta1 165 | kind: TestDummy 166 | metadata: 167 | name: test-demo-template-docs 168 | namespace: tests 169 | labels: 170 | docs.koreo.dev/example: template-label 171 | docs.koreo.dev/from-function: label 172 | annotations: 173 | docs.koreo.dev/example: template-two 174 | spec: 175 | value: 42 176 | addedProperty: 714 177 | structure: 178 | - name: doc 179 | - name: examples 180 | ``` 181 | -------------------------------------------------------------------------------- /docs/value-funciton.md: -------------------------------------------------------------------------------- 1 | # ValueFunction 2 | 3 | `ValueFunctions` provide a means of validation, computation, and data 4 | reshaping. There are three common use cases for `ValueFunction`: 5 | 6 | 1. Validating data and building "known-good" structures to be provided to other 7 | functions. 8 | 1. Computing data structures, such as metadata, to overlay onto other resources 9 | in order to standardize them. 10 | 1. Validating or reshaping return values into a structure that is more 11 | convenient to use in other locations within a `Workflow`. 12 | 13 | Though `ValueFunction` is a very simple construct, they are a powerful means of 14 | reshaping or building data structures such as common labels, entire metadata 15 | blocks, or default values for use within other Functions or Workflows. 16 | 17 | 18 | | Full Specification | Description | 19 | | :-----------------------------| :-------------------- | 20 | | **`apiVersion`**: `koreo.dev/v1beta1` | Specification version | 21 | | **`kind`**: `ValueFunction` | Always `ValueFunction` | 22 | | **`metadata`**: | | 23 | | **` name`**: | Name of the `ValueFunction`| 24 | | **` namespace`**: | Namespace | 25 | | **`spec`**: | | 26 | | *` preconditions`*: | A set of assertions to determine if this function can and should be run, and if not the function's return-type. These are run in order and the first failure is returned. Exactly one return type is required. | 27 | | **` assert`**: | Must be a Koreo Expression specifying an assertion, if the assertion is false the function evaluates to the specified result. | 28 | | *` defaultReturn`*: | Indicate that no further conditions should be checked, return `Ok` with the static value specified. | 29 | | **` {}`** | The return value must be an object, but may be the empty object. | 30 | | *` skip`*: | Return with a `Skip` status. This is useful for functions which optionally run based on input values. | 31 | | **` message`**: | The message to be returned is used for condition / status reporting. Make it descriptive of the reason for the return value. | 32 | | *` depSkip`*: | Return with a `DepSkip` status, indicating that a dependency is not ready. | 33 | | **` message`**: | The message to be returned is used for condition / status reporting. Make it descriptive of the reason for the return value. | 34 | | *` retry`*: | Return a `Retry` (Wait) which will cause the `Workflow` to re-reconcile after `delay` seconds. | 35 | | **` message`**: | The message to be returned is used for condition / status reporting. Make it descriptive of the reason for the return value. | 36 | | **` delay`**: | Seconds to wait before re-reconciliation is attempted. | 37 | | *` permFail`*: | Return a `PermFail` which will cause the `Workflow` not to re-reconcile until the parent has been updated. This is for fatal, unrecoverable errors that will require human intervention. | 38 | | **` message`**: | The message to be returned is used for condition / status reporting. Make it descriptive of the reason for the return value. | 39 | | *` locals`*: | Locals allows for defining constant values or for interim calculations. Must be an object. The values may be accessed using `locals` within the `return` block. | 40 | | *` return`*: | The return value for the function. It must be an object. | 41 | 42 | ## Usage 43 | 44 | The following sections explain the key features of `ValueFunction` and their 45 | intended uses. 46 | 47 | ### `spec.preconditions`: Performing Validation 48 | 49 | It is important to check preconditions in order to determine if it is possible 50 | to evaluate a function. For instance, it might be important to check that a 51 | number falls within an allowed range, or a that a string meets requirements 52 | such as length or only contains allowed characters. `spec.preconditions` allows 53 | conditions to be _asserted_, and if the assertion fails then the function will 54 | return a specified outcome. 55 | 56 | You may leverage a `ValueFunction` purely to run its `spec.preconditions`. This 57 | can be helpful to cause a `Workflow` to `Retry` or `PermFail` due to some 58 | condition. Note that in order to block other steps, they should express a 59 | dependency on the `ValueFunction` via their inputs—otherwise those steps will 60 | run. 61 | 62 | ### `spec.locals`: Interim values 63 | 64 | Because Koreo Expressions are often used to extract values or reshape data 65 | structures, they can be rather long. `spec.locals` provides a means of naming 66 | expressions, which can improve readability of the return value expression. 67 | 68 | `spec.locals` is also useful for defining constant values, which may be complex 69 | structures, such as lists or objects, or simple values. Locals are used to help 70 | construct the return value, used within Koreo Expressions, or directly 71 | returned. 72 | 73 | 74 | > 📘 Note 75 | > 76 | > Currently, `spec.locals` may not reference other `locals`. 77 | 78 | 79 | ### `spec.return`: Returned value 80 | 81 | The primary use cases of `ValueFunction` is to reshape or compute a return 82 | value expression. The return expression must be an object. The keys of the 83 | object may be constant values, data structures, or Koreo Expressions which 84 | reference inputs (`inputs.`) or locals (`locals`). 85 | 86 | 87 | ## Example ValueFunction 88 | 89 | The following `ValueFunction` demonstrates some of the capabilities. 90 | 91 | ```yaml 92 | apiVersion: koreo.dev/v1beta1 93 | kind: ValueFunction 94 | metadata: 95 | name: simple-example.v1 96 | namespace: koreo-demo 97 | spec: 98 | 99 | # Checking input values are within range or ensuring that a config is enabled 100 | # are common needs, preconditions support both use cases. 101 | preconditions: 102 | - assert: =inputs.values.int > 0 103 | permFail: 104 | message: ="The int input value must be positive, received '" + string(inputs.values.int) + "'" 105 | 106 | - assert: =inputs.enabled 107 | skip: 108 | message: User disabled the ValueFunction 109 | 110 | # Locals are especially useful for interim expressions to improve 111 | # readability, make complex expressions more ergonomic to write, or for 112 | # defining constant values for use within the return expression. 113 | locals: 114 | computedValues: 115 | halfed: =inputs.values.int / 2 116 | doubled: =inputs.values.int * 2 117 | 118 | constantList: [NORTH, SOUTH, EAST, WEST] 119 | 120 | # The return value of a ValueFunction must be an object, Koreo Expressions 121 | # have access to the `spec.locals` values. 122 | return: 123 | allowedRange: 124 | lower: =locals.computedValues.halfed 125 | upper: =locals.computedValues.doubled 126 | 127 | lowerWords: =locals.constantList.map(word, word.lower()) 128 | --- 129 | # FunctionTests provide a solution for testing your logic and error handling. 130 | # See the FunctionTest documentation for a full description of their 131 | # capabilities. 132 | apiVersion: koreo.dev/v1beta1 133 | kind: FunctionTest 134 | metadata: 135 | name: simple-example.v1 136 | namespace: koreo-demo 137 | spec: 138 | 139 | # Specify the Function to test. 140 | functionRef: 141 | kind: ValueFunction 142 | name: simple-example.v1 143 | 144 | # Provide a base set of inputs. 145 | inputs: 146 | enabled: true 147 | values: 148 | int: 4 149 | 150 | # Define your test cases, each list-item is a test case. 151 | testCases: 152 | # Test the happy-path return. 153 | - expectReturn: 154 | allowedRange: 155 | lower: 2 156 | upper: 8 157 | lowerWords: [north, south, east, west] 158 | 159 | # Tweak the input, test again. This input tweak will carry forward. 160 | - inputOverrides: 161 | values: 162 | int: 16 163 | expectReturn: 164 | allowedRange: 165 | lower: 8 166 | upper: 32 167 | lowerWords: [north, south, east, west] 168 | 169 | # Tweak the input and test an error case. Due to `variant`, this will not 170 | # carry forward. 171 | - variant: true 172 | inputOverrides: 173 | values: 174 | int: 0 175 | expectOutcome: 176 | permFail: 177 | message: must be positive 178 | 179 | # Tweak the input and test another other error case. Due to `variant`, this 180 | # will not carry forward. 181 | - variant: true 182 | inputOverrides: 183 | enabled: false 184 | expectOutcome: 185 | skip: 186 | message: User disabled 187 | ``` 188 | -------------------------------------------------------------------------------- /examples/dummy-crds.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: testdummies.koreo.dev 5 | spec: 6 | scope: Namespaced 7 | group: koreo.dev 8 | names: 9 | kind: TestDummy 10 | plural: testdummies 11 | singular: testdummy 12 | versions: 13 | - name: v1beta1 14 | served: true 15 | storage: true 16 | schema: 17 | openAPIV3Schema: 18 | type: object 19 | properties: 20 | spec: 21 | type: object 22 | x-kubernetes-preserve-unknown-fields: true 23 | status: 24 | x-kubernetes-preserve-unknown-fields: true 25 | type: object 26 | --- 27 | apiVersion: apiextensions.k8s.io/v1 28 | kind: CustomResourceDefinition 29 | metadata: 30 | name: triggerdummies.koreo.dev 31 | spec: 32 | scope: Namespaced 33 | group: koreo.dev 34 | names: 35 | kind: TriggerDummy 36 | plural: triggerdummies 37 | singular: triggerdummy 38 | versions: 39 | - name: v1beta1 40 | served: true 41 | storage: true 42 | schema: 43 | openAPIV3Schema: 44 | type: object 45 | properties: 46 | spec: 47 | type: object 48 | x-kubernetes-preserve-unknown-fields: true 49 | status: 50 | x-kubernetes-preserve-unknown-fields: true 51 | type: object 52 | -------------------------------------------------------------------------------- /examples/k8s-sa.koreo: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: koreo-demo 5 | --- 6 | apiVersion: koreo.dev/v1beta1 7 | kind: Workflow 8 | metadata: 9 | name: deployment-service-account.v1 10 | namespace: koreo-demo 11 | spec: 12 | # This is used for testing. This workflow is meant to be a sub-workflow. 13 | crdRef: 14 | apiGroup: koreo.dev 15 | version: v1alpha8 16 | kind: TriggerDummy 17 | 18 | steps: 19 | - label: config 20 | ref: 21 | kind: ValueFunction 22 | name: service-account-config.v1 23 | inputs: 24 | metadata: =parent.metadata 25 | name: =parent.spec.name 26 | resources: =parent.spec.resources 27 | 28 | - label: service_account 29 | ref: 30 | kind: ResourceFunction 31 | name: deployment-service-account.v1 32 | inputs: 33 | metadata: =steps.config.metadata 34 | state: 35 | ref: =resource.self_ref() 36 | 37 | - label: role 38 | ref: 39 | kind: ResourceFunction 40 | name: deployment-service-account-role.v1 41 | inputs: 42 | metadata: =steps.config.metadata 43 | resources: =steps.config.resources 44 | 45 | - label: binding 46 | ref: 47 | kind: ResourceFunction 48 | name: deployment-service-account-binding.v1 49 | inputs: 50 | metadata: =steps.config.metadata 51 | service_account: =steps.service_account 52 | role: =steps.role 53 | --- 54 | apiVersion: koreo.dev/v1beta1 55 | kind: ValueFunction 56 | metadata: 57 | name: service-account-config.v1 58 | namespace: koreo-demo 59 | spec: 60 | locals: 61 | service_account: =inputs.name + "-workload" 62 | 63 | return: 64 | metadata: 65 | name: =locals.service_account 66 | namespace: =inputs.metadata.namespace 67 | resources: =inputs.resources 68 | --- 69 | apiVersion: koreo.dev/v1beta1 70 | kind: FunctionTest 71 | metadata: 72 | name: service-account-config.v1 73 | namespace: koreo-demo 74 | spec: 75 | functionRef: 76 | kind: ValueFunction 77 | name: service-account-config.v1 78 | 79 | inputs: 80 | metadata: 81 | namespace: test-namespace 82 | name: test-account 83 | resources: 84 | - kind: secret 85 | name: test-secret 86 | - kind: configmap 87 | name: test-configmap 88 | 89 | testCases: 90 | - expectReturn: 91 | metadata: 92 | name: test-account-workload 93 | namespace: test-namespace 94 | resources: 95 | - kind: secret 96 | name: test-secret 97 | - kind: configmap 98 | name: test-configmap 99 | --- 100 | apiVersion: koreo.dev/v1beta1 101 | kind: ResourceTemplate 102 | metadata: 103 | name: deployment-service-account.v1 104 | namespace: koreo-demo 105 | spec: 106 | template: 107 | apiVersion: v1 108 | kind: ServiceAccount 109 | metadata: 110 | labels: 111 | workloads.realkinetic.com/auto-sa: "true" 112 | --- 113 | apiVersion: koreo.dev/v1beta1 114 | kind: ResourceFunction 115 | metadata: 116 | name: deployment-service-account.v1 117 | namespace: koreo-demo 118 | spec: 119 | apiConfig: 120 | apiVersion: v1 121 | kind: ServiceAccount 122 | 123 | name: =inputs.metadata.name 124 | namespace: =inputs.metadata.namespace 125 | 126 | resourceTemplateRef: 127 | name: deployment-service-account.v1 128 | 129 | create: 130 | delay: 10 131 | 132 | update: 133 | recreate: 134 | delay: 10 135 | 136 | return: 137 | ref: =resource.self_ref() 138 | --- 139 | apiVersion: koreo.dev/v1beta1 140 | kind: FunctionTest 141 | metadata: 142 | name: deployment-service-account.v1 143 | namespace: koreo-demo 144 | spec: 145 | functionRef: 146 | kind: ResourceFunction 147 | name: deployment-service-account.v1 148 | 149 | inputs: 150 | metadata: 151 | namespace: test-namespace 152 | name: test-account 153 | 154 | testCases: 155 | - expectResource: 156 | apiVersion: v1 157 | kind: ServiceAccount 158 | metadata: 159 | labels: 160 | workloads.realkinetic.com/auto-sa: 'true' 161 | name: test-account 162 | namespace: test-namespace 163 | - expectReturn: 164 | ref: 165 | apiVersion: v1 166 | kind: ServiceAccount 167 | name: test-account 168 | namespace: test-namespace 169 | --- 170 | apiVersion: koreo.dev/v1beta1 171 | kind: ResourceFunction 172 | metadata: 173 | name: deployment-service-account-role.v1 174 | namespace: koreo-demo 175 | spec: 176 | apiConfig: 177 | apiVersion: rbac.authorization.k8s.io/v1 178 | kind: Role 179 | 180 | name: =inputs.metadata.name 181 | namespace: =inputs.metadata.namespace 182 | 183 | locals: 184 | rules: | 185 | =inputs.resources.map( 186 | resource, 187 | {'apiGroups': [resource.apiGroup], 188 | 'resources': [resource.kind.lower() + "s"], 189 | 'verbs': ["get"], 190 | 'resourceNames': [resource.name] 191 | } 192 | ) 193 | 194 | resource: 195 | apiVersion: rbac.authorization.k8s.io/v1 196 | kind: Role 197 | rules: = locals.rules 198 | 199 | return: 200 | ref: =resource.self_ref() 201 | --- 202 | apiVersion: koreo.dev/v1beta1 203 | kind: FunctionTest 204 | metadata: 205 | name: deployment-service-account-role.v1 206 | namespace: koreo-demo 207 | spec: 208 | functionRef: 209 | kind: ResourceFunction 210 | name: deployment-service-account-role.v1 211 | 212 | inputs: 213 | metadata: 214 | name: test-workload 215 | namespace: test-namespace 216 | resources: 217 | - apiGroup: v1 218 | kind: Secret 219 | name: contact-list-api-key 220 | 221 | testCases: 222 | - expectResource: 223 | apiVersion: rbac.authorization.k8s.io/v1 224 | kind: Role 225 | metadata: 226 | name: test-workload 227 | namespace: test-namespace 228 | rules: 229 | - apiGroups: [v1] 230 | resourceNames: [contact-list-api-key] 231 | resources: [secrets] 232 | verbs: [get] 233 | --- 234 | apiVersion: koreo.dev/v1beta1 235 | kind: ResourceFunction 236 | metadata: 237 | name: deployment-service-account-binding.v1 238 | namespace: koreo-demo 239 | spec: 240 | apiConfig: 241 | apiVersion: rbac.authorization.k8s.io/v1 242 | kind: RoleBinding 243 | 244 | name: =inputs.metadata.name 245 | namespace: =inputs.metadata.namespace 246 | 247 | locals: 248 | sa_ref: 249 | kind: ServiceAccount 250 | name: =inputs.service_account.ref.name 251 | role_group_ref: =inputs.role.ref.group_ref() 252 | 253 | resource: 254 | apiVersion: rbac.authorization.k8s.io/v1 255 | kind: RoleBinding 256 | subjects: [=locals.sa_ref] 257 | roleRef: 258 | apiGroup: =locals.role_group_ref.apiGroup 259 | kind: =locals.role_group_ref.kind 260 | name: =locals.role_group_ref.name 261 | 262 | return: 263 | ref: =resource.self_ref() 264 | --- 265 | apiVersion: koreo.dev/v1beta1 266 | kind: FunctionTest 267 | metadata: 268 | name: deployment-service-account-binding.v1 269 | namespace: koreo-demo 270 | spec: 271 | functionRef: 272 | kind: ResourceFunction 273 | name: deployment-service-account-binding.v1 274 | 275 | inputs: 276 | metadata: 277 | name: test-workload 278 | namespace: test-namespace 279 | service_account: 280 | ref: 281 | apiVersion: v1 282 | kind: ServiceAccount 283 | name: contect-list-sa 284 | namespace: koreo-demo 285 | role: 286 | ref: 287 | apiGroup: rbac.authorization.k8s.io 288 | kind: Role 289 | name: secret-contact-list-api-key 290 | namespace: koreo-demo 291 | 292 | testCases: 293 | - expectResource: 294 | apiVersion: rbac.authorization.k8s.io/v1 295 | kind: RoleBinding 296 | metadata: 297 | name: test-workload 298 | namespace: test-namespace 299 | roleRef: 300 | apiGroup: rbac.authorization.k8s.io 301 | kind: Role 302 | name: secret-contact-list-api-key 303 | subjects: 304 | - kind: ServiceAccount 305 | name: contect-list-sa 306 | -------------------------------------------------------------------------------- /examples/patch-loop-testing.koreo: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: koreo-update-loop 5 | --- 6 | apiVersion: koreo.dev/v1beta1 7 | kind: Workflow 8 | metadata: 9 | name: update-loop.v1 10 | namespace: koreo-update-loop 11 | spec: 12 | crdRef: 13 | apiGroup: koreo.dev 14 | version: v1alpha8 15 | kind: TriggerDummy 16 | 17 | steps: 18 | - label: config 19 | ref: 20 | kind: ValueFunction 21 | name: update-loop-config.v1 22 | inputs: 23 | metadata: =parent.metadata 24 | 25 | - label: patcher 26 | ref: 27 | kind: ResourceFunction 28 | name: patch-looper.v1 29 | inputs: 30 | metadata: =steps.config.metadata 31 | condition: 32 | type: PatchLooper 33 | name: Patch Loop Difference Testing 34 | 35 | - label: recreater 36 | ref: 37 | kind: ResourceFunction 38 | name: recreate-looper.v1 39 | inputs: 40 | metadata: =steps.config.metadata 41 | condition: 42 | type: RecreateLooper 43 | name: Recreate Loop Difference Testing 44 | --- 45 | apiVersion: koreo.dev/v1beta1 46 | kind: ValueFunction 47 | metadata: 48 | name: update-loop-config.v1 49 | namespace: koreo-update-loop 50 | spec: 51 | return: 52 | metadata: 53 | name: =inputs.metadata.name 54 | namespace: =inputs.metadata.namespace 55 | --- 56 | apiVersion: koreo.dev/v1beta1 57 | kind: ResourceFunction 58 | metadata: 59 | name: patch-looper.v1 60 | namespace: koreo-update-loop 61 | spec: 62 | apiConfig: 63 | apiVersion: koreo.dev/v1beta1 64 | kind: TestDummy 65 | plural: testdummies 66 | 67 | name: =inputs.metadata.name + "-patch-loop" 68 | namespace: =inputs.metadata.namespace 69 | 70 | resource: 71 | spec: 72 | simpleValue: "simple-update" 73 | nested: 74 | map: 75 | value: 76 | a: complex 77 | difference: here 78 | and: 79 | - a 80 | - list 81 | - here 82 | list: 83 | - value: one 84 | - value: two 85 | - structure: three 86 | - and: four 87 | 88 | create: 89 | delay: 15 90 | overlay: 91 | spec: 92 | list: 93 | - structure: wrong 94 | - value: one 95 | - and: four 96 | - value: two 97 | nested: 98 | map: 99 | value: 100 | difference: happens 101 | stuff: 102 | - at 103 | - bad 104 | - spots 105 | 106 | update: 107 | patch: 108 | delay: 15 109 | 110 | return: 111 | ref: =resource.self_ref() 112 | --- 113 | apiVersion: koreo.dev/v1beta1 114 | kind: FunctionTest 115 | metadata: 116 | name: patch-looper.v1 117 | namespace: koreo-update-loop 118 | spec: 119 | functionRef: 120 | kind: ResourceFunction 121 | name: patch-looper.v1 122 | 123 | inputs: 124 | metadata: 125 | name: basic-test-case 126 | namespace: testing 127 | 128 | testCases: 129 | - label: Creation Values 130 | expectResource: 131 | apiVersion: koreo.dev/v1beta1 132 | kind: TestDummy 133 | metadata: 134 | name: basic-test-case-patch-loop 135 | namespace: testing 136 | spec: 137 | simpleValue: "simple-update" 138 | nested: 139 | map: 140 | value: 141 | a: complex 142 | difference: happens 143 | and: 144 | - a 145 | - list 146 | - here 147 | stuff: 148 | - at 149 | - bad 150 | - spots 151 | list: 152 | - structure: wrong 153 | - value: one 154 | - and: four 155 | - value: two 156 | - # This test will catch the "unexpected" update-loop. 157 | label: Check return value 158 | skip: true 159 | expectOutcome: 160 | ok: {} 161 | - label: Unexpected update Values 162 | expectResource: 163 | apiVersion: koreo.dev/v1beta1 164 | kind: TestDummy 165 | metadata: 166 | name: basic-test-case-patch-loop 167 | namespace: testing 168 | spec: 169 | simpleValue: "simple-update" 170 | nested: 171 | map: 172 | value: 173 | a: complex 174 | difference: here 175 | and: 176 | - a 177 | - list 178 | - here 179 | list: 180 | - value: one 181 | - value: two 182 | - structure: three 183 | - and: four 184 | - label: Stable return value 185 | expectReturn: 186 | ref: 187 | apiVersion: koreo.dev/v1beta1 188 | kind: TestDummy 189 | name: basic-test-case-patch-loop 190 | namespace: testing 191 | --- 192 | apiVersion: koreo.dev/v1beta1 193 | kind: ResourceFunction 194 | metadata: 195 | name: recreate-looper.v1 196 | namespace: koreo-update-loop 197 | spec: 198 | apiConfig: 199 | apiVersion: koreo.dev/v1beta1 200 | kind: TestDummy 201 | plural: testdummies 202 | 203 | name: =inputs.metadata.name + "-recreate-loop" 204 | namespace: =inputs.metadata.namespace 205 | 206 | resource: 207 | spec: 208 | simpleValue: "simple-update" 209 | nested: 210 | map: 211 | value: 212 | a: complex 213 | difference: here 214 | and: 215 | - a 216 | - list 217 | - here 218 | list: 219 | - value: one 220 | - value: two 221 | - structure: three 222 | - missing: value 223 | and: four 224 | 225 | create: 226 | delay: 15 227 | overlay: 228 | spec: 229 | nested: 230 | map: 231 | value: 232 | difference: value 233 | 234 | update: 235 | recreate: 236 | delay: 15 237 | 238 | return: 239 | ref: =resource.self_ref() 240 | --- 241 | apiVersion: koreo.dev/v1beta1 242 | kind: FunctionTest 243 | metadata: 244 | name: recreate-looper.v1 245 | namespace: koreo-update-loop 246 | spec: 247 | functionRef: 248 | kind: ResourceFunction 249 | name: recreate-looper.v1 250 | 251 | inputs: 252 | metadata: 253 | name: basic-test-case 254 | namespace: testing 255 | 256 | testCases: 257 | - label: Creation Values 258 | expectResource: 259 | apiVersion: koreo.dev/v1beta1 260 | kind: TestDummy 261 | metadata: 262 | name: basic-test-case-recreate-loop 263 | namespace: testing 264 | spec: 265 | simpleValue: "simple-update" 266 | nested: 267 | map: 268 | value: 269 | a: complex 270 | difference: value 271 | and: 272 | - a 273 | - list 274 | - here 275 | list: 276 | - value: one 277 | - value: two 278 | - structure: three 279 | - missing: value 280 | and: four 281 | - # This would indicate stability 282 | label: Check return value 283 | skip: true 284 | expectOutcome: 285 | ok: {} 286 | - # This indicates a loop, since we'll create... delete.. create... 287 | label: Check the delete call 288 | expectDelete: true 289 | - label: Yet another create... 290 | expectOutcome: 291 | retry: 292 | delay: 0 # Any delay is OK 293 | message: Creating 294 | - # This indicates a loop, since we'll create... delete.. create... 295 | label: Check the delete call 296 | expectDelete: true 297 | -------------------------------------------------------------------------------- /examples/resource-functions.koreo: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: koreo-testing 5 | --- 6 | apiVersion: koreo.dev/v1beta1 7 | kind: ResourceFunction 8 | metadata: 9 | name: resource-reader.v1 10 | namespace: koreo-testing 11 | spec: 12 | preconditions: 13 | - assert: =inputs.validators.skip 14 | skip: 15 | message: skip message 16 | 17 | - assert: =inputs.validators.depSkip 18 | depSkip: 19 | message: depSkip message 20 | 21 | - assert: =inputs.validators.permFail 22 | permFail: 23 | message: permFail message 24 | 25 | - assert: =inputs.validators.retry 26 | retry: 27 | message: retry message 28 | delay: 13 29 | 30 | - assert: =inputs.validators.ok 31 | ok: {} 32 | 33 | locals: 34 | computed: =inputs.values.string + " local computed" 35 | resource_name: =inputs.name + "-test-name" 36 | 37 | apiConfig: 38 | apiVersion: koreo.dev/v1beta1 39 | kind: TestDummy 40 | plural: testdummies 41 | namespaced: true 42 | readonly: true 43 | 44 | name: =locals.resource_name 45 | namespace: koreo-testing 46 | 47 | resource: {} 48 | 49 | return: 50 | ref: =resource.self_ref() 51 | computedInt: =resource.spec.int 52 | --- 53 | apiVersion: koreo.dev/v1beta1 54 | kind: ResourceFunction 55 | metadata: 56 | name: resource-factory.v1 57 | namespace: koreo-testing 58 | spec: 59 | preconditions: 60 | - assert: =inputs.validators.skip 61 | skip: 62 | message: skip message 63 | 64 | - assert: =inputs.validators.depSkip 65 | depSkip: 66 | message: depSkip message 67 | 68 | - assert: =inputs.validators.permFail 69 | permFail: 70 | message: permFail message 71 | 72 | - assert: =inputs.validators.retry 73 | retry: 74 | message: retry message 75 | delay: 13 76 | 77 | - assert: =inputs.validators.ok 78 | ok: {} 79 | 80 | locals: 81 | computed: =inputs.values.string + " local computed" 82 | resource_name: =inputs.name + "-" + inputs.suffix 83 | 84 | apiConfig: 85 | apiVersion: koreo.dev/v1beta1 86 | kind: TestDummy 87 | plural: testdummies 88 | namespaced: true 89 | 90 | name: =locals.resource_name 91 | namespace: koreo-testing 92 | 93 | resource: 94 | spec: 95 | string: =locals.computed 96 | int: =inputs.values.int 97 | 98 | return: 99 | ref: =resource.self_ref() 100 | computedInt: =resource.spec.int 101 | -------------------------------------------------------------------------------- /examples/resource-templates.koreo: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: koreo-testing 5 | --- 6 | apiVersion: koreo.dev/v1beta1 7 | kind: ResourceTemplate 8 | metadata: 9 | name: simple-template.v1 10 | namespace: koreo-testing 11 | spec: 12 | template: 13 | apiVersion: koreo.dev/v1beta1 14 | kind: TestDummy 15 | metadata: 16 | name: simple-test-resource-simple 17 | namespace: koreo-testing 18 | labels: 19 | testing.realkinetic.com/a-label: some-forced-label 20 | spec: 21 | aTrue: true 22 | aNumber: 18 23 | aString: This is a test. 24 | aList: 25 | - This 26 | - Is 27 | - A 28 | - Test 29 | aMap: 30 | name: some 31 | number: 821 32 | --- 33 | apiVersion: koreo.dev/v1beta1 34 | kind: ResourceTemplate 35 | metadata: 36 | name: template-a.v1 37 | namespace: koreo-testing 38 | spec: 39 | template: 40 | apiVersion: koreo.dev/v1beta1 41 | kind: TestDummy 42 | metadata: 43 | name: simple-test-resource-template-a 44 | namespace: koreo-testing 45 | labels: 46 | testing.realkinetic.com/a-label: some-forced-label 47 | spec: 48 | value: I am template A 49 | intvalue: 99 50 | aFalse: false 51 | --- 52 | apiVersion: koreo.dev/v1beta1 53 | kind: ResourceTemplate 54 | metadata: 55 | name: template-b.v1 56 | namespace: koreo-testing 57 | spec: 58 | template: 59 | apiVersion: koreo.dev/v1beta1 60 | kind: TestDummy 61 | metadata: 62 | name: simple-test-resource-template-b 63 | namespace: koreo-testing 64 | labels: 65 | testing.realkinetic.com/a-label: some-forced-label 66 | spec: 67 | someting: I am template B 68 | different: This is a test 69 | aTrue: true 70 | -------------------------------------------------------------------------------- /examples/value-functions.koreo: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: koreo-testing 5 | --- 6 | apiVersion: koreo.dev/v1beta1 7 | kind: ValueFunction 8 | metadata: 9 | name: config-test.v1 10 | namespace: koreo-testing 11 | spec: 12 | preconditions: 13 | - assert: =inputs.validators.skip 14 | skip: 15 | message: skip message 16 | 17 | - assert: =inputs.validators.depSkip 18 | depSkip: 19 | message: depSkip message 20 | 21 | - assert: =inputs.validators.permFail 22 | permFail: 23 | message: permFail message 24 | 25 | - assert: =inputs.validators.retry 26 | retry: 27 | message: retry message 28 | delay: 13 29 | 30 | - assert: =inputs.validators.ok 31 | ok: {} 32 | 33 | locals: 34 | computed: =inputs.values.string + " local computed" 35 | 36 | return: 37 | string: =inputs.values.string 38 | int: =inputs.values.int 39 | local_computed: =locals.computed 40 | use_default: =inputs.use_default 41 | skips: =inputs.optional_skips 42 | --- 43 | apiVersion: koreo.dev/v1beta1 44 | kind: ValueFunction 45 | metadata: 46 | name: return-test.v1 47 | namespace: koreo-testing 48 | spec: 49 | return: 50 | a_string: =inputs.string + " string" 51 | an_int: =inputs.int + 8 52 | nested: 53 | a_string: =inputs.string + " nested string" 54 | an_int: =inputs.int + 17 55 | bools: 56 | true: =true 57 | false: =false 58 | empties: 59 | emptyMap: {} 60 | emptyList: [] 61 | none: null 62 | --- 63 | apiVersion: koreo.dev/v1beta1 64 | kind: ValueFunction 65 | metadata: 66 | name: resource-user-test.v1 67 | namespace: koreo-testing 68 | spec: 69 | return: 70 | int: =inputs.computedInt 71 | ref: =inputs.resourceRef 72 | --- 73 | apiVersion: koreo.dev/v1beta1 74 | kind: ValueFunction 75 | metadata: 76 | name: maybe-or-default-config-test.v1 77 | namespace: koreo-testing 78 | spec: 79 | return: 80 | use_default: =inputs.parent.use_default 81 | --- 82 | apiVersion: koreo.dev/v1beta1 83 | kind: ValueFunction 84 | metadata: 85 | name: maybe-return-value.v1 86 | namespace: koreo-testing 87 | spec: 88 | preconditions: 89 | - assert: =inputs.use_default 90 | skip: 91 | message: Using default, per your request. 92 | return: 93 | value: =inputs.value 94 | --- 95 | apiVersion: koreo.dev/v1beta1 96 | kind: ValueFunction 97 | metadata: 98 | name: maybe-default-return-value.v1 99 | namespace: koreo-testing 100 | spec: 101 | preconditions: 102 | - assert: =!inputs.use_default 103 | skip: 104 | message: Using non-default 105 | return: 106 | value: =inputs.value 107 | --- 108 | apiVersion: koreo.dev/v1beta1 109 | kind: ValueFunction 110 | metadata: 111 | name: optional-skip-config.v1 112 | namespace: koreo-testing 113 | spec: 114 | return: 115 | skips: =inputs.parent.skips 116 | --- 117 | apiVersion: koreo.dev/v1beta1 118 | kind: ValueFunction 119 | metadata: 120 | name: maybe-skip.v1 121 | namespace: koreo-testing 122 | spec: 123 | preconditions: 124 | - assert: =inputs.should_skip 125 | skip: 126 | message: Skipping per your request 127 | return: 128 | value: =inputs.name 129 | --- 130 | apiVersion: koreo.dev/v1beta1 131 | kind: ValueFunction 132 | metadata: 133 | name: combine-optional-skips.v1 134 | namespace: koreo-testing 135 | spec: 136 | return: 137 | not_skipped: =inputs.optionals.map(key, key) 138 | -------------------------------------------------------------------------------- /examples/workflows.koreo: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: koreo-testing 5 | --- 6 | apiVersion: koreo.dev/v1beta1 7 | kind: Workflow 8 | metadata: 9 | name: fully-built.v1 10 | namespace: koreo-testing 11 | spec: 12 | crdRef: 13 | apiGroup: koreo.dev 14 | version: v1alpha8 15 | kind: TriggerDummy 16 | 17 | steps: 18 | - label: config 19 | ref: 20 | kind: ValueFunction 21 | name: config-test.v1 22 | inputs: 23 | validators: =parent.spec.validators 24 | values: =parent.spec.values 25 | use_default: =parent.spec.use_default 26 | optional_skips: =parent.spec.optionalSkips 27 | 28 | - label: return_value 29 | ref: 30 | kind: ValueFunction 31 | name: return-test.v1 32 | inputs: 33 | string: =steps.config.string 34 | int: =steps.config.int 35 | state: 36 | config: 37 | nested_string: =value.nested.a_string 38 | empty_list: =value.empties.emptyList 39 | 40 | - label: resource_reader 41 | ref: 42 | kind: ResourceFunction 43 | name: resource-reader.v1 44 | inputs: 45 | name: resource-function-test 46 | validators: 47 | skip: false 48 | depSkip: false 49 | permFail: false 50 | retry: false 51 | ok: false 52 | values: 53 | string: =steps.config.string 54 | int: =steps.config.int 55 | state: {} 56 | 57 | - label: resource_factory 58 | ref: 59 | kind: ResourceFunction 60 | name: resource-factory.v1 61 | forEach: 62 | itemIn: =["a", "b", "c"] 63 | inputKey: suffix 64 | inputs: 65 | name: resource-function-test 66 | validators: 67 | skip: false 68 | depSkip: false 69 | permFail: false 70 | retry: false 71 | ok: false 72 | values: 73 | string: =steps.config.string 74 | int: =steps.config.int 75 | state: 76 | resources: =value.map(resource, resource.computedInt) 77 | 78 | - label: resource_value_user 79 | ref: 80 | kind: ValueFunction 81 | name: resource-user-test.v1 82 | inputs: 83 | computedInt: =steps.resource_reader.computedInt 84 | resourceRef: =steps.resource_reader.ref 85 | 86 | - label: maybe_or_default_example 87 | ref: 88 | kind: Workflow 89 | name: run-or-default.v1 90 | inputs: 91 | use_default: =steps.config.use_default 92 | 93 | - label: optional_skips 94 | ref: 95 | kind: Workflow 96 | name: optional-skips.v1 97 | inputs: 98 | skips: =steps.config.skips 99 | 100 | - label: combine_optional_skips 101 | ref: 102 | kind: ValueFunction 103 | name: combine-optional-skips.v1 104 | inputs: 105 | optionals: =steps.optional_skips 106 | --- 107 | apiVersion: koreo.dev/v1beta1 108 | kind: Workflow 109 | metadata: 110 | name: run-or-default.v1 111 | namespace: koreo-testing 112 | spec: 113 | 114 | steps: 115 | - label: config 116 | ref: 117 | kind: ValueFunction 118 | name: maybe-or-default-config-test.v1 119 | inputs: 120 | parent: =parent 121 | 122 | - label: maybe_return_value 123 | ref: 124 | kind: ValueFunction 125 | name: maybe-return-value.v1 126 | inputs: 127 | use_default: =steps.config.use_default 128 | value: Non-default Return Value 129 | state: 130 | value: =value.value 131 | 132 | - label: default_return_value 133 | ref: 134 | kind: ValueFunction 135 | name: maybe-default-return-value.v1 136 | inputs: 137 | use_default: =steps.config.use_default 138 | value: Default Return Value 139 | state: 140 | value: =value.value 141 | --- 142 | apiVersion: koreo.dev/v1beta1 143 | kind: Workflow 144 | metadata: 145 | name: optional-skips.v1 146 | namespace: koreo-testing 147 | spec: 148 | steps: 149 | - label: config 150 | ref: 151 | kind: ValueFunction 152 | name: optional-skip-config.v1 153 | inputs: 154 | parent: =parent 155 | state: {} 156 | 157 | - label: option_one 158 | ref: 159 | kind: ValueFunction 160 | name: maybe-skip.v1 161 | inputs: 162 | name: One 163 | should_skip: =steps.config.skips.one 164 | state: 165 | one: =value.value 166 | 167 | - label: option_two 168 | ref: 169 | kind: ValueFunction 170 | name: maybe-skip.v1 171 | inputs: 172 | name: Two 173 | should_skip: =steps.config.skips.two 174 | state: 175 | two: =value.value 176 | 177 | - label: option_three 178 | ref: 179 | kind: ValueFunction 180 | name: maybe-skip.v1 181 | inputs: 182 | name: Three 183 | should_skip: =steps.config.skips.three 184 | state: 185 | three: =value.value 186 | --- 187 | apiVersion: koreo.dev/v1beta1 188 | kind: TriggerDummy 189 | metadata: 190 | name: fully-built-trigger 191 | namespace: koreo-testing 192 | labels: 193 | konfig.realkinetic.com/bump: "2" 194 | spec: 195 | validators: 196 | skip: false 197 | depSkip: false 198 | permFail: false 199 | retry: false 200 | ok: false 201 | 202 | values: 203 | string: A test string 204 | int: 89 205 | 206 | use_default: yes 207 | optionalSkips: 208 | one: no 209 | two: yes 210 | three: no 211 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "koreo-core" 3 | version = "0.1.11" 4 | description = "Type-safe and testable KRM Templates and Workflows." 5 | authors = [ 6 | {name = "Robert Kluin", email = "robert.kluin@realkinetic.com"}, 7 | {name = "Eric Larssen", email = "eric.larssen@realkinetic.com"}, 8 | {name = "Tyler Treat", email = "tyler-treat@realkinetic.com"}, 9 | ] 10 | 11 | dependencies = [ 12 | "cel-python==0.2.0", 13 | "PyYAML==6.0.2", 14 | "kr8s==0.20.6", 15 | "fastjsonschema==2.21.1", 16 | ] 17 | 18 | requires-python = ">=3.13" 19 | readme = "README.md" 20 | license = {text = "Apache-2.0"} 21 | 22 | [project.urls] 23 | Homepage = "https://koreo.dev" 24 | 25 | [dependency-groups] 26 | test = [ 27 | "pytest==8.3.5", 28 | "pytest-cov==6.0.0", 29 | ] 30 | tooling = [ 31 | "ruff==0.11.2", 32 | "pyright==1.1.397", 33 | ] 34 | all = ["koreo-core[test,tooling]"] 35 | 36 | [build-system] 37 | requires = ["pdm-backend"] 38 | build-backend = "pdm.backend" 39 | 40 | [tool.pdm] 41 | distribution = true 42 | 43 | [tool.pytest.ini_options] 44 | pythonpath = "src" 45 | addopts = [ 46 | "-v", 47 | "--import-mode=importlib", 48 | "--cov=src", 49 | "--cov-branch", 50 | "--cov-report=term-missing", 51 | ] 52 | -------------------------------------------------------------------------------- /src/koreo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koreo-dev/koreo-core/1c0ad76ab008184b98c019420b5302b2e4eda074/src/koreo/__init__.py -------------------------------------------------------------------------------- /src/koreo/cel/encoder.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import base64 3 | 4 | from celpy import celtypes 5 | 6 | CEL_PREFIX = "=" 7 | 8 | ConvertedType = ( 9 | list["ConvertedType"] 10 | | dict["ConvertedType", "ConvertedType"] 11 | | str 12 | | int 13 | | float 14 | | bool 15 | | None 16 | ) 17 | 18 | 19 | def convert_bools( 20 | cel_object: celtypes.Value, 21 | ) -> ConvertedType: 22 | """Recursive walk through the CEL object, replacing celtypes with native 23 | types. This lets the :py:mod:`json` module correctly represent the obects 24 | and allows Python code to treat these as normal objects. 25 | """ 26 | match cel_object: 27 | case celtypes.BoolType(): 28 | return True if cel_object else False 29 | 30 | case celtypes.StringType() | celtypes.TimestampType() | celtypes.DurationType(): 31 | return str(cel_object) 32 | 33 | case celtypes.IntType() | celtypes.UintType(): 34 | return int(cel_object) 35 | 36 | case celtypes.DoubleType(): 37 | return float(cel_object) 38 | 39 | case celtypes.BytesType(): 40 | return base64.b64encode(cel_object).decode("ASCII") 41 | 42 | case celtypes.ListType() | list() | tuple(): 43 | return [convert_bools(item) for item in cel_object] 44 | 45 | case celtypes.MapType() | dict(): 46 | return { 47 | convert_bools(key): convert_bools(value) 48 | for key, value in cel_object.items() 49 | } 50 | 51 | case celtypes.NullType(): 52 | return None 53 | 54 | case _: 55 | return cel_object 56 | 57 | 58 | def encode_cel(value: Any) -> str: 59 | if isinstance(value, dict): 60 | return f"{{{ ",".join( 61 | f'"{f"{key}".replace('"', '\"')}":{encode_cel(value)}' 62 | for key, value in value.items() 63 | ) }}}" 64 | 65 | if isinstance(value, list): 66 | return f"[{ ",".join(encode_cel(item) for item in value) }]" 67 | 68 | if isinstance(value, bool): 69 | return "true" if value else "false" 70 | 71 | if value is None: 72 | return "null" 73 | 74 | if _encode_plain(value): 75 | return f"{value}" 76 | 77 | if not value: 78 | return '""' 79 | 80 | if not value.startswith(CEL_PREFIX): 81 | if "\n" in value: 82 | return f'r"""{ value.replace('"', '\"') }"""' # fmt: skip 83 | 84 | if '"' in value: 85 | return f'"""{ value.replace('"', r'\"') }"""' # fmt: skip 86 | 87 | return f'"{value}"' # fmt: skip 88 | 89 | return value.lstrip(CEL_PREFIX) 90 | 91 | 92 | def _encode_plain(maybe_number) -> bool: 93 | if not isinstance(maybe_number, str): 94 | return True 95 | 96 | try: 97 | int(maybe_number) 98 | return True 99 | except ValueError: 100 | pass 101 | 102 | try: 103 | float(maybe_number) 104 | return True 105 | except ValueError: 106 | pass 107 | 108 | return False 109 | -------------------------------------------------------------------------------- /src/koreo/cel/evaluation.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | 4 | logger = logging.getLogger("koreo.cel.evaluation") 5 | 6 | from celpy import celtypes 7 | from celpy.celparser import tree_dump 8 | import celpy 9 | 10 | from koreo.predicate_helpers import predicate_to_koreo_result 11 | from koreo.result import NonOkOutcome, PermFail 12 | 13 | from koreo.cel.prepare import Index, Overlay 14 | 15 | 16 | def evaluate( 17 | expression: celpy.Runner | None, inputs: dict[str, celtypes.Value], location: str 18 | ) -> None | celtypes.Value | PermFail: 19 | if not expression: 20 | return None 21 | 22 | try: 23 | expression_value = expression.evaluate(inputs) 24 | 25 | if eval_errors := check_for_celevalerror(expression_value, location): 26 | return eval_errors 27 | 28 | return expression_value 29 | 30 | except celpy.CELEvalError as err: 31 | tree = tree_dump(err.tree) if err and err.tree else "" 32 | return PermFail( 33 | message=f"Error evaluating `{location}` (at {tree}) {err.args}", 34 | location=tree, 35 | ) 36 | 37 | except: 38 | msg = f"Unknown failure evaluating `{location}`." 39 | logger.exception(msg) 40 | return PermFail(msg) 41 | 42 | 43 | def evaluate_predicates( 44 | predicates: celpy.Runner | None, inputs: dict[str, celtypes.Value], location: str 45 | ) -> None | NonOkOutcome: 46 | if not predicates: 47 | return None 48 | 49 | try: 50 | raw_result = predicates.evaluate(inputs) 51 | if eval_error := check_for_celevalerror(raw_result, location): 52 | return eval_error 53 | 54 | # This should be impossible, unless prepare validation was missed. 55 | if not isinstance(raw_result, celtypes.ListType): 56 | return PermFail( 57 | f"Bad structure for `{location}`, expected list of assertions but received {type(raw_result)}.", 58 | location=location, 59 | ) 60 | 61 | return predicate_to_koreo_result(raw_result, location=location) 62 | 63 | except celpy.CELEvalError as err: 64 | tree = tree_dump(err.tree) if err and err.tree else "" 65 | return PermFail( 66 | message=f"Error evaluating `{location}` (at {tree}) {err.args}", 67 | location=tree, 68 | ) 69 | except Exception as err: 70 | return PermFail(f"Error evaluating `{location}`: {err}", location=location) 71 | 72 | 73 | def evaluate_overlay( 74 | overlay: Overlay, 75 | inputs: dict[str, celtypes.Value], 76 | base: celtypes.MapType, 77 | location: str, 78 | ) -> PermFail | celtypes.MapType: 79 | if not isinstance(overlay.value_index, dict): 80 | return PermFail( 81 | f"Bad overlay structure for `{location}`, expected mapping", 82 | location=location, 83 | ) 84 | 85 | combined_inputs = inputs | {celtypes.StringType("resource"): base} 86 | 87 | try: 88 | overlay_values = overlay.values.evaluate(combined_inputs) 89 | if eval_error := check_for_celevalerror(overlay_values, location): 90 | return eval_error 91 | 92 | if not isinstance(overlay_values, celtypes.ListType): 93 | return PermFail( 94 | f"Bad overlay structure for `{location}`, received {type(overlay_values)}.", 95 | location=location, 96 | ) 97 | 98 | except celpy.CELEvalError as err: 99 | tree = tree_dump(err.tree) if err and err.tree else "" 100 | return PermFail( 101 | message=f"Error evaluating `{location}` (at {tree}) {err.args}", 102 | location=tree, 103 | ) 104 | 105 | except: 106 | msg = f"Unknown failure evaluating `{location}`." 107 | logger.exception(msg) 108 | return PermFail(msg) 109 | 110 | return _overlay_applier(base, index=overlay.value_index, values=overlay_values) 111 | 112 | 113 | def _overlay_applier( 114 | base: celtypes.MapType, index: dict[str, Index], values: celtypes.ListType 115 | ) -> celtypes.MapType: 116 | overlaid = copy.deepcopy(base) 117 | for key, value_index in index.items(): 118 | cel_key = celtypes.StringType(key) 119 | match value_index: 120 | case int(): 121 | overlaid[cel_key] = values[value_index] 122 | case dict(): 123 | match base.get(cel_key): 124 | case dict() as sub_overlaid: 125 | overlaid[cel_key] = _overlay_applier( 126 | base=sub_overlaid, 127 | index=value_index, 128 | values=values, 129 | ) 130 | case None | _: 131 | overlaid[cel_key] = _overlay_applier( 132 | base=celtypes.MapType(), 133 | index=value_index, 134 | values=values, 135 | ) 136 | return overlaid 137 | 138 | 139 | def check_for_celevalerror( 140 | value: celtypes.Value | celpy.CELEvalError, location: str | None 141 | ) -> None | PermFail: 142 | match value: 143 | case celpy.CELEvalError(tree=error_tree): 144 | tree = tree_dump(error_tree) if error_tree else "" 145 | return PermFail( 146 | message=f"Error evaluating `{location}` (at {tree}) {value.args}", 147 | location=tree, 148 | ) 149 | 150 | case celtypes.MapType() | dict(): 151 | for key, subvalue in value.items(): 152 | if eval_error := check_for_celevalerror(key, location): 153 | return eval_error 154 | 155 | if eval_error := check_for_celevalerror(subvalue, location): 156 | return eval_error 157 | 158 | case celtypes.ListType() | list() | tuple(): 159 | for subvalue in value: 160 | if eval_error := check_for_celevalerror(subvalue, location): 161 | return eval_error 162 | 163 | return None 164 | -------------------------------------------------------------------------------- /src/koreo/cel/prepare.py: -------------------------------------------------------------------------------- 1 | from typing import Any, NamedTuple 2 | import logging 3 | 4 | import celpy 5 | 6 | from koreo.cel.encoder import encode_cel 7 | from koreo.cel.functions import koreo_cel_functions 8 | from koreo.result import PermFail 9 | 10 | 11 | def prepare_expression( 12 | cel_env: celpy.Environment, spec: Any | None, location: str 13 | ) -> None | celpy.Runner | PermFail: 14 | if not spec: 15 | return None 16 | 17 | try: 18 | encoded = encode_cel(spec) 19 | except Exception as err: 20 | return PermFail( 21 | message=f"Structural error in {location}, while building expression '{err}'.", 22 | ) 23 | 24 | try: 25 | value = cel_env.program(cel_env.compile(encoded), functions=koreo_cel_functions) 26 | except celpy.CELParseError as err: 27 | return PermFail( 28 | message=f"Parsing error at line {err.line}, column {err.column}. '{err}' in {location} ('{encoded}')", 29 | ) 30 | 31 | value.logger.setLevel(logging.WARNING) 32 | 33 | return value 34 | 35 | 36 | def prepare_map_expression( 37 | cel_env: celpy.Environment, spec: Any | None, location: str 38 | ) -> None | celpy.Runner | PermFail: 39 | if not spec: 40 | return None 41 | 42 | if not isinstance(spec, dict): 43 | return PermFail(message=f"Malformed {location}, expected a mapping") 44 | 45 | return prepare_expression(cel_env=cel_env, spec=spec, location=location) 46 | 47 | 48 | Index = dict[str, "Index"] | int 49 | 50 | 51 | class Overlay(NamedTuple): 52 | value_index: Index 53 | values: celpy.Runner 54 | 55 | 56 | def prepare_overlay_expression( 57 | cel_env: celpy.Environment, spec: Any | None, location: str 58 | ) -> None | Overlay | PermFail: 59 | if not spec: 60 | return None 61 | 62 | if not isinstance(spec, dict): 63 | return PermFail(message=f"Malformed {location}, expected a mapping") 64 | 65 | overlay_index, overlay_values = _overlay_indexer(spec=spec, base=0) 66 | 67 | match prepare_expression(cel_env=cel_env, spec=overlay_values, location=location): 68 | case None: 69 | return None 70 | case PermFail() as err: 71 | return err 72 | case celpy.Runner() as value_expression: 73 | return Overlay(value_index=overlay_index, values=value_expression) 74 | 75 | 76 | def _overlay_indexer(spec: dict, base: int = 0) -> tuple[Index, list]: 77 | index = {} 78 | values = [] 79 | for key, value in spec.items(): 80 | match value: 81 | case dict() if value: 82 | index[key], key_values = _overlay_indexer( 83 | value, base=len(values) + base 84 | ) 85 | values.extend(key_values) 86 | case _: 87 | index[key] = len(values) + base 88 | values.append(value) 89 | 90 | return index, values 91 | -------------------------------------------------------------------------------- /src/koreo/cel/structure_extractor.py: -------------------------------------------------------------------------------- 1 | from lark import Tree, Token 2 | 3 | 4 | def extract_argument_structure(compiled: Tree) -> set[str]: 5 | var_map: set[str] = set() 6 | for thing in compiled.iter_subtrees(): 7 | if thing.data == "member_dot": 8 | member_dot: str = _process_member_dot(thing) 9 | var_map.add(member_dot) 10 | 11 | if thing.data == "member_index": 12 | member_index: str = _process_member_index(thing) 13 | var_map.add(member_index) 14 | 15 | return var_map 16 | 17 | 18 | def _process_member_dot(tree: Tree): 19 | if len(tree.children) != 2: 20 | # TODO: Not sure this is possible? 21 | raise Exception(f"UNKNOWN MEMBER_DOT LENGTH! {len(tree.children)}: {tree}") 22 | 23 | # print(f"{len(tree.children)}: {tree.children[0]}") 24 | 25 | terminal = f"{tree.children[1]}" 26 | 27 | root: Tree = tree.children[0].children[0] 28 | 29 | if root.data == "member_dot": 30 | return f"{_process_member_dot(root)}.{terminal}" 31 | 32 | if root.data == "member_index": 33 | return f"{_process_member_index(root)}.{terminal}" 34 | 35 | if root.data == "member_dot_arg": 36 | return f"{_process_member_dot_arg(root)}.{terminal}" 37 | 38 | if root.data == "primary": 39 | return f"{_process_primary(root)}.{terminal}" 40 | 41 | # TODO: Is this possible? 42 | raise Exception(f"UNKNOWN MEMBER_DOT ROOT TYPE! {root}") 43 | 44 | 45 | def _process_member_dot_arg(tree: Tree): 46 | if len(tree.children) != 3: 47 | # TODO: Not sure this is possible? 48 | raise Exception(f"UNKNOWN MEMBER_DOT_ARG LENGTH! {len(tree.children)}: {tree}") 49 | 50 | # print(f"{len(tree.children)}: {tree.children[0]}") 51 | 52 | terminal = f"{tree.children[1]}" 53 | 54 | root: Tree = tree.children[0].children[0] 55 | 56 | if root.data == "member_dot": 57 | return f"{_process_member_dot(root)}.{terminal}" 58 | 59 | if root.data == "member_index": 60 | return f"{_process_member_index(root)}.{terminal}" 61 | 62 | if root.data == "member_dot_arg": 63 | return f"{_process_member_dot_arg(root)}.{terminal}" 64 | 65 | if root.data == "primary" or root.data == "ident": 66 | return f"{_process_primary(root)}.{terminal}" 67 | 68 | # TODO: Is this possible? 69 | raise Exception(f"UNKNOWN MEMBER_DOT_ARG ROOT TYPE! {root}") 70 | 71 | 72 | def _process_member_index(tree: Tree): 73 | if len(tree.children) != 2: 74 | # TODO: Not sure this is possible? 75 | raise Exception(f"UNKNOWN MEMBER_INDEX LENGTH! {len(tree.children)}: {tree}") 76 | 77 | # print(f"{len(tree.children)}: {tree.children[0]}") 78 | 79 | terminal: Tree = tree.children[1] 80 | 81 | if terminal.data == "primary": 82 | terminal_value: str = _process_primary(terminal) 83 | 84 | elif terminal.data == "expr": 85 | terminal_value = None 86 | while terminal and terminal.children: 87 | if terminal.data == "primary": 88 | terminal_value = _process_primary(terminal) 89 | break 90 | 91 | terminal: Tree = terminal.children[0] 92 | 93 | if not terminal_value: 94 | raise Exception(f"CAN NOT PROCESS MEMBER_INDEX terminal expr: {terminal}") 95 | 96 | else: 97 | raise Exception(f"UNKNOWN MEMBER_INDEX terminal TYPE: {terminal}") 98 | 99 | root: Tree = tree.children[0].children[0] 100 | 101 | if root.data == "member_dot": 102 | root_value = _process_member_dot(root) 103 | elif root.data == "member_index": 104 | root_value = _process_member_index(root) 105 | elif root.data == "member_dot_arg": 106 | root_value = _process_member_dot_arg(root) 107 | elif root.data == "primary": 108 | root_value: str = _process_primary(root) 109 | else: 110 | raise Exception(f"UNKNOWN MEMBER_INDEX root TYPE: {root}") 111 | 112 | return f"{root_value}.{terminal_value}" 113 | 114 | 115 | def _process_primary(tree: Tree) -> str: 116 | if len(tree.children) != 1: 117 | raise Exception(f"UNKNOWN PRIMARY LENGTH! {len(tree.children)}: {tree}") 118 | 119 | primary: Tree = tree.children[0] 120 | 121 | # print(f"{len(tree.children)}: {primary}") 122 | 123 | if primary.data == "ident": 124 | return f"{primary.children[0]}" 125 | 126 | if primary.data == "literal": 127 | literal: Token = primary.children[0] 128 | if literal.type == "INT_LIT": 129 | return literal.value 130 | return literal.strip(literal[0]) 131 | 132 | raise Exception(f"UNKNOWN PRIMARY DATA TYPE! {primary}") 133 | -------------------------------------------------------------------------------- /src/koreo/conditions.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from datetime import UTC, datetime 3 | from typing import TypedDict, NotRequired 4 | 5 | 6 | class Condition(TypedDict): 7 | lastTransitionTime: NotRequired[str] 8 | lastUpdateTime: NotRequired[str] 9 | location: NotRequired[str | None] 10 | message: NotRequired[str | None] 11 | status: str 12 | reason: str 13 | type: str 14 | 15 | 16 | Conditions = list[Condition] 17 | 18 | 19 | def update_condition(conditions: Conditions, condition: Condition): 20 | conditions = copy.deepcopy(conditions) 21 | 22 | for index, candidate in enumerate(conditions): 23 | if candidate.get("type") == condition.get("type"): 24 | conditions[index] = _merge_conditions(candidate, condition) 25 | return conditions 26 | 27 | conditions.append(_new_condition(condition)) 28 | return conditions 29 | 30 | 31 | def _new_condition(new: Condition) -> Condition: 32 | event_time = datetime.now(UTC).isoformat() 33 | return Condition( 34 | lastUpdateTime=event_time, 35 | lastTransitionTime=event_time, 36 | type=new.get("type"), 37 | status=new.get("status"), 38 | reason=new.get("reason"), 39 | message=new.get("message"), 40 | location=new.get("location"), 41 | ) 42 | 43 | 44 | def _merge_conditions(base: Condition, updated: Condition) -> Condition: 45 | lastTransitionTime = base.get("lastTransitionTime") 46 | if base.get("status") != updated.get("status") or not lastTransitionTime: 47 | lastTransitionTime = datetime.now(UTC).isoformat() 48 | 49 | return Condition( 50 | type=base.get("type"), 51 | lastUpdateTime=datetime.now(UTC).isoformat(), 52 | lastTransitionTime=lastTransitionTime, 53 | status=updated.get("status"), 54 | reason=updated.get("reason"), 55 | message=updated.get("message"), 56 | location=updated.get("location"), 57 | ) 58 | -------------------------------------------------------------------------------- /src/koreo/constants.py: -------------------------------------------------------------------------------- 1 | API_GROUP = PREFIX = "koreo.dev" 2 | 3 | DEFAULT_API_VERSION = "v1beta1" 4 | 5 | ACTIVE_LABEL = f"{PREFIX}/active" 6 | 7 | LAST_APPLIED_ANNOTATION = f"{PREFIX}/last-applied-configuration" 8 | 9 | DEFAULT_CREATE_DELAY = 30 10 | DEFAULT_DELETE_DELAY = 15 11 | DEFAULT_LOAD_RETRY_DELAY = 30 12 | DEFAULT_PATCH_DELAY = 30 13 | 14 | KOREO_DIRECTIVE_KEYS: set[str] = { 15 | "x-koreo-compare-as-set", 16 | "x-koreo-compare-as-map", 17 | "x-koreo-compare-last-applied", 18 | } 19 | 20 | 21 | PLURAL_LOOKUP_NEEDED = "+++missing plural+++" 22 | -------------------------------------------------------------------------------- /src/koreo/function_test/structure.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Sequence 2 | 3 | from celpy import celtypes 4 | 5 | from koreo.cel.prepare import Overlay 6 | from koreo.resource_function.structure import ResourceFunction 7 | from koreo.result import NonOkOutcome, UnwrappedOutcome 8 | from koreo.value_function.structure import ValueFunction 9 | 10 | 11 | class ExpectOutcome(NamedTuple): 12 | outcome: NonOkOutcome | None = None 13 | 14 | 15 | class ExpectReturn(NamedTuple): 16 | value: dict | None = None 17 | 18 | 19 | class ExpectResource(NamedTuple): 20 | resource: dict | None = None 21 | 22 | 23 | class ExpectDelete(NamedTuple): 24 | expected: bool = False 25 | 26 | 27 | Assertion = ExpectOutcome | ExpectResource | ExpectReturn | ExpectDelete 28 | 29 | 30 | class TestCase(NamedTuple): 31 | assertion: Assertion 32 | 33 | # Variant test cases do not carry forward. 34 | variant: bool = False 35 | 36 | # Skip 37 | skip: bool = False 38 | 39 | # For non-vaiant cases, these updates to inputs carry forward. 40 | input_overrides: celtypes.MapType | None = None 41 | 42 | # Mutually exclusive 43 | current_resource: dict | None = None 44 | resource_overlay: Overlay | None = None 45 | 46 | # Human friendly output 47 | label: str | None = None 48 | 49 | 50 | class FunctionTest(NamedTuple): 51 | function_under_test: UnwrappedOutcome[ResourceFunction | ValueFunction] 52 | 53 | inputs: celtypes.MapType | None 54 | initial_resource: dict | None 55 | 56 | test_cases: Sequence[TestCase] | None 57 | -------------------------------------------------------------------------------- /src/koreo/predicate_helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | import json 3 | import logging 4 | 5 | from celpy import celtypes 6 | import celpy 7 | 8 | from koreo import result 9 | from koreo.cel.encoder import encode_cel 10 | from koreo.cel.functions import koreo_cel_functions 11 | 12 | 13 | def predicate_extractor( 14 | cel_env: celpy.Environment, 15 | predicate_spec: Sequence[dict] | None, 16 | ) -> None | celpy.Runner | result.PermFail: 17 | if not predicate_spec: 18 | return None 19 | 20 | if not isinstance(predicate_spec, (list, tuple)): 21 | return result.PermFail(message="Malformed conditions, expected a list") 22 | 23 | predicates = encode_cel(predicate_spec) 24 | conditions = f"{predicates}.filter(predicate, !predicate.assert)" 25 | 26 | try: 27 | program = cel_env.program( 28 | cel_env.compile(conditions), functions=koreo_cel_functions 29 | ) 30 | except celpy.CELParseError as err: 31 | return result.PermFail( 32 | message=f"Parsing error at line {err.line}, column {err.column}. " 33 | f"'{err}' in '{predicates}'", 34 | ) 35 | 36 | program.logger.setLevel(logging.WARNING) 37 | return program 38 | 39 | 40 | def predicate_to_koreo_result( 41 | predicates: celtypes.ListType, location: str 42 | ) -> result.NonOkOutcome | None: 43 | if not predicates: 44 | return None 45 | 46 | for predicate in predicates: 47 | match predicate: 48 | case {"assert": _, "ok": {}}: 49 | return None 50 | 51 | case {"assert": _, "depSkip": {"message": message}}: 52 | return result.DepSkip(message=f"{message}", location=location) 53 | 54 | case {"assert": _, "skip": {"message": message}}: 55 | return result.Skip(message=f"{message}", location=location) 56 | 57 | case {"assert": _, "retry": {"message": message, "delay": delay}}: 58 | return result.Retry( 59 | message=f"{message}", 60 | delay=int(f"{delay}"), 61 | location=location, 62 | ) 63 | 64 | case {"assert": _, "permFail": {"message": message}}: 65 | return result.PermFail(message=f"{message}", location=location) 66 | 67 | case _: 68 | return result.PermFail( 69 | f"Unknown predicate type: {json.dumps(predicate)}", 70 | location=location, 71 | ) 72 | 73 | return None 74 | -------------------------------------------------------------------------------- /src/koreo/ref_helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from koreo import registry 4 | from koreo import result 5 | 6 | from koreo.resource_function.structure import ResourceFunction 7 | from koreo.value_function.structure import ValueFunction 8 | from koreo.workflow.structure import Workflow 9 | 10 | FunctionResource = ( 11 | registry.Resource[ResourceFunction] | registry.Resource[ValueFunction] 12 | ) 13 | 14 | LogicResource = ( 15 | registry.Resource[ResourceFunction] 16 | | registry.Resource[ValueFunction] 17 | | registry.Resource[Workflow] 18 | ) 19 | 20 | 21 | def function_ref_spec_to_resource( 22 | spec: Any, location: str 23 | ) -> None | FunctionResource | result.PermFail: 24 | if not spec: 25 | return None 26 | 27 | if not isinstance(spec, dict): 28 | return result.PermFail( 29 | message=( 30 | "Failed to process `functionRef`, expected object with `kind` and `name`. " 31 | f"received {type(spec)}" 32 | ), 33 | location=location, 34 | ) 35 | 36 | logic_kind = spec.get("kind") 37 | logic_name = spec.get("name") 38 | 39 | if not logic_kind: 40 | return result.PermFail(message="Missing `functionRef.kind`.", location=location) 41 | 42 | if not logic_name: 43 | return result.PermFail( 44 | message=f"Missing `functionRef.name`.", location=location 45 | ) 46 | 47 | # This is not using a "dict lookup" because of Python's deficient type 48 | # narrowing. 49 | match logic_kind: 50 | case "ValueFunction": 51 | return registry.Resource(resource_type=ValueFunction, name=logic_name) 52 | case "ResourceFunction": 53 | return registry.Resource(resource_type=ResourceFunction, name=logic_name) 54 | case _: 55 | return result.PermFail( 56 | message=f"Invalid `functionRef.kind` ({logic_kind}).", location=location 57 | ) 58 | -------------------------------------------------------------------------------- /src/koreo/registry.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import NamedTuple, Sequence 3 | import asyncio 4 | import time 5 | import logging 6 | 7 | logger = logging.getLogger(name="koreo.registry") 8 | 9 | 10 | class Resource[T](NamedTuple): 11 | resource_type: type[T] 12 | name: str 13 | namespace: str | None = None 14 | 15 | 16 | class Kill: ... 17 | 18 | 19 | class ResourceEvent[T](NamedTuple): 20 | resource: Resource[T] 21 | event_time: float 22 | 23 | 24 | type RegistryQueue = asyncio.Queue[ResourceEvent | Kill] 25 | 26 | 27 | def register[T]( 28 | registerer: Resource[T], 29 | queue: RegistryQueue | None = None, 30 | ) -> RegistryQueue: 31 | registerer_key = _resource_key(registerer) 32 | 33 | if registerer_key in _SUBSCRIPTION_QUEUES: 34 | return _SUBSCRIPTION_QUEUES[registerer_key] 35 | 36 | if not queue: 37 | queue = asyncio.LifoQueue[ResourceEvent | Kill]() 38 | 39 | _SUBSCRIPTION_QUEUES[registerer_key] = queue 40 | 41 | event_time = time.monotonic() 42 | notify_subscribers(notifier=registerer, event_time=event_time) 43 | 44 | logger.debug(f"Registering {registerer}") 45 | 46 | return queue 47 | 48 | 49 | class SubscriptionCycle(Exception): ... 50 | 51 | 52 | def subscribe(subscriber: Resource, resource: Resource): 53 | subscriber_key = _resource_key(subscriber) 54 | resource_key = _resource_key(resource) 55 | 56 | _check_for_cycles(subscriber_key, (resource_key,)) 57 | 58 | _RESOURCE_SUBSCRIBERS[resource_key].add(subscriber_key) 59 | _SUBSCRIBER_RESOURCES[subscriber_key].add(resource_key) 60 | 61 | logger.debug(f"{subscriber} subscribing to {resource}") 62 | 63 | 64 | def subscribe_only_to(subscriber: Resource, resources: Sequence[Resource]): 65 | subscriber_key = _resource_key(subscriber) 66 | 67 | new = set(_resource_key(subscribe_to) for subscribe_to in resources) 68 | _check_for_cycles(subscriber_key, list(new)) 69 | 70 | current = _SUBSCRIBER_RESOURCES[subscriber_key] 71 | 72 | for resource_key in new - current: 73 | _RESOURCE_SUBSCRIBERS[resource_key].add(subscriber_key) 74 | 75 | for resource_key in current - new: 76 | _RESOURCE_SUBSCRIBERS[resource_key].remove(subscriber_key) 77 | 78 | _SUBSCRIBER_RESOURCES[subscriber_key] = new 79 | 80 | logger.debug(f"{subscriber} subscribing to {resources}") 81 | 82 | 83 | def unsubscribe(unsubscriber: Resource, resource: Resource): 84 | unsubscriber_key = _resource_key(unsubscriber) 85 | resource_key = _resource_key(resource) 86 | 87 | _RESOURCE_SUBSCRIBERS[resource_key].remove(unsubscriber_key) 88 | _SUBSCRIBER_RESOURCES[unsubscriber_key].remove(resource_key) 89 | 90 | 91 | def notify_subscribers(notifier: Resource, event_time: float): 92 | resource_key = _resource_key(notifier) 93 | subscribers = _RESOURCE_SUBSCRIBERS[resource_key] 94 | if not subscribers: 95 | logger.debug(f"{notifier} has no subscribers") 96 | return 97 | 98 | active_subscribers = [ 99 | _SUBSCRIPTION_QUEUES[subscriber] 100 | for subscriber in subscribers 101 | if subscriber in _SUBSCRIPTION_QUEUES 102 | ] 103 | 104 | if not active_subscribers: 105 | logger.debug(f"{notifier} has no active subscribers") 106 | return 107 | 108 | logger.debug(f"{notifier}:{event_time} notifying to {subscribers}") 109 | 110 | for subscriber in active_subscribers: 111 | try: 112 | subscriber.put_nowait( 113 | ResourceEvent(resource=notifier, event_time=event_time) 114 | ) 115 | except asyncio.QueueFull: 116 | pass 117 | # TODO: I think there is a way to monitor for stalled subscribers 118 | # then notify a house-keeper process to deal with it. 119 | 120 | # health_check_task = asyncio.create_task() 121 | # _CHECK_SUBSCRIBER_HEALTH.add(health_check_task) 122 | # health_check_task.add_done_callback(_CHECK_SUBSCRIBER_HEALTH.discard) 123 | 124 | 125 | def get_subscribers(resource: Resource): 126 | resource_key = _resource_key(resource) 127 | 128 | return _RESOURCE_SUBSCRIBERS[resource_key] 129 | 130 | 131 | def kill_resource(resource: Resource) -> RegistryQueue | None: 132 | resource_key = _resource_key(resource) 133 | if resource_key not in _SUBSCRIPTION_QUEUES: 134 | return None 135 | 136 | _kill_resource(resource_key) 137 | 138 | 139 | def deregister(deregisterer: Resource, deregistered_at: float): 140 | deregisterer_key = _resource_key(deregisterer) 141 | 142 | # This resource is no longer following any resources. 143 | subscribe_only_to(subscriber=deregisterer, resources=[]) 144 | 145 | # Remove this resource's subscription queue 146 | if deregisterer_key in _SUBSCRIPTION_QUEUES: 147 | queue = _kill_resource(resource_key=deregisterer_key) 148 | assert queue # Just for the type-checker 149 | 150 | del _SUBSCRIPTION_QUEUES[deregisterer_key] 151 | 152 | # This is to prevent blocking anything waiting for this resource to do 153 | # something. 154 | while not queue.empty(): 155 | try: 156 | queue.get_nowait() 157 | queue.task_done() 158 | except asyncio.QueueEmpty: 159 | break 160 | 161 | # Inform subscribers of a change 162 | notify_subscribers(notifier=deregisterer, event_time=deregistered_at) 163 | 164 | 165 | class _ResourceKey(NamedTuple): 166 | resource_type: str 167 | name: str 168 | namespace: str | None = None 169 | 170 | 171 | def _resource_key(resource: Resource) -> _ResourceKey: 172 | return _ResourceKey( 173 | resource_type=resource.resource_type.__qualname__, 174 | name=resource.name, 175 | namespace=resource.namespace, 176 | ) 177 | 178 | 179 | def _kill_resource(resource_key: _ResourceKey) -> RegistryQueue | None: 180 | queue = _SUBSCRIPTION_QUEUES[resource_key] 181 | try: 182 | queue.put_nowait(Kill()) 183 | 184 | except asyncio.QueueShutDown: 185 | return queue 186 | 187 | except asyncio.QueueFull: 188 | pass 189 | 190 | queue.shutdown() 191 | 192 | return queue 193 | 194 | 195 | def _check_for_cycles( 196 | subscriber_key: _ResourceKey, resource_keys: Sequence[_ResourceKey] 197 | ): 198 | # Simple, inefficient cycle detection. This is a simple brute-force check, 199 | # which hopefully given the problem space is sufficient. 200 | checked: set[_ResourceKey] = set() 201 | to_check: set[_ResourceKey] = set(resource_keys) 202 | while True: 203 | if not to_check: 204 | break 205 | 206 | if subscriber_key in to_check: 207 | raise SubscriptionCycle( 208 | f"Detected subscription cycle due to {subscriber_key}" 209 | ) 210 | 211 | checked.update(to_check) 212 | 213 | next_check_set = set[_ResourceKey]() 214 | for check_resource_key in to_check: 215 | if check_resource_key not in _SUBSCRIBER_RESOURCES: 216 | continue 217 | 218 | check_resource_subscriptions = _SUBSCRIBER_RESOURCES[check_resource_key] 219 | next_check_set.update(check_resource_subscriptions) 220 | to_check = next_check_set 221 | 222 | 223 | _RESOURCE_SUBSCRIBERS: defaultdict[_ResourceKey, set[_ResourceKey]] = defaultdict( 224 | set[_ResourceKey] 225 | ) 226 | _SUBSCRIBER_RESOURCES: defaultdict[_ResourceKey, set[_ResourceKey]] = defaultdict( 227 | set[_ResourceKey] 228 | ) 229 | _SUBSCRIPTION_QUEUES: dict[_ResourceKey, RegistryQueue] = {} 230 | 231 | 232 | def _reset_registries(): 233 | _RESOURCE_SUBSCRIBERS.clear() 234 | _SUBSCRIBER_RESOURCES.clear() 235 | 236 | for queue in _SUBSCRIPTION_QUEUES.values(): 237 | try: 238 | queue.put_nowait(Kill()) 239 | except (asyncio.QueueFull, asyncio.QueueShutDown): 240 | pass 241 | 242 | try: 243 | while not queue.empty(): 244 | queue.get_nowait() 245 | queue.task_done() 246 | except asyncio.QueueEmpty: 247 | pass 248 | 249 | _SUBSCRIPTION_QUEUES.clear() 250 | -------------------------------------------------------------------------------- /src/koreo/resource_function/reconcile/kind_lookup.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import kr8s.asyncio 5 | 6 | logger = logging.getLogger("koreo.resource_function.reconcile") 7 | 8 | LOOKUP_TIMEOUT = 15 9 | 10 | _plural_map: dict[str, str] = {} 11 | 12 | _lookup_locks: dict[str, asyncio.Event] = {} 13 | 14 | 15 | async def get_plural_kind( 16 | api: kr8s.asyncio.Api, kind: str, api_version: str 17 | ) -> str | None: 18 | if api_version == "v1": 19 | lookup_kind = f"{kind}" 20 | else: 21 | lookup_kind = f"{kind}.{api_version}" 22 | 23 | if lookup_kind in _plural_map: 24 | return _plural_map[lookup_kind] 25 | 26 | lookup_lock = _lookup_locks.get(lookup_kind) 27 | if lookup_lock: 28 | try: 29 | await asyncio.wait_for(lookup_lock.wait(), timeout=LOOKUP_TIMEOUT) 30 | except asyncio.TimeoutError: 31 | pass 32 | 33 | if lookup_kind in _plural_map: 34 | return _plural_map[lookup_kind] 35 | 36 | raise Exception(f"Waiting on {lookup_kind} failed.") 37 | 38 | lookup_lock = asyncio.Event() 39 | _lookup_locks[lookup_kind] = lookup_lock 40 | 41 | for _ in range(3): 42 | try: 43 | async with asyncio.timeout(LOOKUP_TIMEOUT): 44 | try: 45 | (_, plural_kind, _) = await api.lookup_kind(lookup_kind) 46 | break 47 | except ValueError: 48 | del _lookup_locks[lookup_kind] 49 | logger.error(f"Failed to find Kind (`{lookup_kind}`) information.") 50 | return None 51 | except asyncio.TimeoutError: 52 | continue 53 | except: 54 | del _lookup_locks[lookup_kind] 55 | raise 56 | else: 57 | del _lookup_locks[lookup_kind] 58 | raise Exception( 59 | f"Too many failed attempts to find plural kind for {lookup_kind} failed." 60 | ) 61 | 62 | _plural_map[lookup_kind] = plural_kind 63 | 64 | lookup_lock.set() 65 | 66 | return plural_kind 67 | 68 | 69 | def _reset(): 70 | """Helper for unit testing; not intended for usage in normal code.""" 71 | _plural_map.clear() 72 | 73 | for lock in _lookup_locks.values(): 74 | lock.set() 75 | 76 | _lookup_locks.clear() 77 | -------------------------------------------------------------------------------- /src/koreo/resource_function/reconcile/validate.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Sequence 2 | 3 | from koreo.constants import KOREO_DIRECTIVE_KEYS 4 | 5 | 6 | class ResourceMatch(NamedTuple): 7 | match: bool 8 | differences: Sequence[str] 9 | 10 | 11 | def validate_match( 12 | target, 13 | actual, 14 | last_applied_value=None, 15 | compare_list_as_set: bool = False, 16 | ) -> ResourceMatch: 17 | """Compare the specified (`target`) state against the actual (`actual`) 18 | reosurce state. We compare all target fields and ignore anything extra so 19 | that defaults, controller set, or explicitly set values do not cause 20 | compare issues. 21 | """ 22 | 23 | match (target, actual): 24 | # Objects need a special comparator 25 | case dict(), dict(): 26 | return _validate_dict_match(target, actual, last_applied_value) 27 | case dict(), _: 28 | return ResourceMatch( 29 | match=False, 30 | differences=[f""], 31 | ) 32 | case _, dict(): 33 | return ResourceMatch( 34 | match=False, 35 | differences=[f""], 36 | ) 37 | 38 | # Arrays need a special comparator 39 | case (list() | tuple(), list() | tuple()): 40 | if compare_list_as_set: 41 | # Sets must be simple, so if needed last_applied_value is 42 | # already the compare value. 43 | return _validate_set_match(target, actual) 44 | 45 | return _validate_list_match(target, actual, last_applied_value) 46 | 47 | case (list() | tuple(), _): 48 | return ResourceMatch( 49 | match=False, 50 | differences=[f""], 51 | ) 52 | case (_, list() | tuple()): 53 | return ResourceMatch( 54 | match=False, 55 | differences=[f""], 56 | ) 57 | 58 | # Bool needs a special comparator, due to Python's int truthiness rules 59 | case bool(), bool(): 60 | if target == actual: 61 | return ResourceMatch(match=True, differences=()) 62 | else: 63 | return ResourceMatch( 64 | match=False, 65 | differences=[f""], 66 | ) 67 | case bool(), _: 68 | return ResourceMatch( 69 | match=False, 70 | differences=[f""], 71 | ) 72 | case _, bool(): 73 | return ResourceMatch( 74 | match=False, 75 | differences=[f""], 76 | ) 77 | 78 | case _, None if target: 79 | return ResourceMatch( 80 | match=False, 81 | differences=[f""], 82 | ) 83 | 84 | case None, _ if actual: 85 | return ResourceMatch( 86 | match=False, 87 | differences=[f""], 97 | ) 98 | 99 | 100 | def _obj_to_key(obj: dict, fields: Sequence[int | str]) -> str: 101 | return "$".join(f"{obj.get(field)}".strip() for field in fields) 102 | 103 | 104 | def _list_to_object( 105 | obj_list: Sequence[dict], key_fields: Sequence[int | str] 106 | ) -> dict[str, dict] | None: 107 | if not obj_list: 108 | return None 109 | 110 | return {_obj_to_key(obj, key_fields): obj for obj in obj_list} 111 | 112 | 113 | def _validate_dict_match( 114 | target: dict, actual: dict, last_applied_value: dict | None = None 115 | ) -> ResourceMatch: 116 | target_keys = set(target.keys()) - KOREO_DIRECTIVE_KEYS 117 | 118 | compare_list_as_set_keys = { 119 | key for key in target.get("x-koreo-compare-as-set", ()) if key 120 | } 121 | 122 | compare_last_applied_keys = { 123 | key for key in target.get("x-koreo-compare-last-applied", ()) if key 124 | } 125 | 126 | compare_as_map = { 127 | key: [field_name for field_name in fields if field_name] 128 | for key, fields in target.get("x-koreo-compare-as-map", {}).items() 129 | if key 130 | } 131 | 132 | if not last_applied_value: 133 | last_applied_value = {} 134 | 135 | for target_key in target_keys: 136 | # TODO: At some point, this will appear outside metadata and eventually 137 | # cause a problem. Perhaps `_extract_last_applied` should instead 138 | # mutate the object for compare? 139 | if target_key == "ownerReferences": 140 | continue 141 | 142 | # NOTE: I'm not sure this is correct. 143 | if target_key not in last_applied_value: 144 | last_applied_value[target_key] = None 145 | 146 | if target_key in compare_last_applied_keys: 147 | compare_value = last_applied_value[target_key] 148 | 149 | else: 150 | if target_key not in actual: 151 | return ResourceMatch( 152 | match=False, differences=[f""] 153 | ) 154 | 155 | compare_value = actual[target_key] 156 | 157 | if target_key in compare_as_map: 158 | map_keys = compare_as_map[target_key] 159 | key_match = validate_match( 160 | target=_list_to_object(target[target_key], map_keys), 161 | actual=_list_to_object(compare_value, map_keys), 162 | last_applied_value=_list_to_object( 163 | last_applied_value[target_key], map_keys 164 | ), 165 | ) 166 | else: 167 | key_match = validate_match( 168 | target=target[target_key], 169 | actual=compare_value, 170 | last_applied_value=last_applied_value[target_key], 171 | compare_list_as_set=(target_key in compare_list_as_set_keys), 172 | ) 173 | 174 | if not key_match.match: 175 | differences = [f"'{target_key}'"] 176 | differences.extend(key_match.differences) 177 | return ResourceMatch(match=False, differences=differences) 178 | 179 | return ResourceMatch(match=True, differences=()) 180 | 181 | 182 | def _validate_list_match( 183 | target: list | tuple, 184 | actual: list | tuple, 185 | last_applied_value: list | tuple | None = None, 186 | ) -> ResourceMatch: 187 | if not target and not actual: 188 | return ResourceMatch(match=True, differences=()) 189 | 190 | if target is None and actual: 191 | return ResourceMatch(match=False, differences=("",)) 192 | 193 | if target and actual is None: 194 | return ResourceMatch( 195 | match=False, differences=("",) 196 | ) 197 | 198 | if len(target) != len(actual): 199 | return ResourceMatch( 200 | match=False, 201 | differences=[ 202 | f" ResourceMatch: 231 | if not target and not actual: 232 | return ResourceMatch(match=True, differences=()) 233 | 234 | if target is None and actual: 235 | return ResourceMatch(match=False, differences=("",)) 236 | 237 | if target and actual is None: 238 | return ResourceMatch( 239 | match=False, differences=("",) 240 | ) 241 | 242 | try: 243 | target_set = set(target) 244 | actual_set = set(actual) 245 | except TypeError as err: 246 | if "dict" in f"{err}": 247 | return ResourceMatch( 248 | match=False, 249 | differences=( 250 | f"Could not compare array-as-set, try: `x-koreo-compare-as-map`", 251 | ), 252 | ) 253 | 254 | return ResourceMatch( 255 | match=False, differences=(f"Could not compare array-as-set ({err})",) 256 | ) 257 | except Exception as err: 258 | return ResourceMatch( 259 | match=False, differences=(f"Could not compare array-as-set ({err})",) 260 | ) 261 | 262 | missing_values = target_set - actual_set 263 | unexpected_values = actual_set - target_set 264 | 265 | if not (missing_values or unexpected_values): 266 | return ResourceMatch(match=True, differences=()) 267 | 268 | for missing_value in missing_values: 269 | return ResourceMatch(match=False, differences=(f"",)) 270 | 271 | for unexpected_value in unexpected_values: 272 | return ResourceMatch( 273 | match=False, differences=(f"",) 274 | ) 275 | 276 | # This is impossible 277 | return ResourceMatch(match=True, differences=()) 278 | -------------------------------------------------------------------------------- /src/koreo/resource_function/structure.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Sequence 2 | 3 | from kr8s._objects import APIObject 4 | 5 | import celpy 6 | 7 | from koreo.cel.prepare import Overlay 8 | from koreo.result import UnwrappedOutcome 9 | from koreo.value_function.structure import ValueFunction 10 | 11 | 12 | class ResourceTemplateRef(NamedTuple): 13 | name: celpy.Runner | None 14 | 15 | 16 | class InlineResourceTemplate(NamedTuple): 17 | template: celpy.Runner | None = None 18 | 19 | 20 | class ValueFunctionOverlay(NamedTuple): 21 | overlay: ValueFunction 22 | skip_if: celpy.Runner | None = None 23 | inputs: celpy.Runner | None = None 24 | 25 | 26 | class InlineOverlay(NamedTuple): 27 | overlay: Overlay 28 | skip_if: celpy.Runner | None = None 29 | 30 | 31 | class Create(NamedTuple): 32 | enabled: bool = True 33 | delay: int = 30 34 | overlay: Overlay | None = None 35 | 36 | 37 | class UpdatePatch(NamedTuple): 38 | delay: int = 30 39 | 40 | 41 | class UpdateRecreate(NamedTuple): 42 | delay: int = 30 43 | 44 | 45 | class UpdateNever(NamedTuple): 46 | pass 47 | 48 | 49 | Update = UpdatePatch | UpdateRecreate | UpdateNever 50 | 51 | 52 | class DeleteAbandon(NamedTuple): 53 | pass 54 | 55 | 56 | class DeleteDestroy(NamedTuple): 57 | force: bool = False 58 | 59 | 60 | class CRUDConfig(NamedTuple): 61 | resource_api: type[APIObject] 62 | resource_id: celpy.Runner 63 | own_resource: bool 64 | readonly: bool 65 | delete_if_exists: bool 66 | 67 | resource_template: InlineResourceTemplate | ResourceTemplateRef 68 | overlays: UnwrappedOutcome[Sequence[InlineOverlay | ValueFunctionOverlay]] 69 | 70 | create: Create 71 | update: Update 72 | 73 | 74 | class ResourceFunction(NamedTuple): 75 | name: str 76 | preconditions: celpy.Runner | None 77 | local_values: celpy.Runner | None 78 | 79 | crud_config: CRUDConfig 80 | 81 | postconditions: celpy.Runner | None 82 | return_value: celpy.Runner | None 83 | 84 | dynamic_input_keys: set[str] 85 | -------------------------------------------------------------------------------- /src/koreo/resource_template/prepare.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger("koreo.resourcetemplate.prepare") 4 | 5 | import celpy 6 | from celpy import celtypes 7 | 8 | from koreo import schema 9 | from koreo.result import PermFail, UnwrappedOutcome 10 | 11 | from . import structure 12 | 13 | 14 | async def prepare_resource_template( 15 | cache_key: str, spec: dict 16 | ) -> UnwrappedOutcome[tuple[structure.ResourceTemplate, None]]: 17 | logger.debug(f"Prepare resource template {cache_key}") 18 | 19 | if error := schema.validate( 20 | resource_type=structure.ResourceTemplate, spec=spec, validation_required=True 21 | ): 22 | return PermFail( 23 | error.message, 24 | location=f"prepare:ResourceTemplate:{cache_key}", 25 | ) 26 | 27 | template_spec = spec.get("template", {}) 28 | template = celpy.json_to_cel(template_spec) 29 | if not template_spec: 30 | return PermFail( 31 | message=f"Missing `spec.template` for ResourceTemplate '{cache_key}'.", 32 | location=f"prepare:ResourceTemplate:{cache_key}", 33 | ) 34 | 35 | if not isinstance(template, celtypes.MapType): 36 | return PermFail( 37 | message=f"ResourceTemplate '{cache_key}' `spec.template` must be an object.", 38 | location=f"prepare:ResourceTemplate:{cache_key}", 39 | ) 40 | 41 | if not (template_spec.get("apiVersion") and template_spec.get("kind")): 42 | return PermFail( 43 | message=( 44 | f"ResourceTemplate '{cache_key}' `apiVersion` and `kind` must " 45 | "be set within `spec.template` (" 46 | f"apiVersion: '{template_spec.get("apiVersion")}', " 47 | f"kind: '{template_spec.get("kind")}') " 48 | ), 49 | location=f"prepare:ResourceTemplate:{cache_key}", 50 | ) 51 | 52 | context = celpy.json_to_cel(spec.get("context", {})) 53 | if not isinstance(context, celtypes.MapType): 54 | return PermFail( 55 | message=f"ResourceTemplate '{cache_key}' `spec.context` ('{context}') must be an object.", 56 | location=f"prepare:ResourceTemplate:{cache_key}", 57 | ) 58 | 59 | return ( 60 | structure.ResourceTemplate( 61 | context=context, 62 | template=template, 63 | ), 64 | None, 65 | ) 66 | -------------------------------------------------------------------------------- /src/koreo/resource_template/structure.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | from celpy import celtypes 4 | 5 | 6 | class ResourceTemplate(NamedTuple): 7 | context: celtypes.MapType 8 | 9 | template: celtypes.MapType 10 | -------------------------------------------------------------------------------- /src/koreo/result.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import reduce 4 | from typing import Any, TypeIs, TypeVar, Iterable 5 | 6 | 7 | class DepSkip: 8 | """This is internal for skipping due to dependency fail.""" 9 | 10 | message: str | None 11 | location: str | None 12 | 13 | def __init__(self, message: str | None = None, location: str | None = None): 14 | self.message = message 15 | self.location = location 16 | 17 | def combine(self, other: Outcome): 18 | return other 19 | 20 | def __str__(self) -> str: 21 | if self.message: 22 | return f"Dependency Skip (message={self.message})" 23 | 24 | return "Dependency Violation Skip" 25 | 26 | 27 | class Skip: 28 | """Indicates that this was intentially skipped.""" 29 | 30 | message: str | None 31 | location: str | None 32 | 33 | def __init__(self, message: str | None = None, location: str | None = None): 34 | self.message = message 35 | self.location = location 36 | 37 | def combine(self, other: Outcome): 38 | if isinstance(other, (DepSkip,)): 39 | return self 40 | 41 | return other 42 | 43 | def __str__(self) -> str: 44 | if self.message: 45 | return f"Skip (message={self.message})" 46 | 47 | return "User Skip" 48 | 49 | 50 | class Ok[T]: 51 | """Indicates success and `self.data` contains a value of type `T`.""" 52 | 53 | data: T 54 | location: str | None 55 | 56 | def __init__(self, data: T, location: str | None = None): 57 | self.data = data 58 | self.location = location 59 | 60 | def __str__(self) -> str: 61 | return f"Ok(data={self.data})" 62 | 63 | def combine(self, other: Outcome): 64 | # This is a lower-priority outcome 65 | if isinstance(other, (DepSkip, Skip)): 66 | if isinstance(self.data, _OkData): 67 | return self 68 | 69 | return Ok(data=_OkData([self.data]), location=self.location) 70 | 71 | if not isinstance(other, Ok): 72 | return other 73 | 74 | if isinstance(self.data, _OkData): 75 | combined_data = self.data.append(other.data) 76 | else: 77 | combined_data = _OkData([self.data, other.data]) 78 | 79 | location = [] 80 | if self.location: 81 | location.append(self.location) 82 | 83 | if other.location: 84 | location.append(other.location) 85 | 86 | return Ok(data=combined_data, location=", ".join(location)) 87 | 88 | 89 | class Retry: 90 | """Retry reconciliation after `self.delay` seconds.""" 91 | 92 | message: str | None 93 | delay: int 94 | location: str | None 95 | 96 | def __init__( 97 | self, delay: int = 60, message: str | None = None, location: str | None = None 98 | ): 99 | self.message = message 100 | self.delay = delay 101 | self.location = location 102 | 103 | def __str__(self) -> str: 104 | return f"Retry(delay={self.delay}, message={self.message})" 105 | 106 | def combine(self, other: Outcome): 107 | if isinstance(other, (DepSkip, Skip, Ok)): 108 | return self 109 | 110 | if isinstance(other, PermFail): 111 | return other 112 | 113 | message = [] 114 | if self.message: 115 | message.append(self.message) 116 | 117 | if other.message: 118 | message.append(other.message) 119 | 120 | location = [] 121 | if self.location: 122 | location.append(self.location) 123 | 124 | if other.location: 125 | location.append(other.location) 126 | 127 | return Retry( 128 | message=", ".join(message), 129 | delay=max(self.delay, other.delay), 130 | location=", ".join(location), 131 | ) 132 | 133 | 134 | class PermFail: 135 | """An error indicating retries should not be attempted.""" 136 | 137 | message: str | None 138 | location: str | None 139 | 140 | def __init__(self, message: str | None = None, location: str | None = None): 141 | self.message = message 142 | self.location = location 143 | 144 | def __str__(self) -> str: 145 | return f"Permanent Failure (message={self.message})" 146 | 147 | def combine(self, other: Outcome): 148 | if isinstance(other, (DepSkip, Skip, Ok, Retry)): 149 | return self 150 | 151 | message = [] 152 | if self.message: 153 | message.append(self.message) 154 | 155 | if other.message: 156 | message.append(other.message) 157 | 158 | location = [] 159 | if self.location: 160 | location.append(self.location) 161 | 162 | if other.location: 163 | location.append(other.location) 164 | 165 | return PermFail(message=", ".join(message), location=", ".join(location)) 166 | 167 | 168 | OkT = TypeVar("OkT") 169 | 170 | ErrorOutcome = Retry | PermFail 171 | SkipOutcome = DepSkip | Skip 172 | NonOkOutcome = SkipOutcome | ErrorOutcome 173 | Outcome = NonOkOutcome | Ok[OkT] 174 | UnwrappedOutcome = NonOkOutcome | OkT 175 | 176 | 177 | # This is just for the Ok combine's use so that we know if we've already 178 | # combined results. 179 | class _OkData: 180 | def __init__(self, values: list): 181 | self.values = values 182 | 183 | def append(self, value): 184 | new = self.values[:] 185 | new.append(value) 186 | return _OkData(new) 187 | 188 | 189 | def combine(outcomes: Iterable[Outcome]): 190 | if not outcomes: 191 | return Skip() 192 | 193 | combined = reduce(lambda acc, outcome: acc.combine(outcome), outcomes, DepSkip()) 194 | 195 | if not isinstance(combined, Ok): 196 | return combined 197 | 198 | if isinstance(combined.data, _OkData): 199 | return Ok(data=combined.data.values, location=combined.location) 200 | 201 | # Python will not run reduce if there's a single element in the list, it 202 | # returns the first element, so the value should be wrapped. 203 | return Ok(data=[combined.data], location=combined.location) 204 | 205 | 206 | def unwrapped_combine(outcomes: Iterable[UnwrappedOutcome]) -> UnwrappedOutcome: 207 | if not outcomes: 208 | return Skip() 209 | 210 | combined = reduce( 211 | lambda acc, outcome: acc.combine( 212 | Ok(outcome) if is_unwrapped_ok(outcome) else outcome 213 | ), 214 | outcomes, 215 | DepSkip(), # The lowest priority in a combine flow 216 | ) 217 | 218 | if not is_ok(combined): 219 | return combined 220 | 221 | if isinstance(combined.data, _OkData): 222 | return combined.data.values 223 | 224 | # Python will not run reduce if there's a single element in the list, it 225 | # returns the first element, so the value should be wrapped. 226 | return [combined.data] 227 | 228 | 229 | def is_ok[T](candidate: Outcome[T]) -> TypeIs[Ok[T]]: 230 | if isinstance(candidate, Ok): 231 | return True 232 | 233 | return False 234 | 235 | 236 | def is_unwrapped_ok[T](candidate: UnwrappedOutcome[T]) -> TypeIs[T]: 237 | if is_error(candidate): 238 | return False 239 | 240 | if is_skip(candidate): 241 | return False 242 | 243 | return True 244 | 245 | 246 | def is_not_ok(candidate: Outcome) -> TypeIs[NonOkOutcome]: 247 | return not is_ok(candidate=candidate) 248 | 249 | 250 | def is_error(candidate: Any) -> TypeIs[ErrorOutcome]: 251 | if isinstance(candidate, (Retry, PermFail)): 252 | return True 253 | 254 | return False 255 | 256 | 257 | def is_skip(candidate: Any) -> TypeIs[SkipOutcome]: 258 | if isinstance(candidate, (DepSkip, Skip)): 259 | return True 260 | 261 | return False 262 | 263 | 264 | def is_not_error(candidate: Outcome) -> TypeIs[Ok | Skip | DepSkip]: 265 | return not is_error(candidate=candidate) 266 | -------------------------------------------------------------------------------- /src/koreo/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import logging 3 | import pathlib 4 | 5 | logger = logging.getLogger("koreo.schema") 6 | 7 | import fastjsonschema 8 | import yaml 9 | 10 | from koreo.constants import DEFAULT_API_VERSION 11 | from koreo.function_test.structure import FunctionTest 12 | from koreo.resource_function.structure import ResourceFunction 13 | from koreo.resource_template.structure import ResourceTemplate 14 | from koreo.result import PermFail 15 | from koreo.value_function.structure import ValueFunction 16 | from koreo.workflow.structure import Workflow 17 | 18 | CRD_ROOT = pathlib.Path(__file__).parent.parent.parent.joinpath("crd") 19 | 20 | CRD_MAP = { 21 | FunctionTest: "function-test.yaml", 22 | ResourceFunction: "resource-function.yaml", 23 | ResourceTemplate: "resource-template.yaml", 24 | ValueFunction: "value-function.yaml", 25 | Workflow: "workflow.yaml", 26 | } 27 | 28 | _SCHEMA_VALIDATORS = {} 29 | 30 | 31 | def validate( 32 | resource_type: type, 33 | spec: Any, 34 | schema_version: str | None = None, 35 | validation_required: bool = False, 36 | ): 37 | schema_validator = _get_validator( 38 | resource_type=resource_type, version=schema_version 39 | ) 40 | if not schema_validator: 41 | if not validation_required: 42 | return None 43 | 44 | return PermFail( 45 | f"Schema validator not found for {resource_type.__name__} version {schema_version or DEFAULT_API_VERSION}", 46 | ) 47 | 48 | try: 49 | schema_validator(spec) 50 | except fastjsonschema.JsonSchemaValueException as err: 51 | # This is hacky, and likely buggy, but it makes the messages easier to grok. 52 | validation_err = f"{err.rule_definition} {err}".replace( 53 | "data.", "spec." 54 | ).replace("data ", "spec ") 55 | return PermFail(validation_err) 56 | 57 | return None 58 | 59 | 60 | def _get_validator(resource_type: type, version: str | None = None): 61 | if not _SCHEMA_VALIDATORS: 62 | load_validators_from_files() 63 | 64 | if not version: 65 | version = DEFAULT_API_VERSION 66 | 67 | resource_version_key = f"{resource_type.__qualname__}:{version}" 68 | 69 | return _SCHEMA_VALIDATORS.get(resource_version_key) 70 | 71 | 72 | def load_validator(resource_type_name: str, resource_schema: dict): 73 | spec = resource_schema.get("spec") 74 | if not spec: 75 | return None 76 | 77 | spec_names = spec.get("names") 78 | if spec_names: 79 | spec_kind = spec_names.get("kind", "") 80 | else: 81 | spec_kind = "" 82 | 83 | schema_specs = spec.get("versions") 84 | if not schema_specs: 85 | return None 86 | 87 | for schema_spec in schema_specs: 88 | version = schema_spec.get("name") 89 | if not version: 90 | continue 91 | 92 | schema_block = schema_spec.get("schema") 93 | if not schema_block: 94 | continue 95 | 96 | openapi_schema = schema_block.get("openAPIV3Schema") 97 | if not openapi_schema: 98 | continue 99 | 100 | openapi_properties = openapi_schema.get("properties") 101 | if not openapi_properties: 102 | continue 103 | 104 | openapi_spec = openapi_properties.get("spec") 105 | 106 | try: 107 | version_validator = fastjsonschema.compile(openapi_spec) 108 | except fastjsonschema.JsonSchemaDefinitionException: 109 | logger.exception(f"Failed to process {spec_kind} {version}") 110 | continue 111 | except AttributeError as err: 112 | logger.error( 113 | f"Probably encountered an empty `properties` block for {spec_kind} {version} (err: {err})" 114 | ) 115 | raise 116 | 117 | resource_version_key = f"{resource_type_name}:{version}" 118 | _SCHEMA_VALIDATORS[resource_version_key] = version_validator 119 | 120 | 121 | def load_validators_from_files(clear_existing: bool = False, path: str = CRD_ROOT): 122 | if clear_existing: 123 | _SCHEMA_VALIDATORS.clear() 124 | 125 | for resource_type, schema_file in CRD_MAP.items(): 126 | full_path = path.joinpath(schema_file) 127 | if not full_path.exists(): 128 | logger.error( 129 | f"Failed to load {resource_type} schema from file '{schema_file}'" 130 | ) 131 | continue 132 | 133 | with full_path.open() as crd_content: 134 | parsed = yaml.load(crd_content, Loader=yaml.Loader) 135 | if not parsed: 136 | logger.error( 137 | f"Failed to parse {resource_type} schema content from file '{full_path}'" 138 | ) 139 | continue 140 | 141 | load_validator( 142 | resource_type_name=resource_type.__qualname__, resource_schema=parsed 143 | ) 144 | -------------------------------------------------------------------------------- /src/koreo/value_function/prepare.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger("koreo.valuefunction.prepare") 4 | 5 | import celpy 6 | 7 | from koreo import schema 8 | from koreo.cel.functions import koreo_function_annotations 9 | from koreo.cel.prepare import ( 10 | Overlay, 11 | prepare_map_expression, 12 | prepare_overlay_expression, 13 | ) 14 | from koreo.cel.structure_extractor import extract_argument_structure 15 | from koreo.predicate_helpers import predicate_extractor 16 | from koreo.result import PermFail, UnwrappedOutcome 17 | 18 | from . import structure 19 | 20 | # Try to reduce the incredibly verbose logging from celpy 21 | logging.getLogger("Environment").setLevel(logging.WARNING) 22 | logging.getLogger("NameContainer").setLevel(logging.WARNING) 23 | logging.getLogger("Evaluator").setLevel(logging.WARNING) 24 | logging.getLogger("evaluation").setLevel(logging.WARNING) 25 | logging.getLogger("celtypes").setLevel(logging.WARNING) 26 | 27 | 28 | async def prepare_value_function( 29 | cache_key: str, spec: dict 30 | ) -> UnwrappedOutcome[tuple[structure.ValueFunction, None]]: 31 | logger.debug(f"Prepare ValueFunction:{cache_key}") 32 | 33 | if error := schema.validate( 34 | resource_type=structure.ValueFunction, spec=spec, validation_required=True 35 | ): 36 | return PermFail( 37 | error.message, 38 | location=_location(cache_key, "spec"), 39 | ) 40 | 41 | env = celpy.Environment(annotations=koreo_function_annotations) 42 | 43 | used_vars = set[str]() 44 | 45 | match predicate_extractor(cel_env=env, predicate_spec=spec.get("preconditions")): 46 | case PermFail(message=message): 47 | return PermFail( 48 | message=message, location=_location(cache_key, "spec.preconditions") 49 | ) 50 | case None: 51 | preconditions = None 52 | case celpy.Runner() as preconditions: 53 | used_vars.update(extract_argument_structure(preconditions.ast)) 54 | 55 | match prepare_map_expression( 56 | cel_env=env, spec=spec.get("locals"), location="spec.locals" 57 | ): 58 | case PermFail(message=message): 59 | return PermFail( 60 | message=message, 61 | location=_location(cache_key, "spec.locals"), 62 | ) 63 | case None: 64 | local_values = None 65 | case celpy.Runner() as local_values: 66 | used_vars.update(extract_argument_structure(local_values.ast)) 67 | 68 | match prepare_overlay_expression( 69 | cel_env=env, spec=spec.get("return"), location="spec.return" 70 | ): 71 | case PermFail(message=message): 72 | return PermFail( 73 | message=message, 74 | location=_location(cache_key, "spec.return"), 75 | ) 76 | case None: 77 | return_value = None 78 | 79 | case Overlay(values=values) as return_value: 80 | used_vars.update(extract_argument_structure(values.ast)) 81 | 82 | return ( 83 | structure.ValueFunction( 84 | preconditions=preconditions, 85 | local_values=local_values, 86 | return_value=return_value, 87 | dynamic_input_keys=used_vars, 88 | ), 89 | None, 90 | ) 91 | 92 | 93 | def _location(cache_key: str, extra: str | None = None) -> str: 94 | base = f"prepare:ValueFunction:{cache_key}" 95 | if not extra: 96 | return base 97 | 98 | return f"{base}:{extra}" 99 | -------------------------------------------------------------------------------- /src/koreo/value_function/reconcile.py: -------------------------------------------------------------------------------- 1 | import celpy 2 | from celpy import celtypes 3 | 4 | 5 | from koreo.cel.evaluation import evaluate, evaluate_predicates, evaluate_overlay 6 | from koreo.result import PermFail, UnwrappedOutcome 7 | 8 | from .structure import ValueFunction 9 | 10 | 11 | async def reconcile_value_function( 12 | location: str, 13 | function: ValueFunction, 14 | inputs: celtypes.Value, 15 | value_base: celtypes.MapType | None = None, 16 | ) -> UnwrappedOutcome[celtypes.Value]: 17 | full_inputs: dict[str, celtypes.Value] = { 18 | "inputs": inputs, 19 | } 20 | 21 | # TODO: Need to decide if this is correct to do. 22 | if value_base: 23 | full_inputs = full_inputs | {celtypes.StringType("resource"): value_base} 24 | 25 | if precondition_error := evaluate_predicates( 26 | predicates=function.preconditions, 27 | inputs=full_inputs, 28 | location=f"{location}:spec.preconditions", 29 | ): 30 | return precondition_error 31 | 32 | # If there's no return_value, just bail out. No point in extra work. 33 | if not function.return_value: 34 | return celpy.json_to_cel(None) 35 | 36 | match evaluate( 37 | expression=function.local_values, 38 | inputs=full_inputs, 39 | location=f"{location}:spec.locals", 40 | ): 41 | case PermFail() as err: 42 | return err 43 | case celtypes.MapType() as local_values: 44 | full_inputs["locals"] = local_values 45 | case None: 46 | full_inputs["locals"] = celtypes.MapType({}) 47 | case bad_type: 48 | # Due to validation within `prepare`, this should never happen. 49 | return PermFail( 50 | message=f"Invalid `locals` expression type ({type(bad_type)})", 51 | location=f"{location}:spec.locals", 52 | ) 53 | 54 | if value_base is None: 55 | value_base = celtypes.MapType({}) 56 | 57 | return evaluate_overlay( 58 | overlay=function.return_value, 59 | inputs=full_inputs, 60 | base=value_base, 61 | location=f"{location}:spec.return", 62 | ) 63 | -------------------------------------------------------------------------------- /src/koreo/value_function/structure.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | import celpy 4 | from koreo.cel.prepare import Overlay 5 | 6 | 7 | class ValueFunction(NamedTuple): 8 | preconditions: celpy.Runner | None 9 | local_values: celpy.Runner | None 10 | return_value: Overlay | None 11 | 12 | dynamic_input_keys: set[str] 13 | -------------------------------------------------------------------------------- /src/koreo/workflow/structure.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import NamedTuple, Sequence 3 | 4 | import celpy 5 | 6 | from koreo.result import NonOkOutcome, Outcome 7 | 8 | from koreo.resource_function.structure import ResourceFunction 9 | from koreo.value_function.structure import ValueFunction 10 | 11 | 12 | class ConfigCRDRef(NamedTuple): 13 | api_group: str 14 | version: str 15 | kind: str 16 | 17 | 18 | class StepConditionSpec(NamedTuple): 19 | type_: str 20 | name: str 21 | 22 | 23 | class LogicSwitch(NamedTuple): 24 | switch_on: celpy.Runner 25 | logic_map: dict[str | int, ResourceFunction | ValueFunction | Workflow] 26 | default_logic: ResourceFunction | ValueFunction | Workflow | None 27 | 28 | dynamic_input_keys: set[str] 29 | 30 | 31 | class Step(NamedTuple): 32 | label: str 33 | logic: ResourceFunction | ValueFunction | Workflow | LogicSwitch 34 | 35 | skip_if: celpy.Runner | None 36 | for_each: ForEach | None 37 | inputs: celpy.Runner | None 38 | 39 | condition: StepConditionSpec | None 40 | state: celpy.Runner | None 41 | 42 | dynamic_input_keys: set[str] 43 | 44 | 45 | class ForEach(NamedTuple): 46 | source_iterator: celpy.Runner 47 | input_key: str 48 | condition: StepConditionSpec | None 49 | 50 | 51 | class ErrorStep(NamedTuple): 52 | label: str 53 | outcome: NonOkOutcome 54 | 55 | condition: StepConditionSpec | None 56 | state: None = None 57 | 58 | 59 | class Workflow(NamedTuple): 60 | name: str 61 | crd_ref: ConfigCRDRef | None 62 | 63 | steps_ready: Outcome 64 | steps: Sequence[Step | ErrorStep] 65 | 66 | dynamic_input_keys: set[str] 67 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koreo-dev/koreo-core/1c0ad76ab008184b98c019420b5302b2e4eda074/tests/__init__.py -------------------------------------------------------------------------------- /tests/koreo/cel/test_encoder.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | from celpy import celtypes 5 | 6 | from koreo.cel.encoder import convert_bools, encode_cel 7 | 8 | 9 | class TestEncodeBools(unittest.TestCase): 10 | def test_structure(self): 11 | value = { 12 | "zero": 0, 13 | "one": 1, 14 | celtypes.BoolType(True): celtypes.BoolType(True), 15 | celtypes.StringType("true"): celtypes.BoolType(True), 16 | celtypes.BoolType(False): celtypes.BoolType(False), 17 | celtypes.StringType("false"): celtypes.BoolType(False), 18 | "nested_map": { 19 | "zero": 0, 20 | "one": 1, 21 | celtypes.BoolType(True): celtypes.BoolType(True), 22 | celtypes.StringType("true"): celtypes.BoolType(True), 23 | celtypes.BoolType(False): celtypes.BoolType(False), 24 | celtypes.StringType("false"): celtypes.BoolType(False), 25 | }, 26 | "list": [ 27 | "zero", 28 | 0, 29 | "one", 30 | 1, 31 | celtypes.BoolType(True), 32 | celtypes.StringType("true"), 33 | celtypes.BoolType(False), 34 | celtypes.StringType("false"), 35 | ], 36 | "cel_values": celtypes.MapType( 37 | { 38 | celtypes.StringType("zero"): celtypes.IntType(0), 39 | celtypes.StringType("one"): celtypes.IntType(1), 40 | celtypes.BoolType(True): celtypes.BoolType(True), 41 | celtypes.StringType("true"): celtypes.BoolType(True), 42 | celtypes.StringType("false"): celtypes.BoolType(False), 43 | celtypes.StringType("nested_map"): celtypes.MapType( 44 | { 45 | celtypes.StringType("zero"): celtypes.IntType(0), 46 | celtypes.StringType("one"): celtypes.IntType(1), 47 | celtypes.BoolType(True): celtypes.BoolType(True), 48 | celtypes.StringType("true"): celtypes.BoolType(True), 49 | celtypes.BoolType(False): celtypes.BoolType(False), 50 | celtypes.StringType("false"): celtypes.BoolType(False), 51 | } 52 | ), 53 | celtypes.StringType("list"): celtypes.ListType( 54 | [ 55 | celtypes.StringType("zero"), 56 | celtypes.IntType(0), 57 | celtypes.StringType("one"), 58 | celtypes.IntType(1), 59 | celtypes.BoolType(True), 60 | celtypes.StringType("true"), 61 | celtypes.BoolType(False), 62 | celtypes.StringType("false"), 63 | ] 64 | ), 65 | } 66 | ), 67 | } 68 | self.maxDiff = None 69 | self.assertEqual( 70 | '{"zero": 0, "one": 1, "true": true, "true": true, "false": false, "false": false, "nested_map": {"zero": 0, "one": 1, "true": true, "true": true, "false": false, "false": false}, "list": ["zero", 0, "one", 1, true, "true", false, "false"], "cel_values": {"zero": 0, "one": 1, "true": true, "true": true, "false": false, "nested_map": {"zero": 0, "one": 1, "true": true, "true": true, "false": false, "false": false}, "list": ["zero", 0, "one", 1, true, "true", false, "false"]}}', 71 | json.dumps(convert_bools(value)), 72 | ) 73 | 74 | 75 | class TestEncodeCel(unittest.TestCase): 76 | def test_empty_str(self): 77 | value = "" 78 | self.assertEqual('""', encode_cel(value)) 79 | 80 | def test_str(self): 81 | value = "This is a plain old string." 82 | self.assertEqual('"This is a plain old string."', encode_cel(value)) 83 | 84 | def test_str_with_quote(self): 85 | value = 'This is a "test."' 86 | self.assertEqual('"""This is a \\\"test.\\\""""', encode_cel(value)) # fmt: skip 87 | 88 | def test_str_with_tripple_quote(self): 89 | value = 'This is a """test."""' 90 | self.assertEqual('"""This is a \\\"\\\"\\\"test.\\\"\\\"\\\""""', encode_cel(value)) # fmt: skip 91 | 92 | def test_expression_str(self): 93 | value = "=1 + 1" 94 | self.assertEqual("1 + 1", encode_cel(value)) 95 | 96 | def test_int(self): 97 | value = 1010 98 | self.assertEqual("1010", encode_cel(value)) 99 | 100 | def test_int_str(self): 101 | value = "3213" 102 | self.assertEqual("3213", encode_cel(value)) 103 | 104 | def test_float(self): 105 | value = 99.3 106 | self.assertEqual("99.3", encode_cel(value)) 107 | 108 | def test_float_str(self): 109 | value = "72.3" 110 | self.assertEqual("72.3", encode_cel(value)) 111 | 112 | def test_bool(self): 113 | value = True 114 | self.assertEqual("true", encode_cel(value)) 115 | 116 | def test_list_of_int(self): 117 | value = [1, 2, 3] 118 | self.assertEqual("[1,2,3]", encode_cel(value)) 119 | 120 | def test_list_of_float(self): 121 | value = [1.2, 2.1, 8.9, 9.0] 122 | self.assertEqual("[1.2,2.1,8.9,9.0]", encode_cel(value)) 123 | 124 | def test_list_of_bool(self): 125 | value = [False, True, True, False] 126 | self.assertEqual("[false,true,true,false]", encode_cel(value)) 127 | 128 | def test_flat_dict(self): 129 | value = { 130 | "a_string": "testing", 131 | "a_quoted_string": 'you should "test"', 132 | "a_tripple_quoted_string": 'you """should""" "test"', 133 | "an_int": 7, 134 | "an_int_str": "29", 135 | "a_float": 82.34, 136 | "a_float_str": "94.55", 137 | "bool_true": True, 138 | "bool_false": False, 139 | "empty_list": [], 140 | "complex_list": ["a", 2, "4", 3.2, "53.4", True, False], 141 | "nested_list": [[1, 2, 3], [True, False, False], ["a", "b", "c"]], 142 | "cel_expr": "=8 + 3", 143 | "cel_expr_list": ["=8 + 3", "='a' + 'b'"], 144 | "cel_expr_list_list": [["=8 + 3", "='a' + 'b'"], ["=has(value)"]], 145 | "false": False, 146 | "true": True, 147 | "yes": True, 148 | "no": False, 149 | "none": None, 150 | "null": "=null", 151 | } 152 | 153 | # fmt: off 154 | self.maxDiff = None 155 | self.assertEqual( 156 | '{"a_string":"testing","a_quoted_string":"""you should \\\"test\\\"""","a_tripple_quoted_string":"""you \\\"\\\"\\\"should\\\"\\\"\\\" \\\"test\\\"""","an_int":7,"an_int_str":29,"a_float":82.34,"a_float_str":94.55,"bool_true":true,"bool_false":false,"empty_list":[],"complex_list":["a",2,4,3.2,53.4,true,false],"nested_list":[[1,2,3],[true,false,false],["a","b","c"]],"cel_expr":8 + 3,"cel_expr_list":[8 + 3,\'a\' + \'b\'],"cel_expr_list_list":[[8 + 3,\'a\' + \'b\'],[has(value)]],"false":false,"true":true,"yes":true,"no":false,"none":null,"null":null}', 157 | encode_cel(value), 158 | ) 159 | # fmt: on 160 | 161 | def test_nested_dict(self): 162 | value = { 163 | "strings": { 164 | "a_string": "testing", 165 | "a_quoted_string": '"testing" is important', 166 | "an_int_str": "29", 167 | "a_float_str": "94.55", 168 | }, 169 | "numbers": {"an_int": 7, "a_float": 82.34}, 170 | "bools": {"bool_true": True, "bool_false": False}, 171 | "lists": { 172 | "empty_list": [], 173 | "complex_list": ["a", 2, "4", 3.2, "53.4", True, False], 174 | "nested_list": [[1, 2, 3], [True, False, False], ["a", "b", "c"]], 175 | }, 176 | "cel": { 177 | "cel_expr": "=8 + 3", 178 | "cel_expr_list": ["=8 + 3", "='a' + 'b'"], 179 | "cel_expr_list_list": [["=8 + 3", "='a' + 'b'"], ["=has(value)"]], 180 | }, 181 | "dicts": { 182 | "level_one": { 183 | "level_two": {"value": "deep", "cel": "=has(formula)"}, 184 | "sibling": "=8 + 99", 185 | }, 186 | "sibling": "a", 187 | }, 188 | } 189 | self.maxDiff = None 190 | # fmt: off 191 | self.assertEqual( 192 | '{"strings":{"a_string":"testing","a_quoted_string":"""\\\"testing\\\" is important""","an_int_str":29,"a_float_str":94.55},"numbers":{"an_int":7,"a_float":82.34},"bools":{"bool_true":true,"bool_false":false},"lists":{"empty_list":[],"complex_list":["a",2,4,3.2,53.4,true,false],"nested_list":[[1,2,3],[true,false,false],["a","b","c"]]},"cel":{"cel_expr":8 + 3,"cel_expr_list":[8 + 3,\'a\' + \'b\'],"cel_expr_list_list":[[8 + 3,\'a\' + \'b\'],[has(value)]]},"dicts":{"level_one":{"level_two":{"value":"deep","cel":has(formula)},"sibling":8 + 99},"sibling":"a"}}', 193 | encode_cel(value), 194 | ) 195 | # fmt: on 196 | -------------------------------------------------------------------------------- /tests/koreo/cel/test_structure.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import celpy 4 | 5 | from koreo.cel.structure_extractor import extract_argument_structure 6 | 7 | 8 | class TestArgumentStructureExtractor(unittest.TestCase): 9 | def test_simple_args(self): 10 | env = celpy.Environment() 11 | 12 | cel_str = """{ 13 | "simple_dot": steps.simple1, 14 | "simple_index": steps['simple2'], 15 | "nested_dots": steps.nested1.one, 16 | "nested_index_dot": steps['nested2'].two, 17 | "nested_index_index": steps['nested3']['one'], 18 | "nested_dot_index": steps.nested4['one'] 19 | } 20 | """ 21 | 22 | result = extract_argument_structure(env.compile(cel_str)) 23 | 24 | sorted_result = sorted(result) 25 | 26 | expected = sorted( 27 | [ 28 | "steps.nested1", 29 | "steps.nested2", 30 | "steps.nested3", 31 | "steps.nested4", 32 | "steps.simple1", 33 | "steps.simple2", 34 | "steps.nested1.one", 35 | "steps.nested2.two", 36 | "steps.nested3.one", 37 | "steps.nested4.one", 38 | ] 39 | ) 40 | 41 | self.assertListEqual(expected, sorted_result) 42 | 43 | def test_numeric_indexes(self): 44 | env = celpy.Environment() 45 | 46 | cel_str = """{ 47 | "simple_index": steps[0], 48 | "nested_index_dot": steps[2].two, 49 | "nested_index_index": steps[3][1], 50 | "nested_dot_index": steps.nested4[4] 51 | } 52 | """ 53 | 54 | result = extract_argument_structure(env.compile(cel_str)) 55 | 56 | sorted_result = sorted(result) 57 | 58 | expected = sorted( 59 | [ 60 | "steps.0", 61 | "steps.2", 62 | "steps.2.two", 63 | "steps.3", 64 | "steps.3.1", 65 | "steps.nested4", 66 | "steps.nested4.4", 67 | ] 68 | ) 69 | 70 | self.assertListEqual(expected, sorted_result) 71 | 72 | def test_formula_args(self): 73 | env = celpy.Environment() 74 | 75 | cel_str = """{ 76 | "simple_add": steps.simple1 + steps['simple2'], 77 | "nested_add": steps.nested1.one + steps['nested2'].two + steps['nested3']['one'] + steps.nested4['one'] 78 | } 79 | """ 80 | 81 | result = extract_argument_structure(env.compile(cel_str)) 82 | 83 | sorted_result = sorted(result) 84 | 85 | expected = sorted( 86 | [ 87 | "steps.nested1", 88 | "steps.nested2", 89 | "steps.nested3", 90 | "steps.nested4", 91 | "steps.simple1", 92 | "steps.simple2", 93 | "steps.nested1.one", 94 | "steps.nested2.two", 95 | "steps.nested3.one", 96 | "steps.nested4.one", 97 | ] 98 | ) 99 | 100 | self.assertListEqual(expected, sorted_result) 101 | -------------------------------------------------------------------------------- /tests/koreo/function_test/test_prepare.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from koreo.result import DepSkip, is_error, is_unwrapped_ok 4 | 5 | from koreo.function_test import prepare 6 | 7 | 8 | class TestMalformedFunctionTests(unittest.IsolatedAsyncioTestCase): 9 | async def test_missing_spec(self): 10 | # None 11 | outcome = await prepare.prepare_function_test(cache_key="unit-test", spec=None) 12 | self.assertTrue(is_error(outcome)) 13 | 14 | # Empty dict 15 | outcome = await prepare.prepare_function_test(cache_key="unit-test", spec={}) 16 | self.assertTrue(is_error(outcome)) 17 | 18 | async def test_missing_function(self): 19 | outcome = await prepare.prepare_function_test( 20 | cache_key="unit-test", 21 | spec={ 22 | "functionRef": { 23 | "kind": "ValueFunction", 24 | "name": "unit-test-function.fake", 25 | }, 26 | "testCases": [{"expectOutcome": {"ok": {}}}], 27 | }, 28 | ) 29 | assert is_unwrapped_ok(outcome), outcome.message 30 | 31 | function_test, _ = outcome 32 | self.assertIsInstance(function_test.function_under_test, DepSkip) 33 | -------------------------------------------------------------------------------- /tests/koreo/function_test/test_run.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from koreo import cache 4 | from koreo import result 5 | from koreo.resource_function.prepare import prepare_resource_function 6 | from koreo.resource_function.structure import ResourceFunction 7 | 8 | from koreo.function_test import prepare 9 | from koreo.function_test import run 10 | 11 | 12 | class TestEndToEndResourceFunction(unittest.IsolatedAsyncioTestCase): 13 | async def asyncTearDown(self): 14 | if cache.get_resource_from_cache( 15 | resource_class=ResourceFunction, cache_key="function-under-test" 16 | ): 17 | await cache.delete_from_cache( 18 | resource_class=ResourceFunction, cache_key="function-under-test" 19 | ) 20 | 21 | async def asyncSetUp(self): 22 | if cache.get_resource_from_cache( 23 | resource_class=ResourceFunction, cache_key="function-under-test" 24 | ): 25 | cache.delete_from_cache 26 | return 27 | 28 | await cache.prepare_and_cache( 29 | resource_class=ResourceFunction, 30 | preparer=prepare_resource_function, 31 | metadata={"name": "function-under-test", "resourceVersion": "sam123"}, 32 | spec={ 33 | "apiConfig": { 34 | "apiVersion": "test.koreo.dev/v1", 35 | "kind": "TestResource", 36 | "name": "=inputs.metadata.name + '-suffix'", 37 | "namespace": "=inputs.metadata.namespace", 38 | }, 39 | "preconditions": [ 40 | { 41 | "assert": "=!has(inputs.preconditions.depSkip)", 42 | "depSkip": {"message": "Input Validator Dep Skip"}, 43 | }, 44 | { 45 | "assert": "=!has(inputs.preconditions.skip)", 46 | "skip": {"message": "Input Validator Skip"}, 47 | }, 48 | { 49 | "assert": "=!has(inputs.preconditions.permFail)", 50 | "permFail": {"message": "Input Validator Perm Fail"}, 51 | }, 52 | { 53 | "assert": "=!has(inputs.preconditions.retry)", 54 | "retry": {"delay": 18, "message": "Input Validator Retry"}, 55 | }, 56 | ], 57 | "resource": { 58 | "apiVersion": "test.koreo.dev/v1", 59 | "kind": "TestResource", 60 | "metadata": "=inputs.metadata", 61 | "spec": { 62 | "subStructure": { 63 | "int": 1, 64 | "bool": True, 65 | "list": [1, 2, 3], 66 | "object": {"one": 1, "two": 2}, 67 | } 68 | }, 69 | }, 70 | "create": { 71 | "overlay": { 72 | "spec": { 73 | "subStructure": { 74 | "createOnly": "value", 75 | }, 76 | }, 77 | }, 78 | }, 79 | "postconditions": [ 80 | { 81 | "assert": "=!has(resource.status.postconditions.depSkip)", 82 | "depSkip": {"message": "Output Validator Dep Skip"}, 83 | }, 84 | { 85 | "assert": "=!has(resource.status.postconditions.skip)", 86 | "skip": {"message": "Output Validator Skip"}, 87 | }, 88 | { 89 | "assert": "=!has(resource.status.postconditions.permFail)", 90 | "permFail": {"message": "Output Validator Perm Fail"}, 91 | }, 92 | { 93 | "assert": "=!has(resource.status.postconditions.retry)", 94 | "retry": {"delay": 18, "message": "Output Validator Retry"}, 95 | }, 96 | { 97 | "assert": "=has(resource.status.returnValue)", 98 | "retry": {"delay": 1, "message": "Waiting for ready-state"}, 99 | }, 100 | ], 101 | "return": {"value": "=resource.status.returnValue"}, 102 | }, 103 | ) 104 | 105 | async def test_resource_function_fully(self): 106 | prepared_test = await prepare.prepare_function_test( 107 | cache_key="function-test:end-to-end", 108 | spec={ 109 | "functionRef": { 110 | "kind": "ResourceFunction", 111 | "name": "function-under-test", 112 | }, 113 | "inputs": { 114 | "metadata": { 115 | "name": "function-test:end-to-end", 116 | "namespace": "unittests", 117 | }, 118 | }, 119 | "testCases": [ 120 | { 121 | "label": "Initial create", 122 | "expectResource": { 123 | "apiVersion": "test.koreo.dev/v1", 124 | "kind": "TestResource", 125 | "metadata": { 126 | "name": "function-test:end-to-end-suffix", 127 | "namespace": "unittests", 128 | }, 129 | "spec": { 130 | "subStructure": { 131 | "int": 1, 132 | "bool": True, 133 | "list": [1, 2, 3], 134 | "object": {"one": 1, "two": 2}, 135 | "createOnly": "value", 136 | } 137 | }, 138 | }, 139 | }, 140 | { 141 | "label": "[variant] Rename causes create", 142 | "variant": True, 143 | "inputOverrides": { 144 | "metadata": {"name": "pumpkins"}, 145 | }, 146 | "expectResource": { 147 | "apiVersion": "test.koreo.dev/v1", 148 | "kind": "TestResource", 149 | "metadata": { 150 | "name": "pumpkins-suffix", 151 | "namespace": "unittests", 152 | }, 153 | "spec": { 154 | "subStructure": { 155 | "int": 1, 156 | "bool": True, 157 | "list": [1, 2, 3], 158 | "object": {"one": 1, "two": 2}, 159 | } 160 | }, 161 | }, 162 | }, 163 | { 164 | "label": "Setting status overlay returns value", 165 | "overlayResource": { 166 | "status": {"returnValue": "controller-set-value"} 167 | }, 168 | "expectReturn": {"value": "controller-set-value"}, 169 | }, 170 | { 171 | "label": "Non-variant status overlay persists", 172 | "expectReturn": {"value": "controller-set-value"}, 173 | }, 174 | { 175 | "label": "[variant] Status overlay failure", 176 | "variant": True, 177 | "overlayResource": { 178 | "status": { 179 | "returnValue": "variant-value", 180 | "postconditions": {"permFail": True}, 181 | } 182 | }, 183 | "expectOutcome": {"permFail": {"message": "Output Validator"}}, 184 | }, 185 | { 186 | "label": "Variant status overlay did not persist", 187 | "expectReturn": {"value": "controller-set-value"}, 188 | }, 189 | { 190 | "label": "[variant] Input overlay triggers preconditions", 191 | "variant": True, 192 | "inputOverrides": { 193 | "preconditions": {"permFail": True}, 194 | }, 195 | "expectOutcome": {"permFail": {"message": "Input Validator"}}, 196 | }, 197 | { 198 | "label": "variant input overlay did not persist", 199 | "expectReturn": {"value": "controller-set-value"}, 200 | }, 201 | ], 202 | }, 203 | ) 204 | 205 | if not result.is_unwrapped_ok(prepared_test): 206 | raise self.failureException( 207 | f"Failed to prepare function-test {prepared_test}!" 208 | ) 209 | 210 | function_test, _ = prepared_test 211 | 212 | test_result = await run.run_function_test( 213 | location="unit-test:end-to-end", function_test=function_test 214 | ) 215 | 216 | self.assertGreater(len(test_result.test_results), 0, "No test results") 217 | for idx, test_case_result in enumerate(test_result.test_results): 218 | if test_case_result.test_pass: 219 | continue 220 | 221 | if test_case_result.message: 222 | message = f"{test_case_result.message}" 223 | elif test_case_result.differences: 224 | message = f"{test_case_result.differences}" 225 | else: 226 | message = f"{test_case_result.outcome}" 227 | 228 | test_name = ( 229 | test_case_result.label if test_case_result.label else f"Test {idx}" 230 | ) 231 | 232 | raise self.failureException(f"{test_name} Failure: {message}") 233 | -------------------------------------------------------------------------------- /tests/koreo/resource_function/reconcile/test_kind_lookup.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | import asyncio 3 | import unittest 4 | 5 | import kr8s.asyncio 6 | 7 | from koreo.resource_function.reconcile.kind_lookup import ( 8 | get_plural_kind, 9 | _reset, 10 | _lookup_locks, 11 | ) 12 | 13 | 14 | class TestGetFullKind(unittest.IsolatedAsyncioTestCase): 15 | def tearDown(self): 16 | _reset() 17 | 18 | async def test_ok_lookup(self): 19 | plural_kind = "unittesties" 20 | 21 | api_mock = AsyncMock(kr8s.asyncio.Api) 22 | api_mock.lookup_kind.return_value = (None, plural_kind, None) 23 | 24 | api_version = "unit.test/v1" 25 | 26 | result = await get_plural_kind(api_mock, "UnitTest", api_version) 27 | 28 | self.assertEqual(result, plural_kind) 29 | self.assertEqual(1, api_mock.lookup_kind.call_count) 30 | 31 | async def test_successive_lookups(self): 32 | plural_kind = "unittesties" 33 | 34 | api_mock = AsyncMock(kr8s.asyncio.Api) 35 | api_mock.lookup_kind.return_value = (None, plural_kind, None) 36 | 37 | api_version = "unit.test/v1" 38 | 39 | result = await get_plural_kind(api_mock, "UnitTest", api_version) 40 | self.assertEqual(result, plural_kind) 41 | 42 | result = await get_plural_kind(api_mock, "UnitTest", api_version) 43 | self.assertEqual(result, plural_kind) 44 | 45 | self.assertEqual(1, api_mock.lookup_kind.call_count) 46 | 47 | async def test_multiple_requests(self): 48 | plural_kind = "unittesties" 49 | 50 | async def lookup(_): 51 | await asyncio.sleep(0) 52 | return (None, plural_kind, None) 53 | 54 | api_mock = AsyncMock(kr8s.asyncio.Api) 55 | api_mock.lookup_kind.side_effect = lookup 56 | 57 | api_version = "unit.test/v1" 58 | 59 | tasks = [ 60 | asyncio.create_task(get_plural_kind(api_mock, "UnitTest", api_version)), 61 | asyncio.create_task(get_plural_kind(api_mock, "UnitTest", api_version)), 62 | asyncio.create_task(get_plural_kind(api_mock, "UnitTest", api_version)), 63 | ] 64 | done, pending = await asyncio.wait(tasks) 65 | 66 | self.assertEqual(len(tasks), len(done)) 67 | self.assertEqual(0, len(pending)) 68 | 69 | for task in tasks: 70 | self.assertEqual(task.result(), plural_kind) 71 | 72 | self.assertEqual(1, api_mock.lookup_kind.call_count) 73 | 74 | async def test_missing_kind(self): 75 | api_mock = AsyncMock(kr8s.asyncio.Api) 76 | api_mock.lookup_kind.side_effect = ValueError("Kind not found") 77 | 78 | api_version = "unit.test/v1" 79 | 80 | plural_kind = await get_plural_kind(api_mock, "UnitTest", api_version) 81 | 82 | self.assertIsNone(plural_kind) 83 | 84 | async def test_2_timeout_retries(self): 85 | plural_kind = "unittesties" 86 | 87 | api_mock = AsyncMock(kr8s.asyncio.Api) 88 | api_mock.lookup_kind.side_effect = [ 89 | asyncio.TimeoutError(), 90 | (None, plural_kind, None), 91 | ] 92 | 93 | api_version = "unit.test/v1" 94 | 95 | result = await get_plural_kind(api_mock, "UnitTest", api_version) 96 | self.assertEqual(result, plural_kind) 97 | 98 | async def test_too_many_timeout_retries(self): 99 | api_mock = AsyncMock(kr8s.asyncio.Api) 100 | api_mock.lookup_kind.side_effect = asyncio.TimeoutError() 101 | 102 | api_version = "unit.test/v1" 103 | 104 | with self.assertRaises(Exception): 105 | await get_plural_kind(api_mock, "unittest", api_version) 106 | 107 | async def test_lock_timeout(self): 108 | api_mock = AsyncMock(kr8s.asyncio.Api) 109 | api_mock.lookup_kind.side_effect = asyncio.TimeoutError() 110 | 111 | api_version = "unit.test/v1" 112 | 113 | lock_mock = AsyncMock(asyncio.Event) 114 | lock_mock.wait.side_effect = asyncio.TimeoutError() 115 | 116 | lookup_kind = f"unittest.{api_version}" 117 | _lookup_locks[lookup_kind] = lock_mock 118 | 119 | with self.assertRaises(Exception): 120 | await get_plural_kind(api_mock, "unittest", api_version) 121 | 122 | async def test_lock_cleared_on_errors(self): 123 | api_mock = AsyncMock(kr8s.asyncio.Api) 124 | api_mock.lookup_kind.side_effect = ZeroDivisionError("Unit Test") 125 | 126 | api_version = "unit.test/v1" 127 | 128 | with self.assertRaises(ZeroDivisionError): 129 | await get_plural_kind(api_mock, "unittest", api_version) 130 | -------------------------------------------------------------------------------- /tests/koreo/resource_template/test_prepare.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from celpy import celtypes 4 | 5 | from koreo.result import PermFail, is_unwrapped_ok 6 | 7 | from koreo.resource_template.prepare import prepare_resource_template 8 | 9 | 10 | class TestPrepareResourceTemplate(unittest.IsolatedAsyncioTestCase): 11 | async def test_missing_spec(self): 12 | prepared = await prepare_resource_template("test-case", {}) 13 | self.assertIsInstance(prepared, PermFail) 14 | self.assertIn("spec must contain", prepared.message) 15 | 16 | async def test_missing_template(self): 17 | prepared = await prepare_resource_template( 18 | "test-case", 19 | {"context": {}}, 20 | ) 21 | self.assertIsInstance(prepared, PermFail) 22 | self.assertIn("must contain", prepared.message) 23 | 24 | async def test_bad_template_type(self): 25 | prepared = await prepare_resource_template( 26 | "test-case", 27 | { 28 | "template": "bad value", 29 | }, 30 | ) 31 | self.assertIsInstance(prepared, PermFail) 32 | self.assertIn("spec.template must be object", prepared.message) 33 | 34 | async def test_missing_apiVersion(self): 35 | prepared = await prepare_resource_template( 36 | "test-case", 37 | { 38 | "template": { 39 | "kind": "TestResource", 40 | }, 41 | }, 42 | ) 43 | self.assertIsInstance(prepared, PermFail) 44 | self.assertIn("`apiVersion` and `kind` must be set", prepared.message) 45 | 46 | async def test_bad_context(self): 47 | prepared = await prepare_resource_template( 48 | "test-case", 49 | { 50 | "template": { 51 | "apiVersion": "api.group/v1", 52 | "kind": "TestResource", 53 | }, 54 | "context": "bad value", 55 | }, 56 | ) 57 | self.assertIsInstance(prepared, PermFail) 58 | self.assertIn("must be object", prepared.message) 59 | 60 | async def test_good_config(self): 61 | prepared = await prepare_resource_template( 62 | "test-case", 63 | { 64 | "template": { 65 | "apiVersion": "api.group/v1", 66 | "kind": "TestResource", 67 | "spec": {"bool": True}, 68 | }, 69 | "context": {"values": "ok"}, 70 | }, 71 | ) 72 | 73 | self.assertTrue(is_unwrapped_ok(prepared)) 74 | prepared_template, _ = prepared 75 | 76 | self.assertIsInstance( 77 | prepared_template.template.get("spec", {}).get("bool"), celtypes.BoolType 78 | ) 79 | -------------------------------------------------------------------------------- /tests/koreo/test_conditions.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | from unittest.mock import patch 3 | import unittest 4 | 5 | from koreo.conditions import Condition, Conditions, update_condition 6 | 7 | 8 | class TestConditions(unittest.TestCase): 9 | def test_new(self): 10 | conditions: Conditions = [] 11 | update: Condition = Condition( 12 | type="NewTest", 13 | reason="Testing", 14 | status="AwaitDependency", 15 | message="Testing new condition insertion.", 16 | location="testing.location", 17 | ) 18 | 19 | frozen_now = datetime.now(UTC) 20 | with patch("koreo.conditions.datetime") as frozen_now_datetime: 21 | frozen_now_datetime.now.return_value = frozen_now 22 | updated_conditions = update_condition( 23 | conditions=conditions, condition=update 24 | ) 25 | 26 | self.assertFalse(conditions) 27 | self.assertEqual(1, len(updated_conditions)) 28 | 29 | condition = updated_conditions[0] 30 | self.assertDictEqual( 31 | condition, 32 | { 33 | "lastUpdateTime": frozen_now.isoformat(), 34 | "lastTransitionTime": frozen_now.isoformat(), 35 | "type": "NewTest", 36 | "reason": "Testing", 37 | "status": "AwaitDependency", 38 | "message": "Testing new condition insertion.", 39 | "location": "testing.location", 40 | }, 41 | ) 42 | 43 | def test_non_update_update(self): 44 | conditions: Conditions = [] 45 | condition: Condition = Condition( 46 | type="UpdateTest", 47 | reason="Done", 48 | status="Ready", 49 | message="All set.", 50 | location="testing.location", 51 | ) 52 | 53 | insert_frozen_now = datetime.now(UTC) 54 | with patch("koreo.conditions.datetime") as frozen_now_datetime: 55 | frozen_now_datetime.now.return_value = insert_frozen_now 56 | 57 | updated_conditions = update_condition( 58 | conditions=conditions, condition=condition 59 | ) 60 | 61 | update_frozen_now = datetime.now(UTC) 62 | with patch("koreo.conditions.datetime") as frozen_now_datetime: 63 | frozen_now_datetime.now.return_value = update_frozen_now 64 | 65 | updated_conditions = update_condition( 66 | conditions=updated_conditions, condition=condition 67 | ) 68 | 69 | self.assertFalse(conditions) 70 | self.assertEqual(1, len(updated_conditions)) 71 | 72 | self.assertDictEqual( 73 | updated_conditions[0], 74 | { 75 | "lastUpdateTime": update_frozen_now.isoformat(), 76 | "lastTransitionTime": insert_frozen_now.isoformat(), 77 | "type": "UpdateTest", 78 | "reason": "Done", 79 | "status": "Ready", 80 | "message": "All set.", 81 | "location": "testing.location", 82 | }, 83 | ) 84 | 85 | def test_multiple_conditions(self): 86 | conditions: Conditions = [] 87 | update_one: Condition = Condition( 88 | type="UpdateTest", 89 | reason="Testing", 90 | status="AwaitDependency", 91 | message="Testing new condition insertion.", 92 | location="testing.location.1", 93 | ) 94 | 95 | updated_conditions = update_condition( 96 | conditions=conditions, condition=update_one 97 | ) 98 | 99 | new: Condition = Condition( 100 | type="NewTest", 101 | reason="Waiting", 102 | status="AwaitDependency", 103 | message="Testing new condition insertion.", 104 | location="testing.location.new", 105 | ) 106 | 107 | new_frozen_now = datetime.now(UTC) 108 | with patch("koreo.conditions.datetime") as frozen_now_datetime: 109 | frozen_now_datetime.now.return_value = new_frozen_now 110 | 111 | updated_conditions = update_condition( 112 | conditions=updated_conditions, condition=new 113 | ) 114 | 115 | update_two: Condition = Condition( 116 | type="UpdateTest", 117 | reason="Done", 118 | status="Ready", 119 | message="All set.", 120 | location="testing.location.2", 121 | ) 122 | 123 | update_frozen_now = datetime.now(UTC) 124 | with patch("koreo.conditions.datetime") as frozen_now_datetime: 125 | frozen_now_datetime.now.return_value = update_frozen_now 126 | 127 | updated_conditions = update_condition( 128 | conditions=updated_conditions, condition=update_two 129 | ) 130 | 131 | self.assertFalse(conditions) 132 | self.assertEqual(2, len(updated_conditions)) 133 | 134 | # NOTE: I do not like how this assumes a specific order of conditions 135 | self.assertDictEqual( 136 | updated_conditions[0], 137 | { 138 | "lastUpdateTime": update_frozen_now.isoformat(), 139 | "lastTransitionTime": update_frozen_now.isoformat(), 140 | "type": "UpdateTest", 141 | "reason": "Done", 142 | "status": "Ready", 143 | "message": "All set.", 144 | "location": "testing.location.2", 145 | }, 146 | ) 147 | 148 | self.assertDictEqual( 149 | updated_conditions[1], 150 | { 151 | "lastUpdateTime": new_frozen_now.isoformat(), 152 | "lastTransitionTime": new_frozen_now.isoformat(), 153 | "type": "NewTest", 154 | "reason": "Waiting", 155 | "status": "AwaitDependency", 156 | "message": "Testing new condition insertion.", 157 | "location": "testing.location.new", 158 | }, 159 | ) 160 | -------------------------------------------------------------------------------- /tests/koreo/test_registry.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | import unittest 4 | 5 | from koreo import registry 6 | 7 | 8 | class ResourceA: ... 9 | 10 | 11 | class ResourceB: ... 12 | 13 | 14 | class TestRegistry(unittest.IsolatedAsyncioTestCase): 15 | def setUp(self): 16 | registry._reset_registries() 17 | 18 | async def test_register_your_own_queue(self): 19 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 20 | the_queue = asyncio.LifoQueue() 21 | 22 | a_notifications = registry.register(resource_a, queue=the_queue) 23 | 24 | self.assertIs(a_notifications, the_queue) 25 | 26 | async def test_double_register(self): 27 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 28 | 29 | a_queue = registry.register(resource_a) 30 | 31 | for _ in range(5): 32 | self.assertIs(a_queue, registry.register(resource_a)) 33 | 34 | async def test_basic_notifications(self): 35 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 36 | resource_b = registry.Resource(resource_type=ResourceB, name="resource-2") 37 | 38 | a_notifications = registry.register(resource_a) 39 | b_notifications = registry.register(resource_b) 40 | 41 | registry.subscribe(resource_a, resource_b) 42 | 43 | event_time = time.monotonic() 44 | registry.notify_subscribers(resource_b, event_time) 45 | 46 | notification, notification_time = await a_notifications.get() 47 | self.assertEqual(notification, resource_b) 48 | self.assertEqual(notification_time, event_time) 49 | a_notifications.task_done() 50 | 51 | self.assertTrue(a_notifications.empty()) 52 | self.assertTrue(b_notifications.empty()) 53 | 54 | async def test_unsubscribe(self): 55 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 56 | resource_b = registry.Resource(resource_type=ResourceB, name="resource-2") 57 | 58 | a_notifications = registry.register(resource_a) 59 | b_notifications = registry.register(resource_b) 60 | 61 | registry.subscribe(resource_a, resource_b) 62 | registry.unsubscribe(resource_a, resource_b) 63 | 64 | registry.notify_subscribers(resource_b, time.monotonic()) 65 | 66 | self.assertTrue(a_notifications.empty()) 67 | self.assertTrue(b_notifications.empty()) 68 | 69 | async def test_deregister_subscriber(self): 70 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 71 | resource_b = registry.Resource(resource_type=ResourceB, name="resource-2") 72 | 73 | a_notifications = registry.register(resource_a) 74 | b_notifications = registry.register(resource_b) 75 | 76 | registry.subscribe(resource_a, resource_b) 77 | for _ in range(10): 78 | registry.notify_subscribers(resource_b, time.monotonic()) 79 | 80 | # Ensure there are pending notifications for A (non for B). 81 | self.assertFalse(a_notifications.empty()) 82 | self.assertTrue(b_notifications.empty()) 83 | 84 | registry.deregister(resource_a, time.monotonic()) 85 | 86 | for _ in range(10): 87 | registry.notify_subscribers(resource_b, time.monotonic()) 88 | 89 | # Ensure pending notifications for A are released. 90 | self.assertTrue(a_notifications.empty()) 91 | self.assertTrue(b_notifications.empty()) 92 | 93 | # Ensure re-registering returns a new Queue 94 | a_notifications_new = registry.register(resource_a) 95 | self.assertIsNot(a_notifications, a_notifications_new) 96 | 97 | async def test_deregister_resource(self): 98 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 99 | resource_b = registry.Resource(resource_type=ResourceB, name="resource-2") 100 | 101 | a_notifications = registry.register(resource_a) 102 | b_notifications = registry.register(resource_b) 103 | 104 | registry.subscribe(resource_a, resource_b) 105 | 106 | self.assertTrue(a_notifications.empty()) 107 | self.assertTrue(b_notifications.empty()) 108 | 109 | registry.deregister(resource_b, time.monotonic()) 110 | 111 | notification, _ = await a_notifications.get() 112 | self.assertEqual(notification, resource_b) 113 | a_notifications.task_done() 114 | 115 | registry.notify_subscribers(resource_b, time.monotonic()) 116 | 117 | # Notifications for the resource should still be sent. 118 | self.assertFalse(a_notifications.empty()) 119 | self.assertTrue(b_notifications.empty()) 120 | 121 | async def test_deregister_unregistered(self): 122 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 123 | resource_b = registry.Resource(resource_type=ResourceB, name="resource-2") 124 | 125 | b_notifications = registry.register(resource_b) 126 | 127 | registry.subscribe(resource_a, resource_b) 128 | for _ in range(10): 129 | registry.notify_subscribers(resource_b, time.monotonic()) 130 | 131 | # Ensure there are pending notifications for A (non for B). 132 | self.assertTrue(b_notifications.empty()) 133 | 134 | registry.deregister(resource_a, time.monotonic()) 135 | 136 | for _ in range(10): 137 | registry.notify_subscribers(resource_b, time.monotonic()) 138 | 139 | # Ensure pending notifications for A are released. 140 | self.assertTrue(b_notifications.empty()) 141 | 142 | async def test_changing_subscriptions(self): 143 | resource_a = registry.Resource(resource_type=ResourceA, name="resource-1") 144 | resource_b = registry.Resource(resource_type=ResourceB, name="resource-2") 145 | resource_c = registry.Resource( 146 | resource_type=ResourceB, name="resource-3", namespace="old" 147 | ) 148 | resource_d = registry.Resource( 149 | resource_type=ResourceB, name="resource-4", namespace="new" 150 | ) 151 | resource_e = registry.Resource( 152 | resource_type=ResourceB, name="resource-5", namespace="new" 153 | ) 154 | 155 | a_notifications = registry.register(resource_a) 156 | registry.register(resource_b) 157 | registry.register(resource_c) 158 | registry.register(resource_d) 159 | registry.register(resource_e) 160 | 161 | all_resources = set( 162 | [resource_a, resource_b, resource_c, resource_d, resource_e] 163 | ) 164 | first_set = set([resource_b, resource_c]) 165 | second_set = set([resource_b, resource_d, resource_e]) 166 | 167 | for resource in first_set: 168 | registry.subscribe(resource_a, resource) 169 | 170 | for resource in all_resources: 171 | registry.notify_subscribers(resource, time.monotonic()) 172 | 173 | while not a_notifications.empty(): 174 | notifier, _ = await a_notifications.get() 175 | self.assertIn(notifier, first_set) 176 | a_notifications.task_done() 177 | 178 | self.assertTrue(a_notifications.empty()) 179 | 180 | registry.subscribe_only_to(resource_a, second_set) 181 | 182 | for resource in all_resources: 183 | registry.notify_subscribers(resource, time.monotonic()) 184 | 185 | while not a_notifications.empty(): 186 | notifier, _ = await a_notifications.get() 187 | self.assertIn(notifier, second_set) 188 | a_notifications.task_done() 189 | 190 | self.assertTrue(a_notifications.empty()) 191 | 192 | async def test_cycle_detection_self(self): 193 | resource = registry.Resource(resource_type=ResourceA, name="resource-1") 194 | with self.assertRaises(registry.SubscriptionCycle): 195 | registry.subscribe(resource, resource) 196 | 197 | async def test_cycle_detection(self): 198 | all_resources = [ 199 | registry.Resource(resource_type=ResourceA, name=f"resource-{idx}") 200 | for idx in range(5) 201 | ] 202 | 203 | for idx, resource in enumerate(all_resources[1:]): 204 | registry.subscribe(all_resources[idx - 1], resource) 205 | 206 | with self.assertRaises(registry.SubscriptionCycle): 207 | registry.subscribe(all_resources[-1], all_resources[0]) 208 | 209 | async def test_cycle_detection_sub_only_to(self): 210 | all_resources = [ 211 | registry.Resource(resource_type=ResourceA, name=f"resource-{idx}") 212 | for idx in range(5) 213 | ] 214 | 215 | for idx, resource in enumerate(all_resources[:-1]): 216 | registry.subscribe_only_to(resource, all_resources[idx + 1 :]) 217 | 218 | with self.assertRaises(registry.SubscriptionCycle): 219 | registry.subscribe(all_resources[-1], all_resources[0]) 220 | -------------------------------------------------------------------------------- /tests/koreo/test_result.py: -------------------------------------------------------------------------------- 1 | from random import shuffle 2 | import unittest 3 | 4 | from koreo import result 5 | 6 | 7 | class TestCombineMessages(unittest.TestCase): 8 | def test_no_outcomes(self): 9 | outcomes: list[result.Outcome] = [] 10 | 11 | combined = result.combine(outcomes) 12 | 13 | self.assertIsInstance(combined, result.Skip) 14 | 15 | def test_depskips(self): 16 | outcomes: list[result.Outcome] = [ 17 | result.DepSkip(), 18 | result.DepSkip(), 19 | result.DepSkip(), 20 | result.DepSkip(), 21 | result.DepSkip(), 22 | ] 23 | shuffle(outcomes) 24 | 25 | combined = result.combine(outcomes) 26 | 27 | self.assertIsInstance(combined, result.DepSkip) 28 | 29 | def test_skips(self): 30 | outcomes: list[result.Outcome] = [ 31 | result.Skip(), 32 | result.Skip(), 33 | result.Skip(), 34 | result.Skip(), 35 | result.Skip(), 36 | ] 37 | shuffle(outcomes) 38 | 39 | combined = result.combine(outcomes) 40 | 41 | self.assertIsInstance(combined, result.Skip) 42 | 43 | def test_depskips_and_skips(self): 44 | outcomes: list[result.Outcome] = [ 45 | result.DepSkip(), 46 | result.Skip(), 47 | result.DepSkip(), 48 | result.Skip(), 49 | ] 50 | shuffle(outcomes) 51 | 52 | combined = result.combine(outcomes) 53 | 54 | self.assertIsInstance(combined, result.Skip) 55 | 56 | def test_oks(self): 57 | outcomes: list[result.Outcome] = [ 58 | result.Ok("test"), 59 | result.Ok(8), 60 | result.Ok(88), 61 | result.Ok(None), 62 | result.Ok(True), 63 | ] 64 | shuffle(outcomes) 65 | 66 | combined = result.combine(outcomes) 67 | 68 | self.assertIsInstance(combined, result.Ok) 69 | 70 | # Note, this is done so that we can shuffle the outcomes so that we're 71 | # testing different combinations of outcome orderings. 72 | for value in [None, "test", 8, 88, True]: 73 | self.assertIn(value, combined.data) 74 | 75 | def test_skips_and_oks(self): 76 | outcomes: list[result.Outcome] = [ 77 | result.DepSkip(), 78 | result.DepSkip(), 79 | result.Ok("test"), 80 | result.Ok(8), 81 | result.Ok(False), 82 | result.Ok(None), 83 | result.Skip(), 84 | result.Skip(), 85 | ] 86 | shuffle(outcomes) 87 | 88 | combined = result.combine(outcomes) 89 | 90 | self.assertIsInstance(combined, result.Ok) 91 | 92 | # Note, this is done so that we can shuffle the outcomes so that we're 93 | # testing different combinations of outcome orderings. 94 | for value in [None, "test", 8, False]: 95 | self.assertIn(value, combined.data) 96 | 97 | def test_combine_one_value(self): 98 | outcomes: list[result.Outcome] = [ 99 | result.Ok("ok-value"), 100 | ] 101 | 102 | combined = result.combine(outcomes) 103 | 104 | self.assertIsInstance(combined, result.Ok) 105 | 106 | print(combined) 107 | self.assertListEqual(["ok-value"], combined.data) 108 | 109 | def test_unwrapped_combine_one_value(self): 110 | outcomes: list[result.UnwrappedOutcome] = [ 111 | "one-ok-value", 112 | ] 113 | 114 | combined = result.unwrapped_combine(outcomes) 115 | 116 | print(combined) 117 | self.assertListEqual(["one-ok-value"], combined) 118 | 119 | def test_retries(self): 120 | delay_5_message = "Waiting" 121 | default_delay_message = "Will retry" 122 | 123 | outcomes: list[result.Outcome] = [ 124 | result.Retry(delay=500), 125 | result.Retry(delay=5, message="Waiting"), 126 | result.Retry(delay=59), 127 | result.Retry(message="Will retry"), 128 | ] 129 | shuffle(outcomes) 130 | 131 | combined = result.combine(outcomes) 132 | 133 | self.assertIsInstance(combined, result.Retry) 134 | self.assertEqual(500, combined.delay) 135 | self.assertIn(delay_5_message, combined.message) 136 | self.assertIn(default_delay_message, combined.message) 137 | 138 | def test_skips_oks_and_retries(self): 139 | outcomes: list[result.Outcome] = [ 140 | result.DepSkip(), 141 | result.Skip(), 142 | result.Ok(None), 143 | result.Ok("test"), 144 | result.Ok(8), 145 | result.Retry(delay=500), 146 | result.Retry(delay=5, message="Waiting"), 147 | ] 148 | shuffle(outcomes) 149 | 150 | combined = result.combine(outcomes) 151 | 152 | self.assertIsInstance(combined, result.Retry) 153 | self.assertEqual(500, combined.delay) 154 | self.assertEqual("Waiting", combined.message) 155 | 156 | def test_permfail(self): 157 | first_message = "A bad error" 158 | second_message = "A really, really bad error" 159 | 160 | outcomes: list[result.Outcome] = [ 161 | result.PermFail(), 162 | result.PermFail(message=first_message), 163 | result.PermFail(message=second_message), 164 | result.PermFail(), 165 | ] 166 | shuffle(outcomes) 167 | 168 | combined = result.combine(outcomes) 169 | 170 | self.assertIsInstance(combined, result.PermFail) 171 | self.assertIn(first_message, combined.message) 172 | self.assertIn(second_message, combined.message) 173 | 174 | def test_skips_oks_retries_and_permfail(self): 175 | outcomes: list[result.Outcome] = [ 176 | result.DepSkip(), 177 | result.Skip(), 178 | result.Ok(None), 179 | result.Ok("test"), 180 | result.Ok(8), 181 | result.Retry(delay=500), 182 | result.Retry(delay=5, message="Waiting"), 183 | result.PermFail(), 184 | result.PermFail(message="All done"), 185 | ] 186 | shuffle(outcomes) 187 | 188 | combined = result.combine(outcomes) 189 | 190 | self.assertIsInstance(combined, result.PermFail) 191 | self.assertEqual("All done", combined.message) 192 | 193 | 194 | class TestIsOk(unittest.TestCase): 195 | def test_skip(self): 196 | self.assertFalse(result.is_ok(result.Skip())) 197 | 198 | def test_ok(self): 199 | self.assertTrue(result.is_ok(result.Ok(None))) 200 | 201 | def test_retry(self): 202 | self.assertFalse(result.is_ok(result.Retry())) 203 | 204 | def test_permfail(self): 205 | self.assertFalse(result.is_ok(result.PermFail())) 206 | 207 | 208 | class TestIsNotError(unittest.TestCase): 209 | def test_skip(self): 210 | self.assertTrue(result.is_not_error(result.Skip())) 211 | 212 | def test_ok(self): 213 | self.assertTrue(result.is_not_error(result.Ok(None))) 214 | 215 | def test_retry(self): 216 | self.assertFalse(result.is_not_error(result.Retry())) 217 | 218 | def test_permfail(self): 219 | self.assertFalse(result.is_not_error(result.PermFail())) 220 | 221 | 222 | class TestIsError(unittest.TestCase): 223 | def test_skip(self): 224 | self.assertFalse(result.is_error(result.Skip())) 225 | 226 | def test_ok(self): 227 | self.assertFalse(result.is_error(result.Ok(None))) 228 | 229 | def test_retry(self): 230 | self.assertTrue(result.is_error(result.Retry())) 231 | 232 | def test_permfail(self): 233 | self.assertTrue(result.is_error(result.PermFail())) 234 | -------------------------------------------------------------------------------- /tests/koreo/value_function/test_prepare.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import celpy 4 | from celpy import celtypes 5 | 6 | from koreo import result 7 | from koreo.cel.evaluation import evaluate_overlay 8 | from koreo.value_function import prepare 9 | from koreo.value_function.structure import ValueFunction 10 | 11 | 12 | class TestValueFunctionPrepare(unittest.IsolatedAsyncioTestCase): 13 | async def test_full_spec(self): 14 | prepared = await prepare.prepare_value_function( 15 | cache_key="test", 16 | spec={ 17 | "preconditions": [ 18 | {"assert": "=!inputs.skip", "skip": {"message": "Skip"}}, 19 | { 20 | "assert": "=!inputs.permFail", 21 | "permFail": {"message": "Perm Fail"}, 22 | }, 23 | { 24 | "assert": "=!inputs.depSkip", 25 | "depSkip": {"message": "Dep Skip"}, 26 | }, 27 | ], 28 | "locals": { 29 | "some_list": [1, 2, 3, 4], 30 | "a_value_map": {"a": "b"}, 31 | "integer": 7, 32 | "a_none": None, 33 | "cel_expr": "=1 + 17 / 2", 34 | }, 35 | "return": { 36 | "string": "1 + 1", 37 | "simple_cel": "=1 + 1", 38 | "nested": { 39 | "string": "this is a test", 40 | "simple_cel": "='a fun' + ' test'", 41 | }, 42 | "list": [ 43 | 1, 44 | 2, 45 | 3, 46 | "hopefully", 47 | "it", 48 | "works", 49 | "=1 - 2", 50 | "='a string' + ' concat'", 51 | ], 52 | }, 53 | }, 54 | ) 55 | 56 | function, _ = prepared 57 | self.assertIsInstance( 58 | function, 59 | ValueFunction, 60 | ) 61 | 62 | async def test_missing_and_bad_spec(self): 63 | bad_specs = [None, {}, "asda", [], 2, True] 64 | for bad_spec in bad_specs: 65 | outcome = await prepare.prepare_value_function( 66 | cache_key="test", spec=bad_spec 67 | ) 68 | self.assertIsInstance( 69 | outcome, 70 | result.PermFail, 71 | msg=f'Expected `PermFail` for malformed `spec` "{bad_spec}"', 72 | ) 73 | 74 | async def test_malformed_locals(self): 75 | bad_locals = ["asda", [], 2, True] 76 | for bad_locals in bad_locals: 77 | outcome = await prepare.prepare_value_function( 78 | cache_key="test", spec={"locals": bad_locals} 79 | ) 80 | self.assertIsInstance( 81 | outcome, 82 | result.PermFail, 83 | msg=f'Expected `PermFail` for malformed `spec.locals` "{bad_locals}"', 84 | ) 85 | 86 | async def test_preconditions_none_and_empty_list(self): 87 | outcome = await prepare.prepare_value_function("test", {"preconditions": None}) 88 | 89 | self.assertIsInstance( 90 | outcome, 91 | result.PermFail, 92 | ) 93 | 94 | prepared = await prepare.prepare_value_function("test", {"preconditions": []}) 95 | function, _ = prepared 96 | self.assertIsInstance( 97 | function, 98 | ValueFunction, 99 | msg="Unexpected error with empty list `spec.preconditions`", 100 | ) 101 | 102 | async def test_bad_precondition_input_type(self): 103 | bad_values = [1, "abc", {"value": "one"}, True] 104 | for value in bad_values: 105 | self.assertIsInstance( 106 | await prepare.prepare_value_function("test", {"preconditions": value}), 107 | result.PermFail, 108 | msg=f"Expected PermFail for bad `predicate_spec` '{value}' (type: {type(value)})", 109 | ) 110 | 111 | async def test_malformed_precondition_input(self): 112 | bad_values = [ 113 | {"skip": {"message": "=1 + missing"}}, 114 | {"assert": "=1 / 0 '", "permFail": {"message": "Bogus assert"}}, 115 | ] 116 | self.assertIsInstance( 117 | await prepare.prepare_value_function("test", {"preconditions": bad_values}), 118 | result.PermFail, 119 | ) 120 | 121 | async def test_none_and_empty_list_return(self): 122 | outcome = await prepare.prepare_value_function("test", {"return": None}) 123 | self.assertIsInstance(outcome, result.PermFail) 124 | 125 | function, _ = await prepare.prepare_value_function("test", {"return": {}}) 126 | self.assertIsInstance( 127 | function, 128 | ValueFunction, 129 | msg="Unexpected error with empty map `return`", 130 | ) 131 | 132 | async def test_bad_return_input_type(self): 133 | bad_values = [1, "abc", ["value", "one"], True] 134 | for value in bad_values: 135 | self.assertIsInstance( 136 | await prepare.prepare_value_function("test", {"return": value}), 137 | result.PermFail, 138 | msg=f"Expected PermFail for bad `return` '{value}' (type: {type(value)})", 139 | ) 140 | 141 | async def test_malformed_return_input(self): 142 | bad_values = { 143 | "skip": {"message": "=1 + missing"}, 144 | "assert": "=1 / 0 '", 145 | "permFail": {"message": "Bogus assert"}, 146 | } 147 | self.assertIsInstance( 148 | await prepare.prepare_value_function("test", {"return": bad_values}), 149 | result.PermFail, 150 | ) 151 | 152 | async def test_ok_return_input(self): 153 | 154 | return_value_cel = { 155 | "string": "1 + 1", 156 | "simple_cel": "=1 + 1", 157 | "nested": { 158 | "string": "this is a test", 159 | "simple_cel": "='a fun' + ' test'", 160 | }, 161 | "list": [ 162 | 1, 163 | 2, 164 | 3, 165 | "hopefully", 166 | "it", 167 | "works", 168 | "=1 - 2", 169 | "='a string' + ' concat'", 170 | ], 171 | } 172 | 173 | inputs = { 174 | "skip": celtypes.BoolType(False), 175 | "permFail": celtypes.BoolType(False), 176 | "depSkip": celtypes.BoolType(False), 177 | "retry": celtypes.BoolType(False), 178 | "ok": celtypes.BoolType(False), 179 | } 180 | 181 | expected_return = { 182 | "string": "1 + 1", 183 | "simple_cel": 2, 184 | "nested": { 185 | "string": "this is a test", 186 | "simple_cel": "a fun test", 187 | }, 188 | "list": [ 189 | 1, 190 | 2, 191 | 3, 192 | "hopefully", 193 | "it", 194 | "works", 195 | -1, 196 | "a string concat", 197 | ], 198 | } 199 | 200 | prepared = await prepare.prepare_value_function( 201 | "test", {"return": return_value_cel} 202 | ) 203 | 204 | assert result.is_unwrapped_ok(prepared) 205 | 206 | function, _ = prepared 207 | 208 | assert function.return_value is not None 209 | 210 | return_value = evaluate_overlay( 211 | overlay=function.return_value, 212 | inputs=inputs, 213 | base=celtypes.MapType({}), 214 | location="unittest", 215 | ) 216 | self.assertDictEqual( 217 | return_value, 218 | expected_return, 219 | ) 220 | -------------------------------------------------------------------------------- /tests/koreo/value_function/test_reconcile.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import unittest 3 | 4 | import celpy 5 | 6 | from koreo import result 7 | 8 | from koreo.value_function import prepare 9 | from koreo.value_function import reconcile 10 | from koreo.value_function.structure import ValueFunction 11 | 12 | 13 | class TestReconcileValueFunction(unittest.IsolatedAsyncioTestCase): 14 | 15 | async def test_no_spec_returns_none(self): 16 | result = await reconcile.reconcile_value_function( 17 | location="test-fn", 18 | function=ValueFunction( 19 | preconditions=None, 20 | local_values=None, 21 | return_value=None, 22 | dynamic_input_keys=set(), 23 | ), 24 | inputs=celpy.json_to_cel({}), 25 | ) 26 | 27 | self.assertEqual(result, None) 28 | 29 | async def test_full_scenario(self): 30 | prepared = await prepare.prepare_value_function( 31 | cache_key="test", 32 | spec={ 33 | "preconditions": [ 34 | { 35 | "assert": "=!inputs.preconditions.skip", 36 | "skip": {"message": "=inputs.messages.skip"}, 37 | }, 38 | { 39 | "assert": "=!inputs.preconditions.permFail", 40 | "permFail": {"message": "=inputs.messages.permFail"}, 41 | }, 42 | { 43 | "assert": "=!inputs.preconditions.depSkip", 44 | "depSkip": {"message": "=inputs.messages.depSkip"}, 45 | }, 46 | ], 47 | "locals": { 48 | "mapKey": "a-key", 49 | }, 50 | "return": { 51 | "simple_cel": "=inputs.ints.a + inputs.ints.b", 52 | "list": ["=inputs.ints.a + inputs.ints.b", 17, "constant"], 53 | "map": {"mapKey": "=inputs.ints.a + inputs.ints.b"}, 54 | }, 55 | }, 56 | ) 57 | 58 | function, _ = prepared 59 | base_inputs = { 60 | "preconditions": { 61 | "skip": False, 62 | "permFail": False, 63 | "depSkip": False, 64 | "retry": False, 65 | "ok": False, 66 | }, 67 | "messages": { 68 | "skip": "skip message", 69 | "permFail": "permFail message", 70 | "depSkip": "depSkip message", 71 | }, 72 | "ints": {"a": 1, "b": 8}, 73 | } 74 | 75 | reconcile_result = await reconcile.reconcile_value_function( 76 | location="test-fn", 77 | function=function, 78 | inputs=celpy.json_to_cel(base_inputs), 79 | ) 80 | 81 | expected_value = { 82 | "simple_cel": 9, 83 | "list": [9, 17, "constant"], 84 | "map": {"mapKey": 9}, 85 | } 86 | 87 | self.assertDictEqual(reconcile_result, expected_value) 88 | 89 | async def test_precondition_exits(self): 90 | predicate_pairs = ( 91 | ( 92 | "skip", 93 | result.Skip, 94 | { 95 | "assert": "=!inputs.skip", 96 | "skip": {"message": "skip message"}, 97 | }, 98 | ), 99 | ( 100 | "permFail", 101 | result.PermFail, 102 | { 103 | "assert": "=!inputs.permFail", 104 | "permFail": {"message": "permFail message"}, 105 | }, 106 | ), 107 | ( 108 | "depSkip", 109 | result.DepSkip, 110 | { 111 | "assert": "=!inputs.depSkip", 112 | "depSkip": {"message": "depSkip message"}, 113 | }, 114 | ), 115 | ( 116 | "retry", 117 | result.Retry, 118 | { 119 | "assert": "=!inputs.retry", 120 | "retry": {"message": "retry message", "delay": 17}, 121 | }, 122 | ), 123 | ( 124 | "ok", 125 | None, 126 | {"assert": "=!inputs.ok", "ok": {}}, 127 | ), 128 | ) 129 | 130 | predicates = [predicate for _, __, predicate in predicate_pairs] 131 | prepared_function = await prepare.prepare_value_function( 132 | cache_key="test", spec={"preconditions": predicates} 133 | ) 134 | assert isinstance(prepared_function, tuple) 135 | function, _ = prepared_function 136 | 137 | base_inputs = { 138 | "skip": False, 139 | "permFail": False, 140 | "depSkip": False, 141 | "retry": False, 142 | "ok": False, 143 | "bogus": False, 144 | } 145 | for input_key, expected_type, _ in predicate_pairs: 146 | test_inputs = copy.deepcopy(base_inputs) 147 | test_inputs[input_key] = True 148 | 149 | reconcile_result = await reconcile.reconcile_value_function( 150 | location="test-fn", 151 | function=function, 152 | inputs=celpy.json_to_cel(test_inputs), 153 | ) 154 | 155 | if expected_type is None: 156 | self.assertIsNone(reconcile_result) 157 | else: 158 | self.assertIsInstance(reconcile_result, expected_type) 159 | if input_key == "bogus": 160 | self.assertTrue( 161 | reconcile_result.message.startswith("Unknown predicate type") 162 | ) 163 | else: 164 | self.assertEqual(reconcile_result.message, f"{input_key} message") 165 | 166 | async def test_simple_return(self): 167 | prepared = await prepare.prepare_value_function( 168 | cache_key="test", 169 | spec={ 170 | "return": { 171 | "value": "=inputs.a + inputs.b", 172 | "list": ["=inputs.a + inputs.b", 17, "constant"], 173 | "map": {"mapKey": "=inputs.a + inputs.b"}, 174 | }, 175 | }, 176 | ) 177 | 178 | function, _ = prepared 179 | 180 | base_inputs = {"a": 1, "b": 8} 181 | 182 | reconcile_result = await reconcile.reconcile_value_function( 183 | location="test-fn", 184 | function=function, 185 | inputs=celpy.json_to_cel(base_inputs), 186 | ) 187 | 188 | expected_value = { 189 | "value": 9, 190 | "list": [9, 17, "constant"], 191 | "map": {"mapKey": 9}, 192 | } 193 | 194 | self.assertDictEqual(reconcile_result, expected_value) 195 | 196 | async def test_return_with_locals(self): 197 | prepared = await prepare.prepare_value_function( 198 | cache_key="test", 199 | spec={ 200 | "locals": { 201 | "value": "=inputs.a + inputs.b", 202 | "list": ["=inputs.a + inputs.b", 17, "constant"], 203 | "map": {"mapKey": "=inputs.a + inputs.b"}, 204 | }, 205 | "return": { 206 | "value": "=locals.value * locals.value", 207 | "list": "=locals.list.map(value, string(value) + ' value')", 208 | "map": "=locals.map.map(key, locals.map[key] * 3)", 209 | }, 210 | }, 211 | ) 212 | 213 | function, _ = prepared 214 | 215 | base_inputs = {"a": 1, "b": 8} 216 | 217 | reconcile_result = await reconcile.reconcile_value_function( 218 | location="test-fn", 219 | function=function, 220 | inputs=celpy.json_to_cel(base_inputs), 221 | ) 222 | 223 | expected_value = { 224 | "value": 81, 225 | "list": ["9 value", "17 value", "constant value"], 226 | "map": [27], 227 | } 228 | 229 | self.maxDiff = None 230 | self.assertDictEqual(reconcile_result, expected_value) 231 | 232 | async def test_corrupt_precondition(self): 233 | prepared = await prepare.prepare_value_function( 234 | cache_key="test", 235 | spec={ 236 | "preconditions": [ 237 | { 238 | "assert": "='a' + 9", 239 | "skip": {"message": "skip message"}, 240 | }, 241 | ], 242 | }, 243 | ) 244 | 245 | function, _ = prepared 246 | 247 | base_inputs = {} 248 | 249 | reconcile_result = await reconcile.reconcile_value_function( 250 | location="test-fn", 251 | function=function, 252 | inputs=celpy.json_to_cel(base_inputs), 253 | ) 254 | 255 | self.assertIsInstance(reconcile_result, result.PermFail) 256 | self.assertIn("spec.preconditions", reconcile_result.message) 257 | 258 | async def test_corrupt_locals(self): 259 | prepared = await prepare.prepare_value_function( 260 | cache_key="test", 261 | spec={ 262 | "locals": { 263 | "busted": "='a' + 9", 264 | }, 265 | "return": { 266 | "value": "unused", # Needed to prevent early-exit eval of locals 267 | }, 268 | }, 269 | ) 270 | 271 | function, _ = prepared 272 | 273 | base_inputs = {} 274 | 275 | reconcile_result = await reconcile.reconcile_value_function( 276 | location="test-fn", 277 | function=function, 278 | inputs=celpy.json_to_cel(base_inputs), 279 | ) 280 | 281 | self.assertIsInstance(reconcile_result, result.PermFail) 282 | self.assertIn("spec.locals", reconcile_result.message) 283 | 284 | async def test_corrupt_return(self): 285 | prepared = await prepare.prepare_value_function( 286 | cache_key="test", 287 | spec={ 288 | "return": { 289 | "value": "='a' + 18", 290 | }, 291 | }, 292 | ) 293 | 294 | function, _ = prepared 295 | 296 | base_inputs = {} 297 | 298 | reconcile_result = await reconcile.reconcile_value_function( 299 | location="test-fn", 300 | function=function, 301 | inputs=celpy.json_to_cel(base_inputs), 302 | ) 303 | 304 | self.assertIsInstance(reconcile_result, result.PermFail) 305 | self.assertIn("spec.return", reconcile_result.message) 306 | --------------------------------------------------------------------------------