232 lines
7.6 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|