Add secure Node image webhook service
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { writeInventories } from './inventory.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const BRANCH_PATTERN = /^(?!\/)(?!.*(?:\.\.|\/\/|@\{|\\))[A-Za-z0-9._/-]{1,200}$/;
|
||||
const ZERO_SHA = /^0{40,64}$/;
|
||||
|
||||
export class DeploymentError extends Error {
|
||||
constructor(message, status = 409) {
|
||||
super(message);
|
||||
this.name = 'DeploymentError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class GitService {
|
||||
constructor(config, { run = execFileAsync, now = () => new Date() } = {}) {
|
||||
this.config = config;
|
||||
this.run = run;
|
||||
this.now = now;
|
||||
this.queue = Promise.resolve();
|
||||
this.state = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await mkdir(dirname(this.config.statePath), { recursive: true });
|
||||
this.state = await this.#loadState();
|
||||
await this.#assertRepository();
|
||||
await this.#assertClean();
|
||||
if (!this.config.allowedBranches.includes(this.state.activeBranch)) {
|
||||
throw new Error(`Active branch ${this.state.activeBranch} is not allowed`);
|
||||
}
|
||||
await this.refreshInventory();
|
||||
}
|
||||
|
||||
enqueue(operation) {
|
||||
const result = this.queue.then(operation, operation);
|
||||
this.queue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async deployWebhook({ deliveryId, branch, after }) {
|
||||
return this.enqueue(async () => {
|
||||
if (!deliveryId || deliveryId.length > 128) {
|
||||
throw new DeploymentError('Invalid delivery ID', 400);
|
||||
}
|
||||
if (this.state.deliveries.includes(deliveryId)) {
|
||||
return { duplicate: true, ...this.publicStatus() };
|
||||
}
|
||||
if (branch !== this.state.activeBranch) {
|
||||
return { ignored: true, reason: 'inactive branch', ...this.publicStatus() };
|
||||
}
|
||||
if (ZERO_SHA.test(after)) {
|
||||
return { ignored: true, reason: 'deleted ref', ...this.publicStatus() };
|
||||
}
|
||||
|
||||
const result = await this.#deploy({ branch, expectedCommit: after, allowUnrelated: false });
|
||||
this.state.deliveries = [...this.state.deliveries.slice(-199), deliveryId];
|
||||
await this.#saveState();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async deployAdmin({ requestId, branch, expectedCommit }) {
|
||||
return this.enqueue(async () => {
|
||||
if (this.state.adminRequests.includes(requestId)) {
|
||||
return { duplicate: true, ...this.publicStatus() };
|
||||
}
|
||||
const result = await this.#deploy({ branch, expectedCommit, allowUnrelated: branch !== this.state.activeBranch });
|
||||
this.state.activeBranch = branch;
|
||||
this.state.adminRequests = [...this.state.adminRequests.slice(-199), requestId];
|
||||
await this.#saveState();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async #deploy({ branch, expectedCommit, allowUnrelated }) {
|
||||
this.#validateBranch(branch);
|
||||
await this.#assertClean();
|
||||
const remoteRef = `refs/remotes/image-hook/${branch}`;
|
||||
await this.#git('fetch', '--no-tags', '--prune', this.config.remoteUrl, `+refs/heads/${branch}:${remoteRef}`);
|
||||
const target = await this.#gitText('rev-parse', '--verify', `${remoteRef}^{commit}`);
|
||||
if (expectedCommit && target !== expectedCommit) {
|
||||
throw new DeploymentError('Payload commit does not match remote branch tip');
|
||||
}
|
||||
|
||||
const current = await this.#gitText('rev-parse', 'HEAD');
|
||||
if (!allowUnrelated && current !== target) {
|
||||
const ancestor = await this.#gitExit('merge-base', '--is-ancestor', current, target);
|
||||
if (ancestor !== 0) {
|
||||
throw new DeploymentError('Non-fast-forward deployment rejected');
|
||||
}
|
||||
}
|
||||
|
||||
if (current !== target) {
|
||||
await this.#git('checkout', '--detach', target);
|
||||
}
|
||||
this.state.lastSuccess = {
|
||||
branch,
|
||||
commit: target,
|
||||
at: this.now().toISOString(),
|
||||
};
|
||||
this.state.lastError = null;
|
||||
const inventory = await this.refreshInventory(branch, target);
|
||||
await this.#saveState();
|
||||
return { changed: current !== target, inventoryAssets: inventory.assets.length, ...this.publicStatus() };
|
||||
}
|
||||
|
||||
async refreshInventory(branch = this.state.activeBranch, commit) {
|
||||
const resolvedCommit = commit ?? await this.#gitText('rev-parse', 'HEAD');
|
||||
const output = await this.#gitRaw('ls-files', '-z', '--', 'game', 'icons');
|
||||
const paths = output.split('\0').filter(Boolean);
|
||||
return writeInventories({
|
||||
repositoryPath: this.config.repositoryPath,
|
||||
paths,
|
||||
branch,
|
||||
commit: resolvedCommit,
|
||||
publicBases: this.config.publicBases,
|
||||
generatedAt: this.now(),
|
||||
});
|
||||
}
|
||||
|
||||
publicStatus() {
|
||||
return {
|
||||
activeBranch: this.state.activeBranch,
|
||||
lastSuccess: this.state.lastSuccess,
|
||||
lastError: this.state.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
async recordError(error) {
|
||||
this.state.lastError = { message: error.message, at: this.now().toISOString() };
|
||||
await this.#saveState();
|
||||
}
|
||||
|
||||
#validateBranch(branch) {
|
||||
if (!BRANCH_PATTERN.test(branch) || !this.config.allowedBranches.includes(branch)) {
|
||||
throw new DeploymentError('Branch is not allowed', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async #assertRepository() {
|
||||
const inside = await this.#gitText('rev-parse', '--is-inside-work-tree');
|
||||
if (inside !== 'true') {
|
||||
throw new Error('IMAGE_REPOSITORY_PATH is not a Git worktree');
|
||||
}
|
||||
}
|
||||
|
||||
async #assertClean() {
|
||||
const status = await this.#gitRaw('status', '--porcelain=v1', '--untracked-files=no');
|
||||
if (status.trim()) {
|
||||
throw new DeploymentError('Tracked worktree changes prevent deployment');
|
||||
}
|
||||
}
|
||||
|
||||
async #loadState() {
|
||||
try {
|
||||
const saved = JSON.parse(await readFile(this.config.statePath, 'utf8'));
|
||||
return {
|
||||
activeBranch: saved.activeBranch ?? this.config.defaultBranch,
|
||||
deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [],
|
||||
adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [],
|
||||
lastSuccess: saved.lastSuccess ?? null,
|
||||
lastError: saved.lastError ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
activeBranch: this.config.defaultBranch,
|
||||
deliveries: [],
|
||||
adminRequests: [],
|
||||
lastSuccess: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async #saveState() {
|
||||
const temporary = `${this.config.statePath}.tmp-${process.pid}`;
|
||||
await writeFile(temporary, JSON.stringify(this.state, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
await rename(temporary, this.config.statePath);
|
||||
}
|
||||
|
||||
async #git(...args) {
|
||||
await this.run('git', ['-C', this.config.repositoryPath, ...args], { maxBuffer: 16 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
async #gitRaw(...args) {
|
||||
const { stdout } = await this.run('git', ['-C', this.config.repositoryPath, ...args], {
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout;
|
||||
}
|
||||
|
||||
async #gitText(...args) {
|
||||
return (await this.#gitRaw(...args)).trim();
|
||||
}
|
||||
|
||||
async #gitExit(...args) {
|
||||
try {
|
||||
await this.#git(...args);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
return error.code ?? 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user