├── .gitattributes ├── dummy.rs ├── .gitignore ├── docs ├── kuberest.png ├── kuberest_flow.png ├── kuberest_logo.png ├── kuberest_teardown_flow.png ├── teardown_flow.drawio └── flow.drawio ├── charts └── kuberest │ ├── test.yaml │ ├── Chart.yaml │ ├── .helmignore │ ├── templates │ ├── service.yaml │ ├── _helpers.tpl │ ├── servicemonitor.yaml │ ├── networkpolicy.yaml │ ├── deployment.yaml │ └── rbac.yaml │ └── values.yaml ├── deploy ├── crd │ ├── kustomization.yaml │ └── crd.yaml ├── ns.yaml ├── kustomization.yaml └── operator │ ├── kustomization.yaml │ └── deployment.yaml ├── .dockerignore ├── src ├── crdgen.rs ├── hasheshandlers.rs ├── main.rs ├── passwordhandler.rs ├── metrics.rs ├── telemetry.rs ├── handlebarshandler.rs ├── lib.rs ├── rhaihandler.rs ├── fixtures.rs ├── k8shandlers.rs └── httphandler.rs ├── rustfmt.toml ├── .github ├── dependabot.yml └── workflows │ ├── chart.yml │ ├── rustfmt.yml │ └── ci.yml ├── examples ├── abuses │ ├── secret-copy.yaml │ ├── README.md │ ├── password-generator.yaml │ ├── k8s-system-pod.yaml │ └── uuidgen.yaml ├── instance-gen-config-from-get.yaml ├── gitlab │ ├── create-gitlab-hook.yaml │ └── set-all-projects-config.yaml ├── harbor │ └── mirror-docker.yaml ├── gitea │ ├── openid-woodpecker.yaml │ └── organisations-team.yaml └── authentik │ └── openid-gitea.yaml ├── release.toml ├── Dockerfile ├── README.md ├── Cargo.toml └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | Cargo.lock linguist-generated 2 | -------------------------------------------------------------------------------- /dummy.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("Dummy") 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | controller 4 | audit 5 | audit.yaml 6 | -------------------------------------------------------------------------------- /docs/kuberest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebt3/kuberest/HEAD/docs/kuberest.png -------------------------------------------------------------------------------- /docs/kuberest_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebt3/kuberest/HEAD/docs/kuberest_flow.png -------------------------------------------------------------------------------- /docs/kuberest_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebt3/kuberest/HEAD/docs/kuberest_logo.png -------------------------------------------------------------------------------- /docs/kuberest_teardown_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebt3/kuberest/HEAD/docs/kuberest_teardown_flow.png -------------------------------------------------------------------------------- /charts/kuberest/test.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | repository: sebt3/kuberest 3 | pullPolicy: Always 4 | tag: "latest" 5 | namespace: default 6 | -------------------------------------------------------------------------------- /deploy/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: kustomize.config.k8s.io/v1beta1 3 | kind: Kustomization 4 | resources: 5 | - crd.yaml 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | deploy 3 | docs 4 | examples 5 | chart 6 | .github 7 | Dockerfile 8 | README.md 9 | release.toml 10 | rustfmt.toml 11 | -------------------------------------------------------------------------------- /deploy/ns.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | app: kuberest 6 | app.kubernetes.io/name: kuberest 7 | name: kuberest -------------------------------------------------------------------------------- /deploy/kustomization.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: kustomize.config.k8s.io/v1beta1 3 | kind: Kustomization 4 | 5 | resources: 6 | - ./crd/ 7 | - ./ns.yaml 8 | - ./operator/ 9 | -------------------------------------------------------------------------------- /deploy/operator/kustomization.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: kustomize.config.k8s.io/v1beta1 3 | kind: Kustomization 4 | 5 | namespace: "kuberest" 6 | 7 | resources: 8 | - deployment.yaml 9 | -------------------------------------------------------------------------------- /src/crdgen.rs: -------------------------------------------------------------------------------- 1 | use kube::CustomResourceExt; 2 | fn main() { 3 | print!( 4 | "{}", 5 | serde_yaml::to_string(&controller::RestEndPoint::crd()).unwrap() 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /charts/kuberest/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: kuberest 3 | description: Allow to Control remote REST api endpoints from the confort of your cluster 4 | type: application 5 | version: "1.3.3" 6 | appVersion: "1.3.3" 7 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | overflow_delimited_expr = true 2 | newline_style = "Unix" 3 | imports_granularity = "Crate" 4 | reorder_impl_items = true 5 | fn_single_line = false 6 | blank_lines_upper_bound = 2 7 | ignore = [ 8 | ] 9 | comment_width = 110 10 | max_width = 110 11 | inline_attribute_width = 80 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | labels: 8 | - "dependencies" 9 | 10 | - package-ecosystem: "cargo" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | labels: 15 | - "cargo" 16 | -------------------------------------------------------------------------------- /charts/kuberest/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /examples/abuses/secret-copy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: secret-copy 5 | spec: 6 | checkFrequency: 900 7 | inputs: 8 | - name: token 9 | secretRef: 10 | name: copied-from 11 | namespace: source-namespace 12 | client: 13 | baseurl: "http://localhost:8080" 14 | outputs: 15 | - kind: Secret 16 | metadata: 17 | name: copied-to 18 | namespace: destination-namespace 19 | data: 20 | gitlab_token: "{{ base64_decode input.token.data.gitlab_token }}" 21 | -------------------------------------------------------------------------------- /charts/kuberest/templates/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Expose the http port of the service 3 | apiVersion: v1 4 | kind: Service 5 | metadata: 6 | name: {{ include "controller.fullname" . }} 7 | namespace: {{ .Values.namespace }} 8 | labels: 9 | {{- include "controller.labels" . | nindent 4 }} 10 | {{- with .Values.service.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | spec: 15 | type: {{ .Values.service.type }} 16 | ports: 17 | - port: {{ .Values.service.port }} 18 | targetPort: 8080 19 | protocol: TCP 20 | name: http 21 | selector: 22 | app: {{ include "controller.fullname" . }} 23 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | # Release process 2 | # 3 | # Versions are bumped in both Cargo.toml and Chart.yaml simultaneously through cargo-release 4 | # 5 | # 1. cargo release patch --execute 6 | # 2. cargo cmd generate 7 | # 3. git add deploy && git commit --amend --signoff 8 | 9 | # Reference 10 | # https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md 11 | 12 | pre-release-replacements = [ 13 | {file="charts/kuberest/Chart.yaml", search="appVersion: .*", replace="appVersion: {{version}}"}, 14 | ] 15 | pre-release-commit-message = "release {{version}}" 16 | push = false 17 | tag = true 18 | tag-name = "{{version}}" 19 | sign-tag = true 20 | sign-commit = true 21 | enable-all-features = true 22 | -------------------------------------------------------------------------------- /.github/workflows/chart.yml: -------------------------------------------------------------------------------- 1 | name: Release Charts 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Configure Git 20 | run: | 21 | git config user.name "$GITHUB_ACTOR" 22 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 23 | 24 | - name: Install Helm 25 | uses: azure/setup-helm@v4 26 | env: 27 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 28 | 29 | - name: Run chart-releaser 30 | uses: helm/chart-releaser-action@v1.6.0 31 | env: 32 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 33 | -------------------------------------------------------------------------------- /.github/workflows/rustfmt.yml: -------------------------------------------------------------------------------- 1 | # When pushed to main, run `cargo +nightly fmt --all` and open a PR. 2 | name: rustfmt 3 | on: 4 | push: 5 | # Limit to `main` because this action creates a PR 6 | branches: 7 | - main 8 | jobs: 9 | rustfmt_nightly: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v4 14 | - uses: dtolnay/rust-toolchain@stable 15 | with: 16 | toolchain: nightly 17 | components: rustfmt 18 | - run: cargo +nightly fmt 19 | 20 | - name: Create Pull Request 21 | uses: peter-evans/create-pull-request@v7 22 | with: 23 | commit-message: rustfmt 24 | signoff: true 25 | title: rustfmt 26 | body: Changes from `cargo +nightly fmt`. 27 | branch: rustfmt 28 | # Delete branch when merged 29 | delete-branch: true 30 | -------------------------------------------------------------------------------- /examples/instance-gen-config-from-get.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: gen-config-from-get 5 | spec: 6 | inputs: 7 | - name: token 8 | secretRef: 9 | name: token 10 | client: 11 | baseurl: "https://gitlab.com/api/v4" 12 | headers: 13 | Authorization: "Bearer {{ base64_decode input.token.data.gitlab_token }}" 14 | reads: 15 | - name: gitlabUser 16 | path: "/users/{{ base64_decode input.token.data.user_id }}" 17 | items: 18 | - name: projects 19 | key: projects 20 | outputs: 21 | - kind: ConfigMap 22 | metadata: 23 | name: project-list 24 | data: 25 | config.yaml: |- 26 | --- 27 | names: 28 | {{ json_to_str ( json_query "projects[*].name_with_namespace" read.gitlabUser ) format="yaml" }} 29 | ids: 30 | {{ json_to_str ( json_query "projects[*].id" read.gitlabUser ) format="yaml" }} 31 | -------------------------------------------------------------------------------- /charts/kuberest/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{- define "controller.name" -}} 2 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 3 | {{- end }} 4 | 5 | {{- define "controller.fullname" -}} 6 | {{- $name := default .Chart.Name .Values.nameOverride }} 7 | {{- $name | trunc 63 | trimSuffix "-" }} 8 | {{- end }} 9 | 10 | {{- define "controller.labels" -}} 11 | {{- include "controller.selectorLabels" . }} 12 | app.kubernetes.io/name: {{ include "controller.name" . }} 13 | app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }} 14 | {{- end }} 15 | 16 | {{- define "controller.selectorLabels" -}} 17 | app: {{ include "controller.name" . }} 18 | {{- end }} 19 | 20 | {{- define "controller.tag" -}} 21 | {{- if .Values.tracing.enabled }} 22 | {{- "otel-" }}{{ .Values.version | default .Chart.AppVersion }} 23 | {{- else }} 24 | {{- .Values.version | default .Chart.AppVersion }} 25 | {{- end }} 26 | {{- end }} 27 | -------------------------------------------------------------------------------- /examples/gitlab/create-gitlab-hook.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: create-gitlab-hook 5 | spec: 6 | inputs: 7 | - name: token 8 | secretRef: 9 | name: token 10 | - name: projectid 11 | handleBarsRender: "{{ base64_decode input.token.data.project_id }}" 12 | templates: 13 | - name: token 14 | template: "{{ base64_decode input.token.data.gitlab_token }}" 15 | - name: hook 16 | template: |- 17 | id: "{{ name }}" 18 | name: "{{ name }}" 19 | description: "{{ name }} awesome hook" 20 | url: "https://some.url.on.the.net/my/hook" 21 | client: 22 | baseurl: "https://gitlab.com/api/v4" 23 | updateMethod: Put 24 | headers: 25 | Authorization: "Bearer {{> token }}" 26 | writes: 27 | - name: hook 28 | path: "projects/{{ input.projectid }}/hooks" 29 | items: 30 | - name: test 31 | values: |- 32 | {{> hook name="test" }} 33 | -------------------------------------------------------------------------------- /examples/abuses/README.md: -------------------------------------------------------------------------------- 1 | # Warning 2 | 3 | Examples in this directory are only here to demo some of the operator features. 4 | 5 | This directory is named "abuses" for a reason: kuberest have never been designed to do theses things. It is just possible by its featureset. 6 | 7 | There is probably a better operator out there better suited for the task than kuberest ([Secret Generator](https://github.com/mittwald/kubernetes-secret-generator), [External Secret](https://external-secrets.io/latest/), [reflector](https://github.com/emberstack/kubernetes-reflector)...) do not use kuberest if your use-case is only any of these but use a better suited tool :) 8 | 9 | ## secret-copy 10 | 11 | For this one to work, multi-tenancy have to be disabled at the operator level. 12 | 13 | ## k8s-system-pod 14 | 15 | Seriously don't do this. This is just an mTLS demo, and the api-server is an mTLS enabled API we all knows. Having your admin mtls keys within the cluster is a huge security issue. Writes on the api-server is completly untested 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/abuses/password-generator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: password-generator 5 | spec: 6 | inputs: 7 | - name: output 8 | secretRef: 9 | optional: true 10 | name: output 11 | - name: password 12 | passwordGenerator: {} 13 | - name: weak 14 | passwordGenerator: 15 | length: 8 16 | weightSymbols: 0 17 | - name: hbsweak 18 | handleBarsRender: "{{ gen_password_alphanum 8 }}" 19 | - name: hbspassword 20 | handleBarsRender: "{{ gen_password 32 }}" 21 | templates: 22 | - name: password 23 | template: >- 24 | {{#if input.output.data.password }}{{ base64_decode input.output.data.password }}{{else}}{{ input.password }}{{/if}} 25 | client: 26 | baseurl: "http://localhost:8080" 27 | outputs: 28 | - kind: Secret 29 | metadata: 30 | name: output 31 | data: 32 | password: "{{> password }}" 33 | weak: "{{ input.weak }}" 34 | hbsweak: "{{ input.hbsweak }}" 35 | hbspassword: "{{ input.hbspassword }}" 36 | -------------------------------------------------------------------------------- /examples/harbor/mirror-docker.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: harbor-mirror-docker 5 | spec: 6 | inputs: 7 | - name: admin 8 | secretRef: 9 | name: harbor-basic 10 | client: 11 | baseurl: https://harbor.your-company.com/api/v2.0 12 | headers: 13 | Authorization: '{{ header_basic "admin" (base64_decode input.admin.data.HARBOR_ADMIN_PASSWORD) }}' 14 | writes: 15 | - name: registries 16 | path: registries 17 | updateMethod: Put 18 | items: 19 | - name: mirror 20 | readPath: registries?name=docker 21 | readJsonQuery: $[0] 22 | values: | 23 | name: docker 24 | url: https://hub.docker.com 25 | type: docker-hub 26 | - name: projects 27 | path: projects 28 | updateMethod: Put 29 | keyName: project_id 30 | items: 31 | - name: mirror 32 | readPath: projects?name=docker 33 | readJsonQuery: $[0] 34 | values: |- 35 | project_name: docker 36 | public: true 37 | registry_id: {{ write.registries.mirror.id }} 38 | -------------------------------------------------------------------------------- /examples/abuses/k8s-system-pod.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: k8s-system-pod 5 | spec: 6 | inputs: 7 | - name: admin 8 | secretRef: 9 | name: k8s-admin 10 | client: 11 | baseurl: >- 12 | https://{{ env_var "KUBERNETES_SERVICE_HOST" }}:{{ env_var "KUBERNETES_SERVICE_PORT" }} 13 | serverCa: "{{ base64_decode input.admin.data.certificate-authority-data }}" 14 | clientCert: "{{ base64_decode input.admin.data.client-certificate-data }}" 15 | clientKey: "{{ base64_decode input.admin.data.client-key-data }}" 16 | reads: 17 | - name: version 18 | path: version 19 | items: [{"name": "get", "key": ""}] 20 | - name: pod 21 | path: api/v1/namespaces/kube-system/pods 22 | items: [{"name": "list", "key": ""}] 23 | outputs: 24 | - kind: ConfigMap 25 | metadata: 26 | name: result 27 | data: 28 | version.yaml: |- 29 | --- 30 | {{ json_to_str read.version.get format="yaml" }} 31 | pod.yaml: |- 32 | --- 33 | {{ json_to_str read.pod.list format="yaml" }} 34 | -------------------------------------------------------------------------------- /src/hasheshandlers.rs: -------------------------------------------------------------------------------- 1 | use crate::{rhai_err, Error, Result, RhaiRes}; 2 | use argon2::{ 3 | password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, 4 | Argon2, 5 | }; 6 | use bcrypt::{hash, DEFAULT_COST}; 7 | 8 | #[derive(Clone, Debug)] 9 | pub struct Argon { 10 | salt: SaltString, 11 | argon: Argon2<'static>, 12 | } 13 | impl Default for Argon { 14 | fn default() -> Self { 15 | Self::new() 16 | } 17 | } 18 | 19 | impl Argon { 20 | #[must_use] 21 | pub fn new() -> Self { 22 | Self { 23 | salt: SaltString::generate(&mut OsRng), 24 | argon: Argon2::default(), 25 | } 26 | } 27 | 28 | pub fn hash(&self, password: String) -> Result { 29 | Ok(self 30 | .argon 31 | .hash_password(password.as_bytes(), &self.salt) 32 | .map_err(Error::Argon2hash)? 33 | .to_string()) 34 | } 35 | 36 | pub fn rhai_hash(&mut self, password: String) -> RhaiRes { 37 | self.hash(password).map_err(rhai_err) 38 | } 39 | } 40 | 41 | pub fn bcrypt_hash(password: String) -> Result { 42 | hash(&password, DEFAULT_COST).map_err(Error::BcryptError) 43 | } 44 | -------------------------------------------------------------------------------- /docs/teardown_flow.drawio: -------------------------------------------------------------------------------- 1 | 3ZZdb9owFIZ/DZdMSRxCerlCWadpG4iLtpcmPhB3Tpw5Dgn99XOI8+kgKm2V0K7weW0f2897YjxBi6j4InASfucE2MSxSDFBy4nj2C5y1U+pnCrFR3eVcBCU6EGtsKVvoEVLqxklkPYGSs6ZpElfDHgcQyB7GhaC5/1he876qyb4AIawDTAz1SdKZKhP4cxb/RHoIaxXtj19vgjXg/VJ0hATnnck9DBBC8G5rFpRsQBWwqu5VPNWF3qbjQmI5XsmTF+39Jinb6/fTkf762p2/9PPpjrLEbNMH5jGVE7TQNBE6n3LUw1DhDzaZYrofR5SCdsEB2VPrqxXWigjpiJbNfc8ltpLe1bGlLEFZ1yc86A9JnMcKD2Vgv+CTs/O9WaWq3r0vkBIKC4e2G4wqvoDHoEUJzVET0CWJq9Lz66dyDtGainseFhrWJfOocnc0lUNDXgc9urH9Ol3tFp7LMrd50d7Y202U9s1aAeMlqdRlEFmiQFc1UxSNlU9Mp7J6+ATEFRtFkQ7ad1Khi8JT6mkPFYUl9anEafIDHzijjnlOzvkeWVPlc+1OvmadBc8G3H2so1O30bPdLFxtmvj/MNsdAwbJWChPu74Zj6cvyPu39qHM3JLJZlMTc48iwkQDfMa60GpY/D3o2y9wIfdvnZHrzZwy/lH7O0he+ud5e59FHxkwFf/8eo++R/pu96t0Z8b9A3sEJPP5UunvPIZTlMa9Em3tlgDamWsYInTs+48By/N7X0Ol0W3c3mqo4LKZ71C2e7MUlE7qQzqORcdklgcQF4vQiCD11rKMxHAtavD9Lvj52zEzloTwLCkx/6aYx7rFdacnv/N64t0PignNCiTavt6VvftNkg0rEt0N0hUATQSnUuuOfZYFaqwfYJWw9uHPHr4Aw== -------------------------------------------------------------------------------- /examples/gitlab/set-all-projects-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: set-all-projects-config 5 | spec: 6 | inputs: 7 | - name: token 8 | secretRef: 9 | name: token 10 | - name: config 11 | handleBarsRender: |- 12 | {{#to_json format="yaml"}} 13 | merge_method: ff 14 | issues_enabled: false 15 | {{/to_json}} 16 | client: 17 | baseurl: "https://gitlab.com/api/v4" 18 | headers: 19 | Authorization: "Bearer {{ base64_decode input.token.data.gitlab_token }}" 20 | reads: 21 | - name: projects 22 | path: "users/{{ base64_decode input.token.data.user_id }}/projects" 23 | items: 24 | - name: list 25 | key: "" 26 | post: |- 27 | let results=[]; 28 | for id in read.projects.list.map(|p| p.id) { 29 | results += client.http_put("projects/"+id", input.config); 30 | } 31 | #{ 32 | results: results.map(|r| #{ 33 | code: r.code, 34 | id: r.json.id, 35 | name: r.json.name 36 | }) 37 | } 38 | 39 | # outputs: 40 | # - kind: ConfigMap 41 | # metadata: 42 | # name: set-all-projects-config-results 43 | # data: 44 | # result.yaml: |- 45 | # --- 46 | # {{ json_to_str post.results format="yaml" }} 47 | -------------------------------------------------------------------------------- /examples/gitea/openid-woodpecker.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: openid-woodpecker 5 | spec: 6 | inputs: 7 | - name: output 8 | secretRef: 9 | optional: true 10 | name: woodpecker-gitea-openid 11 | - name: admin 12 | secretRef: 13 | name: gitea-admin-user 14 | client: 15 | baseurl: "https://gitea.your-company.com/api/v1" 16 | headers: 17 | Authorization: "{{ header_basic (base64_decode input.admin.data.username) (base64_decode input.admin.data.password) }}" 18 | writes: 19 | - name: application 20 | path: user/applications/oauth2 21 | items: 22 | - name: woodpecker 23 | values: |- 24 | confidential_client: true 25 | name: woodpecker 26 | redirect_uris: 27 | - "https://woodpecker.your-company.com/authorize" 28 | outputs: 29 | - kind: Secret 30 | metadata: 31 | name: woodpecker-gitea-openid 32 | data: 33 | WOODPECKER_GITEA_CLIENT: "{{ write.application.woodpecker.client_id }}" 34 | # Since gitea only give the secret once, keep the value from the initial create in the secret 35 | WOODPECKER_GITEA_SECRET: "{{#if input.output.data.WOODPECKER_GITEA_SECRET }}{{ base64_decode input.output.data.WOODPECKER_GITEA_SECRET }}{{else}}{{ write.application.woodpecker.client_secret }}{{/if}}" -------------------------------------------------------------------------------- /docs/flow.drawio: -------------------------------------------------------------------------------- 1 | 7ZhLc9owEMc/DUc6fuMcG/I6NJ0waSf0KOwFq5W9riwHyKevjGX8kHlMQlom0xPalbS2fv/VSmZgj+PVLSdpdI8hsIFlhKuBfTWwLNOxHflTeNalx7cvSseC01ANqh2P9AWU01DenIaQtQYKRCZo2nYGmCQQiJaPcI7L9rA5svZTU7IAzfEYEKZ7n2goIrUKa1T774AuourJpqfWF5NqsFpJFpEQlw2XfT2wxxxRlK14NQZWwKu4lPNudvRuX4xDIo6ZgOJbNB3ez8StP717+R5nE5gMVZRnwnK1YJpQMcwCTlOh3lusKxg8wniWS6KXy4gKeExJUPQspfTSF4mYScuUzTkmQmlpuoVNGRsjQ76JY89JOCKB9GeC4y9o9MwczzUc2aPeC7iA1c4Fm1uMMv8AYxB8LYdUEywlxbpKRqXEsiGkckUNDSsfUamz2Eau6cqGAtwP++br8Ol3fPPgsXjpTO/MiTGRsB2NdsBosRpJGUSeasBlzqRFU+Yjw1wcBp8Cp/JlgdeTHmqXpkuKGRUUE0nxyvjUo1Togh86fUr51sz2vKKnjCfh1vG24XZo1qPsbhmdtoy+rqJp9cg4ejcZDU3GlMPZ7Jk3wbbMc9sztg4bs/OpUG+i7V6cG+2e4yDNRaZzxjwJIVQwD7Hu1BQC/ryXrRf4MJtX6qinddSyTsS+m+jWkWXFey/2lsaeAwk/Inqrm/b/nL2vUYZQXgOViVxEuMCEsOvae1nrUJx89ZgviKli9xOEWCt4JBfY1gZWVEwb7R/bU7OwrlYq8sZYV0YilzttGo1ZhVlP21jVvKaKxj4VM8x5AHtQqXosCF+A2DNO3XUKjntzggMjgj63r9snF1g/RZZcbpyPuLsc79x2l37tlddSeax8QPief27wPQ2+XuuS8HPxqSytgJEso0GbdLvSdUvJ3yxjry9b1X8Hh8qWdWTZaujp9shZ+Y6ubuoJD0g3n4PVXnbb6eR006Rct5rV/PjvBHKNTiCzE6gEowXapNx22a/PwtH/LGxk18EstM8rC80TZWH3aDpdFkqz/ietHF7/H2lf/wE= -------------------------------------------------------------------------------- /charts/kuberest/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceMonitor.enabled }} 2 | --- 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | name: {{ include "controller.fullname" . }} 7 | namespace: {{ .Values.namespace }} 8 | labels: 9 | {{- include "controller.labels" . | nindent 4 }} 10 | {{- with .Values.service.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | spec: 15 | endpoints: 16 | - port: http 17 | {{- with .Values.serviceMonitor.interval }} 18 | interval: {{ . }} 19 | {{- end }} 20 | {{- with .Values.serviceMonitor.scrapeTimeout }} 21 | scrapeTimeout: {{ . }} 22 | {{- end }} 23 | honorLabels: true 24 | path: {{ .Values.serviceMonitor.path }} 25 | scheme: {{ .Values.serviceMonitor.scheme }} 26 | {{- with .Values.serviceMonitor.relabelings }} 27 | relabelings: 28 | {{- toYaml . | nindent 6 }} 29 | {{- end }} 30 | {{- with .Values.serviceMonitor.metricRelabelings }} 31 | metricRelabelings: 32 | {{- toYaml . | nindent 6 }} 33 | {{- end }} 34 | jobLabel: {{ include "controller.fullname" . }} 35 | selector: 36 | matchLabels: 37 | {{- include "controller.selectorLabels" . | nindent 6 }} 38 | namespaceSelector: 39 | matchNames: 40 | - {{ .Values.namespace }} 41 | {{- with .Values.serviceMonitor.targetLabels }} 42 | targetLabels: 43 | {{- toYaml . | nindent 4 }} 44 | {{- end }} 45 | {{- end }} 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG RUST_VERSION=1.81 2 | ARG DEBIAN_VERSION=bookworm 3 | FROM --platform=${BUILDPLATFORM:-linux/amd64} rust:${RUST_VERSION}-slim-${DEBIAN_VERSION} AS builder 4 | ARG BUILD_DEPS="binutils libssl-dev pkg-config git build-essential" 5 | ARG FEATURES="" 6 | # hadolint ignore=DL3027,DL3008,DL3015 7 | RUN DEBIAN_FRONTEND=noninteractive apt-get update \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get -y install ${BUILD_DEPS} 9 | WORKDIR /usr/src/operator 10 | COPY Cargo.lock Cargo.toml dummy.rs ./ 11 | RUN touch lib.rs && sed -i 's#src/lib.rs#lib.rs#' Cargo.toml \ 12 | && CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build $FEATURES --release --bin dummy \ 13 | && sed -i 's#lib.rs#src/lib.rs#' Cargo.toml 14 | COPY . . 15 | RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build $FEATURES --release --bin controller \ 16 | && strip target/release/controller 17 | 18 | FROM --platform=${BUILDPLATFORM:-linux/amd64} debian:${DEBIAN_VERSION}-slim AS target 19 | ARG DEB_PACKAGES="openssl ca-certificates" 20 | # hadolint ignore=DL3027,DL3008 21 | RUN DEBIAN_FRONTEND=noninteractive apt-get update \ 22 | && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade \ 23 | && DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends ${DEB_PACKAGES} \ 24 | && apt-get clean \ 25 | && rm -rf /var/lib/apt/lists/* \ 26 | && mkdir -p /work \ 27 | && chown nobody:nogroup /work 28 | COPY --from=builder /usr/src/operator/target/release/controller /usr/local/bin/controller 29 | USER nobody 30 | WORKDIR /work 31 | ENTRYPOINT ["controller"] 32 | -------------------------------------------------------------------------------- /charts/kuberest/templates/networkpolicy.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.networkPolicy.enabled }} 2 | --- 3 | apiVersion: networking.k8s.io/v1 4 | kind: NetworkPolicy 5 | metadata: 6 | name: {{ include "controller.fullname" . }} 7 | namespace: {{ .Values.namespace }} 8 | labels: 9 | {{- include "controller.labels" . | nindent 4 }} 10 | spec: 11 | podSelector: 12 | matchLabels: 13 | {{- include "controller.selectorLabels" . | nindent 6 }} 14 | policyTypes: 15 | - Ingress 16 | - Egress 17 | egress: 18 | {{- if .Values.tracing.enabled }} 19 | # pushing tracing spans to a collector 20 | - to: 21 | - namespaceSelector: 22 | matchLabels: 23 | name: {{.Values.tracing.namespace }} 24 | ports: 25 | - port: {{ .Values.tracing.port }} 26 | protocol: TCP 27 | {{- end }} 28 | 29 | # Kubernetes apiserver access 30 | - to: 31 | - ipBlock: 32 | {{- range .Values.networkPolicy.apiserver }} 33 | cidr: {{ . }} 34 | {{- end }} 35 | ports: 36 | - port: 443 37 | protocol: TCP 38 | - port: 6443 39 | protocol: TCP 40 | 41 | {{- if .Values.networkPolicy.dns }} 42 | # DNS egress 43 | - to: 44 | - podSelector: 45 | matchLabels: 46 | k8s-app: kube-dns 47 | ports: 48 | - port: 53 49 | protocol: UDP 50 | {{- end }} 51 | 52 | ingress: 53 | {{- with .Values.networkPolicy.prometheus }} 54 | {{- if .enabled }} 55 | # prometheus metrics scraping support 56 | - from: 57 | - namespaceSelector: 58 | matchLabels: 59 | name: {{ .namespace }} 60 | podSelector: 61 | matchLabels: 62 | app: {{ .app }} 63 | ports: 64 | - port: {{ .port }} 65 | protocol: TCP 66 | {{- end }} 67 | {{- end }} 68 | {{- end }} 69 | -------------------------------------------------------------------------------- /examples/abuses/uuidgen.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: uuidgen 5 | spec: 6 | checkFrequency: 10 7 | inputs: 8 | - name: output 9 | secretRef: 10 | optional: true 11 | name: output 12 | - name: uuid 13 | handleBarsRender: "{{ uuid_new_v7 }}" 14 | - name: other 15 | handleBarsRender: "{{> uuidfrom input.output.data.other }}" 16 | - name: password 17 | passwordGenerator: {} 18 | - name: weak 19 | passwordGenerator: 20 | length: 8 21 | weightSymbols: 0 22 | - name: hbsweak 23 | handleBarsRender: "{{ gen_password_alphanum 8 }}" 24 | - name: hbspassword 25 | handleBarsRender: "{{ gen_password 32 }}" 26 | templates: 27 | - name: uuidfrom 28 | template: >- 29 | {{#if this }}{{ base64_decode this }}{{else}}{{ uuid_new_v7 }}{{/if}} 30 | - name: uuid 31 | template: >- 32 | {{#if input.output.data.uuid }}{{ base64_decode input.output.data.uuid }}{{else}}{{ input.uuid }}{{/if}} 33 | - name: password 34 | template: >- 35 | {{#if input.output.data.password }}{{ base64_decode input.output.data.password }}{{else}}{{ input.password }}{{/if}} 36 | - name: uuidgen 37 | template: >- 38 | {{ uuid_new_v7 }} 39 | client: 40 | baseurl: "http://localhost:8080" 41 | outputs: 42 | - kind: Secret 43 | metadata: 44 | name: output 45 | data: 46 | uuid: "{{> uuid }}" 47 | same_uuid: "{{> uuid }}" 48 | change_every_chech: "{{ input.uuid }}" 49 | other: "{{ input.other }}" 50 | same_other: "{{ input.other }}" 51 | change_every_chech_too: "{{> uuidgen }}" 52 | password: "{{> password }}" 53 | weak: "{{ input.weak }}" 54 | hbsweak: "{{ input.hbsweak }}" 55 | hbspassword: "{{ input.hbspassword }}" 56 | -------------------------------------------------------------------------------- /charts/kuberest/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | nameOverride: "" 3 | namespace: "default" 4 | version: "" # pin a specific version 5 | 6 | image: 7 | repository: sebt3/kuberest 8 | pullPolicy: IfNotPresent 9 | 10 | imagePullSecrets: [] 11 | 12 | serviceAccount: 13 | create: true 14 | annotations: {} 15 | podAnnotations: {} 16 | 17 | podSecurityContext: {} 18 | # fsGroup: 2000 19 | securityContext: {} 20 | # capabilities: 21 | # drop: 22 | # - ALL 23 | # readOnlyRootFilesystem: true 24 | # runAsNonRoot: true 25 | # runAsUser: 1000 26 | 27 | # Configure the gRPC opentelemetry push url 28 | tracing: 29 | # Use the telemetry built image and inject OPENTELEMETRY_ENDPOINT_URL 30 | enabled: false 31 | # namespace of the collector 32 | namespace: monitoring 33 | # collector service name 34 | service: promstack-tempo 35 | # collector port for OTLP gRPC 36 | port: 4317 37 | 38 | networkPolicy: 39 | enabled: false 40 | dns: true 41 | # apiserver access: please scope; take addresses from "kubectl get endpoints kubernetes -n default" 42 | apiserver: 43 | - "0.0.0.0/0" # extremely wide-open egress on ports 443 + 6443 44 | prometheus: 45 | enabled: true 46 | namespace: monitoring 47 | app: prometheus 48 | port: http 49 | 50 | logging: 51 | env_filter: info,kube=debug,controller=debug 52 | 53 | # set to false if you want to use Secrets across-namespaces (unsafe in a multi-tenants cluster) 54 | tenants: 55 | enabled: true 56 | label: "kuberest.solidite.fr/tenant" 57 | 58 | env: [] 59 | 60 | service: 61 | type: ClusterIP 62 | port: 80 63 | 64 | resources: 65 | limits: 66 | cpu: 200m 67 | memory: 256Mi 68 | requests: 69 | cpu: 50m 70 | memory: 100Mi 71 | 72 | serviceMonitor: 73 | enabled: false 74 | path: /metrics 75 | scheme: http 76 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports, unused_variables)] 2 | use actix_web::{get, middleware, web::Data, App, HttpRequest, HttpResponse, HttpServer, Responder}; 3 | pub use controller::{self, telemetry, State}; 4 | use prometheus::{Encoder, TextEncoder}; 5 | use std::env; 6 | 7 | #[get("/metrics")] 8 | async fn metrics(c: Data, _req: HttpRequest) -> impl Responder { 9 | let metrics = c.metrics(); 10 | let encoder = TextEncoder::new(); 11 | let mut buffer = vec![]; 12 | encoder.encode(&metrics, &mut buffer).unwrap(); 13 | HttpResponse::Ok().body(buffer) 14 | } 15 | 16 | #[get("/health")] 17 | async fn health(_: HttpRequest) -> impl Responder { 18 | HttpResponse::Ok().json("healthy") 19 | } 20 | 21 | #[get("/")] 22 | async fn index(c: Data, _req: HttpRequest) -> impl Responder { 23 | let d = c.diagnostics().await; 24 | HttpResponse::Ok().json(&d) 25 | } 26 | 27 | static PORT_VAR_NAME: &str = "PORT"; 28 | #[tokio::main] 29 | async fn main() -> anyhow::Result<()> { 30 | telemetry::init().await; 31 | let port = if env::var(PORT_VAR_NAME).is_ok() { 32 | env::var(PORT_VAR_NAME).unwrap() 33 | } else { 34 | "8080".to_string() 35 | }; 36 | 37 | // Initiatilize Kubernetes controller state 38 | let state = State::default(); 39 | let controller = controller::run(state.clone()); 40 | 41 | // Start web server 42 | let server = HttpServer::new(move || { 43 | App::new() 44 | .app_data(Data::new(state.clone())) 45 | .wrap(middleware::Logger::default().exclude("/health")) 46 | .service(index) 47 | .service(health) 48 | .service(metrics) 49 | }) 50 | .bind(format!("0.0.0.0:{port}"))? 51 | .shutdown_timeout(5); 52 | 53 | // Both runtimes implements graceful shutdown, so poll until both are done 54 | tokio::join!(controller, server.run()).1?; 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /charts/kuberest/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "controller.fullname" . }} 6 | namespace: {{ required "namespace is required" .Values.namespace }} 7 | labels: 8 | {{- include "controller.labels" . | nindent 4 }} 9 | spec: 10 | replicas: {{ .Values.replicaCount }} 11 | selector: 12 | matchLabels: 13 | {{- include "controller.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | labels: 17 | {{- include "controller.selectorLabels" . | nindent 8 }} 18 | annotations: 19 | kubectl.kubernetes.io/default-container: {{ .Chart.Name }} 20 | {{- if .Values.podAnnotations }} 21 | {{- toYaml .Values.podAnnotations | nindent 8 }} 22 | {{- end }} 23 | spec: 24 | serviceAccountName: {{ include "controller.fullname" . }} 25 | {{- with .Values.imagePullSecrets }} 26 | imagePullSecrets: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | securityContext: 30 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 31 | containers: 32 | - name: {{ .Chart.Name }} 33 | image: {{ .Values.image.repository }}:{{ include "controller.tag" . }} 34 | imagePullPolicy: {{ .Values.image.pullPolicy }} 35 | securityContext: 36 | {{- toYaml .Values.securityContext | nindent 10 }} 37 | resources: 38 | {{- toYaml .Values.resources | nindent 10 }} 39 | ports: 40 | - name: http 41 | containerPort: 8080 42 | protocol: TCP 43 | env: 44 | - name: RUST_LOG 45 | value: {{ .Values.logging.env_filter }} 46 | {{- if .Values.tracing.enabled }} 47 | - name: OPENTELEMETRY_ENDPOINT_URL 48 | value: https://{{ .Values.tracing.service }}.{{ .Values.tracing.namespace }}.cluster.local:{{ .Values.tracing.port }} 49 | {{- end }} 50 | - name: MULTI_TENANT 51 | value: "{{ .Values.tenants.enabled }}" 52 | - name: TENANT_LABEL 53 | value: "{{ .Values.tenants.label }}" 54 | {{- with .Values.env }} 55 | {{- toYaml . | nindent 8 }} 56 | {{- end }} 57 | readinessProbe: 58 | httpGet: 59 | path: /health 60 | port: http 61 | initialDelaySeconds: 5 62 | periodSeconds: 5 63 | -------------------------------------------------------------------------------- /src/passwordhandler.rs: -------------------------------------------------------------------------------- 1 | use rand::{ 2 | distributions::{Distribution, Uniform, WeightedIndex}, 3 | thread_rng, RngCore, 4 | }; 5 | 6 | //const LOWER: &[char] = &['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']; 7 | //const UPPER: &[char] = &['A', 'B', 'C', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']; 8 | const ALPHA: &[char] = &[ 9 | 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 10 | 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 11 | 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 12 | ]; 13 | const NUMBERS: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; 14 | const SYMBOLS: &[char] = &[ 15 | '~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '+', '=', '{', '}', '[', ']', '|', 16 | '\\', ':', ';', '"', '\'', ',', '<', '>', '.', '/', '?', 17 | ]; 18 | 19 | pub struct Passwords { 20 | rng: Box, 21 | } 22 | impl Passwords { 23 | #[must_use] 24 | pub fn new() -> Passwords { 25 | Passwords { 26 | rng: Box::new(thread_rng()), 27 | } 28 | } 29 | 30 | pub fn generate(&mut self, length: u32, alpha: u32, numbers: u32, symbols: u32) -> String { 31 | let mut character_sets = vec![ALPHA]; 32 | if numbers > 0 { 33 | character_sets.push(NUMBERS); 34 | } 35 | if symbols > 0 { 36 | character_sets.push(SYMBOLS); 37 | } 38 | let weights = match (numbers > 0, symbols > 0) { 39 | (true, true) => vec![alpha, numbers, symbols], 40 | (true, false) => vec![alpha, numbers], 41 | (false, true) => vec![alpha, symbols], 42 | (false, false) => vec![alpha], 43 | }; 44 | let weighted_dist = WeightedIndex::new(weights).unwrap(); 45 | let mut password = String::with_capacity(length as usize); 46 | for _ in 0..length { 47 | let selected_set = character_sets.get(weighted_dist.sample(&mut self.rng)).unwrap(); 48 | let dist_char = Uniform::from(0..selected_set.len()); 49 | let index = dist_char.sample(&mut self.rng); 50 | password.push(selected_set[index]); 51 | } 52 | password 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/authentik/openid-gitea.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: openid-gitea 5 | spec: 6 | inputs: 7 | - name: output 8 | secretRef: 9 | optional: true 10 | name: gitea-openid 11 | - name: uuid 12 | handleBarsRender: "{{#if input.output.data.key }}{{ base64_decode input.output.data.key }}{{else}}{{ uuid_new_v7 }}{{/if}}" 13 | - name: admin 14 | secretRef: 15 | name: authentik 16 | client: 17 | baseurl: "http://authentik.{{ input.admin.metadata.namespace }}.svc/api/v3" 18 | keyName: pk 19 | headers: 20 | Authorization: "Bearer {{ base64_decode input.admin.data.AUTHENTIK_BOOTSTRAP_TOKEN }}" 21 | reads: 22 | - name: keypair 23 | path: crypto/certificatekeypairs 24 | items: 25 | - name: ak 26 | key: "?name=authentik+Self-signed+Certificate" 27 | - name: flow 28 | path: flows/instances 29 | items: 30 | - name: authorization 31 | key: default-provider-authorization-implicit-consent/ 32 | - name: default 33 | key: default-authentication-flow/ 34 | - name: scopes 35 | path: propertymappings/scope 36 | items: 37 | - name: email 38 | key: "?scope_name=email" 39 | - name: profile 40 | key: "?scope_name=profile" 41 | - name: openid 42 | key: "?scope_name=openid" 43 | writes: 44 | - name: oauth 45 | path: providers/oauth2 46 | keyUseSlash: true 47 | items: 48 | - name: gitea 49 | values: |- 50 | name: gitea-app 51 | authorization_flow: "{{ read.flow.authorization.pk }}" 52 | authentication_flow: "{{ read.flow.default.pk }}" 53 | client_id: "{{ input.uuid }}" 54 | property_mappings: 55 | - "{{ json_query "results[0].pk" read.scopes.email }}" 56 | - "{{ json_query "results[0].pk" read.scopes.openid }}" 57 | - "{{ json_query "results[0].pk" read.scopes.profile }}" 58 | client_type: "confidential" 59 | sub_mode: "user_username" 60 | signing_key: "{{ json_query "results[0].pk" read.keypair.ak }}" 61 | redirect_uris: "https://gitea.your-company.com/user/oauth2/authentik/callback" 62 | - name: applications 63 | path: core/applications 64 | keyUseSlash: true 65 | keyName: slug 66 | items: 67 | - name: gitea 68 | values: |- 69 | name: gitea-app 70 | slug: gitea-app 71 | provider: "{{ write.oauth.gitea.pk }}" 72 | meta_launch_url: https://gitea.your-company.com 73 | outputs: 74 | - kind: Secret 75 | metadata: 76 | name: gitea-openid 77 | data: 78 | key: "{{ write.oauth.gitea.client_id }}" 79 | secret: "{{ write.oauth.gitea.client_secret }}" -------------------------------------------------------------------------------- /src/metrics.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, RestEndPoint}; 2 | use kube::ResourceExt; 3 | use prometheus::{histogram_opts, opts, HistogramVec, IntCounter, IntCounterVec, Registry}; 4 | use tokio::time::Instant; 5 | 6 | #[derive(Clone)] 7 | pub struct Metrics { 8 | pub reconciliations: IntCounter, 9 | pub failures: IntCounterVec, 10 | pub reconcile_duration: HistogramVec, 11 | } 12 | 13 | impl Default for Metrics { 14 | fn default() -> Self { 15 | let reconcile_duration = HistogramVec::new( 16 | histogram_opts!( 17 | "kuberest_reconcile_duration_seconds", 18 | "The duration of reconcile to complete in seconds" 19 | ) 20 | .buckets(vec![0.01, 0.1, 0.25, 0.5, 1., 5., 15., 60.]), 21 | &[], 22 | ) 23 | .unwrap(); 24 | let failures = IntCounterVec::new( 25 | opts!("kuberest_reconciliation_errors_total", "reconciliation errors",), 26 | &["instance", "error"], 27 | ) 28 | .unwrap(); 29 | let reconciliations = IntCounter::new("kuberest_reconciliations_total", "reconciliations").unwrap(); 30 | Metrics { 31 | reconciliations, 32 | failures, 33 | reconcile_duration, 34 | } 35 | } 36 | } 37 | 38 | impl Metrics { 39 | /// Register API metrics to start tracking them. 40 | pub fn register(self, registry: &Registry) -> Result { 41 | registry.register(Box::new(self.reconcile_duration.clone()))?; 42 | registry.register(Box::new(self.failures.clone()))?; 43 | registry.register(Box::new(self.reconciliations.clone()))?; 44 | Ok(self) 45 | } 46 | 47 | pub fn reconcile_failure(&self, doc: &RestEndPoint, e: &Error) { 48 | self.failures 49 | .with_label_values(&[doc.name_any().as_ref(), e.metric_label().as_ref()]) 50 | .inc() 51 | } 52 | 53 | pub fn count_and_measure(&self) -> ReconcileMeasurer { 54 | self.reconciliations.inc(); 55 | ReconcileMeasurer { 56 | start: Instant::now(), 57 | metric: self.reconcile_duration.clone(), 58 | } 59 | } 60 | } 61 | 62 | /// Smart function duration measurer 63 | /// 64 | /// Relies on Drop to calculate duration and register the observation in the histogram 65 | pub struct ReconcileMeasurer { 66 | start: Instant, 67 | metric: HistogramVec, 68 | } 69 | 70 | impl Drop for ReconcileMeasurer { 71 | fn drop(&mut self) { 72 | #[allow(clippy::cast_precision_loss)] 73 | let duration = self.start.elapsed().as_millis() as f64 / 1000.0; 74 | self.metric.with_label_values(&[]).observe(duration); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /charts/kuberest/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create }} 2 | --- 3 | # Scoped service account 4 | apiVersion: v1 5 | kind: ServiceAccount 6 | metadata: 7 | name: {{ include "controller.fullname" . }} 8 | labels: 9 | {{- include "controller.labels" . | nindent 4 }} 10 | {{- with .Values.serviceAccount.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | namespace: {{ .Values.namespace }} 15 | automountServiceAccountToken: true 16 | {{- end }} 17 | 18 | --- 19 | # Access for the service account 20 | kind: ClusterRole 21 | apiVersion: rbac.authorization.k8s.io/v1 22 | metadata: 23 | name: {{ include "controller.fullname" . }} 24 | rules: 25 | - apiGroups: ["kuberest.solidite.fr"] 26 | resources: ["restendpoints", "restendpoints/status", "restendpoints/finalizers"] 27 | verbs: ["get", "list", "watch", "patch", "update"] 28 | - apiGroups: [""] 29 | resources: ["secrets", "configmaps"] 30 | verbs: ["*"] 31 | - apiGroups: [""] 32 | resources: ["namespaces"] 33 | verbs: ["get", "list"] 34 | - apiGroups: ["events.k8s.io"] 35 | resources: ["events"] 36 | verbs: ["create"] 37 | 38 | --- 39 | # Binding the role to the account 40 | kind: ClusterRoleBinding 41 | apiVersion: rbac.authorization.k8s.io/v1 42 | metadata: 43 | name: {{ include "controller.fullname" . }} 44 | subjects: 45 | - kind: ServiceAccount 46 | namespace: {{ .Values.namespace }} 47 | name: {{ include "controller.fullname" . }} 48 | roleRef: 49 | kind: ClusterRole 50 | name: {{ include "controller.fullname" . }} 51 | apiGroup: rbac.authorization.k8s.io 52 | --- 53 | kind: ClusterRole 54 | apiVersion: rbac.authorization.k8s.io/v1 55 | metadata: 56 | labels: 57 | rbac.authorization.k8s.io/aggregate-to-view: 'true' 58 | name: {{ include "controller.fullname" . }}:aggregate-to-view 59 | rules: 60 | - apiGroups: ["kuberest.solidite.fr"] 61 | resources: ["restendpoints"] 62 | verbs: ["get", "watch", "list"] 63 | --- 64 | kind: ClusterRole 65 | apiVersion: rbac.authorization.k8s.io/v1 66 | metadata: 67 | labels: 68 | rbac.authorization.k8s.io/aggregate-to-edit: 'true' 69 | name: {{ include "controller.fullname" . }}:aggregate-to-edit 70 | rules: 71 | - apiGroups: ["kuberest.solidite.fr"] 72 | resources: ["restendpoints"] 73 | verbs: 74 | - patch 75 | - update 76 | --- 77 | kind: ClusterRole 78 | apiVersion: rbac.authorization.k8s.io/v1 79 | metadata: 80 | labels: 81 | rbac.authorization.k8s.io/aggregate-to-admin: 'true' 82 | name: {{ include "controller.fullname" . }}:aggregate-to-admin 83 | rules: 84 | - apiGroups: ["kuberest.solidite.fr"] 85 | resources: ["restendpoints/status"] 86 | verbs: 87 | - update 88 | - apiGroups: ["kuberest.solidite.fr"] 89 | resources: ["restendpoints"] 90 | verbs: 91 | - create 92 | - delete 93 | - deletecollection 94 | -------------------------------------------------------------------------------- /src/telemetry.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports)] // some used only for telemetry feature 2 | use opentelemetry::trace::{TraceId, TracerProvider}; 3 | use opentelemetry_sdk::{runtime, trace as sdktrace, trace::Config, Resource}; 4 | use tracing_subscriber::{prelude::*, EnvFilter, Registry}; 5 | 6 | /// Fetch an opentelemetry::trace::TraceId as hex through the full tracing stack 7 | pub fn get_trace_id() -> TraceId { 8 | use opentelemetry::trace::TraceContextExt as _; // opentelemetry::Context -> opentelemetry::trace::Span 9 | use tracing_opentelemetry::OpenTelemetrySpanExt as _; // tracing::Span to opentelemetry::Context 10 | tracing::Span::current() 11 | .context() 12 | .span() 13 | .span_context() 14 | .trace_id() 15 | } 16 | 17 | #[cfg(feature = "telemetry")] 18 | fn resource() -> Resource { 19 | use opentelemetry::KeyValue; 20 | Resource::new([ 21 | KeyValue::new("service.name", env!("CARGO_PKG_NAME")), 22 | KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), 23 | ]) 24 | } 25 | 26 | #[cfg(feature = "telemetry")] 27 | fn init_tracer() -> sdktrace::Tracer { 28 | use opentelemetry_otlp::WithExportConfig; 29 | let endpoint = std::env::var("OPENTELEMETRY_ENDPOINT_URL").expect("Needs an otel collector"); 30 | let exporter = opentelemetry_otlp::new_exporter().tonic().with_endpoint(endpoint); 31 | 32 | let provider = opentelemetry_otlp::new_pipeline() 33 | .tracing() 34 | .with_exporter(exporter) 35 | .with_trace_config(Config::default().with_resource(resource())) 36 | .install_batch(runtime::Tokio) 37 | .expect("valid tracer"); 38 | 39 | opentelemetry::global::set_tracer_provider(provider.clone()); 40 | provider.tracer("tracing-otel-subscriber") 41 | } 42 | 43 | /// Initialize tracing 44 | pub async fn init() { 45 | // Setup tracing layers 46 | #[cfg(feature = "telemetry")] 47 | let otel = tracing_opentelemetry::OpenTelemetryLayer::new(init_tracer()); 48 | 49 | let logger = tracing_subscriber::fmt::layer().compact(); 50 | let env_filter = EnvFilter::try_from_default_env() 51 | .or(EnvFilter::try_new("info")) 52 | .unwrap(); 53 | 54 | // Decide on layers 55 | let reg = Registry::default(); 56 | #[cfg(feature = "telemetry")] 57 | reg.with(env_filter).with(logger).with(otel).init(); 58 | #[cfg(not(feature = "telemetry"))] 59 | reg.with(env_filter).with(logger).init(); 60 | } 61 | 62 | #[cfg(test)] 63 | mod test { 64 | // This test only works when telemetry is initialized fully 65 | // and requires OPENTELEMETRY_ENDPOINT_URL pointing to a valid server 66 | #[cfg(feature = "telemetry")] 67 | #[tokio::test] 68 | #[ignore = "requires a trace exporter"] 69 | async fn get_trace_id_returns_valid_traces() { 70 | use super::*; 71 | super::init().await; 72 | #[tracing::instrument(name = "test_span")] // need to be in an instrumented fn 73 | fn test_trace_id() -> TraceId { 74 | get_trace_id() 75 | } 76 | assert_ne!(test_trace_id(), TraceId::INVALID, "valid trace"); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Configure applications and their relationship from kubernetes 2 | ![logo](docs/kuberest_logo.png "KubeRest") 3 | ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) 4 | ![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white) 5 | 6 | [![Apache licensed](https://img.shields.io/badge/license-Apache-blue.svg)](./LICENSE) 7 | [![ci](https://github.com/sebt3/kuberest/actions/workflows/ci.yml/badge.svg)](https://github.com/sebt3/kuberest/actions/workflows/ci.yml) 8 | [![docker image](https://img.shields.io/docker/pulls/sebt3/kuberest.svg)]( 9 | https://hub.docker.com/r/sebt3/kuberest/tags/) 10 | 11 | 12 | This repository contains a custom Kubernetes controller that can create/update/delete REST object on RESTfull api-endpoint. 13 | The main goal is to not write a post-install Job filled with curl commands ever again for my applications deployments on Kubernetes. Inspirations come from the excellent [restapi terraform provider](https://registry.terraform.io/providers/Mastercard/restapi/latest/docs) and [Tekton](https://tekton.dev/docs/pipelines/). 14 | 15 | ## Use cases 16 | 17 | - Configure OpenID applications in most ID-provider to provide seamless integration between applications 18 | - Configure your own forge projects 19 | - Configure any application that provide REST endpoints (that's a lot of them) 20 | 21 | ## Installation 22 | 23 | (TL;DR: `kubectl apply -k github.com/sebt3/kuberest//deploy`) 24 | 25 | Since this is a kubernetes operator, the installations steps are: 26 | - first the CustomResourceDefinition 27 | - then the operator controlling the ressources 28 | 29 | Feel free to pick any of the installtions options for both. 30 | 31 | ### CRD 32 | 33 | 34 | #### kubectl 35 | 36 | ```sh 37 | kubectl apply -f deploy/crd/crd.yaml 38 | ``` 39 | #### kustomize 40 | 41 | ```sh 42 | kubectl apply -k github.com/sebt3/kuberest//deploy/crd 43 | ``` 44 | 45 | ### Operator 46 | 47 | #### kubectl 48 | 49 | ```sh 50 | helm template charts/kuberest | kubectl apply -f - 51 | kubectl wait --for=condition=available deploy/kuberest --timeout=30s 52 | ``` 53 | 54 | #### kustomize 55 | 56 | ```sh 57 | kubectl create ns kuberest 58 | kubectl apply -k github.com/sebt3/kuberest//deploy/operator 59 | kubectl wait -n kuberest --for=condition=available deploy/kuberest --timeout=30s 60 | ``` 61 | 62 | #### helm 63 | 64 | ```sh 65 | helm repo add kuberest https://sebt3.github.io/kuberest/ 66 | kubectl create ns kuberest 67 | helm install kuberest/kuberest kuberest --namespace kuberest 68 | kubectl wait -n kuberest --for=condition=available deploy/kuberest --timeout=30s 69 | ``` 70 | 71 | 72 | ### Tenant aware 73 | 74 | The controller can either function per-tenant (refuse to read secrets from namespace that doesnt share the same label mostly) or behave globally. The default behaviour is to limit to current tenant, to activate, set the environement variable MULTI_TENANT to false (or tenants.enabled=false for the chart). You can also select the common label value using the variable TENANT_LABEL (or tenants.label for the chart). 75 | 76 | ## Usage 77 | Please see the [documentation](https://sebt3.github.io/kuberest/docs/) -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kuberest" 3 | version = "1.3.3" 4 | authors = ["Sébastien Huss "] 5 | edition = "2021" 6 | default-run = "controller" 7 | license = "Apache-2.0" 8 | publish = false 9 | 10 | # use "cargo install cargo-commander", then "cargo cmd generate" 11 | [package.metadata.commands] 12 | generate = { cmd=[ 13 | "sed -i \"s/^appVersion:.*/appVersion: $(awk '/^version =/{print $3;exit}' Cargo.toml)/;s/^version:.*/version: $(awk '/^version =/{print $3;exit}' Cargo.toml)/\" charts/kuberest/Chart.yaml", 14 | "cargo run --bin crdgen > deploy/crd/crd.yaml", 15 | "helm template charts/kuberest > deploy/operator/deployment.yaml" 16 | ]} 17 | crd = { cmd=[ 18 | "cargo run --bin crdgen > deploy/crd/crd.yaml", 19 | "kubectl apply -f deploy/crd/crd.yaml" 20 | ]} 21 | fmt = { cmd=[ 22 | "cargo +nightly fmt" 23 | ]} 24 | operator = { cmd=[ 25 | "podman build . -t docker.io/sebt3/kuberest:$(awk '/^version =/{print $3;exit}' Cargo.toml|sed 's/\"//g') && podman push docker.io/sebt3/kuberest:$(awk '/^version =/{print $3;exit}' Cargo.toml|sed 's/\"//g')", 26 | ]} 27 | precommit = { cmd=[ 28 | "cargo update", 29 | "cargo clippy --fix --allow-dirty --allow-staged", 30 | "cargo cmd generate", 31 | "cargo +nightly fmt" 32 | ]} 33 | 34 | [[bin]] 35 | doc = false 36 | name = "dummy" 37 | path = "dummy.rs" 38 | 39 | [[bin]] 40 | doc = false 41 | name = "controller" 42 | path = "src/main.rs" 43 | 44 | [[bin]] 45 | doc = false 46 | name = "crdgen" 47 | path = "src/crdgen.rs" 48 | 49 | [lib] 50 | name = "controller" 51 | path = "src/lib.rs" 52 | 53 | [features] 54 | default = [] 55 | telemetry = ["opentelemetry-otlp"] 56 | 57 | [dependencies] 58 | actix-web = "4.9.0" 59 | futures = "0.3.28" 60 | tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] } 61 | k8s-openapi = { version = "0.23.0", features = ["latest"] } 62 | schemars = { version = "0.8.12", features = ["chrono"] } 63 | serde = { version = "1.0.214", features = ["derive"] } 64 | serde_json = "1.0.105" 65 | serde_yaml = "0.9.25" 66 | prometheus = "0.13.4" 67 | chrono = { version = "0.4.38", features = ["serde"] } 68 | tracing = "0.1.37" 69 | tracing-subscriber = { version = "0.3.18", features = ["json", "env-filter"] } 70 | tracing-opentelemetry = "0.27.0" 71 | opentelemetry = { version = "0.26.0", features = ["trace"] } 72 | opentelemetry_sdk = { version = "0.26.0", features = ["rt-tokio"] } 73 | opentelemetry-otlp = { version = "0.26.0", optional = true } 74 | thiserror = "2.0.3" 75 | anyhow = "1.0.75" 76 | handlebars = { version = "6.2.0", features = ["script_helper", "string_helpers"] } 77 | handlebars_misc_helpers = { version = "0.17.0", features = ["string", "json", "jsonnet", "regex", "uuid"] } 78 | rhai = { version = "1.20.0", features = ["sync", "serde"] } 79 | reqwest = { version = "0.12.4", features = ["rustls-tls"] } 80 | base64 = "0.22.1" 81 | rand = "0.8.5" 82 | argon2 = { version = "0.5.3", features = ["std"] } 83 | bcrypt = "0.16.0" 84 | serde_json_path = "0.7.1" 85 | 86 | [dev-dependencies] 87 | assert-json-diff = "2.0.2" 88 | http = "1" 89 | hyper = "1" 90 | tower-test = "0.4.0" 91 | 92 | [dependencies.kube] 93 | features = ["runtime", "client", "derive" ] 94 | version = "0.96.0" 95 | -------------------------------------------------------------------------------- /examples/gitea/organisations-team.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuberest.solidite.fr/v1 2 | kind: RestEndPoint 3 | metadata: 4 | name: gitea-org-images 5 | spec: 6 | inputs: 7 | - name: admin 8 | secretRef: 9 | name: gitea-admin-user 10 | client: 11 | baseurl: https://gitea.your-company.com/api/v1 12 | headers: 13 | Authorization: '{{ header_basic (base64_decode input.admin.data.username) (base64_decode input.admin.data.password) }}' 14 | writes: 15 | - name: orgs 16 | path: orgs 17 | keyName: username 18 | items: 19 | - name: org 20 | values: | 21 | username: images 22 | visibility: public 23 | repo_admin_change_team_access: true 24 | - name: teams 25 | path: orgs/images/teams 26 | updatePath: teams 27 | items: 28 | - name: dev 29 | values: |- 30 | name: dev 31 | includes_all_repositories: true 32 | can_create_org_repo: false 33 | permission: read 34 | units: 35 | - repo.issues 36 | - repo.ext_issues 37 | - repo.releases 38 | - repo.ext_wiki 39 | - repo.projects 40 | - repo.actions 41 | - repo.code 42 | - repo.pulls 43 | - repo.wiki 44 | - repo.packages 45 | units_map: 46 | repo.actions: write 47 | repo.code: write 48 | repo.ext_issues: read 49 | repo.ext_wiki: read 50 | repo.issues: write 51 | repo.packages: write 52 | repo.projects: write 53 | repo.pulls: write 54 | repo.releases: read 55 | repo.wiki: write 56 | - name: qa 57 | values: |- 58 | name: qa 59 | includes_all_repositories: true 60 | can_create_org_repo: false 61 | permission: read 62 | units: 63 | - repo.issues 64 | - repo.ext_issues 65 | - repo.releases 66 | - repo.ext_wiki 67 | - repo.projects 68 | - repo.actions 69 | - repo.code 70 | - repo.pulls 71 | - repo.wiki 72 | - repo.packages 73 | units_map: 74 | repo.actions: read 75 | repo.code: read 76 | repo.ext_issues: read 77 | repo.ext_wiki: read 78 | repo.issues: write 79 | repo.packages: read 80 | repo.projects: write 81 | repo.pulls: read 82 | repo.releases: read 83 | repo.wiki: write 84 | - name: read 85 | values: |- 86 | name: read 87 | includes_all_repositories: true 88 | can_create_org_repo: false 89 | permission: read 90 | units: 91 | - repo.issues 92 | - repo.ext_issues 93 | - repo.releases 94 | - repo.ext_wiki 95 | - repo.projects 96 | - repo.actions 97 | - repo.code 98 | - repo.pulls 99 | - repo.wiki 100 | - repo.packages 101 | units_map: 102 | repo.actions: read 103 | repo.code: read 104 | repo.ext_issues: read 105 | repo.ext_wiki: read 106 | repo.issues: read 107 | repo.packages: read 108 | repo.projects: read 109 | repo.pulls: read 110 | repo.releases: read 111 | repo.wiki: read 112 | -------------------------------------------------------------------------------- /src/handlebarshandler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | hasheshandlers::{self, Argon}, 3 | passwordhandler::Passwords, 4 | Error, Result, RhaiRes, 5 | }; 6 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 7 | use handlebars::{handlebars_helper, Handlebars}; 8 | use handlebars_misc_helpers::new_hbs; 9 | pub use serde_json::Value; 10 | use tracing::*; 11 | // TODO: improve error management 12 | handlebars_helper!(base64_decode: |arg:Value| String::from_utf8(STANDARD.decode(arg.as_str().unwrap_or_else(|| { 13 | warn!("handlebars::base64_decode received a non-string parameter: {:?}",arg); 14 | "" 15 | })).unwrap_or_else(|e| { 16 | warn!("handlebars::base64_decode failed to decode with: {e:?}"); 17 | vec![] 18 | })).unwrap_or_else(|e| { 19 | warn!("handlebars::base64_decode failed to convert to string with: {e:?}"); 20 | String::new() 21 | })); 22 | handlebars_helper!(base64_encode: |arg:Value| STANDARD.encode(arg.as_str().unwrap_or_else(|| { 23 | warn!("handlebars::base64_encode received a non-string parameter: {:?}",arg); 24 | "" 25 | }))); 26 | handlebars_helper!(header_basic: |username:Value, password:Value| format!("Basic {}",STANDARD.encode(format!("{}:{}",username.as_str().unwrap_or_else(|| { 27 | warn!("handlebars::header_basic received a non-string username: {:?}",username); 28 | "" 29 | }),password.as_str().unwrap_or_else(|| { 30 | warn!("handlebars::header_basic received a non-string password: {:?}",password); 31 | "" 32 | }))))); 33 | handlebars_helper!(argon_hash: |password:Value| Argon::new().hash(password.as_str().unwrap_or_else(|| { 34 | warn!("handlebars::argon_hash received a non-string password: {:?}",password); 35 | "" 36 | }).to_string()).unwrap_or_else(|e| { 37 | warn!("handlebars::argon_hash failed to convert to string with: {e:?}"); 38 | String::new() 39 | })); 40 | handlebars_helper!(bcrypt_hash: |password:Value| hasheshandlers::bcrypt_hash(password.as_str().unwrap_or_else(|| { 41 | warn!("handlebars::bcrypt_hash received a non-string password: {:?}",password); 42 | "" 43 | }).to_string()).unwrap_or_else(|e| { 44 | warn!("handlebars::bcrypt_hash failed to convert to string with: {e:?}"); 45 | String::new() 46 | })); 47 | handlebars_helper!(gen_password: |len:u32| Passwords::new().generate(len, 6, 2, 2)); 48 | handlebars_helper!(gen_password_alphanum: |len:u32| Passwords::new().generate(len, 8, 2, 0)); 49 | 50 | #[derive(Clone, Debug)] 51 | pub struct HandleBars<'a> { 52 | engine: Handlebars<'a>, 53 | } 54 | impl HandleBars<'_> { 55 | #[must_use] 56 | pub fn new() -> HandleBars<'static> { 57 | let mut engine = new_hbs(); 58 | engine.register_helper("base64_decode", Box::new(base64_decode)); 59 | engine.register_helper("base64_encode", Box::new(base64_encode)); 60 | engine.register_helper("header_basic", Box::new(header_basic)); 61 | engine.register_helper("argon_hash", Box::new(argon_hash)); 62 | engine.register_helper("bcrypt_hash", Box::new(bcrypt_hash)); 63 | engine.register_helper("gen_password", Box::new(gen_password)); 64 | engine.register_helper("gen_password_alphanum", Box::new(gen_password_alphanum)); 65 | // TODO: add more helpers 66 | HandleBars { engine } 67 | } 68 | 69 | pub fn register_template(&mut self, name: &str, template: &str) -> Result<()> { 70 | self.engine 71 | .register_template_string(name, template) 72 | .map_err(Error::HbsTemplateError) 73 | } 74 | 75 | pub fn rhai_register_template(&mut self, name: String, template: String) -> RhaiRes<()> { 76 | self.register_template(name.as_str(), template.as_str()) 77 | .map_err(|e| format!("{e}").into()) 78 | } 79 | 80 | pub fn render(&mut self, template: &str, data: &Value) -> Result { 81 | self.engine 82 | .render_template(template, data) 83 | .map_err(Error::HbsRenderError) 84 | } 85 | 86 | pub fn rhai_render(&mut self, template: String, data: rhai::Map) -> RhaiRes { 87 | self.engine 88 | .render_template(template.as_str(), &data) 89 | .map_err(|e| format!("{e}").into()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /deploy/operator/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Source: kuberest/templates/rbac.yaml 3 | # Scoped service account 4 | apiVersion: v1 5 | kind: ServiceAccount 6 | metadata: 7 | name: kuberest 8 | labels: 9 | app: kuberest 10 | app.kubernetes.io/name: kuberest 11 | app.kubernetes.io/version: "1.3.3" 12 | namespace: default 13 | automountServiceAccountToken: true 14 | --- 15 | # Source: kuberest/templates/rbac.yaml 16 | # Access for the service account 17 | kind: ClusterRole 18 | apiVersion: rbac.authorization.k8s.io/v1 19 | metadata: 20 | name: kuberest 21 | rules: 22 | - apiGroups: ["kuberest.solidite.fr"] 23 | resources: ["restendpoints", "restendpoints/status", "restendpoints/finalizers"] 24 | verbs: ["get", "list", "watch", "patch", "update"] 25 | - apiGroups: [""] 26 | resources: ["secrets", "configmaps"] 27 | verbs: ["*"] 28 | - apiGroups: [""] 29 | resources: ["namespaces"] 30 | verbs: ["get", "list"] 31 | - apiGroups: ["events.k8s.io"] 32 | resources: ["events"] 33 | verbs: ["create"] 34 | --- 35 | # Source: kuberest/templates/rbac.yaml 36 | kind: ClusterRole 37 | apiVersion: rbac.authorization.k8s.io/v1 38 | metadata: 39 | labels: 40 | rbac.authorization.k8s.io/aggregate-to-view: 'true' 41 | name: kuberest:aggregate-to-view 42 | rules: 43 | - apiGroups: ["kuberest.solidite.fr"] 44 | resources: ["restendpoints"] 45 | verbs: ["get", "watch", "list"] 46 | --- 47 | # Source: kuberest/templates/rbac.yaml 48 | kind: ClusterRole 49 | apiVersion: rbac.authorization.k8s.io/v1 50 | metadata: 51 | labels: 52 | rbac.authorization.k8s.io/aggregate-to-edit: 'true' 53 | name: kuberest:aggregate-to-edit 54 | rules: 55 | - apiGroups: ["kuberest.solidite.fr"] 56 | resources: ["restendpoints"] 57 | verbs: 58 | - patch 59 | - update 60 | --- 61 | # Source: kuberest/templates/rbac.yaml 62 | kind: ClusterRole 63 | apiVersion: rbac.authorization.k8s.io/v1 64 | metadata: 65 | labels: 66 | rbac.authorization.k8s.io/aggregate-to-admin: 'true' 67 | name: kuberest:aggregate-to-admin 68 | rules: 69 | - apiGroups: ["kuberest.solidite.fr"] 70 | resources: ["restendpoints/status"] 71 | verbs: 72 | - update 73 | - apiGroups: ["kuberest.solidite.fr"] 74 | resources: ["restendpoints"] 75 | verbs: 76 | - create 77 | - delete 78 | - deletecollection 79 | --- 80 | # Source: kuberest/templates/rbac.yaml 81 | # Binding the role to the account 82 | kind: ClusterRoleBinding 83 | apiVersion: rbac.authorization.k8s.io/v1 84 | metadata: 85 | name: kuberest 86 | subjects: 87 | - kind: ServiceAccount 88 | namespace: default 89 | name: kuberest 90 | roleRef: 91 | kind: ClusterRole 92 | name: kuberest 93 | apiGroup: rbac.authorization.k8s.io 94 | --- 95 | # Source: kuberest/templates/service.yaml 96 | # Expose the http port of the service 97 | apiVersion: v1 98 | kind: Service 99 | metadata: 100 | name: kuberest 101 | namespace: default 102 | labels: 103 | app: kuberest 104 | app.kubernetes.io/name: kuberest 105 | app.kubernetes.io/version: "1.3.3" 106 | spec: 107 | type: ClusterIP 108 | ports: 109 | - port: 80 110 | targetPort: 8080 111 | protocol: TCP 112 | name: http 113 | selector: 114 | app: kuberest 115 | --- 116 | # Source: kuberest/templates/deployment.yaml 117 | apiVersion: apps/v1 118 | kind: Deployment 119 | metadata: 120 | name: kuberest 121 | namespace: default 122 | labels: 123 | app: kuberest 124 | app.kubernetes.io/name: kuberest 125 | app.kubernetes.io/version: "1.3.3" 126 | spec: 127 | replicas: 1 128 | selector: 129 | matchLabels: 130 | app: kuberest 131 | template: 132 | metadata: 133 | labels: 134 | app: kuberest 135 | annotations: 136 | kubectl.kubernetes.io/default-container: kuberest 137 | spec: 138 | serviceAccountName: kuberest 139 | securityContext: 140 | {} 141 | containers: 142 | - name: kuberest 143 | image: sebt3/kuberest:1.3.3 144 | imagePullPolicy: IfNotPresent 145 | securityContext: 146 | {} 147 | resources: 148 | limits: 149 | cpu: 200m 150 | memory: 256Mi 151 | requests: 152 | cpu: 50m 153 | memory: 100Mi 154 | ports: 155 | - name: http 156 | containerPort: 8080 157 | protocol: TCP 158 | env: 159 | - name: RUST_LOG 160 | value: info,kube=debug,controller=debug 161 | - name: MULTI_TENANT 162 | value: "true" 163 | - name: TENANT_LABEL 164 | value: "kuberest.solidite.fr/tenant" 165 | readinessProbe: 166 | httpGet: 167 | path: /health 168 | port: http 169 | initialDelaySeconds: 5 170 | periodSeconds: 5 171 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum Error { 5 | #[error("SerializationError: {0}")] 6 | SerializationError(#[from] serde_json::Error), 7 | 8 | #[error("YamlError: {0}")] 9 | YamlError(#[from] serde_yaml::Error), 10 | 11 | #[error("K8s error: {0}")] 12 | KubeError(#[from] kube::Error), 13 | 14 | #[error("Finalizer error: {0}")] 15 | // NB: awkward type because finalizer::Error embeds the reconciler error (which is this) 16 | // so boxing this error to break cycles 17 | FinalizerError(#[from] Box>), 18 | 19 | #[error("Registering template failed with error: {0}")] 20 | HbsTemplateError(#[from] handlebars::TemplateError), 21 | #[error("Renderer error: {0}")] 22 | HbsRenderError(#[from] handlebars::RenderError), 23 | 24 | #[error("Rhai script error: {0}")] 25 | RhaiError(#[from] Box), 26 | 27 | #[error("Reqwest error: {0}")] 28 | ReqwestError(#[from] reqwest::Error), 29 | 30 | #[error("Json decoding error: {0}")] 31 | JsonError(#[source] serde_json::Error), 32 | 33 | #[error("{0} query failed: {1}")] 34 | MethodFailed(String, u16, String), 35 | 36 | #[error("Argon2 password_hash error {0}")] 37 | Argon2hash(#[from] argon2::password_hash::Error), 38 | 39 | #[error("Bcrypt hash error {0}")] 40 | BcryptError(#[from] bcrypt::BcryptError), 41 | 42 | #[error("Unsupported method")] 43 | UnsupportedMethod, 44 | 45 | #[error("TeardownIncomplete")] 46 | TeardownIncomplete, 47 | 48 | #[error("IllegalRestEndPoint")] 49 | IllegalRestEndPoint, 50 | } 51 | pub type Result = std::result::Result; 52 | 53 | impl Error { 54 | pub fn metric_label(&self) -> String { 55 | format!("{self:?}").to_lowercase() 56 | } 57 | } 58 | pub type RhaiRes = std::result::Result>; 59 | pub fn rhai_err(e: Error) -> Box { 60 | format!("{e}").into() 61 | } 62 | 63 | /// Expose all restpath components used by main 64 | pub mod restendpoint; 65 | pub use crate::restendpoint::*; 66 | 67 | /// Log and trace integrations 68 | pub mod telemetry; 69 | 70 | /// Metrics 71 | mod metrics; 72 | pub use metrics::Metrics; 73 | 74 | mod handlebarshandler; 75 | pub mod hasheshandlers; 76 | mod httphandler; 77 | mod k8shandlers; 78 | mod passwordhandler; 79 | mod rhaihandler; 80 | 81 | #[cfg(test)] pub mod fixtures; 82 | 83 | #[macro_export] 84 | macro_rules! template { 85 | ( $( $tmpl:expr, $hbs:expr, $values:expr, $conditions:expr, $recorder:expr ),* ) => { 86 | { $( 87 | $hbs.clone().render($tmpl, $values).unwrap_or_else(|e| { 88 | $conditions.push(ApplicationCondition::template_failed(&format!("`{}` raised {e:?}",$tmpl))); 89 | tokio::task::block_in_place(|| {Handle::current().block_on(async { 90 | $recorder.publish(Event { 91 | type_: EventType::Warning, 92 | reason: format!("Failed templating: {}",$tmpl), 93 | note: Some(format!("{e:?}")), 94 | action: "templating".into(), 95 | secondary: None, 96 | }).await.map_err(Error::KubeError).unwrap_or(()); 97 | })}); 98 | $tmpl.to_string() 99 | }) 100 | )*} 101 | }; 102 | } 103 | 104 | #[macro_export] 105 | macro_rules! update { 106 | ( $list:expr, $type_obj:expr, $output:expr, $my_ns:expr, $my_values:expr, $conditions:expr, $recorder:expr ) => { 107 | match $list.update(&$output.metadata, $my_values).await { 108 | Ok(x) => Some(x), 109 | Err(e) => { 110 | $recorder 111 | .publish(Event { 112 | type_: EventType::Warning, 113 | reason: format!( 114 | "Failed to update {}: {}.{}", 115 | $type_obj, $my_ns, $output.metadata.name 116 | ), 117 | note: Some(format!("{e:?}")), 118 | action: "updating".into(), 119 | secondary: None, 120 | }) 121 | .await 122 | .map_err(Error::KubeError) 123 | .unwrap(); 124 | $conditions.push(ApplicationCondition::output_failed(&format!( 125 | "Patching {} {}.{} raised {e:?}", 126 | $type_obj, $my_ns, $output.metadata.name 127 | ))); 128 | None 129 | } 130 | } 131 | }; 132 | } 133 | #[macro_export] 134 | macro_rules! create { 135 | ( $list:expr, $type_obj:expr, $own:expr, $output:expr, $my_ns:expr, $my_values:expr, $conditions:expr, $recorder:expr ) => { 136 | match $list.create($own, &$output.clone().metadata, $my_values).await { 137 | Ok(x) => Some(x), 138 | Err(e) => { 139 | $recorder 140 | .publish(Event { 141 | type_: EventType::Warning, 142 | reason: format!( 143 | "Failed to create {}: {}.{}", 144 | $type_obj, $my_ns, $output.metadata.name 145 | ), 146 | note: Some(format!("{e:?}")), 147 | action: "updating".into(), 148 | secondary: None, 149 | }) 150 | .await 151 | .map_err(Error::KubeError) 152 | .unwrap(); 153 | $conditions.push(ApplicationCondition::output_failed(&format!( 154 | "Creating {} {}.{} raised {e:?}", 155 | $type_obj, $my_ns, $output.metadata.name 156 | ))); 157 | None 158 | } 159 | } 160 | }; 161 | } 162 | -------------------------------------------------------------------------------- /src/rhaihandler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handlebarshandler::HandleBars, 3 | hasheshandlers::Argon, 4 | httphandler::RestClient, 5 | passwordhandler::Passwords, 6 | rhai_err, 7 | Error::{self, *}, 8 | RhaiRes, 9 | }; 10 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 11 | use rhai::{Dynamic, Engine, ImmutableString, Map, Module, Scope}; 12 | use serde::Deserialize; 13 | 14 | #[derive(Debug)] 15 | pub struct Script { 16 | pub engine: Engine, 17 | pub ctx: Scope<'static>, 18 | } 19 | 20 | impl Script { 21 | #[must_use] 22 | pub fn new() -> Script { 23 | let mut script = Script { 24 | engine: Engine::new(), 25 | ctx: Scope::new(), 26 | }; 27 | script 28 | .engine 29 | .register_fn("log_debug", |s: ImmutableString| tracing::debug!("{s}")) 30 | .register_fn("log_info", |s: ImmutableString| tracing::info!("{s}")) 31 | .register_fn("log_warn", |s: ImmutableString| tracing::warn!("{s}")) 32 | .register_fn("log_error", |s: ImmutableString| tracing::error!("{s}")) 33 | .register_fn("bcrypt_hash", |s: ImmutableString| { 34 | crate::hasheshandlers::bcrypt_hash(s.to_string()).map_err(rhai_err) 35 | }) 36 | .register_fn("gen_password", |len: u32| -> String { 37 | Passwords::new().generate(len, 6, 2, 2) 38 | }) 39 | .register_fn("gen_password_alphanum", |len: u32| -> String { 40 | Passwords::new().generate(len, 8, 2, 0) 41 | }) 42 | .register_fn("get_env", |var: ImmutableString| -> String { 43 | std::env::var(var.to_string()).unwrap_or("".into()) 44 | }) 45 | .register_fn("base64_decode", |val: ImmutableString| -> ImmutableString { 46 | String::from_utf8(STANDARD.decode(val.to_string()).unwrap()) 47 | .unwrap() 48 | .into() 49 | }) 50 | .register_fn("base64_encode", |val: ImmutableString| -> ImmutableString { 51 | STANDARD.encode(val.to_string()).into() 52 | }) 53 | .register_fn("json_encode", |val: Dynamic| -> RhaiRes { 54 | serde_json::to_string(&val) 55 | .map_err(|e| rhai_err(Error::SerializationError(e))) 56 | .map(|v| v.into()) 57 | }) 58 | .register_fn("json_encode_escape", |val: Dynamic| -> RhaiRes { 59 | let str = serde_json::to_string(&val).map_err(|e| rhai_err(Error::SerializationError(e)))?; 60 | Ok(format!("{:?}", str).into()) 61 | }) 62 | .register_fn("json_decode", |val: ImmutableString| -> RhaiRes { 63 | serde_json::from_str(val.as_ref()).map_err(|e| rhai_err(Error::SerializationError(e))) 64 | }) 65 | .register_fn("yaml_encode", |val: Dynamic| -> RhaiRes { 66 | serde_yaml::to_string(&val) 67 | .map_err(|e| rhai_err(Error::YamlError(e))) 68 | .map(|v| v.into()) 69 | }) 70 | .register_fn("yaml_encode", |val: Map| -> RhaiRes { 71 | serde_yaml::to_string(&val) 72 | .map_err(|e| rhai_err(Error::YamlError(e))) 73 | .map(|v| v.into()) 74 | }) 75 | .register_fn("yaml_decode", |val: ImmutableString| -> RhaiRes { 76 | serde_yaml::from_str(val.as_ref()).map_err(|e| rhai_err(Error::YamlError(e))) 77 | }) 78 | .register_fn( 79 | "yaml_decode_multi", 80 | |val: ImmutableString| -> RhaiRes> { 81 | let mut res = Vec::new(); 82 | if val.len() > 5 { 83 | // non-empty string only 84 | for document in serde_yaml::Deserializer::from_str(val.as_ref()) { 85 | let doc = 86 | Dynamic::deserialize(document).map_err(|e| rhai_err(Error::YamlError(e)))?; 87 | res.push(doc); 88 | } 89 | } 90 | Ok(res) 91 | }, 92 | ); 93 | script 94 | .engine 95 | .register_type_with_name::("HandleBars") 96 | .register_fn("new_hbs", HandleBars::new) 97 | .register_fn("register_template", HandleBars::rhai_register_template) 98 | .register_fn("render_from", HandleBars::rhai_render); 99 | script 100 | .engine 101 | .register_type_with_name::("RestClient") 102 | .register_fn("new_client", RestClient::new) 103 | .register_fn("headers_reset", RestClient::headers_reset_rhai) 104 | .register_fn("set_baseurl", RestClient::baseurl_rhai) 105 | .register_fn("set_server_ca", RestClient::set_server_ca) 106 | .register_fn("set_mtls_cert_key", RestClient::set_mtls) 107 | .register_fn("add_header", RestClient::add_header_rhai) 108 | .register_fn("add_header_json", RestClient::add_header_json) 109 | .register_fn("add_header_bearer", RestClient::add_header_bearer) 110 | .register_fn("add_header_basic", RestClient::add_header_basic) 111 | .register_fn("head", RestClient::rhai_head) 112 | .register_fn("get", RestClient::rhai_get) 113 | .register_fn("delete", RestClient::rhai_delete) 114 | .register_fn("patch", RestClient::rhai_patch) 115 | .register_fn("post", RestClient::rhai_post) 116 | .register_fn("put", RestClient::rhai_put) 117 | .register_fn("http_get", RestClient::rhai_get) 118 | .register_fn("http_delete", RestClient::rhai_delete) 119 | .register_fn("http_patch", RestClient::rhai_patch) 120 | .register_fn("http_post", RestClient::rhai_post) 121 | .register_fn("http_put", RestClient::rhai_put); 122 | script 123 | .engine 124 | .register_type_with_name::("Argon") 125 | .register_fn("new_argon", Argon::new) 126 | .register_fn("hash", Argon::rhai_hash); 127 | script.add_code("fn assert(cond, mess) {if (!cond){throw mess}}"); 128 | script 129 | } 130 | 131 | pub fn add_code(&mut self, code: &str) { 132 | match self.engine.compile(code) { 133 | Ok(ast) => { 134 | match Module::eval_ast_as_new(self.ctx.clone(), &ast, &self.engine) { 135 | Ok(module) => { 136 | self.engine.register_global_module(module.into()); 137 | } 138 | Err(e) => { 139 | tracing::error!("Parsing {code} failed with: {e:}"); 140 | } 141 | }; 142 | } 143 | Err(e) => { 144 | tracing::error!("Loading {code} failed with: {e:}") 145 | } 146 | }; 147 | } 148 | 149 | pub fn set_dynamic(&mut self, name: &str, val: &serde_json::Value) { 150 | let value: Dynamic = serde_json::from_str(&serde_json::to_string(&val).unwrap()).unwrap(); 151 | self.ctx.set_or_push(name, value); 152 | } 153 | 154 | pub fn eval(&mut self, script: &str) -> Result { 155 | match self.engine.eval_with_scope::(&mut self.ctx, script) { 156 | Ok(v) => { 157 | let value: serde_json::Value = 158 | serde_json::from_str(&serde_json::to_string(&v).unwrap()).unwrap(); 159 | Ok(value) 160 | } 161 | Err(e) => Err(RhaiError(e)), 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | # Setup qemu for multi-arch support 17 | - name: Docker Setup qemu-action 18 | uses: docker/setup-qemu-action@v3 19 | # Build and push with docker buildx 20 | - name: Setup docker buildx 21 | uses: docker/setup-buildx-action@v2 22 | 23 | - name: Configure tags based on git tags + latest 24 | uses: docker/metadata-action@v5 25 | id: meta 26 | with: 27 | images: ${{ github.repository_owner }}/kuberest 28 | tags: | 29 | type=ref,event=pr 30 | type=raw,value=latest,enable={{is_default_branch}} 31 | type=semver,pattern={{version}} 32 | type=semver,pattern={{major}}.{{minor}} 33 | 34 | - name: Docker login on main origin 35 | uses: docker/login-action@v3 36 | if: github.event_name != 'pull_request' 37 | with: 38 | username: ${{ github.repository_owner }} 39 | password: ${{ secrets.DOCKERHUB_TOKEN }} 40 | 41 | - name: Docker buildx 42 | uses: docker/build-push-action@v6 43 | with: 44 | context: . 45 | cache-from: type=gha,scope=base 46 | cache-to: type=gha,scope=base,mode=max 47 | push: ${{ github.event_name != 'pull_request' }} 48 | tags: ${{ steps.meta.outputs.tags }} 49 | labels: ${{ steps.meta.outputs.labels }} 50 | platforms: linux/amd64,linux/arm64 51 | 52 | - name: Persist base image build to a tarball 53 | uses: docker/build-push-action@v6 54 | with: 55 | context: . 56 | platforms: linux/amd64 57 | tags: ${{ steps.meta.outputs.tags }} 58 | cache-from: type=gha,scope=base 59 | outputs: type=docker,dest=/tmp/image.tar 60 | 61 | - name: Upload base docker image as artifact for e2e tests 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: controller-image 65 | path: /tmp/image.tar 66 | 67 | otel-docker: 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v4 71 | # Setup qemu for multi-arch support 72 | - name: Docker Setup qemu-action 73 | uses: docker/setup-qemu-action@v2 74 | # Build and push with docker buildx 75 | - name: Setup docker buildx 76 | uses: docker/setup-buildx-action@v3 77 | 78 | - name: Configure tags based on git tags + latest 79 | uses: docker/metadata-action@v5 80 | id: meta 81 | with: 82 | images: ${{ github.repository_owner }}/kuberest 83 | tags: | 84 | type=semver,pattern={{version}},prefix=otel- 85 | type=semver,pattern={{major}}.{{minor}},prefix=otel- 86 | type=raw,value=otel-latest,enable={{is_default_branch}} 87 | type=ref,event=pr 88 | 89 | - name: Docker login on main origin 90 | uses: docker/login-action@v3 91 | if: github.event_name != 'pull_request' 92 | with: 93 | username: ${{ github.repository_owner }} 94 | password: ${{ secrets.DOCKERHUB_TOKEN }} 95 | 96 | - name: Docker buildx 97 | uses: docker/build-push-action@v6 98 | with: 99 | context: . 100 | cache-from: type=gha,scope=otel 101 | cache-to: type=gha,scope=otel,mode=max 102 | push: ${{ github.event_name != 'pull_request' }} 103 | tags: ${{ steps.meta.outputs.tags }} 104 | labels: ${{ steps.meta.outputs.labels }} 105 | platforms: linux/amd64,linux/arm64 106 | build-args: | 107 | FEATURES=--features=telemetry 108 | 109 | e2e: 110 | runs-on: ubuntu-latest 111 | needs: [docker] 112 | steps: 113 | - uses: actions/checkout@v4 114 | - uses: nolar/setup-k3d-k3s@v1 115 | with: 116 | version: v1.30 117 | k3d-name: kube 118 | k3d-args: "--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*" 119 | - run: kubectl apply -k deploy/crd 120 | - name: Set up Docker Buildx 121 | uses: docker/setup-buildx-action@v2 122 | - name: Download docker image artifact from docker job 123 | uses: actions/download-artifact@v4 124 | with: 125 | name: controller-image 126 | path: /tmp 127 | - name: Load docker image from tarball 128 | run: docker load --input /tmp/image.tar 129 | - name: helm template | kubctl apply 130 | run: | 131 | apiserver="$(kubectl get endpoints kubernetes -ojson | jq '.subsets[0].addresses[0].ip' -r)" 132 | helm template charts/kuberest \ 133 | --set version=latest \ 134 | --set networkPolicy.enabled=true \ 135 | --set networkPolicy.apiserver.0=${apiserver}/32 \ 136 | | kubectl apply -f - 137 | - run: kubectl wait --for=condition=available deploy/kuberest --timeout=60s 138 | - run: kubectl apply -f examples/abuses/uuidgen.yaml 139 | - run: kubectl wait --for=condition=ready rep/uuidgen 140 | # verify reconcile actions have happened 141 | - run: kubectl logs deploy/kuberest 142 | - run: kubectl get event --field-selector "involvedObject.kind=RestEndPoint,involvedObject.name=uuidgen" | grep "IgnoredInput" 143 | - run: kubectl get secrets output 144 | - run: kubectl get rep -oyaml | grep -A1 finalizers | grep restendpoints.kuberest.solidite.fr 145 | # TODO: add more e2e tests 146 | 147 | lint: 148 | runs-on: ubuntu-latest 149 | steps: 150 | - uses: actions/checkout@v4 151 | - name: Install protoc 152 | run: sudo apt-get install -y protobuf-compiler 153 | - uses: dtolnay/rust-toolchain@stable 154 | with: 155 | toolchain: nightly 156 | components: rustfmt,clippy 157 | - run: cargo +nightly fmt -- --check 158 | 159 | - uses: giraffate/clippy-action@v1 160 | with: 161 | reporter: 'github-pr-review' 162 | github_token: ${{ secrets.GITHUB_TOKEN }} 163 | clippy_flags: --all-features 164 | 165 | integration: 166 | runs-on: ubuntu-latest 167 | steps: 168 | - uses: actions/checkout@v4 169 | - uses: dtolnay/rust-toolchain@stable 170 | - uses: Swatinem/rust-cache@v2 171 | - uses: nolar/setup-k3d-k3s@v1 172 | with: 173 | version: v1.30 174 | k3d-name: kube 175 | k3d-args: "--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*" 176 | 177 | - name: Build workspace 178 | run: cargo build 179 | - name: Install crd 180 | run: cargo run --bin crdgen | kubectl apply -f - 181 | - name: Run all default features integration library tests 182 | run: cargo test --lib --all -- --ignored 183 | 184 | unit: 185 | runs-on: ubuntu-latest 186 | steps: 187 | - uses: actions/checkout@v4 188 | with: 189 | fetch-depth: 2 190 | - uses: dtolnay/rust-toolchain@stable 191 | - uses: Swatinem/rust-cache@v2 192 | 193 | # Real CI work starts here 194 | - name: Build workspace 195 | run: cargo build 196 | - name: Generate crd.yaml 197 | run: cargo run --bin crdgen > deploy/crd/crd.yaml 198 | - name: Generate deployment.yaml 199 | run: helm template charts/kuberest > deploy/operator/deployment.yaml 200 | - name: Ensure generated output is committed 201 | run: | 202 | if ! git diff --exit-code deploy/; then 203 | echo "Uncommitted changes in yaml directory" 204 | echo "Please run 'cargo cmd generate' and commit the results" 205 | exit 1 206 | fi 207 | - name: Run workspace unit tests 208 | run: cargo test 209 | -------------------------------------------------------------------------------- /src/fixtures.rs: -------------------------------------------------------------------------------- 1 | //! Helper methods only available for tests 2 | use crate::{ 3 | Context, Metrics, RestEndPoint, RestEndPointSpec, RestEndPointStatus, Result, RESTPATH_FINALIZER, 4 | }; 5 | use assert_json_diff::assert_json_include; 6 | use http::{Request, Response}; 7 | use kube::{client::Body, Client, Resource, ResourceExt}; 8 | use prometheus::Registry; 9 | use std::sync::Arc; 10 | 11 | impl RestEndPoint { 12 | /// A document that will cause the reconciler to fail 13 | pub fn illegal() -> Self { 14 | let mut d = RestEndPoint::new("illegal", RestEndPointSpec::default()); 15 | d.meta_mut().namespace = Some("default".into()); 16 | d 17 | } 18 | 19 | /// A normal test document 20 | pub fn test() -> Self { 21 | let mut d = RestEndPoint::new("test", RestEndPointSpec::default()); 22 | d.meta_mut().namespace = Some("default".into()); 23 | d 24 | } 25 | 26 | /* /// Modify document to be set to hide 27 | pub fn needs_hide(mut self) -> Self { 28 | self.spec.hide = true; 29 | self 30 | } 31 | */ 32 | /// Modify document to set a deletion timestamp 33 | pub fn needs_delete(mut self) -> Self { 34 | use chrono::prelude::{DateTime, TimeZone, Utc}; 35 | let now: DateTime = Utc.with_ymd_and_hms(2017, 4, 2, 12, 50, 32).unwrap(); 36 | use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; 37 | self.meta_mut().deletion_timestamp = Some(Time(now)); 38 | self 39 | } 40 | 41 | /// Modify a document to have the expected finalizer 42 | pub fn finalized(mut self) -> Self { 43 | self.finalizers_mut().push(RESTPATH_FINALIZER.to_string()); 44 | self 45 | } 46 | 47 | /// Modify a document to have an expected status 48 | pub fn with_status(mut self, status: RestEndPointStatus) -> Self { 49 | self.status = Some(status); 50 | self 51 | } 52 | } 53 | 54 | // We wrap tower_test::mock::Handle 55 | type ApiServerHandle = tower_test::mock::Handle, Response>; 56 | pub struct ApiServerVerifier(ApiServerHandle); 57 | 58 | /// Scenarios we test for in ApiServerVerifier 59 | pub enum Scenario { 60 | /// objects without finalizers will get a finalizer applied (and not call the apply loop) 61 | FinalizerCreation(RestEndPoint), 62 | /// objects that do not fail and do not cause publishes will only patch 63 | StatusPatch(RestEndPoint), 64 | /// finalized objects with hide set causes both an event and then a hide patch 65 | EventPublishThenStatusPatch(String, RestEndPoint), 66 | /// finalized objects "with errors" (i.e. the "illegal" object) will short circuit the apply loop 67 | RadioSilence, 68 | /// objects with a deletion timestamp will run the cleanup loop sending event and removing the finalizer 69 | Cleanup(String, RestEndPoint), 70 | } 71 | 72 | pub async fn timeout_after_1s(handle: tokio::task::JoinHandle<()>) { 73 | tokio::time::timeout(std::time::Duration::from_secs(1), handle) 74 | .await 75 | .expect("timeout on mock apiserver") 76 | .expect("scenario succeeded") 77 | } 78 | 79 | impl ApiServerVerifier { 80 | /// Tests only get to run specific scenarios that has matching handlers 81 | /// 82 | /// This setup makes it easy to handle multiple requests by chaining handlers together. 83 | /// 84 | /// NB: If the controller is making more calls than we are handling in the scenario, 85 | /// you then typically see a `KubeError(Service(Closed(())))` from the reconciler. 86 | /// 87 | /// You should await the `JoinHandle` (with a timeout) from this function to ensure that the 88 | /// scenario runs to completion (i.e. all expected calls were responded to), 89 | /// using the timeout to catch missing api calls to Kubernetes. 90 | pub fn run(self, scenario: Scenario) -> tokio::task::JoinHandle<()> { 91 | tokio::spawn(async move { 92 | // moving self => one scenario per test 93 | match scenario { 94 | Scenario::FinalizerCreation(doc) => self.handle_finalizer_creation(doc).await, 95 | Scenario::StatusPatch(doc) => self.handle_status_patch(doc).await, 96 | Scenario::EventPublishThenStatusPatch(reason, doc) => { 97 | self.handle_event_create(reason) 98 | .await 99 | .unwrap() 100 | .handle_status_patch(doc) 101 | .await 102 | } 103 | Scenario::RadioSilence => Ok(self), 104 | Scenario::Cleanup(reason, doc) => { 105 | self.handle_event_create(reason) 106 | .await 107 | .unwrap() 108 | .handle_finalizer_removal(doc) 109 | .await 110 | } 111 | } 112 | .expect("scenario completed without errors"); 113 | }) 114 | } 115 | 116 | // chainable scenario handlers 117 | 118 | async fn handle_finalizer_creation(mut self, doc: RestEndPoint) -> Result { 119 | let (request, send) = self.0.next_request().await.expect("service not called"); 120 | // We expect a json patch to the specified document adding our finalizer 121 | assert_eq!(request.method(), http::Method::PATCH); 122 | assert_eq!( 123 | request.uri().to_string(), 124 | format!( 125 | "/apis/kuberest.solidite.fr/v1/namespaces/default/restendpoints/{}?", 126 | doc.name_any() 127 | ) 128 | ); 129 | let expected_patch = serde_json::json!([ 130 | { "op": "test", "path": "/metadata/finalizers", "value": null }, 131 | { "op": "add", "path": "/metadata/finalizers", "value": vec![RESTPATH_FINALIZER] } 132 | ]); 133 | let req_body = request.into_body().collect_bytes().await.unwrap(); 134 | let runtime_patch: serde_json::Value = 135 | serde_json::from_slice(&req_body).expect("valid document from runtime"); 136 | assert_json_include!(actual: runtime_patch, expected: expected_patch); 137 | 138 | let response = serde_json::to_vec(&doc.finalized()).unwrap(); // respond as the apiserver would have 139 | send.send_response(Response::builder().body(Body::from(response)).unwrap()); 140 | Ok(self) 141 | } 142 | 143 | async fn handle_finalizer_removal(mut self, doc: RestEndPoint) -> Result { 144 | let (request, send) = self.0.next_request().await.expect("service not called"); 145 | // We expect a json patch to the specified document removing our finalizer (at index 0) 146 | assert_eq!(request.method(), http::Method::PATCH); 147 | assert_eq!( 148 | request.uri().to_string(), 149 | format!( 150 | "/apis/kuberest.solidite.fr/v1/namespaces/default/restendpoints/{}?", 151 | doc.name_any() 152 | ) 153 | ); 154 | let expected_patch = serde_json::json!([ 155 | { "op": "test", "path": "/metadata/finalizers/0", "value": RESTPATH_FINALIZER }, 156 | { "op": "remove", "path": "/metadata/finalizers/0", "path": "/metadata/finalizers/0" } 157 | ]); 158 | let req_body = request.into_body().collect_bytes().await.unwrap(); 159 | let runtime_patch: serde_json::Value = 160 | serde_json::from_slice(&req_body).expect("valid document from runtime"); 161 | assert_json_include!(actual: runtime_patch, expected: expected_patch); 162 | 163 | let response = serde_json::to_vec(&doc).unwrap(); // respond as the apiserver would have 164 | send.send_response(Response::builder().body(Body::from(response)).unwrap()); 165 | Ok(self) 166 | } 167 | 168 | async fn handle_event_create(mut self, reason: String) -> Result { 169 | let (request, send) = self.0.next_request().await.expect("service not called"); 170 | assert_eq!(request.method(), http::Method::PATCH); 171 | assert_eq!( 172 | request.uri().to_string(), 173 | format!("/apis/events.k8s.io/v1/namespaces/default/events?") 174 | ); 175 | // verify the event reason matches the expected 176 | let req_body = request.into_body().collect_bytes().await.unwrap(); 177 | let postdata: serde_json::Value = 178 | serde_json::from_slice(&req_body).expect("valid event from runtime"); 179 | dbg!("postdata for event: {}", postdata.clone()); 180 | assert_eq!( 181 | postdata.get("reason").unwrap().as_str().map(String::from), 182 | Some(reason) 183 | ); 184 | // then pass through the body 185 | send.send_response(Response::builder().body(Body::from(req_body)).unwrap()); 186 | Ok(self) 187 | } 188 | 189 | async fn handle_status_patch(mut self, doc: RestEndPoint) -> Result { 190 | let (request, send) = self.0.next_request().await.expect("service not called"); 191 | assert_eq!(request.method(), http::Method::PATCH); 192 | assert_eq!( 193 | request.uri().to_string(), 194 | format!( 195 | "/apis/kuberest.solidite.fr/v1/namespaces/default/restendpoints/{}/status?&force=true&fieldManager=restendpoints.kuberest.solidite.fr", 196 | doc.name_any() 197 | ) 198 | ); 199 | let req_body = request.into_body().collect_bytes().await.unwrap(); 200 | let json: serde_json::Value = serde_json::from_slice(&req_body).expect("patch_status object is json"); 201 | let status_json = json.get("status").expect("status object").clone(); 202 | let status: RestEndPointStatus = serde_json::from_value(status_json).expect("valid status"); 203 | // assert_eq!(status.hidden, doc.spec.hide, "status.hidden iff doc.spec.hide"); 204 | let response = serde_json::to_vec(&doc.with_status(status)).unwrap(); 205 | // pass through document "patch accepted" 206 | send.send_response(Response::builder().body(Body::from(response)).unwrap()); 207 | Ok(self) 208 | } 209 | } 210 | 211 | impl Context { 212 | // Create a test context with a mocked kube client, locally registered metrics and default diagnostics 213 | pub fn test() -> (Arc, ApiServerVerifier, Registry) { 214 | let (mock_service, handle) = tower_test::mock::pair::, Response>(); 215 | let mock_client = Client::new(mock_service, "default"); 216 | let registry = Registry::default(); 217 | let ctx = Self { 218 | client: mock_client, 219 | metrics: Metrics::default().register(®istry).unwrap(), 220 | diagnostics: Arc::default(), 221 | }; 222 | (Arc::new(ctx), ApiServerVerifier(handle), registry) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/k8shandlers.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::restendpoint::{Metadata, RestEndPoint, RESTPATH_FINALIZER}; 4 | use anyhow::{bail, Result}; 5 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 6 | use kube::{ 7 | api::{Api, DeleteParams, ListParams, ObjectList, Patch, PatchParams, PostParams}, 8 | Client, 9 | }; 10 | 11 | pub use k8s_openapi::api::core::v1::Secret; 12 | pub struct SecretHandler { 13 | api: Api, 14 | namespace: String, 15 | } 16 | impl SecretHandler { 17 | #[must_use] 18 | pub fn new(cl: &Client, ns: &str) -> SecretHandler { 19 | SecretHandler { 20 | api: Api::namespaced(cl.clone(), ns), 21 | namespace: ns.to_string(), 22 | } 23 | } 24 | 25 | pub async fn list(&mut self) -> Result, kube::Error> { 26 | let lp = ListParams::default(); 27 | self.api.list(&lp).await 28 | } 29 | 30 | pub async fn have(&mut self, name: &str) -> bool { 31 | let list = self.list().await.unwrap(); 32 | for secret in list { 33 | if secret.metadata.name.clone().unwrap_or_default() == name { 34 | return true; 35 | } 36 | } 37 | false 38 | } 39 | 40 | pub async fn have_with_data(&mut self, name: &str, strings: &HashMap) -> bool { 41 | let list = self.list().await.unwrap(); 42 | let mut s: Option = None; 43 | for secret in list { 44 | if secret.metadata.name.clone().unwrap_or_default() == name { 45 | s = Some(secret.clone()); 46 | break; 47 | } 48 | } 49 | let mut matched = true; 50 | if let Some(secret) = s { 51 | if let Some(data) = secret.data { 52 | for (k, decoded) in strings { 53 | if serde_json::to_string(&data[k]).unwrap() 54 | != serde_json::to_string(&STANDARD.encode(decoded)).unwrap() 55 | { 56 | tracing::debug!("Unmatched data for {k}"); 57 | matched = false; 58 | break; 59 | } 60 | } 61 | matched 62 | } else { 63 | false 64 | } 65 | } else { 66 | false 67 | } 68 | } 69 | 70 | pub async fn have_uid(&mut self, name: &str, uid: &str) -> bool { 71 | let list = self.list().await.unwrap(); 72 | for secret in list { 73 | if secret.metadata.name.clone().unwrap_or_default() == name 74 | && secret.metadata.uid.clone().unwrap_or_default() == uid 75 | { 76 | return true; 77 | } 78 | } 79 | false 80 | } 81 | 82 | pub async fn get(&mut self, name: &str) -> Result { 83 | self.api.get(name).await 84 | } 85 | 86 | pub async fn create( 87 | &mut self, 88 | owner: Option<&RestEndPoint>, 89 | meta: &Metadata, 90 | strings: &HashMap, 91 | ) -> Result { 92 | let mut metadata = serde_json::json!({ 93 | "name": meta.name 94 | }); 95 | if let Some(own) = owner { 96 | // cannot flag ownership is namespace are not the same 97 | if own.metadata.namespace.clone().unwrap_or(self.namespace.clone()) == self.namespace { 98 | metadata["ownerReferences"] = serde_json::json!([{ 99 | "apiVersion": "kuberest.solidite.fr/v1", 100 | "blockOwnerDeletion": true, 101 | "controller": true, 102 | "kind": "RestEndPoint", 103 | "name": own.metadata.name, 104 | "uid": own.metadata.uid 105 | }]); 106 | } 107 | } 108 | if let Some(labels) = meta.labels.clone() { 109 | metadata["labels"] = serde_json::json!(labels); 110 | } 111 | if let Some(annotations) = meta.annotations.clone() { 112 | metadata["annotations"] = serde_json::json!(annotations); 113 | } 114 | let secret = serde_json::from_value(serde_json::json!({ 115 | "apiVersion": "v1", 116 | "kind": "Secret", 117 | "metadata": metadata, 118 | "stringData": strings 119 | })) 120 | .unwrap(); 121 | self.api.create(&PostParams::default(), &secret).await 122 | } 123 | 124 | pub async fn update( 125 | &mut self, 126 | meta: &Metadata, 127 | strings: &HashMap, 128 | ) -> Result { 129 | let mut metadata = serde_json::json!({ 130 | "name": meta.name 131 | }); 132 | if let Some(labels) = meta.labels.clone() { 133 | metadata["labels"] = serde_json::json!(labels); 134 | } 135 | if let Some(annotations) = meta.annotations.clone() { 136 | metadata["annotations"] = serde_json::json!(annotations); 137 | } 138 | let params = PatchParams::apply(RESTPATH_FINALIZER).force(); 139 | let secret = Patch::Apply(serde_json::json!({ 140 | "apiVersion": "v1", 141 | "kind": "Secret", 142 | "metadata": metadata, 143 | "stringData": strings 144 | })); 145 | self.api.patch(&meta.name, ¶ms, &secret).await 146 | } 147 | 148 | pub async fn delete(&mut self, name: &str) -> Result<()> { 149 | let _ = self 150 | .api 151 | .delete(name, &DeleteParams::default()) 152 | .await 153 | .or_else(|e| bail!("{e:?}")); 154 | Ok(()) 155 | } 156 | } 157 | 158 | pub use k8s_openapi::api::core::v1::ConfigMap; 159 | pub struct ConfigMapHandler { 160 | api: Api, 161 | namespace: String, 162 | } 163 | impl ConfigMapHandler { 164 | #[must_use] 165 | pub fn new(cl: &Client, ns: &str) -> ConfigMapHandler { 166 | ConfigMapHandler { 167 | api: Api::namespaced(cl.clone(), ns), 168 | namespace: ns.to_string(), 169 | } 170 | } 171 | 172 | pub async fn list(&mut self) -> Result, kube::Error> { 173 | let lp = ListParams::default(); 174 | self.api.list(&lp).await 175 | } 176 | 177 | pub async fn have(&mut self, name: &str) -> bool { 178 | let list = self.list().await.unwrap(); 179 | for cm in list { 180 | if cm.metadata.name.clone().unwrap_or_default() == name { 181 | return true; 182 | } 183 | } 184 | false 185 | } 186 | 187 | pub async fn have_uid(&mut self, name: &str, uid: &str) -> bool { 188 | let list = self.list().await.unwrap(); 189 | for cm in list { 190 | if cm.metadata.name.clone().unwrap_or_default() == name 191 | && cm.metadata.uid.clone().unwrap_or_default() == uid 192 | { 193 | return true; 194 | } 195 | } 196 | false 197 | } 198 | 199 | pub async fn have_with_data(&mut self, name: &str, datas: &HashMap) -> bool { 200 | let list = self.list().await.unwrap(); 201 | let mut c: Option = None; 202 | for cm in list { 203 | if cm.metadata.name.clone().unwrap_or_default() == name { 204 | c = Some(cm.clone()); 205 | break; 206 | } 207 | } 208 | let mut matched = true; 209 | if let Some(cm) = c { 210 | if let Some(data) = cm.data { 211 | for (k, val) in datas { 212 | if data[k] != *val { 213 | tracing::debug!("Unmatched data for {k}"); 214 | matched = false; 215 | break; 216 | } 217 | } 218 | matched 219 | } else { 220 | false 221 | } 222 | } else { 223 | false 224 | } 225 | } 226 | 227 | pub async fn get(&mut self, name: &str) -> Result { 228 | self.api.get(name).await 229 | } 230 | 231 | pub async fn create( 232 | &mut self, 233 | owner: Option<&RestEndPoint>, 234 | meta: &Metadata, 235 | data: &HashMap, 236 | ) -> Result { 237 | let mut metadata = serde_json::json!({ 238 | "name": meta.name 239 | }); 240 | if let Some(own) = owner { 241 | // cannot flag ownership is namespace are not the same 242 | if own.metadata.namespace.clone().unwrap_or(self.namespace.clone()) == self.namespace { 243 | metadata["ownerReferences"] = serde_json::json!([{ 244 | "apiVersion": "kuberest.solidite.fr/v1", 245 | "blockOwnerDeletion": true, 246 | "controller": true, 247 | "kind": "RestEndPoint", 248 | "name": own.metadata.name, 249 | "uid": own.metadata.uid 250 | }]); 251 | } 252 | } 253 | if let Some(labels) = meta.labels.clone() { 254 | metadata["labels"] = serde_json::json!(labels); 255 | } 256 | if let Some(annotations) = meta.annotations.clone() { 257 | metadata["annotations"] = serde_json::json!(annotations); 258 | } 259 | let cm = serde_json::from_value(serde_json::json!({ 260 | "apiVersion": "v1", 261 | "kind": "ConfigMap", 262 | "metadata": metadata, 263 | "data": data 264 | })) 265 | .unwrap(); 266 | self.api.create(&PostParams::default(), &cm).await 267 | } 268 | 269 | pub async fn update( 270 | &mut self, 271 | meta: &Metadata, 272 | data: &HashMap, 273 | ) -> Result { 274 | let mut metadata = serde_json::json!({ 275 | "name": meta.name 276 | }); 277 | if let Some(labels) = meta.labels.clone() { 278 | metadata["labels"] = serde_json::json!(labels); 279 | } 280 | if let Some(annotations) = meta.annotations.clone() { 281 | metadata["annotations"] = serde_json::json!(annotations); 282 | } 283 | let params = PatchParams::apply(RESTPATH_FINALIZER).force(); 284 | let cm = Patch::Apply(serde_json::json!({ 285 | "apiVersion": "v1", 286 | "kind": "ConfigMap", 287 | "metadata": metadata, 288 | "data": data 289 | })); 290 | self.api.patch(&meta.name, ¶ms, &cm).await 291 | } 292 | 293 | pub async fn delete(&mut self, name: &str) -> Result<()> { 294 | let _ = self 295 | .api 296 | .delete(name, &DeleteParams::default()) 297 | .await 298 | .or_else(|e| bail!("{e:?}")); 299 | Ok(()) 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2021 The Kube-rs Authors. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /deploy/crd/crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: restendpoints.kuberest.solidite.fr 5 | spec: 6 | group: kuberest.solidite.fr 7 | names: 8 | categories: [] 9 | kind: RestEndPoint 10 | plural: restendpoints 11 | shortNames: 12 | - rep 13 | singular: restendpoint 14 | scope: Namespaced 15 | versions: 16 | - additionalPrinterColumns: 17 | - description: Base URL 18 | jsonPath: .spec.client.baseurl 19 | name: baseurl 20 | type: string 21 | - description: Last update date 22 | format: date-time 23 | jsonPath: .status.conditions[?(@.type == 'Ready')].lastTransitionTime 24 | name: last_updated 25 | type: date 26 | - description: Errors 27 | jsonPath: .status.conditions[?(@.status == 'False')].message 28 | name: errors 29 | type: string 30 | name: v1 31 | schema: 32 | openAPIV3Schema: 33 | description: Custom resource representing a RestEndPoint for kuberest 34 | properties: 35 | spec: 36 | description: Describe the specification of a RestEndPoint 37 | properties: 38 | checkFrequency: 39 | description: 'checkFrequency define the pooling interval (in seconds, default: 3600 aka 1h)' 40 | format: uint64 41 | minimum: 0.0 42 | nullable: true 43 | type: integer 44 | client: 45 | description: Define the how the client should connect to the API endpoint(s) 46 | properties: 47 | baseurl: 48 | description: The baseurl the client will use. All path will use this as a prefix 49 | type: string 50 | clientCert: 51 | description: mTLS client certificate 52 | nullable: true 53 | type: string 54 | clientKey: 55 | description: mTLS client key 56 | nullable: true 57 | type: string 58 | createMethod: 59 | description: 'Method to use when creating an object (default: Get)' 60 | enum: 61 | - Post 62 | nullable: true 63 | type: string 64 | deleteMethod: 65 | description: 'Method to use when deleting an object (default: Delete)' 66 | enum: 67 | - Delete 68 | nullable: true 69 | type: string 70 | headers: 71 | additionalProperties: 72 | type: string 73 | description: Headers to use on each requests to the endpoint 74 | nullable: true 75 | type: object 76 | keyName: 77 | description: 'keyName: the key of the object (default: id)' 78 | nullable: true 79 | type: string 80 | readMethod: 81 | description: 'Method to use when reading an object (default: Post)' 82 | enum: 83 | - Get 84 | nullable: true 85 | type: string 86 | serverCa: 87 | description: For self-signed Certificates on the destination endpoint 88 | nullable: true 89 | type: string 90 | teardown: 91 | description: 'Delete the Objects on RestEndPoint deletion (default: true, inability to do so will block RestEndPoint)' 92 | nullable: true 93 | type: boolean 94 | updateMethod: 95 | description: 'Method to use when updating an object (default: Put)' 96 | enum: 97 | - Patch 98 | - Put 99 | - Post 100 | nullable: true 101 | type: string 102 | required: 103 | - baseurl 104 | type: object 105 | init: 106 | description: A rhai pre-script to setup some complex variables before client setup 107 | nullable: true 108 | type: string 109 | inputs: 110 | description: List input source for Handlebars renders 111 | items: 112 | description: inputItem describe a data input for handlebars renders 113 | properties: 114 | configMapRef: 115 | description: The ConfigMap to select from 116 | nullable: true 117 | properties: 118 | name: 119 | description: Name of the ConfigMap 120 | type: string 121 | namespace: 122 | description: 'Namespace of the ConfigMap, only used if the cross-namespace option is enabled (default: current object namespace)' 123 | nullable: true 124 | type: string 125 | optional: 126 | description: 'Is the ConfigMap requiered for processing ? (default: false)' 127 | nullable: true 128 | type: boolean 129 | required: 130 | - name 131 | type: object 132 | handleBarsRender: 133 | description: an handlebars template to be rendered 134 | nullable: true 135 | type: string 136 | name: 137 | description: name of the input (used for handlebars renders) 138 | type: string 139 | passwordGenerator: 140 | description: A password generator 141 | nullable: true 142 | properties: 143 | length: 144 | description: 'length of the password (default: 32)' 145 | format: uint32 146 | minimum: 0.0 147 | nullable: true 148 | type: integer 149 | weightAlphas: 150 | description: 'weight of alpha caracters (default: 60)' 151 | format: uint32 152 | minimum: 0.0 153 | nullable: true 154 | type: integer 155 | weightNumbers: 156 | description: 'weight of numbers caracters (default: 20)' 157 | format: uint32 158 | minimum: 0.0 159 | nullable: true 160 | type: integer 161 | weightSymbols: 162 | description: 'weight of symbols caracters (default: 20)' 163 | format: uint32 164 | minimum: 0.0 165 | nullable: true 166 | type: integer 167 | type: object 168 | secretRef: 169 | description: The Secret to select from 170 | nullable: true 171 | properties: 172 | name: 173 | description: Name of the Secret 174 | type: string 175 | namespace: 176 | description: 'Namespace of the Secret, only used if the cross-namespace option is enabled (default: current object namespace)' 177 | nullable: true 178 | type: string 179 | optional: 180 | description: 'Is the Secret optional for processing ? (default: false)' 181 | nullable: true 182 | type: boolean 183 | required: 184 | - name 185 | type: object 186 | required: 187 | - name 188 | type: object 189 | nullable: true 190 | type: array 191 | outputs: 192 | description: Objects (Secret or ConfigMap) to create at the end of the process 193 | items: 194 | description: outputItem describe an object that will be created/updated after the path objects are all handled 195 | properties: 196 | data: 197 | additionalProperties: 198 | type: string 199 | description: Data of the Output (will be base64-encoded for secret Secrets) 200 | type: object 201 | kind: 202 | description: Either ConfigMap or Secret 203 | enum: 204 | - Secret 205 | - ConfigMap 206 | type: string 207 | metadata: 208 | description: 'The metadata of the Object (requiered: name)' 209 | properties: 210 | annotations: 211 | additionalProperties: 212 | type: string 213 | description: annotations of the objects 214 | nullable: true 215 | type: object 216 | labels: 217 | additionalProperties: 218 | type: string 219 | description: labels of the objects 220 | nullable: true 221 | type: object 222 | name: 223 | description: name of the created object 224 | type: string 225 | namespace: 226 | description: namespace of the created object 227 | nullable: true 228 | type: string 229 | required: 230 | - name 231 | type: object 232 | teardown: 233 | description: 'Delete the Secret on RestEndPoint deletion (default: true)' 234 | nullable: true 235 | type: boolean 236 | required: 237 | - data 238 | - kind 239 | - metadata 240 | type: object 241 | nullable: true 242 | type: array 243 | post: 244 | description: A rhai post-script for final validation if any 245 | nullable: true 246 | type: string 247 | pre: 248 | description: A rhai pre-script to setup some complex variables 249 | nullable: true 250 | type: string 251 | reads: 252 | description: Allow to read some pre-existing objects 253 | items: 254 | description: ReadGroup describe a rest endpoint within the client sub-paths, 255 | properties: 256 | items: 257 | description: The list of object mapping 258 | items: 259 | description: readGroupItem describe an object to read with the client 260 | properties: 261 | json_query: 262 | description: Get the result from a json-query 263 | nullable: true 264 | type: string 265 | key: 266 | description: configuration of this object 267 | type: string 268 | name: 269 | description: name of the item (used for handlebars renders) 270 | type: string 271 | optional: 272 | description: Allow missing object (default false) 273 | nullable: true 274 | type: boolean 275 | required: 276 | - key 277 | - name 278 | type: object 279 | type: array 280 | name: 281 | description: name of the write (used for handlebars renders) 282 | type: string 283 | path: 284 | description: path appended to the client's baseurl for this group of objects 285 | type: string 286 | read_method: 287 | description: 'Method to use when reading an object (default: Get)' 288 | enum: 289 | - Get 290 | nullable: true 291 | type: string 292 | required: 293 | - items 294 | - name 295 | - path 296 | type: object 297 | nullable: true 298 | type: array 299 | retryFrequency: 300 | description: 'retryFrequency define the pooling interval if previous try have failed (in seconds, default: 300 aka 5mn)' 301 | format: uint64 302 | minimum: 0.0 303 | nullable: true 304 | type: integer 305 | teardown: 306 | description: A rhai teardown-script for a final cleanup on RestEndPoint deletion 307 | nullable: true 308 | type: string 309 | templates: 310 | description: List Handlebars templates to register 311 | items: 312 | description: templateItem describe a list of handlebars templates that will be registered with given name 313 | properties: 314 | name: 315 | description: name of the input (used for handlebars renders) 316 | type: string 317 | template: 318 | description: The template to register 319 | type: string 320 | required: 321 | - name 322 | - template 323 | type: object 324 | nullable: true 325 | type: array 326 | writes: 327 | description: Sub-paths to the client. Allow to describe the objects to create on the end-point 328 | items: 329 | description: writeGroup describe a rest endpoint within the client sub-paths, 330 | properties: 331 | createMethod: 332 | description: 'Method to use when creating an object (default: Post)' 333 | enum: 334 | - Post 335 | nullable: true 336 | type: string 337 | deleteMethod: 338 | description: 'Method to use when deleting an object (default: Delete)' 339 | enum: 340 | - Delete 341 | nullable: true 342 | type: string 343 | items: 344 | description: The list of object mapping 345 | items: 346 | description: writeGroupItem describe an object to maintain within 347 | properties: 348 | name: 349 | description: 'name of the item (used for handlebars renders: write..)' 350 | type: string 351 | readJsonQuery: 352 | description: If writes doesnt return values, (only used when readPath is specified too) 353 | nullable: true 354 | type: string 355 | readPath: 356 | description: If writes doesnt return values, use this read query to re-read 357 | nullable: true 358 | type: string 359 | teardown: 360 | description: 'Delete the Object on RestEndPoint deletion (default: true, inability to do so will block RestEndPoint)' 361 | nullable: true 362 | type: boolean 363 | values: 364 | description: configuration of this object (yaml format, use handlebars to generate your needed values) 365 | type: string 366 | required: 367 | - name 368 | - values 369 | type: object 370 | type: array 371 | keyName: 372 | description: 'keyName: the key of the object (default: id)' 373 | nullable: true 374 | type: string 375 | keyUseSlash: 376 | description: 'keyUseSlash: should the update/delete url end with a slash at the end (default: false)' 377 | nullable: true 378 | type: boolean 379 | name: 380 | description: 'name of the write (used for handlebars renders: write.)' 381 | type: string 382 | path: 383 | description: path appended to the client's baseurl for this group of objects 384 | type: string 385 | readMethod: 386 | description: 'Method to use when reading an object (default: Get)' 387 | enum: 388 | - Get 389 | nullable: true 390 | type: string 391 | teardown: 392 | description: 'Delete the Objects on RestEndPoint deletion (default: true, inability to do so will block RestEndPoint)' 393 | nullable: true 394 | type: boolean 395 | updateMethod: 396 | description: 'Method to use when updating an object (default: Patch)' 397 | enum: 398 | - Patch 399 | - Put 400 | - Post 401 | nullable: true 402 | type: string 403 | updatePath: 404 | description: Path to use to update/delete this write_group 405 | nullable: true 406 | type: string 407 | required: 408 | - items 409 | - name 410 | - path 411 | type: object 412 | nullable: true 413 | type: array 414 | required: 415 | - client 416 | type: object 417 | status: 418 | description: The status object of `RestEndPoint` 419 | nullable: true 420 | properties: 421 | conditions: 422 | items: 423 | description: ApplicationCondition contains details about an application condition, which is usually an error or warning 424 | properties: 425 | lastTransitionTime: 426 | description: LastTransitionTime is the time the condition was last observed 427 | format: date-time 428 | nullable: true 429 | type: string 430 | message: 431 | description: Message contains human-readable message indicating details about condition 432 | type: string 433 | status: 434 | description: Status ("True" or "False") describe if the condition is enbled 435 | enum: 436 | - 'True' 437 | - 'False' 438 | type: string 439 | type: 440 | description: Type is an application condition type 441 | enum: 442 | - Ready 443 | - InputMissing 444 | - InputFailed 445 | - TemplateFailed 446 | - InitScriptFailed 447 | - PreScriptFailed 448 | - PostScriptFailed 449 | - TeardownScriptFailed 450 | - ReadFailed 451 | - ReadMissing 452 | - ReReadFailed 453 | - WriteFailed 454 | - WriteDeleteFailed 455 | - WriteAlreadyExist 456 | - OutputFailed 457 | - OutputDeleteFailed 458 | - OutputAlreadyExist 459 | type: string 460 | required: 461 | - message 462 | - status 463 | - type 464 | type: object 465 | type: array 466 | generation: 467 | format: int64 468 | type: integer 469 | owned: 470 | items: 471 | description: List all owned k8s objects 472 | properties: 473 | kind: 474 | description: Either ConfigMap or Secret 475 | enum: 476 | - Secret 477 | - ConfigMap 478 | type: string 479 | name: 480 | description: name of the owned object 481 | type: string 482 | namespace: 483 | description: namespace of the owned object 484 | type: string 485 | uid: 486 | description: uid of the owned object 487 | type: string 488 | required: 489 | - kind 490 | - name 491 | - namespace 492 | - uid 493 | type: object 494 | type: array 495 | ownedTarget: 496 | items: 497 | description: List all owned rest objects 498 | properties: 499 | group: 500 | description: Object writeGroup 501 | type: string 502 | key: 503 | description: Object key 504 | type: string 505 | name: 506 | description: Object name within its writeGroup 507 | type: string 508 | path: 509 | description: Object path within the client 510 | type: string 511 | teardown: 512 | description: should we manage this object deletion 513 | type: boolean 514 | required: 515 | - group 516 | - key 517 | - name 518 | - path 519 | - teardown 520 | type: object 521 | type: array 522 | required: 523 | - conditions 524 | - generation 525 | - owned 526 | - ownedTarget 527 | type: object 528 | required: 529 | - spec 530 | title: RestEndPoint 531 | type: object 532 | served: true 533 | storage: true 534 | subresources: 535 | status: {} 536 | -------------------------------------------------------------------------------- /src/httphandler.rs: -------------------------------------------------------------------------------- 1 | use crate::{get_client_name, Error, Error::*, RhaiRes}; 2 | use actix_web::Result; 3 | use base64::{engine::general_purpose::STANDARD, Engine as _}; 4 | use reqwest::{Certificate, Client, Response, StatusCode}; 5 | use rhai::{Dynamic, Map}; 6 | use schemars::JsonSchema; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::{json, Value}; 9 | use tokio::runtime::Handle; 10 | use tracing::*; 11 | 12 | #[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)] 13 | pub enum ReadMethod { 14 | #[default] 15 | Get, 16 | } 17 | #[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)] 18 | pub enum CreateMethod { 19 | #[default] 20 | Post, 21 | } 22 | 23 | #[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)] 24 | pub enum UpdateMethod { 25 | #[default] 26 | Patch, 27 | Put, 28 | Post, 29 | } 30 | 31 | #[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)] 32 | pub enum DeleteMethod { 33 | #[default] 34 | Delete, 35 | } 36 | 37 | #[derive(Clone, Debug)] 38 | pub struct RestClient { 39 | baseurl: String, 40 | headers: Map, 41 | server_ca: Option, 42 | client_key: Option, 43 | client_cert: Option, 44 | } 45 | 46 | impl RestClient { 47 | #[must_use] 48 | pub fn new(base: &str) -> Self { 49 | Self { 50 | baseurl: base.to_string(), 51 | headers: Map::new(), 52 | server_ca: None, 53 | client_cert: None, 54 | client_key: None, 55 | } 56 | } 57 | 58 | pub fn baseurl(&mut self, base: &str) -> &mut RestClient { 59 | self.baseurl = base.to_string(); 60 | self 61 | } 62 | 63 | pub fn set_server_ca(&mut self, ca: &str) { 64 | self.server_ca = Some(ca.to_string()); 65 | } 66 | 67 | pub fn set_mtls(&mut self, cert: &str, key: &str) { 68 | self.client_cert = Some(cert.to_string()); 69 | self.client_key = Some(key.to_string()); 70 | } 71 | 72 | pub fn baseurl_rhai(&mut self, base: String) { 73 | self.baseurl(base.as_str()); 74 | } 75 | 76 | pub fn headers_reset(&mut self) -> &mut RestClient { 77 | self.headers = Map::new(); 78 | self 79 | } 80 | 81 | pub fn headers_reset_rhai(&mut self) { 82 | self.headers_reset(); 83 | } 84 | 85 | pub fn add_header(&mut self, key: &str, value: &str) -> &mut RestClient { 86 | self.headers 87 | .insert(key.to_string().into(), value.to_string().into()); 88 | self 89 | } 90 | 91 | pub fn add_header_rhai(&mut self, key: String, value: String) { 92 | self.add_header(key.as_str(), value.as_str()); 93 | } 94 | 95 | pub fn add_header_json_content(&mut self) -> &mut RestClient { 96 | if self 97 | .headers 98 | .clone() 99 | .into_iter() 100 | .any(|(c, _)| c == *"Content-Type") 101 | { 102 | self 103 | } else { 104 | self.add_header("Content-Type", "application/json; charset=utf-8") 105 | } 106 | } 107 | 108 | pub fn add_header_json_accept(&mut self) -> &mut RestClient { 109 | /*for (key, val) in self.headers.clone() { 110 | debug!("RestClient.header: {:} {:}", key, val); 111 | }*/ 112 | if self.headers.clone().into_iter().any(|(c, _)| c == *"Accept") { 113 | self 114 | } else { 115 | self.add_header("Accept", "application/json") 116 | } 117 | } 118 | 119 | pub fn add_header_json(&mut self) { 120 | self.add_header_json_content().add_header_json_accept(); 121 | } 122 | 123 | pub fn add_header_bearer(&mut self, token: &str) { 124 | self.add_header("Authorization", format!("Bearer {token}").as_str()); 125 | } 126 | 127 | pub fn add_header_basic(&mut self, username: &str, password: &str) { 128 | let hash = STANDARD.encode(format!("{username}:{password}")); 129 | self.add_header("Authorization", format!("Basic {hash}").as_str()); 130 | } 131 | 132 | fn get_client(&mut self) -> std::result::Result { 133 | let five_sec = std::time::Duration::from_secs(60 * 5); 134 | if self.server_ca.is_none() && (self.client_cert.is_none() || self.client_key.is_none()) { 135 | Client::builder() 136 | .user_agent(get_client_name()) 137 | .timeout(five_sec) 138 | .build() 139 | } else if self.client_cert.is_none() || self.client_key.is_none() { 140 | match Certificate::from_pem(self.server_ca.clone().unwrap().as_bytes()) { 141 | Ok(c) => Client::builder() 142 | .user_agent(get_client_name()) 143 | .timeout(five_sec) 144 | .add_root_certificate(c) 145 | .use_rustls_tls() 146 | .build(), 147 | Err(e) => Err(e), 148 | } 149 | } else { 150 | let cli_cert = format!( 151 | "{} 152 | {}", 153 | self.client_key.clone().unwrap(), 154 | self.client_cert.clone().unwrap() 155 | ); 156 | match reqwest::Identity::from_pem(cli_cert.as_bytes()) { 157 | Ok(identity) => { 158 | if self.server_ca.is_none() { 159 | Client::builder() 160 | .user_agent(get_client_name()) 161 | .timeout(five_sec) 162 | .use_rustls_tls() 163 | .identity(identity) 164 | .build() 165 | } else { 166 | match Certificate::from_pem(self.server_ca.clone().unwrap().as_bytes()) { 167 | Ok(c) => Client::builder() 168 | .user_agent(get_client_name()) 169 | .timeout(five_sec) 170 | .add_root_certificate(c) 171 | .use_rustls_tls() 172 | .identity(identity) 173 | .build(), 174 | Err(e) => Err(e), 175 | } 176 | } 177 | } 178 | Err(e) => Err(e), 179 | } 180 | } 181 | } 182 | 183 | pub fn http_get(&mut self, path: &str) -> std::result::Result { 184 | debug!("http_get '{}' ", format!("{}/{}", self.baseurl, path)); 185 | match self.get_client() { 186 | Ok(client) => { 187 | let mut req = client.get(format!("{}/{}", self.baseurl, path)); 188 | for (key, val) in self.headers.clone() { 189 | req = req.header(key.to_string(), val.to_string()); 190 | } 191 | tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await })) 192 | } 193 | Err(e) => { 194 | if e.is_builder() { 195 | warn!("CLIENT: {e:?}"); 196 | } 197 | Err(e) 198 | } 199 | } 200 | } 201 | 202 | pub fn body_get(&mut self, path: &str) -> Result { 203 | let response = self.http_get(path).map_err(Error::ReqwestError)?; 204 | if !response.status().is_success() { 205 | let status = response.status(); 206 | let text = tokio::task::block_in_place(|| { 207 | Handle::current().block_on(async move { response.text().await }) 208 | }) 209 | .map_err(Error::ReqwestError)?; 210 | return Err(Error::MethodFailed( 211 | "Get".to_string(), 212 | status.as_u16(), 213 | format!( 214 | "The server returned the error: {} {} | {text}", 215 | status.as_str(), 216 | status.canonical_reason().unwrap_or("unknown") 217 | ), 218 | )); 219 | } 220 | let text = 221 | tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await })) 222 | .map_err(Error::ReqwestError)?; 223 | Ok(text) 224 | } 225 | 226 | pub fn json_get(&mut self, path: &str) -> Result { 227 | let text = self.body_get(path)?; 228 | let json = serde_json::from_str(&text).map_err(Error::JsonError)?; 229 | Ok(json) 230 | } 231 | 232 | pub fn rhai_get(&mut self, path: String) -> RhaiRes { 233 | let mut ret = Map::new(); 234 | match self.http_get(path.as_str()) { 235 | Ok(result) => { 236 | ret.insert( 237 | "code".to_string().into(), 238 | Dynamic::from_int(result.status().as_u16().to_string().parse::().unwrap()), 239 | ); 240 | tokio::task::block_in_place(|| { 241 | tokio::runtime::Handle::current().block_on(async { 242 | let headers = result 243 | .headers() 244 | .into_iter() 245 | .map(|(key, val)| { 246 | ( 247 | key.as_str().to_string(), 248 | val.to_str().unwrap_or_default().to_string(), 249 | ) 250 | }) 251 | .collect::>(); 252 | let text = result.text().await.unwrap(); 253 | ret.insert( 254 | "json".to_string().into(), 255 | serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))), 256 | ); 257 | ret.insert("headers".to_string().into(), Dynamic::from(headers.clone())); 258 | ret.insert("body".to_string().into(), Dynamic::from(text)); 259 | Ok(ret) 260 | }) 261 | }) 262 | } 263 | Err(e) => Err(format!("{e}").into()), 264 | } 265 | } 266 | 267 | pub fn http_head(&mut self, path: &str) -> std::result::Result { 268 | debug!("http_get '{}' ", format!("{}/{}", self.baseurl, path)); 269 | match self.get_client() { 270 | Ok(client) => { 271 | let mut req = client.head(format!("{}/{}", self.baseurl, path)); 272 | for (key, val) in self.headers.clone() { 273 | req = req.header(key.to_string(), val.to_string()); 274 | } 275 | tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await })) 276 | } 277 | Err(e) => { 278 | if e.is_builder() { 279 | warn!("CLIENT: {e:?}"); 280 | } 281 | Err(e) 282 | } 283 | } 284 | } 285 | 286 | pub fn rhai_head(&mut self, path: String) -> RhaiRes { 287 | let mut ret = Map::new(); 288 | match self.http_head(path.as_str()) { 289 | Ok(result) => { 290 | ret.insert( 291 | "code".to_string().into(), 292 | Dynamic::from_int(result.status().as_u16().to_string().parse::().unwrap()), 293 | ); 294 | let headers = result 295 | .headers() 296 | .into_iter() 297 | .map(|(key, val)| { 298 | ( 299 | key.as_str().to_string(), 300 | val.to_str().unwrap_or_default().to_string(), 301 | ) 302 | }) 303 | .collect::>(); 304 | ret.insert("headers".to_string().into(), Dynamic::from(headers.clone())); 305 | Ok(ret) 306 | } 307 | Err(e) => Err(format!("{e}").into()), 308 | } 309 | } 310 | 311 | pub fn http_patch(&mut self, path: &str, body: &str) -> Result { 312 | debug!("http_patch '{}' ", format!("{}/{}", self.baseurl, path)); 313 | match self.get_client() { 314 | Ok(client) => { 315 | let mut req = client 316 | .patch(format!("{}/{}", self.baseurl, path)) 317 | .body(body.to_string()); 318 | for (key, val) in self.headers.clone() { 319 | req = req.header(key.to_string(), val.to_string()); 320 | } 321 | tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await })) 322 | } 323 | Err(e) => Err(e), 324 | } 325 | } 326 | 327 | pub fn body_patch(&mut self, path: &str, body: &str) -> Result { 328 | let response = self.http_patch(path, body).map_err(Error::ReqwestError)?; 329 | if !response.status().is_success() { 330 | let status = response.status(); 331 | let text = tokio::task::block_in_place(|| { 332 | Handle::current().block_on(async move { response.text().await }) 333 | }) 334 | .map_err(Error::ReqwestError)?; 335 | return Err(Error::MethodFailed( 336 | "Patch".to_string(), 337 | status.as_u16(), 338 | format!( 339 | "The server returned the error: {} {} | {text}", 340 | status.as_str(), 341 | status.canonical_reason().unwrap_or("unknown") 342 | ), 343 | )); 344 | } 345 | let text = 346 | tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await })) 347 | .map_err(Error::ReqwestError)?; 348 | Ok(text) 349 | } 350 | 351 | pub fn json_patch(&mut self, path: &str, input: &Value) -> Result { 352 | let body = serde_json::to_string(input).map_err(Error::JsonError)?; 353 | let text = self.body_patch(path, body.as_str())?; 354 | let json = serde_json::from_str(&text).map_err(Error::JsonError)?; 355 | Ok(json) 356 | } 357 | 358 | pub fn rhai_patch(&mut self, path: String, val: Dynamic) -> RhaiRes { 359 | let body = if val.is_string() { 360 | val.to_string() 361 | } else { 362 | serde_json::to_string(&val).unwrap() 363 | }; 364 | let mut ret = Map::new(); 365 | match self.http_patch(path.as_str(), &body) { 366 | Ok(result) => { 367 | ret.insert( 368 | "code".to_string().into(), 369 | Dynamic::from_int(result.status().as_u16().to_string().parse::().unwrap()), 370 | ); 371 | tokio::task::block_in_place(|| { 372 | tokio::runtime::Handle::current().block_on(async { 373 | let headers = result 374 | .headers() 375 | .into_iter() 376 | .map(|(key, val)| { 377 | ( 378 | key.as_str().to_string(), 379 | val.to_str().unwrap_or_default().to_string(), 380 | ) 381 | }) 382 | .collect::>(); 383 | let text = result.text().await.unwrap(); 384 | ret.insert( 385 | "json".to_string().into(), 386 | serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))), 387 | ); 388 | ret.insert("headers".to_string().into(), Dynamic::from(headers.clone())); 389 | ret.insert("body".to_string().into(), Dynamic::from(text)); 390 | Ok(ret) 391 | }) 392 | }) 393 | } 394 | Err(e) => Err(format!("{e}").into()), 395 | } 396 | } 397 | 398 | pub fn http_put(&mut self, path: &str, body: &str) -> Result { 399 | debug!("http_put '{}' ", format!("{}/{}", self.baseurl, path)); 400 | match self.get_client() { 401 | Ok(client) => { 402 | let mut req = client 403 | .put(format!("{}/{}", self.baseurl, path)) 404 | .body(body.to_string()); 405 | for (key, val) in self.headers.clone() { 406 | req = req.header(key.to_string(), val.to_string()); 407 | } 408 | tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await })) 409 | } 410 | Err(e) => Err(e), 411 | } 412 | } 413 | 414 | pub fn body_put(&mut self, path: &str, body: &str) -> Result { 415 | let response = self.http_put(path, body).map_err(Error::ReqwestError)?; 416 | if !response.status().is_success() { 417 | let status = response.status(); 418 | let text = tokio::task::block_in_place(|| { 419 | Handle::current().block_on(async move { response.text().await }) 420 | }) 421 | .map_err(Error::ReqwestError)?; 422 | return Err(Error::MethodFailed( 423 | "Put".to_string(), 424 | status.as_u16(), 425 | format!( 426 | "The server returned the error: {} {} | {text}", 427 | status.as_str(), 428 | status.canonical_reason().unwrap_or("unknown") 429 | ), 430 | )); 431 | } 432 | let text = 433 | tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await })) 434 | .map_err(Error::ReqwestError)?; 435 | Ok(text) 436 | } 437 | 438 | pub fn json_put(&mut self, path: &str, input: &Value) -> Result { 439 | let body = serde_json::to_string(input).map_err(Error::JsonError)?; 440 | let text = self.body_put(path, body.as_str())?; 441 | let json = serde_json::from_str(&text).map_err(Error::JsonError)?; 442 | Ok(json) 443 | } 444 | 445 | pub fn rhai_put(&mut self, path: String, val: Dynamic) -> RhaiRes { 446 | let body = if val.is_string() { 447 | val.to_string() 448 | } else { 449 | serde_json::to_string(&val).unwrap() 450 | }; 451 | let mut ret = Map::new(); 452 | match self.http_put(path.as_str(), &body) { 453 | Ok(result) => { 454 | ret.insert( 455 | "code".to_string().into(), 456 | Dynamic::from_int(result.status().as_u16().to_string().parse::().unwrap()), 457 | ); 458 | tokio::task::block_in_place(|| { 459 | tokio::runtime::Handle::current().block_on(async { 460 | let headers = result 461 | .headers() 462 | .into_iter() 463 | .map(|(key, val)| { 464 | ( 465 | key.as_str().to_string(), 466 | val.to_str().unwrap_or_default().to_string(), 467 | ) 468 | }) 469 | .collect::>(); 470 | let text = result.text().await.unwrap(); 471 | ret.insert( 472 | "json".to_string().into(), 473 | serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))), 474 | ); 475 | ret.insert("headers".to_string().into(), Dynamic::from(headers.clone())); 476 | ret.insert("body".to_string().into(), Dynamic::from(text)); 477 | Ok(ret) 478 | }) 479 | }) 480 | } 481 | Err(e) => Err(format!("{e}").into()), 482 | } 483 | } 484 | 485 | pub fn http_post(&mut self, path: &str, body: &str) -> Result { 486 | debug!("http_post '{}' ", format!("{}/{}", self.baseurl, path)); 487 | match self.get_client() { 488 | Ok(client) => { 489 | let mut req = client 490 | .post(format!("{}/{}", self.baseurl, path)) 491 | .body(body.to_string()); 492 | for (key, val) in self.headers.clone() { 493 | req = req.header(key.to_string(), val.to_string()); 494 | } 495 | tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await })) 496 | } 497 | Err(e) => Err(e), 498 | } 499 | } 500 | 501 | pub fn body_post(&mut self, path: &str, body: &str) -> Result { 502 | let response = self.http_post(path, body).map_err(Error::ReqwestError)?; 503 | if !response.status().is_success() { 504 | let status = response.status(); 505 | let text = tokio::task::block_in_place(|| { 506 | Handle::current().block_on(async move { response.text().await }) 507 | }) 508 | .map_err(Error::ReqwestError)?; 509 | return Err(Error::MethodFailed( 510 | "Post".to_string(), 511 | status.as_u16(), 512 | format!( 513 | "The server returned the error: {} {} | {text}", 514 | status.as_str(), 515 | status.canonical_reason().unwrap_or("unknown") 516 | ), 517 | )); 518 | } 519 | let text = 520 | tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await })) 521 | .map_err(Error::ReqwestError)?; 522 | Ok(text) 523 | } 524 | 525 | pub fn json_post(&mut self, path: &str, input: &Value) -> Result { 526 | let body = serde_json::to_string(input).map_err(Error::JsonError)?; 527 | let text = self.body_post(path, body.as_str())?; 528 | let json = serde_json::from_str(&text).map_err(Error::JsonError)?; 529 | Ok(json) 530 | } 531 | 532 | pub fn rhai_post(&mut self, path: String, val: Dynamic) -> RhaiRes { 533 | let body = if val.is_string() { 534 | val.to_string() 535 | } else { 536 | serde_json::to_string(&val).unwrap() 537 | }; 538 | let mut ret = Map::new(); 539 | match self.http_post(path.as_str(), &body) { 540 | Ok(result) => { 541 | ret.insert( 542 | "code".to_string().into(), 543 | Dynamic::from_int(result.status().as_u16().to_string().parse::().unwrap()), 544 | ); 545 | tokio::task::block_in_place(|| { 546 | tokio::runtime::Handle::current().block_on(async { 547 | let headers = result 548 | .headers() 549 | .into_iter() 550 | .map(|(key, val)| { 551 | ( 552 | key.as_str().to_string(), 553 | val.to_str().unwrap_or_default().to_string(), 554 | ) 555 | }) 556 | .collect::>(); 557 | let text = result.text().await.unwrap(); 558 | ret.insert( 559 | "json".to_string().into(), 560 | serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))), 561 | ); 562 | ret.insert("headers".to_string().into(), Dynamic::from(headers.clone())); 563 | ret.insert("body".to_string().into(), Dynamic::from(text)); 564 | Ok(ret) 565 | }) 566 | }) 567 | } 568 | Err(e) => Err(format!("{e}").into()), 569 | } 570 | } 571 | 572 | pub fn http_delete(&mut self, path: &str) -> Result { 573 | debug!("http_delete '{}' ", format!("{}/{}", self.baseurl, path)); 574 | match self.get_client() { 575 | Ok(client) => { 576 | let mut req = client.delete(format!("{}/{}", self.baseurl, path)); 577 | for (key, val) in self.headers.clone() { 578 | req = req.header(key.to_string(), val.to_string()); 579 | } 580 | tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await })) 581 | } 582 | Err(e) => Err(e), 583 | } 584 | } 585 | 586 | pub fn body_delete(&mut self, path: &str) -> Result { 587 | let response = self.http_delete(path).map_err(Error::ReqwestError)?; 588 | if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND { 589 | let status = response.status(); 590 | let text = tokio::task::block_in_place(|| { 591 | Handle::current().block_on(async move { response.text().await }) 592 | }) 593 | .map_err(Error::ReqwestError)?; 594 | return Err(Error::MethodFailed( 595 | "Delete".to_string(), 596 | status.as_u16(), 597 | format!( 598 | "The server returned the error: {} {} | {text}", 599 | status.as_str(), 600 | status.canonical_reason().unwrap_or("unknown") 601 | ), 602 | )); 603 | } 604 | let text = 605 | tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await })) 606 | .map_err(Error::ReqwestError)?; 607 | Ok(text) 608 | } 609 | 610 | pub fn json_delete(&mut self, path: &str) -> Result { 611 | let text = self.body_delete(path)?; 612 | let json = 613 | serde_json::from_str(&text).or_else(|_| Ok::(json!({"body": text})))?; 614 | Ok(json) 615 | } 616 | 617 | pub fn rhai_delete(&mut self, path: String) -> RhaiRes { 618 | let mut ret = Map::new(); 619 | match self.http_delete(path.as_str()) { 620 | Ok(result) => { 621 | ret.insert( 622 | "code".to_string().into(), 623 | Dynamic::from_int(result.status().as_u16().to_string().parse::().unwrap()), 624 | ); 625 | tokio::task::block_in_place(|| { 626 | tokio::runtime::Handle::current().block_on(async { 627 | let headers = result 628 | .headers() 629 | .into_iter() 630 | .map(|(key, val)| { 631 | ( 632 | key.as_str().to_string(), 633 | val.to_str().unwrap_or_default().to_string(), 634 | ) 635 | }) 636 | .collect::>(); 637 | let text = result.text().await.unwrap(); 638 | ret.insert( 639 | "json".to_string().into(), 640 | serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))), 641 | ); 642 | ret.insert("headers".to_string().into(), Dynamic::from(headers.clone())); 643 | ret.insert("body".to_string().into(), Dynamic::from(text)); 644 | Ok(ret) 645 | }) 646 | }) 647 | } 648 | Err(e) => Err(format!("{e}").into()), 649 | } 650 | } 651 | 652 | pub fn obj_read(&mut self, method: ReadMethod, path: &str, key: &str) -> Result { 653 | let full_path = if key.is_empty() { 654 | path.to_string() 655 | } else { 656 | format!("{path}/{key}") 657 | }; 658 | if method == ReadMethod::Get { 659 | self.json_get(&full_path) 660 | } else { 661 | Err(UnsupportedMethod) 662 | } 663 | } 664 | 665 | pub fn obj_create(&mut self, method: CreateMethod, path: &str, input: &Value) -> Result { 666 | if method == CreateMethod::Post { 667 | self.json_post(path, input) 668 | } else { 669 | Err(UnsupportedMethod) 670 | } 671 | } 672 | 673 | pub fn obj_update( 674 | &mut self, 675 | method: UpdateMethod, 676 | path: &str, 677 | key: &str, 678 | input: &Value, 679 | ) -> Result { 680 | let full_path = if key.is_empty() { 681 | path.to_string() 682 | } else { 683 | format!("{path}/{key}") 684 | }; 685 | if method == UpdateMethod::Patch { 686 | self.json_patch(&full_path, input) 687 | } else if method == UpdateMethod::Put { 688 | self.json_put(&full_path, input) 689 | } else if method == UpdateMethod::Post { 690 | self.json_post(&full_path, input) 691 | } else { 692 | Err(UnsupportedMethod) 693 | } 694 | } 695 | 696 | pub fn obj_delete(&mut self, method: DeleteMethod, path: &str, key: &str) -> Result { 697 | let full_path = if key.is_empty() { 698 | path.to_string() 699 | } else { 700 | format!("{path}/{key}") 701 | }; 702 | if method == DeleteMethod::Delete { 703 | self.json_delete(&full_path) 704 | } else { 705 | Err(UnsupportedMethod) 706 | } 707 | } 708 | } 709 | --------------------------------------------------------------------------------