Initial commit
This commit is contained in:
129
scripts/images.js
Normal file
129
scripts/images.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
const config = {
|
||||
registry: process.env.IMAGE_REGISTRY || 'registry.internal.budgethost.io',
|
||||
namespace: process.env.IMAGE_NAMESPACE || 'skipper',
|
||||
tag: process.env.IMAGE_TAG || 'latest',
|
||||
platform: process.env.IMAGE_PLATFORM || '',
|
||||
images: [
|
||||
{
|
||||
id: 'skipper-api',
|
||||
context: '.',
|
||||
dockerfile: 'skipper-api/Dockerfile',
|
||||
repository: 'skipper-api',
|
||||
},
|
||||
{
|
||||
id: 'skippy-agent',
|
||||
context: '.',
|
||||
dockerfile: 'skippy-agent/Dockerfile',
|
||||
repository: 'skippy-agent',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function getAction() {
|
||||
const action = process.argv[2] || 'print';
|
||||
|
||||
if (!['print', 'build', 'push', 'release'].includes(action)) {
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
function imageRef(image, tag) {
|
||||
return `${config.registry}/${config.namespace}/${image.repository}:${tag}`;
|
||||
}
|
||||
|
||||
function spawnCommand(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildImage(image) {
|
||||
const tags = [imageRef(image, config.tag)];
|
||||
|
||||
if (config.tag !== 'latest') {
|
||||
tags.push(imageRef(image, 'latest'));
|
||||
}
|
||||
|
||||
const args = ['build', '-f', image.dockerfile];
|
||||
|
||||
if (config.platform) {
|
||||
args.push('--platform', config.platform);
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
args.push('-t', tag);
|
||||
}
|
||||
|
||||
args.push(image.context);
|
||||
|
||||
console.log(`Building ${image.id}`);
|
||||
await spawnCommand('docker', args);
|
||||
}
|
||||
|
||||
async function pushImage(image) {
|
||||
const tags = [config.tag];
|
||||
|
||||
if (config.tag !== 'latest') {
|
||||
tags.push('latest');
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const ref = imageRef(image, tag);
|
||||
console.log(`Pushing ${ref}`);
|
||||
await spawnCommand('docker', ['push', ref]);
|
||||
}
|
||||
}
|
||||
|
||||
function printPlan() {
|
||||
for (const image of config.images) {
|
||||
console.log(`${image.id}: ${imageRef(image, config.tag)}`);
|
||||
|
||||
if (config.tag !== 'latest') {
|
||||
console.log(`${image.id}: ${imageRef(image, 'latest')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const action = getAction();
|
||||
|
||||
if (action === 'print') {
|
||||
printPlan();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const image of config.images) {
|
||||
if (action === 'build' || action === 'release') {
|
||||
await buildImage(image);
|
||||
}
|
||||
|
||||
if (action === 'push' || action === 'release') {
|
||||
await pushImage(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
86
scripts/register-node.js
Normal file
86
scripts/register-node.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const path = require('path');
|
||||
const { randomBytes } = require('crypto');
|
||||
const { writeJson } = require('../shared/fs');
|
||||
const { dataDir } = require('../shared/paths');
|
||||
|
||||
function getArg(flag, fallback) {
|
||||
const index = process.argv.indexOf(flag);
|
||||
|
||||
if (index === -1 || index + 1 >= process.argv.length) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function randomTokenHex(bytes) {
|
||||
return randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
async function registerNode({ nodeId, role, region, token }) {
|
||||
const createdAt = nowIso();
|
||||
const nodeFile = path.join(dataDir, 'resources', 'nodes', `${nodeId}.json`);
|
||||
const tokenFile = path.join(dataDir, 'auth', 'nodes', `${nodeId}.json`);
|
||||
|
||||
await writeJson(nodeFile, {
|
||||
id: nodeId,
|
||||
resource_type: 'node',
|
||||
schema_version: 'v1',
|
||||
desired_state: {
|
||||
enabled: true,
|
||||
labels: {
|
||||
role,
|
||||
region,
|
||||
},
|
||||
},
|
||||
current_state: {},
|
||||
last_applied_state: {},
|
||||
metadata: {},
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
});
|
||||
|
||||
await writeJson(tokenFile, {
|
||||
node_id: nodeId,
|
||||
token,
|
||||
schema_version: 'v1',
|
||||
updated_at: createdAt,
|
||||
});
|
||||
|
||||
return {
|
||||
node_id: nodeId,
|
||||
token,
|
||||
node_file: nodeFile,
|
||||
token_file: tokenFile,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const nodeId = getArg('--node-id', process.env.NODE_ID);
|
||||
const role = getArg('--role', process.env.NODE_ROLE || 'worker');
|
||||
const region = getArg('--region', process.env.NODE_REGION || 'default');
|
||||
const token = getArg('--token', process.env.NODE_TOKEN || randomTokenHex(32));
|
||||
|
||||
if (!nodeId) {
|
||||
throw new Error('Missing node id. Use --node-id <id> or NODE_ID=<id>.');
|
||||
}
|
||||
|
||||
const result = await registerNode({ nodeId, role, region, token });
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerNode,
|
||||
randomTokenHex,
|
||||
};
|
||||
204
scripts/release.js
Normal file
204
scripts/release.js
Normal file
@@ -0,0 +1,204 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const artifactsDir = path.join(rootDir, 'artifacts', 'releases');
|
||||
const registry = process.env.IMAGE_REGISTRY || 'registry.internal.budgethost.io';
|
||||
const namespace = process.env.IMAGE_NAMESPACE || 'skipper';
|
||||
const channel = process.env.RELEASE_CHANNEL || 'stable';
|
||||
|
||||
function getAction() {
|
||||
const action = process.argv[2] || 'print';
|
||||
|
||||
if (!['print', 'plan', 'build', 'publish'].includes(action)) {
|
||||
throw new Error(`Unsupported action: ${action}`);
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
function timestampUtc() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function compactTimestamp(value) {
|
||||
return value.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
function sanitizeTagPart(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unknown';
|
||||
}
|
||||
|
||||
function spawnCapture(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `${command} ${args.join(' ')} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function spawnInherited(command, args, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function detectGitSha() {
|
||||
try {
|
||||
const result = await spawnCapture('git', ['rev-parse', '--short', 'HEAD']);
|
||||
return result.stdout || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildReleaseMetadata() {
|
||||
const createdAt = timestampUtc();
|
||||
const gitSha = sanitizeTagPart(process.env.RELEASE_GIT_SHA || (await detectGitSha()) || 'nogit');
|
||||
const versionTag = sanitizeTagPart(
|
||||
process.env.RELEASE_VERSION_TAG || `${channel}-${gitSha}-${compactTimestamp(createdAt)}`
|
||||
);
|
||||
const images = [
|
||||
{
|
||||
id: 'skipper-api',
|
||||
repository: 'skipper-api',
|
||||
image: `${registry}/${namespace}/skipper-api:${versionTag}`,
|
||||
latest_image: `${registry}/${namespace}/skipper-api:latest`,
|
||||
},
|
||||
{
|
||||
id: 'skippy-agent',
|
||||
repository: 'skippy-agent',
|
||||
image: `${registry}/${namespace}/skippy-agent:${versionTag}`,
|
||||
latest_image: `${registry}/${namespace}/skippy-agent:latest`,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
schema_version: 'v1',
|
||||
created_at: createdAt,
|
||||
registry,
|
||||
namespace,
|
||||
channel,
|
||||
git_sha: gitSha,
|
||||
version_tag: versionTag,
|
||||
images,
|
||||
compose: {
|
||||
files: ['docker-compose.yml', 'docker-compose.registry.yml'],
|
||||
image_tag: versionTag,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeManifest(metadata) {
|
||||
await fs.mkdir(artifactsDir, { recursive: true });
|
||||
|
||||
const versionPath = path.join(artifactsDir, `${metadata.version_tag}.json`);
|
||||
const latestPath = path.join(artifactsDir, 'latest.json');
|
||||
const payload = JSON.stringify(metadata, null, 2);
|
||||
|
||||
await fs.writeFile(versionPath, payload);
|
||||
await fs.writeFile(latestPath, payload);
|
||||
|
||||
return {
|
||||
versionPath,
|
||||
latestPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function runImagesRelease(mode, metadata) {
|
||||
const scriptName = mode === 'build' ? 'images:build' : 'images:release';
|
||||
|
||||
await spawnInherited('npm', ['run', scriptName], {
|
||||
IMAGE_REGISTRY: metadata.registry,
|
||||
IMAGE_NAMESPACE: metadata.namespace,
|
||||
IMAGE_TAG: metadata.version_tag,
|
||||
});
|
||||
}
|
||||
|
||||
function printMetadata(metadata, manifestPaths) {
|
||||
console.log(`channel: ${metadata.channel}`);
|
||||
console.log(`git_sha: ${metadata.git_sha}`);
|
||||
console.log(`version_tag: ${metadata.version_tag}`);
|
||||
|
||||
for (const image of metadata.images) {
|
||||
console.log(`${image.id}: ${image.image}`);
|
||||
console.log(`${image.id}: ${image.latest_image}`);
|
||||
}
|
||||
|
||||
if (manifestPaths) {
|
||||
console.log(`manifest: ${manifestPaths.versionPath}`);
|
||||
console.log(`manifest_latest: ${manifestPaths.latestPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const action = getAction();
|
||||
const metadata = await buildReleaseMetadata();
|
||||
|
||||
if (action === 'print') {
|
||||
printMetadata(metadata);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifestPaths = await writeManifest(metadata);
|
||||
|
||||
if (action === 'plan') {
|
||||
printMetadata(metadata, manifestPaths);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.RUN_SMOKE_TEST === '1') {
|
||||
await spawnInherited('npm', ['run', 'smoke:test']);
|
||||
}
|
||||
|
||||
await runImagesRelease(action, metadata);
|
||||
printMetadata(metadata, manifestPaths);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
169
scripts/skipper.js
Normal file
169
scripts/skipper.js
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
const { randomUUID } = require('crypto');
|
||||
const { registerNode, randomTokenHex } = require('./register-node');
|
||||
|
||||
function getArgs() {
|
||||
return process.argv.slice(2);
|
||||
}
|
||||
|
||||
function getFlagValue(args, flag, fallback) {
|
||||
const index = args.indexOf(flag);
|
||||
|
||||
if (index === -1 || index + 1 >= args.length) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
function hasFlag(args, flag) {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function jsonOut(value) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function fetchEnvelope(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || payload.error) {
|
||||
const error = new Error(payload && payload.error ? payload.error.message : `Request failed: ${response.status}`);
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildContextHeaders() {
|
||||
return {
|
||||
'x-request-id': randomUUID(),
|
||||
'x-correlation-id': randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
function usage() {
|
||||
process.stdout.write([
|
||||
'Skipper CLI',
|
||||
'',
|
||||
'Commands:',
|
||||
' skipper health --url <base_url>',
|
||||
' skipper deploy apply --url <base_url> --admin-token <token> --tenant-id <tenant_id> [--idempotency-key <key>]',
|
||||
' skipper node register --node-id <id> [--role <role>] [--region <region>] [--token <token>]',
|
||||
' skipper node show --url <base_url> --admin-token <token> --node-id <id>',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function cmdHealth(args) {
|
||||
const baseUrl = getFlagValue(args, '--url', process.env.SKIPPER_URL || 'http://127.0.0.1:3000');
|
||||
const payload = await fetchEnvelope(`${baseUrl.replace(/\/$/, '')}/v1/health`, {
|
||||
headers: buildContextHeaders(),
|
||||
});
|
||||
jsonOut(payload);
|
||||
}
|
||||
|
||||
async function cmdDeployApply(args) {
|
||||
const baseUrl = getFlagValue(args, '--url', process.env.SKIPPER_URL || 'http://127.0.0.1:3000');
|
||||
const adminToken = getFlagValue(args, '--admin-token', process.env.ADMIN_TOKEN);
|
||||
const tenantId = getFlagValue(args, '--tenant-id', process.env.TENANT_ID);
|
||||
const idempotencyKey = getFlagValue(args, '--idempotency-key', process.env.IDEMPOTENCY_KEY || randomUUID());
|
||||
|
||||
if (!adminToken) {
|
||||
throw new Error('Missing admin token. Use --admin-token or ADMIN_TOKEN.');
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
throw new Error('Missing tenant id. Use --tenant-id or TENANT_ID.');
|
||||
}
|
||||
|
||||
const payload = await fetchEnvelope(`${baseUrl.replace(/\/$/, '')}/v1/deployments/${tenantId}/apply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...buildContextHeaders(),
|
||||
'x-admin-token': adminToken,
|
||||
'x-idempotency-key': idempotencyKey,
|
||||
},
|
||||
});
|
||||
|
||||
jsonOut(payload);
|
||||
}
|
||||
|
||||
async function cmdNodeRegister(args) {
|
||||
const nodeId = getFlagValue(args, '--node-id', process.env.NODE_ID);
|
||||
const role = getFlagValue(args, '--role', process.env.NODE_ROLE || 'worker');
|
||||
const region = getFlagValue(args, '--region', process.env.NODE_REGION || 'default');
|
||||
const token = getFlagValue(args, '--token', process.env.NODE_TOKEN || randomTokenHex(32));
|
||||
|
||||
if (!nodeId) {
|
||||
throw new Error('Missing node id. Use --node-id or NODE_ID.');
|
||||
}
|
||||
|
||||
jsonOut(await registerNode({ nodeId, role, region, token }));
|
||||
}
|
||||
|
||||
async function cmdNodeShow(args) {
|
||||
const baseUrl = getFlagValue(args, '--url', process.env.SKIPPER_URL || 'http://127.0.0.1:3000');
|
||||
const adminToken = getFlagValue(args, '--admin-token', process.env.ADMIN_TOKEN);
|
||||
const nodeId = getFlagValue(args, '--node-id', process.env.NODE_ID);
|
||||
|
||||
if (!adminToken) {
|
||||
throw new Error('Missing admin token. Use --admin-token or ADMIN_TOKEN.');
|
||||
}
|
||||
|
||||
if (!nodeId) {
|
||||
throw new Error('Missing node id. Use --node-id or NODE_ID.');
|
||||
}
|
||||
|
||||
const payload = await fetchEnvelope(`${baseUrl.replace(/\/$/, '')}/v1/resources/node/${nodeId}`, {
|
||||
headers: {
|
||||
...buildContextHeaders(),
|
||||
'x-admin-token': adminToken,
|
||||
},
|
||||
});
|
||||
|
||||
jsonOut(payload);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = getArgs();
|
||||
|
||||
if (args.length === 0 || hasFlag(args, '--help') || hasFlag(args, '-h')) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
const [group, command, subcommand] = args;
|
||||
|
||||
if (group === 'health') {
|
||||
await cmdHealth(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (group === 'deploy' && command === 'apply') {
|
||||
await cmdDeployApply(args.slice(2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (group === 'node' && command === 'register') {
|
||||
await cmdNodeRegister(args.slice(2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (group === 'node' && command === 'show') {
|
||||
await cmdNodeShow(args.slice(2));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown command: ${args.join(' ')}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
if (error.payload) {
|
||||
process.stderr.write(`${JSON.stringify(error.payload, null, 2)}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
307
scripts/smoke-test.js
Normal file
307
scripts/smoke-test.js
Normal file
@@ -0,0 +1,307 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const apiDir = path.join(rootDir, 'skipper-api');
|
||||
const agentDir = path.join(rootDir, 'skippy-agent');
|
||||
const adminToken = 'smoke-admin-token';
|
||||
const agentToken = 'smoke-node-token';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath) {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await ensureDir(path.dirname(filePath));
|
||||
await fs.writeFile(filePath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function startProcess(name, command, args, options) {
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
process.stdout.write(`[${name}] ${chunk.toString()}`);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(`[${name}] ${chunk.toString()}`);
|
||||
});
|
||||
|
||||
return { child };
|
||||
}
|
||||
|
||||
async function stopProcess(proc) {
|
||||
if (!proc || proc.child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
proc.child.kill('SIGINT');
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => proc.child.once('exit', resolve)),
|
||||
sleep(3000).then(() => {
|
||||
proc.child.kill('SIGKILL');
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitForHealth(url, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
|
||||
if (payload.data && payload.data.ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Retry until timeout.
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${url}`);
|
||||
}
|
||||
|
||||
async function waitForFinishedWorkOrder(finishedDir, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const entries = await fs.readdir(finishedDir);
|
||||
|
||||
if (entries.length > 0) {
|
||||
const filePath = path.join(finishedDir, entries.sort()[0]);
|
||||
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for finished work order');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runId = randomUUID();
|
||||
const testRoot = path.join(os.tmpdir(), `skipper-smoke-${runId}`);
|
||||
const dataDir = path.join(testRoot, 'data');
|
||||
const composeDir = path.join(testRoot, 'compose');
|
||||
const mockBinDir = path.join(testRoot, 'mockbin');
|
||||
const apiPort = 3100;
|
||||
const apiUrl = `http://127.0.0.1:${apiPort}`;
|
||||
const finishedDir = path.join(dataDir, 'work-orders', 'finished');
|
||||
const deployLogsPath = path.join(testRoot, 'docker-invocations.log');
|
||||
|
||||
await ensureDir(path.join(dataDir, 'work-orders', 'pending'));
|
||||
await ensureDir(finishedDir);
|
||||
await ensureDir(path.join(dataDir, 'resources', 'tenants'));
|
||||
await ensureDir(path.join(dataDir, 'resources', 'nodes'));
|
||||
await ensureDir(path.join(dataDir, 'resources', 'services'));
|
||||
await ensureDir(path.join(dataDir, 'resources', 'deployments'));
|
||||
await ensureDir(path.join(dataDir, 'resources', 'resource-limits'));
|
||||
await ensureDir(path.join(dataDir, 'resources', 'networks'));
|
||||
await ensureDir(path.join(dataDir, 'resources', 'volumes'));
|
||||
await ensureDir(path.join(dataDir, 'auth', 'nodes'));
|
||||
await ensureDir(composeDir);
|
||||
await ensureDir(mockBinDir);
|
||||
|
||||
await writeJson(path.join(dataDir, 'resources', 'nodes', 'host-1.json'), {
|
||||
id: 'host-1',
|
||||
resource_type: 'node',
|
||||
schema_version: 'v1',
|
||||
desired_state: {
|
||||
enabled: true,
|
||||
labels: {
|
||||
role: 'smoke-test',
|
||||
},
|
||||
},
|
||||
current_state: {},
|
||||
last_applied_state: {},
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await writeJson(path.join(dataDir, 'resources', 'tenants', 'example-tenant.json'), {
|
||||
id: 'example-tenant',
|
||||
resource_type: 'tenant',
|
||||
schema_version: 'v1',
|
||||
desired_state: {
|
||||
display_name: 'Example Tenant',
|
||||
deployment_policy: {
|
||||
target_node_id: 'host-1',
|
||||
},
|
||||
service_ids: ['service-web'],
|
||||
compose: {
|
||||
tenant_id: 'example-tenant',
|
||||
compose_file: [
|
||||
'services:',
|
||||
' web:',
|
||||
' image: nginx:alpine',
|
||||
' restart: unless-stopped',
|
||||
' ports:',
|
||||
' - "${NGINX_PORT}:80"',
|
||||
'',
|
||||
].join('\n'),
|
||||
env: {
|
||||
NGINX_PORT: '8081',
|
||||
},
|
||||
},
|
||||
},
|
||||
current_state: {},
|
||||
last_applied_state: {},
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await writeJson(path.join(dataDir, 'resources', 'services', 'service-web.json'), {
|
||||
id: 'service-web',
|
||||
resource_type: 'service',
|
||||
schema_version: 'v1',
|
||||
desired_state: {
|
||||
tenant_id: 'example-tenant',
|
||||
service_kind: 'nginx',
|
||||
image: 'nginx:alpine',
|
||||
networks: [],
|
||||
volumes: [],
|
||||
resource_limits: null,
|
||||
},
|
||||
current_state: {},
|
||||
last_applied_state: {},
|
||||
metadata: {},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await writeJson(path.join(dataDir, 'auth', 'nodes', 'host-1.json'), {
|
||||
node_id: 'host-1',
|
||||
token: agentToken,
|
||||
schema_version: 'v1',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(mockBinDir, 'docker'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`echo "$@" >> "${deployLogsPath}"`,
|
||||
'echo "mock docker $@"',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
|
||||
const api = startProcess('api', process.execPath, ['src/index.js'], {
|
||||
cwd: apiDir,
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: dataDir,
|
||||
PORT: String(apiPort),
|
||||
HOST: '127.0.0.1',
|
||||
ADMIN_TOKEN: adminToken,
|
||||
},
|
||||
});
|
||||
|
||||
let agent;
|
||||
|
||||
try {
|
||||
await waitForHealth(`${apiUrl}/v1/health`, 10000);
|
||||
|
||||
agent = startProcess('agent', process.execPath, ['src/index.js'], {
|
||||
cwd: agentDir,
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: dataDir,
|
||||
SKIPPER_URL: apiUrl,
|
||||
AGENT_ID: 'host-1',
|
||||
AGENT_TOKEN: agentToken,
|
||||
POLL_INTERVAL_MS: '500',
|
||||
HEARTBEAT_INTERVAL_MS: '1000',
|
||||
SKIPPY_COMPOSE_BASE_DIR: composeDir,
|
||||
PATH: `${mockBinDir}:${process.env.PATH}`,
|
||||
},
|
||||
});
|
||||
|
||||
const deployResponse = await fetch(`${apiUrl}/v1/deployments/example-tenant/apply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-admin-token': adminToken,
|
||||
'x-idempotency-key': `smoke-${runId}`,
|
||||
'x-request-id': randomUUID(),
|
||||
'x-correlation-id': randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!deployResponse.ok) {
|
||||
throw new Error(`Deploy request failed: ${deployResponse.status} ${await deployResponse.text()}`);
|
||||
}
|
||||
|
||||
const createdResponse = await deployResponse.json();
|
||||
const createdWorkOrder = createdResponse.data.work_order;
|
||||
const completedWorkOrder = await waitForFinishedWorkOrder(finishedDir, 10000);
|
||||
const composeFile = await fs.readFile(path.join(composeDir, 'example-tenant', 'docker-compose.yml'), 'utf8');
|
||||
const envFile = await fs.readFile(path.join(composeDir, 'example-tenant', '.env'), 'utf8');
|
||||
const dockerLog = await fs.readFile(deployLogsPath, 'utf8');
|
||||
const nodeState = JSON.parse(await fs.readFile(path.join(dataDir, 'resources', 'nodes', 'host-1.json'), 'utf8'));
|
||||
const tenantState = JSON.parse(await fs.readFile(path.join(dataDir, 'resources', 'tenants', 'example-tenant.json'), 'utf8'));
|
||||
|
||||
if (completedWorkOrder.id !== createdWorkOrder.id) {
|
||||
throw new Error(`Completed unexpected work order ${completedWorkOrder.id}`);
|
||||
}
|
||||
|
||||
if (!completedWorkOrder.result || !completedWorkOrder.result.success) {
|
||||
throw new Error('Work order did not complete successfully');
|
||||
}
|
||||
|
||||
if (!dockerLog.includes(`compose -f ${path.join(composeDir, 'example-tenant', 'docker-compose.yml')} up -d`)) {
|
||||
throw new Error('Mock docker command was not invoked as expected');
|
||||
}
|
||||
|
||||
if (!composeFile.includes('image: nginx:alpine')) {
|
||||
throw new Error('Compose file was not written correctly');
|
||||
}
|
||||
|
||||
if (!envFile.includes('NGINX_PORT=8081')) {
|
||||
throw new Error('.env file was not written correctly');
|
||||
}
|
||||
|
||||
if (nodeState.id !== 'host-1' || !nodeState.current_state.heartbeat_at) {
|
||||
throw new Error('Node heartbeat was not persisted');
|
||||
}
|
||||
|
||||
if (tenantState.current_state.last_deployment_status !== 'success') {
|
||||
throw new Error('Tenant current state was not updated');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Smoke test passed');
|
||||
console.log(`Work order: ${completedWorkOrder.id}`);
|
||||
console.log(`Duration: ${completedWorkOrder.result.details.duration_ms}ms`);
|
||||
} finally {
|
||||
await stopProcess(agent);
|
||||
await stopProcess(api);
|
||||
await fs.rm(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user