Initial commit

This commit is contained in:
2026-04-05 15:28:04 +02:00
commit 0435b3d07d
43 changed files with 4394 additions and 0 deletions

20
skippy-agent/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine
RUN apk add --no-cache docker-cli docker-cli-compose
WORKDIR /app/skippy-agent
COPY skippy-agent/package.json ./package.json
RUN npm install --omit=dev
COPY skippy-agent/src ./src
COPY shared /app/shared
ENV DATA_DIR=/app/data
ENV SKIPPER_URL=http://skipper-api:3000
ENV AGENT_ID=host-1
ENV POLL_INTERVAL_MS=5000
ENV HEARTBEAT_INTERVAL_MS=15000
CMD ["npm", "start"]

12
skippy-agent/package-lock.json generated Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "skippy-agent",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skippy-agent",
"version": "1.0.0"
}
}
}

10
skippy-agent/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "skippy-agent",
"version": "1.0.0",
"private": true,
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
}
}

238
skippy-agent/src/index.js Normal file
View File

@@ -0,0 +1,238 @@
const fs = require('fs/promises');
const path = require('path');
const os = require('os');
const { createContext } = require('../../shared/context');
const { createLogger } = require('../../shared/logs');
const { getJson, postJson } = require('./lib/http');
const docker = require('./modules/docker');
const agentId = process.env.AGENT_ID || 'host-1';
const agentToken = process.env.AGENT_TOKEN || 'dev-node-token';
const skipperUrl = (process.env.SKIPPER_URL || 'http://localhost:3000').replace(/\/$/, '');
const pollIntervalMs = Number(process.env.POLL_INTERVAL_MS || 5000);
const heartbeatIntervalMs = Number(process.env.HEARTBEAT_INTERVAL_MS || 15000);
const logger = createLogger({ service: 'skippy', node_id: agentId });
let isPolling = false;
async function sendHeartbeat() {
const context = createContext();
await postJson(`${skipperUrl}/v1/nodes/${agentId}/heartbeat`, {
hostname: os.hostname(),
capabilities: ['deploy_service'],
agent_version: '1.0.0',
}, agentToken, context);
await logger.info('node.heartbeat', 'success', context);
}
function buildComposePath(workOrder) {
return path.join(docker.composeBaseDir, workOrder.desired_state.compose_project.tenant_id, 'docker-compose.yml');
}
async function executeDeployService(workOrder) {
const composeProject = workOrder.desired_state.compose_project;
const composePath = buildComposePath(workOrder);
await fs.mkdir(path.dirname(composePath), { recursive: true });
await fs.writeFile(composePath, composeProject.compose_file, 'utf8');
const commandResult = await docker.applyCompose({
path: composePath,
env: composeProject.env || {},
});
const status = commandResult.code === 0 ? 'success' : 'failed';
return {
status,
result: {
success: status === 'success',
code: status === 'success' ? 'APPLY_OK' : 'STATE_APPLY_FAILED',
message: status === 'success' ? 'Desired state applied' : 'Desired state apply failed',
details: {
duration_ms: 0,
compose_path: composePath,
changed_resources: workOrder.desired_state.service_ids || [],
unchanged_resources: [],
command: {
program: 'docker',
args: ['compose', '-f', composePath, 'up', '-d'],
exit_code: commandResult.code,
stdout: commandResult.stdout.trim(),
stderr: commandResult.stderr.trim(),
},
},
},
state_report: {
resources: [
{
resource_type: 'deployment',
resource_id: workOrder.desired_state.deployment_id,
current_state: {
status,
observed_at: new Date().toISOString(),
node_id: agentId,
compose_path: composePath,
},
last_applied_state: workOrder.desired_state,
},
{
resource_type: 'tenant',
resource_id: workOrder.target.tenant_id,
current_state: {
deployed_on_node_id: agentId,
last_deployment_status: status,
observed_at: new Date().toISOString(),
},
last_applied_state: {
deployment_id: workOrder.desired_state.deployment_id,
compose_project: {
tenant_id: composeProject.tenant_id,
env: composeProject.env || {},
},
},
},
],
},
};
}
async function executeWorkOrder(workOrder) {
const startedAt = Date.now();
const context = createContext({
request_id: workOrder.request_id,
correlation_id: workOrder.correlation_id,
});
await logger.info('work_order.execute', 'started', {
...context,
work_order_id: workOrder.id,
type: workOrder.type,
tenant_id: workOrder.target.tenant_id || null,
});
try {
let execution;
switch (workOrder.type) {
case 'deploy_service':
execution = await executeDeployService(workOrder);
break;
default:
execution = {
status: 'failed',
result: {
success: false,
code: 'STATE_APPLY_FAILED',
message: `Unsupported work order type: ${workOrder.type}`,
details: {
duration_ms: 0,
changed_resources: [],
unchanged_resources: [],
},
},
state_report: { resources: [] },
};
break;
}
execution.result.details.duration_ms = Date.now() - startedAt;
await logger.info('work_order.execute', execution.status, {
...context,
work_order_id: workOrder.id,
type: workOrder.type,
tenant_id: workOrder.target.tenant_id || null,
code: execution.result.code,
});
return {
...execution,
context,
};
} catch (error) {
await logger.error('work_order.execute', 'failed', {
...context,
work_order_id: workOrder.id,
type: workOrder.type,
tenant_id: workOrder.target.tenant_id || null,
message: error.message,
code: 'STATE_APPLY_FAILED',
});
return {
status: 'failed',
result: {
success: false,
code: 'STATE_APPLY_FAILED',
message: error.message,
details: {
duration_ms: Date.now() - startedAt,
changed_resources: [],
unchanged_resources: [],
},
},
state_report: {
resources: [],
},
context,
};
}
}
async function pollOnce() {
if (isPolling) {
return;
}
isPolling = true;
try {
const workOrder = await getJson(`${skipperUrl}/v1/nodes/${agentId}/work-orders/next`, agentToken);
if (!workOrder) {
return;
}
const execution = await executeWorkOrder(workOrder);
await postJson(`${skipperUrl}/v1/work-orders/${workOrder.id}/result`, {
status: execution.status,
result: execution.result,
state_report: execution.state_report,
}, agentToken, execution.context);
} finally {
isPolling = false;
}
}
async function start() {
await sendHeartbeat();
setInterval(() => {
sendHeartbeat().catch(async (error) => {
await logger.error('node.heartbeat', 'failed', {
node_id: agentId,
message: error.message,
});
});
}, heartbeatIntervalMs);
setInterval(() => {
pollOnce().catch(async (error) => {
await logger.error('work_order.poll', 'failed', {
node_id: agentId,
message: error.message,
});
});
}, pollIntervalMs);
await pollOnce();
}
start().catch(async (error) => {
await logger.error('agent.start', 'failed', {
node_id: agentId,
message: error.message,
});
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
const { createContext } = require('../../../shared/context');
async function parseJson(response) {
const text = await response.text();
if (!text) {
return null;
}
return JSON.parse(text);
}
function buildHeaders(context, token) {
return {
'content-type': 'application/json',
'x-request-id': context.request_id,
'x-correlation-id': context.correlation_id,
authorization: `Bearer ${token}`,
};
}
function unwrapEnvelope(payload) {
if (!payload) {
return null;
}
if (payload.error) {
const error = new Error(payload.error.message);
error.code = payload.error.code;
error.details = payload.error.details;
throw error;
}
return payload.data;
}
async function getJson(url, token) {
const context = createContext();
const response = await fetch(url, {
headers: buildHeaders(context, token),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`GET ${url} failed: ${response.status} ${body}`);
}
return unwrapEnvelope(await parseJson(response));
}
async function postJson(url, body, token, requestContext) {
const context = createContext(requestContext);
const response = await fetch(url, {
method: 'POST',
headers: buildHeaders(context, token),
body: JSON.stringify({
request_id: context.request_id,
correlation_id: context.correlation_id,
data: body,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`POST ${url} failed: ${response.status} ${text}`);
}
return unwrapEnvelope(await parseJson(response));
}
module.exports = {
getJson,
postJson,
};

View File

@@ -0,0 +1,51 @@
const fs = require('fs/promises');
const path = require('path');
const { spawn } = require('child_process');
const composeBaseDir = process.env.SKIPPY_COMPOSE_BASE_DIR || '/opt/skipper/tenants';
async function writeEnvFile(filePath, env) {
const entries = Object.entries(env || {}).map(([key, value]) => `${key}=${String(value)}`);
await fs.writeFile(filePath, `${entries.join('\n')}\n`);
}
function runCommand(command, args, options) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
resolve({ code, stdout, stderr });
});
});
}
async function applyCompose({ path: composePath, env }) {
const cwd = path.dirname(composePath);
await fs.mkdir(cwd, { recursive: true });
await writeEnvFile(path.join(cwd, '.env'), env || {});
return runCommand('docker', ['compose', '-f', composePath, 'up', '-d'], {
cwd,
env: {
...process.env,
...Object.fromEntries(Object.entries(env || {}).map(([key, value]) => [key, String(value)])),
},
});
}
module.exports = {
composeBaseDir,
applyCompose,
};