mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
11 Commits
326c152fac
...
feat_apt_u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875f5023e9 | ||
|
|
debc831603 | ||
|
|
32622615f1 | ||
|
|
b12a4df454 | ||
|
|
6f00d5331c | ||
|
|
f987b10ec6 | ||
|
|
d04685f0bd | ||
|
|
b5b39d4ed0 | ||
|
|
133b8ac757 | ||
|
|
fd051b9b07 | ||
|
|
99fb93fdb1 |
@@ -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
|
||||
}
|
||||
|
||||
7
@xen-orchestra/rest-api/src/lib-types/xen-orchestra-cron.d.ts
vendored
Normal file
7
@xen-orchestra/rest-api/src/lib-types/xen-orchestra-cron.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare module '@xen-orchestra/cron' {
|
||||
type Schedule = {
|
||||
next: (n: number) => Date[]
|
||||
}
|
||||
|
||||
export const createSchedule: (pattern: string, zone?: string) => Schedule
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
84
@xen-orchestra/web/src/pages/admin/patches.vue
Normal file
84
@xen-orchestra/web/src/pages/admin/patches.vue
Normal 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>
|
||||
13
@xen-orchestra/web/src/route-map.d.ts
vendored
13
@xen-orchestra/web/src/route-map.d.ts
vendored
@@ -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'
|
||||
|
||||
11
packages/xo-server-package-manager/.USAGE.md
Normal file
11
packages/xo-server-package-manager/.USAGE.md
Normal 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`.
|
||||
1
packages/xo-server-package-manager/.npmignore
Symbolic link
1
packages/xo-server-package-manager/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
34
packages/xo-server-package-manager/README.md
Normal file
34
packages/xo-server-package-manager/README.md
Normal 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)
|
||||
50
packages/xo-server-package-manager/package.json
Normal file
50
packages/xo-server-package-manager/package.json
Normal 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
|
||||
}
|
||||
51
packages/xo-server-package-manager/src/apt.integ.mts
Normal file
51
packages/xo-server-package-manager/src/apt.integ.mts
Normal 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/
|
||||
)
|
||||
})
|
||||
})
|
||||
147
packages/xo-server-package-manager/src/apt.mts
Normal file
147
packages/xo-server-package-manager/src/apt.mts
Normal 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)
|
||||
}
|
||||
3
packages/xo-server-package-manager/src/declaration.d.ts
vendored
Normal file
3
packages/xo-server-package-manager/src/declaration.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module 'xo-common/api-errors.js' {
|
||||
export function invalidParameters(message?: string, errors?: unknown): Error
|
||||
}
|
||||
109
packages/xo-server-package-manager/src/index.mts
Normal file
109
packages/xo-server-package-manager/src/index.mts
Normal 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)
|
||||
}
|
||||
74
packages/xo-server-package-manager/src/parse.mts
Normal file
74
packages/xo-server-package-manager/src/parse.mts
Normal 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
|
||||
}
|
||||
149
packages/xo-server-package-manager/src/parse.test.mts
Normal file
149
packages/xo-server-package-manager/src/parse.test.mts
Normal 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)
|
||||
})
|
||||
})
|
||||
17
packages/xo-server-package-manager/tsconfig.json
Normal file
17
packages/xo-server-package-manager/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user