Initial commit
This commit is contained in:
20
skippy-agent/Dockerfile
Normal file
20
skippy-agent/Dockerfile
Normal 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
12
skippy-agent/package-lock.json
generated
Normal 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
10
skippy-agent/package.json
Normal 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
238
skippy-agent/src/index.js
Normal 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);
|
||||
});
|
||||
74
skippy-agent/src/lib/http.js
Normal file
74
skippy-agent/src/lib/http.js
Normal 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,
|
||||
};
|
||||
51
skippy-agent/src/modules/docker.js
Normal file
51
skippy-agent/src/modules/docker.js
Normal 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user