Files
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

296 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Shipping Collate / OpenMetadata releases through CloudFront
Each customer gets a Collate deployment at their own host —
`acme.getcolate.io`, `widgets.getcolate.io`, `globex.getcolate.io` — and each customer can
be on a different release. This is the AWS-only design for serving the UI bundle from
CloudFront in that model, and the coordination story when a request lands at one of those
hosts.
## What we want
- **One CloudFront distribution** for every customer (not one per customer).
- **One S3 bucket** for every release. Releases are immutable; promotion is a separate
step from upload.
- **Per-customer version pinning** that updates atomically — no DNS change, no CloudFront
redeploy.
- **Customer's own ALB** continues to serve `/api/*`; CloudFront only handles the UI bundle.
## What we explicitly do NOT want
- A new external data store to maintain (DynamoDB, an extra RDS, a separate Redis). The
customer-version mapping is small (a few hundred entries, two tiny strings each) and
changes rarely (a few writes per week, even at peak). Standing up a data store for that
buys nothing and adds backup, monitoring, IAM, and cost surface.
- Per-customer CloudFront distributions. They give clean isolation but at N customers we
have N distributions to manage, N caches that share no edge state across customers, and
hit the AWS 200-distributions-per-account cap by default. The savings from edge cache
sharing (a thousand customers on v1.12.0 hit the same cached chunk) are the entire
reason the shared model is worth using.
- A lookup that requires Lambda@Edge. The cold start and per-request cost is real
($1+/M, plus 30-60 ms when cold) and we don't need the SDK access Lambda@Edge gives.
## The architecture
```
┌──────────────────────────────────────┐
acme.getcolate.io ───┐ │ CloudFront distribution │
widgets.getcolate.io ───┼─────►│ d1234abc.cloudfront.net │
globex.getcolate.io ───┘ │ │
│ ┌─ behavior: /* ──────────────────┐ │
│ │ origin: S3 │ │ ┌─────────────────────────────┐
│ │ viewer-request: host_router.js │─┼────►│ S3: collate-cdn │
│ │ rewrites /foo → │ │ │ release/v1.11.2/index.html │
│ │ /release/<version>/foo │ │ │ release/v1.12.0/index.html │
│ └──────────────────────────────────┘ │ │ release/v1.13.0-beta/... │
│ │ └─────────────────────────────┘
│ ┌─ behavior: /api/* ──────────────┐ │
│ │ bypass: same host's per- │ │ ┌─────────────────────────────┐
│ │ customer ALB (Option A below) │─┼────►│ Each customer's own ALB │
│ └──────────────────────────────────┘ │ └─────────────────────────────┘
└──────────────────────────────────────┘
```
The CloudFront Function holds the customer→version routing table **as JavaScript object
literal**. Source of truth is the Function's source code in our git repo. Promotion is
a Function code update.
## The Function (no external lookup)
```js
// host_router.js — CloudFront Function v2.0 (no Lambda@Edge, no KVS, no DynamoDB)
//
// Source of truth for which release each customer is pinned to. Edit, commit, deploy.
// CI propagates a change to every edge POP in ~60 s.
const CUSTOMER_VERSIONS = {
acme: 'v1.12.0',
widgets: 'v1.11.2',
globex: 'v1.13.0-beta',
// … N customers
};
// Hosts that don't match a customer slug (apex, www., staging) fall back to the latest
// stable release. Bump this in lockstep with every GA release so new customers that
// haven't been added to CUSTOMER_VERSIONS yet still get a current build.
const DEFAULT_VERSION = 'v1.12.0';
function handler(event) {
const request = event.request;
// /api/* lives on a separate behavior with the customer's own ALB as origin.
// The Function should never see these requests under the current behavior config,
// but guard anyway.
if (request.uri.startsWith('/api/')) {
return request;
}
const host = (request.headers.host && request.headers.host.value) || '';
// Convention: customer slug is the first label of the host.
// acme.getcolate.io -> 'acme'
const slug = host.split('.')[0];
const version = CUSTOMER_VERSIONS[slug] || DEFAULT_VERSION;
// /assets/foo.js -> /release/v1.12.0/assets/foo.js
request.uri = '/release/' + version + request.uri;
return request;
}
```
Function v2.0 has a 10 KB code limit. At ~30 bytes per entry that's ~300 customers
comfortably; well beyond that the design needs revisiting — but if you ever reach 300+
customers on this product, the operational economics of standing up KVS or DynamoDB
will have shifted significantly anyway.
## Promotion flow
1. Edit `CUSTOMER_VERSIONS` in the Function source.
2. Commit, push, open PR. The PR diff IS the promotion record — reviewable, auditable,
git-blame'd.
3. CI runs on merge: pushes the new Function code via `aws cloudfront update-function`
and `publish-function`.
4. ~60 s of edge propagation. Every POP picks up the new code.
A typical promotion PR looks like one line changed:
```diff
const CUSTOMER_VERSIONS = {
- acme: 'v1.12.0',
+ acme: 'v1.12.1',
widgets: 'v1.11.2',
globex: 'v1.13.0-beta',
};
```
That's the entire surface area of a promotion. No DynamoDB write. No KVS API call. No
extra IAM role. No backup story. Just a code change reviewed like any other.
Rollback is symmetric: revert the commit. Canary is "promote one slug first, watch error
metrics, then PR the next batch." Roll-forward on a regression is the same revert.
### Release upload (independent of promotion)
The bundle bytes go to S3 separately, on every release tag, regardless of which customer
ends up using them:
```bash
VERSION="v1.12.0"
aws s3 sync openmetadata-ui/src/main/resources/ui/dist/assets/ \
s3://collate-cdn/release/${VERSION}/assets/ \
--cache-control "public, max-age=31536000, immutable"
aws s3 cp openmetadata-ui/src/main/resources/ui/dist/index.html \
s3://collate-cdn/release/${VERSION}/index.html \
--cache-control "no-cache, must-revalidate" \
--content-type "text/html; charset=utf-8"
```
After this, the release exists in S3 but no customer is using it. Promotion (the PR
above) is what flips customers to it. The decoupling matters: you can sit on a release
in S3 for a week, watching it on staging, before promoting any customer to it.
## Why the Function code is a fine routing table
Honest comparison of the three approaches:
| | Function-embedded (this design) | CloudFront KeyValueStore | DynamoDB + Lambda@Edge |
|---|---|---|---|
| New AWS service to monitor / back up | none | KVS | DynamoDB + Lambda |
| Read latency at edge | ~0 (in-function) | ~1 ms | ~10 ms (warm Lambda) |
| Cold start | none | none | 30-60 ms |
| Per-request cost | $0.10/M Function | $0.10/M Function + $0.04/M KVS | $0.10/M + $1+/M Lambda + DynamoDB reads |
| Promotion surface | git PR | API call (`put-key`) | API call (`update-item`) |
| Audit trail | git history | CloudWatch + KVS audit logs | CloudWatch + DDB streams |
| Capacity ceiling | ~300 customers (10 KB code limit) | millions | millions |
| Concurrent promotion safety | git merge serializes | `IfMatch` ETag | conditional writes |
| Operational ownership | "this is in the repo" | "who paged on this last quarter?" | "who paged on this last quarter?" |
For a product that ships per-customer clusters and reaches dozens-to-low-hundreds of
customers, "the routing table is a file in the repo" wins on every operational axis that
matters. It only loses on capacity ceiling, and the day that becomes a problem we already
have a clear migration target (KVS) without changing anything else in the design.
## API routing — two options, pick one
The Function above only handles UI bundle requests. `/api/*` still has to reach the
customer's own ALB.
### Option A — Separate API host (recommended)
```
acme.getcolate.io → CNAME → CloudFront distribution (this design)
api-acme.getcolate.io → CNAME → acme's ALB
```
SPA's API base URL is derived from the page host at runtime: `https://api-{slug}.getcolate.io/api`.
Pros: CloudFront does one thing well (static delivery). No Lambda@Edge anywhere. Failure
modes are easy to reason about. Cons: SPA has a cookie/CORS story that knows about two
hosts; we already handle this for various integrations.
### Option B — Same host, Lambda@Edge for `/api/*`
CloudFront's `/api/*` behavior runs a Lambda@Edge on origin-request that reads the host
header and rewrites the origin to the right ALB.
Pros: single host per customer. Cons: now we DO have Lambda@Edge (which we explicitly
chose to avoid for routing), and the operational cost is per-customer-API-request, not
just per-promotion. We strongly prefer Option A.
## S3 bucket layout
```
collate-cdn/
└── release/
├── v1.11.5/
│ ├── index.html no-cache, must-revalidate
│ ├── assets/index-Z3O_FBkA.js immutable
│ ├── assets/index-Z3O_FBkA.js.br immutable
│ ├── assets/index-Z3O_FBkA.js.gz immutable
│ ├── assets/vendor-antd-BgrjOjhB.js immutable
│ └── ...
├── v1.12.0/ ← acme + widgets currently here
│ └── ...
└── v1.13.0-beta/ ← globex currently here (canary)
└── ...
```
Releases are immutable once uploaded. The promotion step never modifies S3 contents —
only the Function code that maps `slug → /release/<v>/`.
Disk cost is small: a typical OM bundle is ~12 MB on disk after content-hash dedup,
Brotli+gzip siblings add ~25%, call it 15 MB per release. 100 releases × 15 MB =
1.5 GB. S3 standard rates put that at a few cents per month — keep many releases live
for instant rollback and don't bother with aggressive lifecycle pruning.
## CloudFront cache behaviors
| Path pattern (after Function rewrite) | Edge TTL | Notes |
|---|---|---|
| `/release/<v>/assets/*` | 1 year | Content-addressed; bytes can't change |
| `/release/<v>/index.html`, `/release/<v>/` | 30 s | Concurrent users in one region share one origin hit; ETag layer takes over after 30 s |
| `/api/*` | bypass | Separate behavior to customer ALB (Option A: not via CloudFront at all) |
30 s on the shell is the sweet spot: long enough to dedupe a thousand concurrent reloads
to one origin fetch, short enough that a promotion lands at all customers within ~90 s
end-to-end (60 s Function propagation + 30 s residual edge cache).
## Per-customer branding (without per-customer bundles)
If a customer needs a different logo or accent colour, the right move is to keep one
universal bundle and overlay branding assets at request time:
- Universal default: `/release/v1.12.0/images/logo.png` in S3.
- Per-customer override (optional, only when needed): the Function checks for
`s3://collate-cdn/customer-overrides/<slug>/logo.png` first and rewrites if it exists.
Branding stays out of the build artifact, which means one bundle still serves every
customer and the cache-sharing argument holds.
## Verification after promotion
Two synthetic checks worth running automatically after a promotion PR merges:
```bash
SLUG=acme
EXPECTED_VERSION=v1.12.0
# 1. CloudFront serves the right release for this slug
RESPONSE=$(curl -s "https://${SLUG}.getcolate.io/?nocache=$(uuidgen)")
echo "$RESPONSE" | grep -oE 'index-[A-Za-z0-9_-]+\.js' | sort -u
# Should match the hash from the v1.12.0 build manifest
# 2. The HTML shell is being served fresh from the right S3 prefix
curl -sI "https://${SLUG}.getcolate.io/" \
| grep -i 'x-amz-cf-pop\|via\|x-cache'
# Should show an edge POP near the test runner, and either "Miss from cloudfront"
# (first request after promotion) or "Hit from cloudfront" (within the 30 s edge TTL)
```
CI runs this on every promotion PR after the Function deploys, and fails loud if the
served bundle doesn't match the version we just pinned.
## What's not in this design
- **Per-customer API origin selection inside CloudFront**. Option A keeps `/api/*` off
the CloudFront path entirely. If a customer ever needs single-host behavior, that's
the moment to revisit Option B and accept Lambda@Edge.
- **Multi-region S3 origin failover**. Single bucket in one region; CloudFront's edge
caching handles regional reach. If you want CRR + origin groups, add them; the cost
is straightforward but rarely justified for a UI bundle.
- **WAF / Shield Advanced**. Add separately if your security posture requires them.
## What this design is good for and what would push it elsewhere
- **Good for**: dozens to low-hundreds of customers, infrequent promotion (a few per
week), engineering ownership over the routing table.
- **Push toward KVS** when: customer count grows past a few hundred (function size
pressure) OR promotions happen via a non-engineering UI (a customer-success dashboard
that flips slugs without a git PR).
- **Push toward Lambda@Edge** when: routing decisions stop being a slug→version map and
start needing per-request information not available in the host header (e.g. A/B
testing by user ID, geo-routing, header-derived feature flags).
When those days come, the migration path from this design is small — the Function code
becomes a `kvs.get(slug)` instead of a hash lookup, and the rest of the architecture
(S3 layout, distribution behaviors, ALB routing) is identical.