Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a89e38be64 | |||
| ede6604ab1 | |||
| f60e69bb79 | |||
| 5f0b9f53ad | |||
| 7f1f57eb8d | |||
| d3b022d646 | |||
| 343d60cfcf | |||
| c1b3ba1c34 | |||
| 52e183e6f3 | |||
| e7760b79f4 | |||
| 9bbaa2b8ba | |||
| 54d3e38223 | |||
| 619aa6751d | |||
| 4b0430f0df | |||
| 253105f914 | |||
| 466514063a | |||
| ab21378f8f | |||
| fa7d7281f0 | |||
| c7dcf29858 |
@@ -3,8 +3,8 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: docker-build-operator
|
- name: docker-build-operator
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||||
settings:
|
settings:
|
||||||
repo: git.unkin.net/unkin/cephrgw-operator
|
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/cephrgw-operator
|
||||||
dockerfile: Dockerfile.operator
|
dockerfile: Dockerfile.operator
|
||||||
dry_run: true
|
dry_run: true
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: docker-operator
|
- name: docker-operator
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||||
settings:
|
settings:
|
||||||
registry: git.unkin.net
|
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||||
repo: git.unkin.net/unkin/cephrgw-operator
|
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/cephrgw-operator
|
||||||
dockerfile: Dockerfile.operator
|
dockerfile: Dockerfile.operator
|
||||||
username: droneci
|
buildkit_config: |
|
||||||
password:
|
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||||
from_secret: DRONECI_PASSWORD
|
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||||
tags:
|
tags:
|
||||||
- ${CI_COMMIT_TAG}
|
- ${CI_COMMIT_TAG}
|
||||||
- latest
|
- latest
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
# cephrgw-operator
|
# cephrgw-operator
|
||||||
|
|
||||||
A Kubernetes operator that provisions Ceph RGW (S3) **buckets** and **access
|
A Kubernetes operator that provisions Ceph RGW (S3) **buckets** and **access
|
||||||
keys** declaratively, driving the Ceph **manager dashboard REST API**. You
|
keys** declaratively, talking **directly to radosgw** the way the CLI does. You
|
||||||
describe a bucket, its owner, and who may read or write it as custom resources;
|
describe a bucket, its owner, and who may read or write it as custom resources;
|
||||||
the operator creates the RGW users and bucket, delivers the access/secret keys
|
the operator creates the RGW users and bucket, delivers the access/secret keys
|
||||||
into Kubernetes Secrets, and maintains the bucket's S3 policy.
|
into Kubernetes Secrets, and maintains the bucket's S3 policy.
|
||||||
|
|
||||||
It talks only to the dashboard API (e.g. `https://dashboard.ceph.unkin.net`) —
|
It uses native Go libraries against radosgw (e.g.
|
||||||
no RADOS access, no admin socket, no in-cluster Ceph required.
|
`https://radosgw.service.consul:443`) — no manager dashboard, no RADOS access,
|
||||||
|
no admin socket, no in-cluster Ceph required:
|
||||||
|
|
||||||
|
- **[go-ceph](https://github.com/ceph/go-ceph) `rgw/admin`** drives the RGW
|
||||||
|
**Admin Ops API** (`/admin/...`) for users, keys, quotas and bucket
|
||||||
|
info/removal — pure Go, no cgo.
|
||||||
|
- **[aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2)** drives the **S3 API**
|
||||||
|
for bucket creation, versioning, policy, tagging and object lock — the
|
||||||
|
operations the Admin Ops API does not expose. These are signed as the bucket
|
||||||
|
**owner**, so the owner owns the bucket directly.
|
||||||
|
|
||||||
## Custom resources
|
## Custom resources
|
||||||
|
|
||||||
@@ -31,16 +40,84 @@ user for that grant and writes its keys into `spec.secretName` (default
|
|||||||
`<name>-rgw`). If `userRef` names an existing `ObjectStoreUser`, that user's own
|
`<name>-rgw`). If `userRef` names an existing `ObjectStoreUser`, that user's own
|
||||||
credential Secret is reused and only the policy is extended.
|
credential Secret is reused and only the policy is extended.
|
||||||
|
|
||||||
|
#### Fine-grained grants
|
||||||
|
|
||||||
|
The level is the ergonomic default; four optional fields on `BucketAccess`
|
||||||
|
refine it (see `config/samples/04-access-fine-grained.yaml`):
|
||||||
|
|
||||||
|
- `spec.paths` — scope object access to key prefixes; each becomes the resource
|
||||||
|
`<bucket>/<prefix>*`. The bucket-level `ListBucket` still spans the whole
|
||||||
|
bucket.
|
||||||
|
- `spec.actions` — grant exactly these S3 actions instead of the level's set (on
|
||||||
|
the bucket and its, optionally prefixed, objects).
|
||||||
|
- `spec.conditions` — `sourceIPs` (an `aws:SourceIp` CIDR allowlist) and
|
||||||
|
`secureTransportOnly` (require TLS).
|
||||||
|
- `spec.rawStatements` — an escape hatch of raw S3 policy statements
|
||||||
|
(`effect`/`actions`/`resources`/`conditions`) merged for this grant's
|
||||||
|
principal. When set, `level`, `actions`, `paths` and `conditions` are ignored;
|
||||||
|
resources without an `arn:` prefix are treated as bucket-relative key prefixes.
|
||||||
|
|
||||||
|
RGW honours S3 bucket policy on **Reef 18.2+ / Squid**; condition-key support is
|
||||||
|
a subset of AWS, so validate exotic conditions against your cluster.
|
||||||
|
|
||||||
|
### Placement targets
|
||||||
|
|
||||||
|
`Bucket.spec.placementTarget` selects the RGW **placement target** that backs the
|
||||||
|
bucket — i.e. which pools, and therefore which durability profile, store its
|
||||||
|
data. The valid values are cluster configuration, not a fixed set baked into the
|
||||||
|
operator. On this estate radosgw exposes two:
|
||||||
|
|
||||||
|
- `default-placement` — 3× replicated (the cluster default).
|
||||||
|
- `ec` — 4+1 erasure-coded (cheaper capacity, for bulk/archival data).
|
||||||
|
|
||||||
|
Leaving `placementTarget` empty keeps the current behaviour: the owning user's
|
||||||
|
`default_placement` (falling back to the zonegroup default). When set, the
|
||||||
|
operator threads it into the S3 `CreateBucket` `LocationConstraint` as
|
||||||
|
`<zonegroup>:<placementTarget>`; with `spec.zonegroup` empty (the default) that
|
||||||
|
is `:<placementTarget>`, which selects the local/master zonegroup with the given
|
||||||
|
placement — so you do not need to know the zonegroup's api-name to pick a target.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: raw-archive
|
||||||
|
spec:
|
||||||
|
ownerRef: logarchiver
|
||||||
|
placementTarget: ec # 4+1 erasure-coded pool
|
||||||
|
```
|
||||||
|
|
||||||
|
Placement is **immutable**: RGW fixes it at bucket creation and cannot move an
|
||||||
|
existing bucket between targets. The CRD rejects changing `placementTarget` (and
|
||||||
|
`zonegroup`) on an existing `Bucket`, and if a bucket already lives on a
|
||||||
|
different target than the spec requests (e.g. an adopted bucket, or a value
|
||||||
|
sneaked in around the CRD guard) the controller sets an `Error` phase with a
|
||||||
|
`PlacementImmutable` reason rather than ever deleting and recreating it. The
|
||||||
|
placement RGW actually stores the bucket on is reported in
|
||||||
|
`status.placementTarget` (and the `Placement` print column), so drift is visible.
|
||||||
|
See `config/samples/06-bucket-ec.yaml`.
|
||||||
|
|
||||||
|
### Adopting existing buckets and users
|
||||||
|
|
||||||
|
The operator can take over buckets/users that already exist in radosgw and hand
|
||||||
|
them back without deleting them. In short: matching CRDs manage the resource in
|
||||||
|
place (no recreation, keys reused, `status.adopted: true`), the bucket policy is
|
||||||
|
**merged** so an existing hand-written policy is preserved (`spec.managePolicy:
|
||||||
|
false` opts out entirely), and `spec.retainOnDelete` on `ObjectStoreUser` /
|
||||||
|
`Bucket` / `BucketAccess` orphans the RGW object instead of deleting it. See
|
||||||
|
**[docs/adoption.md](docs/adoption.md)** and
|
||||||
|
`config/samples/05-adoption.yaml`.
|
||||||
|
|
||||||
The `Bucket` controller renders the policy as the **union of every ready
|
The `Bucket` controller renders the policy as the **union of every ready
|
||||||
`BucketAccess`** that targets it, so the result is convergent regardless of the
|
`BucketAccess`** that targets it, so the result is convergent regardless of the
|
||||||
order objects are created or deleted. It watches `BucketAccess` and
|
order objects are created or deleted. It watches `BucketAccess` and
|
||||||
`ObjectStoreUser`, re-reconciling the bucket whenever a grant or user changes.
|
`ObjectStoreUser`, re-reconciling the bucket whenever a grant or user changes.
|
||||||
|
|
||||||
```
|
```
|
||||||
ObjectStoreUser ──create user──▶ dashboard /api/rgw/user ──▶ Secret (AK/SK)
|
ObjectStoreUser ──admin PUT /admin/user──────▶ Secret (AK/SK)
|
||||||
Bucket ──create bucket─▶ dashboard /api/rgw/bucket ─▶ owns S3 policy
|
Bucket ──S3 CreateBucket (as owner)─▶ owns S3 policy
|
||||||
BucketAccess ──ensure user───▶ dashboard /api/rgw/user ──▶ Secret (AK/SK, RW or RO)
|
BucketAccess ──admin PUT /admin/user──────▶ Secret (AK/SK, RW or RO)
|
||||||
└────── enqueues Bucket ──▶ PUT bucket_policy (aggregate)
|
└────── enqueues Bucket ──▶ S3 PutBucketPolicy (aggregate)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Credential Secrets
|
## Credential Secrets
|
||||||
@@ -50,7 +127,7 @@ into a workload:
|
|||||||
|
|
||||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
|
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
|
||||||
- `RGW_UID`
|
- `RGW_UID`
|
||||||
- `S3_ENDPOINT`, `BUCKET_HOST` (when `CEPH_RGW_ENDPOINT` is configured)
|
- `S3_ENDPOINT`, `BUCKET_HOST` (when `CEPH_RGW_ENDPOINT` is set)
|
||||||
- `BUCKET_NAME` (on `BucketAccess` Secrets)
|
- `BUCKET_NAME` (on `BucketAccess` Secrets)
|
||||||
|
|
||||||
Secrets are owner-referenced by the resource that produced them, so they are
|
Secrets are owner-referenced by the resource that produced them, so they are
|
||||||
@@ -58,10 +135,11 @@ garbage-collected when the resource is deleted.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
The operator needs a dashboard login with the `rgw-manager` role, a dashboard
|
The operator needs an RGW user with admin caps (`users=*;buckets=*`) and its
|
||||||
that is wired to RGW, and (for `read-only`/non-owner `read-write` grants) Ceph
|
access/secret key, the radosgw endpoint, and (for `read-only`/non-owner
|
||||||
**Reef 18.2+ / Squid**. See **[docs/ceph-setup.md](docs/ceph-setup.md)** for the
|
`read-write` grants) Ceph **Reef 18.2+ / Squid**. See
|
||||||
exact commands and the `cephrgw-credentials` Secret schema.
|
**[docs/ceph-setup.md](docs/ceph-setup.md)** for the exact commands and the
|
||||||
|
`cephrgw-credentials` Secret schema.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -99,17 +177,17 @@ kubectl apply -f hack/kind/manifests/ # edit the Secret first
|
|||||||
|
|
||||||
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
|
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
|
||||||
on pull requests; pushing a `v*` tag builds and pushes
|
on pull requests; pushing a `v*` tag builds and pushes
|
||||||
`git.unkin.net/unkin/cephrgw-operator` to the Gitea registry. Bump a release
|
`artifactapi.k8s.syd1.au.unkin.net/docker-internal/cephrgw-operator` to the
|
||||||
|
artifactapi local docker registry. Bump a release
|
||||||
with `make patch|minor|major`.
|
with `make patch|minor|major`.
|
||||||
|
|
||||||
## Notes & caveats
|
## Notes & caveats
|
||||||
|
|
||||||
- **Policy clearing.** Removing the last `BucketAccess` asks the dashboard to
|
- **Policy clearing.** Removing the last `BucketAccess` issues an S3
|
||||||
clear the bucket policy. Not every release honours an empty policy string; if
|
`DeleteBucketPolicy`. A `NoSuchBucketPolicy` response is treated as already
|
||||||
a stale policy lingers, clear it once by hand. Adding/replacing grants always
|
clear. Adding/replacing grants always works.
|
||||||
works.
|
|
||||||
- **Per-bucket quota.** `Bucket.spec.quota` is applied as the owner's default
|
- **Per-bucket quota.** `Bucket.spec.quota` is applied as the owner's default
|
||||||
bucket quota via the dashboard, which is per-owner rather than strictly
|
bucket quota via the Admin Ops API, which is per-owner rather than strictly
|
||||||
per-bucket. Use distinct owners if you need independent bucket quotas.
|
per-bucket. Use distinct owners if you need independent bucket quotas.
|
||||||
- **Immutability.** `bucketName`, an `ObjectStoreUser`'s `uid`, and object lock
|
- **Immutability.** `bucketName`, an `ObjectStoreUser`'s `uid`, and object lock
|
||||||
are fixed at creation; changing them on an existing object has no effect.
|
are fixed at creation; changing them on an existing object has no effect.
|
||||||
|
|||||||
@@ -40,11 +40,29 @@ type BucketSpec struct {
|
|||||||
// with BucketAccess objects.
|
// with BucketAccess objects.
|
||||||
OwnerRef string `json:"ownerRef"`
|
OwnerRef string `json:"ownerRef"`
|
||||||
|
|
||||||
// Zonegroup optionally pins the bucket to a specific RGW zonegroup.
|
// Zonegroup optionally pins the bucket to a specific RGW zonegroup by its
|
||||||
|
// api-name. Empty (the default) uses the cluster's local/master zonegroup, so
|
||||||
|
// PlacementTarget selection works without naming the zonegroup. Immutable:
|
||||||
|
// RGW resolves the zonegroup at bucket creation and cannot move it afterwards.
|
||||||
|
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="zonegroup is immutable; RGW fixes it at bucket creation"
|
||||||
// +optional
|
// +optional
|
||||||
Zonegroup string `json:"zonegroup,omitempty"`
|
Zonegroup string `json:"zonegroup,omitempty"`
|
||||||
|
|
||||||
// PlacementTarget optionally selects a non-default placement target/pool.
|
// PlacementTarget optionally selects the RGW placement target that backs the
|
||||||
|
// bucket, choosing which pools (and thus replication/erasure profile) store
|
||||||
|
// its data. Empty (the default) uses the owning user's default_placement, or
|
||||||
|
// the zonegroup default. The valid values are cluster configuration, not a
|
||||||
|
// fixed set; on this estate the two configured targets are
|
||||||
|
// "default-placement" (3x replicated) and "ec" (4+1 erasure-coded).
|
||||||
|
//
|
||||||
|
// Immutable: RGW chooses the placement at bucket creation (from the S3
|
||||||
|
// LocationConstraint) and cannot move an existing bucket between placement
|
||||||
|
// targets. Set it on a fresh Bucket; changing it later is rejected, and if a
|
||||||
|
// pre-existing bucket is on a different placement the operator reports an
|
||||||
|
// error instead of recreating it.
|
||||||
|
// +kubebuilder:validation:MaxLength=63
|
||||||
|
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`
|
||||||
|
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="placementTarget is immutable; RGW cannot move a bucket between placement targets"
|
||||||
// +optional
|
// +optional
|
||||||
PlacementTarget string `json:"placementTarget,omitempty"`
|
PlacementTarget string `json:"placementTarget,omitempty"`
|
||||||
|
|
||||||
@@ -64,6 +82,16 @@ type BucketSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
Tags map[string]string `json:"tags,omitempty"`
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
|
|
||||||
|
// ManagePolicy controls whether the operator manages the bucket's S3 policy
|
||||||
|
// from BucketAccess grants. When true (the default) the operator reconciles
|
||||||
|
// its own statements while preserving any statements it does not own, so it
|
||||||
|
// is safe to adopt a bucket that already has a policy. Set to false to leave
|
||||||
|
// the bucket policy entirely untouched (BucketAccess grants then have no
|
||||||
|
// effect on this bucket).
|
||||||
|
// +kubebuilder:default=true
|
||||||
|
// +optional
|
||||||
|
ManagePolicy *bool `json:"managePolicy,omitempty"`
|
||||||
|
|
||||||
// RetainOnDelete keeps the RGW bucket (and its objects) when the Bucket
|
// RetainOnDelete keeps the RGW bucket (and its objects) when the Bucket
|
||||||
// resource is deleted. By default the operator removes the empty bucket;
|
// resource is deleted. By default the operator removes the empty bucket;
|
||||||
// it never purges objects unless PurgeOnDelete is also set.
|
// it never purges objects unless PurgeOnDelete is also set.
|
||||||
@@ -90,10 +118,19 @@ type BucketStatus struct {
|
|||||||
// Owner is the RGW uid that owns the bucket.
|
// Owner is the RGW uid that owns the bucket.
|
||||||
// +optional
|
// +optional
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
// PlacementTarget is the placement target RGW actually stores the bucket on,
|
||||||
|
// read back from the live bucket. It makes placement drift (a bucket landing
|
||||||
|
// on a different target than spec requested) visible.
|
||||||
|
// +optional
|
||||||
|
PlacementTarget string `json:"placementTarget,omitempty"`
|
||||||
// PolicyPrincipals is the number of extra principals granted via
|
// PolicyPrincipals is the number of extra principals granted via
|
||||||
// BucketAccess and reflected in the bucket policy.
|
// BucketAccess and reflected in the bucket policy.
|
||||||
// +optional
|
// +optional
|
||||||
PolicyPrincipals int32 `json:"policyPrincipals,omitempty"`
|
PolicyPrincipals int32 `json:"policyPrincipals,omitempty"`
|
||||||
|
// Adopted reports that the RGW bucket already existed when the operator
|
||||||
|
// first reconciled this resource (it was taken over, not created).
|
||||||
|
// +optional
|
||||||
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
@@ -107,7 +144,9 @@ type BucketStatus struct {
|
|||||||
// +kubebuilder:resource:shortName=bkt
|
// +kubebuilder:resource:shortName=bkt
|
||||||
// +kubebuilder:printcolumn:name="Bucket",type=string,JSONPath=`.status.bucketName`
|
// +kubebuilder:printcolumn:name="Bucket",type=string,JSONPath=`.status.bucketName`
|
||||||
// +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.status.owner`
|
// +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.status.owner`
|
||||||
|
// +kubebuilder:printcolumn:name="Placement",type=string,JSONPath=`.status.placementTarget`
|
||||||
// +kubebuilder:printcolumn:name="Grants",type=integer,JSONPath=`.status.policyPrincipals`
|
// +kubebuilder:printcolumn:name="Grants",type=integer,JSONPath=`.status.policyPrincipals`
|
||||||
|
// +kubebuilder:printcolumn:name="Adopted",type=boolean,JSONPath=`.status.adopted`
|
||||||
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||||
|
|
||||||
// Bucket is a Ceph RGW S3 bucket.
|
// Bucket is a Ceph RGW S3 bucket.
|
||||||
|
|||||||
@@ -45,6 +45,78 @@ type BucketAccessSpec struct {
|
|||||||
// dedicated user it creates (UserRef empty). Defaults to "<name>-rgw".
|
// dedicated user it creates (UserRef empty). Defaults to "<name>-rgw".
|
||||||
// +optional
|
// +optional
|
||||||
SecretName string `json:"secretName,omitempty"`
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
|
||||||
|
// RetainOnDelete keeps the dedicated RGW user (created when UserRef is empty)
|
||||||
|
// instead of deleting it when this BucketAccess is removed. Ignored when
|
||||||
|
// UserRef is set (that user is never managed here). Defaults to false.
|
||||||
|
// +optional
|
||||||
|
RetainOnDelete bool `json:"retainOnDelete,omitempty"`
|
||||||
|
|
||||||
|
// Paths optionally scopes object-level access to these key prefixes within
|
||||||
|
// the bucket; each becomes the resource "<bucket>/<prefix>*". Empty grants
|
||||||
|
// the whole bucket. The bucket-level ListBucket action always applies to the
|
||||||
|
// whole bucket. Ignored when RawStatements is set.
|
||||||
|
// +optional
|
||||||
|
Paths []string `json:"paths,omitempty"`
|
||||||
|
|
||||||
|
// Actions optionally overrides the S3 actions granted by Level. When set,
|
||||||
|
// exactly these actions are granted, on the bucket and its (optionally
|
||||||
|
// prefixed) objects. Ignored when RawStatements is set.
|
||||||
|
// +optional
|
||||||
|
Actions []string `json:"actions,omitempty"`
|
||||||
|
|
||||||
|
// Conditions optionally restricts when the grant applies (e.g. source IPs,
|
||||||
|
// TLS required). Ignored when RawStatements is set.
|
||||||
|
// +optional
|
||||||
|
Conditions *AccessConditions `json:"conditions,omitempty"`
|
||||||
|
|
||||||
|
// RawStatements is an escape hatch for arbitrary S3 policy statements, merged
|
||||||
|
// into the bucket policy for this grant's principal. When set, Level,
|
||||||
|
// Actions, Paths and Conditions on this object are ignored; the operator only
|
||||||
|
// fills in the Principal (this grant's user) when a statement omits one.
|
||||||
|
// +optional
|
||||||
|
RawStatements []PolicyStatement `json:"rawStatements,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessConditions restricts when a grant applies. Each field maps to an S3
|
||||||
|
// policy condition and, when several are set, all must hold (they are AND'd).
|
||||||
|
type AccessConditions struct {
|
||||||
|
// SourceIPs restricts the grant to requests from these CIDRs (or single
|
||||||
|
// addresses), via the S3 aws:SourceIp condition.
|
||||||
|
// +optional
|
||||||
|
SourceIPs []string `json:"sourceIPs,omitempty"`
|
||||||
|
|
||||||
|
// SecureTransportOnly requires the request to use TLS, via the S3
|
||||||
|
// aws:SecureTransport condition.
|
||||||
|
// +optional
|
||||||
|
SecureTransportOnly bool `json:"secureTransportOnly,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyStatement is a raw S3 bucket-policy statement, exposed for grants that
|
||||||
|
// need control beyond Level/Actions/Paths/Conditions.
|
||||||
|
type PolicyStatement struct {
|
||||||
|
// Sid is an optional statement id. The operator derives one when empty.
|
||||||
|
// +optional
|
||||||
|
Sid string `json:"sid,omitempty"`
|
||||||
|
|
||||||
|
// Effect is Allow or Deny. Defaults to Allow.
|
||||||
|
// +kubebuilder:validation:Enum=Allow;Deny
|
||||||
|
// +kubebuilder:default=Allow
|
||||||
|
// +optional
|
||||||
|
Effect string `json:"effect,omitempty"`
|
||||||
|
|
||||||
|
// Actions are the S3 actions the statement covers (e.g. s3:GetObject).
|
||||||
|
Actions []string `json:"actions"`
|
||||||
|
|
||||||
|
// Resources are S3 resource ARNs, or bucket-relative key prefixes when they
|
||||||
|
// do not start with "arn:". Empty means the whole bucket and its objects.
|
||||||
|
// +optional
|
||||||
|
Resources []string `json:"resources,omitempty"`
|
||||||
|
|
||||||
|
// Conditions is the raw S3 condition block: operator -> condition key ->
|
||||||
|
// values, e.g. {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}.
|
||||||
|
// +optional
|
||||||
|
Conditions map[string]map[string][]string `json:"conditions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BucketAccessStatus reports observed grant state.
|
// BucketAccessStatus reports observed grant state.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ObjectStoreUserSpec defines a Ceph RGW (S3) user. The operator creates the
|
// ObjectStoreUserSpec defines a Ceph RGW (S3) user. The operator creates the
|
||||||
// user through the Ceph dashboard API and writes its generated access/secret
|
// user through the radosgw Admin Ops API and writes its generated access/secret
|
||||||
// key pair into a Kubernetes Secret. The key material is never stored on the
|
// key pair into a Kubernetes Secret. The key material is never stored on the
|
||||||
// resource itself.
|
// resource itself.
|
||||||
type ObjectStoreUserSpec struct {
|
type ObjectStoreUserSpec struct {
|
||||||
@@ -27,9 +27,11 @@ type ObjectStoreUserSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
MaxBuckets *int32 `json:"maxBuckets,omitempty"`
|
MaxBuckets *int32 `json:"maxBuckets,omitempty"`
|
||||||
|
|
||||||
// Suspended, when true, suspends the user so its keys stop working.
|
// Suspended manages the user's suspended state: true suspends the user so
|
||||||
|
// its keys stop working, false resumes it. When unset the operator does not
|
||||||
|
// touch the suspended state (useful when adopting an existing user).
|
||||||
// +optional
|
// +optional
|
||||||
Suspended bool `json:"suspended,omitempty"`
|
Suspended *bool `json:"suspended,omitempty"`
|
||||||
|
|
||||||
// Quota optionally applies a user-level quota.
|
// Quota optionally applies a user-level quota.
|
||||||
// +optional
|
// +optional
|
||||||
@@ -40,6 +42,12 @@ type ObjectStoreUserSpec struct {
|
|||||||
// AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
// AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
||||||
// +optional
|
// +optional
|
||||||
SecretName string `json:"secretName,omitempty"`
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
|
||||||
|
// RetainOnDelete keeps the RGW user (and its keys) when the ObjectStoreUser
|
||||||
|
// resource is deleted, instead of removing it. Set this before adopting an
|
||||||
|
// existing user you may later want to hand back. Defaults to false.
|
||||||
|
// +optional
|
||||||
|
RetainOnDelete bool `json:"retainOnDelete,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectStoreUserStatus reports observed user state.
|
// ObjectStoreUserStatus reports observed user state.
|
||||||
@@ -53,6 +61,10 @@ type ObjectStoreUserStatus struct {
|
|||||||
// SecretName is the Secret holding the user's credentials.
|
// SecretName is the Secret holding the user's credentials.
|
||||||
// +optional
|
// +optional
|
||||||
SecretName string `json:"secretName,omitempty"`
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
// Adopted reports that the RGW user already existed when the operator first
|
||||||
|
// reconciled this resource (it was taken over, not created).
|
||||||
|
// +optional
|
||||||
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
@@ -66,6 +78,7 @@ type ObjectStoreUserStatus struct {
|
|||||||
// +kubebuilder:resource:shortName=osu
|
// +kubebuilder:resource:shortName=osu
|
||||||
// +kubebuilder:printcolumn:name="UID",type=string,JSONPath=`.status.uid`
|
// +kubebuilder:printcolumn:name="UID",type=string,JSONPath=`.status.uid`
|
||||||
// +kubebuilder:printcolumn:name="Secret",type=string,JSONPath=`.status.secretName`
|
// +kubebuilder:printcolumn:name="Secret",type=string,JSONPath=`.status.secretName`
|
||||||
|
// +kubebuilder:printcolumn:name="Adopted",type=boolean,JSONPath=`.status.adopted`
|
||||||
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||||
|
|
||||||
// ObjectStoreUser is a Ceph RGW S3 user whose keys are delivered into a Secret.
|
// ObjectStoreUser is a Ceph RGW S3 user whose keys are delivered into a Secret.
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ import (
|
|||||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *AccessConditions) DeepCopyInto(out *AccessConditions) {
|
||||||
|
*out = *in
|
||||||
|
if in.SourceIPs != nil {
|
||||||
|
in, out := &in.SourceIPs, &out.SourceIPs
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessConditions.
|
||||||
|
func (in *AccessConditions) DeepCopy() *AccessConditions {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(AccessConditions)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *Bucket) DeepCopyInto(out *Bucket) {
|
func (in *Bucket) DeepCopyInto(out *Bucket) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -41,7 +61,7 @@ func (in *BucketAccess) DeepCopyInto(out *BucketAccess) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
in.Spec.DeepCopyInto(&out.Spec)
|
||||||
in.Status.DeepCopyInto(&out.Status)
|
in.Status.DeepCopyInto(&out.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +118,28 @@ func (in *BucketAccessList) DeepCopyObject() runtime.Object {
|
|||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *BucketAccessSpec) DeepCopyInto(out *BucketAccessSpec) {
|
func (in *BucketAccessSpec) DeepCopyInto(out *BucketAccessSpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
if in.Paths != nil {
|
||||||
|
in, out := &in.Paths, &out.Paths
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.Actions != nil {
|
||||||
|
in, out := &in.Actions, &out.Actions
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.Conditions != nil {
|
||||||
|
in, out := &in.Conditions, &out.Conditions
|
||||||
|
*out = new(AccessConditions)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
if in.RawStatements != nil {
|
||||||
|
in, out := &in.RawStatements, &out.RawStatements
|
||||||
|
*out = make([]PolicyStatement, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketAccessSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketAccessSpec.
|
||||||
@@ -184,6 +226,11 @@ func (in *BucketSpec) DeepCopyInto(out *BucketSpec) {
|
|||||||
(*out)[key] = val
|
(*out)[key] = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if in.ManagePolicy != nil {
|
||||||
|
in, out := &in.ManagePolicy, &out.ManagePolicy
|
||||||
|
*out = new(bool)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketSpec.
|
||||||
@@ -310,6 +357,11 @@ func (in *ObjectStoreUserSpec) DeepCopyInto(out *ObjectStoreUserSpec) {
|
|||||||
*out = new(int32)
|
*out = new(int32)
|
||||||
**out = **in
|
**out = **in
|
||||||
}
|
}
|
||||||
|
if in.Suspended != nil {
|
||||||
|
in, out := &in.Suspended, &out.Suspended
|
||||||
|
*out = new(bool)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
if in.Quota != nil {
|
if in.Quota != nil {
|
||||||
in, out := &in.Quota, &out.Quota
|
in, out := &in.Quota, &out.Quota
|
||||||
*out = new(Quota)
|
*out = new(Quota)
|
||||||
@@ -349,6 +401,58 @@ func (in *ObjectStoreUserStatus) DeepCopy() *ObjectStoreUserStatus {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *PolicyStatement) DeepCopyInto(out *PolicyStatement) {
|
||||||
|
*out = *in
|
||||||
|
if in.Actions != nil {
|
||||||
|
in, out := &in.Actions, &out.Actions
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.Resources != nil {
|
||||||
|
in, out := &in.Resources, &out.Resources
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.Conditions != nil {
|
||||||
|
in, out := &in.Conditions, &out.Conditions
|
||||||
|
*out = make(map[string]map[string][]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
var outVal map[string][]string
|
||||||
|
if val == nil {
|
||||||
|
(*out)[key] = nil
|
||||||
|
} else {
|
||||||
|
inVal := (*in)[key]
|
||||||
|
in, out := &inVal, &outVal
|
||||||
|
*out = make(map[string][]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
var outVal []string
|
||||||
|
if val == nil {
|
||||||
|
(*out)[key] = nil
|
||||||
|
} else {
|
||||||
|
inVal := (*in)[key]
|
||||||
|
in, out := &inVal, &outVal
|
||||||
|
*out = make([]string, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
(*out)[key] = outVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(*out)[key] = outVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyStatement.
|
||||||
|
func (in *PolicyStatement) DeepCopy() *PolicyStatement {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(PolicyStatement)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *Quota) DeepCopyInto(out *Quota) {
|
func (in *Quota) DeepCopyInto(out *Quota) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
|||||||
+31
-14
@@ -40,19 +40,19 @@ func main() {
|
|||||||
|
|
||||||
cephCfg, endpoint, err := cephConfigFromEnv()
|
cephCfg, endpoint, err := cephConfigFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err, "invalid Ceph dashboard configuration")
|
logger.Error(err, "invalid radosgw configuration")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
cephClient, err := ceph.NewClient(cephCfg)
|
cephClient, err := ceph.NewClient(cephCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err, "unable to build Ceph dashboard client")
|
logger.Error(err, "unable to build radosgw client")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// Fail fast on obviously-broken credentials, but do not block startup on a
|
// Fail fast on obviously-broken credentials, but do not block startup on a
|
||||||
// transiently unreachable dashboard.
|
// transiently unreachable radosgw.
|
||||||
pingCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
pingCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
if err := cephClient.Ping(pingCtx); err != nil {
|
if err := cephClient.Ping(pingCtx); err != nil {
|
||||||
logger.Error(err, "initial dashboard authentication failed; continuing and will retry per-reconcile")
|
logger.Error(err, "initial radosgw authentication failed; continuing and will retry per-reconcile")
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
@@ -73,6 +73,13 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advisory only: warn (never exit) if the installed CRDs are missing or
|
||||||
|
// predate this operator's schema, which otherwise surfaces only as opaque
|
||||||
|
// strict-decode failures during reconcile.
|
||||||
|
crdCtx, crdCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
controller.CheckCRDVersions(crdCtx, mgr.GetConfig())
|
||||||
|
crdCancel()
|
||||||
|
|
||||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||||
logger.Error(err, "unable to set up health check")
|
logger.Error(err, "unable to set up health check")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -89,24 +96,34 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cephConfigFromEnv reads dashboard connection settings from the environment,
|
// cephConfigFromEnv reads radosgw connection settings from the environment,
|
||||||
// which the deployment sources from the cephrgw-credentials Secret.
|
// which the deployment sources from the cephrgw-credentials Secret.
|
||||||
|
//
|
||||||
|
// The operator talks to the radosgw Admin Ops and S3 APIs at
|
||||||
|
// CEPH_RGW_ADMIN_ENDPOINT (falling back to CEPH_RGW_ENDPOINT). The returned
|
||||||
|
// endpoint string is the S3 endpoint written into consumer credential Secrets,
|
||||||
|
// which may differ (e.g. a public S3 name) from the API endpoint.
|
||||||
func cephConfigFromEnv() (ceph.Config, string, error) {
|
func cephConfigFromEnv() (ceph.Config, string, error) {
|
||||||
cfg := ceph.Config{
|
consumerEndpoint := os.Getenv("CEPH_RGW_ENDPOINT")
|
||||||
BaseURL: os.Getenv("CEPH_DASHBOARD_URL"),
|
apiEndpoint := os.Getenv("CEPH_RGW_ADMIN_ENDPOINT")
|
||||||
Username: os.Getenv("CEPH_DASHBOARD_USERNAME"),
|
if apiEndpoint == "" {
|
||||||
Password: os.Getenv("CEPH_DASHBOARD_PASSWORD"),
|
apiEndpoint = consumerEndpoint
|
||||||
Insecure: os.Getenv("CEPH_DASHBOARD_INSECURE") == "true",
|
|
||||||
}
|
}
|
||||||
if f := os.Getenv("CEPH_DASHBOARD_CA_FILE"); f != "" {
|
cfg := ceph.Config{
|
||||||
|
Endpoint: apiEndpoint,
|
||||||
|
AccessKey: os.Getenv("CEPH_RGW_ACCESS_KEY"),
|
||||||
|
SecretKey: os.Getenv("CEPH_RGW_SECRET_KEY"),
|
||||||
|
Region: os.Getenv("CEPH_RGW_REGION"),
|
||||||
|
Insecure: os.Getenv("CEPH_RGW_INSECURE") == "true",
|
||||||
|
}
|
||||||
|
if f := os.Getenv("CEPH_RGW_CA_FILE"); f != "" {
|
||||||
b, err := os.ReadFile(f)
|
b, err := os.ReadFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, "", err
|
return cfg, "", err
|
||||||
}
|
}
|
||||||
cfg.CACert = b
|
cfg.CACert = b
|
||||||
} else if inline := os.Getenv("CEPH_DASHBOARD_CA"); inline != "" {
|
} else if inline := os.Getenv("CEPH_RGW_CA"); inline != "" {
|
||||||
cfg.CACert = []byte(inline)
|
cfg.CACert = []byte(inline)
|
||||||
}
|
}
|
||||||
endpoint := os.Getenv("CEPH_RGW_ENDPOINT")
|
return cfg, consumerEndpoint, nil
|
||||||
return cfg, endpoint, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,10 +60,36 @@ spec:
|
|||||||
operator provisions a dedicated user for this grant and writes its keys into
|
operator provisions a dedicated user for this grant and writes its keys into
|
||||||
a Secret; otherwise it grants an existing ObjectStoreUser.
|
a Secret; otherwise it grants an existing ObjectStoreUser.
|
||||||
properties:
|
properties:
|
||||||
|
actions:
|
||||||
|
description: |-
|
||||||
|
Actions optionally overrides the S3 actions granted by Level. When set,
|
||||||
|
exactly these actions are granted, on the bucket and its (optionally
|
||||||
|
prefixed) objects. Ignored when RawStatements is set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
bucketRef:
|
bucketRef:
|
||||||
description: BucketRef names the Bucket (in this namespace) to grant
|
description: BucketRef names the Bucket (in this namespace) to grant
|
||||||
access to.
|
access to.
|
||||||
type: string
|
type: string
|
||||||
|
conditions:
|
||||||
|
description: |-
|
||||||
|
Conditions optionally restricts when the grant applies (e.g. source IPs,
|
||||||
|
TLS required). Ignored when RawStatements is set.
|
||||||
|
properties:
|
||||||
|
secureTransportOnly:
|
||||||
|
description: |-
|
||||||
|
SecureTransportOnly requires the request to use TLS, via the S3
|
||||||
|
aws:SecureTransport condition.
|
||||||
|
type: boolean
|
||||||
|
sourceIPs:
|
||||||
|
description: |-
|
||||||
|
SourceIPs restricts the grant to requests from these CIDRs (or single
|
||||||
|
addresses), via the S3 aws:SourceIp condition.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
level:
|
level:
|
||||||
description: Level is the access level to grant.
|
description: Level is the access level to grant.
|
||||||
enum:
|
enum:
|
||||||
@@ -71,6 +97,71 @@ spec:
|
|||||||
- read-write
|
- read-write
|
||||||
- full
|
- full
|
||||||
type: string
|
type: string
|
||||||
|
paths:
|
||||||
|
description: |-
|
||||||
|
Paths optionally scopes object-level access to these key prefixes within
|
||||||
|
the bucket; each becomes the resource "<bucket>/<prefix>*". Empty grants
|
||||||
|
the whole bucket. The bucket-level ListBucket action always applies to the
|
||||||
|
whole bucket. Ignored when RawStatements is set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
rawStatements:
|
||||||
|
description: |-
|
||||||
|
RawStatements is an escape hatch for arbitrary S3 policy statements, merged
|
||||||
|
into the bucket policy for this grant's principal. When set, Level,
|
||||||
|
Actions, Paths and Conditions on this object are ignored; the operator only
|
||||||
|
fills in the Principal (this grant's user) when a statement omits one.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
PolicyStatement is a raw S3 bucket-policy statement, exposed for grants that
|
||||||
|
need control beyond Level/Actions/Paths/Conditions.
|
||||||
|
properties:
|
||||||
|
actions:
|
||||||
|
description: Actions are the S3 actions the statement covers
|
||||||
|
(e.g. s3:GetObject).
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
conditions:
|
||||||
|
additionalProperties:
|
||||||
|
additionalProperties:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
description: |-
|
||||||
|
Conditions is the raw S3 condition block: operator -> condition key ->
|
||||||
|
values, e.g. {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}.
|
||||||
|
type: object
|
||||||
|
effect:
|
||||||
|
default: Allow
|
||||||
|
description: Effect is Allow or Deny. Defaults to Allow.
|
||||||
|
enum:
|
||||||
|
- Allow
|
||||||
|
- Deny
|
||||||
|
type: string
|
||||||
|
resources:
|
||||||
|
description: |-
|
||||||
|
Resources are S3 resource ARNs, or bucket-relative key prefixes when they
|
||||||
|
do not start with "arn:". Empty means the whole bucket and its objects.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
sid:
|
||||||
|
description: Sid is an optional statement id. The operator derives
|
||||||
|
one when empty.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- actions
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the dedicated RGW user (created when UserRef is empty)
|
||||||
|
instead of deleting it when this BucketAccess is removed. Ignored when
|
||||||
|
UserRef is set (that user is never managed here). Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes credentials into for the
|
SecretName is the Secret the operator writes credentials into for the
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ spec:
|
|||||||
- jsonPath: .status.owner
|
- jsonPath: .status.owner
|
||||||
name: Owner
|
name: Owner
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.placementTarget
|
||||||
|
name: Placement
|
||||||
|
type: string
|
||||||
- jsonPath: .status.policyPrincipals
|
- jsonPath: .status.policyPrincipals
|
||||||
name: Grants
|
name: Grants
|
||||||
type: integer
|
type: integer
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -58,6 +64,16 @@ spec:
|
|||||||
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
||||||
Immutable.
|
Immutable.
|
||||||
type: string
|
type: string
|
||||||
|
managePolicy:
|
||||||
|
default: true
|
||||||
|
description: |-
|
||||||
|
ManagePolicy controls whether the operator manages the bucket's S3 policy
|
||||||
|
from BucketAccess grants. When true (the default) the operator reconciles
|
||||||
|
its own statements while preserving any statements it does not own, so it
|
||||||
|
is safe to adopt a bucket that already has a policy. Set to false to leave
|
||||||
|
the bucket policy entirely untouched (BucketAccess grants then have no
|
||||||
|
effect on this bucket).
|
||||||
|
type: boolean
|
||||||
objectLock:
|
objectLock:
|
||||||
description: ObjectLock configures S3 object lock. Enabling it forces
|
description: ObjectLock configures S3 object lock. Enabling it forces
|
||||||
versioning on.
|
versioning on.
|
||||||
@@ -92,9 +108,26 @@ spec:
|
|||||||
with BucketAccess objects.
|
with BucketAccess objects.
|
||||||
type: string
|
type: string
|
||||||
placementTarget:
|
placementTarget:
|
||||||
description: PlacementTarget optionally selects a non-default placement
|
description: |-
|
||||||
target/pool.
|
PlacementTarget optionally selects the RGW placement target that backs the
|
||||||
|
bucket, choosing which pools (and thus replication/erasure profile) store
|
||||||
|
its data. Empty (the default) uses the owning user's default_placement, or
|
||||||
|
the zonegroup default. The valid values are cluster configuration, not a
|
||||||
|
fixed set; on this estate the two configured targets are
|
||||||
|
"default-placement" (3x replicated) and "ec" (4+1 erasure-coded).
|
||||||
|
|
||||||
|
Immutable: RGW chooses the placement at bucket creation (from the S3
|
||||||
|
LocationConstraint) and cannot move an existing bucket between placement
|
||||||
|
targets. Set it on a fresh Bucket; changing it later is rejected, and if a
|
||||||
|
pre-existing bucket is on a different placement the operator reports an
|
||||||
|
error instead of recreating it.
|
||||||
|
maxLength: 63
|
||||||
|
pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: placementTarget is immutable; RGW cannot move a bucket
|
||||||
|
between placement targets
|
||||||
|
rule: self == oldSelf
|
||||||
purgeOnDelete:
|
purgeOnDelete:
|
||||||
description: |-
|
description: |-
|
||||||
PurgeOnDelete deletes the bucket together with all objects it contains
|
PurgeOnDelete deletes the bucket together with all objects it contains
|
||||||
@@ -135,15 +168,26 @@ spec:
|
|||||||
description: Versioning enables S3 object versioning on the bucket.
|
description: Versioning enables S3 object versioning on the bucket.
|
||||||
type: boolean
|
type: boolean
|
||||||
zonegroup:
|
zonegroup:
|
||||||
description: Zonegroup optionally pins the bucket to a specific RGW
|
description: |-
|
||||||
zonegroup.
|
Zonegroup optionally pins the bucket to a specific RGW zonegroup by its
|
||||||
|
api-name. Empty (the default) uses the cluster's local/master zonegroup, so
|
||||||
|
PlacementTarget selection works without naming the zonegroup. Immutable:
|
||||||
|
RGW resolves the zonegroup at bucket creation and cannot move it afterwards.
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: zonegroup is immutable; RGW fixes it at bucket creation
|
||||||
|
rule: self == oldSelf
|
||||||
required:
|
required:
|
||||||
- ownerRef
|
- ownerRef
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BucketStatus reports observed bucket state.
|
description: BucketStatus reports observed bucket state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW bucket already existed when the operator
|
||||||
|
first reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
bucketID:
|
bucketID:
|
||||||
description: BucketID is the RGW internal bucket instance id.
|
description: BucketID is the RGW internal bucket instance id.
|
||||||
type: string
|
type: string
|
||||||
@@ -218,6 +262,12 @@ spec:
|
|||||||
phase:
|
phase:
|
||||||
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
||||||
type: string
|
type: string
|
||||||
|
placementTarget:
|
||||||
|
description: |-
|
||||||
|
PlacementTarget is the placement target RGW actually stores the bucket on,
|
||||||
|
read back from the live bucket. It makes placement drift (a bucket landing
|
||||||
|
on a different target than spec requested) visible.
|
||||||
|
type: string
|
||||||
policyPrincipals:
|
policyPrincipals:
|
||||||
description: |-
|
description: |-
|
||||||
PolicyPrincipals is the number of extra principals granted via
|
PolicyPrincipals is the number of extra principals granted via
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ spec:
|
|||||||
- jsonPath: .status.secretName
|
- jsonPath: .status.secretName
|
||||||
name: Secret
|
name: Secret
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -52,7 +55,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
description: |-
|
description: |-
|
||||||
ObjectStoreUserSpec defines a Ceph RGW (S3) user. The operator creates the
|
ObjectStoreUserSpec defines a Ceph RGW (S3) user. The operator creates the
|
||||||
user through the Ceph dashboard API and writes its generated access/secret
|
user through the radosgw Admin Ops API and writes its generated access/secret
|
||||||
key pair into a Kubernetes Secret. The key material is never stored on the
|
key pair into a Kubernetes Secret. The key material is never stored on the
|
||||||
resource itself.
|
resource itself.
|
||||||
properties:
|
properties:
|
||||||
@@ -90,6 +93,12 @@ spec:
|
|||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the RGW user (and its keys) when the ObjectStoreUser
|
||||||
|
resource is deleted, instead of removing it. Set this before adopting an
|
||||||
|
existing user you may later want to hand back. Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes the access/secret key into.
|
SecretName is the Secret the operator writes the access/secret key into.
|
||||||
@@ -97,8 +106,10 @@ spec:
|
|||||||
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
||||||
type: string
|
type: string
|
||||||
suspended:
|
suspended:
|
||||||
description: Suspended, when true, suspends the user so its keys stop
|
description: |-
|
||||||
working.
|
Suspended manages the user's suspended state: true suspends the user so
|
||||||
|
its keys stop working, false resumes it. When unset the operator does not
|
||||||
|
touch the suspended state (useful when adopting an existing user).
|
||||||
type: boolean
|
type: boolean
|
||||||
uid:
|
uid:
|
||||||
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
||||||
@@ -108,6 +119,11 @@ spec:
|
|||||||
status:
|
status:
|
||||||
description: ObjectStoreUserStatus reports observed user state.
|
description: ObjectStoreUserStatus reports observed user state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW user already existed when the operator first
|
||||||
|
reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
conditions:
|
conditions:
|
||||||
items:
|
items:
|
||||||
description: Condition contains details for one aspect of the current
|
description: Condition contains details for one aspect of the current
|
||||||
|
|||||||
+164
-7
@@ -61,10 +61,36 @@ spec:
|
|||||||
operator provisions a dedicated user for this grant and writes its keys into
|
operator provisions a dedicated user for this grant and writes its keys into
|
||||||
a Secret; otherwise it grants an existing ObjectStoreUser.
|
a Secret; otherwise it grants an existing ObjectStoreUser.
|
||||||
properties:
|
properties:
|
||||||
|
actions:
|
||||||
|
description: |-
|
||||||
|
Actions optionally overrides the S3 actions granted by Level. When set,
|
||||||
|
exactly these actions are granted, on the bucket and its (optionally
|
||||||
|
prefixed) objects. Ignored when RawStatements is set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
bucketRef:
|
bucketRef:
|
||||||
description: BucketRef names the Bucket (in this namespace) to grant
|
description: BucketRef names the Bucket (in this namespace) to grant
|
||||||
access to.
|
access to.
|
||||||
type: string
|
type: string
|
||||||
|
conditions:
|
||||||
|
description: |-
|
||||||
|
Conditions optionally restricts when the grant applies (e.g. source IPs,
|
||||||
|
TLS required). Ignored when RawStatements is set.
|
||||||
|
properties:
|
||||||
|
secureTransportOnly:
|
||||||
|
description: |-
|
||||||
|
SecureTransportOnly requires the request to use TLS, via the S3
|
||||||
|
aws:SecureTransport condition.
|
||||||
|
type: boolean
|
||||||
|
sourceIPs:
|
||||||
|
description: |-
|
||||||
|
SourceIPs restricts the grant to requests from these CIDRs (or single
|
||||||
|
addresses), via the S3 aws:SourceIp condition.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
level:
|
level:
|
||||||
description: Level is the access level to grant.
|
description: Level is the access level to grant.
|
||||||
enum:
|
enum:
|
||||||
@@ -72,6 +98,71 @@ spec:
|
|||||||
- read-write
|
- read-write
|
||||||
- full
|
- full
|
||||||
type: string
|
type: string
|
||||||
|
paths:
|
||||||
|
description: |-
|
||||||
|
Paths optionally scopes object-level access to these key prefixes within
|
||||||
|
the bucket; each becomes the resource "<bucket>/<prefix>*". Empty grants
|
||||||
|
the whole bucket. The bucket-level ListBucket action always applies to the
|
||||||
|
whole bucket. Ignored when RawStatements is set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
rawStatements:
|
||||||
|
description: |-
|
||||||
|
RawStatements is an escape hatch for arbitrary S3 policy statements, merged
|
||||||
|
into the bucket policy for this grant's principal. When set, Level,
|
||||||
|
Actions, Paths and Conditions on this object are ignored; the operator only
|
||||||
|
fills in the Principal (this grant's user) when a statement omits one.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
PolicyStatement is a raw S3 bucket-policy statement, exposed for grants that
|
||||||
|
need control beyond Level/Actions/Paths/Conditions.
|
||||||
|
properties:
|
||||||
|
actions:
|
||||||
|
description: Actions are the S3 actions the statement covers
|
||||||
|
(e.g. s3:GetObject).
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
conditions:
|
||||||
|
additionalProperties:
|
||||||
|
additionalProperties:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
description: |-
|
||||||
|
Conditions is the raw S3 condition block: operator -> condition key ->
|
||||||
|
values, e.g. {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}.
|
||||||
|
type: object
|
||||||
|
effect:
|
||||||
|
default: Allow
|
||||||
|
description: Effect is Allow or Deny. Defaults to Allow.
|
||||||
|
enum:
|
||||||
|
- Allow
|
||||||
|
- Deny
|
||||||
|
type: string
|
||||||
|
resources:
|
||||||
|
description: |-
|
||||||
|
Resources are S3 resource ARNs, or bucket-relative key prefixes when they
|
||||||
|
do not start with "arn:". Empty means the whole bucket and its objects.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
sid:
|
||||||
|
description: Sid is an optional statement id. The operator derives
|
||||||
|
one when empty.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- actions
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the dedicated RGW user (created when UserRef is empty)
|
||||||
|
instead of deleting it when this BucketAccess is removed. Ignored when
|
||||||
|
UserRef is set (that user is never managed here). Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes credentials into for the
|
SecretName is the Secret the operator writes credentials into for the
|
||||||
@@ -202,9 +293,15 @@ spec:
|
|||||||
- jsonPath: .status.owner
|
- jsonPath: .status.owner
|
||||||
name: Owner
|
name: Owner
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.placementTarget
|
||||||
|
name: Placement
|
||||||
|
type: string
|
||||||
- jsonPath: .status.policyPrincipals
|
- jsonPath: .status.policyPrincipals
|
||||||
name: Grants
|
name: Grants
|
||||||
type: integer
|
type: integer
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -237,6 +334,16 @@ spec:
|
|||||||
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
||||||
Immutable.
|
Immutable.
|
||||||
type: string
|
type: string
|
||||||
|
managePolicy:
|
||||||
|
default: true
|
||||||
|
description: |-
|
||||||
|
ManagePolicy controls whether the operator manages the bucket's S3 policy
|
||||||
|
from BucketAccess grants. When true (the default) the operator reconciles
|
||||||
|
its own statements while preserving any statements it does not own, so it
|
||||||
|
is safe to adopt a bucket that already has a policy. Set to false to leave
|
||||||
|
the bucket policy entirely untouched (BucketAccess grants then have no
|
||||||
|
effect on this bucket).
|
||||||
|
type: boolean
|
||||||
objectLock:
|
objectLock:
|
||||||
description: ObjectLock configures S3 object lock. Enabling it forces
|
description: ObjectLock configures S3 object lock. Enabling it forces
|
||||||
versioning on.
|
versioning on.
|
||||||
@@ -271,9 +378,26 @@ spec:
|
|||||||
with BucketAccess objects.
|
with BucketAccess objects.
|
||||||
type: string
|
type: string
|
||||||
placementTarget:
|
placementTarget:
|
||||||
description: PlacementTarget optionally selects a non-default placement
|
description: |-
|
||||||
target/pool.
|
PlacementTarget optionally selects the RGW placement target that backs the
|
||||||
|
bucket, choosing which pools (and thus replication/erasure profile) store
|
||||||
|
its data. Empty (the default) uses the owning user's default_placement, or
|
||||||
|
the zonegroup default. The valid values are cluster configuration, not a
|
||||||
|
fixed set; on this estate the two configured targets are
|
||||||
|
"default-placement" (3x replicated) and "ec" (4+1 erasure-coded).
|
||||||
|
|
||||||
|
Immutable: RGW chooses the placement at bucket creation (from the S3
|
||||||
|
LocationConstraint) and cannot move an existing bucket between placement
|
||||||
|
targets. Set it on a fresh Bucket; changing it later is rejected, and if a
|
||||||
|
pre-existing bucket is on a different placement the operator reports an
|
||||||
|
error instead of recreating it.
|
||||||
|
maxLength: 63
|
||||||
|
pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: placementTarget is immutable; RGW cannot move a bucket
|
||||||
|
between placement targets
|
||||||
|
rule: self == oldSelf
|
||||||
purgeOnDelete:
|
purgeOnDelete:
|
||||||
description: |-
|
description: |-
|
||||||
PurgeOnDelete deletes the bucket together with all objects it contains
|
PurgeOnDelete deletes the bucket together with all objects it contains
|
||||||
@@ -314,15 +438,26 @@ spec:
|
|||||||
description: Versioning enables S3 object versioning on the bucket.
|
description: Versioning enables S3 object versioning on the bucket.
|
||||||
type: boolean
|
type: boolean
|
||||||
zonegroup:
|
zonegroup:
|
||||||
description: Zonegroup optionally pins the bucket to a specific RGW
|
description: |-
|
||||||
zonegroup.
|
Zonegroup optionally pins the bucket to a specific RGW zonegroup by its
|
||||||
|
api-name. Empty (the default) uses the cluster's local/master zonegroup, so
|
||||||
|
PlacementTarget selection works without naming the zonegroup. Immutable:
|
||||||
|
RGW resolves the zonegroup at bucket creation and cannot move it afterwards.
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: zonegroup is immutable; RGW fixes it at bucket creation
|
||||||
|
rule: self == oldSelf
|
||||||
required:
|
required:
|
||||||
- ownerRef
|
- ownerRef
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BucketStatus reports observed bucket state.
|
description: BucketStatus reports observed bucket state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW bucket already existed when the operator
|
||||||
|
first reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
bucketID:
|
bucketID:
|
||||||
description: BucketID is the RGW internal bucket instance id.
|
description: BucketID is the RGW internal bucket instance id.
|
||||||
type: string
|
type: string
|
||||||
@@ -397,6 +532,12 @@ spec:
|
|||||||
phase:
|
phase:
|
||||||
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
||||||
type: string
|
type: string
|
||||||
|
placementTarget:
|
||||||
|
description: |-
|
||||||
|
PlacementTarget is the placement target RGW actually stores the bucket on,
|
||||||
|
read back from the live bucket. It makes placement drift (a bucket landing
|
||||||
|
on a different target than spec requested) visible.
|
||||||
|
type: string
|
||||||
policyPrincipals:
|
policyPrincipals:
|
||||||
description: |-
|
description: |-
|
||||||
PolicyPrincipals is the number of extra principals granted via
|
PolicyPrincipals is the number of extra principals granted via
|
||||||
@@ -434,6 +575,9 @@ spec:
|
|||||||
- jsonPath: .status.secretName
|
- jsonPath: .status.secretName
|
||||||
name: Secret
|
name: Secret
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -463,7 +607,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
description: |-
|
description: |-
|
||||||
ObjectStoreUserSpec defines a Ceph RGW (S3) user. The operator creates the
|
ObjectStoreUserSpec defines a Ceph RGW (S3) user. The operator creates the
|
||||||
user through the Ceph dashboard API and writes its generated access/secret
|
user through the radosgw Admin Ops API and writes its generated access/secret
|
||||||
key pair into a Kubernetes Secret. The key material is never stored on the
|
key pair into a Kubernetes Secret. The key material is never stored on the
|
||||||
resource itself.
|
resource itself.
|
||||||
properties:
|
properties:
|
||||||
@@ -501,6 +645,12 @@ spec:
|
|||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the RGW user (and its keys) when the ObjectStoreUser
|
||||||
|
resource is deleted, instead of removing it. Set this before adopting an
|
||||||
|
existing user you may later want to hand back. Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes the access/secret key into.
|
SecretName is the Secret the operator writes the access/secret key into.
|
||||||
@@ -508,8 +658,10 @@ spec:
|
|||||||
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
||||||
type: string
|
type: string
|
||||||
suspended:
|
suspended:
|
||||||
description: Suspended, when true, suspends the user so its keys stop
|
description: |-
|
||||||
working.
|
Suspended manages the user's suspended state: true suspends the user so
|
||||||
|
its keys stop working, false resumes it. When unset the operator does not
|
||||||
|
touch the suspended state (useful when adopting an existing user).
|
||||||
type: boolean
|
type: boolean
|
||||||
uid:
|
uid:
|
||||||
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
||||||
@@ -519,6 +671,11 @@ spec:
|
|||||||
status:
|
status:
|
||||||
description: ObjectStoreUserStatus reports observed user state.
|
description: ObjectStoreUserStatus reports observed user state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW user already existed when the operator first
|
||||||
|
reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
conditions:
|
conditions:
|
||||||
items:
|
items:
|
||||||
description: Condition contains details for one aspect of the current
|
description: Condition contains details for one aspect of the current
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ rules:
|
|||||||
- patch
|
- patch
|
||||||
- update
|
- update
|
||||||
- watch
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- apiextensions.k8s.io
|
||||||
|
resources:
|
||||||
|
- customresourcedefinitions
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- ceph.unkin.net
|
- ceph.unkin.net
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Fine-grained grants. Each of these refines the coarse read-only/read-write/full
|
||||||
|
# levels with prefix scoping, action overrides, conditions, or raw statements.
|
||||||
|
|
||||||
|
# 1. Prefix-scoped read-write: this workload may read/write objects only under
|
||||||
|
# the "uploads/" and "tmp/" key prefixes (bucket-level ListBucket still spans
|
||||||
|
# the whole bucket).
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: BucketAccess
|
||||||
|
metadata:
|
||||||
|
name: app-data-uploader
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
bucketRef: app-data
|
||||||
|
level: read-write
|
||||||
|
secretName: app-data-uploader-rgw
|
||||||
|
paths:
|
||||||
|
- uploads/
|
||||||
|
- tmp/
|
||||||
|
---
|
||||||
|
# 2. Read-only from inside the cluster only: restrict the grant to a source CIDR
|
||||||
|
# and require TLS.
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: BucketAccess
|
||||||
|
metadata:
|
||||||
|
name: app-data-internal-ro
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
bucketRef: app-data
|
||||||
|
level: read-only
|
||||||
|
secretName: app-data-internal-ro-rgw
|
||||||
|
conditions:
|
||||||
|
sourceIPs:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
secureTransportOnly: true
|
||||||
|
---
|
||||||
|
# 3. Explicit action set: grant exactly these actions instead of a level's
|
||||||
|
# canned set (level is still required but its actions are ignored).
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: BucketAccess
|
||||||
|
metadata:
|
||||||
|
name: app-data-getput
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
bucketRef: app-data
|
||||||
|
level: read-only
|
||||||
|
secretName: app-data-getput-rgw
|
||||||
|
actions:
|
||||||
|
- s3:GetObject
|
||||||
|
- s3:PutObject
|
||||||
|
---
|
||||||
|
# 4. Raw statements escape hatch: full control over the policy statement. Level,
|
||||||
|
# actions, paths and conditions are ignored; the operator only injects the
|
||||||
|
# Principal (this grant's user). Resources without an "arn:" prefix are
|
||||||
|
# treated as bucket-relative key prefixes.
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: BucketAccess
|
||||||
|
metadata:
|
||||||
|
name: app-data-raw
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
bucketRef: app-data
|
||||||
|
level: read-only
|
||||||
|
secretName: app-data-raw-rgw
|
||||||
|
rawStatements:
|
||||||
|
- effect: Allow
|
||||||
|
actions:
|
||||||
|
- s3:GetObject
|
||||||
|
resources:
|
||||||
|
- public/
|
||||||
|
- effect: Deny
|
||||||
|
actions:
|
||||||
|
- s3:DeleteObject
|
||||||
|
resources:
|
||||||
|
- locked/
|
||||||
|
conditions:
|
||||||
|
Bool:
|
||||||
|
aws:SecureTransport:
|
||||||
|
- "false"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Adopting an existing radosgw user + bucket. The operator takes them over in
|
||||||
|
# place: no recreation, existing keys reused, existing bucket policy preserved.
|
||||||
|
# retainOnDelete keeps the RGW objects if these CRDs are later deleted.
|
||||||
|
# See docs/adoption.md.
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: ObjectStoreUser
|
||||||
|
metadata:
|
||||||
|
name: legacy-owner
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
# uid must match the existing RGW user id.
|
||||||
|
uid: legacy-owner
|
||||||
|
# Set maxBuckets to the existing user's limit (it otherwise defaults to 1000
|
||||||
|
# and would be applied). Leave displayName/suspended unset to keep them as-is.
|
||||||
|
maxBuckets: 1000
|
||||||
|
retainOnDelete: true
|
||||||
|
---
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: legacy-data
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
# bucketName must match the existing bucket.
|
||||||
|
bucketName: legacy-data
|
||||||
|
ownerRef: legacy-owner
|
||||||
|
retainOnDelete: true
|
||||||
|
# managePolicy defaults to true: the operator merges its BucketAccess grants
|
||||||
|
# into the existing policy, preserving statements it does not own. Set it to
|
||||||
|
# false to leave the bucket policy entirely under manual control.
|
||||||
|
managePolicy: true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# A bucket placed on the erasure-coded (4+1) placement target instead of the
|
||||||
|
# default 3x-replicated pool. Good for bulk/archival data where capacity matters
|
||||||
|
# more than the extra replica.
|
||||||
|
#
|
||||||
|
# placementTarget is immutable: RGW chooses the placement at bucket creation and
|
||||||
|
# cannot move an existing bucket between targets, so it can only be set on a
|
||||||
|
# fresh Bucket. The operator reports the live placement in status.placementTarget.
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: raw-archive
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
bucketName: raw-archive
|
||||||
|
ownerRef: app-owner
|
||||||
|
# Cluster-configured placement target. On this estate: "default-placement"
|
||||||
|
# (3x replicated) or "ec" (4+1 erasure-coded).
|
||||||
|
placementTarget: ec
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Adopting existing radosgw buckets and users
|
||||||
|
|
||||||
|
The operator can **take over** buckets and users that already exist in radosgw:
|
||||||
|
write CRDs that match them and it manages them in place instead of recreating
|
||||||
|
them. It can also **hand them back** without deleting the underlying RGW objects.
|
||||||
|
|
||||||
|
## What happens when you create a CRD for an existing resource
|
||||||
|
|
||||||
|
| CRD | On adoption |
|
||||||
|
|-----|-------------|
|
||||||
|
| `ObjectStoreUser` | The user is **not** recreated and its **existing keys are reused** (never rotated). `status.adopted` becomes `true`. Attributes are only changed if the spec sets them (see below). |
|
||||||
|
| `Bucket` | The bucket is **not** recreated. `status.adopted` becomes `true`. Versioning/tags/quota are only touched if the spec sets them. The policy is **merged**, not overwritten (see below). |
|
||||||
|
| `BucketAccess` | Adds the grant's statement to the bucket policy and (for a dedicated user) reuses that user's keys. |
|
||||||
|
|
||||||
|
`status.adopted` is also shown in `kubectl get osu` / `kubectl get bkt` under
|
||||||
|
the **ADOPTED** column.
|
||||||
|
|
||||||
|
### User attributes
|
||||||
|
|
||||||
|
To avoid clobbering an adopted user, the operator only sends attributes you set:
|
||||||
|
|
||||||
|
- `displayName` — left unchanged when empty.
|
||||||
|
- `suspended` — left unchanged when unset (it is an optional `*bool`; set it
|
||||||
|
explicitly to `true`/`false` to manage it).
|
||||||
|
- `email` — left unchanged when empty.
|
||||||
|
- `maxBuckets` — **defaults to `1000`** and is always applied. If the existing
|
||||||
|
user has a different limit you want to keep, set `maxBuckets` to match (or to
|
||||||
|
the value you want).
|
||||||
|
|
||||||
|
### Bucket policy is merged, not replaced
|
||||||
|
|
||||||
|
The operator owns only the policy statements it writes — they carry a
|
||||||
|
`cephrgwop…` statement id. On every reconcile it **preserves statements it does
|
||||||
|
not own** and reconciles only its own. So adopting a bucket that already has a
|
||||||
|
hand-written policy keeps that policy; your `BucketAccess` grants are added
|
||||||
|
alongside it.
|
||||||
|
|
||||||
|
- Reserve the `cephrgwop` prefix (and the legacy prefixes `full`, `custom`,
|
||||||
|
`raw`, `readonly*`, `readwrite*`) for the operator — do not name your own
|
||||||
|
statements with them, or they will be treated as operator-owned and replaced.
|
||||||
|
- To have the operator **never touch** a bucket's policy, set
|
||||||
|
`spec.managePolicy: false`. `BucketAccess` grants then have no effect on that
|
||||||
|
bucket.
|
||||||
|
|
||||||
|
## What happens when you delete the CRD
|
||||||
|
|
||||||
|
By default, deleting a CRD deletes the underlying RGW resource. Opt out per
|
||||||
|
resource to **orphan** it instead (the finalizer is dropped, the RGW object is
|
||||||
|
left in place):
|
||||||
|
|
||||||
|
| CRD | Default on delete | Keep the RGW object |
|
||||||
|
|-----|-------------------|---------------------|
|
||||||
|
| `ObjectStoreUser` | deletes the RGW user + keys | `spec.retainOnDelete: true` |
|
||||||
|
| `Bucket` | deletes the (empty) bucket | `spec.retainOnDelete: true` (and never set `purgeOnDelete`) |
|
||||||
|
| `BucketAccess` (dedicated user) | deletes the dedicated user | `spec.retainOnDelete: true` |
|
||||||
|
| `BucketAccess` (`userRef`) | only drops the policy statement | n/a (never manages that user) |
|
||||||
|
|
||||||
|
## Recommended adoption procedure
|
||||||
|
|
||||||
|
1. Create an `ObjectStoreUser` for each owner, setting `retainOnDelete: true`
|
||||||
|
and `maxBuckets` to the value you want. Confirm `ADOPTED=true`.
|
||||||
|
2. Create the `Bucket` (with `retainOnDelete: true`) referencing that owner.
|
||||||
|
With `managePolicy: true` (default) the existing policy is preserved.
|
||||||
|
3. Optionally add `BucketAccess` objects to model existing grants; they merge
|
||||||
|
into the policy. If you would rather keep managing the policy by hand, set
|
||||||
|
`managePolicy: false` on the Bucket.
|
||||||
|
4. To hand a resource back, delete its CRD — with `retainOnDelete: true` the RGW
|
||||||
|
bucket/user is left untouched.
|
||||||
+107
-114
@@ -1,102 +1,73 @@
|
|||||||
# Ceph setup: credentials and permissions the operator needs
|
# Ceph setup: credentials and permissions the operator needs
|
||||||
|
|
||||||
`cephrgw-operator` never talks to RADOS or the RGW admin socket directly. It
|
`cephrgw-operator` talks **directly to radosgw**, the same way the `radosgw-admin`
|
||||||
drives the **Ceph manager dashboard REST API** (the same API the web dashboard
|
CLI and S3 clients do — no manager dashboard involved. It uses two native Go
|
||||||
uses) at `https://dashboard.ceph.unkin.net`. Everything below is about giving
|
libraries against the RGW endpoint (e.g. `https://radosgw.service.consul:443`):
|
||||||
the operator a dashboard login with enough RGW authority, and making sure the
|
|
||||||
dashboard itself is wired to your RGW.
|
|
||||||
|
|
||||||
There are **two** credentials involved. Don't confuse them:
|
- **go-ceph `rgw/admin`** → the RGW **Admin Ops API** (`/admin/user`,
|
||||||
|
`/admin/bucket`), signed with the operator's access/secret key, for users,
|
||||||
|
keys, quotas and bucket info/removal.
|
||||||
|
- **aws-sdk-go-v2** → the **S3 API**, signed as each bucket's **owner**, for
|
||||||
|
bucket creation, versioning, policy, tagging and object lock.
|
||||||
|
|
||||||
| # | Credential | Who uses it | What it is |
|
So there is exactly **one** credential to provision: a radosgw user with admin
|
||||||
|---|------------|-------------|------------|
|
caps, plus its access/secret key.
|
||||||
| 1 | Dashboard login (username + password) | the operator → `POST /api/auth` | a **dashboard account** with the `rgw-manager` role |
|
|
||||||
| 2 | RGW admin connection | the dashboard → RGW | a **radosgw system user** (access/secret key) the dashboard is configured with |
|
|
||||||
|
|
||||||
The operator only holds #1. #2 is what actually lets the dashboard create RGW
|
|
||||||
users, buckets and bucket policies on the operator's behalf, so it must exist
|
|
||||||
and be privileged.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Create the dashboard login for the operator
|
## 1. Create the operator's RGW admin user
|
||||||
|
|
||||||
Create a dedicated dashboard user with the built-in **`rgw-manager`** role. That
|
Create a dedicated radosgw user and give it the admin caps the operator needs.
|
||||||
role grants full create/read/update/delete on the dashboard's `rgw` scope
|
Only `users` and `buckets` caps are required (the operator never reads usage or
|
||||||
(users, buckets, policies) and nothing else — least privilege for this operator.
|
metadata endpoints):
|
||||||
|
|
||||||
```bash
|
|
||||||
# Put the password in a file so it never lands in shell history.
|
|
||||||
printf '%s' 'REPLACE-WITH-A-STRONG-PASSWORD' > /tmp/cephrgw.pw
|
|
||||||
|
|
||||||
ceph dashboard ac-user-create k8s-cephrgw-operator -i /tmp/cephrgw.pw rgw-manager
|
|
||||||
|
|
||||||
rm -f /tmp/cephrgw.pw
|
|
||||||
```
|
|
||||||
|
|
||||||
If your Ceph version wants the arguments in a different order, check
|
|
||||||
`ceph dashboard ac-user-create -h`. To confirm the role exists and what it
|
|
||||||
grants:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ceph dashboard ac-role-show rgw-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
> Prefer `rgw-manager` over `administrator`. The operator only needs RGW
|
|
||||||
> authority; giving it full dashboard admin is unnecessary blast radius.
|
|
||||||
|
|
||||||
## 2. Make sure the dashboard can manage RGW
|
|
||||||
|
|
||||||
The dashboard performs RGW operations through a **radosgw system user**. On
|
|
||||||
recent Ceph (Pacific and later) the mgr/dashboard module usually auto-discovers
|
|
||||||
and configures this. Verify it first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ceph dashboard get-rgw-api-access-key # should print a key, not empty
|
|
||||||
```
|
|
||||||
|
|
||||||
If it is empty, create a system user and point the dashboard at it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
radosgw-admin user create \
|
radosgw-admin user create \
|
||||||
--uid=dashboard \
|
--uid=cephrgw-operator \
|
||||||
--display-name="Ceph Dashboard" \
|
--display-name="cephrgw-operator" \
|
||||||
--system
|
--caps="users=*;buckets=*"
|
||||||
|
|
||||||
# Feed the returned keys to the dashboard.
|
# Grab its keys (these become CEPH_RGW_ACCESS_KEY / CEPH_RGW_SECRET_KEY):
|
||||||
radosgw-admin user info --uid=dashboard \
|
radosgw-admin user info --uid=cephrgw-operator \
|
||||||
| jq -r '.keys[0].access_key' > /tmp/ak
|
| jq -r '.keys[0] | .access_key, .secret_key'
|
||||||
radosgw-admin user info --uid=dashboard \
|
|
||||||
| jq -r '.keys[0].secret_key' > /tmp/sk
|
|
||||||
|
|
||||||
ceph dashboard set-rgw-api-access-key -i /tmp/ak
|
|
||||||
ceph dashboard set-rgw-api-secret-key -i /tmp/sk
|
|
||||||
rm -f /tmp/ak /tmp/sk
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A `--system` user has the admin caps the dashboard needs to create/delete RGW
|
If the user already exists, add the caps instead:
|
||||||
users and buckets and to set bucket policies on any bucket. If you would rather
|
|
||||||
not use `--system`, grant an equivalent admin cap set instead:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
radosgw-admin caps add --uid=dashboard \
|
radosgw-admin caps add --uid=cephrgw-operator --caps="users=*;buckets=*"
|
||||||
--caps="users=*;buckets=*;metadata=*;usage=read;zone=read"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If the dashboard reaches RGW over TLS with a private CA, you may also need:
|
> `users=*;buckets=*` lets the operator create/read/delete RGW users and read/
|
||||||
|
> remove buckets through the Admin Ops API. Bucket **creation** and all bucket
|
||||||
|
> sub-resources (versioning, policy, tagging, object lock) go over the S3 API
|
||||||
|
> signed as the bucket owner, so they need no extra admin cap — every RGW user
|
||||||
|
> can manage its own buckets. The `--system` flag is **not** required.
|
||||||
|
|
||||||
|
## 2. Admin Ops API must be enabled on radosgw
|
||||||
|
|
||||||
|
The Admin Ops API is served by radosgw at the `admin` resource and is enabled by
|
||||||
|
default. If your deployment has trimmed `rgw_enable_apis`, make sure it includes
|
||||||
|
both `s3` and `admin`:
|
||||||
|
|
||||||
|
```
|
||||||
|
rgw_enable_apis = s3, admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick check from your workstation (a `403`/`AccessDenied` still proves the
|
||||||
|
endpoint is reachable and the API is on; a connection error means it is not):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ceph dashboard set-rgw-api-ssl-verify true # keep verification on in prod
|
curl -sk "https://radosgw.service.consul:443/admin/user?format=json"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Bucket policy support (read-only / non-owner read-write)
|
## 3. Bucket policy support (read-only / non-owner read-write)
|
||||||
|
|
||||||
The operator enforces `read-only` and non-owner `read-write` grants by writing
|
The operator enforces `read-only` and non-owner `read-write` grants by writing an
|
||||||
an **S3 bucket policy** through the dashboard's bucket API (the `bucket_policy`
|
**S3 bucket policy** (`PutBucketPolicy`). Bucket-policy support is available on
|
||||||
field on `PUT /api/rgw/bucket/{name}`). That field is available on **Ceph Reef
|
**Ceph Reef 18.2+ / Squid**. On older releases bucket creation and owner
|
||||||
18.2+ / Squid**. On older releases bucket creation and owner (`full`) access
|
(`full`) access still work, but policy-based grants will fail — upgrade the
|
||||||
still work, but policy-based grants will fail — upgrade the cluster, or only use
|
cluster, or only use owner credentials, if you are pre-Reef.
|
||||||
owner credentials, if you are pre-Reef.
|
|
||||||
|
|
||||||
Check your version:
|
Check your version:
|
||||||
|
|
||||||
@@ -108,8 +79,9 @@ ceph versions | jq -r '.mon | keys[]'
|
|||||||
|
|
||||||
The operator can stamp the S3 endpoint into every credential Secret it writes
|
The operator can stamp the S3 endpoint into every credential Secret it writes
|
||||||
(`S3_ENDPOINT` and `BUCKET_HOST`) so applications don't have to hard-code it.
|
(`S3_ENDPOINT` and `BUCKET_HOST`) so applications don't have to hard-code it.
|
||||||
This is the RGW/S3 endpoint your clients use — **not** the dashboard URL. Provide
|
Provide it via `CEPH_RGW_ENDPOINT` (see below); if unset, those keys are simply
|
||||||
it via `CEPH_RGW_ENDPOINT` (see below); if unset, those keys are simply omitted.
|
omitted. This is also the default endpoint for the Admin Ops and S3 API calls
|
||||||
|
when `CEPH_RGW_ADMIN_ENDPOINT` is not set separately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,55 +93,76 @@ deployment sources from a Secret named **`cephrgw-credentials`** in its namespac
|
|||||||
|
|
||||||
| Secret key | Required | Meaning |
|
| Secret key | Required | Meaning |
|
||||||
|------------|----------|---------|
|
|------------|----------|---------|
|
||||||
| `CEPH_DASHBOARD_URL` | yes | dashboard base URL, e.g. `https://dashboard.ceph.unkin.net` |
|
| `CEPH_RGW_ACCESS_KEY` | yes | access key of the RGW admin user from step 1 |
|
||||||
| `CEPH_DASHBOARD_USERNAME` | yes | the `rgw-manager` account from step 1 |
|
| `CEPH_RGW_SECRET_KEY` | yes | its secret key |
|
||||||
| `CEPH_DASHBOARD_PASSWORD` | yes | its password |
|
| `CEPH_RGW_ENDPOINT` | see note | S3 endpoint; written into consumer Secrets and used for API calls unless `CEPH_RGW_ADMIN_ENDPOINT` is set |
|
||||||
| `CEPH_RGW_ENDPOINT` | no | S3 endpoint written into consumer Secrets |
|
| `CEPH_RGW_ADMIN_ENDPOINT` | no | radosgw endpoint for the Admin Ops + S3 API calls, if it differs from the public `CEPH_RGW_ENDPOINT` |
|
||||||
| `CEPH_DASHBOARD_CA` | no | PEM CA bundle to verify the dashboard TLS cert (inline) |
|
| `CEPH_RGW_REGION` | no | SigV4 credential-scope region for S3 requests (default `default`) |
|
||||||
| `CEPH_DASHBOARD_CA_FILE` | no | path to a mounted CA file (alternative to the above) |
|
| `CEPH_RGW_CA` | no | PEM CA bundle to verify the radosgw TLS cert (inline) |
|
||||||
| `CEPH_DASHBOARD_INSECURE` | no | `"true"` to skip TLS verification (dev only) |
|
| `CEPH_RGW_CA_FILE` | no | path to a mounted CA file (alternative to the above) |
|
||||||
|
| `CEPH_RGW_INSECURE` | no | `"true"` to skip TLS verification (dev only) |
|
||||||
|
|
||||||
Create it directly:
|
> At least one of `CEPH_RGW_ENDPOINT` or `CEPH_RGW_ADMIN_ENDPOINT` must be set —
|
||||||
|
> the API endpoint falls back to `CEPH_RGW_ENDPOINT` when the admin one is unset.
|
||||||
|
|
||||||
|
### Primary method: Vault + VSO
|
||||||
|
|
||||||
|
The Secret is not managed in GitOps; it is rendered from Vault by the Vault
|
||||||
|
Secrets Operator (VSO). The argocd-apps `cephrgw-system` app ships a `VaultAuth`
|
||||||
|
and a `VaultStaticSecret` that authenticate with the shared `default` Kubernetes
|
||||||
|
auth role and render the KV path
|
||||||
|
`kubernetes/namespace/cephrgw-system/default/cephrgw-credentials` into the
|
||||||
|
`cephrgw-credentials` Secret. That path sits under the cluster's templated
|
||||||
|
default policy (`kv/data/kubernetes/namespace/<ns>/<sa>/*`), so **no dedicated
|
||||||
|
Vault role or policy is required** — you only seed the values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vault kv put kv/kubernetes/namespace/cephrgw-system/default/cephrgw-credentials \
|
||||||
|
CEPH_RGW_ENDPOINT=https://s3.ceph.unkin.net \
|
||||||
|
CEPH_RGW_ADMIN_ENDPOINT=https://radosgw.service.consul:443 \
|
||||||
|
CEPH_RGW_ACCESS_KEY='REPLACE-WITH-ACCESS-KEY' \
|
||||||
|
CEPH_RGW_SECRET_KEY='REPLACE-WITH-SECRET-KEY'
|
||||||
|
```
|
||||||
|
|
||||||
|
The keys under that KV path are copied verbatim into the Secret, so they must be
|
||||||
|
named exactly as the table above. VSO refreshes the Secret every few minutes,
|
||||||
|
and the deployment's `reloader.stakater.com/auto: "true"` annotation restarts the
|
||||||
|
operator when it changes — so rotating the credential is just a new `vault kv
|
||||||
|
put`, no manual rollout.
|
||||||
|
|
||||||
|
### Fallback: a plain Secret
|
||||||
|
|
||||||
|
Outside this cluster (or for a quick `kind` test) you can create the Secret
|
||||||
|
directly instead of using Vault:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n cephrgw-system create secret generic cephrgw-credentials \
|
kubectl -n cephrgw-system create secret generic cephrgw-credentials \
|
||||||
--from-literal=CEPH_DASHBOARD_URL=https://dashboard.ceph.unkin.net \
|
--from-literal=CEPH_RGW_ENDPOINT=https://s3.ceph.unkin.net \
|
||||||
--from-literal=CEPH_DASHBOARD_USERNAME=k8s-cephrgw-operator \
|
--from-literal=CEPH_RGW_ADMIN_ENDPOINT=https://radosgw.service.consul:443 \
|
||||||
--from-literal=CEPH_DASHBOARD_PASSWORD='REPLACE-WITH-A-STRONG-PASSWORD' \
|
--from-literal=CEPH_RGW_ACCESS_KEY='REPLACE-WITH-ACCESS-KEY' \
|
||||||
--from-literal=CEPH_RGW_ENDPOINT=https://s3.ceph.unkin.net
|
--from-literal=CEPH_RGW_SECRET_KEY='REPLACE-WITH-SECRET-KEY'
|
||||||
```
|
```
|
||||||
|
|
||||||
The deployment carries the `reloader.stakater.com/auto: "true"` annotation, so
|
The operator does not care where the Secret comes from, only that those keys
|
||||||
rotating this Secret triggers an automatic operator restart — no manual rollout
|
exist.
|
||||||
needed.
|
|
||||||
|
|
||||||
### Sourcing it from Vault (optional)
|
|
||||||
|
|
||||||
If you keep the password in Vault, sync it with a `VaultStaticSecret` (VSO is
|
|
||||||
already running in `vso-system`) that renders into `cephrgw-credentials` with
|
|
||||||
the keys above, instead of the plain `kubectl create secret`. The operator does
|
|
||||||
not care where the Secret comes from, only that those keys exist.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick verification
|
## Quick verification
|
||||||
|
|
||||||
Once the Secret and dashboard account exist, a smoke test from your workstation:
|
Once the Secret and RGW admin user exist, a smoke test from your workstation
|
||||||
|
using the operator's keys (this is the same Admin Ops call the operator's
|
||||||
|
readiness `Ping` makes):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Log in and capture a token.
|
# Signing an Admin Ops request by hand is fiddly; the simplest proof is to use
|
||||||
TOKEN=$(curl -sk -X POST https://dashboard.ceph.unkin.net/api/auth \
|
# the AWS CLI configured with the operator's keys against the S3 endpoint:
|
||||||
-H 'Accept: application/vnd.ceph.api.v1.0+json' \
|
AWS_ACCESS_KEY_ID=REPLACE-WITH-ACCESS-KEY \
|
||||||
-H 'Content-Type: application/json' \
|
AWS_SECRET_ACCESS_KEY=REPLACE-WITH-SECRET-KEY \
|
||||||
-d '{"username":"k8s-cephrgw-operator","password":"REPLACE-WITH-A-STRONG-PASSWORD"}' \
|
aws --endpoint-url https://s3.ceph.unkin.net s3 ls
|
||||||
| jq -r .token)
|
|
||||||
|
|
||||||
# 2. List RGW users — a 200 with a JSON array means the role + RGW wiring work.
|
|
||||||
curl -sk https://dashboard.ceph.unkin.net/api/rgw/user \
|
|
||||||
-H 'Accept: application/vnd.ceph.api.v1.0+json' \
|
|
||||||
-H "Authorization: Bearer $TOKEN"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If step 1 fails the login/role is wrong (step 1–2 above); if step 1 works but
|
A successful (even empty) listing proves the keys and endpoint work. If the
|
||||||
step 2 returns 500/empty, the dashboard→RGW connection is not configured
|
operator logs `initial radosgw authentication failed`, the keys are wrong or the
|
||||||
(step 2).
|
`admin` API is disabled (steps 1–2); if users are created but bucket policy
|
||||||
|
grants fail, the cluster is likely pre-Reef (step 3).
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
module git.unkin.net/unkin/cephrgw-operator
|
module git.unkin.net/unkin/cephrgw-operator
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
|
||||||
|
github.com/aws/smithy-go v1.27.4
|
||||||
|
github.com/ceph/go-ceph v0.40.0
|
||||||
k8s.io/api v0.34.4
|
k8s.io/api v0.34.4
|
||||||
|
k8s.io/apiextensions-apiserver v0.34.1
|
||||||
k8s.io/apimachinery v0.34.4
|
k8s.io/apimachinery v0.34.4
|
||||||
k8s.io/client-go v0.34.4
|
k8s.io/client-go v0.34.4
|
||||||
sigs.k8s.io/controller-runtime v0.22.4
|
sigs.k8s.io/controller-runtime v0.22.4
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -48,7 +62,7 @@ require (
|
|||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/oauth2 v0.27.0 // indirect
|
golang.org/x/oauth2 v0.27.0 // indirect
|
||||||
golang.org/x/sync v0.12.0 // indirect
|
golang.org/x/sync v0.12.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/term v0.30.0 // indirect
|
golang.org/x/term v0.30.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
golang.org/x/time v0.9.0 // indirect
|
golang.org/x/time v0.9.0 // indirect
|
||||||
@@ -57,7 +71,6 @@ require (
|
|||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/apiextensions-apiserver v0.34.1 // indirect
|
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.22 h1:Vfvp7+fYKsVCADcWOEllqEV47aIBXhNchvyDFu1B5fY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.22/go.mod h1:0+H+0nPKbvWltf5vSIGkApv+hGbaQ4FfwTjGIYQREcw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg=
|
||||||
|
github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M=
|
||||||
|
github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/ceph/go-ceph v0.40.0 h1:Wz9WOX6i73Hz74mpwhTO9S6IyX3eFPv88VUc7FRMPRk=
|
||||||
|
github.com/ceph/go-ceph v0.40.0/go.mod h1:1oFtT/x/4y+teLsNiogdd/Kj81Gmrw6JM5w1bWnZoc4=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
@@ -101,8 +139,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -138,8 +176,8 @@ golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
# Dashboard credentials for local testing. Replace the values, or create the
|
# radosgw admin credentials for local testing. Replace the values, or create the
|
||||||
# Secret out-of-band, before applying. Keys map 1:1 to the operator env vars.
|
# Secret out-of-band, before applying. Keys map 1:1 to the operator env vars.
|
||||||
|
# The access/secret key belong to an RGW user with admin caps
|
||||||
|
# (users=*, buckets=*, metadata=read).
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@@ -8,13 +10,17 @@ metadata:
|
|||||||
namespace: cephrgw-system
|
namespace: cephrgw-system
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
CEPH_DASHBOARD_URL: "https://dashboard.ceph.unkin.net"
|
# radosgw endpoint the operator talks to (Admin Ops + S3 APIs).
|
||||||
CEPH_DASHBOARD_USERNAME: "k8s-cephrgw-operator"
|
CEPH_RGW_ADMIN_ENDPOINT: "https://radosgw.service.consul:443"
|
||||||
CEPH_DASHBOARD_PASSWORD: "change-me"
|
CEPH_RGW_ACCESS_KEY: "change-me"
|
||||||
# Optional: the S3 endpoint written into credential Secrets for consumers.
|
CEPH_RGW_SECRET_KEY: "change-me"
|
||||||
|
# The S3 endpoint written into credential Secrets for consumers (may be a
|
||||||
|
# public name that differs from the API endpoint above).
|
||||||
CEPH_RGW_ENDPOINT: "https://s3.ceph.unkin.net"
|
CEPH_RGW_ENDPOINT: "https://s3.ceph.unkin.net"
|
||||||
|
# Optional: SigV4 credential-scope region (defaults to "default").
|
||||||
|
# CEPH_RGW_REGION: "default"
|
||||||
# Optional: set to "true" to skip TLS verification (dev only).
|
# Optional: set to "true" to skip TLS verification (dev only).
|
||||||
# CEPH_DASHBOARD_INSECURE: "true"
|
# CEPH_RGW_INSECURE: "true"
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
|
|||||||
+184
-74
@@ -2,22 +2,30 @@ package ceph
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"encoding/json"
|
||||||
"net/url"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
"github.com/ceph/go-ceph/rgw/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BucketInfo is the subset of an RGW bucket record the operator consumes.
|
// BucketInfo is the subset of an RGW bucket record the operator consumes, as
|
||||||
// Different Ceph releases name the id/name fields slightly differently, so the
|
// returned by the Admin Ops API (GET /admin/bucket).
|
||||||
// struct captures the known variants and Name/ID normalise them.
|
|
||||||
type BucketInfo struct {
|
type BucketInfo struct {
|
||||||
Bucket string `json:"bucket"`
|
Bucket string
|
||||||
Bid string `json:"bid"`
|
Bid string
|
||||||
ID string `json:"id"`
|
ID string
|
||||||
Owner string `json:"owner"`
|
Owner string
|
||||||
|
// PlacementRule is the placement target RGW stores the bucket on (e.g.
|
||||||
|
// "default-placement" or "ec"), read from the Admin Ops bucket stats.
|
||||||
|
PlacementRule string
|
||||||
|
// Zonegroup is the RGW zonegroup id the bucket belongs to.
|
||||||
|
Zonegroup string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name returns the bucket name regardless of the field the dashboard used.
|
// Name returns the bucket name regardless of the field radosgw used.
|
||||||
func (b *BucketInfo) Name() string {
|
func (b *BucketInfo) Name() string {
|
||||||
if b.Bucket != "" {
|
if b.Bucket != "" {
|
||||||
return b.Bucket
|
return b.Bucket
|
||||||
@@ -40,90 +48,192 @@ type CreateBucketSpec struct {
|
|||||||
LockYears *int32
|
LockYears *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type createBucketRequest struct {
|
// GetBucket fetches a bucket by name via the Admin Ops API, returning an error
|
||||||
Bucket string `json:"bucket"`
|
// classified by IsNotFound (admin.ErrNoSuchBucket) when it does not exist.
|
||||||
UID string `json:"uid"`
|
|
||||||
Zonegroup string `json:"zonegroup,omitempty"`
|
|
||||||
PlacementTarget string `json:"placement_target,omitempty"`
|
|
||||||
LockEnabled string `json:"lock_enabled"`
|
|
||||||
LockMode string `json:"lock_mode,omitempty"`
|
|
||||||
LockDays string `json:"lock_retention_period_days,omitempty"`
|
|
||||||
LockYears string `json:"lock_retention_period_years,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBucket fetches a bucket by name, returning an *APIError with status 404
|
|
||||||
// (see IsNotFound) when it does not exist.
|
|
||||||
func (c *Client) GetBucket(ctx context.Context, name string) (*BucketInfo, error) {
|
func (c *Client) GetBucket(ctx context.Context, name string) (*BucketInfo, error) {
|
||||||
var b BucketInfo
|
b, err := c.admin.GetBucketInfo(ctx, admin.Bucket{Bucket: name})
|
||||||
if err := c.do(ctx, http.MethodGet, "/api/rgw/bucket/"+url.PathEscape(name), nil, &b, ""); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &b, nil
|
return &BucketInfo{
|
||||||
|
Bucket: b.Bucket,
|
||||||
|
ID: b.ID,
|
||||||
|
Owner: b.Owner,
|
||||||
|
PlacementRule: b.PlacementRule,
|
||||||
|
Zonegroup: b.Zonegroup,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateBucket provisions a bucket owned by spec.OwnerUID.
|
// CreateBucket provisions a bucket owned by spec.OwnerUID. The Admin Ops API
|
||||||
|
// cannot create buckets, so the operator issues an S3 CreateBucket signed as the
|
||||||
|
// owner (which makes the owner the bucket owner directly).
|
||||||
func (c *Client) CreateBucket(ctx context.Context, spec CreateBucketSpec) (*BucketInfo, error) {
|
func (c *Client) CreateBucket(ctx context.Context, spec CreateBucketSpec) (*BucketInfo, error) {
|
||||||
req := createBucketRequest{
|
owner, err := c.asOwner(ctx, spec.OwnerUID)
|
||||||
Bucket: spec.Bucket,
|
if err != nil {
|
||||||
UID: spec.OwnerUID,
|
|
||||||
Zonegroup: spec.Zonegroup,
|
|
||||||
PlacementTarget: spec.PlacementTarget,
|
|
||||||
LockEnabled: strconv.FormatBool(spec.LockEnabled),
|
|
||||||
LockMode: spec.LockMode,
|
|
||||||
}
|
|
||||||
if spec.LockDays != nil {
|
|
||||||
req.LockDays = strconv.Itoa(int(*spec.LockDays))
|
|
||||||
}
|
|
||||||
if spec.LockYears != nil {
|
|
||||||
req.LockYears = strconv.Itoa(int(*spec.LockYears))
|
|
||||||
}
|
|
||||||
var b BucketInfo
|
|
||||||
if err := c.do(ctx, http.MethodPost, "/api/rgw/bucket", req, &b, ""); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type setBucketRequest struct {
|
input := &s3.CreateBucketInput{Bucket: aws.String(spec.Bucket)}
|
||||||
BucketID string `json:"bucket_id"`
|
if loc := locationConstraint(spec.Zonegroup, spec.PlacementTarget); loc != "" {
|
||||||
UID string `json:"uid"`
|
input.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{
|
||||||
VersioningState *string `json:"versioning_state,omitempty"`
|
LocationConstraint: s3types.BucketLocationConstraint(loc),
|
||||||
BucketPolicy *string `json:"bucket_policy,omitempty"`
|
}
|
||||||
Tags *string `json:"tags,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBucketVersioning enables or suspends S3 versioning on a bucket.
|
|
||||||
func (c *Client) SetBucketVersioning(ctx context.Context, name, bucketID, ownerUID string, enabled bool) error {
|
|
||||||
state := "Suspended"
|
|
||||||
if enabled {
|
|
||||||
state = "Enabled"
|
|
||||||
}
|
}
|
||||||
req := setBucketRequest{BucketID: bucketID, UID: ownerUID, VersioningState: &state}
|
if spec.LockEnabled {
|
||||||
return c.do(ctx, http.MethodPut, "/api/rgw/bucket/"+url.PathEscape(name), req, nil, "")
|
input.ObjectLockEnabledForBucket = aws.Bool(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.s3.CreateBucket(ctx, input, owner); err != nil && !IsConflict(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a default object-lock retention when requested.
|
||||||
|
if spec.LockEnabled && spec.LockMode != "" && (spec.LockDays != nil || spec.LockYears != nil) {
|
||||||
|
if err := c.setObjectLockDefault(ctx, owner, spec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.GetBucket(ctx, spec.Bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBucketPolicy replaces the S3 bucket policy. An empty policy string asks the
|
// SetBucketVersioning enables or suspends S3 versioning on a bucket. bucketID is
|
||||||
// dashboard to clear it; not every release honours clearing, so callers should
|
// unused (kept for call-site stability).
|
||||||
// treat a clear as best-effort.
|
func (c *Client) SetBucketVersioning(ctx context.Context, name, bucketID, ownerUID string, enabled bool) error {
|
||||||
|
owner, err := c.asOwner(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status := s3types.BucketVersioningStatusSuspended
|
||||||
|
if enabled {
|
||||||
|
status = s3types.BucketVersioningStatusEnabled
|
||||||
|
}
|
||||||
|
_, err = c.s3.PutBucketVersioning(ctx, &s3.PutBucketVersioningInput{
|
||||||
|
Bucket: aws.String(name),
|
||||||
|
VersioningConfiguration: &s3types.VersioningConfiguration{Status: status},
|
||||||
|
}, owner)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBucketPolicy replaces the S3 bucket policy. An empty policy clears it.
|
||||||
|
// bucketID is unused (kept for call-site stability).
|
||||||
func (c *Client) SetBucketPolicy(ctx context.Context, name, bucketID, ownerUID, policy string) error {
|
func (c *Client) SetBucketPolicy(ctx context.Context, name, bucketID, ownerUID, policy string) error {
|
||||||
req := setBucketRequest{BucketID: bucketID, UID: ownerUID, BucketPolicy: &policy}
|
owner, err := c.asOwner(ctx, ownerUID)
|
||||||
return c.do(ctx, http.MethodPut, "/api/rgw/bucket/"+url.PathEscape(name), req, nil, "")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if policy == "" {
|
||||||
|
_, err := c.s3.DeleteBucketPolicy(ctx, &s3.DeleteBucketPolicyInput{Bucket: aws.String(name)}, owner)
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = c.s3.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
||||||
|
Bucket: aws.String(name),
|
||||||
|
Policy: aws.String(policy),
|
||||||
|
}, owner)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBucketTags replaces the bucket tag set. tagsJSON is the RGW/S3 tag JSON
|
// GetBucketPolicy returns the bucket's current S3 policy JSON, or "" when it has
|
||||||
// (a list of {"Key","Value"} objects).
|
// none. It is signed as the bucket owner.
|
||||||
|
func (c *Client) GetBucketPolicy(ctx context.Context, name, ownerUID string) (string, error) {
|
||||||
|
owner, err := c.asOwner(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, err := c.s3.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{Bucket: aws.String(name)}, owner)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out.Policy == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return *out.Policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBucketTags replaces the bucket tag set. tagsJSON is the JSON produced by
|
||||||
|
// BuildTagJSON (a list of {"Key","Value"} objects). bucketID is unused (kept for
|
||||||
|
// call-site stability).
|
||||||
func (c *Client) SetBucketTags(ctx context.Context, name, bucketID, ownerUID, tagsJSON string) error {
|
func (c *Client) SetBucketTags(ctx context.Context, name, bucketID, ownerUID, tagsJSON string) error {
|
||||||
req := setBucketRequest{BucketID: bucketID, UID: ownerUID, Tags: &tagsJSON}
|
owner, err := c.asOwner(ctx, ownerUID)
|
||||||
return c.do(ctx, http.MethodPut, "/api/rgw/bucket/"+url.PathEscape(name), req, nil, "")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type tag struct {
|
||||||
|
Key string `json:"Key"`
|
||||||
|
Value string `json:"Value"`
|
||||||
|
}
|
||||||
|
var tags []tag
|
||||||
|
if tagsJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tagsJSON), &tags); err != nil {
|
||||||
|
return fmt.Errorf("ceph: parse bucket tags: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tags) == 0 {
|
||||||
|
_, err := c.s3.DeleteBucketTagging(ctx, &s3.DeleteBucketTaggingInput{Bucket: aws.String(name)}, owner)
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagSet := make([]s3types.Tag, 0, len(tags))
|
||||||
|
for _, t := range tags {
|
||||||
|
tagSet = append(tagSet, s3types.Tag{Key: aws.String(t.Key), Value: aws.String(t.Value)})
|
||||||
|
}
|
||||||
|
_, err = c.s3.PutBucketTagging(ctx, &s3.PutBucketTaggingInput{
|
||||||
|
Bucket: aws.String(name),
|
||||||
|
Tagging: &s3types.Tagging{TagSet: tagSet},
|
||||||
|
}, owner)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteBucket removes a bucket. When purge is true its objects are deleted too;
|
// DeleteBucket removes a bucket via the Admin Ops API. When purge is true its
|
||||||
// otherwise deletion of a non-empty bucket fails. A 404 is treated as success.
|
// objects are deleted too. A NoSuchBucket response is treated as success.
|
||||||
func (c *Client) DeleteBucket(ctx context.Context, name string, purge bool) error {
|
func (c *Client) DeleteBucket(ctx context.Context, name string, purge bool) error {
|
||||||
path := "/api/rgw/bucket/" + url.PathEscape(name) + "?purge_objects=" + strconv.FormatBool(purge)
|
err := c.admin.RemoveBucket(ctx, admin.Bucket{Bucket: name, PurgeObject: &purge})
|
||||||
err := c.do(ctx, http.MethodDelete, path, nil, nil, "")
|
|
||||||
if IsNotFound(err) {
|
if IsNotFound(err) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setObjectLockDefault sets the bucket's default object-lock retention.
|
||||||
|
func (c *Client) setObjectLockDefault(ctx context.Context, owner func(*s3.Options), spec CreateBucketSpec) error {
|
||||||
|
_, err := c.s3.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
|
||||||
|
Bucket: aws.String(spec.Bucket),
|
||||||
|
ObjectLockConfiguration: &s3types.ObjectLockConfiguration{
|
||||||
|
ObjectLockEnabled: s3types.ObjectLockEnabledEnabled,
|
||||||
|
Rule: &s3types.ObjectLockRule{
|
||||||
|
DefaultRetention: &s3types.DefaultRetention{
|
||||||
|
Mode: s3types.ObjectLockRetentionMode(spec.LockMode),
|
||||||
|
Days: spec.LockDays,
|
||||||
|
Years: spec.LockYears,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, owner)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// locationConstraint renders the RGW S3 CreateBucket LocationConstraint from a
|
||||||
|
// zonegroup api-name and a placement target. RGW's S3 create-bucket handler
|
||||||
|
// splits the value on the first ":" — the part before is the zonegroup api-name,
|
||||||
|
// the part after is the placement target id. An empty zonegroup (the common
|
||||||
|
// case) yields ":<placement>", which selects the local/master zonegroup with the
|
||||||
|
// given placement, so callers need not know the zonegroup's api-name to pick a
|
||||||
|
// placement target. Both empty yields "" (no constraint: user/zonegroup
|
||||||
|
// default). Placement empty with a zonegroup set yields just the zonegroup.
|
||||||
|
func locationConstraint(zonegroup, placement string) string {
|
||||||
|
loc := zonegroup
|
||||||
|
if placement != "" {
|
||||||
|
loc = zonegroup + ":" + placement
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package ceph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocationConstraint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
zonegroup string
|
||||||
|
placement string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"both empty -> no constraint", "", "", ""},
|
||||||
|
{"placement only -> local zonegroup", "", "ec", ":ec"},
|
||||||
|
{"placement only default target", "", "default-placement", ":default-placement"},
|
||||||
|
{"zonegroup and placement", "default", "ec", "default:ec"},
|
||||||
|
{"zonegroup only", "default", "", "default"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := locationConstraint(tc.zonegroup, tc.placement); got != tc.want {
|
||||||
|
t.Errorf("locationConstraint(%q,%q)=%q want %q", tc.zonegroup, tc.placement, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetBucketPlacement verifies GetBucket surfaces the placement target and
|
||||||
|
// zonegroup from the Admin Ops bucket-stats response, so the controller can
|
||||||
|
// detect placement drift.
|
||||||
|
func TestGetBucketPlacement(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"bucket": "raw-archive",
|
||||||
|
"id": "eae688bc-ee35-445d-9188-111b73c8b4a0.12345.1",
|
||||||
|
"owner": "logarchiver",
|
||||||
|
"zonegroup": "eae688bc-ee35-445d-9188-111b73c8b4a0",
|
||||||
|
"placement_rule": "ec"
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c, err := NewClient(Config{Endpoint: srv.URL, AccessKey: "a", SecretKey: "s"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
info, err := c.GetBucket(context.Background(), "raw-archive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBucket: %v", err)
|
||||||
|
}
|
||||||
|
if info.PlacementRule != "ec" {
|
||||||
|
t.Errorf("PlacementRule=%q want %q", info.PlacementRule, "ec")
|
||||||
|
}
|
||||||
|
if info.Zonegroup != "eae688bc-ee35-445d-9188-111b73c8b4a0" {
|
||||||
|
t.Errorf("Zonegroup=%q unexpected", info.Zonegroup)
|
||||||
|
}
|
||||||
|
if info.Owner != "logarchiver" {
|
||||||
|
t.Errorf("Owner=%q want logarchiver", info.Owner)
|
||||||
|
}
|
||||||
|
if info.Name() != "raw-archive" {
|
||||||
|
t.Errorf("Name()=%q want raw-archive", info.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
-150
@@ -1,39 +1,50 @@
|
|||||||
// Package ceph is a small client for the Ceph manager dashboard REST API,
|
// Package ceph is a small client for the Ceph RGW (radosgw) admin and S3 APIs,
|
||||||
// scoped to the RGW (S3) user and bucket endpoints the operator needs.
|
// scoped to the user, bucket and policy operations the operator needs.
|
||||||
//
|
//
|
||||||
// The dashboard authenticates with a username/password to POST /api/auth, which
|
// It talks directly to radosgw (e.g. https://radosgw.service.consul:443) rather
|
||||||
// returns a bearer (JWT) token. The client caches that token and transparently
|
// than the manager dashboard, via two native Go libraries:
|
||||||
// re-authenticates when the server returns 401 (expired/invalid token).
|
//
|
||||||
|
// - github.com/ceph/go-ceph/rgw/admin drives the RGW Admin Ops API
|
||||||
|
// (/admin/...), signed with the operator's admin access/secret key, to
|
||||||
|
// manage users, keys, quotas and bucket info/removal.
|
||||||
|
// - github.com/aws/aws-sdk-go-v2/service/s3 drives the S3 API (/), signed as
|
||||||
|
// the bucket's owner, to create buckets and set versioning, tagging, policy
|
||||||
|
// and object lock — operations the Admin Ops API does not expose.
|
||||||
package ceph
|
package ceph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
smithy "github.com/aws/smithy-go"
|
||||||
|
awshttp "github.com/aws/smithy-go/transport/http"
|
||||||
|
"github.com/ceph/go-ceph/rgw/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultAccept is the versioned media type the Ceph dashboard requires on its
|
// Config configures a Client.
|
||||||
// RGW endpoints. The dashboard rejects requests without a matching version.
|
|
||||||
const defaultAccept = "application/vnd.ceph.api.v1.0+json"
|
|
||||||
|
|
||||||
// Config configures a dashboard Client.
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// BaseURL is the dashboard root, e.g. https://dashboard.ceph.unkin.net.
|
// Endpoint is the radosgw root, e.g. https://radosgw.service.consul:443.
|
||||||
BaseURL string
|
Endpoint string
|
||||||
// Username / Password authenticate to POST /api/auth. The account needs the
|
// AccessKey / SecretKey are the S3 credentials of an RGW user holding the
|
||||||
// rgw-manager role (or admin) on the dashboard.
|
// admin caps the operator needs (users=*, buckets=*).
|
||||||
Username string
|
AccessKey string
|
||||||
Password string
|
SecretKey string
|
||||||
// CACert is an optional PEM bundle used to verify the dashboard TLS cert.
|
// Region is the SigV4 credential-scope region used for S3 requests. radosgw
|
||||||
|
// verifies the signature against whatever region the client used, so any
|
||||||
|
// consistent value works; defaults to "default". (The go-ceph admin client
|
||||||
|
// always signs its own requests with region "default".)
|
||||||
|
Region string
|
||||||
|
// CACert is an optional PEM bundle used to verify the radosgw TLS cert.
|
||||||
CACert []byte
|
CACert []byte
|
||||||
// Insecure disables TLS verification (not recommended).
|
// Insecure disables TLS verification (not recommended).
|
||||||
Insecure bool
|
Insecure bool
|
||||||
@@ -41,49 +52,80 @@ type Config struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client talks to the Ceph dashboard API. It is safe for concurrent use.
|
// Client talks to radosgw. It is safe for concurrent use.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
base string
|
admin *admin.API
|
||||||
user string
|
s3 *s3.Client
|
||||||
pass string
|
region string
|
||||||
http *http.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
// keyCache memoises owner uid -> S3 credentials (via the Admin Ops API) so
|
||||||
token string
|
// per-owner S3 calls do not re-fetch keys on every reconcile.
|
||||||
|
mu sync.Mutex
|
||||||
|
keyCache map[string]aws.CredentialsProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIError is returned for any non-2xx dashboard response.
|
// notFoundCodes and conflictCodes classify RGW/S3 error codes that surface only
|
||||||
type APIError struct {
|
// as a generic smithy.APIError (i.e. not a modeled S3 error type).
|
||||||
Status int
|
var notFoundCodes = map[string]bool{
|
||||||
Method string
|
"NoSuchUser": true, "NoSuchBucket": true, "NoSuchKey": true,
|
||||||
Path string
|
"NoSuchBucketPolicy": true, "NoSuchTagSet": true,
|
||||||
Body string
|
"NoSuchTagSetError": true, "NotFound": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
var conflictCodes = map[string]bool{
|
||||||
return fmt.Sprintf("ceph dashboard %s %s: status %d: %s", e.Method, e.Path, e.Status, e.Body)
|
"BucketAlreadyExists": true, "BucketAlreadyOwnedByYou": true, "UserAlreadyExists": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsNotFound reports whether err is a 404 from the dashboard.
|
// IsNotFound reports whether err represents a missing user, bucket, key, policy
|
||||||
|
// or tag set, on either the admin or the S3 path.
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
var a *APIError
|
if err == nil {
|
||||||
return errors.As(err, &a) && a.Status == http.StatusNotFound
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrNoSuchUser) || errors.Is(err, admin.ErrNoSuchBucket) ||
|
||||||
|
errors.Is(err, admin.ErrNoSuchKey) || errors.Is(err, admin.ErrNoSuchObject) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var apiErr smithy.APIError
|
||||||
|
if errors.As(err, &apiErr) && notFoundCodes[apiErr.ErrorCode()] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var respErr *awshttp.ResponseError
|
||||||
|
if errors.As(err, &respErr) && respErr.HTTPStatusCode() == http.StatusNotFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConflict reports whether err is a 409 from the dashboard.
|
// IsConflict reports whether err represents an already-exists conflict on either
|
||||||
|
// the admin or the S3 path.
|
||||||
func IsConflict(err error) bool {
|
func IsConflict(err error) bool {
|
||||||
var a *APIError
|
if err == nil {
|
||||||
return errors.As(err, &a) && a.Status == http.StatusConflict
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrUserExists) || errors.Is(err, admin.ErrEmailExists) ||
|
||||||
|
errors.Is(err, admin.ErrKeyExists) || errors.Is(err, admin.ErrBucketNotEmpty) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var apiErr smithy.APIError
|
||||||
|
if errors.As(err, &apiErr) && conflictCodes[apiErr.ErrorCode()] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var respErr *awshttp.ResponseError
|
||||||
|
if errors.As(err, &respErr) && respErr.HTTPStatusCode() == http.StatusConflict {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient validates cfg and builds a Client.
|
// NewClient validates cfg and builds a Client.
|
||||||
func NewClient(cfg Config) (*Client, error) {
|
func NewClient(cfg Config) (*Client, error) {
|
||||||
base := strings.TrimRight(cfg.BaseURL, "/")
|
endpoint := strings.TrimRight(cfg.Endpoint, "/")
|
||||||
if base == "" {
|
if endpoint == "" {
|
||||||
return nil, fmt.Errorf("ceph: dashboard base URL is required")
|
return nil, fmt.Errorf("ceph: radosgw endpoint is required")
|
||||||
}
|
}
|
||||||
if cfg.Username == "" || cfg.Password == "" {
|
if cfg.AccessKey == "" || cfg.SecretKey == "" {
|
||||||
return nil, fmt.Errorf("ceph: dashboard username and password are required")
|
return nil, fmt.Errorf("ceph: radosgw admin access and secret key are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure} //nolint:gosec // opt-in via config
|
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure} //nolint:gosec // opt-in via config
|
||||||
@@ -99,124 +141,84 @@ func NewClient(cfg Config) (*Client, error) {
|
|||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
region := cfg.Region
|
||||||
|
if region == "" {
|
||||||
|
region = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAPI, err := admin.New(endpoint, cfg.AccessKey, cfg.SecretKey, httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ceph: build admin client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Client := s3.New(s3.Options{
|
||||||
|
Region: region,
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, ""),
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
BaseEndpoint: aws.String(endpoint),
|
||||||
|
// radosgw serves buckets path-style, not virtual-host style.
|
||||||
|
UsePathStyle: true,
|
||||||
|
// radosgw (pre-Reef backports) rejects the SDK's default CRC32 /
|
||||||
|
// aws-chunked integrity protections; only send checksums when the API
|
||||||
|
// requires them.
|
||||||
|
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
|
||||||
|
ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
|
||||||
|
})
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
base: base,
|
admin: adminAPI,
|
||||||
user: cfg.Username,
|
s3: s3Client,
|
||||||
pass: cfg.Password,
|
region: region,
|
||||||
http: &http.Client{
|
keyCache: map[string]aws.CredentialsProvider{},
|
||||||
Timeout: timeout,
|
|
||||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// do performs an authenticated request, decoding a 2xx JSON body into out (when
|
// asOwner returns a per-call S3 option that signs the request as the RGW user
|
||||||
// non-nil). On a 401 it drops the cached token, re-authenticates, and retries
|
// uid, looking up (and caching) the user's first key pair via the Admin Ops API.
|
||||||
// once. accept overrides the Accept header version when non-empty.
|
// Signing S3 sub-resource operations as the bucket owner (rather than the admin
|
||||||
func (c *Client) do(ctx context.Context, method, path string, body, out any, accept string) error {
|
// user) makes the owner the bucket owner directly and keeps RGW's per-user S3
|
||||||
if accept == "" {
|
// authorization intact.
|
||||||
accept = defaultAccept
|
func (c *Client) asOwner(ctx context.Context, uid string) (func(*s3.Options), error) {
|
||||||
}
|
c.mu.Lock()
|
||||||
tok, err := c.ensureToken(ctx)
|
provider, ok := c.keyCache[uid]
|
||||||
if err != nil {
|
c.mu.Unlock()
|
||||||
return err
|
if !ok {
|
||||||
}
|
user, err := c.GetUser(ctx, uid)
|
||||||
status, err := c.execute(ctx, method, path, body, accept, tok, out)
|
|
||||||
if status == http.StatusUnauthorized {
|
|
||||||
c.clearToken()
|
|
||||||
tok, err = c.ensureToken(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
_, err = c.execute(ctx, method, path, body, accept, tok, out)
|
key, has := user.S3Key()
|
||||||
|
if !has {
|
||||||
|
return nil, fmt.Errorf("ceph: user %s has no S3 keys to sign bucket operations", uid)
|
||||||
|
}
|
||||||
|
provider = credentials.NewStaticCredentialsProvider(key.AccessKey, key.SecretKey, "")
|
||||||
|
c.mu.Lock()
|
||||||
|
c.keyCache[uid] = provider
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
return err
|
return func(o *s3.Options) { o.Credentials = provider }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ensureToken(ctx context.Context) (string, error) {
|
// forgetIdentity drops any cached S3 credentials for uid, e.g. after its keys
|
||||||
|
// may have changed.
|
||||||
|
func (c *Client) forgetIdentity(uid string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
delete(c.keyCache, uid)
|
||||||
if c.token != "" {
|
|
||||||
return c.token, nil
|
|
||||||
}
|
|
||||||
tok, err := c.login(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
c.token = tok
|
|
||||||
return tok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) clearToken() {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.token = ""
|
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) login(ctx context.Context) (string, error) {
|
// Ping verifies connectivity and that the admin credentials sign correctly. It
|
||||||
var out struct {
|
// asks the Admin Ops API for a sentinel user: a NoSuchUser answer still proves
|
||||||
Token string `json:"token"`
|
// the request authenticated, so only transport/auth errors fail the check.
|
||||||
}
|
|
||||||
payload := map[string]string{"username": c.user, "password": c.pass}
|
|
||||||
if _, err := c.execute(ctx, http.MethodPost, "/api/auth", payload, defaultAccept, "", &out); err != nil {
|
|
||||||
return "", fmt.Errorf("dashboard login failed: %w", err)
|
|
||||||
}
|
|
||||||
if out.Token == "" {
|
|
||||||
return "", fmt.Errorf("dashboard login returned no token")
|
|
||||||
}
|
|
||||||
return out.Token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// execute runs a single request and returns the HTTP status. A non-2xx status
|
|
||||||
// yields an *APIError. token is sent as a bearer when non-empty.
|
|
||||||
func (c *Client) execute(ctx context.Context, method, path string, body any, accept, token string, out any) (int, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
b, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("marshal request body: %w", err)
|
|
||||||
}
|
|
||||||
reader = bytes.NewReader(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, reader)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", accept)
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return resp.StatusCode, &APIError{
|
|
||||||
Status: resp.StatusCode,
|
|
||||||
Method: method,
|
|
||||||
Path: path,
|
|
||||||
Body: strings.TrimSpace(string(data)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out != nil && len(data) > 0 {
|
|
||||||
if err := json.Unmarshal(data, out); err != nil {
|
|
||||||
return resp.StatusCode, fmt.Errorf("decode %s %s response: %w", method, path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ping verifies connectivity and credentials by authenticating.
|
|
||||||
func (c *Client) Ping(ctx context.Context) error {
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
_, err := c.ensureToken(ctx)
|
_, err := c.GetUser(ctx, "cephrgw-operator-ping-nonexistent")
|
||||||
|
if err == nil || IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package ceph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
smithy "github.com/aws/smithy-go"
|
||||||
|
"github.com/ceph/go-ceph/rgw/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClientValidation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"ok", Config{Endpoint: "https://rgw:443", AccessKey: "a", SecretKey: "s"}, false},
|
||||||
|
{"no endpoint", Config{AccessKey: "a", SecretKey: "s"}, true},
|
||||||
|
{"no access key", Config{Endpoint: "https://rgw:443", SecretKey: "s"}, true},
|
||||||
|
{"no secret key", Config{Endpoint: "https://rgw:443", AccessKey: "a"}, true},
|
||||||
|
{"bad ca", Config{Endpoint: "https://rgw:443", AccessKey: "a", SecretKey: "s", CACert: []byte("not pem")}, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := NewClient(tc.cfg)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("NewClient err=%v wantErr=%v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNotFound(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, false},
|
||||||
|
{"admin no such user", admin.ErrNoSuchUser, true},
|
||||||
|
{"admin no such bucket", admin.ErrNoSuchBucket, true},
|
||||||
|
{"admin no such key", admin.ErrNoSuchKey, true},
|
||||||
|
{"s3 no such bucket", &s3types.NoSuchBucket{}, true},
|
||||||
|
{"s3 no such key", &s3types.NoSuchKey{}, true},
|
||||||
|
{"generic no such bucket policy", &smithy.GenericAPIError{Code: "NoSuchBucketPolicy"}, true},
|
||||||
|
{"generic no such tag set", &smithy.GenericAPIError{Code: "NoSuchTagSet"}, true},
|
||||||
|
{"admin user exists is not notfound", admin.ErrUserExists, false},
|
||||||
|
{"unrelated", &smithy.GenericAPIError{Code: "AccessDenied"}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := IsNotFound(tc.err); got != tc.want {
|
||||||
|
t.Errorf("IsNotFound(%v)=%v want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsConflict(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, false},
|
||||||
|
{"admin user exists", admin.ErrUserExists, true},
|
||||||
|
{"admin bucket not empty", admin.ErrBucketNotEmpty, true},
|
||||||
|
{"s3 bucket already owned by you", &s3types.BucketAlreadyOwnedByYou{}, true},
|
||||||
|
{"s3 bucket already exists", &s3types.BucketAlreadyExists{}, true},
|
||||||
|
{"generic bucket already exists", &smithy.GenericAPIError{Code: "BucketAlreadyExists"}, true},
|
||||||
|
{"admin no such user is not conflict", admin.ErrNoSuchUser, false},
|
||||||
|
{"unrelated", &smithy.GenericAPIError{Code: "AccessDenied"}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := IsConflict(tc.err); got != tc.want {
|
||||||
|
t.Errorf("IsConflict(%v)=%v want %v", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+280
-47
@@ -2,7 +2,9 @@ package ceph
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,10 +16,53 @@ const (
|
|||||||
LevelFull = "full"
|
LevelFull = "full"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Grant couples an RGW user id with the access level to grant it on a bucket.
|
// managedSidPrefix marks statement ids the operator owns, so it can reconcile
|
||||||
|
// its own statements while preserving foreign ones when adopting a bucket that
|
||||||
|
// already has a policy. Do not reuse this prefix (or the legacy prefixes below)
|
||||||
|
// for statements you manage yourself.
|
||||||
|
const managedSidPrefix = "cephrgwop"
|
||||||
|
|
||||||
|
// legacyManagedSidPrefixes are the statement-id prefixes the operator emitted
|
||||||
|
// before managedSidPrefix existed; they are still recognised as operator-owned
|
||||||
|
// so upgrading does not duplicate statements.
|
||||||
|
var legacyManagedSidPrefixes = []string{
|
||||||
|
"readonlybkt", "readonlyobj", "readwritebkt", "readwriteobj", "full", "custom", "raw",
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantConditions restricts when a grant's statements apply. The zero value adds
|
||||||
|
// no conditions.
|
||||||
|
type GrantConditions struct {
|
||||||
|
// SourceIPs restricts the grant to these CIDRs (S3 aws:SourceIp).
|
||||||
|
SourceIPs []string
|
||||||
|
// SecureTransportOnly requires TLS (S3 aws:SecureTransport).
|
||||||
|
SecureTransportOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawStatement is a caller-supplied S3 policy statement for a grant.
|
||||||
|
type RawStatement struct {
|
||||||
|
Sid string
|
||||||
|
Effect string
|
||||||
|
Actions []string
|
||||||
|
Resources []string
|
||||||
|
Condition map[string]map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant couples an RGW user id with the access it should have on a bucket. The
|
||||||
|
// simple form is a Level; Paths, Actions and Conditions refine it, and Raw
|
||||||
|
// replaces it entirely with caller-supplied statements.
|
||||||
type Grant struct {
|
type Grant struct {
|
||||||
UID string
|
UID string
|
||||||
Level string
|
Level string
|
||||||
|
// Paths scopes object-level access to these key prefixes; empty = whole
|
||||||
|
// bucket.
|
||||||
|
Paths []string
|
||||||
|
// Actions overrides the level's action set; empty = derive from Level.
|
||||||
|
Actions []string
|
||||||
|
// Conditions optionally restricts when the grant applies.
|
||||||
|
Conditions *GrantConditions
|
||||||
|
// Raw, when non-empty, replaces Level/Actions/Paths/Conditions with these
|
||||||
|
// statements (the operator still fills in a Principal when one is omitted).
|
||||||
|
Raw []RawStatement
|
||||||
}
|
}
|
||||||
|
|
||||||
type policyDocument struct {
|
type policyDocument struct {
|
||||||
@@ -26,11 +71,12 @@ type policyDocument struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type policyStatement struct {
|
type policyStatement struct {
|
||||||
Sid string `json:"Sid"`
|
Sid string `json:"Sid,omitempty"`
|
||||||
Effect string `json:"Effect"`
|
Effect string `json:"Effect"`
|
||||||
Principal map[string][]string `json:"Principal"`
|
Principal map[string][]string `json:"Principal,omitempty"`
|
||||||
Action []string `json:"Action"`
|
Action []string `json:"Action"`
|
||||||
Resource []string `json:"Resource"`
|
Resource []string `json:"Resource"`
|
||||||
|
Condition map[string]map[string][]string `json:"Condition,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// bucket-level and object-level S3 actions per access level.
|
// bucket-level and object-level S3 actions per access level.
|
||||||
@@ -68,13 +114,101 @@ var objectActions = map[string][]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildBucketPolicy renders a deterministic S3 bucket policy granting each
|
// BuildBucketPolicy renders a deterministic S3 bucket policy granting each
|
||||||
// principal its requested level. It returns "" when there are no grants so the
|
// principal its requested access. It returns "" when there are no grants so the
|
||||||
// caller can clear the policy.
|
// caller can clear the policy.
|
||||||
func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
|
func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
|
||||||
if len(grants) == 0 {
|
statements := buildStatements(bucket, grants)
|
||||||
|
if len(statements) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(policyDocument{Version: "2012-10-17", Statement: statements})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeBucketPolicy renders the operator's statements for grants and merges them
|
||||||
|
// into an existing policy, preserving any statement the operator does not own
|
||||||
|
// (identified by its Sid). It returns "" only when the merged policy would be
|
||||||
|
// empty, so a bucket adopted with a hand-written policy keeps that policy.
|
||||||
|
func MergeBucketPolicy(existing, bucket string, grants []Grant) (string, error) {
|
||||||
|
version := "2012-10-17"
|
||||||
|
var id string
|
||||||
|
var foreign []json.RawMessage
|
||||||
|
|
||||||
|
if strings.TrimSpace(existing) != "" {
|
||||||
|
var doc struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
ID string `json:"Id,omitempty"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(existing), &doc); err != nil {
|
||||||
|
return "", fmt.Errorf("parse existing bucket policy: %w", err)
|
||||||
|
}
|
||||||
|
if doc.Version != "" {
|
||||||
|
version = doc.Version
|
||||||
|
}
|
||||||
|
id = doc.ID
|
||||||
|
for _, raw := range doc.Statement {
|
||||||
|
var meta struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
}
|
||||||
|
// Ignore unmarshal errors: a statement we cannot read the Sid of is
|
||||||
|
// treated as foreign and preserved verbatim.
|
||||||
|
_ = json.Unmarshal(raw, &meta)
|
||||||
|
if isManagedSid(meta.Sid) {
|
||||||
|
continue // operator-owned; re-rendered below
|
||||||
|
}
|
||||||
|
foreign = append(foreign, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
managed := buildStatements(bucket, grants)
|
||||||
|
if len(foreign) == 0 && len(managed) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statements := make([]json.RawMessage, 0, len(foreign)+len(managed))
|
||||||
|
statements = append(statements, foreign...)
|
||||||
|
for _, st := range managed {
|
||||||
|
b, err := json.Marshal(st)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
statements = append(statements, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
ID string `json:"Id,omitempty"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
}{Version: version, ID: id, Statement: statements})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isManagedSid reports whether a statement id was emitted by the operator.
|
||||||
|
func isManagedSid(s string) bool {
|
||||||
|
if strings.HasPrefix(s, managedSidPrefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range legacyManagedSidPrefixes {
|
||||||
|
if strings.HasPrefix(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildStatements renders the operator's statements for grants, sorted for
|
||||||
|
// deterministic output.
|
||||||
|
func buildStatements(bucket string, grants []Grant) []policyStatement {
|
||||||
|
if len(grants) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
sorted := make([]Grant, len(grants))
|
sorted := make([]Grant, len(grants))
|
||||||
copy(sorted, grants)
|
copy(sorted, grants)
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
@@ -85,50 +219,148 @@ func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bucketARN := "arn:aws:s3:::" + bucket
|
bucketARN := "arn:aws:s3:::" + bucket
|
||||||
objectARN := bucketARN + "/*"
|
var statements []policyStatement
|
||||||
|
|
||||||
doc := policyDocument{Version: "2012-10-17"}
|
|
||||||
for _, g := range sorted {
|
for _, g := range sorted {
|
||||||
principal := map[string][]string{"AWS": {"arn:aws:iam:::user/" + g.UID}}
|
statements = append(statements, statementsForGrant(bucketARN, g)...)
|
||||||
switch g.Level {
|
|
||||||
case LevelFull:
|
|
||||||
doc.Statement = append(doc.Statement, policyStatement{
|
|
||||||
Sid: sid("full", g.UID),
|
|
||||||
Effect: "Allow",
|
|
||||||
Principal: principal,
|
|
||||||
Action: []string{"s3:*"},
|
|
||||||
Resource: []string{bucketARN, objectARN},
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
doc.Statement = append(doc.Statement,
|
|
||||||
policyStatement{
|
|
||||||
Sid: sid(g.Level+"-bkt", g.UID),
|
|
||||||
Effect: "Allow",
|
|
||||||
Principal: principal,
|
|
||||||
Action: bucketActions[g.Level],
|
|
||||||
Resource: []string{bucketARN},
|
|
||||||
},
|
|
||||||
policyStatement{
|
|
||||||
Sid: sid(g.Level+"-obj", g.UID),
|
|
||||||
Effect: "Allow",
|
|
||||||
Principal: principal,
|
|
||||||
Action: objectActions[g.Level],
|
|
||||||
Resource: []string{objectARN},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return statements
|
||||||
b, err := json.Marshal(doc)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sid builds a policy statement id that only contains characters S3 accepts.
|
// statementsForGrant renders the policy statements for a single grant.
|
||||||
|
func statementsForGrant(bucketARN string, g Grant) []policyStatement {
|
||||||
|
principal := map[string][]string{"AWS": {"arn:aws:iam:::user/" + g.UID}}
|
||||||
|
|
||||||
|
if len(g.Raw) > 0 {
|
||||||
|
out := make([]policyStatement, 0, len(g.Raw))
|
||||||
|
for i, rs := range g.Raw {
|
||||||
|
st := policyStatement{
|
||||||
|
// Always operator-owned so a merge re-renders (not duplicates)
|
||||||
|
// it; any user-supplied Sid is folded into the managed id.
|
||||||
|
Sid: sid(firstNonEmpty(rs.Sid, "raw"+strconv.Itoa(i)), g.UID),
|
||||||
|
Effect: firstNonEmpty(rs.Effect, "Allow"),
|
||||||
|
Principal: principal,
|
||||||
|
Action: rs.Actions,
|
||||||
|
Resource: resolveResources(bucketARN, rs.Resources),
|
||||||
|
Condition: rs.Condition,
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := buildCondition(g.Conditions)
|
||||||
|
objectARNs := objectResources(bucketARN, g.Paths)
|
||||||
|
|
||||||
|
if len(g.Actions) > 0 {
|
||||||
|
return []policyStatement{{
|
||||||
|
Sid: sid("custom", g.UID),
|
||||||
|
Effect: "Allow",
|
||||||
|
Principal: principal,
|
||||||
|
Action: g.Actions,
|
||||||
|
Resource: append([]string{bucketARN}, objectARNs...),
|
||||||
|
Condition: cond,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Level == LevelFull {
|
||||||
|
return []policyStatement{{
|
||||||
|
Sid: sid("full", g.UID),
|
||||||
|
Effect: "Allow",
|
||||||
|
Principal: principal,
|
||||||
|
Action: []string{"s3:*"},
|
||||||
|
Resource: append([]string{bucketARN}, objectARNs...),
|
||||||
|
Condition: cond,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []policyStatement{
|
||||||
|
{
|
||||||
|
Sid: sid(g.Level+"-bkt", g.UID),
|
||||||
|
Effect: "Allow",
|
||||||
|
Principal: principal,
|
||||||
|
Action: bucketActions[g.Level],
|
||||||
|
Resource: []string{bucketARN},
|
||||||
|
Condition: cond,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Sid: sid(g.Level+"-obj", g.UID),
|
||||||
|
Effect: "Allow",
|
||||||
|
Principal: principal,
|
||||||
|
Action: objectActions[g.Level],
|
||||||
|
Resource: objectARNs,
|
||||||
|
Condition: cond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectResources renders the object-level resource ARNs for a grant: the whole
|
||||||
|
// bucket ("<bucket>/*") when no paths are given, or one "<bucket>/<prefix>*" per
|
||||||
|
// prefix (deduplicated and sorted for determinism).
|
||||||
|
func objectResources(bucketARN string, paths []string) []string {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return []string{bucketARN + "/*"}
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
p = strings.TrimPrefix(p, "/")
|
||||||
|
arn := bucketARN + "/" + p + "*"
|
||||||
|
if _, dup := seen[arn]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[arn] = struct{}{}
|
||||||
|
out = append(out, arn)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveResources renders raw-statement resources: entries that already look
|
||||||
|
// like ARNs pass through verbatim; bucket-relative prefixes become
|
||||||
|
// "<bucket>/<prefix>*". An empty list defaults to the whole bucket and objects.
|
||||||
|
func resolveResources(bucketARN string, resources []string) []string {
|
||||||
|
if len(resources) == 0 {
|
||||||
|
return []string{bucketARN, bucketARN + "/*"}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(resources))
|
||||||
|
for _, r := range resources {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r, "arn:"):
|
||||||
|
out = append(out, r)
|
||||||
|
case r == "" || r == "/":
|
||||||
|
out = append(out, bucketARN+"/*")
|
||||||
|
default:
|
||||||
|
out = append(out, bucketARN+"/"+strings.TrimPrefix(r, "/")+"*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCondition renders the S3 condition block for a grant, or nil when there
|
||||||
|
// is nothing to add.
|
||||||
|
func buildCondition(c *GrantConditions) map[string]map[string][]string {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cond := map[string]map[string][]string{}
|
||||||
|
if len(c.SourceIPs) > 0 {
|
||||||
|
cond["IpAddress"] = map[string][]string{"aws:SourceIp": c.SourceIPs}
|
||||||
|
}
|
||||||
|
if c.SecureTransportOnly {
|
||||||
|
cond["Bool"] = map[string][]string{"aws:SecureTransport": {"true"}}
|
||||||
|
}
|
||||||
|
if len(cond) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cond
|
||||||
|
}
|
||||||
|
|
||||||
|
// sid builds a policy statement id that only contains characters S3 accepts,
|
||||||
|
// prefixed with managedSidPrefix so the operator can recognise its own
|
||||||
|
// statements when merging into an adopted bucket's policy.
|
||||||
func sid(prefix, uid string) string {
|
func sid(prefix, uid string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
b.WriteString(managedSidPrefix)
|
||||||
b.WriteString(strings.ReplaceAll(prefix, "-", ""))
|
b.WriteString(strings.ReplaceAll(prefix, "-", ""))
|
||||||
for _, r := range uid {
|
for _, r := range uid {
|
||||||
switch {
|
switch {
|
||||||
@@ -139,7 +371,8 @@ func sid(prefix, uid string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildTagJSON renders bucket tags in the JSON form the dashboard expects.
|
// BuildTagJSON renders bucket tags as a {Key,Value} JSON list, the intermediate
|
||||||
|
// form SetBucketTags parses and re-encodes into the S3 Tagging XML document.
|
||||||
func BuildTagJSON(tags map[string]string) (string, error) {
|
func BuildTagJSON(tags map[string]string) (string, error) {
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
|
|||||||
@@ -88,3 +88,273 @@ func TestBuildBucketPolicyStructure(t *testing.T) {
|
|||||||
t.Fatal("reader principal ARN missing")
|
t.Fatal("reader principal ARN missing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parsedPolicy is a fuller parse of a rendered policy for the fine-grained tests.
|
||||||
|
type parsedPolicy struct {
|
||||||
|
Statement []struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
Effect string `json:"Effect"`
|
||||||
|
Principal map[string][]string `json:"Principal"`
|
||||||
|
Action []string `json:"Action"`
|
||||||
|
Resource []string `json:"Resource"`
|
||||||
|
Condition map[string]map[string][]string `json:"Condition"`
|
||||||
|
} `json:"Statement"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePolicy(t *testing.T, raw string) parsedPolicy {
|
||||||
|
t.Helper()
|
||||||
|
var doc parsedPolicy
|
||||||
|
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||||
|
t.Fatalf("policy is not valid JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBucketPolicyPaths(t *testing.T) {
|
||||||
|
raw, err := BuildBucketPolicy("data", []Grant{
|
||||||
|
{UID: "reader", Level: LevelReadOnly, Paths: []string{"team-a/", "/shared/inbox/"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
doc := parsePolicy(t, raw)
|
||||||
|
|
||||||
|
var objResources []string
|
||||||
|
for _, s := range doc.Statement {
|
||||||
|
for _, a := range s.Action {
|
||||||
|
if a == "s3:GetObject" {
|
||||||
|
objResources = s.Resource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := map[string]bool{
|
||||||
|
"arn:aws:s3:::data/shared/inbox/*": false,
|
||||||
|
"arn:aws:s3:::data/team-a/*": false,
|
||||||
|
}
|
||||||
|
if len(objResources) != len(want) {
|
||||||
|
t.Fatalf("expected %d object resources, got %v", len(want), objResources)
|
||||||
|
}
|
||||||
|
for _, r := range objResources {
|
||||||
|
if _, ok := want[r]; !ok {
|
||||||
|
t.Fatalf("unexpected object resource %q (leading slash not trimmed?)", r)
|
||||||
|
}
|
||||||
|
want[r] = true
|
||||||
|
}
|
||||||
|
for r, seen := range want {
|
||||||
|
if !seen {
|
||||||
|
t.Fatalf("missing object resource %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBucketPolicyActionsOverride(t *testing.T) {
|
||||||
|
raw, err := BuildBucketPolicy("data", []Grant{
|
||||||
|
{UID: "svc", Level: LevelReadOnly, Actions: []string{"s3:GetObject", "s3:PutObject"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
doc := parsePolicy(t, raw)
|
||||||
|
if len(doc.Statement) != 1 {
|
||||||
|
t.Fatalf("expected a single custom-action statement, got %d", len(doc.Statement))
|
||||||
|
}
|
||||||
|
s := doc.Statement[0]
|
||||||
|
if len(s.Action) != 2 || s.Action[0] != "s3:GetObject" || s.Action[1] != "s3:PutObject" {
|
||||||
|
t.Fatalf("actions not taken verbatim: %v", s.Action)
|
||||||
|
}
|
||||||
|
// Custom-action statement lists both the bucket and object resources.
|
||||||
|
if len(s.Resource) != 2 || s.Resource[0] != "arn:aws:s3:::data" || s.Resource[1] != "arn:aws:s3:::data/*" {
|
||||||
|
t.Fatalf("unexpected resources: %v", s.Resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBucketPolicyConditions(t *testing.T) {
|
||||||
|
raw, err := BuildBucketPolicy("data", []Grant{
|
||||||
|
{UID: "reader", Level: LevelReadOnly, Conditions: &GrantConditions{
|
||||||
|
SourceIPs: []string{"10.0.0.0/8"},
|
||||||
|
SecureTransportOnly: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
doc := parsePolicy(t, raw)
|
||||||
|
for _, s := range doc.Statement {
|
||||||
|
if s.Condition == nil {
|
||||||
|
t.Fatalf("statement %q missing condition block", s.Sid)
|
||||||
|
}
|
||||||
|
if ip := s.Condition["IpAddress"]["aws:SourceIp"]; len(ip) != 1 || ip[0] != "10.0.0.0/8" {
|
||||||
|
t.Fatalf("unexpected SourceIp condition: %v", s.Condition["IpAddress"])
|
||||||
|
}
|
||||||
|
if tls := s.Condition["Bool"]["aws:SecureTransport"]; len(tls) != 1 || tls[0] != "true" {
|
||||||
|
t.Fatalf("unexpected SecureTransport condition: %v", s.Condition["Bool"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBucketPolicyRawStatements(t *testing.T) {
|
||||||
|
raw, err := BuildBucketPolicy("data", []Grant{
|
||||||
|
{UID: "svc", Level: LevelReadOnly, Raw: []RawStatement{
|
||||||
|
{
|
||||||
|
Effect: "Deny",
|
||||||
|
Actions: []string{"s3:DeleteObject"},
|
||||||
|
Resources: []string{"locked/", "arn:aws:s3:::other/*"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
doc := parsePolicy(t, raw)
|
||||||
|
if len(doc.Statement) != 1 {
|
||||||
|
t.Fatalf("expected 1 raw statement, got %d", len(doc.Statement))
|
||||||
|
}
|
||||||
|
s := doc.Statement[0]
|
||||||
|
if s.Effect != "Deny" {
|
||||||
|
t.Fatalf("raw effect not honoured: %q", s.Effect)
|
||||||
|
}
|
||||||
|
// The operator fills in the principal; bucket-relative prefixes are expanded
|
||||||
|
// while explicit ARNs pass through.
|
||||||
|
if len(s.Principal["AWS"]) != 1 || !strings.HasSuffix(s.Principal["AWS"][0], "user/svc") {
|
||||||
|
t.Fatalf("raw statement principal not injected: %v", s.Principal)
|
||||||
|
}
|
||||||
|
if len(s.Resource) != 2 || s.Resource[0] != "arn:aws:s3:::data/locked/*" || s.Resource[1] != "arn:aws:s3:::other/*" {
|
||||||
|
t.Fatalf("unexpected raw resources: %v", s.Resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyPreservesForeign(t *testing.T) {
|
||||||
|
// An existing policy with a foreign statement (unknown Sid, and a scalar
|
||||||
|
// condition value S3 allows but our typed struct does not model).
|
||||||
|
existing := `{"Version":"2012-10-17","Statement":[` +
|
||||||
|
`{"Sid":"AllowPublicRead","Effect":"Allow","Principal":"*","Action":["s3:GetObject"],` +
|
||||||
|
`"Resource":"arn:aws:s3:::data/public/*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}`
|
||||||
|
|
||||||
|
merged, err := MergeBucketPolicy(existing, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
doc := parsePolicyRaw(t, merged)
|
||||||
|
|
||||||
|
var sawForeign, sawManaged bool
|
||||||
|
for _, raw := range doc.Statement {
|
||||||
|
var s struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
Condition map[string]map[string]any
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
t.Fatalf("statement not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if s.Sid == "AllowPublicRead" {
|
||||||
|
sawForeign = true
|
||||||
|
// The scalar condition value must survive verbatim.
|
||||||
|
if v := s.Condition["Bool"]["aws:SecureTransport"]; v != "true" {
|
||||||
|
t.Fatalf("foreign scalar condition mangled: %v", s.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isManagedSid(s.Sid) {
|
||||||
|
sawManaged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawForeign {
|
||||||
|
t.Fatal("foreign statement was dropped")
|
||||||
|
}
|
||||||
|
if !sawManaged {
|
||||||
|
t.Fatal("operator statement missing from merge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyReplacesManaged(t *testing.T) {
|
||||||
|
// Two rounds: an existing policy already carrying the operator's statements
|
||||||
|
// must not accumulate duplicates when re-merged.
|
||||||
|
first, err := MergeBucketPolicy("", "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
second, err := MergeBucketPolicy(first, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("re-merging its own policy was not idempotent:\n first=%s\nsecond=%s", first, second)
|
||||||
|
}
|
||||||
|
// A legacy (unprefixed) operator statement must also be recognised and
|
||||||
|
// replaced rather than preserved as foreign.
|
||||||
|
legacy := `{"Version":"2012-10-17","Statement":[` +
|
||||||
|
`{"Sid":"readonlybktreader","Effect":"Allow","Principal":{"AWS":["arn:aws:iam:::user/reader"]},` +
|
||||||
|
`"Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::data"]}]}`
|
||||||
|
merged, err := MergeBucketPolicy(legacy, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
for _, raw := range parsePolicyRaw(t, merged).Statement {
|
||||||
|
var s struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &s)
|
||||||
|
if s.Sid == "readonlybktreader" {
|
||||||
|
t.Fatal("legacy operator statement was preserved instead of replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyForeignOnlyKept(t *testing.T) {
|
||||||
|
existing := `{"Version":"2012-10-17","Statement":[` +
|
||||||
|
`{"Sid":"AllowPublicRead","Effect":"Allow","Principal":"*","Action":["s3:GetObject"],` +
|
||||||
|
`"Resource":"arn:aws:s3:::data/*"}]}`
|
||||||
|
// No grants: the operator adds nothing but must not wipe the foreign policy.
|
||||||
|
merged, err := MergeBucketPolicy(existing, "data", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged == "" {
|
||||||
|
t.Fatal("merge cleared a policy that had a foreign statement")
|
||||||
|
}
|
||||||
|
if len(parsePolicyRaw(t, merged).Statement) != 1 {
|
||||||
|
t.Fatalf("expected the single foreign statement, got %s", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyEmpty(t *testing.T) {
|
||||||
|
merged, err := MergeBucketPolicy("", "data", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged != "" {
|
||||||
|
t.Fatalf("expected empty policy, got %q", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePolicyRaw parses a policy keeping statements as raw JSON.
|
||||||
|
func parsePolicyRaw(t *testing.T, raw string) struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
} {
|
||||||
|
t.Helper()
|
||||||
|
var doc struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||||
|
t.Fatalf("policy is not valid JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBucketPolicyFineGrainedDeterministic(t *testing.T) {
|
||||||
|
grants := []Grant{
|
||||||
|
{UID: "reader", Level: LevelReadOnly, Paths: []string{"a/", "b/"}},
|
||||||
|
{UID: "svc", Level: LevelReadWrite, Conditions: &GrantConditions{SecureTransportOnly: true}},
|
||||||
|
}
|
||||||
|
a, err := BuildBucketPolicy("data", grants)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
b, err := BuildBucketPolicy("data", []Grant{grants[1], grants[0]})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("fine-grained policy is order-dependent:\n a=%s\n b=%s", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+98
-74
@@ -2,25 +2,25 @@ package ceph
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"github.com/ceph/go-ceph/rgw/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserKey is an S3 access/secret key pair belonging to an RGW user.
|
// UserKey is an S3 access/secret key pair belonging to an RGW user.
|
||||||
type UserKey struct {
|
type UserKey struct {
|
||||||
User string `json:"user"`
|
User string
|
||||||
AccessKey string `json:"access_key"`
|
AccessKey string
|
||||||
SecretKey string `json:"secret_key"`
|
SecretKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is the subset of an RGW user record the operator consumes.
|
// User is the subset of an RGW user record the operator consumes.
|
||||||
type User struct {
|
type User struct {
|
||||||
UID string `json:"user_id"`
|
UID string
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string
|
||||||
Email string `json:"email"`
|
Email string
|
||||||
MaxBuckets int `json:"max_buckets"`
|
MaxBuckets int
|
||||||
Suspended int `json:"suspended"`
|
Suspended int
|
||||||
Keys []UserKey `json:"keys"`
|
Keys []UserKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3Key returns the first access/secret key pair, if any.
|
// S3Key returns the first access/secret key pair, if any.
|
||||||
@@ -31,101 +31,103 @@ func (u *User) S3Key() (UserKey, bool) {
|
|||||||
return u.Keys[0], true
|
return u.Keys[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSpec describes the desired state of an RGW user.
|
// UserSpec describes the desired state of an RGW user. A nil DisplayName/
|
||||||
|
// Suspended (empty string / nil pointer) leaves that attribute untouched on an
|
||||||
|
// existing user, so an adopted user is not mutated unless the fields are set.
|
||||||
type UserSpec struct {
|
type UserSpec struct {
|
||||||
UID string
|
UID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Email string
|
Email string
|
||||||
MaxBuckets *int32
|
MaxBuckets *int32
|
||||||
Suspended bool
|
Suspended *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type createUserRequest struct {
|
// fromAdminUser converts a go-ceph admin.User into the subset the operator uses.
|
||||||
UID string `json:"uid"`
|
func fromAdminUser(u admin.User) *User {
|
||||||
DisplayName string `json:"display_name"`
|
out := &User{
|
||||||
Email string `json:"email,omitempty"`
|
UID: u.ID,
|
||||||
MaxBuckets *int32 `json:"max_buckets,omitempty"`
|
DisplayName: u.DisplayName,
|
||||||
Suspended bool `json:"suspended"`
|
Email: u.Email,
|
||||||
GenerateKey bool `json:"generate_key"`
|
MaxBuckets: derefInt(u.MaxBuckets),
|
||||||
|
Suspended: derefInt(u.Suspended),
|
||||||
|
}
|
||||||
|
for _, k := range u.Keys {
|
||||||
|
out.Keys = append(out.Keys, UserKey{User: k.User, AccessKey: k.AccessKey, SecretKey: k.SecretKey})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateUserRequest struct {
|
// GetUser fetches an RGW user by uid, returning an error classified by
|
||||||
DisplayName string `json:"display_name"`
|
// IsNotFound (admin.ErrNoSuchUser) when it does not exist.
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
MaxBuckets *int32 `json:"max_buckets,omitempty"`
|
|
||||||
Suspended bool `json:"suspended"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUser fetches an RGW user by uid, returning an *APIError with status 404
|
|
||||||
// (see IsNotFound) when it does not exist.
|
|
||||||
func (c *Client) GetUser(ctx context.Context, uid string) (*User, error) {
|
func (c *Client) GetUser(ctx context.Context, uid string) (*User, error) {
|
||||||
var u User
|
u, err := c.admin.GetUser(ctx, admin.User{ID: uid})
|
||||||
if err := c.do(ctx, http.MethodGet, "/api/rgw/user/"+url.PathEscape(uid), nil, &u, ""); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &u, nil
|
return fromAdminUser(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates an RGW user, asking the dashboard to generate an S3 key
|
// CreateUser creates an RGW user, asking radosgw to generate an S3 key pair. The
|
||||||
// pair. The returned User carries the generated keys.
|
// returned User carries the generated keys.
|
||||||
func (c *Client) CreateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
func (c *Client) CreateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
||||||
req := createUserRequest{
|
u, err := c.admin.CreateUser(ctx, admin.User{
|
||||||
UID: spec.UID,
|
ID: spec.UID,
|
||||||
DisplayName: firstNonEmpty(spec.DisplayName, spec.UID),
|
DisplayName: firstNonEmpty(spec.DisplayName, spec.UID),
|
||||||
Email: spec.Email,
|
Email: spec.Email,
|
||||||
MaxBuckets: spec.MaxBuckets,
|
MaxBuckets: int32PtrToIntPtr(spec.MaxBuckets),
|
||||||
Suspended: spec.Suspended,
|
Suspended: boolPtrToIntPtr(spec.Suspended),
|
||||||
GenerateKey: true,
|
GenerateKey: boolPtr(true),
|
||||||
}
|
})
|
||||||
var u User
|
if err != nil {
|
||||||
if err := c.do(ctx, http.MethodPost, "/api/rgw/user", req, &u, ""); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &u, nil
|
c.forgetIdentity(spec.UID)
|
||||||
|
return fromAdminUser(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser reconciles the mutable attributes of an existing RGW user.
|
// UpdateUser reconciles the mutable attributes of an existing RGW user. It only
|
||||||
|
// sends attributes the spec sets: an empty DisplayName or nil Suspended is left
|
||||||
|
// as-is, so reconciling (or adopting) a user does not clobber those fields.
|
||||||
func (c *Client) UpdateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
func (c *Client) UpdateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
||||||
req := updateUserRequest{
|
req := admin.User{
|
||||||
DisplayName: firstNonEmpty(spec.DisplayName, spec.UID),
|
ID: spec.UID,
|
||||||
Email: spec.Email,
|
Email: spec.Email,
|
||||||
MaxBuckets: spec.MaxBuckets,
|
MaxBuckets: int32PtrToIntPtr(spec.MaxBuckets),
|
||||||
Suspended: spec.Suspended,
|
Suspended: boolPtrToIntPtr(spec.Suspended),
|
||||||
}
|
}
|
||||||
var u User
|
if spec.DisplayName != "" {
|
||||||
if err := c.do(ctx, http.MethodPut, "/api/rgw/user/"+url.PathEscape(spec.UID), req, &u, ""); err != nil {
|
req.DisplayName = spec.DisplayName
|
||||||
|
}
|
||||||
|
u, err := c.admin.ModifyUser(ctx, req)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &u, nil
|
return fromAdminUser(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser removes an RGW user. A 404 is treated as success.
|
// DeleteUser removes an RGW user. A NoSuchUser response is treated as success.
|
||||||
func (c *Client) DeleteUser(ctx context.Context, uid string) error {
|
func (c *Client) DeleteUser(ctx context.Context, uid string) error {
|
||||||
err := c.do(ctx, http.MethodDelete, "/api/rgw/user/"+url.PathEscape(uid), nil, nil, "")
|
err := c.admin.RemoveUser(ctx, admin.User{ID: uid})
|
||||||
|
c.forgetIdentity(uid)
|
||||||
if IsNotFound(err) {
|
if IsNotFound(err) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type quotaRequest struct {
|
|
||||||
QuotaType string `json:"quota_type"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
MaxSizeKb int64 `json:"max_size_kb"`
|
|
||||||
MaxObjects int64 `json:"max_objects"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetUserQuota applies a quota to a user. quotaType is "user" or "bucket" (the
|
// SetUserQuota applies a quota to a user. quotaType is "user" or "bucket" (the
|
||||||
// latter sets the per-bucket default for buckets the user owns). A nil or
|
// latter sets the per-bucket default for buckets the user owns). A nil or
|
||||||
// negative limit means unlimited for that dimension.
|
// negative limit means unlimited for that dimension.
|
||||||
func (c *Client) SetUserQuota(ctx context.Context, uid, quotaType string, enabled bool, maxSizeBytes, maxObjects *int64) error {
|
func (c *Client) SetUserQuota(ctx context.Context, uid, quotaType string, enabled bool, maxSizeBytes, maxObjects *int64) error {
|
||||||
req := quotaRequest{
|
maxSize := valueOr(maxSizeBytes, -1)
|
||||||
|
maxObj := valueOr(maxObjects, -1)
|
||||||
|
return c.admin.SetUserQuota(ctx, admin.QuotaSpec{
|
||||||
|
UID: uid,
|
||||||
QuotaType: quotaType,
|
QuotaType: quotaType,
|
||||||
Enabled: enabled,
|
Enabled: &enabled,
|
||||||
MaxSizeKb: bytesToKb(maxSizeBytes),
|
MaxSize: &maxSize,
|
||||||
MaxObjects: valueOr(maxObjects, -1),
|
MaxObjects: &maxObj,
|
||||||
}
|
})
|
||||||
return c.do(ctx, http.MethodPut, "/api/rgw/user/"+url.PathEscape(uid)+"/quota", req, nil, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmpty(vals ...string) string {
|
func firstNonEmpty(vals ...string) string {
|
||||||
@@ -137,16 +139,38 @@ func firstNonEmpty(vals ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func bytesToKb(b *int64) int64 {
|
|
||||||
if b == nil || *b < 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return *b / 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueOr(v *int64, fallback int64) int64 {
|
func valueOr(v *int64, fallback int64) int64 {
|
||||||
if v == nil || *v < 0 {
|
if v == nil || *v < 0 {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefInt(p *int) int {
|
||||||
|
if p == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
func int32PtrToIntPtr(p *int32) *int {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := int(*p)
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolPtrToIntPtr renders an optional bool as RGW's 0/1 int, or nil to omit.
|
||||||
|
func boolPtrToIntPtr(b *bool) *int {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := 0
|
||||||
|
if *b {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
@@ -76,7 +77,11 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
}
|
}
|
||||||
ownerUID := owner.Status.UID
|
ownerUID := owner.Status.UID
|
||||||
|
|
||||||
// Ensure the bucket exists.
|
// Ensure the bucket exists. Record adoption once: whether the RGW bucket
|
||||||
|
// already existed the first time we reconciled this resource. status.BucketID
|
||||||
|
// is only set on a successful reconcile, so a Pending wait on the owner (or a
|
||||||
|
// transient failure) does not pollute the signal.
|
||||||
|
firstObserve := b.Status.BucketID == ""
|
||||||
info, err := r.Ceph.GetBucket(ctx, bucketName)
|
info, err := r.Ceph.GetBucket(ctx, bucketName)
|
||||||
if ceph.IsNotFound(err) {
|
if ceph.IsNotFound(err) {
|
||||||
createSpec := ceph.CreateBucketSpec{
|
createSpec := ceph.CreateBucketSpec{
|
||||||
@@ -96,11 +101,29 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
return r.fail(ctx, &b, "CreateFailed", err)
|
return r.fail(ctx, &b, "CreateFailed", err)
|
||||||
}
|
}
|
||||||
logger.Info("created bucket", "bucket", bucketName, "owner", ownerUID)
|
logger.Info("created bucket", "bucket", bucketName, "owner", ownerUID)
|
||||||
|
if firstObserve {
|
||||||
|
b.Status.Adopted = false
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return r.fail(ctx, &b, "LookupFailed", err)
|
return r.fail(ctx, &b, "LookupFailed", err)
|
||||||
|
} else if firstObserve {
|
||||||
|
b.Status.Adopted = true
|
||||||
|
logger.Info("adopted existing bucket", "bucket", bucketName, "owner", ownerUID)
|
||||||
}
|
}
|
||||||
bucketID := info.InstanceID()
|
bucketID := info.InstanceID()
|
||||||
|
|
||||||
|
// Placement is fixed at creation: RGW cannot move an existing bucket between
|
||||||
|
// placement targets. If the live bucket sits on a different target than the
|
||||||
|
// spec asks for (a changed spec, or an adopted bucket that predates the
|
||||||
|
// request), surface a clear error instead of ever deleting/recreating it. An
|
||||||
|
// empty PlacementTarget imposes no constraint.
|
||||||
|
if pc := placementConflict(b.Spec.PlacementTarget, info.PlacementRule); pc {
|
||||||
|
b.Status.PlacementTarget = info.PlacementRule
|
||||||
|
return r.fail(ctx, &b, "PlacementImmutable", fmt.Errorf(
|
||||||
|
"bucket %q is on placement target %q but spec requests %q; RGW cannot move a bucket between placement targets",
|
||||||
|
bucketName, info.PlacementRule, b.Spec.PlacementTarget))
|
||||||
|
}
|
||||||
|
|
||||||
// Versioning (forced on when object lock is enabled).
|
// Versioning (forced on when object lock is enabled).
|
||||||
if b.Spec.Versioning || (b.Spec.ObjectLock != nil && b.Spec.ObjectLock.Enabled) {
|
if b.Spec.Versioning || (b.Spec.ObjectLock != nil && b.Spec.ObjectLock.Enabled) {
|
||||||
if err := r.Ceph.SetBucketVersioning(ctx, bucketName, bucketID, ownerUID, true); err != nil {
|
if err := r.Ceph.SetBucketVersioning(ctx, bucketName, bucketID, ownerUID, true); err != nil {
|
||||||
@@ -128,23 +151,35 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render and apply the aggregate S3 policy from all BucketAccess grants.
|
// Render and apply the aggregate S3 policy from all BucketAccess grants,
|
||||||
grants, principals, err := r.collectGrants(ctx, b.Namespace, b.Name)
|
// unless the bucket opts out of policy management. The merge preserves any
|
||||||
if err != nil {
|
// statements the operator does not own, so an adopted bucket keeps its
|
||||||
return r.fail(ctx, &b, "GrantsFailed", err)
|
// existing policy.
|
||||||
}
|
principals := 0
|
||||||
policy, err := ceph.BuildBucketPolicy(bucketName, grants)
|
if managePolicy(&b) {
|
||||||
if err != nil {
|
grants, p, err := r.collectGrants(ctx, b.Namespace, b.Name)
|
||||||
return r.fail(ctx, &b, "PolicyBuildFailed", err)
|
if err != nil {
|
||||||
}
|
return r.fail(ctx, &b, "GrantsFailed", err)
|
||||||
if err := r.Ceph.SetBucketPolicy(ctx, bucketName, bucketID, ownerUID, policy); err != nil {
|
}
|
||||||
return r.fail(ctx, &b, "PolicyFailed", err)
|
existing, err := r.Ceph.GetBucketPolicy(ctx, bucketName, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return r.fail(ctx, &b, "PolicyReadFailed", err)
|
||||||
|
}
|
||||||
|
policy, err := ceph.MergeBucketPolicy(existing, bucketName, grants)
|
||||||
|
if err != nil {
|
||||||
|
return r.fail(ctx, &b, "PolicyBuildFailed", err)
|
||||||
|
}
|
||||||
|
if err := r.Ceph.SetBucketPolicy(ctx, bucketName, bucketID, ownerUID, policy); err != nil {
|
||||||
|
return r.fail(ctx, &b, "PolicyFailed", err)
|
||||||
|
}
|
||||||
|
principals = p
|
||||||
}
|
}
|
||||||
|
|
||||||
b.Status.Phase = "Ready"
|
b.Status.Phase = "Ready"
|
||||||
b.Status.BucketName = bucketName
|
b.Status.BucketName = bucketName
|
||||||
b.Status.BucketID = bucketID
|
b.Status.BucketID = bucketID
|
||||||
b.Status.Owner = ownerUID
|
b.Status.Owner = ownerUID
|
||||||
|
b.Status.PlacementTarget = info.PlacementRule
|
||||||
b.Status.PolicyPrincipals = int32(principals)
|
b.Status.PolicyPrincipals = int32(principals)
|
||||||
b.Status.ObservedGeneration = b.Generation
|
b.Status.ObservedGeneration = b.Generation
|
||||||
setReady(&b.Status.Conditions, b.Generation, true, "Provisioned", "bucket provisioned")
|
setReady(&b.Status.Conditions, b.Generation, true, "Provisioned", "bucket provisioned")
|
||||||
@@ -176,17 +211,69 @@ func (r *BucketReconciler) collectGrants(ctx context.Context, namespace, bucketR
|
|||||||
if ba.Status.UID == "" {
|
if ba.Status.UID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := ba.Status.UID + "|" + string(ba.Spec.Level)
|
g := grantFromAccess(ba.Status.UID, ba)
|
||||||
|
key := grantKey(g)
|
||||||
if _, dup := seen[key]; dup {
|
if _, dup := seen[key]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
principals[ba.Status.UID] = struct{}{}
|
principals[ba.Status.UID] = struct{}{}
|
||||||
grants = append(grants, ceph.Grant{UID: ba.Status.UID, Level: string(ba.Spec.Level)})
|
grants = append(grants, g)
|
||||||
}
|
}
|
||||||
return grants, len(principals), nil
|
return grants, len(principals), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantFromAccess translates a BucketAccess spec into the ceph grant model,
|
||||||
|
// carrying the fine-grained scoping (paths, actions, conditions, raw statements).
|
||||||
|
func grantFromAccess(uid string, ba *v1alpha1.BucketAccess) ceph.Grant {
|
||||||
|
g := ceph.Grant{
|
||||||
|
UID: uid,
|
||||||
|
Level: string(ba.Spec.Level),
|
||||||
|
Paths: ba.Spec.Paths,
|
||||||
|
Actions: ba.Spec.Actions,
|
||||||
|
}
|
||||||
|
if c := ba.Spec.Conditions; c != nil {
|
||||||
|
g.Conditions = &ceph.GrantConditions{
|
||||||
|
SourceIPs: c.SourceIPs,
|
||||||
|
SecureTransportOnly: c.SecureTransportOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range ba.Spec.RawStatements {
|
||||||
|
g.Raw = append(g.Raw, ceph.RawStatement{
|
||||||
|
Sid: s.Sid,
|
||||||
|
Effect: s.Effect,
|
||||||
|
Actions: s.Actions,
|
||||||
|
Resources: s.Resources,
|
||||||
|
Condition: s.Conditions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantKey is a stable fingerprint of a grant used to collapse duplicate
|
||||||
|
// BucketAccess objects that would render identical policy statements.
|
||||||
|
func grantKey(g ceph.Grant) string {
|
||||||
|
b, _ := json.Marshal(g)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// managePolicy reports whether the operator should reconcile this bucket's S3
|
||||||
|
// policy. A nil ManagePolicy (the CRD default) is treated as true.
|
||||||
|
func managePolicy(b *v1alpha1.Bucket) bool {
|
||||||
|
return b.Spec.ManagePolicy == nil || *b.Spec.ManagePolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// placementConflict reports whether a bucket's live placement target violates
|
||||||
|
// the spec. An empty spec placement imposes no constraint (the bucket may sit on
|
||||||
|
// whatever default it was created with). Otherwise the live placement must match
|
||||||
|
// exactly, since RGW cannot move a bucket between placement targets.
|
||||||
|
func placementConflict(specPlacement, livePlacement string) bool {
|
||||||
|
if specPlacement == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return specPlacement != livePlacement
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BucketReconciler) pending(ctx context.Context, b *v1alpha1.Bucket, reason, msg string) (ctrl.Result, error) {
|
func (r *BucketReconciler) pending(ctx context.Context, b *v1alpha1.Bucket, reason, msg string) (ctrl.Result, error) {
|
||||||
b.Status.Phase = "Pending"
|
b.Status.Phase = "Pending"
|
||||||
b.Status.ObservedGeneration = b.Generation
|
b.Status.ObservedGeneration = b.Generation
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPlacementConflict(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
specPlacement string
|
||||||
|
livePlacement string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unset spec never conflicts", "", "default-placement", false},
|
||||||
|
{"unset spec unset live", "", "", false},
|
||||||
|
{"matching ec", "ec", "ec", false},
|
||||||
|
{"matching default", "default-placement", "default-placement", false},
|
||||||
|
{"ec requested but default live", "ec", "default-placement", true},
|
||||||
|
{"default requested but ec live", "default-placement", "ec", true},
|
||||||
|
{"spec set live empty", "ec", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := placementConflict(tc.specPlacement, tc.livePlacement); got != tc.want {
|
||||||
|
t.Errorf("placementConflict(%q,%q)=%v want %v", tc.specPlacement, tc.livePlacement, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,8 +49,9 @@ func (r *BucketAccessReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
|||||||
|
|
||||||
if !ba.DeletionTimestamp.IsZero() {
|
if !ba.DeletionTimestamp.IsZero() {
|
||||||
if controllerutil.ContainsFinalizer(&ba, finalizer) {
|
if controllerutil.ContainsFinalizer(&ba, finalizer) {
|
||||||
// Only delete a user the operator created for this grant.
|
// Only delete a user the operator created for this grant, and only
|
||||||
if managed && uid != "" {
|
// when the grant does not ask to retain it.
|
||||||
|
if managed && uid != "" && !ba.Spec.RetainOnDelete {
|
||||||
if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
||||||
return r.fail(ctx, &ba, "DeleteFailed", err)
|
return r.fail(ctx, &ba, "DeleteFailed", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||||
|
apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The CRD version check reads CustomResourceDefinitions to compare the
|
||||||
|
// installed schema against what this operator expects.
|
||||||
|
//
|
||||||
|
// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list
|
||||||
|
|
||||||
|
// crdSentinel names a CRD and a spec property that only exists in the schema
|
||||||
|
// version shipped alongside this operator build. If the installed CRD lacks the
|
||||||
|
// sentinel, its schema predates this operator and strict decoding of new spec
|
||||||
|
// fields will silently fail. Keep this list in one place so new fields are easy
|
||||||
|
// to register as sentinels.
|
||||||
|
type crdSentinel struct {
|
||||||
|
// crd is the metadata.name of the CustomResourceDefinition.
|
||||||
|
crd string
|
||||||
|
// specProperty is a key expected under
|
||||||
|
// .spec.versions[].schema.openAPIV3Schema.properties.spec.properties.
|
||||||
|
specProperty string
|
||||||
|
}
|
||||||
|
|
||||||
|
// crdSentinels is the authoritative list checked at startup. Extend it whenever
|
||||||
|
// a new spec field is added that older CRDs would reject.
|
||||||
|
var crdSentinels = []crdSentinel{
|
||||||
|
{crd: "buckets.ceph.unkin.net", specProperty: "managePolicy"},
|
||||||
|
{crd: "objectstoreusers.ceph.unkin.net", specProperty: "retainOnDelete"},
|
||||||
|
{crd: "bucketaccesses.ceph.unkin.net", specProperty: "rawStatements"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCRDVersions verifies that every CRD this operator owns is installed and
|
||||||
|
// carries the schema fields this build expects. It is advisory only: it logs a
|
||||||
|
// distinct WARNING per problem and never returns an error or exits, so a stale
|
||||||
|
// or missing CRD cannot block startup.
|
||||||
|
func CheckCRDVersions(ctx context.Context, cfg *rest.Config) {
|
||||||
|
log := ctrl.Log.WithName("crd-version-check")
|
||||||
|
|
||||||
|
client, err := apiextensionsclient.NewForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err, "unable to build apiextensions client; skipping CRD version check")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range crdSentinels {
|
||||||
|
crd, err := client.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, s.crd, metav1.GetOptions{})
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
log.Info("WARNING: CRD is not installed — apply the CRDs matching this operator version",
|
||||||
|
"crd", s.crd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err, "unable to read CRD; cannot verify it matches this operator version",
|
||||||
|
"crd", s.crd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !crdHasSpecProperty(crd, s.specProperty) {
|
||||||
|
log.Info("WARNING: CRD is out of date — apply the CRDs matching this operator version",
|
||||||
|
"crd", s.crd, "missingField", "spec."+s.specProperty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crdHasSpecProperty reports whether any served/stored version of the CRD
|
||||||
|
// declares the given property under spec.
|
||||||
|
func crdHasSpecProperty(crd *apiextensionsv1.CustomResourceDefinition, property string) bool {
|
||||||
|
for _, v := range crd.Spec.Versions {
|
||||||
|
schema := v.Schema
|
||||||
|
if schema == nil || schema.OpenAPIV3Schema == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
specSchema, ok := schema.OpenAPIV3Schema.Properties["spec"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := specSchema.Properties[property]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -40,7 +40,9 @@ func (r *ObjectStoreUserReconciler) Reconcile(ctx context.Context, req ctrl.Requ
|
|||||||
|
|
||||||
if !osu.DeletionTimestamp.IsZero() {
|
if !osu.DeletionTimestamp.IsZero() {
|
||||||
if controllerutil.ContainsFinalizer(&osu, finalizer) {
|
if controllerutil.ContainsFinalizer(&osu, finalizer) {
|
||||||
if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
if osu.Spec.RetainOnDelete {
|
||||||
|
logger.Info("retaining RGW user on delete", "uid", uid)
|
||||||
|
} else if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
||||||
return r.fail(ctx, &osu, "DeleteFailed", err)
|
return r.fail(ctx, &osu, "DeleteFailed", err)
|
||||||
}
|
}
|
||||||
controllerutil.RemoveFinalizer(&osu, finalizer)
|
controllerutil.RemoveFinalizer(&osu, finalizer)
|
||||||
@@ -65,17 +67,31 @@ func (r *ObjectStoreUserReconciler) Reconcile(ctx context.Context, req ctrl.Requ
|
|||||||
Suspended: osu.Spec.Suspended,
|
Suspended: osu.Spec.Suspended,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := r.Ceph.GetUser(ctx, uid); ceph.IsNotFound(err) {
|
// Record adoption once: whether the RGW user already existed the first time
|
||||||
|
// we reconciled this resource (taken over rather than created). status.UID is
|
||||||
|
// only set on a successful reconcile, so it is a clean "never provisioned"
|
||||||
|
// signal that transient failures do not pollute.
|
||||||
|
firstObserve := osu.Status.UID == ""
|
||||||
|
_, getErr := r.Ceph.GetUser(ctx, uid)
|
||||||
|
switch {
|
||||||
|
case ceph.IsNotFound(getErr):
|
||||||
if _, err := r.Ceph.CreateUser(ctx, spec); err != nil {
|
if _, err := r.Ceph.CreateUser(ctx, spec); err != nil {
|
||||||
return r.fail(ctx, &osu, "CreateFailed", err)
|
return r.fail(ctx, &osu, "CreateFailed", err)
|
||||||
}
|
}
|
||||||
logger.Info("created RGW user", "uid", uid)
|
logger.Info("created RGW user", "uid", uid)
|
||||||
} else if err != nil {
|
if firstObserve {
|
||||||
return r.fail(ctx, &osu, "LookupFailed", err)
|
osu.Status.Adopted = false
|
||||||
} else {
|
}
|
||||||
|
case getErr != nil:
|
||||||
|
return r.fail(ctx, &osu, "LookupFailed", getErr)
|
||||||
|
default:
|
||||||
if _, err := r.Ceph.UpdateUser(ctx, spec); err != nil {
|
if _, err := r.Ceph.UpdateUser(ctx, spec); err != nil {
|
||||||
return r.fail(ctx, &osu, "UpdateFailed", err)
|
return r.fail(ctx, &osu, "UpdateFailed", err)
|
||||||
}
|
}
|
||||||
|
if firstObserve {
|
||||||
|
osu.Status.Adopted = true
|
||||||
|
logger.Info("adopted existing RGW user", "uid", uid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if q := osu.Spec.Quota; q != nil {
|
if q := osu.Spec.Quota; q != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user