Files
Sebastian Poxhofer 97327c3d28 refactor(linters): add prefer-coerce-array rule (#45492)
* refactor(linters): add prefer-coerce-array rule

Refs #45217

`x ?? []` and `coerceArray(x)` say the same thing, but only the second
one is searchable and consistent. The rule flags a `??` whose default is
a bare `[]` literal; a non-empty default or an asserted `([] as Foo[])`
still passes.

Applying it across `lib/` surfaced three spots the helper cannot cover,
which keep `?? []` with a disable comment or a narrower fix:

- `package-files.ts` destructures a tuple, which `coerceArray()` widens
- `generate.spec.ts` defaults `matchAll()`, an iterator rather than an array
- `mise/upgradeable-tooling.ts` declares a `Record` return, so its
  `?? []` fallback was wrong to begin with and becomes `coerceObject()`

* refactor(platform/github): hoist the GHE version header lookup

`Object.entries(...).find()` returns a tuple, and defaulting it with
`?? []` only to destructure the second element reads as an array
default when it is not one. Name the found entry instead, which drops
the `?? []` rather than routing a tuple through `coerceArray()`.
2026-08-28 09:27:58 +00:00

253 lines
7.8 KiB
TypeScript

import { isNonEmptyString, isString, isTruthy } from '@sindresorhus/is';
import { join, quote } from 'shlex';
import { GlobalConfig } from '../../../config/global.ts';
import { SYSTEM_INSUFFICIENT_MEMORY } from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { coerceArray } from '../../array.ts';
import { newlineRegex, regEx } from '../../regex.ts';
import { uniq } from '../../uniq.ts';
import { rawExec } from '../common.ts';
import type {
CommandWithOptions,
DockerOptions,
VolumeOption,
VolumesPair,
} from '../types.ts';
import { isCommandWithOptions } from '../utils.ts';
const prefetchedImages = new Map<string, string>();
const digestRegex = regEx('Digest: (.*?)\n');
export const sideCarName = 'sidecar';
export async function prefetchDockerImage(taggedImage: string): Promise<void> {
if (prefetchedImages.has(taggedImage)) {
logger.debug(
`Docker image is already prefetched: ${taggedImage}@${prefetchedImages.get(
taggedImage,
)!}`,
);
} else {
logger.debug(`Fetching Docker image: ${taggedImage}`);
const res = await rawExec(`docker pull ${taggedImage}`, {});
const imageDigest = digestRegex.exec(res?.stdout)?.[1] ?? 'unknown';
logger.debug(
`Finished fetching Docker image ${taggedImage}@${imageDigest}`,
);
prefetchedImages.set(taggedImage, imageDigest);
}
}
export function resetPrefetchedImages(): void {
prefetchedImages.clear();
}
function expandVolumeOption(x: VolumeOption): VolumesPair | null {
if (isNonEmptyString(x)) {
return [x, x];
}
if (Array.isArray(x) && x.length === 2) {
const [from, to] = x;
// v8 ignore else -- TODO: add test #40625
if (isNonEmptyString(from) && isNonEmptyString(to)) {
return [from, to];
}
}
return null;
}
function volumesEql(x: VolumesPair, y: VolumesPair): boolean {
const [xFrom, xTo] = x;
const [yFrom, yTo] = y;
return xFrom === yFrom && xTo === yTo;
}
function prepareVolumes(volumes: VolumeOption[]): string[] {
const expanded: (VolumesPair | null)[] = volumes.map(expandVolumeOption);
const filtered: VolumesPair[] = expanded.filter(
(vol): vol is VolumesPair => vol !== null,
);
const unique: VolumesPair[] = uniq<VolumesPair>(filtered, volumesEql);
return unique.map(([from, to]) => `-v "${from}":"${to}"`);
}
function getContainerName(name: string, prefix?: string): string {
return `${prefix ?? 'renovate_'}${name}`.replace(regEx(/\//g), '_');
}
function getContainerLabel(prefix: string | undefined): string {
return `${prefix ?? 'renovate_'}child`;
}
export async function removeDockerContainer(
image: string,
prefix: string,
): Promise<void> {
const containerName = getContainerName(sideCarName, prefix);
let cmd = `docker ps --filter name=${containerName} -aq`;
try {
const res = await rawExec(cmd, {});
const containerId = res?.stdout?.trim() || '';
if (containerId.length) {
logger.debug(`Removing container with ID: ${containerId}`);
cmd = `docker rm -f ${containerId}`;
await rawExec(cmd, {});
} else {
logger.trace({ image, containerName }, 'No running containers to remove');
}
} catch (err) {
logger.warn(
{ image, containerName, cmd, err },
'Could not remove Docker container',
);
}
}
export async function removeDanglingContainers(): Promise<void> {
if (GlobalConfig.get('binarySource') !== 'docker') {
return;
}
try {
const containerLabel = getContainerLabel(
GlobalConfig.get('dockerChildPrefix'),
);
logger.debug(
`Removing dangling child containers with label ${containerLabel}`,
);
const res = await rawExec(
`docker ps --filter label=${containerLabel} -aq`,
{},
);
if (res?.stdout?.trim().length) {
const containerIds = res.stdout
.trim()
.split(newlineRegex)
.map((container) => container.trim())
.filter(isTruthy);
logger.debug({ containerIds }, 'Removing dangling child containers');
await rawExec(`docker rm -f ${containerIds.join(' ')}`, {});
} else {
logger.debug('No dangling containers to remove');
}
} catch (err) {
if (err.errno === 'ENOMEM') {
throw new Error(SYSTEM_INSUFFICIENT_MEMORY);
}
if (err.stderr?.includes('Cannot connect to the Docker daemon')) {
logger.info('No docker daemon found');
} else {
logger.warn({ err }, 'Error removing dangling containers');
}
}
}
export async function generateDockerCommand(
commands: (string | CommandWithOptions)[],
preCommands: (string | CommandWithOptions)[],
options: DockerOptions,
sideCarImage: string,
): Promise<string> {
const { envVars, cwd } = options;
const volumes = coerceArray(options.volumes);
const {
localDir,
cacheDir,
containerbaseDir,
dockerUser,
dockerChildPrefix,
dockerCliOptions,
} = GlobalConfig.get();
const result = ['docker run --rm'];
const containerName = getContainerName(sideCarName, dockerChildPrefix);
const containerLabel = getContainerLabel(dockerChildPrefix);
result.push(`--name=${containerName}`);
result.push(`--label=${containerLabel}`);
if (dockerUser) {
result.push(`--user=${dockerUser}`);
}
if (dockerCliOptions) {
result.push(dockerCliOptions);
}
const volumeDirs: VolumeOption[] = [localDir, cacheDir];
if (containerbaseDir) {
if (cacheDir && containerbaseDir.startsWith(cacheDir)) {
logger.debug('containerbaseDir is inside cacheDir');
} else {
logger.debug('containerbaseDir is separate from cacheDir');
volumeDirs.push(containerbaseDir);
}
} else {
logger.debug('containerbaseDir is missing');
}
volumeDirs.push(...volumes);
result.push(...prepareVolumes(volumeDirs));
// v8 ignore else -- TODO: add test #40625
if (envVars) {
result.push(
...uniq(envVars)
.filter(isString)
.map((e) => `-e ${e}`),
);
}
if (cwd) {
result.push(`-w "${cwd}"`);
}
logger.debug({ image: sideCarImage }, 'Resolved tag constraint');
await prefetchDockerImage(sideCarImage);
result.push(sideCarImage);
const bashCommandParts = [];
for (const preCommand of preCommands) {
if (
isCommandWithOptions(preCommand) &&
isString(join(preCommand.command))
) {
if (preCommand.ignoreFailure) {
bashCommandParts.push(`${join(preCommand.command)} || true`);
} else {
bashCommandParts.push(join(preCommand.command));
}
} else if (isString(preCommand)) {
bashCommandParts.push(preCommand);
}
}
for (const command of commands) {
if (isCommandWithOptions(command)) {
if (command.ignoreFailure) {
bashCommandParts.push(`${join(command.command)} || true`);
} else {
bashCommandParts.push(join(command.command));
}
} else {
bashCommandParts.push(command);
}
}
const bashCommand = bashCommandParts.join(' && ');
// Single-quote the whole `-c` argument rather than hand-escaping `"`. Escaping
// only `"` left `\` unhandled, so a value containing `\"` produced a string that
// `shlex.split` rejects outright ("Got EOF while in a quoted string"), failing
// the update.
//
// It also left `$` and backticks live in the outer context, which silently
// defeats the `quote()` calls the managers apply: their single quotes are not
// special inside double quotes. That is inert while nothing pairs
// `ExecOptions.shell` with `docker` — `rawExec` otherwise splits argv without a
// shell — but it made the wrapper one `shell: true` away from turning a quoted
// dep name into host-side command substitution, which is how the flux manager
// was configured until it moved to `CommandWithOptions`. Being correct here
// means that pairing is no longer load-bearing.
result.push(`bash -l -c ${quote(bashCommand)}`);
return result.join(' ');
}