diff --git a/README.md b/README.md index f82d939..865f822 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,23 @@ const sb = await porter.sandboxes.create({ }); ``` -Set `PORTER_SANDBOX_API_KEY` in your environment, or pass `apiKey` when constructing the client. -By default, the SDK connects to Porter's in-cluster sandbox API at -`http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080`. Override it -with `PORTER_SANDBOX_BASE_URL` or by passing `baseUrl`. +Inside a sandbox-enabled Porter cluster, the SDK connects to the in-cluster +sandbox API at `http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080` +automatically, with no configuration needed. + +From outside the cluster, set a Porter API token (created from +**Settings > API tokens** in the Porter Dashboard) and the cluster where +sandboxes are enabled: + +```bash +export PORTER_SANDBOX_API_KEY= +export PORTER_CLUSTER_ID= +``` + +The SDK reads the project from the token and calls the sandbox API through the +Porter API at `dashboard.porter.run`. To target a specific URL instead, set +`PORTER_SANDBOX_BASE_URL` or pass `baseUrl` - both take precedence over +everything above. ### Concurrency diff --git a/src/_config.ts b/src/_config.ts index b4eb516..e538186 100644 --- a/src/_config.ts +++ b/src/_config.ts @@ -1,4 +1,4 @@ -const DEFAULT_BASE_URL = 'http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080'; +const IN_CLUSTER_BASE_URL = 'http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080'; const DEFAULT_TIMEOUT_MS = 30_000; export interface Config { @@ -13,11 +13,85 @@ export interface ConfigInput { timeoutMs?: number; } +const externalBaseUrl = (projectId: number, clusterId: string): string => + `https://dashboard.porter.run/api/v2/alpha/projects/${projectId}/clusters/${clusterId}`; + +// Reads the project_id claim from a Porter API token without verifying the signature. +// Signature verification is the server's job; the SDK only needs the claim to build +// the URL, and a tampered claim just produces a URL the server will reject. +const projectIdFromApiKey = (apiKey: string): number => { + const error = new Error( + 'PORTER_SANDBOX_API_KEY does not look like a Porter API token (expected a JWT with a ' + + 'project_id claim). Create one from Settings > API tokens in the Porter Dashboard.', + ); + + const segments = apiKey.split('.'); + const payloadSegment = segments[1]; + if (segments.length !== 3 || payloadSegment === undefined) { + throw error; + } + let payload: unknown; + try { + payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8')); + } catch { + throw error; + } + + if (typeof payload !== 'object' || payload === null || !('project_id' in payload)) { + throw error; + } + const projectId = (payload as Record).project_id; + if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) { + throw error; + } + return projectId; +}; + +const resolveBaseUrl = (baseUrl: string | undefined, apiKey: string | undefined): string => { + const explicit = baseUrl ?? process.env.PORTER_SANDBOX_BASE_URL; + if (explicit) { + return explicit; + } + + const clusterId = process.env.PORTER_CLUSTER_ID; + if (clusterId) { + if (!apiKey) { + throw new Error( + 'PORTER_CLUSTER_ID is set, so the SDK will call the Porter API from outside the ' + + 'cluster, which requires an API token. Set PORTER_SANDBOX_API_KEY or pass apiKey. ' + + 'You can create an API token from Settings > API tokens in the Porter Dashboard ' + + '(requires admin permissions).', + ); + } + return externalBaseUrl(projectIdFromApiKey(apiKey), clusterId); + } + + // Kubernetes sets KUBERNETES_SERVICE_HOST in every pod, so its presence means the + // in-cluster sandbox API service address is at least reachable in principle. + if (process.env.KUBERNETES_SERVICE_HOST) { + return IN_CLUSTER_BASE_URL; + } + + if (apiKey) { + throw new Error( + 'An API key is set but PORTER_CLUSTER_ID is not. Set PORTER_CLUSTER_ID to the cluster ' + + 'where sandboxes are enabled so the SDK can call the Porter API from outside the cluster.', + ); + } + + throw new Error( + 'Could not determine the sandbox API base URL. Either run inside a sandbox-enabled Porter ' + + 'cluster, or set PORTER_CLUSTER_ID and PORTER_SANDBOX_API_KEY to call the Porter API ' + + 'from outside the cluster. You can also set PORTER_SANDBOX_BASE_URL or pass baseUrl to ' + + 'target a specific URL.', + ); +}; + export const resolveConfig = (input: ConfigInput = {}): Config => { - const rawBaseUrl = input.baseUrl ?? process.env.PORTER_SANDBOX_BASE_URL ?? DEFAULT_BASE_URL; + const apiKey = input.apiKey ?? process.env.PORTER_SANDBOX_API_KEY; return { - apiKey: input.apiKey ?? process.env.PORTER_SANDBOX_API_KEY, - baseUrl: rawBaseUrl.replace(/\/$/, ''), + apiKey, + baseUrl: resolveBaseUrl(input.baseUrl, apiKey).replace(/\/$/, ''), timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, }; }; diff --git a/tests/config.test.ts b/tests/config.test.ts index f1c6419..29cdf6c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,23 +1,46 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { resolveConfig } from '../src/_config.js'; -const DEFAULT_BASE_URL = 'http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080'; +const IN_CLUSTER_BASE_URL = 'http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080'; +const EXTERNAL_BASE_URL = 'https://dashboard.porter.run/api/v2/alpha/projects/123/clusters/456'; + +const RESOLUTION_ENV_VARS = [ + 'PORTER_SANDBOX_BASE_URL', + 'PORTER_SANDBOX_API_KEY', + 'PORTER_CLUSTER_ID', + 'KUBERNETES_SERVICE_HOST', +] as const; + +const makeApiKey = (payload: unknown): string => + `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`; + +const API_KEY = makeApiKey({ project_id: 123, sub: 'api', token_id: 'abc' }); describe('resolveConfig', () => { - const originalBaseUrl = process.env.PORTER_SANDBOX_BASE_URL; + const originalValues = new Map(); + + beforeEach(() => { + for (const envVar of RESOLUTION_ENV_VARS) { + originalValues.set(envVar, process.env[envVar]); + delete process.env[envVar]; + } + }); afterEach(() => { - if (originalBaseUrl === undefined) { - delete process.env.PORTER_SANDBOX_BASE_URL; - } else { - process.env.PORTER_SANDBOX_BASE_URL = originalBaseUrl; + for (const envVar of RESOLUTION_ENV_VARS) { + const original = originalValues.get(envVar); + if (original === undefined) { + delete process.env[envVar]; + } else { + process.env[envVar] = original; + } } }); - it('falls back to the in-cluster sandbox API URL', () => { - delete process.env.PORTER_SANDBOX_BASE_URL; + it('falls back to the in-cluster sandbox API URL when running in Kubernetes', () => { + process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1'; - expect(resolveConfig().baseUrl).toBe(DEFAULT_BASE_URL); + expect(resolveConfig().baseUrl).toBe(IN_CLUSTER_BASE_URL); }); it('uses PORTER_SANDBOX_BASE_URL when no baseUrl is passed', () => { @@ -33,4 +56,88 @@ describe('resolveConfig', () => { 'https://sandbox.override', ); }); + + it('parses the project ID from a Porter-minted token', () => { + // Exact claim shape minted by the monolith's JWTForAPI: iat is a string, + // project_id is a number, sub/sub_kind/token_id are always present. + const porterToken = makeApiKey({ + iat: '1783537707', + project_id: 1, + sub: 'api', + sub_kind: 'api', + token_id: '7dc2a111-f851-4935-a414-b72945be0883', + }); + process.env.PORTER_CLUSTER_ID = '651'; + process.env.PORTER_SANDBOX_API_KEY = porterToken; + + expect(resolveConfig().baseUrl).toBe( + 'https://dashboard.porter.run/api/v2/alpha/projects/1/clusters/651', + ); + }); + + it('builds the external URL from the API key and PORTER_CLUSTER_ID', () => { + process.env.PORTER_CLUSTER_ID = '456'; + process.env.PORTER_SANDBOX_API_KEY = API_KEY; + + expect(resolveConfig().baseUrl).toBe(EXTERNAL_BASE_URL); + }); + + it('prefers PORTER_CLUSTER_ID over in-cluster detection', () => { + process.env.PORTER_CLUSTER_ID = '456'; + process.env.PORTER_SANDBOX_API_KEY = API_KEY; + process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1'; + + expect(resolveConfig().baseUrl).toBe(EXTERNAL_BASE_URL); + }); + + it('prefers PORTER_SANDBOX_BASE_URL over PORTER_CLUSTER_ID', () => { + process.env.PORTER_CLUSTER_ID = '456'; + process.env.PORTER_SANDBOX_BASE_URL = 'https://sandbox.example'; + + expect(resolveConfig().baseUrl).toBe('https://sandbox.example'); + }); + + it('requires an API key for the external URL', () => { + process.env.PORTER_CLUSTER_ID = '456'; + + expect(() => resolveConfig()).toThrow(/PORTER_SANDBOX_API_KEY/); + }); + + it('accepts an apiKey argument for the external URL', () => { + process.env.PORTER_CLUSTER_ID = '456'; + + expect(resolveConfig({ apiKey: API_KEY }).baseUrl).toBe(EXTERNAL_BASE_URL); + }); + + it.each([ + ['a non-JWT string', 'not-a-jwt'], + ['a two-segment token', 'one.two'], + ['a token without a project_id claim', makeApiKey({ sub: 'api' })], + ['a token with a string project_id', makeApiKey({ project_id: '123' })], + ['a token with a zero project_id', makeApiKey({ project_id: 0 })], + ['a token with a non-object payload', makeApiKey(['not', 'an', 'object'])], + ['a token with an unparseable payload', 'header.!!!not-base64!!!.signature'], + ])('rejects %s', (_desc, apiKey) => { + process.env.PORTER_CLUSTER_ID = '456'; + process.env.PORTER_SANDBOX_API_KEY = apiKey; + + expect(() => resolveConfig()).toThrow(/project_id claim/); + }); + + it('requires PORTER_CLUSTER_ID when an API key is set', () => { + process.env.PORTER_SANDBOX_API_KEY = API_KEY; + + expect(() => resolveConfig()).toThrow(/PORTER_CLUSTER_ID is not/); + }); + + it('ignores a missing cluster ID when running in Kubernetes', () => { + process.env.PORTER_SANDBOX_API_KEY = API_KEY; + process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1'; + + expect(resolveConfig().baseUrl).toBe(IN_CLUSTER_BASE_URL); + }); + + it('errors when no resolution path is available', () => { + expect(() => resolveConfig()).toThrow(/Could not determine the sandbox API base URL/); + }); });