feat(update): add Onebox self-upgrade flow

This commit is contained in:
2026-05-24 11:49:43 +00:00
parent 4812621376
commit 05235ec284
10 changed files with 487 additions and 48 deletions
+9
View File
@@ -5,6 +5,7 @@
*/
import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import { getErrorMessage } from '../utils/error.ts';
import { hashPassword } from '../utils/auth.ts';
import { OneboxDatabase } from './database.ts';
@@ -26,6 +27,7 @@ import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts';
import { ExternalGatewayManager } from './external-gateway.ts';
import { ManagedDcRouterManager } from './managed-dcrouter.ts';
import { OneboxUpdateManager } from './update-manager.ts';
import { OpsServer } from '../opsserver/index.ts';
export class Onebox {
@@ -48,6 +50,7 @@ export class Onebox {
public backupScheduler: BackupScheduler;
public managedDcRouter: ManagedDcRouterManager;
public externalGateway: ExternalGatewayManager;
public updateManager: OneboxUpdateManager;
public opsServer: OpsServer;
private initialized = false;
@@ -93,6 +96,7 @@ export class Onebox {
// Initialize optional dcrouter gateway integration
this.managedDcRouter = new ManagedDcRouterManager(this);
this.externalGateway = new ExternalGatewayManager(this);
this.updateManager = new OneboxUpdateManager();
// Initialize OpsServer (TypedRequest-based server)
this.opsServer = new OpsServer(this);
@@ -305,6 +309,7 @@ export class Onebox {
const proxyStatus = this.reverseProxy.getStatus();
const dnsConfigured = this.dns.isConfigured();
const sslConfigured = this.ssl.isConfigured();
const oneboxUpdate = await this.updateManager.getUpdateStatus();
const services = this.services.listServices();
const runningServices = services.filter((s) => s.status === 'running').length;
@@ -407,6 +412,10 @@ export class Onebox {
}
return {
onebox: {
version: projectInfo.version,
update: oneboxUpdate,
},
docker: {
running: dockerRunning,
version: dockerRunning ? await this.docker.getDockerVersion() : null,
+214
View File
@@ -0,0 +1,214 @@
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 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<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', `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<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
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
`;
}
}