Skip to content
Closed
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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<project-id>
export PORTER_CLUSTER_ID=<cluster-id>
export PORTER_SANDBOX_API_KEY=<porter-api-token>
```

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

Expand Down
53 changes: 49 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,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,
};
};
94 changes: 84 additions & 10 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof RESOLUTION_ENV_VARS)[number], string | undefined>> = {};

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