Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<porter-api-token>
export PORTER_CLUSTER_ID=<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

Expand Down
82 changes: 78 additions & 4 deletions src/_config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string, unknown>).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,
};
};
127 changes: 117 additions & 10 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>();

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', () => {
Expand All @@ -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/);
});
});
Loading