From 53d6126fd563be1e150b7bdee92125efb3b7e5cc Mon Sep 17 00:00:00 2001 From: Jack Decker <24392469+jackowfish@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:36:46 -0400 Subject: [PATCH] resolve base url from project/cluster ids or in-cluster detection --- README.md | 22 +++++++++-- src/_config.ts | 53 +++++++++++++++++++++++-- tests/config.test.ts | 94 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 151 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f82d939..84a4caf 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,24 @@ 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 the project and cluster where sandboxes are +enabled, plus a Porter API token (created from **Settings > API tokens** in the +Porter Dashboard): + +```bash +export PORTER_PROJECT_ID= +export PORTER_CLUSTER_ID= +export PORTER_SANDBOX_API_KEY= +``` + +The SDK then 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..e84b734 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,56 @@ export interface ConfigInput { timeoutMs?: number; } +const externalBaseUrl = (projectId: string, clusterId: string): string => + `https://dashboard.porter.run/api/v2/alpha/projects/${projectId}/clusters/${clusterId}`; + +const resolveBaseUrl = (baseUrl: string | undefined, apiKey: string | undefined): string => { + const explicit = baseUrl ?? process.env.PORTER_SANDBOX_BASE_URL; + if (explicit) { + return explicit; + } + + const projectId = process.env.PORTER_PROJECT_ID; + const clusterId = process.env.PORTER_CLUSTER_ID; + if (projectId && clusterId) { + if (!apiKey) { + throw new Error( + 'PORTER_PROJECT_ID and PORTER_CLUSTER_ID are 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(projectId, clusterId); + } + if (projectId || clusterId) { + const [setVar, missingVar] = projectId + ? ['PORTER_PROJECT_ID', 'PORTER_CLUSTER_ID'] + : ['PORTER_CLUSTER_ID', 'PORTER_PROJECT_ID']; + throw new Error( + `${setVar} is set but ${missingVar} is not. Set both to call the Porter API from outside the cluster.`, + ); + } + + // 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; + } + + throw new Error( + 'Could not determine the sandbox API base URL. Either run inside a sandbox-enabled Porter ' + + 'cluster, or set PORTER_PROJECT_ID and PORTER_CLUSTER_ID (plus 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..7cddb16 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,23 +1,42 @@ -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_PROJECT_ID', + 'PORTER_CLUSTER_ID', + 'KUBERNETES_SERVICE_HOST', +] as const; describe('resolveConfig', () => { - const originalBaseUrl = process.env.PORTER_SANDBOX_BASE_URL; + const originalEnv: Partial> = {}; + + beforeEach(() => { + for (const name of RESOLUTION_ENV_VARS) { + originalEnv[name] = process.env[name]; + delete process.env[name]; + } + }); afterEach(() => { - if (originalBaseUrl === undefined) { - delete process.env.PORTER_SANDBOX_BASE_URL; - } else { - process.env.PORTER_SANDBOX_BASE_URL = originalBaseUrl; + for (const name of RESOLUTION_ENV_VARS) { + const value = originalEnv[name]; + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } } }); - 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 a cluster', () => { + 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 +52,59 @@ describe('resolveConfig', () => { 'https://sandbox.override', ); }); + + it('builds the external Porter API URL from PORTER_PROJECT_ID and PORTER_CLUSTER_ID', () => { + process.env.PORTER_PROJECT_ID = '123'; + process.env.PORTER_CLUSTER_ID = '456'; + process.env.PORTER_SANDBOX_API_KEY = 'token'; + + expect(resolveConfig().baseUrl).toBe(EXTERNAL_BASE_URL); + }); + + it('prefers project and cluster ids over in-cluster detection', () => { + process.env.PORTER_PROJECT_ID = '123'; + process.env.PORTER_CLUSTER_ID = '456'; + process.env.PORTER_SANDBOX_API_KEY = 'token'; + process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1'; + + expect(resolveConfig().baseUrl).toBe(EXTERNAL_BASE_URL); + }); + + it('prefers PORTER_SANDBOX_BASE_URL over project and cluster ids', () => { + process.env.PORTER_PROJECT_ID = '123'; + 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 token when resolving the external Porter API URL', () => { + process.env.PORTER_PROJECT_ID = '123'; + process.env.PORTER_CLUSTER_ID = '456'; + + expect(() => resolveConfig()).toThrowError(/PORTER_SANDBOX_API_KEY/); + }); + + it('accepts an apiKey argument when resolving the external Porter API URL', () => { + process.env.PORTER_PROJECT_ID = '123'; + process.env.PORTER_CLUSTER_ID = '456'; + + expect(resolveConfig({ apiKey: 'token' }).baseUrl).toBe(EXTERNAL_BASE_URL); + }); + + it('rejects PORTER_PROJECT_ID without PORTER_CLUSTER_ID', () => { + process.env.PORTER_PROJECT_ID = '123'; + + expect(() => resolveConfig()).toThrowError(/PORTER_CLUSTER_ID is not/); + }); + + it('rejects PORTER_CLUSTER_ID without PORTER_PROJECT_ID', () => { + process.env.PORTER_CLUSTER_ID = '456'; + + expect(() => resolveConfig()).toThrowError(/PORTER_PROJECT_ID is not/); + }); + + it('throws when the base URL cannot be determined', () => { + expect(() => resolveConfig()).toThrowError(/Could not determine the sandbox API base URL/); + }); });