OSC Ceph S3 Extension
Introduction
The OSC Ceph S3 Extension provides S3-compatible object storage for Gardener Shoot clusters backed by Ceph RadosGW. It enables users to create S3 users and buckets directly from their Shoot cluster using Kubernetes Custom Resources.
The extension manages the full lifecycle of storage resources: provisioning, credential management, quota enforcement, and deletion with configurable retention policies.
Enabling the Extension
To enable the Ceph S3 extension for a Shoot cluster, add the extension service ceph-s3 to the Shoot manifest:
apiVersion: core.gardener.cloud/v1beta1
kind: Shoot
metadata:
name: my-shoot
namespace: garden-my-project
spec:
extensions:
- type: ceph-s3
Check if the extension is active:
kubectl get cm -n kube-system shoot-info -o jsonpath='{.data.extensions}'
Disabling the Extension
To disable a globally enabled extension, set disabled: true:
apiVersion: core.gardener.cloud/v1beta1
kind: Shoot
metadata:
name: my-shoot
namespace: garden-my-project
spec:
extensions:
- type: ceph-s3
disabled: true
Warning
Disabling the extension does not delete existing S3 resources. Manage resource lifecycle explicitly before disabling.
Before you start
Warning
If you also run the legacy osc-s3-bucket-service MinIO extension, its S3User and S3Bucket Kinds share the s3user / s3bucket names with this extension (under the different API group storage.osc.extensions.gardener.cloud instead of ceph-s3's s3.osc.t-systems.com). A bare kubectl get s3user / kubectl get s3bucket is therefore ambiguous and may return the wrong extension's resources.
To avoid the collision, either use the fully-qualified name, e.g. kubectl get s3buckets.s3.osc.t-systems.com, or use the ceph-s3 short names s3u (for S3User) and s3b (for S3Bucket), which are unique to this extension and do not collide with the MinIO extension:
kubectl get s3u # ceph-s3 S3User
kubectl get s3b # ceph-s3 S3Bucket
The s3 and oscs3 short names, by contrast, belong to the legacy MinIO extension's S3Bucket — use those only when you specifically want that extension's resources.
Quick Start
Note
Unlike the MinIO S3 extension where a bucket carries its own credentials, this extension separates identity from storage. You must first create an S3User (which holds the credentials) and then create an S3Bucket bound to that user. The user's credentials grant access to all buckets it owns. This separation allows defining additional users with different access levels to one S3 Bucket. These users are listed in .spec.additionalUsers.
Step 1: Create an S3 User
Create a minimal S3User resource:
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: my-user
namespace: default
spec:
displayName: "My Application User"
tier: Tier3
Apply the manifest:
$ kubectl apply -f s3user.yaml
s3user.s3.osc.t-systems.com/my-user created
Step 2: Verify the User is Ready
$ kubectl get s3users
NAME PHASE CREDENTIALS SECRET MESSAGE AGE
my-user Ready Transferred my-user-credentials User provisioned, credentials transferred 17s
Step 3: Create an S3 Bucket
Create a bucket owned by the user:
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: my-bucket
namespace: default
spec:
userRef:
name: my-user
Apply the manifest:
$ kubectl apply -f s3bucket.yaml
s3bucket.s3.osc.t-systems.com/my-bucket created
Step 4: Verify the Bucket is Ready
$ kubectl get s3buckets
NAME NAMESPACE PHASE BUCKETNAME VERSIONING MESSAGE AGE
my-bucket default Ready my-bucket-7505d64a-54ef1f-dc99dd false Bucket is ready and accessible 6s
Step 5: Access Credentials
The S3 credentials are stored in a Kubernetes Secret referenced by the S3User status:
$ kubectl get s3user my-user -o jsonpath='{.status.secretName}'
my-user-credentials
Retrieve the credentials:
$ kubectl get secret my-user-credentials -o go-template='
ACCESS_KEY_ID: {{ .data.accessKeyID | base64decode }}
SECRET_ACCESS_KEY: {{ .data.secretAccessKey | base64decode }}
ENDPOINT: {{ .data.endpoint | base64decode }}
'
The secret contains the following fields:
accessKeyID- The S3 access key identifier for authentication
secretAccessKey- The S3 secret access key for authentication
endpoint- The S3-compatible endpoint URL for API requests
ca.crt- The CA certificate used to sign the S3 endpoint's TLS certificate. Required when connecting with TLS verification enabled. Present only when the storage backend uses a non-public CA.
Note
Credentials are automatically provisioned when the S3User reaches the Ready phase. The secret is created in the same namespace as the S3User resource. You can verify the namespace in the .status.secretNamespace property.
Important
Every S3User and S3Bucket is granted a quota that caps how much it can consume, drawn from your tenant's overall quota. See the Tenant Quotas section for how quotas are assigned, inherited, and enforced.
Accessing S3 Storage
Use the credentials from the secret with any S3-compatible client. The endpoint uses S3v4 signature and path-style addressing.
Important
Due to the zero-trust security model, listing buckets (aws s3 ls, mc ls s3/) returns no results even when buckets exist. Access is scoped per bucket — you must address the buckets by their exact provisioned name.
Retrieve the actual bucket name from the S3Bucket status:
$ kubectl get s3bucket my-bucket -o jsonpath='{.status.bucketName}'
my-bucket-7505d64a-54ef1f-dc99dd
Use that value as the bucket name in all S3 client operations.
Warning
The Ceph S3 endpoint is only reachable from inside the Shoot cluster. Run any S3 client from a pod or Job on the Shoot (for example a temporary kubectl run container), not from your local machine, unless you have arranged external network access to the endpoint.
Using the AWS CLI
export AWS_ACCESS_KEY_ID=$(kubectl get secret my-user-credentials -o jsonpath='{.data.accessKeyID}' | base64 -d)
export AWS_SECRET_ACCESS_KEY=$(kubectl get secret my-user-credentials -o jsonpath='{.data.secretAccessKey}' | base64 -d)
export S3_ENDPOINT=$(kubectl get secret my-user-credentials -o jsonpath='{.data.endpoint}' | base64 -d)
# Save CA certificate if present
kubectl get secret my-user-credentials -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/s3-ca.crt
# Get the provisioned bucket name
export BUCKET_NAME=$(kubectl get s3bucket my-bucket -o jsonpath='{.status.bucketName}')
# List objects in the bucket
aws s3 ls "s3://$BUCKET_NAME" --endpoint-url "$S3_ENDPOINT" --ca-bundle /tmp/s3-ca.crt
Using the MinIO CLI (mc)
export S3_ENDPOINT=$(kubectl get secret my-user-credentials -o jsonpath='{.data.endpoint}' | base64 -d)
export ACCESS_KEY=$(kubectl get secret my-user-credentials -o jsonpath='{.data.accessKeyID}' | base64 -d)
export SECRET_KEY=$(kubectl get secret my-user-credentials -o jsonpath='{.data.secretAccessKey}' | base64 -d)
# Save CA certificate if present
kubectl get secret my-user-credentials -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.mc/certs/CAs/s3-ca.crt
# Configure alias
mc alias set s3 "$S3_ENDPOINT" "$ACCESS_KEY" "$SECRET_KEY"
# Get the provisioned bucket name
export BUCKET_NAME=$(kubectl get s3bucket my-bucket -o jsonpath='{.status.bucketName}')
# List objects in the bucket
mc ls s3/$BUCKET_NAME
Deleting Resources
To delete a bucket:
kubectl delete s3bucket my-bucket
To delete a user:
kubectl delete s3user my-user
Warning
The default deletion policy is Retain. Resources are not immediately removed after kubectl delete — they enter a retention phase. See the Deletion and Retention section for details on how to control this behavior.
S3 Users
The S3User resource represents an S3 storage identity in your Shoot cluster. Each S3User maps to a dedicated user in the Ceph RadosGW storage backend with its own access credentials, quotas, and lifecycle management.
Creating an S3 User
A minimal S3User requires only a display name. The storage tier is optional — if omitted, the controller applies an environment-derived default (see Storage Tiers):
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: my-user
namespace: default
spec:
displayName: "My Application User"
tier: Tier3
$ kubectl apply -f s3user.yaml
s3user.s3.osc.t-systems.com/my-user created
Full example with all available fields:
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: my-user
namespace: default
spec:
displayName: "My Application User"
tier: Tier3
quotas:
buckets:
hard: 10
soft: 8
suspended: false
deletionPolicy: Retain
retentionExpiresAt: "2026-09-01T00:00:00Z"
Verify the user is ready:
$ kubectl get s3users
NAME PHASE CREDENTIALS SECRET MESSAGE AGE
my-user Ready Transferred my-user-credentials User provisioned, credentials transferred 17s
Storage Tiers
The storage tier determines the underlying hardware. The tier is immutable after creation.
tier is optional. If omitted, the controller applies a default: if the region allows exactly one tier, that tier is
used; otherwise the operator-configured default tier is used. Check status or spec.tier
after creation to see which tier was applied.
Currently supported tiers:
| Tier | Storage type |
|---|---|
Tier3 |
HDD |
Warning
The tier field cannot be changed after the S3User is created. To switch tiers, create a new S3User with the desired tier and migrate your data.
Note
All S3Buckets owned by or shared with this user must use the same tier. Cross-tier access is not supported because different tiers may use different physical storage clusters.
Bucket Quotas
You can limit how many buckets a user is allowed to own using the quotas.buckets configuration:
- hard: Maximum bucket count. Enforced by the storage backend — creating a bucket beyond this limit fails.
- soft: Optional warning threshold. When set, a Kubernetes warning event is emitted, and a status condition is set once usage reaches it (see Quota Warning Events). It does not block operations.
When set, soft must be strictly less than hard; otherwise the resource is rejected on apply.
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: limited-user
namespace: default
spec:
displayName: "Limited User"
tier: Tier3
quotas:
buckets:
hard: 5
soft: 4
If quotas is omitted, the user inherits the full tenant quota.
Checking the Granted Quota
When a bucket quota is set, the value actually approved by the storage backend appears in
status.grantedQuota once the reservation succeeds:
$ kubectl get s3user limited-user -o jsonpath='{.status.grantedQuota}'
{"maxBuckets":5}
The granted value can differ from the requested spec.quotas — for example when you increase
the limit but the additional buckets no longer fit into the remaining tenant quota. In that case
spec.quotas shows the requested value while status.grantedQuota keeps the previously approved
one, so comparing the two tells you whether an increase was actually granted. When no quota is
set on the user, status.grantedQuota is absent.
Suspending a User
Setting suspended: true immediately blocks all S3 API requests for this user at the storage backend level. Credentials
remain valid and are not changed — access is simply denied.
kubectl patch s3user my-user --type=merge -p '{"spec":{"suspended":true}}'
To restore access:
kubectl patch s3user my-user --type=merge -p '{"spec":{"suspended":false}}'
Use cases for suspension:
- Incident response (immediately revoke access without deleting data)
- Maintenance windows
- Billing holds
- Compliance freezes
The user phase changes to Suspended while suspended and returns to Ready once unsuspended.
Credentials
When an S3User reaches the Ready phase, credentials are automatically delivered to a Kubernetes Secret in the same namespace.
Retrieve the secret name and namespace from the status:
$ kubectl get s3user my-user -o jsonpath='{.status.secretName}'
my-user-credentials
$ kubectl get s3user my-user -o jsonpath='{.status.secretNamespace}'
default
Both status.secretName and status.secretNamespace are set together when credentials are available. Use them to locate the secret precisely, especially when the S3User is in a different namespace than expected.
The secret contains the following keys:
| Key | Description |
|---|---|
accessKeyID |
S3 access key identifier for authentication |
secretAccessKey |
S3 secret access key for authentication |
endpoint |
S3-compatible endpoint URL for API requests |
ca.crt |
CA certificate for TLS verification (present only when the storage backend uses a non-public CA) |
configHash |
Revision marker for endpoint and ca.crt; changes whenever either of them is updated |
Retrieve credentials:
$ kubectl get secret my-user-credentials -o go-template='
ACCESS_KEY_ID: {{ .data.accessKeyID | base64decode }}
SECRET_ACCESS_KEY: {{ .data.secretAccessKey | base64decode }}
ENDPOINT: {{ .data.endpoint | base64decode }}
'
Note
The credential secret is protected by a finalizer (s3.osc.t-systems.com/credential-secret), which prevents accidental deletion while the S3User is active. The finalizer is removed automatically when the S3User itself is deleted, after which the secret is cleaned up.
If the secret is forcefully deleted (e.g. by bypassing the finalizer with kubectl delete secret <name> --grace-period=0), the extension detects the missing secret and automatically re-triggers credential issuance. No manual action is required — the secret is recreated within the next reconcile cycle.
Endpoint and Certificate Updates
The endpoint and ca.crt values are not fixed for the lifetime of the S3User. When the platform operator changes
the storage backend's endpoint or renews its TLS certificate, the updated values are written into your existing
credential secret automatically. The secret keeps the same name and the keypair is untouched — this is not a rotation,
and no action is required from you.
Warning
Read the credential secret at use time, not only at pod start. A workload that copies ca.crt or endpoint
into memory once, or bakes it into an image, keeps using the old value and will fail TLS verification after a
certificate renewal. Mount the secret as a volume (kubelet refreshes mounted secrets in place) rather than
injecting it through env/envFrom, which is only evaluated when the container starts.
The configHash key changes whenever endpoint or ca.crt changes, so a workload that needs to react to an update
can watch that single value instead of comparing certificates:
kubectl get secret my-user-credentials -o jsonpath='{.data.configHash}' | base64 -d
If the storage backend stops using TLS, or its CA is removed from the configuration, the ca.crt key is deleted
from the secret, so a certificate that is no longer in use cannot be trusted by mistake.
You can observe an endpoint or certificate update on the CredentialsConfigChanged condition, which reports
ConfigChangeInProgress while the new configuration is being delivered to your secret and ConfigChangeCompleted once
it has landed:
kubectl get s3user my-user -o jsonpath='{.status.conditions[?(@.type=="CredentialsConfigChanged")].reason}'
Rotating Credentials
You can rotate an S3User's credentials in place by annotating it. The credential secret keeps the same name; only the
accessKeyID and secretAccessKey values change. The endpoint and CA certificate are unaffected.
Request a rotation:
kubectl annotate s3user my-user s3.osc.t-systems.com/rotation-requested=true
Rotation is zero-downtime: the old key stays valid until the new credentials are confirmed delivered to the credential secret (or a configured grace period elapses), so a running workload never loses access during the rotation. Once the new credentials are in the secret, restart or reload any workload that cached the old values (if you use secretKeyRef directly):
kubectl rollout restart deployment/my-app
Track progress with the CredentialsRotation condition and the status.lastRotationTime field:
$ kubectl get s3user my-user -o jsonpath='{.status.conditions[?(@.type=="CredentialsRotation")].reason}'
RotationInProgress
# once the new credentials are delivered to the secret, while the previous key
# stays valid until the rotation policy's revocation gate opens:
RotationSettling
# after completion:
RotationCompleted
During RotationSettling the secret already holds the new credentials; the condition message names what the
rotation is still waiting for before revoking the previous key (the settle window, or your confirmation under
the manual policy).
kubectl get s3user my-user -o jsonpath='{.status.conditions[?(@.type=="CredentialsRotation")].message}'
When the rotation completes, the s3.osc.t-systems.com/rotation-requested annotation is removed automatically,
s3.osc.t-systems.com/last-rotation-at records the completion time, and status.lastRotationTime is updated. To rotate
again, re-add the annotation.
Note
A rotation request is processed once. If you re-add the annotation while a rotation is still in progress, it is ignored
until the current rotation finishes — the CredentialsRotation condition does not report RotationCompleted until
then. Only one rotation runs at a time per user.
Rotation policy
The s3.osc.t-systems.com/rotation-policy annotation selects how the previous key is revoked once the new credentials are delivered.
Set it alongside the rotation request:
kubectl annotate s3user my-user s3.osc.t-systems.com/rotation-policy=manual
kubectl annotate s3user my-user s3.osc.t-systems.com/rotation-requested=true
Warning
Always make sure the rotation-policy annotation is set before requesting a rotation. If not set, it defaults to auto.
auto(default)-
The previous key is revoked after a settle window elapses following delivery of the new credentials. The
CredentialsRotationcondition message names the settle period. manual-
The previous key stays valid until you confirm that your workloads have adopted the new key. Confirm by passing the new access key ID:
kubectl annotate s3user my-user s3.osc.t-systems.com/rotation-confirm=<new-accessKeyID>
The confirmation is scoped to the access key ID, so a confirmation recorded for an earlier rotation can never silently confirm a later one.
If the confirmation names a key that is not the newly published one, the rotation does not complete: the
CredentialsRotation condition reports RotationConfirmMismatch and its message names the value you applied. The
expected key is deliberately not included — the confirmation proves the rotated credentials actually arrived in your
secret. Read the accessKeyID from your credential secret, verify your workloads use it, and re-apply the annotation
with that value.
forced-
The previous key is revoked as soon as delivery of the new credentials is confirmed.
The rotation-policy and rotation-confirm annotations only take effect together with a rotation request: set alone,
they stay on the S3User and are not acted on until you also set s3.osc.t-systems.com/rotation-requested=true. When
the rotation completes, both annotations are removed automatically along with the request — each rotation chooses its
policy explicitly, so a manual or forced choice from an earlier rotation is never silently reused by the next one.
If a rotation makes no visible progress, check the CredentialsRotation condition: it reports RotationStalled when
new credentials are waiting on a delivery or adoption confirmation, and RotationRejected when the request named an
unknown rotation policy.
Credential Lifecycle Events
In addition to the conditions above, the S3User records a Kubernetes Event the moment its
CredentialsRotation/CredentialsConfigChanged condition transitions, giving you a timestamped history in
kubectl describe. Each event fires exactly when the corresponding condition reaches that state:
| Event reason | Fires when the condition reaches | Meaning |
|---|---|---|
CredentialConfigUpdated |
CredentialsConfigChanged = ConfigChangeCompleted |
An endpoint or CA certificate change was delivered to the secret. |
CredentialsRotated |
CredentialsRotation = RotationSettling |
The new access key and secret key were delivered to the secret; the previous key is still valid during the settle window. |
CredentialsRevoked |
CredentialsRotation = RotationCompleted |
The previous key has been revoked; only the current key in the secret is valid. |
$ kubectl describe s3user my-user
...
Events:
Type Reason Age From Message
Normal CredentialsRotated 15m ceph-s3-shoot-controller Credentials rotated: the credential secret now holds the new access key and secret key
Normal CredentialsRevoked 10s ceph-s3-shoot-controller Previous credentials revoked: only the current key in the secret is valid
Under the auto and manual policies the two rotation events are separated by the settle window (the default is
15 minutes for auto): CredentialsRotated fires when the new key lands in the secret, and CredentialsRevoked fires
later when the old key is revoked. The forced policy revokes immediately without a settle window, so there is no
RotationSettling state — only CredentialsRevoked is recorded.
Deleting a User
kubectl delete s3user my-user
Note
With the default Retain deletion policy, the user enters the Retention phase instead of being immediately removed. See the Deletion and Retention section.
To configure immediate deletion without retention:
spec:
deletionPolicy: Delete
To schedule automatic purge after a date:
spec:
deletionPolicy: Retain
retentionExpiresAt: "2027-01-01T00:00:00Z"
When set, the value is mirrored to status.retentionExpiresAt so you can always read the active expiry from the resource status:
$ kubectl get s3user my-user -o jsonpath='{.status.retentionExpiresAt}'
2027-01-01T00:00:00Z
If retentionExpiresAt is omitted with Retain policy, the resource enters PendingPurge state with a default 7-day
grace period from the deletion date. The computed expiry date is shown in status.message. You can also apply
the force-delete annotation to purge immediately without waiting.
S3 Buckets
The S3Bucket resource represents an S3 storage bucket in your Shoot cluster. Each bucket is owned by an S3User and can optionally grant access to additional users with configurable permissions.
Creating a Bucket
A minimal bucket requires only a reference to its owner:
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: my-bucket
namespace: default
spec:
userRef:
name: my-user
$ kubectl apply -f s3bucket.yaml
s3bucket.s3.osc.t-systems.com/my-bucket created
Full example with all available fields:
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: my-bucket
namespace: default
spec:
userRef:
name: my-user
namespace: other-namespace
additionalUsers:
- userRef:
name: reader-user
permissions: ReadOnly
- userRef:
name: writer-user
namespace: other-namespace
permissions: ReadWrite
deletionPolicy: Retain
retentionExpiresAt: "2026-09-01T00:00:00Z"
quotas:
storage:
hard: "100Gi"
soft: "80Gi"
objects:
hard: 1000000
soft: 800000
Requirements:
- The referenced owner (
userRef) must exist and be inReadyphase - If you use
additionalUsers, all users (owner and additional) must share the same storage tier
Verify the bucket is ready:
$ kubectl get s3buckets
NAME NAMESPACE PHASE BUCKETNAME VERSIONING MESSAGE AGE
my-bucket default Ready my-bucket-7505d64a-54ef1f-dc99dd false Bucket is ready and accessible 6s
Sharing Access with Additional Users
You can grant other S3Users access to a bucket with configurable permission levels:
graph LR
subgraph S3 Bucket
B[("my-bucket")]
end
subgraph S3 Users
O["owner-user"]
R["reader-user"]
W["writer-user"]
U["uploader-user"]
end
O -- "FullAccess (owner)" --> B
R -- "ReadOnly" --> B
W -- "ReadWrite" --> B
U -- "WriteOnly" --> B
Permission Levels
| Permission | Read Objects | Write Objects | Delete Objects |
|---|---|---|---|
ReadOnly |
Yes | No | No |
ReadWrite |
Yes | Yes | Yes |
WriteOnly |
No | Yes | No |
Example: Shared Bucket
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: shared-bucket
namespace: default
spec:
userRef:
name: owner-user
additionalUsers:
- userRef:
name: reader-user
permissions: ReadOnly
- userRef:
name: writer-user
permissions: ReadWrite
- userRef:
name: uploader-user
permissions: WriteOnly
Validation Rules
- The bucket owner cannot be listed as an additional user (owner already has full access)
- All additional users must be in the same tier as the bucket owner
- All referenced users must exist
- User names cannot be empty
Adding and Removing Users
Add an additional user:
kubectl patch s3bucket shared-bucket --type=json -p '[
{"op": "add", "path": "/spec/additionalUsers/-", "value": {
"userRef": {"name": "new-reader"},
"permissions": "ReadOnly"
}}
]'
Remove an additional user (by index):
kubectl patch s3bucket shared-bucket --type=json -p '[
{"op": "remove", "path": "/spec/additionalUsers/0"}
]'
Cross-Namespace Ownership
Buckets can reference an S3User in a different namespace. This enables multi-team setups where a central user owns storage used across namespaces:
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: team-bucket
namespace: team-b
spec:
userRef:
name: shared-user
namespace: team-a
additionalUsers:
- userRef:
name: team-b-user
namespace: team-b
permissions: ReadWrite
If namespace is omitted in userRef or additionalUsers[].userRef, it defaults to the bucket's own namespace.
Storage and Object Quotas
You can limit how much storage a bucket consumes and how many objects it can hold:
- hard: Maximum limit. Enforced by the storage backend — operations fail when the limit is reached.
- soft: Optional warning threshold. When set, a Kubernetes warning event is emitted, and a status condition is set once usage reaches it (see Quota Warning Events). It does not block operations.
When set, soft must be strictly less than hard; otherwise the resource is rejected on apply.
Storage Quota
Uses Kubernetes resource quantity format (e.g., "10Gi", "500Mi", "1Ti"):
spec:
quotas:
storage:
hard: "50Gi"
soft: "40Gi"
Object Count Quota
Limits the total number of objects in the bucket:
spec:
quotas:
objects:
hard: 500000
soft: 400000
Full Quota Example
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: limited-bucket
namespace: default
spec:
userRef:
name: my-user
quotas:
storage:
hard: "100Gi"
soft: "80Gi"
objects:
hard: 1000000
soft: 800000
If quotas is omitted, the bucket inherits the full tenant quota.
Checking the Granted Quota
When storage or object quotas are set, the values actually approved by the storage backend appear
in status.grantedQuota once the reservation succeeds:
$ kubectl get s3bucket limited-bucket -o jsonpath='{.status.grantedQuota}'
{"maxSize":"100Gi","maxObjects":1000000}
The granted values can differ from the requested spec.quotas — for example when you increase a
limit but the additional capacity no longer fits into the remaining tenant quota. In that case
spec.quotas shows the requested values while status.grantedQuota keeps the previously approved
ones, so comparing the two tells you whether an increase was actually granted. When no quota is
set on the bucket, status.grantedQuota is absent.
Bucket Name
The actual bucket name in the storage backend differs from the Kubernetes resource name. This is by design for global uniqueness and the zero-trust security model.
Retrieve the provisioned bucket name:
$ kubectl get s3bucket my-bucket -o jsonpath='{.status.bucketName}'
my-bucket-7505d64a-54ef1f-dc99dd
Important
Due to the zero-trust security model, listing buckets (aws s3 ls, mc ls s3/) returns no results. You must address buckets by their exact provisioned name from status.bucketName in all S3 client operations.
Deleting a Bucket
kubectl delete s3bucket my-bucket
Note
With the default Retain deletion policy, the bucket enters the Retention phase instead of being immediately removed. See the Deletion and Retention section.
To configure immediate deletion (bucket and all its data are permanently removed):
spec:
deletionPolicy: Delete
To schedule automatic purge after a date:
spec:
deletionPolicy: Retain
retentionExpiresAt: "2027-01-01T00:00:00Z"
When set, the value is mirrored to status.retentionExpiresAt so you can always read the active expiry from the resource status:
$ kubectl get s3bucket my-bucket -o jsonpath='{.status.retentionExpiresAt}'
2027-01-01T00:00:00Z
If retentionExpiresAt is omitted with Retain policy, the resource enters PendingPurge state with a default 30-days
grace period from the deletion date. The computed expiry date is shown in status.message. You can also apply the
force-delete annotation to purge immediately without waiting.
Bucket Versioning
You can manage versioning for your S3 bucket using MinIO Client (mc) via a temporary pod.
Checking Versioning Status via Kubernetes
The current versioning state of your bucket is automatically reflected in the S3Bucket resource status. You can check it without connecting directly to the S3 endpoint.
kubectl get s3buckets.s3.osc.t-systems.com -o wide
The output looks like this:
NAME NAMESPACE PHASE BUCKETNAME VERSIONING MESSAGE AGE
mybucket kube-system Ready mybucket-e853ff73-64c8c7-7a10dd false Bucket is ready and accessible 15m
Managing Versioning
Enable versioning:
mc version enable mybucket/$BUCKET
mybucket/mybucket-e853ff73-64c8c7-7a10dd versioning is enabled
Once the controller reconciles the S3Bucket, the status.versioningEnabled field reflects the actual versioning state:
NAME NAMESPACE PHASE BUCKETNAME VERSIONING MESSAGE AGE
mybucket kube-system Ready mybucket-e853ff73-64c8c7-7a10dd true Bucket is ready and accessible 20m
Note
The status.versioningEnabled field is propagated through the controllers' sync chain, so there may be a delay before it reflects changes applied via the S3 API.
Suspend versioning:
mc version suspend mybucket/$BUCKET
mybucket/mybucket-e853ff73-64c8c7-7a10dd versioning is suspended
Check the current versioning status from API:
mc version info mybucket/$BUCKET
mybucket/mybucket-e853ff73-64c8c7-7a10dd versioning is suspended
Bucket Encryption
Currently, S3Bucket supports only SSE-C (Server-Side Encryption with Customer-Provided Keys), which enables object encryption using keys managed by the user.
SSE-C Customer-Provided Keys
This mode is implemented according to the Amazon SSE-C specification.
Warning
Encryption key management is your responsibility. Losing a key means losing access to the encrypted data.
Considerations Before Using SSE-C
- Downloading SSE-C encrypted data requires the same encryption key that was used during upload.
- A reliable mapping between objects and encryption keys is required. If a key is lost, access to the corresponding object is lost.
- Because key management is client-side, safeguards such as key rotation are also client-side responsibilities.
- HTTPS is required for all requests.
- In versioning-enabled buckets, each object version can use a different encryption key, so key-to-object-version mapping must be tracked.
Prerequisites
mc(MinIO Client) configured with your bucket alias or theawsCLIopensslavailable on your system
Usage
-
Generate an encryption key:
KEY=$(openssl rand -hex 32) echo $KEY -
Upload a file using a key:
mc cp file.txt myminio/mybucket/file.txt \ --enc-c "myminio/mybucket=$KEY" -
Download a file:
You must supply the same key used during upload:
mc cp myminio/mybucket/file.txt downloaded.txt \ --enc-c "myminio/mybucket=$KEY"Attempting to download without the key returns a
400 Bad Requesterror:mc cp myminio/mybucket/file.txt downloaded.txt mc: <ERROR> Unable to prepare URL for copying. 400 Bad Request -
Verify object metadata:
mc stat myminio/mybucket/file.txt \ --enc-c "myminio/mybucket=$KEY"The output confirms the encryption mode:
Name : file.txt Date : 2026-05-27 11:58:04 UTC Size : 0 B ETag : d41d8cd98f00b204e9800998ecf8427e Type : file Encryption: SSE-C Metadata : Content-Type: text/plain
Warning
Store keys in a secrets manager (e.g., Vault, AWS Secrets Manager) or an encrypted keystore. Never commit them to version control.
Per-Object Keys
SSE-C supports a different key per object (and per version):
KEY_A=$(openssl rand -hex 32)
KEY_B=$(openssl rand -hex 32)
touch log.A
touch log.B
mc cp log.A myminio/mybucket/report.pdf --enc-c "myminio/mybucket=$KEY_A"
mc cp log.B myminio/mybucket/secrets.json --enc-c "myminio/mybucket=$KEY_B"
Warning
Key-to-object mapping is your responsibility. In SSE-C mode, there is no server-side record of which key was used for which object — if the mapping is lost, the encrypted data cannot be recovered.
Cross-Versioning Example
When bucket versioning is enabled, each new upload to the same object path creates a new object version. With SSE-C, each version can be encrypted with a different key.
-
Ensure bucket versioning is enabled:
mc version enable myminio/mybucket -
Create version-specific keys:
KEY_V1=$(openssl rand -hex 32) KEY_V2=$(openssl rand -hex 32) -
Upload version 1 of the same object path:
echo '{"revision":"v1"}' > app-config.json mc cp app-config.json myminio/mybucket/app-config.json \ --enc-c "myminio/mybucket=$KEY_V1" -
Upload version 2 of the same object path using a different key:
echo '{"revision":"v2"}' > app-config.json mc cp app-config.json myminio/mybucket/app-config.json \ --enc-c "myminio/mybucket=$KEY_V2" -
List object versions and collect the version IDs:
mc ls --versions myminio/mybucket/app-config.json [2026-05-27 14:08:06 CEST] 18B STANDARD WGcpjBHX2VYz0C3WvhZtFwD5hZ219kx v2 PUT app-config.json [2026-05-27 14:07:22 CEST] 18B STANDARD 48clq5r7UzO1Yd7JysbFlhAU3DItguH v1 PUT app-config.json
Download each version with its matching key:
# Replace with actual IDs from: mc ls --versions myminio/mybucket/app-config.json
export VERSION_ID_V1="<version-id-v1>"
export VERSION_ID_V2="<version-id-v2>"
mc cp --version-id "$VERSION_ID_V1" myminio/mybucket/app-config.json app-config-v1.json \
--enc-c "myminio/mybucket=$KEY_V1"
mc cp --version-id "$VERSION_ID_V2" myminio/mybucket/app-config.json app-config-v2.json \
--enc-c "myminio/mybucket=$KEY_V2"
Verify:
cat app-config-v1.json
cat app-config-v2.json
If an incorrect key is used, the request returns HTTP 400 Bad Request:
mc cp --version-id "$VERSION_ID_V2" myminio/mybucket/app-config.json app-config-v2.json \
--enc-c "myminio/mybucket=$KEY_V1"
mc: <DEBUG> HTTP/1.1 400 Bad Request
Expected behavior:
- A version can only be decrypted with the key used for that version.
- Using the wrong key (or no key) for a specific version fails with a request error.
- Key-to-version mapping must be preserved, not only key-to-object mapping.
Key Rotation for SSE-C
SSE-C does not support in-place key rotation. To change the encryption key for an object, download it with the current key and re-upload it with the new key. When versioning is enabled, each version is independently encrypted — old versions remain accessible only with their original key, so either retain the old keys or re-upload the versions you need.
Key rotation using the mc CLI:
-
Set variables
ALIAS="kind" BUCKET="cu-0000000000-bucket" OBJECT="file.txt" TEMP_FILE="/tmp/rotation-temp-$(date +%s)" -
Generate keys
OLD_KEY=$(openssl rand -hex 32) NEW_KEY=$(openssl rand -hex 32) echo "OLD_KEY: $OLD_KEY" echo "NEW_KEY: $NEW_KEY" -
Create and upload test file with OLD_KEY
echo "hello rotation test" > localfile.txt mc cp \ --enc-c "$ALIAS/$BUCKET/$OBJECT=$OLD_KEY" \ localfile.txt \ "$ALIAS/$BUCKET/$OBJECT" -
Verify OLD_KEY works
mc stat \ --enc-c "$ALIAS/$BUCKET/$OBJECT=$OLD_KEY" \ "$ALIAS/$BUCKET/$OBJECT" -
Download to a local temp file using OLD_KEY
Ceph RGW does not support SSE-C on
CopyObject(501 NotImplemented), so key rotation requires a local download first.mc cp \ --enc-c "$ALIAS/$BUCKET/$OBJECT=$OLD_KEY" \ "$ALIAS/$BUCKET/$OBJECT" \ "$TEMP_FILE" -
Re-upload from local temp file with NEW_KEY
mc cp \ --enc-c "$ALIAS/$BUCKET/$OBJECT=$NEW_KEY" \ "$TEMP_FILE" \ "$ALIAS/$BUCKET/$OBJECT" -
Remove local temp file
rm "$TEMP_FILE" -
Verify NEW_KEY works
mc stat \ --enc-c "$ALIAS/$BUCKET/$OBJECT=$NEW_KEY" \ "$ALIAS/$BUCKET/$OBJECT" -
Verify OLD_KEY is rejected
mc stat \ --enc-c "$ALIAS/$BUCKET/$OBJECT=$OLD_KEY" \ "$ALIAS/$BUCKET/$OBJECT" \ && echo "OLD_KEY still works!"
Using the REST API and AWS SDKs
The S3Bucket endpoint is compatible with the AWS SSE-C request model, so SSE-C can be implemented through direct REST API calls or AWS SDKs, with key management handled directly in your application.
Required SSE-C Request Headers
For SSE-C encryption/decryption requests, include the following headers:
x-amz-server-side-encryption-customer-algorithm(must beAES256)x-amz-server-side-encryption-customer-key(base64-encoded 256-bit key)x-amz-server-side-encryption-customer-key-MD5(base64-encoded MD5 of the key)
For copy operations where the source object is SSE-C encrypted, include source decryption headers as well:
x-amz-copy-source-server-side-encryption-customer-algorithmx-amz-copy-source-server-side-encryption-customer-keyx-amz-copy-source-server-side-encryption-customer-key-MD5
SDK and Multipart Notes
- SDKs can attach SSE-C information to PUT, GET, HEAD, and COPY requests.
- For multipart uploads, encryption settings must be provided consistently across multipart requests.
- The same key used for upload must be provided for subsequent download/metadata operations.
- If the key is incorrect or missing, the request fails (
400 Bad Request). - TLS required: non-HTTPS requests will be rejected with an
InvalidRequesterror.
Go SDK Example
The following snippets demonstrate SSE-C key management using the AWS SDK for Go v2. These are usage examples only — production implementations should add proper error handling, key persistence, and secrets management.
Key generation and encoding
Every SSE-C request requires three values derived from the raw key: the base64-encoded key, its base64-encoded MD5 checksum, and the algorithm name AES256.
import (
"crypto/md5"
"crypto/rand"
"encoding/base64"
)
func generateSSECKey() ([]byte, error) {
key := make([]byte, 32) // 256-bit AES key
_, err := rand.Read(key)
return key, err
}
func encodeKey(key []byte) (keyB64 string, keyMD5 string) {
keyB64 = base64.StdEncoding.EncodeToString(key)
md5sum := md5.Sum(key)
keyMD5 = base64.StdEncoding.EncodeToString(md5sum[:])
return
}
Upload (PutObject)
keyB64, keyMD5 := encodeKey(key)
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
Body: strings.NewReader(content),
ContentLength: aws.Int64(int64(len(content))),
SSECustomerAlgorithm: aws.String("AES256"),
SSECustomerKey: aws.String(keyB64),
SSECustomerKeyMD5: aws.String(keyMD5),
})
Download (GetObject)
The same three SSE-C fields are required on every read request:
keyB64, keyMD5 := encodeKey(key)
resp, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
SSECustomerAlgorithm: aws.String("AES256"),
SSECustomerKey: aws.String(keyB64),
SSECustomerKeyMD5: aws.String(keyMD5),
})
Head (metadata only)
resp, err := client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
SSECustomerAlgorithm: aws.String("AES256"),
SSECustomerKey: aws.String(keyB64),
SSECustomerKeyMD5: aws.String(keyMD5),
})
Key rotation for SSE-C
The workaround is to download the object with the current key and re-upload it with the new key:
-
Download with the source key:
data, err := getObjectBytes(ctx, client, srcKey, `<oldKey>`) -
Re-upload with a new key:
dstKeyB64, dstKeyMD5 := encodeKey(dstKey) _, err = client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(`<newKey>`), Body: bytes.NewReader(data), ContentLength: aws.Int64(int64(len(data))), SSECustomerAlgorithm: aws.String("AES256"), SSECustomerKey: aws.String(dstKeyB64), SSECustomerKeyMD5: aws.String(dstKeyMD5), })
S3 client setup (Ceph-specific options)
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true // required for Ceph
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
})
Other SDK References
For Java, .NET, and other SDKs, see the official AWS documentation:
- Specifying SSE-C (AWS User Guide)
- Using AWS SDKs for SSE-C operations
- Using AWS SDKs for SSE-C multipart uploads
Deletion and Retention
The Ceph S3 extension provides configurable deletion policies and retention mechanisms to protect against accidental data loss. By default, all resources use the Retain policy, which blocks deletion until a retention expiry date is reached (either user-specified or grace period default). You can customize retention by setting spec.retentionExpiresAt, cancel a pending deletion with the s3.osc.t-systems.com/recovery-requested=true annotation, or bypass retention entirely with the s3.osc.t-systems.com/force-delete=true annotation.
Deletion Policies
Retain (Default)
The Retain policy blocks deletion until a retention expiry date is reached. The retention period ends when one of the following conditions is met:
- The
retentionExpiresAttimestamp is reached (user-specified or default period) - The
s3.osc.t-systems.com/force-delete=trueannotation is applied
When a Retain-policy resource is deleted, it enters the Retention phase. During retention:
- The resource remains visible (
kubectl get s3users/kubectl get s3buckets) - The resource has a
DeletionTimestampbut is not yet removed - Data remains accessible until the retention period expires
- The resource cannot be recreated with the same name until purged
- Retention always has an expiry date (either user-specified or backend default)
- The deletion can be canceled with the
recovery-requestedannotation (see Recovery)
- S3User: The user is suspended — S3 access is blocked, credentials stop working
- S3Bucket: Data remains accessible via other valid credentials until the resource is purged
spec:
deletionPolicy: Retain
retentionExpiresAt: "2027-01-01T00:00:00Z" # optional: auto-purge after this date
Delete
The Delete policy removes the resource immediately with no grace period.
- S3User: User identity and credentials are permanently removed
- S3Bucket: Bucket and all its data are permanently removed, regardless of content
spec:
deletionPolicy: Delete
Warning
Resources with Delete policy cannot be recovered after deletion. Use with caution in production environments.
Retention State Machine
When a resource with Retain policy is deleted, it transitions through the following states:
stateDiagram-v2
[*] --> None: Resource created
None --> PendingPurge: kubectl delete (Retain policy)
PendingPurge --> None: recovery-requested annotation
PendingPurge --> Purging: retentionExpiresAt reached
PendingPurge --> Purging: force-delete annotation
Purging --> [*]: Kubernetes object deleted
None --> [*]: kubectl delete (Delete policy)
Retention States
None- Normal operation. Resource is not in any retention lifecycle.
PendingPurge- Deletion was requested. The resource is blocked from permanent removal until the retention expiry date is reached (either user-specified via
spec.retentionExpiresAtor storage backend default synced tostatus.retentionExpiresAt). Therecovery-requestedannotation can cancel the deletion, and theforce-deleteannotation can bypass retention for immediate purge. Purging- The retention expiry date has been reached or the
force-deleteannotation was applied. The resource data is being removed from storage backend and the Kubernetes object will be deleted shortly.
Recovery: Canceling a Pending Deletion
While a resource is still inside its retention window (status.retentionState: PendingPurge), cancel the deletion by applying the recovery-requested annotation:
kubectl annotate s3user my-user s3.osc.t-systems.com/recovery-requested=true
kubectl annotate s3bucket my-bucket s3.osc.t-systems.com/recovery-requested=true
The resource converges back to the Ready phase within a few seconds, with the same name, spec, and underlying storage data:
- S3User: The suspension is lifted — S3 access works again with the same credentials; the credential secret is re-published
- S3Bucket: The bucket and all its contents remain exactly as they were before deletion
Only the Kubernetes UID of the resource changes, because a deletion in progress cannot be undone in place — the resource is recreated with an identical spec.
Requirements:
- The resource must be in
status.retentionState: PendingPurge(inside the retention window) - The annotation value must be exactly
"true"
Note
Removing metadata.deletionTimestamp directly (for example via kubectl patch) does not work and is not a supported recovery method — the Kubernetes API server does not permit unsetting a deletion timestamp once recorded. The recovery-requested annotation is the only supported mechanism.
Warning
Recovery only works while the resource is still inside its retention window. Once retention expires or force-delete triggers the purge (status.retentionState: Purging and beyond), the underlying storage data is gone and recovery is no longer possible. Recreate the resource and restore data from backups instead.
Force Delete
The force-delete annotation bypasses all retention policies and triggers immediate purge.
kubectl annotate s3user my-user s3.osc.t-systems.com/force-delete=true
kubectl annotate s3bucket my-bucket s3.osc.t-systems.com/force-delete=true
Warning
Force-delete is irreversible. The resource and all associated data are permanently removed, including non-empty buckets. Use only when you are certain the data is no longer needed.
Requirements:
- The resource must already have a
DeletionTimestampset (i.e.,kubectl deletewas already issued) - The annotation value must be exactly
"true"
Workflow:
- Delete the resource (enters retention):
kubectl delete s3user my-user - Verify the resource is in retention:
kubectl get s3user my-user -o jsonpath='{.status.retentionState}' - Apply force-delete to bypass retention:
kubectl annotate s3user my-user s3.osc.t-systems.com/force-delete=true
Events
The system emits Kubernetes events during deletion lifecycle transitions:
| Event | Meaning |
|---|---|
Deleting |
Delete policy resource deleted with no prior retention state recorded — proceeding immediately with no retention |
PendingPurge |
Deletion entered an active timed retention window |
RetentionExpired |
Retention expiry date reached, automatic purge proceeding |
ForceDeleted |
Force-delete annotation applied, bypassing retention |
Purging |
Resource data being removed from storage backend |
Purged |
Resource fully removed from Kubernetes and storage, finalizer released |
Each event is emitted only once, on the transition into that state — not on every reconcile loop.
View events:
$ kubectl describe s3user my-user
Events:
Type Reason Age Message
---- ------ ---- -------
Normal PendingPurge 5m Deletion blocked: active retention window, awaiting expiry
Warning RetentionExpired 1s Retention period expired: proceeding with deletion
Practical Examples
Standard Deletion with Retain Policy
# Create user with Retain policy and for e.g. 90-day retention period
$ cat <<EOF | kubectl apply -f -
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: compliance-user
namespace: default
spec:
displayName: "Compliance User"
deletionPolicy: Retain
retentionExpiresAt: "2027-03-15T00:00:00Z"
tier: Tier3
EOF
# Delete the user (enters retention blocked state)
$ kubectl delete s3user compliance-user
s3user.s3.osc.t-systems.com "compliance-user" deleted
# Check retention state (blocked until expiry)
$ kubectl get s3user compliance-user -o jsonpath='{.status.retentionState}'
PendingPurge
#Verify status
# kubectl get s3user compliance-user
...
message: 'User soft-deleted, retention expires 2027-03-15T00:00:00Z'
phase: Retention
retentionExpiresAt: "2027-03-15T00:00:00Z"
retentionState: PendingPurge
suspended: true
# Option 1: Wait for retentionExpiresAt to be reached (automatic purge)
# Option 2: Force-delete to bypass retention
$ kubectl annotate s3user compliance-user s3.osc.t-systems.com/force-delete=true --overwrite
# Option 3: Cancel the deletion and recover the user
$ kubectl annotate s3user compliance-user s3.osc.t-systems.com/recovery-requested=true
Recovering an Accidentally Deleted User
# The user was deleted and entered retention
$ kubectl get s3user compliance-user -o jsonpath='{.status.retentionState}'
PendingPurge
# Cancel the deletion
$ kubectl annotate s3user compliance-user s3.osc.t-systems.com/recovery-requested=true
s3user.s3.osc.t-systems.com/compliance-user annotated
# The user converges back to Ready within a few seconds
$ kubectl get s3user compliance-user
NAME PHASE AGE
compliance-user Ready 12s
Immediate Deletion with Delete Policy
# Create user with Delete policy
$ cat <<EOF | kubectl apply -f -
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: temp-user
namespace: default
spec:
displayName: "Temporary User"
tier: Tier3
deletionPolicy: Delete
EOF
# Delete immediately (no retention)
$ kubectl delete s3user temp-user
s3user.s3.osc.t-systems.com "temp-user" deleted
Timed Retention with User-Specified Expiry
# Create user with 90-day retention window
$ cat <<EOF | kubectl apply -f -
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: audit-user
namespace: default
spec:
displayName: "Audit User"
tier: Tier3
deletionPolicy: Retain
retentionExpiresAt: "2026-08-25T00:00:00Z"
EOF
# Delete the user (enters PendingPurge state, auto-purges on 2026-08-25)
$ kubectl delete s3user audit-user
Bucket Deletion
# With Retain policy (default), bucket enters retention
$ kubectl delete s3bucket app-data
# With Delete policy, bucket and all data are removed immediately
$ kubectl delete s3bucket app-data
s3bucket.s3.osc.t-systems.com "app-data" deleted
S3User Deletion with Dependent Buckets
Before deleting an S3User, ensure no S3Buckets reference it as owner (.spec.userRef). If buckets still reference the user:
- The deletion proceeds but dependent buckets will enter an
Errorphase because the referenced user no longer exists - The bucket itself remains functional (data accessible via other valid credentials)
- Remove or reassign buckets before deleting their owner to avoid the error state
Note
Additional user references (.spec.additionalUsers) do not block user deletion. If a referenced additional user is deleted, the bucket moves to Error phase. Remove the non-existent user from .spec.additionalUsers to clear the error. The bucket remains functional throughout.
Shoot Cluster Deletion
Warning
Deleting the Shoot cluster (the Shoot CR) triggers deletion of all S3 resources defined in it. This respects the configured deletionPolicy of each resource, but if the Shoot is forcefully removed, data may be lost.
To preserve data before Shoot deletion:
- Migrate data to another Shoot's S3 bucket using standard S3 tools (
aws s3 sync,mc mirror) - Ensure credentials for both source and destination buckets are available
- Delete the Shoot only after confirming data migration is complete
Migrate Data from MinIO to Ceph S3
If your application is still using the legacy MinIO-backed S3 extension, you can migrate its object data to a new Ceph S3 bucket using standard S3 tooling such as the MinIO Client (mc) or the AWS CLI.
Prerequisites
- The
ceph-s3extension enabled on the Shoot cluster — see Enabling the Extension - A new S3Bucket created and in
Readyphase
Migrate with the MinIO Client (mc)
Configure aliases for both endpoints and mirror the bucket. mc mirror copies only objects that are missing or changed in the destination, so it is safe to re-run.
mc alias set source <s3_endpoint> <s3_access_key_id> <s3_secret_access_key>
mc alias set dest <endpoint> <accessKeyID> <secretAccessKey>
mc mirror source/<old-bucket> dest/<new-bucket>
Migrate with the AWS CLI
aws s3 sync performs the same incremental copy between two endpoints.
$ AWS_ACCESS_KEY_ID=<s3_access_key_id> AWS_SECRET_ACCESS_KEY=<s3_secret_access_key> \
aws s3 sync s3://<old-bucket> ./migration-tmp --endpoint-url <s3_endpoint>
$ AWS_ACCESS_KEY_ID=<accessKeyID> AWS_SECRET_ACCESS_KEY=<secretAccessKey> \
aws s3 sync ./migration-tmp s3://<new-bucket> --endpoint-url <endpoint>
If both endpoints are reachable from the same location, you can also sync directly without the intermediate directory.
Update Your Application
Once the data is copied, point your workload at the new S3Bucket.
Warning
The old MinIO bucket is not deleted automatically — retain it as a fallback until your application is confirmed healthy on the Ceph endpoint. The old MinIO bucket and its S3User/S3Bucket resources belong to the legacy osc-s3-bucket-service extension, not to ceph-s3 — retire them following that extension's own deletion procedure.
Tenant Quotas
Tenant quota is the global capacity cap for a tenant. It defines the maximum resources your tenant can consume and cannot exceed. When you create your first resource, your tenant is granted a default quota.
Important
The tenant quota is a hard cap that you cannot bypass — if you need more capacity, you must request a quota increase from support. The exact values granted to your tenant are not fixed here — read them from status.quotaGranted on your S3TenantQuota resource (see Viewing Tenant-Wide Quota).
What Tenant Quota Applies To
Tenant quota is applied to:
- Storage (always enforced)
- Buckets (optional, depending on configured limits)
- Objects (optional, depending on configured limits)
- Users (optional, depending on configured limits)
For details on which dimensions are actively enforced for your tenant, see Quota Enforcement.
Shared Quota Across Shoots
All Shoot clusters of a tenant on the same Seed consume from the same quota pool. Usage from any Shoot reduces what remains available for all others.
Quota Inheritance
If spec.quotas is omitted, the resource inherits the full currently available tenant quota. If spec.quotas is set, the resource is constrained to those limits, which should be lower than or equal to the available tenant quota.
Tip
Always set explicit quotas on your S3User and S3Bucket resources. Without a quota, a single resource can consume all remaining tenant capacity, potentially starving other applications sharing the same tenant.
Hard Limit Enforcement
Tenant quota is a hard limit. When usage reaches the limit, write operations are blocked.
Typical effects when the limit is reached:
- Uploads fail
- Creating new S3Users or S3Buckets fails
- Object creation fails
- Increasing per-resource quotas beyond the tenant cap fails
This applies to the tenant cap and to any stricter per-resource hard quota.
If you reach your tenant quota:
- Delete unnecessary objects, buckets, or users to free capacity
- If more capacity is required, contact support to request a tenant quota increase
Viewing Usage Per Resource
Users can see usage per resource in status.usage.
Check S3User usage:
kubectl get s3user my-user -o jsonpath='{.status.usage}'
Check S3Bucket usage:
kubectl get s3bucket my-bucket -o jsonpath='{.status.usage}'
Example output:
{
"buckets": 0,
"lastUpdated": "2026-05-29T08:46:13Z",
"objects": 0,
"storage": "0"
}
Viewing Tenant-Wide Quota
The S3TenantQuota resource provides a read-only, tenant-visible snapshot of your overall quota state. It has no spec — all information is in .status.
There is one S3TenantQuota per tier. If your tenant uses multiple tiers, you will see resource per each tier: tier3-quota, tierXY-quota, ...
The resource is deployed into kube-system by default. Depending on your cluster operator's configuration, it may also appear in the default namespace. Use -A if you are unsure which namespace is used:
kubectl get s3tenantquota -A
You can also use the short names s3tq or s3tqs:
kubectl get s3tq -A
Example output:
NAMESPACE NAME PHASE TIER OVER ALLOCATED GRANTED STORAGE CONSUMED STORAGE REMAINING STORAGE AGE
kube-system tier3-quota Ready Tier3 false 2Ti 600Gi 1448Gi 10d
To inspect the full status for a specific tier:
kubectl describe s3tq tier3-quota -n kube-system
Or as YAML:
kubectl get s3tq tier3-quota -n kube-system -o yaml
Example status block:
status:
lastSyncedAt: "2026-06-23T10:00:00Z"
overAllocated: false
phase: Ready
quotaAssigned:
buckets: 5
objects: 0
storage: 200Gi
users: 3
quotaConsumed:
buckets: 2
objects: 0
storage: 120Gi
users: 2
quotaGranted:
buckets: 500
objects: 0
storage: 500Gi
users: 200
quotaRemaining:
buckets: 495
objects: 0
storage: 300Gi
users: 197
quotasEnforced:
buckets: true
objects: false
storage: true
users: true
Status Fields
| Field | Description |
|---|---|
conditions |
Detailed status conditions for diagnostic purposes |
lastSyncedAt |
Timestamp of the last successful sync from the storage backend |
overAllocated |
true if quotaConsumed exceeds quotaGranted |
overAllocationDetails |
Per-dimension breakdown when overAllocated is true |
phase |
Current phase: Pending, Ready, Deleting, or Error |
quotaAssigned |
Sum of quotas assigned across all S3User and S3Bucket resources (what is set in spec.quotas) |
quotaConsumed |
Actual resource consumption (real usage, not just assigned) |
quotaGranted |
Total capacity granted to your tenant by the operator |
quotaRemaining |
Capacity still available for provisioning - to be set to the S3User/S3Bucket and its spec.quotas (quotaGranted minus quotaAssigned) |
quotasEnforced |
Which dimensions (storage, users, buckets) are actively enforced |
Note
The status reflects the state at the last sync. Updates are propagated approximately every 5 minutes.
Quota Enforcement
The quotasEnforced field shows which quota dimensions are actively enforced for your tenant. An enforced dimension
blocks operations when its limit is reached — for example, uploads fail when the storage limit is hit. A dimension that
is not enforced is tracked in the status but does not block operations when exceeded.
Enforcement is configured by the cluster operator and may differ between tenants. To check which dimensions apply to your tenant:
kubectl get s3tq -n kube-system -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.quotasEnforced}{"\n"}{end}'
Example output:
tier3-quota {"buckets":true,"objects":false,"storage":true,"users":true}
In this example, storage, users, and buckets are enforced. The objects dimension is tracked but not enforced — exceeding an object count limit does not block operations.
Note
When some of the quota dimensions are not enforced (status.quotasEnforced[X]: false), in status.grantedQuota you will see the 0 value for the
dimensions that are not enforced, meaning unlimited value for this dimension.
Quota Relationship Diagram
The diagram below shows a practical example with a 1Ti tenant cap split across two Shoots. One bucket has an explicit storage quota; the other inherits the full remaining tenant capacity.
flowchart TD
TQ["Tenant Quota Cap
Limits: 1Ti storage, 10 users, 20 buckets
Usage: 720Gi storage, 2 users, 2 buckets"]
TQ --> ShootA
TQ --> ShootB
subgraph ShootA ["Shoot Cluster A"]
direction TB
UA["S3User: app-user
quotas.buckets.hard: 5
usage.buckets: 1"]
BA["S3Bucket: app-bucket
quotas.storage.hard: 200 Gi
usage.storage: 120 Gi"]
UA -. "owns" .-> BA
end
subgraph ShootB ["Shoot Cluster B"]
direction TB
UB["S3User: batch-user
(no quotas set)
usage.buckets: 1"]
BB["S3Bucket: archive-bucket
(no quotas set)
usage.storage: 600 Gi
inherits full tenant remaining"]
UB -. "owns" .-> BB
end
classDef cap fill:#fff4d6,stroke:#d9a441,stroke-width:2px;
classDef quotaset fill:#e8f2ff,stroke:#4a90e2,stroke-width:1px;
classDef noquota fill:#fef3f3,stroke:#c0392b,stroke-width:1px,stroke-dasharray:4 3;
class TQ cap;
class UA,BA quotaset;
class UB,BB noquota;
style ShootA fill:#f8f9fc,stroke:#7a9cde,stroke-width:2px,stroke-dasharray: 4 4
style ShootB fill:#f8f9fc,stroke:#7a9cde,stroke-width:2px,stroke-dasharray: 4 4
- Blue border: explicit quota set — resource is constrained to its hard limit
- Red dashed border: no quota set — resource may consume all remaining tenant capacity
In this example:
- Tenant Quota Setting:
1Tistorage,10users,20buckets - Current Usage:
720Gistorage (120Gifromapp-bucket+600Gifromarchive-bucket),2users, and2buckets - Remaining Available: Approximately
304Gistorage,8users, and18buckets
Because app-bucket has an explicit quota set, it is safely bounded at 200Gi. On the other hand, archive-bucket has no explicit limit and could consume the entire remaining ~304Gi of storage capacity, leaving nothing for other apps or users in either Shoot.
Warning
An explicitly set quota guarantees an upper limit, but does not guarantee reserved capacity.
In the example above, app-bucket is limited to 200Gi and is currently using 120Gi. If the unconstrained archive-bucket consumes the entire ~304Gi of remaining tenant capacity, uploads to app-bucket will fail, even though it has not exhausted its own 200Gi quota.
The tenant-wide quota is an absolute ceiling. Once it is full, all users and buckets in the tenant share the outage, regardless of their individual limits. This is why you should always set explicit quotas on all resources to prevent a single unchecked workload from starving the rest of the tenant.
Practical Examples
1. Explicit Per-Resource Quotas (Recommended)
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: app-bucket
namespace: default
spec:
userRef:
name: app-user
quotas:
storage:
hard: "200Gi"
objects:
hard: 1000000
2. Inherited Quota (No Explicit Limits)
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3Bucket
metadata:
name: archive-bucket
namespace: default
spec:
userRef:
name: batch-user
# quotas omitted -> inherits full remaining tenant quota
# not recommended for shared environments
3. Limiting Bucket Count Per User
apiVersion: s3.osc.t-systems.com/v1alpha1
kind: S3User
metadata:
name: app-user
namespace: default
spec:
displayName: "Application User"
tier: Tier3
quotas:
buckets:
hard: 5
app-user can own at most 5 buckets. Attempting to create a sixth bucket fails at the storage backend.
Quota Warning Events
The extension warns you as usage approaches a quota limit, so you can react before write operations start failing. Two mechanisms work together:
- Kubernetes warning events fire exactly once each time a threshold is crossed. Events are informational only — they never block operations.
- Status conditions persist the current over-threshold state on the resource. Events expire, conditions do not — to check whether a threshold is currently crossed, read the conditions, not the event log.
View both with kubectl describe (or kubectl get events) on the resource:
kubectl describe s3bucket my-bucket
kubectl describe s3user my-user
kubectl describe s3tq tier3-quota -n kube-system
Per-Resource Events (S3User / S3Bucket)
For every quota dimension, up to two warnings can fire, each with its own event reason and status condition:
Every reason is dimension-specific, so a dimension crossing never overwrites or hides another:
| Reason | Fires when |
|---|---|
StorageQuotaThresholdExceeded |
storage usage reaches the application threshold (a percentage of the granted hard quota, default 80%) |
ObjectsQuotaThresholdExceeded |
object count reaches the application threshold |
BucketsQuotaThresholdExceeded |
bucket count reaches the application threshold |
StorageQuotaSoftLimitExceeded |
storage usage reaches the user-set soft limit |
ObjectsQuotaSoftLimitExceeded |
object count reaches the user-set soft limit |
BucketsQuotaSoftLimitExceeded |
bucket count reaches the user-set soft limit |
The event reason is always identical to the status condition it accompanies.
- The percentage threshold is evaluated against
status.grantedQuota— the cap actually approved by the storage backend — not against the requestedspec.quotas.[dimension].hard. A dimension whose quota has been requested but not yet granted produces no threshold warning. - If you set only
hard, you get that dimension's*QuotaThresholdExceededwarning at the application threshold percentage. - If you also set
soft, you additionally get its*QuotaSoftLimitExceededwarning when usage reaches yoursoftvalue. Becausesoftmust be less thanhard, the two warnings mark two distinct points on the way to the hard limit. - If the storage backend grants less than you requested, the granted value may end up below your
softsetting; in that case the threshold warning fires first and the soft warning may never fire before the hard cap.
Dimensions covered:
- bucket count (
S3Userquotas.buckets), - storage and object count (
S3Bucketquotas.storage/quotas.objects).
Example events on an S3Bucket whose storage usage has passed both marks:
Type Reason Message
---- ------ -------
Warning StorageQuotaThresholdExceeded storage usage has reached the 80% application threshold of the hard quota (10Gi)
Warning StorageQuotaSoftLimitExceeded storage usage has reached the user-set soft quota (5Gi)
When several dimensions cross at the same time, each one gets its own entry:
Type Reason Message
---- ------ -------
Warning StorageQuotaThresholdExceeded storage usage has reached the 80% application threshold of the hard quota (10Gi)
Warning ObjectsQuotaThresholdExceeded objects usage has reached the 80% application threshold of the hard quota (1000)
The message never includes the live usage value — read that from .status.usage.
Status Conditions
Each warning has a matching condition on the resource, using the same name as the event reason
listed in the table above. The condition is True while usage is at or above that limit.
The event fires exactly when the condition transitions from False to True. When usage drops
back below the limit, the condition returns to False silently — no recovery event is emitted.
A later re-crossing fires the same event again; because the message is identical, Kubernetes
aggregates it into the existing event entry and increments its count instead of creating a new
one.
kubectl get s3bucket my-bucket -o jsonpath='{.status.conditions}' | jq
Tenant Events (S3TenantQuota)
A warning event is emitted on the S3TenantQuota for each enforced dimension whose
quotaConsumed reaches the application threshold percentage of quotaGranted (storage is always
evaluated; other dimensions only when enforced). Tenant reasons carry a
Tenant prefix so they read distinctly from the per-resource ones:
TenantStorageQuotaThresholdExceeded, TenantUsersQuotaThresholdExceeded,
TenantBucketsQuotaThresholdExceeded and TenantObjectsQuotaThresholdExceeded.
Each dimension gets its own event and its own *QuotaThresholdExceeded condition (including
UsersQuotaThresholdExceeded), so a dimension crossing later adds its own warning without
disturbing the others:
Type Reason Message
---- ------ -------
Warning TenantStorageQuotaThresholdExceeded tenant storage consumption has reached the 80% application threshold of the granted quota (100Gi)
Warning TenantBucketsQuotaThresholdExceeded tenant buckets consumption has reached the 80% application threshold of the granted quota (10)
Application Threshold Percentage
The threshold percentage (default 80%) is configured by your cluster operator. It applies to
every *QuotaThresholdExceeded and Tenant*QuotaThresholdExceeded warning. Soft limits are
unaffected — those fire at the exact value you set.
Validation
When soft is set, it must be strictly less than hard for the same dimension — otherwise both
warnings would fire at the same point. Invalid combinations are rejected when you apply the
resource.
Troubleshooting
Diagnostic Commands
Quick Status Check
# List all S3 resources with their phases
$ kubectl get s3users,s3buckets -A
# Detailed status of a specific resource
$ kubectl describe s3user <name>
$ kubectl describe s3bucket <name>
# Check conditions
$ kubectl get s3user <name> -o jsonpath='{.status.conditions[*].type}'
# Check events
$ kubectl get events --field-selector involvedObject.name=<name>
Common Issues
Problem: Error Phase with a "contact support" Message
Symptoms: S3User or S3Bucket shows phase Error with one of these messages:
user API access is not configured for this environment - contact supporttenant-id is not defined for this environment - contact supportuser API connection is not configured correctly for this environment - contact support
Causes:
- The storage platform for this environment is not fully provisioned or misconfigured
Solution:
-
No user action is possible — these are platform-side configuration errors. Contact support and include the resource name and the exact message.
-
Once the platform is fixed, the resource recovers automatically; no recreation is needed.
Problem: Credentials Not Appearing
Symptoms: S3User is in Ready phase but secretName is empty or the secret does not exist.
Causes:
- Credentials still being transferred (check
credentialsPhase)
Solution:
-
Check the credentials phase:
kubectl get s3user <name> -o jsonpath='{.status.credentialsPhase}'Phase Action PendingWait for storage backend to generate credentials IssuedCredentials generated, transfer in progress TransferredCheck secretName— secret should exist -
Verify the secret exists:
kubectl get secret $(kubectl get s3user <name> -o jsonpath='{.status.secretName}')
Problem: Credential Secret Was Deleted
Symptoms: The credential secret referenced in status.secretName no longer exists, but the S3User is still in Ready phase.
Causes:
- The secret was forcefully deleted (e.g.,
kubectl delete secret <name>) while the S3User was still active.
Solution:
No manual action is required. The extension automatically detects the missing secret and re-triggers credential issuance. The status.credentialsPhase returns to Issued during re-issuance and transitions back to Transferred once the new secret is available. Wait for the reconcile cycle to complete (typically within seconds).
To monitor recovery:
kubectl get s3user <name> -o jsonpath='{.status.credentialsPhase}'
Note
To prevent accidental deletion, the credential secret carries a finalizer (s3.osc.t-systems.com/credential-secret). The finalizer is removed automatically when the S3User itself is deleted. Forcefully bypassing the finalizer (kubectl delete secret <name> --grace-period=0) will trigger automatic re-issuance as described above.
Problem: S3Bucket Stuck in Pending with User Not Found
Symptoms: S3Bucket shows Pending phase with message referencing user not found.
Causes:
- Referenced S3User does not exist
- S3User is in a different namespace than expected
- Typo in
.spec.userRef.name
Solution:
-
Check the status message:
kubectl get s3bucket <name> -o jsonpath='{.status.message}' -
Verify the referenced user exists:
kubectl get s3user <userRef-name> -n <userRef-namespace> -
If the user is in a different namespace, set
.spec.userRef.namespaceexplicitly.
Problem: S3Bucket Error with Tier Mismatch
Symptoms: S3Bucket shows Error phase with tier mismatch message for additional users.
Causes:
- An additional user has a different tier than the bucket owner
- Different tiers use different physical storage clusters, making cross-tier access impossible
Solution:
-
Check the error message:
kubectl get s3bucket <name> -o jsonpath='{.status.message}' -
Verify the owner's tier:
kubectl get s3user <owner-name> -o jsonpath='{.spec.tier}' -
Verify additional users' tiers:
kubectl get s3user <additional-user-name> -o jsonpath='{.spec.tier}' -
Ensure all users (owner and additional) share the same tier value.
Problem: Deletion Blocked (Retention)
Symptoms: kubectl delete was issued but the resource persists, showing Retention phase.
Causes:
- Default
Retaindeletion policy is active retentionExpiresAthas not been reached
Solution:
-
Check the retention state:
kubectl get s3user <name> -o jsonpath='{.status.retentionState}' -
Check retention expiry:
kubectl get s3user <name> -o jsonpath='{.status.retentionExpiresAt}' -
To force immediate deletion (irreversible):
kubectl annotate s3user <name> s3.osc.t-systems.com/force-delete=true
See the Deletion and Retention section for full details.
Problem: Resource Deleted by Accident
Symptoms: kubectl delete was issued on an S3User or S3Bucket that is still needed. The resource shows Retention phase with status.retentionState: PendingPurge; an S3User is suspended and its credentials stop working.
Causes:
- The deletion was unintentional and the resource uses the
Retaindeletion policy, so it is still inside its retention window
Solution:
-
Verify the resource is still recoverable:
kubectl get s3user <name> -o jsonpath='{.status.retentionState}'Recovery is only possible while the state is
PendingPurge. If the state isPurgingor the resource is gone, the storage data has been removed — recreate the resource and restore from backups. -
Cancel the deletion:
kubectl annotate s3user <name> s3.osc.t-systems.com/recovery-requested=true(Use
s3bucketfor buckets.) -
Wait for the phase to return to
Ready(typically a few seconds). The resource keeps its name, spec, credentials, and data; only its Kubernetes UID changes.
See Recovery: Canceling a Pending Deletion for full details.
Problem: S3User Shows Suspended Phase
Symptoms: S3User phase is Suspended, S3 API requests fail with access denied.
Causes:
.spec.suspendedis set totrue
Solution::
-
Verify the suspension setting:
kubectl get s3user <name> -o jsonpath='{.spec.suspended}' -
To restore access:
kubectl patch s3user <name> --type=merge -p '{"spec":{"suspended":false}}' -
Wait for the phase to return to
Ready.
Problem: S3Bucket Owner Cannot Be Additional User
Symptoms: Applying an S3Bucket manifest fails with validation error about owner being listed as additional user.
Causes:
- The same user referenced in
.spec.userRefalso appears in.spec.additionalUsers
Solution:
Remove the owner from the additionalUsers list. The owner already has full access to the bucket.
Problem: Resource Creation Fails — Quota Assignment Limit Reached
Symptoms: Creating a new S3User or S3Bucket with explicit spec.quotas fails. The error references quota exceeded or insufficient remaining quota.
Causes:
- The requested quota assignment would push
quotaAssignedoverquotaGrantedfor one or more dimensions. - Example:
quotaGranted.bucketsis20,quotaAssigned.bucketsis already18, and the new S3User requestsquotas.buckets.hard: 5. The resulting assignment would be23, which exceeds the granted limit of20— the request is rejected.
Solution:
-
Check the current tenant quota state:
kubectl get s3tq -A -
Identify which dimension is exhausted by comparing
quotaAssignedagainstquotaGranted:kubectl get s3tq tier3-quota -n kube-system -o yaml -
List existing S3Users and their assigned quotas to find candidates for reduction:
kubectl get s3users -A -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.quotas}{"\n"}{end}' -
Choose one of the following:
- Reduce the quota on an existing user or bucket that has more assigned than it consumes
- Set a lower limit on the new resource so the total assignment stays within the granted cap
- Contact support to request a tenant quota increase if neither adjustment is feasible
Problem: Upload Fails — Storage Quota Consumed
Symptoms: S3 API rejects an upload with an error such as QuotaExceeded or EntityTooLarge. The S3Bucket and S3User are both in Ready phase.
Causes:
quotaConsumed.storageis close toquotaGranted.storageand the upload would push actual consumption over the granted limit.- Example:
quotaGranted.storageis1Gi,quotaConsumed.storageis900Mi, and the file being uploaded is200Mi. The resulting consumption would be1100Mi, exceeding the1Gicap — the storage backend rejects the write. - The S3Bucket itself has a
spec.quotas.storage.hardlimit set, and that per-bucket cap is reached regardless of remaining tenant capacity.
Solution:
-
Check how much storage has been consumed versus what is granted at the tenant level:
kubectl get s3tq -A -
Confirm storage is the constraint at the tenant level:
kubectl get s3tq tier3-quota -n kube-system -o jsonpath='{.status.quotaConsumed}{"\n"}{.status.quotaGranted}' -
If the tenant quota has remaining capacity, check whether the S3Bucket has a per-bucket storage limit that is being hit:
kubectl get s3bucket <name> -o jsonpath='{.spec.quotas.storage}'If the bucket's
hardlimit is too low, increase it:kubectl patch s3bucket <name> --type=merge -p '{"spec":{"quotas":{"storage":{"hard":"<new-limit>"}}}}' -
If the tenant quota is the constraint, free storage by deleting unused objects or buckets, then retry the upload.
-
If more capacity is required, contact support to request a tenant quota increase.
See the Viewing Tenant-Wide Quota section for details on reading quota status.