Compare commits

20 Commits

Author SHA1 Message Date
a221159136 Check chart releaser
Some checks failed
ci/woodpecker/push/publish-helm-chart Pipeline failed
ci/woodpecker/push/build-dev-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 15:54:19 +01:00
7af185f4d8 Check chart releaser
Some checks failed
ci/woodpecker/push/publish-helm-chart Pipeline failed
ci/woodpecker/push/build-dev-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 15:45:38 +01:00
afa59c7edb Check chart releaser
Some checks failed
ci/woodpecker/push/publish-helm-chart Pipeline failed
ci/woodpecker/push/build-dev-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 15:44:25 +01:00
ec87215b95 Check chart releaser
Some checks failed
ci/woodpecker/push/publish-helm-chart Pipeline failed
ci/woodpecker/push/build-dev-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 15:39:07 +01:00
672249dcac Check chart releaser
Some checks failed
ci/woodpecker/push/publish-helm-chart Pipeline failed
ci/woodpecker/push/build-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 15:36:56 +01:00
cb2f89e80c Fix the containerfile again
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 14:40:15 +01:00
43dc59bff7 Fix the containerfile again
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 14:19:36 +01:00
001eb4edda Fix the containerfile again
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 10:55:20 +01:00
b2993acedb Fix the containerfile again
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-18 10:37:40 +01:00
84c5ac2024 Build the dev version on branches
Some checks failed
ci/woodpecker/push/build-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-17 22:06:56 +01:00
f3e74d6dee Rename the binary
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-17 22:01:48 +01:00
a5ce9ed3da A lot of fixes
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-17 21:35:26 +01:00
0e9f35969e Fix the containerfile
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-17 18:16:09 +01:00
2da7660f9c Try a nightly toolchain in the build
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-15 17:12:13 +01:00
cba4da6d84 Try a nightly toolchain in the build
All checks were successful
ci/woodpecker/push/build-container Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-15 16:48:05 +01:00
334cfddcf0 Try a nightly toolchain in the build
Some checks failed
ci/woodpecker/push/build-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-15 14:28:34 +01:00
3b1dd27b26 WIP: Build a first container
Some checks failed
ci/woodpecker/push/build-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-14 22:46:04 +01:00
3393cf86e8 WIP: Build a first container
Some checks failed
ci/woodpecker/push/build-container Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-14 22:38:37 +01:00
e532d860bd WIP: Build a first container
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-14 22:38:23 +01:00
22d6813c24 WIP: First more or less working version
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-03-14 19:53:05 +01:00
66 changed files with 8217 additions and 2 deletions

View File

@@ -0,0 +1,29 @@
---
when:
event:
- push
branch:
exclude:
- main
steps:
- name: Build and push a dev container image
image: gitea.badhouseplants.net/badhouseplants/container-builder
environment:
PACKAGE_NAME: badhouseplants/rustfs-manager-operator-dev
REGISTRY_TOKEN:
from_secret: GITEA_REGISTRY_TOKEN
REGISTRY_USER: devops-bot
privileged: true
commands:
- cd ./operator && build-container
backend_options:
kubernetes:
resources:
requests:
memory: 700Mi
cpu: 1000m
limits:
cpu: 1000m
securityContext:
privileged: true

View File

@@ -0,0 +1,56 @@
---
when:
event:
- push
steps:
- name: Build and push helm charts
image: docker.io/library/ubuntu:25.04
environment:
PACKAGE_NAME: badhouseplants/rustfs-manager-operator-dev
REGISTRY_TOKEN:
from_secret: GITEA_REGISTRY_TOKEN
REGISTRY_USER: devops-bot
commands:
- apt-get update && apt-get install curl gpg apt-transport-https --yes
- apt-get install curl gpg apt-transport-https --yes
- curl -fsSL https://packages.buildkite.com/helm-linux/helm-debian/gpgkey | gpg --dearmor | tee /usr/share/keyrings/helm.gpg > /dev/null
- echo "deb [signed-by=/usr/share/keyrings/helm.gpg] https://packages.buildkite.com/helm-linux/helm-debian/any/ any main" | tee /etc/apt/sources.list.d/helm-stable-debian.list
- apt-get update
- apt-get install helm -y
- export SHA="+$(git rev-parse --short HEAD)"
- helm registry login gitea.badhouseplants.net \
--username=$REGISTRY_USER \
--password=$REGISTRY_TOKEN
- |-
for chart in $(find charts -maxdepth 1 -mindepth 1 -type d); do
if [ "$CI_COMMIT_BRANCH" != "main" ]; then
yq e -i ".version += env(SHA)" "$chart/Chart.yaml"
fi
helm dep build $chart
helm package $chart -d chart-packages;
done
- export CHARTS=$(find chart-packages -maxdepth 1 -mindepth 1 -type f)
- export REGISTRY=$(echo oci://ghcr.io/$CI_REPO | tr '[:upper:]' '[:lower:]')
- |-
for chart in $CHARTS; do
echo ${chart}
CHART_NAME=$(helm show chart "${chart}" | yq .name)
CHART_VERSION=$(helm show chart "${chart}" | yq .version)
if helm pull ${REGISTRY}/${CHART_NAME}:${CHART_VERSION}; then
echo "Chart is found in the upstream: ${CHART_NAME}:${CHART_VERSION}"
continue;
fi
helm push "${chart}" "${REGISTRY}"
done
backend_options:
kubernetes:
resources:
requests:
memory: 700Mi
cpu: 1000m
limits:
cpu: 1000m
securityContext:
privileged: true

View File

@@ -1,3 +1,4 @@
# rustfs-manager-operator # RustFS Manager Operator
An operator to manage bucket, users, and policies on the RustfFS instance through Kubernetes CRDs.
An operator to manage bucket, users, and policies on the RustfFS instance through CRDs

25
Taskfile.yml Normal file
View File

@@ -0,0 +1,25 @@
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
vars:
GREETING: Hello, world!
tasks:
default:
desc: Print a greeting message
cmds:
- echo "{{.GREETING}}"
silent: true
sync-crds:
desc: Sync CRD from the operator to the chart
vars:
WORKDIR:
sh: mktemp -d
cmds:
- cd operator && cargo run -- --print-crd > {{.WORKDIR}}/crds.yaml
- cd {{.WORKDIR}} && yq -s '.metadata.name + ".yaml"' crds.yaml && rm crds.yaml
- rm -rf ./helm/rustfs-manager-operator/crd/*
- cp {{.WORKDIR}}/* ./helm/rustfs-manager-operator/crd/
- rm -rf {{.WORKDIR}}

View File

@@ -0,0 +1 @@
# RustFS Manager Operator

213
documentation/poetry.lock generated Normal file
View File

@@ -0,0 +1,213 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "click"
version = "8.3.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "deepmerge"
version = "2.0"
description = "A toolset for deeply merging Python dictionaries."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00"},
{file = "deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20"},
]
[package.extras]
dev = ["black", "build", "mypy", "pytest", "pyupgrade", "twine", "validate-pyproject[all]"]
[[package]]
name = "markdown"
version = "3.10.2"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"},
{file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pymdown-extensions"
version = "10.21"
description = "Extension pack for Python Markdown."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f"},
{file = "pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5"},
]
[package.dependencies]
markdown = ">=3.6"
pyyaml = "*"
[package.extras]
extra = ["pygments (>=2.19.1)"]
[[package]]
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "zensical"
version = "0.0.27"
description = "A modern static site generator built by the creators of Material for MkDocs"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "zensical-0.0.27-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d51ebf4b038f3eea99fd337119b99d92ad92bbe674372d5262e6dbbabbe4e9b5"},
{file = "zensical-0.0.27-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a627cd4599cf2c5a5a5205f0510667227d1fe4579b6f7445adba2d84bab9fbc8"},
{file = "zensical-0.0.27-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99cbc493022f8749504ef10c71772d360b705b4e2fd1511421393157d07bdccf"},
{file = "zensical-0.0.27-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecc20a85e8a23ad9ab809b2f268111321be7b2e214021b3b00f138936a87a434"},
{file = "zensical-0.0.27-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da11e0f0861dbd7d3b5e6fe1e3a53b361b2181c53f3abe9fb4cdf2ed0cea47bf"},
{file = "zensical-0.0.27-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e11d220181477040a4b22bf2b8678d5b0c878e7aae194fad4133561cb976d69"},
{file = "zensical-0.0.27-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06b9e308aec8c5db1cd623e2e98e1b25c3f5cab6b25fcc9bac1e16c0c2b93837"},
{file = "zensical-0.0.27-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:682085155126965b091cb9f915cd2e4297383ac500122fd4b632cf4511733eb2"},
{file = "zensical-0.0.27-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b367c285157c8e1099ae9e2b36564e07d3124bf891e96194a093bc836f3058d2"},
{file = "zensical-0.0.27-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:847c881209e65e1db1291c59a9db77966ac50f7c66bf9a733c3c7832144dbfca"},
{file = "zensical-0.0.27-cp310-abi3-win32.whl", hash = "sha256:f31ec13c700794be3f9c0b7d90f09a7d23575a3a27c464994b9bb441a22d880b"},
{file = "zensical-0.0.27-cp310-abi3-win_amd64.whl", hash = "sha256:9d3b1fca7ea99a7b2a8db272dd7f7839587c4ebf4f56b84ff01c97b3893ec9f8"},
{file = "zensical-0.0.27.tar.gz", hash = "sha256:6d8d74aba4a9f9505e6ba1c43d4c828ba4ff7bb1ff9b005e5174c5b92cf23419"},
]
[package.dependencies]
click = ">=8.1.8"
deepmerge = ">=2.0"
markdown = ">=3.7"
pygments = ">=2.16"
pymdown-extensions = ">=10.15"
pyyaml = ">=6.0.2"
[metadata]
lock-version = "2.1"
python-versions = ">=3.14"
content-hash = "07407b9a1e6b704c9524ee6ed39ed6795dfa430ee6f4207bd440c1e149dd2a2f"

View File

@@ -0,0 +1,17 @@
[project]
name = "documentation"
version = "0.1.0"
description = ""
authors = [
{name = "Nikolai Rodionov",email = "iam@allanger.xyz"}
]
license = {text = "GPL 3.0"}
requires-python = ">=3.14"
dependencies = [
"zensical (>=0.0.27,<0.0.28)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

355
documentation/site/404.html Normal file
View File

@@ -0,0 +1,355 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="A new project generated from the default template project.">
<meta name="author" content="<your name here>">
<link rel="icon" href="/assets/logo.png">
<meta name="generator" content="zensical-0.0.27">
<title>Documentation</title>
<link rel="stylesheet" href="/assets/stylesheets/modern/main.50057488.min.css">
<link rel="stylesheet" href="/assets/stylesheets/modern/palette.dfe2e883.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter:300,300i,400,400i,500,500i,700,700i%7CJetBrains+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Inter";--md-code-font:"JetBrains Mono"}</style>
<script>__md_scope=new URL("/",location),__md_hash=e=>[...e].reduce(((e,t)=>(e<<5)-e+t.charCodeAt(0)),0),__md_get=(e,t=localStorage,a=__md_scope)=>JSON.parse(t.getItem(a.pathname+"."+e)),__md_set=(e,t,a=localStorage,_=__md_scope)=>{try{a.setItem(_.pathname+"."+e,JSON.stringify(t))}catch(e){}},document.documentElement.setAttribute("data-platform",navigator.platform)</script>
</head>
<body dir="ltr" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer" aria-label="Navigation"></label>
<div data-md-component="skip">
<a href="#__skip" class="md-skip">
Skip to content
</a>
</div>
<div data-md-component="announce">
</div>
<header class="md-header md-header--shadow" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="/" title="Documentation" class="md-header__button md-logo" aria-label="Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-book-open" viewBox="0 0 24 24"><path d="M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>
</a>
<label class="md-header__button md-icon" for="__drawer" aria-label="Navigation">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-menu" viewBox="0 0 24 24"><path d="M4 5h16M4 12h16M4 19h16"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
Documentation
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
</span>
</div>
</div>
</div>
<form class="md-header__option" data-md-component="palette">
<input class="md-option" data-md-color-media="none" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_0">
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_1" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
</label>
<input class="md-option" data-md-color-media="none" data-md-color-scheme="slate" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_1">
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_0" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>
</label>
</form>
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog" aria-label="Search">
<button type="button" class="md-search__button">
Search
</button>
</div>
<div class="md-header__source">
<a href="https://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path fill="currentColor" d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
badhouseplants/rustfs-manager-operator
</div>
</a>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="/" title="Documentation" class="md-nav__button md-logo" aria-label="Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-book-open" viewBox="0 0 24 24"><path d="M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>
</a>
Documentation
</label>
<div class="md-nav__source">
<a href="https://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path fill="currentColor" d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
badhouseplants/rustfs-manager-operator
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/" class="md-nav__link">
<span class="md-ellipsis">
RustFS Manager Operator
</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--secondary" aria-label="On this page">
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
<h1>404 - Not found</h1>
</article>
</div>
<script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var labels=set.querySelector(".tabbed-labels");for(var tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-circle-arrow-up" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4M12 16V8"/></svg>
Back to top
</button>
</main>
<footer class="md-footer">
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
<div class="md-copyright__highlight">
Copyright &copy; 2026 The authors
</div>
Made with
<a href="https://zensical.org/" target="_blank" rel="noopener">
Zensical
</a>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"annotate":null,"base":"/","features":["announce.dismiss","content.code.annotate","content.code.copy","content.code.select","content.footnote.tooltips","content.tabs.link","content.tooltips","navigation.footer","navigation.indexes","navigation.instant","navigation.instant.prefetch","navigation.path","navigation.sections","navigation.top","navigation.tracking","search.highlight","toc.follow"],"search":"/assets/javascripts/workers/search.e2d2d235.min.js","tags":null,"translations":{"clipboard.copied":"Copied to clipboard","clipboard.copy":"Copy to clipboard","search.result.more.one":"1 more on this page","search.result.more.other":"# more on this page","search.result.none":"No matching documents","search.result.one":"1 matching document","search.result.other":"# matching documents","search.result.placeholder":"Type to start searching","search.result.term.missing":"Missing","select.version":"Select version"},"version":null}</script>
<script src="/assets/javascripts/bundle.5fd3284f.min.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,29 @@
-------------------------------------------------------------------------------
Third-Party licenses
-------------------------------------------------------------------------------
Package: clipboard@2.0.11
License: MIT
Copyright: Zeno Rocha
-------------------------------------------------------------------------------
Package: escape-html@1.0.3
License: MIT
Copyright: 2012-2013 TJ Holowaychuk
2015 Andreas Lubbe
2015 Tiancheng "Timothy" Gu
-------------------------------------------------------------------------------
Package: focus-visible@5.2.1
License: W3C
Copyright: WICG
-------------------------------------------------------------------------------
Package: rxjs@7.8.2
License: Apache-2.0
Copyright: 2015-2018 Google, Inc.,
2015-2018 Netflix, Inc.,
2015-2018 Microsoft Corp. and contributors

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,384 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="A new project generated from the default template project.">
<meta name="author" content="<your name here>">
<link rel="icon" href="./assets/logo.png">
<meta name="generator" content="zensical-0.0.27">
<title>RustFS Manager Operator - Documentation</title>
<link rel="stylesheet" href="./assets/stylesheets/modern/main.50057488.min.css">
<link rel="stylesheet" href="./assets/stylesheets/modern/palette.dfe2e883.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter:300,300i,400,400i,500,500i,700,700i%7CJetBrains+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Inter";--md-code-font:"JetBrains Mono"}</style>
<script>__md_scope=new URL(".",location),__md_hash=e=>[...e].reduce(((e,t)=>(e<<5)-e+t.charCodeAt(0)),0),__md_get=(e,t=localStorage,a=__md_scope)=>JSON.parse(t.getItem(a.pathname+"."+e)),__md_set=(e,t,a=localStorage,_=__md_scope)=>{try{a.setItem(_.pathname+"."+e,JSON.stringify(t))}catch(e){}},document.documentElement.setAttribute("data-platform",navigator.platform)</script>
</head>
<body dir="ltr" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer" aria-label="Navigation"></label>
<div data-md-component="skip">
<a href="#rustfs-manager-operator" class="md-skip">
Skip to content
</a>
</div>
<div data-md-component="announce">
</div>
<header class="md-header md-header--shadow" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="" title="Documentation" class="md-header__button md-logo" aria-label="Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-book-open" viewBox="0 0 24 24"><path d="M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>
</a>
<label class="md-header__button md-icon" for="__drawer" aria-label="Navigation">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-menu" viewBox="0 0 24 24"><path d="M4 5h16M4 12h16M4 19h16"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
Documentation
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
RustFS Manager Operator
</span>
</div>
</div>
</div>
<form class="md-header__option" data-md-component="palette">
<input class="md-option" data-md-color-media="none" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_0">
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_1" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
</label>
<input class="md-option" data-md-color-media="none" data-md-color-scheme="slate" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_1">
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_0" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>
</label>
</form>
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog" aria-label="Search">
<button type="button" class="md-search__button">
Search
</button>
</div>
<div class="md-header__source">
<a href="https://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path fill="currentColor" d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
badhouseplants/rustfs-manager-operator
</div>
</a>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="" title="Documentation" class="md-nav__button md-logo" aria-label="Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-book-open" viewBox="0 0 24 24"><path d="M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>
</a>
Documentation
</label>
<div class="md-nav__source">
<a href="https://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path fill="currentColor" d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
badhouseplants/rustfs-manager-operator
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item md-nav__item--active">
<input class="md-nav__toggle md-toggle" type="checkbox" id="__toc">
<a href="" class="md-nav__link md-nav__link--active">
<span class="md-ellipsis">
RustFS Manager Operator
</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--secondary" aria-label="On this page">
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
<h1 id="rustfs-manager-operator">RustFS Manager Operator<a class="headerlink" href="#rustfs-manager-operator" title="Permanent link">&para;</a></h1>
</article>
</div>
<script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var labels=set.querySelector(".tabbed-labels");for(var tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" class="lucide lucide-circle-arrow-up" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4M12 16V8"/></svg>
Back to top
</button>
</main>
<footer class="md-footer">
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
<div class="md-copyright__highlight">
Copyright &copy; 2026 The authors
</div>
Made with
<a href="https://zensical.org/" target="_blank" rel="noopener">
Zensical
</a>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"annotate":null,"base":".","features":["announce.dismiss","content.code.annotate","content.code.copy","content.code.select","content.footnote.tooltips","content.tabs.link","content.tooltips","navigation.footer","navigation.indexes","navigation.instant","navigation.instant.prefetch","navigation.path","navigation.sections","navigation.top","navigation.tracking","search.highlight","toc.follow"],"search":"./assets/javascripts/workers/search.e2d2d235.min.js","tags":null,"translations":{"clipboard.copied":"Copied to clipboard","clipboard.copy":"Copy to clipboard","search.result.more.one":"1 more on this page","search.result.more.other":"# more on this page","search.result.none":"No matching documents","search.result.one":"1 matching document","search.result.other":"# matching documents","search.result.placeholder":"Type to start searching","search.result.term.missing":"Missing","select.version":"Select version"},"version":null}</script>
<script src="./assets/javascripts/bundle.5fd3284f.min.js"></script>
</body>
</html>

View File

View File

@@ -0,0 +1 @@
{"config":{"separator":"[\\s\\-_,:!=\\[\\]()\\\\\"`/]+|\\.(?!\\d)"},"items":[{"location":"","level":1,"title":"RustFS Manager Operator","text":"","path":["RustFS Manager Operator"],"tags":[]}]}

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>

View File

@@ -0,0 +1,52 @@
[project]
site_name = "Documentation"
site_description = "A new project generated from the default template project."
site_author = "<your name here>"
copyright = """
Copyright &copy; 2026 The authors
"""
repo_url = "https://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator"
repo_name = "badhouseplants/rustfs-manager-operator"
[project.theme]
favicon = "assets/logo.png"
language = "en"
features = [
"announce.dismiss",
"content.code.annotate",
"content.code.copy",
"content.code.select",
"content.footnote.tooltips",
"content.tabs.link",
"content.tooltips",
"navigation.footer",
"navigation.indexes",
"navigation.instant",
"navigation.instant.prefetch",
"navigation.path",
"navigation.sections",
"navigation.top",
"navigation.tracking",
"search.highlight",
"toc.follow",
]
[[project.theme.palette]]
scheme = "default"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
[[project.theme.palette]]
scheme = "slate"
toggle.icon = "lucide/moon"
toggle.name = "Switch to light mode"
#[project.theme.font]
#text = "Inter"
#code = "Jetbrains Mono"
[project.theme.icon]
#logo = "assets/logo.png"
#repo = "lucide/smile"
#[[project.extra.social]]
#icon = "fontawesome/brands/github"
#link = "https://github.com/user/repo"

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,24 @@
apiVersion: v2
name: rustfs-instance
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

View File

View File

@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "rustfs-instance.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "rustfs-instance.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "rustfs-instance.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "rustfs-instance.labels" -}}
helm.sh/chart: {{ include "rustfs-instance.chart" . }}
{{ include "rustfs-instance.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "rustfs-instance.selectorLabels" -}}
app.kubernetes.io/name: {{ include "rustfs-instance.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "rustfs-instance.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "rustfs-instance.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,12 @@
---
apiVersion: rustfs.badhouseplants.net/v1beta1
kind: RustFSInstance
metadata:
name: {{ include "rustfs-instance.name" . }}
labels:
{{- include "rustfs-instance.labels" . | nindent 4 }}
spec:
endpoint: {{ .Values.endpoint }}
credentialsSecret:
namespace: {{ .Release.Namespace }}
name: {{ include "rustfs-instance.name" . }}

View File

@@ -0,0 +1,10 @@
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "rustfs-instance.name" . }}
labels:
{{- include "rustfs-instance.labels" . | nindent 4 }}
data:
ACCESS_KEY: {{ .Values.username | toString | b64enc }}
SECRET_KEY: {{ .Values.password | toString | b64enc }}

View File

@@ -0,0 +1,3 @@
endpoint: https://rustfs.company.my
username: admin
password: qwertyu9

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,24 @@
apiVersion: v2
name: rustfs-manager-operator
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

View File

@@ -0,0 +1,114 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: rustfsbuckets.rustfs.badhouseplants.net
spec:
group: rustfs.badhouseplants.net
names:
categories: []
kind: RustFSBucket
plural: rustfsbuckets
shortNames:
- bucket
singular: rustfsbucket
scope: Namespaced
versions:
- additionalPrinterColumns:
- description: The name of the bucket
jsonPath: .status.bucketName
name: Bucket Name
type: string
- description: The URL of the instance
jsonPath: .status.endpoint
name: Endpoint
type: string
- description: The region of the instance
jsonPath: .status.region
name: Region
type: string
- description: Is the S3Instance ready
jsonPath: .status.ready
name: Status
type: boolean
name: v1beta1
schema:
openAPIV3Schema:
description: Manage buckets on a RustFs instance
properties:
spec:
properties:
cleanup:
default: false
description: When set to true, the operator will try remove the bucket upon object deletion
type: boolean
instance:
type: string
objectLock:
default: false
type: boolean
versioning:
default: false
type: boolean
required:
- instance
type: object
status:
description: The status object of `DbInstance`
nullable: true
properties:
bucketName:
nullable: true
type: string
conditions:
items:
description: Condition contains details for one aspect of the current state of this API Resource.
properties:
lastTransitionTime:
description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: message is a human readable message indicating details about the transition. This may be an empty string.
type: string
observedGeneration:
description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
format: int64
type: integer
reason:
description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
type: string
status:
description: status of the condition, one of True, False, Unknown.
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
endpoint:
nullable: true
type: string
ready:
default: false
type: boolean
region:
nullable: true
type: string
required:
- conditions
type: object
required:
- spec
title: RustFSBucket
type: object
served: true
storage: true
subresources:
status: {}

View File

@@ -0,0 +1,119 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: rustfsinstances.rustfs.badhouseplants.net
spec:
group: rustfs.badhouseplants.net
names:
categories: []
kind: RustFSInstance
plural: rustfsinstances
shortNames:
- rustfs
singular: rustfsinstance
scope: Cluster
versions:
- additionalPrinterColumns:
- description: The URL of the instance
jsonPath: .spec.endpoint
name: Endpoint
type: string
- description: The region of the instance
jsonPath: .status.region
name: Region
type: string
- description: How many buckets are there on the instance
jsonPath: .status.total_buckets
name: Total Buckets
type: number
- description: Is the S3Instance ready
jsonPath: .status.ready
name: Status
type: boolean
name: v1beta1
schema:
openAPIV3Schema:
description: Connect the operator to a RustFs cluster using this resource
properties:
spec:
properties:
credentialsSecret:
properties:
name:
type: string
namespace:
type: string
required:
- name
- namespace
type: object
endpoint:
type: string
required:
- credentialsSecret
- endpoint
type: object
status:
description: The status object of `DbInstance`
nullable: true
properties:
buckets:
items:
type: string
nullable: true
type: array
conditions:
items:
description: Condition contains details for one aspect of the current state of this API Resource.
properties:
lastTransitionTime:
description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: message is a human readable message indicating details about the transition. This may be an empty string.
type: string
observedGeneration:
description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
format: int64
type: integer
reason:
description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
type: string
status:
description: status of the condition, one of True, False, Unknown.
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
ready:
default: false
type: boolean
region:
nullable: true
type: string
total_buckets:
format: uint
minimum: 0.0
nullable: true
type: integer
required:
- conditions
type: object
required:
- spec
title: RustFSInstance
type: object
served: true
storage: true
subresources:
status: {}

View File

@@ -0,0 +1,105 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: rustfsusers.rustfs.badhouseplants.net
spec:
group: rustfs.badhouseplants.net
names:
categories: []
kind: RustFSUser
plural: rustfsusers
shortNames: []
singular: rustfsuser
scope: Namespaced
versions:
- additionalPrinterColumns: []
name: v1beta1
schema:
openAPIV3Schema:
description: Manage buckets on a RustFs instance
properties:
spec:
properties:
access:
enum:
- readOnly
- readWrite
type: string
bucket:
type: string
cleanup:
default: false
type: boolean
required:
- access
- bucket
type: object
status:
description: The status object of `DbInstance`
nullable: true
properties:
conditions:
items:
description: Condition contains details for one aspect of the current state of this API Resource.
properties:
lastTransitionTime:
description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: message is a human readable message indicating details about the transition. This may be an empty string.
type: string
observedGeneration:
description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
format: int64
type: integer
reason:
description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
type: string
status:
description: status of the condition, one of True, False, Unknown.
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
configMapName:
nullable: true
type: string
passwordHash:
nullable: true
type: string
policy:
nullable: true
type: string
ready:
default: false
type: boolean
secretName:
nullable: true
type: string
status:
nullable: true
type: string
username:
nullable: true
type: string
required:
- conditions
type: object
required:
- spec
title: RustFSUser
type: object
served: true
storage: true
subresources:
status: {}

View File

@@ -0,0 +1,35 @@
1. Get the application URL by running these commands:
{{- if .Values.httpRoute.enabled }}
{{- if .Values.httpRoute.hostnames }}
export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }}
{{- else }}
export APP_HOSTNAME=$(kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o jsonpath="{.spec.listeners[0].hostname}")
{{- end }}
{{- if and .Values.httpRoute.rules (first .Values.httpRoute.rules).matches (first (first .Values.httpRoute.rules).matches).path.value }}
echo "Visit http://$APP_HOSTNAME{{ (first (first .Values.httpRoute.rules).matches).path.value }} to use your application"
NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules.
The rules can be set for path, method, header and query parameters.
You can check the gateway configuration with 'kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o yaml'
{{- end }}
{{- else if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "rustfs-manager-operator.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "rustfs-manager-operator.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "rustfs-manager-operator.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "rustfs-manager-operator.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "rustfs-manager-operator.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "rustfs-manager-operator.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "rustfs-manager-operator.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "rustfs-manager-operator.labels" -}}
helm.sh/chart: {{ include "rustfs-manager-operator.chart" . }}
{{ include "rustfs-manager-operator.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "rustfs-manager-operator.selectorLabels" -}}
app.kubernetes.io/name: {{ include "rustfs-manager-operator.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "rustfs-manager-operator.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "rustfs-manager-operator.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,69 @@
{{- if .Values.rbac.create -}}
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: {{ include "rustfs-manager-operator.name" . }}
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 4 }}
rules:
- apiGroups:
- ""
resources:
- secrets
- configmaps
verbs:
- get
- list
- watch
- create
- update
- patch
- apiGroups:
- rustfs.badhouseplants.net
resources:
- rustfsbuckets
- rustfsinstances
- rustfsusers
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- rustfs.badhouseplants.net
resources:
- rustfsbuckets/finalizers
- rustfsintsances/finalizers
- rustfsusers/finalizers
verbs:
- update
- apiGroups:
- rustfs.badhouseplants.net
resources:
- rustfsbuckets/status
- rustfsinstances/status
- rustfsusers/status
verbs:
- get
- patch
- update
- apiGroups:
- "events.k8s.io"
resources:
- events
verbs:
- create
- update
- patch
- get
- list
- watch
{{- end }}

View File

@@ -0,0 +1,17 @@
{{- if .Values.rbac.create -}}
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: {{ include "rustfs-manager-operator.name" . }}
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 4 }}
subjects:
- kind: ServiceAccount
name: {{ include "rustfs-manager-operator.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
roleRef:
kind: ClusterRole
name: {{ include "rustfs-manager-operator.name" . }}
apiGroup: rbac.authorization.k8s.io
{{- end }}

View File

@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "rustfs-manager-operator.name" . }}-config
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 4 }}
data:
config.json: |-
{{- .Values.config | toJson | nindent 4 }}

View File

@@ -0,0 +1,28 @@
{{- if .Values.crds.install }}
{{- $manifests := dict }}
{{- range $path, $index := .Files.Glob "crd/*" }}
{{- $file := $.Files.Get $path }}
{{- $_ := set $manifests ($index | toString ) $file }}
{{- end }}
{{- range $_, $file := $manifests }}
---
{{- $manifest := $file | fromYaml }}
apiVersion: {{ get $manifest "apiVersion" }}
kind: {{ get $manifest "kind" }}
{{- $metadata := get $manifest "metadata" }}
metadata:
name: {{ get $metadata "name" }}
{{- with $.Values.labels }}
labels:
{{- . | toYaml | nindent 4 }}
{{- end }}
annotations:
{{- if $.Values.crds.keep }}
helm.sh/resource-policy: keep
{{- end }}
spec:
{{ get $manifest "spec" | toYaml | indent 2 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "rustfs-manager-operator.fullname" . }}
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "rustfs-manager-operator.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "rustfs-manager-operator.serviceAccountName" . }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: 8080
name: http
volumeMounts:
- mountPath: /srv/config/
name: config
readOnly: true
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: config
configMap:
name: {{ include "rustfs-manager-operator.name" . }}-config
defaultMode: 420
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "rustfs-manager-operator.serviceAccountName" . }}
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

View File

@@ -0,0 +1,157 @@
crds:
install: true
keep: true
image:
repository: gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator-dev
# This sets the pull policy for images.
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: "cb2f89e80cce4be5d1a76cc34244a96e866fcd35"
rbac:
create: true
# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
serviceAccount:
# Specifies whether a service account should be created.
create: true
# Automatically mount a ServiceAccount's API credentials?
automount: true
# Annotations to add to the service account.
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template.
name: ""
config:
setOwnerReference: true
# This is for setting Kubernetes Annotations to a Pod.
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
podAnnotations: {}
# This is for setting Kubernetes Labels to a Pod.
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
podLabels: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
service:
# This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
type: ClusterIP
# This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
port: 80
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
# -- Expose the service via gateway-api HTTPRoute
# Requires Gateway API resources and suitable controller installed within the cluster
# (see: https://gateway-api.sigs.k8s.io/guides/)
httpRoute:
# HTTPRoute enabled.
enabled: false
# HTTPRoute annotations.
annotations: {}
# Which Gateways this Route is attached to.
parentRefs:
- name: gateway
sectionName: http
# namespace: default
# Hostnames matching HTTP header.
hostnames:
- chart-example.local
# List of rules and filters applied.
rules:
- matches:
- path:
type: PathPrefix
value: /headers
# filters:
# - type: RequestHeaderModifier
# requestHeaderModifier:
# set:
# - name: My-Overwrite-Header
# value: this-is-the-only-value
# remove:
# - User-Agent
# - matches:
# - path:
# type: PathPrefix
# value: /echo
# headers:
# - name: version
# value: v2
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
livenessProbe:
httpGet:
path: /health
port: http
readinessProbe:
httpGet:
path: /health
port: http
# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
# Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# Additional volumeMounts on the output Deployment definition.
volumeMounts: []
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1 @@
target

3720
operator/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
operator/Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "rustfs-manager-operator"
version = "0.1.0"
edition = "2024"
default-bin = "controller"
[lib]
name = "api"
path = "src/lib.rs"
[dependencies]
kube = { version = "3.0.1", features = ["runtime", "derive", "client", "aws-lc-rs"] }
k8s-openapi = { version = "0.27.0", features = ["latest", "schemars"] }
schemars = { version = "1" }
darling = "0.23.0"
clap = { version = "4.5.60", features = ["derive"] }
serde = { version = "1.0.228", features = ["serde_derive"] }
serde_json = "1.0.149"
serde_yaml = "0.9.34"
thiserror = "2.0.18"
tracing = "0.1.44"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
anyhow = "1.0.102"
futures = "0.3.32"
actix-web = "4.13.0"
tracing-subscriber = { version = "0.3.22", features = ["json", "env-filter"] }
rand = "0.10.0"
tempfile = "3.27.0"
password-hash = "0.6.0"
sha-crypt = "0.5.0"
argon2 = "0.5.3"
handlebars = "6.4.0"
[dev-dependencies]
assert-json-diff = "2.0.2"
envtest = "0.1.2"
http = "1"
hyper = "1"
tower-test = "0.4.0"

16
operator/Containerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM docker.io/rustfs/rc:v0.1.7 AS rc
WORKDIR /output
RUN cp $(which rc) .
FROM rust:alpine3.23 AS builder
WORKDIR /src
COPY . .
RUN cargo build --release
WORKDIR /output
RUN cp /src/target/release/rustfs-manager-operator .
FROM gcr.io/distroless/static
COPY --from=builder /output/rustfs-manager-operator /usr/bin/controller
COPY --from=rc /output/rc /usr/bin/rc
ENTRYPOINT ["/usr/bin/controller"]
USER 1001

View File

@@ -0,0 +1,8 @@
FROM docker.io/rustfs/rc:v0.1.7 AS rc
WORKDIR /output
RUN cp $(which rc) .
FROM gcr.io/distroless/static
COPY --from=rc /output/rc /usr/bin/rc
ENTRYPOINT ["/usr/bin/rc"]
USER 1001

3
operator/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"setOwnerReference": true
}

3
operator/src/api/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod v1beta1_rustfs_instance;
pub mod v1beta_rustfs_bucket;
pub mod v1beta_rustfs_user;

View File

@@ -0,0 +1,47 @@
use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
use k8s_openapi::serde::{Deserialize, Serialize};
use kube::CustomResource;
use kube::{self};
use schemars::JsonSchema;
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
kind = "RustFSInstance",
group = "rustfs.badhouseplants.net",
version = "v1beta1",
shortname = "rustfs",
doc = "Connect the operator to a RustFs cluster using this resource",
status = "RustFSInstanceStatus",
printcolumn = r#"{"name":"Endpoint","type":"string","description":"The URL of the instance","jsonPath":".spec.endpoint"}"#,
printcolumn = r#"{"name":"Region","type":"string","description":"The region of the instance","jsonPath":".status.region"}"#,
printcolumn = r#"{"name":"Total Buckets","type":"number","description":"How many buckets are there on the instance","jsonPath":".status.total_buckets"}"#,
printcolumn = r#"{"name":"Status","type":"boolean","description":"Is the S3Instance ready","jsonPath":".status.ready"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct S3InstanceSpec {
pub endpoint: String,
pub credentials_secret: NamespacedName,
}
/// The status object of `DbInstance`
#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)]
pub struct RustFSInstanceStatus {
#[serde(default)]
pub ready: bool,
//#[schemars(schema_with = "conditions")]
pub conditions: Vec<Condition>,
#[serde(default)]
pub buckets: Option<Vec<String>>,
#[serde(default)]
pub total_buckets: Option<usize>,
#[serde(default)]
pub region: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub struct NamespacedName {
#[serde(rename = "namespace")]
pub namespace: String,
#[serde(rename = "name")]
pub name: String,
}

View File

@@ -0,0 +1,57 @@
use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
use k8s_openapi::serde::{Deserialize, Serialize};
use kube::CustomResource;
use kube::{self};
use schemars::JsonSchema;
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
kind = "RustFSBucket",
group = "rustfs.badhouseplants.net",
version = "v1beta1",
shortname = "bucket",
doc = "Manage buckets on a RustFs instance",
namespaced,
status = "RustFSBucketStatus",
printcolumn = r#"{"name":"Bucket Name","type":"string","description":"The name of the bucket","jsonPath":".status.bucketName"}"#,
printcolumn = r#"{"name":"Endpoint","type":"string","description":"The URL of the instance","jsonPath":".status.endpoint"}"#,
printcolumn = r#"{"name":"Region","type":"string","description":"The region of the instance","jsonPath":".status.region"}"#,
printcolumn = r#"{"name":"Status","type":"boolean","description":"Is the S3Instance ready","jsonPath":".status.ready"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct RustFSBucketSpec {
pub instance: String,
/// When set to true, the operator will try remove the bucket upon object deletion
#[serde(default)]
pub cleanup: bool,
#[serde(default)]
#[kube(validation = Rule::new("self == oldSelf").message("field is immutable"))]
pub object_lock: bool,
#[serde(default)]
#[kube(validation = Rule::new("self == oldSelf").message("field is immutable"))]
pub versioning: bool,
}
/// The status object of `DbInstance`
#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RustFSBucketStatus {
#[serde(default)]
pub ready: bool,
//#[schemars(schema_with = "conditions")]
pub conditions: Vec<Condition>,
#[serde(default)]
pub bucket_name: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub region: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
pub struct NamespacedName {
#[serde(rename = "namespace")]
pub namespace: String,
#[serde(rename = "name")]
pub name: String,
}

View File

@@ -0,0 +1,52 @@
use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition;
use k8s_openapi::serde::{Deserialize, Serialize};
use kube::CustomResource;
use kube::{self};
use schemars::JsonSchema;
#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug)]
pub enum Access {
#[serde(rename = "readOnly")]
ReadOnly,
#[serde(rename = "readWrite")]
ReadWrite,
}
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
#[kube(
kind = "RustFSUser",
group = "rustfs.badhouseplants.net",
version = "v1beta1",
doc = "Manage buckets on a RustFs instance",
namespaced,
status = "RustFSUserStatus"
)]
#[serde(rename_all = "camelCase")]
pub struct RustFSUserSpec {
pub bucket: String,
#[serde(default)]
pub cleanup: bool,
pub access: Access,
}
/// The status object of `DbInstance`
#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RustFSUserStatus {
#[serde(default)]
pub ready: bool,
//#[schemars(schema_with = "conditions")]
pub conditions: Vec<Condition>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password_hash: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub policy: Option<String>,
#[serde(default)]
pub secret_name: Option<String>,
#[serde(default)]
pub config_map_name: Option<String>,
}

70
operator/src/cli.rs Normal file
View File

@@ -0,0 +1,70 @@
use anyhow::{anyhow, Result};
use std::process::Command;
use tracing::info;
pub(crate) fn rc_exec(args: Vec<&str>) -> Result<String, anyhow::Error> {
info!("Executing rc + {:?}", args);
let expect = format!("command has failed: rc {:?}", args);
let output = Command::new("rc").args(args).output().expect(&expect);
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !&output.status.success() {
return Err(anyhow!(stderr));
};
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub(crate) fn cli_exec_from_dir(command: String, dir: String) -> Result<String, anyhow::Error> {
info!("executing: {}", command);
let expect = format!("command has failed: {}", command);
let output = Command::new("sh")
.arg("-c")
.current_dir(dir)
.arg(command)
.output()
.expect(&expect);
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !&output.status.success() {
return Err(anyhow!(stderr));
};
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
stdout.pop();
Ok(stdout)
}
#[cfg(test)]
mod tests {
use crate::cli::{cli_exec_from_dir, rc_exec};
use tempfile::TempDir;
#[test]
fn test_stderr() {
let command = ">&2 echo \"error\" && exit 1";
let test = rc_exec(command.to_string());
assert_eq!(test.err().unwrap().to_string(), "error\n".to_string());
}
#[test]
fn test_stdout() {
let command = "echo test";
let test = rc_exec(command.to_string());
assert_eq!(test.unwrap().to_string(), "test\n".to_string());
}
#[test]
fn test_stdout_current_dir() {
let dir = TempDir::new().unwrap();
let dir_str = dir.path().to_str().unwrap().to_string();
let command = "echo $PWD";
let test = cli_exec_from_dir(command.to_string(), dir_str.clone());
assert!(test.unwrap().to_string().contains(dir_str.as_str()));
}
#[test]
fn test_stderr_current_dir() {
let dir = TempDir::new().unwrap();
let dir_str = dir.path().to_str().unwrap().to_string();
let command = ">&2 echo \"error\" && exit 1";
let test = cli_exec_from_dir(command.to_string(), dir_str.clone());
assert_eq!(test.err().unwrap().to_string(), "error\n".to_string());
}
}

View File

@@ -0,0 +1,67 @@
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time};
use k8s_openapi::jiff::Timestamp;
use kube::api::ObjectMeta;
pub(crate) fn set_condition(
mut conditions: Vec<Condition>,
metadata: ObjectMeta,
condition_type: &str,
condition_status: String,
condition_reason: String,
condition_message: String,
) -> Vec<Condition> {
if let Some(condition) = conditions.iter_mut().find(|c| c.type_ == condition_type) {
condition.status = condition_status;
condition.last_transition_time = Time::from(Timestamp::now());
condition.message = condition_message;
condition.reason = condition_reason;
condition.observed_generation = metadata.generation;
} else {
conditions.push(Condition {
last_transition_time: Time::from(Timestamp::now()),
message: condition_message,
observed_generation: metadata.generation,
reason: condition_reason,
status: condition_status,
type_: condition_type.to_string(),
});
}
conditions
}
pub(crate) fn init_conditions(types: Vec<String>) -> Vec<Condition> {
let mut conditions: Vec<Condition> = vec![];
types.iter().for_each(|t| {
let condition = Condition {
last_transition_time: Time::from(Timestamp::now()),
message: "Reconciliation started".to_string(),
observed_generation: Some(1),
reason: "Reconciling".to_string(),
status: "Unknown".to_string(),
type_: t.clone(),
};
conditions.push(condition);
});
conditions
}
pub(crate) fn is_condition_true(mut conditions: Vec<Condition>, condition_type: &str) -> bool {
if let Some(condition) = conditions.iter_mut().find(|c| c.type_ == condition_type) {
return condition.status == "True";
}
false
}
pub(crate) fn is_condition_false(mut conditions: Vec<Condition>, condition_type: &str) -> bool {
if let Some(condition) = conditions.iter_mut().find(|c| c.type_ == condition_type) {
return condition.status == "False";
}
false
}
pub(crate) fn is_condition_unknown(mut conditions: Vec<Condition>, condition_type: &str) -> bool {
if let Some(condition) = conditions.iter_mut().find(|c| c.type_ == condition_type) {
return condition.status == "Unknown";
}
false
}

36
operator/src/config.rs Normal file
View File

@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use std::fs::File;
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OperatorConfig {
pub set_owner_reference: bool,
}
pub(crate) fn read_config_from_file(path: String) -> Result<OperatorConfig, anyhow::Error> {
let file = File::open(path)?;
let config: OperatorConfig = serde_json::from_reader(file)?;
Ok(config)
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::NamedTempFile;
use crate::config::read_config_from_file;
#[test]
fn test_read_config() {
let config_json = r#"{
"setOwnerReference": true
}
"#;
let mut file = NamedTempFile::new().expect("Can't create a file");
let path = file.path().to_path_buf();
writeln!(file, "{}", config_json).expect("Can't write a config file");
let config = read_config_from_file(path.to_str().expect("Can't get the path").to_string())
.expect("Can't read the config file");
assert!(config.set_owner_reference);
}
}

103
operator/src/controller.rs Normal file
View File

@@ -0,0 +1,103 @@
mod conditions;
mod controllers;
mod rc;
mod cli;
mod config;
use crate::controllers::{rustfs_instance};
use actix_web::{App, HttpRequest, HttpResponse, HttpServer, Responder, get, middleware};
use clap::Parser;
use kube::{Client, CustomResourceExt};
use tracing_subscriber::EnvFilter;
use self::config::read_config_from_file;
use self::controllers::{rustfs_bucket, rustfs_user};
use api::api::v1beta1_rustfs_instance::RustFSInstance;
use api::api::v1beta_rustfs_bucket::RustFSBucket;
use api::api::v1beta_rustfs_user::RustFSUser;
/// Simple program to greet a person
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(long, default_value_t = 60000)]
/// The address the metric endpoint binds to.
metrics_port: u16,
#[arg(long, default_value_t = 8081)]
/// The address the probe endpoint binds to.
health_probe_port: u16,
#[arg(long, default_value_t = true)]
/// Enabling this will ensure there is only one active controller manager.
// DB Operator feature flags
#[arg(long, default_value_t = false)]
/// If enabled, DB Operator will run full reconciliation only
/// when changes are detected
is_change_check_nabled: bool,
#[arg(long, default_value = "/src/config/config.json")]
/// A path to a config file
config: String,
/// Set to true to generate crds
#[arg(long, default_value_t = false)]
crdgen: bool,
}
#[get("/health")]
async fn health(_: HttpRequest) -> impl Responder {
HttpResponse::Ok().json("healthy")
}
fn crdgen() {
println!(
"---\n{}",
serde_yaml::to_string(&RustFSInstance::crd()).unwrap()
);
println!(
"---\n{}",
serde_yaml::to_string(&RustFSBucket::crd()).unwrap()
);
println!(
"---\n{}",
serde_yaml::to_string(&RustFSUser::crd()).unwrap()
);
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
if args.crdgen {
crdgen();
return Ok(());
}
tracing_subscriber::fmt()
.json()
.with_env_filter(EnvFilter::from_default_env())
.init();
let client = Client::try_default()
.await
.expect("failed to create kube Client");
let config = read_config_from_file(args.config)?;
let rustfs_instance_ctrl = rustfs_instance::run(client.clone());
let rustfs_bucket_ctrl = rustfs_bucket::run(client.clone(), config.clone());
let rustfs_user_ctrl = rustfs_user::run(client.clone());
// Start web server
let server = HttpServer::new(move || {
App::new()
.wrap(middleware::Logger::default().exclude("/health"))
.service(health)
})
.bind("0.0.0.0:8080")?
.shutdown_timeout(5);
// Both runtimes implements graceful shutdown, so poll until both are done
tokio::join!(
rustfs_instance_ctrl,
rustfs_bucket_ctrl,
rustfs_user_ctrl,
server.run()
)
.3?;
Ok(())
}

View File

@@ -0,0 +1,3 @@
pub(crate) mod rustfs_bucket;
pub(crate) mod rustfs_instance;
pub(crate) mod rustfs_user;

View File

@@ -0,0 +1,396 @@
use crate::conditions::{is_condition_true, set_condition};
use crate::config::OperatorConfig;
use crate::rc::{create_bucket, delete_bucket, list_buckets};
use api::api::v1beta1_rustfs_instance::RustFSInstance;
use futures::StreamExt;
use k8s_openapi::api::core::v1::ConfigMap;
use api::api::v1beta_rustfs_bucket::{RustFSBucket, RustFSBucketStatus};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
use kube::api::{ListParams, ObjectMeta, PostParams};
use kube::runtime::Controller;
use kube::runtime::controller::Action;
use kube::runtime::events::Recorder;
use kube::runtime::watcher::Config;
use kube::{Api, Client, Error, Resource, ResourceExt};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use tracing::*;
const TYPE_BUCKET_READY: &str = "BucketReady";
const FIN_CLEANUP: &str = "s3.badhouseplants.net/bucket-cleanup";
const CONFIGMAP_LABEL: &str = "s3.badhouseplants.net/s3-bucket";
const AWS_REGION: &str = "AWS_REGION";
const AWS_ENDPOINT_URL: &str = "AWS_ENDPOINT_URL";
#[instrument(skip(ctx, obj), fields(trace_id, controller = "rustfs-bucket"))]
pub(crate) async fn reconcile(obj: Arc<RustFSBucket>, ctx: Arc<Context>) -> RustFSBucketResult<Action> {
info!("Staring to reconcile");
let bucket_api: Api<RustFSBucket> =
Api::namespaced(ctx.client.clone(), &obj.namespace().unwrap());
let cm_api: Api<ConfigMap> = Api::namespaced(ctx.client.clone(), &obj.namespace().unwrap());
let rustfs_api: Api<RustFSInstance> = Api::all(ctx.client.clone());
info!("Getting the RustFSBucket resource");
let mut bucket_cr = match bucket_api.get(&obj.name_any()).await {
Ok(cr) => cr,
Err(Error::Api(ae)) if ae.code == 404 => {
info!("Object is not found, probably removed");
return Ok(Action::await_change());
}
Err(err) => return Err(RustFSBucketError::KubeError(err)),
};
// On the first reconciliation status is None
// it needs to be initialized
let mut status = match bucket_cr.clone().status {
None => {
info!("Status is not yet set, initializing the object");
return init_object(bucket_cr, bucket_api).await;
}
Some(status) => status,
};
let configmap_name = format!("{}-bucket-info", bucket_cr.name_any());
info!("Getting the configmap");
// Get the cm, if it's already there, we need to validate, or create an empty one
let mut configmap = match get_configmap(cm_api.clone(), &configmap_name).await {
Ok(configmap) => configmap,
Err(Error::Api(ae)) if ae.code == 404 => {
info!("ConfigMap is not found, creating a new one");
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(configmap_name),
namespace: Some(bucket_cr.clone().namespace().unwrap()),
..Default::default()
},
..Default::default()
};
match create_configmap(cm_api.clone(), cm).await {
Ok(cm) => cm,
Err(err) => return Err(RustFSBucketError::KubeError(err)),
}
}
Err(err) => return Err(RustFSBucketError::KubeError(err)),
};
info!("Labeling the configmap");
configmap = match label_configmap(cm_api.clone(), &bucket_cr.name_any(), configmap).await {
Ok(configmap) => configmap,
Err(err) => {
error!("{}", err);
return Err(RustFSBucketError::KubeError(err));
}
};
if ctx.config.set_owner_reference{
info!("Setting owner references to the configmap");
configmap = match own_configmap(cm_api.clone(), bucket_cr.clone(), configmap).await {
Ok(configmap) => configmap,
Err(err) => {
error!("{}", err);
return Err(RustFSBucketError::KubeError(err));
}
};
};
if is_condition_true(status.clone().conditions, TYPE_BUCKET_READY) {
let mut current_finalizers = match bucket_cr.clone().metadata.finalizers {
Some(finalizers) => finalizers,
None => vec![],
};
if bucket_cr.spec.cleanup {
if !current_finalizers.contains(&FIN_CLEANUP.to_string()) {
info!("Adding a finalizer");
current_finalizers.push(FIN_CLEANUP.to_string());
}
} else {
if current_finalizers.contains(&FIN_CLEANUP.to_string()) {
if let Some(index) = current_finalizers
.iter()
.position(|x| *x == FIN_CLEANUP.to_string())
{
current_finalizers.remove(index);
};
}
};
bucket_cr.metadata.finalizers = Some(current_finalizers);
if let Err(err) = bucket_api
.replace(&bucket_cr.name_any(), &PostParams::default(), &bucket_cr)
.await
{
return Err(RustFSBucketError::KubeError(err));
}
};
info!("Getting the RustFSIntsance");
let rustfs_cr = match rustfs_api.get(&bucket_cr.spec.instance).await {
Ok(rustfs_cr) => rustfs_cr,
Err(err) => {
error!("{}", err);
return Err(RustFSBucketError::KubeError(err));
}
};
if rustfs_cr.clone().status.is_none_or(|s| ! s.ready) {
info!("Instance is not ready, waiting");
return Ok(Action::requeue(Duration::from_secs(120)));
}
info!("Updating the ConfigMap");
if let Err(err) = ensure_data_configmap(cm_api.clone(), rustfs_cr.clone(), configmap.clone()).await {
return Err(RustFSBucketError::KubeError(err));
};
let bucket_name = format!("{}-{}", bucket_cr.namespace().unwrap(), bucket_cr.name_any());
if bucket_cr.metadata.deletion_timestamp.is_some() {
info!("Object is marked for deletion");
if let Some(mut finalizers) = bucket_cr.clone().metadata.finalizers {
if finalizers.contains(&FIN_CLEANUP.to_string()) {
match delete_bucket(rustfs_cr.name_any(), bucket_name.clone()) {
Ok(_) => {
if let Some(index) = finalizers
.iter()
.position(|x| x == FIN_CLEANUP)
{
finalizers.remove(index);
};
}
Err(err) => return Err(RustFSBucketError::RcCliError(err)),
}
}
bucket_cr.metadata.finalizers = Some(finalizers);
};
match bucket_api
.replace(&bucket_cr.name_any(), &PostParams::default(), &bucket_cr)
.await
{
Ok(_) => return Ok(Action::await_change()),
Err(err) => return Err(RustFSBucketError::KubeError(err)),
}
}
info!("Getting buckets");
let bucket_list: Vec<String> = match list_buckets(rustfs_cr.name_any().to_string()){
Ok(bl) => bl.items.unwrap().iter().map(|b| b.clone().key.unwrap()).collect(),
Err(err) => return Err(RustFSBucketError::RcCliError(err)),
};
if bucket_list.contains(&bucket_name) {
info!("Bucket already exists");
} else {
if let Err(err) = create_bucket(rustfs_cr.name_any(), bucket_name.clone(), bucket_cr.spec.versioning, bucket_cr.spec.object_lock) {
return Err(RustFSBucketError::RcCliError(err));
}
}
status.ready = true;
status.conditions = set_condition(
status.conditions,
bucket_cr.metadata.clone(),
TYPE_BUCKET_READY,
"True".to_string(),
"Reconciled".to_string(),
"Bucket is ready".to_string(),
);
status.endpoint = Some(rustfs_cr.clone().spec.endpoint);
status.region = Some(rustfs_cr.clone().status.unwrap().region.unwrap());
status.bucket_name = Some(bucket_name.clone());
bucket_cr.status = Some(status);
info!("Updating status of the bucket resource");
match bucket_api
.replace_status(&bucket_cr.name_any(), &PostParams::default(), &bucket_cr)
.await
{
Ok(_) => return Ok(Action::requeue(Duration::from_secs(120))),
Err(err) => return Err(RustFSBucketError::KubeError(err)),
};
}
// Bootstrap the object by adding a default status to it
async fn init_object(mut obj: RustFSBucket, api: Api<RustFSBucket>) -> Result<Action, RustFSBucketError> {
let conditions = set_condition(
vec![],
obj.metadata.clone(),
TYPE_BUCKET_READY,
"Unknown".to_string(),
"Reconciling".to_string(),
"Reconciliation started".to_string(),
);
obj.status = Some(RustFSBucketStatus {
conditions,
..RustFSBucketStatus::default()
});
match api
.replace_status(obj.clone().name_any().as_str(), &Default::default(), &obj)
.await
{
Ok(_) => Ok(Action::await_change()),
Err(err) => {
error!("{}", err);
Err(RustFSBucketError::KubeError(err))
}
}
}
// Get the configmap with the bucket data
async fn get_configmap(api: Api<ConfigMap>, name: &str) -> Result<ConfigMap, kube::Error> {
info!("Getting a configmap: {}", name);
match api.get(name).await {
Ok(cm) => Ok(cm),
Err(err) => Err(err),
}
}
// Create ConfigMap
async fn create_configmap(api: Api<ConfigMap>, cm: ConfigMap) -> Result<ConfigMap, kube::Error> {
match api.create(&PostParams::default(), &cm).await {
Ok(cm) => get_configmap(api, &cm.name_any()).await,
Err(err) => Err(err),
}
}
async fn label_configmap(
api: Api<ConfigMap>,
bucket_name: &str,
mut cm: ConfigMap,
) -> Result<ConfigMap, kube::Error> {
let mut labels = match &cm.clone().metadata.labels {
Some(labels) => labels.clone(),
None => {
let map: BTreeMap<String, String> = BTreeMap::new();
map
}
};
labels.insert(CONFIGMAP_LABEL.to_string(), bucket_name.to_string());
cm.metadata.labels = Some(labels);
api.replace(&cm.name_any(), &PostParams::default(), &cm)
.await?;
let cm = match api.get(&cm.name_any()).await {
Ok(cm) => cm,
Err(err) => {
return Err(err);
}
};
Ok(cm)
}
async fn own_configmap(
api: Api<ConfigMap>,
bucket_cr: RustFSBucket,
mut cm: ConfigMap,
) -> Result<ConfigMap, kube::Error> {
let mut owner_references = match &cm.clone().metadata.owner_references {
Some(owner_references) => owner_references.clone(),
None => {
let owner_references: Vec<OwnerReference> = vec![];
owner_references
}
};
if owner_references
.iter()
.find(|or| or.uid == bucket_cr.uid().unwrap())
.is_some()
{
return Ok(cm);
}
let new_owner_reference = OwnerReference {
api_version: RustFSBucket::api_version(&()).into(),
kind: RustFSBucket::kind(&()).into(),
name: bucket_cr.name_any(),
uid: bucket_cr.uid().unwrap(),
..Default::default()
};
owner_references.push(new_owner_reference);
cm.metadata.owner_references = Some(owner_references);
api.replace(&cm.name_any(), &PostParams::default(), &cm)
.await?;
let cm = match api.get(&cm.name_any()).await {
Ok(cm) => cm,
Err(err) => {
return Err(err);
}
};
Ok(cm)
}
async fn ensure_data_configmap(
api: Api<ConfigMap>,
rustfs_cr: RustFSInstance,
mut cm: ConfigMap,
) -> Result<ConfigMap, kube::Error> {
let mut data = match &cm.clone().data {
Some(data) => data.clone(),
None => {
let map: BTreeMap<String, String> = BTreeMap::new();
map
}
};
data.insert(AWS_REGION.to_string(), rustfs_cr.status.unwrap().region.unwrap());
data.insert(AWS_ENDPOINT_URL.to_string(), rustfs_cr.spec.endpoint);
cm.data = Some(data);
api.replace(&cm.name_any(), &PostParams::default(), &cm)
.await?;
match api.get(&cm.name_any()).await {
Ok(cm) => Ok(cm),
Err(err) => Err(err),
}
}
pub(crate) fn error_policy(_: Arc<RustFSBucket>, err: &RustFSBucketError, _: Arc<Context>) -> Action {
error!(trace.error = %err, "Error occured during the reconciliation");
Action::requeue(Duration::from_secs(5 * 60))
}
#[instrument(skip(client), fields(trace_id))]
pub async fn run(client: Client, config: OperatorConfig) {
let buckets = Api::<RustFSBucket>::all(client.clone());
if let Err(err) = buckets.list(&ListParams::default().limit(1)).await {
error!("{}", err);
std::process::exit(1);
}
let recorder = Recorder::new(client.clone(), "bucket-controller".into());
let context = Context { client, recorder, config };
Controller::new(buckets, Config::default().any_semantic())
.shutdown_on_signal()
.run(reconcile, error_policy, Arc::new(context))
.filter_map(|x| async move { std::result::Result::ok(x) })
.for_each(|_| futures::future::ready(()))
.await;
}
// Context for our reconciler
#[derive(Clone)]
pub(crate) struct Context {
/// Kubernetes client
pub client: Client,
/// Event recorder
pub recorder: Recorder,
pub(crate) config: OperatorConfig,
}
#[derive(Error, Debug)]
pub enum RustFSBucketError {
#[error("Kube Error: {0}")]
KubeError(#[source] kube::Error),
#[error("Error while executing rc cli: {0}")]
RcCliError(#[source] anyhow::Error),
}
pub type RustFSBucketResult<T, E = RustFSBucketError> = std::result::Result<T, E>;

View File

@@ -0,0 +1,379 @@
use crate::conditions::{init_conditions, is_condition_true, is_condition_unknown, set_condition};
use crate::rc::{RcAlias, admin_info, get_aliases, list_buckets, set_alias};
use api::api::v1beta1_rustfs_instance::{RustFSInstance, RustFSInstanceStatus};
use futures::StreamExt;
use k8s_openapi::api::core::v1::Secret;
use kube::api::{ListParams, PostParams};
use kube::runtime::Controller;
use kube::runtime::controller::Action;
use kube::runtime::events::Recorder;
use kube::runtime::watcher::Config;
use kube::{Api, Client, Error, ResourceExt};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use tracing::*;
const TYPE_CONNECTED: &str = "RustFSReady";
const TYPE_SECRET_LABELED: &str = "SecretLabeled";
const FIN_SECRET_LABEL: &str = "s3.badhouseplants.net/s3-label";
const SECRET_LABEL: &str = "s3.badhouseplants.net/s3-instance";
pub(crate) const ACCESS_KEY: &str = "ACCESS_KEY";
pub(crate) const SECRET_KEY: &str = "SECRET_KEY";
#[instrument(skip(ctx, req), fields(trace_id, controller = "rustfs-instance"))]
pub(crate) async fn reconcile(req: Arc<RustFSInstance>, ctx: Arc<Context>) -> RustFSInstanceResult<Action> {
info!("Staring to reconcile");
info!("Getting the RustFSInstance resource");
let rustfs_api: Api<RustFSInstance> = Api::all(ctx.client.clone());
let mut rustfs_cr = match rustfs_api.get(req.name_any().as_str()).await {
Ok(res) => res,
Err(Error::Api(ae)) if ae.code == 404 => {
info!("Object is not found, probably removed");
return Ok(Action::await_change());
}
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
};
let secret_ns = rustfs_cr.clone().spec.credentials_secret.namespace;
let secret_api: Api<Secret> = Api::namespaced(ctx.client.clone(), &secret_ns);
// If status is none, we need to initialize the object
let mut status = match rustfs_cr.clone().status {
None => {
info!("Status is not yet set, initializing the object");
return init_object(rustfs_cr, rustfs_api).await;
}
Some(status) => status,
};
// We need to know the secret before deletion, because the operator needs to unlabel it
let secret = match get_secret(secret_api.clone(), rustfs_cr.clone()).await {
Ok(secret) => secret,
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
};
// Handle the deletion logic
if rustfs_cr.metadata.deletion_timestamp.is_some() {
info!("Object is marked for deletion");
if let Some(mut finalizers) = rustfs_cr.clone().metadata.finalizers {
if finalizers.contains(&FIN_SECRET_LABEL.to_string()) {
info!("Removing labels from the secret with credentials");
match unlabel_secret(ctx.clone(), rustfs_cr.clone(), secret).await {
Ok(_) => {
if let Some(index) = finalizers
.iter()
.position(|x| x == FIN_SECRET_LABEL)
{
finalizers.remove(index);
};
}
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
};
}
rustfs_cr.metadata.finalizers = Some(finalizers);
};
match rustfs_api
.replace(&rustfs_cr.name_any(), &PostParams::default(), &rustfs_cr)
.await
{
Ok(_) => return Ok(Action::await_change()),
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
}
}
// If secret is labeled, add a finalizer to the rustfs_cr
if is_condition_true(status.clone().conditions, TYPE_SECRET_LABELED) {
let mut current_finalizers = rustfs_cr.clone().metadata.finalizers.unwrap_or_default();
// Only if the finalizer is not added yet
if !current_finalizers.contains(&FIN_SECRET_LABEL.to_string()) {
info!("Adding a finalizer");
current_finalizers.push(FIN_SECRET_LABEL.to_string());
rustfs_cr.metadata.finalizers = Some(current_finalizers);
match rustfs_api
.replace(&rustfs_cr.name_any(), &PostParams::default(), &rustfs_cr)
.await
{
Ok(_) => return Ok(Action::await_change()),
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
}
}
}
// Label the secret, if not yet labeled
if is_condition_unknown(status.clone().conditions, TYPE_SECRET_LABELED) {
if is_secret_labeled(secret.clone()) {
if is_secret_labeled_by_another_obj(rustfs_cr.clone(), secret.clone()) {
return Err(RustFSInstanceError::SecretIsAlreadyLabeled);
}
if is_secret_labeled_by_obj(rustfs_cr.clone(), secret.clone()) {
info!("Secret is already labeled");
status.conditions = set_condition(
status.clone().conditions,
req.metadata.clone(),
TYPE_SECRET_LABELED,
"True".to_string(),
"Reconciled".to_string(),
"Secret is already labeled".to_string(),
);
}
} else {
info!("Labeling the secret");
if let Err(err) = label_secret(ctx.clone(), rustfs_cr.clone(), secret).await {
return Err(RustFSInstanceError::KubeError(err));
};
status.conditions = set_condition(
status.clone().conditions,
req.metadata.clone(),
TYPE_SECRET_LABELED,
"True".to_string(),
"Reconciled".to_string(),
"Secret is labeled".to_string(),
);
};
rustfs_cr.status = Some(status);
match rustfs_api
.replace_status(&rustfs_cr.name_any(), &PostParams::default(), &rustfs_cr)
.await
{
Ok(_) => return Ok(Action::await_change()),
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
}
};
info!("Checking if the secret is labeled by another object");
if !is_secret_labeled_by_obj(rustfs_cr.clone(), secret.clone()) {
status.conditions = set_condition(
status.conditions,
rustfs_cr.clone().metadata,
TYPE_SECRET_LABELED,
"Unknown".to_string(),
"RustFSInstanceReconciliation".to_string(),
"Secret is not labeled".to_string(),
);
rustfs_cr.status = Some(status);
match rustfs_api
.replace_status(&rustfs_cr.clone().name_any(), &PostParams::default(), &rustfs_cr)
.await
{
Ok(_) => return Ok(Action::await_change()),
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
}
};
info!("Getting data from the secret");
let (access_key, secret_key) = match get_data_from_secret(secret) {
Ok((ak, sk)) => (ak, sk),
Err(err) => return Err(RustFSInstanceError::InvalidSecret(err)),
};
let current_aliases = match get_aliases() {
Ok(aliases) => aliases,
Err(err) => return Err(RustFSInstanceError::RcCliError(err)),
};
// Check if alias already exists
if current_aliases.aliases.is_none_or(|a| !a.contains(&RcAlias{name: rustfs_cr.name_any().to_string()}))
&& let Err(err) = set_alias(rustfs_cr.name_any(), rustfs_cr.clone().spec.endpoint, access_key.clone(), secret_key.clone()){
return Err(RustFSInstanceError::RcCliError(err));
}
let admin_info = match admin_info(rustfs_cr.name_any().to_string()){
Ok(ai) => ai,
Err(err) =>return Err(RustFSInstanceError::RcCliError(err)),
};
let bucket_list = match list_buckets(rustfs_cr.name_any().to_string()){
Ok(bl) => bl,
Err(err) => return Err(RustFSInstanceError::RcCliError(err)),
};
status.ready = true;
status.total_buckets = admin_info.buckets;
status.region = admin_info.region;
status.buckets = Some(bucket_list.items.unwrap().iter().map(|b| b.clone().key.unwrap()).collect());
rustfs_cr.status = Some(status);
match rustfs_api
.replace_status(&rustfs_cr.name_any(), &PostParams::default(), &rustfs_cr)
.await
{
Ok(_) => return Ok(Action::requeue(Duration::from_secs(120))),
Err(err) => return Err(RustFSInstanceError::KubeError(err)),
};
}
// Bootstrap the object by adding a default status to it
async fn init_object(mut obj: RustFSInstance, api: Api<RustFSInstance>) -> Result<Action, RustFSInstanceError> {
let conditions = init_conditions(vec![TYPE_CONNECTED.to_string(), TYPE_SECRET_LABELED.to_string()]);
obj.status = Some(RustFSInstanceStatus {
conditions,
..Default::default()
});
match api
.replace_status(obj.clone().name_any().as_str(), &Default::default(), &obj)
.await
{
Ok(_) => Ok(Action::await_change()),
Err(err) => Err(RustFSInstanceError::KubeError(err)),
}
}
// Get the secret with credentials
pub(crate) async fn get_secret(api: Api<Secret>, obj: RustFSInstance) -> Result<Secret, kube::Error> {
api.get(&obj.spec.credentials_secret.name).await
}
async fn unlabel_secret(
ctx: Arc<Context>,
obj: RustFSInstance,
mut secret: Secret,
) -> Result<(), kube::Error> {
let secret_ns = obj.clone().spec.credentials_secret.namespace;
let api: Api<Secret> = Api::namespaced(ctx.client.clone(), &secret_ns);
if let Some(mut labels) = secret.clone().metadata.labels {
labels.remove(SECRET_LABEL);
secret.metadata.labels = Some(labels);
api.replace(&secret.name_any(), &PostParams::default(), &secret)
.await?;
}
Ok(())
}
async fn label_secret(
ctx: Arc<Context>,
obj: RustFSInstance,
mut secret: Secret,
) -> Result<Secret, kube::Error> {
let secret_ns = obj.clone().spec.credentials_secret.namespace;
let api: Api<Secret> = Api::namespaced(ctx.client.clone(), &secret_ns);
secret
.clone()
.metadata
.labels
.get_or_insert_with(BTreeMap::new)
.insert(SECRET_LABEL.to_string(), obj.name_any());
let mut labels = match &secret.clone().metadata.labels {
Some(labels) => labels.clone(),
None => {
let map: BTreeMap<String, String> = BTreeMap::new();
map
}
};
labels.insert(SECRET_LABEL.to_string(), obj.name_any());
secret.metadata.labels = Some(labels);
api.replace(&secret.name_any(), &PostParams::default(), &secret)
.await?;
let secret = match api.get(&obj.spec.credentials_secret.name).await {
Ok(secret) => secret,
Err(err) => return Err(err),
};
Ok(secret)
}
// Checks whether a secret ia already labeled by the operator
fn is_secret_labeled(secret: Secret) -> bool {
match secret.metadata.labels {
Some(labels) => labels.get_key_value(SECRET_LABEL).is_some(),
None => false,
}
}
// Checks whether a secret is already labeled by another object
fn is_secret_labeled_by_another_obj(obj: RustFSInstance, secret: Secret) -> bool {
match secret.metadata.labels {
Some(labels) => labels
.get(SECRET_LABEL)
.is_some_and(|label| label != &obj.name_any()),
None => false,
}
}
// Checks whether a secret is already labeled by this object
fn is_secret_labeled_by_obj(obj: RustFSInstance, secret: Secret) -> bool {
match secret.metadata.labels {
Some(labels) => labels
.get(SECRET_LABEL)
.is_some_and(|label| label == &obj.name_any()),
None => false,
}
}
// Returns (access_key, secret_key)
pub(crate) fn get_data_from_secret(secret: Secret) -> Result<(String, String), anyhow::Error> {
let data = match secret.data {
Some(data) => data,
None => return Err(anyhow::Error::msg("empty data")),
};
let access_key = match data.get(ACCESS_KEY) {
Some(access_key) => String::from_utf8(access_key.0.clone()).unwrap(),
None =>return Err(anyhow::Error::msg("empty access key")),
};
let secret_key = match data.get(SECRET_KEY) {
Some(secret_key) => String::from_utf8(secret_key.0.clone()).unwrap(),
None => return Err(anyhow::Error::msg("empty secret key")),
};
Ok((access_key, secret_key))
}
pub(crate) fn error_policy(_rustfs_cr: Arc<RustFSInstance>, err: &RustFSInstanceError, _ctx: Arc<Context>) -> Action {
error!(trace.error = %err, "Error occured during the reconciliation");
Action::requeue(Duration::from_secs(5 * 60))
}
#[instrument(skip(client), fields(trace_id))]
pub async fn run(client: Client) {
let s3instances = Api::<RustFSInstance>::all(client.clone());
if let Err(err) = s3instances.list(&ListParams::default().limit(1)).await {
error!("{}", err);
std::process::exit(1);
}
let recorder = Recorder::new(client.clone(), "s3instance-controller".into());
let context = Context { client, recorder };
Controller::new(s3instances, Config::default().any_semantic())
.shutdown_on_signal()
.run(reconcile, error_policy, Arc::new(context))
.filter_map(|x| async move { std::result::Result::ok(x) })
.for_each(|_| futures::future::ready(()))
.await;
}
// Context for our reconciler
#[derive(Clone)]
pub(crate) struct Context {
/// Kubernetes client
pub client: Client,
/// Event recorder
pub recorder: Recorder,
}
#[derive(Error, Debug)]
pub enum RustFSInstanceError {
#[error("Kube Error: {0}")]
KubeError(#[source] kube::Error),
#[error("SecretIsAlreadyLabeled")]
SecretIsAlreadyLabeled,
#[error("Invalid Secret: {0}")]
InvalidSecret(#[source] anyhow::Error),
#[error("Error while executing rc cli: {0}")]
RcCliError(#[source] anyhow::Error),
}
pub type RustFSInstanceResult<T, E = RustFSInstanceError> = std::result::Result<T, E>;

View File

@@ -0,0 +1,611 @@
use crate::conditions::{init_conditions, is_condition_true, is_condition_unknown, set_condition};
use crate::config::OperatorConfig;
use crate::rc::{POLICY_READ_ONLY, POLICY_READ_WRITE, RcPolicyData, assign_policy, create_bucket, create_policy, create_user, delete_user, list_buckets, render_policy, user_info};
use api::api::v1beta_rustfs_bucket::RustFSBucket;
use api::api::v1beta_rustfs_user::{RustFSUser, RustFSUserStatus};
use api::api::v1beta1_rustfs_instance::RustFSInstance;
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use futures::StreamExt;
use k8s_openapi::ByteString;
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
use kube::api::{ListParams, ObjectMeta, PostParams};
use kube::runtime::Controller;
use kube::runtime::controller::Action;
use kube::runtime::events::Recorder;
use kube::runtime::watcher::Config;
use kube::{Api, Client, Error, Resource, ResourceExt};
use rand::RngExt;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use anyhow::{anyhow, Result};
use tracing::*;
const TYPE_USER_READY: &str = "UserReady";
const TYPE_SECRET_READY: &str = "SecretReady";
const FIN_CLEANUP: &str = "s3.badhouseplants.net/bucket-cleanup";
const CONFIGMAP_LABEL: &str = "s3.badhouseplants.net/s3-bucket";
const AWS_ACCESS_KEY_ID: &str = "AWS_ACCESS_KEY_ID";
const AWS_SECCRET_ACCESS_KEY: &str = "AWS_SECRET_ACCESS_KEY";
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz\
0123456789)(*&^%$#@!~";
const PASSWORD_LEN: usize = 40;
#[instrument(skip(ctx, obj), fields(trace_id))]
pub(crate) async fn reconcile(obj: Arc<RustFSUser>, ctx: Arc<Context>) -> RustFSUserResult<Action> {
info!("Staring reconciling");
let user_api: Api<RustFSUser> =
Api::namespaced(ctx.client.clone(), &obj.namespace().unwrap());
let bucket_api: Api<RustFSBucket> =
Api::namespaced(ctx.client.clone(), &obj.namespace().unwrap());
let secret_api: Api<Secret> = Api::namespaced(ctx.client.clone(), &obj.namespace().unwrap());
let rustfs_api: Api<RustFSInstance> = Api::all(ctx.client.clone());
info!("Getting the RustFSUser resource");
let mut user_cr = match user_api.get(&obj.name_any()).await {
Ok(cr) => cr,
Err(Error::Api(ae)) if ae.code == 404 => {
info!("Object is not found, probably removed");
return Ok(Action::await_change());
}
Err(err) => return Err(RustFSUserError::KubeError(err)),
};
// On the first reconciliation status is None
// it needs to be initialized
let mut status = match user_cr.clone().status {
None => {
info!("Status is not yet set, initializing the object");
return init_object(user_cr, user_api).await;
}
Some(status) => status,
};
let secret_name = format!("{}-bucket-creds", user_cr.name_any());
info!("Getting the secret");
// Get the secret, if it's already there, we need to validate, or create an empty one
let mut secret = match get_secret(secret_api.clone(), &secret_name).await {
Ok(secret) => secret,
Err(Error::Api(ae)) if ae.code == 404 => {
info!("Secret is not found, creating a new one");
let secret = Secret {
metadata: ObjectMeta {
name: Some(secret_name),
namespace: Some(user_cr.clone().namespace().unwrap()),
..Default::default()
},
..Default::default()
};
match create_secret(secret_api.clone(), secret).await {
Ok(secret) => secret,
Err(err) => return Err(RustFSUserError::KubeError(err)),
}
}
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
}
};
info!("Labeling the secret");
secret = match label_secret(secret_api.clone(), &user_cr.name_any(), secret).await {
Ok(secret) => secret,
Err(err) => return Err(RustFSUserError::KubeError(err)),
};
info!("Setting owner references to the secret");
if ctx.config.set_owner_reference {
secret = match own_secret(secret_api.clone(), user_cr.clone(), secret).await {
Ok(secret) => secret,
Err(err) => return Err(RustFSUserError::KubeError(err)),
};
};
// TODO: It shouldn't stop reconciliation
if is_condition_true(status.clone().conditions, TYPE_USER_READY) {
let mut current_finalizers = match user_cr.clone().metadata.finalizers {
Some(finalizers) => finalizers,
None => vec![],
};
if user_cr.spec.cleanup {
if !current_finalizers.contains(&FIN_CLEANUP.to_string()) {
info!("Adding a finalizer");
current_finalizers.push(FIN_CLEANUP.to_string());
}
} else {
if current_finalizers.contains(&FIN_CLEANUP.to_string()) {
if let Some(index) = current_finalizers
.iter()
.position(|x| *x == FIN_CLEANUP.to_string())
{
current_finalizers.remove(index);
};
}
};
user_cr.metadata.finalizers = Some(current_finalizers);
if let Err(err) = user_api
.replace(&user_cr.name_any(), &PostParams::default(), &user_cr)
.await
{
return Err(RustFSUserError::KubeError(err));
}
};
info!("Getting the RustFsBucket");
let bucket_cr = match bucket_api.get(&user_cr.spec.bucket).await {
Ok(bucket) => bucket,
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
}
};
let bucket_status = match bucket_cr.clone().status {
Some(status) => {
if ! status.ready {
return Err(RustFSUserError::BucketNotReadyError);
};
status
}
None => {
return Err(RustFSUserError::BucketNotReadyError);
},
};
info!("Getting the RustFSIntsance");
let rustfs_cr = match rustfs_api.get(&bucket_cr.spec.instance).await {
Ok(rustfs_cr) => rustfs_cr,
Err(err) => return Err(RustFSUserError::KubeError(err)),
};
if rustfs_cr.clone().status.is_none_or(|s| ! s.ready) {
info!("Instance is not ready, waiting");
return Ok(Action::requeue(Duration::from_secs(120)));
}
// Check the secret
let username = format!(
"{}-{}",
user_cr.namespace().unwrap(),
user_cr.name_any()
);
// If password missing, regen the secret
// Update the user
if user_cr.metadata.deletion_timestamp.is_some() {
info!("Object is marked for deletion");
if let Some(mut finalizers) = user_cr.clone().metadata.finalizers {
if finalizers.contains(&FIN_CLEANUP.to_string()) {
match delete_user(rustfs_cr.name_any(), username){
Ok(_) => {
if let Some(index) = finalizers
.iter()
.position(|x| *x == FIN_CLEANUP.to_string())
{
finalizers.remove(index);
};
}
Err(err) => return Err(RustFSUserError::RcCliError(err)),
}
}
user_cr.metadata.finalizers = Some(finalizers);
};
match user_api
.replace(&user_cr.name_any(), &PostParams::default(), &user_cr)
.await
{
Ok(_) => return Ok(Action::await_change()),
Err(err) => return Err(RustFSUserError::KubeError(err)),
}
}
// If secret is not ready, generate a new one
if !is_condition_true(status.clone().conditions, TYPE_SECRET_READY) {
let password = generate_password();
let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let password_hash = match argon2.hash_password(&password.as_bytes(), &salt) {
Ok(hash) => hash.to_string(),
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::IllegalRustFSUser);
},
};
status.password_hash = Some(password_hash);
match set_secret_data(secret_api.clone(), secret.clone(), username.clone(), password.clone()).await {
Ok(_) => {
status.conditions = set_condition(status.clone().conditions, user_cr.clone().metadata, TYPE_SECRET_READY, "True".to_string(), "Reconciled".to_string(), "Secret is up-to-date".to_string());
user_cr.status = Some(status);
match user_api.replace_status(&user_cr.name_any(), &PostParams::default(), &user_cr).await {
Ok(_) => {
return Ok(Action::await_change());
},
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
},
}
},
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
},
}
}
let credentials = match secret.data {
Some(data) => {
match check_credentials(data, username, status.clone().password_hash) {
Some(creds) => creds,
None => {
status.conditions = set_condition(status.clone().conditions, user_cr.clone().metadata, TYPE_SECRET_READY, "False".to_string(), "Reconciled".to_string(), "Invalid credentials in the secret".to_string());
user_cr.status = Some(status);
info!("I'm setting the condition");
match user_api.replace_status(&user_cr.name_any(), &PostParams::default(), &user_cr).await {
Ok(_) => {
return Ok(Action::await_change());
},
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
},
}
}
}
},
None => {
status.clone().conditions = set_condition(status.clone().conditions, user_cr.clone().metadata, TYPE_SECRET_READY, "False".to_string(), "Reconciled".to_string(), "Invalid credentials in the secret".to_string());
user_cr.status = Some(status);
match user_api.replace_status(&user_cr.name_any(), &PostParams::default(), &user_cr).await {
Ok(_) => {
return Ok(Action::await_change());
},
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
},
}
},
};
let username = credentials.0;
let password = credentials.1;
if let Err(err) = create_user(rustfs_cr.name_any(), username.clone(), password) {
error!("{}", err);
return Err(RustFSUserError::IllegalRustFSUser);
}
let userinfo = match user_info(rustfs_cr.name_any(), username.clone()) {
Ok(info) => info,
Err(err) => return Err(RustFSUserError::RcCliError(err)),
};
let policy_template = match user_cr.spec.access {
api::api::v1beta_rustfs_user::Access::ReadOnly => POLICY_READ_ONLY,
api::api::v1beta_rustfs_user::Access::ReadWrite => POLICY_READ_WRITE,
};
let bucket_name = match bucket_status.bucket_name {
Some(name) => name,
None => {
error!("bucket name is not yet set");
return Err(RustFSUserError::IllegalRustFSUser);
},
};
let data = RcPolicyData{bucket: bucket_name};
let policy = match render_policy(policy_template.to_string(), data){
Ok(policy) => policy,
Err(err) => return Err(RustFSUserError::RcCliError(anyhow!(err))),
};
if let Err(err) = create_policy(rustfs_cr.name_any(), username.clone(), policy_template.to_string()) {
return Err(RustFSUserError::RcCliError(err));
};
if let Err(err) = assign_policy(rustfs_cr.name_any(), username.clone()) {
return Err(RustFSUserError::RcCliError(err));
};
// create a user
status.policy = Some(policy);
status.username = Some(userinfo.access_key);
status.status = Some(userinfo.status);
status.ready = true;
status.conditions = set_condition(
status.clone().conditions,
user_cr.metadata.clone(),
TYPE_USER_READY,
"True".to_string(),
"Reconciled".to_string(),
"User is ready".to_string(),
);
user_cr.status = Some(status);
info!("Updating status of the bucket resource");
match user_api
.replace_status(&user_cr.name_any(), &PostParams::default(), &user_cr)
.await
{
Ok(_) => {
return Ok(Action::requeue(Duration::from_secs(120)));
}
Err(err) => {
error!("{}", err);
return Err(RustFSUserError::KubeError(err));
}
};
}
// Bootstrap the object by adding a default status to it
async fn init_object(mut obj: RustFSUser, api: Api<RustFSUser>) -> Result<Action, RustFSUserError> {
let conditions = init_conditions(vec![TYPE_SECRET_READY.to_string(), TYPE_USER_READY.to_string()]);
obj.status = Some(RustFSUserStatus {
conditions,
..RustFSUserStatus::default()
});
match api
.replace_status(obj.clone().name_any().as_str(), &Default::default(), &obj)
.await
{
Ok(_) => Ok(Action::await_change()),
Err(err) => {
error!("{}", err);
Err(RustFSUserError::KubeError(err))
}
}
}
// Get the secret with the bucket data
async fn get_secret(api: Api<Secret>, name: &str) -> Result<Secret, kube::Error> {
info!("Getting a secret: {}", name);
api.get(name).await
}
// checks if the secret has all the required data
fn check_secret_data(secret: Secret) -> bool {
let data = match secret.data {
Some(data) => data,
None => {
return false;
},
};
data.contains_key(AWS_SECCRET_ACCESS_KEY) && data.contains_key(AWS_ACCESS_KEY_ID)
}
// Returns false if password is not valid
fn check_credentials(data: BTreeMap<String, ByteString>, username: String, password_hash: Option<String>) -> Option<(String, String)> {
let current_username = match data.get(AWS_ACCESS_KEY_ID) {
Some(username) => String::from_utf8(username.0.clone()).unwrap(),
None => {
return None;
}
};
info!("Username is there");
let current_password = match data.get(AWS_SECCRET_ACCESS_KEY) {
Some(password) => String::from_utf8(password.0.clone()).unwrap(),
None => {
return None;
}
};
info!("Password is there");
if current_username != username {
return None
};
info!("hash is {:?}", password_hash);
info!("Username is fine");
if let Some(password_hash) = password_hash {
let parsed_hash = match PasswordHash::new(&password_hash){
Ok(hash) => hash,
Err(_) => {
return None;
},
};
match Argon2::default().verify_password(current_password.as_bytes(), &parsed_hash) {
Ok(_) => {
return Some((current_username, current_password));
},
Err(err) => {
error!("{}", err);
return None
}
};
};
return None;
}
// Create Secret
async fn create_secret(api: Api<Secret>, secret: Secret) -> Result<Secret, kube::Error> {
match api.create(&PostParams::default(), &secret).await {
Ok(secret) => get_secret(api, &secret.name_any()).await,
Err(err) => Err(err),
}
}
async fn label_secret(
api: Api<Secret>,
bucket_name: &str,
mut secret: Secret,
) -> Result<Secret, kube::Error> {
let mut labels = match &secret.clone().metadata.labels {
Some(labels) => labels.clone(),
None => {
let map: BTreeMap<String, String> = BTreeMap::new();
map
}
};
labels.insert(CONFIGMAP_LABEL.to_string(), bucket_name.to_string());
secret.metadata.labels = Some(labels);
api.replace(&secret.name_any(), &PostParams::default(), &secret)
.await?;
let secret = match api.get(&secret.name_any()).await {
Ok(secret) => secret,
Err(err) => {
return Err(err);
}
};
Ok(secret)
}
async fn own_secret(
api: Api<Secret>,
user_cr: RustFSUser,
mut secret: Secret,
) -> Result<Secret, kube::Error> {
let mut owner_references = match &secret.clone().metadata.owner_references {
Some(owner_references) => owner_references.clone(),
None => {
let owner_references: Vec<OwnerReference> = vec![];
owner_references
}
};
if owner_references
.iter()
.find(|or| or.uid == user_cr.uid().unwrap())
.is_some()
{
return Ok(secret);
}
let new_owner_reference = OwnerReference {
api_version: RustFSUser::api_version(&()).into(),
kind: RustFSUser::kind(&()).into(),
name: user_cr.name_any(),
uid: user_cr.uid().unwrap(),
..Default::default()
};
owner_references.push(new_owner_reference);
secret.metadata.owner_references = Some(owner_references);
api.replace(&secret.name_any(), &PostParams::default(), &secret)
.await?;
let secret = match api.get(&secret.name_any()).await {
Ok(secret) => secret,
Err(err) => {
return Err(err);
}
};
Ok(secret)
}
async fn set_secret_data(
api: Api<Secret>,
mut secret: Secret,
username: String,
password: String,
) -> Result<Secret, kube::Error> {
let mut data = match &secret.clone().data {
Some(data) => data.clone(),
None => {
let map: BTreeMap<String, ByteString> = BTreeMap::new();
map
}
};
data.insert(
AWS_ACCESS_KEY_ID.to_string(),
ByteString(username.as_bytes().to_vec()),
);
data.insert(
AWS_SECCRET_ACCESS_KEY.to_string(),
ByteString(password.as_bytes().to_vec()),
);
secret.data = Some(data);
api.replace(&secret.name_any(), &PostParams::default(), &secret)
.await?;
match api.get(&secret.name_any()).await {
Ok(secret) => Ok(secret),
Err(err) => Err(err),
}
}
fn generate_password() -> String {
let mut rng = rand::rng();
let password: String = (0..PASSWORD_LEN)
.map(|_| {
let idx = rng.random_range(0..CHARSET.len());
char::from(CHARSET[idx])
})
.collect();
password
}
pub(crate) fn error_policy(_: Arc<RustFSUser>, err: &RustFSUserError, _: Arc<Context>) -> Action {
error!(trace.error = %err, "Error occured during the reconciliation");
Action::requeue(Duration::from_secs(5 * 60))
}
#[instrument(skip(client), fields(trace_id))]
pub async fn run(client: Client, config: OperatorConfig) {
let users = Api::<RustFSUser>::all(client.clone());
if let Err(err) = users.list(&ListParams::default().limit(1)).await {
error!("{}", err);
std::process::exit(1);
}
let recorder = Recorder::new(client.clone(), "user-controller".into());
let context = Context { client, recorder, config };
Controller::new(users, Config::default().any_semantic())
.shutdown_on_signal()
.run(reconcile, error_policy, Arc::new(context))
.filter_map(|x| async move { std::result::Result::ok(x) })
.for_each(|_| futures::future::ready(()))
.await;
}
// Context for our reconciler
#[derive(Clone)]
pub(crate) struct Context {
/// Kubernetes client
pub client: Client,
/// Event recorder
pub recorder: Recorder,
pub(crate) config: OperatorConfig,
}
#[derive(Error, Debug)]
pub enum RustFSUserError {
#[error("SerializationError: {0}")]
SerializationError(#[source] serde_json::Error),
#[error("Kube Error: {0}")]
KubeError(#[source] kube::Error),
#[error("Finalizer Error: {0}")]
// NB: awkward type because finalizer::Error embeds the reconciler error (which is this)
// so boxing this error to break cycles
FinalizerError(#[source] Box<kube::runtime::finalizer::Error<RustFSUserError>>),
#[error("IllegalRustFSUser")]
IllegalRustFSUser,
#[error("SecretIsAlreadyLabeled")]
SecretIsAlreadyLabeled,
#[error("Invalid Secret: {0}")]
InvalidSecret(#[source] anyhow::Error),
#[error("Error while executing rc cli: {0}")]
RcCliError(#[source] anyhow::Error),
#[error("Bucket is not yet ready")]
BucketNotReadyError,
}
pub type RustFSUserResult<T, E = RustFSUserError> = std::result::Result<T, E>;

19
operator/src/crdgen.rs Normal file
View File

@@ -0,0 +1,19 @@
use api::api::v1beta1_rustfs_instance::RustFSInstance;
use api::api::v1beta_rustfs_bucket::RustFSBucket;
use api::api::v1beta_rustfs_user::RustFSUser;
use kube::CustomResourceExt;
fn main() {
println!(
"---\n{}",
serde_yaml::to_string(&RustFSInstance::crd()).unwrap()
);
println!(
"---\n{}",
serde_yaml::to_string(&RustFSBucket::crd()).unwrap()
);
println!(
"---\n{}",
serde_yaml::to_string(&RustFSUser::crd()).unwrap()
);
}

1
operator/src/lib.rs Normal file
View File

@@ -0,0 +1 @@
pub mod api;

109
operator/src/main.rs Normal file
View File

@@ -0,0 +1,109 @@
mod conditions;
mod controllers;
mod rc;
mod cli;
mod config;
use crate::controllers::{rustfs_instance};
use crate::rc::check_rc;
use actix_web::{App, HttpRequest, HttpResponse, HttpServer, Responder, get, middleware};
use clap::Parser;
use kube::{Client, CustomResourceExt};
use tracing::error;
use tracing_subscriber::EnvFilter;
use self::config::read_config_from_file;
use self::controllers::{rustfs_bucket, rustfs_user};
use api::api::v1beta1_rustfs_instance::RustFSInstance;
use api::api::v1beta_rustfs_bucket::RustFSBucket;
use api::api::v1beta_rustfs_user::RustFSUser;
/// Simple program to greet a person
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(long, default_value_t = 60000)]
/// The address the metric endpoint binds to.
metrics_port: u16,
#[arg(long, default_value_t = 8081)]
/// The address the probe endpoint binds to.
health_probe_port: u16,
#[arg(long, default_value_t = true)]
/// Enabling this will ensure there is only one active controller manager.
// DB Operator feature flags
#[arg(long, default_value_t = false)]
/// If enabled, DB Operator will run full reconciliation only
/// when changes are detected
is_change_check_nabled: bool,
#[arg(long, default_value = "/srv/config/config.json")]
/// A path to a config file
config: String,
/// Set to true to generate crds
#[arg(long, default_value_t = false)]
print_crd: bool,
}
#[get("/health")]
async fn health(_: HttpRequest) -> impl Responder {
HttpResponse::Ok().json("healthy")
}
fn crdgen() {
println!(
"---\n{}",
serde_yaml::to_string(&RustFSInstance::crd()).unwrap()
);
println!(
"---\n{}",
serde_yaml::to_string(&RustFSBucket::crd()).unwrap()
);
println!(
"---\n{}",
serde_yaml::to_string(&RustFSUser::crd()).unwrap()
);
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
if args.print_crd {
crdgen();
return Ok(());
}
tracing_subscriber::fmt()
.json()
.with_env_filter(EnvFilter::from_default_env())
.init();
if let Err(err) = check_rc() {
error!("{}", err);
std::process::exit(1);
}
let client = Client::try_default()
.await
.expect("failed to create kube Client");
let config = read_config_from_file(args.config)?;
let rustfs_instance_ctrl = rustfs_instance::run(client.clone());
let rustfs_bucket_ctrl = rustfs_bucket::run(client.clone(), config.clone());
let rustfs_user_ctrl = rustfs_user::run(client.clone(), config.clone());
// Start web server
let server = HttpServer::new(move || {
App::new()
.wrap(middleware::Logger::default().exclude("/health"))
.service(health)
})
.bind("0.0.0.0:8080")?
.shutdown_timeout(5);
// Both runtimes implements graceful shutdown, so poll until both are done
tokio::join!(
rustfs_instance_ctrl,
rustfs_bucket_ctrl,
rustfs_user_ctrl,
server.run()
)
.3?;
Ok(())
}

305
operator/src/rc.rs Normal file
View File

@@ -0,0 +1,305 @@
use handlebars::{Handlebars, RenderError};
use serde::{Deserialize, Serialize};
use serde_json::from_str;
use std::io::Write;
use tempfile::{tempfile, NamedTempFile};
use tracing::info;
use crate::cli;
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub(crate) struct RcAliasList {
pub(crate) aliases: Option<Vec<RcAlias>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub(crate) struct RcAlias {
pub(crate) name: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub(crate) struct RcAdminInfo {
pub(crate) buckets: Option<usize>,
pub(crate) region: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub(crate) struct RcBucketList {
pub(crate) items: Option<Vec<RcBucket>>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub(crate) struct RcBucket {
pub(crate) key: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RcUserInfo {
pub(crate) status: String,
pub(crate) access_key: String,
}
pub(crate) const POLICY_READ_ONLY: &str = r#"{
"ID": "",
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Action": [
"s3:GetBucketQuota",
"s3:GetBucketLocation",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::{{bucket}}",
"arn:aws:s3:::{{bucket}}/*"
],
"Condition": {}
}
]
}"#;
pub(crate) const POLICY_READ_WRITE: &str = r#"{
"ID": "",
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Action": [
"s3:*"
],
"Resource": [
"arn:aws:s3:::{{bucket}}",
"arn:aws:s3:::{{bucket}}/*"
],
"Condition": {}
}
]
}"#;
pub(crate) fn get_aliases() -> Result<RcAliasList, anyhow::Error> {
let output = cli::rc_exec(vec!["alias", "list", "--json"])?;
let alias_list: RcAliasList = from_str::<RcAliasList>(&output)?;
Ok(alias_list)
}
pub(crate) fn set_alias(
name: String,
endpoint: String,
username: String,
password: String,
) -> Result<(), anyhow::Error> {
cli::rc_exec(vec![
"alias",
"set",
name.as_str(),
endpoint.as_str(),
username.as_str(),
password.as_str(),
])?;
Ok(())
}
pub(crate) fn admin_info(name: String) -> Result<RcAdminInfo, anyhow::Error> {
let output = cli::rc_exec(vec!["admin", "info", "cluster", name.as_str(), "--json"])?;
let admin_info: RcAdminInfo = from_str::<RcAdminInfo>(&output)?;
Ok(admin_info)
}
pub(crate) fn list_buckets(name: String) -> Result<RcBucketList, anyhow::Error> {
let output = cli::rc_exec(vec!["ls", name.as_str(), "--json"])?;
let bucket_list = from_str::<RcBucketList>(&output)?;
Ok(bucket_list)
}
pub(crate) fn create_bucket(
alias: String,
bucket_name: String,
versioning: bool,
object_lock: bool,
) -> Result<(), anyhow::Error> {
let path_string = format!("{}/{}", alias, bucket_name);
let path: &str = &path_string;
let mut args = vec!["mb", path];
if versioning {
args.push("--with-versioning");
}
if object_lock {
args.push("--with-lock");
}
cli::rc_exec(args)?;
Ok(())
}
pub(crate) fn create_user(
alias: String,
username: String,
password: String,
) -> Result<(), anyhow::Error> {
cli::rc_exec(vec![
"admin",
"user",
"add",
alias.as_str(),
username.as_str(),
password.as_str(),
])?;
Ok(())
}
pub(crate) fn delete_user(alias: String, username: String) -> Result<(), anyhow::Error> {
cli::rc_exec(vec![
"admin",
"user",
"rm",
alias.as_str(),
username.as_str(),
])?;
Ok(())
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub(crate) struct RcPolicyData {
pub(crate) bucket: String,
}
pub(crate) fn render_policy(template: String, data: RcPolicyData) -> Result<String, RenderError> {
let reg = Handlebars::new();
reg.render_template(&template, &data)
}
pub(crate) fn create_policy(
alias: String,
username: String,
policy: String,
) -> Result<(), anyhow::Error> {
let mut file = NamedTempFile::new()?;
let path = file.path().to_path_buf();
writeln!(file, "{}", policy)?;
cli::rc_exec(vec![
"admin",
"policy",
"create",
alias.as_str(),
username.as_str(),
path.to_str().unwrap(),
])?;
Ok(())
}
pub(crate) fn assign_policy(alias: String, username: String) -> Result<(), anyhow::Error> {
cli::rc_exec(vec![
"admin",
"policy",
"attach",
alias.as_str(),
username.as_str(),
"--user",
username.as_str(),
"--json",
])?;
Ok(())
}
pub(crate) fn user_info(alias: String, username: String) -> Result<RcUserInfo, anyhow::Error> {
let output = cli::rc_exec(vec![
"admin",
"user",
"info",
alias.as_str(),
username.as_str(),
"--json",
])?;
let user_info = from_str::<RcUserInfo>(&output)?;
Ok(user_info)
}
pub(crate) fn delete_bucket(alias: String, bucket_name: String) -> Result<(), anyhow::Error> {
let path_string = format!("{}/{}", alias, bucket_name);
let path: &str = &path_string;
cli::rc_exec(vec!["rb", path, "--force", "--json"])?;
Ok(())
}
pub(crate) fn check_rc() -> Result<(), anyhow::Error> {
cli::rc_exec(vec!["--version"])?;
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::from_str;
use crate::rc::{RcAlias, RcAliasList, RcBucket, RcBucketList};
#[test]
fn test_ser_alias_list() {
let output = r#"{
"aliases": [
{
"name": "test",
"endpoint": "https://something",
"region": "us-east-1",
"bucket_lookup": "auto"
},
{
"name": "test2",
"endpoint": "https://something",
"region": "us-east-1",
"bucket_lookup": "auto"
}
]
}"#;
let alias_list: RcAliasList = from_str::<RcAliasList>(&output).unwrap();
let expected_res = RcAliasList {
aliases: Some(vec![
RcAlias {
name: "test".to_string(),
},
RcAlias {
name: "test2".to_string(),
},
]),
};
assert_eq!(alias_list, expected_res);
}
#[test]
fn test_ser_bucket_list() {
let output = r#"{
"items": [
{
"key": "check",
"last_modified": "2026-03-10T19:24:10Z",
"is_dir": true
},
{
"key": "default-test",
"last_modified": "2026-03-11T13:24:26Z",
"is_dir": true
},
{
"key": "test",
"last_modified": "2026-03-10T19:24:07Z",
"is_dir": true
}
],
"truncated": false
}"#;
let bucket_list = from_str::<RcBucketList>(&output).unwrap();
let expected_res = RcBucketList {
items: Some(vec![
RcBucket {
key: Some("test".to_string()),
},
RcBucket {
key: Some("test2".to_string()),
},
]),
};
assert_eq!(bucket_list, expected_res);
}
}