├── .codecov.yml ├── .conform.yaml ├── .dockerignore ├── .drone.yml ├── .github ├── dependabot.yml ├── renovate.json └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── .kres.yaml ├── .license-header.go.txt ├── .markdownlint.json ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── key_storage │ ├── key_storage.pb.go │ ├── key_storage.proto │ └── key_storage_vtproto.pb.go └── v1alpha1 │ ├── meta.pb.go │ ├── meta_vtproto.pb.go │ ├── resource.pb.go │ ├── resource_vtproto.pb.go │ ├── state.pb.go │ ├── state.pb.gw.go │ ├── state_grpc.pb.go │ └── state_vtproto.pb.go ├── cmd └── runtime │ └── main.go ├── go.mod ├── go.sum ├── hack ├── git-chglog │ ├── CHANGELOG.tpl.md │ └── config.yaml ├── release.sh └── release.toml └── pkg ├── controller ├── conformance │ ├── conformance.go │ ├── controllers.go │ ├── qcontrollers.go │ ├── resource.go │ ├── resources.go │ └── runtime.go ├── controller.go ├── dependency.go ├── generic │ ├── cleanup │ │ ├── cleanup.go │ │ ├── cleanup_test.go │ │ └── resource_test.go │ ├── destroy │ │ ├── destroy.go │ │ └── destroy_test.go │ ├── generic.go │ ├── qtransform │ │ ├── options.go │ │ ├── qtransform.go │ │ ├── qtransform_test.go │ │ └── resource_test.go │ └── transform │ │ ├── controller.go │ │ ├── metrics.go │ │ ├── options.go │ │ ├── resource_test.go │ │ ├── transform.go │ │ └── transform_test.go ├── qcontroller.go ├── runtime.go ├── runtime │ ├── internal │ │ ├── adapter │ │ │ └── adapter.go │ │ ├── cache │ │ │ ├── cache.go │ │ │ ├── cache_test.go │ │ │ ├── errors.go │ │ │ ├── handler.go │ │ │ └── state.go │ │ ├── controllerstate │ │ │ └── adapter.go │ │ ├── dependency │ │ │ ├── bench_test.go │ │ │ ├── database.go │ │ │ └── database_test.go │ │ ├── qruntime │ │ │ ├── backoff.go │ │ │ ├── internal │ │ │ │ ├── containers │ │ │ │ │ ├── containers.go │ │ │ │ │ ├── priority_queue.go │ │ │ │ │ ├── priority_queue_test.go │ │ │ │ │ ├── slice_set.go │ │ │ │ │ └── slice_set_test.go │ │ │ │ ├── queue │ │ │ │ │ ├── queue.go │ │ │ │ │ └── queue_test.go │ │ │ │ └── timer │ │ │ │ │ ├── resettable.go │ │ │ │ │ └── resettable_test.go │ │ │ ├── qitem.go │ │ │ ├── qruntime.go │ │ │ └── watch.go │ │ ├── reduced │ │ │ ├── filter.go │ │ │ └── reduced.go │ │ └── rruntime │ │ │ ├── output_tracker.go │ │ │ ├── rruntime.go │ │ │ ├── run.go │ │ │ ├── state.go │ │ │ ├── tracking_pool.go │ │ │ └── watch.go │ ├── metrics │ │ ├── metrics.go │ │ └── state.go │ ├── options │ │ └── options.go │ ├── runtime.go │ └── runtime_test.go └── runtime_test.go ├── future ├── future.go └── future_test.go ├── keystorage ├── keystorage.go ├── keystorage_test.go └── testdata │ ├── private.key │ └── public.key ├── logging └── logging.go ├── resource ├── annotations.go ├── annotations_test.go ├── any.go ├── any_test.go ├── finalizer.go ├── finalizer_test.go ├── handle │ ├── handle.go │ └── handle_test.go ├── id_query.go ├── id_query_test.go ├── internal │ ├── compare │ │ ├── compare.go │ │ └── compare_test.go │ └── kv │ │ ├── kv.go │ │ └── kv_test.go ├── kind.go ├── kvutils │ └── kvutils.go ├── label_query.go ├── label_query_test.go ├── labels.go ├── labels_test.go ├── list.go ├── meta │ ├── core.go │ ├── namespace.go │ ├── namespace_test.go │ ├── resource_definition.go │ ├── resource_definition_test.go │ ├── sensitivity.go │ └── spec │ │ ├── resource_definition.go │ │ └── sensitivity.go ├── metadata.go ├── metadata_test.go ├── owner.go ├── phase.go ├── pointer.go ├── protobuf │ ├── protobuf.go │ ├── registry.go │ ├── registry_test.go │ ├── resource.go │ ├── resource_test.go │ ├── spec.go │ ├── spec_test.go │ ├── spec_unmarshal_test.go │ ├── yaml.go │ └── yaml_test.go ├── reference.go ├── resource.go ├── resource_test.go ├── rtestutils │ ├── assertions.go │ ├── destroy.go │ ├── errors.go │ ├── ids.go │ └── rtestutils.go ├── tombstone.go ├── typed │ ├── typed_resource.go │ └── typed_resource_test.go └── version.go ├── safe ├── reader.go ├── safe.go ├── state.go ├── state_test.go ├── util.go └── writer.go ├── state ├── condition.go ├── conformance │ ├── conformance.go │ ├── resources.go │ ├── state.go │ └── watch.go ├── errors.go ├── filter.go ├── filter_test.go ├── impl │ ├── inmem │ │ ├── backing_store.go │ │ ├── backing_store_test.go │ │ ├── build.go │ │ ├── collection.go │ │ ├── errors.go │ │ ├── errors_test.go │ │ ├── inmem.go │ │ ├── local_test.go │ │ └── options.go │ ├── namespaced │ │ ├── namespaced.go │ │ └── namespaced_test.go │ └── store │ │ ├── bolt │ │ ├── bbolt.go │ │ ├── bbolt_test.go │ │ ├── conformance_test.go │ │ ├── example_test.go │ │ └── namespaced.go │ │ ├── compression │ │ ├── compression.go │ │ ├── compression_test.go │ │ └── zstd.go │ │ ├── encryption │ │ ├── marshaler.go │ │ └── marshaler_test.go │ │ ├── protobuf.go │ │ ├── protobuf_test.go │ │ └── store.go ├── options.go ├── owned │ ├── owned.go │ ├── owned_test.go │ └── state.go ├── protobuf │ ├── client │ │ ├── client.go │ │ ├── client_test.go │ │ ├── errors.go │ │ ├── id_query.go │ │ └── label_query.go │ ├── protobuf.go │ ├── protobuf_test.go │ ├── runtime_test.go │ └── server │ │ ├── helpers.go │ │ └── server.go ├── registry │ ├── namespace.go │ ├── namespace_test.go │ ├── registry.go │ ├── resource.go │ └── resource_test.go ├── state.go ├── wrap.go └── wrap_test.go └── task ├── runner.go ├── runner_test.go ├── task.go └── task_test.go /.codecov.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2024-01-30T09:06:08Z by kres latest. 4 | 5 | codecov: 6 | require_ci_to_pass: false 7 | 8 | coverage: 9 | status: 10 | project: 11 | default: 12 | target: 45% 13 | threshold: 0.5% 14 | base: auto 15 | if_ci_failed: success 16 | patch: off 17 | 18 | comment: false 19 | -------------------------------------------------------------------------------- /.conform.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2024-02-28T13:50:02Z by kres latest. 4 | 5 | policies: 6 | - type: commit 7 | spec: 8 | dco: true 9 | gpg: 10 | required: true 11 | identity: 12 | gitHubOrganization: cosi-project 13 | spellcheck: 14 | locale: US 15 | maximumOfOneCommit: true 16 | header: 17 | length: 89 18 | imperative: true 19 | case: lower 20 | invalidLastCharacters: . 21 | body: 22 | required: true 23 | conventional: 24 | types: 25 | - chore 26 | - docs 27 | - perf 28 | - refactor 29 | - style 30 | - test 31 | - release 32 | scopes: 33 | - .* 34 | - type: license 35 | spec: 36 | root: . 37 | skipPaths: 38 | - .git/ 39 | - testdata/ 40 | includeSuffixes: 41 | - .go 42 | excludeSuffixes: 43 | - .pb.go 44 | - .pb.gw.go 45 | header: | 46 | // This Source Code Form is subject to the terms of the Mozilla Public 47 | // License, v. 2.0. If a copy of the MPL was not distributed with this 48 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 49 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2022-11-21T12:24:35Z by kres latest. 4 | 5 | * 6 | !api 7 | !cmd 8 | !pkg 9 | !go.mod 10 | !go.sum 11 | !.golangci.yml 12 | !README.md 13 | !.markdownlint.json 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | commit-message: 12 | prefix: "chore:" 13 | open-pull-requests-limit: 10 14 | rebase-strategy: disabled 15 | schedule: 16 | interval: "weekly" 17 | day: "monday" 18 | time: "01:00" 19 | timezone: "UTC" 20 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "description": "THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.", 4 | "prHeader": "Update Request | Renovate Bot", 5 | "extends": [ 6 | ":dependencyDashboard", 7 | ":gitSignOff", 8 | ":semanticCommitScopeDisabled", 9 | "schedule:earlyMondays" 10 | ], 11 | "packageRules": [ 12 | { 13 | "groupName": "dependencies", 14 | "matchUpdateTypes": [ 15 | "major", 16 | "minor", 17 | "patch", 18 | "pin", 19 | "digest" 20 | ] 21 | }, 22 | { 23 | "enabled": false, 24 | "matchFileNames": [ 25 | "Dockerfile" 26 | ] 27 | }, 28 | { 29 | "enabled": false, 30 | "matchFileNames": [ 31 | ".github/workflows/*.yaml" 32 | ] 33 | } 34 | ], 35 | "separateMajorMinor": false 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v4.2.2 17 | - 18 | name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3.6.0 20 | - 21 | name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3.10.0 23 | - 24 | name: Login to GitHub Container Registry 25 | uses: docker/login-action@v3.4.0 26 | if: github.ref == 'refs/heads/main' 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | - 32 | name: base 33 | run: make base 34 | - 35 | name: unit-tests 36 | run: make unit-tests 37 | - 38 | name: codecov 39 | uses: codecov/codecov-action@v5.4.2 40 | with: 41 | files: ./_out/coverage-unit-tests.txt 42 | - 43 | name: unit-tests-race 44 | run: make unit-tests-race 45 | - 46 | name: lint 47 | run: make lint 48 | - 49 | name: runtime 50 | run: make runtime 51 | - 52 | name: image-runtime 53 | run: make image-runtime 54 | - 55 | name: push-image-runtime 56 | if: github.ref == 'refs/heads/main' 57 | env: 58 | PUSH: "true" 59 | run: make image-runtime 60 | - 61 | name: push-image-runtime-latest 62 | if: github.ref == 'refs/heads/main' 63 | env: 64 | PUSH: "true" 65 | run: make image-runtime TAG=latest 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2020-08-28T20:43:11Z by kres 292ed36-dirty. 4 | 5 | _out 6 | -------------------------------------------------------------------------------- /.kres.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: golang.Generate 3 | spec: 4 | experimentalFlags: 5 | - --experimental_allow_proto3_optional 6 | vtProtobufEnabled: true 7 | specs: 8 | - source: https://raw.githubusercontent.com/cosi-project/specification/a25fac056c642b32468b030387ab94c17bc3ba1d/proto/v1alpha1/resource.proto 9 | subdirectory: v1alpha1/ 10 | genGateway: true 11 | external: false 12 | - source: https://raw.githubusercontent.com/cosi-project/specification/a25fac056c642b32468b030387ab94c17bc3ba1d/proto/v1alpha1/state.proto 13 | subdirectory: v1alpha1/ 14 | genGateway: true 15 | external: false 16 | - source: https://raw.githubusercontent.com/cosi-project/specification/a25fac056c642b32468b030387ab94c17bc3ba1d/proto/v1alpha1/meta.proto 17 | subdirectory: v1alpha1/ 18 | genGateway: true 19 | external: false 20 | - source: api/key_storage/key_storage.proto 21 | subdirectory: key_storage/ 22 | genGateway: false 23 | external: false 24 | --- 25 | kind: golang.UnitTests 26 | spec: 27 | extraArgs: "-p 1" # limit parallelism to avoid flakiness on busy GH runners 28 | --- 29 | kind: auto.CI 30 | spec: 31 | provider: drone 32 | --- 33 | kind: service.CodeCov 34 | spec: 35 | targetThreshold: 45 36 | -------------------------------------------------------------------------------- /.license-header.go.txt: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2020-08-28T20:43:11Z by kres 292ed36-dirty. 4 | 5 | { 6 | "MD013": false, 7 | "MD033": false, 8 | "default": true 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # runtime 2 | 3 | COSI Runtime contains core resource (state) and controller (operator) engine to build operating systems. 4 | 5 | ## Design 6 | 7 | ### Resources 8 | 9 | A **resource** is a _metadata_ plus opaque _spec_. 10 | Metadata structure is strictly defined, while a spec is transparent to the `runtime`. 11 | Metadata defines an address of the resource: (namespace, type, id, version) and additional fields (finalizers, owner, etc.) 12 | 13 | ### Controllers 14 | 15 | A **controller** is a task that runs as a single thread of execution. 16 | A controller has defined _input_ and _outputs_. 17 | Outputs are static and should be defined at the registration time, while inputs are dynamic and might change during controller execution. 18 | 19 | A controller is supposed to implement a reconcile loop: for each reconcile event (coming from the runtime) the controller wakes up, checks the inputs, 20 | performs any actions and modifies the outputs. 21 | 22 | Controller inputs are resources which controller can read (it can't read resources that are not declared as inputs), and inputs are the resources controller 23 | gets notified about changes: 24 | 25 | * `strong` inputs are the inputs controller depends on in a strong way: it has to be notified when inputs are going to be destroyed via finalizer mechanism; 26 | * `weak` inputs are the inputs controller watches, but it doesn't have to do any cleanup when weak inputs are being destroyed. 27 | 28 | A controller can modify finalizers of strong controller inputs; any other modifications to the inputs are not permitted. 29 | 30 | Controller outputs are resources which controller can write (create, destroy, update): 31 | 32 | * `exclusive` outputs are managed by only a single controller; no other controller can modify exclusive resources 33 | * `shared` outputs are resources that are created by multiple controllers, but each specific resource can only be modified by a controller which created that resource 34 | 35 | Runtime verifies that only one controller has `exclusive` access to the resource. 36 | 37 | ### Principles 38 | 39 | * simple and structured: impose structure to make things simple. 40 | * avoid conflicts by design: resources don't have multiple entities which can modify them. 41 | * use controller structure as documentation: graph of dependencies between controllers and resources documents system design and current state. 42 | -------------------------------------------------------------------------------- /api/key_storage/key_storage.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package cosi.internal.key_storage; 4 | 5 | option go_package = "github.com/cosi-project/runtime/api/key_storage"; 6 | 7 | // Storage is a main storage for keys in memory and in db. 8 | message Storage { 9 | StorageVersion storage_version = 1; 10 | map key_slots = 2; 11 | bytes keys_hmac_hash = 3; 12 | } 13 | 14 | // KeySlot is a single key slot in KeyStorage. 15 | message KeySlot { 16 | Algorithm algorithm = 1; 17 | bytes encrypted_key = 2; 18 | } 19 | 20 | // StorageVersion is a version of KeyStorage. 21 | enum StorageVersion { 22 | STORAGE_VERSION_UNSPECIFIED = 0; 23 | STORAGE_VERSION_1 = 1; 24 | } 25 | 26 | // Algorithm is an algorithm used for encryption. 27 | enum Algorithm { 28 | UNKNOWN = 0; 29 | PGP_AES_GCM_256 = 1; // PGP encrypted AES-256-GCM key 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cosi-project/runtime 2 | 3 | go 1.24.0 4 | 5 | // forked yaml that introduces RawYAML interface that can be used to provide YAML encoder bytes 6 | // which are then encoded as a valid YAML block with proper indentiation 7 | replace gopkg.in/yaml.v3 => github.com/unix4ever/yaml v0.0.0-20220527175918-f17b0f05cf2c 8 | 9 | require ( 10 | github.com/ProtonMail/gopenpgp/v2 v2.8.3 11 | github.com/cenkalti/backoff/v4 v4.3.0 12 | github.com/gertd/go-pluralize v0.2.1 13 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 14 | github.com/hashicorp/go-multierror v1.1.1 15 | github.com/klauspost/compress v1.18.0 16 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 17 | github.com/siderolabs/gen v0.8.1 18 | github.com/siderolabs/go-pointer v1.0.1 19 | github.com/siderolabs/go-retry v0.3.3 20 | github.com/siderolabs/protoenc v0.2.2 21 | github.com/stretchr/testify v1.10.0 22 | go.etcd.io/bbolt v1.4.0 23 | go.uber.org/goleak v1.3.0 24 | go.uber.org/zap v1.27.0 25 | golang.org/x/sync v0.14.0 26 | golang.org/x/time v0.11.0 27 | google.golang.org/grpc v1.72.0 28 | google.golang.org/protobuf v1.36.6 29 | gopkg.in/yaml.v3 v3.0.1 30 | ) 31 | 32 | require ( 33 | github.com/ProtonMail/go-crypto v1.2.0 // indirect 34 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect 35 | github.com/cloudflare/circl v1.6.1 // indirect 36 | github.com/davecgh/go-spew v1.1.1 // indirect 37 | github.com/hashicorp/errwrap v1.1.0 // indirect 38 | github.com/pkg/errors v0.9.1 // indirect 39 | github.com/pmezard/go-difflib v1.0.0 // indirect 40 | github.com/rogpeppe/go-internal v1.14.1 // indirect 41 | go.uber.org/multierr v1.11.0 // indirect 42 | golang.org/x/crypto v0.38.0 // indirect 43 | golang.org/x/net v0.40.0 // indirect 44 | golang.org/x/sys v0.33.0 // indirect 45 | golang.org/x/text v0.25.0 // indirect 46 | google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 // indirect 47 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect 48 | ) 49 | 50 | retract ( 51 | v0.7.3 // Typo in the test type result 52 | v0.4.7 // Wait with locked mutex leads to the deadlock 53 | ) 54 | -------------------------------------------------------------------------------- /hack/git-chglog/CHANGELOG.tpl.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ range .Versions }} 6 | 7 | ## {{ if .Tag.Previous }}[{{ .Tag.Name }}]({{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }}){{ else }}{{ .Tag.Name }}{{ end }} ({{ datetime "2006-01-02" .Tag.Date }}) 8 | 9 | {{ range .CommitGroups -}} 10 | ### {{ .Title }} 11 | 12 | {{ range .Commits -}} 13 | * {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} 14 | {{ end }} 15 | {{ end -}} 16 | 17 | {{- if .NoteGroups -}} 18 | {{ range .NoteGroups -}} 19 | ### {{ .Title }} 20 | 21 | {{ range .Notes }} 22 | {{ .Body }} 23 | {{ end }} 24 | {{ end -}} 25 | {{ end -}} 26 | {{ end -}} 27 | -------------------------------------------------------------------------------- /hack/git-chglog/config.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2021-04-08T12:27:41Z by kres 7917d0d-dirty. 4 | 5 | style: github 6 | template: CHANGELOG.tpl.md 7 | info: 8 | title: CHANGELOG 9 | repository_url: https://github.com/cosi-project/runtime 10 | options: 11 | commits: 12 | # filters: 13 | # Type: 14 | # - feat 15 | # - fix 16 | # - perf 17 | # - refactor 18 | commit_groups: 19 | # title_maps: 20 | # feat: Features 21 | # fix: Bug Fixes 22 | # perf: Performance Improvements 23 | # refactor: Code Refactoring 24 | header: 25 | pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$" 26 | pattern_maps: 27 | - Type 28 | - Scope 29 | - Subject 30 | notes: 31 | keywords: 32 | - BREAKING CHANGE 33 | -------------------------------------------------------------------------------- /hack/release.toml: -------------------------------------------------------------------------------- 1 | # THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT. 2 | # 3 | # Generated on 2021-06-21T09:20:43Z by kres latest. 4 | 5 | 6 | # commit to be tagged for the new release 7 | commit = "HEAD" 8 | 9 | project_name = "runtime" 10 | github_repo = "cosi-project/runtime" 11 | match_deps = "^github.com/(cosi-project/[a-zA-Z0-9-]+)$" 12 | 13 | # previous = - 14 | # pre_release = true 15 | 16 | # [notes] 17 | -------------------------------------------------------------------------------- /pkg/controller/conformance/conformance.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package conformance implements tests which verify conformance of the implementation with the spec. 6 | package conformance 7 | -------------------------------------------------------------------------------- /pkg/controller/conformance/resource.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package conformance 6 | 7 | import "github.com/cosi-project/runtime/pkg/resource" 8 | 9 | // Resource represents some T value. 10 | type Resource[T any, S Spec[T], SS SpecPtr[T, S]] struct { 11 | value S 12 | md resource.Metadata 13 | } 14 | 15 | // NewResource creates new Resource. 16 | func NewResource[T any, S Spec[T], SS SpecPtr[T, S]](md resource.Metadata, value T) *Resource[T, S, SS] { 17 | var s S 18 | ss := SS(&s) 19 | ss.SetValue(value) 20 | 21 | r := &Resource[T, S, SS]{ 22 | md: md, 23 | value: s, 24 | } 25 | 26 | return r 27 | } 28 | 29 | // Metadata implements resource.Resource. 30 | func (r *Resource[T, S, SS]) Metadata() *resource.Metadata { 31 | return &r.md 32 | } 33 | 34 | // Spec implements resource.Resource. 35 | func (r *Resource[T, S, SS]) Spec() any { 36 | return r.value 37 | } 38 | 39 | // Value returns a value inside the spec. 40 | func (r *Resource[T, S, SS]) Value() T { //nolint:ireturn 41 | return r.value.Value() 42 | } 43 | 44 | // SetValue set spec with provided value. 45 | func (r *Resource[T, S, SS]) SetValue(v T) { 46 | val := SS(&r.value) 47 | val.SetValue(v) 48 | } 49 | 50 | // DeepCopy implements resource.Resource. 51 | func (r *Resource[T, S, SS]) DeepCopy() resource.Resource { //nolint:ireturn 52 | return &Resource[T, S, SS]{ 53 | md: r.md, 54 | value: r.value, 55 | } 56 | } 57 | 58 | // UnmarshalProto implements protobuf.ResourceUnmarshaler. 59 | func (r *Resource[T, S, SS]) UnmarshalProto(md *resource.Metadata, protoSpec []byte) error { 60 | r.md = *md 61 | val := SS(&r.value) 62 | val.FromProto(protoSpec) 63 | 64 | return nil 65 | } 66 | 67 | // SpecPtr requires Spec to be a pointer and have a set of methods. 68 | type SpecPtr[T, S any] interface { 69 | *S 70 | Spec[T] 71 | FromProto([]byte) 72 | SetValue(T) 73 | } 74 | 75 | // Spec requires spec to have a set of Get methods. 76 | type Spec[T any] interface { 77 | Value() T 78 | } 79 | -------------------------------------------------------------------------------- /pkg/controller/conformance/resources.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package conformance 6 | 7 | import ( 8 | "encoding/binary" 9 | 10 | "github.com/cosi-project/runtime/pkg/resource" 11 | ) 12 | 13 | // IntegerResource is implemented by resources holding ints. 14 | type IntegerResource interface { 15 | Value() int 16 | SetValue(int) 17 | } 18 | 19 | // StringResource is implemented by resources holding strings. 20 | type StringResource interface { 21 | Value() string 22 | SetValue(string) 23 | } 24 | 25 | // IntResourceType is the type of IntResource. 26 | const IntResourceType = resource.Type("test/int") 27 | 28 | // IntResource represents some integer value. 29 | type IntResource = Resource[int, intSpec, *intSpec] 30 | 31 | // NewIntResource creates new IntResource. 32 | func NewIntResource(ns resource.Namespace, id resource.ID, value int) *IntResource { 33 | return NewResource[int, intSpec, *intSpec](resource.NewMetadata(ns, IntResourceType, id, resource.VersionUndefined), value) 34 | } 35 | 36 | type intSpec struct{ ValueGetSet[int] } //nolint:recvcheck 37 | 38 | func (is *intSpec) FromProto(bytes []byte) { 39 | v, _ := binary.Varint(bytes) 40 | is.value = int(v) 41 | } 42 | 43 | func (is intSpec) MarshalProto() ([]byte, error) { 44 | buf := make([]byte, binary.MaxVarintLen64) 45 | n := binary.PutVarint(buf, int64(is.value)) 46 | 47 | return buf[:n], nil 48 | } 49 | 50 | // StrResourceType is the type of StrResource. 51 | const StrResourceType = resource.Type("test/str") 52 | 53 | // StrResource represents some string value. 54 | type StrResource = Resource[string, strSpec, *strSpec] 55 | 56 | // NewStrResource creates new StrResource. 57 | func NewStrResource(ns resource.Namespace, id resource.ID, value string) *StrResource { 58 | return NewResource[string, strSpec, *strSpec](resource.NewMetadata(ns, StrResourceType, id, resource.VersionUndefined), value) 59 | } 60 | 61 | type strSpec struct{ ValueGetSet[string] } //nolint:recvcheck 62 | 63 | func (s *strSpec) FromProto(bytes []byte) { s.value = string(bytes) } 64 | func (s strSpec) MarshalProto() ([]byte, error) { return []byte(s.value), nil } 65 | 66 | // SentenceResourceType is the type of SentenceResource. 67 | const SentenceResourceType = resource.Type("test/sentence") 68 | 69 | // SentenceResource represents some string value. 70 | type SentenceResource = Resource[string, sentenceSpec, *sentenceSpec] 71 | 72 | // NewSentenceResource creates new SentenceResource. 73 | func NewSentenceResource(ns resource.Namespace, id resource.ID, value string) *SentenceResource { 74 | return NewResource[string, sentenceSpec, *sentenceSpec](resource.NewMetadata(ns, SentenceResourceType, id, resource.VersionUndefined), value) 75 | } 76 | 77 | type sentenceSpec struct{ ValueGetSet[string] } //nolint:recvcheck 78 | 79 | func (s *sentenceSpec) FromProto(bytes []byte) { s.value = string(bytes) } 80 | func (s sentenceSpec) MarshalProto() ([]byte, error) { return []byte(s.value), nil } 81 | 82 | // ValueGetSet is a basic building block for IntegerResource and StringResource implementations. 83 | type ValueGetSet[T any] struct{ value T } 84 | 85 | func (s *ValueGetSet[T]) SetValue(t T) { s.value = t } //nolint:revive 86 | func (s ValueGetSet[T]) Value() T { return s.value } //nolint:ireturn,revive 87 | -------------------------------------------------------------------------------- /pkg/controller/controller.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package controller defines common interfaces to be implemented by the controllers and controller runtime. 6 | package controller 7 | 8 | import ( 9 | "context" 10 | 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // Controller interface should be implemented by Controllers. 15 | type Controller interface { 16 | Name() string 17 | Inputs() []Input 18 | Outputs() []Output 19 | 20 | Run(context.Context, Runtime, *zap.Logger) error 21 | } 22 | 23 | // Engine is the entrypoint into Controller Runtime. 24 | type Engine interface { 25 | // RegisterController registers new controller. 26 | RegisterController(ctrl Controller) error 27 | // RegisterQController registers new QController. 28 | RegisterQController(ctrl QController) error 29 | // Run the controllers. 30 | Run(ctx context.Context) error 31 | } 32 | -------------------------------------------------------------------------------- /pkg/controller/dependency.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package controller 6 | 7 | import "github.com/cosi-project/runtime/pkg/resource" 8 | 9 | // DependencyGraph is the exported information about controller/resources dependencies. 10 | type DependencyGraph struct { 11 | Edges []DependencyEdge 12 | } 13 | 14 | // DependencyEdgeType is edge type in controller graph. 15 | type DependencyEdgeType int 16 | 17 | // Controller graph edge types. 18 | const ( 19 | EdgeOutputExclusive DependencyEdgeType = iota 20 | EdgeOutputShared 21 | EdgeInputStrong 22 | EdgeInputWeak 23 | EdgeInputDestroyReady 24 | EdgeInputQPrimary 25 | EdgeInputQMapped 26 | EdgeInputQMappedDestroyReady 27 | ) 28 | 29 | // DependencyEdge represents relationship between controller and resource(s). 30 | type DependencyEdge struct { 31 | ControllerName string 32 | 33 | ResourceNamespace resource.Namespace 34 | ResourceType resource.Type 35 | ResourceID resource.ID 36 | 37 | EdgeType DependencyEdgeType 38 | } 39 | -------------------------------------------------------------------------------- /pkg/controller/generic/destroy/destroy.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package destroy provides a generic implementation of controller which cleans up tearing down resources without finalizers. 6 | package destroy 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | 12 | "github.com/siderolabs/gen/optional" 13 | "go.uber.org/zap" 14 | 15 | "github.com/cosi-project/runtime/pkg/controller" 16 | "github.com/cosi-project/runtime/pkg/controller/generic" 17 | "github.com/cosi-project/runtime/pkg/resource" 18 | "github.com/cosi-project/runtime/pkg/safe" 19 | "github.com/cosi-project/runtime/pkg/state" 20 | ) 21 | 22 | // Controller provides a generic implementation of a QController which destroys tearing down resources without finalizers. 23 | type Controller[Input generic.ResourceWithRD] struct { 24 | generic.NamedController 25 | concurrency optional.Optional[uint] 26 | } 27 | 28 | // NewController creates a new destroy Controller. 29 | func NewController[Input generic.ResourceWithRD](concurrency optional.Optional[uint]) *Controller[Input] { 30 | var input Input 31 | 32 | name := fmt.Sprintf("Destroy[%s]", input.ResourceDefinition().Type) 33 | 34 | return &Controller[Input]{ 35 | concurrency: concurrency, 36 | NamedController: generic.NamedController{ 37 | ControllerName: name, 38 | }, 39 | } 40 | } 41 | 42 | // Settings implements controller.QController interface. 43 | func (ctrl *Controller[Input]) Settings() controller.QSettings { 44 | var input Input 45 | 46 | return controller.QSettings{ 47 | Inputs: []controller.Input{ 48 | { 49 | Namespace: input.ResourceDefinition().DefaultNamespace, 50 | Type: input.ResourceDefinition().Type, 51 | Kind: controller.InputQPrimary, 52 | }, 53 | }, 54 | Outputs: []controller.Output{ 55 | { 56 | Type: input.ResourceDefinition().Type, 57 | Kind: controller.OutputShared, 58 | }, 59 | }, 60 | Concurrency: ctrl.concurrency, 61 | } 62 | } 63 | 64 | // Reconcile implements controller.QController interface. 65 | func (ctrl *Controller[Input]) Reconcile(ctx context.Context, logger *zap.Logger, r controller.QRuntime, ptr resource.Pointer) error { 66 | in, err := safe.ReaderGet[Input](ctx, r, ptr) 67 | if err != nil { 68 | if state.IsNotFoundError(err) { 69 | return nil 70 | } 71 | 72 | return fmt.Errorf("error reading input resource: %w", err) 73 | } 74 | 75 | // only handle tearing down resources 76 | if in.Metadata().Phase() != resource.PhaseTearingDown { 77 | return nil 78 | } 79 | 80 | // only destroy resources without owner 81 | if in.Metadata().Owner() != "" { 82 | return nil 83 | } 84 | 85 | // do not do anything while the resource has any finalizers 86 | if !in.Metadata().Finalizers().Empty() { 87 | return nil 88 | } 89 | 90 | logger.Info("destroy the resource without finalizers") 91 | 92 | return r.Destroy(ctx, in.Metadata(), controller.WithOwner("")) 93 | } 94 | 95 | // MapInput implements controller.QController interface. 96 | func (ctrl *Controller[Input]) MapInput(context.Context, *zap.Logger, controller.QRuntime, resource.Pointer) ([]resource.Pointer, error) { 97 | return nil, nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/controller/generic/generic.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package generic provides implementations of generic controllers. 6 | package generic 7 | 8 | import ( 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | ) 11 | 12 | // ResourceWithRD is an alias for meta.ResourceWithRD. 13 | type ResourceWithRD = meta.ResourceWithRD 14 | 15 | // NamedController is provides Name() method. 16 | type NamedController struct { 17 | ControllerName string 18 | } 19 | 20 | // Name implements controller.Controller interface. 21 | func (c *NamedController) Name() string { 22 | return c.ControllerName 23 | } 24 | -------------------------------------------------------------------------------- /pkg/controller/generic/transform/metrics.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package transform 6 | 7 | import ( 8 | "expvar" 9 | ) 10 | 11 | // TransformController specific metrics. 12 | var ( 13 | // MetricReconcileCycles counts the number of times reconcile loop ran. 14 | MetricReconcileCycles = expvar.NewMap("reconcile_cycles") 15 | 16 | // MetricReconcileInputItems counts the number of resources reconciled overall. 17 | MetricReconcileInputItems = expvar.NewMap("reconcile_input_items") 18 | 19 | // MetricCycleBusy counts the number of seconds the controller was busy in the reconcile loop. 20 | MetricCycleBusy = expvar.NewMap("reconcile_busy") 21 | ) 22 | -------------------------------------------------------------------------------- /pkg/controller/generic/transform/resource_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package transform_test 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | "github.com/cosi-project/runtime/pkg/resource/typed" 11 | ) 12 | 13 | // ANamespaceName is the namespace of A resource. 14 | const ANamespaceName = resource.Namespace("ns-a") 15 | 16 | // AType is the type of A. 17 | const AType = resource.Type("A.test.cosi.dev") 18 | 19 | // A is a test resource. 20 | type A = typed.Resource[ASpec, AE] 21 | 22 | // NewA initializes a A resource. 23 | func NewA(id resource.ID, spec ASpec) *A { 24 | return typed.NewResource[ASpec, AE]( 25 | resource.NewMetadata(ANamespaceName, AType, id, resource.VersionUndefined), 26 | spec, 27 | ) 28 | } 29 | 30 | // AE provides auxiliary methods for A. 31 | type AE struct{} 32 | 33 | // ResourceDefinition implements core.ResourceDefinitionProvider interface. 34 | func (AE) ResourceDefinition() meta.ResourceDefinitionSpec { 35 | return meta.ResourceDefinitionSpec{ 36 | Type: AType, 37 | DefaultNamespace: ANamespaceName, 38 | } 39 | } 40 | 41 | // ASpec provides A definition. 42 | type ASpec struct { 43 | Str string 44 | Int int 45 | } 46 | 47 | // DeepCopy generates a deep copy of NamespaceSpec. 48 | func (a ASpec) DeepCopy() ASpec { 49 | return a 50 | } 51 | 52 | // BNamespaceName is the namespace of B resource. 53 | const BNamespaceName = resource.Namespace("ns-b") 54 | 55 | // BType is the type of B. 56 | const BType = resource.Type("B.test.cosi.dev") 57 | 58 | // B is a test resource. 59 | type B = typed.Resource[BSpec, BE] 60 | 61 | // NewB initializes a B resource. 62 | func NewB(id resource.ID, spec BSpec) *B { 63 | return typed.NewResource[BSpec, BE]( 64 | resource.NewMetadata(BNamespaceName, BType, id, resource.VersionUndefined), 65 | spec, 66 | ) 67 | } 68 | 69 | // BE provides auxiliary methods for B. 70 | type BE struct{} 71 | 72 | // ResourceDefinition implements core.ResourceDefinitionProvider interface. 73 | func (BE) ResourceDefinition() meta.ResourceDefinitionSpec { 74 | return meta.ResourceDefinitionSpec{ 75 | Type: BType, 76 | DefaultNamespace: BNamespaceName, 77 | } 78 | } 79 | 80 | // BSpec provides B definition. 81 | type BSpec struct { 82 | Out string 83 | TransformCount int 84 | } 85 | 86 | // DeepCopy generates a deep copy of BSpec. 87 | func (b BSpec) DeepCopy() BSpec { 88 | return b 89 | } 90 | 91 | var ( 92 | _ resource.Resource = &A{} 93 | _ resource.Resource = &B{} 94 | _ meta.ResourceDefinitionProvider = &A{} 95 | _ meta.ResourceDefinitionProvider = &B{} 96 | ) 97 | -------------------------------------------------------------------------------- /pkg/controller/generic/transform/transform.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package transform provides a generic implementation of controller which transforms resources A into resources B. 6 | package transform 7 | 8 | // SkipReconcileTag is used to tag errors when reconciliation should be skipped without an error. 9 | // 10 | // It's useful when next reconcile event should bring things into order. 11 | type SkipReconcileTag struct{} 12 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/adapter/adapter.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package adapter provides common interface for controller adapters. 6 | package adapter 7 | 8 | import ( 9 | "context" 10 | 11 | "go.uber.org/zap" 12 | 13 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/cache" 14 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/dependency" 15 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/reduced" 16 | "github.com/cosi-project/runtime/pkg/controller/runtime/options" 17 | "github.com/cosi-project/runtime/pkg/resource" 18 | "github.com/cosi-project/runtime/pkg/state" 19 | ) 20 | 21 | // Adapter is common interface for controller adapters. 22 | type Adapter interface { 23 | // Run starts the adapter. 24 | Run(ctx context.Context) 25 | // WatchTrigger is called to notify the adapter about a new watch event. 26 | // 27 | // WatchTrigger should not block and should process the event asynchronously. 28 | WatchTrigger(md *reduced.Metadata) 29 | } 30 | 31 | // Options are options for creating a new Adapter. 32 | type Options struct { 33 | Logger *zap.Logger 34 | State state.State 35 | Cache *cache.ResourceCache 36 | DepDB *dependency.Database 37 | RegisterWatch func(resourceNamespace resource.Namespace, resourceType resource.Type) error 38 | RuntimeOptions options.Options 39 | } 40 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/cache/errors.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package cache 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/cosi-project/runtime/pkg/resource" 11 | ) 12 | 13 | //nolint:errname 14 | type eNotFound struct { 15 | error 16 | } 17 | 18 | func (eNotFound) NotFoundError() {} 19 | 20 | // ErrNotFound generates error compatible with state.ErrNotFound. 21 | func ErrNotFound(r resource.Pointer) error { 22 | return eNotFound{ 23 | fmt.Errorf("resource %s doesn't exist", r), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/dependency/bench_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package dependency_test 6 | 7 | import ( 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/siderolabs/gen/optional" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/cosi-project/runtime/pkg/controller" 15 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/dependency" 16 | "github.com/cosi-project/runtime/pkg/resource" 17 | ) 18 | 19 | func BenchmarkGetDependentControllers(b *testing.B) { 20 | db, err := dependency.NewDatabase() 21 | require.NoError(b, err) 22 | 23 | require.NoError(b, db.AddControllerInput("ConfigController", controller.Input{ 24 | Namespace: "user", 25 | Type: "Config", 26 | Kind: controller.InputWeak, 27 | })) 28 | 29 | require.NoError(b, db.AddControllerInput("ConfigController", controller.Input{ 30 | Namespace: "user", 31 | Type: "Source", 32 | Kind: controller.InputWeak, 33 | })) 34 | 35 | require.NoError(b, db.AddControllerInput("GreatController", controller.Input{ 36 | Namespace: "user", 37 | Type: "Config", 38 | Kind: controller.InputStrong, 39 | })) 40 | 41 | in := controller.Input{ 42 | Namespace: "user", 43 | Type: "Config", 44 | ID: optional.Some[resource.ID]("aaaa"), 45 | } 46 | 47 | b.ResetTimer() 48 | 49 | for range b.N { 50 | _, err := db.GetDependentControllers(in) 51 | if err != nil { 52 | b.FailNow() 53 | } 54 | } 55 | } 56 | 57 | func BenchmarkBuildDatabase(b *testing.B) { 58 | db, err := dependency.NewDatabase() 59 | require.NoError(b, err) 60 | 61 | b.ResetTimer() 62 | 63 | for i := range b.N { 64 | iS := strconv.Itoa(i) 65 | ctrl := "ConfigController" + iS 66 | typ := "Resource" + iS 67 | greatCtrl := "GreatController" + iS[0:1] 68 | 69 | require.NoError(b, db.AddControllerInput(ctrl, controller.Input{ 70 | Namespace: "user", 71 | Type: "Config", 72 | Kind: controller.InputWeak, 73 | })) 74 | 75 | require.NoError(b, db.AddControllerInput(ctrl, controller.Input{ 76 | Namespace: "user", 77 | Type: typ, 78 | Kind: controller.InputWeak, 79 | })) 80 | 81 | require.NoError(b, db.AddControllerOutput(ctrl, controller.Output{ 82 | Type: typ, 83 | Kind: controller.OutputExclusive, 84 | })) 85 | 86 | require.NoError(b, db.AddControllerInput(greatCtrl, controller.Input{ 87 | Namespace: "user", 88 | Type: typ, 89 | Kind: controller.InputStrong, 90 | })) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/backoff.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package qruntime 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/cenkalti/backoff/v4" 11 | ) 12 | 13 | func (adapter *Adapter) getBackoffInterval(item QItem) time.Duration { 14 | adapter.backoffsMu.Lock() 15 | defer adapter.backoffsMu.Unlock() 16 | 17 | bckoff, ok := adapter.backoffs[item] 18 | if !ok { 19 | bckoff = backoff.NewExponentialBackOff() 20 | bckoff.MaxElapsedTime = 0 21 | adapter.backoffs[item] = bckoff 22 | } 23 | 24 | return bckoff.NextBackOff() 25 | } 26 | 27 | func (adapter *Adapter) clearBackoff(item QItem) { 28 | adapter.backoffsMu.Lock() 29 | defer adapter.backoffsMu.Unlock() 30 | 31 | delete(adapter.backoffs, item) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/containers/containers.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package containers provides helper containers for qruntime. 6 | package containers 7 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/containers/priority_queue.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package containers 6 | 7 | import ( 8 | "slices" 9 | "time" 10 | 11 | "github.com/siderolabs/gen/optional" 12 | ) 13 | 14 | type itemWithBackoff[T comparable] struct { 15 | Item T 16 | ReleaseAfter time.Time 17 | } 18 | 19 | // PriorityQueue keeps a priority queue of items with backoff (release after). 20 | // 21 | // PriorityQueue deduplicates by item (T). 22 | type PriorityQueue[T comparable] struct { 23 | items []itemWithBackoff[T] 24 | } 25 | 26 | // Push item to the queue with releaseAfter time. 27 | // 28 | // If the item is not in the queue, it will be added. 29 | // If the item is in the queue, and releaseAfter is less than the existing releaseAfter, it will be re-added in the new position. 30 | // 31 | // Push returns true if the item was added, and false if the existing item in the queue was updated (or skipped). 32 | func (queue *PriorityQueue[T]) Push(item T, releaseAfter time.Time) bool { 33 | idx := slices.IndexFunc(queue.items, func(queueItem itemWithBackoff[T]) bool { 34 | return queueItem.Item == item 35 | }) 36 | 37 | if idx != -1 { // the item is already in the queue 38 | // if new releaseAfter > existing releaseAfter, do nothing 39 | if releaseAfter.Compare(queue.items[idx].ReleaseAfter) > 0 { 40 | return false 41 | } 42 | 43 | // re-order the queue by deleting existing item from the queue, it will be re-added below 44 | queue.items = slices.Delete(queue.items, idx, idx+1) 45 | } 46 | 47 | // find a position and add an item to the queue 48 | newIdx, _ := slices.BinarySearchFunc(queue.items, releaseAfter, func(queueItem itemWithBackoff[T], releaseAfter time.Time) int { 49 | c := queueItem.ReleaseAfter.Compare(releaseAfter) 50 | if c == 0 { 51 | // force the binary search to insert to the "tail" if it encounters the same releaseAfter value 52 | // this way the queue is more "fair", so that new items are added closer to the end of the queue 53 | return -1 54 | } 55 | 56 | return c 57 | }) 58 | 59 | queue.items = slices.Insert(queue.items, newIdx, itemWithBackoff[T]{Item: item, ReleaseAfter: releaseAfter}) 60 | 61 | return idx == -1 62 | } 63 | 64 | // Peek returns the top item from the queue if it is ready to be released at now. 65 | // 66 | // If Peek returns optional.None, it also returns delay to get the next item from the queue. 67 | // If there are no items in the queue, Peek returns optional.None and zero delay. 68 | func (queue *PriorityQueue[T]) Peek(now time.Time) (item optional.Optional[T], nextDelay time.Duration) { 69 | if len(queue.items) > 0 { 70 | delay := queue.items[0].ReleaseAfter.Sub(now) 71 | 72 | if delay <= 0 { 73 | return optional.Some[T](queue.items[0].Item), 0 74 | } 75 | 76 | return optional.None[T](), delay 77 | } 78 | 79 | return optional.None[T](), 0 80 | } 81 | 82 | // Pop removes the top item from the queue. 83 | // 84 | // Pop should only be called if Peek returned optional.Some. 85 | func (queue *PriorityQueue[T]) Pop() { 86 | queue.items = slices.Delete(queue.items, 0, 1) 87 | } 88 | 89 | // Len returns the number of items in the queue. 90 | func (queue *PriorityQueue[T]) Len() int { 91 | return len(queue.items) 92 | } 93 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/containers/slice_set.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package containers 6 | 7 | import ( 8 | "slices" 9 | ) 10 | 11 | // SliceSet is a set implementation based on slices (for small number of items). 12 | type SliceSet[T comparable] struct { 13 | items []T 14 | } 15 | 16 | // Add item to the set. 17 | func (set *SliceSet[T]) Add(item T) bool { 18 | if slices.Contains(set.items, item) { 19 | return false 20 | } 21 | 22 | set.items = append(set.items, item) 23 | 24 | return true 25 | } 26 | 27 | // Contains returns true if the set contains the item. 28 | func (set *SliceSet[T]) Contains(item T) bool { 29 | return slices.Contains(set.items, item) 30 | } 31 | 32 | // Remove item from the set. 33 | func (set *SliceSet[T]) Remove(item T) (found bool) { 34 | idx := slices.Index(set.items, item) 35 | if idx == -1 { 36 | return false 37 | } 38 | 39 | set.items = slices.Delete(set.items, idx, idx+1) 40 | 41 | return true 42 | } 43 | 44 | // Len returns the number of items in the set. 45 | func (set *SliceSet[T]) Len() int { 46 | return len(set.items) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/containers/slice_set_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package containers_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/qruntime/internal/containers" 14 | ) 15 | 16 | func TestSliceSet(t *testing.T) { 17 | var set containers.SliceSet[int] 18 | 19 | for _, i := range []int{1, 2, 2, 3, 4, 5, 5} { 20 | set.Add(i) 21 | } 22 | 23 | assert.Equal(t, 5, set.Len()) 24 | 25 | for _, i := range []int{1, 2, 3, 4, 5} { 26 | require.True(t, set.Contains(i)) 27 | } 28 | 29 | require.False(t, set.Contains(0)) 30 | require.False(t, set.Remove(0)) 31 | 32 | for _, i := range []int{2, 4} { 33 | require.True(t, set.Remove(i)) 34 | } 35 | 36 | assert.Equal(t, 3, set.Len()) 37 | 38 | for _, i := range []int{1, 2, 2, 3, 4, 5, 5} { 39 | set.Add(i) 40 | } 41 | 42 | assert.Equal(t, 5, set.Len()) 43 | 44 | for _, i := range []int{1, 2, 3, 4, 5} { 45 | require.True(t, set.Contains(i)) 46 | } 47 | 48 | require.True(t, set.Remove(3)) 49 | require.False(t, set.Remove(3)) 50 | 51 | require.True(t, set.Add(3)) 52 | require.False(t, set.Add(3)) 53 | 54 | assert.Equal(t, 5, set.Len()) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/queue/queue_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package queue_test 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "sync" 11 | "testing" 12 | "time" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "golang.org/x/sync/errgroup" 17 | 18 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/qruntime/internal/queue" 19 | ) 20 | 21 | type itemTracker struct { 22 | processed map[int]int 23 | concurrent map[int]struct{} 24 | mu sync.Mutex 25 | } 26 | 27 | func (tracker *itemTracker) start(item int) error { 28 | tracker.mu.Lock() 29 | defer tracker.mu.Unlock() 30 | 31 | if _, present := tracker.concurrent[item]; present { 32 | return fmt.Errorf("duplicate item processing: %d", item) 33 | } 34 | 35 | tracker.concurrent[item] = struct{}{} 36 | 37 | return nil 38 | } 39 | 40 | func (tracker *itemTracker) doneWith(item int) { 41 | tracker.mu.Lock() 42 | tracker.processed[item]++ 43 | delete(tracker.concurrent, item) 44 | tracker.mu.Unlock() 45 | } 46 | 47 | func TestQueue(t *testing.T) { 48 | q := queue.NewQueue[int]() 49 | 50 | tracker := &itemTracker{ 51 | processed: make(map[int]int), 52 | concurrent: make(map[int]struct{}), 53 | } 54 | 55 | const ( 56 | numWorkers = 5 57 | numItems = 100 58 | numIterations = 100 59 | ) 60 | 61 | ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) 62 | t.Cleanup(cancel) 63 | 64 | now := time.Now() 65 | 66 | eg, ctx := errgroup.WithContext(ctx) 67 | 68 | eg.Go(func() error { 69 | q.Run(ctx) 70 | 71 | return nil 72 | }) 73 | 74 | for i := range numWorkers { 75 | eg.Go(func() error { 76 | for { 77 | select { 78 | case <-ctx.Done(): 79 | return nil 80 | case item := <-q.Get(): 81 | if err := tracker.start(item.Value()); err != nil { 82 | item.Release() 83 | 84 | return err 85 | } 86 | 87 | time.Sleep(5 * time.Millisecond) 88 | 89 | tracker.doneWith(item.Value()) 90 | 91 | if i%2 == 0 { 92 | item.Requeue(time.Now().Add(10 * time.Millisecond)) 93 | } else { 94 | item.Release() 95 | } 96 | } 97 | } 98 | }) 99 | } 100 | 101 | for range numIterations { 102 | for i := range numItems { 103 | q.Put(i) 104 | 105 | time.Sleep(time.Millisecond) 106 | } 107 | } 108 | 109 | // wait for the queue to be empty 110 | waitLoop: 111 | for { 112 | select { 113 | case <-time.After(time.Second): 114 | break waitLoop 115 | case item := <-q.Get(): 116 | item.Requeue(now) 117 | } 118 | } 119 | 120 | cancel() 121 | 122 | require.NoError(t, eg.Wait()) 123 | 124 | assert.Equal(t, int64(0), q.Len()) 125 | 126 | for i := range numItems { 127 | assert.GreaterOrEqual(t, tracker.processed[i], 50) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/timer/resettable.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package timer provides a resettable timer. 6 | package timer 7 | 8 | import "time" 9 | 10 | // ResettableTimer wraps time.Timer to allow resetting the timer to any duration. 11 | type ResettableTimer struct { 12 | timer *time.Timer 13 | } 14 | 15 | // Reset resets the timer to the given duration. 16 | // 17 | // If the duration is zero, the timer is removed (and stopped as needed). 18 | // If the duration is non-zero, the timer is created if it doesn't exist, or reset if it does. 19 | func (rt *ResettableTimer) Reset(delay time.Duration) { 20 | if delay == 0 { 21 | if rt.timer != nil { 22 | if !rt.timer.Stop() { 23 | <-rt.timer.C 24 | } 25 | 26 | rt.timer = nil 27 | } 28 | } else { 29 | if rt.timer == nil { 30 | rt.timer = time.NewTimer(delay) 31 | } else { 32 | if !rt.timer.Stop() { 33 | <-rt.timer.C 34 | } 35 | 36 | rt.timer.Reset(delay) 37 | } 38 | } 39 | } 40 | 41 | // Clear should be called after receiving from the timer channel. 42 | func (rt *ResettableTimer) Clear() { 43 | rt.timer = nil 44 | } 45 | 46 | // C returns the timer channel. 47 | // 48 | // If the timer was not reset to a non-zero duration, nil is returned. 49 | func (rt *ResettableTimer) C() <-chan time.Time { 50 | if rt.timer == nil { 51 | return nil 52 | } 53 | 54 | return rt.timer.C 55 | } 56 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/internal/timer/resettable_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package timer_test 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/qruntime/internal/timer" 14 | ) 15 | 16 | func TestResettableTimer(t *testing.T) { 17 | var tmr timer.ResettableTimer 18 | 19 | assert.Nil(t, tmr.C()) 20 | 21 | tmr.Reset(0) 22 | 23 | assert.Nil(t, tmr.C()) 24 | 25 | tmr.Reset(time.Millisecond) 26 | 27 | assert.NotNil(t, tmr.C()) 28 | <-tmr.C() 29 | 30 | tmr.Clear() 31 | 32 | assert.Nil(t, tmr.C()) 33 | 34 | tmr.Reset(time.Hour) 35 | tmr.Reset(time.Millisecond) 36 | 37 | <-tmr.C() 38 | 39 | tmr.Clear() 40 | 41 | tmr.Reset(time.Millisecond) 42 | 43 | time.Sleep(2 * time.Millisecond) 44 | 45 | tmr.Reset(0) 46 | 47 | tmr.Reset(time.Millisecond) 48 | 49 | time.Sleep(2 * time.Millisecond) 50 | 51 | tmr.Reset(time.Millisecond) 52 | 53 | <-tmr.C() 54 | 55 | tmr.Clear() 56 | } 57 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/qitem.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package qruntime 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/reduced" 9 | "github.com/cosi-project/runtime/pkg/resource" 10 | ) 11 | 12 | // QJob is a job to be executed by the reconcile queue. 13 | type QJob int 14 | 15 | // QJob constants. 16 | const ( 17 | QJobReconcile QJob = iota 18 | QJobMap 19 | ) 20 | 21 | func (job QJob) String() string { 22 | switch job { 23 | case QJobReconcile: 24 | return "reconcile" 25 | case QJobMap: 26 | return "map" 27 | default: 28 | return "unknown" 29 | } 30 | } 31 | 32 | // NewQItem creates a new QItem. 33 | func NewQItem(md resource.Pointer, job QJob) QItem { 34 | return QItem{ 35 | namespace: md.Namespace(), 36 | typ: md.Type(), 37 | id: md.ID(), 38 | job: job, 39 | } 40 | } 41 | 42 | // NewQItemFromReduced creates a new QItem from a reduced Metadata. 43 | func NewQItemFromReduced(md *reduced.Metadata, job QJob) QItem { 44 | return QItem{ 45 | namespace: md.Namespace, 46 | typ: md.Typ, 47 | id: md.ID, 48 | job: job, 49 | } 50 | } 51 | 52 | // QItem is stored in the reconcile queue. 53 | type QItem struct { 54 | namespace resource.Namespace 55 | typ resource.Type 56 | id resource.ID 57 | 58 | job QJob 59 | } 60 | 61 | // Namespace implements resource.Pointer interface. 62 | func (item QItem) Namespace() resource.Namespace { 63 | return item.namespace 64 | } 65 | 66 | // Type implements resource.Pointer interface. 67 | func (item QItem) Type() resource.Type { 68 | return item.typ 69 | } 70 | 71 | // ID implements resource.Pointer interface. 72 | func (item QItem) ID() resource.ID { 73 | return item.id 74 | } 75 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/qruntime/watch.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package qruntime 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/controller" 9 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/reduced" 10 | ) 11 | 12 | // WatchTrigger is called by common controller runtime when there is a change in the watched resources. 13 | func (adapter *Adapter) WatchTrigger(md *reduced.Metadata) { 14 | // figure out the type: primary or mapped, and queue accordingly 15 | for _, in := range adapter.Inputs { 16 | if in.Namespace == md.Namespace && in.Type == md.Typ { 17 | switch in.Kind { 18 | case controller.InputQPrimary: 19 | adapter.queue.Put(NewQItemFromReduced(md, QJobReconcile)) 20 | case controller.InputQMapped: 21 | adapter.queue.Put(NewQItemFromReduced(md, QJobMap)) 22 | case controller.InputQMappedDestroyReady: 23 | if reduced.FilterDestroyReady(md) { 24 | adapter.queue.Put(NewQItemFromReduced(md, QJobMap)) 25 | } 26 | } 27 | } 28 | } 29 | 30 | if adapter.queueLenExpVar != nil { 31 | adapter.queueLenExpVar.Set(adapter.queue.Len()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/reduced/filter.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package reduced 6 | 7 | import "github.com/cosi-project/runtime/pkg/resource" 8 | 9 | // WatchFilter filters watches on reduced Metadata. 10 | type WatchFilter func(*Metadata) bool 11 | 12 | // FilterDestroyReady returns true if the Metadata is ready to be destroyed. 13 | func FilterDestroyReady(md *Metadata) bool { 14 | return md.Phase == resource.PhaseTearingDown && md.FinalizersEmpty 15 | } 16 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/reduced/reduced.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package reduced implements reducing resource metadata to a comparable value. 6 | package reduced 7 | 8 | import "github.com/cosi-project/runtime/pkg/resource" 9 | 10 | // Metadata is _comparable_, so that it can be a map key. 11 | type Metadata struct { 12 | Namespace resource.Namespace 13 | Typ resource.Type 14 | ID resource.ID 15 | Phase resource.Phase 16 | FinalizersEmpty bool 17 | } 18 | 19 | // NewMetadata creates a new reduced Metadata from a resource.Metadata. 20 | func NewMetadata(md *resource.Metadata) Metadata { 21 | return Metadata{ 22 | Namespace: md.Namespace(), 23 | Typ: md.Type(), 24 | ID: md.ID(), 25 | Phase: md.Phase(), 26 | FinalizersEmpty: md.Finalizers().Empty(), 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/rruntime/output_tracker.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rruntime 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/siderolabs/gen/pair/ordered" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource" 14 | ) 15 | 16 | type outputTrackingID = ordered.Triple[resource.Namespace, resource.Type, resource.ID] 17 | 18 | func makeOutputTrackingID(md *resource.Metadata) outputTrackingID { 19 | return ordered.MakeTriple(md.Namespace(), md.Type(), md.ID()) 20 | } 21 | 22 | // StartTrackingOutputs enables output tracking for the controller. 23 | func (adapter *Adapter) StartTrackingOutputs() { 24 | if adapter.outputTracker != nil { 25 | panic("output tracking already enabled") 26 | } 27 | 28 | adapter.outputTracker = trackingPoolInstance.Get() 29 | } 30 | 31 | // CleanupOutputs destroys all output resources that were not tracked. 32 | func (adapter *Adapter) CleanupOutputs(ctx context.Context, outputs ...resource.Kind) error { 33 | if adapter.outputTracker == nil { 34 | panic("output tracking not enabled") 35 | } 36 | 37 | for _, outputKind := range outputs { 38 | list, err := adapter.List(ctx, outputKind) 39 | if err != nil { 40 | return fmt.Errorf("error listing output resources: %w", err) 41 | } 42 | 43 | for _, resource := range list.Items { 44 | if resource.Metadata().Owner() != adapter.Name { 45 | // skip resources not owned by this controller 46 | continue 47 | } 48 | 49 | trackingID := makeOutputTrackingID(resource.Metadata()) 50 | 51 | if _, touched := adapter.outputTracker[trackingID]; touched { 52 | // skip touched resources 53 | continue 54 | } 55 | 56 | if err = adapter.Destroy(ctx, resource.Metadata()); err != nil { 57 | return fmt.Errorf("error destroying resource %s: %w", resource.Metadata(), err) 58 | } 59 | } 60 | } 61 | 62 | trackingPoolInstance.Put(adapter.outputTracker) 63 | adapter.outputTracker = nil 64 | 65 | adapter.ResetRestartBackoff() 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/rruntime/run.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rruntime 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "fmt" 11 | "runtime/debug" 12 | "time" 13 | 14 | "go.uber.org/zap" 15 | 16 | "github.com/cosi-project/runtime/pkg/controller/runtime/metrics" 17 | "github.com/cosi-project/runtime/pkg/logging" 18 | ) 19 | 20 | // Run the controller loop via the adapter. 21 | func (adapter *Adapter) Run(ctx context.Context) { 22 | logger := adapter.logger.With(logging.Controller(adapter.Name)) 23 | 24 | for { 25 | err := adapter.runOnce(ctx, logger) 26 | if err == nil { 27 | return 28 | } 29 | 30 | if adapter.runtimeOptions.MetricsEnabled { 31 | metrics.ControllerCrashes.Add(adapter.Name, 1) 32 | } 33 | 34 | interval := adapter.backoff.NextBackOff() 35 | 36 | logger.Sugar().Debugf("restarting controller in %s", interval) 37 | 38 | select { 39 | case <-ctx.Done(): 40 | return 41 | case <-time.After(interval): 42 | } 43 | 44 | // schedule reconcile after restart 45 | adapter.triggerReconcile() 46 | } 47 | } 48 | 49 | func (adapter *Adapter) runOnce(ctx context.Context, logger *zap.Logger) (err error) { 50 | defer func() { 51 | if err != nil && errors.Is(err, context.Canceled) { 52 | err = nil 53 | } 54 | 55 | if err != nil { 56 | logger.Error("controller failed", zap.Error(err)) 57 | } else { 58 | logger.Debug("controller finished") 59 | } 60 | 61 | // clean up output tracker on any exit from Run method 62 | adapter.outputTracker = nil 63 | }() 64 | 65 | defer func() { 66 | if p := recover(); p != nil { 67 | err = fmt.Errorf("controller %q panicked: %s\n\n%s", adapter.Name, p, string(debug.Stack())) 68 | } 69 | }() 70 | 71 | logger.Debug("controller starting") 72 | 73 | return adapter.ctrl.Run(ctx, adapter, logger) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/rruntime/state.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rruntime 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cosi-project/runtime/pkg/controller" 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | ) 13 | 14 | // Create augments StateAdapter Create with output tracking. 15 | func (adapter *Adapter) Create(ctx context.Context, r resource.Resource) error { 16 | err := adapter.StateAdapter.Create(ctx, r) 17 | 18 | if adapter.outputTracker != nil { 19 | adapter.outputTracker[makeOutputTrackingID(r.Metadata())] = struct{}{} 20 | } 21 | 22 | return err 23 | } 24 | 25 | // Update augments StateAdapter Update with output tracking. 26 | func (adapter *Adapter) Update(ctx context.Context, newResource resource.Resource) error { 27 | err := adapter.StateAdapter.Update(ctx, newResource) 28 | 29 | if adapter.outputTracker != nil { 30 | adapter.outputTracker[makeOutputTrackingID(newResource.Metadata())] = struct{}{} 31 | } 32 | 33 | return err 34 | } 35 | 36 | // Modify augments StateAdapter Modify with output tracking. 37 | func (adapter *Adapter) Modify(ctx context.Context, emptyResource resource.Resource, updateFunc func(resource.Resource) error, options ...controller.ModifyOption) error { 38 | err := adapter.StateAdapter.Modify(ctx, emptyResource, updateFunc, options...) 39 | 40 | if adapter.outputTracker != nil { 41 | adapter.outputTracker[makeOutputTrackingID(emptyResource.Metadata())] = struct{}{} 42 | } 43 | 44 | return err 45 | } 46 | 47 | // ModifyWithResult augments StateAdapter ModifyWithResult with output tracking. 48 | func (adapter *Adapter) ModifyWithResult( 49 | ctx context.Context, emptyResource resource.Resource, updateFunc func(resource.Resource) error, options ...controller.ModifyOption, 50 | ) (resource.Resource, error) { 51 | result, err := adapter.StateAdapter.ModifyWithResult(ctx, emptyResource, updateFunc, options...) 52 | 53 | if adapter.outputTracker != nil { 54 | adapter.outputTracker[makeOutputTrackingID(emptyResource.Metadata())] = struct{}{} 55 | } 56 | 57 | return result, err 58 | } 59 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/rruntime/tracking_pool.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rruntime 6 | 7 | import "sync" 8 | 9 | type trackingOutputPool struct { 10 | pool sync.Pool 11 | } 12 | 13 | var trackingPoolInstance trackingOutputPool 14 | 15 | func (mp *trackingOutputPool) Get() map[outputTrackingID]struct{} { 16 | val := mp.pool.Get() 17 | if val, ok := val.(map[outputTrackingID]struct{}); ok { 18 | clear(val) 19 | 20 | return val 21 | } 22 | 23 | return map[outputTrackingID]struct{}{} 24 | } 25 | 26 | func (mp *trackingOutputPool) Put(x map[outputTrackingID]struct{}) { 27 | mp.pool.Put(x) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/controller/runtime/internal/rruntime/watch.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rruntime 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/controller" 9 | "github.com/cosi-project/runtime/pkg/controller/runtime/internal/reduced" 10 | "github.com/cosi-project/runtime/pkg/controller/runtime/metrics" 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | ) 13 | 14 | type watchKey struct { 15 | Namespace resource.Namespace 16 | Type resource.Type 17 | } 18 | 19 | func (adapter *Adapter) addWatchFilter(resourceNamespace resource.Namespace, resourceType resource.Type, filter reduced.WatchFilter) { 20 | adapter.watchFilterMu.Lock() 21 | defer adapter.watchFilterMu.Unlock() 22 | 23 | if adapter.watchFilters == nil { 24 | adapter.watchFilters = make(map[watchKey]reduced.WatchFilter) 25 | } 26 | 27 | adapter.watchFilters[watchKey{resourceNamespace, resourceType}] = filter 28 | } 29 | 30 | func (adapter *Adapter) deleteWatchFilter(resourceNamespace resource.Namespace, resourceType resource.Type) { 31 | adapter.watchFilterMu.Lock() 32 | defer adapter.watchFilterMu.Unlock() 33 | 34 | delete(adapter.watchFilters, watchKey{resourceNamespace, resourceType}) 35 | } 36 | 37 | // WatchTrigger is called by common controller runtime when there is a change in the watched resources. 38 | func (adapter *Adapter) WatchTrigger(md *reduced.Metadata) { 39 | adapter.watchFilterMu.Lock() 40 | defer adapter.watchFilterMu.Unlock() 41 | 42 | if adapter.watchFilters != nil { 43 | if filter := adapter.watchFilters[watchKey{md.Namespace, md.Typ}]; filter != nil && !filter(md) { 44 | // skip reconcile if the event doesn't match the filter 45 | return 46 | } 47 | } 48 | 49 | adapter.triggerReconcile() 50 | } 51 | 52 | func (adapter *Adapter) triggerReconcile() { 53 | // schedule reconcile if channel is empty 54 | // otherwise channel is not empty, and reconcile is anyway scheduled 55 | select { 56 | case adapter.ch <- controller.ReconcileEvent{}: 57 | if adapter.runtimeOptions.MetricsEnabled { 58 | metrics.ControllerWakeups.Add(adapter.Name, 1) 59 | } 60 | 61 | default: 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/controller/runtime/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package metrics expose various controller runtime metrics using expvar. 6 | package metrics 7 | 8 | import ( 9 | "expvar" 10 | ) 11 | 12 | var ( 13 | // ControllerCrashes counts the number of crashes per Controller. 14 | ControllerCrashes = expvar.NewMap("controller_crashes") 15 | 16 | // ControllerWakeups counts the number of wakeups per Controller. 17 | ControllerWakeups = expvar.NewMap("controller_wakeups") 18 | 19 | // ControllerReads counts the number of reads per controller (both Controller and QController). 20 | // 21 | // Each call to controller.Reader is counted as a single read. 22 | ControllerReads = expvar.NewMap("controller_reads") 23 | 24 | // ControllerWrites counts the number of writes per controller (both Controller and QController). 25 | // 26 | // Each call to controller.Writer is counted as a single write. 27 | ControllerWrites = expvar.NewMap("controller_writes") 28 | 29 | // QControllerCrashes counts the number of crashes per QController. 30 | QControllerCrashes = expvar.NewMap("qcontroller_crashes") 31 | 32 | // QControllerRequeues counts the number of requeue events per QController. 33 | QControllerRequeues = expvar.NewMap("qcontroller_requeues") 34 | 35 | // QControllerProcessed counts the number of processed reconcile events per QController. 36 | QControllerProcessed = expvar.NewMap("qcontroller_processed") 37 | 38 | // QControllerMappedIn counts the number of map events per QController. 39 | QControllerMappedIn = expvar.NewMap("qcontroller_mapped_in") 40 | 41 | // QControllerMappedOut counts the number outputs for map events per QController. 42 | QControllerMappedOut = expvar.NewMap("qcontroller_mapped_out") 43 | 44 | // QControllerQueueLength reports the outstanding queue length per QController (both map and reconcile events). 45 | QControllerQueueLength = expvar.NewMap("qcontroller_queue_length") 46 | 47 | // QControllerMapBusy reports the number of seconds QController was busy processing map events. 48 | QControllerMapBusy = expvar.NewMap("qcontroller_map_busy") 49 | 50 | // QControllerReconcileBusy reports the number of seconds QController was busy processing reconcile events. 51 | QControllerReconcileBusy = expvar.NewMap("qcontroller_reconcile_busy") 52 | 53 | // CachedResources reports the number of cached resources per resource type. 54 | CachedResources = expvar.NewMap("cached_resources") 55 | ) 56 | -------------------------------------------------------------------------------- /pkg/controller/runtime/metrics/state.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package metrics 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cosi-project/runtime/pkg/resource" 11 | "github.com/cosi-project/runtime/pkg/state" 12 | ) 13 | 14 | type metricsWrapper struct { 15 | innerState state.CoreState 16 | controllerName string 17 | } 18 | 19 | func (m *metricsWrapper) Get(ctx context.Context, pointer resource.Pointer, option ...state.GetOption) (resource.Resource, error) { 20 | ControllerReads.Add(m.controllerName, 1) 21 | 22 | return m.innerState.Get(ctx, pointer, option...) 23 | } 24 | 25 | func (m *metricsWrapper) List(ctx context.Context, kind resource.Kind, option ...state.ListOption) (resource.List, error) { 26 | ControllerReads.Add(m.controllerName, 1) 27 | 28 | return m.innerState.List(ctx, kind, option...) 29 | } 30 | 31 | func (m *metricsWrapper) Create(ctx context.Context, resource resource.Resource, option ...state.CreateOption) error { 32 | ControllerWrites.Add(m.controllerName, 1) 33 | 34 | return m.innerState.Create(ctx, resource, option...) 35 | } 36 | 37 | func (m *metricsWrapper) Update(ctx context.Context, newResource resource.Resource, opts ...state.UpdateOption) error { 38 | ControllerWrites.Add(m.controllerName, 1) 39 | 40 | return m.innerState.Update(ctx, newResource, opts...) 41 | } 42 | 43 | func (m *metricsWrapper) Destroy(ctx context.Context, pointer resource.Pointer, option ...state.DestroyOption) error { 44 | ControllerWrites.Add(m.controllerName, 1) 45 | 46 | return m.innerState.Destroy(ctx, pointer, option...) 47 | } 48 | 49 | func (m *metricsWrapper) Watch(ctx context.Context, pointer resource.Pointer, events chan<- state.Event, option ...state.WatchOption) error { 50 | ControllerReads.Add(m.controllerName, 1) 51 | 52 | return m.innerState.Watch(ctx, pointer, events, option...) 53 | } 54 | 55 | func (m *metricsWrapper) WatchKind(ctx context.Context, kind resource.Kind, events chan<- state.Event, option ...state.WatchKindOption) error { 56 | ControllerReads.Add(m.controllerName, 1) 57 | 58 | return m.innerState.WatchKind(ctx, kind, events, option...) 59 | } 60 | 61 | func (m *metricsWrapper) WatchKindAggregated(ctx context.Context, kind resource.Kind, c chan<- []state.Event, option ...state.WatchKindOption) error { 62 | ControllerReads.Add(m.controllerName, 1) 63 | 64 | return m.innerState.WatchKindAggregated(ctx, kind, c, option...) 65 | } 66 | 67 | // WrapState wraps state.State with metrics for the given controller name. 68 | func WrapState(controllerName string, st state.State) state.State { 69 | return state.WrapCore(&metricsWrapper{ 70 | controllerName: controllerName, 71 | innerState: st, 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/controller/runtime/options/options.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package options provides functional options for controller runtime. 6 | package options 7 | 8 | import ( 9 | "golang.org/x/time/rate" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | ) 13 | 14 | // Options configures controller runtime. 15 | type Options struct { 16 | // CachedResources is a list of resources that should be cached by controller runtime. 17 | CachedResources []CachedResource 18 | // ChangeRateLimit and ChangeBurst configure rate limiting of changes performed by controllers. 19 | ChangeRateLimit rate.Limit 20 | ChangeBurst int 21 | // MetricsEnabled enables runtime metrics to be exposed via metrics package. 22 | MetricsEnabled bool 23 | // WarnOnUncachedReads adds a warning log when a controller reads an uncached resource. 24 | WarnOnUncachedReads bool 25 | } 26 | 27 | // CachedResource is a resource that should be cached by controller runtime. 28 | type CachedResource struct { 29 | Namespace resource.Namespace 30 | Type resource.Type 31 | } 32 | 33 | // Option is a functional option for controller runtime. 34 | type Option func(*Options) 35 | 36 | // WithChangeRateLimit sets rate limit for changes performed by controllers. 37 | // 38 | // This might be used to rate limit ill-behaving controllers from overloading the system with changes. 39 | func WithChangeRateLimit(limit rate.Limit, burst int) Option { 40 | return func(options *Options) { 41 | options.ChangeRateLimit = limit 42 | options.ChangeBurst = burst 43 | } 44 | } 45 | 46 | // WithMetrics enables runtime metrics to be exposed via metrics package. 47 | func WithMetrics(enabled bool) Option { 48 | return func(options *Options) { 49 | options.MetricsEnabled = enabled 50 | } 51 | } 52 | 53 | // WithCachedResource adds a resource to the list of resources that should be cached by controller runtime. 54 | func WithCachedResource(namespace resource.Namespace, typ resource.Type) Option { 55 | return func(options *Options) { 56 | options.CachedResources = append(options.CachedResources, CachedResource{ 57 | Namespace: namespace, 58 | Type: typ, 59 | }) 60 | } 61 | } 62 | 63 | // WithWarnOnUncachedReads adds a warning log when a controller reads an uncached resource. 64 | func WithWarnOnUncachedReads(warn bool) Option { 65 | return func(options *Options) { 66 | options.WarnOnUncachedReads = warn 67 | } 68 | } 69 | 70 | // DefaultOptions returns default value of Options. 71 | func DefaultOptions() Options { 72 | return Options{ 73 | ChangeRateLimit: rate.Inf, 74 | ChangeBurst: 0, 75 | MetricsEnabled: true, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/future/future.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package future provides a set of functions for observing the state of a running program. 6 | package future 7 | 8 | import "context" 9 | 10 | // GoContext runs a function in a goroutine and returns a channel that will receive the result. It will close the 11 | // channel and cancel the context when the function returns. 12 | func GoContext[T any](ctx context.Context, fn func(context.Context) T) (context.Context, <-chan T) { 13 | ctx, cancel := context.WithCancel(ctx) 14 | ch := make(chan T, 1) 15 | 16 | go func() { 17 | defer cancel() 18 | defer close(ch) 19 | 20 | ch <- fn(ctx) 21 | }() 22 | 23 | return ctx, ch 24 | } 25 | 26 | // Go runs a function in a goroutine and returns a channel that will receive the result. 27 | // It will close the channel when the function returns. 28 | func Go[T any](fn func() T) <-chan T { 29 | ch := make(chan T, 1) 30 | 31 | go func() { 32 | defer close(ch) 33 | 34 | ch <- fn() 35 | }() 36 | 37 | return ch 38 | } 39 | -------------------------------------------------------------------------------- /pkg/future/future_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package future_test 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/cosi-project/runtime/pkg/future" 14 | ) 15 | 16 | func TestGo(t *testing.T) { 17 | t.Parallel() 18 | 19 | ctx, res := future.GoContext(t.Context(), func(context.Context) int { 20 | return 42 21 | }) 22 | 23 | <-ctx.Done() 24 | assert.Equal(t, 42, <-res) 25 | 26 | ctx, cancel := context.WithCancel(t.Context()) 27 | defer cancel() 28 | 29 | type result struct { 30 | err error 31 | value int 32 | } 33 | 34 | _, newRes := future.GoContext(ctx, func(ctx context.Context) result { 35 | <-ctx.Done() 36 | 37 | if ctx.Err() != nil { 38 | return result{err: ctx.Err()} 39 | } 40 | 41 | return result{value: 42} 42 | }) 43 | 44 | cancel() 45 | assert.Equal(t, result{err: context.Canceled}, <-newRes) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/keystorage/testdata/public.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | Version: GopenPGP 2.4.10 3 | Comment: https://gopenpgp.org 4 | 5 | xsFNBGMscC4BEAC76m7SzaTLyTdhA1wChiwSZrQGywcmvG5S8hKAQLqTdULzJpnw 6 | uPH9stsPPvDKCJpxx5n6OwL/vDiPj7sslLJtDi3jhexmvWBEgYovU3BY4Udxozmm 7 | jVb3KdMV5PvZ0zH5UNWEeUm30FLfsnBVctLKcCNgg0jTlBn8x/cWSa2OtJky7o3K 8 | 5Z3ahUnN7uWZ5gtrr5OPEFiNU4+4ZZGKvr3KqQOvoHa4V7ciPJ/rJZBNizY0TiXI 9 | 5YFSZ4B/k3tWWQSiJQX9fWTPugYGmSZfLqvB71VI5Wz6Dor4IfGIWKHBfhHO4NPh 10 | 9VXr9+SQki0jORhpg+4eZhLoFYV8R4kSDQplCC/+DP5zDYEclGYliQQTGON1UqL6 11 | 0XJrWPsLWc3ZxqKZVvhlzdNBME1SrRYGC/FETynuLG/W84tZxMnt/MX4dAuNx0WT 12 | SAVCzuG7igXBSp1xxzZfxV6SdwDsuVxrNTz3pIdckusnOHA084zmUdQu0cvUJ5Os 13 | 9h/ih9gwfnESIt2+OZSWczSxCob+OxF9ZoGVITJQrLu2KDbi7ochmMojW/8iUarI 14 | w1CVtPthfw6LKvpx0nf+1hi7bRULr+/JgNvU7GLDqKS03z6jG4dqUU4/9wwxYq5R 15 | dFOZ0hd0qc5QKhiSWx5+WxPc5HmuZe6+vE1axesV+FC5Q0QnUe7mKE7fWwARAQAB 16 | zR9zaWRlcm8gPGRlZmF1bHRAc2lkZXJvbGFicy5jb20+wsGKBBMBCAA+BQJjLHAu 17 | CZCau9Ss/e4cRhYhBBsHV7z1hfc18NW4lZq71Kz97hxGAhsDAh4BAhkBAwsJBwIV 18 | CAMWAAICIgEAAG7fD/0clE1AFKSjiT+iesTCR1s1UEGGnpsBpFRdyTEUQzqO2EQs 19 | 5btztglcmuOSuKVsoeJ2QDYFUTgEKoyawCh2T45oBw11z8ylLuH/3O8i1NUv9icw 20 | 6oW+QxbpQovoH6RzX/XrfodCONdtpoZ/UQxbf6PR5jOtTjU3vBiuHlnCTGdXVpv8 21 | MdTpPmqYPL3NG5Pc7aIc3MrxtHZD4g9SszW9UiCtgZ0+7WfMLYD1FL2rLsbaPDge 22 | jzscMlvSuwjwTyFpB8YRHXMIEeY9fVLsoadgO3Pw1gnHpJP6bBkmrK5aYShkp2eM 23 | BR8Td3UIhYV7tom73F3wBHZq52oRubdckyRmUx7JADS0QjIVAjIDC3bMlNMcnTW3 24 | XxSe6RNiexyD6ngnO227U6wtO8nAsqDWlZCnP5QzANI1Zu+8tyuXzPh0V67Vy5Hd 25 | QvK30pCICMkkVDfLYcjdYv1Yfv3zk+YOT0ID6qN8YIrtdAEmZzEYW/jywzRBTVhe 26 | 0S1MBEgbz09uue188Ub8DGMhjmP07kTyhW67ATtPzcTZn/bLSns3SG/We+s85X32 27 | qv9tAbYPftk9XiFydTImZYnXu6OsDtI0heJ29sHJslC35nbqPOBPFOPMPrzHndOE 28 | aRc7Mg1sOlIzSZvDqZbLdh6CbJPQt5Vw/5F+x9hTJlV/qNk0SIUVQ+yK0w0NCc7B 29 | TQRjLHAuARAA1zYZ0AMsGbi24I6lY5HNAEDfMs+tx9D6JsI2merwYwucuAlaWaWS 30 | Opnb9kWpNwDsvQjSQmaur2qGtDw+xLc3ZCjMNp+Rl6oCwMksy0pCgLWWt9AI2Yjt 31 | V/7tzjZTWgkNuGMeKSQdV1jEoHKQ1FU250rl/4i0JWbelW59Dbh6nTjMqeaO2WtH 32 | sa7ec9YqvFolzYMegH1urSNpPS70bzkI7u3oIyL1ZMQHn585U9yhqbGELf3gH33q 33 | BdZOC/CncOEFabu1mbYupszl64Ua/c3RIYWpv1kmoPfyVXZJWH+bJ0fav+mXD6RQ 34 | F/pwzLDl8ysSH37a7RYQ6h50dguHisNVJ0MYXosc/1KjYjCIxET/AHYFFQ3KVaeH 35 | JjGjMtUNF3aiaqO2PvjIN0icx2z6FQwqOHocRa4OCCdH9UgO5bApRMOisNXq59bF 36 | m9dOOmvtgkYqDEHB5VN8UR2i3XncZga89CofBoLpncl+78AtJYuH37UpwT8VG1Z5 37 | qB6Kr4Cs0E+GswsOrNMpr5sAftqekGrO72PxestpFIWV11jsf3FkxYqMTiChLO52 38 | /ttAjg76BnMJlmVEgMiYPkG7tv3qwC+I+gf+1wYKMMURSY8V0BFlXF+jM9aM0mXE 39 | aWymLAdYOtbrR3Fpvk057HLQ7oGnlMmK6WtjhAGxbn7Cy6wHmkUoGqcAEQEAAcLB 40 | dgQYAQgAKgUCYyxwLgmQmrvUrP3uHEYWIQQbB1e89YX3NfDVuJWau9Ss/e4cRgIb 41 | DAAA8xMP/jnqUgFL+Uduvt4QhHWoC4jFuJCpzy/gdbvn3m95DYx/sJwTyqu1zovW 42 | VGudX9LkiQfWRWfuFRS9W6oX86nCH5/iXjR0xpSLv+1tqz87Dj8ngMJH9+9dQZpE 43 | WvHoQ3va/d9mudPfHxMPe81FLWaoiYU9QB4S1rErSxwzVROuhWeaXwvORhUfwx6L 44 | W0rHMRaFAD8ULwFJGEhT+zik5BLzmlvZhMcQeu3tX0AV4q7S/iIw2BXwBf3lpaan 45 | /RFAboQN7k6NZwx4MGiGCkx5XuHu2IEiT6Vd4E5whxkY3vYtktK438+kVfPrEiFQ 46 | /iODER0j4TKMMqWEDlnPX0Gs2kCyIstbXCjbbI91UTPGy6oYv4iLMjTUnsyWAPa3 47 | RoRFWln0rzyBBdmrOckaKEhjAuL17Pdq7xfARateCSHcCa3gFJUEPcMwVk9Iakdv 48 | 6hgA4mctok9yG5e/cnoz1tje0y0nnVm3x059ILhGKHfj8pk/mwD9th6New8JD7Xg 49 | RuvYaaK5qcUf3SbT7cuiwC/tcYg1VsGtpLsIwLtQFR+gXaU5SLy9q+LToxm4WqOP 50 | 1PmaxQGxw6p3oswwAY7BCtLGiYIH5Bm8Q2L7lhq70FZoFKzpMVRyU7hmzRPJEJwx 51 | 2golOjh3qA1R94wl8dYBNLJvGL0xH5dlaBwqrezm4h+lGE9o01wX 52 | =3arf 53 | -----END PGP PUBLIC KEY BLOCK----- 54 | -------------------------------------------------------------------------------- /pkg/logging/logging.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package logging defines logging helpers. 6 | package logging 7 | 8 | import ( 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | ) 12 | 13 | // Controller creates controller zap field. 14 | func Controller(name string) zap.Field { 15 | return zap.String("controller", name) 16 | } 17 | 18 | // DefaultLogger creates default logger. 19 | func DefaultLogger() *zap.Logger { 20 | config := zap.NewDevelopmentConfig() 21 | config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 22 | logger, _ := config.Build() //nolint:errcheck 23 | 24 | return logger.With(zap.String("component", "controller-runtime")) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/resource/annotations.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource/internal/kv" 9 | ) 10 | 11 | // Annotations is a set free-form of key-value pairs. 12 | // 13 | // Order of keys is not guaranteed. 14 | // 15 | // Annotations support copy-on-write semantics, so metadata copies share common Annotations as long as possible. 16 | type Annotations struct { 17 | kv.KV 18 | } 19 | 20 | // Equal checks Annotations for equality. 21 | func (annotations Annotations) Equal(other Annotations) bool { 22 | return annotations.KV.Equal(other.KV) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/resource/annotations_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | ) 14 | 15 | func TestAnnotations(t *testing.T) { 16 | var annotations resource.Annotations 17 | 18 | assert.True(t, annotations.Empty()) 19 | 20 | annotations.Set("a", "b") 21 | assert.False(t, annotations.Empty()) 22 | 23 | v, ok := annotations.Get("a") 24 | assert.True(t, ok) 25 | assert.Equal(t, "b", v) 26 | 27 | annotationsCopy := annotations 28 | annotations.Set("c", "d") 29 | 30 | assert.False(t, annotations.Equal(annotationsCopy)) 31 | 32 | v, ok = annotations.Get("c") 33 | assert.True(t, ok) 34 | assert.Equal(t, "d", v) 35 | 36 | _, ok = annotationsCopy.Get("c") 37 | assert.False(t, ok) 38 | 39 | annotationsCopy2 := annotations 40 | annotationsCopy2.Set("a", "bb") 41 | assert.False(t, annotations.Equal(annotationsCopy2)) 42 | 43 | annotationsCopy3 := annotations 44 | assert.True(t, annotations.Equal(annotationsCopy3)) 45 | 46 | annotationsCopy3.Set("a", "b") 47 | assert.True(t, annotations.Equal(annotationsCopy3)) 48 | 49 | annotationsCopy3.Delete("d") 50 | assert.True(t, annotations.Equal(annotationsCopy3)) 51 | 52 | annotationsCopy3.Delete("a") 53 | assert.False(t, annotations.Equal(annotationsCopy3)) 54 | 55 | _, ok = annotationsCopy3.Get("a") 56 | assert.False(t, ok) 57 | 58 | _, ok = annotations.Get("a") 59 | assert.True(t, ok) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/resource/any.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import ( 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // Any can hold data from any resource type. 12 | type Any struct { 13 | spec anySpec 14 | md Metadata 15 | } 16 | 17 | type anySpec struct { 18 | value any 19 | yaml []byte 20 | } 21 | 22 | // MarshalYAMLBytes implements RawYAML interface. 23 | func (s anySpec) MarshalYAMLBytes() ([]byte, error) { 24 | return s.yaml, nil 25 | } 26 | 27 | // SpecProto is a protobuf interface of resource spec. 28 | type SpecProto interface { 29 | GetYaml() []byte 30 | } 31 | 32 | // NewAnyFromProto unmarshals Any from protobuf interface. 33 | func NewAnyFromProto(protoMd MetadataProto, protoSpec SpecProto) (*Any, error) { 34 | md, err := NewMetadataFromProto(protoMd) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | result := &Any{ 40 | md: md, 41 | spec: anySpec{ 42 | yaml: protoSpec.GetYaml(), 43 | }, 44 | } 45 | 46 | if err = yaml.Unmarshal(result.spec.yaml, &result.spec.value); err != nil { 47 | return nil, err 48 | } 49 | 50 | return result, nil 51 | } 52 | 53 | // Metadata implements resource.Resource. 54 | func (a *Any) Metadata() *Metadata { 55 | return &a.md 56 | } 57 | 58 | // Spec implements resource.Resource. 59 | func (a *Any) Spec() any { 60 | return a.spec 61 | } 62 | 63 | // Value returns decoded value as Go type. 64 | func (a *Any) Value() any { 65 | return a.spec.value 66 | } 67 | 68 | // DeepCopy implements resource.Resource. 69 | func (a *Any) DeepCopy() Resource { //nolint:ireturn 70 | return &Any{ 71 | md: a.md, 72 | spec: a.spec, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/resource/any_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource_test 6 | 7 | import ( 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "gopkg.in/yaml.v3" 13 | 14 | "github.com/cosi-project/runtime/pkg/resource" 15 | ) 16 | 17 | type protoSpec struct{} 18 | 19 | func (s *protoSpec) GetYaml() []byte { 20 | return []byte(`value: xyz 21 | something: [a, b, c] 22 | `) 23 | } 24 | 25 | func TestNewAnyFromProto(t *testing.T) { 26 | r, err := resource.NewAnyFromProto(&protoMd{}, &protoSpec{}) 27 | assert.NoError(t, err) 28 | 29 | assert.Equal(t, map[string]any{"something": []any{"a", "b", "c"}, "value": "xyz"}, r.Value()) 30 | assert.Equal(t, "aaa", r.Metadata().ID()) 31 | 32 | enc, err := resource.MarshalYAML(r) 33 | assert.NoError(t, err) 34 | 35 | out, err := yaml.Marshal(enc) 36 | assert.NoError(t, err) 37 | 38 | assert.Equal(t, strings.TrimSpace(` 39 | metadata: 40 | namespace: default 41 | type: type 42 | id: aaa 43 | version: 1 44 | owner: FooController 45 | phase: running 46 | created: 2021-06-23T19:22:29Z 47 | updated: 2021-06-23T19:22:29Z 48 | labels: 49 | app: foo 50 | stage: initial 51 | annotations: 52 | ttl: 1h 53 | finalizers: 54 | - resource1 55 | - resource2 56 | spec: 57 | value: xyz 58 | something: [a, b, c] 59 | `)+"\n", string(out)) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/resource/finalizer.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import "slices" 8 | 9 | // Finalizer is a free-form string which blocks resource destruction. 10 | // 11 | // Resource can't be destroyed until all the finalizers are cleared. 12 | type Finalizer = string 13 | 14 | // Finalizers is a set of Finalizer's with methods to add/remove items. 15 | // 16 | //nolint:recvcheck 17 | type Finalizers []Finalizer 18 | 19 | // Add a (unique) Finalizer to the set. 20 | func (fins *Finalizers) Add(fin Finalizer) bool { 21 | *fins = slices.Clone(*fins) 22 | 23 | if slices.Contains(*fins, fin) { 24 | return false 25 | } 26 | 27 | *fins = append(*fins, fin) 28 | 29 | return true 30 | } 31 | 32 | // Remove a (unique) Finalizer from the set. 33 | func (fins *Finalizers) Remove(fin Finalizer) bool { 34 | *fins = slices.Clone(*fins) 35 | 36 | for i, f := range *fins { 37 | if f == fin { 38 | *fins = append((*fins)[:i], (*fins)[i+1:]...) 39 | 40 | return true 41 | } 42 | } 43 | 44 | return false 45 | } 46 | 47 | // Empty returns true if list of finalizers is empty. 48 | func (fins Finalizers) Empty() bool { 49 | return len(fins) == 0 50 | } 51 | 52 | // Has returns true if fin is present in the list of finalizers. 53 | func (fins Finalizers) Has(fin Finalizer) bool { 54 | return slices.Contains(fins, fin) 55 | } 56 | 57 | // Set copies the finalizers from the other. 58 | func (fins *Finalizers) Set(other Finalizers) { 59 | *fins = slices.Clone(other) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/resource/finalizer_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | ) 14 | 15 | func TestFinalizers(t *testing.T) { 16 | const ( 17 | A resource.Finalizer = "A" 18 | B resource.Finalizer = "B" 19 | C resource.Finalizer = "C" 20 | ) 21 | 22 | var fins resource.Finalizers 23 | 24 | assert.True(t, fins.Empty()) 25 | 26 | assert.True(t, fins.Add(A)) 27 | 28 | finsCopy := fins 29 | 30 | assert.False(t, fins.Empty()) 31 | assert.False(t, finsCopy.Empty()) 32 | 33 | assert.True(t, fins.Add(B)) 34 | assert.False(t, fins.Add(B)) 35 | assert.True(t, fins.Has(B)) 36 | assert.False(t, fins.Has(C)) 37 | 38 | assert.True(t, finsCopy.Add(B)) 39 | 40 | assert.False(t, fins.Remove(C)) 41 | assert.True(t, fins.Remove(B)) 42 | assert.False(t, fins.Remove(B)) 43 | assert.False(t, fins.Has(B)) 44 | assert.False(t, fins.Has(C)) 45 | 46 | finsCopy = fins 47 | 48 | assert.True(t, finsCopy.Add(C)) 49 | assert.True(t, fins.Add(C)) 50 | assert.True(t, fins.Remove(C)) 51 | 52 | fins = nil 53 | 54 | finsCopy.Set(fins) 55 | assert.True(t, finsCopy.Empty()) 56 | assert.Nil(t, finsCopy) 57 | 58 | assert.True(t, fins.Add(A)) 59 | assert.True(t, fins.Add(C)) 60 | 61 | finsCopy.Set(fins) 62 | assert.True(t, finsCopy.Has(A)) 63 | assert.True(t, finsCopy.Has(C)) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/resource/handle/handle.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package handle provides a way to wrap "handle/descriptor-like" resources. That is, for this resource 6 | // any sort of unmarsahling is not possible, but the user should define a way to marshal one into the yaml 7 | // representation and can define equality checks. 8 | package handle 9 | 10 | import ( 11 | "errors" 12 | "reflect" 13 | 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | // Spec should be yaml.Marshaler. 18 | type Spec interface { 19 | yaml.Marshaler 20 | } 21 | 22 | // ResourceSpec wraps "handle-like" structures and adds DeepCopy and marshaling methods. 23 | type ResourceSpec[S Spec] struct { 24 | Value S 25 | } 26 | 27 | // MarshalYAML implements yaml.Marshaler interface. It calls MarshalYAML on the wrapped object. 28 | func (spec *ResourceSpec[S]) MarshalYAML() (any, error) { return spec.Value.MarshalYAML() } 29 | 30 | // DeepCopy implemenents DeepCopyable without actually copying the object sine there is no way to actually do this. 31 | func (spec ResourceSpec[S]) DeepCopy() ResourceSpec[S] { return spec } 32 | 33 | // MarshalJSON implements json.Marshaler. 34 | func (spec *ResourceSpec[S]) MarshalJSON() ([]byte, error) { 35 | return nil, errors.New("cannot marshal handle resource into the json") 36 | } 37 | 38 | // MarshalProto implements ProtoMarshaler. 39 | func (spec *ResourceSpec[S]) MarshalProto() ([]byte, error) { 40 | return nil, nil 41 | } 42 | 43 | // UnmarshalYAML implements yaml.Unmarshaler interface. Since we cannot unmarshal the object, we just return an error. 44 | func (spec *ResourceSpec[S]) UnmarshalYAML(*yaml.Node) error { 45 | return errors.New("cannot unmarshal handle resource from the yaml") 46 | } 47 | 48 | // UnmarshalJSON implements json.Unmarshaler. 49 | func (spec *ResourceSpec[S]) UnmarshalJSON([]byte) error { 50 | return errors.New("cannot unmarshal handle resource from the json") 51 | } 52 | 53 | // UnmarshalProto implements protobuf.ResourceUnmarshaler. 54 | func (spec *ResourceSpec[S]) UnmarshalProto([]byte) error { 55 | return errors.New("cannot unmarshal handle resource from the protobuf") 56 | } 57 | 58 | // Equal implements spec equality check. 59 | func (spec *ResourceSpec[S]) Equal(other any) bool { 60 | otherSpec, ok := other.(*ResourceSpec[S]) 61 | if !ok { 62 | return false 63 | } 64 | 65 | if isSamePtr(spec.Value, otherSpec.Value) { 66 | return true 67 | } 68 | 69 | eq, ok := any(spec.Value).(interface { 70 | Equal(other S) bool 71 | }) 72 | if !ok { 73 | return false 74 | } 75 | 76 | return eq.Equal(otherSpec.Value) 77 | } 78 | 79 | // equalPtr is equality check function for cases where S is a pointer. 80 | // 81 | // Starting from Go 1.21 [reflect.ValueOf] no longer escapes for most cases. 82 | func isSamePtr[S any](a, b S) bool { 83 | ar := reflect.ValueOf(a) 84 | 85 | if ar.Kind() != reflect.Pointer { 86 | // Not pointers so not equal. 87 | return false 88 | } 89 | 90 | // Point to the same location. 91 | return ar.Pointer() == reflect.ValueOf(b).Pointer() 92 | } 93 | -------------------------------------------------------------------------------- /pkg/resource/id_query.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import ( 8 | "regexp" 9 | ) 10 | 11 | // IDQuery is the query on the resource ID. 12 | type IDQuery struct { 13 | Regexp *regexp.Regexp 14 | } 15 | 16 | // Matches if the resource ID matches the ID query. 17 | func (query IDQuery) Matches(md Metadata) bool { 18 | if query.Regexp == nil { 19 | return true 20 | } 21 | 22 | return query.Regexp.MatchString(md.ID()) 23 | } 24 | 25 | // IDQueryOption allows to build an IDQuery with functional parameters. 26 | type IDQueryOption func(*IDQuery) 27 | 28 | // IDRegexpMatch checks that the ID matches the regexp. 29 | func IDRegexpMatch(re *regexp.Regexp) IDQueryOption { 30 | return func(q *IDQuery) { 31 | q.Regexp = re 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/resource/id_query_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource_test 6 | 7 | import ( 8 | "regexp" 9 | "testing" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | ) 13 | 14 | func TestIDQuery(t *testing.T) { 15 | t.Parallel() 16 | 17 | for _, test := range []struct { //nolint:govet 18 | name string 19 | opts []resource.IDQueryOption 20 | id string 21 | want bool 22 | }{ 23 | { 24 | name: "empty", 25 | opts: nil, 26 | id: "foo", 27 | want: true, 28 | }, 29 | { 30 | name: "match", 31 | opts: []resource.IDQueryOption{ 32 | resource.IDRegexpMatch(regexp.MustCompile("^first-")), 33 | }, 34 | id: "first-second", 35 | want: true, 36 | }, 37 | { 38 | name: "no match", 39 | opts: []resource.IDQueryOption{ 40 | resource.IDRegexpMatch(regexp.MustCompile("^first-")), 41 | }, 42 | id: "second-first-third", 43 | want: false, 44 | }, 45 | { 46 | name: "match middle", 47 | opts: []resource.IDQueryOption{ 48 | resource.IDRegexpMatch(regexp.MustCompile("first-")), 49 | }, 50 | id: "second-first-third", 51 | want: true, 52 | }, 53 | } { 54 | t.Run(test.name, func(t *testing.T) { 55 | t.Parallel() 56 | 57 | q := resource.IDQuery{} 58 | 59 | for _, o := range test.opts { 60 | o(&q) 61 | } 62 | 63 | if got := q.Matches(resource.NewMetadata("namespace", "type", test.id, resource.VersionUndefined)); got != test.want { 64 | t.Fatalf("unexpected result: got %t, want %t", got, test.want) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/resource/internal/compare/compare.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package compare implements term operation helpers. 6 | package compare 7 | 8 | import ( 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // GetNumbers returns numbers parsed from left and right params, if any of string to number conversion fails returns false as the 3rd arg. 14 | func GetNumbers(left, right string) (int64, int64, bool) { 15 | numLeft, ok := parseValue(left) 16 | if !ok { 17 | return 0, 0, false 18 | } 19 | 20 | numRight, ok := parseValue(right) 21 | if !ok { 22 | return 0, 0, false 23 | } 24 | 25 | return numLeft, numRight, true 26 | } 27 | 28 | func parseValue(value string) (int64, bool) { 29 | value = strings.TrimSpace(value) 30 | 31 | splitPoint := len(value) 32 | 33 | for i, c := range value { 34 | if c >= '0' && c <= '9' || c == '-' { 35 | continue 36 | } 37 | 38 | splitPoint = i 39 | 40 | break 41 | } 42 | 43 | digits, units := value[:splitPoint], value[splitPoint:] 44 | 45 | if len(digits) == 0 { 46 | return 0, false 47 | } 48 | 49 | res, err := strconv.ParseInt(digits, 10, 64) 50 | if err != nil { 51 | return 0, false 52 | } 53 | 54 | multiplier, ok := getMultiplier(units) 55 | if !ok { 56 | return 0, false 57 | } 58 | 59 | return res * multiplier, true 60 | } 61 | 62 | func getMultiplier(value string) (int64, bool) { 63 | value = strings.TrimSpace(strings.ToLower(value)) 64 | 65 | if len(value) == 0 { 66 | return 1, true 67 | } 68 | 69 | if len(value) > 1 { 70 | switch strings.ToLower(value[:2]) { 71 | case "pi": 72 | return 1 << 50, true 73 | case "ti": 74 | return 1 << 40, true 75 | case "gi": 76 | return 1 << 30, true 77 | case "mi": 78 | return 1 << 20, true 79 | case "ki": 80 | return 1 << 10, true 81 | } 82 | } 83 | 84 | switch strings.ToLower(value[:1]) { 85 | case "p": 86 | return 1e15, true 87 | case "t": 88 | return 1e12, true 89 | case "g": 90 | return 1e9, true 91 | case "m": 92 | return 1e6, true 93 | case "k": 94 | return 1e3, true 95 | } 96 | 97 | return 0, false 98 | } 99 | -------------------------------------------------------------------------------- /pkg/resource/internal/compare/compare_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package compare_test 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource/internal/compare" 14 | ) 15 | 16 | func TestCompare(t *testing.T) { 17 | for _, tt := range []struct { 18 | check func(assertions *require.Assertions, left int64, right int64, ok bool) 19 | left string 20 | right string 21 | }{ 22 | { 23 | left: "4GiB", 24 | right: "4GB", 25 | check: func(assertions *require.Assertions, left, right int64, ok bool) { 26 | assertions.True(ok) 27 | assertions.Less(right, left) 28 | assertions.Equal(int64(4e9), right) 29 | assertions.Equal(int64(4*1<<30), left) 30 | }, 31 | }, 32 | { 33 | left: "1", 34 | right: "2000", 35 | check: func(assertions *require.Assertions, left, right int64, ok bool) { 36 | assertions.True(ok) 37 | assertions.Less(left, right) 38 | assertions.Equal(int64(1), left) 39 | assertions.Equal(int64(2000), right) 40 | }, 41 | }, 42 | { 43 | left: "1 1", 44 | right: "2000 3", 45 | check: func(assertions *require.Assertions, _, _ int64, ok bool) { 46 | assertions.False(ok) 47 | }, 48 | }, 49 | { 50 | left: " 1 k", 51 | right: "2000", 52 | check: func(assertions *require.Assertions, left, right int64, ok bool) { 53 | assertions.True(ok) 54 | assertions.Less(left, right) 55 | assertions.Equal(int64(1000), left) 56 | assertions.Equal(int64(2000), right) 57 | }, 58 | }, 59 | { 60 | left: "-1 k", 61 | right: "2000", 62 | check: func(assertions *require.Assertions, left, _ int64, ok bool) { 63 | assertions.True(ok) 64 | assertions.Equal(int64(-1000), left) 65 | }, 66 | }, 67 | { 68 | left: "1.1 k", 69 | check: func(assertions *require.Assertions, _, _ int64, ok bool) { 70 | assertions.False(ok) 71 | }, 72 | }, 73 | { 74 | left: "1 i", 75 | check: func(assertions *require.Assertions, _, _ int64, ok bool) { 76 | assertions.False(ok) 77 | }, 78 | }, 79 | } { 80 | t.Run(fmt.Sprintf("left %s, right %s", tt.left, tt.right), func(t *testing.T) { 81 | left, right, ok := compare.GetNumbers(tt.left, tt.right) 82 | 83 | tt.check(require.New(t), left, right, ok) 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pkg/resource/internal/kv/kv_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package kv_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource/internal/kv" 13 | ) 14 | 15 | func TestEqualSameLenEmptyValue(t *testing.T) { 16 | var kv1, kv2 kv.KV 17 | 18 | kv1.Set("a", "") 19 | kv2.Set("b", "") 20 | 21 | equal := kv1.Equal(kv2) 22 | 23 | assert.False(t, equal, "Expected kv1 and kv2 to be not equal") 24 | } 25 | -------------------------------------------------------------------------------- /pkg/resource/kind.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | // Kind is a Pointer minus resource ID. 8 | type Kind interface { 9 | Namespace() Namespace 10 | Type() Type 11 | } 12 | -------------------------------------------------------------------------------- /pkg/resource/kvutils/kvutils.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package kvutils provides utilities to internal/kv package. 6 | package kvutils 7 | 8 | // TempKV is a temporary key-value store. 9 | type TempKV interface { 10 | Delete(key string) 11 | Set(key, value string) 12 | Get(key string) (string, bool) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/resource/labels.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import ( 8 | "fmt" 9 | "slices" 10 | 11 | "github.com/siderolabs/go-pointer" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource/internal/compare" 14 | "github.com/cosi-project/runtime/pkg/resource/internal/kv" 15 | ) 16 | 17 | // Labels is a set free-form of key-value pairs. 18 | // 19 | // Order of keys is not guaranteed. 20 | // 21 | // Labels support copy-on-write semantics, so metadata copies share common labels as long as possible. 22 | // Labels support querying with LabelTerm. 23 | type Labels struct { 24 | kv.KV 25 | } 26 | 27 | // Equal checks labels for equality. 28 | func (labels Labels) Equal(other Labels) bool { 29 | return labels.KV.Equal(other.KV) 30 | } 31 | 32 | // Matches if labels match the LabelTerm. 33 | func (labels Labels) Matches(term LabelTerm) bool { 34 | matches := labels.matches(term) 35 | 36 | if matches == nil { 37 | return false 38 | } 39 | 40 | m := *matches 41 | 42 | if term.Invert { 43 | return !m 44 | } 45 | 46 | return m 47 | } 48 | 49 | func (labels Labels) matches(term LabelTerm) *bool { 50 | if labels.Empty() && term.Op == LabelOpExists { 51 | return pointer.To(false) 52 | } 53 | 54 | value, ok := labels.Get(term.Key) 55 | 56 | if !ok { 57 | if term.Op.isComparison() { 58 | return nil 59 | } 60 | 61 | return pointer.To(false) 62 | } 63 | 64 | if term.Op != LabelOpExists && len(term.Value) == 0 { 65 | return pointer.To(false) 66 | } 67 | 68 | switch term.Op { 69 | case LabelOpExists: 70 | return pointer.To(true) 71 | case LabelOpEqual: 72 | return pointer.To(value == term.Value[0]) 73 | case LabelOpIn: 74 | return pointer.To(slices.Contains(term.Value, value)) 75 | case LabelOpLTE: 76 | return pointer.To(value <= term.Value[0]) 77 | case LabelOpLT: 78 | return pointer.To(value < term.Value[0]) 79 | case LabelOpLTNumeric: 80 | left, right, ok := compare.GetNumbers(value, term.Value[0]) 81 | if !ok { 82 | return nil 83 | } 84 | 85 | return pointer.To(left < right) 86 | case LabelOpLTENumeric: 87 | left, right, ok := compare.GetNumbers(value, term.Value[0]) 88 | if !ok { 89 | return nil 90 | } 91 | 92 | return pointer.To(left <= right) 93 | default: 94 | panic(fmt.Sprintf("unsupported label term operator: %v", term.Op)) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pkg/resource/list.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | // List is a list of resources. 8 | type List struct { 9 | Items []Resource 10 | } 11 | -------------------------------------------------------------------------------- /pkg/resource/meta/core.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package meta provides definition of core metadata resources. 6 | package meta 7 | 8 | import "github.com/cosi-project/runtime/pkg/resource" 9 | 10 | // NamespaceName is the name of 'meta' namespace. 11 | const NamespaceName resource.Namespace = "meta" 12 | 13 | // Owner is the owner for the 'meta' objects. 14 | const Owner resource.Owner = "meta" 15 | -------------------------------------------------------------------------------- /pkg/resource/meta/namespace.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package meta 6 | 7 | import ( 8 | "github.com/siderolabs/gen/ensure" 9 | 10 | "github.com/cosi-project/runtime/api/v1alpha1" 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 13 | "github.com/cosi-project/runtime/pkg/resource/typed" 14 | ) 15 | 16 | // NamespaceType is the type of Namespace. 17 | const NamespaceType = resource.Type("Namespaces.meta.cosi.dev") 18 | 19 | // Namespace provides metadata about namespaces. 20 | type Namespace = typed.Resource[NamespaceSpec, NamespaceExtension] 21 | 22 | // NewNamespace initializes a Namespace resource. 23 | func NewNamespace(id resource.ID, spec NamespaceSpec) *Namespace { 24 | return typed.NewResource[NamespaceSpec, NamespaceExtension]( 25 | resource.NewMetadata(NamespaceName, NamespaceType, id, resource.VersionUndefined), 26 | spec, 27 | ) 28 | } 29 | 30 | // NamespaceExtension provides auxiliary methods for Namespace. 31 | type NamespaceExtension struct{} 32 | 33 | // ResourceDefinition implements core.ResourceDefinitionProvider interface. 34 | func (NamespaceExtension) ResourceDefinition() ResourceDefinitionSpec { 35 | return ResourceDefinitionSpec{ 36 | Type: NamespaceType, 37 | DefaultNamespace: NamespaceName, 38 | Aliases: []resource.Type{"ns"}, 39 | } 40 | } 41 | 42 | // NamespaceSpec provides Namespace definition. 43 | // 44 | //nolint:recvcheck 45 | type NamespaceSpec struct { 46 | Description string `yaml:"description"` 47 | } 48 | 49 | // DeepCopy generates a deep copy of NamespaceSpec. 50 | func (n NamespaceSpec) DeepCopy() NamespaceSpec { 51 | return n 52 | } 53 | 54 | // MarshalProto implements ProtoMarshaler. 55 | func (n NamespaceSpec) MarshalProto() ([]byte, error) { 56 | protoSpec := v1alpha1.NamespaceSpec{ 57 | Description: n.Description, 58 | } 59 | 60 | return protobuf.ProtoMarshal(&protoSpec) 61 | } 62 | 63 | // UnmarshalProto implements protobuf.ResourceUnmarshaler. 64 | func (n *NamespaceSpec) UnmarshalProto(protoBytes []byte) error { 65 | protoSpec := v1alpha1.NamespaceSpec{} 66 | 67 | if err := protobuf.ProtoUnmarshal(protoBytes, &protoSpec); err != nil { 68 | return err 69 | } 70 | 71 | *n = NamespaceSpec{ 72 | Description: protoSpec.Description, 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func init() { 79 | ensure.NoError(protobuf.RegisterResource(NamespaceType, &Namespace{})) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/resource/meta/namespace_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package meta_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource" 14 | "github.com/cosi-project/runtime/pkg/resource/meta" 15 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 16 | ) 17 | 18 | func TestProtobufNamespace(t *testing.T) { 19 | ns := meta.NewNamespace("test", meta.NamespaceSpec{ 20 | Description: "Test namespace", 21 | }) 22 | 23 | protoR, err := protobuf.FromResource(ns) 24 | require.NoError(t, err) 25 | 26 | marshaled, err := protoR.Marshal() 27 | require.NoError(t, err) 28 | 29 | r, err := protobuf.Unmarshal(marshaled) 30 | require.NoError(t, err) 31 | 32 | back, err := protobuf.UnmarshalResource(r) 33 | require.NoError(t, err) 34 | 35 | assert.True(t, resource.Equal(ns, back)) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/resource/meta/resource_definition.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package meta 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/siderolabs/gen/ensure" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | "github.com/cosi-project/runtime/pkg/resource/meta/spec" 14 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 15 | "github.com/cosi-project/runtime/pkg/resource/typed" 16 | ) 17 | 18 | // ResourceDefinitionType is the type of ResourceDefinition. 19 | const ResourceDefinitionType = resource.Type("ResourceDefinitions.meta.cosi.dev") 20 | 21 | type ( 22 | // PrintColumn describes extra columns to print for the resources. 23 | PrintColumn = spec.PrintColumn 24 | 25 | // ResourceDefinitionSpec provides ResourceDefinition definition. 26 | ResourceDefinitionSpec = spec.ResourceDefinitionSpec 27 | 28 | // ResourceDefinition provides metadata about namespaces. 29 | ResourceDefinition = typed.Resource[ResourceDefinitionSpec, ResourceDefinitionExtension] 30 | ) 31 | 32 | // NewResourceDefinition initializes a ResourceDefinition resource. 33 | func NewResourceDefinition(spec ResourceDefinitionSpec) (*ResourceDefinition, error) { 34 | if err := spec.Fill(); err != nil { 35 | return nil, fmt.Errorf("error validating resource definition %q: %w", spec.Type, err) 36 | } 37 | 38 | return typed.NewResource[ResourceDefinitionSpec, ResourceDefinitionExtension]( 39 | resource.NewMetadata(NamespaceName, ResourceDefinitionType, spec.ID(), resource.VersionUndefined), 40 | spec, 41 | ), nil 42 | } 43 | 44 | // ResourceDefinitionExtension provides auxiliary methods for ResourceDefinition. 45 | type ResourceDefinitionExtension struct{} 46 | 47 | // ResourceDefinition implements core.ResourceDefinitionProvider interface. 48 | func (ResourceDefinitionExtension) ResourceDefinition() ResourceDefinitionSpec { 49 | return ResourceDefinitionSpec{ 50 | Type: ResourceDefinitionType, 51 | Aliases: []resource.Type{"api-resources"}, 52 | DefaultNamespace: NamespaceName, 53 | PrintColumns: []PrintColumn{ 54 | { 55 | Name: "Aliases", 56 | JSONPath: "{.aliases[:]}", 57 | }, 58 | }, 59 | } 60 | } 61 | 62 | // ResourceDefinitionProvider is implemented by resources which can be registered automatically. 63 | type ResourceDefinitionProvider interface { 64 | ResourceDefinition() ResourceDefinitionSpec 65 | } 66 | 67 | // ResourceWithRD is a resource providing resource definition. 68 | // 69 | // ResourceWithRD allows to pull resource namespace and type from the RD. 70 | type ResourceWithRD interface { 71 | ResourceDefinitionProvider 72 | resource.Resource 73 | } 74 | 75 | func init() { 76 | ensure.NoError(protobuf.RegisterResource(ResourceDefinitionType, &ResourceDefinition{})) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/resource/meta/sensitivity.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package meta 6 | 7 | import "github.com/cosi-project/runtime/pkg/resource/meta/spec" 8 | 9 | // Sensitivity values. 10 | const ( 11 | NonSensitive = spec.NonSensitive 12 | Sensitive = spec.Sensitive 13 | ) 14 | -------------------------------------------------------------------------------- /pkg/resource/meta/spec/sensitivity.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package spec 6 | 7 | // Sensitivity indicates how secret resource is. 8 | // The empty value represents a non-sensitive resource. 9 | type Sensitivity string 10 | 11 | // Sensitivity values. 12 | const ( 13 | NonSensitive Sensitivity = "" 14 | Sensitive Sensitivity = "sensitive" 15 | ) 16 | 17 | var allSensitivities = map[Sensitivity]struct{}{ 18 | NonSensitive: {}, 19 | Sensitive: {}, 20 | } 21 | -------------------------------------------------------------------------------- /pkg/resource/owner.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | // Owner of the resource (controller which manages the resource). 8 | type Owner = string 9 | -------------------------------------------------------------------------------- /pkg/resource/phase.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import "fmt" 8 | 9 | // Phase represents state of the resource. 10 | // 11 | // Resource might be either Running or TearingDown (waiting for the finalizers to be removed). 12 | type Phase int 13 | 14 | // Phase constants. 15 | const ( 16 | PhaseRunning Phase = iota 17 | PhaseTearingDown 18 | ) 19 | 20 | const ( 21 | strPhaseRunning = "running" 22 | strPhaseTearingDown = "tearingDown" 23 | ) 24 | 25 | func (ph Phase) String() string { 26 | return [...]string{strPhaseRunning, strPhaseTearingDown}[ph] 27 | } 28 | 29 | // ParsePhase from string representation. 30 | func ParsePhase(ph string) (Phase, error) { 31 | switch ph { 32 | case strPhaseRunning: 33 | return PhaseRunning, nil 34 | case strPhaseTearingDown: 35 | return PhaseTearingDown, nil 36 | default: 37 | return 0, fmt.Errorf("unknown phase: %v", ph) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/resource/pointer.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | // Pointer is a Reference minus resource version. 8 | type Pointer interface { 9 | Kind 10 | 11 | ID() ID 12 | } 13 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/protobuf.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package protobuf provides a bridge between resources and protobuf interface. 6 | package protobuf 7 | 8 | import "google.golang.org/protobuf/proto" 9 | 10 | // vtprotoMessage is the interface for vtproto additions. 11 | // 12 | // We use only a subset of that interface but include additional methods 13 | // to prevent accidental successful type assertion for unrelated types. 14 | type vtprotoMessage interface { 15 | MarshalVT() ([]byte, error) 16 | MarshalToVT([]byte) (int, error) 17 | MarshalToSizedBufferVT([]byte) (int, error) 18 | UnmarshalVT([]byte) error 19 | } 20 | 21 | type vtprotoEqual interface { 22 | EqualMessageVT(proto.Message) bool 23 | } 24 | 25 | // ProtoMarshal returns the wire-format encoding of m. 26 | func ProtoMarshal(m proto.Message) ([]byte, error) { 27 | if vm, ok := m.(vtprotoMessage); ok { 28 | return vm.MarshalVT() 29 | } 30 | 31 | return proto.Marshal(m) 32 | } 33 | 34 | // ProtoUnmarshal parses the wire-format message in b and places the result in m. 35 | // The provided message must be mutable (e.g., a non-nil pointer to a message). 36 | func ProtoUnmarshal(b []byte, m proto.Message) error { 37 | if vm, ok := m.(vtprotoMessage); ok { 38 | return vm.UnmarshalVT(b) 39 | } 40 | 41 | return proto.Unmarshal(b, m) 42 | } 43 | 44 | // ProtoEqual returns true if the two messages are equal. 45 | // 46 | // This is a wrapper around proto.Equal which also supports vtproto messages. 47 | func ProtoEqual(a, b proto.Message) bool { 48 | if vm, ok := a.(vtprotoEqual); ok { 49 | return vm.EqualMessageVT(b) 50 | } 51 | 52 | return proto.Equal(a, b) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/registry_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/siderolabs/gen/ensure" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/cosi-project/runtime/api/v1alpha1" 15 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 16 | "github.com/cosi-project/runtime/pkg/state/conformance" 17 | ) 18 | 19 | func init() { 20 | ensure.NoError(protobuf.RegisterResource(conformance.PathResourceType, &conformance.PathResource{})) 21 | } 22 | 23 | func BenchmarkCreateResource(b *testing.B) { 24 | protoR := &v1alpha1.Resource{ 25 | Metadata: &v1alpha1.Metadata{ 26 | Namespace: "ns", 27 | Type: conformance.PathResourceType, 28 | Id: "a/b", 29 | Version: "3", 30 | Phase: "running", 31 | }, 32 | Spec: &v1alpha1.Spec{ 33 | YamlSpec: "nil", 34 | ProtoSpec: nil, 35 | }, 36 | } 37 | 38 | r, err := protobuf.Unmarshal(protoR) 39 | require.NoError(b, err) 40 | 41 | b.ResetTimer() 42 | 43 | for range b.N { 44 | rr, err := protobuf.UnmarshalResource(r) 45 | 46 | if _, ok := rr.(*conformance.PathResource); !ok { 47 | b.Fatalf("unexpected resource type %T", rr) 48 | } 49 | 50 | if err != nil { 51 | b.Fatal(err) 52 | } 53 | } 54 | } 55 | 56 | func TestRegistry(t *testing.T) { 57 | t.Parallel() 58 | 59 | protoR := &v1alpha1.Resource{ 60 | Metadata: &v1alpha1.Metadata{ 61 | Namespace: "ns", 62 | Type: conformance.PathResourceType, 63 | Id: "a/b", 64 | Version: "3", 65 | Phase: "running", 66 | }, 67 | Spec: &v1alpha1.Spec{ 68 | YamlSpec: "nil", 69 | ProtoSpec: nil, 70 | }, 71 | } 72 | 73 | r, err := protobuf.Unmarshal(protoR) 74 | require.NoError(t, err) 75 | 76 | rr, err := protobuf.UnmarshalResource(r) 77 | require.NoError(t, err) 78 | 79 | require.IsType(t, rr, &conformance.PathResource{}) 80 | 81 | assert.Equal(t, rr.Metadata().ID(), "a/b") 82 | } 83 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/resource_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf_test 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "google.golang.org/protobuf/types/known/timestamppb" 14 | "gopkg.in/yaml.v3" 15 | 16 | "github.com/cosi-project/runtime/api/v1alpha1" 17 | "github.com/cosi-project/runtime/pkg/resource" 18 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 19 | ) 20 | 21 | func TestMarshalUnmarshal(t *testing.T) { 22 | t.Parallel() 23 | 24 | created, _ := time.Parse(time.RFC3339, "2021-06-23T19:22:29Z") //nolint:errcheck 25 | updated, _ := time.Parse(time.RFC3339, "2021-06-23T20:22:29Z") //nolint:errcheck 26 | 27 | protoR := &v1alpha1.Resource{ 28 | Metadata: &v1alpha1.Metadata{ 29 | Namespace: "ns", 30 | Type: "typ", 31 | Id: "id", 32 | Version: "3", 33 | Owner: "FooController", 34 | Phase: "running", 35 | Created: timestamppb.New(created), 36 | Updated: timestamppb.New(updated), 37 | Finalizers: []string{"a1", "a2"}, 38 | Annotations: map[string]string{ 39 | "ttl": "1h", 40 | }, 41 | Labels: map[string]string{ 42 | "app": "foo", 43 | "stage": "initial", 44 | }, 45 | }, 46 | Spec: &v1alpha1.Spec{ 47 | YamlSpec: "true", 48 | ProtoSpec: []byte("test"), 49 | }, 50 | } 51 | 52 | r, err := protobuf.Unmarshal(protoR) 53 | require.NoError(t, err) 54 | 55 | protoR2, err := r.Marshal() 56 | require.NoError(t, err) 57 | 58 | assert.True(t, protobuf.ProtoEqual(protoR, protoR2)) 59 | 60 | r2, err := protobuf.Unmarshal(protoR2) 61 | require.NoError(t, err) 62 | 63 | assert.True(t, resource.Equal(r, r2)) 64 | 65 | assert.True(t, resource.Equal(r, r.DeepCopy())) 66 | 67 | y, err := resource.MarshalYAML(r) 68 | require.NoError(t, err) 69 | 70 | yy, err := yaml.Marshal(y) 71 | require.NoError(t, err) 72 | 73 | assert.Equal(t, `metadata: 74 | namespace: ns 75 | type: typ 76 | id: id 77 | version: 3 78 | owner: FooController 79 | phase: running 80 | created: 2021-06-23T19:22:29Z 81 | updated: 2021-06-23T20:22:29Z 82 | labels: 83 | app: foo 84 | stage: initial 85 | annotations: 86 | ttl: 1h 87 | finalizers: 88 | - a1 89 | - a2 90 | spec: 91 | true 92 | `, 93 | string(yy)) 94 | } 95 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/spec.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf 6 | 7 | import ( 8 | "encoding/json" 9 | 10 | "google.golang.org/protobuf/encoding/protojson" 11 | "google.golang.org/protobuf/proto" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | // Spec should be proto.Message and pointer. 16 | type Spec[T any] interface { 17 | proto.Message 18 | *T 19 | } 20 | 21 | // ResourceSpec wraps proto.Message structures and adds DeepCopy and marshaling methods. 22 | // T is a protobuf generated structure. 23 | // S is a pointer to T. 24 | // Example usage: 25 | // type WrappedSpec = ResourceSpec[ProtoSpec, *ProtoSpec]. 26 | type ResourceSpec[T any, S Spec[T]] struct { 27 | Value S 28 | } 29 | 30 | // MarshalYAML implements yaml.Marshaler interface. We want it to inline `Value` field, without 31 | // using `inline` tag. 32 | func (spec *ResourceSpec[T, S]) MarshalYAML() (any, error) { 33 | return spec.Value, nil 34 | } 35 | 36 | // DeepCopy creates a copy of the wrapped proto.Message. 37 | func (spec ResourceSpec[T, S]) DeepCopy() ResourceSpec[T, S] { 38 | if cast, ok := any(spec.Value).(interface{ CloneVT() S }); ok { 39 | return ResourceSpec[T, S]{ 40 | Value: cast.CloneVT(), 41 | } 42 | } 43 | 44 | return ResourceSpec[T, S]{ 45 | Value: proto.Clone(spec.Value).(S), //nolint:forcetypeassert,errcheck 46 | } 47 | } 48 | 49 | // MarshalJSON implements json.Marshaler. 50 | func (spec *ResourceSpec[T, S]) MarshalJSON() ([]byte, error) { 51 | return json.Marshal(spec.Value) 52 | } 53 | 54 | // MarshalProto implements ProtoMarshaler. 55 | func (spec *ResourceSpec[T, S]) MarshalProto() ([]byte, error) { 56 | return ProtoMarshal(spec.Value) 57 | } 58 | 59 | // UnmarshalYAML implements yaml.Unmarshaler interface. We want it to inline `Value` field, without 60 | // using `inline` tag. 61 | func (spec *ResourceSpec[T, S]) UnmarshalYAML(node *yaml.Node) error { 62 | if spec.Value == nil { 63 | spec.Value = S(new(T)) 64 | } 65 | 66 | return node.Decode(spec.Value) 67 | } 68 | 69 | // UnmarshalJSON implements json.Unmarshaler. 70 | func (spec *ResourceSpec[T, S]) UnmarshalJSON(bytes []byte) error { 71 | spec.Value = new(T) 72 | 73 | if unmarshaler, ok := any(spec.Value).(json.Unmarshaler); ok { 74 | return unmarshaler.UnmarshalJSON(bytes) 75 | } 76 | 77 | opts := protojson.UnmarshalOptions{} 78 | 79 | return opts.Unmarshal(bytes, spec.Value) 80 | } 81 | 82 | // UnmarshalProto implements protobuf.ResourceUnmarshaler. 83 | func (spec *ResourceSpec[T, S]) UnmarshalProto(protoBytes []byte) error { 84 | spec.Value = new(T) 85 | 86 | return ProtoUnmarshal(protoBytes, spec.Value) 87 | } 88 | 89 | // GetValue returns wrapped protobuf object. 90 | func (spec *ResourceSpec[T, S]) GetValue() proto.Message { //nolint:ireturn 91 | return spec.Value 92 | } 93 | 94 | // Equal implements spec equality check. 95 | func (spec *ResourceSpec[T, S]) Equal(other any) bool { 96 | otherSpec, ok := other.(*ResourceSpec[T, S]) 97 | if !ok { 98 | return false 99 | } 100 | 101 | return ProtoEqual(spec.Value, otherSpec.Value) 102 | } 103 | 104 | // NewResourceSpec creates new ResourceSpec[T, S]. 105 | // T is a protobuf generated structure. 106 | // S is a pointer to T. 107 | func NewResourceSpec[T any, S Spec[T]](value S) ResourceSpec[T, S] { 108 | return ResourceSpec[T, S]{ 109 | Value: value, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/spec_unmarshal_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf_test 6 | 7 | import ( 8 | "encoding/json" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "google.golang.org/protobuf/reflect/protoreflect" 15 | "gopkg.in/yaml.v3" 16 | 17 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 18 | ) 19 | 20 | type rawSpec struct { 21 | Str string 22 | Num int 23 | } 24 | 25 | type customUnmarshalerSpec struct { 26 | Str string 27 | Num int 28 | } 29 | 30 | func (spec *customUnmarshalerSpec) ProtoReflect() protoreflect.Message { return nil } 31 | 32 | // UnmarshalJSON uppercases the string and doubles the number. 33 | func (spec *customUnmarshalerSpec) UnmarshalJSON(data []byte) error { 34 | var raw rawSpec 35 | 36 | err := json.Unmarshal(data, &raw) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | spec.Str = strings.ToUpper(raw.Str) 42 | spec.Num = raw.Num * 2 43 | 44 | return nil 45 | } 46 | 47 | // UnmarshalYAML lowercases the string and halves the number. 48 | func (spec *customUnmarshalerSpec) UnmarshalYAML(node *yaml.Node) error { 49 | var raw rawSpec 50 | 51 | err := node.Decode(&raw) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | spec.Str = strings.ToLower(raw.Str) 57 | spec.Num = raw.Num / 2 58 | 59 | return nil 60 | } 61 | 62 | func TestCustomJSONUnmarshal(t *testing.T) { 63 | spec := protobuf.NewResourceSpec(&customUnmarshalerSpec{}) 64 | 65 | err := json.Unmarshal([]byte(`{"str":"aaaa","num":2222}`), &spec) 66 | require.NoError(t, err) 67 | 68 | assert.Equal(t, "AAAA", spec.Value.Str) 69 | assert.Equal(t, 4444, spec.Value.Num) 70 | } 71 | 72 | func TestCustomYAMLUnmarshal(t *testing.T) { 73 | spec := protobuf.NewResourceSpec(&customUnmarshalerSpec{}) 74 | 75 | err := yaml.Unmarshal([]byte(`str: AAAA 76 | num: 2222`), &spec) 77 | require.NoError(t, err) 78 | 79 | assert.Equal(t, "aaaa", spec.Value.Str) 80 | assert.Equal(t, 1111, spec.Value.Num) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/yaml.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf 6 | 7 | import ( 8 | "fmt" 9 | 10 | "gopkg.in/yaml.v3" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | ) 14 | 15 | // YAMLResource is a wrapper around Resource which implements yaml.Unmarshaler. 16 | // Its here and not in resource package to avoid circular dependency. 17 | type YAMLResource struct { 18 | r resource.Resource 19 | } 20 | 21 | // Resource returns the underlying resource. 22 | func (r *YAMLResource) Resource() resource.Resource { 23 | if r.r == nil { 24 | panic("resource is not set") 25 | } 26 | 27 | return r.r.DeepCopy() 28 | } 29 | 30 | // UnmarshalYAML implements yaml.Unmarshaler. 31 | func (r *YAMLResource) UnmarshalYAML(value *yaml.Node) error { 32 | if value.Kind != yaml.MappingNode { 33 | return fmt.Errorf("expected mapping node, got %d", value.Kind) 34 | } 35 | 36 | if len(value.Content) != 4 { 37 | return fmt.Errorf("expected 4 elements node, got %d", len(value.Content)) 38 | } 39 | 40 | var mdNode, specNode *yaml.Node 41 | 42 | for i := 0; i < len(value.Content); i += 2 { 43 | key := value.Content[i] 44 | val := value.Content[i+1] 45 | 46 | if key.Kind != yaml.ScalarNode { 47 | return fmt.Errorf("expected scalar node, got %d", key.Kind) 48 | } 49 | 50 | if val.Kind != yaml.MappingNode { 51 | return fmt.Errorf("expected mapping node, got %d", value.Content[i+1].Kind) 52 | } 53 | 54 | switch key.Value { 55 | case "metadata": 56 | mdNode = val 57 | case "spec": 58 | specNode = val 59 | default: 60 | return fmt.Errorf("unexpected key %v", key) 61 | } 62 | } 63 | 64 | if mdNode == nil || specNode == nil { 65 | return fmt.Errorf("metadata or spec node is missing") 66 | } 67 | 68 | var md resource.Metadata 69 | 70 | err := md.UnmarshalYAML(mdNode) 71 | if err != nil { 72 | return fmt.Errorf("failed to unmarshal metadata: %w", err) 73 | } 74 | 75 | result, err := CreateResource(md.Type()) 76 | if err != nil { 77 | return fmt.Errorf("failed to create resource: %w", err) 78 | } 79 | 80 | *result.Metadata() = md 81 | 82 | err = specNode.Decode(result.Spec()) 83 | if err != nil { 84 | return fmt.Errorf("failed to unmarshal spec: %w", err) 85 | } 86 | 87 | r.r = result 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/resource/protobuf/yaml_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/siderolabs/gen/ensure" 11 | "github.com/siderolabs/go-pointer" 12 | "github.com/stretchr/testify/require" 13 | "gopkg.in/yaml.v3" 14 | 15 | "github.com/cosi-project/runtime/api/v1alpha1" 16 | "github.com/cosi-project/runtime/pkg/resource" 17 | "github.com/cosi-project/runtime/pkg/resource/meta" 18 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 19 | "github.com/cosi-project/runtime/pkg/resource/typed" 20 | ) 21 | 22 | func init() { 23 | ensure.NoError(protobuf.RegisterResource(TestType, &TestResource{})) 24 | } 25 | 26 | // TestNamespaceName is the namespace of Test resource. 27 | const TestNamespaceName = resource.Namespace("ns-event") 28 | 29 | // TestType is the type of Test. 30 | const TestType = resource.Type("Test.test.cosi.dev") 31 | 32 | type ( 33 | // TestResource is a test resource. 34 | TestResource = typed.Resource[TestSpec, TestExtension] 35 | ) 36 | 37 | // NewTestResource initializes TestResource resource. 38 | func NewTestResource(id resource.ID, spec TestSpec) *TestResource { 39 | return typed.NewResource[TestSpec, TestExtension]( 40 | resource.NewMetadata(TestNamespaceName, TestType, id, resource.VersionUndefined), 41 | spec, 42 | ) 43 | } 44 | 45 | // TestExtension provides auxiliary methods for A. 46 | type TestExtension struct{} 47 | 48 | // ResourceDefinition implements core.ResourceDefinitionProvider interface. 49 | func (TestExtension) ResourceDefinition() meta.ResourceDefinitionSpec { 50 | return meta.ResourceDefinitionSpec{ 51 | Type: TestType, 52 | DefaultNamespace: TestNamespaceName, 53 | } 54 | } 55 | 56 | type TestSpec = protobuf.ResourceSpec[v1alpha1.UpdateOptions, *v1alpha1.UpdateOptions] 57 | 58 | func TestYAMLResource(t *testing.T) { 59 | original := NewTestResource("id", TestSpec{ 60 | Value: &v1alpha1.UpdateOptions{ 61 | Owner: "some owner", 62 | ExpectedPhase: pointer.To(resource.Phase(0).String()), 63 | }, 64 | }) 65 | 66 | strct, err := resource.MarshalYAML(original) 67 | require.NoError(t, err) 68 | 69 | raw, err := yaml.Marshal(strct) 70 | require.NoError(t, err) 71 | 72 | var result protobuf.YAMLResource 73 | 74 | err = yaml.Unmarshal(raw, &result) 75 | require.NoError(t, err) 76 | require.True(t, resource.Equal(original, result.Resource())) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/resource/reference.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import "fmt" 8 | 9 | // Reference to a resource. 10 | type Reference interface { 11 | fmt.Stringer 12 | 13 | Pointer 14 | Version() Version 15 | } 16 | -------------------------------------------------------------------------------- /pkg/resource/resource.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package resource provides core resource definition. 6 | package resource 7 | 8 | import ( 9 | "fmt" 10 | "reflect" 11 | ) 12 | 13 | type ( 14 | // ID is a resource ID. 15 | ID = string 16 | // Type is a resource type. 17 | // 18 | // Type could be e.g. runtime/os/mount. 19 | Type = string 20 | // Namespace of a resource. 21 | Namespace = string 22 | ) 23 | 24 | var _ Resource = (*Any)(nil) 25 | 26 | // Resource is an abstract resource managed by the state. 27 | // 28 | // Resource is uniquely identified by the tuple (Namespace, Type, ID). 29 | // Resource might have additional opaque data in Spec(). 30 | // When resource is updated, Version should change with each update. 31 | type Resource interface { 32 | // Metadata for the resource. 33 | // 34 | // Metadata.Version should change each time Spec changes. 35 | Metadata() *Metadata 36 | 37 | // Opaque data resource contains. 38 | Spec() any 39 | 40 | // Deep copy of the resource. 41 | DeepCopy() Resource 42 | } 43 | 44 | // Equal tests two resources for equality. 45 | func Equal(r1, r2 Resource) bool { 46 | if !r1.Metadata().Equal(*r2.Metadata()) { 47 | return false 48 | } 49 | 50 | spec1, spec2 := r1.Spec(), r2.Spec() 51 | 52 | if equality, ok := spec1.(interface { 53 | Equal(any) bool 54 | }); ok { 55 | return equality.Equal(spec2) 56 | } 57 | 58 | return reflect.DeepEqual(spec1, spec2) 59 | } 60 | 61 | // MarshalYAML marshals resource to YAML definition. 62 | func MarshalYAML(r Resource) (any, error) { 63 | return &struct { 64 | Metadata *Metadata `yaml:"metadata"` 65 | Spec any `yaml:"spec"` 66 | }{ 67 | Metadata: r.Metadata(), 68 | Spec: r.Spec(), 69 | }, nil 70 | } 71 | 72 | // String returns representation suitable for %s formatting. 73 | func String(r Resource) string { 74 | md := r.Metadata() 75 | 76 | return fmt.Sprintf("%s(%s/%s)", md.Type(), md.Namespace(), md.ID()) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/resource/resource_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | ) 14 | 15 | func TestIsTombstone(t *testing.T) { 16 | t.Parallel() 17 | 18 | assert.True(t, resource.IsTombstone(new(resource.Tombstone))) 19 | assert.False(t, resource.IsTombstone((resource.Resource)(nil))) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/resource/rtestutils/errors.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rtestutils 6 | 7 | import ( 8 | "fmt" 9 | "sort" 10 | "strings" 11 | ) 12 | 13 | type assertionAggregator struct { 14 | errors map[string]struct{} 15 | hadErrors bool 16 | } 17 | 18 | func (agg *assertionAggregator) Errorf(format string, args ...any) { 19 | errorString := fmt.Sprintf(format, args...) 20 | 21 | if agg.errors == nil { 22 | agg.errors = map[string]struct{}{} 23 | } 24 | 25 | agg.errors[errorString] = struct{}{} 26 | agg.hadErrors = true 27 | } 28 | 29 | func (agg *assertionAggregator) String() string { 30 | lines := make([]string, 0, len(agg.errors)) 31 | 32 | for errorString := range agg.errors { 33 | lines = append(lines, " * "+errorString) 34 | } 35 | 36 | sort.Strings(lines) 37 | 38 | return strings.Join(lines, "\n") 39 | } 40 | 41 | func (agg *assertionAggregator) Equal(other *assertionAggregator) bool { 42 | if agg.hadErrors != other.hadErrors { 43 | return false 44 | } 45 | 46 | if agg.errors == nil { 47 | return other.errors == nil 48 | } 49 | 50 | if other.errors == nil { 51 | return false 52 | } 53 | 54 | if len(agg.errors) != len(other.errors) { 55 | return false 56 | } 57 | 58 | for errorString := range agg.errors { 59 | if _, ok := other.errors[errorString]; !ok { 60 | return false 61 | } 62 | } 63 | 64 | return true 65 | } 66 | -------------------------------------------------------------------------------- /pkg/resource/rtestutils/ids.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package rtestutils 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | ) 16 | 17 | // ResourceIDsWithOwner returns a list of resource IDs and filters them by owner (if set). 18 | func ResourceIDsWithOwner[R ResourceWithRD](ctx context.Context, t *testing.T, st state.State, owner *string, options ...state.ListOption) []resource.ID { 19 | require := require.New(t) 20 | 21 | var r R 22 | 23 | rds := r.ResourceDefinition() 24 | 25 | items, err := st.List(ctx, resource.NewMetadata(rds.DefaultNamespace, rds.Type, "", resource.VersionUndefined), options...) 26 | require.NoError(err) 27 | 28 | ids := make([]resource.ID, 0, len(items.Items)) 29 | 30 | for _, item := range items.Items { 31 | if owner != nil && item.Metadata().Owner() != *owner { 32 | continue 33 | } 34 | 35 | ids = append(ids, item.Metadata().ID()) 36 | } 37 | 38 | return ids 39 | } 40 | 41 | // ResourceIDs returns a list of resource IDs. 42 | func ResourceIDs[R ResourceWithRD](ctx context.Context, t *testing.T, st state.State, options ...state.ListOption) []resource.ID { 43 | return ResourceIDsWithOwner[R](ctx, t, st, nil, options...) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/resource/rtestutils/rtestutils.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package rtestutils provides utilities for testing with resource API. 6 | package rtestutils 7 | 8 | import ( 9 | "github.com/cosi-project/runtime/pkg/resource/meta" 10 | ) 11 | 12 | // ResourceWithRD is an alias for meta.ResourceWithRD. 13 | type ResourceWithRD = meta.ResourceWithRD 14 | -------------------------------------------------------------------------------- /pkg/resource/tombstone.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import "fmt" 8 | 9 | var _ Resource = (*Tombstone)(nil) 10 | 11 | // Tombstone is a resource without a Spec. 12 | // 13 | // Tombstones are used to present state of a deleted resource. 14 | type Tombstone struct { 15 | ref Metadata 16 | } 17 | 18 | // NewTombstone builds a tombstone from resource reference. 19 | func NewTombstone(ref Reference) *Tombstone { 20 | return &Tombstone{ 21 | ref: NewMetadata(ref.Namespace(), ref.Type(), ref.ID(), ref.Version()), 22 | } 23 | } 24 | 25 | // String method for debugging/logging. 26 | func (t *Tombstone) String() string { 27 | return fmt.Sprintf("Tombstone(%s)", t.ref.String()) 28 | } 29 | 30 | // Metadata for the resource. 31 | // 32 | // Metadata.Version should change each time Spec changes. 33 | func (t *Tombstone) Metadata() *Metadata { 34 | return &t.ref 35 | } 36 | 37 | // Spec is not implemented for tobmstones. 38 | func (t *Tombstone) Spec() any { 39 | panic("tombstone doesn't contain spec") 40 | } 41 | 42 | // DeepCopy returns self, as tombstone is immutable. 43 | func (t *Tombstone) DeepCopy() Resource { //nolint:ireturn 44 | return t 45 | } 46 | 47 | // Tombstone implements Tombstoned interface. 48 | func (t *Tombstone) Tombstone() { 49 | } 50 | 51 | // Tombstoned is a marker interface for Tombstones. 52 | type Tombstoned interface { 53 | Tombstone() 54 | } 55 | 56 | // IsTombstone checks if resource is represented by the Tombstone. 57 | func IsTombstone(res Resource) bool { 58 | _, ok := res.(Tombstoned) 59 | 60 | return ok 61 | } 62 | -------------------------------------------------------------------------------- /pkg/resource/version.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package resource 6 | 7 | import ( 8 | "fmt" 9 | "strconv" 10 | 11 | "github.com/siderolabs/go-pointer" 12 | ) 13 | 14 | // Version of a resource. 15 | type Version struct { 16 | // make versions uncomparable with equality operator 17 | _ [0]func() 18 | 19 | *uint64 20 | } 21 | 22 | // Special version constants. 23 | var ( 24 | VersionUndefined = Version{} 25 | ) 26 | 27 | const undefinedVersion = "undefined" 28 | 29 | // Value returns the underlying version number. Returns 0 if the version is undefined. 30 | func (v Version) Value() uint64 { 31 | return pointer.SafeDeref(v.uint64) 32 | } 33 | 34 | // Next returns a new incremented version. 35 | func (v Version) Next() Version { 36 | return Version{ 37 | uint64: pointer.To(pointer.SafeDeref(v.uint64) + 1), 38 | } 39 | } 40 | 41 | func (v Version) String() string { 42 | if v.uint64 == nil { 43 | return undefinedVersion 44 | } 45 | 46 | return strconv.FormatUint(*v.uint64, 10) 47 | } 48 | 49 | // Equal compares versions. 50 | func (v Version) Equal(other Version) bool { 51 | if v.uint64 == nil || other.uint64 == nil { 52 | return v.uint64 == nil && other.uint64 == nil 53 | } 54 | 55 | return *v.uint64 == *other.uint64 56 | } 57 | 58 | // ParseVersion from string representation. 59 | func ParseVersion(ver string) (Version, error) { 60 | if ver == undefinedVersion { 61 | return VersionUndefined, nil 62 | } 63 | 64 | intVersion, err := strconv.ParseInt(ver, 10, 64) 65 | if err != nil { 66 | return VersionUndefined, fmt.Errorf("error parsing version: %w", err) 67 | } 68 | 69 | return Version{ 70 | uint64: pointer.To(uint64(intVersion)), 71 | }, nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/safe/reader.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package safe 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cosi-project/runtime/pkg/controller" 11 | "github.com/cosi-project/runtime/pkg/controller/generic" 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | "github.com/cosi-project/runtime/pkg/state" 14 | ) 15 | 16 | // ReaderGet is a type safe wrapper around reader.Get. 17 | func ReaderGet[T resource.Resource](ctx context.Context, rdr controller.Reader, ptr resource.Pointer) (T, error) { //nolint:ireturn 18 | got, err := rdr.Get(ctx, ptr) 19 | if err != nil { 20 | var zero T 21 | 22 | return zero, err 23 | } 24 | 25 | result, ok := got.(T) 26 | if !ok { 27 | var zero T 28 | 29 | return zero, typeMismatchErr(result, got) 30 | } 31 | 32 | return result, nil 33 | } 34 | 35 | // ReaderGetByID is a type safe wrapper around reader.Get. 36 | func ReaderGetByID[T generic.ResourceWithRD](ctx context.Context, rdr controller.Reader, id resource.ID) (T, error) { //nolint:ireturn 37 | var r T 38 | 39 | md := resource.NewMetadata( 40 | r.ResourceDefinition().DefaultNamespace, 41 | r.ResourceDefinition().Type, 42 | id, 43 | resource.VersionUndefined, 44 | ) 45 | 46 | got, err := rdr.Get(ctx, md) 47 | if err != nil { 48 | var zero T 49 | 50 | return zero, err 51 | } 52 | 53 | result, ok := got.(T) 54 | if !ok { 55 | var zero T 56 | 57 | return zero, typeMismatchErr(result, got) 58 | } 59 | 60 | return result, nil 61 | } 62 | 63 | // ReaderList is a type safe wrapper around Reader.List. 64 | func ReaderList[T resource.Resource](ctx context.Context, rdr controller.Reader, kind resource.Kind, opts ...state.ListOption) (List[T], error) { 65 | got, err := rdr.List(ctx, kind, opts...) 66 | if err != nil { 67 | var zero List[T] 68 | 69 | return zero, err 70 | } 71 | 72 | if len(got.Items) == 0 { 73 | var zero List[T] 74 | 75 | return zero, nil 76 | } 77 | 78 | // Early assertion to make sure we don't have a type mismatch. 79 | if firstElExpected, ok := got.Items[0].(T); !ok { 80 | var zero List[T] 81 | 82 | return zero, typeMismatchFirstElErr(firstElExpected, got.Items[0]) 83 | } 84 | 85 | return NewList[T](got), nil 86 | } 87 | 88 | // ReaderListAll is a type safe wrapper around Reader.List that uses default namaespace and type from ResourceDefinitionProvider. 89 | func ReaderListAll[T generic.ResourceWithRD](ctx context.Context, rdr controller.Reader, opts ...state.ListOption) (List[T], error) { 90 | var r T 91 | 92 | md := resource.NewMetadata( 93 | r.ResourceDefinition().DefaultNamespace, 94 | r.ResourceDefinition().Type, 95 | "", 96 | resource.VersionUndefined, 97 | ) 98 | 99 | return ReaderList[T](ctx, rdr, md, opts...) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/safe/safe.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package safe provides a safe wrappers around the cosi runtime. 6 | package safe 7 | 8 | import "github.com/cosi-project/runtime/pkg/resource" 9 | 10 | func typeAssertOrZero[T resource.Resource](got resource.Resource, err error) (T, error) { //nolint:ireturn 11 | if err != nil { 12 | var zero T 13 | 14 | return zero, err 15 | } 16 | 17 | result, ok := got.(T) 18 | if !ok { 19 | var zero T 20 | 21 | return zero, typeMismatchErr(result, got) 22 | } 23 | 24 | return result, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/safe/util.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package safe 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cosi-project/runtime/pkg/controller" 11 | "github.com/cosi-project/runtime/pkg/controller/generic" 12 | "github.com/cosi-project/runtime/pkg/controller/runtime/options" 13 | "github.com/cosi-project/runtime/pkg/resource" 14 | ) 15 | 16 | // Map applies the given function to each element of the list and returns a new slice with the results. It 17 | // returns an error if the given function had returned an error. 18 | func Map[T resource.Resource, R any](list List[T], fn func(T) (R, error)) ([]R, error) { 19 | result := make([]R, 0, list.Len()) 20 | 21 | for _, item := range list.list.Items { 22 | r, err := fn(item.(T)) //nolint:errcheck,forcetypeassert 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | result = append(result, r) 28 | } 29 | 30 | return result, nil 31 | } 32 | 33 | // ToSlice applies the given function to each element of the list and returns a new slice with the results. 34 | func ToSlice[T resource.Resource, R any](list List[T], fn func(T) R) []R { 35 | result := make([]R, 0, list.Len()) 36 | 37 | for _, item := range list.list.Items { 38 | result = append(result, fn(item.(T))) //nolint:forcetypeassert,errcheck 39 | } 40 | 41 | return result 42 | } 43 | 44 | // Input returns a controller.Input for the given resource. 45 | func Input[R generic.ResourceWithRD](kind controller.InputKind) controller.Input { 46 | var r R 47 | 48 | return controller.Input{ 49 | Namespace: r.ResourceDefinition().DefaultNamespace, 50 | Type: r.ResourceDefinition().Type, 51 | Kind: kind, 52 | } 53 | } 54 | 55 | // CleanupOutputs wraps the controller.OutputTracker.CleanupOutputs method. 56 | func CleanupOutputs[R generic.ResourceWithRD](ctx context.Context, tracker controller.OutputTracker) error { 57 | var r R 58 | 59 | return tracker.CleanupOutputs(ctx, 60 | resource.NewMetadata( 61 | r.ResourceDefinition().DefaultNamespace, 62 | r.ResourceDefinition().Type, 63 | "", 64 | resource.VersionUndefined, 65 | ), 66 | ) 67 | } 68 | 69 | // WithResourceCache returns a controller runtime options.WithResourceCache. 70 | func WithResourceCache[R generic.ResourceWithRD]() options.Option { 71 | var r R 72 | 73 | return options.WithCachedResource(r.ResourceDefinition().DefaultNamespace, r.ResourceDefinition().Type) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/safe/writer.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package safe 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/cosi-project/runtime/pkg/controller" 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | ) 14 | 15 | // WriterModify is a type safe wrapper around writer.Modify. 16 | func WriterModify[T resource.Resource](ctx context.Context, writer controller.Writer, r T, fn func(T) error, options ...controller.ModifyOption) error { 17 | return writer.Modify(ctx, r, func(r resource.Resource) error { 18 | arg, ok := r.(T) 19 | if !ok { 20 | return fmt.Errorf("type mismatch: expected %T, got %T", arg, r) 21 | } 22 | 23 | return fn(arg) 24 | }, options...) 25 | } 26 | 27 | // WriterModifyWithResult is a type safe wrapper around writer.ModifyWithResult. 28 | func WriterModifyWithResult[T resource.Resource](ctx context.Context, writer controller.Writer, r T, fn func(T) error, options ...controller.ModifyOption) (T, error) { 29 | got, err := writer.ModifyWithResult(ctx, r, func(r resource.Resource) error { 30 | arg, ok := r.(T) 31 | if !ok { 32 | return fmt.Errorf("type mismatch: expected %T, got %T", arg, r) 33 | } 34 | 35 | return fn(arg) 36 | }, options...) 37 | 38 | return typeAssertOrZero[T](got, err) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/state/conformance/conformance.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package conformance implements tests which verify conformance of the implementation with the spec. 6 | package conformance 7 | -------------------------------------------------------------------------------- /pkg/state/conformance/resources.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package conformance 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/cosi-project/runtime/pkg/resource" 11 | ) 12 | 13 | // PathResourceType is the type of PathResource. 14 | const PathResourceType = resource.Type("os/path") 15 | 16 | // PathResource represents a path in the filesystem. 17 | // 18 | // Resource ID is the path. 19 | type PathResource struct { 20 | md resource.Metadata 21 | } 22 | 23 | type pathSpec struct{} 24 | 25 | func (spec pathSpec) MarshalProto() ([]byte, error) { 26 | return nil, nil 27 | } 28 | 29 | // NewPathResource creates new PathResource. 30 | func NewPathResource(ns resource.Namespace, path string) *PathResource { 31 | r := &PathResource{ 32 | md: resource.NewMetadata(ns, PathResourceType, path, resource.VersionUndefined), 33 | } 34 | 35 | return r 36 | } 37 | 38 | // Metadata implements resource.Resource. 39 | func (path *PathResource) Metadata() *resource.Metadata { 40 | return &path.md 41 | } 42 | 43 | // Spec implements resource.Resource. 44 | func (path *PathResource) Spec() any { 45 | return pathSpec{} 46 | } 47 | 48 | // DeepCopy implements resource.Resource. 49 | func (path *PathResource) DeepCopy() resource.Resource { //nolint:ireturn 50 | return &PathResource{ 51 | md: path.md, 52 | } 53 | } 54 | 55 | // UnmarshalProto implements protobuf.ResourceUnmarshaler. 56 | func (path *PathResource) UnmarshalProto(md *resource.Metadata, protoSpec []byte) error { 57 | path.md = *md 58 | 59 | if protoSpec != nil { 60 | return fmt.Errorf("unexpected non-nil protoSpec") 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/state/conformance/watch.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package conformance 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/siderolabs/gen/channel" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | "github.com/cosi-project/runtime/pkg/state" 14 | ) 15 | 16 | func watchAggregateAdapter(ctx context.Context, useAggregated bool, st state.State, md resource.Kind, ch chan<- state.Event, options ...state.WatchKindOption) error { 17 | if useAggregated { 18 | aggCh := make(chan []state.Event) 19 | 20 | err := st.WatchKindAggregated(ctx, md, aggCh, options...) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | go func() { 26 | for { 27 | select { 28 | case events := <-aggCh: 29 | for _, event := range events { 30 | if !channel.SendWithContext(ctx, ch, event) { 31 | return 32 | } 33 | } 34 | case <-ctx.Done(): 35 | return 36 | } 37 | } 38 | }() 39 | 40 | return nil 41 | } 42 | 43 | return st.WatchKind(ctx, md, ch, options...) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/state/filter_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package state_test 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "github.com/stretchr/testify/suite" 15 | 16 | "github.com/cosi-project/runtime/pkg/resource" 17 | "github.com/cosi-project/runtime/pkg/state" 18 | "github.com/cosi-project/runtime/pkg/state/conformance" 19 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 20 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 21 | ) 22 | 23 | func TestFilterPasshtroughConformance(t *testing.T) { 24 | t.Parallel() 25 | 26 | suite.Run(t, &conformance.StateSuite{ 27 | State: state.WrapCore( 28 | state.Filter( 29 | namespaced.NewState(inmem.Build), 30 | func(context.Context, state.Access) error { 31 | return nil 32 | }, 33 | ), 34 | ), 35 | Namespaces: []resource.Namespace{"default", "controller", "system", "runtime"}, 36 | }) 37 | } 38 | 39 | func TestFilterSingleResource(t *testing.T) { 40 | t.Parallel() 41 | 42 | const ( 43 | namespace = "default" 44 | resourceType = conformance.PathResourceType 45 | resourceID = "/var/lib" 46 | ) 47 | 48 | resources := state.WrapCore( 49 | state.Filter( 50 | namespaced.NewState(inmem.Build), 51 | func(_ context.Context, access state.Access) error { 52 | if access.ResourceNamespace != namespace || access.ResourceType != resourceType || access.ResourceID != resourceID { 53 | return fmt.Errorf("access denied") 54 | } 55 | 56 | if access.Verb == state.Watch { 57 | return fmt.Errorf("access denied") 58 | } 59 | 60 | return nil 61 | }, 62 | ), 63 | ) 64 | 65 | path := conformance.NewPathResource(namespace, resourceID) 66 | require.NoError(t, resources.Create(t.Context(), path)) 67 | 68 | path2 := conformance.NewPathResource(namespace, resourceID+"/exta") 69 | require.Error(t, resources.Create(t.Context(), path2)) 70 | 71 | _, err := resources.List(t.Context(), path.Metadata()) 72 | require.Error(t, err) 73 | 74 | require.Error(t, resources.Watch(t.Context(), path.Metadata(), nil)) 75 | require.Error(t, resources.WatchKind(t.Context(), path.Metadata(), nil)) 76 | 77 | destroyReady, err := resources.Teardown(t.Context(), path.Metadata()) 78 | require.NoError(t, err) 79 | assert.True(t, destroyReady) 80 | 81 | require.NoError(t, resources.Destroy(t.Context(), path.Metadata())) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/state/impl/inmem/backing_store.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package inmem 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cosi-project/runtime/pkg/resource" 11 | ) 12 | 13 | // LoadHandler is called for each resource loaded from the backing store. 14 | type LoadHandler func(resourceType resource.Type, resource resource.Resource) error 15 | 16 | // BackingStore provides a way to persist contents of in-memory resource collection. 17 | // 18 | // All resources are still kept in memory, but the backing store is used to persist 19 | // the resources across process restarts. 20 | // 21 | // BackingStore is responsible for marshaling/unmarshaling of resources. 22 | // 23 | // BackingStore is optional for in-memory resource collection. 24 | type BackingStore interface { 25 | // Load contents of the backing store into the in-memory resource collection. 26 | // 27 | // Handler should be called for each resource in the backing store. 28 | Load(ctx context.Context, handler LoadHandler) error 29 | // Put the resource to the backing store. 30 | Put(ctx context.Context, resourceType resource.Type, resource resource.Resource) error 31 | // Destroy the resource from the backing store. 32 | Destroy(ctx context.Context, resourceType resource.Type, resourcePointer resource.Pointer) error 33 | } 34 | -------------------------------------------------------------------------------- /pkg/state/impl/inmem/build.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package inmem 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/pkg/resource" 9 | "github.com/cosi-project/runtime/pkg/state" 10 | ) 11 | 12 | // Build a local state for namespace. 13 | func Build(ns resource.Namespace) state.CoreState { //nolint:ireturn 14 | return NewState(ns) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/state/impl/inmem/errors.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package inmem 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | ) 13 | 14 | //nolint:errname 15 | type eNotFound struct { 16 | error 17 | } 18 | 19 | func (eNotFound) NotFoundError() {} 20 | 21 | // ErrNotFound generates error compatible with state.ErrNotFound. 22 | func ErrNotFound(r resource.Pointer) error { 23 | return eNotFound{ 24 | fmt.Errorf("resource %s doesn't exist", r), 25 | } 26 | } 27 | 28 | //nolint:errname 29 | type eConflict struct { 30 | error 31 | resource resource.Pointer 32 | } 33 | 34 | func (eConflict) ConflictError() {} 35 | 36 | func (e eConflict) GetResource() resource.Pointer { 37 | return e.resource 38 | } 39 | 40 | //nolint:errname 41 | type eOwnerConflict struct { 42 | eConflict 43 | } 44 | 45 | func (eOwnerConflict) OwnerConflictError() {} 46 | 47 | //nolint:errname 48 | type ePhaseConflict struct { 49 | eConflict 50 | } 51 | 52 | func (ePhaseConflict) PhaseConflictError() {} 53 | 54 | // ErrAlreadyExists generates error compatible with state.ErrConflict. 55 | func ErrAlreadyExists(r resource.Reference) error { 56 | return eConflict{ 57 | error: fmt.Errorf("resource %s already exists", r), 58 | resource: r, 59 | } 60 | } 61 | 62 | // ErrVersionConflict generates error compatible with state.ErrConflict. 63 | func ErrVersionConflict(r resource.Reference, expected, found resource.Version) error { 64 | return eConflict{ 65 | error: fmt.Errorf("resource %s update conflict: expected version %q, actual version %q", r, expected, found), 66 | } 67 | } 68 | 69 | // ErrUpdateSameVersion generates error compatible with state.ErrConflict. 70 | func ErrUpdateSameVersion(r resource.Reference, version resource.Version) error { 71 | return eConflict{ 72 | error: fmt.Errorf("resource %s update conflict: same %q version for new and existing objects", r, version), 73 | resource: r, 74 | } 75 | } 76 | 77 | // ErrPendingFinalizers generates error compatible with state.ErrConflict. 78 | func ErrPendingFinalizers(r resource.Metadata) error { 79 | return eConflict{ 80 | error: fmt.Errorf("resource %s has pending finalizers %s", r, r.Finalizers()), 81 | resource: r, 82 | } 83 | } 84 | 85 | // ErrOwnerConflict generates error compatible with state.ErrConflict. 86 | func ErrOwnerConflict(r resource.Reference, owner string) error { 87 | return eOwnerConflict{ 88 | eConflict{ 89 | error: fmt.Errorf("resource %s is owned by %q", r, owner), 90 | resource: r, 91 | }, 92 | } 93 | } 94 | 95 | // ErrPhaseConflict generates error compatible with ErrConflict. 96 | func ErrPhaseConflict(r resource.Reference, expectedPhase resource.Phase) error { 97 | return ePhaseConflict{ 98 | eConflict{ 99 | error: fmt.Errorf("resource %s is not in phase %s", r, expectedPhase), 100 | resource: r, 101 | }, 102 | } 103 | } 104 | 105 | //nolint:errname 106 | type eInvalidWatchBookmark struct { 107 | error 108 | } 109 | 110 | func (eInvalidWatchBookmark) InvalidWatchBookmarkError() {} 111 | 112 | // ErrInvalidWatchBookmark generates error compatible with state.ErrInvalidWatchBookmark. 113 | var ErrInvalidWatchBookmark = eInvalidWatchBookmark{ 114 | errors.New("invalid watch bookmark"), 115 | } 116 | -------------------------------------------------------------------------------- /pkg/state/impl/inmem/errors_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | package inmem_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | "github.com/cosi-project/runtime/pkg/state" 13 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 14 | ) 15 | 16 | func TestErrors(t *testing.T) { 17 | t.Parallel() 18 | 19 | assert.True(t, state.IsNotFoundError(inmem.ErrNotFound(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)))) 20 | assert.True(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)))) 21 | assert.True(t, state.IsConflictError(inmem.ErrVersionConflict(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined), resource.VersionUndefined, resource.VersionUndefined))) 22 | assert.True(t, state.IsConflictError(inmem.ErrPendingFinalizers(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)))) 23 | assert.True(t, state.IsConflictError(inmem.ErrOwnerConflict(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined), "owner"))) 24 | assert.True(t, state.IsOwnerConflictError(inmem.ErrOwnerConflict(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined), "owner"))) 25 | assert.True(t, state.IsPhaseConflictError(inmem.ErrPhaseConflict(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined), resource.PhaseTearingDown))) 26 | 27 | assert.True(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)), state.WithResourceNamespace("ns"))) 28 | assert.False(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)), state.WithResourceNamespace("a"))) 29 | 30 | assert.True(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)), state.WithResourceType("a"))) 31 | assert.False(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)), state.WithResourceType("z"))) 32 | 33 | assert.True(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)), state.WithResourceType("a"), state.WithResourceNamespace("ns"))) 34 | assert.False(t, state.IsConflictError(inmem.ErrAlreadyExists(resource.NewMetadata("ns", "a", "b", resource.VersionUndefined)), state.WithResourceType("z"), state.WithResourceNamespace("ns"))) 35 | 36 | assert.True(t, state.IsInvalidWatchBookmarkError(inmem.ErrInvalidWatchBookmark)) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/state/impl/inmem/options.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package inmem 6 | 7 | // StateOptions configure inmem.State. 8 | type StateOptions struct { 9 | BackingStore BackingStore 10 | HistoryMaxCapacity int 11 | HistoryInitialCapacity int 12 | HistoryGap int 13 | } 14 | 15 | // StateOption applies settings to StateOptions. 16 | type StateOption func(options *StateOptions) 17 | 18 | // WithHistoryCapacity sets history depth for a given namspace and resource. 19 | // 20 | // Deprecated: use WithHistoryMaxCapacity and WithHistoryInitialCapacity instead. 21 | func WithHistoryCapacity(capacity int) StateOption { 22 | return func(options *StateOptions) { 23 | options.HistoryMaxCapacity = capacity 24 | options.HistoryInitialCapacity = capacity 25 | } 26 | } 27 | 28 | // WithHistoryMaxCapacity sets history depth for a given namspace and resource. 29 | // 30 | // Deep history requires more memory, but allows Watch request to return more historical entries, and also 31 | // acts like a buffer if watch consumer can't keep up with events. 32 | // 33 | // Max capacity limits the maximum depth of the history buffer. 34 | func WithHistoryMaxCapacity(maxCapacity int) StateOption { 35 | return func(options *StateOptions) { 36 | options.HistoryMaxCapacity = maxCapacity 37 | 38 | if options.HistoryInitialCapacity > options.HistoryMaxCapacity { 39 | options.HistoryInitialCapacity = options.HistoryMaxCapacity 40 | } 41 | } 42 | } 43 | 44 | // WithHistoryInitialCapacity sets initial history depth for a given namspace and resource. 45 | // 46 | // Deep history requires more memory, but allows Watch request to return more historical entries, and also 47 | // acts like a buffer if watch consumer can't keep up with events. 48 | // 49 | // Initial capacity of the history buffer is used at the creation time and grows to the max capacity 50 | // based on the number of events. 51 | func WithHistoryInitialCapacity(initialCapacity int) StateOption { 52 | return func(options *StateOptions) { 53 | options.HistoryInitialCapacity = initialCapacity 54 | 55 | if options.HistoryMaxCapacity < options.HistoryInitialCapacity { 56 | options.HistoryMaxCapacity = options.HistoryInitialCapacity 57 | } 58 | } 59 | } 60 | 61 | // WithHistoryGap sets a safety gap between watch events consumers and events producers. 62 | // 63 | // Bigger gap reduces effective history depth (HistoryCapacity - HistoryGap). 64 | // Smaller gap might result in buffer overruns if consumer can't keep up with the events. 65 | // It's recommended to have gap 5% of the capacity. 66 | func WithHistoryGap(gap int) StateOption { 67 | return func(options *StateOptions) { 68 | options.HistoryGap = gap 69 | } 70 | } 71 | 72 | // WithBackingStore sets a BackingStore for a in-memory resource collection. 73 | // 74 | // Default value is nil (no backing store). 75 | func WithBackingStore(store BackingStore) StateOption { 76 | return func(options *StateOptions) { 77 | options.BackingStore = store 78 | } 79 | } 80 | 81 | // DefaultStateOptions returns default value of StateOptions. 82 | func DefaultStateOptions() StateOptions { 83 | return StateOptions{ 84 | HistoryMaxCapacity: 100, 85 | HistoryInitialCapacity: 100, 86 | HistoryGap: 5, 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/state/impl/namespaced/namespaced_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package namespaced_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/suite" 11 | "go.uber.org/goleak" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource" 14 | "github.com/cosi-project/runtime/pkg/state" 15 | "github.com/cosi-project/runtime/pkg/state/conformance" 16 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 17 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 18 | ) 19 | 20 | func TestNamespacedConformance(t *testing.T) { 21 | t.Parallel() 22 | 23 | defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) 24 | 25 | suite.Run(t, &conformance.StateSuite{ 26 | State: state.WrapCore(namespaced.NewState(inmem.Build)), 27 | Namespaces: []resource.Namespace{"default", "controller", "system", "runtime"}, 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/state/impl/store/bolt/bbolt.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package bolt implements inmem resource collection backing store in BoltDB (github.com/etcd-io/bbolt). 6 | package bolt 7 | 8 | import ( 9 | "go.etcd.io/bbolt" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | "github.com/cosi-project/runtime/pkg/state/impl/store" 13 | ) 14 | 15 | // BackingStore implements inmem.BackingStore using BoltDB. 16 | // 17 | // Layout of the database: 18 | // 19 | // -> top-level bucket: $namespace 20 | // -> bucket: $resourceType 21 | // -> key: $resourceID 22 | // -> value: marshaled resource 23 | type BackingStore struct { 24 | db *bbolt.DB 25 | marshaler store.Marshaler 26 | } 27 | 28 | // NewBackingStore opens the BoltDB store with the given marshaler. 29 | func NewBackingStore(opener func() (*bbolt.DB, error), marshaler store.Marshaler) (*BackingStore, error) { 30 | db, err := opener() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return &BackingStore{ 36 | db: db, 37 | marshaler: marshaler, 38 | }, nil 39 | } 40 | 41 | // Close the database. 42 | func (store *BackingStore) Close() error { 43 | return store.db.Close() 44 | } 45 | 46 | // WithNamespace returns an implementation of inmem.BackingStore interface for a given namespace. 47 | func (store *BackingStore) WithNamespace(namespace resource.Namespace) *NamespacedBackingStore { 48 | return &NamespacedBackingStore{ 49 | store: store, 50 | namespace: namespace, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/state/impl/store/bolt/bbolt_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package bolt_test 6 | 7 | import ( 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/siderolabs/gen/ensure" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "go.etcd.io/bbolt" 15 | 16 | "github.com/cosi-project/runtime/pkg/resource" 17 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 18 | "github.com/cosi-project/runtime/pkg/state/conformance" 19 | "github.com/cosi-project/runtime/pkg/state/impl/store" 20 | "github.com/cosi-project/runtime/pkg/state/impl/store/bolt" 21 | ) 22 | 23 | func init() { 24 | ensure.NoError(protobuf.RegisterResource(conformance.PathResourceType, &conformance.PathResource{})) 25 | } 26 | 27 | func TestBboltStore(t *testing.T) { //nolint:tparallel 28 | t.Parallel() 29 | 30 | tmpDir := t.TempDir() 31 | 32 | marshaler := store.ProtobufMarshaler{} 33 | 34 | store, err := bolt.NewBackingStore( 35 | func() (*bbolt.DB, error) { 36 | return bbolt.Open(filepath.Join(tmpDir, "test.db"), 0o600, nil) 37 | }, 38 | marshaler, 39 | ) 40 | require.NoError(t, err) 41 | 42 | t.Cleanup(func() { 43 | assert.NoError(t, store.Close()) 44 | }) 45 | 46 | path1 := conformance.NewPathResource("ns1", "var/run1") 47 | path2 := conformance.NewPathResource("ns1", "var/run2") 48 | path3 := conformance.NewPathResource("ns2", "var/run3") 49 | 50 | t.Run("Fill", func(t *testing.T) { 51 | require.NoError(t, store.WithNamespace(path1.Metadata().Namespace()).Put(t.Context(), path1.Metadata().Type(), path1)) 52 | require.NoError(t, store.WithNamespace(path2.Metadata().Namespace()).Put(t.Context(), path2.Metadata().Type(), path2)) 53 | require.NoError(t, store.WithNamespace(path2.Metadata().Namespace()).Put(t.Context(), path2.Metadata().Type(), path2)) 54 | require.NoError(t, store.WithNamespace(path3.Metadata().Namespace()).Put(t.Context(), path3.Metadata().Type(), path3)) 55 | }) 56 | 57 | t.Run("Remove", func(t *testing.T) { 58 | require.NoError(t, store.WithNamespace(path1.Metadata().Namespace()).Destroy(t.Context(), path1.Metadata().Type(), path1.Metadata())) 59 | }) 60 | 61 | t.Run("Load", func(t *testing.T) { 62 | var resources []resource.Resource 63 | 64 | require.NoError(t, store.WithNamespace(path1.Metadata().Namespace()).Load(t.Context(), func(_ resource.Type, resource resource.Resource) error { 65 | resources = append(resources, resource) 66 | 67 | return nil 68 | })) 69 | 70 | require.Len(t, resources, 1) 71 | assert.True(t, resource.Equal(path2, resources[0])) 72 | 73 | resources = nil 74 | 75 | require.NoError(t, store.WithNamespace(path3.Metadata().Namespace()).Load(t.Context(), func(_ resource.Type, resource resource.Resource) error { 76 | resources = append(resources, resource) 77 | 78 | return nil 79 | })) 80 | 81 | require.Len(t, resources, 1) 82 | assert.True(t, resource.Equal(path3, resources[0])) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/state/impl/store/bolt/conformance_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package bolt_test 6 | 7 | import ( 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "github.com/stretchr/testify/suite" 14 | "go.etcd.io/bbolt" 15 | 16 | "github.com/cosi-project/runtime/pkg/resource" 17 | "github.com/cosi-project/runtime/pkg/state" 18 | "github.com/cosi-project/runtime/pkg/state/conformance" 19 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 20 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 21 | "github.com/cosi-project/runtime/pkg/state/impl/store" 22 | "github.com/cosi-project/runtime/pkg/state/impl/store/bolt" 23 | "github.com/cosi-project/runtime/pkg/state/impl/store/encryption" 24 | ) 25 | 26 | func TestBboltConformance(t *testing.T) { 27 | t.Parallel() 28 | 29 | tmpDir := t.TempDir() 30 | 31 | marshaler := encryption.NewMarshaler( 32 | store.ProtobufMarshaler{}, 33 | encryption.NewCipher( 34 | encryption.KeyProviderFunc(func() ([]byte, error) { 35 | return []byte("this key len is exactly 32 bytes"), nil 36 | }), 37 | ), 38 | ) 39 | 40 | backingStore, err := bolt.NewBackingStore( 41 | func() (*bbolt.DB, error) { 42 | return bbolt.Open(filepath.Join(tmpDir, "test.db"), 0o600, nil) 43 | }, 44 | marshaler, 45 | ) 46 | require.NoError(t, err) 47 | 48 | t.Cleanup(func() { 49 | assert.NoError(t, backingStore.Close()) 50 | }) 51 | 52 | suite.Run(t, &conformance.StateSuite{ 53 | State: state.WrapCore(namespaced.NewState( 54 | func(ns resource.Namespace) state.CoreState { 55 | return inmem.NewStateWithOptions( 56 | inmem.WithBackingStore(backingStore.WithNamespace(ns)), 57 | )(ns) 58 | }, 59 | )), 60 | Namespaces: []resource.Namespace{"default", "controller", "system", "runtime"}, 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/state/impl/store/bolt/namespaced.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package bolt 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "go.etcd.io/bbolt" 12 | 13 | "github.com/cosi-project/runtime/pkg/resource" 14 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 15 | ) 16 | 17 | var _ inmem.BackingStore = (*NamespacedBackingStore)(nil) 18 | 19 | // NamespacedBackingStore implements inmem.BackingStore for a given namespace. 20 | type NamespacedBackingStore struct { 21 | store *BackingStore 22 | namespace resource.Namespace 23 | } 24 | 25 | // Put implements inmem.BackingStore. 26 | func (store *NamespacedBackingStore) Put(_ context.Context, resourceType resource.Type, res resource.Resource) error { 27 | marshaled, err := store.store.marshaler.MarshalResource(res) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | return store.store.db.Update(func(tx *bbolt.Tx) error { 33 | bucket, err := tx.CreateBucketIfNotExists([]byte(store.namespace)) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | typeBucket, err := bucket.CreateBucketIfNotExists([]byte(resourceType)) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | return typeBucket.Put([]byte(res.Metadata().ID()), marshaled) 44 | }) 45 | } 46 | 47 | // Destroy implements inmem.BackingStore. 48 | func (store *NamespacedBackingStore) Destroy(_ context.Context, resourceType resource.Type, ptr resource.Pointer) error { 49 | return store.store.db.Update(func(tx *bbolt.Tx) error { 50 | bucket, err := tx.CreateBucketIfNotExists([]byte(store.namespace)) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | typeBucket, err := bucket.CreateBucketIfNotExists([]byte(resourceType)) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return typeBucket.Delete([]byte(ptr.ID())) 61 | }) 62 | } 63 | 64 | // Load implements inmem.BackingStore. 65 | func (store *NamespacedBackingStore) Load(_ context.Context, handler inmem.LoadHandler) error { 66 | return store.store.db.View(func(tx *bbolt.Tx) error { 67 | bucket := tx.Bucket([]byte(store.namespace)) 68 | if bucket == nil { 69 | return nil 70 | } 71 | 72 | return bucket.ForEach(func(typeKey, val []byte) error { 73 | if val != nil { 74 | return fmt.Errorf("expected only buckets, got value for key %v", string(typeKey)) 75 | } 76 | 77 | typeBucket := bucket.Bucket(typeKey) 78 | resourceType := resource.Type(typeKey) 79 | 80 | return typeBucket.ForEach(func(_, marshaled []byte) error { 81 | res, err := store.store.marshaler.UnmarshalResource(marshaled) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | return handler(resourceType, res) 87 | }) 88 | }) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/state/impl/store/compression/compression.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package compression provides compression support for [store.Marshaler]. 6 | package compression 7 | 8 | import ( 9 | "fmt" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | "github.com/cosi-project/runtime/pkg/state/impl/store" 13 | ) 14 | 15 | // Marshaler compresses and decompresses data from the underlying marshaler. 16 | // 17 | // Marshaler also handles case when the underlying data is not compressed. 18 | // 19 | // The trick used is that `0x00` can't start a valid protobuf message, so we use 20 | // `0x00` as a marker for compressed data. 21 | type Marshaler struct { 22 | underlying store.Marshaler 23 | compressor Compressor 24 | minSize int 25 | } 26 | 27 | // Compressor defines interface for compression and decompression. 28 | type Compressor interface { 29 | Compress(prefix, data []byte) ([]byte, error) 30 | Decompress(data []byte) ([]byte, error) 31 | ID() byte 32 | } 33 | 34 | // NewMarshaler creates new Marshaler. 35 | func NewMarshaler(m store.Marshaler, c Compressor, minSize int) *Marshaler { 36 | return &Marshaler{underlying: m, compressor: c, minSize: minSize} 37 | } 38 | 39 | // MarshalResource implements Marshaler interface. 40 | func (m *Marshaler) MarshalResource(r resource.Resource) ([]byte, error) { 41 | encoded, err := m.underlying.MarshalResource(r) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to marshal resource: %w", err) 44 | } 45 | 46 | if len(encoded) < m.minSize { 47 | return encoded, nil 48 | } 49 | 50 | compressed, err := m.compressor.Compress([]byte{0x0, m.compressor.ID()}, encoded) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to compress: %w", err) 53 | } 54 | 55 | return compressed, nil 56 | } 57 | 58 | // UnmarshalResource implements Marshaler interface. 59 | func (m *Marshaler) UnmarshalResource(b []byte) (resource.Resource, error) { //nolint:ireturn 60 | if len(b) > 1 && b[0] == 0x0 { 61 | id := b[1] 62 | 63 | if id != m.compressor.ID() { 64 | return nil, fmt.Errorf("unknown compression ID: %d", id) 65 | } 66 | 67 | var err error 68 | 69 | // Data is compressed, decompress it. 70 | b, err = m.compressor.Decompress(b[2:]) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to decompress: %w", err) 73 | } 74 | } 75 | 76 | return m.underlying.UnmarshalResource(b) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/state/impl/store/compression/zstd.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package compression 6 | 7 | import ( 8 | "github.com/klauspost/compress/zstd" 9 | "github.com/siderolabs/gen/ensure" 10 | ) 11 | 12 | // ZStd returns zstd compressor. 13 | func ZStd() Compressor { 14 | return &zstdCompressor{ 15 | encoder: ensure.Value(zstd.NewWriter( 16 | nil, 17 | zstd.WithEncoderConcurrency(2), 18 | zstd.WithWindowSize(zstd.MinWindowSize), 19 | )), 20 | decoder: ensure.Value(zstd.NewReader(nil, zstd.WithDecoderConcurrency(0))), 21 | } 22 | } 23 | 24 | var _ Compressor = (*zstdCompressor)(nil) 25 | 26 | type zstdCompressor struct { 27 | encoder *zstd.Encoder 28 | decoder *zstd.Decoder 29 | } 30 | 31 | func (z *zstdCompressor) Compress(prefix, data []byte) ([]byte, error) { 32 | return z.encoder.EncodeAll(data, prefix), nil 33 | } 34 | 35 | func (z *zstdCompressor) Decompress(data []byte) ([]byte, error) { 36 | return z.decoder.DecodeAll(data, nil) 37 | } 38 | 39 | func (z *zstdCompressor) ID() byte { 40 | return 'z' 41 | } 42 | -------------------------------------------------------------------------------- /pkg/state/impl/store/protobuf.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package store 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/api/v1alpha1" 9 | "github.com/cosi-project/runtime/pkg/resource" 10 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 11 | ) 12 | 13 | // ProtobufMarshaler implements Marshaler using resources protobuf representation. 14 | // 15 | // Resources should implement protobuf marshaling. 16 | type ProtobufMarshaler struct{} 17 | 18 | // MarshalResource implements Marshaler interface. 19 | func (ProtobufMarshaler) MarshalResource(r resource.Resource) ([]byte, error) { 20 | protoR, err := protobuf.FromResource(r, protobuf.WithoutYAML()) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | protoD, err := protoR.Marshal() 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return protobuf.ProtoMarshal(protoD) 31 | } 32 | 33 | // UnmarshalResource implements Marshaler interface. 34 | func (ProtobufMarshaler) UnmarshalResource(b []byte) (resource.Resource, error) { //nolint:ireturn 35 | var protoD v1alpha1.Resource 36 | 37 | if err := protobuf.ProtoUnmarshal(b, &protoD); err != nil { 38 | return nil, err 39 | } 40 | 41 | protoR, err := protobuf.Unmarshal(&protoD) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return protobuf.UnmarshalResource(protoR) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/state/impl/store/protobuf_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package store_test 6 | 7 | import ( 8 | "strings" 9 | "testing" 10 | 11 | "github.com/siderolabs/gen/ensure" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/cosi-project/runtime/pkg/resource" 16 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 17 | "github.com/cosi-project/runtime/pkg/state/conformance" 18 | "github.com/cosi-project/runtime/pkg/state/impl/store" 19 | ) 20 | 21 | func TestProtobufMarshaler(t *testing.T) { 22 | path := conformance.NewPathResource("default", "var/run") 23 | path.Metadata().Labels().Set("app", "foo") 24 | path.Metadata().Annotations().Set("ttl", "1h") 25 | path.Metadata().Finalizers().Add("controller1") 26 | 27 | marshaler := store.ProtobufMarshaler{} 28 | 29 | data, err := marshaler.MarshalResource(path) 30 | require.NoError(t, err) 31 | 32 | unmarshaled, err := marshaler.UnmarshalResource(data) 33 | require.NoError(t, err) 34 | 35 | assert.Equal(t, resource.String(path), resource.String(unmarshaled)) 36 | } 37 | 38 | func BenchmarkProto(b *testing.B) { 39 | path := conformance.NewPathResource("default", strings.Repeat("var/run", 100)) 40 | path.Metadata().Labels().Set("app", "foo") 41 | path.Metadata().Annotations().Set("ttl", "1h") 42 | path.Metadata().Finalizers().Add("controller1") 43 | 44 | marshaler := store.ProtobufMarshaler{} 45 | 46 | for range b.N { 47 | _, err := marshaler.MarshalResource(path) 48 | require.NoError(b, err) 49 | } 50 | } 51 | 52 | func init() { 53 | ensure.NoError(protobuf.RegisterResource(conformance.PathResourceType, &conformance.PathResource{})) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/state/impl/store/store.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package store provides support for in-memory backing store implementations. 6 | package store 7 | 8 | import "github.com/cosi-project/runtime/pkg/resource" 9 | 10 | // Marshaler provides marshal/unmarshal for resources and backing store. 11 | type Marshaler interface { 12 | MarshalResource(resource.Resource) ([]byte, error) 13 | UnmarshalResource([]byte) (resource.Resource, error) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/state/owned/owned.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package owned contains the wrapping state for enforcing ownership. 6 | package owned 7 | 8 | import ( 9 | "context" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | "github.com/cosi-project/runtime/pkg/state" 13 | ) 14 | 15 | // Reader provides read-only access to the state. 16 | // 17 | // Is is identical to the [state.State] interface, and it does not 18 | // provide any additional methods. 19 | type Reader interface { 20 | Get(context.Context, resource.Pointer, ...state.GetOption) (resource.Resource, error) 21 | List(context.Context, resource.Kind, ...state.ListOption) (resource.List, error) 22 | ContextWithTeardown(context.Context, resource.Pointer) (context.Context, error) 23 | } 24 | 25 | // Writer provides write access to the state. 26 | // 27 | // Write methods enforce that the resources are owned by the designated owner. 28 | type Writer interface { 29 | Create(context.Context, resource.Resource) error 30 | Update(context.Context, resource.Resource) error 31 | Modify(context.Context, resource.Resource, func(resource.Resource) error, ...ModifyOption) error 32 | ModifyWithResult(context.Context, resource.Resource, func(resource.Resource) error, ...ModifyOption) (resource.Resource, error) 33 | Teardown(context.Context, resource.Pointer, ...DeleteOption) (bool, error) 34 | Destroy(context.Context, resource.Pointer, ...DeleteOption) error 35 | 36 | AddFinalizer(context.Context, resource.Pointer, ...resource.Finalizer) error 37 | RemoveFinalizer(context.Context, resource.Pointer, ...resource.Finalizer) error 38 | } 39 | 40 | // ReaderWriter combines Reader and Writer interfaces. 41 | type ReaderWriter interface { 42 | Reader 43 | Writer 44 | } 45 | 46 | // DeleteOption for operation Teardown/Destroy. 47 | type DeleteOption func(*DeleteOptions) 48 | 49 | // DeleteOptions for operation Teardown/Destroy. 50 | type DeleteOptions struct { 51 | Owner *string 52 | } 53 | 54 | // WithOwner allows to specify owner of the resource. 55 | func WithOwner(owner string) DeleteOption { 56 | return func(o *DeleteOptions) { 57 | o.Owner = &owner 58 | } 59 | } 60 | 61 | // ToDeleteOptions converts variadic options to DeleteOptions. 62 | func ToDeleteOptions(opts ...DeleteOption) DeleteOptions { 63 | var options DeleteOptions 64 | 65 | for _, opt := range opts { 66 | opt(&options) 67 | } 68 | 69 | return options 70 | } 71 | 72 | // ModifyOption for operation Modify. 73 | type ModifyOption func(*ModifyOptions) 74 | 75 | // ModifyOptions for operation Modify. 76 | type ModifyOptions struct { 77 | ExpectedPhase *resource.Phase 78 | } 79 | 80 | // WithExpectedPhase allows to specify expected phase of the resource. 81 | func WithExpectedPhase(phase resource.Phase) ModifyOption { 82 | return func(o *ModifyOptions) { 83 | o.ExpectedPhase = &phase 84 | } 85 | } 86 | 87 | // WithExpectedPhaseAny allows to specify any phase of the resource. 88 | func WithExpectedPhaseAny() ModifyOption { 89 | return func(o *ModifyOptions) { 90 | o.ExpectedPhase = nil 91 | } 92 | } 93 | 94 | // ToModifyOptions converts variadic options to ModifyOptions. 95 | func ToModifyOptions(opts ...ModifyOption) ModifyOptions { 96 | phase := resource.PhaseRunning 97 | 98 | options := ModifyOptions{ 99 | ExpectedPhase: &phase, 100 | } 101 | 102 | for _, opt := range opts { 103 | opt(&options) 104 | } 105 | 106 | return options 107 | } 108 | -------------------------------------------------------------------------------- /pkg/state/protobuf/client/errors.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import "github.com/cosi-project/runtime/pkg/resource" 8 | 9 | //nolint:errname 10 | type eNotFound struct { 11 | error 12 | } 13 | 14 | func (eNotFound) NotFoundError() {} 15 | 16 | //nolint:errname 17 | type eConflict struct { 18 | error 19 | resource resource.Pointer 20 | } 21 | 22 | func (eConflict) ConflictError() {} 23 | 24 | func (e eConflict) GetResource() resource.Pointer { 25 | return e.resource 26 | } 27 | 28 | //nolint:errname 29 | type eOwnerConflict struct { 30 | eConflict 31 | } 32 | 33 | func (eOwnerConflict) OwnerConflictError() {} 34 | 35 | //nolint:errname 36 | type ePhaseConflict struct { 37 | eConflict 38 | } 39 | 40 | func (ePhaseConflict) PhaseConflictError() {} 41 | 42 | //nolint:errname 43 | type eInvalidWatchBookmark struct { 44 | error 45 | } 46 | 47 | func (eInvalidWatchBookmark) InvalidWatchBookmarkError() {} 48 | -------------------------------------------------------------------------------- /pkg/state/protobuf/client/id_query.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "github.com/cosi-project/runtime/api/v1alpha1" 9 | "github.com/cosi-project/runtime/pkg/resource" 10 | ) 11 | 12 | func transformIDQuery(input resource.IDQuery) *v1alpha1.IDQuery { 13 | if input.Regexp == nil { 14 | return nil 15 | } 16 | 17 | return &v1alpha1.IDQuery{ 18 | Regexp: input.Regexp.String(), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/state/protobuf/client/label_query.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package client 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/cosi-project/runtime/api/v1alpha1" 11 | "github.com/cosi-project/runtime/pkg/resource" 12 | ) 13 | 14 | func transformLabelQuery(input resource.LabelQuery) (*v1alpha1.LabelQuery, error) { 15 | labelQuery := &v1alpha1.LabelQuery{ 16 | Terms: make([]*v1alpha1.LabelTerm, 0, len(input.Terms)), 17 | } 18 | 19 | for _, term := range input.Terms { 20 | switch term.Op { 21 | case resource.LabelOpEqual: 22 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 23 | Key: term.Key, 24 | Value: term.Value, 25 | Op: v1alpha1.LabelTerm_EQUAL, 26 | Invert: term.Invert, 27 | }) 28 | case resource.LabelOpExists: 29 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 30 | Key: term.Key, 31 | Op: v1alpha1.LabelTerm_EXISTS, 32 | Invert: term.Invert, 33 | }) 34 | case resource.LabelOpIn: 35 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 36 | Key: term.Key, 37 | Value: term.Value, 38 | Op: v1alpha1.LabelTerm_IN, 39 | Invert: term.Invert, 40 | }) 41 | case resource.LabelOpLT: 42 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 43 | Key: term.Key, 44 | Value: term.Value, 45 | Op: v1alpha1.LabelTerm_LT, 46 | Invert: term.Invert, 47 | }) 48 | case resource.LabelOpLTE: 49 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 50 | Key: term.Key, 51 | Value: term.Value, 52 | Op: v1alpha1.LabelTerm_LTE, 53 | Invert: term.Invert, 54 | }) 55 | case resource.LabelOpLTNumeric: 56 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 57 | Key: term.Key, 58 | Value: term.Value, 59 | Op: v1alpha1.LabelTerm_LT_NUMERIC, 60 | Invert: term.Invert, 61 | }) 62 | case resource.LabelOpLTENumeric: 63 | labelQuery.Terms = append(labelQuery.Terms, &v1alpha1.LabelTerm{ 64 | Key: term.Key, 65 | Value: term.Value, 66 | Op: v1alpha1.LabelTerm_LTE_NUMERIC, 67 | Invert: term.Invert, 68 | }) 69 | default: 70 | return nil, fmt.Errorf("unsupported label query operator: %v", term.Op) 71 | } 72 | } 73 | 74 | return labelQuery, nil 75 | } 76 | -------------------------------------------------------------------------------- /pkg/state/protobuf/protobuf.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package protobuf provides wrappers/adapters between gRPC service and state.CoreState. 6 | package protobuf 7 | -------------------------------------------------------------------------------- /pkg/state/protobuf/runtime_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package protobuf_test 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | "time" 11 | 12 | "github.com/siderolabs/gen/ensure" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "go.uber.org/zap/zaptest" 16 | 17 | "github.com/cosi-project/runtime/api/v1alpha1" 18 | "github.com/cosi-project/runtime/pkg/controller/conformance" 19 | "github.com/cosi-project/runtime/pkg/controller/runtime" 20 | "github.com/cosi-project/runtime/pkg/future" 21 | "github.com/cosi-project/runtime/pkg/resource/protobuf" 22 | "github.com/cosi-project/runtime/pkg/state" 23 | "github.com/cosi-project/runtime/pkg/state/protobuf/client" 24 | ) 25 | 26 | func init() { 27 | ensure.NoError(protobuf.RegisterResource(conformance.IntResourceType, &conformance.IntResource{})) 28 | ensure.NoError(protobuf.RegisterResource(conformance.StrResourceType, &conformance.StrResource{})) 29 | } 30 | 31 | func TestProtobufWatchRuntimeRestart(t *testing.T) { 32 | grpcConn, grpcServer, restartServer, _ := ProtobufSetup(t) 33 | 34 | stateClient := v1alpha1.NewStateClient(grpcConn) 35 | 36 | logger := zaptest.NewLogger(t) 37 | 38 | st := state.WrapCore(client.NewAdapter(stateClient, 39 | client.WithRetryLogger(logger), 40 | )) 41 | 42 | rt, err := runtime.NewRuntime(st, logger) 43 | require.NoError(t, err) 44 | 45 | ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) 46 | t.Cleanup(cancel) 47 | 48 | ctx, errCh := future.GoContext(ctx, rt.Run) 49 | 50 | require.NoError(t, rt.RegisterController(&conformance.IntToStrController{ 51 | SourceNamespace: "one", 52 | TargetNamespace: "default", 53 | })) 54 | require.NoError(t, rt.RegisterController(&conformance.IntDoublerController{ 55 | SourceNamespace: "another", 56 | TargetNamespace: "default", 57 | })) 58 | 59 | require.NoError(t, st.Create(ctx, conformance.NewIntResource("one", "1", 1))) 60 | require.NoError(t, st.Create(ctx, conformance.NewIntResource("another", "4", 4))) 61 | 62 | // wait for controller to start up 63 | _, err = st.WatchFor(ctx, conformance.NewStrResource("default", "1", "1").Metadata(), state.WithEventTypes(state.Created)) 64 | require.NoError(t, err) 65 | _, err = st.WatchFor(ctx, conformance.NewIntResource("default", "4", 8).Metadata(), state.WithEventTypes(state.Created)) 66 | require.NoError(t, err) 67 | 68 | // abort the server, watch should enter retry loop 69 | grpcServer.Stop() 70 | 71 | select { 72 | case err = <-errCh: 73 | require.Fail(t, "runtime finished unexpectedly", "error: %v", err) 74 | case <-time.After(100 * time.Millisecond): 75 | } 76 | 77 | _ = restartServer() 78 | 79 | // now another resource 80 | require.EventuallyWithT(t, func(collectT *assert.CollectT) { 81 | asrt := assert.New(collectT) 82 | 83 | // the call might fail as the connection is re-established 84 | asrt.NoError(st.Create(ctx, conformance.NewIntResource("another", "2", 2))) 85 | }, time.Second, 10*time.Millisecond, "failed to create resource") 86 | 87 | // wait for controller to start up 88 | _, err = st.WatchFor(ctx, conformance.NewIntResource("default", "2", 4).Metadata(), state.WithEventTypes(state.Created)) 89 | require.NoError(t, err) 90 | 91 | cancel() 92 | 93 | err = <-errCh 94 | require.NoError(t, err) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/state/protobuf/server/helpers.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package server 6 | 7 | import ( 8 | "regexp" 9 | 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | 13 | "github.com/cosi-project/runtime/api/v1alpha1" 14 | "github.com/cosi-project/runtime/pkg/resource" 15 | ) 16 | 17 | // ConvertLabelQuery converts protobuf representation of LabelQuery to state representation. 18 | func ConvertLabelQuery(terms []*v1alpha1.LabelTerm) ([]resource.LabelQueryOption, error) { 19 | labelOpts := make([]resource.LabelQueryOption, 0, len(terms)) 20 | 21 | for _, term := range terms { 22 | var opts []resource.TermOption 23 | 24 | if term.Invert { 25 | opts = append(opts, resource.NotMatches) 26 | } 27 | 28 | switch term.Op { 29 | case v1alpha1.LabelTerm_EQUAL: 30 | labelOpts = append(labelOpts, resource.LabelEqual(term.Key, term.Value[0], opts...)) 31 | case v1alpha1.LabelTerm_EXISTS: 32 | labelOpts = append(labelOpts, resource.LabelExists(term.Key, opts...)) 33 | case v1alpha1.LabelTerm_NOT_EXISTS: //nolint:staticcheck 34 | labelOpts = append(labelOpts, resource.LabelExists(term.Key, resource.NotMatches)) 35 | case v1alpha1.LabelTerm_IN: 36 | labelOpts = append(labelOpts, resource.LabelIn(term.Key, term.Value, opts...)) 37 | case v1alpha1.LabelTerm_LT: 38 | labelOpts = append(labelOpts, resource.LabelLT(term.Key, term.Value[0], opts...)) 39 | case v1alpha1.LabelTerm_LTE: 40 | labelOpts = append(labelOpts, resource.LabelLTE(term.Key, term.Value[0], opts...)) 41 | case v1alpha1.LabelTerm_LT_NUMERIC: 42 | labelOpts = append(labelOpts, resource.LabelLTNumeric(term.Key, term.Value[0], opts...)) 43 | case v1alpha1.LabelTerm_LTE_NUMERIC: 44 | labelOpts = append(labelOpts, resource.LabelLTENumeric(term.Key, term.Value[0], opts...)) 45 | default: 46 | return nil, status.Errorf(codes.Unimplemented, "unsupported label query operator: %v", term.Op) 47 | } 48 | } 49 | 50 | return labelOpts, nil 51 | } 52 | 53 | // ConvertIDQuery converts protobuf representation of IDQuery to state representation. 54 | func ConvertIDQuery(input *v1alpha1.IDQuery) ([]resource.IDQueryOption, error) { 55 | if input == nil || input.Regexp == "" { 56 | return nil, nil 57 | } 58 | 59 | re, err := regexp.Compile(input.Regexp) 60 | if err != nil { 61 | return nil, status.Errorf(codes.InvalidArgument, "failed to compile regexp: %v", err) 62 | } 63 | 64 | return []resource.IDQueryOption{resource.IDRegexpMatch(re)}, nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/state/registry/namespace.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package registry 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/cosi-project/runtime/pkg/resource" 11 | "github.com/cosi-project/runtime/pkg/resource/meta" 12 | "github.com/cosi-project/runtime/pkg/state" 13 | ) 14 | 15 | // NamespaceRegistry facilitates tracking namespaces. 16 | type NamespaceRegistry struct { 17 | state state.State 18 | } 19 | 20 | // NewNamespaceRegistry creates new NamespaceRegistry. 21 | func NewNamespaceRegistry(state state.State) *NamespaceRegistry { 22 | return &NamespaceRegistry{ 23 | state: state, 24 | } 25 | } 26 | 27 | // RegisterDefault registers default namespaces. 28 | func (registry *NamespaceRegistry) RegisterDefault(ctx context.Context) error { 29 | return registry.Register(ctx, meta.NamespaceName, "Metadata namespace which contains resource and namespace definitions.") 30 | } 31 | 32 | // Register a namespace. 33 | func (registry *NamespaceRegistry) Register(ctx context.Context, ns resource.Namespace, description string) error { 34 | return registry.state.Create(ctx, meta.NewNamespace(ns, meta.NamespaceSpec{ 35 | Description: description, 36 | }), state.WithCreateOwner(meta.Owner)) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/state/registry/namespace_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package registry_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/cosi-project/runtime/pkg/state" 13 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 14 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 15 | "github.com/cosi-project/runtime/pkg/state/registry" 16 | ) 17 | 18 | func TestNamespaceRegistry(t *testing.T) { 19 | t.Parallel() 20 | 21 | r := registry.NewNamespaceRegistry(state.WrapCore(namespaced.NewState(inmem.Build))) 22 | 23 | assert.NoError(t, r.RegisterDefault(t.Context())) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/state/registry/registry.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package registry provides registries for namespaces and resource definitions. 6 | package registry 7 | -------------------------------------------------------------------------------- /pkg/state/registry/resource.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package registry 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/cosi-project/runtime/pkg/resource/meta" 12 | "github.com/cosi-project/runtime/pkg/state" 13 | ) 14 | 15 | // ResourceRegistry facilitates tracking namespaces. 16 | type ResourceRegistry struct { 17 | state state.State 18 | } 19 | 20 | // NewResourceRegistry creates new ResourceRegistry. 21 | func NewResourceRegistry(state state.State) *ResourceRegistry { 22 | return &ResourceRegistry{ 23 | state: state, 24 | } 25 | } 26 | 27 | // RegisterDefault registers default resource definitions. 28 | func (registry *ResourceRegistry) RegisterDefault(ctx context.Context) error { 29 | for _, r := range []meta.ResourceWithRD{&meta.ResourceDefinition{}, &meta.Namespace{}} { 30 | if err := registry.Register(ctx, r); err != nil { 31 | return err 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | // Register a namespace. 39 | func (registry *ResourceRegistry) Register(ctx context.Context, r meta.ResourceWithRD) error { 40 | r, err := meta.NewResourceDefinition(r.ResourceDefinition()) 41 | if err != nil { 42 | return fmt.Errorf("error registering resource %s: %w", r, err) 43 | } 44 | 45 | return registry.state.Create(ctx, r, state.WithCreateOwner(meta.Owner)) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/state/registry/resource_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package registry_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/cosi-project/runtime/pkg/state" 13 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 14 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 15 | "github.com/cosi-project/runtime/pkg/state/registry" 16 | ) 17 | 18 | func TestResourceRegistry(t *testing.T) { 19 | t.Parallel() 20 | 21 | r := registry.NewResourceRegistry(state.WrapCore(namespaced.NewState(inmem.Build))) 22 | 23 | assert.NoError(t, r.RegisterDefault(t.Context())) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/state/wrap_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package state_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/suite" 11 | 12 | "github.com/cosi-project/runtime/pkg/resource" 13 | "github.com/cosi-project/runtime/pkg/state" 14 | "github.com/cosi-project/runtime/pkg/state/conformance" 15 | "github.com/cosi-project/runtime/pkg/state/impl/inmem" 16 | "github.com/cosi-project/runtime/pkg/state/impl/namespaced" 17 | ) 18 | 19 | func TestWrapConformance(t *testing.T) { 20 | t.Parallel() 21 | 22 | suite.Run(t, &conformance.StateSuite{ 23 | State: state.WrapCore(namespaced.NewState(inmem.Build)), 24 | Namespaces: []resource.Namespace{"default", "controller", "system", "runtime"}, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/task/runner.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package task 6 | 7 | import ( 8 | "context" 9 | "sync" 10 | 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // EqualityFunc is used to compare two task specs. 15 | type EqualityFunc[T any] func(x, y T) bool 16 | 17 | // Runner manages running tasks. 18 | type Runner[T any, S Spec[T]] struct { 19 | running map[ID]*Task[T, S] 20 | equalityFunc EqualityFunc[S] 21 | mu sync.Mutex 22 | } 23 | 24 | // NewRunner creates a new task runner. 25 | func NewRunner[T any, S Spec[T]](equalityFunc EqualityFunc[S]) *Runner[T, S] { 26 | if equalityFunc == nil { 27 | panic("equalityFunc must not be nil") 28 | } 29 | 30 | return &Runner[T, S]{ 31 | running: make(map[ID]*Task[T, S]), 32 | equalityFunc: equalityFunc, 33 | } 34 | } 35 | 36 | // NewEqualRunner creates a new task runner from spec with Equal method. 37 | func NewEqualRunner[S EqualSpec[T, S], T any]() *Runner[T, S] { 38 | return NewRunner[T, S](func(x, y S) bool { return x.Equal(y) }) 39 | } 40 | 41 | // Stop all running tasks. 42 | func (runner *Runner[T, S]) Stop() { 43 | for _, task := range runner.running { 44 | task.Stop() 45 | } 46 | } 47 | 48 | // StartTask starts a new task. 49 | func (runner *Runner[T, S]) StartTask(ctx context.Context, logger *zap.Logger, id string, spec S, task T) { 50 | runner.mu.Lock() 51 | defer runner.mu.Unlock() 52 | 53 | running, ok := runner.running[id] 54 | 55 | if ok { 56 | if runner.equalityFunc(spec, running.spec) { 57 | return 58 | } 59 | 60 | logger.Debug("replacing task", zap.String("task", id)) 61 | 62 | runner.stopTask(id) 63 | } 64 | 65 | runner.running[id] = New(logger, spec, task) 66 | 67 | logger.Debug("starting task", zap.String("task", id)) 68 | runner.running[id].Start(ctx) 69 | } 70 | 71 | // StopTask stop the running task. 72 | func (runner *Runner[T, S]) StopTask(logger *zap.Logger, id string) { 73 | runner.mu.Lock() 74 | defer runner.mu.Unlock() 75 | 76 | logger.Debug("stopping task", zap.String("task", id)) 77 | 78 | runner.stopTask(id) 79 | } 80 | 81 | func (runner *Runner[T, S]) stopTask(id string) { 82 | if _, ok := runner.running[id]; !ok { 83 | return 84 | } 85 | 86 | runner.running[id].Stop() 87 | delete(runner.running, id) 88 | } 89 | 90 | // Reconcile running tasks. 91 | func (runner *Runner[T, S]) Reconcile(ctx context.Context, logger *zap.Logger, shouldRun map[ID]S, in T) { 92 | runner.mu.Lock() 93 | defer runner.mu.Unlock() 94 | 95 | // stop running tasks which shouldn't run 96 | for id := range runner.running { 97 | if _, exists := shouldRun[id]; !exists { 98 | logger.Debug("stopping task", zap.String("task", id)) 99 | 100 | runner.stopTask(id) 101 | } else if !runner.equalityFunc(shouldRun[id], runner.running[id].Spec()) { 102 | logger.Debug("replacing task", zap.String("task", id)) 103 | 104 | runner.stopTask(id) 105 | } 106 | } 107 | 108 | // start tasks which aren't running 109 | for id := range shouldRun { 110 | if _, exists := runner.running[id]; !exists { 111 | runner.running[id] = New(logger, shouldRun[id], in) 112 | 113 | logger.Debug("starting task", zap.String("task", id)) 114 | runner.running[id].Start(ctx) 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/task/runner_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package task_test 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "go.uber.org/zap/zaptest" 13 | 14 | "github.com/cosi-project/runtime/pkg/task" 15 | ) 16 | 17 | func TestRunner(t *testing.T) { 18 | t.Parallel() 19 | 20 | logger := zaptest.NewLogger(t) 21 | ctx := t.Context() 22 | 23 | assert := assert.New(t) 24 | 25 | in := &taskInputMock{ 26 | commandCh: make(chan taskCommand), 27 | } 28 | 29 | assertTask := func(id string, expectedRunning bool) { 30 | assert.Eventually(func() bool { 31 | running, _ := in.runningTasks.Get(id) 32 | 33 | return running == expectedRunning 34 | }, time.Second, time.Millisecond) 35 | } 36 | 37 | runner := task.NewRunner(func(a, b taskSpec) bool { 38 | return a == b 39 | }) 40 | 41 | runner.Reconcile(ctx, logger, nil, in) 42 | 43 | runner.Reconcile(ctx, logger, map[task.ID]taskSpec{ 44 | "task1": "task1", 45 | "task2": "task2", 46 | }, in) 47 | 48 | assertTask("task1", true) 49 | assertTask("task2", true) 50 | 51 | runner.Reconcile(ctx, logger, map[task.ID]taskSpec{ 52 | "task2": "task2", 53 | }, in) 54 | 55 | assertTask("task1", false) 56 | assertTask("task2", true) 57 | 58 | runner.Reconcile(ctx, logger, map[task.ID]taskSpec{ 59 | "task2": "task3", // a bit of hack with different IDs to test the replace logic 60 | "task4": "task4", 61 | }, in) 62 | 63 | assertTask("task2", false) 64 | assertTask("task3", true) 65 | assertTask("task4", true) 66 | 67 | runner.Stop() 68 | 69 | assertTask("task3", false) 70 | assertTask("task4", false) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/task/task.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | // Package task implements generic controller tasks running in goroutines. 6 | package task 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "sync" 12 | "time" 13 | 14 | "github.com/cenkalti/backoff/v4" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // ID is a task ID. 19 | type ID = string 20 | 21 | // Spec configures a task. 22 | type Spec[T any] interface { 23 | ID() ID 24 | RunTask(ctx context.Context, logger *zap.Logger, in T) error 25 | } 26 | 27 | // Task is a generic controller task that can run in a goroutine with restarts and panic handling. 28 | type Task[T any, S Spec[T]] struct { 29 | spec S 30 | in T 31 | 32 | logger *zap.Logger 33 | cancel context.CancelFunc 34 | wg sync.WaitGroup 35 | } 36 | 37 | // New creates a new task. 38 | func New[T any, S Spec[T]](logger *zap.Logger, spec S, in T) *Task[T, S] { 39 | return &Task[T, S]{ 40 | spec: spec, 41 | in: in, 42 | logger: logger.With(zap.String("task", spec.ID())), 43 | } 44 | } 45 | 46 | // Spec returns the task spec. 47 | func (task *Task[T, S]) Spec() S { 48 | return task.spec 49 | } 50 | 51 | // Start the task in a separate goroutine. 52 | func (task *Task[T, S]) Start(ctx context.Context) { 53 | task.wg.Add(1) 54 | 55 | ctx, task.cancel = context.WithCancel(ctx) 56 | 57 | go func() { 58 | defer task.wg.Done() 59 | 60 | task.runWithRestarts(ctx) 61 | }() 62 | } 63 | 64 | func (task *Task[T, S]) runWithRestarts(ctx context.Context) { 65 | backoff := backoff.NewExponentialBackOff() 66 | 67 | // disable number of retries limit 68 | backoff.MaxElapsedTime = 0 69 | 70 | for ctx.Err() == nil { 71 | err := task.runWithPanicHandler(ctx) 72 | 73 | // finished without an error 74 | if err == nil { 75 | task.logger.Info("task finished") 76 | 77 | return 78 | } 79 | 80 | interval := backoff.NextBackOff() 81 | 82 | task.logger.Error("restarting task", zap.Duration("interval", interval), zap.Error(err)) 83 | 84 | select { 85 | case <-ctx.Done(): 86 | return 87 | case <-time.After(interval): 88 | } 89 | } 90 | } 91 | 92 | func (task *Task[T, S]) runWithPanicHandler(ctx context.Context) (err error) { //nolint:nonamedreturns 93 | defer func() { 94 | if p := recover(); p != nil { 95 | err = fmt.Errorf("panic: %v", p) 96 | 97 | task.logger.Error("task panicked", zap.Stack("stack"), zap.Error(err)) 98 | } 99 | }() 100 | 101 | return task.spec.RunTask(ctx, task.logger, task.in) 102 | } 103 | 104 | // Stop the task waiting for it to finish. 105 | func (task *Task[T, S]) Stop() { 106 | task.cancel() 107 | 108 | task.wg.Wait() 109 | } 110 | 111 | // EqualSpec is like [Spec] but it requires an Equal method from the spec. 112 | type EqualSpec[T any, S Spec[T]] interface { 113 | Spec[T] 114 | Equal(S) bool 115 | } 116 | -------------------------------------------------------------------------------- /pkg/task/task_test.go: -------------------------------------------------------------------------------- 1 | // This Source Code Form is subject to the terms of the Mozilla Public 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this 3 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | 5 | package task_test 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "testing" 11 | "time" 12 | 13 | "github.com/siderolabs/gen/containers" 14 | "github.com/stretchr/testify/assert" 15 | "go.uber.org/zap" 16 | "go.uber.org/zap/zaptest" 17 | 18 | "github.com/cosi-project/runtime/pkg/task" 19 | ) 20 | 21 | type taskCommand struct { 22 | returnWithError error 23 | panicNow bool 24 | } 25 | 26 | type taskInputMock struct { 27 | commandCh chan taskCommand 28 | runningTasks containers.ConcurrentMap[task.ID, bool] 29 | } 30 | 31 | type taskSpec task.ID 32 | 33 | func (spec taskSpec) ID() task.ID { 34 | return task.ID(spec) 35 | } 36 | 37 | func (spec taskSpec) RunTask(ctx context.Context, _ *zap.Logger, in *taskInputMock) error { 38 | in.runningTasks.Set(task.ID(spec), true) 39 | defer in.runningTasks.Set(task.ID(spec), false) 40 | 41 | select { 42 | case <-ctx.Done(): 43 | return nil 44 | case cmd := <-in.commandCh: 45 | if cmd.panicNow { 46 | panic("panic") 47 | } 48 | 49 | return cmd.returnWithError 50 | } 51 | } 52 | 53 | func TestTask(t *testing.T) { 54 | t.Parallel() 55 | 56 | logger := zaptest.NewLogger(t) 57 | ctx := t.Context() 58 | 59 | assert := assert.New(t) 60 | 61 | in := &taskInputMock{ 62 | commandCh: make(chan taskCommand), 63 | } 64 | 65 | assertTask := func(id string, expectedRunning bool) { 66 | assert.Eventually(func() bool { 67 | running, _ := in.runningTasks.Get(id) 68 | 69 | return running == expectedRunning 70 | }, 3*time.Second, time.Millisecond) 71 | } 72 | 73 | t1 := task.New(logger, taskSpec("task1"), in) 74 | t1.Start(ctx) 75 | 76 | assertTask("task1", true) 77 | 78 | // should restart on panic 79 | in.commandCh <- taskCommand{ 80 | panicNow: true, 81 | } 82 | 83 | assertTask("task1", false) 84 | assertTask("task1", true) 85 | 86 | // short restart on error 87 | in.commandCh <- taskCommand{ 88 | returnWithError: errors.New("failed"), 89 | } 90 | 91 | assertTask("task1", false) 92 | assertTask("task1", true) 93 | 94 | t1.Stop() 95 | assertTask("task1", false) 96 | } 97 | --------------------------------------------------------------------------------