diff --git a/.claude/skills/tail-logs/SKILL.md b/.claude/skills/tail-logs/SKILL.md index 8974cf5c9bb..b9b8ee2c1f5 100644 --- a/.claude/skills/tail-logs/SKILL.md +++ b/.claude/skills/tail-logs/SKILL.md @@ -15,7 +15,7 @@ Tail Loki logs from a laptop. The script handles auth (SSM bearer token), URL re - Correlating a request id, job id, or realm name across services - Cross-checking that something just shipped a log line you expect -If the user is asking what *changed* (deploy diffs, code history) rather than what *happened*, this is the wrong tool — go to git/PR APIs instead. +If the user is asking what _changed_ (deploy diffs, code history) rather than what _happened_, this is the wrong tool — go to git/PR APIs instead. ## How to invoke @@ -39,19 +39,19 @@ Common flag patterns: ./scripts/tail-logs.sh --env production --service synapse --since 30m --no-follow --confirm ``` -| Flag | Default | Notes | -|------|---------|-------| -| `--env` | (required) | `local`, `staging`, `production` | -| `--service` | (required) | `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` (or any local container name) | -| `--realm` | unset | Filter to a single realm. Only set on tasks where realm is meaningful. | -| `--worker-id` | unset | Per-Fargate-task id; only available on workers. | -| `--filter` | unset | LogQL line-filter (`\|=` literal substring). Mutually exclusive with `--regex`. | -| `--regex` | unset | LogQL line-regex (`\|~`). | -| `--since` | `15m` | `30s`, `15m`, `1h`, `2d` — anything matching `^\d+[smhd]$`. | -| `--limit` | `200` | Max lines per batch. | -| `--no-follow` | follow on | One-shot mode for diagnostics. **Always use `--no-follow` from a skill** — follow mode is interactive. | -| `--json` | text | Raw Loki response per batch. Use when you need to parse beyond the formatted line. | -| `--confirm` | n/a | Required for `--env production`. | +| Flag | Default | Notes | +| ------------- | ---------- | ------------------------------------------------------------------------------------------------------ | +| `--env` | (required) | `local`, `staging`, `production` | +| `--service` | (required) | `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` (or any local container name) | +| `--realm` | unset | Filter to a single realm. Only set on tasks where realm is meaningful. | +| `--worker-id` | unset | One worker process, `-pid-`; only available on workers. | +| `--filter` | unset | LogQL line-filter (`\|=` literal substring). Mutually exclusive with `--regex`. | +| `--regex` | unset | LogQL line-regex (`\|~`). | +| `--since` | `15m` | `30s`, `15m`, `1h`, `2d` — anything matching `^\d+[smhd]$`. | +| `--limit` | `200` | Max lines per batch. | +| `--no-follow` | follow on | One-shot mode for diagnostics. **Always use `--no-follow` from a skill** — follow mode is interactive. | +| `--json` | text | Raw Loki response per batch. Use when you need to parse beyond the formatted line. | +| `--confirm` | n/a | Required for `--env production`. | ## Auth requirements @@ -69,12 +69,12 @@ Labels are sorted alphabetically inside the brackets. With `--json`, you get the ## Patterns by task family -| You want to... | Suggested invocation | -|---|---| +| You want to... | Suggested invocation | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Find the last 100 errors in any staging service | `--env staging --service --regex '(?i)error\|exception\|fatal' --since 1h --limit 100 --no-follow` (run per service; LogQL doesn't take an OR over labels in one query) | -| Watch a slow indexer batch | `--env staging --service realm-server --regex 'duration_ms=[0-9]{4,}'` | -| See what one Fargate worker is doing | `--env staging --service worker --worker-id --since 30m --no-follow` | -| Tail synapse | `--env staging --service synapse --since 15m --no-follow` | +| Watch a slow indexer batch | `--env staging --service realm-server --regex 'duration_ms=[0-9]{4,}'` | +| See what one Fargate worker is doing | `--env staging --service worker --worker-id --since 30m --no-follow` | +| Tail synapse | `--env staging --service synapse --since 15m --no-follow` | ## Limits and caveats diff --git a/.eslintrc.js b/.eslintrc.js index b715464fbba..a50bb0e67ba 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,6 +5,11 @@ const { CJS_GLOBALS_IN_ESM, } = require('./eslint/erasable-syntax-selectors.cjs'); const { DATA_TEST_SELECTORS } = require('./eslint/data-test-selectors.cjs'); +const { + CLOUD_PROVIDER_SDK_RULE, + RUNTIME_PACKAGE_FILES, + CLOUD_PROVIDER_ADAPTER_MODULES, +} = require('./eslint/cloud-provider-sdk-imports.cjs'); module.exports = { root: true, @@ -100,5 +105,22 @@ module.exports = { 'no-restricted-globals': ['error', ...CJS_GLOBALS_IN_ESM], }, }, + { + // Cloud provider SDKs only in adapter modules (ADR 003 §1.2). The + // runtime packages with their own config, `ai-bot`, `host` and + // `boxel-ui`, re-declare it. + files: RUNTIME_PACKAGE_FILES, + excludedFiles: ['**/tests/**'], + plugins: ['@cardstack/boxel'], + rules: { + '@cardstack/boxel/no-cloud-provider-sdk': CLOUD_PROVIDER_SDK_RULE, + }, + }, + { + files: CLOUD_PROVIDER_ADAPTER_MODULES, + rules: { + '@cardstack/boxel/no-cloud-provider-sdk': 'off', + }, + }, ], }; diff --git a/docs/adr/003-reach-cloud-services-through-adapters.md b/docs/adr/003-reach-cloud-services-through-adapters.md new file mode 100644 index 00000000000..4f4611ad241 --- /dev/null +++ b/docs/adr/003-reach-cloud-services-through-adapters.md @@ -0,0 +1,177 @@ +--- +status: proposed +date: 2026-10-18 +scope: + - packages/realm-server/** + - packages/runtime-common/** + - packages/postgres/** + - packages/billing/** + - packages/ai-bot/** + - packages/bot-runner/** + - packages/host/app/** + - packages/base/** + - packages/bxl/** + - packages/boxel-ui/** +--- + +# 003. Reach proprietary cloud services only through adapters + +Our organization deploys Boxel on AWS, but Boxel is designed as a federation +of realm servers that other people host +([ADR 004](004-federate-realm-servers-each-paired-with-its-own-homeserver.md)), +and they may not use AWS. So the runtime code must not depend on any one +provider. Each proprietary cloud service the runtime uses sits behind an +interface. The interface is named for the capability that Boxel needs. An +adapter implements the interface for one provider, and a local adapter +implements it for development and tests. Services that speak an open protocol +(Postgres, a POSIX filesystem, HTTP, the S3 API) need no interface, but the +code must use only what that protocol defines. The runtime must not detect +which platform hosts it. To run a realm server on a different provider, a host +writes adapters and changes configuration. It does not rewrite the code that +calls the adapters. + +## 1. Decision + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this +document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, +and only when, they appear in all capitals, as shown here. + +### 1.1. Scope + +This ADR governs runtime code: code that runs in a deployed service or in the +browser. Infrastructure-as-code, deploy configuration such as +`packages/host/config/deploy.js` and the realm-server's deployed +`scripts/start-*.sh`, CI workflows, and operational runbooks and skills are +provider-specific by nature, and this ADR does not govern them. + +### 1.2. Proprietary services sit behind an adapter + +Runtime code MUST reach a proprietary cloud service, such as KMS, only through +an interface. The interface MUST be named and shaped for the capability that +Boxel needs, for example `SecretsKeyProvider` or `MediaCacheAdapter`. It MUST +NOT be named or shaped for the service. An interface MUST NOT expose provider +types, request or command objects, resource identifiers such as ARNs, or +provider error codes. An adapter MUST translate provider errors into the +interface's own results or errors. + +A provider SDK (`@aws-sdk/*`, and the SDK of any other cloud provider) MUST be +imported only in an adapter module, or in a module that speaks the S3 API +(§1.3). A lint rule MUST enforce this. The rule's allowlist, +`eslint/cloud-provider-sdk-imports.cjs`, names each such module. A change to +the allowlist is a reviewed change to the boundary. + +### 1.3. Open protocols are the boundary + +Runtime code MAY reach a service that speaks an open, widely implemented +protocol directly, without an interface. These protocols include Postgres, a +POSIX filesystem, HTTP, and the S3 API. Code that uses such a service MUST use +only what the protocol defines. It MUST NOT depend on extensions that only one +provider offers, such as engine-specific features of a managed Postgres or +S3 features that S3-compatible stores do not implement. + +An S3 client MUST take its endpoint, region, and path-style addressing from +configuration, so that it can reach any S3-compatible store. It MUST get +credentials from configuration or the SDK's default chain, and it MUST NOT +assume a particular source for them. The runtime SHOULD construct S3 clients +through `packages/realm-server/lib/s3-client.ts`. + +### 1.4. Every capability runs locally + +Every interface from §1.2 MUST have a local adapter that runs without cloud +credentials. Development and CI MUST use it. For an S3-API store, +development and CI MAY instead use a local S3-compatible server or a +local-disk adapter. + +### 1.5. Configuration selects the adapter + +One factory per capability MUST choose the adapter from configuration at +startup. Code outside the factory MUST depend on the interface, and it MUST +NOT know which adapter is in use. Incomplete configuration for an adapter MUST +fail startup. It MUST NOT fall back silently to another adapter. Settings that +only one adapter reads MAY be named for its provider, such as +`SECRETS_KMS_KEY_ARN`. Only that adapter's factory MAY read them. + +### 1.6. No assumptions about the hosting platform + +Runtime code MUST NOT detect its platform from environment variables that a +provider injects, such as `ECS_CONTAINER_METADATA_URI`. Code MUST NOT name a +provider-specific request header or user agent, such as `ELB-HealthChecker`. +When behavior depends on one, the deployment configuration MUST name it. A +fact about the deployment, such as an instance id or whether the server is +deployed, MUST come from a provider-neutral setting or derivation, such as the +server's own URL. + +### 1.7. New dependencies + +A new dependency on a hosted service SHOULD use an open protocol rather than a +proprietary API. A new proprietary dependency MUST meet §1.2–§1.5 when it is +added. This ADR does not require an adapter for another provider until a +deployment needs one. + +## 2. Rationale + +Boxel is designed as a federation of realm servers (ADR 004). People and +organizations other than ours will host their own realm servers, which work +with ours and with the rest of Boxel. They will pick their own hosting: another +cloud provider, their own data center, or a single machine. The realm server +they run is the same code we run, so every AWS assumption in it becomes a +barrier to joining the federation. A host that has to fork the code to replace +one service falls behind our releases, and out of step with the servers it +federates with. + +The same rule keeps our own options open. Cost, a customer's data-residency +requirement, or a service that another provider offers better can each make a +move worthwhile for us too. + +Each piece of code that is coupled to one provider raises the cost of hosting +anywhere else. Coupling also spreads, because new code copies the code around +it. Today only a few runtime modules touch AWS. This makes the rule cheap to +adopt now, and the cost of adopting it will only increase. + +The two existing seams show the shape of the rule. `MediaCacheAdapter` and +`SecretsKeyProvider` each have a cloud adapter and a local one. A factory +chooses between them from the environment. The local adapter makes the rule +cheap to keep. Development and CI already run without AWS, so the interface +is used by two implementations from the start, and it cannot become a thin +wrapper over one provider's API. + +The S3 API counts as an open protocol because many object stores implement +it, including Cloudflare R2, MinIO, Backblaze B2, and Google Cloud Storage +through its XML API. An interface in front of it would add a layer with no +gain in portability. What it does need is a configurable endpoint (§1.3). + +The import rule is a lint rule because the boundary can break quietly. An +SDK import in a large diff is easy to miss in review, and agents write much of +this code. A lint failure states the rule at the point where the code breaks +it. + +## 3. Considered options + +- **Adapters for proprietary services, open protocols as the boundary + (chosen).** Portability work goes only where it is needed, and code stays + direct where a standard already makes it portable. +- **Call the provider directly, and port when needed.** This costs nothing + now, but the port would fall to each outside host, and coupling spreads. + Assumptions about the platform, such as which environment variables exist, + stay invisible until the code runs somewhere else. +- **A generic multi-cloud library.** Its lowest-common-denominator API is + wider than what Boxel needs. It does not model capabilities such as + envelope-key unwrapping, and it adds a dependency that must track every + provider. Small interfaces of our own are cheaper. +- **A Boxel interface in front of the S3 API as well.** Rejected; see §2. +- **Interfaces for everything external, including Postgres and the + filesystem.** These are already portable standards. An interface would cost + effort without making the code more portable. +- **Self-hosted open-source services only, such as MinIO or Vault, in + production.** This avoids proprietary APIs, but at an operational cost that + adapters make unnecessary. +- **A dedicated health-check path instead of a configured user agent.** This + is the more common pattern. But it moves the load balancer's probe, so the + application and the infrastructure must change in a set order. A + configured user agent keeps the probe where it is, and a different load + balancer needs only a new setting. + +## 4. Remediation + +No known gaps. diff --git a/docs/adr/README.md b/docs/adr/README.md index e8fd91ac2cb..5dd2c7928f1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,4 +15,5 @@ or backfill one. | ADR | Title | Status | | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------- | | [001](001-record-architecture-decisions.md) | Record architecture decisions as short, normative ADRs | proposed | +| [003](003-reach-cloud-services-through-adapters.md) | Reach proprietary cloud services only through adapters | proposed | | [005](005-authorize-key-changes-only-with-a-session-card-code-cant-reach.md) | Authorize changes to a user's keys only with a session that card code can't reach | proposed | diff --git a/docs/realm-server-health-signals.md b/docs/realm-server-health-signals.md index e709976feb7..99dcb28767a 100644 --- a/docs/realm-server-health-signals.md +++ b/docs/realm-server-health-signals.md @@ -15,9 +15,11 @@ signal answers which question, and what to do with each. ### `GET /` — the load balancer's check `healthCheck` in `packages/realm-server/middleware/index.ts` answers `OK` from -memory to any request whose user-agent starts with `ELB-HealthChecker`, ahead of -the host-app and realm-serving middleware. It touches no database, no filesystem, -and no realm state. +memory to any request whose user-agent starts with a prefix listed in +`HEALTH_CHECK_USER_AGENTS`, ahead of the host-app and realm-serving middleware. +The deployed start scripts set it to `ELB-HealthChecker`, and a deployed server +with it unset logs a warning at boot. It touches no +database, no filesystem, and no realm state. It is served **by the event loop**, so it answers only when the loop has capacity to answer. That is what makes it the right signal for routing: a target that diff --git a/eslint/cloud-provider-sdk-imports.cjs b/eslint/cloud-provider-sdk-imports.cjs new file mode 100644 index 00000000000..fe766081d23 --- /dev/null +++ b/eslint/cloud-provider-sdk-imports.cjs @@ -0,0 +1,43 @@ +'use strict'; + +// Keeps cloud provider SDKs inside the modules that adapt them (ADR 003 +// §1.2). Runtime code reaches a proprietary cloud service only through an +// interface named for the capability Boxel needs, so loading a provider's SDK +// belongs only in that interface's adapter, or in a module that speaks the S3 +// API (ADR 003 §1.3). `@cardstack/boxel/no-cloud-provider-sdk` reports every +// way of loading one: static import, re-export, dynamic `import()`, and +// `require()`. +// +// Adding a module to CLOUD_PROVIDER_ADAPTER_MODULES moves the boundary, so it +// is a reviewed change, not a way to silence the rule. + +const CLOUD_PROVIDER_SDK_RULE = [ + 'error', + { patterns: ['@aws-sdk/*', 'aws-sdk', '@google-cloud/*', '@azure/*'] }, +]; + +// The runtime packages ADR 003 governs that lint through an eslintrc config, +// relative to the repository root. `host` and `ai-bot` are `root: true` and +// `boxel-ui` uses a flat config, so each of those declares the rule itself. +const RUNTIME_PACKAGE_FILES = [ + 'packages/realm-server/**/*', + 'packages/runtime-common/**/*', + 'packages/postgres/**/*', + 'packages/billing/**/*', + 'packages/bot-runner/**/*', + 'packages/base/**/*', + 'packages/bxl/**/*', +]; + +const CLOUD_PROVIDER_ADAPTER_MODULES = [ + 'packages/realm-server/lib/s3-client.ts', + 'packages/realm-server/lib/secrets-at-rest/providers.ts', + 'packages/realm-server/media-cache/s3-adapter.ts', + 'packages/realm-server/prerender/artifact-sink.ts', +]; + +module.exports = { + CLOUD_PROVIDER_SDK_RULE, + RUNTIME_PACKAGE_FILES, + CLOUD_PROVIDER_ADAPTER_MODULES, +}; diff --git a/mise-tasks/services/worker b/mise-tasks/services/worker index 73db0df85dd..38abe280064 100755 --- a/mise-tasks/services/worker +++ b/mise-tasks/services/worker @@ -51,6 +51,7 @@ NODE_ENV=development \ --port="${WORKER_PORT}" \ --matrixURL="${MATRIX_URL_VAL}" \ --prerendererUrl="${PRERENDER_MGR_URL}" \ + --indexingDashboard \ \ --fromUrl='https://cardstack.com/base/' \ --toUrl="${REALM_BASE_URL}/base/" \ diff --git a/package.json b/package.json index 437ac9b32f5..054cb169660 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@babel/core": "catalog:", "@babel/parser": "catalog:", "@babel/traverse": "catalog:", + "@cardstack/eslint-plugin-boxel": "workspace:*", "@glimmer/component": "catalog:", "@glimmer/syntax": "^0.95.0", "@glimmer/tracking": "^1.1.2", diff --git a/packages/ai-bot/.eslintrc.cjs b/packages/ai-bot/.eslintrc.cjs index 5b111560323..99b9e5fc07e 100644 --- a/packages/ai-bot/.eslintrc.cjs +++ b/packages/ai-bot/.eslintrc.cjs @@ -1,11 +1,15 @@ 'use strict'; // `root: true` here, so the root config does not apply and the -// erasable-syntax rule must be re-declared from the shared list. +// erasable-syntax and cloud-provider-SDK rules must be re-declared from the +// shared lists. const { NO_COMPILATION_REQUIRED_TS_SELECTORS, CJS_GLOBALS_IN_ESM, } = require('../../eslint/erasable-syntax-selectors.cjs'); +const { + CLOUD_PROVIDER_SDK_RULE, +} = require('../../eslint/cloud-provider-sdk-imports.cjs'); module.exports = { root: true, @@ -20,7 +24,7 @@ module.exports = { legacyDecorators: true, }, }, - plugins: ['ember'], + plugins: ['ember', '@cardstack/boxel'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', @@ -60,6 +64,8 @@ module.exports = { { prefer: 'class-property' }, ], 'no-restricted-syntax': ['error', ...NO_COMPILATION_REQUIRED_TS_SELECTORS], + // Re-declared from the root config (ADR 003 §1.2). + '@cardstack/boxel/no-cloud-provider-sdk': CLOUD_PROVIDER_SDK_RULE, }, overrides: [ { diff --git a/packages/ai-bot/package.json b/packages/ai-bot/package.json index bef9947773d..71e0226d96c 100644 --- a/packages/ai-bot/package.json +++ b/packages/ai-bot/package.json @@ -22,6 +22,7 @@ "undici": "catalog:" }, "devDependencies": { + "@cardstack/eslint-plugin-boxel": "workspace:*", "@cardstack/local-types": "workspace:*", "@glint/ember-tsc": "catalog:", "@sinonjs/fake-timers": "catalog:", diff --git a/packages/bot-runner/package.json b/packages/bot-runner/package.json index b087a057583..1942a543675 100644 --- a/packages/bot-runner/package.json +++ b/packages/bot-runner/package.json @@ -13,11 +13,16 @@ "@glint/ember-tsc": "catalog:", "@types/node": "catalog:", "@types/qunit": "catalog:", + "concurrently": "catalog:", "qunit": "catalog:" }, "scripts": { "start": "NODE_NO_WARNINGS=1 node main.ts", "test": "NODE_NO_WARNINGS=1 node tests/index.ts", - "lint": "ember-tsc --noEmit" + "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"", + "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\"", + "lint:js": "eslint . --report-unused-disable-directives --cache", + "lint:js:fix": "eslint . --report-unused-disable-directives --fix", + "lint:types": "ember-tsc --noEmit" } } diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 3b57d098709..0a95f860c54 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -274,7 +274,7 @@ module('timeline handler', () => { allFileContents: [ { filename: 'experiments/MyListing/listing.json', - contents: '{\"data\":{\"type\":\"card\"}}', + contents: '{"data":{"type":"card"}}', }, { filename: 'experiments/MyListing/readme.md', diff --git a/packages/boxel-ui/eslint.config.mjs b/packages/boxel-ui/eslint.config.mjs index 3df70119b60..bb63c62579d 100644 --- a/packages/boxel-ui/eslint.config.mjs +++ b/packages/boxel-ui/eslint.config.mjs @@ -31,6 +31,9 @@ const require = createRequire(import.meta.url); const boxel = require('@cardstack/eslint-plugin-boxel'); const MISSING_INVOKABLES_CONFIG = require('../runtime-common/etc/eslint/missing-invokables-config'); const { DATA_TEST_SELECTORS } = require('../../eslint/data-test-selectors.cjs'); +const { + CLOUD_PROVIDER_SDK_RULE, +} = require('../../eslint/cloud-provider-sdk-imports.cjs'); const esmParserOptions = { ecmaFeatures: { modules: true }, @@ -149,6 +152,17 @@ export default defineConfig([ 'import/extensions': ['error', 'always', { ignorePackages: true }], }, }, + { + // Cloud provider SDKs only in adapter modules, and boxel-ui has none + // (ADR 003 §1.2). + files: ['src/**/*'], + plugins: { + '@cardstack/boxel': boxel, + }, + rules: { + '@cardstack/boxel/no-cloud-provider-sdk': CLOUD_PROVIDER_SDK_RULE, + }, + }, { files: ['**/*.{gts,gjs}'], plugins: { diff --git a/packages/eslint-plugin-boxel/README.md b/packages/eslint-plugin-boxel/README.md index ace59f72105..05a46809a5e 100644 --- a/packages/eslint-plugin-boxel/README.md +++ b/packages/eslint-plugin-boxel/README.md @@ -37,6 +37,7 @@ Then configure the rules you want to use under the rules section: | Name                                | Description | 💼 | 🔧 | | :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-- | :-- | | [missing-card-api-import](docs/rules/missing-card-api-import.md) | disallow usage of card-api with missing imports with auto-fix | ✅ | 🔧 | +| [no-cloud-provider-sdk](docs/rules/no-cloud-provider-sdk.md) | Disallow loading a cloud provider SDK outside the modules that adapt it, by static import, re-export, dynamic `import()`, or `require()` | | | | [no-css-position-fixed](docs/rules/no-css-position-fixed.md) | disallow `position: fixed` in card CSS because cards should not break out of their bounding box | | | | [no-duplicate-imports](docs/rules/no-duplicate-imports.md) | Prevent duplicate imports from the same module | ✅ | 🔧 | | [no-forbidden-head-tags](docs/rules/no-forbidden-head-tags.md) | disallow forbidden HTML elements in `static head` templates — only ``, `<meta>`, and `<link>` are permitted | ✅ | | diff --git a/packages/eslint-plugin-boxel/docs/rules/no-cloud-provider-sdk.md b/packages/eslint-plugin-boxel/docs/rules/no-cloud-provider-sdk.md new file mode 100644 index 00000000000..d8de01ad9cc --- /dev/null +++ b/packages/eslint-plugin-boxel/docs/rules/no-cloud-provider-sdk.md @@ -0,0 +1,13 @@ +# Disallow loading a cloud provider SDK outside the modules that adapt it, by static import, re-export, dynamic `import()`, or `require()` (`@cardstack/boxel/no-cloud-provider-sdk`) + +<!-- end auto-generated rule header --> + +Runtime code reaches a proprietary cloud service only through an interface named for the capability Boxel needs, so a provider's SDK belongs only in that interface's adapter (ADR 003 §1.2). This rule reports every way a module can load one: a static import or re-export, a dynamic `import()`, `require()` or `require.resolve()` (whatever `require` is bound to, so a `createRequire` result counts), and TypeScript's `import x = require()`. + +A source the rule can't read statically, such as `import(name)`, is not reported. + +## Options + +- `patterns` — the restricted modules. A pattern ending in `/*` names every module under that scope or path, such as `@aws-sdk/*`. Any other pattern names one module exactly, such as `aws-sdk`. + +The repository configures the patterns, the packages the rule covers, and the adapter modules exempt from it in `eslint/cloud-provider-sdk-imports.cjs`. diff --git a/packages/eslint-plugin-boxel/lib/rules/no-cloud-provider-sdk.js b/packages/eslint-plugin-boxel/lib/rules/no-cloud-provider-sdk.js new file mode 100644 index 00000000000..5e23f5138db --- /dev/null +++ b/packages/eslint-plugin-boxel/lib/rules/no-cloud-provider-sdk.js @@ -0,0 +1,103 @@ +'use strict'; + +// A pattern ending in `/*` names every module under that scope or path, and +// any other pattern names one module exactly. +function isRestricted(source, patterns) { + return patterns.some((pattern) => + pattern.endsWith('/*') + ? source.startsWith(pattern.slice(0, -1)) + : source === pattern, + ); +} + +// The module a source node names, when it is written as a string. +function staticSource(node) { + if (!node) { + return undefined; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return node.quasis[0].value.cooked; + } + return undefined; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Disallow loading a cloud provider SDK outside the modules that adapt it, by static import, re-export, dynamic `import()`, or `require()`', + category: 'Architecture', + recommended: false, + }, + schema: [ + { + type: 'object', + properties: { + patterns: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['patterns'], + additionalProperties: false, + }, + ], + messages: { + noCloudProviderSdk: + "Load the cloud provider SDK '{{source}}' only in an adapter module (ADR 003 §1.2). Put the call behind an interface named for the capability, or add an adapter module to CLOUD_PROVIDER_ADAPTER_MODULES in eslint/cloud-provider-sdk-imports.cjs.", + }, + }, + + create(context) { + let { patterns } = context.options[0] ?? { patterns: [] }; + + function check(node, sourceNode) { + let source = staticSource(sourceNode); + if (source !== undefined && isRestricted(source, patterns)) { + context.report({ + node, + messageId: 'noCloudProviderSdk', + data: { source }, + }); + } + } + + return { + ImportDeclaration(node) { + check(node, node.source); + }, + ExportAllDeclaration(node) { + check(node, node.source); + }, + ExportNamedDeclaration(node) { + check(node, node.source); + }, + ImportExpression(node) { + check(node, node.source); + }, + // `require('x')` and `require.resolve('x')`, whatever `require` is bound + // to, so a `createRequire` result is caught too. + CallExpression(node) { + let { callee } = node; + let isRequire = + (callee.type === 'Identifier' && callee.name === 'require') || + (callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'require' && + callee.property.type === 'Identifier' && + callee.property.name === 'resolve'); + if (isRequire) { + check(node, node.arguments[0]); + } + }, + TSExternalModuleReference(node) { + check(node, node.expression); + }, + }; + }, +}; diff --git a/packages/eslint-plugin-boxel/tests/lib/rules/no-cloud-provider-sdk-test.js b/packages/eslint-plugin-boxel/tests/lib/rules/no-cloud-provider-sdk-test.js new file mode 100644 index 00000000000..99e00f74c97 --- /dev/null +++ b/packages/eslint-plugin-boxel/tests/lib/rules/no-cloud-provider-sdk-test.js @@ -0,0 +1,79 @@ +'use strict'; + +const rule = require('../../../lib/rules/no-cloud-provider-sdk'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +const options = [{ patterns: ['@aws-sdk/*', 'aws-sdk', '@azure/*'] }]; +const error = (source) => ({ + messageId: 'noCloudProviderSdk', + data: { source }, +}); + +ruleTester.run('no-cloud-provider-sdk', rule, { + valid: [ + { code: `import { Pool } from 'pg';`, options }, + { code: `import x from 'aws-sdk-mock';`, options }, + { code: `import x from '@aws-sdkx/client';`, options }, + { code: `const m = await import('./local.js');`, options }, + { code: `const m = await import(moduleName);`, options }, + { code: `const m = require('pg');`, options }, + { code: `export { x } from './x';`, options }, + { code: `export const y = 1;`, options }, + ], + + invalid: [ + { + code: `import { S3Client } from '@aws-sdk/client-s3';`, + options, + errors: [error('@aws-sdk/client-s3')], + }, + { + code: `import type { S3Client } from '@aws-sdk/client-s3';`, + options, + errors: [error('@aws-sdk/client-s3')], + }, + { + code: `export * from '@azure/storage-blob';`, + options, + errors: [error('@azure/storage-blob')], + }, + { + code: `export { BlobClient } from '@azure/storage-blob';`, + options, + errors: [error('@azure/storage-blob')], + }, + { + code: `const m = await import('@aws-sdk/client-sqs');`, + options, + errors: [error('@aws-sdk/client-sqs')], + }, + { + code: 'const m = await import(`@aws-sdk/client-sqs`);', + options, + errors: [error('@aws-sdk/client-sqs')], + }, + { + code: `const AWS = require('aws-sdk');`, + options, + errors: [error('aws-sdk')], + }, + { + code: `const path = require.resolve('@aws-sdk/client-kms');`, + options, + errors: [error('@aws-sdk/client-kms')], + }, + { + code: `import AWS = require('aws-sdk');`, + options, + errors: [error('aws-sdk')], + }, + ], +}); diff --git a/packages/host/.eslintrc.js b/packages/host/.eslintrc.js index 7bd718f9cd8..f6db37da8cd 100644 --- a/packages/host/.eslintrc.js +++ b/packages/host/.eslintrc.js @@ -2,6 +2,9 @@ const MISSING_INVOKABLES_CONFIG = require('../runtime-common/etc/eslint/missing-invokables-config'); const { DATA_TEST_SELECTORS } = require('../../eslint/data-test-selectors.cjs'); +const { + CLOUD_PROVIDER_SDK_RULE, +} = require('../../eslint/cloud-provider-sdk-imports.cjs'); // Applies to all of JS, TS, GJS, and GTS in the browser context. const sharedBrowserConfig = { @@ -76,6 +79,14 @@ module.exports = { browser: true, }, overrides: [ + { + // Re-declared from the root config, which this `root: true` config does + // not inherit (ADR 003 §1.2). + files: ['app/**/*'], + rules: { + '@cardstack/boxel/no-cloud-provider-sdk': CLOUD_PROVIDER_SDK_RULE, + }, + }, { // Disallow `data-test-*` CSS/DOM selectors in app code. ember-test-selectors // strips these attributes in production, so selectors like diff --git a/packages/observability/README.md b/packages/observability/README.md index f5df7f36580..06439873440 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -268,12 +268,12 @@ you change it, change it everywhere — `alloy/config.alloy` (local) and `cardstack/infra:modules/aws/ecs/firelens/templates/extra.conf.tftpl` (staging + production). -| Label | Local source | Staging / production source | When set | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `env` | constant `local` — Alloy relabel rule (Docker source) and inline target attribute (file source) | constant `staging` / `production` (Fluent Bit static `Labels`) | always | -| `service` | Docker container name, leading `/` stripped (Docker source) — or `realm-server` / `worker` / `prerender` / `prerender-manager` (file source, mise dev tasks) | ECS task family — `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` | always | -| `realm` | opt-in via Docker label `boxel.realm=<name>` | task env when the task pins a single realm (omitted on multi-realm workloads) | when meaningful | -| `worker_id` | not set locally | per-process worker id (`<runtime-id>-pid-<pid>`), parsed from `[worker <id> priority N]:` log line prefixes by a Fluent Bit Lua filter. Matches `job_reservations.worker_id`. | worker tasks only, lines with the prefix | +| Label | Local source | Staging / production source | When set | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `env` | constant `local` — Alloy relabel rule (Docker source) and inline target attribute (file source) | constant `staging` / `production` (Fluent Bit static `Labels`) | always | +| `service` | Docker container name, leading `/` stripped (Docker source) — or `realm-server` / `worker` / `prerender` / `prerender-manager` (file source, mise dev tasks) | ECS task family — `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` | always | +| `realm` | opt-in via Docker label `boxel.realm=<name>` | task env when the task pins a single realm (omitted on multi-realm workloads) | when meaningful | +| `worker_id` | not set locally | per-process worker id (`<instance-id>-pid-<pid>`), parsed from `[worker <id> priority N]:` log line prefixes by a Fluent Bit Lua filter. Matches `job_reservations.worker_id`. | worker tasks only, lines with the prefix | The local Alloy scraper drops the observability stack's own Compose services (`grafana`, `loki`, `alloy`) so `{env="local"}` queries don't @@ -356,7 +356,7 @@ window, not the query window. # Per-worker tail (worker_id matches job_reservations.worker_id — # look it up there, e.g. for the worker that ran a specific job). -{env="<env>", service="worker", worker_id="abc123-3236013547-pid-42"} +{env="<env>", service="worker", worker_id="ip-10-0-1-23.ec2.internal-9f3c2a1b-pid-42"} # Logs around a specific job id (LogQL line-filter, not regex) {env="<env>", service="worker"} |= "job_id=42" @@ -401,7 +401,7 @@ URL from SSM. | `--env` | (required) | `local`, `staging`, `production` | | `--service` | (required) | `realm-server`, `worker`, `prerender`, `prerender-manager`, `synapse` (or any local container name) | | `--realm` | unset | Restrict to a single realm. | -| `--worker-id` | unset | Per-Fargate-task id; workers only. | +| `--worker-id` | unset | One worker process, `<instance-id>-pid-<pid>`; workers only. | | `--filter` | unset | LogQL line-filter (`\|=`); literal substring. | | `--regex` | unset | LogQL line-regex (`\|~`). Mutually exclusive with `--filter`. | | `--since` | `15m` | `30s`, `15m`, `1h`, `2d` — pattern `^\d+[smhd]$`. | diff --git a/packages/observability/grafanactl/resources/dashboards/boxel-status/indexing.json b/packages/observability/grafanactl/resources/dashboards/boxel-status/indexing.json index afffd7cf20d..ab42230b111 100644 --- a/packages/observability/grafanactl/resources/dashboards/boxel-status/indexing.json +++ b/packages/observability/grafanactl/resources/dashboards/boxel-status/indexing.json @@ -840,13 +840,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1287,13 +1297,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1412,13 +1432,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1549,13 +1579,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } diff --git a/packages/observability/grafanactl/resources/dashboards/boxel-status/job-queue.json b/packages/observability/grafanactl/resources/dashboards/boxel-status/job-queue.json index 043a8761375..878c0677772 100644 --- a/packages/observability/grafanactl/resources/dashboards/boxel-status/job-queue.json +++ b/packages/observability/grafanactl/resources/dashboards/boxel-status/job-queue.json @@ -850,13 +850,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1035,13 +1045,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } diff --git a/packages/observability/grafanactl/resources/dashboards/boxel-status/prerender-html.json b/packages/observability/grafanactl/resources/dashboards/boxel-status/prerender-html.json index bdeb9f43103..73390a4c571 100644 --- a/packages/observability/grafanactl/resources/dashboards/boxel-status/prerender-html.json +++ b/packages/observability/grafanactl/resources/dashboards/boxel-status/prerender-html.json @@ -947,13 +947,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1407,13 +1417,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1532,13 +1552,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } @@ -1669,13 +1699,23 @@ "value": [ { "options": { - "pattern": "^(.{6}).*$", + "pattern": "^.*-([0-9a-f]{8}-pid-\\d+)$", "result": { "index": 0, "text": "View logs ($1)" } }, "type": "regex" + }, + { + "options": { + "pattern": "^(.+)$", + "result": { + "index": 1, + "text": "View logs ($1)" + } + }, + "type": "regex" } ] } diff --git a/packages/realm-server/lib/instance-id.ts b/packages/realm-server/lib/instance-id.ts new file mode 100644 index 00000000000..567a7d362d1 --- /dev/null +++ b/packages/realm-server/lib/instance-id.ts @@ -0,0 +1,44 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { hostname } from 'node:os'; + +// Names the running instance of a service. Worker ids begin with it, and so +// do `job_reservations.worker_id` and the `worker_id` log label, so it must +// be unique among running instances and across restarts: the orphan cleanup +// closes every open reservation under a worker id. +// +// The id is a base name plus a per-boot suffix. The base name is +// BOXEL_INSTANCE_ID when the deployment sets it, as a more readable name than +// the hostname, and the hostname otherwise. The suffix keeps the id unique +// when replicas share the setting, or when an instance reuses a hostname. +// Nothing here reads a platform-specific variable (ADR 003 §1.6). +// +// The worker manager passes its whole id to its workers in +// BOXEL_INHERITED_INSTANCE_ID, so every worker id begins with the id of the +// instance that runs it. +export const INSTANCE_ID_ENV = 'BOXEL_INSTANCE_ID'; +export const INHERITED_INSTANCE_ID_ENV = 'BOXEL_INHERITED_INSTANCE_ID'; +export const INSTANCE_ID_HEADER = 'X-Boxel-Instance-Id'; + +let instanceId: string | undefined; +let publicInstanceId: string | undefined; + +export function getInstanceId(): string { + if (instanceId === undefined) { + let inherited = process.env[INHERITED_INSTANCE_ID_ENV]?.trim(); + let base = process.env[INSTANCE_ID_ENV]?.trim() || hostname(); + instanceId = inherited || `${base}-${randomBytes(4).toString('hex')}`; + } + return instanceId; +} + +// What the `X-Boxel-Instance-Id` response header carries: a digest of the +// instance id. It tells replicas apart and changes when one restarts, without +// giving every client the hostname, which on most platforms names the +// instance's private address. +export function getPublicInstanceId(): string { + publicInstanceId ??= createHash('sha256') + .update(getInstanceId()) + .digest('hex') + .slice(0, 12); + return publicInstanceId; +} diff --git a/packages/realm-server/lib/s3-client.ts b/packages/realm-server/lib/s3-client.ts new file mode 100644 index 00000000000..84e23dd065f --- /dev/null +++ b/packages/realm-server/lib/s3-client.ts @@ -0,0 +1,31 @@ +import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3'; + +// The one place the runtime constructs S3 clients. Any store that speaks the +// S3 API can stand behind them (ADR 003 §1.3): AWS when S3_ENDPOINT is unset, +// or another S3-compatible store, such as Cloudflare R2, MinIO, or Google +// Cloud Storage's XML API, at the URL it names. Credentials come from the +// SDK's default chain: the task role on ECS, or AWS_ACCESS_KEY_ID and +// AWS_SECRET_ACCESS_KEY for a store that issues keys. +// +// S3_ENDPOINT the store's URL +// S3_FORCE_PATH_STYLE `true` to address buckets by path rather than by +// subdomain, as MinIO and most self-hosted stores need +// AWS_REGION the region, when the caller names none + +const DEFAULT_REGION = 'us-east-1'; + +export function s3ClientConfig( + { region }: { region?: string } = {}, + env: NodeJS.ProcessEnv = process.env, +): S3ClientConfig { + let endpoint = env.S3_ENDPOINT?.trim(); + return { + region: region?.trim() || env.AWS_REGION?.trim() || DEFAULT_REGION, + ...(endpoint ? { endpoint } : {}), + forcePathStyle: env.S3_FORCE_PATH_STYLE?.trim() === 'true', + }; +} + +export function createS3Client(opts: { region?: string } = {}): S3Client { + return new S3Client(s3ClientConfig(opts)); +} diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index 379492435e6..abf722eb4a9 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -53,6 +53,7 @@ import { ModuleCacheCoordinator } from './lib/module-cache-coordination.ts'; import { JobsFinishedListener } from './lib/jobs-finished-listener.ts'; import { JobScopedSearchCache } from './job-scoped-search-cache.ts'; import { startHealthSampler } from './health-sampler.ts'; +import { healthCheckUserAgents } from './middleware/index.ts'; import { isDeployedServerURL, startOutboundGuard, @@ -605,6 +606,16 @@ const reportHostShellToManager = async (dbAdapter: PgAdapter) => { console.error(`Secrets-at-rest self-check failed: ${e.message}`); process.exit(-1); } + // Without it, the load balancer's probe on `/` falls through to the host app, + // which is slower and can fail the probe under load or during boot. + if ( + isDeployedServerURL(String(serverURL)) && + healthCheckUserAgents().length === 0 + ) { + console.warn( + `HEALTH_CHECK_USER_AGENTS is not set, so no health check is answered from memory. Set it to your load balancer's health-check user-agent prefix (see docs/realm-server-health-signals.md).`, + ); + } try { await smokeTestHostApp(); } catch (e: any) { diff --git a/packages/realm-server/media-cache/s3-adapter.ts b/packages/realm-server/media-cache/s3-adapter.ts index f290c3ca178..aa3d1843731 100644 --- a/packages/realm-server/media-cache/s3-adapter.ts +++ b/packages/realm-server/media-cache/s3-adapter.ts @@ -1,16 +1,15 @@ // S3-backed MediaCache object store. The bucket holds only content-addressed // derived media (captures); the `media_cache_ledger` table is the sole // catalog of what's in here, so nothing in this adapter ever lists the -// bucket. Region/credential resolution mirrors `prerender/artifact-sink.ts`: -// in ECS the write grant rides on the task role, which the SDK resolves from -// the container credentials endpoint — no keys are configured here. +// bucket. The client comes from `lib/s3-client.ts`, which resolves the +// endpoint, region and credentials for every S3 client in the runtime. import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, - S3Client, + type S3Client, } from '@aws-sdk/client-s3'; import type { MediaCacheAdapter, @@ -18,8 +17,7 @@ import type { MediaPutResult, } from '@cardstack/runtime-common'; import type { Readable } from 'stream'; - -const DEFAULT_REGION = 'us-east-1'; +import { createS3Client } from '../lib/s3-client.ts'; export class S3MediaCacheAdapter implements MediaCacheAdapter { #client: S3Client; @@ -42,11 +40,7 @@ export class S3MediaCacheAdapter implements MediaCacheAdapter { }) { this.#bucket = bucket; this.#keyPrefix = keyPrefix; - this.#client = - client ?? - new S3Client({ - region: region ?? process.env.AWS_REGION?.trim() ?? DEFAULT_REGION, - }); + this.#client = client ?? createS3Client({ region }); } private objectKey(key: string): string { diff --git a/packages/realm-server/middleware/index.ts b/packages/realm-server/middleware/index.ts index 4de676cb537..9ddf2622b3c 100644 --- a/packages/realm-server/middleware/index.ts +++ b/packages/realm-server/middleware/index.ts @@ -19,6 +19,7 @@ import { import { nodeStreamToText, nodeStreamToBuffer } from '../stream.ts'; import { guardDeclaredLength } from '../lib/declared-length-guard.ts'; import { retrieveUserSessionClaim } from '../utils/jwt.ts'; +import { getPublicInstanceId, INSTANCE_ID_HEADER } from '../lib/instance-id.ts'; import { verifyOperatorToken, type OperatorScope, @@ -190,9 +191,36 @@ export function livenessCheck(ctxt: Koa.Context, _next: Koa.Next) { ctxt.set('server', '@cardstack/host'); } -// Respond to AWS ELB health check +let parsedHealthCheckUserAgents: + | { raw: string; prefixes: string[] } + | undefined; + +// The user-agent prefixes the deployment names in HEALTH_CHECK_USER_AGENTS +// (comma-separated), so the code carries no provider's name (ADR 003 §1.6). +// The parse is kept until the setting changes, so tests and operators can +// change it without a restart. +export function healthCheckUserAgents(): string[] { + let raw = process.env.HEALTH_CHECK_USER_AGENTS ?? ''; + if (parsedHealthCheckUserAgents?.raw !== raw) { + parsedHealthCheckUserAgents = { + raw, + prefixes: raw + .split(',') + .map((prefix) => prefix.trim()) + .filter(Boolean), + }; + } + return parsedHealthCheckUserAgents.prefixes; +} + +// Answers the load balancer's health check from memory, ahead of the host-app +// and realm-serving middleware. export function healthCheck(ctxt: Koa.Context, next: Koa.Next) { - if (ctxt.req.headers['user-agent']?.startsWith('ELB-HealthChecker')) { + let userAgent = ctxt.req.headers['user-agent']; + if ( + userAgent && + healthCheckUserAgents().some((prefix) => userAgent.startsWith(prefix)) + ) { ctxt.body = 'OK'; return; } @@ -489,13 +517,10 @@ async function shedSearch(ctxt: Koa.Context, waitedMs: number) { ); } -export function ecsMetadata(ctxt: Koa.Context, next: Koa.Next) { - if (process.env['ECS_CONTAINER_METADATA_URI_V4']) { - ctxt.set( - 'X-ECS-Container-Metadata-URI-v4', - process.env['ECS_CONTAINER_METADATA_URI_V4'], - ); - } +// Names the replica that answered, by a digest of its instance id, so a client +// behind a load balancer can tell replicas apart and see when one is replaced. +export function instanceIdHeader(ctxt: Koa.Context, next: Koa.Next) { + ctxt.set(INSTANCE_ID_HEADER, getPublicInstanceId()); return next(); } diff --git a/packages/realm-server/prerender/artifact-sink.ts b/packages/realm-server/prerender/artifact-sink.ts index bfaa4bfa919..5b12fe9f323 100644 --- a/packages/realm-server/prerender/artifact-sink.ts +++ b/packages/realm-server/prerender/artifact-sink.ts @@ -27,14 +27,14 @@ // budget is a soft ceiling, never a source of truncated/!invalid // blobs). // -// In ECS the bucket write grant rides on the task role, which the AWS SDK -// resolves automatically from the container credentials endpoint — there -// are no access keys or profiles to configure here. +// The client comes from `lib/s3-client.ts`, which resolves the endpoint, +// region and credentials for every S3 client in the runtime. import type { Readable } from 'stream'; -import { S3Client } from '@aws-sdk/client-s3'; +import type { S3Client } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { logger } from '@cardstack/runtime-common'; +import { createS3Client } from '../lib/s3-client.ts'; const log = logger('prerenderer'); @@ -44,9 +44,6 @@ const log = logger('prerenderer'); // the bucket's lifecycle expiry; an operator targeting a long session can // raise it. const DEFAULT_MAX_SESSION_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB -// Fallback S3 region. Both deployed environments live in us-east-1; an -// operator can override per-process without a code change. -const DEFAULT_REGION = 'us-east-1'; // The artifact formats this sink carries. Each maps to a file suffix the // usual tools recognise: `.cpuprofile` (Chrome DevTools / speedscope), @@ -121,12 +118,8 @@ function artifactEnv(): string { return env.length > 0 ? env : 'unknown'; } -function artifactRegion(): string { - return ( - process.env.PRERENDER_ARTIFACTS_REGION?.trim() || - process.env.AWS_REGION?.trim() || - DEFAULT_REGION - ); +function artifactRegion(): string | undefined { + return process.env.PRERENDER_ARTIFACTS_REGION?.trim() || undefined; } // The configured per-session byte budget, or the built-in default when the @@ -255,7 +248,7 @@ function sanitizeSegment(value: string | undefined): string { let client: S3Client | undefined; function getClient(): S3Client { if (!client) { - client = new S3Client({ region: artifactRegion() }); + client = createS3Client({ region: artifactRegion() }); } return client; } diff --git a/packages/realm-server/prerender/prerender-app.ts b/packages/realm-server/prerender/prerender-app.ts index b91c6f6fb48..6650cf7ffe2 100644 --- a/packages/realm-server/prerender/prerender-app.ts +++ b/packages/realm-server/prerender/prerender-app.ts @@ -24,7 +24,7 @@ import { type CapturePrerenderResponse, } from '@cardstack/runtime-common'; import { - ecsMetadata, + instanceIdHeader, fullRequestURL, livenessCheck, fetchRequestFromContext, @@ -1771,7 +1771,7 @@ export function buildPrerenderApp(options: { }); return next(); }) - .use(ecsMetadata) + .use(instanceIdHeader) .use(router.routes()); app.on('error', (err: any) => { diff --git a/packages/realm-server/scripts/load-harness/README.md b/packages/realm-server/scripts/load-harness/README.md index 08d8078215b..7bfd5d93d70 100644 --- a/packages/realm-server/scripts/load-harness/README.md +++ b/packages/realm-server/scripts/load-harness/README.md @@ -486,9 +486,9 @@ document at `<realm server>/_standby`, over plain HTTP, with no AWS session: (`assets/main-<hash>.js`) and the host's own build version from its config meta (`0.0.0+<sha>`). Either moving means the client half of a measurement changed underneath it. -- **Which replica answered** — the container id in - `X-ECS-Container-Metadata-URI-v4`. A fleet that turned over is visible even - when the task definition did not change, and a replaced task is a cold one. +- **Which replica answered** — the digest of its instance id in + `X-Boxel-Instance-Id`. A fleet that turned over is visible even when the build did not change, and a + replaced replica is a cold one. A run opens with the reading printed: diff --git a/packages/realm-server/scripts/load-harness/lib/deploy-pin.ts b/packages/realm-server/scripts/load-harness/lib/deploy-pin.ts index ff766a4d3e9..2a411623248 100644 --- a/packages/realm-server/scripts/load-harness/lib/deploy-pin.ts +++ b/packages/realm-server/scripts/load-harness/lib/deploy-pin.ts @@ -18,10 +18,11 @@ // its config meta (`0.0.0+<sha>`). Either one moving means the client half // of any measurement changed underneath it. // -// - WHICH REPLICAS ANSWERED. `X-ECS-Container-Metadata-URI-v4` carries the -// answering container's id. A fleet that turned over mid-run is then -// visible even when the task definition did not change, and a replaced -// task is a cold one, which invalidates the window on its own. +// - WHICH REPLICAS ANSWERED. `X-Boxel-Instance-Id` names the answering +// process, and a restarted or replaced process answers with a new id. A +// fleet that turned over mid-run is then visible even when the build did +// not change, and a replaced replica is a cold one, which invalidates the +// window on its own. // // The realm server fetches the boot document once per process and caches it // for that process's life, so what a replica serves is pinned to when that @@ -51,7 +52,7 @@ export interface ServedBuild { } export interface FleetReading { - // Which replicas answered at all, by container id — including the ones whose + // Which replicas answered at all, by instance id — including the ones whose // answer was unusable, because a replica that says "502" has still said it is // there, and dropping it would report it as departed. A replica that answered // no probe is absent, so this is what was SEEN and never the whole fleet. @@ -66,9 +67,10 @@ export interface FleetReading { responses: number; } -// The key a response with no container id is filed under. A deployment that -// identifies no replicas — a local stack, anything not on ECS — collapses into -// this single entry, so its readings compare builds and never turnover. +// The key a response with no instance id is filed under. A target that +// identifies no replicas — a proxy that strips the header, a server that +// predates it — collapses into this single entry, so its readings compare +// builds and never turnover. export const UNIDENTIFIED_REPLICA = 'unidentified'; // `_standby` is the cheapest document that names the bundle: it serves the @@ -136,15 +138,9 @@ function hostVersion(html: string): string | undefined { } export function replicaIdFromHeaders(headers: Headers): string | undefined { - // The header carries the task metadata endpoint, `…/v4/<container id>`, - // which is reachable only from inside the container. The id is the useful - // part: it changes when the task is replaced, which a deploy always does and - // a recycle does too. - let uri = headers.get('x-ecs-container-metadata-uri-v4'); - if (!uri) { - return undefined; - } - return uri.split('/').filter(Boolean).pop() || undefined; + // The id changes when the process restarts or is replaced, which a deploy + // always does and a recycle does too. + return headers.get('x-boxel-instance-id')?.trim() || undefined; } export interface ReadFleetOptions { diff --git a/packages/realm-server/scripts/start-production.sh b/packages/realm-server/scripts/start-production.sh index f1afc0dea8b..100d1e76c25 100755 --- a/packages/realm-server/scripts/start-production.sh +++ b/packages/realm-server/scripts/start-production.sh @@ -33,6 +33,7 @@ BOXEL_HOMEPAGE_REALM_URL="${RESOLVED_BOXEL_HOMEPAGE_REALM_URL:-$DEFAULT_BOXEL_HO echo "[start-production] pid=$$ ppid=$PPID about to exec node at $(date -Iseconds)" >&2 NODE_NO_WARNINGS=1 \ + HEALTH_CHECK_USER_AGENTS='ELB-HealthChecker' \ LOW_CREDIT_THRESHOLD=2000 \ MATRIX_URL=https://matrix.boxel.ai \ MATRIX_SERVER_NAME=boxel.ai \ diff --git a/packages/realm-server/scripts/start-staging.sh b/packages/realm-server/scripts/start-staging.sh index ff4e636e344..f78890405a3 100755 --- a/packages/realm-server/scripts/start-staging.sh +++ b/packages/realm-server/scripts/start-staging.sh @@ -33,6 +33,7 @@ BOXEL_HOMEPAGE_REALM_URL="${RESOLVED_BOXEL_HOMEPAGE_REALM_URL:-$DEFAULT_BOXEL_HO echo "[start-staging] pid=$$ ppid=$PPID about to exec node at $(date -Iseconds)" >&2 NODE_NO_WARNINGS=1 \ + HEALTH_CHECK_USER_AGENTS='ELB-HealthChecker' \ LOW_CREDIT_THRESHOLD=2000 \ MATRIX_URL=https://matrix-staging.stack.cards \ MATRIX_SERVER_NAME=stack.cards \ diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index f88ab7006ef..ecd79917fa1 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -25,7 +25,7 @@ import fsExtra from 'fs-extra'; const { ensureDirSync } = fsExtra; import { httpLogging, - ecsMetadata, + instanceIdHeader, searchAdmission, orderConnectionsByRealm, methodOverrideSupport, @@ -931,7 +931,7 @@ export class RealmServer { // headers it sets. .use(clientAddress(this.clientAddress)) .use(httpLogging) - .use(ecsMetadata) + .use(instanceIdHeader) .use( cors({ origin: '*', diff --git a/packages/realm-server/tests/health-check-test.ts b/packages/realm-server/tests/health-check-test.ts new file mode 100644 index 00000000000..bcccbff70ff --- /dev/null +++ b/packages/realm-server/tests/health-check-test.ts @@ -0,0 +1,74 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import type Koa from 'koa'; +import { healthCheck, healthCheckUserAgents } from '../middleware/index.ts'; + +function probe(userAgent: string | undefined) { + let ctxt = { + req: { headers: userAgent ? { 'user-agent': userAgent } : {} }, + body: undefined as unknown, + } as unknown as Koa.Context; + let passedOn = false; + healthCheck(ctxt, async () => { + passedOn = true; + }); + return { answered: ctxt.body, passedOn }; +} + +module(basename(import.meta.filename), function (hooks) { + let original: string | undefined; + hooks.beforeEach(function () { + original = process.env.HEALTH_CHECK_USER_AGENTS; + }); + hooks.afterEach(function () { + if (original === undefined) { + delete process.env.HEALTH_CHECK_USER_AGENTS; + } else { + process.env.HEALTH_CHECK_USER_AGENTS = original; + } + }); + + test('answers a configured health checker from memory', function (assert) { + process.env.HEALTH_CHECK_USER_AGENTS = 'ELB-HealthChecker, kube-probe/'; + assert.deepEqual(probe('ELB-HealthChecker/2.0'), { + answered: 'OK', + passedOn: false, + }); + assert.deepEqual(probe('kube-probe/1.31'), { + answered: 'OK', + passedOn: false, + }); + }); + + test('passes any other request on', function (assert) { + process.env.HEALTH_CHECK_USER_AGENTS = 'ELB-HealthChecker'; + assert.deepEqual(probe('Mozilla/5.0'), { + answered: undefined, + passedOn: true, + }); + assert.deepEqual(probe(undefined), { + answered: undefined, + passedOn: true, + }); + }); + + test('follows a change to the setting without a restart', function (assert) { + process.env.HEALTH_CHECK_USER_AGENTS = ' ELB-HealthChecker , ,'; + assert.deepEqual(healthCheckUserAgents(), ['ELB-HealthChecker']); + process.env.HEALTH_CHECK_USER_AGENTS = 'kube-probe/'; + assert.deepEqual(healthCheckUserAgents(), ['kube-probe/']); + assert.deepEqual(probe('ELB-HealthChecker/2.0'), { + answered: undefined, + passedOn: true, + }); + }); + + test('with no health checker configured, recognizes none', function (assert) { + delete process.env.HEALTH_CHECK_USER_AGENTS; + assert.deepEqual(probe('ELB-HealthChecker/2.0'), { + answered: undefined, + passedOn: true, + }); + }); +}); diff --git a/packages/realm-server/tests/instance-id-test.ts b/packages/realm-server/tests/instance-id-test.ts new file mode 100644 index 00000000000..649e05f5900 --- /dev/null +++ b/packages/realm-server/tests/instance-id-test.ts @@ -0,0 +1,81 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename, join } from 'path'; +import { hostname } from 'os'; +import { execFileSync } from 'child_process'; +import type Koa from 'koa'; +import { instanceIdHeader } from '../middleware/index.ts'; +import { + getInstanceId, + getPublicInstanceId, + INSTANCE_ID_HEADER, +} from '../lib/instance-id.ts'; + +// The id is fixed once per process, so each case reads it in a fresh one. +function instanceIdOfFreshProcess(settings: NodeJS.ProcessEnv = {}): string { + let env = { ...process.env }; + delete env.BOXEL_INSTANCE_ID; + delete env.BOXEL_INHERITED_INSTANCE_ID; + let modulePath = join(import.meta.dirname, '..', 'lib', 'instance-id.ts'); + return execFileSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import { getInstanceId } from ${JSON.stringify(modulePath)}; + process.stdout.write(getInstanceId());`, + ], + { env: { ...env, ...settings }, encoding: 'utf8' }, + ); +} + +module(basename(import.meta.filename), function () { + test('is the hostname plus a per-boot suffix by default', function (assert) { + let first = instanceIdOfFreshProcess(); + let second = instanceIdOfFreshProcess(); + assert.ok( + first.startsWith(`${hostname()}-`), + `${first} begins with the hostname`, + ); + assert.ok(/-[0-9a-f]{8}$/.test(first), `${first} ends with the suffix`); + assert.notStrictEqual(first, second, 'each boot gets its own id'); + }); + + test('BOXEL_INSTANCE_ID replaces the hostname, not the per-boot suffix', function (assert) { + let first = instanceIdOfFreshProcess({ BOXEL_INSTANCE_ID: 'replica-7' }); + let second = instanceIdOfFreshProcess({ BOXEL_INSTANCE_ID: 'replica-7' }); + assert.ok(/^replica-7-[0-9a-f]{8}$/.test(first), `${first} is suffixed`); + assert.notStrictEqual( + first, + second, + 'replicas that share the setting still get their own ids', + ); + }); + + test("a worker takes its manager's id as it is", function (assert) { + assert.strictEqual( + instanceIdOfFreshProcess({ + BOXEL_INSTANCE_ID: 'replica-7', + BOXEL_INHERITED_INSTANCE_ID: 'replica-7-9f3c2a1b', + }), + 'replica-7-9f3c2a1b', + ); + }); + + test('every response names the instance that sent it, by digest', async function (assert) { + let headers: Record<string, string> = {}; + let ctxt = { + set(name: string, value: string) { + headers[name] = value; + }, + } as unknown as Koa.Context; + await instanceIdHeader(ctxt, async () => {}); + assert.deepEqual(headers, { [INSTANCE_ID_HEADER]: getPublicInstanceId() }); + assert.ok(/^[0-9a-f]{12}$/.test(headers[INSTANCE_ID_HEADER])); + assert.false( + headers[INSTANCE_ID_HEADER].includes(hostname()), + 'the hostname stays private', + ); + assert.notStrictEqual(headers[INSTANCE_ID_HEADER], getInstanceId()); + }); +}); diff --git a/packages/realm-server/tests/load-harness-test.ts b/packages/realm-server/tests/load-harness-test.ts index 4a5c0ae43ec..5cd9b37d6b0 100644 --- a/packages/realm-server/tests/load-harness-test.ts +++ b/packages/realm-server/tests/load-harness-test.ts @@ -1274,10 +1274,7 @@ module(basename(import.meta.filename), function () { } = {}): Response { let headers = new Headers({ 'content-type': 'text/html' }); if (replicaId) { - headers.set( - 'x-ecs-container-metadata-uri-v4', - `http://169.254.170.2/v4/${replicaId}`, - ); + headers.set('x-boxel-instance-id', replicaId); } return new Response(bootDocument(build), { status, headers }); } @@ -1372,15 +1369,14 @@ module(basename(import.meta.filename), function () { assert.true(pinIsReadable(reading({ 'task-a': served }))); }); - test('the replica id comes off the container metadata header', function (assert) { + test('the replica id comes off the instance id header', function (assert) { assert.strictEqual( replicaIdFromHeaders( new Headers({ - 'x-ecs-container-metadata-uri-v4': - 'http://169.254.170.2/v4/6654a7425d164a77803fe13a88ae21ab-3236013547', + 'x-boxel-instance-id': 'ip-10-0-1-23.ec2.internal-9f3c2a1b', }), ), - '6654a7425d164a77803fe13a88ae21ab-3236013547', + 'ip-10-0-1-23.ec2.internal-9f3c2a1b', ); assert.strictEqual( replicaIdFromHeaders(new Headers()), @@ -1566,7 +1562,7 @@ module(basename(import.meta.filename), function () { status: 200, headers: new Headers({ 'content-type': 'text/html', - 'x-ecs-container-metadata-uri-v4': 'http://169.254.170.2/v4/task-b', + 'x-boxel-instance-id': 'task-b', }), }, ); diff --git a/packages/realm-server/tests/s3-client-test.ts b/packages/realm-server/tests/s3-client-test.ts new file mode 100644 index 00000000000..183561cf91f --- /dev/null +++ b/packages/realm-server/tests/s3-client-test.ts @@ -0,0 +1,53 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import { s3ClientConfig } from '../lib/s3-client.ts'; + +module(basename(import.meta.filename), function () { + test('with no settings, reaches AWS in the default region', function (assert) { + assert.deepEqual(s3ClientConfig({}, {}), { + region: 'us-east-1', + forcePathStyle: false, + }); + }); + + test('takes the region from AWS_REGION when the caller names none', function (assert) { + assert.strictEqual( + s3ClientConfig({}, { AWS_REGION: 'eu-west-1' }).region, + 'eu-west-1', + ); + assert.strictEqual( + s3ClientConfig({ region: 'ap-south-1' }, { AWS_REGION: 'eu-west-1' }) + .region, + 'ap-south-1', + "the caller's region wins", + ); + }); + + test('reaches an S3-compatible store at S3_ENDPOINT', function (assert) { + assert.deepEqual( + s3ClientConfig( + { region: 'auto' }, + { + S3_ENDPOINT: 'http://localhost:9000', + S3_FORCE_PATH_STYLE: 'true', + }, + ), + { + region: 'auto', + endpoint: 'http://localhost:9000', + forcePathStyle: true, + }, + ); + }); + + test('a blank setting counts as unset', function (assert) { + assert.deepEqual( + s3ClientConfig( + { region: ' ' }, + { S3_ENDPOINT: ' ', AWS_REGION: '', S3_FORCE_PATH_STYLE: ' ' }, + ), + { region: 'us-east-1', forcePathStyle: false }, + ); + }); +}); diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 790c9e4a8f9..3e7f85f193b 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -60,7 +60,7 @@ import pluralize from 'pluralize'; import Koa from 'koa'; import Router from '@koa/router'; import { - ecsMetadata, + instanceIdHeader, fullRequestURL, livenessCheck, } from './middleware/index.ts'; @@ -91,6 +91,7 @@ import { } from './lib/worker-request-forwarder.ts'; import { isDeployedServerURL } from './lib/outbound/client.ts'; import { withCredentialHeadersMasked } from './lib/request-telemetry.ts'; +import { getInstanceId, INHERITED_INSTANCE_ID_ENV } from './lib/instance-id.ts'; import { encodeSecretsKeyringIpc, SECRETS_KEYRING_REQUEST, @@ -120,12 +121,6 @@ const WORKER_START_TIMEOUT_MS = (() => { return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000; })(); -// This is an ENV var we get from ECS that looks like: -// http://169.254.170.2/v3/a1de500d004f49bea02ace30cefb0f01-3236013547 where the -// last segment is the "container runtime ID", where the value on the left of -// the '-' is the task ID. -const ECS_CONTAINER_METADATA_URI = process.env.ECS_CONTAINER_METADATA_URI; - const REALM_SECRET_SEED = process.env.REALM_SECRET_SEED; if (!REALM_SECRET_SEED) { log.error( @@ -143,6 +138,7 @@ let { fromUrl: fromUrls, toUrl: toUrls, migrateDB, + indexingDashboard, prerendererUrl, serviceName = 'worker', skipPrerenderHtmlRealm: skipPrerenderHtmlRealms = [], @@ -174,6 +170,11 @@ let { 'When this flag is set the database will automatically migrate when server is started', type: 'boolean', }, + indexingDashboard: { + description: + 'Serve the indexing dashboard at /_indexing-dashboard. It shows pending jobs, their realms and their args, so only the development worker manager passes this flag.', + type: 'boolean', + }, fromUrl: { description: 'the source of the realm URL proxy', demandOption: true, @@ -210,8 +211,10 @@ let { let isReady = false; let isExiting = false; let workers: ChildProcess[] = []; +// The indexing dashboard is for development, so it is off unless the start +// command asks for it. function isIndexingDashboardEnabled(): boolean { - return !ECS_CONTAINER_METADATA_URI; + return indexingDashboard === true; } // Always create the sink — its in-memory state is small, and the @@ -323,7 +326,7 @@ if (port != null) { }); return next(); }) - .use(ecsMetadata); + .use(instanceIdHeader); webServer.on('error', (err: any) => { log.error(`worker manager HTTP server error: ${err.message}`); @@ -858,13 +861,10 @@ async function startWorker( ], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, [INHERITED_INSTANCE_ID_ENV]: getInstanceId() }, }, ); - let name = String( - (ECS_CONTAINER_METADATA_URI - ? `${ECS_CONTAINER_METADATA_URI.split('/').pop()!}-pid-${worker.pid}` - : worker.pid)!, - ); + let name = `${getInstanceId()}-pid-${worker.pid}`; let watchdog: NodeJS.Timeout; let workerId: string | undefined; diff --git a/packages/realm-server/worker.ts b/packages/realm-server/worker.ts index d2193ecc356..fe7b8da0c12 100644 --- a/packages/realm-server/worker.ts +++ b/packages/realm-server/worker.ts @@ -51,6 +51,7 @@ import { buildCreatePrerenderAuth } from './prerender/auth.ts'; import { createMediaCacheAdapterFromEnv } from './media-cache/index.ts'; import { finalizeChildReservationAsFailure } from './lib/finalize-child-fatal-failure.ts'; import { receiveSecretsKeyring } from './lib/secrets-at-rest/index.ts'; +import { getInstanceId } from './lib/instance-id.ts'; let log = logger('worker'); @@ -69,14 +70,7 @@ if (!REALM_SERVER_MATRIX_USERNAME) { process.exit(-1); } -// This is an ENV var we get from ECS that looks like: -// http://169.254.170.2/v3/a1de500d004f49bea02ace30cefb0f01-3236013547 where the -// last segment is the "container runtime ID", where the value on the left of -// the '-' is the task ID. -const ECS_CONTAINER_METADATA_URI = process.env.ECS_CONTAINER_METADATA_URI; -let workerId = ECS_CONTAINER_METADATA_URI - ? `${ECS_CONTAINER_METADATA_URI.split('/').pop()!}-pid-${process.pid}` - : `worker-pid-${process.pid}`; +let workerId = `${getInstanceId()}-pid-${process.pid}`; let { matrixURL, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0752a0c836..f3573650c4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,9 @@ importers: '@babel/traverse': specifier: 'catalog:' version: 7.27.0(supports-color@8.1.1) + '@cardstack/eslint-plugin-boxel': + specifier: workspace:* + version: link:packages/eslint-plugin-boxel '@glimmer/component': specifier: ^2.0.0 version: 2.1.1(supports-color@8.1.1) @@ -923,6 +926,9 @@ importers: specifier: 'catalog:' version: 7.30.0 devDependencies: + '@cardstack/eslint-plugin-boxel': + specifier: workspace:* + version: link:../eslint-plugin-boxel '@cardstack/local-types': specifier: workspace:* version: link:../local-types @@ -1139,6 +1145,9 @@ importers: '@types/qunit': specifier: 'catalog:' version: 2.19.14 + concurrently: + specifier: 'catalog:' + version: 8.2.2 qunit: specifier: 'catalog:' version: 2.26.0