WIP: First more or less working version #1

Draft
allanger wants to merge 1 commits from init-project into main
70 changed files with 8282 additions and 3 deletions

1
.gitignore vendored
View File

@@ -191,4 +191,3 @@ cython_debug/
# PyPI configuration file
.pypirc

11
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,11 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: detect-private-key
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell

View File

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

View File

@@ -0,0 +1,31 @@
---
when:
event:
- tag
matrix:
include:
- PACKAGE_NAME: badhouseplants/rustfs-manager-operator-docs
DIR: ./documentation
- PACKAGE_NAME: badhouseplants/rustfs-manager-operator
DIR: ./operator
steps:
- name: Build and push a tagged container
image: gitea.badhouseplants.net/badhouseplants/container-builder
environment:
REGISTRY_TOKEN:
from_secret: GITEA_REGISTRY_TOKEN
REGISTRY_USER: devops-bot
privileged: true
commands:
- cd $DIR && build-container
backend_options:
kubernetes:
resources:
requests:
memory: 700Mi
cpu: 1000m
limits:
cpu: 1000m
securityContext:
privileged: true

View File

@@ -0,0 +1,11 @@
---
# Run basic quick code checks
when:
event:
- pull_request
steps:
- name: Execute the pre-commit hook
image: codeberg.org/sp1thas/woodpecker-ci-pre-commit-runner
settings:
args: "--all-files"

View File

@@ -0,0 +1,59 @@
---
when:
event:
- pull_request
branch:
include:
- main
steps:
- name: Build and push helm charts
image: quay.io/helmpack/chart-testing:v3.14.0
environment:
REGISTRY_TOKEN:
from_secret: GITEA_REGISTRY_TOKEN
REGISTRY_USER: devops-bot
commands:
- apk update && apk add yq
- export SHA="+$(git rev-parse --short HEAD)"
- helm registry login gitea.badhouseplants.net --username=$REGISTRY_USER --password=$REGISTRY_TOKEN
- export DOCS_IMAGE_REPOSITORY=$(yq '.image.repository + "-dev"' ./charts/rustfs-manager-operator-docs/values.yaml)
- yq -i '.image.repository = env(DOCS_IMAGE_REPOSITORY)' ./charts/rustfs-manager-operator-docs/values.yaml
- yq -i '.image.tag = env(CI_COMMIT_SHA)' ./charts/rustfs-manager-operator-docs/values.yaml
- export OPERATOR_IMAGE_REPOSITORY=$(yq '.image.repository + "-dev"' ./charts/rustfs-manager-operator/values.yaml)
- yq -i '.image.repository = env(OPERATOR_IMAGE_REPOSITORY)' ./charts/rustfs-manager-operator/values.yaml
- yq -i '.image.tag = env(CI_COMMIT_SHA)' ./charts/rustfs-manager-operator/values.yaml
- |-
for chart in $(find helm -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://gitea.badhouseplants.net/$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

@@ -0,0 +1,47 @@
---
when:
event:
- tag
steps:
- name: Build and push helm charts
image: quay.io/helmpack/chart-testing:v3.14.0
environment:
REGISTRY_TOKEN:
from_secret: GITEA_REGISTRY_TOKEN
REGISTRY_USER: devops-bot
commands:
- apk update && apk add yq
- helm registry login gitea.badhouseplants.net --username=$REGISTRY_USER --password=$REGISTRY_TOKEN
- |-
for chart in $(find helm -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://gitea.badhouseplants.net/$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

@@ -0,0 +1,12 @@
when:
event:
- pull_request
branch:
include:
- main
steps:
- name: Lint helm charts
image: quay.io/helmpack/chart-testing:v3.14.0
commands:
- ct lint --all --validate-maintainers=false

View File

@@ -1,3 +1,94 @@
# rustfs-manager-operator
# RustFS Manager Operator
[![Woodpecker CI](https://ci.badhouseplants.net/api/badges/29/status.svg)](https://ci.badhouseplants.net/repos/29)
An operator to manage bucket, users, and policies on the RustfFS instance through CRDs
An operator to manage bucket and user on a RustfFS instance through Kubernetes CRDs.
## Getting started
Find better docs here: <https://rustfs.badhouseplants.net>
### Install the operator
```shell
helm install rustfs-manager-operator oci://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator/rustfs-manager-operator --version 0.1.0
```
### Connect it to a RustFS instance
1. Create a values file:
```yaml
# values.yaml
endpoint: https://your.rust.fs
username: admin
password: qwertyu9
```
2. Install the **rustfs-instance** helm chart
```shell
helm install rustfs-instance oci://gitea.badhouseplants.net/badhouseplants/rustfs-u/rustfs-instance --version 0.1.0 -f ./values.yaml
```
### Start creating Buckets and Users
#### Buckets
```yaml
apiVersion: rustfs.badhouseplants.net/v1beta1
kind: RustFSBucket
metadata:
name: <bucket name>
namespace: <application namespace>
spec:
# When cleanup is set to true, bucket will be removed from the instance
cleanup: false
# On which instance this bucket should be created
instance: rustfs-instance
# If true, bucket will be created with object locking
objectLock: false
# If true, bucket will be created with versioning
versioning: false
```
#### Users
```yaml
apiVersion: rustfs.badhouseplants.net/v1beta1
kind: RustFSBucketUser
metadata:
name: <username>
namespace: <application namespace>
spec:
bucket: <a name of the bucket CR>
# User will be removed from the RustFS instance if set to true
cleanup: false
access: readWrite # or readOnly
```
### Access credentials via ConfigMaps and Secrets
#### ConfigMap:
```shell
kubectl get configmap <bucket name>-bucket-info -o yaml
apiVersion: v1
kind: ConfigMap
data:
AWS_BUCKET_NAME: <bucket name>
AWS_ENDPOINT_URL: <endpoint>
AWS_REGION: <region>
```
#### Secret:
```shell
kubectl get secret <username>-bucket-creds -o yaml
apiVersion: v1
kind: ConfigMap
data:
AWS_ACCESS_KEY_ID: <username>
AWS_SECRET_ACCESS_KEY: <a generated password>
```

24
Taskfile.yml Normal file
View File

@@ -0,0 +1,24 @@
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
vars:
GREETING: Hello, world!
tasks:
pre-commit-all:
desc: Run pre-commit --all
silent: true
cmd: pre-commit run --all
sync-crds:
desc: Sync CRD from the operator to the chart
silent: true
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 ./charts/rustfs-manager-operator/crd/*
- cp {{.WORKDIR}}/* ./helm/rustfs-manager-operator/crd/
- rm -rf {{.WORKDIR}}

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,10 @@
apiVersion: v2
name: rustfs-instance
description: Connect the rustfs manager operator to your RustFS instance
type: application
version: 0.1.0
appVersion: "0.1.0"
maintainers:
- name: Nikolai Rodionov
email: allanger@badhouseplants.net
url: https://allanger.xyz

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,10 @@
apiVersion: v2
name: rustfs-manager-operator-docs
description: Deploy documentation for the RustFS Manager Operator
type: application
version: 0.1.0
appVersion: "0.1.0"
maintainers:
- name: Nikolai Rodionov
email: allanger@badhouseplants.net
url: https://allanger.xyz

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-docs.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-docs.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "rustfs-manager-operator-docs.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-docs.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,82 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "rustfs-manager-operator-docs.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-docs.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-docs.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "rustfs-manager-operator-docs.labels" -}}
helm.sh/chart: {{ include "rustfs-manager-operator-docs.chart" . }}
{{ include "rustfs-manager-operator-docs.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-docs.selectorLabels" -}}
app.kubernetes.io/name: {{ include "rustfs-manager-operator-docs.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "rustfs-manager-operator-docs.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "rustfs-manager-operator-docs.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Build an image out of object like that:
image:
registry: ghcr.io
repositoru: db-operator/db-operator
tag: latest
Might be used to make it easier to configure mirroring
*/}}
{{- define "rustfs-manager-operator-docs.imageBootsrap" -}}
{{- $image := "" }}
{{- if .image.registry }}
{{- $image = printf "%s/" .image.registry }}
{{- end }}
{{- $tag := printf "%s" .chart.AppVersion }}
{{- if .image.tag }}
{{- $tag = .image.tag }}
{{- end }}
{{- printf "%s%s:%s" $image .image.repository $tag }}
{{- end }}

View File

@@ -0,0 +1,78 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "rustfs-manager-operator-docs.fullname" . }}
labels:
{{- include "rustfs-manager-operator-docs.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "rustfs-manager-operator-docs.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "rustfs-manager-operator-docs.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "rustfs-manager-operator-docs.serviceAccountName" . }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
image: {{ include "rustfs-manager-operator-docs.imageBootsrap" (dict "image" $.Values.image "chart" $.Chart) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumes }}
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,43 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "rustfs-manager-operator-docs.fullname" . }}
labels:
{{- include "rustfs-manager-operator-docs.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- with .pathType }}
pathType: {{ . }}
{{- end }}
backend:
service:
name: {{ include "rustfs-manager-operator-docs.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "rustfs-manager-operator-docs.fullname" . }}
labels:
{{- include "rustfs-manager-operator-docs.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "rustfs-manager-operator-docs.selectorLabels" . | nindent 4 }}

View File

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

View File

@@ -0,0 +1,158 @@
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
replicaCount: 1
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
image:
registry: gitea.badhouseplants.net
repository: badhouseplants/rustfs-manager-operator-docs
# This sets the pull policy for images.
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
imagePullSecrets: []
# This is to override the chart name.
nameOverride: ""
fullnameOverride: ""
# 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: ""
# 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: 8080
# 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: /
port: http
readinessProbe:
httpGet:
path: /
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,2 @@
charts
Chart.lock

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,16 @@
apiVersion: v2
name: rustfs-manager-operator
description: A Helm chart for the RustFS Manager Operator
type: application
version: 0.1.0
appVersion: "0.1.0"
dependencies:
- name: rustfs-manager-operator-docs
alias: docs
condition: docs.enabled
repository: file://../rustfs-manager-operator-docs
version: "*"
maintainers:
- name: Nikolai Rodionov
email: allanger@badhouseplants.net
url: https://allanger.xyz

View File

@@ -0,0 +1,117 @@
---
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
configMapName:
nullable: true
type: string
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,125 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: rustfsbucketusers.rustfs.badhouseplants.net
spec:
group: rustfs.badhouseplants.net
names:
categories: []
kind: RustFSBucketUser
plural: rustfsbucketusers
shortNames:
- bucketuser
singular: rustfsbucketuser
scope: Namespaced
versions:
- additionalPrinterColumns:
- description: The name of the user
jsonPath: .status.username
name: User Name
type: string
- description: The name of the secret
jsonPath: .status.secretName
name: Secret
type: string
- description: The name of the configmap
jsonPath: .status.configMapName
name: ConfigMap
type: string
- description: Which access is set to the user
jsonPath: .spec.access
name: Access
type: string
- description: Is the S3Instance ready
jsonPath: .status.ready
name: Status
type: boolean
name: v1beta1
schema:
openAPIV3Schema:
description: Manage users 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:
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: RustFSBucketUser
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,6 @@
-----------------------------------------------------------------------
Release information
Chart name: {{ .Chart.Name }}.
Version of the chart: {{ .Chart.Version }}
Release name {{ .Release.Name }}.
-----------------------------------------------------------------------

View File

@@ -0,0 +1,82 @@
{{/*
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 }}
{{/*
Build an image out of object like that:
image:
registry: ghcr.io
repositoru: db-operator/db-operator
tag: latest
Might be used to make it easier to configure mirroring
*/}}
{{- define "rustfs-manager-operator.imageBootsrap" -}}
{{- $image := "" }}
{{- if .image.registry }}
{{- $image = printf "%s/" .image.registry }}
{{- end }}
{{- $tag := printf "%s" .chart.AppVersion }}
{{- if .image.tag }}
{{- $tag = .image.tag }}
{{- end }}
{{- printf "%s%s:%s" $image .image.repository $tag }}
{{- 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
- rustfsbucketusers
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- rustfs.badhouseplants.net
resources:
- rustfsbuckets/finalizers
- rustfsintsances/finalizers
- rustfsbucketusers/finalizers
verbs:
- update
- apiGroups:
- rustfs.badhouseplants.net
resources:
- rustfsbuckets/status
- rustfsinstances/status
- rustfsbucketusers/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,27 @@
{{- 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,88 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "rustfs-manager-operator.fullname" . }}
labels:
{{- include "rustfs-manager-operator.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
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: {{ include "rustfs-manager-operator.imageBootsrap" (dict "image" $.Values.image "chart" $.Chart) }}
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: /tmp
name: tmp
- mountPath: /.config/rc
name: tmp
- mountPath: /srv/config/
name: config
readOnly: true
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
- 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,92 @@
crds:
install: true
keep: true
image:
registry: gitea.badhouseplants.net
repository: badhouseplants/rustfs-manager-operator
# This sets the pull policy for images.
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
config:
setOwnerReference: false
docs:
enabled: false
ingress:
enabled: true
className: "traefik"
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
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: ""
# 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
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
nodeSelector: {}
tolerations: []
affinity: {}

2
documentation/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.cache
site

View File

@@ -0,0 +1,11 @@
FROM python:3.14 AS builder
RUN apt-get update -y
RUN apt-get install -y pipx
RUN pip install poetry
WORKDIR /src
COPY . .
RUN python -m poetry install --no-root
RUN python -m poetry run zensical build
FROM nginxinc/nginx-unprivileged:alpine3.23-perl
COPY --from=builder /src/site /usr/share/nginx/html

126
documentation/docs/index.md Normal file
View File

@@ -0,0 +1,126 @@
# RustFS Manager Operator
## Getting started
This operator is supposed to connect to an existing **RustFS** instance and manage bucket and users on it.
### How to install
The operator is distributed as a helm chart, that can be installed either from a self-hosted **gitea** or **github**.
#### Gitea
```shell
helm install rustfs-manager-operator oci://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator/rustfs-manager-operator --version 0.1.0
```
#### Github
```shell
helm install rustfs-manager-operator oci://ghcr.io/badhouseplants.net/rustfs-manager-operator/rustfs-manager-operator --version 0.1.0
```
#### Docs
Documentation is added as a dependency to the main helm chart, you can install it by setting `.docs.enabled` value to `true`. Then you will have the up-to-date documentation deployed to your cluster as well.
### Connect the operator to RustFS
Once the operator is running, you will need to create a `RustFSIntsance` CR to connect the operator to your **RustFS**. You can either create it manually, or use a helm chart as well.
#### Helm chart
1. Create values.yaml
```yaml
# values.yaml
endpoint: https://your.rust.fs
username: admin
password: qwertyu9
```
```shell
helm install <your instance name> oci://gitea.badhouseplants.net/badhouseplants/rustfs-manager-operator/rustfs-instance --version 0.1.0 -f values.yaml
```
And wait until it becomes ready.
```shell
kubectl get rustfs <your instance name>
NAME ENDPOINT REGION TOTAL BUCKETS STATUS
<your instance name> <your instance url> us-east-1 2 true
```
### Start using
Now you can start creating buckets and users.
#### Create a bucket
```yaml
apiVersion: rustfs.badhouseplants.net/v1beta1
kind: RustFSBucket
metadata:
name: <bucket name>
namespace: <application namespace>
spec:
# When cleanup is set to true, bucket will be removed from the instance
cleanup: false
# On which instance this bucket should be created
instance: rustfs-instance
# If true, bucket will be created with object locking
objectLock: false
# If true, bucket will be created with versioning
versioning: false
```
```shell
kubectl get bucket <bucket name>
NAME BUCKET NAME ENDPOINT REGION STATUS
<bucket-name> <namespace>-<bucket-name> <endpoint> us-east-1 true
```
When bucket is created, there will be a secret created in the same namespace: `<bucket name>-bucket-info`
```shell
kubectl get configmap <bucket name>-bucket-info -o yaml
apiVersion: v1
kind: ConfigMap
data:
AWS_BUCKET_NAME: <bucket name>
AWS_ENDPOINT_URL: <endpoint>
AWS_REGION: <region>
```
#### Creating a user
When the bucket is ready, you can create a user that will have access to this bucket:
```yaml
apiVersion: rustfs.badhouseplants.net/v1beta1
kind: RustFSBucketUser
metadata:
name: <username>
namespace: <application namespace>
spec:
bucket: <a name of the bucket CR>
# User will be removed from the RustFS instance if set to true
cleanup: false
access: readWrite # or readOnly
```
```shell
kubectl get bucketuser <username>
NAME USER NAME SECRET CONFIGMAP ACCESS STATUS
<username> <namespace>-<username> <username>-bucket-creds <bucket name> -bucket-info readWrite true
```
Operator will also add a Secret to the same namespace: `<username>-bucket-creds`, that will contain the following keys:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
You can use them to connect your application to the bucket.

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"

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 @@
target

3720
operator/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
operator/Cargo.toml Normal file
View File

@@ -0,0 +1,41 @@
[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_bucket;
pub mod v1beta1_rustfs_bucket_user;
pub mod v1beta1_rustfs_instance;

View File

@@ -0,0 +1,59 @@
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>,
#[serde(default)]
pub config_map_name: 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(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 = "RustFSBucketUser",
group = "rustfs.badhouseplants.net",
version = "v1beta1",
doc = "Manage users on a RustFs instance",
shortname = "bucketuser",
namespaced,
status = "RustFSBucketUserStatus",
printcolumn = r#"{"name":"User Name","type":"string","description":"The name of the user","jsonPath":".status.username"}"#,
printcolumn = r#"{"name":"Secret","type":"string","description":"The name of the secret","jsonPath":".status.secretName"}"#,
printcolumn = r#"{"name":"ConfigMap","type":"string","description":"The name of the configmap","jsonPath":".status.configMapName"}"#,
printcolumn = r#"{"name":"Access","type":"string","description":"Which access is set to the user","jsonPath":".spec.access"}"#,
printcolumn = r#"{"name":"Status","type":"boolean","description":"Is the S3Instance ready","jsonPath":".status.ready"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct RustFSUserSpec {
pub bucket: String,
#[serde(default)]
pub cleanup: bool,
pub access: Access,
}
#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct RustFSBucketUserStatus {
#[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>,
}

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,
}

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

@@ -0,0 +1,70 @@
use anyhow::{Result, anyhow};
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_bucket_user;
pub(crate) mod rustfs_instance;

View File

@@ -0,0 +1,432 @@
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_bucket::{RustFSBucket, RustFSBucketStatus};
use api::api::v1beta1_rustfs_instance::RustFSInstance;
use futures::StreamExt;
use k8s_openapi::api::core::v1::ConfigMap;
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";
const AWS_BUCKET_NAME: &str = "AWS_BUCKET_NAME";
#[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.clone()),
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)));
}
let bucket_name = format!(
"{}-{}",
bucket_cr.namespace().unwrap(),
bucket_cr.name_any()
);
info!("Updating the ConfigMap");
if let Err(err) = ensure_data_configmap(
cm_api.clone(),
configmap.clone(),
rustfs_cr.clone(),
&bucket_name,
)
.await
{
return Err(RustFSBucketError::KubeError(err));
};
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());
status.config_map_name = Some(configmap_name);
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>,
mut cm: ConfigMap,
rustfs_cr: RustFSInstance,
bucket_name: &String,
) -> 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);
data.insert(AWS_BUCKET_NAME.to_string(), bucket_name.clone());
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 occurred 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,665 @@
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 anyhow::{Result, anyhow};
use api::api::v1beta1_rustfs_bucket::RustFSBucket;
use api::api::v1beta1_rustfs_bucket_user::{RustFSBucketUser, RustFSBucketUserStatus};
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 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<RustFSBucketUser>,
ctx: Arc<Context>,
) -> RustFSBucketUserResult<Action> {
info!("Staring reconciling");
let user_api: Api<RustFSBucketUser> =
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 RustFSBucketUser 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(RustFSBucketUserError::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.clone()),
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(RustFSBucketUserError::KubeError(err)),
}
}
Err(err) => {
error!("{}", err);
return Err(RustFSBucketUserError::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(RustFSBucketUserError::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(RustFSBucketUserError::KubeError(err)),
};
};
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(RustFSBucketUserError::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(RustFSBucketUserError::KubeError(err));
}
};
let bucket_status = match bucket_cr.clone().status {
Some(status) => {
if !status.ready {
return Err(RustFSBucketUserError::BucketNotReadyError);
};
status
}
None => {
return Err(RustFSBucketUserError::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(RustFSBucketUserError::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(RustFSBucketUserError::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(RustFSBucketUserError::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(RustFSBucketUserError::IllegalRustFSBucketUser);
}
};
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(RustFSBucketUserError::KubeError(err));
}
}
}
Err(err) => {
error!("{}", err);
return Err(RustFSBucketUserError::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(RustFSBucketUserError::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(RustFSBucketUserError::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(RustFSBucketUserError::IllegalRustFSBucketUser);
}
let userinfo = match user_info(rustfs_cr.name_any(), username.clone()) {
Ok(info) => info,
Err(err) => return Err(RustFSBucketUserError::RcCliError(err)),
};
let policy_template = match user_cr.spec.access {
api::api::v1beta1_rustfs_bucket_user::Access::ReadOnly => POLICY_READ_ONLY,
api::api::v1beta1_rustfs_bucket_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(RustFSBucketUserError::IllegalRustFSBucketUser);
}
};
let data = RcPolicyData {
bucket: bucket_name,
};
let policy = match render_policy(policy_template.to_string(), data) {
Ok(policy) => policy,
Err(err) => return Err(RustFSBucketUserError::RcCliError(anyhow!(err))),
};
if let Err(err) = create_policy(rustfs_cr.name_any(), username.clone(), policy.to_string()) {
return Err(RustFSBucketUserError::RcCliError(err));
};
if let Err(err) = assign_policy(rustfs_cr.name_any(), username.clone()) {
return Err(RustFSBucketUserError::RcCliError(err));
};
// create a user
status.policy = Some(policy);
status.username = Some(userinfo.access_key);
status.status = Some(userinfo.status);
status.ready = true;
status.secret_name = Some(secret_name);
status.config_map_name = bucket_status.config_map_name;
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(RustFSBucketUserError::KubeError(err));
}
};
}
// Bootstrap the object by adding a default status to it
async fn init_object(
mut obj: RustFSBucketUser,
api: Api<RustFSBucketUser>,
) -> Result<Action, RustFSBucketUserError> {
let conditions = init_conditions(vec![
TYPE_SECRET_READY.to_string(),
TYPE_USER_READY.to_string(),
]);
obj.status = Some(RustFSBucketUserStatus {
conditions,
..RustFSBucketUserStatus::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(RustFSBucketUserError::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: RustFSBucketUser,
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: RustFSBucketUser::api_version(&()).into(),
kind: RustFSBucketUser::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<RustFSBucketUser>,
err: &RustFSBucketUserError,
_: Arc<Context>,
) -> Action {
error!(trace.error = %err, "Error occurred 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::<RustFSBucketUser>::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 RustFSBucketUserError {
#[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<RustFSBucketUserError>>),
#[error("IllegalRustFSBucketUser")]
IllegalRustFSBucketUser,
#[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 RustFSBucketUserResult<T, E = RustFSBucketUserError> = std::result::Result<T, E>;

View File

@@ -0,0 +1,411 @@
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 occurred 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>;

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

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

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

@@ -0,0 +1,108 @@
mod cli;
mod conditions;
mod config;
mod controllers;
mod rc;
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_bucket_user};
use api::api::v1beta1_rustfs_bucket::RustFSBucket;
use api::api::v1beta1_rustfs_bucket_user::RustFSBucketUser;
use api::api::v1beta1_rustfs_instance::RustFSInstance;
/// 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(&RustFSBucketUser::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_bucket_user_ctrl = rustfs_bucket_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_bucket_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::{NamedTempFile, tempfile};
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);
}
}