Files
image/node-hook/src/git-service.mjs
T

232 lines
7.6 KiB
JavaScript

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 deploySync({ requestKey, expectedCommit }) {
return this.enqueue(async () => {
if (this.state.syncRequests.includes(requestKey)) {
return { duplicate: true, ...this.publicStatus() };
}
const result = await this.#deploy({
branch: this.state.activeBranch,
expectedCommit,
allowUnrelated: false,
});
this.state.syncRequests = [...this.state.syncRequests.slice(-199), requestKey];
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 : [],
syncRequests: Array.isArray(saved.syncRequests) ? saved.syncRequests : [],
lastSuccess: saved.lastSuccess ?? null,
lastError: saved.lastError ?? null,
};
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
return {
activeBranch: this.config.defaultBranch,
deliveries: [],
adminRequests: [],
syncRequests: [],
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;
}
}
}