Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/cli/install-tool/install-tool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export class InstallToolService {
@inject(VersionService)
private readonly versionSvc!: VersionService;

/**
* Runs the install lifecycle of a tool: prepare, initialize, validate,
* install, link and test.
*
* @returns an exit code when the tool could not be installed
*/
async install(
tool: string,
version: string,
Expand Down Expand Up @@ -206,6 +212,12 @@ export class InstallToolService {
}
}

/**
* Removes a tool version, or every installed version when none is given.
*
* @returns an exit code when the tool could not be uninstalled, eg. because
* another tool depends on the version
*/
async uninstall(
tool: string,
version?: string,
Expand Down Expand Up @@ -317,6 +329,11 @@ export class InstallToolService {
}
}

/**
* Links the version onto the path and records it as the current one, unless
* it already is. Then runs the post-install step, records the created shell
* wrappers and runs the tool test, unless tests are skipped.
*/
Comment thread
viceice marked this conversation as resolved.
private async linkAndTest(
toolSvc: BaseInstallService,
version: string,
Expand Down Expand Up @@ -346,6 +363,10 @@ export class InstallToolService {
}
}

/**
* Records which shell wrappers the last link step created, so they can be
* removed again when the version is uninstalled.
*/
private async _storeLinks(tool: string, version: string): Promise<void> {
const links = this._link.links;
logger.debug({ tool, version, links }, 'linked tools');
Expand Down
13 changes: 13 additions & 0 deletions src/cli/prepare-tool/base-prepare.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { EnvService, PathService } from '../services/index.ts';
import { NoInitTools, NoPrepareTools } from '../tools/index.ts';
import { type SpawnOptions, type SpawnResult, spawn } from '../utils/index.ts';

/**
* The setup a tool needs before it can be installed, split in two steps:
*
* - prepare runs once per image, as root, eg. to install apt packages or add
* an apt source
* - initialize runs once per container, as whoever first runs the tool, eg. to
* create the folders and config files the tool expects in the home directory
*/
@injectable()
export abstract class BasePrepareService {
@inject(PathService)
Expand All @@ -12,17 +20,22 @@ export abstract class BasePrepareService {

abstract readonly name: string;

/** Runs the one time, root only setup. */
prepare(): Promise<void> | void {
// noting to do;
}

/** Runs the per container setup. */
initialize(): Promise<void> | void {
// noting to do;
}

/** Whether the tool has an initialize step, see `NoInitTools`. */
needsInitialize(): boolean {
return !NoInitTools.includes(this.name);
}

/** Whether the tool has a prepare step, see `NoPrepareTools`. */
needsPrepare(): boolean {
return !NoPrepareTools.includes(this.name);
}
Expand Down
14 changes: 14 additions & 0 deletions src/cli/prepare-tool/prepare-tool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export class PrepareToolService {
@inject(EnvService)
private readonly envSvc!: EnvService;

/**
* Prepares the given tools, or every tool for `all`, and marks them as
* prepared. Must run as root.
*
* @returns an exit code when the tools could not be prepared
*/
async prepare(tools: string[], dryRun = false): Promise<number | void> {
const supportedTools = this.toolSvcs.map((t) => t.name).sort();
logger.trace(
Expand Down Expand Up @@ -74,6 +80,12 @@ export class PrepareToolService {
}
}

/**
* Initializes the given tools, or for `all` every tool which was prepared in
* this image, and marks them as initialized.
*
* @returns an exit code when the tools could not be initialized
*/
async initialize(tools: string[], dryRun = false): Promise<number | void> {
const supportedTools = this.toolSvcs.map((t) => t.name).sort();
logger.trace(
Expand Down Expand Up @@ -112,6 +124,7 @@ export class PrepareToolService {
}
}

/** Initializes a tool, unless it is ignored, needs no init or already had it. */
private async _initTool(
tool: BasePrepareService,
_dryRun: boolean,
Expand All @@ -134,6 +147,7 @@ export class PrepareToolService {
await tool.initialize();
}

/** Prepares a tool, unless it is ignored, needs no prepare or already had it. */
private async _prepareTool(
tool: BasePrepareService,
_dryRun: boolean,
Expand Down
6 changes: 6 additions & 0 deletions src/cli/services/apt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export class AptService {
@inject(EnvService)
private readonly envSvc!: EnvService;

/**
* Installs the packages which are not installed yet, honouring the apt proxy.
*/
async install(...packages: string[]): Promise<void> {
const todo: string[] = [];

Expand Down Expand Up @@ -56,6 +59,9 @@ export class AptService {
}
}

/**
* Whether dpkg reports the package as installed and configured.
*/
private async isInstalled(pkg: string): Promise<boolean> {
try {
const res = await execa('dpkg', ['-s', pkg]);
Expand Down
26 changes: 26 additions & 0 deletions src/cli/services/http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ export class HttpService {
});
}

/**
* Downloads a file into the cache and returns its path.
*
* The cache folder is derived from the url, so a file downloaded before is
* reused, as long as it still matches the expected checksum when one is
* given. Urls go through the configured url replacements, eg. a CDN. A
* failed download or checksum mismatch is tried up to three times.
*
* @throws when all attempts failed
*/
async download({
url,
expectedChecksum,
Expand Down Expand Up @@ -123,6 +133,11 @@ export class HttpService {
throw new Error('download failed');
}

/**
* Whether the url exists, checked with a `HEAD` request.
*
* @throws on any error other than a 404
*/
async exists(url: string): Promise<boolean> {
try {
await got.head(this.envSvc.replaceUrl(url), this._opts);
Expand All @@ -137,6 +152,11 @@ export class HttpService {
}
}

/**
* Fetches the body as text, trying up to three times.
*
* @throws when all attempts failed
*/
async get(
url: string,
opts: OptionsOfTextResponseBody = {},
Expand Down Expand Up @@ -167,6 +187,12 @@ export class HttpService {
throw new Error('download failed');
}

/**
* Fetches and parses a json body, trying up to three times. The result is
* not validated, parse it with a schema.
*
* @throws when all attempts failed
*/
async getJson<T = unknown>(
url: string,
opts: OptionsOfJSONResponseBody = {},
Expand Down
6 changes: 6 additions & 0 deletions src/cli/services/link-tool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export class LinkToolService {
this._links.length = 0;
}

/**
* Writes the shell wrapper which puts a tool on the path. It loads the
* containerbase env, initializes the tool on first use, sources the tool envs
* and finally executes the tool.
*/
async shellwrapper(
tool: string,
{ args, name, srcDir, exports, extraToolEnvs, body }: ShellWrapperConfig,
Expand Down Expand Up @@ -101,6 +106,7 @@ export class LinkToolService {
await this.pathSvc.setOwner({ path: tgt });
}

/** Removes a shell wrapper, if it exists. */
async rm(name: string): Promise<void> {
const tgt = join(this.pathSvc.binDir, name);
if (await pathExists(tgt, 'file')) {
Expand Down
36 changes: 36 additions & 0 deletions src/cli/services/version.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export interface ToolType {
type: InstallToolType;
}

/**
* Keeps track of the installed tools in four separate stores:
*
* - versions: every installed version, a tool can have many, each optionally
* installed for a parent tool version, eg. a npm package for a node version
* - state: the current version per tool, the one on the path
* - links: the shell wrapper names created for a tool version
* - types: the installer of dynamically installed tools, eg. `npm`
*/
@injectable()
export class VersionService {
@inject(DataService)
Expand All @@ -54,10 +63,14 @@ export class VersionService {
private _types!: Database<Doc<ToolType>>;
private _versions!: Database<Doc<ToolVersion>>;

/**
* Whether exactly this version is recorded, including its parent when given.
*/
async isInstalled(tool: ToolVersion): Promise<boolean> {
return (await this._versions.findOneAsync(tool)) !== null;
}

/** All recorded versions of a tool, for any parent. */
findInstalled(name: string): Promise<Doc<ToolVersion>[]> {
return this._versions.findAsync({ name });
}
Expand Down Expand Up @@ -100,46 +113,66 @@ export class VersionService {
});
}

/** Records an installed version. */
async addInstalled(tool: ToolVersion): Promise<void> {
await this._versions.insertAsync(tool);
}

/** Removes every recorded version matching the given fields. */
async removeInstalled(tool: Partial<ToolVersion>): Promise<void> {
await this._versions.removeAsync(tool, { multi: true });
}

/**
* The versions installed for exactly this parent version. Children of other
* versions of the same parent tool are not included.
*/
getChilds(parent: Tool): Promise<Doc<ToolVersion>[]> {
return this._versions.findAsync({ parent });
}

/** Whether the shell wrapper name points at exactly this tool version. */
async isLinked(tool: ToolLink): Promise<boolean> {
return (await this._links.findOneAsync(tool)) !== null;
}

/** The shell wrapper names created for a tool version. */
findLinks(tool: Tool): Promise<Doc<ToolLink>[]> {
return this._links.findAsync({ tool });
}

/**
* Points a shell wrapper name at a tool version, replacing whatever it
* pointed at before.
*/
async setLink(tool: ToolLink): Promise<void> {
await this._links.updateAsync({ name: tool.name }, tool, { upsert: true });
}

/** Forgets every shell wrapper name created for a tool version. */
async removeLinks(tool: Tool): Promise<void> {
await this._links.removeAsync({ tool }, { multi: true });
}

/** Whether exactly this version, and parent, is the current one. */
async isCurrent(tool: ToolState): Promise<boolean> {
return (await this._state.findOneAsync(tool)) !== null;
}

/** Makes a version the current one, replacing the previous current one. */
async setCurrent(tool: ToolState): Promise<void> {
await this._state.updateAsync({ name: tool.name }, tool, { upsert: true });
}

/**
* The current version, looked up by the name the tool is linked as, which
* is its alias, eg. `java` for `java-jdk`.
*/
async getCurrent(name: string): Promise<ToolState | null> {
return await this._state.findOneAsync({ name });
}

/** Forgets the current version and removes its legacy version file. */
async removeCurrent(name: string): Promise<void> {
await this._state.removeAsync({ name }, { multi: false });
const path = join(this.pathSvc.versionPath, tool2path(name));
Expand All @@ -150,15 +183,18 @@ export class VersionService {
}
}

/** The installer a dynamically installed tool was installed with. */
async getType(name: string): Promise<InstallToolType | undefined> {
const doc = await this._types.findOneAsync({ name });
return doc?.type;
}

/** Every dynamically installed tool with its installer. */
async getTypes(): Promise<ToolType[]> {
return await this._types.findAsync({});
}

/** Records the installer of a dynamically installed tool. */
async setType(
name: string,
type: InstallToolType | undefined,
Expand Down
14 changes: 14 additions & 0 deletions src/cli/tools/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export class GitInstallService extends BaseInstallService {
/** git is installed system wide by apt, other tools depend on it */
override readonly canUninstall = false;

/**
* Installs git from the ppa and verifies the version is new enough.
*/
override async install(_version: string): Promise<void> {
// TODO: the ppa only serves the latest version, so the requested version is ignored
await this.aptSvc.install(this.name);
Expand All @@ -89,16 +92,27 @@ export class GitInstallService extends BaseInstallService {
return Promise.resolve();
}

/**
* Marks every repository as safe, whoever owns it.
*/
override async postInstall(_version: string): Promise<void> {
// flutter workaround
// allow all, so it works in older git versions when the ppa is not working
await this._spawn(this.name, ['config', '--system', 'safe.directory', '*']);
}

/**
* Prints the installed version, which also proves git runs.
*/
override async test(_version: string): Promise<void> {
await this._spawn(this.name, ['--version']);
}

/**
* The version apt installed, which is not necessarily the requested one.
*
* @throws when the output holds no version at all
*/
private async installedVersion(): Promise<string> {
const res = await execa(this.name, ['--version']);
// `git --version` prints eg. `git version 2.55.0`, but the vendor may add a
Expand Down
3 changes: 3 additions & 0 deletions tools/lint-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';

// checks that the tool versions pinned in `mise.toml` match the ones used by CI

/** Reads a file from the repository root. */
async function read(file: string): Promise<string> {
return await readFile(new URL(`../${file}`, import.meta.url), 'utf8');
}
Expand All @@ -14,11 +15,13 @@ const { packageManager } = JSON.parse(await read('package.json')) as {

let failed = false;

/** Reports a mismatch and makes the script exit non zero. */
function fail(message: string): void {
console.error(message);
failed = true;
}

/** Compares the version `mise.toml` pins for a tool with the given one. */
function compare(tool: string, version: string, source: string): void {
const match = new RegExp(`^${tool} = "(?<version>[^"]+)"`, 'm').exec(mise);
const pinned = match?.groups?.version ?? 'nothing';
Expand Down