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
6 changes: 6 additions & 0 deletions src/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ export function createWorkspaceProviderOperations(options = {}) {
async validateProvider(kind) {
return providers.get(parseProviderKind(kind)).validate();
},
/** Read-only view of what the host already knows about reaching this provider. */
async describeProvider(kind) {
const target = providers.get(parseProviderKind(kind));
if (typeof target.describe !== 'function') return { provider: kind, contexts: [], currentContext: null };
return target.describe();
},
/** Completes one setup requirement for a provider that can do so itself. */
async prepareProvider(kind, action) {
const target = providers.get(parseProviderKind(kind));
Expand Down
11 changes: 8 additions & 3 deletions src/policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,14 @@ export function requireDockerEgress(policy) {
throw new PolicyError('Docker external egress requires egress.proxyUrl');
}

export function requireKubernetesEgress(policy) {
if (policy.egress.dnsCIDRs.length === 0) throw new PolicyError('Kubernetes egress requires egress.dnsCIDRs for controlled DNS');
for (const cidr of policy.egress.dnsCIDRs) validateCIDR(cidr, 'Workspace egress DNS CIDR');
/**
* @param dnsCIDRs the DNS ranges actually in force — discovered from the cluster when the
* policy leaves them unset, so a discovered value is validated as strictly as a
* configured one.
*/
export function requireKubernetesEgress(policy, dnsCIDRs = policy.egress.dnsCIDRs) {
if (dnsCIDRs.length === 0) throw new PolicyError('Kubernetes egress requires egress.dnsCIDRs for controlled DNS');
for (const cidr of dnsCIDRs) validateCIDR(cidr, 'Workspace egress DNS CIDR');
if (policy.egress.mode === 'managed') {
validateGatewayImage(policy.egress.gatewayImage);
return;
Expand Down
101 changes: 101 additions & 0 deletions src/providers/kubernetes-dns.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createKubernetesProvider, resetKubernetesDiscoveryCache } from './kubernetes.js';

const BASE_POLICY = {
defaultProvider: 'kubernetes',
defaultImage: 'ghcr.io/openchamber/opencode-workspace@sha256:1111111111111111111111111111111111111111111111111111111111111111',
allowedImages: [],
requirePinnedImage: true,
modelAuth: 'explicit-opencode-auth-content',
egress: {
mode: 'managed',
gatewayImage: 'ghcr.io/openchamber/workspace-egress-gateway@sha256:2222222222222222222222222222222222222222222222222222222222222222',
allowedDomains: ['api.anthropic.com'],
allowedCIDRs: [],
allowedPorts: [443],
dnsCIDRs: [],
gatewayPolicy: { allowedDomains: ['api.anthropic.com'], allowedCIDRs: [], allowedPorts: [443] },
},
kubernetes: {
context: 'provided-cluster', namespace: 'workspaces', connectivity: 'port-forward',
storage: '8Gi', cpuRequest: '250m', memoryRequest: '512Mi', cpuLimit: '2', memoryLimit: '4Gi',
ingress: { ingressClassName: '', hostTemplate: '', pathTemplate: '/', tls: { mode: 'existing-secret', secretName: '' }, controllerNamespaceSelector: {}, controllerPodSelector: {}, annotations: {} },
},
};

/** Answers the preflight sequence so a test only states the DNS services the cluster has. */
function clusterWith({ dnsServices, dnsError, policy = BASE_POLICY }) {
const calls = [];
const run = vi.fn(async (command, args) => {
calls.push(args.join(' '));
const joined = args.join(' ');
if (joined.includes('version --client')) return { stdout: 'Client Version: v1.36.1' };
if (joined.includes('get namespace')) return { stdout: 'namespace/workspaces' };
if (joined.includes('auth can-i')) return { stdout: 'yes' };
if (joined.includes('k8s-app=kube-dns')) {
if (dnsError) throw Object.assign(new Error('forbidden'), { stderr: 'services is forbidden' });
return { stdout: JSON.stringify({ items: dnsServices }) };
}
throw new Error(`unexpected kubectl call: ${joined}`);
});
return { run, calls, policy };
}

vi.mock('../process.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, run: (...args) => globalThis.__kubectlRun(...args) };
});

const validate = async (fixture) => {
globalThis.__kubectlRun = fixture.run;
return createKubernetesProvider({ policy: fixture.policy, sourceDirectory: process.cwd() }).validate();
};

describe('kubernetes cluster DNS discovery', () => {
beforeEach(() => resetKubernetesDiscoveryCache());

it('discovers the cluster DNS address instead of demanding it be configured', async () => {
const fixture = clusterWith({ dnsServices: [{ spec: { clusterIP: '10.96.0.10' } }] });

await expect(validate(fixture)).resolves.toMatchObject({ available: true });
expect(fixture.calls.some((call) => call.includes('k8s-app=kube-dns'))).toBe(true);
});

it('covers both addresses of a dual-stack DNS service', async () => {
const fixture = clusterWith({ dnsServices: [{ spec: { clusterIPs: ['10.96.0.10', 'fd00::10'] } }] });

await expect(validate(fixture)).resolves.toMatchObject({ available: true });
});

it('keeps a configured range authoritative and does not query the cluster for it', async () => {
const policy = { ...BASE_POLICY, egress: { ...BASE_POLICY.egress, dnsCIDRs: ['10.43.0.10/32'] } };
const fixture = clusterWith({ dnsServices: [{ spec: { clusterIP: '10.96.0.10' } }], policy });

await expect(validate(fixture)).resolves.toMatchObject({ available: true });
expect(fixture.calls.some((call) => call.includes('k8s-app=kube-dns'))).toBe(false);
});

it('asks for the address only when the cluster will not reveal it', async () => {
const fixture = clusterWith({ dnsServices: [], dnsError: true });

await expect(validate(fixture)).rejects.toMatchObject({ code: 'WORKSPACE_PROVIDER_DNS_UNRESOLVED' });
});

it('treats a headless DNS service as no answer rather than a usable range', async () => {
const fixture = clusterWith({ dnsServices: [{ spec: { clusterIP: 'None' } }] });

await expect(validate(fixture)).rejects.toMatchObject({ code: 'WORKSPACE_PROVIDER_DNS_UNRESOLVED' });
});

it('reports an unreachable cluster before complaining about the egress policy', async () => {
const fixture = clusterWith({ dnsServices: [] });
fixture.run.mockImplementation(async (command, args) => {
const joined = args.join(' ');
if (joined.includes('version --client')) return { stdout: 'Client Version: v1.36.1' };
if (joined.includes('get namespace')) throw Object.assign(new Error('connection refused'), { stderr: 'Unable to connect to the server' });
throw new Error(`unexpected kubectl call: ${joined}`);
});

await expect(validate(fixture)).rejects.toMatchObject({ code: 'WORKSPACE_PROVIDER_CLUSTER_UNREACHABLE' });
});
});
71 changes: 68 additions & 3 deletions src/providers/kubernetes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ import { ARTIFACT_LIMITS, RUNTIME_ARTIFACT_SCRIPT } from '../artifact.js';
import { checkNetworkPolicyEnforcement, lastEnforcementVerdict, requireNetworkPolicyEnforcement } from './network-policy-enforcement.js';

const portForwards = new Map();
// Cluster DNS is a property of the cluster, so it is cached per kubeconfig context, and
// expires so a cluster that is rebuilt under the same context name is not answered from
// a stale entry for the lifetime of the process.
const DNS_CACHE_TTL_MS = 10 * 60 * 1000;
const dnsCIDRCache = new Map();

export function resetKubernetesDiscoveryCache() {
dnsCIDRCache.clear();
}

export const KUBERNETES_SEED_EXTRACT_COMMAND = `set -eu; cat > /tmp/source.tar; tar --no-same-owner --no-overwrite-dir --strip-components=1 -xf /tmp/source.tar -C "$1"; mkdir -p "$1/.openchamber-runtime"; printf '%s' "$2" > "$1/.openchamber-runtime/source-generation"`;

Expand All @@ -31,7 +40,6 @@ export function createKubernetesProvider({ policy, sourceDirectory }) {
// gap first hides the real reason this host cannot run Kubernetes workspaces.
await kubectl(['version', '--client=true'], { timeoutMs: 15_000 }).catch((cause) => { throw new ProviderUnavailableError('kubectl is not available', { provider, code: 'WORKSPACE_PROVIDER_CLI_MISSING', cause }); });
if (!kubernetesConfigured(policy)) throw new ProviderUnavailableError('No Kubernetes configuration was found for this host', { provider, code: 'WORKSPACE_PROVIDER_NOT_CONFIGURED' });
requireKubernetesEgress(policy);
await kubectl(['get', 'namespace', policy.kubernetes.namespace, '-o', 'name'], { timeoutMs: 20_000 }).catch((cause) => {
const text = `${cause?.stderr ?? ''} ${cause instanceof Error ? cause.message : String(cause)}`;
if (/NotFound|not found/i.test(text)) {
Expand All @@ -43,6 +51,9 @@ export function createKubernetesProvider({ policy, sourceDirectory }) {
const { stdout } = await kubectl(['auth', 'can-i', verb, resource, '-n', policy.kubernetes.namespace], { timeoutMs: 20_000 });
if (stdout.trim() !== 'yes') throw new ProviderUnavailableError(`Kubernetes RBAC denies ${verb} ${resource} in namespace ${policy.kubernetes.namespace}`, { provider, code: 'WORKSPACE_PROVIDER_RBAC_DENIED' });
}
// Validated after the environment checks because the DNS address is discovered from
// the cluster, and an unreachable cluster is a more actionable answer than a policy gap.
requireKubernetesEgress(policy, await resolveDnsCIDRs());
if (policy.kubernetes.connectivity === 'ingress') {
await kubectl(['get', 'ingressclass', policy.kubernetes.ingress.ingressClassName, '-o', 'name'], { timeoutMs: 20_000 });
if (policy.kubernetes.ingress.tls.mode === 'existing-secret') await kubectl(['get', 'secret', policy.kubernetes.ingress.tls.secretName, '-n', policy.kubernetes.namespace, '-o', 'name'], { timeoutMs: 20_000 });
Expand All @@ -60,6 +71,60 @@ export function createKubernetesProvider({ policy, sourceDirectory }) {
return { provider, available: true, diagnostics: enforcement?.diagnostics ?? [], isolation: enforcement ? { verdict: enforcement.verdict } : null };
}

/**
* The address of the cluster's DNS service, which is different on every cluster and is
* therefore discovered rather than asked for. The setting remains an override, and is
* the only route left when RBAC hides `kube-system` from this account.
*/
async function resolveDnsCIDRs() {
if (policy.egress.dnsCIDRs.length > 0) return policy.egress.dnsCIDRs;
const cached = dnsCIDRCache.get(policy.kubernetes.context ?? '');
if (cached && cached.expiresAt > Date.now()) return cached.cidrs;
let discovered = [];
try {
const { stdout } = await kubectl(['get', 'service', '-n', 'kube-system', '-l', 'k8s-app=kube-dns', '-o', 'json'], { timeoutMs: 20_000 });
const services = JSON.parse(stdout).items ?? [];
const addresses = services.flatMap((item) => item?.spec?.clusterIPs ?? (item?.spec?.clusterIP ? [item.spec.clusterIP] : []));
discovered = addresses.filter((address) => typeof address === 'string' && address && address !== 'None')
.map((address) => (address.includes(':') ? `${address}/128` : `${address}/32`));
} catch {
discovered = [];
}
if (discovered.length === 0) {
throw new ProviderUnavailableError(
'The cluster DNS address could not be determined, and no DNS range is configured. Ask your cluster administrator for the DNS service address and set it under Advanced.',
{ provider, code: 'WORKSPACE_PROVIDER_DNS_UNRESOLVED' },
);
}
dnsCIDRCache.set(policy.kubernetes.context ?? '', { cidrs: discovered, expiresAt: Date.now() + DNS_CACHE_TTL_MS });
return discovered;
}

/**
* What the host already knows about reaching a cluster. kubeconfig is where the
* industry keeps this, and it binds cluster and namespace together, so it is read
* rather than retyped. Only names travel: a kubeconfig also holds tokens, client
* certificates and server addresses, and none of that belongs in a settings surface.
*/
async function describe() {
if (!kubernetesConfigured(policy)) return { provider, contexts: [], currentContext: null };
try {
const { stdout } = await run('kubectl', ['config', 'view', '-o', 'json'], { timeoutMs: 20_000 });
const config = JSON.parse(stdout);
const currentContext = typeof config['current-context'] === 'string' ? config['current-context'] : null;
const contexts = (Array.isArray(config.contexts) ? config.contexts : [])
.filter((entry) => typeof entry?.name === 'string' && entry.name)
.map((entry) => ({
name: entry.name,
namespace: typeof entry.context?.namespace === 'string' ? entry.context.namespace : null,
current: entry.name === currentContext,
}));
return { provider, contexts, currentContext };
} catch {
return { provider, contexts: [], currentContext: null };
}
}

/**
* Completes a setup requirement on the operator's behalf. Kept separate from preflight
* so that inspecting readiness never changes the cluster.
Expand Down Expand Up @@ -106,7 +171,7 @@ export function createKubernetesProvider({ policy, sourceDirectory }) {
const secrets = await createWorkspaceSecrets(meta.providerResourceID, grantedCredentials);
const hostPort = await availableStablePort(meta.providerResourceID);
await transaction.update({ hostPort, imageDigest: image });
const manifests = buildManifests({ identity, refs, image, policy: { ...policy, egress: grantedEgressPolicy(policy, grantedCredentials) }, token: secrets.token, modelAuth: secrets.modelAuth });
const manifests = buildManifests({ identity, refs, image, policy: { ...policy, egress: { ...grantedEgressPolicy(policy, grantedCredentials), dnsCIDRs: await resolveDnsCIDRs() } }, token: secrets.token, modelAuth: secrets.modelAuth });
for (const manifest of manifests.infrastructure) {
const resource = `${manifest.kind.toLowerCase()}:${manifest.metadata.name}`;
await transaction.create(resource, () => createManifest(kubectl, manifest), async () => {
Expand Down Expand Up @@ -242,7 +307,7 @@ export function createKubernetesProvider({ policy, sourceDirectory }) {
});
}

return { kind: provider, configure, create, target, remove, list, health, exportWorkspace, reconcile, rotateCredentials, validate: preflight, setup };
return { kind: provider, configure, create, target, remove, list, health, exportWorkspace, reconcile, rotateCredentials, validate: preflight, setup, describe };
}

export function buildManifests({ identity, refs, image, policy, token, modelAuth }) {
Expand Down
Loading