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 { 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 { 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 command = new Deno.Command('bash', { args: ['-c', this.createDetachedUpgradeScript()], stdin: 'null', stdout: 'null', stderr: 'null', detached: true, }); const child = command.spawn(); child.unref(); this.upgradeStartedAt = Date.now(); logger.info(`Started detached Onebox upgrade process ${child.pid}`); return { accepted: true, currentVersion: status.currentVersion, targetVersion, message: 'Onebox upgrade started. The service will restart automatically.', pid: child.pid, logPath: UPGRADE_LOG_PATH, }; } public async runUpgradeForeground( statusArg?: interfaces.data.IOneboxUpdateStatus, ): Promise { 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', `curl -sSL ${ONEBOX_INSTALL_SCRIPT_URL} | bash`], 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 { 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 { 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 mkdir -p /var/log { echo "==== Onebox upgrade started $(date -Is) ====" sleep 2 curl -sSL ${ONEBOX_INSTALL_SCRIPT_URL} | bash echo "==== Onebox upgrade finished $(date -Is) ====" } >> ${UPGRADE_LOG_PATH} 2>&1 `; } }