From 5b53013578a83bd210de41e26448651734378ce9 Mon Sep 17 00:00:00 2001 From: jhw <835269233@qq.com> Date: Sat, 28 Mar 2026 22:55:40 +0800 Subject: [PATCH] feat: pool CEL validation, tenant DNS cap, deploy tooling, skill - Align Pool CRD CEL with console API (min volumes + 3-server rule); validate_pool_total_volumes + tests; regen CRDs - Cap Tenant metadata.name at 55 chars for derived Service names (-console) - Deploy scripts: docker_build_cached, RUSTFS_DOCKER_NO_CACHE; 4-node help/sudo cleanup - Dockerfiles: cargo-chef pin / frontend image tweaks - Add .cursor/skills/rustfs-operator-contribute for commit/PR workflow; adjust .gitignore for skills - CHANGELOG, README, examples, scripts docs Made-with: Cursor --- .../rustfs-operator-contribute/SKILL.md | 70 ++++++ .gitignore | 5 +- CHANGELOG.md | 14 ++ Dockerfile | 34 ++- README.md | 75 +++++- console-web/Dockerfile | 5 +- deploy/rustfs-operator/crds/tenant-crd.yaml | 80 ++++--- deploy/rustfs-operator/crds/tenant.yaml | 225 ++++++++++++++++-- examples/README.md | 7 +- scripts/README.md | 23 ++ scripts/cleanup/cleanup-rustfs-4node.sh | 14 +- scripts/deploy/deploy-rustfs-4node.sh | 41 ++-- scripts/deploy/deploy-rustfs.sh | 16 +- src/console/handlers/pools.rs | 31 +-- src/console/handlers/tenants.rs | 7 + src/reconcile.rs | 18 +- src/types/error.rs | 3 + src/types/v1alpha1.rs | 2 +- src/types/v1alpha1/pool.rs | 71 +++++- src/types/v1alpha1/tenant.rs | 135 +++++++++++ 20 files changed, 752 insertions(+), 124 deletions(-) create mode 100644 .cursor/skills/rustfs-operator-contribute/SKILL.md diff --git a/.cursor/skills/rustfs-operator-contribute/SKILL.md b/.cursor/skills/rustfs-operator-contribute/SKILL.md new file mode 100644 index 0000000..0f03a4f --- /dev/null +++ b/.cursor/skills/rustfs-operator-contribute/SKILL.md @@ -0,0 +1,70 @@ +--- +name: rustfs-operator-contribute +description: Commits, pushes, and opens pull requests for the RustFS Operator repo per CONTRIBUTING.md and AGENTS.md. Use when the user asks to commit, push to remote my, submit a PR upstream, or follow project contribution workflow. +--- + +# RustFS Operator — commit, push, PR + +## Preconditions + +- Run from repository root: `/home/jhw/my/operator` (or clone path). +- Source of truth: [`CONTRIBUTING.md`](../../../CONTRIBUTING.md), [`Makefile`](../../../Makefile), [`.github/pull_request_template.md`](../../../.github/pull_request_template.md). + +## Before commit + +1. Run **`make pre-commit`** (fmt-check → clippy → test → console-lint → console-fmt-check). Fix failures before committing. +2. User-visible changes: update **[`CHANGELOG.md`](../../../CHANGELOG.md)** under `[Unreleased]` (Keep a Changelog). +3. **Commit message**: [Conventional Commits](https://www.conventionalcommits.org/), **English**, subject **≤ 72 characters** (e.g. `fix(pool): align CEL with console validation`). + +## Commit + +```bash +git add -A +git status +git commit -m "type(scope): short description" +``` + +## Push to fork (`my`) + +Remote is typically `my` → `git@github.com:GatewayJ/operator.git` (verify with `git remote -v`). + +```bash +git push my main +``` + +If `main` is non-fast-forward on `my`, integrate or use `git push my main --force-with-lease` only when intentionally replacing fork history (dangerous). + +## Open PR upstream (`rustfs/operator`) + +- **Target**: `rustfs/operator` branch **`main`**. +- **Head**: fork branch (e.g. `GatewayJ:main`). +- **PR title and body**: **English**. +- **Body**: Must follow **every section** in [`.github/pull_request_template.md`](../../../.github/pull_request_template.md); use **`N/A`** where not applicable; keep all headings. + +**Do not** pass multiline `--body` to `gh` inline. Write a file and use `--body-file`: + +```bash +cat > /tmp/pr_body.md <<'EOF' +## Type of Change +- [x] Bug Fix +... +EOF + +gh pr create --repo rustfs/operator --head GatewayJ:main --base main \ + --title "fix: concise English title" \ + --body-file /tmp/pr_body.md +``` + +Adjust checkboxes and sections to match the change. Include **`make pre-commit`** under Verification. + +## Quick checklist + +- [ ] `make pre-commit` passed +- [ ] CHANGELOG updated if user-visible +- [ ] Commit message conventional, English +- [ ] PR template complete, English, `--body-file` used + +## References + +- [AGENTS.md](../../../AGENTS.md) — language, security, architecture notes +- [`.cursor/rules/pr.mdc`](../../../.cursor/rules/pr.mdc) — PR / path conventions (if present) diff --git a/.gitignore b/.gitignore index aa2fd74..c3f77e0 100755 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,10 @@ console-web/.next/ console-web/docs/ console-web/out/ console-web/node_modules/ -.cursor/ +# Cursor IDE: ignore contents except versioned Agent skills +.cursor/* +!.cursor/skills/ +!.cursor/skills/** # Docs / summaries (local or generated) CONSOLE-INTEGRATION-SUMMARY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f4ae4..59029f4 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation +- Expanded root [`README.md`](README.md) with overview, quick start, development commands, CI vs `make pre-commit`, and documentation index. - Aligned [`CLAUDE.md`](CLAUDE.md) and [`ROADMAP.md`](ROADMAP.md) with current code: Tenant status conditions and StatefulSet updates on the successful reconcile path are documented as implemented; remaining work (status on early errors, integration tests, rollout extras) is listed explicitly. - Clarified the documentation map: [`CONTRIBUTING.md`](CONTRIBUTING.md) (quality gates and CI alignment), [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) (environment setup), [`docs/DEVELOPMENT-NOTES.md`](docs/DEVELOPMENT-NOTES.md) (historical notes, not normative). - Updated [`examples/README.md`](examples/README.md): Tenant Services document S3 **9000** and RustFS Console **9001**; distinguished the Operator HTTP Console (default **9090**, `cargo run -- console`) from the Tenant `{tenant}-console` Service. @@ -19,8 +20,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`console-web` / `make pre-commit`**: `npm run lint` now runs `eslint .` (bare `eslint` only printed CLI help). Added `format` / `format:check` scripts; [`Makefile`](Makefile) `console-fmt` and `console-fmt-check` call them so Prettier resolves from `node_modules` after `npm install` in `console-web/`. +- **Tenant `Pool` CRD validation (CEL)**: Match the operator console API — require `servers × volumesPerServer >= 4` for every pool, and `>= 6` total volumes when `servers == 3` (fixes the previous 3-server rule using `< 4` in CEL). Regenerated [`deploy/rustfs-operator/crds/tenant-crd.yaml`](deploy/rustfs-operator/crds/tenant-crd.yaml) and [`tenant.yaml`](deploy/rustfs-operator/crds/tenant.yaml). Added [`validate_pool_total_volumes`](src/types/v1alpha1/pool.rs) as the shared Rust implementation used by [`src/console/handlers/pools.rs`](src/console/handlers/pools.rs). + +- **Tenant name length**: [`validate_dns1035_label`](src/types/v1alpha1/tenant.rs) now caps `metadata.name` at **55** characters so derived names like `{name}-console` remain valid Kubernetes DNS labels (≤ 63). + +### Changed + +- **Deploy scripts** ([`scripts/deploy/deploy-rustfs.sh`](scripts/deploy/deploy-rustfs.sh), [`deploy-rustfs-4node.sh`](scripts/deploy/deploy-rustfs-4node.sh)): Docker builds use **layer cache by default** (`docker_build_cached`); set `RUSTFS_DOCKER_NO_CACHE=true` for a full rebuild. Documented in [`scripts/README.md`](scripts/README.md). +- **4-node deploy**: Help text moved to an early heredoc (avoids trailing `case`/parse issues); see script header. +- **4-node cleanup** ([`cleanup-rustfs-4node.sh`](scripts/cleanup/cleanup-rustfs-4node.sh)): Host storage dirs under `/tmp/rustfs-storage-*` may require `sudo rm -rf` after Kind (root-owned bind mounts). +- **Dockerfile** (operator and [`console-web/Dockerfile`](console-web/Dockerfile)): Build caching and reproducibility tweaks (cargo-chef pin, pnpm in frontend image as applicable). + ### Added +- Cursor Agent skill [`.cursor/skills/rustfs-operator-contribute/SKILL.md`](.cursor/skills/rustfs-operator-contribute/SKILL.md) for `make pre-commit`, commit, push to fork `my`, and opening PRs to `rustfs/operator` with the project template. + #### **StatefulSet Reconciliation Improvements** (2025-12-03, Issue #43) Implemented intelligent StatefulSet update detection and validation to improve reconciliation efficiency and safety: diff --git a/Dockerfile b/Dockerfile index 1504959..eb4b69f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -4,25 +4,47 @@ ARG BASE_IMAGE=debian:bookworm-slim # Use rust:bookworm so the binary is linked against glibc 2.36, matching final image. ARG RUST_BUILD_IMAGE=rust:bookworm -# When Docker build cannot reach crates.io (DNS/network), use host network: +# cargo-chef version (pin for reproducible builds; override if needed) +ARG CARGO_CHEF_VERSION=0.1.77 + +# When Docker build cannot reach crates.io (DNS/network), try: # docker build --network=host -t rustfs/operator:dev . +# For China mirrors, mount or COPY a .cargo/config.toml (see docs) before cargo install. + +# Shared Cargo settings for slow / flaky networks (applies to all Rust stages) +FROM ${RUST_BUILD_IMAGE} AS rust-base +RUN mkdir -p /usr/local/cargo && \ + printf '%s\n' \ + '[http]' \ + 'timeout = 300' \ + 'multiplexing = false' \ + '' \ + '[net]' \ + 'retry = 10' \ + > /usr/local/cargo/config.toml +ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse + +# Install cargo-chef once; planner + cacher only COPY the binary (avoids two slow installs) +FROM rust-base AS cargo-chef-installer +ARG CARGO_CHEF_VERSION +RUN cargo install cargo-chef --version "${CARGO_CHEF_VERSION}" # Stage 1: Generate recipe for dependency caching -FROM ${RUST_BUILD_IMAGE} AS planner +FROM rust-base AS planner +COPY --from=cargo-chef-installer /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef WORKDIR /app -RUN cargo install cargo-chef COPY . . RUN cargo chef prepare --recipe-path recipe.json # Stage 2: Build dependencies only (cached unless Cargo.lock changes) -FROM ${RUST_BUILD_IMAGE} AS cacher +FROM rust-base AS cacher +COPY --from=cargo-chef-installer /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef WORKDIR /app -RUN cargo install cargo-chef COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json # Stage 3: Build the binary -FROM ${RUST_BUILD_IMAGE} AS builder +FROM rust-base AS builder WORKDIR /app COPY . . COPY --from=cacher /app/target target diff --git a/README.md b/README.md index 819699b..40e212d 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,64 @@ # RustFS Kubernetes Operator -RustFS Kubernetes operator (under development; not production-ready). +A Kubernetes operator for [RustFS](https://rustfs.com/) object storage, written in Rust with [kube-rs](https://github.com/kube-rs/kube). It reconciles a **`Tenant` custom resource** (`rustfs.com/v1alpha1`) and provisions ConfigMaps, Secrets, RBAC, Services, and StatefulSets so RustFS runs as an erasure-coded cluster inside your cluster. + +**Status:** v0.1.0 pre-release — under active development, **not production-ready**. + +## Features + +- **Tenant CRD** — Declare pools, persistence, scheduling, credentials (Secret or env), TLS, and more; see [`examples/`](examples/). +- **Controller** — Reconciliation loop with status conditions (`Ready` / `Progressing` / `Degraded`), events, and safe StatefulSet update checks. +- **Operator HTTP console** — Optional management API (`cargo run -- console`, default port **9090**) used by [`console-web/`](console-web/) (Next.js UI). +- **Tooling** — CRD YAML generation, Docker multi-stage image, Kind-focused scripts under [`scripts/`](scripts/). + +RustFS **S3 API** and **RustFS Console UI** inside a Tenant are exposed on **9000** and **9001** respectively; the operator’s own HTTP API is separate (typically **9090**). See [`CLAUDE.md`](CLAUDE.md) for ports and env vars. + +## Requirements + +- **Rust** — Toolchain from [`rust-toolchain.toml`](rust-toolchain.toml) (stable; edition 2024). +- **Kubernetes** — Target API **v1.30** (see `Cargo.toml` / `k8s-openapi` features); a reachable cluster for `server` mode. +- **console-web** (optional) — **Node.js ≥ 20** and `npm install` in `console-web/` if you run frontend lint/format or UI dev. + +## Quick start + +```bash +# Clone and build +git clone https://github.com/rustfs/operator.git +cd operator +cargo build --release + +# Emit Tenant CRD YAML (stdout or file) +cargo run -- crd +cargo run -- crd -f tenant-crd.yaml + +# Run the controller (needs kubeconfig / in-cluster config) +cargo run -- server + +# Run the operator HTTP console API (default :9090) +cargo run -- console +``` + +**Docker** + +```bash +docker build -t rustfs/operator:dev . +``` + +**End-to-end on Kind** (single-node or multi-node) — see [`scripts/README.md`](scripts/README.md). + +## Development + +From the repo root: + +| Command | Purpose | +|--------|---------| +| `make pre-commit` | Full local gate: Rust `fmt` / `clippy` / `test` + `console-web` ESLint and Prettier (run after `npm install` in `console-web/`). | +| `make fmt` / `make clippy` / `make test` | Individual Rust checks. | +| `make console-lint` / `make console-fmt-check` | Frontend only. | + +CI (`.github/workflows/ci.yml`) runs Rust tests (including `nextest`), `cargo fmt --check`, and `clippy`; it does **not** run `console-web` checks — use **`make pre-commit`** before opening a PR so frontend changes are validated. + +Contribution workflow, commit style, and PR expectations: [`CONTRIBUTING.md`](CONTRIBUTING.md). ## Repository layout @@ -13,4 +71,19 @@ RustFS Kubernetes operator (under development; not production-ready). - `deploy/k8s-dev/` — Development Kubernetes YAML - `deploy/kind/` — Kind cluster configs (e.g. 4-node) - **examples/** — Sample Tenant CRs +- **console-web/** — Operator management UI (Next.js) - **docs/** — Architecture and development documentation + +## Documentation + +| Doc | Content | +|-----|---------| +| [CLAUDE.md](CLAUDE.md) | Architecture, reconcile loop, CRD fields, RustFS ports and env (maintainer / AI context). | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Quality gates, `make pre-commit`, PR rules. | +| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local environment (kind, IDE, workflows). | +| [docs/architecture-decisions.md](docs/architecture-decisions.md) | ADRs. | +| [CHANGELOG.md](CHANGELOG.md) | Release notes. | + +## License + +Licensed under the **Apache License 2.0** — see [LICENSE](LICENSE). diff --git a/console-web/Dockerfile b/console-web/Dockerfile index 76582ed..95eb728 100755 --- a/console-web/Dockerfile +++ b/console-web/Dockerfile @@ -3,7 +3,10 @@ FROM node:22-alpine AS builder WORKDIR /app -RUN corepack enable && corepack prepare pnpm@latest --activate +# Pin pnpm to package.json "packageManager" (avoid corepack fetching pnpm@latest from npm; +# that fetch can fail behind proxies / flaky TLS during docker build). +ARG PNPM_VERSION=10.28.1 +RUN npm install -g pnpm@${PNPM_VERSION} COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ RUN pnpm install --frozen-lockfile diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index d31c7db..b6db5da 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -96,33 +96,6 @@ spec: format: int32 nullable: true type: integer - securityContext: - description: |- - Override Pod SecurityContext when encryption is enabled. - If not set, the default RustFS Pod SecurityContext is used - (runAsUser/runAsGroup/fsGroup = 10001). - nullable: true - properties: - fsGroup: - description: GID applied to all volumes mounted in the Pod. - format: int64 - nullable: true - type: integer - runAsGroup: - description: GID to run the container process as. - format: int64 - nullable: true - type: integer - runAsNonRoot: - description: 'Enforce non-root execution (default: true).' - nullable: true - type: boolean - runAsUser: - description: UID to run the container process as. - format: int64 - nullable: true - type: integer - type: object vault: description: 'Vault-specific settings (required when `backend: vault`).' nullable: true @@ -145,12 +118,21 @@ spec: type: integer type: object authType: - default: token - description: |- - Authentication method: `token` (default, implemented) or `approle` - (type defined in rustfs-kms but backend not yet functional). + description: Authentication method. Defaults to `token` when not set. + enum: + - token + - approle + - null nullable: true type: string + customCertificates: + description: |- + Enable custom TLS certificates for the Vault connection. + When `true`, the operator mounts TLS certificate files from the KMS Secret + and configures the corresponding environment variables. + The Secret must contain: `vault-ca-cert`, `vault-client-cert`, `vault-client-key`. + nullable: true + type: boolean endpoint: description: Vault server endpoint (e.g. `https://vault.example.com:8200`). type: string @@ -167,7 +149,7 @@ spec: nullable: true type: string tlsSkipVerify: - description: 'Enable TLS verification for Vault connection (default: true).' + description: Skip TLS certificate verification for Vault connection. nullable: true type: boolean required: @@ -1199,7 +1181,7 @@ spec: format: int32 type: integer x-kubernetes-validations: - - message: servers must be gather than 0 + - message: servers must be greater than 0 rule: self > 0 tolerations: description: Tolerations allow pods to schedule onto nodes with matching taints. @@ -1314,12 +1296,12 @@ spec: - servers type: object x-kubernetes-validations: - - messageExpression: '"pool " + self.name + " with 2 servers must have at least 4 volumes in total"' + - messageExpression: '"pool " + self.name + " must have at least 4 total volumes (servers × volumesPerServer)"' reason: FieldValueInvalid - rule: '!(self.servers * self.persistence.volumesPerServer < 4 && self.servers == 2)' + rule: self.servers * self.persistence.volumesPerServer >= 4 - messageExpression: '"pool " + self.name + " with 3 servers must have at least 6 volumes in total"' reason: FieldValueInvalid - rule: '!(self.servers * self.persistence.volumesPerServer < 4 && self.servers == 3)' + rule: self.servers != 3 || self.servers * self.persistence.volumesPerServer >= 6 type: array x-kubernetes-validations: - message: pools must be configured @@ -1330,6 +1312,32 @@ spec: scheduler: nullable: true type: string + securityContext: + description: |- + Override the default Pod SecurityContext (runAsUser/runAsGroup/fsGroup = 10001). + Applies to all RustFS pods in this Tenant. + nullable: true + properties: + fsGroup: + description: GID applied to all volumes mounted in the Pod. + format: int64 + nullable: true + type: integer + runAsGroup: + description: GID to run the container process as. + format: int64 + nullable: true + type: integer + runAsNonRoot: + description: 'Enforce non-root execution (default: true).' + nullable: true + type: boolean + runAsUser: + description: UID to run the container process as. + format: int64 + nullable: true + type: integer + type: object serviceAccountName: nullable: true type: string diff --git a/deploy/rustfs-operator/crds/tenant.yaml b/deploy/rustfs-operator/crds/tenant.yaml index 275dd18..b6db5da 100755 --- a/deploy/rustfs-operator/crds/tenant.yaml +++ b/deploy/rustfs-operator/crds/tenant.yaml @@ -45,6 +45,117 @@ spec: required: - name type: object + encryption: + description: |- + Encryption / KMS configuration for server-side encryption. + When enabled, the operator injects KMS environment variables and mounts + secrets into RustFS pods so the in-process `rustfs-kms` library is configured. + nullable: true + properties: + backend: + default: local + description: 'KMS backend type: `local` or `vault`.' + enum: + - local + - vault + type: string + enabled: + default: false + description: Enable server-side encryption. When `false`, all other fields are ignored. + type: boolean + kmsSecret: + description: |- + Reference to a Secret containing sensitive KMS credentials + (Vault token or AppRole credentials, TLS certificates). + nullable: true + properties: + name: + description: 'Name of the referent. This field is effectively required, but due to backwards compatibility is allowed to be empty. Instances of this type with an empty value here are almost certainly wrong. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - name + type: object + local: + description: 'Local file-based settings (optional when `backend: local`).' + nullable: true + properties: + keyDirectory: + description: 'Directory for key files inside the container (default: `/data/kms-keys`).' + nullable: true + type: string + masterKeyId: + description: 'Master key identifier (default: `default-master-key`).' + nullable: true + type: string + type: object + pingSeconds: + description: |- + Interval in seconds for KMS health-check pings (default: disabled). + When set, the operator stores the value; the in-process KMS library + picks it up from `RUSTFS_KMS_PING_SECONDS`. + format: int32 + nullable: true + type: integer + vault: + description: 'Vault-specific settings (required when `backend: vault`).' + nullable: true + properties: + appRole: + description: |- + AppRole authentication settings. Only used when `authType: approle`. + The actual `role_id` and `secret_id` values live in the KMS Secret + under keys `vault-approle-id` and `vault-approle-secret`. + nullable: true + properties: + engine: + description: Engine mount path for AppRole auth (e.g. `approle`). + nullable: true + type: string + retrySeconds: + description: 'Retry interval in seconds for AppRole login attempts (default: 10).' + format: int32 + nullable: true + type: integer + type: object + authType: + description: Authentication method. Defaults to `token` when not set. + enum: + - token + - approle + - null + nullable: true + type: string + customCertificates: + description: |- + Enable custom TLS certificates for the Vault connection. + When `true`, the operator mounts TLS certificate files from the KMS Secret + and configures the corresponding environment variables. + The Secret must contain: `vault-ca-cert`, `vault-client-cert`, `vault-client-key`. + nullable: true + type: boolean + endpoint: + description: Vault server endpoint (e.g. `https://vault.example.com:8200`). + type: string + engine: + description: 'Vault KV2 engine mount path (default: `kv`).' + nullable: true + type: string + namespace: + description: Vault namespace (Enterprise feature). + nullable: true + type: string + prefix: + description: Key prefix inside the engine. + nullable: true + type: string + tlsSkipVerify: + description: Skip TLS certificate verification for Vault connection. + nullable: true + type: boolean + required: + - endpoint + type: object + type: object env: items: description: EnvVar represents an environment variable present in a Container. @@ -94,7 +205,7 @@ spec: type: string divisor: description: Specifies the output format of the exposed resources, defaults to "1" - type: string + x-kubernetes-int-or-string: true resource: description: 'Required: resource to select' type: string @@ -293,22 +404,48 @@ spec: type: object type: object type: object - mountPath: - default: /data - nullable: true - type: string - podManagementPolicy: + logging: description: |- - Pod management policy for StatefulSets - - OrderedReady: Respect the ordering guarantees demonstrated - - Parallel: launch or terminate all Pods in parallel, and not to wait for Pods to become Running - and Ready or completely terminated prior to launching or terminating another Pod + Logging configuration for RustFS - https://kubernetes.io/docs/tutorials/stateful-application/basic-stateful-set/#pod-management-policy - enum: - - OrderedReady - - Parallel - - null + Controls how RustFS outputs logs. Defaults to stdout (cloud-native best practice). + Can also configure emptyDir (temporary) or persistent (PVC-backed) logging. + nullable: true + properties: + mode: + default: stdout + description: |- + Logging mode: stdout, emptyDir, or persistent + + - stdout: Output logs to stdout/stderr (default, recommended for cloud-native) + - emptyDir: Write logs to an emptyDir volume (temporary, lost on Pod restart) + - persistent: Write logs to a PersistentVolumeClaim (persisted across restarts) + enum: + - stdout + - emptydir + - persistent + type: string + mountPath: + description: |- + Custom mount path for log directory + Defaults to /logs if not specified + nullable: true + type: string + storageClass: + description: |- + Storage class for persistent logs (only used when mode=persistent) + If not specified, uses the cluster's default StorageClass + nullable: true + type: string + storageSize: + description: |- + Storage size for persistent logs (only used when mode=persistent) + Defaults to 5Gi if not specified + nullable: true + type: string + type: object + mountPath: + default: /data nullable: true type: string podDeletionPolicyWhenNodeIsDown: @@ -316,7 +453,7 @@ spec: Controls how the operator handles Pods when the node hosting them is down (NotReady/Unknown). Typical use-case: a StatefulSet Pod gets stuck in Terminating when the node goes down. - Setting this to ForceDelete allows the operator to force delete the Pod object so the + Setting this to `ForceDelete` allows the operator to force delete the Pod object so the StatefulSet controller can recreate it elsewhere. Values: DoNothing | Delete | ForceDelete @@ -330,6 +467,20 @@ spec: - null nullable: true type: string + podManagementPolicy: + description: |- + Pod management policy for StatefulSets + - OrderedReady: Respect the ordering guarantees demonstrated + - Parallel: launch or terminate all Pods in parallel, and not to wait for Pods to become Running + and Ready or completely terminated prior to launching or terminating another Pod + + https://kubernetes.io/docs/tutorials/stateful-application/basic-stateful-set/#pod-management-policy + enum: + - OrderedReady + - Parallel + - null + nullable: true + type: string pools: items: description: |- @@ -926,13 +1077,13 @@ spec: limits: additionalProperties: description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." - type: string + x-kubernetes-int-or-string: true description: 'Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' type: object requests: additionalProperties: description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." - type: string + x-kubernetes-int-or-string: true description: 'Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' type: object type: object @@ -1016,13 +1167,13 @@ spec: limits: additionalProperties: description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." - type: string + x-kubernetes-int-or-string: true description: 'Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' type: object requests: additionalProperties: description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation." - type: string + x-kubernetes-int-or-string: true description: 'Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/' type: object type: object @@ -1030,7 +1181,7 @@ spec: format: int32 type: integer x-kubernetes-validations: - - message: servers must be gather than 0 + - message: servers must be greater than 0 rule: self > 0 tolerations: description: Tolerations allow pods to schedule onto nodes with matching taints. @@ -1145,12 +1296,12 @@ spec: - servers type: object x-kubernetes-validations: - - messageExpression: '"pool " + self.name + " with 2 servers must have at least 4 volumes in total"' + - messageExpression: '"pool " + self.name + " must have at least 4 total volumes (servers × volumesPerServer)"' reason: FieldValueInvalid - rule: '!(self.servers * self.persistence.volumesPerServer < 4 && self.servers == 2)' + rule: self.servers * self.persistence.volumesPerServer >= 4 - messageExpression: '"pool " + self.name + " with 3 servers must have at least 6 volumes in total"' reason: FieldValueInvalid - rule: '!(self.servers * self.persistence.volumesPerServer < 4 && self.servers == 3)' + rule: self.servers != 3 || self.servers * self.persistence.volumesPerServer >= 6 type: array x-kubernetes-validations: - message: pools must be configured @@ -1161,6 +1312,32 @@ spec: scheduler: nullable: true type: string + securityContext: + description: |- + Override the default Pod SecurityContext (runAsUser/runAsGroup/fsGroup = 10001). + Applies to all RustFS pods in this Tenant. + nullable: true + properties: + fsGroup: + description: GID applied to all volumes mounted in the Pod. + format: int64 + nullable: true + type: integer + runAsGroup: + description: GID to run the container process as. + format: int64 + nullable: true + type: integer + runAsNonRoot: + description: 'Enforce non-root execution (default: true).' + nullable: true + type: boolean + runAsUser: + description: UID to run the container process as. + format: int64 + nullable: true + type: integer + type: object serviceAccountName: nullable: true type: string diff --git a/examples/README.md b/examples/README.md index 3594e7e..273e0cc 100755 --- a/examples/README.md +++ b/examples/README.md @@ -360,21 +360,26 @@ spec: ### Pool Requirements -- **Minimum volumes**: `servers * volumesPerServer >= 4` (RustFS erasure coding requirement) +- **Minimum total volumes**: `servers * volumesPerServer >= 4` (RustFS erasure coding requirement) +- **Three-server pools**: `servers * volumesPerServer >= 6` (stricter than the general minimum) - **Server count**: Must be > 0 - **Volumes per server**: Must be > 0 - **Pool name**: Must not be empty +These rules are enforced by the Tenant CRD (CEL) and the operator console API. + ### Valid Examples ✅ `servers: 4, volumesPerServer: 1` → 4 total volumes ✅ `servers: 2, volumesPerServer: 2` → 4 total volumes +✅ `servers: 3, volumesPerServer: 2` → 6 total volumes (minimum for 3 servers) ✅ `servers: 4, volumesPerServer: 4` → 16 total volumes ### Invalid Examples ❌ `servers: 2, volumesPerServer: 1` → 2 total volumes (< 4) ❌ `servers: 1, volumesPerServer: 1` → 1 total volume (< 4) +❌ `servers: 3, volumesPerServer: 1` → 3 total volumes (< 6 for 3 servers) ❌ `servers: 0, volumesPerServer: 4` → Server count must be > 0 ## Common Configurations diff --git a/scripts/README.md b/scripts/README.md index 91e71d7..ee56a08 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -42,6 +42,29 @@ scripts/ - Kind 4-node config: `deploy/kind/kind-rustfs-cluster.yaml`. - Each script switches to the project root before running. +## Docker image builds (deploy scripts) + +Deploy scripts use **`docker build` with layer caching by default** so repeated runs reuse `cargo-chef` and base layers (much faster than before, when `--no-cache` was always set). + +- **`RUSTFS_DOCKER_NO_CACHE=true`** — force a full rebuild (equivalent to adding `--no-cache` to every `docker build` in the script). Use when you need a clean image, e.g. after changing base images or debugging cache issues. + +From the repo root: + +```bash +# Fast rebuilds (default): uses cache +./scripts/deploy/deploy-rustfs.sh +./scripts/deploy/deploy-rustfs-4node.sh + +# One-off clean rebuild +RUSTFS_DOCKER_NO_CACHE=true ./scripts/deploy/deploy-rustfs-4node.sh +``` + +**Further speed-ups (optional):** + +- **`docker buildx build --load`** — BuildKit builder; can pair with [cache backends](https://docs.docker.com/build/cache/backends/) (e.g. registry cache in CI). Local `docker build` already uses BuildKit when `DOCKER_BUILDKIT=1` (default on recent Docker Engine). +- **Avoid duplicate work** — `deploy-rustfs-4node.sh` may run `cargo build --release` on the host and the Dockerfile also compiles inside the image; the host step is not required for the image itself (only speeds local binaries). You can skip the host `cargo build` when you only need the container. +- **`.dockerignore`** — ensure large unrelated paths are ignored so `COPY . .` stays small (repo should already exclude `target/` where appropriate). + ## Related docs - Deployment: [deploy/README.md](../deploy/README.md) diff --git a/scripts/cleanup/cleanup-rustfs-4node.sh b/scripts/cleanup/cleanup-rustfs-4node.sh index fa0a058..f651804 100755 --- a/scripts/cleanup/cleanup-rustfs-4node.sh +++ b/scripts/cleanup/cleanup-rustfs-4node.sh @@ -192,11 +192,21 @@ delete_kind_cluster() { cleanup_storage_dirs() { log_info "Cleaning host storage directories..." + log_info "Kind bind mounts are often root-owned on the host; sudo may be required." for dir in /tmp/rustfs-storage-1 /tmp/rustfs-storage-2 /tmp/rustfs-storage-3; do - if [ -d "$dir" ]; then + if [ ! -d "$dir" ]; then + continue + fi + if [ "$(id -u)" -eq 0 ]; then rm -rf "$dir" log_info "Removed $dir" + elif command -v sudo >/dev/null 2>&1 && sudo rm -rf "$dir"; then + log_info "Removed $dir" + elif rm -rf "$dir" 2>/dev/null; then + log_info "Removed $dir" + else + log_warning "Could not remove $dir (permission denied). Run: sudo rm -rf $dir" fi done @@ -239,7 +249,7 @@ while [[ $# -gt 0 ]]; do echo "" echo "Options:" echo " -f, --force Skip confirmation" - echo " -s, --clean-storage Also remove host dirs /tmp/rustfs-storage-{1,2,3}" + echo " -s, --clean-storage Also remove host dirs /tmp/rustfs-storage-{1,2,3} (uses sudo if needed)" echo " -h, --help Show this help" exit 0 ;; diff --git a/scripts/deploy/deploy-rustfs-4node.sh b/scripts/deploy/deploy-rustfs-4node.sh index eb90e74..52dde32 100755 --- a/scripts/deploy/deploy-rustfs-4node.sh +++ b/scripts/deploy/deploy-rustfs-4node.sh @@ -39,6 +39,21 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$PROJECT_ROOT" +# Help first (here-doc avoids any `(` / `|` parsing quirks; do not use `sh` — see bash arrays below). +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + cat < bool { true } -/// Validate pool volume count (same rules as CRD: 2 servers => min 4 vols, 3 servers => min 6, else min 4). +/// Validate pool volume count (same rules as CRD CEL on [`Pool`] and [`validate_pool_total_volumes`]). fn validate_pool_volumes(servers: i32, volumes_per_server: i32) -> Result { - let total = servers * volumes_per_server; - if servers <= 0 || volumes_per_server <= 0 { - return Err(Error::BadRequest { - message: "servers and volumes_per_server must be positive".to_string(), - }); - } - if servers == 2 && total < 4 { - return Err(Error::BadRequest { - message: "Pool with 2 servers must have at least 4 volumes in total".to_string(), - }); - } - if servers == 3 && total < 6 { - return Err(Error::BadRequest { - message: "Pool with 3 servers must have at least 6 volumes in total".to_string(), - }); - } - if total < 4 { - return Err(Error::BadRequest { - message: format!( - "Pool must have at least 4 total volumes (got {} servers × {} volumes = {})", - servers, volumes_per_server, total - ), - }); - } - Ok(total) + validate_pool_total_volumes(servers, volumes_per_server) + .map_err(|message| Error::BadRequest { message }) } /// List pools for a tenant (from spec + StatefulSet status). diff --git a/src/console/handlers/tenants.rs b/src/console/handlers/tenants.rs index 846211a..b9d61ca 100755 --- a/src/console/handlers/tenants.rs +++ b/src/console/handlers/tenants.rs @@ -193,6 +193,13 @@ pub async fn create_tenant( Extension(claims): Extension, Json(req): Json, ) -> Result> { + // Validate tenant name is DNS-1035 compliant before hitting the K8s API + if let Err(e) = crate::types::v1alpha1::tenant::validate_dns1035_label(&req.name) { + return Err(Error::BadRequest { + message: format!("{}", e), + }); + } + let client = create_client(&claims).await?; // Ensure namespace exists diff --git a/src/reconcile.rs b/src/reconcile.rs index eb09bf5..2627bac 100755 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -47,6 +47,19 @@ pub async fn reconcile_rustfs(tenant: Arc, ctx: Arc) -> Result< return Ok(Action::await_change()); } + // Validate tenant name is DNS-1035 compliant (required for derived Service names) + if let Err(e) = latest_tenant.validate_name() { + let _ = ctx + .record( + &latest_tenant, + EventType::Warning, + "InvalidTenantName", + &format!("{}", e), + ) + .await; + return Err(e.into()); + } + // Validate credential Secret if configured // This only validates the Secret exists and has required keys. // Actual credential injection happens via secretKeyRef in the StatefulSet. @@ -609,9 +622,10 @@ pub fn error_policy(_object: Arc, error: &Error, _ctx: Arc) -> // Type errors - validation issues, use moderate requeue Error::Types { source } => match source { - // Immutable field modification errors - require user intervention + // Immutable field / invalid name errors - require user intervention // Use 60-second requeue to reduce event/log spam while user fixes the issue - types::error::Error::ImmutableFieldModified { .. } => { + types::error::Error::ImmutableFieldModified { .. } + | types::error::Error::InvalidTenantName { .. } => { Action::requeue(Duration::from_secs(60)) } diff --git a/src/types/error.rs b/src/types/error.rs index 75e039e..4c4c30a 100755 --- a/src/types/error.rs +++ b/src/types/error.rs @@ -30,6 +30,9 @@ pub enum Error { message: String, }, + #[snafu(display("invalid tenant name '{}': {}", name, reason))] + InvalidTenantName { name: String, reason: String }, + #[snafu(display("serde_json error: {}", source))] SerdeJson { source: serde_json::Error }, } diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index 1bef3c0..20ec5d0 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -21,4 +21,4 @@ pub mod status; pub mod tenant; // Re-export commonly used types -pub use pool::SchedulingConfig; +pub use pool::{SchedulingConfig, validate_pool_total_volumes}; diff --git a/src/types/v1alpha1/pool.rs b/src/types/v1alpha1/pool.rs index 6c0429e..e0774a8 100755 --- a/src/types/v1alpha1/pool.rs +++ b/src/types/v1alpha1/pool.rs @@ -51,11 +51,11 @@ pub struct SchedulingConfig { #[derive(Deserialize, Serialize, Clone, Debug, KubeSchema)] #[serde(rename_all = "camelCase")] -#[x_kube(validation = Rule::new("!(self.servers * self.persistence.volumesPerServer < 4 && self.servers == 2)"). - message(Message::Expression(r#""pool " + self.name + " with 2 servers must have at least 4 volumes in total""#.into())). +#[x_kube(validation = Rule::new("self.servers * self.persistence.volumesPerServer >= 4"). + message(Message::Expression(r#""pool " + self.name + " must have at least 4 total volumes (servers × volumesPerServer)""#.into())). reason(Reason::FieldValueInvalid)) ] -#[x_kube(validation = Rule::new("!(self.servers * self.persistence.volumesPerServer < 4 && self.servers == 3)"). +#[x_kube(validation = Rule::new("self.servers != 3 || self.servers * self.persistence.volumesPerServer >= 6"). message(Message::Expression(r#""pool " + self.name + " with 3 servers must have at least 6 volumes in total""#.into())). reason(Reason::FieldValueInvalid)) ] @@ -63,7 +63,7 @@ pub struct Pool { #[x_kube(validation = Rule::new("self != ''").message("pool name must be not empty"))] pub name: String, - #[x_kube(validation = Rule::new("self > 0").message("servers must be gather than 0"))] + #[x_kube(validation = Rule::new("self > 0").message("servers must be greater than 0"))] pub servers: i32, pub persistence: PersistenceConfig, @@ -73,3 +73,66 @@ pub struct Pool { #[serde(flatten)] pub scheduling: SchedulingConfig, } + +/// Validates total volume count (`servers * volumesPerServer`) for RustFS erasure coding. +/// Same rules as CRD CEL on [`Pool`] and the operator console API (`validate_pool_volumes`). +pub fn validate_pool_total_volumes(servers: i32, volumes_per_server: i32) -> Result { + let total = servers * volumes_per_server; + if servers <= 0 || volumes_per_server <= 0 { + return Err("servers and volumes_per_server must be positive".to_string()); + } + if servers == 2 && total < 4 { + return Err("Pool with 2 servers must have at least 4 volumes in total".to_string()); + } + if servers == 3 && total < 6 { + return Err("Pool with 3 servers must have at least 6 volumes in total".to_string()); + } + if total < 4 { + return Err(format!( + "Pool must have at least 4 total volumes (got {} servers × {} volumes = {})", + servers, volumes_per_server, total + )); + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::validate_pool_total_volumes; + + #[test] + fn rejects_non_positive_inputs() { + assert!(validate_pool_total_volumes(0, 4).is_err()); + assert!(validate_pool_total_volumes(4, 0).is_err()); + } + + #[test] + fn rejects_total_under_four_when_not_caught_by_special_cases() { + assert!(validate_pool_total_volumes(1, 3).is_err()); + assert!(validate_pool_total_volumes(1, 2).is_err()); + } + + #[test] + fn four_servers_one_volume_each_is_ok() { + assert_eq!(validate_pool_total_volumes(4, 1).unwrap(), 4); + } + + #[test] + fn two_servers_need_at_least_four_total() { + assert!(validate_pool_total_volumes(2, 1).is_err()); + assert_eq!(validate_pool_total_volumes(2, 2).unwrap(), 4); + } + + #[test] + fn three_servers_need_at_least_six_total() { + assert!(validate_pool_total_volumes(3, 1).is_err()); + assert_eq!(validate_pool_total_volumes(3, 2).unwrap(), 6); + } + + #[test] + fn accepts_common_valid_configs() { + assert_eq!(validate_pool_total_volumes(1, 4).unwrap(), 4); + assert_eq!(validate_pool_total_volumes(4, 1).unwrap(), 4); + assert_eq!(validate_pool_total_volumes(2, 2).unwrap(), 4); + } +} diff --git a/src/types/v1alpha1/tenant.rs b/src/types/v1alpha1/tenant.rs index 3ea8ffb..779aa39 100755 --- a/src/types/v1alpha1/tenant.rs +++ b/src/types/v1alpha1/tenant.rs @@ -163,6 +163,14 @@ impl Tenant { ResourceExt::name_any(self) } + /// Validate the tenant name conforms to DNS-1035 label rules. + /// Kubernetes Services derived from the tenant name (e.g. `{name}-io`) + /// require DNS-1035 compliance: lowercase alphanumeric or '-', + /// must start with a letter, end with an alphanumeric, max 63 chars. + pub fn validate_name(&self) -> Result<(), types::error::Error> { + validate_dns1035_label(&self.name()) + } + /// a new owner reference for tenant pub fn new_owner_ref(&self) -> metav1::OwnerReference { metav1::OwnerReference { @@ -298,6 +306,58 @@ impl Tenant { } } +/// Validate a name conforms to DNS-1035 label rules: +/// `[a-z]([-a-z0-9]*[a-z0-9])?`, max 63 characters. +pub fn validate_dns1035_label(name: &str) -> Result<(), types::error::Error> { + if name.is_empty() { + return Err(types::error::Error::InvalidTenantName { + name: name.to_string(), + reason: "name must not be empty".to_string(), + }); + } + + // Longest derived DNS label is "{name}-console" (+8). RFC 1123 labels max 63 chars ⇒ |name| ≤ 55. + if name.len() > 55 { + return Err(types::error::Error::InvalidTenantName { + name: name.to_string(), + reason: format!( + "name must be at most 55 characters (longest derived name is {{name}}-console), got {}", + name.len() + ), + }); + } + + let bytes = name.as_bytes(); + + if !bytes[0].is_ascii_lowercase() { + return Err(types::error::Error::InvalidTenantName { + name: name.to_string(), + reason: "must start with a lowercase letter (a-z), not a digit or symbol".to_string(), + }); + } + + if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() { + return Err(types::error::Error::InvalidTenantName { + name: name.to_string(), + reason: "must end with a lowercase alphanumeric character (a-z, 0-9)".to_string(), + }); + } + + for &b in bytes { + if !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'-' { + return Err(types::error::Error::InvalidTenantName { + name: name.to_string(), + reason: format!( + "contains invalid character '{}'; only lowercase alphanumeric and '-' are allowed", + b as char + ), + }); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { @@ -437,4 +497,79 @@ mod tests { "Pool selector should have tenant + pool labels" ); } + + // Test 8: DNS-1035 validation - valid names + #[test] + fn test_validate_dns1035_valid_names() { + use super::validate_dns1035_label; + + assert!(validate_dns1035_label("my-tenant").is_ok()); + assert!(validate_dns1035_label("a").is_ok()); + assert!(validate_dns1035_label("abc-123").is_ok()); + assert!(validate_dns1035_label("example-tenant").is_ok()); + assert!(validate_dns1035_label("a1").is_ok()); + } + + // Test 9: DNS-1035 validation - name starting with digit rejected + #[test] + fn test_validate_dns1035_digit_start() { + use super::validate_dns1035_label; + + let err = validate_dns1035_label("111").unwrap_err(); + assert!( + err.to_string() + .contains("must start with a lowercase letter"), + "Error should mention starting with a letter, got: {}", + err + ); + } + + // Test 10: DNS-1035 validation - empty name rejected + #[test] + fn test_validate_dns1035_empty() { + use super::validate_dns1035_label; + + let err = validate_dns1035_label("").unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + } + + // Test 11: DNS-1035 validation - uppercase rejected + #[test] + fn test_validate_dns1035_uppercase() { + use super::validate_dns1035_label; + + let err = validate_dns1035_label("MyTenant").unwrap_err(); + assert!( + err.to_string() + .contains("must start with a lowercase letter") + ); + } + + // Test 12: DNS-1035 validation - trailing hyphen rejected + #[test] + fn test_validate_dns1035_trailing_hyphen() { + use super::validate_dns1035_label; + + let err = validate_dns1035_label("my-tenant-").unwrap_err(); + assert!(err.to_string().contains("must end with")); + } + + // Test 13: DNS-1035 validation - too long rejected + #[test] + fn test_validate_dns1035_too_long() { + use super::validate_dns1035_label; + + let long_name = format!("a{}", "b".repeat(55)); + let err = validate_dns1035_label(&long_name).unwrap_err(); + assert!(err.to_string().contains("at most 55 characters")); + } + + // Test 14: DNS-1035 validation - underscore rejected + #[test] + fn test_validate_dns1035_underscore() { + use super::validate_dns1035_label; + + let err = validate_dns1035_label("my_tenant").unwrap_err(); + assert!(err.to_string().contains("invalid character")); + } }