feat: add scoped image sync endpoint
This commit is contained in:
@@ -18,6 +18,35 @@ function secret(name, fileName) {
|
||||
return readFileSync(path, 'utf8').trim();
|
||||
}
|
||||
|
||||
function syncClientSecrets() {
|
||||
const entries = text('IMAGE_SYNC_CLIENT_SECRET_FILES', '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (entries.length === 0) {
|
||||
throw new Error('IMAGE_SYNC_CLIENT_SECRET_FILES is required');
|
||||
}
|
||||
|
||||
const result = Object.create(null);
|
||||
for (const entry of entries) {
|
||||
const separator = entry.indexOf('=');
|
||||
const client = entry.slice(0, separator);
|
||||
const path = entry.slice(separator + 1);
|
||||
if (separator < 1 || !/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client) || !path) {
|
||||
throw new Error(`Invalid image sync client entry: ${entry}`);
|
||||
}
|
||||
const value = readFileSync(path, 'utf8').trim();
|
||||
if (value.length < 32) {
|
||||
throw new Error(`Image sync secret for ${client} must be at least 32 characters`);
|
||||
}
|
||||
if (result[client]) {
|
||||
throw new Error(`Duplicate image sync client: ${client}`);
|
||||
}
|
||||
result[client] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function loadConfig() {
|
||||
const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE');
|
||||
const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE');
|
||||
@@ -44,6 +73,7 @@ export function loadConfig() {
|
||||
.filter(Boolean),
|
||||
webhookSecret,
|
||||
adminSecret,
|
||||
syncClientSecrets: syncClientSecrets(),
|
||||
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,22 @@ export class GitService {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -163,6 +179,7 @@ export class GitService {
|
||||
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,
|
||||
};
|
||||
@@ -174,6 +191,7 @@ export class GitService {
|
||||
activeBranch: this.config.defaultBranch,
|
||||
deliveries: [],
|
||||
adminRequests: [],
|
||||
syncRequests: [],
|
||||
lastSuccess: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
@@ -35,8 +35,8 @@ function parseJson(body) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApp(config = loadConfig()) {
|
||||
const service = new GitService(config);
|
||||
export async function createApp(config = loadConfig(), dependencies = {}) {
|
||||
const service = dependencies.service ?? new GitService(config);
|
||||
await service.initialize();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -100,6 +100,41 @@ export async function createApp(config = loadConfig()) {
|
||||
const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit });
|
||||
return json(response, 200, { ok: true, ...result });
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/v1/sync') {
|
||||
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
||||
throw new DeploymentError('Content-Type must be application/json', 415);
|
||||
}
|
||||
const body = await readBody(request, config.maxBodyBytes);
|
||||
const client = request.headers['x-image-client'];
|
||||
const timestamp = request.headers['x-image-timestamp'];
|
||||
const requestId = request.headers['x-image-request-id'];
|
||||
const knownClient = typeof client === 'string'
|
||||
&& Object.hasOwn(config.syncClientSecrets, client);
|
||||
const signatureValid = verifyAdminSignature({
|
||||
secret: knownClient ? config.syncClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'),
|
||||
timestamp,
|
||||
requestId,
|
||||
body,
|
||||
supplied: request.headers['x-image-signature'],
|
||||
});
|
||||
if (!knownClient || !signatureValid) {
|
||||
return json(response, 401, { ok: false, reason: 'invalid sync signature' });
|
||||
}
|
||||
const payload = parseJson(body);
|
||||
if (!payload || Array.isArray(payload) || typeof payload !== 'object'
|
||||
|| Object.keys(payload).some((key) => key !== 'commit')) {
|
||||
throw new DeploymentError('Sync body may only contain commit', 400);
|
||||
}
|
||||
if (payload.commit !== undefined
|
||||
&& (typeof payload.commit !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.commit))) {
|
||||
throw new DeploymentError('Invalid target commit', 400);
|
||||
}
|
||||
const result = await service.deploySync({
|
||||
requestKey: `${client}:${requestId}`,
|
||||
expectedCommit: payload.commit,
|
||||
});
|
||||
return json(response, 200, { ok: true, ...result });
|
||||
}
|
||||
return json(response, 404, { ok: false, reason: 'not found' });
|
||||
} catch (error) {
|
||||
await service.recordError(error).catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user