The module lifecycle stage: Experimental
The module has requirements for installation
The sds-object module is in the Experimental stage. Experimental modules are not enabled by default. Set allowExperimentalModules: true in the deckhouse ModuleConfig before enabling the module.
Enabling the module
d8 k apply -f - <<EOF
apiVersion: deckhouse.io/v1alpha1
kind: ModuleConfig
metadata:
name: sds-object
spec:
enabled: true
version: 1
EOFCreating a store
A store is the data plane, and there is one Kind per backend. A SeaweedFS store on an existing StorageClass:
apiVersion: storage.deckhouse.io/v1alpha1
kind: SeaweedFSStore
metadata:
name: default
spec:
masters: 3
volumeServers: 3
replication: "001" # one extra copy on another volume server
storage:
sizePerNode: 50Gi
class: localpathThe counts and the replication code are SeaweedFS’s own settings, and they have to agree: the third digit of the code is how many copies land on other servers, so volumeServers must be at least one more than that or writes never complete. The admission webhook rejects a pair that cannot work rather than letting the store come up and stall.
More than one filer needs a shared metadata store — the default LevelDB lives on the filer’s own volume and cannot be shared:
spec:
filers: 3
metadataStore: Postgres # requires the managed-postgres moduleCreating a class
An ObjectStore is the class tenants name. It carries the reference to the store and the defaults buckets inherit:
apiVersion: storage.deckhouse.io/v1alpha1
kind: ObjectStore
metadata:
name: standard
spec:
storeRef:
kind: SeaweedFSStore
name: default
reclaimPolicy: Retain
quota:
maxSize: 100Gi # ceiling per bucket; a Bucket asking for more is rejectedTrack readiness:
d8 k get seaweedfsstore
# NAME VOLUMES REPLICATION PHASE ENDPOINT READY AGE
# default 3 001 Ready http://default-seaweedfs.d8-sds-object... True 3m
d8 k get objectstore
# NAME STORE-KIND STORE RECLAIM PHASE READY AGE
# standard SeaweedFSStore default Retain Ready True 2mA class is Ready exactly when its store resolves and is itself Ready. An unknown storeRef.kind is rejected at admission: the set of store Kinds lives in the controller’s driver registry, not in the schema, so adding a backend does not mean editing the CRD every class is validated by.
Requesting a bucket
A Bucket is what you create to get a bucket. The controller provisions a
cluster-scoped BucketContents for it — named after the Bucket, owned by it and
private to the namespace — resolves the class to a store, and creates the bucket
there:
apiVersion: storage.deckhouse.io/v1alpha1
kind: Bucket
metadata:
name: app-data
namespace: my-app
spec:
objectStoreRef: standard
accessPolicy: Private
reclaimPolicy: Retain # omit to take the class defaultd8 k -n my-app get bucket app-data
# NAME CONTENTS PHASE READY AGE
# app-data contents-1f2e3d-... Ready True 20sThe BucketContents it creates is visible cluster-wide (d8 k get bktc) but is
not meant to be written by hand: the admission webhook admits it only from the
module’s own service account, because contents without an owning Bucket are
storage no namespace can reach and nobody is accountable for.
There is no way to bind an existing bucket, and no way to use one namespace’s
bucket from another. Both needed BucketClaimPolicy, which selected namespaces
by name or by regular expression; it was removed, and namespace peering will be
designed as a feature of its own instead.
A bucket that is already there
The backend bucket name is derived from the BucketContents, so a bucket can
already exist under that name for reasons that have nothing to do with the
module: one made by hand, a leftover from a store that was rebuilt, or another
cluster pointed at the same backend.
The module marks every bucket it creates with two tags:
storage.deckhouse.io/owned-by sds-object
storage.deckhouse.io/bucket-contents contents-1a2b3c4d5e-team-a-data
A bucket that carries neither is not taken over. The BucketContents stays not
ready, BucketReady reports the reason BucketNotOwnedByModule, and nothing in
the bucket is touched:
BucketReady False BucketNotOwnedByModule
bucket "team-a-data" already exists in the backend and is not managed by this
module (it carries no ownership tag); remove it or point this Bucket at
another store
The decision is yours to make — remove that bucket, or point the Bucket at
another store — so the controller reports it instead of retrying. Adopting an
existing bucket was a feature once and was removed together with
BucketClaimPolicy; what is left under that name is an accident, and handing a
tenant credentials to somebody else’s data is not a recoverable one.
Buckets created before the module started marking them carry no tag either. Those
are adopted and marked on the next reconcile, recognised by the module’s own
record of having provisioned them (status.bucketName), so an upgrade does not
turn every existing bucket into a refusal.
Requesting credentials
Each workload declares a BucketAccess referencing a Bound Bucket in its namespace. The controller mints a dedicated access key / secret key scoped to that bucket and writes a Secret (named <access>-s3-credentials by default) in the same namespace:
apiVersion: storage.deckhouse.io/v1alpha1
kind: BucketAccess
metadata:
name: app-data
namespace: my-app
spec:
bucketRef: app-data
permission: ReadWrite # or ReadOnlyd8 k -n my-app get bucketaccess app-data
# NAME BUCKET PHASE SECRET READY AGE
# app-data app-data Ready app-data-s3-credentials True 20sConsuming the credentials
The credentials Secret holds the standard S3 connection variables, ready to be mounted with envFrom:
| Key | Description |
|---|---|
S3_ENDPOINT |
In-cluster S3 endpoint URL |
S3_REGION |
S3 region |
S3_BUCKET |
Bucket name |
AWS_ACCESS_KEY_ID |
Access key |
AWS_SECRET_ACCESS_KEY |
Secret key |
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: my-app
spec:
template:
spec:
containers:
- name: app
image: my-app:latest
envFrom:
- secretRef:
name: app-data-s3-credentialsPublishing a store outside the cluster
By default a store is reachable in-cluster only: status.endpoint carries just
internal, and that is the address a Secret gets. To use a bucket from outside,
publish the store through the alb module — a Gateway API
implementation on top of Envoy.
What an administrator has to prepare before publishing:
- a
Gateway. The alb module creates it from anALBInstance(a team’s own Gateway in its namespace) or aClusterALBInstance(a shared one in the controller’s namespace). Take its name and namespace from that object’sstatus; - a DNS record for the hostname the endpoint will answer on;
- a
kubernetes.io/tlsSecret with a certificate for that hostname, in the module namespace (d8-sds-object). The module issues no certificates: point this at the Secret cert-manager produces, or put your own there.
Then add a publish block to the store:
apiVersion: storage.deckhouse.io/v1alpha1
kind: SeaweedFSStore
metadata:
name: default
spec:
storage:
class: linstor-thin-r2
publish:
hostname: s3.example.com
gatewayRef:
name: public-gw # the Gateway the administrator created
namespace: d8-alb
tls:
secretRef:
name: s3-example-com-tlsThe module creates a ListenerSet (the hostname, port 443, TLS terminated with
that certificate) and an HTTPRoute to the store’s S3 port, after which
status.endpoint.external carries the URL:
d8 k get seaweedfsstore default -o jsonpath='{.status.endpoint}'
# {"external":"https://s3.example.com","internal":"http://default-seaweedfs...:8333","region":"us-east-1"}TLS is mandatory and cannot be turned off: S3 credentials travel in the
Authorization header, so publishing over plain HTTP would hand the bucket to
anyone who can see the traffic.
A note on ports: the module publishes a hostname, not a port — the listener is
always 443, as the alb guide requires. If the ALBInstance or
ClusterALBInstance uses a HostPort inlet on a non-standard port (8443, say),
status.endpoint.external still reads https://<host> while a client on that
stand needs https://<host>:8443. That is a property of the inlet rather than of
the address: mapping a name to a port is the administrator’s business (a
production LoadBalancer inlet listens on 443 and the address from the status is
used as it is).
Choosing the address in the credentials
A Secret carries the in-cluster address by default — most consumers run in the
same cluster, and there is no reason to send their traffic through an external
load balancer. The external address is requested explicitly:
apiVersion: storage.deckhouse.io/v1alpha1
kind: BucketAccess
metadata:
name: backup-writer
namespace: my-app
spec:
bucketRef: app-data
permission: ReadWrite
endpointScope: ExternalIf the store is not published, the BucketAccess does not quietly get the
in-cluster address: it stays not ready, and its condition says that the store
needs spec.publish. A Secret holding an address that is unreachable from where
it is about to be used is worse than no Secret at all.
When an endpoint moves — publishing switched on, the hostname changed — the
Secrets of every affected BucketAccess are re-issued automatically.
Addressing limits
Path-style addressing is supported: https://s3.example.com/<bucket>/<key>.
It needs one DNS name and one certificate.
Virtual-hosted addressing (https://<bucket>.s3.example.com/<key>) exists in the
API as spec.publish.addressing: VirtualHosted but is currently refused by the
webhook: it needs wildcard DNS and a wildcard certificate (issuable over DNS-01
only), the backends’ S3 gateways support it unevenly, and none has been measured
against it yet. Clients that can only do virtual-hosted addressing will not work
with a published endpoint — a deliberate limit of the first implementation rather
than a defect.
What stays inside
Only the S3 port is exposed. For SeaweedFS a separate single-port Service is
created for that: the store’s main Service also publishes the filer’s HTTP and
gRPC APIs, and a route to it would carry metadata read and write out of the
cluster without passing through S3 authorization.
Ceph RGW has no port split — its admin ops API is served on the same port as S3,
under /admin/. Publishing an SDSElasticStore therefore publishes that path
too, and it is worth being precise about what protects it.
The users this module issues hold no admin capabilities, so a tenant’s key cannot
reach the admin API. But Rook creates an admin-capable user of its own on every
object store — rgw-admin-ops-user, with capabilities buckets=*;users=* — and
its keys live in a Secret in the sds-elastic namespace. What stands between the
internet and full bucket and user administration is that credential, not the
route.
Weigh that before publishing a Ceph store:
- if the platform team can deny
/adminon the Gateway they own, do that; - otherwise consider a separate
CephObjectStore(its own RGW) for the public endpoint, so the admin API of the store your tenants use is not reachable from outside at all; - and treat the
rgw-admin-ops-userSecret as an internet-facing credential — because once the store is published, it is one.
A SeaweedFS store has no equivalent exposure: the published Service carries the S3 port only, and the filer’s own APIs are on ports that are not routed.
When publishing does not work
Publication state is a condition of its own, PublishedEndpointReady, and it does
not take the store out of Ready: a broken external route stops neither
creating buckets, nor deleting them, nor issuing keys.
d8 k get seaweedfsstore default -o jsonpath='{range .status.conditions[?(@.type=="PublishedEndpointReady")]}{.reason}: {.message}{end}'Common reasons:
| Reason | What to do |
|---|---|
GatewayAPIMissing |
the cluster has no Gateway API CRDs — enable the alb module |
NotAllowedByListeners |
the Gateway’s listener does not admit routes from the module’s namespace; the administrator has to allow them (allowedRoutes, or a ReferenceGrant in the Gateway’s namespace) |
BackendNotFound |
the Gateway cannot see the store’s Service; check that the store came up |
Pending |
the Gateway controller has not answered about the route yet |
Public read
accessPolicy: PublicRead on a Bucket lets anyone fetch its objects without
credentials. Both backends enforce it:
apiVersion: storage.deckhouse.io/v1alpha1
kind: Bucket
metadata:
name: site-assets
namespace: my-app
spec:
objectStoreRef: standard
accessPolicy: PublicReadcurl -s https://s3.example.com/my-app-site-assets/logo.png -o logo.pngWhat it grants is reading objects, and nothing else:
- Objects only, no listing. Anonymous callers can
GetObjectif they know the key;ListObjectson the bucket stays denied. A public listing turns the bucket into an index of everything in it, which is a separate decision — ask for it and it will be designed as one, rather than arriving as a side effect of “these files are public”. - Reads only. Anonymous writes, deletes and tagging are denied on both backends.
- This bucket only. The grant names the bucket, so other buckets in the same store are unaffected.
Two things worth knowing before you use it:
- It only means something where the store is reachable. Inside the cluster
the S3 endpoint answers on the module’s own
Service; to serve the objects to the internet the store has to be published (spec.publishon the store), and then the whole endpoint is reachable — public bucket or not. Publishing is what exposes the endpoint;accessPolicyis what decides who may read through it. - A
Releasedbucket stays public. Deleting theBucketunderreclaimPolicy: Retainkeeps the data and theBucketContents, including itsaccessPolicy— that is what lets the sameBucket, recreated, pick the data back up unchanged. To take public read away, setaccessPolicy: Privateon theBucketContentsor delete it.
Going back to Private revokes it on the next reconcile, and does not disturb the
access keys issued for the bucket.
Versioning and object lock
versioning: Enabled keeps every version of an object instead of overwriting it.
objectLock goes further and makes the bucket write-once-read-many: a version
under retention cannot be deleted — not by the tenant, not by the storage
administrator, and not by this module:
apiVersion: storage.deckhouse.io/v1alpha1
kind: Bucket
metadata:
name: audit-log
namespace: my-app
spec:
objectStoreRef: standard
versioning: Enabled
objectLock:
mode: Compliance # Governance | Compliance
days: 365
reclaimPolicy: Retain # required with objectLockGovernance can be bypassed by a caller holding the bypass permission;
Compliance cannot be bypassed by anyone until the retention expires, and an
object’s retention can be extended but never shortened. Both are enforced by the
backend, not by the module.
What the module reports back is what the backend has, not what was asked for:
d8 k get bktc -o custom-columns='NAME:.metadata.name,VERSIONING:.status.versioning,LOCK:.status.objectLock.enabled,MODE:.status.objectLock.mode'Rules the API enforces
objectLockrequiresversioning: Enabled. Object lock is built on versions.objectLockis immutable, and can only be set when the Bucket is created. Ceph RGW accepts object lock only in the bucket-creation call, so a bucket that exists without it can never be given one. The field is fixed on both backends so that the same manifest means the same thing on either — protecting an existing bucket means creating a new one and copying the data across.objectLockcannot be combined withreclaimPolicy: Delete, including whenDeleteis what the class hands out by default. A bucket holding protected objects cannot be removed, so that pair would leave theBucketContentsinTerminatinguntil the last retention expired. The request is refused rather than quietly switched toRetain.- Versioning cannot be suspended once the bucket is locked. Both backends refuse it, and so does the API.
Legal hold
A legal hold blocks deletion of a version for as long as it is set, with no expiry. It is not configured here: it is an operation a client performs on an object, with the credentials it already has:
mc legalhold set myalias/my-app-audit-log/report.pdf
mc legalhold clear myalias/my-app-audit-log/report.pdfWhile a hold is set, nothing can remove that version, and waiting does not help — only clearing it does.
Letting protected data go
Deleting the Bucket under reclaimPolicy: Retain leaves the data and the
BucketContents behind in phase Released, retention intact. Deleting the
BucketContents is the documented way to let retained data go — but on a locked
bucket the backend refuses while anything in it is still protected. The object
then keeps its finalizer and says why:
d8 k get bktc contents-... -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}'
# the backend refuses to remove the bucket while its objects are protected; ...The deletion resumes on its own once the last retention expires. It can be
finished sooner only by removing the protected versions with credentials that may
bypass the retention, which Compliance does not allow at all.
Rotating credentials
To rotate the access key of an BucketAccess, set or change the storage.deckhouse.io/rotate annotation. The controller issues a fresh key pair, updates the Secret, and revokes the previous key:
d8 k -n my-app annotate bucketaccess app-data \
storage.deckhouse.io/rotate="$(date +%s)" --overwriteMetadata in a database you already run
metadataStore: External points the filers at a PostgreSQL this module does not
run. The capability is the same as Postgres — several filers sharing one
database — and the ownership is not: its availability, its backups and its
upgrades are yours. A store whose metadata database is down serves nothing,
and no amount of replication in the store makes up for a metadata server that
is not there.
apiVersion: v1
kind: Secret
metadata:
name: media-metadata-db
namespace: d8-sds-object
stringData:
host: pg.example.internal
port: "5432"
database: seaweedfs_media
username: seaweedfs
password: <password>
sslmode: verify-full
ca.crt: |
-----BEGIN CERTIFICATE-----
...
---
apiVersion: storage.deckhouse.io/v1alpha1
kind: SeaweedFSStore
metadata:
name: media
spec:
metadataStore: External
externalMetadataStore:
secretRef:
name: media-metadata-db
filers: 3
storage:
class: linstor-r2What the database has to provide. PostgreSQL 12 or newer, an empty database,
and a role that may create tables in it — the filer creates one table per bucket
on first use and never migrates a schema, so nothing has to be prepared beyond
the database itself. Give it its own database rather than a shared one: the
table names come from bucket names, and a bucket called users would meet
whatever else is in there.
TLS. sslmode defaults to require, which encrypts without checking who
answers. Supply ca.crt and set sslmode: verify-full to check as well — the
bundle is mounted into the filers and the connection is verified against it.
sslmode: disable is refused: the password and every object name cross that
connection. Any value libpq does not know is refused too, with the accepted list
in the message — a misspelt mode passes the reachability check, because TCP is
fine, and then leaves the filers crash-looping on a typo.
Changing the connection. Edit the Secret — move the database, rotate the password, replace the CA — and the filers restart to pick it up. They read the connection once, at startup, so a change that did not restart them would be a change that had not happened yet. Expect the S3 endpoint to be briefly unavailable on a single-filer store; a multi-replica set rolls one pod at a time.
Where the Pods run
spec.placement on the store carries a nodeSelector and tolerations, and
they are applied to every Pod the module creates itself: masters, volume servers
and filers.
Replicas of one component are kept on different nodes. For masters and volume
servers this is a hard rule: three volume servers on one node survive no node
failure, and a replication code promising copies “on other servers” would have
placed them all in one failure domain — so a replica that cannot be given a node
of its own stays Pending, where the unmet promise is visible. Filers prefer to
spread and still run when they cannot: they front metadata that lives elsewhere,
so losing several at once costs availability while they reschedule, not data.
The managed metadata database is placed differently, and spec.placement does
not reach it. The Postgres resource this module creates has no scheduling
fields at all — tolerations, nodeSelector and nodeAffinity belong to the
PostgresClass. To put the database on particular nodes, create a PostgresClass
that says so and name it in spec.postgresClassName; empty means the class
called default.
spec:
metadataStore: Postgres
postgresClassName: storage-dedicated
placement:
nodeSelector:
node-role/storage: ""
tolerations:
- key: storage.deckhouse.io/dedicated
operator: Exists
effect: NoScheduleWhat the module will not do. It does not create the database or the role, does not run migrations, does not back anything up and does not watch the server. What it does check, before pointing the filers at it, is that something is listening — a host that does not resolve or a port nothing answers on is reported on the store:
BackendReady False Pending
the external metadata database at pg.example.internal:5432 did not answer:
dial tcp: lookup pg.example.internal: no such host
Losing that database loses the store: the objects are still on the volume servers, and nothing knows which bucket or which name any of them belongs to. Back it up like the rest of the store.
How much space is left
Every store reports its space in status.capacity, and kubectl shows the
headline figures in the wide output:
d8 k get seaweedfsstore media -o wideNAME VOLUMES REPLICATION PHASE ENDPOINT USED% CAPACITY READY
media 3 001 Ready http://...svc:8333 37.24 300Gi True
status:
capacity:
total: 300Gi
used: 111Gi
available: 189Gi
usedPercent: "37.24"
lastUpdated: "2026-08-30T09:12:04Z"What the numbers are made of depends on the backend, and the difference matters.
SeaweedFSStore— the disks under the store’s own volume servers, summed. This is the store’s space: the module provisioned those PVCs and nothing else writes to them. A volume server that does not answer the probe is left out of the sum rather than counted as empty, so the total shrinks while one is unreachable — better than a used fraction that quietly falls because a node went missing.SDSElasticStore— the raw, cluster-wide capacity of the Ceph cluster behind it, as the cluster itself reports it. It is not the store’s share and not what a client can write: replication divides raw capacity by the pool’s size, and the same cluster serves every other pool on it. Read it as “how much room does the cluster this store lives on have left”, not as a quota.
status.capacity is absent, rather than zero, while nothing has answered — a
store that has not been measured says so instead of reporting itself full.
Metrics and alerts
The same numbers are exported per store, labelled store_kind and store:
| Series | What it is |
|---|---|
sds_object_store_ready |
1 while the store serves S3, 0 while it does not |
sds_object_store_capacity_bytes_total |
space the store has |
sds_object_store_capacity_bytes_used |
space in use |
sds_object_store_capacity_bytes_available |
space left |
A store that was not measured publishes no capacity series at all, so an
alert on “nearly full” cannot fire on a store nobody probed. Readiness is the
opposite: it is always published, because 0 and “no series” have to mean
different things — otherwise uninstalling the module would read as an outage.
Three alerts come with them: D8SdsObjectStoreNotReady (not ready for half an
hour — rollouts and PVC provisioning take minutes, this is for the store that
did not come back), D8SdsObjectStoreFillingUp (over 85% for an hour) and
D8SdsObjectStoreAlmostFull (over 95%). The dashboard is SDS Object —
Stores.
Worth knowing where the second threshold comes from: on SeaweedFS the master
stops placing new volumes on a disk past 90%, so a store can refuse a new bucket
while still reporting Ready.
Data integrity
Every store reports what is known about its data being intact — when it was last checked, by whom, and what was found:
d8 k get seaweedfsstore,sdselasticstore \
-o custom-columns='NAME:.metadata.name,INTEGRITY:.status.conditions[?(@.type=="IntegrityHealthy")].status,LAST SCRUB:.status.integrity.lastScrubTime,DAMAGED:.status.integrity.damaged'IntegrityHealthy is Unknown until the first check completes — “we have not
looked” and “we looked and it is fine” are different answers, and only one of them
is the store’s own doing. A finding makes it False and puts the backend’s own
words in the message, which is what names the disk to suspect.
It never makes the store not Ready. A store with one damaged volume keeps serving every other object, and taking those away would not repair anything.
Who checks, and when
-
SeaweedFS — the module does. The engine verifies a checksum on every read and has a scrub that verifies every stored object, but it never runs that scrub on its own, so cold data would never be checked. Nothing rots faster than data nobody reads.
apiVersion: storage.deckhouse.io/v1alpha1 kind: SeaweedFSStore metadata: name: media spec: integrity: interval: 24h # default 168h; values under 1h are raised to 1h mode: Full # Full (default) reads every byte; Index only checks indexes enabled: true # ...Fullis the default becauseIndexcannot find a rotted object body — and that is the thing worth looking for. The interval default is deliberately conservative: a full scrub reads every stored byte.Because it reads every stored byte, the scrub runs in the background, not inside a reconcile. While a pass is under way
IntegrityHealthysays so (ScrubInProgress), and the result lands in the status when the pass finishes — which on a large store is minutes or hours after it started. Restarting the controller cancels a pass in flight; the schedule simply starts another.A volume server that could not be asked is not counted as damage. It appears as
status.integrity.unreachable, and a pass with unreachable nodes and no findings leaves the conditionUnknownwith reasonScrubIncompleterather thanTrue: part of the store went unchecked, and that is a different claim from “checked and clean”.A pass that found no volumes at all — a store that has not been written to yet — is not recorded as a check. The condition stays
Unknownwith reasonNothingToCheckandstatus.integrityis not stamped, so the store remains due and is checked as soon as it holds something. Publishing that pass would say “checked and clean” over no data and buy a whole interval of silence over the first data written afterwards. -
Ceph RGW — the engine does, on its own schedule, and the module reports what it found by reading the
ElasticClusterhealth checks (OSD_SCRUB_ERRORS,PG_DAMAGED,PG_NOT_DEEP_SCRUBBED, …). There is nospec.integrityonSDSElasticStore: the module never starts a deep-scrub and never turns on automatic repair, because that is configuration of a Ceph cluster it does not own.
status.integrity.source says which of the two produced the reported result.
Enough copies?
A missing copy is not damage, so it is reported separately — by the
RedundancyHealthy condition and status.redundancy:
d8 k get seaweedfsstore -o custom-columns='NAME:.metadata.name,WANTED:.status.redundancy.copiesWanted,VOLUMES:.status.redundancy.volumes,SHORT:.status.redundancy.underReplicated'The accounting comes from the master’s own topology, so it is refreshed on every reconcile rather than on the scrub schedule — it costs nothing to ask.
RedundancyHealthy going False while IntegrityHealthy stays True says
exactly what it should: the data is intact, and there is less of a safety net
under it than was asked for. A store with replication: "000" asks for one copy
and has one, so it is never reported short.
Nothing puts the copy back on its own. SeaweedFS 4.39 has an admin server with a maintenance framework and workers that execute its tasks, and the protocol even carries a replication task message — but the tasks it ships are balance, vacuum, erasure coding, EC balance, S3 lifecycle and Iceberg. There is no replication task, so a volume that lost a replica stays short until somebody restores it:
# from a pod with the seaweedfs image
weed shell -master=<store>-seaweedfs-master:9333 <<'EOF'
lock
volume.fix.replication -apply
unlock
EOFOn SDSElasticStore the condition is Unknown with reason NotAccountedHere:
how many copies Ceph keeps is the cluster’s own accounting, reported through its
health, and a second opinion from this module would be exactly that.
What the module does with a finding
By default it reports it and stops there. With spec.integrity.autoRepair: true
it also puts it right — on SeaweedFS, where a rotted copy is replaced by pulling a
fresh one from a copy that is intact:
spec:
replication: "001" # more than one copy, or there is nothing to pull from
integrity:
autoRepair: trueIt is off by default because it is the most destructive thing the module does: the repair deletes a copy of live data on an automatic decision. Two conditions have to hold before it touches anything, and both are checked per volume, per pass:
- the store keeps more than one copy. On
replication: "000"the damaged copy is the only copy, and deleting it is deleting the data; - another copy of that volume scrubbed clean in the same pass. “This copy is bad” is not “that one is good”, and a volume server that did not answer the scrub has told us nothing.
When either fails, the damage is reported, the copy is left alone, and
status.integrity.details says which condition stopped it. The same applies when
the copy back fails: the fresh copy is pulled onto the node the damaged one was
deleted from, and if that does not complete the volume is one copy short — which
RedundancyHealthy then reports, and which is a better state than a copy whose
contents are wrong.
Repaired volumes are counted in status.integrity.repaired and stop counting as
damaged, so a store that fixed itself does not keep alerting about it.
On Ceph nothing here applies: the cluster repairs its own placement groups, and
this module neither starts a deep-scrub nor turns on osd_scrub_auto_repair.
Metrics and alerts
The findings above are also exported as metrics, one series per store, labelled
store_kind and store:
| Metric | What it says |
|---|---|
sds_object_store_integrity_damaged |
units of storage the last scrub found damaged |
sds_object_store_integrity_repaired |
how many of them the module put right |
sds_object_store_integrity_scanned_volumes |
how much that scrub covered |
sds_object_store_integrity_last_scrub_timestamp_seconds |
when it completed; absent until the first one does |
sds_object_store_scrub_interval_seconds |
the interval the store asks for; absent when the scrub is off |
sds_object_store_redundancy_under_replicated |
units with fewer copies than asked for |
sds_object_store_integrity_unreachable_nodes |
storage nodes the last check could not ask at all |
sds_object_store_redundancy_copies_wanted / _volumes |
what was asked for, and how much was accounted |
sds_object_store_encryption_key_withheld |
1 while a changed encryption key is being refused |
Three alerts come with them, and the third is the one worth having:
D8SdsObjectStoreDataDamaged— a scrub found damage. Fires immediately: the number comes from a check that already finished.D8SdsObjectStoreUnderReplicated— fewer copies than asked for, after 15 minutes so a rolling restart does not page anyone.D8SdsObjectStoreEncryptionKeyWithheld— the key in the spec is not the key the data was written with, and the module is refusing to apply it. The highest severity of the four precisely because nothing is failing yet: the running gateway still holds the working key, and the breakage is scheduled for its next restart.D8SdsObjectStoreNotScrubbed— the store has not been checked in three of its own intervals. This is the quiet failure the whole feature exists for: theIntegrityHealthycondition keeps carrying the verdict of the last completed scrub, so a store whose scrub stopped running reads as healthy indefinitely. The threshold followsspec.integrity.interval, so a daily store and a weekly one are each judged at their own pace, and a store with the scrub switched off publishes no interval and is never overdue.
A Grafana dashboard — SDS Object — Data integrity, folder Storage — puts the same series on one page: how many stores are damaged, short of copies or overdue, a table with a row per store, and the sawtooth of scrub age against each store’s own alerting threshold.
Encryption at rest
Without spec.encryption, objects are written to the volume servers as they
arrive: anyone holding the disk reads them. Turning it on takes two steps and no
change to the applications writing the data.
# 32 bytes of key material. Keep a copy somewhere that is not this cluster.
d8 k -n d8-sds-object create secret generic media-sse \
--from-literal=kek="$(head -c 32 /dev/urandom | xxd -p -c 32)"apiVersion: storage.deckhouse.io/v1alpha1
kind: SeaweedFSStore
metadata:
name: media
spec:
encryption:
mode: ServerManaged
keySecretRef:
name: media-sseThe Secret may instead carry key with a passphrase, from which the key is
derived — useful when the key material comes from somewhere that hands out
strings rather than bytes.
From then on the module hands the S3 gateway the wrapping key and marks every bucket it manages so that objects written without any encryption header are encrypted anyway. Each object gets its own data key, wrapped with the key from the Secret. The wrapping key itself is never written to the filer’s metadata store, so holding that store — the Postgres database or the filer PVC — is not enough to read anything.
$ d8 k get swfsstore media -o jsonpath='{.status.encryption}' | jq
{
"mode": "ServerManaged",
"keyFingerprint": "sha256:9c1f4a0b7e2d8536",
"since": "2026-08-25T09:12:04Z"
}
Do not lose the key, and do not change it
Nothing re-wraps existing objects. A gateway started with a different wrapping key answers every read of previously encrypted data with an internal server error — not with anything naming the key, and not with a hint that the key is the problem. The module therefore fingerprints the key and, when the fingerprint changes, refuses to apply the new one:
$ d8 k get swfsstore media -o jsonpath='{.status.conditions[?(@.type=="EncryptionActive")]}' | jq -r .message
the wrapping key changed (sha256:9c1f4a0b7e2d8536 -> sha256:22ba07f6c4e1d980) and was NOT applied: ...
While that condition is False the gateway is left exactly as it is running, on the key that can still read the data — so the fix is to put the old key material back. Only if the objects written under the previous key are genuinely expendable:
d8 k annotate swfsstore media \
storage.deckhouse.io/encryption-key-change-acknowledged=sha256:22ba07f6c4e1d980Note that the danger is latent without this guard: the gateway reads the key at startup, so rewriting the Secret breaks nothing today — it breaks everything at the next pod restart, which will happen weeks later for an unrelated reason.
spec.encryption.mode also cannot be turned back off. Switching it off would only
stop encrypting new writes while every object already written still needed the
key, so a store that says “disabled” would have to keep the key forever. The way
back is a new store and a copy.
What is not encrypted, and what the client can do itself
- Object names, sizes and tags are not encrypted — only object contents.
- Objects written before encryption was turned on stay as they were. Nothing rewrites them; re-uploading them is the only way to encrypt them.
- A client that wants to hold its own key can (SSE-C) — pass the key with each
request and the store never sees it. That needs no configuration here and works
regardless of
spec.encryption. Note that on the in-cluster HTTP endpoint the key travels unencrypted in a header, so SSE-C belongs on the published TLS endpoint. - SSE-KMS is not available on SeaweedFS stores. The gateway reads KMS
configuration only from a static credentials file, which is incompatible with
the per-
BucketAccesskeys this module mints.SDSElasticStore(Ceph RGW) is where an external KMS belongs.
On Ceph RGW stores
The same field on an SDSElasticStore needs an external secrets store, and that is
not a matter of taste: RGW’s server-managed encryption keeps its keys in a KMS and
has no mode where a key can simply be handed to the daemon. On this platform that
store is Deckhouse Stronghold, whose transit
engine is exactly what RGW asks for.
# a token that may read and use the transit key, and nothing else
d8 k -n d8-sds-object create secret generic rgw-stronghold-token \
--from-literal=token="$STRONGHOLD_TOKEN"apiVersion: storage.deckhouse.io/v1alpha1
kind: SDSElasticStore
metadata:
name: heavy
spec:
elasticClusterRef: main
encryption:
mode: ServerManaged
stronghold:
address: https://stronghold.d8-stronghold.svc.cluster.local:8200
tokenSecretRef:
name: rgw-stronghold-token
# for a Stronghold with a private certificate:
# caSecretRef:
# name: stronghold-caFour things follow from how Ceph does this, and each is visible in the API:
- Stronghold is named, the Vault API is spoken. What reaches Rook is
KMS_PROVIDER: vaultandVAULT_ADDR, because Stronghold keeps the Vault API and RGW speaks it. Any other Vault-compatible store works for the same reason; it is simply not what this page walks you through. - The transit mount path is not configurable. For SSE-S3 Rook builds the RGW
prefix from the engine name alone (
/v1/transit), so a transit mounted elsewhere would be accepted here and never reached. Mount it attransit. - The token is copied into the
sds-elasticnamespace. Rook resolves the token Secret next to theCephObjectStore, not next to the store object, so the module keeps a copy there owned by the store. Deleting the store takes the copy with it. Rotating the token is a write to your Secret; the copy follows on the next reconcile. A CA bundle is copied the same way and is renamed on the way — you writeca.crt, Rook projectscert. - There is no key fingerprint in
status.encryption, and that is not an omission. The key never passes through the module — it lives in Stronghold — so there is nothing here to fingerprint, and no guard against changing it: what the data depends on is the key in Stronghold, not the token that reads it. Keeping that is Stronghold’s job, and losing it loses the objects just as surely.
status.encryption.message and the EncryptionActive condition report the
configuration not reaching RGW — a missing token Secret, most often. While that is
the case the store keeps reconciling everything else, and a store that has never
been created is not created unencrypted: objects written before the Secret appears
could never be encrypted afterwards.
Expiring objects on a schedule
A bucket that collects logs, exports or build artefacts grows until somebody
remembers to empty it. spec.lifecycle puts the forgetting in the bucket:
apiVersion: storage.deckhouse.io/v1alpha1
kind: Bucket
metadata:
namespace: team-a
name: build-logs
spec:
objectStoreRef: standard
lifecycle:
rules:
- id: logs
prefix: logs/
expireAfterDays: 30
- id: leftovers
abortIncompleteUploadsAfterDays: 1Every rule needs an id, and no two may share one. It is the name the rule
carries in the backend, which S3 requires to be unique and which this module
uses to tell whether anything needs writing — so it is given, never derived from
the rule’s position, where inserting a rule would rename the ones after it.
expireAfterDaysdeletes an object that many days after it was written. On a versioned bucket this only makes the current version non-current — the data is still there, andexpireNoncurrentAfterDaysis what reclaims the space.expireNoncurrentAfterDaysdeletes a version that many days after it stopped being current.abortIncompleteUploadsAfterDaysremoves the parts of multipart uploads that were never completed. Those parts occupy space and appear in no listing, so without this they are storage nobody can see and nobody frees.prefixlimits a rule to keys starting with it. Absent, the rule applies to every object in the bucket — worth reading twice before applying.
The whole set is written to the backend on every pass, assembled from the spec
alone: a rule removed from the Bucket is a rule gone from the bucket. Nothing
is patched, so a rule nobody asks for any more cannot be left behind quietly
deleting objects.
Object lock outranks expiry. A version under an unexpired retention is not deleted, whatever a rule says — the lock is the stronger statement, and the engine enforces it.
Expiry only, no tiering. Moving objects between storage tiers is a lifecycle
transition, and it is not offered here because it would work on one backend
and not the other: SeaweedFS 4.39’s lifecycle engine compiles six actions —
expiration by days, expiration by date, noncurrent expiration,
newer-noncurrent, abort of incomplete uploads and delete-marker cleanup — and
has no transition action at all. A Transition rule would be accepted, stored,
listed back, and never run. Ceph RGW does support transitions across storage
classes; when tiering arrives it will arrive as a field that says which backends
honour it.
Which S3 operations work
The two backends are not the same S3, and the difference is worth knowing before an application meets it rather than after.
Where this table comes from. For SeaweedFS it is read off the engine’s own
router at 4.39 — every operation it registers a handler for, and nothing else.
For Ceph RGW it is Ceph’s own feature-support table for the release sds-elastic
vendors. Neither is a promise this module makes: both are what the engine does,
recorded so nobody has to find out from a failing SDK call. It is revisited when
either engine version changes, and the version it was read at is in the heading
of each column for exactly that reason.
| SeaweedFS 4.39 | Ceph RGW (Squid) | |
|---|---|---|
| Create / delete / head bucket, bucket location | yes | yes |
| List objects (v1, v2), list versions | yes | yes |
| Put / get / head / delete object, delete multiple, copy | yes | yes |
GetObjectAttributes |
yes | yes |
| Multipart upload (create, upload, upload-part-copy, complete, abort, list) | yes | yes |
| POST form upload | yes | yes |
| Object tagging, bucket tagging | yes | yes |
| Bucket and object ACLs | yes | yes, with a different set of canned ACLs |
| Bucket policy (get, put, delete) | yes | yes |
| CORS | yes | yes |
| Lifecycle (get, put, delete) | yes | yes |
| Versioning | yes | yes |
| Object Lock: bucket configuration, retention, legal hold | yes | yes |
| Default bucket encryption (SSE-S3) | yes, admin credentials only | yes |
| Public access block, ownership controls | yes | yes |
| Request payment | accepted, BucketOwner only, stored nowhere |
yes |
| Storage class | no | yes |
| Bucket notification | no | yes |
| Bucket website | no | yes |
| Bucket replication | no | across zones only |
RestoreObject, SelectObjectContent |
no | no |
Answered but not implemented. SeaweedFS serves a handful of endpoints purely
so an SDK’s startup probe does not fail: accelerate configuration always reports
Suspended, bucket logging returns an empty status, and the analytics, inventory,
intelligent-tiering and metrics configuration listings return empty lists. A 200
from any of those means the request was understood, not that anything was
configured.
What the module manages, and what it only passes through. The module drives
bucket creation, the bucket policy behind accessPolicy, quotas, versioning,
object lock and default encryption. Everything else in the table above is between
the application and the engine — the module neither sets nor watches it.
Where the module asks for something a backend cannot do, it says so on the
BucketContents rather than failing the bucket:
FeaturesApplied False Unsupported
backend SeaweedFS does not enforce: quota.maxObjects
That condition is the runtime half of this table: the table says what the engine
serves, and FeaturesApplied says what was actually enforced for one bucket.
Reclaim policy
-
Bucket
reclaimPolicy: Retain(the class default) — deleting theBucketkeeps the bucket and its objects. TheBucketContentsstays behind in phaseReleased, still recording whichBucketit belonged to and which store holds the data:d8 k get bktc # NAME STORE-KIND STORE OWNER-NS OWNER BUCKET PHASE READY AGE # contents-1f2e3d-... SeaweedFSStore default my-app app-data my-app-app-data Released False 4hCreate a
Bucketnamedapp-datainmy-appagain and it re-binds those contents, data and all. This is also what happens when the whole namespace is deleted: theBucketgoes with it, the contents do not.Deleteremoves the bucket and its objects instead, and the contents with them. ABucketthat names no policy takes the class’s; the class default isRetain. -
Deleting a
BucketAccessalways revokes its access key and removes its credentialsSecret(it does not touch bucket data). -
Store
reclaimPolicy: Retain(default) — deleting the store preserves persisted data: anSDSElasticStorekeeps its Ceph RGW pools, aSeaweedFSStoreleaves its PVCs in place.Deletedestroys it. -
Deleting an
ObjectStoreclass destroys nothing. It takes away the name tenants provision through; the store, and every bucket already in it, are untouched — which is whyBucketContentsrecords the store rather than the class.
To let go of retained data, delete the BucketContents object: with the owning Bucket gone, deleting it removes the bucket in the backend.
The reclaim policy decides what happens when the Bucket is deleted; deleting the BucketContents itself is a separate instruction, and it always means “let this data go” — it is the only handle left once the Bucket has gone. Deleting it while the Bucket still exists does not touch the data: the controller provisions the backing object again and re-adopts the bucket. A Bucket that is itself being deleted does not count as still existing — it will never re-adopt anything — so deleting the contents under one means the same as deleting them with the Bucket already gone.
Ceph RGW stores
An SDSElasticStore provisions a Ceph RADOS Gateway on top of an existing sds-elastic cluster, and takes Ceph’s own pool settings:
apiVersion: storage.deckhouse.io/v1alpha1
kind: SDSElasticStore
metadata:
name: heavy
spec:
elasticClusterRef: main
dataPool:
replicated:
size: 3
# or, instead of replication:
# erasureCoded: { dataChunks: 4, codingChunks: 2 }Then a class in front of it, exactly as above:
apiVersion: storage.deckhouse.io/v1alpha1
kind: ObjectStore
metadata:
name: capacity
spec:
storeRef:
kind: SDSElasticStore
name: heavyBuckets and access work the same whichever store is behind the class: for Ceph RGW the Bucket creates a per-bucket owner Rook CephObjectStoreUser and the bucket, and each BucketAccess gets its own CephObjectStoreUser granted on the bucket via a bucket policy.