Compare commits

...

11 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
875f5023e9 fix reactivity 2026-10-29 13:11:08 -04:00
Florent BEAUCHAMP
debc831603 simplify 2026-10-29 13:03:13 -04:00
Florent BEAUCHAMP
32622615f1 rework reboot needed 2026-10-29 11:05:59 -04:00
Florent BEAUCHAMP
b12a4df454 simplify front 2026-10-29 11:05:59 -04:00
Florent BEAUCHAMP
6f00d5331c add tasks to track progress 2026-10-29 11:05:51 -04:00
Florent BEAUCHAMP
f987b10ec6 plug it together 2026-10-29 11:05:51 -04:00
Florent BEAUCHAMP
d04685f0bd front test 2026-10-29 11:05:42 -04:00
Florent BEAUCHAMP
b5b39d4ed0 feat(xo-server-package-manager): add CLI (xo-package-manager) 2026-10-29 11:05:42 -04:00
Florent BEAUCHAMP
133b8ac757 feat(xo-server-package-manager): implement apt backend, plugin integration and tests 2026-10-29 11:05:42 -04:00
Florent BEAUCHAMP
fd051b9b07 feat(xo-server-package-manager): add plugin skeleton, build config and docs 2026-10-29 11:05:42 -04:00
Florent BEAUCHAMP
99fb93fdb1 feat(@vates/types): add UpgradablePackage and XoApp plugin infrastructure
- Add UpgradablePackage interface to common.mts
- Add addApiMethods, defineProperty, defineProperties to XoApp
2026-10-29 11:05:42 -04:00
24 changed files with 980 additions and 1 deletions

View File

@@ -817,3 +817,12 @@ export type RPU_RECOVERY_STEP_NAME = (typeof RPU_RECOVERY_STEP_NAMES)[number]
export const COMMON_TAG = {
NOBAK: 'xo:no-bak',
} as const
/** a Debian package of the XO appliance with a newer version available */
export interface UpgradablePackage {
name: string
version: string
installedVersion: string
release: string
description: string
}

View File

@@ -0,0 +1,7 @@
declare module '@xen-orchestra/cron' {
type Schedule = {
next: (n: number) => Date[]
}
export const createSchedule: (pattern: string, zone?: string) => Schedule
}

View File

@@ -135,6 +135,7 @@
"action:reboot": "Reboot",
"action:reboot-host": "@:action:reboot host",
"action:reformat": "Reformat",
"action:refresh": "Refresh",
"action:reset-all-networks": "Reset all networks",
"action:reset-all-srs": "Reset all SRs",
"action:reset-to-default": "Reset to default",
@@ -180,6 +181,7 @@
"action:turn-into-group": "Turn into group",
"action:unload": "Unload",
"action:update": "Update",
"action:upgrade-all": "Upgrade all",
"action:use-query-builder": "Use query builder",
"action:visit-pool-dashboard": "Visit pool dashboard",
"add-or": "+OR",
@@ -648,6 +650,8 @@
"job:network-delete:has-n-pif-connected": "The network has one PIF connected | The network has {n} PIFs connected",
"job:network-delete:has-n-vif-attached": "The network has one VIF attached | The network has {n} VIFs attached",
"job:network-delete:missing-network": "No network to delete",
"job:package-refresh:in-progress": "Package list refresh in progress…",
"job:package-upgrade:in-progress": "A package upgrade is in progress…",
"job:pbd-plug:missing-pbd": "No PBDs to connect",
"job:pbd-plug:pbd-connected": "One or several PBDs are already connected",
"job:pbd-unplug:missing-pbd": "No PBDs to disconnect",
@@ -1052,6 +1056,7 @@
"other": "Other",
"other-properties": "Other properties",
"ova": "OVA",
"package": "Package",
"page-not-found": "This page is not to be found…",
"page-please-wait": "Hold tight, astronaut! The page is getting ready to take off.{newline}We're just aligning the thrusters and calibrating the systems. It won't be long now, stay tuned!",
"pagination:all": "@:all",
@@ -1062,6 +1067,10 @@
"patch-name": "Patch name",
"patches": "Patches",
"patches-up-to-date": "Patches up to date",
"patches:n-available": "{n} patch available | {n} patches available",
"patches:reboot-required": "Some installed patches require a reboot of the XO appliance to take effect.",
"patches:upgrade-all": "Upgrade all patches?",
"patches:upgrade-all-confirm": "{n} patch will be installed. | {n} patches will be installed.",
"path": "Path",
"path-in-bucket": "Path in bucket",
"path-in-container": "Path in container",

View File

@@ -135,6 +135,7 @@
"action:reboot": "Redémarrer",
"action:reboot-host": "@:action:reboot l'hôte",
"action:reformat": "Reformater",
"action:refresh": "Rafraîchir",
"action:reset-all-networks": "Réinitialiser tous les réseaux",
"action:reset-all-srs": "Réinitialiser tous les SRs",
"action:reset-to-default": "Rétablir la valeur par défaut",
@@ -180,6 +181,7 @@
"action:turn-into-group": "Transformer en groupe",
"action:unload": "Décharger",
"action:update": "Mettre à jour",
"action:upgrade-all": "Tout mettre à jour",
"action:use-query-builder": "Constructeur de requête",
"action:visit-pool-dashboard": "Visiter le tableau de bord du pool",
"add-or": "+OU",
@@ -648,6 +650,8 @@
"job:network-delete:has-n-pif-connected": "Le réseau a un PIF connecté | Le réseau a {n} PIFs connectés",
"job:network-delete:has-n-vif-attached": "Le réseau a un VIF attaché | Le réseau a {n} VIFs attachés",
"job:network-delete:missing-network": "Aucun réseau à supprimer",
"job:package-refresh:in-progress": "Rafraîchissement de la liste des paquets en cours…",
"job:package-upgrade:in-progress": "Une mise à jour de paquets est en cours…",
"job:pbd-plug:missing-pbd": "Aucun PBD à connecter",
"job:pbd-plug:pbd-connected": "Un ou plusieurs PBDs sont déjà connectés",
"job:pbd-unplug:missing-pbd": "Aucun PBD à déconnecter",
@@ -1052,6 +1056,7 @@
"other": "Autre",
"other-properties": "Autres propriétés",
"ova": "OVA",
"package": "Paquet",
"page-not-found": "Cette page est introuvable…",
"page-please-wait": "Accrochez-vous, astronaute ! La page est sur le point de décoller.{newline}Nous alignons les propulseurs et calibrons les systèmes. Plus que quelques instants, restez à l'écoute !",
"pagination:all": "Tous",
@@ -1062,6 +1067,10 @@
"patch-name": "Nom du patch",
"patches": "Patches",
"patches-up-to-date": "Patches à jour",
"patches:n-available": "{n} patch disponible | {n} patches disponibles",
"patches:reboot-required": "Certains patches installés nécessitent un redémarrage de l'appliance XO pour être appliqués.",
"patches:upgrade-all": "Installer tous les patches ?",
"patches:upgrade-all-confirm": "{n} patch va être installé. | {n} patches vont être installés.",
"path": "Chemin",
"path-in-bucket": "Chemin dans le bucket",
"path-in-container": "Chemin dans le conteneur",

View File

@@ -0,0 +1,16 @@
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useButtonColumn } from '@core/tables/column-definitions/button-column.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import { useTruncatedTextColumn } from '@core/tables/column-definitions/truncated-text-column.ts'
import { useI18n } from 'vue-i18n'
export const useUpgradablePackageColumns = defineColumns(() => {
const { t } = useI18n()
return {
name: useTextColumn({ headerLabel: () => t('package') }),
version: useTextColumn({ headerLabel: () => t('version') }),
description: useTruncatedTextColumn({ headerLabel: () => t('description') }),
upgrade: useButtonColumn({ buttonVariant: 'secondary', buttonSize: 'small' }),
}
})

View File

@@ -73,7 +73,10 @@ const sections = computed<AdminMenuSection[]>(() => [
},
{
title: t('settings'),
items: [{ label: t('plugins'), href: undefined, to: { name: '/admin/plugins' } }],
items: [
{ label: t('plugins'), href: undefined, to: { name: '/admin/plugins' } },
{ label: t('patches'), href: undefined, to: { name: '/admin/patches' } },
],
},
])
</script>

View File

@@ -0,0 +1,58 @@
<template>
<UiTitle>{{ t('patches:n-available', packages.length) }}</UiTitle>
<VtsTable :state :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="upgradablePackage of paginatedPackages" :key="upgradablePackage.name">
<BodyCells :item="upgradablePackage" />
</VtsRow>
</tbody>
</VtsTable>
</template>
<script setup lang="ts">
import { useXoPackageUpgradeJob } from '@/modules/xoa/jobs/xo-package-upgrade.job.ts'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { usePagination } from '@core/composables/pagination.composable.ts'
import { useTableState } from '@core/composables/table-state.composable.ts'
import { useUpgradablePackageColumns } from '@core/tables/column-sets/upgradable-package-columns.ts'
import type { UpgradablePackage } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { packages, busy, error } = defineProps<{
packages: UpgradablePackage[]
busy: boolean
error: boolean
}>()
const { t } = useI18n()
const { pageRecords: paginatedPackages, paginationBindings } = usePagination('upgradable-packages', () => packages)
const state = useTableState({
busy: () => busy,
error: () => error,
empty: () => (packages.length === 0 ? { type: 'all-done', message: t('patches-up-to-date') } : false),
})
const { HeadCells, BodyCells } = useUpgradablePackageColumns({
body: (upgradablePackage: UpgradablePackage) => {
// the job is shared by all rows (one apt operation at a time): rows are disabled, not busy
const { run, canRun } = useXoPackageUpgradeJob(upgradablePackage.name)
return {
name: r => r(upgradablePackage.name),
version: r => r(`${upgradablePackage.installedVersion} → ${upgradablePackage.version}`),
description: r => r(upgradablePackage.description),
upgrade: r => r(t('action:update'), () => run(), { disabled: !canRun.value }),
}
},
})
</script>

View File

@@ -0,0 +1,32 @@
<template>
<UiModal accent="warning" icon="status:warning-picto" @confirm="emit('confirm')" @dismiss="emit('cancel')">
<template #title>{{ t('patches:upgrade-all') }}</template>
<template #content>
{{ t('patches:upgrade-all-confirm', count) }}
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')">{{ t('action:go-back') }}</VtsOverlayCancelButton>
<VtsOverlayConfirmButton>{{ t('action:upgrade-all') }}</VtsOverlayConfirmButton>
</template>
</UiModal>
</template>
<script lang="ts" setup>
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiModal from '@core/components/ui/modal/UiModal.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
count: number
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,24 @@
import { useXoPackageUpdates } from '@/modules/xoa/remote-resources/use-xo-package-updates.ts'
import { fetchPost } from '@/shared/utils/fetch.util.ts'
import { defineJob, JobRunningError } from '@core/packages/job'
import { useI18n } from 'vue-i18n'
/**
* Refresh the package indexes of the appliance, then reload the upgradable packages
*/
export const useXoPackageRefreshJob = defineJob('package.refresh', [], () => {
const { t } = useI18n()
const { $context } = useXoPackageUpdates()
return {
async run() {
await fetchPost('plugins/package-manager/updates/actions/refresh')
await $context.forceReload()
},
validate(isRunning) {
if (isRunning) {
throw new JobRunningError(t('job:package-refresh:in-progress'))
}
},
}
})

View File

@@ -0,0 +1,42 @@
import type { FrontXoTask } from '@/modules/task/remote-resources/use-xo-task-collection.ts'
import { useXoPackageUpdates } from '@/modules/xoa/remote-resources/use-xo-package-updates.ts'
import { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
import { fetchPost } from '@/shared/utils/fetch.util.ts'
import { defineJob, defineJobArg, JobRunningError } from '@core/packages/job'
import { useI18n } from 'vue-i18n'
// apt runs one operation at a time: the package is not part of the identity
const xoPackageNameArg = defineJobArg<string | undefined>({
identify: false,
toArray: false,
})
/**
* Upgrade one package, or all upgradable packages when the package name is undefined
*/
export const useXoPackageUpgradeJob = defineJob('package.upgrade', [xoPackageNameArg], () => {
const { t } = useI18n()
const { monitorTask } = useXoTaskUtils()
const { $context } = useXoPackageUpdates()
return {
async run(packageName) {
const endpoint =
packageName === undefined
? 'plugins/package-manager/updates/actions/upgrade'
: `plugins/package-manager/updates/${encodeURIComponent(packageName)}/actions/upgrade`
try {
const { taskId } = await fetchPost<{ taskId: FrontXoTask['id'] }>(endpoint)
await monitorTask(taskId)
} finally {
await $context.forceReload()
}
},
validate(isRunning) {
if (isRunning) {
throw new JobRunningError(t('job:package-upgrade:in-progress'))
}
},
}
})

View File

@@ -0,0 +1,27 @@
import { BASE_URL } from '@/shared/utils/fetch.util.ts'
import { defineRemoteResource } from '@core/packages/remote-resource/define-remote-resource.ts'
import type { UpgradablePackage } from '@vates/types'
import { computed } from 'vue'
export type XoPackageUpdates = {
packages: UpgradablePackage[]
isRebootRequired: boolean
}
// served by the `package-manager` plugin
export const useXoPackageUpdates = defineRemoteResource({
url: `${BASE_URL}/plugins/package-manager/updates`,
initialData: (): XoPackageUpdates => ({ packages: [], isRebootRequired: false }),
// listing calls apt on the appliance: only reload after a refresh or an upgrade
pollingIntervalMs: false,
// the default merge would mutate `packages` in place (same reference, not reactive) and never shrink it
onDataReceived: async (updates, receivedUpdates: XoPackageUpdates) => {
updates.value = receivedUpdates
},
state: (updates, context) => ({
upgradablePackages: computed(() => updates.value.packages),
isRebootRequired: computed(() => updates.value.isRebootRequired),
arePackageUpdatesReady: context.isReady,
hasPackageUpdatesFetchError: context.hasError,
}),
})

View File

@@ -0,0 +1,84 @@
<template>
<UiHeadBar>
{{ t('patches') }}
<template #actions>
<UiButton
accent="brand"
variant="secondary"
size="medium"
left-icon="fa:rotate-left"
:disabled="!canRefresh"
:busy="isRefreshing"
@click="refresh()"
>
{{ t('action:refresh') }}
</UiButton>
<UiButton
accent="brand"
variant="primary"
size="medium"
:disabled="!canUpgrade || upgradablePackages.length === 0"
:busy="isUpgrading"
@click="openUpgradeAllModal()"
>
{{ t('action:upgrade-all') }}
</UiButton>
</template>
</UiHeadBar>
<div class="container">
<VtsBanner v-if="isRebootRequired" accent="warning">
{{ t('patches:reboot-required') }}
</VtsBanner>
<UiCard>
<UpgradablePackagesTable
:packages="upgradablePackages"
:busy="!arePackageUpdatesReady"
:error="hasPackageUpdatesFetchError"
/>
</UiCard>
</div>
</template>
<script setup lang="ts">
import UpgradablePackagesTable from '@/modules/xoa/components/UpgradablePackagesTable.vue'
import { useXoPackageRefreshJob } from '@/modules/xoa/jobs/xo-package-refresh.job.ts'
import { useXoPackageUpgradeJob } from '@/modules/xoa/jobs/xo-package-upgrade.job.ts'
import { useXoPackageUpdates } from '@/modules/xoa/remote-resources/use-xo-package-updates.ts'
import VtsBanner from '@core/components/banner/VtsBanner.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { upgradablePackages, isRebootRequired, arePackageUpdatesReady, hasPackageUpdatesFetchError } =
useXoPackageUpdates()
const { run: refresh, canRun: canRefresh, isRunning: isRefreshing } = useXoPackageRefreshJob()
const { run: upgradeAll, canRun: canUpgrade, isRunning: isUpgrading } = useXoPackageUpgradeJob(undefined)
const { open } = useOverlay({
component: () => import('@/modules/xoa/components/modal/PackageUpgradeAllModal.vue'),
events: {
onConfirm: () => upgradeAll({ detached: true }),
onCancel: true,
},
})
function openUpgradeAllModal() {
return open({ props: { count: upgradablePackages.value.length } })
}
</script>
<style scoped lang="postcss">
.container {
height: fit-content;
gap: 0.8rem;
margin: 0.8rem;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -134,6 +134,13 @@ declare module 'vue-router/auto-routes' {
{ id: ParamValue<false> },
| never
>,
'/admin/patches': RouteRecordInfo<
'/admin/patches',
'/admin/patches',
Record<never, never>,
Record<never, never>,
| never
>,
'/admin/plugins': RouteRecordInfo<
'/admin/plugins',
'/admin/plugins',
@@ -633,6 +640,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/admin/patches.vue': {
routes:
| '/admin/patches'
views:
| never
}
'src/pages/admin/plugins.vue': {
routes:
| '/admin/plugins'

View File

@@ -0,0 +1,11 @@
Lists and upgrades the Debian packages of the XO appliance (apt only). Once loaded, the following REST routes are
available to administrators:
| Route | Description |
| --------------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /rest/v0/plugins/package-manager/updates` | Upgradable packages (from the local indexes) and reboot flag |
| `POST /rest/v0/plugins/package-manager/updates/actions/refresh` | `apt-get update` |
| `POST /rest/v0/plugins/package-manager/updates/actions/upgrade` | Upgrade all packages, returns a task |
| `POST /rest/v0/plugins/package-manager/updates/:name/actions/upgrade` | Upgrade one package, returns a task |
The full apt output is in `/var/log/apt/term.log`.

View File

@@ -0,0 +1 @@
../../scripts/npmignore

View File

@@ -0,0 +1,34 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# xo-server-package-manager
> XO Server plugin for managing system packages (apt) on the XO appliance
## Usage
Lists and upgrades the Debian packages of the XO appliance (apt only). Once loaded, the following REST routes are
available to administrators:
| Route | Description |
| --------------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /rest/v0/plugins/package-manager/updates` | Upgradable packages (from the local indexes) and reboot flag |
| `POST /rest/v0/plugins/package-manager/updates/actions/refresh` | `apt-get update` |
| `POST /rest/v0/plugins/package-manager/updates/actions/upgrade` | Upgrade all packages, returns a task |
| `POST /rest/v0/plugins/package-manager/updates/:name/actions/upgrade` | Upgrade one package, returns a task |
The full apt output is in `/var/log/apt/term.log`.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[AGPL-3.0-or-later](https://spdx.org/licenses/AGPL-3.0-or-later) © [Vates SAS](https://vates.fr)

View File

@@ -0,0 +1,50 @@
{
"name": "xo-server-package-manager",
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"description": "XO Server plugin for managing system packages (apt) on the XO appliance",
"type": "module",
"keywords": [
"xen-orchestra",
"xo-server",
"plugin",
"package-manager",
"apt",
"upgrade"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-package-manager",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "packages/xo-server-package-manager",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"main": "dist/index.mjs",
"engines": {
"node": ">=20"
},
"dependencies": {
"@vates/types": "^2.5.0",
"@xen-orchestra/log": "^0.7.1",
"xo-common": "^0.11.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"rimraf": "^6.0.1",
"typescript": "^5.0.0"
},
"scripts": {
"build": "tsc",
"clean": "rimraf dist/",
"dev": "yarn run clean && tsc --watch",
"prebuild": "yarn run clean",
"prepublishOnly": "yarn run build",
"test": "cd dist && node --test",
"test-integration": "cd dist && node --test apt.integ.mjs"
},
"private": true
}

View File

@@ -0,0 +1,51 @@
/**
* Integration tests of the apt wrapper
*
* Requires a Debian system with apt and root access, upgrades one real package
*
* Run: sudo node --test dist/apt.integ.mjs
*/
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { checkAptAvailable, listUpgradablePackages, upgradePackages } from './apt.mjs'
describe('apt', () => {
it('checkAptAvailable does not throw on a Debian system', async () => {
await checkAptAvailable()
})
it('listUpgradablePackages returns packages with all fields', async () => {
for (const pkg of await listUpgradablePackages()) {
assert.ok(pkg.name !== '')
assert.ok(pkg.version !== '', `version of ${pkg.name}`)
assert.ok(pkg.installedVersion !== '', `installed version of ${pkg.name}`)
assert.ok(pkg.release !== '', `release of ${pkg.name}`)
assert.equal(typeof pkg.description, 'string')
}
})
it('upgrades a single package and reports progress', async t => {
const packages = await listUpgradablePackages()
const target = packages.find(p => p.name === 'tzdata') ?? packages.find(p => p.name === 'distro-info-data')
if (target === undefined) {
t.skip('no safe package to upgrade')
return
}
const progresses: number[] = []
await upgradePackages({ packageName: target.name, onProgress: percentage => progresses.push(percentage) })
assert.equal(progresses.at(-1), 100)
assert.equal(
(await listUpgradablePackages()).find(p => p.name === target.name),
undefined
)
})
it('rejects an invalid package name', async () => {
await assert.rejects(
upgradePackages({ packageName: '-oAPT::foo=bar', onProgress: () => {} }),
/invalid package name/
)
})
})

View File

@@ -0,0 +1,147 @@
import type { UpgradablePackage } from '@vates/types'
import { execFile, spawn } from 'node:child_process'
import { access } from 'node:fs/promises'
import { promisify } from 'node:util'
import { parseDescriptions, parseStatusFdLine, parseUpgradableList } from './parse.mjs'
const execFileAsync = promisify(execFile)
const APT_GET_PATH = '/usr/bin/apt-get'
const APT_PATH = '/usr/bin/apt'
const APT_CACHE_PATH = '/usr/bin/apt-cache'
const REBOOT_REQUIRED_PATH = '/var/run/reboot-required'
// `apt-get update` fetches the indexes from the mirrors
const UPDATE_TIMEOUT = 5 * 60e3
// Debian policy: lowercase alphanumerics, `+`, `-` and `.`, starting with an alphanumeric
const PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9+.-]+$/
const APT_ENV = { ...process.env, DEBIAN_FRONTEND: 'noninteractive', LC_ALL: 'C' }
async function exec(cmd: string, args: string[], { timeout = 0 } = {}): Promise<string> {
try {
const { stdout } = await execFileAsync(cmd, args, { env: APT_ENV, maxBuffer: 10 * 1024 * 1024, timeout })
return stdout
} catch (err) {
const error = new Error(`${cmd} ${args.join(' ')} failed`)
error.cause = err
throw error
}
}
export async function checkAptAvailable(): Promise<void> {
try {
await access(APT_GET_PATH)
} catch (err) {
const error = new Error(`${APT_GET_PATH} not found, this plugin requires a Debian-based system`)
error.cause = err
throw error
}
}
export function isValidPackageName(name: string): boolean {
return PACKAGE_NAME_RE.test(name)
}
export async function isRebootRequired(): Promise<boolean> {
try {
await access(REBOOT_REQUIRED_PATH)
return true
} catch {
// dpkg creates this file when an upgraded package needs a reboot
return false
}
}
/**
* Refresh the package indexes (`apt-get update`)
*/
export async function refreshPackageIndexes(): Promise<void> {
await exec(APT_GET_PATH, ['update', '-q'], { timeout: UPDATE_TIMEOUT })
}
/**
* List upgradable packages from the local indexes, see `refreshPackageIndexes()`
*/
export async function listUpgradablePackages(): Promise<UpgradablePackage[]> {
const upgradablePackages = parseUpgradableList(await exec(APT_PATH, ['list', '--upgradable']))
if (upgradablePackages.length === 0) {
return []
}
const descriptions = parseDescriptions(await exec(APT_CACHE_PATH, ['show', ...upgradablePackages.map(p => p.name)]))
return upgradablePackages.map(pkg => ({ ...pkg, description: descriptions.get(pkg.name) ?? '' }))
}
/**
* Upgrade one package, or all upgradable packages if `packageName` is undefined
*
* `apt-get upgrade` never installs new dependencies: a package which needs one stays upgradable until it is upgraded
* on its own.
*/
export async function upgradePackages({
packageName,
onProgress,
}: {
readonly packageName?: string
readonly onProgress: (percentage: number) => void
}): Promise<void> {
const args = [
'-y',
'-q',
// keep the local version of modified config files instead of prompting
'-o',
'Dpkg::Options::=--force-confdef',
'-o',
'Dpkg::Options::=--force-confold',
'-o',
'APT::Status-Fd=3',
]
if (packageName === undefined) {
args.push('upgrade')
} else {
if (!isValidPackageName(packageName)) {
throw new Error(`invalid package name: ${packageName}`)
}
args.push('install', '--only-upgrade', packageName)
}
// the full output is already logged by apt in /var/log/apt/term.log
const child = spawn(APT_GET_PATH, args, { env: APT_ENV, stdio: ['ignore', 'ignore', 'pipe', 'pipe'] })
let stderr = ''
child.stderr!.setEncoding('utf8').on('data', (chunk: string) => {
stderr += chunk
})
let pendingStatus = ''
const statusFd = child.stdio[3] as NodeJS.ReadableStream
statusFd.setEncoding('utf8')
statusFd.on('data', (chunk: string) => {
const lines = (pendingStatus + chunk).split('\n')
pendingStatus = lines.pop()!
for (const line of lines) {
const percentage = parseStatusFdLine(line)
if (percentage !== undefined) {
onProgress(percentage)
}
}
})
const exitCode = await new Promise<number | null>((resolve, reject) => {
child.on('error', reject)
child.on('close', resolve)
})
if (exitCode !== 0) {
const error = new Error(
stderr.includes('Could not get lock') || stderr.includes('Unable to acquire')
? 'another package operation is in progress'
: `apt-get failed with exit code ${exitCode}`
)
Object.assign(error, { packageName, stderr })
throw error
}
onProgress(100)
}

View File

@@ -0,0 +1,3 @@
declare module 'xo-common/api-errors.js' {
export function invalidParameters(message?: string, errors?: unknown): Error
}

View File

@@ -0,0 +1,109 @@
import type { PluginRestRouteDefinition, XoApp } from '@vates/types'
import { createLogger } from '@xen-orchestra/log'
import { invalidParameters } from 'xo-common/api-errors.js'
import {
checkAptAvailable,
isRebootRequired,
isValidPackageName,
listUpgradablePackages,
refreshPackageIndexes,
upgradePackages,
} from './apt.mjs'
const log = createLogger('xo:xo-server-package-manager')
async function getUpdates() {
const [packages, rebootRequired] = await Promise.all([listUpgradablePackages(), isRebootRequired()])
return { packages, isRebootRequired: rebootRequired }
}
// routes without `acl` middleware are restricted to administrators
const REST_ROUTES: PluginRestRouteDefinition[] = [
{
method: 'get',
endpoint: 'plugins/package-manager/updates',
description: 'List the upgradable packages of the XO appliance, from the local package indexes',
tags: ['package-manager'],
callback: getUpdates,
},
{
method: 'post',
endpoint: 'plugins/package-manager/updates/actions/refresh',
description: 'Refresh the package indexes (`apt-get update`)',
tags: ['package-manager'],
callback: async () => {
await refreshPackageIndexes()
},
},
{
method: 'post',
endpoint: 'plugins/package-manager/updates/actions/upgrade',
description: 'Upgrade all packages. Returns 202 with a task ID',
tags: ['package-manager'],
callback: ({ createAction }) =>
createAction(task => upgradePackages({ onProgress: percentage => task.set('progress', percentage) }), {
taskProperties: { name: 'upgrade packages' },
}),
},
{
method: 'post',
endpoint: 'plugins/package-manager/updates/{name}/actions/upgrade',
description: 'Upgrade one package. Returns 202 with a task ID',
tags: ['package-manager'],
params: { name: { type: 'string', example: 'curl' } },
callback: ({ req, createAction }) => {
const packageName = req.params.name
if (typeof packageName !== 'string' || !isValidPackageName(packageName)) {
throw invalidParameters(`invalid package name: ${packageName}`)
}
return createAction(
task => upgradePackages({ packageName, onProgress: percentage => task.set('progress', percentage) }),
{ taskProperties: { name: `upgrade package ${packageName}` } }
)
},
},
]
// same cadence as Debian's apt-daily timer
const REFRESH_INTERVAL = 12 * 60 * 60e3
async function refreshInBackground(): Promise<void> {
try {
await refreshPackageIndexes()
} catch (error) {
log.warn('failed to refresh the package indexes', { error })
}
}
class PackageManagerPlugin {
readonly #xo: XoApp
#refreshInterval: NodeJS.Timeout | undefined
#unregisterRestRoutes: (() => void) | undefined
constructor(xo: XoApp) {
this.#xo = xo
}
async load(): Promise<void> {
await checkAptAvailable()
this.#unregisterRestRoutes = this.#xo.registerRestRoutes(REST_ROUTES)
// without it, the upgradable packages are listed from stale indexes
void refreshInBackground()
this.#refreshInterval = setInterval(refreshInBackground, REFRESH_INTERVAL)
this.#refreshInterval.unref()
log.info('plugin loaded')
}
unload(): void {
clearInterval(this.#refreshInterval)
this.#refreshInterval = undefined
this.#unregisterRestRoutes?.()
this.#unregisterRestRoutes = undefined
}
}
export default function pluginFactory({ xo }: { xo: XoApp }): PackageManagerPlugin {
return new PackageManagerPlugin(xo)
}

View File

@@ -0,0 +1,74 @@
import type { UpgradablePackage } from '@vates/types'
export type ListedPackage = Omit<UpgradablePackage, 'description'>
/**
* Parse the output of `apt list --upgradable`
*
* Each line has the format:
* name/release1,release2 version arch [upgradable from: installedVersion]
*
* Example:
* libgnutls30/stable 3.7.1-5+deb12u6 amd64 [upgradable from: 3.7.1-5+deb12u5]
*/
export function parseUpgradableList(stdout: string): ListedPackage[] {
const listedPackages: ListedPackage[] = []
for (const line of stdout.split('\n')) {
// other lines ("Listing...", warnings, empty) are ignored
const match = line.match(/^([^/]+)\/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from:\s+(\S+)]/)
if (match === null) {
continue
}
const [, name, release, version, installedVersion] = match as [string, string, string, string, string]
listedPackages.push({ name, release, version, installedVersion })
}
return listedPackages
}
/**
* Parse the output of `apt-cache show pkg1 pkg2 ...` into a map of package name → short description
*
* Package blocks are separated by a blank line, the first block of a package is the candidate version.
*/
export function parseDescriptions(stdout: string): Map<string, string> {
const descriptions = new Map<string, string>()
for (const block of stdout.split(/\n\n+/)) {
let name: string | undefined
let description = ''
for (const line of block.split('\n')) {
// Debian 13+ uses Description-en instead of Description
const match = line.match(/^(Package|Description|Description-en): (.*)$/)
if (match === null) {
continue
}
const [, field, value] = match as [string, string, string]
if (field === 'Package') {
name = value.trim()
} else if (description === '') {
description = value.trim()
}
}
if (name !== undefined && !descriptions.has(name)) {
descriptions.set(name, description)
}
}
return descriptions
}
/**
* Parse a line of apt's Status-Fd stream and return the overall percentage
*
* Format: pmstatus:package:percentage:message
* Example: pmstatus:libgnutls30:50.0000:Installing libgnutls30 (amd64)...
*/
export function parseStatusFdLine(line: string): number | undefined {
const [type, , percentage] = line.split(':')
if (type !== 'pmstatus' || percentage === undefined) {
return undefined
}
const parsedPercentage = parseFloat(percentage)
return isNaN(parsedPercentage) ? undefined : Math.round(parsedPercentage * 100) / 100
}

View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { parseDescriptions, parseStatusFdLine, parseUpgradableList } from './parse.mjs'
// =============================================================================
// parseUpgradableList
// =============================================================================
describe('parseUpgradableList', () => {
it('returns empty array for empty input', () => {
assert.deepStrictEqual(parseUpgradableList(''), [])
})
it('returns empty array for "Listing..." only', () => {
assert.deepStrictEqual(parseUpgradableList('Listing...\n'), [])
})
it('parses a single upgradable package', () => {
const input = `Listing...
libgnutls30/stable 3.7.1-5+deb12u6 amd64 [upgradable from: 3.7.1-5+deb12u5]
`
const result = parseUpgradableList(input)
assert.equal(result.length, 1)
assert.deepStrictEqual(result[0], {
name: 'libgnutls30',
release: 'stable',
version: '3.7.1-5+deb12u6',
installedVersion: '3.7.1-5+deb12u5',
})
})
it('parses multiple upgradable packages', () => {
const input = `Listing...
libgnutls30/stable 3.7.1-5+deb12u6 amd64 [upgradable from: 3.7.1-5+deb12u5]
curl/stable-security 7.88.1-10+deb12u8 amd64 [upgradable from: 7.88.1-10+deb12u7]
`
const result = parseUpgradableList(input)
assert.equal(result.length, 2)
assert.equal(result[0]!.name, 'libgnutls30')
assert.equal(result[1]!.name, 'curl')
})
it('handles multiple sources (comma-separated release)', () => {
const input = `Listing...
linux-image-amd64/stable,stable-security 6.1.119-1 amd64 [upgradable from: 6.1.112-1]
`
const result = parseUpgradableList(input)
assert.equal(result.length, 1)
assert.equal(result[0]!.release, 'stable,stable-security')
assert.equal(result[0]!.version, '6.1.119-1')
})
it('skips malformed lines gracefully', () => {
const input = `Listing...
this is not a valid line
libgnutls30/stable 3.7.1-5+deb12u6 amd64 [upgradable from: 3.7.1-5+deb12u5]
another bad line
`
const result = parseUpgradableList(input)
assert.equal(result.length, 1)
assert.equal(result[0]!.name, 'libgnutls30')
})
it('skips WARNING lines', () => {
const input = `WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Listing...
curl/stable 7.88.1-10+deb12u8 amd64 [upgradable from: 7.88.1-10+deb12u7]
`
const result = parseUpgradableList(input)
assert.equal(result.length, 1)
assert.equal(result[0]!.name, 'curl')
})
})
// =============================================================================
// parseDescriptions
// =============================================================================
describe('parseDescriptions', () => {
it('returns empty map for empty input', () => {
assert.equal(parseDescriptions('').size, 0)
})
it('parses multiple packages', () => {
const input = `Package: curl
Version: 7.88.1-10+deb12u8
Description: command line tool for transferring data
Size: 443824
Package: libgnutls30
Version: 3.7.1-5+deb12u6
Description: GNU TLS library
`
assert.deepStrictEqual(
parseDescriptions(input),
new Map([
['curl', 'command line tool for transferring data'],
['libgnutls30', 'GNU TLS library'],
])
)
})
it('defaults to an empty description', () => {
assert.equal(parseDescriptions('Package: minimal-pkg\nVersion: 1.0\n').get('minimal-pkg'), '')
})
it('handles Description-en field (Debian 13+)', () => {
const input = `Package: curl
Version: 8.14.1-2+deb13u2
Description-en: command line tool for transferring data with URL syntax
`
assert.equal(parseDescriptions(input).get('curl'), 'command line tool for transferring data with URL syntax')
})
it('keeps only first entry when package appears multiple times', () => {
const input = `Package: curl
Version: 7.88.1-10+deb12u8
Description: newer version
Package: curl
Version: 7.88.1-10+deb12u7
Description: older version
`
assert.deepStrictEqual(parseDescriptions(input), new Map([['curl', 'newer version']]))
})
})
// =============================================================================
// parseStatusFdLine
// =============================================================================
describe('parseStatusFdLine', () => {
it('returns undefined for non-pmstatus lines', () => {
assert.equal(parseStatusFdLine('dlstatus:1:0:Downloading libgnutls30'), undefined)
assert.equal(parseStatusFdLine(''), undefined)
assert.equal(parseStatusFdLine('some random text'), undefined)
})
it('parses the percentage', () => {
assert.equal(parseStatusFdLine('pmstatus:libgnutls30:50.0000:Installing libgnutls30 (amd64)...'), 50)
assert.equal(parseStatusFdLine('pmstatus:curl:0.0000:Preparing curl (amd64)...'), 0)
assert.equal(parseStatusFdLine('pmstatus:curl:33.3333:Unpacking curl (amd64)'), 33.33)
})
it('returns undefined for malformed pmstatus lines', () => {
assert.equal(parseStatusFdLine('pmstatus:curl'), undefined)
assert.equal(parseStatusFdLine('pmstatus:curl:notanumber:msg'), undefined)
})
})

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/*.mts", "src/*.d.ts"],
"exclude": ["node_modules", "dist"]
}