2024-08-16 17:13:47 +08:00
|
|
|
import { defaultExecOptions, execRootSync } from "admina"
|
|
|
|
import { info, warning } from "ci-log"
|
|
|
|
import escapeRegex from "escape-string-regexp"
|
|
|
|
import { type ExecaError, execa } from "execa"
|
|
|
|
import which from "which"
|
2024-08-29 04:54:48 +08:00
|
|
|
import { type AddAptKeyOptions, addAptKey } from "./apt-key.js"
|
2024-08-16 17:13:47 +08:00
|
|
|
import { isAptPackInstalled } from "./is-installed.js"
|
2024-08-16 17:37:02 +08:00
|
|
|
import { updateAptRepos } from "./update.js"
|
2024-08-16 17:13:47 +08:00
|
|
|
|
|
|
|
/**
|
|
|
|
* The information about an installation result
|
|
|
|
*/
|
|
|
|
export type InstallationInfo = {
|
|
|
|
/** The install dir of the package (Defaults to `undefined`) */
|
|
|
|
installDir?: string
|
|
|
|
/** The bin dir of the package (Defaults to `/usr/bin`) */
|
|
|
|
binDir: string
|
|
|
|
/** The bin path of the package (Defaults to `undefined`) */
|
|
|
|
bin?: string
|
|
|
|
}
|
|
|
|
|
|
|
|
/* eslint-disable require-atomic-updates */
|
|
|
|
let didUpdate: boolean = false
|
|
|
|
let didInit: boolean = false
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The timeout to use for apt commands
|
|
|
|
* Wait up to 300 seconds if the apt-get lock is held
|
|
|
|
* @private Used internally
|
|
|
|
*/
|
|
|
|
export const aptTimeout = "Dpkg::Lock::Timeout=300"
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The information about an apt package
|
|
|
|
*/
|
|
|
|
export type AptPackage = {
|
|
|
|
/** The name of the package */
|
|
|
|
name: string
|
|
|
|
/** The version of the package (optional) */
|
|
|
|
version?: string
|
2024-08-29 05:23:40 +08:00
|
|
|
/** The repository to add before installing the package (optional) */
|
|
|
|
repository?: string
|
|
|
|
/** The key to add before installing the package (optional) */
|
|
|
|
key?: AddAptKeyOptions
|
2024-08-16 17:13:47 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
const retryErrors = [
|
|
|
|
"E: Could not get lock",
|
|
|
|
"dpkg: error processing archive",
|
|
|
|
"dpkg: error: dpkg status database is locked by another process",
|
|
|
|
]
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Install a package using apt
|
|
|
|
*
|
|
|
|
* @param packages The packages to install (name, and optional info like version and repositories)
|
|
|
|
* @param update Whether to update the package list before installing (Defaults to `false`)
|
2024-08-29 05:12:56 +08:00
|
|
|
*
|
|
|
|
* @returns The installation information
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```ts
|
|
|
|
* await installAptPack([{ name: "ca-certificates" }, { name: "gnupg" }])
|
|
|
|
* ```
|
|
|
|
*
|
|
|
|
* @example
|
|
|
|
* ```ts
|
|
|
|
await installAptPack([
|
|
|
|
{
|
|
|
|
name: "gcc",
|
|
|
|
version,
|
2024-08-29 05:23:40 +08:00
|
|
|
repository: "ppa:ubuntu-toolchain-r/test",
|
|
|
|
key: { key: "1E9377A2BA9EF27F", fileName: "ubuntu-toolchain-r-test.gpg" },
|
2024-08-29 05:12:56 +08:00
|
|
|
},
|
|
|
|
])
|
|
|
|
* ```
|
2024-08-16 17:13:47 +08:00
|
|
|
*/
|
|
|
|
export async function installAptPack(packages: AptPackage[], update = false): Promise<InstallationInfo> {
|
|
|
|
const apt: string = getApt()
|
|
|
|
|
|
|
|
for (const { name, version } of packages) {
|
|
|
|
info(`Installing ${name} ${version ?? ""} via ${apt}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update the repos if needed
|
|
|
|
if (update) {
|
2024-08-16 17:37:02 +08:00
|
|
|
updateAptRepos(apt)
|
2024-08-16 17:13:47 +08:00
|
|
|
didUpdate = true
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add the repos if needed
|
|
|
|
await addRepositories(apt, packages)
|
|
|
|
|
|
|
|
const needToInstall = await filterAndQualifyAptPackages(apt, packages)
|
|
|
|
|
|
|
|
if (needToInstall.length === 0) {
|
|
|
|
info("All packages are already installed")
|
|
|
|
return { binDir: "/usr/bin/" }
|
|
|
|
}
|
|
|
|
|
|
|
|
// Initialize apt if needed
|
|
|
|
if (!didInit) {
|
|
|
|
await initApt(apt)
|
|
|
|
didInit = true
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2024-08-29 04:54:48 +08:00
|
|
|
// Add the keys if needed
|
|
|
|
await addAptKeys(packages)
|
|
|
|
|
|
|
|
// Install
|
2024-08-16 17:37:02 +08:00
|
|
|
execRootSync(apt, ["install", "--fix-broken", "-y", ...needToInstall], {
|
|
|
|
...defaultExecOptions,
|
|
|
|
env: getAptEnv(apt),
|
|
|
|
})
|
2024-08-16 17:13:47 +08:00
|
|
|
} catch (err) {
|
|
|
|
if (isExecaError(err)) {
|
|
|
|
if (retryErrors.some((error) => err.stderr.includes(error))) {
|
|
|
|
warning(`Failed to install packages ${needToInstall}. Retrying...`)
|
|
|
|
execRootSync(
|
|
|
|
apt,
|
|
|
|
["install", "--fix-broken", "-y", "-o", aptTimeout, ...needToInstall],
|
2024-08-16 17:37:02 +08:00
|
|
|
{ ...defaultExecOptions, env: getAptEnv(apt) },
|
2024-08-16 17:13:47 +08:00
|
|
|
)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
throw err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return { binDir: "/usr/bin/" }
|
|
|
|
}
|
|
|
|
|
2024-08-29 04:54:48 +08:00
|
|
|
async function addAptKeys(packages: AptPackage[]) {
|
|
|
|
await Promise.all(packages.map(async (pack) => {
|
2024-08-29 05:23:40 +08:00
|
|
|
if (pack.key !== undefined) {
|
|
|
|
await addAptKey(pack.key)
|
2024-08-29 04:54:48 +08:00
|
|
|
}
|
|
|
|
}))
|
|
|
|
}
|
|
|
|
|
2024-08-16 17:13:47 +08:00
|
|
|
function isExecaError(err: unknown): err is ExecaError {
|
|
|
|
return typeof (err as ExecaError).stderr === "string"
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if nala is installed
|
|
|
|
*/
|
|
|
|
export function hasNala() {
|
|
|
|
return which.sync("nala", { nothrow: true }) !== null
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the apt command to use
|
|
|
|
* If nala is installed, use that, otherwise use apt-get
|
|
|
|
*/
|
|
|
|
export function getApt() {
|
|
|
|
let apt: string
|
|
|
|
if (hasNala()) {
|
|
|
|
apt = "nala"
|
|
|
|
} else {
|
|
|
|
apt = "apt-get"
|
|
|
|
}
|
|
|
|
return apt
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the environment variables to use for the apt command
|
|
|
|
* @param apt The apt command to use
|
|
|
|
* @private Used internally
|
|
|
|
*/
|
2024-08-16 17:37:02 +08:00
|
|
|
export function getAptEnv(apt: string) {
|
2024-08-16 17:13:47 +08:00
|
|
|
const env: NodeJS.ProcessEnv = { ...process.env, DEBIAN_FRONTEND: "noninteractive" }
|
|
|
|
|
|
|
|
if (apt === "nala") {
|
|
|
|
// if LANG/LC_ALL is not set, enable utf8 otherwise nala fails because of ASCII encoding
|
|
|
|
if (env.LANG === undefined) {
|
|
|
|
env.LANG = "C.UTF-8"
|
|
|
|
}
|
|
|
|
if (env.LC_ALL === undefined) {
|
|
|
|
env.LC_ALL = "C.UTF-8"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return env
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The type of apt package to install
|
|
|
|
*/
|
|
|
|
export enum AptPackageType {
|
|
|
|
NameDashVersion = 0,
|
|
|
|
NameEqualsVersion = 1,
|
|
|
|
Name = 2,
|
|
|
|
None = 3,
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Filter out the packages that are already installed and qualify the packages into a full package name/version
|
|
|
|
*/
|
|
|
|
async function filterAndQualifyAptPackages(apt: string, packages: AptPackage[]) {
|
|
|
|
return (await Promise.all(packages.map((pack) => qualifiedNeededAptPackage(apt, pack))))
|
|
|
|
.filter((pack) => pack !== undefined)
|
|
|
|
}
|
|
|
|
|
|
|
|
async function qualifiedNeededAptPackage(apt: string, pack: AptPackage) {
|
|
|
|
// Qualify the packages into full package name/version
|
|
|
|
const qualified = await getAptArg(apt, pack.name, pack.version)
|
|
|
|
// filter out the packages that are already installed
|
|
|
|
return (await isAptPackInstalled(qualified)) ? undefined : qualified
|
|
|
|
}
|
|
|
|
|
|
|
|
async function addRepositories(apt: string, packages: AptPackage[]) {
|
2024-08-29 05:23:40 +08:00
|
|
|
const allRepositories = [...new Set(packages.flatMap((pack) => pack.repository ?? []))]
|
2024-08-16 17:13:47 +08:00
|
|
|
if (allRepositories.length !== 0) {
|
|
|
|
if (!didInit) {
|
|
|
|
await initApt(apt)
|
|
|
|
didInit = true
|
|
|
|
}
|
|
|
|
await installAddAptRepo(apt)
|
|
|
|
for (const repo of allRepositories) {
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2024-08-16 17:37:02 +08:00
|
|
|
execRootSync("add-apt-repository", ["-y", "--no-update", repo], { ...defaultExecOptions, env: getAptEnv(apt) })
|
2024-08-16 17:13:47 +08:00
|
|
|
}
|
2024-08-16 17:37:02 +08:00
|
|
|
updateAptRepos(apt)
|
2024-08-16 17:13:47 +08:00
|
|
|
didUpdate = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function aptPackageType(apt: string, name: string, version: string | undefined): Promise<AptPackageType> {
|
|
|
|
if (version !== undefined && version !== "") {
|
|
|
|
const { stdout } = await execa("apt-cache", [
|
|
|
|
"search",
|
|
|
|
"--names-only",
|
|
|
|
`^${escapeRegex(name)}-${escapeRegex(version)}$`,
|
2024-08-16 17:37:02 +08:00
|
|
|
], { env: getAptEnv(apt), stdio: "pipe" })
|
2024-08-16 17:13:47 +08:00
|
|
|
if (stdout.trim() !== "") {
|
|
|
|
return AptPackageType.NameDashVersion
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
// check if apt-get show can find the version
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
2024-08-16 17:37:02 +08:00
|
|
|
const { stdout } = await execa("apt-cache", ["show", `${name}=${version}`], { env: getAptEnv(apt) })
|
2024-08-16 17:13:47 +08:00
|
|
|
if (stdout.trim() === "") {
|
|
|
|
return AptPackageType.NameEqualsVersion
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
// ignore
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2024-08-16 17:37:02 +08:00
|
|
|
const { stdout: showStdout } = await execa("apt-cache", ["show", name], { env: getAptEnv(apt), stdio: "pipe" })
|
2024-08-16 17:13:47 +08:00
|
|
|
if (showStdout.trim() !== "") {
|
|
|
|
return AptPackageType.Name
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
// ignore
|
|
|
|
}
|
|
|
|
|
|
|
|
// If apt-cache fails, update the repos and try again
|
|
|
|
if (!didUpdate) {
|
2024-08-16 17:37:02 +08:00
|
|
|
updateAptRepos(getApt())
|
2024-08-16 17:13:47 +08:00
|
|
|
didUpdate = true
|
|
|
|
return aptPackageType(apt, name, version)
|
|
|
|
}
|
|
|
|
|
|
|
|
return AptPackageType.None
|
|
|
|
}
|
|
|
|
|
|
|
|
async function getAptArg(apt: string, name: string, version: string | undefined) {
|
|
|
|
const package_type = await aptPackageType(apt, name, version)
|
|
|
|
switch (package_type) {
|
|
|
|
case AptPackageType.NameDashVersion:
|
|
|
|
return `${name}-${version}`
|
|
|
|
case AptPackageType.NameEqualsVersion:
|
|
|
|
return `${name}=${version}`
|
|
|
|
case AptPackageType.Name:
|
|
|
|
if (version !== undefined && version !== "") {
|
|
|
|
warning(`Could not find package ${name} with version ${version}. Installing the latest version.`)
|
|
|
|
}
|
|
|
|
return name
|
|
|
|
default:
|
|
|
|
throw new Error(`Could not find package ${name} ${version ?? ""}`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function installAddAptRepo(apt: string) {
|
|
|
|
if (await isAptPackInstalled("software-properties-common")) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
execRootSync(
|
|
|
|
apt,
|
|
|
|
["install", "-y", "--fix-broken", "-o", aptTimeout, "software-properties-common"],
|
2024-08-16 17:37:02 +08:00
|
|
|
{ ...defaultExecOptions, env: getAptEnv(apt) },
|
2024-08-16 17:13:47 +08:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Install gnupg and certificates (usually missing from docker containers) */
|
|
|
|
async function initApt(apt: string) {
|
|
|
|
// Update the repos if needed
|
|
|
|
if (!didUpdate) {
|
2024-08-16 17:37:02 +08:00
|
|
|
updateAptRepos(apt)
|
2024-08-16 17:13:47 +08:00
|
|
|
didUpdate = true
|
|
|
|
}
|
|
|
|
|
|
|
|
const toInstall = await filterAndQualifyAptPackages(apt, [
|
|
|
|
{ name: "ca-certificates" },
|
|
|
|
{ name: "gnupg" },
|
|
|
|
{ name: "apt-utils" },
|
|
|
|
])
|
|
|
|
|
|
|
|
if (toInstall.length !== 0) {
|
|
|
|
execRootSync(apt, ["install", "-y", "--fix-broken", "-o", aptTimeout, ...toInstall], {
|
|
|
|
...defaultExecOptions,
|
2024-08-16 17:37:02 +08:00
|
|
|
env: getAptEnv(apt),
|
2024-08-16 17:13:47 +08:00
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|