Files
onebox/ts/classes/update-manager.ts
T

235 lines
7.2 KiB
TypeScript
Raw Normal View History

2026-05-24 11:49:43 +00:00
import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import { getErrorMessage } from '../utils/error.ts';
import * as interfaces from '../../ts_interfaces/index.ts';
const ONEBOX_REPOSITORY_URL = 'https://code.foss.global/serve.zone/onebox';
const ONEBOX_LATEST_RELEASE_API_URL =
'https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/latest';
const ONEBOX_INSTALL_SCRIPT_URL = `${ONEBOX_REPOSITORY_URL}/raw/branch/main/install.sh`;
const ONEBOX_CHANGELOG_URL = `${ONEBOX_REPOSITORY_URL}/src/branch/main/changelog.md`;
const UPGRADE_LOG_PATH = '/var/log/onebox-upgrade.log';
interface IGiteaReleaseResponse {
tag_name?: unknown;
html_url?: unknown;
}
interface IParsedRelease {
tagName: string;
releaseUrl: string;
}
export class OneboxUpdateManager {
private cachedStatus: interfaces.data.IOneboxUpdateStatus | null = null;
private cachedStatusExpiresAt = 0;
private upgradeStartedAt = 0;
private readonly statusCacheTtlMs = 5 * 60 * 1000;
public async getUpdateStatus(
optionsArg: { force?: boolean } = {},
): Promise<interfaces.data.IOneboxUpdateStatus> {
const now = Date.now();
if (!optionsArg.force && this.cachedStatus && this.cachedStatusExpiresAt > now) {
return this.cachedStatus;
}
const status = await this.fetchUpdateStatus();
this.cachedStatus = status;
this.cachedStatusExpiresAt = now + this.statusCacheTtlMs;
return status;
}
public async startDetachedUpgrade(): Promise<interfaces.data.IOneboxUpgradeStartResult> {
this.assertRoot();
const status = await this.getUpdateStatus({ force: true });
this.assertUpdateCheckSucceeded(status);
const targetVersion = status.latestVersion || status.currentVersion;
if (!status.updateAvailable) {
return {
accepted: false,
currentVersion: status.currentVersion,
targetVersion,
message: 'Onebox is already up to date.',
};
}
if (this.upgradeStartedAt && Date.now() - this.upgradeStartedAt < 10 * 60 * 1000) {
return {
accepted: false,
currentVersion: status.currentVersion,
targetVersion,
message: 'A Onebox upgrade has already been started.',
logPath: UPGRADE_LOG_PATH,
};
}
const unitName = `onebox-upgrade-${Date.now()}`;
const command = new Deno.Command('systemd-run', {
args: [
'--unit',
unitName,
'--description',
`Onebox upgrade to ${targetVersion}`,
'--collect',
'--property=Type=oneshot',
'--setenv=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
'bash',
'-lc',
this.createDetachedUpgradeScript(),
],
2026-05-24 11:49:43 +00:00
stdin: 'null',
stdout: 'piped',
stderr: 'piped',
2026-05-24 11:49:43 +00:00
});
const result = await command.output();
if (!result.success) {
const stderr = new TextDecoder().decode(result.stderr).trim();
const stdout = new TextDecoder().decode(result.stdout).trim();
throw new Error(
`Failed to start Onebox upgrade systemd unit: ${
stderr || stdout || `exit code ${result.code}`
}`,
);
}
2026-05-24 11:49:43 +00:00
this.upgradeStartedAt = Date.now();
logger.info(`Started Onebox upgrade systemd unit ${unitName}`);
2026-05-24 11:49:43 +00:00
return {
accepted: true,
currentVersion: status.currentVersion,
targetVersion,
message: 'Onebox upgrade started. The service will restart automatically.',
unitName,
2026-05-24 11:49:43 +00:00
logPath: UPGRADE_LOG_PATH,
};
}
public async runUpgradeForeground(
statusArg?: interfaces.data.IOneboxUpdateStatus,
): Promise<interfaces.data.IOneboxUpgradeStartResult> {
this.assertRoot();
const status = statusArg || (await this.getUpdateStatus({ force: true }));
this.assertUpdateCheckSucceeded(status);
const targetVersion = status.latestVersion || status.currentVersion;
if (!status.updateAvailable) {
return {
accepted: false,
currentVersion: status.currentVersion,
targetVersion,
message: 'Onebox is already up to date.',
};
}
const installCommand = new Deno.Command('bash', {
args: ['-c', `set -o pipefail; curl -fsSL ${ONEBOX_INSTALL_SCRIPT_URL} | bash`],
2026-05-24 11:49:43 +00:00
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
});
const installResult = await installCommand.output();
if (!installResult.success) {
throw new Error('Upgrade failed');
}
return {
accepted: true,
currentVersion: status.currentVersion,
targetVersion,
message: `Upgraded to ${targetVersion}`,
};
}
private async fetchUpdateStatus(): Promise<interfaces.data.IOneboxUpdateStatus> {
const currentVersion = this.normalizeVersion(projectInfo.version);
const checkedAt = Date.now();
try {
const release = await this.fetchLatestRelease();
const latestVersion = this.normalizeVersion(release.tagName);
return {
currentVersion,
latestVersion,
updateAvailable: currentVersion !== latestVersion,
checkedAt,
releaseUrl: release.releaseUrl,
changelogUrl: ONEBOX_CHANGELOG_URL,
};
} catch (error) {
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
checkedAt,
releaseUrl: `${ONEBOX_REPOSITORY_URL}/releases`,
changelogUrl: ONEBOX_CHANGELOG_URL,
error: getErrorMessage(error),
};
}
}
private async fetchLatestRelease(): Promise<IParsedRelease> {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 5000);
try {
const response = await fetch(ONEBOX_LATEST_RELEASE_API_URL, {
headers: { accept: 'application/json' },
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch latest release: HTTP ${response.status}`);
}
const release = await response.json() as IGiteaReleaseResponse;
if (typeof release.tag_name !== 'string' || !release.tag_name) {
throw new Error('Latest release response does not include a tag name');
}
const tagName = release.tag_name;
const releaseUrl = typeof release.html_url === 'string' && release.html_url
? release.html_url
: `${ONEBOX_REPOSITORY_URL}/releases/tag/${this.normalizeVersion(tagName)}`;
return { tagName, releaseUrl };
} finally {
clearTimeout(timeoutId);
}
}
private assertRoot(): void {
if (Deno.uid() !== 0) {
throw new Error('Onebox upgrades must be started as root. Try: sudo onebox upgrade');
}
}
private assertUpdateCheckSucceeded(statusArg: interfaces.data.IOneboxUpdateStatus): void {
if (statusArg.error) {
throw new Error(`Cannot determine latest Onebox release: ${statusArg.error}`);
}
}
private normalizeVersion(versionArg: string): string {
const trimmedVersion = versionArg.trim();
return trimmedVersion.startsWith('v') ? trimmedVersion : `v${trimmedVersion}`;
}
private createDetachedUpgradeScript(): string {
return `
set -e
set -o pipefail
2026-05-24 11:49:43 +00:00
mkdir -p /var/log
{
echo "==== Onebox upgrade started $(date -Is) ===="
sleep 2
curl -fsSL ${ONEBOX_INSTALL_SCRIPT_URL} | bash
2026-05-24 11:49:43 +00:00
echo "==== Onebox upgrade finished $(date -Is) ===="
} >> ${UPGRADE_LOG_PATH} 2>&1
`;
}
}