diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 90317bf..58b5096 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -87,9 +87,11 @@ jobs: kubectl wait --for=condition=Ready node --all --timeout=120s kubectl get nodes - - name: Deploy backing services (Redpanda, VersityGW, MinIO, fake-gcs-server) + - name: Deploy backing services (Redpanda, VersityGW, MinIO, fake-gcs-server, MariaDB) run: | set -euxo pipefail + # Generates the MariaDB account password into Secrets; prints none. + test/e2e/backing-services/mariadb-secret.sh kubectl apply -k test/e2e/backing-services # rollout status (not wait --for=Ready) tolerates the pods not # existing yet at the moment we ask. @@ -97,6 +99,7 @@ jobs: kubectl -n blobs rollout status deploy/versitygw --timeout=180s kubectl -n blobs rollout status deploy/minio --timeout=180s kubectl -n blobs rollout status deploy/fake-gcs --timeout=180s + kubectl -n mysql rollout status statefulset/mariadb --timeout=300s - name: Set up contain uses: solsson/setup-contain@49cc4cce1498df8a59e88fff52c1a9b747f11f08 # v1 @@ -127,7 +130,7 @@ jobs: - name: Run e2e harness env: - IMPLEMENTATIONS: redpanda,versitygw,minio,fakegcs + IMPLEMENTATIONS: redpanda,versitygw,minio,fakegcs,mariadb OCI_DIR: ./oci # PUSH_AS reaches the k3d local registry via its host-published # port; CONTROLLER_IMAGE uses the same registry's in-cluster @@ -148,6 +151,25 @@ jobs: kubectl -n blobs describe pod -l app=versitygw || true kubectl -n blobs describe pod -l app=minio || true kubectl -n blobs describe pod -l app=fake-gcs || true + kubectl -n mysql describe pod -l app=mariadb || true + kubectl -n mysql logs statefulset/mariadb --tail=100 || true + + # The mysql driver against real servers (MariaDB 10.11 and 11.4, + # MySQL 8.0 and 8.4), one container each; the script is the same + # one contributors run, and it owns the image list. + mysql-integration: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: mysql driver integration test + run: test/integration/mysql.sh # The image push job runs only on main-branch builds in the canonical # Yolean org with a successful e2e. It rebuilds (proves reproducibility), @@ -159,7 +181,7 @@ jobs: github.ref == 'refs/heads/main' && github.event_name == 'push' && github.repository_owner == 'Yolean' - needs: e2e + needs: [e2e, mysql-integration] runs-on: ubuntu-latest permissions: contents: read diff --git a/README.md b/README.md index bb1fe5c..17bfe90 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # buckety-controller A small in-cluster operator that provisions named resources on -backing services (Kafka topics, S3 buckets) and mints -`secretKeyRef`-friendly credentials Secrets for consumers. +backing services (Kafka topics, S3 buckets, MySQL databases) and +mints `secretKeyRef`-friendly credentials Secrets for consumers. -> **Status: v1alpha1 shipped.** Three drivers (`kadm`, `s3`, -> `gcs`) are implemented and covered by the e2e suite in CI. Every commit on +> **Status: v1alpha1 shipped.** Four drivers (`kadm`, `s3`, +> `gcs`, `mysql`) are implemented and covered by the e2e suite in CI. Every commit on > `main` pins a reproducible image build in > [`deploy/kustomize/release/`](./deploy/kustomize/release), and > the same digest is pushed to `ghcr.io/yolean/buckety-controller` @@ -56,16 +56,17 @@ here. The deviations are operational, not architectural: ## Status -v1alpha1. Three drivers shipped: +v1alpha1. Four drivers shipped: | Driver | Backing services | Notes | | --- | --- | --- | | `kadm` | Kafka-protocol brokers (Redpanda, Apache Kafka, Confluent) | Topic create/alter/delete. v1alpha1: no per-consumer SASL/SCRAM scoping. | | `s3` | S3-compatible (VersityGW, MinIO, AWS S3, Cloudflare R2, Hetzner, GCS interop) | Bucket create/delete. v1alpha1: all consumers receive the backend's root keys. | | `gcs` | Google Cloud Storage via the native JSON API | Bucket create/update/delete with location, uniform bucket-level access, versioning and lifecycle parameters. Access Secrets carry a static HMAC pair (S3-protocol data path); all consumers receive the same pair. Driver 0.2 adds opt-in per-bucket service accounts (`parameters.serviceAccount`): a bucket-scoped GCP SA whose key JSON lands in each access Secret for OAuth2 bearer-token auth (`examples/gcs/service-account/`). | +| `mysql` | MariaDB 10.11+ and MySQL 8 | Database create/drop with `characterSet` and `collation` parameters. Each access gets its own user, with grants for its role on that one database (`Reader`: `SELECT`; `ReadWrite`: data and schema changes). Every name is confined to the backend's `namePrefix`; see [`docs/mysql.md`](./docs/mysql.md) for the account it needs and why. | e2e coverage in CI runs against Redpanda (`kadm`), VersityGW + -MinIO (`s3`) and fake-gcs-server (`gcs`). The other listed S3 +MinIO (`s3`), fake-gcs-server (`gcs`) and MariaDB (`mysql`). The other listed S3 backends share the same client library and the same e2e shape; if you hit a compatibility issue with one of them, please file an issue. For `gcs`, behaviours the emulator cannot exercise (HMAC @@ -202,8 +203,24 @@ backends: # examples/gcs/service-account/ for required grants. # serviceAccounts: # project: my-buckety-identities + +- name: cluster-mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: ${MYSQL_BUCKETY_PASSWORD} + # Every database and user the driver touches starts with this; + # the account's grants are scoped to it. See docs/mysql.md. + namePrefix: b_ ``` +The mysql backend's account and its first-boot creation from a +Secret are in [`examples/mysql/`](./examples/mysql). `CREATE USER` +cannot be scoped by name on MariaDB or MySQL, so the driver refuses +every database and user outside `namePrefix`; keep accounts that +matter on an instance the controller has no backend for. + Access Secrets carry the S3-interop `endpoint` (a bare host - the scheme is the consumer's choice) and `region`, derived per bucket from its location: `storage..rep.googleapis.com` for @@ -356,16 +373,19 @@ explicit `BucketyAccess` in a second apply, if zero-gap matters. See [`SPEC.md`](./SPEC.md#implicit-access-defaultaccess) for the full lifecycle. -> **`role` is advisory in v1alpha1.** `BucketyAccess.spec.role` -> accepts `Reader`, `Writer`, or `ReadWrite`, but the v1alpha1 -> kadm and s3 drivers do not yet scope credentials per role. +> **`role` is advisory in v1alpha1, except for `mysql`.** +> `BucketyAccess.spec.role` accepts `Reader`, `Writer`, or +> `ReadWrite`, but the v1alpha1 kadm, s3 and gcs drivers do not yet +> scope credentials per role. > Every Secret minted for the same `Buckety` carries identical > root credentials regardless of `role`. The controller surfaces > a `ScopingNotImplemented=True` condition on each affected > `BucketyAccess` (visible via `kubectl describe bucketyaccess`) > so the gap is honest, not silent. Scoped credentials > (SASL/SCRAM, IAM users) are v1alpha2 work. Until then, treat -> `role` as documentation of intent, not enforcement. +> `role` as documentation of intent, not enforcement. The `mysql` +> driver does enforce it: each access is its own database user with +> grants for its role, and `Writer` is refused. S3 is the same shape; see [`examples/s3/`](./examples/s3). @@ -386,13 +406,14 @@ are in [`SPEC.md`](./SPEC.md#naming-templates). ## Resources -- `Buckety` — a topic / bucket / future-MySQL-database. Selects +- `Buckety` — a topic / bucket / MySQL database. Selects a backend by name. Carries mutable parameters; the controller reconciles drift to the backing service. - `BucketyAccess` — a Secret request. Each one mints exactly one Secret in the same namespace as the `BucketyAccess`. Multiple accesses can target the same Buckety (in v1alpha1 they all - receive identical credentials). + receive identical credentials, except with `mysql`, where each + gets its own user). Both kinds are namespaced. Cross-namespace `bucketyRef` is not supported in v1alpha1. @@ -491,9 +512,8 @@ Auto-migration is not in v1alpha1. The full list is in [`SPEC.md`](./SPEC.md). Highlights: -- No per-consumer credential scoping. SASL/SCRAM and IAM-user - minting are v1alpha2 work. -- No MySQL driver. +- No per-consumer credential scoping, except in the `mysql` + driver. SASL/SCRAM and IAM-user minting are v1alpha2 work. - No cross-namespace `bucketyRef`. - No hot-reload of `buckety-controller.yaml`. diff --git a/SPEC.md b/SPEC.md index a2f40d5..6091f50 100644 --- a/SPEC.md +++ b/SPEC.md @@ -14,8 +14,8 @@ the two disagree, this file wins until both are revised together. ## Context Buckety provisions named, individually-configurable resources on -backing services (Kafka topics, S3 buckets, future: MySQL -databases) and mints per-consumer credentials. +backing services (Kafka topics, S3 buckets, MySQL databases) and +mints per-consumer credentials. The design is informed by a Yolean exploration of the Container Object Storage Interface (COSI). COSI's two-resource split @@ -73,13 +73,14 @@ classes — they pick the named thing their cluster offers. | `kadm` | Kafka-protocol topics (Redpanda, Apache Kafka, Confluent) | `github.com/twmb/franz-go/pkg/kadm` | Redpanda | | `s3` | S3 API (VersityGW, MinIO, AWS S3, R2, Hetzner, GCS interop) | `github.com/aws/aws-sdk-go-v2/service/s3` | VersityGW + MinIO | | `gcs` | Google Cloud Storage, provisioned via the native JSON API | `cloud.google.com/go/storage` | fake-gcs-server | +| `mysql` | MariaDB and MySQL databases, one user per access | `github.com/go-sql-driver/mysql` | MariaDB | Driver names are short, semantic, and stable; they MUST NOT -change after v1alpha1 ships. New drivers — `mysql` via -`go-sql-driver/mysql`, `minio-admin` for scoped MinIO IAM, -`versitygw-iam` for scoped VersityGW IAM — are follow-ups, not -v1alpha1. The `gcs` driver shipped as the first such follow-up -(same v1alpha1 CRDs, additive driver registration). +change after v1alpha1 ships. New drivers — `minio-admin` for +scoped MinIO IAM, `versitygw-iam` for scoped VersityGW IAM — are +follow-ups, not v1alpha1. The `gcs` driver shipped as the first +such follow-up and `mysql` as the second (same v1alpha1 CRDs, +additive driver registration). The `gcs` / `s3` boundary: the s3 driver's client-library bet covers GCS on the DATA path (HMAC keys work with SigV4 against @@ -278,7 +279,9 @@ credentials drawn from the backend's root config. The CRD shape already supports per-access scoping for v1alpha2 — drivers are expected to ignore role/parameter values they don't yet implement and surface a `ScopingNotImplemented` condition rather -than silently treating Reader as ReadWrite. +than silently treating Reader as ReadWrite. The mysql driver +scopes per access and role already, and refuses a role it has no +grant set for (`Writer`) instead of ignoring it. ## Implicit access (`defaultAccess`) @@ -568,7 +571,8 @@ applied against minio, versitygw and the GCS emulator. Subfamily hierarchies (definitions shared by a subset of a family) are deferred until a third bucket driver forces the -question; today the family is flat and small. +question; today the family is flat and small. `mysql` is not in +a family; a PostgreSQL driver would make a database family. ## Schemas @@ -597,7 +601,7 @@ standalone whole-document schemas for editors (the kubernetes-json-schema pattern): `buckety.schema.json` plus one per family and per driver (`buckety-blobstore` for the object-store family, `buckety-gcs`, `buckety-s3`, -`buckety-kadm`) and `bucketyaccess.schema.json`. +`buckety-kadm`, `buckety-mysql`) and `bucketyaccess.schema.json`. The suffix forms a specialize/generalize ladder walked by switching the URL: the family rung accepts only family-common parameters, so a resource annotated with it provably stays @@ -639,11 +643,13 @@ call, guided by the chosen operator SDK. (see *`Delete` is recursive*), idempotent on NotFound; called only when `retentionPolicy == Delete`. - `GrantAccess` — mints credentials, applies backend-side - permissions (no-op in v1alpha1 for both shipped drivers), - returns the Secret payload as a flat `map[string][]byte`. - Idempotent for the same `(Buckety, BucketyAccess)` pair. + permissions (no-op in v1alpha1 for kadm and s3; mysql creates + a user per access with the role's grants), returns the Secret + payload as a flat `map[string][]byte`. Idempotent for the same + `(Buckety, BucketyAccess)` pair, which the request identifies + by the access's namespace and name. - `RevokeAccess` — removes the backend-side principal (no-op in - v1alpha1), idempotent on NotFound. + v1alpha1 for kadm and s3), idempotent on NotFound. - `ValidateParameters` — used by the admission webhook and, with the webhook disabled, by the reconciler; returns an error naming the offending key. @@ -720,8 +726,8 @@ identity: on the broker. - `s3` driver → key `bucket` carrying the resolved bucket name on the backend. -- Future drivers follow the same pattern: `database`, - `namespace`, etc. +- `mysql` driver → key `database` carrying the database name. +- Future drivers follow the same pattern: `namespace`, etc. The resource-type key is the unambiguous handle a consumer uses to operate on the resource. The other keys (`bootstrap`, @@ -879,6 +885,48 @@ ever wedges an SA at the key cap, surplus `USER_MANAGED` keys on a marker-verified SA are safe to delete server-side and the fleet re-mints within one reconcile. +### `mysql` driver + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: orders-db + namespace: shop +type: Opaque +data: + host: # mysql.mysql.svc.cluster.local + port: # 3306 + database: # b_shop_orders (resource-type key) + username: # b_shop_orders (this access's own user) + password: + jdbcUrl: # jdbc:mariadb://mysql.mysql.svc.cluster.local:3306/b_shop_orders + url: # mysql://:@mysql.mysql.svc.cluster.local:3306/b_shop_orders +``` + +Unlike the v1alpha1 kadm and s3 drivers, credentials are scoped: +each `BucketyAccess` gets a user of its own, named from the +access's namespace and name, with privileges on its one database +by role (`Reader`: `SELECT`; `ReadWrite`: data changes plus the DDL +schema migrations need; `Writer` is refused). `GrantResult.Scoped` +is true, `status.principal` is `user@host`, and `RevokeAccess` +drops the user under every retention policy. The password is +generated at the first grant and read back from the Secret on every +later one, so a password written into the Secret is applied to the +server and deleting the Secret rotates it. + +**The prefix boundary.** The controller's account needs +`CREATE USER` globally, which MariaDB and MySQL cannot scope by +name and which also allows altering and dropping every account on +the server. The backend's `namePrefix` is therefore the driver's +security boundary: every database and user name it creates or +touches must start with it, `spec.name` must resolve to it +(`b_${namespace}_${name}`), and anything outside it is refused, +including principals read back from status. Accounts that matter +belong on instances the controller has no backend for. +[`docs/mysql.md`](docs/mysql.md) has the account grants, naming +rules, drift detection and the decisions taken. + ## Adoption A resolved backend resource name can collide with a resource that @@ -910,7 +958,8 @@ when the resource already exists: - `AdoptEmpty` (default): adopt only when the resource holds no live content (no current objects for buckets; noncurrent versions and delete markers are not consulted. No retained - records for topics; fully expired records count as none). + records for topics; fully expired records count as none. No + tables, views, routines or events for databases). A non-empty resource surfaces `Ready=False` with reason `BackendResourceExists`, mints no Secret, stamps nothing, and re-checks on the periodic cadence. The message names the @@ -1149,6 +1198,7 @@ Required CI matrix for v1alpha1: | `kadm` | redpanda | | `s3` | versitygw, minio | | `gcs` | fakegcs (fake-gcs-server; covers the JSON-API control plane — real-GCS-only behaviours like HMAC auth enforcement, the 90-day UBLA disable window and the serviceAccounts IAM surface (no iam.googleapis.com or bucket-IAM emulation; unit-tested against an httptest fake instead) are documented, not e2e-gated) | +| `mysql` | mariadb (MariaDB 10.11; the driver's integration test covers MariaDB 10.11 and 11.4 and MySQL 8.0 and 8.4 in a separate CI job, `test/integration/mysql.sh`) | Adding an implementation later (e.g. AWS S3 once the project has credentials and a budget) requires no example or harness changes, @@ -1182,14 +1232,14 @@ and the corresponding GHA secret. ## Non-goals in v1alpha1 -- MySQL driver. - Per-consumer credential scoping (SASL/SCRAM for kafka, IAM users for S3). All `BucketyAccess` instances for the same `Buckety` receive identical credentials. Partial exception since gcs driver 0.2: opt-in per-BUCKET service accounts give each access its own key for a bucket-scoped identity (see Secret output > gcs driver), but role scoping remains - unimplemented. + unimplemented. The mysql driver is the exception: a user per + access, scoped by role (see Secret output > mysql driver). - Cross-namespace `bucketyRef`. - Quota enforcement. - Hot-reload of `buckety-controller.yaml` (envsubst is diff --git a/cmd/buckety/main.go b/cmd/buckety/main.go index 98f761d..3e9deb1 100644 --- a/cmd/buckety/main.go +++ b/cmd/buckety/main.go @@ -46,6 +46,7 @@ import ( // this import list. _ "github.com/Yolean/buckety-controller/pkg/drivers/gcs" _ "github.com/Yolean/buckety-controller/pkg/drivers/kadm" + _ "github.com/Yolean/buckety-controller/pkg/drivers/mysql" _ "github.com/Yolean/buckety-controller/pkg/drivers/s3" ) diff --git a/deploy/kustomize/release/kustomization.yaml b/deploy/kustomize/release/kustomization.yaml index 370a865..2b7dce0 100644 --- a/deploy/kustomize/release/kustomization.yaml +++ b/deploy/kustomize/release/kustomization.yaml @@ -7,7 +7,7 @@ kind: Kustomization resources: - ../base images: -- digest: sha256:19e3172abce9c8be53ea19fbcd60ffc3ec636b11c3457af56e829d063e877efe +- digest: sha256:8c3c493780935b8084e2241722f60605464f2cf3d70f83a9c68c2edd43f222d6 name: ghcr.io/yolean/buckety-controller newName: ghcr.io/yolean/buckety-controller - newTag: 20260904T064507Z + newTag: 20261002T051036Z diff --git a/docs/mysql.md b/docs/mysql.md new file mode 100644 index 0000000..208697a --- /dev/null +++ b/docs/mysql.md @@ -0,0 +1,296 @@ +# The `mysql` driver + +Companion to `SPEC.md` for the MariaDB/MySQL driver (v0.1). The +SPEC has the contract; this page has the security model, the +naming rules and the choices behind them. Setup steps are in +[`examples/mysql/README.md`](../examples/mysql/README.md). + +| Resource | On the server | +| --- | --- | +| `Buckety` | a database, `CREATE DATABASE IF NOT EXISTS` with optional character set and collation | +| `BucketyAccess` | a user of its own, with a generated password and the role's privileges on that one database | +| `retentionPolicy: Delete` | `DROP DATABASE` (adopted databases are retained, as for every driver) | +| `BucketyAccess` deletion | `DROP USER`, under every retention policy | + +CI tests it against MariaDB 10.11 and 11.4 and MySQL 8.0 and 8.4 +(`test/integration/mysql.sh`, which runs +`pkg/drivers/mysql/integration_test.go` against each in Docker), +and end to end against MariaDB 10.11. + +## The controller's account + +```sql +CREATE USER 'buckety'@'%' IDENTIFIED BY '...'; +GRANT CREATE USER ON *.* TO 'buckety'@'%'; +GRANT ALL PRIVILEGES ON `b\_%`.* TO 'buckety'@'%' WITH GRANT OPTION; +``` + +The account can create databases whose names start with `b_`, +create users, and grant on those databases. It cannot read or +change any other database, and it cannot read the `mysql` schema +(so it cannot see other accounts' password hashes or grants). +`\_` matters: in `GRANT ... ON db.*` an unescaped `_` is a +wildcard, and `b_%` would also cover `bad_db`. + +`examples/mysql/bootstrap/` creates the account on the server's +first boot from a mounted Secret, so no password is in source. + +### The CREATE USER caveat + +Neither MariaDB nor MySQL can scope `CREATE USER` by name, and the +privilege also allows `ALTER USER`, `RENAME USER` and `DROP USER` +on **every** account on the server. The server does not confine +the controller's account to its own users; the driver does: + +- every database and user name passes one check (`checkManaged`) + before it reaches SQL: it must start with `namePrefix`, be + lowercase letters, digits, `_` and `-` within the length limits, + and not be a system schema or account (`mysql`, `sys`, `root`, + `mariadb.sys`, ...); +- `namePrefix` is at most 10 characters ending in `_`, which keeps + it off every system schema and account name, and the admin + account itself must be outside it; +- `RevokeAccess` parses `status.principal` and refuses to drop + anything outside the prefix, so a tampered status cannot drop + `root`. Within the prefix the driver trusts status: whoever can + write `BucketyAccess` status (by RBAC, only the controller) could + make it drop another access's user. + +A controller compromise (its credentials, or a code path that +bypasses these checks) can still alter or drop any account on the +server. **Sensitive accounts belong on an instance the controller +has no backend for.** On MySQL 8.0.16+ an account holding +`SYSTEM_USER` (root has it) cannot be altered or dropped by an +account without it, which narrows the exposure there; MariaDB has +no equivalent. + +**`namePrefix` is fixed for the backend's lifetime.** Databases +and principals stamped under one prefix are refused under another: +reconciles fail and access deletion blocks. To change it, add a +second backend with the new prefix and migrate; to retire one +anyway, drop its users and databases by hand and remove the +finalizers. + +## Naming + +**Databases** are the resolved `spec.name`, which must start with +`namePrefix`: `spec.name: "b_${namespace}_${name}"`. The driver +does not prepend the prefix itself, so `status.backendResourceName` +and the Secret's `database` key are the real database name. A +`Buckety` without a prefixed `spec.name` is refused at admission +with that suggestion. Limit: 64 characters. + +The prefix is literal in the CR. A cluster that wants CRs portable +across prefixes can put it in the backend's `defaults` and write +`spec.name: "${backend.namePrefix}${namespace}_${name}"`. + +`spec.name` is tenant-controlled, as for every driver: the prefix +keeps tenants off databases outside it, not off each other's. +`spec.adoption: AdoptEmpty` stops a CR from claiming a database +with tables; `Adopt` claims it regardless. Where tenants are +mutually untrusted, require `${namespace}` in `spec.name` (and +restrict `adoption: Adopt`) with an admission policy. + +**Users** are derived from the `BucketyAccess` namespace and name +(the driver gets them in `GrantRequest`): + +- `b__` when it fits and neither part has a dot, + e.g. `b_shop_orders`; +- otherwise `b___<16 base32 characters of + sha256(namespace/name)>`: access `orders` in namespace + `e2e-mysql-mariadb-mysql-happy-path` is + `b_e2e-mysql-ma__eug6f3psticceco5`. + +Kubernetes names contain no `_`, so the first form has one +underscore after the prefix and the second two: the forms cannot +collide, and two accesses share a user only through an 80-bit hash +collision. Names are unique within one cluster, not across +clusters: run one controller per server and `namePrefix`, or give +each cluster's backend a prefix of its own. The limit is 32 characters: MariaDB allows 80, MySQL 8 +only 32, and one scheme for both keeps a backend portable between +them. + +`status.principal` is `user@host`, with the `userHost` the user +was created with. + +## Roles + +Privileges are granted on the one database, by its escaped name +(`b\_t1\_orders`), never on a wildcard pattern. + +| `spec.role` | Privileges | +| --- | --- | +| `Reader` | `SELECT` | +| `ReadWrite` (default) | `SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES` | +| `Writer` | refused: `Ready=False`, reason `GrantFailed`, no Secret; an access changed to `Writer` loses its user | + +ReadWrite includes the DDL that schema migrations need (an +application that migrates its own schema on startup, as Keycloak +does). Database-level `DROP` and `CREATE` also let a ReadWrite +user drop its own database, or recreate it; nothing beyond it. +On every reconcile the driver grants what is missing and revokes +everything else the user holds on the database by its exact +name, including privileges granted out of band (`EXECUTE`, +`CREATE VIEW`, ...) and `GRANT OPTION`. Grants it cannot see are +listed under *Known limitations*. `Scoped` is true, so +`ScopingNotImplemented` never surfaces for this driver. + +## Credentials and drift + +The Secret holds `host`, `port`, `database` (the resource-type +key), `username`, `password`, `jdbcUrl` +(`jdbc:mariadb://host:port/database`) and `url` +(`mysql://username:password@host:port/database`). + +- The first grant generates a 32-character password from + `crypto/rand` (letters, digits and `-._~`, all four classes, so + `simple_password_check` accepts it). +- Later grants reuse the Secret's password. A password changed in + the Secret is applied to the server; it must be 16-128 + characters of `A-Za-z0-9._~-`, the characters that need no + escaping anywhere the driver puts them. Anything else is refused + without echoing it. +- Deleting the Secret rotates the password: the next reconcile + generates one and sets it with `ALTER USER`. Each change is an + account statement, which on Galera replicates as a cluster-wide + blocking (TOI) operation; there is no rate limit, so a tenant + editing its Secret in a loop can slow the cluster. + +The admin account cannot see other users or their grants without +SELECT on the `mysql` schema, so the driver checks each user by +logging in as it with the Secret's password: + +- login succeeds: read the user's own rows in + `information_schema.SCHEMA_PRIVILEGES` and align the grants. When + nothing differs, nothing is written: steady state costs one login + per access per re-check and no statements that replicate. +- login is refused: the user is missing or its password differs. + `CREATE USER`, or `ALTER USER` when it exists, then grant, then + log in again (retrying for about four seconds, for Galera) to + verify. A password the driver has just generated is not verified + in the same pass: it must reach the Secret first, and the next + re-check logs in with it. + +So **`userHost` must admit the controller's own address**; with +the default `%` it does. Changing `userHost` moves each access to +a new account on its next reconcile, and the controller drops the +old one. + +Passwords never appear in logs or errors: the driver builds no DSN +strings, and an error from a statement that carries a password +keeps only the server's error number (a parse error's message +quotes the statement) before it reaches a condition or event. + +## SQL construction + +- Identifiers (database names, character sets, collations) are + validated against the rules above and backtick-quoted. +- Account names and passwords go in as `?` arguments. The account + statements (`CREATE USER`, `GRANT`) are not parameterisable in + the server-side prepared statement protocol, so the client + interpolates the arguments as escaped string literals + (go-sql-driver's `interpolateParams`); the values are validated + first regardless. +- Privilege names read back from the server are checked against + `^[A-Z]+( [A-Z]+)*$` before they are spliced into `REVOKE`. +- Multi-statements and `LOAD DATA LOCAL` stay disabled. + +## Parameters + +`characterSet` and `collation` are applied at creation and +immutable. The driver never runs `ALTER DATABASE`: it changes the +default for new tables only, and converging silently would hide +that existing tables still differ. A database whose defaults differ +from the parameters (changed out of band, or adopted) surfaces +`ParameterDrift`, and the controller stops re-checking it until the +`Buckety` changes. Fix it out of band and then touch the `Buckety` +(`kubectl annotate buckety/ example.com/recheck="$(date +%s)" +--overwrite`, any annotation will do), or recreate the `Buckety`. +`utf8` is refused as ambiguous (utf8mb3 today, utf8mb4 later). + +## Adoption and retention + +Adoption works as for every driver, within the prefix: a database +is empty when it has no tables, views, routines or events. Existing +unprefixed databases (say, a `keycloak` database created before +buckety) cannot be adopted, because the driver refuses every name +outside the prefix; migrate them into prefixed databases (dump and +restore), or keep them outside buckety. + +`retentionPolicy: Delete` runs `DROP DATABASE` for databases the +controller created. Users are dropped with their `BucketyAccess` +under every policy; a retained database keeps no credentials. + +## TLS + +`tls: {}` in the backend config requires verified TLS on the +controller's connections (`caFile` and `serverName` optional). +Without it, every access's password crosses the network in +cleartext, inside `CREATE USER`/`ALTER USER` statements and the +login checks; leave `tls` out only where that network is trusted +(an in-cluster Service with NetworkPolicies, say). +Created users get no `REQUIRE SSL`; to require TLS from consumers, +set `require_secure_transport` on the server. On MySQL 8 without +`tls`, `caching_sha2_password` makes the client fetch the server's +RSA key unauthenticated before sending the password; configure +`tls` where the network is not trusted. + +## MySQL 8 notes + +- `partial_revokes` must stay `OFF` (the default): with it on, the + `%` in the account's `b\_%` grant is literal. +- User names are capped at 32 characters, which the naming above + already respects. +- The `jdbcUrl` key is in the MariaDB Connector/J form; MySQL + Connector/J wants `jdbc:mysql://`. + +## Design decisions + +1. **Roles.** ReadWrite includes DDL; no separate Owner role. + The CRD's `Writer` is refused rather than given a guessed + meaning; a DML-only Writer (`SELECT, INSERT, UPDATE, DELETE`) is + the obvious candidate if one is wanted. +2. **Naming.** Literal prefix in `spec.name`, validated, 64 + characters for databases; derived user names capped at 32 for + MySQL 8, with a hashed form for long names. +3. **TLS.** Optional `tls` for the controller's connections; no + `REQUIRE SSL` on created users (server-side + `require_secure_transport` instead). +4. **Password rotation.** On demand: delete the Secret, or write a + new password into it. No scheduled rotation; `ALTER USER` does + not end sessions that are already open. +5. **Galera.** `CREATE USER`, `GRANT` and DDL replicate as Total + Order Isolation, which briefly blocks the whole cluster, so the + driver writes only on change. A login check right after a change + may reach a node that has not applied it yet; verification + retries for about four seconds. + +## Known limitations + +- `DROP USER` and `ALTER USER` do not end open sessions; revoking + an access stops new logins, not running ones. Database-level + grant changes, such as a role change from ReadWrite to Reader, + reach an open session only when it next selects the database, + usually on reconnect. Ending sessions would need + `CONNECTION ADMIN`/`SUPER`. +- `DROP DATABASE` waits for open transactions on the database. The + controller's sessions set `lock_wait_timeout` to 50 seconds, so a + blocked drop fails and is retried rather than queueing behind + long-lived consumer sessions. +- A user created in a pass whose Secret write then fails is + dropped again. A user can still end up recorded by no + `BucketyAccess`: a controller crash between `CREATE USER` and the + status write, a failed status write, or a failed drop of a + replaced principal followed by the access's deletion. Such users + are found by the prefix. An access later created with the same + namespace and name takes the user over with its own password. +- Grants the driver cannot see are not managed: table- and + column-level grants, grants that reach the database through a + wildcard pattern (`b\_%`, `b_t1%`), global privileges and roles. + The login check reads only the user's database-level row for the + exact database name. +- An account with the same user name and a more specific host + (created by hand) shadows the managed one: the driver cannot + inspect the managed account through it, so every reconcile sets + its password and grants again, which on Galera means replicated + account statements on every re-check. Drop the shadowing account. diff --git a/examples/mysql/README.md b/examples/mysql/README.md new file mode 100644 index 0000000..0dca2df --- /dev/null +++ b/examples/mysql/README.md @@ -0,0 +1,140 @@ +# mysql driver examples + +MariaDB/MySQL databases as `Buckety`, users as `BucketyAccess`. +The design and the security model are in +[`docs/mysql.md`](../../docs/mysql.md); this page is the setup. + +| Path | What | +| --- | --- | +| `bootstrap/` | Yolean/kubernetes-mysql-cluster `variants/scale-1`, plus the `initdb/` component that creates the controller's account on first boot | +| `buckety-controller.yaml` | the backend config | +| `controller-env-patch.yaml` | the controller Deployment env the config reads | +| `happy-path/`, `multi-consumer/`, `oob-drift/`, `adoption/`, `parameter-mutation/`, `retention-policy/` | e2e scenarios for this driver's behaviour | +| `backend-stickiness/`, `driver-version/`, `misconfigured-startup/`, `scaled-to-zero/` | e2e scenarios whose bodies every driver shares (`test/e2e/lib.sh`) | + +## 1. Generate the account password once + +The MariaDB pod mounts the password and the controller reads it as +env; a pod can only use Secrets from its own namespace, so the same +Secret goes into both: + +```sh +umask 077 +head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n' > mysql-buckety-password +kubectl -n mysql create secret generic mysql-buckety --from-file=password=mysql-buckety-password +kubectl -n buckety create secret generic mysql-buckety --from-file=password=mysql-buckety-password +rm mysql-buckety-password +``` + +Use 16-128 characters of `A-Za-z0-9._~-` and no trailing newline; +the bootstrap script refuses anything else, and the controller +refuses a line break. + +## 2. Create the controller's account + +kubernetes-mysql-cluster lets root connect only from localhost, so +the account is created inside the MariaDB pod. + +**New instance:** deploy with the component, e.g. +`kubectl apply -k examples/mysql/bootstrap`, or add it to your own +kustomization of any kubernetes-mysql-cluster variant: + +```yaml +resources: +- github.com/Yolean/kubernetes-mysql-cluster/variants/scale-1?ref= +components: +- /examples/mysql/bootstrap/initdb +``` + +When the image entrypoint initialises the empty data directory, it +runs `/docker-entrypoint-initdb.d/buckety-account.sh`, which reads +`/run/secrets/buckety/password` and runs: + +```sql +CREATE USER IF NOT EXISTS 'buckety'@'%' IDENTIFIED BY ''; +GRANT CREATE USER ON *.* TO 'buckety'@'%'; +GRANT ALL PRIVILEGES ON `b\_%`.* TO 'buckety'@'%' WITH GRANT OPTION; +``` + +`BUCKETY_ADMIN_USER` and `BUCKETY_NAME_PREFIX` override `buckety` +and `b_`. With Galera (scale-2, the 3-node base) the first node +creates the account and the others receive it with the data. + +**Existing instance:** the entrypoint skips init scripts when data +exists. Apply the component (the pod restarts with the script and +the Secret mounted) and run the script once; it is idempotent: + +```sh +kubectl -n mysql exec mariadb-0 -c mariadb -- /docker-entrypoint-initdb.d/buckety-account.sh +``` + +Or run the three statements above as root by hand. + +## 3. Configure the backend + +Add the backend from `buckety-controller.yaml` to the controller +config and the env from `controller-env-patch.yaml` to its +Deployment (see the top-level README, *Install*). The controller +connects lazily: a database that is down fails reconciles, not +startup. + +## 4. Use it + +```yaml +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: orders + namespace: shop +spec: + backend: cluster-mysql + # Must start with the backend's namePrefix: b_shop_orders + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Retain # Delete drops the database with its data + defaultAccess: + role: ReadWrite + credentialsSecretName: orders-db +--- +# A second consumer that only reads; it gets a user of its own. +# (An explicit access replaces the implicit one from defaultAccess; +# see SPEC.md "Implicit access".) +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: orders-reporting + namespace: shop +spec: + bucketyRef: + name: orders + credentialsSecretName: orders-reporting-db + role: Reader +``` + +The Secret has `host`, `port`, `database`, `username`, `password`, +`jdbcUrl` and `url`. For Keycloak, for example: + +```yaml +env: +- name: KC_DB + value: mariadb +- name: KC_DB_URL + valueFrom: { secretKeyRef: { name: keycloak-db, key: jdbcUrl } } +- name: KC_DB_USERNAME + valueFrom: { secretKeyRef: { name: keycloak-db, key: username } } +- name: KC_DB_PASSWORD + valueFrom: { secretKeyRef: { name: keycloak-db, key: password } } +``` + +Roles: `Reader` gets `SELECT`; `ReadWrite` gets data changes plus +the DDL migrations need; `Writer` is refused. To rotate a password, +delete the access's Secret (the next reconcile generates one), or +write a new password into it (it is applied to the server). Running +consumers keep their open connections; restart them to pick up a +new password. + +Databases that predate the prefix (say, a `keycloak` database +created by hand) cannot be adopted; see +[`docs/mysql.md`](../../docs/mysql.md#adoption-and-retention). diff --git a/examples/mysql/adoption/README.md b/examples/mysql/adoption/README.md new file mode 100644 index 0000000..231b46b --- /dev/null +++ b/examples/mysql/adoption/README.md @@ -0,0 +1,22 @@ +# mysql / adoption + +SPEC scenario 11 ("Adoption") for the `mysql` driver: a `Buckety` +whose resolved name collides with an existing database must not +silently claim it, and deleting an adopted `Buckety` must never +drop data that predates the CR. A database is empty when it holds +no tables, views, routines or events. + +- `adopt-pre` collides with a pre-created database holding a table: + the default policy (AdoptEmpty) surfaces + `Ready=False/BackendResourceExists` and mints no Secret; + `spec.adoption=Adopt` unblocks it with `status.provenance=Adopted`, + and the minted user reads the existing rows. +- `adopt-void` collides with a pre-created empty database and adopts + under the default policy. +- `adopt-fresh` proves `provenance=Created` and that created + databases still honour `retentionPolicy=Delete`. +- Deleting either adopted `Buckety` keeps its database despite + `retentionPolicy=Delete`; assert.sh drops them at the end. + +Only names inside the backend's `namePrefix` can be adopted; the +driver refuses every other name. diff --git a/examples/mysql/adoption/assert.sh b/examples/mysql/adoption/assert.sh new file mode 100755 index 0000000..3f9e499 --- /dev/null +++ b/examples/mysql/adoption/assert.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" +here="$(cd "$(dirname "$0")" && pwd)" + +pre="b_${E2E_NAMESPACE}_adopt-pre" +void="b_${E2E_NAMESPACE}_adopt-void" + +kc apply -f "$here/fresh.yaml" +wait_ready buckety/adopt-fresh 120s +assert_provenance adopt-fresh Created +fresh="$(secret_value adopt-fresh-db database)" + +log "pre-creating $pre (with a table) and $void (empty)" +mysql_root "CREATE DATABASE \`$pre\`; CREATE TABLE \`$pre\`.precious (note VARCHAR(40)); INSERT INTO \`$pre\`.precious VALUES ('predates the CR')" +mysql_root "CREATE DATABASE \`$void\`" + +# Non-empty: refused, nothing stamped, no Secret. +kc apply -f "$here/pre.yaml" +wait_ready_reason buckety/adopt-pre BackendResourceExists 60 +if kc get secret/adopt-pre-db >/dev/null 2>&1; then + fail "Secret minted while adoption was refused" +fi + +# Empty: adopted under the default policy. +kc apply -f "$here/void.yaml" +wait_ready buckety/adopt-void 120s +assert_provenance adopt-void Adopted + +# Explicit opt-in claims the database; its rows are untouched and +# the minted user can read them. +log "unblocking adopt-pre with spec.adoption=Adopt" +kc patch buckety/adopt-pre --type=merge -p '{"spec":{"adoption":"Adopt"}}' +wait_ready buckety/adopt-pre 120s +assert_provenance adopt-pre Adopted +wait_ready bucketyaccess/adopt-pre 120s +[[ "$(mysql_login adopt-pre-db "SELECT note FROM precious")" == "predates the CR" ]] \ + || fail "the adopted database's rows are not readable with the minted Secret" + +# Deleting adopted Bucketys retains the databases despite +# retentionPolicy=Delete. +log "deleting adopted Bucketys (retentionPolicy=Delete must degrade to Retain)" +kc delete buckety/adopt-pre --wait=true --timeout=90s +kc delete buckety/adopt-void --wait=true --timeout=90s +mysql_database_exists "$pre" || fail "adopted database $pre was dropped" +mysql_database_exists "$void" || fail "adopted database $void was dropped" +[[ "$(mysql_root "SELECT COUNT(*) FROM \`$pre\`.precious")" == 1 ]] \ + || fail "adopted database $pre lost its rows" + +# Created databases still delete normally. +kc delete buckety/adopt-fresh --wait=true --timeout=90s +if mysql_database_exists "$fresh"; then + fail "created database $fresh survived retentionPolicy=Delete" +fi + +log "cleaning up retained databases out-of-band" +mysql_root "DROP DATABASE \`$pre\`; DROP DATABASE \`$void\`" + +log "adoption PASS" diff --git a/examples/mysql/adoption/fresh.yaml b/examples/mysql/adoption/fresh.yaml new file mode 100644 index 0000000..b7bf218 --- /dev/null +++ b/examples/mysql/adoption/fresh.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: adopt-fresh +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: adopt-fresh-db diff --git a/examples/mysql/adoption/kustomization.yaml b/examples/mysql/adoption/kustomization.yaml new file mode 100644 index 0000000..98129bd --- /dev/null +++ b/examples/mysql/adoption/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# CRs are applied in stages by assert.sh: the databases they +# collide with must be pre-created out-of-band first. +resources: [] diff --git a/examples/mysql/adoption/pre.yaml b/examples/mysql/adoption/pre.yaml new file mode 100644 index 0000000..b30b237 --- /dev/null +++ b/examples/mysql/adoption/pre.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +# Collides with the pre-created database that holds a table; the +# default adoption policy (AdoptEmpty) must refuse it. +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: adopt-pre +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: adopt-pre-db diff --git a/examples/mysql/adoption/void.yaml b/examples/mysql/adoption/void.yaml new file mode 100644 index 0000000..68b909e --- /dev/null +++ b/examples/mysql/adoption/void.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +# Collides with a pre-created empty database: adopted under the +# default policy. +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: adopt-void +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: adopt-void-db diff --git a/examples/mysql/backend-stickiness/README.md b/examples/mysql/backend-stickiness/README.md new file mode 100644 index 0000000..c4d7169 --- /dev/null +++ b/examples/mysql/backend-stickiness/README.md @@ -0,0 +1,15 @@ +# mysql backend stickiness + +SPEC scenario 7 for the mysql driver: `status.backend` is stamped +at first reconcile and sticky. Renaming the backend in the +controller config surfaces `BackendUnavailable` on existing +resources without mutating their stamped backend; deletion with +`retentionPolicy: Delete` blocks while the backend is missing (the +database would be orphaned otherwise) and completes once the +backend is restored. + +With mysql the implicit access holds a user of its own, so the +block shows on the access first (`cannot revoke principal ...`, +reason `BackendUnavailable`) and the `Buckety` waits for it. The +scenario deletes `sticky-new` before restoring the original config: +once its backend is unregistered, nothing could revoke its user. diff --git a/examples/mysql/backend-stickiness/assert.sh b/examples/mysql/backend-stickiness/assert.sh new file mode 100755 index 0000000..5f8ef05 --- /dev/null +++ b/examples/mysql/backend-stickiness/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +backend_stickiness_scenario mysql "$(dirname "$0")/renamed.yaml" diff --git a/examples/mysql/backend-stickiness/kustomization.yaml b/examples/mysql/backend-stickiness/kustomization.yaml new file mode 100644 index 0000000..0934c1a --- /dev/null +++ b/examples/mysql/backend-stickiness/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# renamed.yaml is applied by assert.sh after the config swap. +resources: +- original.yaml diff --git a/examples/mysql/backend-stickiness/original.yaml b/examples/mysql/backend-stickiness/original.yaml new file mode 100644 index 0000000..6d19893 --- /dev/null +++ b/examples/mysql/backend-stickiness/original.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: sticky-orig +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: sticky-orig-db diff --git a/examples/mysql/backend-stickiness/renamed.yaml b/examples/mysql/backend-stickiness/renamed.yaml new file mode 100644 index 0000000..3e5a7ba --- /dev/null +++ b/examples/mysql/backend-stickiness/renamed.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: sticky-new +spec: + backend: mysql-renamed + name: "b_${namespace}_${name}" + # Deleted by the scenario while the renamed backend still exists. + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: sticky-new-db diff --git a/examples/mysql/bootstrap/initdb/buckety-account.sh b/examples/mysql/bootstrap/initdb/buckety-account.sh new file mode 100644 index 0000000..a17fd01 --- /dev/null +++ b/examples/mysql/bootstrap/initdb/buckety-account.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Creates buckety-controller's account in a fresh MariaDB/MySQL. +# +# The image entrypoint runs this from /docker-entrypoint-initdb.d +# once, when it initialises an empty data directory. The password +# is read from a mounted Secret file, so no password is in source; +# the controller gets the same Secret through ${...} substitution. +# +# Environment (all optional): +# BUCKETY_PASSWORD_FILE default /run/secrets/buckety/password +# BUCKETY_ADMIN_USER default buckety +# BUCKETY_NAME_PREFIX default b_ (the backend's namePrefix) +# +# The body runs in a subshell, so the entrypoint may execute or +# source this file without inheriting its options or variables. + +buckety_account() ( + set -euo pipefail + password_file="${BUCKETY_PASSWORD_FILE:-/run/secrets/buckety/password}" + user="${BUCKETY_ADMIN_USER:-buckety}" + prefix="${BUCKETY_NAME_PREFIX:-b_}" + + if [ ! -s "$password_file" ]; then + echo "buckety-account: $password_file is missing or empty" >&2 + exit 1 + fi + # Keep a trailing newline, so the check below refuses it: the + # controller's env gets the Secret verbatim and would not match. + password="$(cat "$password_file"; printf x)" + password="${password%x}" + # The driver's rule for passwords: characters that need no + # escaping inside a SQL string literal. + if [ ${#password} -lt 16 ] || [ ${#password} -gt 128 ] || [[ "$password" =~ [^A-Za-z0-9._~-] ]]; then + echo "buckety-account: the password must be 16-128 characters of A-Z a-z 0-9 . _ ~ - and no trailing newline" >&2 + exit 1 + fi + if [[ ! "$user" =~ ^[A-Za-z0-9]{1,32}$ ]]; then + echo "buckety-account: BUCKETY_ADMIN_USER must be 1-32 letters or digits" >&2 + exit 1 + fi + if [[ ! "$prefix" =~ ^[a-z][a-z0-9]{0,8}_$ ]]; then + echo "buckety-account: BUCKETY_NAME_PREFIX must be a lowercase letter, letters or digits, and a trailing _" >&2 + exit 1 + fi + # GRANT ... ON db.* reads _ and % in the name as wildcards. + pattern="${prefix//_/\\_}%" + + client=mariadb + command -v mariadb >/dev/null 2>&1 || client=mysql + # Root's password when the image was given one; empty otherwise. + MYSQL_PWD="${MARIADB_ROOT_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}" "$client" --protocol=socket -uroot < parameter defaults). + # parameters: + # characterSet: utf8mb4 + # collation: utf8mb4_unicode_ci diff --git a/examples/mysql/controller-env-patch.yaml b/examples/mysql/controller-env-patch.yaml new file mode 100644 index 0000000..60ad50e --- /dev/null +++ b/examples/mysql/controller-env-patch.yaml @@ -0,0 +1,17 @@ +# Patch for the controller Deployment: the account password for +# ${MYSQL_BUCKETY_PASSWORD} in buckety-controller.yaml. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: buckety-controller +spec: + template: + spec: + containers: + - name: controller + env: + - name: MYSQL_BUCKETY_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-buckety + key: password diff --git a/examples/mysql/driver-version/README.md b/examples/mysql/driver-version/README.md new file mode 100644 index 0000000..fef8016 --- /dev/null +++ b/examples/mysql/driver-version/README.md @@ -0,0 +1,10 @@ +# mysql / driver-version + +**Scenario:** SPEC "Driver versioning" for the mysql driver, the +same image rotation as `examples/kadm/driver-version`. + +Patch bump auto-applies and advances `status.driverBuildVersion`; +major bump surfaces `DriverVersionIncompatible` and pauses +reconcile with `status.driverMajor` unchanged. The expected +versions derive from what the base image stamps, so the scenario +needs no knowledge of the driver's current version. diff --git a/examples/mysql/driver-version/assert.sh b/examples/mysql/driver-version/assert.sh new file mode 100755 index 0000000..7cf6cb4 --- /dev/null +++ b/examples/mysql/driver-version/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +driver_version_scenario pinned diff --git a/examples/mysql/driver-version/buckety.yaml b/examples/mysql/driver-version/buckety.yaml new file mode 100644 index 0000000..0a774f7 --- /dev/null +++ b/examples/mysql/driver-version/buckety.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: pinned +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: pinned-db diff --git a/examples/mysql/driver-version/kustomization.yaml b/examples/mysql/driver-version/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/driver-version/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/happy-path/README.md b/examples/mysql/happy-path/README.md new file mode 100644 index 0000000..11c6359 --- /dev/null +++ b/examples/mysql/happy-path/README.md @@ -0,0 +1,29 @@ +# mysql / happy-path + +**Scenario:** SPEC.md §End-to-end coverage #1 - Single-consumer +happy path, for the `mysql` driver. + +A `Buckety` with `defaultAccess` creates a MariaDB database and a +user with ReadWrite grants on it, and mints the Secret a single +workload consumes. + +**Demonstrates:** + +- `spec.name: "b_${namespace}_${name}"`: database names must start + with the backend's `namePrefix` (`b_`). +- `characterSet` / `collation` parameters, applied at creation. +- The minted Secret carries the documented mysql keys: `host`, + `port`, `database` (resource-type key), `username`, `password`, + `jdbcUrl`, `url`. +- A consumer `Job` connects with the Secret's values, creates and + alters a table (schema migrations need DDL), and reads back a row. + +**Assertions** (`assert.sh`): + +1. `Buckety/orders` and its implicit `BucketyAccess/orders` reach + `Ready=True`. +2. `Secret/orders-db` has all keys and the owned label. +3. `database` and `status.backendResourceName` are + `b__orders`; `status.principal` is `@%`. +4. No `ScopingNotImplemented`: the mysql driver scopes per role. +5. The consumer `Job` completes. diff --git a/examples/mysql/happy-path/assert.sh b/examples/mysql/happy-path/assert.sh new file mode 100755 index 0000000..c430428 --- /dev/null +++ b/examples/mysql/happy-path/assert.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/orders 120s +wait_ready bucketyaccess/orders 120s +secret_has_keys orders-db host port database username password jdbcUrl url +secret_owned_label orders-db + +want="b_${E2E_NAMESPACE}_orders" +db="$(secret_value orders-db database)" +[[ "$db" == "$want" ]] || fail "Secret database is '$db', expected '$want'" +resolved="$(kc get buckety/orders -o jsonpath='{.status.backendResourceName}')" +[[ "$resolved" == "$want" ]] || fail "status.backendResourceName is '$resolved', expected '$want'" + +user="$(secret_value orders-db username)" +[[ "$user" == b_* && ${#user} -le 32 ]] || fail "username '$user' is not a b_ name of at most 32 characters" +principal="$(kc get bucketyaccess/orders -o jsonpath='{.status.principal}')" +[[ "$principal" == "$user@%" ]] || fail "status.principal is '$principal', expected '$user@%'" +jdbc="$(secret_value orders-db jdbcUrl)" +[[ "$jdbc" == "jdbc:mariadb://mysql.mysql.svc.cluster.local:3306/$want" ]] || fail "jdbcUrl is '$jdbc'" + +# The mysql driver scopes per role: no ScopingNotImplemented. +sni="$(condition_status bucketyaccess/orders ScopingNotImplemented)" +[[ -z "$sni" || "$sni" == "False" ]] || fail "ScopingNotImplemented is '$sni'" + +kc wait --for=condition=Complete --timeout=120s job/orders-roundtrip \ + || { kc logs job/orders-roundtrip >&2 || true; fail "roundtrip Job did not complete"; } + +log "happy-path PASS" diff --git a/examples/mysql/happy-path/buckety.yaml b/examples/mysql/happy-path/buckety.yaml new file mode 100644 index 0000000..ca95c70 --- /dev/null +++ b/examples/mysql/happy-path/buckety.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: orders +spec: + backend: mysql + # Database names must start with the backend's namePrefix (b_). + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: orders-db diff --git a/examples/mysql/happy-path/consumer-job.yaml b/examples/mysql/happy-path/consumer-job.yaml new file mode 100644 index 0000000..e38f6ce --- /dev/null +++ b/examples/mysql/happy-path/consumer-job.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: orders-roundtrip +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: roundtrip + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: DB_HOST + valueFrom: { secretKeyRef: { name: orders-db, key: host } } + - name: DB_PORT + valueFrom: { secretKeyRef: { name: orders-db, key: port } } + - name: DB_NAME + valueFrom: { secretKeyRef: { name: orders-db, key: database } } + - name: DB_USER + valueFrom: { secretKeyRef: { name: orders-db, key: username } } + # The client reads MYSQL_PWD, keeping the password off the + # command line. + - name: MYSQL_PWD + valueFrom: { secretKeyRef: { name: orders-db, key: password } } + command: [bash, -ec] + args: + - | + sql() { mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --batch --skip-column-names "$DB_NAME" -e "$1"; } + sql "CREATE TABLE IF NOT EXISTS probe (id INT PRIMARY KEY, msg VARCHAR(40))" + sql "ALTER TABLE probe ADD COLUMN IF NOT EXISTS at TIMESTAMP NULL" + sql "REPLACE INTO probe (id, msg) VALUES (1, 'hello-buckety')" + [ "$(sql 'SELECT msg FROM probe WHERE id = 1')" = hello-buckety ] diff --git a/examples/mysql/happy-path/kustomization.yaml b/examples/mysql/happy-path/kustomization.yaml new file mode 100644 index 0000000..b49066b --- /dev/null +++ b/examples/mysql/happy-path/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml +- consumer-job.yaml diff --git a/examples/mysql/misconfigured-startup/README.md b/examples/mysql/misconfigured-startup/README.md new file mode 100644 index 0000000..9e4eefc --- /dev/null +++ b/examples/mysql/misconfigured-startup/README.md @@ -0,0 +1,12 @@ +# mysql misconfigured controller startup + +SPEC scenario 10 for the mysql driver: each broken config variant +makes the controller exit non-zero with a log message that is +enough to diagnose the mistake. The assert swaps the controller's +config Secret, watches for the expected message in the crash +logs, and restores the original config afterwards. + +Variants: strict-decode failure (typoed field), missing required +`host`, undefined `${VAR}` without a default, and an `adminUser` +inside `namePrefix` (the prefix boundary would not exclude the +controller's own account). diff --git a/examples/mysql/misconfigured-startup/assert.sh b/examples/mysql/misconfigured-startup/assert.sh new file mode 100755 index 0000000..bb73d28 --- /dev/null +++ b/examples/mysql/misconfigured-startup/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +misconfigured_startup_scenario "$(cd "$(dirname "$0")" && pwd)" diff --git a/examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml b/examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml new file mode 100644 index 0000000..9cb9f82 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml @@ -0,0 +1,10 @@ +backends: +- name: mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + # The controller's own account must be outside the names it + # manages, or it could alter or drop itself. + adminUser: b_admin + adminPassword: literal + namePrefix: b_ diff --git a/examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml b/examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml new file mode 100644 index 0000000..d141444 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml @@ -0,0 +1,6 @@ +backends: +- name: mysql + driver: mysql + config: + adminUser: buckety + adminPassword: literal diff --git a/examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml b/examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml new file mode 100644 index 0000000..bce35d7 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml @@ -0,0 +1,7 @@ +backends: +- name: mysql + driver: mysql + config: + hots: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: literal diff --git a/examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml b/examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml new file mode 100644 index 0000000..6125846 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml @@ -0,0 +1,7 @@ +backends: +- name: mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: ${BUCKETY_E2E_UNSET_VAR} diff --git a/examples/mysql/misconfigured-startup/expectations.txt b/examples/mysql/misconfigured-startup/expectations.txt new file mode 100644 index 0000000..d432012 --- /dev/null +++ b/examples/mysql/misconfigured-startup/expectations.txt @@ -0,0 +1,8 @@ +# +# Patterns match against raw JSON-formatted controller log. Quoted +# field names appear as \", so regexes use distinctive substrings +# rather than literal quote matching. +strict-decode.yaml unknown field.*hots +missing-required.yaml missing required field.*host +undefined-var.yaml undefined variable.*BUCKETY_E2E_UNSET_VAR +admin-in-prefix.yaml adminUser.*starts with namePrefix diff --git a/examples/mysql/misconfigured-startup/kustomization.yaml b/examples/mysql/misconfigured-startup/kustomization.yaml new file mode 100644 index 0000000..378dbee --- /dev/null +++ b/examples/mysql/misconfigured-startup/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# This scenario operates on the controller's own config Secret; +# the harness handles apply/restore. Nothing namespace-scoped +# is applied via kustomize here. +resources: [] diff --git a/examples/mysql/multi-consumer/README.md b/examples/mysql/multi-consumer/README.md new file mode 100644 index 0000000..575c1de --- /dev/null +++ b/examples/mysql/multi-consumer/README.md @@ -0,0 +1,29 @@ +# mysql / multi-consumer + +**Scenario:** SPEC.md §End-to-end coverage #2 - Multiple consumers, +different roles, for the `mysql` driver. + +Unlike the v1alpha1 kadm/s3 drivers, the mysql driver gives every +`BucketyAccess` its own user with grants for its role, so the roles +are enforced by the server. + +**Demonstrates:** + +- One database, three `BucketyAccess`: `ReadWrite` (data and DDL), + `Reader` (`SELECT` only) and `Writer`, which the mysql driver + refuses (`Ready=False`, reason `GrantFailed`) rather than guess a + grant set for. +- A `Job` writes as the ReadWrite user and verifies the Reader can + read but not insert or create tables. +- Deletion blocks on explicit accesses; removing them drops their + users and then, with `retentionPolicy: Delete`, the database. + +**Assertions** (`assert.sh`): + +1. Buckety and the ReadWrite/Reader accesses reach Ready, with full + Secrets and no `ScopingNotImplemented`. +2. Both Secrets name the same database and different users. +3. The Writer access reports `GrantFailed` and has no Secret. +4. The roles Job completes. +5. `BlockedByAccesses` while accesses exist; deletion completes + after they are removed. diff --git a/examples/mysql/multi-consumer/access.yaml b/examples/mysql/multi-consumer/access.yaml new file mode 100644 index 0000000..080eef7 --- /dev/null +++ b/examples/mysql/multi-consumer/access.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/bucketyaccess.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shared-rw +spec: + bucketyRef: + name: shared + credentialsSecretName: shared-rw + role: ReadWrite +--- +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shared-reader +spec: + bucketyRef: + name: shared + credentialsSecretName: shared-reader + role: Reader +--- +# The mysql driver refuses Writer instead of guessing a grant set +# (docs/mysql.md); this access stays Ready=False/GrantFailed. +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shared-writer +spec: + bucketyRef: + name: shared + credentialsSecretName: shared-writer + role: Writer diff --git a/examples/mysql/multi-consumer/assert.sh b/examples/mysql/multi-consumer/assert.sh new file mode 100755 index 0000000..dec7905 --- /dev/null +++ b/examples/mysql/multi-consumer/assert.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/shared 120s +for s in shared-rw shared-reader; do + wait_ready "bucketyaccess/$s" 120s + secret_has_keys "$s" host port database username password jdbcUrl url + sni="$(condition_status "bucketyaccess/$s" ScopingNotImplemented)" + [[ -z "$sni" || "$sni" == "False" ]] || fail "$s: ScopingNotImplemented is '$sni'; the mysql driver scopes per role" +done + +[[ "$(secret_value shared-rw database)" == "$(secret_value shared-reader database)" ]] \ + || fail "the accesses name different databases" +[[ "$(secret_value shared-rw username)" != "$(secret_value shared-reader username)" ]] \ + || fail "the accesses share a user; each access must get its own" + +# Writer is refused, and nothing is minted for it. +wait_ready_reason bucketyaccess/shared-writer GrantFailed 60 +if kc get secret/shared-writer >/dev/null 2>&1; then + fail "a Secret was minted for the refused Writer access" +fi + +kc wait --for=condition=Complete --timeout=120s job/shared-roles \ + || { kc logs job/shared-roles >&2 || true; fail "roles Job did not complete"; } + +# Deletion blocks on explicit accesses; removing them drops their +# users, then the database (retentionPolicy=Delete). +log "deleting buckety/shared; expecting BlockedByAccesses" +kc delete buckety/shared --wait=false +wait_condition buckety/shared BlockedByAccesses True 60s +kc delete bucketyaccess --all --wait=true --timeout=90s +kc wait --for=delete buckety/shared --timeout=90s \ + || fail "shared deletion did not complete after accesses were removed" + +log "multi-consumer PASS" diff --git a/examples/mysql/multi-consumer/buckety.yaml b/examples/mysql/multi-consumer/buckety.yaml new file mode 100644 index 0000000..30e010d --- /dev/null +++ b/examples/mysql/multi-consumer/buckety.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shared +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete diff --git a/examples/mysql/multi-consumer/consumer-job.yaml b/examples/mysql/multi-consumer/consumer-job.yaml new file mode 100644 index 0000000..36c8253 --- /dev/null +++ b/examples/mysql/multi-consumer/consumer-job.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: shared-roles +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: roles + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: DB_HOST + valueFrom: { secretKeyRef: { name: shared-rw, key: host } } + - name: DB_PORT + valueFrom: { secretKeyRef: { name: shared-rw, key: port } } + - name: DB_NAME + valueFrom: { secretKeyRef: { name: shared-rw, key: database } } + - name: RW_USER + valueFrom: { secretKeyRef: { name: shared-rw, key: username } } + - name: RW_PASSWORD + valueFrom: { secretKeyRef: { name: shared-rw, key: password } } + - name: RO_USER + valueFrom: { secretKeyRef: { name: shared-reader, key: username } } + - name: RO_PASSWORD + valueFrom: { secretKeyRef: { name: shared-reader, key: password } } + command: [bash, -ec] + args: + - | + rw() { MYSQL_PWD="$RW_PASSWORD" mariadb -h"$DB_HOST" -P"$DB_PORT" -u"$RW_USER" -N -B "$DB_NAME" -e "$1"; } + ro() { MYSQL_PWD="$RO_PASSWORD" mariadb -h"$DB_HOST" -P"$DB_PORT" -u"$RO_USER" -N -B "$DB_NAME" -e "$1"; } + rw "CREATE TABLE IF NOT EXISTS t (id INT PRIMARY KEY)" + rw "REPLACE INTO t VALUES (1)" + [ "$(ro 'SELECT COUNT(*) FROM t')" = 1 ] + if ro "INSERT INTO t VALUES (2)" 2>/tmp/err; then echo "Reader could INSERT" >&2; exit 1; fi + grep -q "command denied" /tmp/err + if ro "CREATE TABLE t2 (id INT)" 2>/tmp/err; then echo "Reader could CREATE" >&2; exit 1; fi + grep -q "command denied" /tmp/err + echo "roles enforced" diff --git a/examples/mysql/multi-consumer/kustomization.yaml b/examples/mysql/multi-consumer/kustomization.yaml new file mode 100644 index 0000000..f5cb454 --- /dev/null +++ b/examples/mysql/multi-consumer/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml +- access.yaml +- consumer-job.yaml diff --git a/examples/mysql/oob-drift/README.md b/examples/mysql/oob-drift/README.md new file mode 100644 index 0000000..8029772 --- /dev/null +++ b/examples/mysql/oob-drift/README.md @@ -0,0 +1,19 @@ +# mysql / oob-drift + +**Scenario:** SPEC.md §End-to-end coverage #4 - out-of-band +changes, for the `mysql` driver. Each change is made as root on the +server; the controller repairs it on a periodic re-check, or reports +it when repairing would hide something. + +1. `DROP USER` of the ReadWrite user: recreated with the password + in its Secret, so consumers need no restart. +2. `GRANT INSERT ... WITH GRANT OPTION` to the Reader: revoked, + `GRANT OPTION` included. +3. `DROP DATABASE`: recreated, empty, and the user can use it. +4. `ALTER DATABASE ... CHARACTER SET latin1`: `ParameterDrift` + with `Ready=False`; the driver does not convert the database. + Restoring the character set and annotating the `Buckety` (drift + pauses re-checks until the resource changes) clears it. +5. Deleting the access Secret rotates the password: a new one is + generated, set on the server and written to a new Secret. +6. A password written into the Secret is applied to the server. diff --git a/examples/mysql/oob-drift/assert.sh b/examples/mysql/oob-drift/assert.sh new file mode 100755 index 0000000..a29838d --- /dev/null +++ b/examples/mysql/oob-drift/assert.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# The e2e controller re-checks every 15s (--periodic-recheck), so +# every repair below is expected within a few re-checks. + +wait_ready buckety/drift 120s +wait_ready bucketyaccess/drift-rw 120s +wait_ready bucketyaccess/drift-reader 120s +db="$(secret_value drift-db database)" +rw="$(secret_value drift-db username)" +ro="$(secret_value drift-reader-db username)" +mysql_login drift-db "CREATE TABLE t (id INT PRIMARY KEY); INSERT INTO t VALUES (1)" \ + || fail "the ReadWrite user cannot create a table" + +# A user dropped out of band is recreated with the Secret's password. +log "out-of-band: DROP USER $rw" +mysql_root "DROP USER '$rw'@'%'" +if mysql_login drift-db "SELECT 1" 2>/dev/null; then + fail "login still works after DROP USER" +fi +wait_until 90 "$rw recreated with the Secret's password" \ + mysql_login drift-db "SELECT COUNT(*) FROM t" + +# Privileges granted out of band are revoked, GRANT OPTION too. +pattern="${db//_/\\_}" +log "out-of-band: GRANT INSERT ... WITH GRANT OPTION to $ro" +mysql_root "GRANT INSERT ON \`$pattern\`.* TO '$ro'@'%' WITH GRANT OPTION" +reader_is_reader() { + local g + g="$(mysql_root "SHOW GRANTS FOR '$ro'@'%'")" + ! grep -qE 'INSERT|GRANT OPTION' <<<"$g" && grep -q 'GRANT SELECT ON' <<<"$g" +} +wait_until 90 "INSERT and GRANT OPTION revoked from $ro" reader_is_reader + +# A database dropped out of band is recreated (empty). +log "out-of-band: DROP DATABASE $db" +mysql_root "DROP DATABASE \`$db\`" +wait_until 90 "database $db recreated" mysql_database_exists "$db" +mysql_login drift-db "CREATE TABLE t (id INT PRIMARY KEY)" \ + || fail "the ReadWrite user cannot use the recreated database" + +# A changed character set is reported, never converted. +log "out-of-band: ALTER DATABASE $db CHARACTER SET latin1" +mysql_root "ALTER DATABASE \`$db\` CHARACTER SET latin1 COLLATE latin1_swedish_ci" +wait_condition buckety/drift ParameterDrift True 90s +[[ "$(condition_status buckety/drift Ready)" == "False" ]] \ + || fail "Ready should be False under ParameterDrift" +[[ "$(mysql_root "SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$db'")" == latin1 ]] \ + || fail "the controller altered the database's character set" +# ParameterDrift pauses re-checks until the Buckety changes: the +# documented resolution is to fix it out of band, then touch it. +mysql_root "ALTER DATABASE \`$db\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" +kc annotate buckety/drift e2e.buckety.yolean.se/recheck="$(date +%s)" --overwrite +wait_ready buckety/drift 90s + +# Deleting the Secret rotates the password. +before="$(secret_value drift-db password)" +log "deleting Secret/drift-db to rotate the password" +kc delete secret/drift-db +rotated() { + kc get secret/drift-db >/dev/null 2>&1 \ + && [[ "$(secret_value drift-db password)" != "$before" ]] \ + && mysql_login drift-db "SELECT 1" +} +wait_until 90 "a new password in Secret/drift-db that logs in" rotated + +# A password written into the Secret is applied to the server. +pw="$(head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')" +patch="$(mktemp)" +trap 'rm -f "$patch"' EXIT +printf '{"stringData":{"password":"%s"}}' "$pw" >"$patch" +log "writing a new password into Secret/drift-db" +kc patch secret/drift-db --type=merge --patch-file="$patch" +wait_until 90 "the written password logs in" mysql_login drift-db "SELECT 1" + +log "oob-drift PASS" diff --git a/examples/mysql/oob-drift/buckety.yaml b/examples/mysql/oob-drift/buckety.yaml new file mode 100644 index 0000000..b7f45ca --- /dev/null +++ b/examples/mysql/oob-drift/buckety.yaml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: drift +spec: + backend: mysql + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Delete +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/bucketyaccess.schema.json +# Explicit accesses: one would replace an implicit defaultAccess. +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: drift-rw +spec: + bucketyRef: + name: drift + credentialsSecretName: drift-db + role: ReadWrite +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/bucketyaccess.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: drift-reader +spec: + bucketyRef: + name: drift + credentialsSecretName: drift-reader-db + role: Reader diff --git a/examples/mysql/oob-drift/kustomization.yaml b/examples/mysql/oob-drift/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/oob-drift/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/parameter-mutation/README.md b/examples/mysql/parameter-mutation/README.md new file mode 100644 index 0000000..3270445 --- /dev/null +++ b/examples/mysql/parameter-mutation/README.md @@ -0,0 +1,22 @@ +# mysql / parameter-mutation + +**Scenario:** SPEC.md §End-to-end coverage, parameter validation, +for the `mysql` driver. Unlike bucket parameters, `characterSet` +and `collation` are set at creation and immutable: changing a +database's default does not convert its tables, so the driver +refuses to pretend it did. + +The admission webhook refuses: + +- a changed `characterSet`, or a removed `collation` (immutable); +- an unknown parameter key; +- `characterSet: utf8` on a new `Buckety` (ambiguous: utf8mb3 now, + utf8mb4 later); +- a `Buckety` whose resolved name lacks the backend's `namePrefix`. + +`BucketyAccess` parameters (the mysql driver accepts none) are +refused by the reconciler: `Ready=False`, reason +`InvalidParameters`, no Secret. + +The database keeps the character set and collation it was created +with throughout. diff --git a/examples/mysql/parameter-mutation/assert.sh b/examples/mysql/parameter-mutation/assert.sh new file mode 100755 index 0000000..65f86c0 --- /dev/null +++ b/examples/mysql/parameter-mutation/assert.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/shape 120s +db="$(secret_value shape-db database)" +[[ "$(mysql_root "SELECT CONCAT(DEFAULT_CHARACTER_SET_NAME, ' ', DEFAULT_COLLATION_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$db'")" == "utf8mb4 utf8mb4_unicode_ci" ]] \ + || fail "database $db was not created with the requested character set and collation" + +err="$(mktemp)" +trap 'rm -f "$err"' EXIT + +# expect_refused +expect_refused() { + local what="$1" regex="$2"; shift 2 + log "expecting admission to refuse: $what" + if "$@" 2>"$err"; then + fail "admission accepted $what" + fi + grep -qE "$regex" "$err" || fail "refusal of $what should match /$regex/: $(cat "$err")" +} + +expect_refused "a characterSet change (immutable)" "immutable" \ + kc patch buckety/shape --type=merge -p '{"spec":{"parameters":{"characterSet":"latin1"}}}' +expect_refused "removing collation (immutable)" "immutable" \ + kc patch buckety/shape --type=json -p '[{"op":"remove","path":"/spec/parameters/collation"}]' +expect_refused "an unknown parameter" "unknownKey" \ + kc patch buckety/shape --type=merge -p '{"spec":{"parameters":{"unknownKey":"x"}}}' + +# New resources: the ambiguous utf8, and a name outside namePrefix. +expect_refused "characterSet utf8" "ambiguous" kc apply -f - <<'YAML' +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shape-utf8 +spec: + backend: mysql + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8 +YAML +expect_refused "a database name outside namePrefix" "namePrefix" kc apply -f - <<'YAML' +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shape-noprefix +spec: + backend: mysql +YAML +# BucketyAccess parameters are checked by the reconciler, which can +# resolve the driver (admission may run before the Buckety exists). +kc apply -f - <<'YAML' +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shape-params +spec: + bucketyRef: + name: shape + credentialsSecretName: shape-params + parameters: + host: "%" +YAML +wait_ready_reason bucketyaccess/shape-params InvalidParameters 60 +if kc get secret/shape-params >/dev/null 2>&1; then + fail "a Secret was minted for an access with parameters" +fi +kc delete bucketyaccess/shape-params --wait=true --timeout=60s + +# Nothing was changed on the server by the refused updates. +[[ "$(mysql_root "SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$db'")" == utf8mb4 ]] \ + || fail "database $db character set changed" +wait_ready buckety/shape 30s + +log "parameter-mutation PASS" diff --git a/examples/mysql/parameter-mutation/buckety.yaml b/examples/mysql/parameter-mutation/buckety.yaml new file mode 100644 index 0000000..c3e3236 --- /dev/null +++ b/examples/mysql/parameter-mutation/buckety.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shape +spec: + backend: mysql + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: shape-db diff --git a/examples/mysql/parameter-mutation/kustomization.yaml b/examples/mysql/parameter-mutation/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/parameter-mutation/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/retention-policy/README.md b/examples/mysql/retention-policy/README.md new file mode 100644 index 0000000..1a2878f --- /dev/null +++ b/examples/mysql/retention-policy/README.md @@ -0,0 +1,12 @@ +# mysql / retention-policy + +**Scenario:** SPEC.md §End-to-end coverage, retention, for the +`mysql` driver. + +- `keep-me` (`Retain`): deleting the `Buckety` keeps the database + and its rows; its user is dropped with the implicit access, so a + retained database keeps no credentials. +- `drop-me` (`Delete`): the database is dropped with its tables, + and its user with it. + +assert.sh drops the retained database at the end. diff --git a/examples/mysql/retention-policy/assert.sh b/examples/mysql/retention-policy/assert.sh new file mode 100755 index 0000000..5e65851 --- /dev/null +++ b/examples/mysql/retention-policy/assert.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/keep-me 120s +wait_ready buckety/drop-me 120s +keep_db="$(secret_value keep-me-db database)" +drop_db="$(secret_value drop-me-db database)" +keep_user="$(secret_value keep-me-db username)" +drop_user="$(secret_value drop-me-db username)" +for s in keep-me-db drop-me-db; do + mysql_login "$s" "CREATE TABLE t (id INT PRIMARY KEY); INSERT INTO t VALUES (1)" \ + || fail "$s: cannot write" +done + +user_count() { mysql_root "SELECT COUNT(*) FROM mysql.user WHERE User = '$1'"; } + +# Retain: the database and its data survive; the user does not. +log "deleting Buckety/keep-me (Retain)" +kc delete buckety/keep-me --wait=true --timeout=90s +resource_absent secret/keep-me-db 30s +mysql_database_exists "$keep_db" || fail "retained database $keep_db is gone" +[[ "$(mysql_root "SELECT COUNT(*) FROM \`$keep_db\`.t")" == 1 ]] \ + || fail "retained database $keep_db lost its rows" +[[ "$(user_count "$keep_user")" == 0 ]] \ + || fail "user $keep_user survived its access; a retained database keeps no credentials" + +# Delete: the database goes, with its tables, and the user. +log "deleting Buckety/drop-me (Delete)" +kc delete buckety/drop-me --wait=true --timeout=90s +resource_absent secret/drop-me-db 30s +if mysql_database_exists "$drop_db"; then + fail "database $drop_db still present after retentionPolicy=Delete" +fi +[[ "$(user_count "$drop_user")" == 0 ]] || fail "user $drop_user survived its access" + +# The retained database is this scenario's to clean up. +mysql_root "DROP DATABASE \`$keep_db\`" + +log "retention-policy PASS" diff --git a/examples/mysql/retention-policy/buckety.yaml b/examples/mysql/retention-policy/buckety.yaml new file mode 100644 index 0000000..6896e7d --- /dev/null +++ b/examples/mysql/retention-policy/buckety.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: keep-me +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Retain + defaultAccess: + role: ReadWrite + credentialsSecretName: keep-me-db +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: drop-me +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: drop-me-db diff --git a/examples/mysql/retention-policy/kustomization.yaml b/examples/mysql/retention-policy/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/retention-policy/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/scaled-to-zero/README.md b/examples/mysql/scaled-to-zero/README.md new file mode 100644 index 0000000..e048aa8 --- /dev/null +++ b/examples/mysql/scaled-to-zero/README.md @@ -0,0 +1,6 @@ +# mysql operator scaled to zero + +SPEC scenario 5 for the mysql driver: after the Secret is minted +the controller is scaled to zero, and a consumer Job still writes +and reads a row using only the Secret. The operator is off the +data path. diff --git a/examples/mysql/scaled-to-zero/assert.sh b/examples/mysql/scaled-to-zero/assert.sh new file mode 100755 index 0000000..1303230 --- /dev/null +++ b/examples/mysql/scaled-to-zero/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +scaled_to_zero_scenario dial-tone dial-tone-db "$(dirname "$0")/consumer-job.yaml" dial-tone-roundtrip host port database username password jdbcUrl url diff --git a/examples/mysql/scaled-to-zero/buckety.yaml b/examples/mysql/scaled-to-zero/buckety.yaml new file mode 100644 index 0000000..fdfe0e2 --- /dev/null +++ b/examples/mysql/scaled-to-zero/buckety.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: dial-tone +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: dial-tone-db diff --git a/examples/mysql/scaled-to-zero/consumer-job.yaml b/examples/mysql/scaled-to-zero/consumer-job.yaml new file mode 100644 index 0000000..8b1c5ef --- /dev/null +++ b/examples/mysql/scaled-to-zero/consumer-job.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: dial-tone-roundtrip +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: roundtrip + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: DB_HOST + valueFrom: { secretKeyRef: { name: dial-tone-db, key: host } } + - name: DB_PORT + valueFrom: { secretKeyRef: { name: dial-tone-db, key: port } } + - name: DB_NAME + valueFrom: { secretKeyRef: { name: dial-tone-db, key: database } } + - name: DB_USER + valueFrom: { secretKeyRef: { name: dial-tone-db, key: username } } + # The client reads MYSQL_PWD, keeping the password off the + # command line. + - name: MYSQL_PWD + valueFrom: { secretKeyRef: { name: dial-tone-db, key: password } } + command: [bash, -ec] + args: + - | + sql() { mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --batch --skip-column-names "$DB_NAME" -e "$1"; } + sql "CREATE TABLE IF NOT EXISTS probe (id INT PRIMARY KEY, msg VARCHAR(40))" + sql "ALTER TABLE probe ADD COLUMN IF NOT EXISTS at TIMESTAMP NULL" + sql "REPLACE INTO probe (id, msg) VALUES (1, 'hello-buckety')" + [ "$(sql 'SELECT msg FROM probe WHERE id = 1')" = hello-buckety ] diff --git a/examples/mysql/scaled-to-zero/kustomization.yaml b/examples/mysql/scaled-to-zero/kustomization.yaml new file mode 100644 index 0000000..4c65358 --- /dev/null +++ b/examples/mysql/scaled-to-zero/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# The consumer Job is applied separately by assert.sh after the +# operator is scaled to zero; only the Buckety is provisioned +# upfront here. +resources: +- buckety.yaml diff --git a/go.mod b/go.mod index f5876d8..1e38991 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/Yolean/buckety-controller -go 1.26.1 +go 1.27.0 -toolchain go1.26.5 +toolchain go1.27.1 require ( cloud.google.com/go/storage v1.63.1 @@ -11,6 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.24 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 github.com/aws/smithy-go v1.27.2 + github.com/go-sql-driver/mysql v1.10.1 github.com/twmb/franz-go v1.21.2 github.com/twmb/franz-go/pkg/kadm v1.18.0 github.com/twmb/franz-go/pkg/kmsg v1.13.1 @@ -32,6 +33,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect diff --git a/go.sum b/go.sum index aba69dc..7dded0b 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ cloud.google.com/go/storage v1.63.1 h1:CYXILV9G4CH0C18IQ9+V0h4XiqD2LhKnMLO0o7uJW cloud.google.com/go/storage v1.63.1/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= @@ -104,6 +106,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/pkg/config/schema/buckety-controller.schema.json b/pkg/config/schema/buckety-controller.schema.json index 5c1915e..083978e 100644 --- a/pkg/config/schema/buckety-controller.schema.json +++ b/pkg/config/schema/buckety-controller.schema.json @@ -21,8 +21,8 @@ }, "driver": { "type": "string", - "enum": ["kadm", "s3", "gcs"], - "description": "Registered driver name. The compiled-in set for this repo's binary is kadm, s3 and gcs." + "enum": ["kadm", "s3", "gcs", "mysql"], + "description": "Registered driver name. The compiled-in set for this repo's binary is kadm, s3, gcs and mysql." }, "config": { "type": "object", diff --git a/pkg/controller/bucketyaccess/reconciler.go b/pkg/controller/bucketyaccess/reconciler.go index 3863eca..4e74d6a 100644 --- a/pkg/controller/bucketyaccess/reconciler.go +++ b/pkg/controller/bucketyaccess/reconciler.go @@ -275,6 +275,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu res, err := backend.Driver.GrantAccess(ctx, registry.GrantRequest{ BucketyName: bky.Status.BackendResourceName, + AccessNamespace: access.Namespace, + AccessName: access.Name, Role: string(access.Spec.Role), Parameters: access.Spec.Parameters, BucketyParameters: bkyParams, diff --git a/pkg/controller/bucketyaccess/reconciler_test.go b/pkg/controller/bucketyaccess/reconciler_test.go index 258a164..6c6a20f 100644 --- a/pkg/controller/bucketyaccess/reconciler_test.go +++ b/pkg/controller/bucketyaccess/reconciler_test.go @@ -206,6 +206,9 @@ func TestGrantGatedOnSecretAndFedExistingData(t *testing.T) { if rec.req.BucketyName != "t1-orders" { t.Errorf("BucketyName: %q", rec.req.BucketyName) } + if rec.req.AccessNamespace != "t1" || rec.req.AccessName != "reader" { + t.Errorf("access identity: %q/%q", rec.req.AccessNamespace, rec.req.AccessName) + } var secret corev1.Secret if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "t1", Name: "reader-creds"}, &secret); err != nil { t.Fatalf("minted secret: %v", err) diff --git a/pkg/drivers/gcs/driver_test.go b/pkg/drivers/gcs/driver_test.go index 42da236..5b016d8 100644 --- a/pkg/drivers/gcs/driver_test.go +++ b/pkg/drivers/gcs/driver_test.go @@ -148,9 +148,8 @@ func TestParseLifecycle(t *testing.T) { t.Errorf("rule[1].numNewerVersions: %d", r1.Condition.NumNewerVersions) } - // The blobs-per-org data plane's exact shape (checkit - // cluster-g2/buckety-controller/GCS_DRIVER_REQUIREMENTS_FROM_BLOBS.md - // must-have 3): multiple concurrent matchesPrefix rules. + // A data plane that expires each prefix on its own schedule: + // multiple concurrent matchesPrefix rules. blobs := `{"rule": [ {"action": {"type": "Delete"}, "condition": {"age": 7, "matchesPrefix": ["board-prints/"]}}, {"action": {"type": "Delete"}, "condition": {"age": 1, "matchesPrefix": [".staging/"]}} diff --git a/pkg/drivers/mysql/config.go b/pkg/drivers/mysql/config.go new file mode 100644 index 0000000..d966e31 --- /dev/null +++ b/pkg/drivers/mysql/config.go @@ -0,0 +1,164 @@ +package mysql + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + gomysql "github.com/go-sql-driver/mysql" + + "github.com/Yolean/y-cluster/pkg/envsubst" + yaml "sigs.k8s.io/yaml" +) + +// Config defaults, applied when the key is absent. +const ( + defaultPort = 3306 + defaultNamePrefix = "b_" + defaultUserHost = "%" +) + +// Config is the typed shape of the `config:` block under a mysql +// backend. Mirrors schema/v0.1/config.schema.json. +type Config struct { + // Host is the server address, used by the controller and + // written to access Secrets. + Host string `json:"host"` + // Port defaults to 3306. + Port int `json:"port,omitempty"` + // AdminUser / AdminPassword are the controller's account; see + // docs/mysql.md for the grants it needs. + AdminUser string `json:"adminUser" envsubst:"true"` + AdminPassword string `json:"adminPassword" envsubst:"true"` + // NamePrefix scopes every database and user the driver touches. + // Defaults to "b_". + NamePrefix string `json:"namePrefix,omitempty"` + // UserHost is the host part of created accounts. Defaults to "%". + UserHost string `json:"userHost,omitempty"` + // TLS, when present, requires verified TLS for the controller's + // connections (admin and login checks). + TLS *TLSConfig `json:"tls,omitempty"` +} + +// TLSConfig enables verified TLS towards the server. +type TLSConfig struct { + // CAFile is a PEM bundle to verify the server with; system + // roots when empty. + CAFile string `json:"caFile,omitempty"` + // ServerName overrides the name verified in the server + // certificate; defaults to Host. + ServerName string `json:"serverName,omitempty"` +} + +func decodeConfig(raw json.RawMessage) (*Config, error) { + var c Config + if err := yaml.UnmarshalStrict(raw, &c); err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + if err := envsubst.Apply(&c, envsubst.OSEnv); err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + if c.Port == 0 { + c.Port = defaultPort + } + if c.NamePrefix == "" { + c.NamePrefix = defaultNamePrefix + } + if c.UserHost == "" { + c.UserHost = defaultUserHost + } + if err := c.validate(); err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + return &c, nil +} + +func (c *Config) validate() error { + switch { + case c.Host == "": + return fmt.Errorf("missing required field %q", "host") + case strings.ContainsAny(c.Host, "/@?# \t"), + strings.Contains(c.Host, ":") && net.ParseIP(c.Host) == nil: + return fmt.Errorf("host %q must be a bare host name or IP, without scheme, port or path", c.Host) + case c.Port < 1 || c.Port > 65535: + return fmt.Errorf("port %d is out of range", c.Port) + case c.AdminUser == "": + return fmt.Errorf("missing required field %q", "adminUser") + case c.AdminPassword == "": + return fmt.Errorf("missing required field %q", "adminPassword") + case strings.ContainsAny(c.AdminPassword, "\r\n"): + // The usual cause is a Secret created from a file that ends + // in a newline; the bootstrap script refuses those too. + return fmt.Errorf("adminPassword contains a line break; recreate its Secret without a trailing newline") + } + if err := checkPrefix(c.NamePrefix); err != nil { + return err + } + if strings.HasPrefix(c.AdminUser, c.NamePrefix) { + return fmt.Errorf("adminUser %q starts with namePrefix %q; the controller's own account must be outside the names it manages", c.AdminUser, c.NamePrefix) + } + if err := checkHost(c.UserHost); err != nil { + return fmt.Errorf("userHost: %w", err) + } + return nil +} + +// Connection timeouts. Reads are generous because DROP DATABASE on +// a large database returns only when the files are gone. +const ( + dialTimeout = 10 * time.Second + ioTimeout = 60 * time.Second + lockWaitTimeout = 50 * time.Second +) + +// clientConfig builds the go-sql-driver config for the admin +// account. Logins as created users clone it. InterpolateParams +// makes the client escape ? arguments into string literals, which +// is what lets account names and passwords travel as arguments in +// statements (CREATE USER, GRANT) that the server-side prepared +// statement protocol does not parameterise. +func (c *Config) clientConfig() (*gomysql.Config, error) { + mc := gomysql.NewConfig() + mc.User = c.AdminUser + mc.Passwd = c.AdminPassword + mc.Net = "tcp" + mc.Addr = net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) + mc.Timeout = dialTimeout + mc.ReadTimeout = ioTimeout + mc.WriteTimeout = ioTimeout + mc.InterpolateParams = true + mc.Collation = "utf8mb4_general_ci" + // The server gives up on a metadata lock before the client + // gives up on the reply. DROP DATABASE waits for open + // transactions on the database (consumer sessions outlive + // DROP USER), by default for a day on MariaDB and a year on + // MySQL; without this, each timed-out retry would queue + // another DROP behind them, and the pending lock blocks new + // queries on that database. + mc.Params = map[string]string{"lock_wait_timeout": strconv.Itoa(int(lockWaitTimeout / time.Second))} + if c.TLS != nil { + tc := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.TLS.ServerName} + if tc.ServerName == "" { + tc.ServerName = c.Host + } + if c.TLS.CAFile != "" { + pem, err := os.ReadFile(c.TLS.CAFile) + if err != nil { + return nil, fmt.Errorf("tls.caFile: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("tls.caFile %q holds no PEM certificates", c.TLS.CAFile) + } + tc.RootCAs = pool + } + mc.TLS = tc + } + return mc, nil +} diff --git a/pkg/drivers/mysql/config_test.go b/pkg/drivers/mysql/config_test.go new file mode 100644 index 0000000..42b1ff5 --- /dev/null +++ b/pkg/drivers/mysql/config_test.go @@ -0,0 +1,206 @@ +package mysql + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" + yaml "sigs.k8s.io/yaml" +) + +// testAdminPassword stands in for the env-substituted secret; the +// tests assert it never shows up in an error. +const testAdminPassword = "Admin-Pw-From-Env-0123" + +func rawConfig(t *testing.T, y string) json.RawMessage { + t.Helper() + j, err := yaml.YAMLToJSON([]byte(y)) + if err != nil { + t.Fatal(err) + } + return j +} + +func TestDecodeConfigDefaults(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + c, err := decodeConfig(rawConfig(t, ` +host: mysql.mysql.svc.cluster.local +adminUser: buckety +adminPassword: ${MYSQL_BUCKETY_PASSWORD} +`)) + if err != nil { + t.Fatal(err) + } + if c.Port != 3306 || c.NamePrefix != "b_" || c.UserHost != "%" || c.TLS != nil { + t.Errorf("defaults: %+v", *c) + } + if c.AdminPassword != testAdminPassword { + t.Error("adminPassword not substituted from the environment") + } +} + +func TestDecodeConfigExplicit(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + t.Setenv("MYSQL_BUCKETY_USER", "buckety") + c, err := decodeConfig(rawConfig(t, ` +host: 10.0.0.5 +port: 3307 +adminUser: ${MYSQL_BUCKETY_USER} +adminPassword: ${MYSQL_BUCKETY_PASSWORD} +namePrefix: app_ +userHost: 10.42.% +`)) + if err != nil { + t.Fatal(err) + } + if c.Port != 3307 || c.NamePrefix != "app_" || c.UserHost != "10.42.%" || c.AdminUser != "buckety" { + t.Errorf("explicit values: %+v", *c) + } +} + +func TestDecodeConfigErrors(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + base := "host: db\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n" + cases := map[string]string{ + "unknown field": base + "adminPasword: x\n", + "missing host": "adminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "missing adminUser": "host: db\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "missing adminPassword": "host: db\nadminUser: buckety\n", + "undefined variable": "host: db\nadminUser: buckety\nadminPassword: ${NOT_SET_ANYWHERE_42}\n", + "untagged substitution": "host: ${MYSQL_BUCKETY_PASSWORD}\nadminUser: buckety\nadminPassword: x\n", + "host with path": "host: db/x\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "host with scheme": "host: mysql://db\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "host with port": "host: db:3306\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "port range": base + "port: 70000\n", + "prefix without _": base + "namePrefix: b\n", + "empty-ish prefix": base + "namePrefix: _\n", + "system prefix": base + "namePrefix: performance_\n", + "admin inside prefix": "host: db\nadminUser: b_admin\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "bad userHost": base + "userHost: \"%' OR 1\"\n", + "uppercase userHost": base + "userHost: DB.example\n", + "tls unknown field": base + "tls:\n insecure: true\n", + } + for name, y := range cases { + _, err := decodeConfig(rawConfig(t, y)) + if err == nil { + t.Errorf("%s: accepted", name) + continue + } + if strings.Contains(err.Error(), testAdminPassword) { + t.Errorf("%s: error contains the admin password", name) + } + } +} + +func TestAdminPasswordLineBreak(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword+"\n") + _, err := decodeConfig(rawConfig(t, "host: db\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n")) + if err == nil || !strings.Contains(err.Error(), "line break") { + t.Fatalf("trailing newline accepted: %v", err) + } + if strings.Contains(err.Error(), testAdminPassword) { + t.Fatal("error contains the admin password") + } +} + +func TestFactoryRegisteredAndLazy(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + f, ok := registry.Lookup(DriverName) + if !ok { + t.Fatal("mysql driver not registered") + } + // No server at this address: the factory must not connect. + d, err := f(rawConfig(t, "host: 127.0.0.1\nport: 1\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n")) + if err != nil { + t.Fatalf("factory: %v", err) + } + if d.Name() != "mysql" || d.Version() == "" { + t.Fatalf("name/version: %q %q", d.Name(), d.Version()) + } +} + +func TestClientConfig(t *testing.T) { + c := &Config{Host: "db.example", Port: 3306, AdminUser: "buckety", AdminPassword: testAdminPassword, NamePrefix: "b_", UserHost: "%"} + mc, err := c.clientConfig() + if err != nil { + t.Fatal(err) + } + if mc.Addr != "db.example:3306" || !mc.InterpolateParams || mc.MultiStatements || mc.AllowAllFiles || mc.TLS != nil || mc.AllowCleartextPasswords { + t.Errorf("client config: addr=%s interpolate=%v multi=%v files=%v tls=%v cleartext=%v", + mc.Addr, mc.InterpolateParams, mc.MultiStatements, mc.AllowAllFiles, mc.TLS != nil, mc.AllowCleartextPasswords) + } + c.Host = "fd00::1" + if mc, _ := c.clientConfig(); mc.Addr != "[fd00::1]:3306" { + t.Errorf("IPv6 addr = %s", mc.Addr) + } + if mc.Params["lock_wait_timeout"] != "50" || lockWaitTimeout >= ioTimeout { + t.Errorf("lock_wait_timeout %q must be set and below the read timeout %s", mc.Params["lock_wait_timeout"], ioTimeout) + } +} + +func TestClientConfigTLS(t *testing.T) { + dir := t.TempDir() + ca := filepath.Join(dir, "ca.crt") + writeTestCA(t, ca) + c := &Config{Host: "db.example", Port: 3306, AdminUser: "buckety", AdminPassword: "x", TLS: &TLSConfig{CAFile: ca}} + mc, err := c.clientConfig() + if err != nil { + t.Fatal(err) + } + if mc.TLS == nil || mc.TLS.RootCAs == nil || mc.TLS.ServerName != "db.example" || mc.TLS.InsecureSkipVerify { + t.Fatalf("tls config: %+v", mc.TLS) + } + c.TLS.ServerName = "mariadb.mysql.svc" + if mc, _ := c.clientConfig(); mc.TLS.ServerName != "mariadb.mysql.svc" { + t.Errorf("serverName override: %s", mc.TLS.ServerName) + } + + c.TLS = &TLSConfig{CAFile: filepath.Join(dir, "missing.crt")} + if _, err := c.clientConfig(); err == nil { + t.Error("missing caFile accepted") + } + notPEM := filepath.Join(dir, "not.pem") + if err := os.WriteFile(notPEM, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + c.TLS = &TLSConfig{CAFile: notPEM} + if _, err := c.clientConfig(); err == nil { + t.Error("caFile without certificates accepted") + } + // System roots when no caFile. + c.TLS = &TLSConfig{} + if mc, err := c.clientConfig(); err != nil || mc.TLS == nil || mc.TLS.RootCAs != nil { + t.Errorf("system roots: %v", err) + } +} + +func writeTestCA(t *testing.T, path string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/drivers/mysql/conn.go b/pkg/drivers/mysql/conn.go new file mode 100644 index 0000000..637aebf --- /dev/null +++ b/pkg/drivers/mysql/conn.go @@ -0,0 +1,142 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + gomysql "github.com/go-sql-driver/mysql" +) + +// MySQL/MariaDB error numbers the driver branches on. +const ( + erAccessDenied = 1045 // login refused: unknown user or wrong password + erCannotUser = 1396 // CREATE USER of an existing account, and similar + erNoSuchGrant = 1141 // REVOKE of a privilege the account does not hold (MySQL 8.4) +) + +var ( + // errLoginDenied is what conn.login returns for erAccessDenied. + errLoginDenied = errors.New("login denied") + // errShadowed: the login matched another account with the same + // user name and a more specific host, so the managed account + // could not be inspected. Seen while userHost changes. + errShadowed = errors.New("login matched another account") +) + +// schemaInfo is a database's defaults as SCHEMATA reports them. +type schemaInfo struct { + charset string + collation string +} + +// grants is what a created user holds on its database. +type grants struct { + privileges []string + grantOption bool +} + +// conn is the driver's view of the server; sqlConn implements it, +// tests substitute a fake. +type conn interface { + // exec runs one statement as the admin account. + exec(ctx context.Context, query string, args ...any) error + // lookupSchema returns nil when the database does not exist or + // is not visible to the admin account. + lookupSchema(ctx context.Context, name string) (*schemaInfo, error) + // countContent counts tables, views, routines and events. + countContent(ctx context.Context, name string) (int, error) + // login connects as acct and reads its grants on the escaped + // database pattern. errLoginDenied when the server refuses. + login(ctx context.Context, acct account, password, pattern string) (*grants, error) +} + +type sqlConn struct { + admin *sql.DB + // base is the admin client config; logins clone it. + base *gomysql.Config +} + +func newSQLConn(base *gomysql.Config) (*sqlConn, error) { + connector, err := gomysql.NewConnector(base) + if err != nil { + return nil, err + } + db := sql.OpenDB(connector) + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(2) + db.SetConnMaxLifetime(5 * time.Minute) + return &sqlConn{admin: db, base: base}, nil +} + +func (c *sqlConn) exec(ctx context.Context, query string, args ...any) error { + _, err := c.admin.ExecContext(ctx, query, args...) + return err +} + +func (c *sqlConn) lookupSchema(ctx context.Context, name string) (*schemaInfo, error) { + var s schemaInfo + err := c.admin.QueryRowContext(ctx, schemaSQL, name).Scan(&s.charset, &s.collation) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +func (c *sqlConn) countContent(ctx context.Context, name string) (int, error) { + var n int + err := c.admin.QueryRowContext(ctx, contentSQL, name, name, name).Scan(&n) + return n, err +} + +func (c *sqlConn) login(ctx context.Context, acct account, password, pattern string) (*grants, error) { + cfg := c.base.Clone() + cfg.User = acct.User + cfg.Passwd = password + cfg.DBName = "" + connector, err := gomysql.NewConnector(cfg) + if err != nil { + return nil, err + } + db := sql.OpenDB(connector) + defer db.Close() + db.SetMaxOpenConns(1) + + var current string + if err := db.QueryRowContext(ctx, currentUserSQL).Scan(¤t); err != nil { + if isMySQLError(err, erAccessDenied) { + return nil, errLoginDenied + } + return nil, err + } + if current != acct.String() { + return nil, fmt.Errorf("%w: logged in as %s instead of %s", errShadowed, current, acct) + } + rows, err := db.QueryContext(ctx, privilegesSQL, acct.grantee(), pattern) + if err != nil { + return nil, err + } + defer rows.Close() + g := &grants{} + for rows.Next() { + var priv, grantable string + if err := rows.Scan(&priv, &grantable); err != nil { + return nil, err + } + g.privileges = append(g.privileges, priv) + if grantable == "YES" { + g.grantOption = true + } + } + return g, rows.Err() +} + +func isMySQLError(err error, number uint16) bool { + var me *gomysql.MySQLError + return errors.As(err, &me) && me.Number == number +} diff --git a/pkg/drivers/mysql/driver.go b/pkg/drivers/mysql/driver.go new file mode 100644 index 0000000..7fb62df --- /dev/null +++ b/pkg/drivers/mysql/driver.go @@ -0,0 +1,463 @@ +// Package mysql is the MariaDB/MySQL driver: a Buckety is a +// database, a BucketyAccess is a user with grants on that one +// database. +// +// The controller's account holds CREATE USER globally (MariaDB and +// MySQL cannot scope it by name) plus ALL on the prefix pattern. +// The driver therefore treats the backend's namePrefix as its +// security boundary: every database and account name passes +// checkManaged before it reaches SQL, and anything outside the +// prefix is refused (names.go). docs/mysql.md has the account +// setup, the naming rules and the decisions behind them. +// +// Credentials: each access gets its own user with a generated +// password, written to its Secret. GrantAccess reuses the Secret's +// password, so a password changed in the Secret is applied to the +// server, and deleting the Secret rotates it. Drift is detected by +// logging in as the user: a refused login means the user is +// missing or its password differs (both repaired by +// CREATE/ALTER USER), and a successful one reads the user's own +// grants, which the admin account cannot see without SELECT on the +// mysql schema. +package mysql + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" +) + +// DriverName matches backends[].driver in buckety-controller.yaml. +const DriverName = "mysql" + +// version is the driver SemVer. Injected at build time via +// +// -ldflags '-X github.com/Yolean/buckety-controller/pkg/drivers/mysql.version=0.1.0' +// +// per SPEC §Driver versioning. Default keeps tests building. +var version = "0.1.0" + +func init() { + registry.Register(DriverName, version, factory) +} + +func factory(raw json.RawMessage) (registry.Driver, error) { + cfg, err := decodeConfig(raw) + if err != nil { + return nil, err + } + return newDriver(cfg) +} + +// newDriver connects lazily: a server that is down at controller +// start fails reconciles, not startup. +func newDriver(cfg *Config) (*Driver, error) { + mc, err := cfg.clientConfig() + if err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + c, err := newSQLConn(mc) + if err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + return &Driver{cfg: cfg, conn: c}, nil +} + +// Driver implements registry.Driver for MariaDB/MySQL. +type Driver struct { + cfg *Config + conn conn +} + +func (d *Driver) Name() string { return DriverName } +func (d *Driver) Version() string { return version } + +// InspectBuckety reports whether the database exists and whether +// it holds tables, views, routines or events. +func (d *Driver) InspectBuckety(ctx context.Context, name string) (registry.Inspection, error) { + if err := d.checkDatabase(name); err != nil { + return registry.Inspection{}, fmt.Errorf("mysql: %w", err) + } + s, err := d.conn.lookupSchema(ctx, name) + if err != nil { + return registry.Inspection{}, fmt.Errorf("mysql: look up database %q: %w", name, err) + } + if s == nil { + return registry.Inspection{}, nil + } + n, err := d.conn.countContent(ctx, name) + if err != nil { + return registry.Inspection{}, fmt.Errorf("mysql: count objects in database %q: %w", name, err) + } + return registry.Inspection{Exists: true, Empty: n == 0}, nil +} + +// EnsureBuckety creates the database when missing and reports a +// character set or collation that differs from the parameters as +// ParameterDrift. The driver never alters an existing database's +// defaults: ALTER DATABASE changes them for new tables only, so +// converging silently would hide that existing tables still differ. +func (d *Driver) EnsureBuckety(ctx context.Context, req registry.EnsureRequest) error { + if err := d.checkDatabase(req.Name); err != nil { + return fmt.Errorf("mysql: %w", err) + } + if err := d.ValidateParameters(req.Parameters); err != nil { + return fmt.Errorf("mysql: %w", err) + } + charset, collation := req.Parameters["characterSet"], req.Parameters["collation"] + s, err := d.conn.lookupSchema(ctx, req.Name) + if err != nil { + return fmt.Errorf("mysql: look up database %q: %w", req.Name, err) + } + if s == nil { + if err := d.conn.exec(ctx, createDatabaseSQL(req.Name, charset, collation)); err != nil { + return fmt.Errorf("mysql: create database %q: %w", req.Name, err) + } + if s, err = d.conn.lookupSchema(ctx, req.Name); err != nil { + return fmt.Errorf("mysql: look up database %q: %w", req.Name, err) + } + if s == nil { + return fmt.Errorf("mysql: database %q exists but is not visible to the controller account; its grants must cover the namePrefix %q", req.Name, d.cfg.NamePrefix) + } + } + var drift []string + if charset != "" && !strings.EqualFold(charset, s.charset) { + drift = append(drift, fmt.Sprintf("characterSet: current=%q requested=%q", s.charset, charset)) + } + if collation != "" && !strings.EqualFold(collation, s.collation) { + drift = append(drift, fmt.Sprintf("collation: current=%q requested=%q", s.collation, collation)) + } + if len(drift) > 0 { + return ®istry.ErrParameterDrift{Reason: fmt.Sprintf( + "database %q %s; the driver does not change an existing database's defaults or convert its tables: ALTER DATABASE (and the tables) out of band and then annotate the Buckety to re-check it, or recreate the Buckety", + req.Name, strings.Join(drift, ", "))} + } + return nil +} + +// DeleteBuckety drops the database and everything in it. +// Idempotent; one statement, so there is no in-progress state. +func (d *Driver) DeleteBuckety(ctx context.Context, req registry.DeleteRequest) error { + if err := d.checkDatabase(req.Name); err != nil { + return fmt.Errorf("mysql: %w", err) + } + if err := d.conn.exec(ctx, dropDatabaseSQL(req.Name)); err != nil { + return fmt.Errorf("mysql: drop database %q: %w", req.Name, err) + } + return nil +} + +// GrantAccess ensures the access's user exists with the Secret's +// password (or a generated one when the Secret has none) and holds +// exactly the role's privileges on the database, then returns the +// Secret payload. +// +// Secret keys: host, port, database (resource-type key), username, +// password, jdbcUrl, url. +func (d *Driver) GrantAccess(ctx context.Context, req registry.GrantRequest) (registry.GrantResult, error) { + db := req.BucketyName + if err := d.checkDatabase(db); err != nil { + return registry.GrantResult{}, fmt.Errorf("mysql: %w", err) + } + user, err := d.userName(req.AccessNamespace, req.AccessName) + if err != nil { + return registry.GrantResult{}, err + } + acct := account{User: user, Host: d.cfg.UserHost} + + want, err := rolePrivileges(req.Role) + if err != nil { + // An access with no grant set holds nothing. Its user may + // exist from an earlier role, and refusing without dropping + // it would leave a downgrade to Writer with ReadWrite's + // grants and a working Secret. Changing the role back + // recreates the user with the Secret's password. + if derr := d.conn.exec(ctx, dropUserSQL, acct.User, acct.Host); derr != nil { + return registry.GrantResult{}, fmt.Errorf("%w; dropping the user %s it may hold from an earlier role failed: %v", err, acct, derr) + } + return registry.GrantResult{}, err + } + + password, fromSecret := string(req.ExistingSecretData["password"]), true + if password == "" { + fromSecret = false + if password, err = generatePassword(); err != nil { + return registry.GrantResult{}, err + } + } else if err := validatePassword(password); err != nil { + return registry.GrantResult{}, fmt.Errorf("mysql: the access Secret's password: %w", err) + } + + created, err := d.ensureAccount(ctx, acct, password, fromSecret, grantPattern(db), want) + if err != nil { + if created { + // Nothing records a user created in a failed pass: drop + // it, the next pass creates it again. Detached from ctx, + // which may be what failed. + cctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dialTimeout) + _ = d.conn.exec(cctx, dropUserSQL, acct.User, acct.Host) + cancel() + } + return registry.GrantResult{}, err + } + return registry.GrantResult{ + SecretData: d.secretData(db, user, password), + Principal: acct.String(), + Scoped: true, + Revocable: true, + Minted: created, + }, nil +} + +// Login verification after a password change: Galera applies +// account changes on the other nodes a moment after the statement +// returns on the one the controller wrote to. +const verifyAttempts = 5 + +var verifyBackoff = time.Second + +// ensureAccount converges acct and reports whether it created the +// user. fromSecret says the password is already stored in the +// access Secret; only then is a failed verification an error, +// because a generated password must reach the Secret first. +func (d *Driver) ensureAccount(ctx context.Context, acct account, password string, fromSecret bool, pattern string, want []string) (created bool, err error) { + if fromSecret { + have, err := d.conn.login(ctx, acct, password, pattern) + if err == nil { + return false, d.alignGrants(ctx, acct, pattern, want, have) + } + if !errors.Is(err, errLoginDenied) && !errors.Is(err, errShadowed) { + return false, fmt.Errorf("mysql: log in as %s to check it: %w", acct, err) + } + // Missing user, a password that differs from the Secret, or + // an account for another userHost matched instead. + } + if created, err = d.setPassword(ctx, acct, password); err != nil { + return false, err + } + // A user this pass created holds nothing yet; a re-keyed one + // holds what it held, which no login has read. + var have *grants + if created { + have = &grants{} + } + if err := d.alignGrants(ctx, acct, pattern, want, have); err != nil { + return created, err + } + if !fromSecret { + return created, nil + } + for attempt := 1; ; attempt++ { + have, err := d.conn.login(ctx, acct, password, pattern) + if err == nil { + return created, d.alignGrants(ctx, acct, pattern, want, have) + } + if errors.Is(err, errShadowed) { + // The account for the previous userHost is more specific + // and still exists; the reconciler drops it once this + // grant's principal is recorded, and the next pass + // verifies. + return created, nil + } + if !errors.Is(err, errLoginDenied) || attempt == verifyAttempts { + return created, fmt.Errorf("mysql: set the password of %s from its Secret, but the controller cannot log in with it (userHost %q must admit the controller's address): %w", acct, d.cfg.UserHost, err) + } + select { + case <-ctx.Done(): + return created, ctx.Err() + case <-time.After(verifyBackoff): + } + } +} + +// setPassword creates the user, or sets the password of an existing +// one. Errors are redacted: the statements carry the password. +func (d *Driver) setPassword(ctx context.Context, acct account, password string) (created bool, err error) { + err = d.conn.exec(ctx, createUserSQL, acct.User, acct.Host, password) + if err == nil { + return true, nil + } + if !isMySQLError(err, erCannotUser) { + return false, fmt.Errorf("mysql: create user %s: %w", acct, statementError(err, password)) + } + if err := d.conn.exec(ctx, alterUserSQL, acct.User, acct.Host, password); err != nil { + return false, fmt.Errorf("mysql: set password of %s: %w", acct, statementError(err, password)) + } + return false, nil +} + +// alignGrants grants what is missing from want and revokes what is +// beyond it, including GRANT OPTION. have is nil when the current +// grants are unknown (the user was re-keyed): then the whole set is +// granted and the rest of the managed set revoked one privilege at +// a time, and a later pass with a working login revokes anything +// else. +func (d *Driver) alignGrants(ctx context.Context, acct account, pattern string, want []string, have *grants) error { + if have == nil { + if err := d.grant(ctx, acct, pattern, want); err != nil { + return err + } + return d.revokeIfHeld(ctx, acct, pattern, without(readWritePrivileges, want)) + } + if err := d.grant(ctx, acct, pattern, without(want, have.privileges)); err != nil { + return err + } + if extra := without(have.privileges, want); len(extra) > 0 { + q, err := revokeSQL(extra, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: revoke %s from %s: %w", strings.Join(extra, ", "), acct, err) + } + } + if have.grantOption { + if err := d.conn.exec(ctx, revokeGrantOptionSQL(pattern), acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: revoke GRANT OPTION from %s: %w", acct, err) + } + } + return nil +} + +func (d *Driver) grant(ctx context.Context, acct account, pattern string, privileges []string) error { + if len(privileges) == 0 { + return nil + } + q, err := grantSQL(privileges, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: grant %s to %s: %w", strings.Join(privileges, ", "), acct, err) + } + return nil +} + +// revokeIfHeld revokes privileges that the account may or may not +// hold. One statement each: MySQL 8.4 refuses to revoke a privilege +// that is not held, and a REVOKE of several is atomic, so a single +// statement would also keep the ones that are. +func (d *Driver) revokeIfHeld(ctx context.Context, acct account, pattern string, privileges []string) error { + for _, p := range privileges { + q, err := revokeSQL([]string{p}, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil && !isMySQLError(err, erNoSuchGrant) { + return fmt.Errorf("mysql: revoke %s from %s: %w", p, acct, err) + } + } + return nil +} + +func (d *Driver) secretData(db, user, password string) map[string][]byte { + port := strconv.Itoa(d.cfg.Port) + hostPort := net.JoinHostPort(d.cfg.Host, port) + u := url.URL{Scheme: "mysql", User: url.UserPassword(user, password), Host: hostPort, Path: "/" + db} + return map[string][]byte{ + "host": []byte(d.cfg.Host), + "port": []byte(port), + "database": []byte(db), + "username": []byte(user), + "password": []byte(password), + "jdbcUrl": []byte("jdbc:mariadb://" + hostPort + "/" + db), + "url": []byte(u.String()), + } +} + +// RevokeAccess drops the access's user. Idempotent; an empty +// principal (never granted) has nothing to drop. +func (d *Driver) RevokeAccess(ctx context.Context, principal string) error { + if principal == "" { + return nil + } + acct, err := d.parsePrincipal(principal) + if err != nil { + return err + } + if err := d.conn.exec(ctx, dropUserSQL, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: drop user %s: %w", acct, err) + } + return nil +} + +var ( + charsetRE = regexp.MustCompile(`^[a-z][a-z0-9]{1,31}$`) + collationRE = regexp.MustCompile(`^[a-z][a-z0-9_]{1,63}$`) +) + +// ValidateParameters accepts characterSet and collation. "utf8" is +// refused: it means utf8mb3 today and is slated to mean utf8mb4, +// so the reported value would never match. +func (d *Driver) ValidateParameters(params map[string]string) error { + for k, v := range params { + switch k { + case "characterSet": + if !charsetRE.MatchString(v) { + return fmt.Errorf("parameters.characterSet %q must be a lowercase character set name, e.g. utf8mb4", v) + } + if v == "utf8" { + return fmt.Errorf("parameters.characterSet \"utf8\" is ambiguous; use utf8mb4 (or utf8mb3)") + } + case "collation": + if !collationRE.MatchString(v) { + return fmt.Errorf("parameters.collation %q must be a lowercase collation name, e.g. utf8mb4_unicode_ci", v) + } + if strings.HasPrefix(v, "utf8_") { + return fmt.Errorf("parameters.collation %q is ambiguous; use the utf8mb4_ (or utf8mb3_) name", v) + } + default: + return fmt.Errorf("unknown parameter %q (mysql v0.1 accepts: characterSet, collation)", k) + } + } + return nil +} + +// ValidateUpdateParameters: both parameters are set at creation and +// immutable, like a gcs bucket's location; adding or removing one +// counts as a change. +func (d *Driver) ValidateUpdateParameters(oldParams, newParams map[string]string) error { + if err := d.ValidateParameters(newParams); err != nil { + return err + } + for _, k := range []string{"characterSet", "collation"} { + if o, n := oldParams[k], newParams[k]; o != n { + return fmt.Errorf("parameters.%s is immutable post-create (current=%q, requested=%q); the driver does not convert existing tables", k, o, n) + } + } + return nil +} + +func (d *Driver) ValidateAccessParameters(params map[string]string) error { + if len(params) == 0 { + return nil + } + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + return fmt.Errorf("mysql accepts no BucketyAccess parameters; got: %s", strings.Join(keys, ", ")) +} + +// ValidateResourceName enforces the prefix boundary and MySQL's +// limits on the resolved spec.name. +func (d *Driver) ValidateResourceName(name string) error { + if err := d.checkDatabase(name); err != nil { + if !strings.HasPrefix(name, d.cfg.NamePrefix) { + return fmt.Errorf("%w (set spec.name, e.g. \"%s${namespace}_${name}\")", err, d.cfg.NamePrefix) + } + return err + } + return nil +} diff --git a/pkg/drivers/mysql/driver_test.go b/pkg/drivers/mysql/driver_test.go new file mode 100644 index 0000000..b4e495e --- /dev/null +++ b/pkg/drivers/mysql/driver_test.go @@ -0,0 +1,655 @@ +package mysql + +import ( + "context" + "errors" + "fmt" + "net/url" + "slices" + "sort" + "strings" + "testing" + + gomysql "github.com/go-sql-driver/mysql" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" +) + +// fakeConn records statements and plays scripted answers. It never +// prints what it records: tests compare passwords, they don't log +// them. +type fakeConn struct { + execs []execCall + onExec func(query string, args []any) error + schemas map[string]*schemaInfo + content map[string]int + // logins are consumed in order; the last one repeats. + logins []loginResult + loginCalls []loginCall + calls int +} + +type execCall struct { + query string + args []any +} + +type loginResult struct { + grants *grants + err error +} + +type loginCall struct { + acct account + password string + pattern string +} + +func (f *fakeConn) exec(_ context.Context, query string, args ...any) error { + f.calls++ + f.execs = append(f.execs, execCall{query, args}) + if f.onExec != nil { + return f.onExec(query, args) + } + return nil +} + +func (f *fakeConn) lookupSchema(_ context.Context, name string) (*schemaInfo, error) { + f.calls++ + return f.schemas[name], nil +} + +func (f *fakeConn) countContent(_ context.Context, name string) (int, error) { + f.calls++ + return f.content[name], nil +} + +func (f *fakeConn) login(_ context.Context, acct account, password, pattern string) (*grants, error) { + f.calls++ + f.loginCalls = append(f.loginCalls, loginCall{acct, password, pattern}) + if len(f.logins) == 0 { + return nil, errLoginDenied + } + r := f.logins[0] + if len(f.logins) > 1 { + f.logins = f.logins[1:] + } + return r.grants, r.err +} + +func (f *fakeConn) queries() []string { + out := make([]string, len(f.execs)) + for i, e := range f.execs { + out[i] = e.query + } + return out +} + +func allRW() *grants { return &grants{privileges: slices.Clone(readWritePrivileges)} } + +func grantReq(role string, secret map[string][]byte) registry.GrantRequest { + return registry.GrantRequest{ + BucketyName: "b_t1_orders", AccessNamespace: "t1", AccessName: "app", + Role: role, ExistingSecretData: secret, + } +} + +const secretPassword = "Secret-Pw-0123456789" + +func withPassword(p string) map[string][]byte { + return map[string][]byte{"password": []byte(p), "username": []byte("ignored")} +} + +func TestValidateParameters(t *testing.T) { + d := testDriver(nil) + if err := d.ValidateParameters(map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"}); err != nil { + t.Fatal(err) + } + if err := d.ValidateParameters(nil); err != nil { + t.Fatal(err) + } + for _, p := range []map[string]string{ + {"charset": "utf8mb4"}, + {"characterSet": "utf8"}, + {"characterSet": "UTF8MB4"}, + {"characterSet": "utf8mb4; DROP DATABASE mysql"}, + {"characterSet": "utf8mb4`"}, + {"collation": "utf8_general_ci"}, + {"collation": "utf8mb4 COLLATE x"}, + {"collation": ""}, + } { + if err := d.ValidateParameters(p); err == nil { + t.Errorf("parameters %v accepted", p) + } + } +} + +func TestValidateUpdateParameters(t *testing.T) { + d := testDriver(nil) + same := map[string]string{"characterSet": "utf8mb4"} + if err := d.ValidateUpdateParameters(same, same); err != nil { + t.Fatal(err) + } + for _, c := range [][2]map[string]string{ + {{"characterSet": "utf8mb4"}, {"characterSet": "latin1"}}, + {{}, {"collation": "utf8mb4_bin"}}, + {{"collation": "utf8mb4_bin"}, {}}, + } { + if err := d.ValidateUpdateParameters(c[0], c[1]); err == nil { + t.Errorf("%v -> %v accepted", c[0], c[1]) + } + } +} + +func TestValidateAccessParameters(t *testing.T) { + d := testDriver(nil) + if err := d.ValidateAccessParameters(nil); err != nil { + t.Fatal(err) + } + if err := d.ValidateAccessParameters(map[string]string{"host": "x"}); err == nil { + t.Fatal("access parameters accepted") + } +} + +func TestInspectBuckety(t *testing.T) { + fc := &fakeConn{ + schemas: map[string]*schemaInfo{"b_full": {}, "b_empty": {}}, + content: map[string]int{"b_full": 3}, + } + d := testDriver(fc) + for name, want := range map[string]registry.Inspection{ + "b_missing": {}, + "b_empty": {Exists: true, Empty: true}, + "b_full": {Exists: true, Empty: false}, + } { + got, err := d.InspectBuckety(context.Background(), name) + if err != nil || got != want { + t.Errorf("InspectBuckety(%s) = %+v, %v", name, got, err) + } + } + fc.calls = 0 + if _, err := d.InspectBuckety(context.Background(), "mysql"); err == nil || fc.calls != 0 { + t.Errorf("outside the prefix: err=%v calls=%d", err, fc.calls) + } +} + +func TestEnsureBucketyCreates(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{}} + fc.onExec = func(q string, _ []any) error { + fc.schemas["b_t1_orders"] = &schemaInfo{charset: "utf8mb4", collation: "utf8mb4_unicode_ci"} + return nil + } + err := testDriver(fc).EnsureBuckety(context.Background(), registry.EnsureRequest{ + Name: "b_t1_orders", + Parameters: map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"}, + }) + if err != nil { + t.Fatal(err) + } + want := []string{"CREATE DATABASE IF NOT EXISTS `b_t1_orders` CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`"} + if !slices.Equal(fc.queries(), want) { + t.Fatalf("statements: %q", fc.queries()) + } +} + +func TestEnsureBucketyExisting(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{"b_x": {charset: "utf8mb4", collation: "utf8mb4_unicode_ci"}}} + d := testDriver(fc) + ctx := context.Background() + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: "b_x", Parameters: map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"}}); err != nil { + t.Fatal(err) + } + // Server spelling may differ in case. + fc.schemas["b_x"] = &schemaInfo{charset: "UTF8MB4", collation: "UTF8MB4_UNICODE_CI"} + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: "b_x", Parameters: map[string]string{"characterSet": "utf8mb4"}}); err != nil { + t.Fatal(err) + } + if len(fc.execs) != 0 { + t.Fatalf("existing database altered: %q", fc.queries()) + } +} + +func TestEnsureBucketyDrift(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{"b_x": {charset: "latin1", collation: "latin1_swedish_ci"}}} + d := testDriver(fc) + for _, p := range []map[string]string{{"characterSet": "utf8mb4"}, {"collation": "latin1_bin"}} { + err := d.EnsureBuckety(context.Background(), registry.EnsureRequest{Name: "b_x", Parameters: p}) + if !registry.IsParameterDrift(err) { + t.Errorf("%v: want ParameterDrift, got %v", p, err) + } + } + if len(fc.execs) != 0 { + t.Fatalf("drift was 'repaired': %q", fc.queries()) + } + // Parameters absent: nothing managed, nothing drifts. + if err := d.EnsureBuckety(context.Background(), registry.EnsureRequest{Name: "b_x"}); err != nil { + t.Fatal(err) + } +} + +func TestEnsureBucketyRefusals(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{}} + d := testDriver(fc) + for _, req := range []registry.EnsureRequest{ + {Name: "mysql"}, + {Name: "keycloak"}, + {Name: "b_x`; DROP DATABASE mysql; --"}, + {Name: "b_x", Parameters: map[string]string{"characterSet": "utf8mb4 COLLATE x"}}, + } { + if err := d.EnsureBuckety(context.Background(), req); err == nil { + t.Errorf("%+v accepted", req) + } + } + if fc.calls != 0 { + t.Fatalf("refused requests reached the server: %d calls", fc.calls) + } + // Created but invisible: the admin grants do not cover it. + if err := d.EnsureBuckety(context.Background(), registry.EnsureRequest{Name: "b_x"}); err == nil || !strings.Contains(err.Error(), "not visible") { + t.Fatalf("invisible database: %v", err) + } +} + +func TestDeleteBuckety(t *testing.T) { + fc := &fakeConn{} + d := testDriver(fc) + if err := d.DeleteBuckety(context.Background(), registry.DeleteRequest{Name: "b_t1_orders"}); err != nil { + t.Fatal(err) + } + if !slices.Equal(fc.queries(), []string{"DROP DATABASE IF EXISTS `b_t1_orders`"}) { + t.Fatalf("statements: %q", fc.queries()) + } + for _, n := range []string{"mysql", "sys", "keycloak", ""} { + if err := d.DeleteBuckety(context.Background(), registry.DeleteRequest{Name: n}); err == nil { + t.Errorf("DeleteBuckety(%q) accepted", n) + } + } + if len(fc.execs) != 1 { + t.Fatalf("refused deletes reached the server: %q", fc.queries()) + } +} + +func TestGrantFirstMint(t *testing.T) { + fc := &fakeConn{} + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) + if err != nil { + t.Fatal(err) + } + if len(fc.loginCalls) != 0 { + t.Errorf("first mint logged in %d times; nothing to verify yet", len(fc.loginCalls)) + } + if len(fc.execs) != 2 || fc.execs[0].query != createUserSQL || !strings.HasPrefix(fc.execs[1].query, "GRANT SELECT, INSERT") { + t.Fatalf("statements: %q", fc.queries()) + } + pw := string(res.SecretData["password"]) + if !slices.Equal(fc.execs[0].args, []any{"b_t1_app", "%", pw}) { + t.Error("CREATE USER arguments are not (user, host, the Secret's password)") + } + if !slices.Equal(fc.execs[1].args, []any{"b_t1_app", "%"}) { + t.Errorf("GRANT arguments: %v", fc.execs[1].args) + } + if !strings.Contains(fc.execs[1].query, "ON `b\\_t1\\_orders`.* TO ?@?") { + t.Errorf("GRANT is not scoped to the escaped database: %s", fc.execs[1].query) + } + if err := validatePassword(pw); err != nil || len(pw) != passwordLen { + t.Errorf("generated password: %v", err) + } + if res.Principal != "b_t1_app@%" || !res.Scoped || !res.Revocable || !res.Minted { + t.Errorf("result: principal=%q scoped=%v revocable=%v minted=%v", res.Principal, res.Scoped, res.Revocable, res.Minted) + } +} + +func TestGrantSecretContents(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{grants: allRW()}}} + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + keys := make([]string, 0, len(res.SecretData)) + for k := range res.SecretData { + keys = append(keys, k) + } + sort.Strings(keys) + if !slices.Equal(keys, []string{"database", "host", "jdbcUrl", "password", "port", "url", "username"}) { + t.Fatalf("keys: %v", keys) + } + want := map[string]string{ + "host": "mysql.mysql.svc.cluster.local", + "port": "3306", + "database": "b_t1_orders", + "username": "b_t1_app", + "jdbcUrl": "jdbc:mariadb://mysql.mysql.svc.cluster.local:3306/b_t1_orders", + } + for k, v := range want { + if got := string(res.SecretData[k]); got != v { + t.Errorf("%s = %q, want %q", k, got, v) + } + } + if string(res.SecretData["password"]) != secretPassword { + t.Error("password is not the Secret's") + } + u, err := url.Parse(string(res.SecretData["url"])) + if err != nil { + t.Fatal("url does not parse") + } + p, _ := u.User.Password() + if u.Scheme != "mysql" || u.User.Username() != "b_t1_app" || p != secretPassword || u.Host != "mysql.mysql.svc.cluster.local:3306" || u.Path != "/b_t1_orders" { + t.Error("url does not carry user, password, host:port and database") + } + if res.Minted { + t.Error("existing user reported as minted") + } +} + +func TestGrantSteadyStateWritesNothing(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{grants: allRW()}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("", withPassword(secretPassword))); err != nil { + t.Fatal(err) + } + if len(fc.execs) != 0 { + t.Fatalf("steady state wrote: %q", fc.queries()) + } + lc := fc.loginCalls[0] + if lc.acct != (account{"b_t1_app", "%"}) || lc.password != secretPassword || lc.pattern != `b\_t1\_orders` { + t.Errorf("login: acct=%v pattern=%q secretPassword=%v", lc.acct, lc.pattern, lc.password == secretPassword) + } +} + +func TestGrantRepairsGrants(t *testing.T) { + cases := []struct { + name string + role string + have *grants + want []string + }{ + {"missing INSERT", "ReadWrite", &grants{privileges: without(readWritePrivileges, []string{"INSERT"})}, + []string{"GRANT INSERT ON `b\\_t1\\_orders`.* TO ?@?"}}, + {"no grants at all", "Reader", &grants{}, + []string{"GRANT SELECT ON `b\\_t1\\_orders`.* TO ?@?"}}, + {"extra privileges and grant option", "ReadWrite", &grants{privileges: append(slices.Clone(readWritePrivileges), "EXECUTE", "CREATE VIEW"), grantOption: true}, + []string{"REVOKE EXECUTE, CREATE VIEW ON `b\\_t1\\_orders`.* FROM ?@?", "REVOKE GRANT OPTION ON `b\\_t1\\_orders`.* FROM ?@?"}}, + {"role downgraded to Reader", "Reader", allRW(), + []string{"REVOKE INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES ON `b\\_t1\\_orders`.* FROM ?@?"}}, + } + for _, c := range cases { + fc := &fakeConn{logins: []loginResult{{grants: c.have}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq(c.role, withPassword(secretPassword))); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if !slices.Equal(fc.queries(), c.want) { + t.Errorf("%s:\n got %q\nwant %q", c.name, fc.queries(), c.want) + } + } +} + +func TestGrantRefusesUnexpectedPrivilegeNames(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{grants: &grants{privileges: append(slices.Clone(readWritePrivileges), "EXECUTE ON *.* TO x; --")}}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))); err == nil { + t.Fatal("privilege name from the server spliced unchecked") + } + if len(fc.execs) != 0 { + t.Fatalf("statements: %q", fc.queries()) + } +} + +func TestGrantRecreatesMissingUser(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{err: errLoginDenied}, {grants: allRW()}}} + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + if len(fc.execs) != 2 || fc.execs[0].query != createUserSQL || fc.execs[0].args[2] != secretPassword { + t.Fatalf("statements: %q", fc.queries()) + } + if len(fc.loginCalls) != 2 { + t.Errorf("login calls: %d (check + verify)", len(fc.loginCalls)) + } + if !res.Minted || string(res.SecretData["password"]) != secretPassword { + t.Errorf("minted=%v, password kept=%v", res.Minted, string(res.SecretData["password"]) == secretPassword) + } +} + +func TestGrantAppliesChangedPassword(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{err: errLoginDenied}, {grants: allRW()}}} + fc.onExec = func(q string, _ []any) error { + if q == createUserSQL { + return &gomysql.MySQLError{Number: erCannotUser, Message: "Operation CREATE USER failed for 'b_t1_app'@'%'"} + } + return nil + } + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + q := fc.queries() + if len(q) < 2 || q[0] != createUserSQL || q[1] != alterUserSQL || fc.execs[1].args[2] != secretPassword { + t.Fatalf("statements: %q", q) + } + if res.Minted { + t.Error("re-keyed user reported as minted") + } +} + +// A user created in this pass holds nothing: no REVOKE, which +// MySQL 8.4 refuses for privileges that are not held. +func TestGrantFirstMintReaderRevokesNothing(t *testing.T) { + fc := &fakeConn{} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Reader", nil)); err != nil { + t.Fatal(err) + } + q := fc.queries() + if len(q) != 2 || q[0] != createUserSQL || !strings.HasPrefix(q[1], "GRANT SELECT ON ") { + t.Fatalf("statements: %q", q) + } +} + +// A re-keyed user's grants are unknown: the rest of the managed set +// is revoked one privilege at a time, and "no such grant" is the +// expected answer for each privilege it does not hold. +func TestGrantRekeyedReaderRevokesEachPrivilege(t *testing.T) { + fc := &fakeConn{} + fc.onExec = func(q string, _ []any) error { + switch { + case q == createUserSQL: + return &gomysql.MySQLError{Number: erCannotUser} + case strings.HasPrefix(q, "REVOKE INSERT "): + return nil + case strings.HasPrefix(q, "REVOKE "): + return &gomysql.MySQLError{Number: erNoSuchGrant} + } + return nil + } + // No password in the Secret: deleting it rotates the password. + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Reader", nil)); err != nil { + t.Fatal(err) + } + var revokes []string + for _, q := range fc.queries() { + if strings.HasPrefix(q, "REVOKE ") { + revokes = append(revokes, q) + } + } + if len(revokes) != len(readWritePrivileges)-1 { + t.Fatalf("want one REVOKE per non-Reader privilege, got %q", revokes) + } + + fc.onExec = func(q string, _ []any) error { + switch { + case q == createUserSQL: + return &gomysql.MySQLError{Number: erCannotUser} + case strings.HasPrefix(q, "REVOKE "): + return &gomysql.MySQLError{Number: 1227} + } + return nil + } + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Reader", nil)); err == nil { + t.Fatal("a REVOKE refused for another reason was swallowed") + } +} + +func TestGrantVerificationFailure(t *testing.T) { + orig := verifyBackoff + verifyBackoff = 0 + t.Cleanup(func() { verifyBackoff = orig }) + fc := &fakeConn{logins: []loginResult{{err: errLoginDenied}}} + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err == nil || !strings.Contains(err.Error(), "userHost") { + t.Fatalf("want an error pointing at userHost, got %v", err) + } + if strings.Contains(err.Error(), secretPassword) { + t.Fatal("error contains the password") + } + if len(fc.loginCalls) != 1+verifyAttempts { + t.Errorf("login calls: %d", len(fc.loginCalls)) + } + // The user this pass created is dropped again. + last := fc.execs[len(fc.execs)-1] + if last.query != dropUserSQL || !slices.Equal(last.args, []any{"b_t1_app", "%"}) { + t.Errorf("last statement: %s %v", last.query, last.args) + } +} + +func TestGrantLoginErrorIsNotRepair(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{err: errors.New("dial tcp: connection refused")}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))); err == nil { + t.Fatal("login error swallowed") + } + if len(fc.execs) != 0 { + t.Fatalf("an unreachable server is no reason to re-key: %q", fc.queries()) + } +} + +// Server messages of statements that carry a password are dropped: +// a parse error quotes the statement, possibly cut mid-password. +func TestGrantKeepsPasswordOutOfErrors(t *testing.T) { + for _, number := range []uint16{1064, 1819} { + fc := &fakeConn{} + fc.onExec = func(q string, args []any) error { + pw := args[len(args)-1].(string) + return &gomysql.MySQLError{Number: number, Message: fmt.Sprintf("near '%s' at line 1", pw[:10])} + } + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("server error %d", number)) { + t.Fatalf("want the error number, got %v", err) + } + if strings.Contains(err.Error(), fc.execs[0].args[2].(string)[:10]) || strings.Contains(err.Error(), "near") { + t.Fatal("error carries the server message, and with it part of the password") + } + } + // Client-side errors keep their text, minus the password. + fc := &fakeConn{} + fc.onExec = func(q string, args []any) error { + return fmt.Errorf("write tcp: broken pipe after %v", args[len(args)-1]) + } + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) + if err == nil || !strings.Contains(err.Error(), "broken pipe") || !strings.Contains(err.Error(), "") { + t.Fatalf("client error: %v", err) + } +} + +func TestGrantRefusals(t *testing.T) { + cases := map[string]registry.GrantRequest{ + "database outside": {BucketyName: "keycloak", AccessNamespace: "t1", AccessName: "app", Role: "ReadWrite"}, + "system database": {BucketyName: "mysql", AccessNamespace: "t1", AccessName: "app", Role: "ReadWrite"}, + "no access name": {BucketyName: "b_t1_orders", Role: "ReadWrite"}, + "unsafe password": grantReq("ReadWrite", withPassword("Robert'); DROP USER root; --")), + "short password": grantReq("ReadWrite", withPassword("Short-1")), + } + for name, req := range cases { + fc := &fakeConn{} + _, err := testDriver(fc).GrantAccess(context.Background(), req) + if err == nil { + t.Errorf("%s: accepted", name) + continue + } + if fc.calls != 0 { + t.Errorf("%s: reached the server (%d calls)", name, fc.calls) + } + if strings.Contains(err.Error(), "DROP USER root") { + t.Errorf("%s: error echoes the Secret's password", name) + } + } +} + +// A refused role holds no user: a downgrade to Writer must not keep +// the grants of the earlier role. +func TestGrantUnsupportedRoleDropsUser(t *testing.T) { + for _, role := range []string{"Writer", "Admin"} { + fc := &fakeConn{} + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq(role, withPassword(secretPassword))) + if err == nil || !strings.Contains(err.Error(), role) { + t.Fatalf("%s: want a role error, got %v", role, err) + } + if len(fc.execs) != 1 || fc.execs[0].query != dropUserSQL || !slices.Equal(fc.execs[0].args, []any{"b_t1_app", "%"}) { + t.Fatalf("%s: statements %q", role, fc.queries()) + } + if len(fc.loginCalls) != 0 { + t.Errorf("%s: logged in", role) + } + } + fc := &fakeConn{onExec: func(string, []any) error { return errors.New("connection refused") }} + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Writer", nil)) + if err == nil || !strings.Contains(err.Error(), "Writer") || !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("a failed drop should be reported with the role error: %v", err) + } +} + +func TestRevokeAccess(t *testing.T) { + fc := &fakeConn{} + d := testDriver(fc) + ctx := context.Background() + if err := d.RevokeAccess(ctx, ""); err != nil || fc.calls != 0 { + t.Fatalf("empty principal: %v, %d calls", err, fc.calls) + } + if err := d.RevokeAccess(ctx, "b_t1_app@%"); err != nil { + t.Fatal(err) + } + if len(fc.execs) != 1 || fc.execs[0].query != dropUserSQL || !slices.Equal(fc.execs[0].args, []any{"b_t1_app", "%"}) { + t.Fatalf("statements: %v", fc.execs) + } + for _, p := range []string{"root@localhost", "buckety@%", "keycloak@%", "b_x@'%'", "nonsense"} { + if err := d.RevokeAccess(ctx, p); err == nil { + t.Errorf("RevokeAccess(%q) accepted", p) + } + } + if len(fc.execs) != 1 { + t.Fatalf("refused revokes reached the server: %v", fc.execs) + } +} + +// The principal is user@host with the host it was created with, so +// a userHost change revokes the old account, not a new one. +func TestPrincipalCarriesHost(t *testing.T) { + fc := &fakeConn{} + d := testDriver(fc) + d.cfg.UserHost = "10.42.%" + res, err := d.GrantAccess(context.Background(), grantReq("Reader", nil)) + if err != nil { + t.Fatal(err) + } + if res.Principal != "b_t1_app@10.42.%" { + t.Fatalf("principal %q", res.Principal) + } +} + +// After a userHost change the login matches the account for the +// previous host: repair creates the new account, and a verification +// that still lands on the old one succeeds without looping. +func TestGrantAcrossUserHostChange(t *testing.T) { + shadowed := fmt.Errorf("%w: logged in as b_t1_app@%% instead of b_t1_app@10.42.%%", errShadowed) + fc := &fakeConn{logins: []loginResult{{err: shadowed}}} + d := testDriver(fc) + d.cfg.UserHost = "10.42.%" + res, err := d.GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + if fc.execs[0].query != createUserSQL || !slices.Equal(fc.execs[0].args[:2], []any{"b_t1_app", "10.42.%"}) { + t.Fatalf("statements: %q %v", fc.queries(), fc.execs[0].args[:2]) + } + if len(fc.loginCalls) != 2 { + t.Errorf("login calls: %d (check + one verify)", len(fc.loginCalls)) + } + if res.Principal != "b_t1_app@10.42.%" || !res.Minted { + t.Errorf("principal %q minted=%v", res.Principal, res.Minted) + } +} diff --git a/pkg/drivers/mysql/integration_test.go b/pkg/drivers/mysql/integration_test.go new file mode 100644 index 0000000..9de399d --- /dev/null +++ b/pkg/drivers/mysql/integration_test.go @@ -0,0 +1,449 @@ +package mysql + +// Integration test against a real MariaDB or MySQL server, skipped +// unless BUCKETY_MYSQL_TEST_DSN holds the controller account's DSN +// in go-sql-driver form, for an account set up as in docs/mysql.md. +// test/integration/mysql.sh runs it against every supported server +// in Docker, as CI does; against a server of your own: +// +// BUCKETY_MYSQL_TEST_DSN="buckety:${PW}@tcp(127.0.0.1:3306)/" \ +// go test ./pkg/drivers/mysql -run TestIntegration -v -count=1 +// +// Optional: BUCKETY_MYSQL_TEST_PREFIX (default b_) must match the +// account's grant pattern; BUCKETY_MYSQL_TEST_USER_HOST (default %) +// must admit the address the test connects from; +// BUCKETY_MYSQL_TEST_ALT_USER_HOST (e.g. 127.0.0.1 through a +// port-forward, or auto for the address the server sees) enables +// the userHost-change step. +// +// The test creates one database and two users named after a random +// run id under the prefix, and drops them at the end. It never +// prints a password. + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "maps" + "net" + "os" + "slices" + "strconv" + "testing" + "time" + + gomysql "github.com/go-sql-driver/mysql" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" +) + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func TestIntegration(t *testing.T) { + dsn := os.Getenv("BUCKETY_MYSQL_TEST_DSN") + if dsn == "" { + t.Skip("BUCKETY_MYSQL_TEST_DSN not set; see the comment at the top of integration_test.go") + } + parsed, err := gomysql.ParseDSN(dsn) + if err != nil { + t.Fatal("BUCKETY_MYSQL_TEST_DSN does not parse as a go-sql-driver DSN") + } + host, portStr, err := net.SplitHostPort(parsed.Addr) + if err != nil { + t.Fatalf("DSN address %q: %v", parsed.Addr, err) + } + port, _ := strconv.Atoi(portStr) + cfg := &Config{ + Host: host, Port: port, + AdminUser: parsed.User, AdminPassword: parsed.Passwd, + NamePrefix: envOr("BUCKETY_MYSQL_TEST_PREFIX", defaultNamePrefix), + UserHost: envOr("BUCKETY_MYSQL_TEST_USER_HOST", defaultUserHost), + } + if err := cfg.validate(); err != nil { + t.Fatal(err) + } + d, err := newDriver(cfg) + if err != nil { + t.Fatal(err) + } + sc := d.conn.(*sqlConn) + admin := sc.admin + ctx := context.Background() + + idb := make([]byte, 4) + _, _ = rand.Read(idb) + run := "it" + hex.EncodeToString(idb) + db := cfg.NamePrefix + run + "_db" + pattern := quoteIdent(grantPattern(db)) + params := map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"} + rwReq := registry.GrantRequest{BucketyName: db, AccessNamespace: run, AccessName: "rw", Role: "ReadWrite"} + // A long namespace exercises the hashed user name form. + roReq := registry.GrantRequest{BucketyName: db, AccessNamespace: run + "-a-rather-long-namespace", AccessName: "ro", Role: "Reader"} + + var principals []string + t.Cleanup(func() { + for _, p := range principals { + _ = d.RevokeAccess(ctx, p) + } + _ = d.DeleteBuckety(ctx, registry.DeleteRequest{Name: db}) + }) + + // connect logs in with a Secret's coordinates. + connect := func(t *testing.T, secret map[string][]byte) *sql.DB { + t.Helper() + c := sc.base.Clone() + c.User = string(secret["username"]) + c.Passwd = string(secret["password"]) + c.Addr = net.JoinHostPort(string(secret["host"]), string(secret["port"])) + c.DBName = string(secret["database"]) + conn, err := gomysql.NewConnector(c) + if err != nil { + t.Fatal(err) + } + u := sql.OpenDB(conn) + u.SetMaxOpenConns(1) + t.Cleanup(func() { u.Close() }) + return u + } + mustExec := func(t *testing.T, conn *sql.DB, q string, args ...any) { + t.Helper() + if _, err := conn.ExecContext(ctx, q, args...); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + denied := func(t *testing.T, conn *sql.DB, q string) { + t.Helper() + _, err := conn.ExecContext(ctx, q) + var me *gomysql.MySQLError + if !errors.As(err, &me) || (me.Number != 1142 && me.Number != 1044 && me.Number != 1227) { + t.Fatalf("%s: want a privilege error, got %v", q, err) + } + } + loginDenied := func(t *testing.T, secret map[string][]byte) { + t.Helper() + err := connect(t, secret).PingContext(ctx) + if !isMySQLError(err, erAccessDenied) { + t.Fatalf("login as %s: want access denied, got %v", secret["username"], err) + } + } + grant := func(t *testing.T, req registry.GrantRequest, existing map[string][]byte) registry.GrantResult { + t.Helper() + req.ExistingSecretData = existing + res, err := d.GrantAccess(ctx, req) + if err != nil { + t.Fatalf("GrantAccess %s/%s: %v", req.AccessNamespace, req.AccessName, err) + } + if !slices.Contains(principals, res.Principal) { + principals = append(principals, res.Principal) + } + return res + } + + // Steps build on each other: stop at the first failure. + step := func(name string, f func(t *testing.T)) { + t.Helper() + if !t.Run(name, f) { + t.FailNow() + } + } + + step("database is created", func(t *testing.T) { + if in, err := d.InspectBuckety(ctx, db); err != nil || in.Exists { + t.Fatalf("before create: %+v, %v", in, err) + } + for i := 0; i < 2; i++ { + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}); err != nil { + t.Fatalf("EnsureBuckety #%d: %v", i, err) + } + } + if in, err := d.InspectBuckety(ctx, db); err != nil || !in.Exists || !in.Empty { + t.Fatalf("after create: %+v, %v", in, err) + } + }) + + rw := grant(t, rwReq, nil) + step("ReadWrite user can migrate and write", func(t *testing.T) { + if !rw.Minted || !rw.Scoped || !rw.Revocable { + t.Fatalf("result flags: %+v", rw.Principal) + } + want := "jdbc:mariadb://" + net.JoinHostPort(host, portStr) + "/" + db + if string(rw.SecretData["jdbcUrl"]) != want || string(rw.SecretData["database"]) != db { + t.Fatalf("Secret coordinates: jdbcUrl=%s database=%s", rw.SecretData["jdbcUrl"], rw.SecretData["database"]) + } + u := connect(t, rw.SecretData) + mustExec(t, u, "CREATE TABLE items (id INT PRIMARY KEY, name VARCHAR(20))") + mustExec(t, u, "INSERT INTO items VALUES (1, 'one')") + mustExec(t, u, "ALTER TABLE items ADD COLUMN note VARCHAR(10)") + mustExec(t, u, "CREATE INDEX items_name ON items (name)") + mustExec(t, u, "CREATE TEMPORARY TABLE scratch (x INT)") + var n int + if err := u.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil || n != 1 { + t.Fatalf("select: %d, %v", n, err) + } + // Scoped to its database: no other schema, no new + // databases - including one an unescaped "_" in the grant + // pattern would have matched. + denied(t, u, "SELECT * FROM mysql.user") + denied(t, u, "CREATE DATABASE "+quoteIdent(cfg.NamePrefix+run+"_other")) + lookalike := []byte(db) + for i := range lookalike { + if lookalike[i] == '_' { + lookalike[i] = '-' + } + } + denied(t, u, "CREATE DATABASE "+quoteIdent(string(lookalike))) + denied(t, u, "CREATE USER 'nope'@'%'") + }) + + step("database with tables is not empty", func(t *testing.T) { + if in, err := d.InspectBuckety(ctx, db); err != nil || !in.Exists || in.Empty { + t.Fatalf("%+v, %v", in, err) + } + }) + + ro := grant(t, roReq, nil) + step("Reader user can only read", func(t *testing.T) { + if u := string(ro.SecretData["username"]); len(u) > maxUserNameLen { + t.Fatalf("user name %q too long", u) + } + u := connect(t, ro.SecretData) + var n int + if err := u.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil || n != 1 { + t.Fatalf("select: %d, %v", n, err) + } + denied(t, u, "INSERT INTO items (id, name) VALUES (2, 'two')") + denied(t, u, "CREATE TABLE t2 (x INT)") + denied(t, u, "DROP TABLE items") + }) + + step("steady state keeps the Secret", func(t *testing.T) { + again := grant(t, rwReq, rw.SecretData) + if again.Minted || !maps.EqualFunc(again.SecretData, rw.SecretData, func(a, b []byte) bool { return string(a) == string(b) }) { + t.Fatal("a reconcile with an intact Secret changed it") + } + }) + + step("missing grant is restored", func(t *testing.T) { + mustExec(t, admin, "REVOKE INSERT ON "+pattern+".* FROM ?@?", string(rw.SecretData["username"]), cfg.UserHost) + u := connect(t, rw.SecretData) + denied(t, u, "INSERT INTO items (id, name) VALUES (3, 'three')") + grant(t, rwReq, rw.SecretData) + mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (3, 'three')") + }) + + step("extra privileges and grant option are revoked", func(t *testing.T) { + mustExec(t, admin, "GRANT EXECUTE ON "+pattern+".* TO ?@?", string(rw.SecretData["username"]), cfg.UserHost) + mustExec(t, admin, "GRANT SELECT ON "+pattern+".* TO ?@? WITH GRANT OPTION", string(rw.SecretData["username"]), cfg.UserHost) + grant(t, rwReq, rw.SecretData) + acct := account{User: string(rw.SecretData["username"]), Host: cfg.UserHost} + g, err := sc.login(ctx, acct, string(rw.SecretData["password"]), grantPattern(db)) + if err != nil { + t.Fatal(err) + } + if g.grantOption || slices.Contains(g.privileges, "EXECUTE") || len(without(readWritePrivileges, g.privileges)) != 0 { + t.Fatalf("privileges after repair: %v grantOption=%v", g.privileges, g.grantOption) + } + }) + + step("dropped user is recreated with the Secret's password", func(t *testing.T) { + mustExec(t, admin, "DROP USER ?@?", string(rw.SecretData["username"]), cfg.UserHost) + loginDenied(t, rw.SecretData) + res := grant(t, rwReq, rw.SecretData) + if !res.Minted || string(res.SecretData["password"]) != string(rw.SecretData["password"]) { + t.Fatal("recreated user: want minted with the Secret's password") + } + var n int + if err := connect(t, rw.SecretData).QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil || n != 2 { + t.Fatalf("data after recreate: %d, %v", n, err) + } + }) + + step("password changed in the Secret is applied", func(t *testing.T) { + pw, err := generatePassword() + if err != nil { + t.Fatal(err) + } + edited := maps.Clone(rw.SecretData) + edited["password"] = []byte(pw) + res := grant(t, rwReq, edited) + if string(res.SecretData["password"]) != pw || res.Minted { + t.Fatal("the Secret's new password was not kept") + } + if err := connect(t, edited).PingContext(ctx); err != nil { + t.Fatalf("login with the new password: %v", err) + } + loginDenied(t, rw.SecretData) + rw = res + }) + + step("role change re-grants", func(t *testing.T) { + toReader := rwReq + toReader.Role = "Reader" + grant(t, toReader, rw.SecretData) + denied(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (4, 'four')") + grant(t, rwReq, rw.SecretData) + mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (4, 'four')") + }) + + step("a downgrade to Writer drops the user, and back restores it", func(t *testing.T) { + toWriter := rwReq + toWriter.Role, toWriter.ExistingSecretData = "Writer", rw.SecretData + if _, err := d.GrantAccess(ctx, toWriter); err == nil { + t.Fatal("Writer accepted") + } + loginDenied(t, rw.SecretData) + back := grant(t, rwReq, rw.SecretData) + if string(back.SecretData["password"]) != string(rw.SecretData["password"]) { + t.Fatal("the Secret's password was not kept") + } + mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (6, 'six')") + mustExec(t, connect(t, rw.SecretData), "DELETE FROM items WHERE id = 6") + }) + + step("the server enforces the lock wait timeout", func(t *testing.T) { + var v int + if err := admin.QueryRowContext(ctx, "SELECT @@SESSION.lock_wait_timeout").Scan(&v); err != nil || v != int(lockWaitTimeout/time.Second) { + t.Fatalf("lock_wait_timeout = %d, %v", v, err) + } + }) + + step("deleting the Secret rotates the password", func(t *testing.T) { + // The re-keyed path: the user exists, its grants are unknown, + // and the Reader must still be refused the ReadWrite set. + rotated := grant(t, roReq, nil) + if rotated.Minted || string(rotated.SecretData["password"]) == string(ro.SecretData["password"]) { + t.Fatalf("minted=%v, password changed=%v", rotated.Minted, string(rotated.SecretData["password"]) != string(ro.SecretData["password"])) + } + loginDenied(t, ro.SecretData) + u := connect(t, rotated.SecretData) + var n int + if err := u.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil { + t.Fatalf("select after rotation: %v", err) + } + denied(t, u, "INSERT INTO items (id, name) VALUES (9, 'nine')") + ro = rotated + }) + + step("character set drift is reported, not repaired", func(t *testing.T) { + mustExec(t, admin, "ALTER DATABASE "+quoteIdent(db)+" CHARACTER SET latin1 COLLATE latin1_swedish_ci") + err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}) + if !registry.IsParameterDrift(err) { + t.Fatalf("want ParameterDrift, got %v", err) + } + s, err := sc.lookupSchema(ctx, db) + if err != nil || s.charset != "latin1" { + t.Fatalf("driver altered the database: %+v, %v", s, err) + } + mustExec(t, admin, "ALTER DATABASE "+quoteIdent(db)+" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}); err != nil { + t.Fatal(err) + } + }) + + step("names outside the prefix are refused", func(t *testing.T) { + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: "mysql"}); err == nil { + t.Error("EnsureBuckety(mysql) accepted") + } + if err := d.DeleteBuckety(ctx, registry.DeleteRequest{Name: "mysql"}); err == nil { + t.Error("DeleteBuckety(mysql) accepted") + } + if err := d.RevokeAccess(ctx, "root@localhost"); err == nil { + t.Error("RevokeAccess(root@localhost) accepted") + } + if err := d.RevokeAccess(ctx, cfg.AdminUser+"@%"); err == nil { + t.Error("RevokeAccess of the admin account accepted") + } + }) + + // Optional: a userHost other than the default that also admits + // the test's address (127.0.0.1 through a port-forward). "auto" + // takes the address the server sees this test connect from. + alt := os.Getenv("BUCKETY_MYSQL_TEST_ALT_USER_HOST") + if alt == "auto" { + var self string + if err := admin.QueryRowContext(ctx, "SELECT HOST FROM information_schema.PROCESSLIST WHERE ID = CONNECTION_ID()").Scan(&self); err != nil { + t.Fatalf("own address for BUCKETY_MYSQL_TEST_ALT_USER_HOST=auto: %v", err) + } + if h, _, err := net.SplitHostPort(self); err == nil { + self = h + } + alt = self + if alt == cfg.UserHost || checkHost(alt) != nil { + t.Logf("connecting from %q: no usable alternative userHost, skipping the userHost change", self) + alt = "" + } + } + if alt != "" { + step("userHost change moves the account", func(t *testing.T) { + altCfg := *cfg + altCfg.UserHost = alt + d2 := &Driver{cfg: &altCfg, conn: d.conn} + moved, err := d2.GrantAccess(ctx, registry.GrantRequest{BucketyName: db, AccessNamespace: run, AccessName: "rw", Role: "ReadWrite", ExistingSecretData: rw.SecretData}) + if err != nil { + t.Fatalf("grant under the new userHost: %v", err) + } + principals = append(principals, moved.Principal) + if moved.Principal == rw.Principal || !moved.Minted { + t.Fatalf("principal %q minted=%v", moved.Principal, moved.Minted) + } + // What the reconciler does next: revoke the replaced principal. + if err := d.RevokeAccess(ctx, rw.Principal); err != nil { + t.Fatal(err) + } + mustExec(t, connect(t, moved.SecretData), "INSERT INTO items (id, name) VALUES (5, 'five')") + // And back, which passes through the shadowed case when + // the alternative host is the more specific one. + back := grant(t, rwReq, moved.SecretData) + if err := d.RevokeAccess(ctx, moved.Principal); err != nil { + t.Fatal(err) + } + if back.Principal != rw.Principal { + t.Fatalf("principal after moving back: %q", back.Principal) + } + back = grant(t, rwReq, moved.SecretData) + mustExec(t, connect(t, back.SecretData), "DELETE FROM items WHERE id = 5") + rw = back + }) + } + + step("revoke drops the users", func(t *testing.T) { + for _, res := range []registry.GrantResult{rw, ro} { + for i := 0; i < 2; i++ { + if err := d.RevokeAccess(ctx, res.Principal); err != nil { + t.Fatalf("RevokeAccess #%d: %v", i, err) + } + } + loginDenied(t, res.SecretData) + } + }) + + step("Retain keeps the database and its data", func(t *testing.T) { + // Retain means the controller never calls DeleteBuckety; + // the accesses are gone and the data must still be there. + var n int + if err := admin.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+quoteIdent(db)+".items").Scan(&n); err != nil || n != 3 { + t.Fatalf("retained rows: %d, %v", n, err) + } + if in, err := d.InspectBuckety(ctx, db); err != nil || !in.Exists || in.Empty { + t.Fatalf("retained database: %+v, %v", in, err) + } + }) + + step("Delete drops the database", func(t *testing.T) { + for i := 0; i < 2; i++ { + if err := d.DeleteBuckety(ctx, registry.DeleteRequest{Name: db}); err != nil { + t.Fatalf("DeleteBuckety #%d: %v", i, err) + } + } + if in, err := d.InspectBuckety(ctx, db); err != nil || in.Exists { + t.Fatalf("after delete: %+v, %v", in, err) + } + }) +} diff --git a/pkg/drivers/mysql/names.go b/pkg/drivers/mysql/names.go new file mode 100644 index 0000000..218a394 --- /dev/null +++ b/pkg/drivers/mysql/names.go @@ -0,0 +1,181 @@ +package mysql + +import ( + "crypto/sha256" + "encoding/base32" + "fmt" + "regexp" + "slices" + "strings" +) + +// Name limits. MariaDB allows 80-character user names but MySQL 8 +// only 32; the driver keeps to the common limit so one naming +// scheme works on both. +const ( + maxDatabaseNameLen = 64 + maxUserNameLen = 32 + maxHostLen = 255 + maxPrefixLen = 10 + userHashLen = 16 +) + +var ( + // prefixRE: a letter, then letters/digits, ending in "_". The + // trailing underscore keeps the prefix off every system schema + // and account name. + prefixRE = regexp.MustCompile(`^[a-z][a-z0-9]*_$`) + // nameRE is the charset for databases and users. Lowercase + // only, so lower_case_table_names cannot alias two names. + nameRE = regexp.MustCompile(`^[a-z0-9_-]+$`) + // hostRE covers host names, IPs, netmasks and the % and _ + // wildcards an operator may use in userHost. Lowercase only: + // servers store hosts lowercased, and the login check compares + // CURRENT_USER() exactly. + hostRE = regexp.MustCompile(`^[a-z0-9.%_:/-]+$`) + // k8sPartRE is a namespace or name usable verbatim in a user + // name: Kubernetes characters without dots. + k8sPartRE = regexp.MustCompile(`^[a-z0-9-]+$`) +) + +// systemNames are schemas, accounts and roles the driver never +// touches, whatever the prefix. +var systemNames = []string{ + "mysql", "information_schema", "performance_schema", "sys", + "root", "mariadb.sys", "mysql.sys", "mysql.session", "mysql.infoschema", + "public", "healthcheck", +} + +func checkPrefix(p string) error { + if len(p) > maxPrefixLen || !prefixRE.MatchString(p) { + return fmt.Errorf("namePrefix %q must be 2-%d characters: a lowercase letter, then lowercase letters or digits, ending with \"_\"", p, maxPrefixLen) + } + for _, s := range systemNames { + if strings.HasPrefix(s, p) { + return fmt.Errorf("namePrefix %q matches the system name %q", p, s) + } + } + return nil +} + +func checkHost(h string) error { + if h == "" || len(h) > maxHostLen || !hostRE.MatchString(h) { + return fmt.Errorf("host %q must be 1-%d characters of lowercase letters, digits and . %% _ : / -", h, maxHostLen) + } + return nil +} + +// checkManaged is the prefix boundary: every database and user +// name reaches SQL only through here. +func (d *Driver) checkManaged(kind, name string, maxLen int) error { + p := d.cfg.NamePrefix + switch { + case !strings.HasPrefix(name, p): + return fmt.Errorf("%s name %q is outside this backend's namePrefix %q; the driver only touches names that start with it", kind, name, p) + case len(name) == len(p): + return fmt.Errorf("%s name %q is only the namePrefix", kind, name) + case len(name) > maxLen: + return fmt.Errorf("%s name %q is %d characters; the limit is %d", kind, name, len(name), maxLen) + case !nameRE.MatchString(name): + return fmt.Errorf("%s name %q may only contain lowercase letters, digits, \"_\" and \"-\"", kind, name) + case slices.Contains(systemNames, name): + return fmt.Errorf("%s name %q is reserved", kind, name) + } + return nil +} + +func (d *Driver) checkDatabase(name string) error { + return d.checkManaged("database", name, maxDatabaseNameLen) +} + +func (d *Driver) checkUser(name string) error { + if err := d.checkManaged("user", name, maxUserNameLen); err != nil { + return err + } + if name == d.cfg.AdminUser { + return fmt.Errorf("user name %q is the controller's own account", name) + } + return nil +} + +// userName derives the account name for a BucketyAccess. +// +// Plain form: _, when both parts are +// dot-free and the result fits. Otherwise the hashed form: +// __, where readable is the dot-free, +// underscore-free start of namespace-name and hash is 16 base32 +// characters (80 bits) of sha256(namespace/name). +// +// Kubernetes namespaces and names contain no "_", so the plain +// form has exactly one underscore after the prefix and the hashed +// form exactly two, adjacent: the forms cannot collide, and two +// accesses share a user only through a sha256 prefix collision. +func (d *Driver) userName(namespace, name string) (string, error) { + if namespace == "" || name == "" { + return "", fmt.Errorf("mysql: the grant request names no BucketyAccess namespace/name; the controller binary is older than this driver") + } + p := d.cfg.NamePrefix + u := p + namespace + "_" + name + if !k8sPartRE.MatchString(namespace) || !k8sPartRE.MatchString(name) || len(u) > maxUserNameLen { + sum := sha256.Sum256([]byte(namespace + "/" + name)) + h := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]))[:userHashLen] + readable := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + return r + } + return '-' + }, namespace+"-"+name) + room := maxUserNameLen - len(p) - 2 - userHashLen + if len(readable) > room { + readable = readable[:room] + } + u = p + readable + "__" + h + } + if err := d.checkUser(u); err != nil { + return "", fmt.Errorf("mysql: derived %w", err) + } + return u, nil +} + +// account is a MySQL account: 'user'@'host'. +type account struct { + User string + Host string +} + +// String is the principal form stamped into status.principal. +func (a account) String() string { return a.User + "@" + a.Host } + +// grantee is the GRANTEE column format of information_schema. +func (a account) grantee() string { return "'" + a.User + "'@'" + a.Host + "'" } + +// parsePrincipal turns a status.principal back into an account, +// refusing anything outside the prefix. Principals come from +// status, so they are validated like any other input. +func (d *Driver) parsePrincipal(principal string) (account, error) { + user, host, ok := strings.Cut(principal, "@") + if !ok { + return account{}, fmt.Errorf("mysql: principal %q is not user@host; refusing to drop it", principal) + } + if err := d.checkUser(user); err != nil { + return account{}, fmt.Errorf("mysql: refusing to drop principal %q: %w", principal, err) + } + if err := checkHost(host); err != nil { + return account{}, fmt.Errorf("mysql: refusing to drop principal %q: %w", principal, err) + } + return account{User: user, Host: host}, nil +} + +// quoteIdent backtick-quotes an identifier. Callers pass validated +// names; the doubling keeps the quoting correct regardless. +func quoteIdent(s string) string { + return "`" + strings.ReplaceAll(s, "`", "``") + "`" +} + +// grantPattern escapes the LIKE wildcards that GRANT ... ON db.* +// interprets in database names: an unescaped "_" in b_t1_orders +// would also grant on b_t1xorders. The escaped form is also what +// information_schema.SCHEMA_PRIVILEGES reports as TABLE_SCHEMA. +func grantPattern(db string) string { + return strings.NewReplacer(`\`, `\\`, `_`, `\_`, `%`, `\%`).Replace(db) +} diff --git a/pkg/drivers/mysql/names_test.go b/pkg/drivers/mysql/names_test.go new file mode 100644 index 0000000..6d357e8 --- /dev/null +++ b/pkg/drivers/mysql/names_test.go @@ -0,0 +1,209 @@ +package mysql + +import ( + "crypto/sha256" + "encoding/base32" + "fmt" + "strings" + "testing" +) + +func testDriver(fc *fakeConn) *Driver { + return &Driver{ + cfg: &Config{ + Host: "mysql.mysql.svc.cluster.local", Port: 3306, + AdminUser: "buckety", AdminPassword: "not-used-by-fakes", + NamePrefix: "b_", UserHost: "%", + }, + conn: fc, + } +} + +func TestCheckPrefix(t *testing.T) { + for _, p := range []string{"b_", "buckety_", "x1_", "abcdefghi_"} { + if err := checkPrefix(p); err != nil { + t.Errorf("checkPrefix(%q): %v", p, err) + } + } + for _, p := range []string{ + "", "b", "_", "B_", "1_", "b-", "b__x", "b.", "abcdefghij_", + "performance_", "information_", // system schemas start with these + } { + if err := checkPrefix(p); err == nil { + t.Errorf("checkPrefix(%q) accepted", p) + } + } +} + +func TestCheckDatabase(t *testing.T) { + d := testDriver(nil) + ok := []string{"b_t1_orders", "b_shop_orders", "b_x", "b_" + strings.Repeat("a", 62)} + for _, n := range ok { + if err := d.checkDatabase(n); err != nil { + t.Errorf("checkDatabase(%q): %v", n, err) + } + } + bad := []string{ + "orders", "mysql", "information_schema", "keycloak", "", "b_", + "B_x", "b_X", "b_x;drop database mysql", "b_x`", "b_x'", "b_x y", `b_x\`, "b_x.y", + "b_x%", "b_é", "b_x\n", + "b_" + strings.Repeat("a", 63), + } + for _, n := range bad { + if err := d.checkDatabase(n); err == nil { + t.Errorf("checkDatabase(%q) accepted", n) + } + } +} + +func TestValidateResourceNameHintsTemplate(t *testing.T) { + err := testDriver(nil).ValidateResourceName("orders") + if err == nil || !strings.Contains(err.Error(), `spec.name, e.g. "b_${namespace}_${name}"`) { + t.Fatalf("missing-prefix error should suggest a template: %v", err) + } +} + +func TestUserNamePlain(t *testing.T) { + d := testDriver(nil) + u, err := d.userName("shop", "orders") + if err != nil || u != "b_shop_orders" { + t.Fatalf("userName = %q, %v", u, err) + } +} + +func TestUserNameHashed(t *testing.T) { + d := testDriver(nil) + cases := [][2]string{ + {"a-rather-long-namespace-name", "orders"}, // too long + {"t1", "orders.v2"}, // dot + {strings.Repeat("n", 63), strings.Repeat("m", 253)}, + } + for _, c := range cases { + u, err := d.userName(c[0], c[1]) + if err != nil { + t.Fatalf("userName(%q, %q): %v", c[0], c[1], err) + } + if len(u) > maxUserNameLen || !strings.HasPrefix(u, "b_") || strings.Count(u[2:], "_") != 2 || !strings.Contains(u, "__") { + t.Errorf("userName(%q, %q) = %q: not the hashed form", c[0], c[1], u) + } + again, _ := d.userName(c[0], c[1]) + if again != u { + t.Errorf("userName not deterministic: %q vs %q", u, again) + } + } +} + +// An access named after another access's hash must not land on +// that access's user: the plain form has one underscore after the +// prefix, the hashed form two. +func TestUserNameFormsCannotCollide(t *testing.T) { + d := testDriver(nil) + victim, err := d.userName("a-rather-long-namespace-name", "orders") + if err != nil { + t.Fatal(err) + } + body := strings.TrimPrefix(victim, "b_") + readable, hash, _ := strings.Cut(body, "__") + attacker, err := d.userName(readable, hash) + if err != nil { + t.Fatal(err) + } + if attacker == victim { + t.Fatalf("namespace %q name %q maps onto %q", readable, hash, victim) + } + // Same with a single underscore spliced in by a plain-form + // lookalike. + attacker2, _ := d.userName(readable+"-", "-"+hash) + if attacker2 == victim { + t.Fatalf("lookalike maps onto %q", victim) + } +} + +func TestUserNameUnique(t *testing.T) { + d := testDriver(nil) + seen := map[string]string{} + for i := 0; i < 3000; i++ { + for _, pair := range [][2]string{ + {fmt.Sprintf("ns%d", i%37), fmt.Sprintf("a%d", i)}, + {fmt.Sprintf("long-namespace-%d", i%41), fmt.Sprintf("access-%d", i)}, + {"t1", fmt.Sprintf("x.%d", i)}, + {"t1", fmt.Sprintf("x-%d", i)}, + } { + u, err := d.userName(pair[0], pair[1]) + if err != nil { + t.Fatal(err) + } + key := pair[0] + "/" + pair[1] + if prev, dup := seen[u]; dup && prev != key { + t.Fatalf("%q and %q both map to %q", prev, key, u) + } + seen[u] = key + } + } +} + +func TestUserNameHashIsSHA256Prefix(t *testing.T) { + d := testDriver(nil) + u, _ := d.userName("a-rather-long-namespace-name", "orders") + sum := sha256.Sum256([]byte("a-rather-long-namespace-name/orders")) + want := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]))[:userHashLen] + if !strings.HasSuffix(u, "__"+want) { + t.Fatalf("%q does not end with the sha256 prefix %q", u, want) + } +} + +func TestUserNameNeedsIdentity(t *testing.T) { + if _, err := testDriver(nil).userName("", "x"); err == nil { + t.Fatal("empty namespace accepted") + } +} + +func TestCheckUserRefusesAdmin(t *testing.T) { + d := testDriver(nil) + d.cfg.AdminUser = "b_admin" // config validation forbids this; the check holds regardless + if err := d.checkUser("b_admin"); err == nil { + t.Fatal("admin account accepted as a managed user") + } +} + +func TestParsePrincipal(t *testing.T) { + d := testDriver(nil) + a, err := d.parsePrincipal("b_t1_reader@%") + if err != nil || a != (account{"b_t1_reader", "%"}) { + t.Fatalf("parsePrincipal = %+v, %v", a, err) + } + if a.String() != "b_t1_reader@%" || a.grantee() != "'b_t1_reader'@'%'" { + t.Fatalf("formats: %q %q", a.String(), a.grantee()) + } + if _, err := d.parsePrincipal("b_t1_reader@10.42.0.0/255.255.0.0"); err != nil { + t.Errorf("netmask host: %v", err) + } + for _, p := range []string{ + "root@localhost", "buckety@%", "keycloak@%", "b_x", "@%", "b_x@", + "b_x@'%'", "b_x@%' OR 1=1", "b_x@% ", "B_x@%", "b_@%", "mysql.sys@localhost", "b_x@LOCALHOST", + } { + if _, err := d.parsePrincipal(p); err == nil { + t.Errorf("parsePrincipal(%q) accepted", p) + } + } +} + +func TestQuoting(t *testing.T) { + if got := quoteIdent("a`b"); got != "`a``b`" { + t.Errorf("quoteIdent = %s", got) + } + if got := grantPattern("b_t1_orders"); got != `b\_t1\_orders` { + t.Errorf("grantPattern = %s", got) + } + if got := grantPattern(`b_a%\`); got != `b\_a\%\\` { + t.Errorf("grantPattern = %s", got) + } +} + +// The example in docs/mysql.md. +func TestUserNameDocExample(t *testing.T) { + u, err := testDriver(nil).userName("e2e-mysql-mariadb-mysql-happy-path", "orders") + if err != nil || u != "b_e2e-mysql-ma__eug6f3psticceco5" { + t.Fatalf("userName = %q, %v", u, err) + } +} diff --git a/pkg/drivers/mysql/password.go b/pkg/drivers/mysql/password.go new file mode 100644 index 0000000..88434dc --- /dev/null +++ b/pkg/drivers/mysql/password.go @@ -0,0 +1,92 @@ +package mysql + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "regexp" + "strings" + + gomysql "github.com/go-sql-driver/mysql" +) + +// Passwords are drawn from the URL-unreserved characters: they need +// no escaping in a SQL string literal, a mysql:// URL, a shell +// heredoc or an env var. Generated ones contain all four character +// classes so password-policy plugins (simple_password_check) accept +// them. +const ( + passwordLen = 32 + minPasswordLen = 16 + maxPasswordLen = 128 + passwordSpecials = "-._~" + passwordAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + passwordSpecials +) + +var passwordRE = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) + +func generatePassword() (string, error) { + max := big.NewInt(int64(len(passwordAlphabet))) + b := make([]byte, passwordLen) + for { + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("mysql: generate password: %w", err) + } + b[i] = passwordAlphabet[n.Int64()] + } + if hasAllClasses(string(b)) { + return string(b), nil + } + } +} + +func hasAllClasses(s string) bool { + return strings.ContainsAny(s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") && + strings.ContainsAny(s, "abcdefghijklmnopqrstuvwxyz") && + strings.ContainsAny(s, "0123456789") && + strings.ContainsAny(s, passwordSpecials) +} + +// validatePassword checks a password taken from an access Secret. +// The error never contains the value. +func validatePassword(p string) error { + if len(p) < minPasswordLen || len(p) > maxPasswordLen || !passwordRE.MatchString(p) { + return fmt.Errorf("password must be %d-%d characters of A-Z a-z 0-9 and %s", minPasswordLen, maxPasswordLen, passwordSpecials) + } + return nil +} + +// redact removes secret from an error's text. Server errors can +// quote the statement, and the statement can carry a password. +func redact(err error, secret string) error { + if err == nil || secret == "" || !strings.Contains(err.Error(), secret) { + return err + } + return redactedError(strings.ReplaceAll(err.Error(), secret, "")) +} + +// statementError describes an error from a statement that carried a +// password. A server error keeps only its number: messages such as +// a parse error's "near '...'" quote the statement and may cut it +// mid-password, which redact cannot catch. +func statementError(err error, password string) error { + var me *gomysql.MySQLError + if errors.As(err, &me) { + hint := "" + switch me.Number { + case 1819: + hint = ": the server's password policy rejected the password" + case 1227: + hint = ": the controller's account lacks the CREATE USER privilege" + } + return redactedError(fmt.Sprintf("server error %d%s", me.Number, hint)) + } + return redact(err, password) +} + +type redactedError string + +func (e redactedError) Error() string { return string(e) } diff --git a/pkg/drivers/mysql/password_test.go b/pkg/drivers/mysql/password_test.go new file mode 100644 index 0000000..e32dc23 --- /dev/null +++ b/pkg/drivers/mysql/password_test.go @@ -0,0 +1,62 @@ +package mysql + +import ( + "errors" + "strings" + "testing" +) + +func TestGeneratePassword(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 200; i++ { + p, err := generatePassword() + if err != nil { + t.Fatal(err) + } + if len(p) != passwordLen || !passwordRE.MatchString(p) || !hasAllClasses(p) { + t.Fatalf("generated password %d does not meet the policy", i) + } + if err := validatePassword(p); err != nil { + t.Fatalf("generated password rejected: %v", err) + } + if seen[p] { + t.Fatal("generated password repeated") + } + seen[p] = true + } +} + +func TestValidatePassword(t *testing.T) { + if err := validatePassword("Abcdefgh-1234567"); err != nil { + t.Errorf("16 safe characters: %v", err) + } + bad := []string{ + "", "short-1A", strings.Repeat("a", 129), + "Abcdefgh-123456'", `Abcdefgh-123456\`, "Abcdefgh-123456 ", "Abcdefgh-123456@", + "Abcdefgh-123456;", "Abcdefgh-123456`", "Abcdefgh-123456ä", "Abcdefgh-123456\n", + } + for _, p := range bad { + err := validatePassword(p) + if err == nil { + t.Errorf("password %d accepted", len(p)) + continue + } + if p != "" && strings.Contains(err.Error(), p) { + t.Errorf("validation error echoes the password") + } + } +} + +func TestRedact(t *testing.T) { + err := redact(errors.New("near 'Secret-Value-123' at line 1"), "Secret-Value-123") + if strings.Contains(err.Error(), "Secret-Value-123") || !strings.Contains(err.Error(), "") { + t.Fatalf("redact = %v", err) + } + orig := errors.New("unrelated") + if redact(orig, "x-y-z") != orig { + t.Fatal("redact replaced an error without the secret") + } + if redact(nil, "x") != nil { + t.Fatal("redact(nil)") + } +} diff --git a/pkg/drivers/mysql/schema/v0.1/config.schema.json b/pkg/drivers/mysql/schema/v0.1/config.schema.json new file mode 100644 index 0000000..707629f --- /dev/null +++ b/pkg/drivers/mysql/schema/v0.1/config.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "mysql driver backend config", + "description": "Schema for the config: block under a backend entry whose driver is mysql (MariaDB 10.11+, MySQL 8). The controller connects with the admin account below; every database and user it manages starts with namePrefix, and it refuses names outside it. See docs/mysql.md for the grants the admin account needs and why CREATE USER makes the prefix the security boundary.", + "type": "object", + "additionalProperties": false, + "required": ["host", "adminUser", "adminPassword"], + "properties": { + "host": { + "type": "string", + "minLength": 1, + "description": "Server host name or IP, without scheme or port, e.g. mysql.mysql.svc.cluster.local. Also written to access Secrets (host, jdbcUrl, url)." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "default": 3306, + "description": "Server port. Also written to access Secrets." + }, + "adminUser": { + "type": "string", + "minLength": 1, + "description": "The controller's account. Must not start with namePrefix. envsubst:\"true\" - supports ${VAR} interpolation at controller startup." + }, + "adminPassword": { + "type": "string", + "minLength": 1, + "description": "The controller's password; use ${VAR} interpolation from a Secret-sourced env var rather than a literal. A line break is a startup error (the usual cause is a Secret created from a file with a trailing newline). envsubst:\"true\"." + }, + "namePrefix": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{0,8}_$", + "default": "b_", + "description": "Every database and user the driver creates or touches starts with this prefix; Buckety names (spec.name) must resolve to it, e.g. b_${namespace}_${name}. The admin account's database grant is scoped to the same prefix (GRANT ALL ON `b\\_%`.*). Fixed for the backend's lifetime: resources stamped under one prefix are refused under another." + }, + "userHost": { + "type": "string", + "pattern": "^[a-z0-9.%_:/-]{1,255}$", + "default": "%", + "description": "Host part of created accounts ('user'@''), lowercase as the server stores it. Must also admit the controller's own address: the driver verifies passwords and grants by logging in as each user. Changing it moves accesses to new accounts on their next reconcile and drops the old ones." + }, + "tls": { + "type": "object", + "additionalProperties": false, + "description": "Present (even as {}) to require verified TLS on the controller's connections. Absent means plaintext. Does not add REQUIRE SSL to created users; enforce TLS for consumers server-side with require_secure_transport.", + "properties": { + "caFile": { + "type": "string", + "description": "PEM bundle (mounted into the controller Pod) to verify the server certificate with. System roots when omitted." + }, + "serverName": { + "type": "string", + "description": "Name to verify in the server certificate. Defaults to host." + } + } + } + } +} diff --git a/pkg/drivers/mysql/schema/v0.1/parameters.schema.json b/pkg/drivers/mysql/schema/v0.1/parameters.schema.json new file mode 100644 index 0000000..912503d --- /dev/null +++ b/pkg/drivers/mysql/schema/v0.1/parameters.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "mysql driver Buckety parameters", + "description": "Schema for Buckety.spec.parameters when the resolved driver is mysql. All values are strings per the CRD contract. Omitted parameters are unmanaged: the server's character_set_server/collation_server defaults apply at creation and the driver never compares them afterwards. Both parameters are set at creation and immutable; the driver never alters an existing database's defaults (that would not convert its tables), so a database whose defaults differ - changed out of band, or adopted - surfaces ParameterDrift.", + "type": "object", + "additionalProperties": false, + "properties": { + "characterSet": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{1,31}$", + "not": {"const": "utf8"}, + "description": "Default character set of the database, e.g. utf8mb4. \"utf8\" is refused as ambiguous (utf8mb3 today, utf8mb4 in future server versions); name utf8mb4 or utf8mb3." + }, + "collation": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{1,63}$", + "not": {"pattern": "^utf8_"}, + "description": "Default collation of the database, e.g. utf8mb4_unicode_ci. Use the full name as information_schema.SCHEMATA reports it, or the value will read as drift." + } + } +} diff --git a/pkg/drivers/mysql/statements.go b/pkg/drivers/mysql/statements.go new file mode 100644 index 0000000..d0db3ae --- /dev/null +++ b/pkg/drivers/mysql/statements.go @@ -0,0 +1,124 @@ +package mysql + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// Database-level privileges per BucketyAccess role, spelled as +// information_schema.SCHEMA_PRIVILEGES reports them. +var ( + readerPrivileges = []string{"SELECT"} + readWritePrivileges = []string{ + "SELECT", "INSERT", "UPDATE", "DELETE", + "CREATE", "ALTER", "INDEX", "DROP", "REFERENCES", + "CREATE TEMPORARY TABLES", "LOCK TABLES", + } +) + +// rolePrivileges maps BucketyAccess.spec.role to a grant set. +// Writer is refused rather than guessed: see docs/mysql.md. +func rolePrivileges(role string) ([]string, error) { + switch role { + case "Reader": + return readerPrivileges, nil + case "", "ReadWrite": + return readWritePrivileges, nil + case "Writer": + return nil, fmt.Errorf("mysql: role Writer is not supported, and the access holds no user; use Reader (SELECT) or ReadWrite (data and schema changes)") + default: + return nil, fmt.Errorf("mysql: unknown role %q", role) + } +} + +// privilegeRE: privilege keywords only. Names read back from the +// server are spliced into REVOKE, so they are checked like input. +var privilegeRE = regexp.MustCompile(`^[A-Z]+( [A-Z]+)*$`) + +// Account statements. ? arguments are interpolated client-side as +// escaped string literals (Config.clientConfig); the values are +// validated before they get here. +const ( + createUserSQL = "CREATE USER ?@? IDENTIFIED BY ?" + alterUserSQL = "ALTER USER ?@? IDENTIFIED BY ?" + dropUserSQL = "DROP USER IF EXISTS ?@?" +) + +const ( + schemaSQL = "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?" + // contentSQL counts what makes a database non-empty for the + // adoption gate: tables, views, routines and events. + contentSQL = "SELECT" + + " (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?)" + + " + (SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?)" + + " + (SELECT COUNT(*) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = ?)" + currentUserSQL = "SELECT CURRENT_USER()" + // privilegesSQL runs as the created user, who sees its own rows. + privilegesSQL = "SELECT PRIVILEGE_TYPE, IS_GRANTABLE FROM information_schema.SCHEMA_PRIVILEGES WHERE GRANTEE = ? AND TABLE_SCHEMA = ?" +) + +func createDatabaseSQL(name, charset, collation string) string { + var b strings.Builder + b.WriteString("CREATE DATABASE IF NOT EXISTS ") + b.WriteString(quoteIdent(name)) + if charset != "" { + b.WriteString(" CHARACTER SET ") + b.WriteString(quoteIdent(charset)) + } + if collation != "" { + b.WriteString(" COLLATE ") + b.WriteString(quoteIdent(collation)) + } + return b.String() +} + +func dropDatabaseSQL(name string) string { + return "DROP DATABASE IF EXISTS " + quoteIdent(name) +} + +// grantSQL and revokeSQL take the escaped grant pattern of one +// database; the account goes in as two ? arguments. +func grantSQL(privileges []string, pattern string) (string, error) { + list, err := privilegeList(privileges) + if err != nil { + return "", err + } + return "GRANT " + list + " ON " + quoteIdent(pattern) + ".* TO ?@?", nil +} + +func revokeSQL(privileges []string, pattern string) (string, error) { + list, err := privilegeList(privileges) + if err != nil { + return "", err + } + return "REVOKE " + list + " ON " + quoteIdent(pattern) + ".* FROM ?@?", nil +} + +func revokeGrantOptionSQL(pattern string) string { + return "REVOKE GRANT OPTION ON " + quoteIdent(pattern) + ".* FROM ?@?" +} + +func privilegeList(privileges []string) (string, error) { + if len(privileges) == 0 { + return "", fmt.Errorf("mysql: empty privilege list") + } + for _, p := range privileges { + if !privilegeRE.MatchString(p) { + return "", fmt.Errorf("mysql: unexpected privilege name %q", p) + } + } + return strings.Join(privileges, ", "), nil +} + +// without returns the elements of a not in b, in a's order. +func without(a, b []string) []string { + var out []string + for _, x := range a { + if !slices.Contains(b, x) { + out = append(out, x) + } + } + return out +} diff --git a/pkg/drivers/mysql/statements_test.go b/pkg/drivers/mysql/statements_test.go new file mode 100644 index 0000000..8c95a15 --- /dev/null +++ b/pkg/drivers/mysql/statements_test.go @@ -0,0 +1,84 @@ +package mysql + +import ( + "slices" + "strings" + "testing" +) + +func TestRolePrivileges(t *testing.T) { + r, err := rolePrivileges("Reader") + if err != nil || !slices.Equal(r, []string{"SELECT"}) { + t.Fatalf("Reader = %v, %v", r, err) + } + wantRW := []string{ + "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "INDEX", + "DROP", "REFERENCES", "CREATE TEMPORARY TABLES", "LOCK TABLES", + } + for _, role := range []string{"ReadWrite", ""} { + rw, err := rolePrivileges(role) + if err != nil || !slices.Equal(rw, wantRW) { + t.Fatalf("%q = %v, %v", role, rw, err) + } + } + for _, role := range []string{"Writer", "Owner", "readwrite"} { + if _, err := rolePrivileges(role); err == nil { + t.Errorf("role %q accepted", role) + } + } +} + +func TestGrantAndRevokeSQL(t *testing.T) { + q, err := grantSQL(readWritePrivileges, grantPattern("b_t1_orders")) + if err != nil { + t.Fatal(err) + } + want := "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES ON `b\\_t1\\_orders`.* TO ?@?" + if q != want { + t.Errorf("grantSQL:\n got %s\nwant %s", q, want) + } + q, err = revokeSQL([]string{"INSERT", "EXECUTE"}, grantPattern("b_x")) + if err != nil || q != "REVOKE INSERT, EXECUTE ON `b\\_x`.* FROM ?@?" { + t.Errorf("revokeSQL = %s, %v", q, err) + } + if q := revokeGrantOptionSQL(grantPattern("b_x")); q != "REVOKE GRANT OPTION ON `b\\_x`.* FROM ?@?" { + t.Errorf("revokeGrantOptionSQL = %s", q) + } + for _, bad := range [][]string{nil, {""}, {"select"}, {"SELECT; DROP DATABASE mysql"}, {"ALL PRIVILEGES ON *.* TO x"}, {"SELECT,INSERT"}} { + if _, err := revokeSQL(bad, "b\\_x"); err == nil { + t.Errorf("revokeSQL(%q) accepted", bad) + } + } +} + +func TestDatabaseSQL(t *testing.T) { + cases := map[string][3]string{ + "CREATE DATABASE IF NOT EXISTS `b_x`": {"b_x", "", ""}, + "CREATE DATABASE IF NOT EXISTS `b_x` CHARACTER SET `utf8mb4`": {"b_x", "utf8mb4", ""}, + "CREATE DATABASE IF NOT EXISTS `b_x` CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`": {"b_x", "utf8mb4", "utf8mb4_unicode_ci"}, + "CREATE DATABASE IF NOT EXISTS `b_x-y` COLLATE `latin1_swedish_ci`": {"b_x-y", "", "latin1_swedish_ci"}, + } + for want, in := range cases { + if got := createDatabaseSQL(in[0], in[1], in[2]); got != want { + t.Errorf("createDatabaseSQL%v = %s", in, got) + } + } + if got := dropDatabaseSQL("b_x"); got != "DROP DATABASE IF EXISTS `b_x`" { + t.Errorf("dropDatabaseSQL = %s", got) + } +} + +func TestAccountStatementsUsePlaceholders(t *testing.T) { + for _, q := range []string{createUserSQL, alterUserSQL, dropUserSQL} { + if !strings.Contains(q, "?@?") { + t.Errorf("%q does not pass the account as arguments", q) + } + } +} + +func TestWithout(t *testing.T) { + got := without([]string{"a", "b", "c"}, []string{"b"}) + if !slices.Equal(got, []string{"a", "c"}) { + t.Fatalf("without = %v", got) + } +} diff --git a/pkg/drivers/registry/registry.go b/pkg/drivers/registry/registry.go index 648380a..f334138 100644 --- a/pkg/drivers/registry/registry.go +++ b/pkg/drivers/registry/registry.go @@ -138,6 +138,11 @@ type GrantRequest struct { // BucketyName is the resolved backend resource name the // access is being minted for. BucketyName string + // AccessNamespace and AccessName identify the BucketyAccess. + // Drivers that create one backend principal per access (mysql + // users) derive its name from them; the others ignore them. + AccessNamespace string + AccessName string // Role is BucketyAccess.spec.role. v1alpha1 drivers // typically ignore this and return Scoped=false. Role string diff --git a/schema/README.md b/schema/README.md index 8e1ce6c..5e6ea46 100644 --- a/schema/README.md +++ b/schema/README.md @@ -23,6 +23,7 @@ nothing else: | `buckety-gcs.schema.json` | the full gcs driver set | | `buckety-s3.schema.json` | the full s3 driver set | | `buckety-kadm.schema.json` | the kadm driver set (`partitions`, `replicationFactor`, `config.*`) - kadm is not in a family | +| `buckety-mysql.schema.json` | the mysql driver set (`characterSet`, `collation`) - mysql is not in a family | | `bucketyaccess.schema.json` | (BucketyAccess; parameters unconstrained) | Editor validation is documentation and early feedback, not diff --git a/schema/buckety-mysql.schema.json b/schema/buckety-mysql.schema.json new file mode 100644 index 0000000..0440e56 --- /dev/null +++ b/schema/buckety-mysql.schema.json @@ -0,0 +1,211 @@ +{ + "$id": "https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Standalone editor schema for a Buckety whose backend resolves to the mysql driver (a MariaDB/MySQL database). mysql is not in a driver family; GENERALIZE to buckety for no parameter constraints. spec.name must resolve to a name starting with the backend's namePrefix, e.g. b_${namespace}_${name}; admission enforces it.", + "properties": { + "apiVersion": { + "enum": [ + "buckety.yolean.se/v1alpha1" + ], + "type": "string" + }, + "kind": { + "enum": [ + "Buckety" + ], + "type": "string" + }, + "metadata": { + "properties": { + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "spec": { + "properties": { + "adoption": { + "default": "AdoptEmpty", + "description": "What happens when the resolved backend resource\nalready exists at first reconcile. AdoptEmpty (the\ndefault) adopts only content-free resources; Adopt\nclaims a resource with pre-existing content.\nAdopted resources are never deleted from the\nbackend, whatever retentionPolicy says. See SPEC.md\nsection \"Adoption\".\n", + "enum": [ + "AdoptEmpty", + "Adopt" + ], + "type": "string" + }, + "backend": { + "description": "Name of a backend defined in the controller's config\nfile. Immutable after creation.\n", + "minLength": 1, + "type": "string" + }, + "defaultAccess": { + "description": "Single-consumer shortcut. Materialises an implicit\nBucketyAccess of the same name; reclaimed via\nowner-ref when an explicit BucketyAccess arrives or\nthis field is removed. See SPEC.md.\n", + "properties": { + "credentialsSecretName": { + "minLength": 1, + "type": "string" + }, + "role": { + "default": "ReadWrite", + "enum": [ + "Reader", + "Writer", + "ReadWrite" + ], + "type": "string" + } + }, + "required": [ + "credentialsSecretName" + ], + "type": "object" + }, + "name": { + "description": "Optional name template. Resolved once at first\nreconcile and frozen in status.backendResourceName.\nDefaults to metadata.name if omitted. See SPEC.md\nsection \"Naming templates\" for the grammar.\n", + "type": "string" + }, + "parameters": { + "additionalProperties": false, + "description": "Schema for Buckety.spec.parameters when the resolved driver is mysql. All values are strings per the CRD contract. Omitted parameters are unmanaged: the server's character_set_server/collation_server defaults apply at creation and the driver never compares them afterwards. Both parameters are set at creation and immutable; the driver never alters an existing database's defaults (that would not convert its tables), so a database whose defaults differ - changed out of band, or adopted - surfaces ParameterDrift.", + "properties": { + "characterSet": { + "description": "Default character set of the database, e.g. utf8mb4. \"utf8\" is refused as ambiguous (utf8mb3 today, utf8mb4 in future server versions); name utf8mb4 or utf8mb3.", + "not": { + "const": "utf8" + }, + "pattern": "^[a-z][a-z0-9]{1,31}$", + "type": "string" + }, + "collation": { + "description": "Default collation of the database, e.g. utf8mb4_unicode_ci. Use the full name as information_schema.SCHEMATA reports it, or the value will read as drift.", + "not": { + "pattern": "^utf8_" + }, + "pattern": "^[a-z][a-z0-9_]{1,63}$", + "type": "string" + } + }, + "title": "mysql driver Buckety parameters", + "type": "object" + }, + "retentionPolicy": { + "default": "Retain", + "description": "What happens to the backend resource when this\nBuckety is deleted.\n", + "enum": [ + "Retain", + "Delete" + ], + "type": "string" + } + }, + "required": [ + "backend" + ], + "type": "object" + }, + "status": { + "properties": { + "backend": { + "description": "Sticky. Stamped at first reconcile.", + "type": "string" + }, + "backendResourceName": { + "description": "Resolved name template. Sticky from first reconcile.\n", + "type": "string" + }, + "conditions": { + "items": { + "properties": { + "lastTransitionTime": { + "format": "date-time", + "type": "string" + }, + "message": { + "type": "string" + }, + "observedGeneration": { + "format": "int64", + "type": "integer" + }, + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "True", + "False", + "Unknown" + ], + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "status", + "lastTransitionTime", + "reason", + "message" + ], + "type": "object" + }, + "type": "array" + }, + "driver": { + "description": "Sticky. Stamped at first reconcile.", + "type": "string" + }, + "driverBuildVersion": { + "description": "Full SemVer of the binary that most recently\nreconciled this resource. Informational.\n", + "type": "string" + }, + "driverMajor": { + "description": "Sticky. Gates driver-version compatibility. See\nSPEC.md \"Driver versioning\".\n", + "type": "integer" + }, + "observedGeneration": { + "format": "int64", + "type": "integer" + }, + "provenance": { + "description": "Created when the controller made the backend\nresource, Adopted when it claimed a pre-existing\none. Sticky. Absent on resources stamped before\nadoption tracking existed. DeleteBuckety only runs\nfor Created; see SPEC.md section \"Adoption\".\n", + "enum": [ + "Created", + "Adopted" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "title": "Buckety (mysql)", + "type": "object" +} diff --git a/schema/buckety.schema.json b/schema/buckety.schema.json index 205cfa9..b5edff1 100644 --- a/schema/buckety.schema.json +++ b/schema/buckety.schema.json @@ -1,7 +1,7 @@ { "$id": "https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm.", + "description": "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm, buckety-mysql.", "properties": { "apiVersion": { "enum": [ diff --git a/scripts/gen-cr-schemas/main.go b/scripts/gen-cr-schemas/main.go index 8f1dff8..43bef6b 100644 --- a/scripts/gen-cr-schemas/main.go +++ b/scripts/gen-cr-schemas/main.go @@ -13,6 +13,7 @@ // buckety-gcs.schema.json full gcs driver parameters // buckety-s3.schema.json full s3 driver parameters // buckety-kadm.schema.json kadm driver parameters (no family) +// buckety-mysql.schema.json mysql driver parameters (no family) // // Run via `go run ./scripts/gen-cr-schemas` from the repo root; CI // asserts the output is committed (git diff --exit-code -- schema/). @@ -41,7 +42,7 @@ func main() { desc string }{ {"", "", - "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm."}, + "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm, buckety-mysql."}, // "blobstore" is the user-facing name of the object-store // driver family (pkg/drivers/objectstore) - it matches how // consumers talk about these resources (blobs namespaces @@ -54,6 +55,8 @@ func main() { "Standalone editor schema for a Buckety whose backend resolves to the s3 driver. GENERALIZE to buckety-blobstore to keep the resource portable across bucket backends, or to buckety for no parameter constraints."}, {"kadm", "pkg/drivers/kadm/schema/v0.1/parameters.schema.json", "Standalone editor schema for a Buckety whose backend resolves to the kadm driver. kadm is deliberately not in a driver family (families are per service kind); GENERALIZE to buckety for no parameter constraints."}, + {"mysql", "pkg/drivers/mysql/schema/v0.1/parameters.schema.json", + "Standalone editor schema for a Buckety whose backend resolves to the mysql driver (a MariaDB/MySQL database). mysql is not in a driver family; GENERALIZE to buckety for no parameter constraints. spec.name must resolve to a name starting with the backend's namePrefix, e.g. b_${namespace}_${name}; admission enforces it."}, } must(os.MkdirAll("schema", 0o755)) diff --git a/test/e2e/backing-services/kustomization.yaml b/test/e2e/backing-services/kustomization.yaml index d9347d9..6aeabc9 100644 --- a/test/e2e/backing-services/kustomization.yaml +++ b/test/e2e/backing-services/kustomization.yaml @@ -6,3 +6,4 @@ resources: - versitygw.yaml - minio.yaml - fakegcs.yaml +- mariadb diff --git a/test/e2e/backing-services/mariadb-secret.sh b/test/e2e/backing-services/mariadb-secret.sh new file mode 100755 index 0000000..8988ff3 --- /dev/null +++ b/test/e2e/backing-services/mariadb-secret.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# Creates the controller account's password for the MariaDB backing +# service: generated once into Secret mysql-buckety in namespace +# mysql (mounted by MariaDB's first-boot script) and copied to the +# controller namespace (env for ${MYSQL_BUCKETY_PASSWORD}). A pod can +# only mount Secrets from its own namespace, hence two copies. +# Idempotent; never prints the password. Run before +# `kubectl apply -k test/e2e/backing-services`. + +set -euo pipefail + +CONTROLLER_NS="${CONTROLLER_NS:-buckety}" + +for ns in mysql "$CONTROLLER_NS"; do + kubectl create namespace "$ns" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +done + +if ! kubectl -n mysql get secret mysql-buckety >/dev/null 2>&1; then + # 32 characters of [A-Za-z0-9_-], within the bootstrap script's rule. + head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n' \ + | kubectl -n mysql create secret generic mysql-buckety --from-file=password=/dev/stdin >/dev/null + echo "created secret mysql/mysql-buckety" +fi + +# create/replace, not apply: apply would also store the data in the +# last-applied-configuration annotation. +verb=create +kubectl -n "$CONTROLLER_NS" get secret mysql-buckety >/dev/null 2>&1 && verb=replace +kubectl -n mysql get secret mysql-buckety -o json \ + | jq --arg ns "$CONTROLLER_NS" '{apiVersion, kind, type, data, metadata: {name: .metadata.name, namespace: $ns}}' \ + | kubectl "$verb" -f - >/dev/null +echo "secret mysql-buckety present in namespaces mysql and $CONTROLLER_NS" diff --git a/test/e2e/backing-services/mariadb/kustomization.yaml b/test/e2e/backing-services/mariadb/kustomization.yaml new file mode 100644 index 0000000..c388db4 --- /dev/null +++ b/test/e2e/backing-services/mariadb/kustomization.yaml @@ -0,0 +1,12 @@ +# MariaDB for the mysql driver's e2e: one ephemeral node, with the +# controller account created on first boot by the same component +# examples/mysql/bootstrap uses. The account password is the Secret +# mysql-buckety, generated by ../mariadb-secret.sh (no password in +# source), which also copies it to the controller namespace. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: mysql +resources: +- mariadb.yaml +components: +- ../../../../examples/mysql/bootstrap/initdb diff --git a/test/e2e/backing-services/mariadb/mariadb.yaml b/test/e2e/backing-services/mariadb/mariadb.yaml new file mode 100644 index 0000000..fcf056d --- /dev/null +++ b/test/e2e/backing-services/mariadb/mariadb.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: Service +metadata: + name: mysql +spec: + selector: + app: mariadb + ports: + - port: 3306 + targetPort: 3306 + name: mysql +--- +# Named and shaped like kubernetes-mysql-cluster's StatefulSet +# (mariadb/mariadb, root only on localhost) so the initdb component +# patches both, but with an emptyDir data directory and no Galera. +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mariadb +spec: + serviceName: mysql + replicas: 1 + selector: + matchLabels: + app: mariadb + template: + metadata: + labels: + app: mariadb + spec: + containers: + - name: mariadb + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: MYSQL_ROOT_HOST + value: localhost + - name: MYSQL_ALLOW_EMPTY_PASSWORD + value: "yes" + - name: MYSQL_INITDB_SKIP_TZINFO + value: "yes" + ports: + - containerPort: 3306 + name: mysql + # TCP, not mysqladmin over the socket: the entrypoint's + # first-boot server is socket-only, and Ready must mean the + # account exists and the real server listens. + readinessProbe: + tcpSocket: + port: 3306 + periodSeconds: 3 + volumeMounts: + - name: data + mountPath: /var/lib/mysql + volumes: + - name: data + emptyDir: {} diff --git a/test/e2e/backing-services/minio.yaml b/test/e2e/backing-services/minio.yaml index 2a459f9..320588b 100644 --- a/test/e2e/backing-services/minio.yaml +++ b/test/e2e/backing-services/minio.yaml @@ -47,7 +47,9 @@ spec: spec: containers: - name: minio - image: docker.io/minio/minio:RELEASE.2025-09-07T16-13-09Z + # Yolean's mirror: docker.io/minio/minio no longer serves + # anonymous pulls. Its newest release, pinned like the rest. + image: ghcr.io/yolean/minio:RELEASE.2025-07-18T21-56-31Z@sha256:da0fe26595711d0fb93dd28e24552520f68897195f4a23f5a17ffa9924ec3fac env: - name: MINIO_ROOT_USER valueFrom: diff --git a/test/e2e/backing-services/namespaces.yaml b/test/e2e/backing-services/namespaces.yaml index 740eb32..5c182f5 100644 --- a/test/e2e/backing-services/namespaces.yaml +++ b/test/e2e/backing-services/namespaces.yaml @@ -12,3 +12,8 @@ apiVersion: v1 kind: Namespace metadata: name: blobs +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mysql diff --git a/test/e2e/configs/mariadb.yaml b/test/e2e/configs/mariadb.yaml new file mode 100644 index 0000000..9aa698f --- /dev/null +++ b/test/e2e/configs/mariadb.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/pkg/config/schema/buckety-controller.schema.json +# Controller config for e2e against MariaDB (backing-services/mariadb). +# The account and its password are set up by +# backing-services/mariadb-secret.sh and the first-boot script. +backends: + +- name: mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: ${MYSQL_BUCKETY_PASSWORD} + defaults: + zone: e2e diff --git a/test/e2e/lib.sh b/test/e2e/lib.sh index 1b40213..f73a55b 100755 --- a/test/e2e/lib.sh +++ b/test/e2e/lib.sh @@ -6,7 +6,7 @@ # E2E_NAMESPACE The namespace the scenario was applied into. # E2E_KUBECONFIG kubeconfig path (falls back to $KUBECONFIG). # E2E_CONTROLLER_NS Namespace the buckety-controller runs in (default: buckety). -# E2E_IMPLEMENTATION versitygw | minio | redpanda (informational; some assertions branch on it). +# E2E_IMPLEMENTATION redpanda | versitygw | minio | fakegcs | mariadb (informational; some assertions branch on it). # # Downstream consumers writing their own platform e2e against # buckety-controller: do NOT source this file from outside this @@ -17,7 +17,8 @@ # secret_value, resource_absent # # Coupled to this repo's harness (assume namespace/service layout): -# kafka_topic_exists, s3_bucket_exists +# kafka_topic_exists, s3_bucket_exists, gcs_*, mysql_root, +# mysql_database_exists, mysql_login # # The coupled helpers kubectl-run pods in conventional namespaces # (redpanda/, buckety/) reading bootstrap addresses from harness @@ -64,7 +65,9 @@ secret_has_keys() { fi done if [[ ${#missing[@]} -gt 0 ]]; then - kc get "secret/$secret" -o yaml >&2 || true + # Key names only: the values are credentials. + # shellcheck disable=SC2016 # a go-template, not shell + log "Secret/$secret has keys: $(kc get "secret/$secret" -o go-template='{{range $k, $v := .data}}{{$k}} {{end}}' 2>/dev/null || true)" fail "Secret/$secret missing keys: ${missing[*]}" fi } @@ -469,21 +472,32 @@ backend_stickiness_scenario() { log "applying new Buckety against $backend-renamed" kc apply -f "$renamed_cr" wait_ready buckety/sticky-new 120s + # Gone before the original config returns: an access that holds a + # principal of its own (mysql users) cannot be revoked once its + # backend is unregistered, and would hold up namespace cleanup. + kc delete buckety/sticky-new --wait=true --timeout=90s # Deletion with retentionPolicy=Delete blocks while the backend # is missing: removing the finalizer would silently orphan the - # backend resource. + # backend resource. Where the implicit access holds a principal + # of its own (mysql users), it is that access's revoke that + # blocks first, and the Buckety waits for it. log "deleting sticky-orig while its backend is missing; expecting the deletion to block" kc delete buckety/sticky-orig --wait=false - local blocked="" + local blocked="" access_msg="" for _ in $(seq 1 20); do blocked="$(kc get buckety/sticky-orig \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true)" [[ "$blocked" == *"deletion blocked"* ]] && break + if [[ "$blocked" == *"to revoke before teardown"* ]]; then + access_msg="$(kc get bucketyaccess/sticky-orig \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true)" + [[ "$access_msg" == *"cannot revoke principal"* ]] && break + fi sleep 3 done - [[ "$blocked" == *"deletion blocked"* ]] \ - || fail "sticky-orig deletion did not surface 'deletion blocked' (Ready message: '$blocked')" + [[ "$blocked" == *"deletion blocked"* || "$access_msg" == *"cannot revoke principal"* ]] \ + || fail "sticky-orig deletion did not surface 'deletion blocked' (Ready message: '$blocked', access: '$access_msg')" kc get buckety/sticky-orig >/dev/null 2>&1 \ || fail "sticky-orig disappeared while its backend was missing; the backend resource would be orphaned" @@ -629,3 +643,47 @@ scaled_to_zero_scenario() { log "scaled-to-zero PASS" } + +# ---- mysql helpers (coupled camp: assume the e2e MariaDB, a +# StatefulSet mariadb in namespace mysql whose root connects only +# on the local socket, as in kubernetes-mysql-cluster) ---- + +# mysql_root +# Runs SQL as root inside the MariaDB pod; prints rows +# tab-separated without column names. +mysql_root() { + kcg -n "${E2E_MYSQL_NAMESPACE:-mysql}" exec "${E2E_MYSQL_POD:-mariadb-0}" -c mariadb -- \ + mariadb -uroot --batch --skip-column-names -e "$1" +} + +# mysql_database_exists +mysql_database_exists() { + [[ "$(mysql_root "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$1'")" == 1 ]] +} + +# mysql_login +# Connects over TCP with a Secret's username, password and +# database and runs SQL. The password travels on stdin, so it is +# in no command line. Caller checks the exit code. +mysql_login() { + local secret="$1" sql="$2" user db + user="$(secret_value "$secret" username)" + db="$(secret_value "$secret" database)" + secret_value "$secret" password \ + | kcg -n "${E2E_MYSQL_NAMESPACE:-mysql}" exec -i "${E2E_MYSQL_POD:-mariadb-0}" -c mariadb -- \ + sh -c 'IFS= read -r MYSQL_PWD || [ -n "$MYSQL_PWD" ]; export MYSQL_PWD; exec mariadb --protocol=tcp -h127.0.0.1 -u"$1" --batch --skip-column-names "$2" -e "$3"' \ + sh "$user" "$db" "$sql" +} + +# wait_until +# Polls the command every 3s until it succeeds. +wait_until() { + local timeout="$1" what="$2"; shift 2 + log "waiting up to ${timeout}s for: $what" + local deadline=$(( $(date +%s) + timeout )) + while (( $(date +%s) < deadline )); do + "$@" >/dev/null 2>&1 && return 0 + sleep 3 + done + fail "not within ${timeout}s: $what" +} diff --git a/test/e2e/overlay/deployment.yaml b/test/e2e/overlay/deployment.yaml index 4ad1f5e..ed7b9c8 100644 --- a/test/e2e/overlay/deployment.yaml +++ b/test/e2e/overlay/deployment.yaml @@ -43,3 +43,11 @@ spec: key: root-secretkey - name: STORAGE_EMULATOR_HOST value: fake-gcs.blobs.svc.cluster.local:8000 + # Optional so clusters without the MariaDB backing service + # (backing-services/mariadb-secret.sh not run) still start. + - name: MYSQL_BUCKETY_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-buckety + key: password + optional: true diff --git a/test/e2e/run.sh b/test/e2e/run.sh index df68825..4b5cb57 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -8,7 +8,7 @@ # cluster with backing services available. # # Inputs (env): -# IMPLEMENTATIONS Comma-separated. Default: redpanda,versitygw,minio,fakegcs. +# IMPLEMENTATIONS Comma-separated. Default: redpanda,versitygw,minio,fakegcs,mariadb. # Each maps via $IMPL_DRIVER below to a driver. # SCENARIOS Optional comma-separated scenario directory # names (e.g. adoption,backend-stickiness) to run @@ -58,7 +58,7 @@ HERE="$(here)" REPO="$(cd "$HERE/../.." && pwd)" CONTROLLER_NS="${CONTROLLER_NS:-buckety}" KEEP_FAILED="${KEEP_FAILED:-false}" -IMPLEMENTATIONS="${IMPLEMENTATIONS:-redpanda,versitygw,minio,fakegcs}" +IMPLEMENTATIONS="${IMPLEMENTATIONS:-redpanda,versitygw,minio,fakegcs,mariadb}" # Map implementation -> driver. Scenario discovery uses this to # pick which examples//* to run for each implementation. @@ -67,6 +67,7 @@ declare -A IMPL_DRIVER=( [versitygw]=s3 [minio]=s3 [fakegcs]=gcs + [mariadb]=mysql ) # Map implementation -> the backend name its config declares for # the driver-agnostic scenarios. backend-stickiness renames it to @@ -77,6 +78,7 @@ declare -A IMPL_BACKEND=( [versitygw]=s3 [minio]=s3 [fakegcs]=gcs + [mariadb]=mysql ) log() { printf '[run.sh] %s\n' "$*" >&2; } diff --git a/test/integration/mysql.sh b/test/integration/mysql.sh new file mode 100755 index 0000000..f833bdb --- /dev/null +++ b/test/integration/mysql.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Runs the mysql driver's integration test +# (pkg/drivers/mysql/integration_test.go) against real servers in +# Docker, one container per image. CI runs this same script. +# +# Each server gets the controller's account the way a cluster +# does: examples/mysql/bootstrap/initdb/buckety-account.sh runs +# from /docker-entrypoint-initdb.d on first boot, as root on the +# local socket, with root allowed only from localhost. So this +# also tests the bootstrap script against every server image. +# +# Inputs (env): +# MYSQL_IMAGES Space-separated images. Default: every image in +# DEFAULT_IMAGES below. +# KEEP true leaves the containers running. +# +# Needs docker and go. Never prints a password. + +set -euo pipefail + +here() { cd "$(dirname "${BASH_SOURCE[0]}")" && pwd; } +REPO="$(cd "$(here)/../.." && pwd)" +KEEP="${KEEP:-false}" + +# The MariaDB the e2e backing service runs, the next MariaDB LTS, +# and both MySQL lines the driver supports. +DEFAULT_IMAGES=" +ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab +mariadb:11.4.13@sha256:03744ebde1e401fabe7ae7243ae2536d83f4f5ef14b87769cd269e072556c4c3 +mysql:8.0.46@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b +mysql:8.4.11@sha256:6ea90827b1100f8f2ae306a539f86d2c264a26ed435a2a9f75551dd5c3aeb242 +" +read -r -a IMAGES <<<"$(echo ${MYSQL_IMAGES:-$DEFAULT_IMAGES})" + +log() { printf '[mysql-it] %s\n' "$*" >&2; } +fail() { printf '[mysql-it][FAIL] %s\n' "$*" >&2; exit 1; } + +WORK="$(mktemp -d)" +CONTAINERS=() +cleanup() { + if [[ "$KEEP" != true ]]; then + for c in "${CONTAINERS[@]}"; do docker rm -f "$c" >/dev/null 2>&1 || true; done + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +# The password file is mounted like the Secret in a cluster. The +# server's init scripts run as the mysql user, hence world-readable +# inside a directory only this run knows about. +password="$(head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')" +mkdir -p "$WORK/secret" +printf '%s' "$password" >"$WORK/secret/password" +chmod 0755 "$WORK" "$WORK/secret" +chmod 0644 "$WORK/secret/password" + +# start : boots a server with the bootstrap script. +start() { + local image="$1" name="$2" + docker run -d --name "$name" \ + -p 127.0.0.1::3306 \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -e MYSQL_ROOT_HOST=localhost \ + -e MYSQL_INITDB_SKIP_TZINFO=yes \ + -v "$REPO/examples/mysql/bootstrap/initdb/buckety-account.sh:/docker-entrypoint-initdb.d/buckety-account.sh:ro" \ + -v "$WORK/secret:/run/secrets/buckety:ro" \ + "$image" >/dev/null +} + +# wait_account : the account logs in over TCP, which only the +# final server accepts (the entrypoint's init server is socket-only). +wait_account() { + local name="$1" client + for _ in $(seq 1 90); do + client="$(docker exec "$name" sh -c 'command -v mariadb || command -v mysql' 2>/dev/null || true)" + if [[ -n "$client" ]] && docker exec -e MYSQL_PWD="$password" "$name" \ + "$client" --protocol=tcp -h127.0.0.1 -ubuckety -e 'SELECT 1' >/dev/null 2>&1; then + return 0 + fi + if [[ "$(docker inspect -f '{{.State.Running}}' "$name")" != true ]]; then + docker logs --tail 50 "$name" >&2 || true + fail "$name exited" + fi + sleep 2 + done + docker logs --tail 50 "$name" >&2 || true + fail "$name: the buckety account cannot log in after 180s" +} + +failed=() +for i in "${!IMAGES[@]}"; do + image="${IMAGES[$i]}" + name="buckety-mysql-it-$$-$i" + CONTAINERS+=("$name") + log "=== ${image%@*}" + start "$image" "$name" + wait_account "$name" + addr="$(docker port "$name" 3306/tcp | head -n1)" + log "server up at $addr" + # auto: the test uses the address the server sees it connect + # from as a second userHost, which covers moving accounts. + if BUCKETY_MYSQL_TEST_DSN="buckety:${password}@tcp(${addr})/" \ + BUCKETY_MYSQL_TEST_ALT_USER_HOST=auto \ + go -C "$REPO" test ./pkg/drivers/mysql -run '^TestIntegration$' -count=1 -v; then + log "PASS ${image%@*}" + else + log "FAIL ${image%@*}" + docker logs --tail 50 "$name" >&2 || true + failed+=("${image%@*}") + fi +done + +if (( ${#failed[@]} > 0 )); then + fail "failed against: ${failed[*]}" +fi +log "all ${#IMAGES[@]} servers PASS"