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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ node_modules
/service-logs/**/*
/tool-call-server-logs/**/*
/.build-lambda-microvm/
/api/.build/
/trees/
/docs/project/_local/

# Helm artifacts
helm/*/charts/*.tgz
Expand Down
187 changes: 152 additions & 35 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
openapi: 3.0.0
openapi: 3.0.3
info:
title: Code Execution API
version: 1.0.0
title: CodeAPI Internal Sandbox Runner API
version: 2.0.0
description: >-
Internal service-to-sandbox contract. This API is not a public client
surface. The public authenticated contract is service/openapi.yml.
x-internal: true

paths:
/execute:
/api/v2/execute:
post:
summary: Execute code
summary: Execute a prepared sandbox job
operationId: executeSandboxJob
requestBody:
required: true
content:
Expand All @@ -15,80 +20,192 @@ paths:
$ref: '#/components/schemas/ExecuteRequest'
responses:
'200':
description: Successful execution
description: Sandbox execution result
content:
application/json:
schema:
$ref: '#/components/schemas/ExecuteResponse'
'400':
description: Bad request
description: Invalid execution request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: Runtime session workspace conflict
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'413':
description: Request body exceeds the configured limit
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
description: Sandbox execution failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

components:
schemas:
Error:
type: object
required: [message]
properties:
message:
type: string
code:
type: string

ExecuteRequest:
type: object
required:
- language
- version
- files
required: [language, version, files]
properties:
session_id:
type: string
description: Top-level execution session identifier.
output_session_id:
type: string
description: Storage session for generated files.
language:
type: string
version:
type: string
files:
args:
type: array
items:
$ref: '#/components/schemas/File'
type: string
stdin:
type: string
args:
files:
type: array
items:
type: string
compileTimeout:
$ref: '#/components/schemas/InputFile'
compile_memory_limit:
type: integer
run_memory_limit:
type: integer
runTimeout:
run_timeout:
type: integer
compileMemoryLimit:
compile_timeout:
type: integer
runMemoryLimit:
run_cpu_time:
type: integer

File:
compile_cpu_time:
type: integer
env_vars:
type: object
additionalProperties:
type: string
egress_grant:
type: string
description: Opaque internal egress capability.
execution_manifest:
type: string
description: Signed internal execution scope.
tool_call_socket:
type: boolean

InputFile:
type: object
required:
- name
- content
required: [name]
properties:
name:
type: string
id:
type: string
description: Storage object identifier for a by-reference input.
storage_session_id:
type: string
description: Storage session for a by-reference input.
input_cache_key:
type: string
description: Stable opaque runner-local cache identity.
name:
type: string
content:
type: string
description: Inline file content.
encoding:
type: string
enum: [base64, hex, utf8]

ExecuteResponse:
entity_id:
type: string
description: Caller authorization scope echoed on inherited outputs.

FileRef:
type: object
required: [id, name, storage_session_id]
properties:
compile:
$ref: '#/components/schemas/ExecutionStage'
run:
$ref: '#/components/schemas/ExecutionStage'

id:
type: string
name:
type: string
storage_session_id:
type: string
modified_from:
type: object
required: [id, storage_session_id]
properties:
id:
type: string
storage_session_id:
type: string
inherited:
type: boolean
enum: [true]
entity_id:
type: string

ExecutionStage:
type: object
required: [stdout, stderr, output]
properties:
stdout:
type: string
stderr:
type: string
exitCode:
code:
type: integer
nullable: true
signal:
type: string
nullable: true
output:
type: string
memory:
type: integer
nullable: true
message:
type: string
nullable: true
status:
type: string
nullable: true
cpu_time:
type: number
nullable: true
wall_time:
type: number
nullable: true

ExecuteResponse:
type: object
required: [language, version, session_id, files]
properties:
compile:
$ref: '#/components/schemas/ExecutionStage'
run:
$ref: '#/components/schemas/ExecutionStage'
language:
type: string
version:
type: string
session_id:
type: string
files:
type: array
items:
$ref: '#/components/schemas/FileRef'
11 changes: 7 additions & 4 deletions api/src/api/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express, { Router, type Request, type Response } from 'express';
import { logger } from '../logger';
import { bindSessionWorkspace, parseSessionBinding, unbindSessionWorkspace } from '../session-workspace';
import { operationalErrorMeta } from '../operational-log';

/**
* AWS Lambda MicroVM hook endpoints. The platform POSTs to
Expand Down Expand Up @@ -60,11 +61,11 @@ export function applyRunHook(body: unknown): MicrovmRunContext {
const binding = parseSessionBinding(runHookPayload);
if (binding) {
bindSessionWorkspace(binding);
logger.info({ runtimeSessionId: binding.runtimeSessionId }, 'Bound persistent session workspace');
logger.info('Bound persistent session workspace');
}
} else if (runContext.microvmId != null && microvmId != null && runContext.microvmId !== microvmId) {
logger.warn(
{ existing: runContext.microvmId, incoming: microvmId },
{ identityConflict: true },
'Ignoring /run hook for a different microvmId',
);
}
Expand All @@ -86,7 +87,7 @@ lifecycleRouter.post('/validate', ackHook('validate'));
lifecycleRouter.post('/run', express.json({ limit: '32kb' }), (req: Request, res: Response) => {
const context = applyRunHook(req.body);
logger.info(
{ hook: 'run', microvmId: context.microvmId, hasPayload: context.runHookPayload != null },
{ hook: 'run', hasIdentity: context.microvmId != null, hasPayload: context.runHookPayload != null },
'MicroVM lifecycle hook invoked',
);
return res.status(200).json({ hook: 'run', status: 'ok' });
Expand All @@ -97,7 +98,9 @@ lifecycleRouter.post('/suspend', ackHook('suspend'));

lifecycleRouter.post('/terminate', (_req: Request, res: Response) => {
logger.info({ hook: 'terminate' }, 'MicroVM lifecycle hook invoked');
void unbindSessionWorkspace().catch((err) => logger.error({ err }, 'Failed to unbind session workspace on terminate'));
void unbindSessionWorkspace().catch((err) => {
logger.error(operationalErrorMeta(err), 'Failed to unbind session workspace on terminate');
});
return res.status(200).json({ hook: 'terminate', status: 'ok' });
});

Expand Down
30 changes: 20 additions & 10 deletions api/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Runtime } from '../runtime';
import type { TFile } from '../job';
import { getLatestRuntimeMatchingLanguageVersion, getRuntimes } from '../runtime';
import { logger } from '../logger';
import { operationalErrorMeta } from '../operational-log';
import { config } from '../config';
import {
Job,
Expand Down Expand Up @@ -441,7 +442,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
logger.warn({ reason: error.reason, status }, 'Rejected sandbox request by execution manifest');
return res.status(status).json({ message: error.message });
}
logger.error({ err: error }, 'Execution manifest validation failed unexpectedly');
logger.error(operationalErrorMeta(error), 'Execution manifest validation failed unexpectedly');
return res.status(500).json({ message: 'Execution manifest validation failed' });
}
}
Expand Down Expand Up @@ -526,7 +527,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
'codeapi.language': job.runtime.language,
}, () => job!.uploadGeneratedFiles())
.catch((err) => {
logger.error({ job: job!.uuid, err }, 'File upload failed');
logger.error(operationalErrorMeta(err), 'File upload failed');
return new Set<string>();
});

Expand All @@ -538,7 +539,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
const dropped = before - result.files.length;
if (dropped > 0) {
logger.warn(
{ job: job.uuid, dropped, kept: result.files.length },
{ dropped, kept: result.files.length },
'Pruned files from response because upload did not reach file_server',
);
}
Expand All @@ -555,15 +556,21 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
* means files really were primed and dirty is the honest answer. */
if (primeCompleted && job?.markSessionDirty('execution failed after input priming')) {
metricsOutcome = 'execution_error';
logger.error({ job: job.uuid, err: error }, 'Session execution left workspace state unknown');
logger.error(
operationalErrorMeta(error),
'Session execution left workspace state unknown',
);
return res.status(409).json({
error: 'session_workspace_dirty',
message: 'Session workspace must be restored before another execute',
});
}
if (error instanceof SessionWorkspaceDirtyError) {
metricsOutcome = 'execution_error';
logger.error({ job: job?.uuid, err: error }, 'Session input priming left a partial workspace');
logger.error(
operationalErrorMeta(error),
'Session input priming left a partial workspace',
);
return res.status(409).json({
error: error.code,
message: error.message,
Expand All @@ -576,11 +583,14 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
const safeError = classifySandboxSafeError(error);
if (safeError) {
metricsOutcome = 'execution_error';
logger.error({ job: job?.uuid, err: error, safeError: safeError.body.error }, 'Sandbox setup failed');
logger.error(
{ status: safeError.status, ...operationalErrorMeta(error) },
'Sandbox setup failed',
);
return res.status(safeError.status).json(safeError.body);
}
metricsOutcome = 'execution_error';
logger.error({ job: job?.uuid, err: error }, 'Error executing job');
logger.error(operationalErrorMeta(error), 'Error executing job');
return res.status(500).json({
error: 'sandbox_execution_failed',
message: 'Sandbox execution failed',
Expand All @@ -604,7 +614,7 @@ router.get('/health', async (_req: Request, res: Response) => {
try {
return res.status(200).json(await checkSandboxWorkspaceHealth());
} catch (error) {
logger.error({ err: error }, 'Sandbox workspace health check failed');
logger.error(operationalErrorMeta(error), 'Sandbox workspace health check failed');
return res.status(503).json({
status: 'unhealthy',
error: 'workspace_unavailable',
Expand Down Expand Up @@ -771,11 +781,11 @@ router.post(
expectedBytes,
);
await pruneInputCache(config.input_cache_max_bytes).catch((err) => {
logger.warn({ err }, 'Failed to prune session input cache');
logger.warn(operationalErrorMeta(err), 'Failed to prune session input cache');
});
return res.status(200).json({ stored });
} catch (error) {
logger.error({ err: error }, 'Failed to store session inputs');
logger.error(operationalErrorMeta(error), 'Failed to store session inputs');
return res.status(500).json({ message: 'session input delivery failed' });
}
},
Expand Down
Loading