From 0511cd324b46b279d51fe25b11f4d6981619f23c Mon Sep 17 00:00:00 2001 From: Hugo Josefson Date: Tue, 7 May 2024 23:53:26 +0200 Subject: [PATCH] feat: setpoint --- .idea/watcherTasks.xml | 2 + .../calculate-app-dir-segments.test.ts | 18 +++ .../setpoint/calculate-app-dir-segments.ts | 21 ++++ .../commands/setpoint/calculate-setpoint.ts | 43 +++++++ .../cli/commands/setpoint/find-app-dirs.ts | 110 ++++++++++++++++++ .../cli/commands/setpoint/mod.ts | 19 +++ .../cli/commands/setpoint/setpoint.ts | 7 ++ src/incus-app-container-files/cli/config.ts | 6 + .../cli/create-cli.ts | 52 ++++++--- src/incus-app-container-files/deps.ts | 17 ++- 10 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.test.ts create mode 100644 src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.ts create mode 100644 src/incus-app-container-files/cli/commands/setpoint/calculate-setpoint.ts create mode 100644 src/incus-app-container-files/cli/commands/setpoint/find-app-dirs.ts create mode 100644 src/incus-app-container-files/cli/commands/setpoint/mod.ts create mode 100644 src/incus-app-container-files/cli/commands/setpoint/setpoint.ts diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml index da5b51e..278dacb 100644 --- a/.idea/watcherTasks.xml +++ b/.idea/watcherTasks.xml @@ -22,6 +22,8 @@ + diff --git a/src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.test.ts b/src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.test.ts new file mode 100644 index 0000000..d0bbfe7 --- /dev/null +++ b/src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.test.ts @@ -0,0 +1,18 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/assert_equals.ts"; +import { calculateAppDirSegments } from "./calculate-app-dir-segments.ts"; +import { s } from "../../../deps.ts"; +import { AbsolutePath } from "../../absolute-path.ts"; + +const cases = [ + ["/mnt/apps", "/mnt/apps/app1", ["app1"]], + ["/mnt/apps", "/mnt/apps/app1/app2", ["app1", "app2"]], + ["/mnt/apps", "/mnt/apps/app1/app2/app3", ["app1", "app2", "app3"]], +] as [AbsolutePath, AbsolutePath, string[]][]; + +for (const [appsDir, appDir, expected] of cases) { + Deno.test(`calculateAppDirSegments(${appsDir}, ${appDir}) => ${s(expected)}`, () => + assertEquals( + calculateAppDirSegments(appsDir)(appDir), + expected, + )); +} diff --git a/src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.ts b/src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.ts new file mode 100644 index 0000000..5fcbfef --- /dev/null +++ b/src/incus-app-container-files/cli/commands/setpoint/calculate-app-dir-segments.ts @@ -0,0 +1,21 @@ +import { AbsolutePath } from "../../absolute-path.ts"; +import { s } from "../../../deps.ts"; + +export function calculateAppDirSegments( + appsDir: AppsDir, +): (appDir: AbsolutePath) => string[] { + return function (appDir: AbsolutePath): string[] { + if (appDir === appsDir) { + return []; + } + if (!appDir.startsWith(appsDir)) { + throw new Error( + `Expected appDir to be a subdirectory of appsDir, but got ${ + s({ appDir, appsDir }) + }`, + ); + } + const relativeAppDir = appDir.slice(appsDir.length + 1); + return relativeAppDir.split("/"); + }; +} diff --git a/src/incus-app-container-files/cli/commands/setpoint/calculate-setpoint.ts b/src/incus-app-container-files/cli/commands/setpoint/calculate-setpoint.ts new file mode 100644 index 0000000..2b025f9 --- /dev/null +++ b/src/incus-app-container-files/cli/commands/setpoint/calculate-setpoint.ts @@ -0,0 +1,43 @@ +import { AbsolutePath } from "../../absolute-path.ts"; +import { CreateAppContainerOptions } from "../create-app-container/options.ts"; +import { Setpoint } from "./setpoint.ts"; +import { + DEFAULT_LOAD_CONFIG_FILES_OPTIONS, + loadConfig, +} from "../../../deps.ts"; +import { findAppDirs } from "./find-app-dirs.ts"; +import { calculateAppDirSegments } from "./calculate-app-dir-segments.ts"; + +/** + * Builds a list of apps, with {@link findAppDirs}. + * Populates each app in the list, with config loaded by https://deno.land/x/load_config_files. { commonNames: ["index", "common", "incus-app-container"]} + * Returns a setpoint, which is a list of apps, with their configs. + * @param appsDir + */ +export async function calculateSetpoint( + appsDir: AppsDir, +): Promise> { + const appDirs: AbsolutePath[] = await findAppDirs(appsDir); + const apps: Setpoint["apps"] = Object.fromEntries( + await Promise.all(appDirs.map(async (appDir) => [ + appDir, + await loadConfig( + new URL(`file://${appsDir}`), + calculateAppDirSegments(appsDir)(appDir), + { + commonNames: [ + ...DEFAULT_LOAD_CONFIG_FILES_OPTIONS.commonNames, + "incus-app-container", + ], + configTransformers: [ + (config) => config as CreateAppContainerOptions, + ], + }, + ) as CreateAppContainerOptions, + ])), + ); + return { + appsDir, + apps, + }; +} diff --git a/src/incus-app-container-files/cli/commands/setpoint/find-app-dirs.ts b/src/incus-app-container-files/cli/commands/setpoint/find-app-dirs.ts new file mode 100644 index 0000000..ef23220 --- /dev/null +++ b/src/incus-app-container-files/cli/commands/setpoint/find-app-dirs.ts @@ -0,0 +1,110 @@ +import { Getter } from "https://deno.land/x/fns@1.1.1/fn/getter.ts"; +import { + DEFAULT_CONFIG_FILE_LOADERS, + exists, + pFilter, + pMap, + prop, +} from "../../../deps.ts"; +import { AbsolutePath, isAbsolutePath } from "../../absolute-path.ts"; + +const CONFIG_FILE_EXTENSIONS = Object.keys(DEFAULT_CONFIG_FILE_LOADERS); + +function getPossibleConfigFilePaths( + directory: AbsolutePath, +): AbsolutePath[] { + return CONFIG_FILE_EXTENSIONS.map((extension) => + `${directory}/incus-app-container.${extension}` as AbsolutePath + ); +} + +function getPossibleDockerComposeFilePaths( + directory: AbsolutePath, +): AbsolutePath[] { + return [ + "yml", + "yaml", + "json", + ].map((extension) => + `${directory}/docker-compose.${extension}` as AbsolutePath + ); +} + +async function directoryHasAnyConfigFile( + directory: AbsolutePath, +): Promise { + if (!(await exists(directory, { isDirectory: true }))) { + return false; + } + const possibleConfigFilePaths = getPossibleConfigFilePaths(directory); + return (await pFilter(possibleConfigFilePaths, (path) => exists(path))) + .length > 0; +} + +async function directoryHasAnyDockerComposeFile( + directory: AbsolutePath, +): Promise { + if (!(await exists(directory, { isDirectory: true }))) { + return false; + } + const possibleDockerComposeFilePaths = getPossibleDockerComposeFilePaths( + directory, + ); + return (await pFilter(possibleDockerComposeFilePaths, (path) => exists(path))) + .length > 0; +} + +/** + * Builds a list of apps, by scanning the appsDirectory recursively. + * When it encounters an incus-app-container.${CONFIG_FILE_EXTENSIONS} file in a directory, it considers that directory an app, and doesn't traverse it any deeper. + * When it encounters a directory named "appdata", or a file named "docker-compose.{yml,yaml,json}", it considers its parent directory a likely disabled app, and doesn't traverse it any deeper. + * This function is recursive, and will call itself on subdirectories. + * + * Does NOT read the config files, just returns the directory names. + * + * @param appsDir the directory where app directories are stored + * @returns a list of directories that are apps (have an `incus-app-container.${CONFIG_FILE_EXTENSIONS}` file) + */ +export async function findAppDirs< + AppsDir extends AbsolutePath, +>(appsDir: AppsDir): Promise { + if (await directoryHasAnyConfigFile(appsDir)) { + return [appsDir as AbsolutePath]; + } + const dirEntryAsyncIterable: AsyncIterable = Deno.readDir( + appsDir, + ); + const dirEntryAsyncIterator: AsyncIterator = + dirEntryAsyncIterable[Symbol.asyncIterator](); + const isDirectoryGetter = prop("isDirectory") as Getter; + const directories = await pFilter( + dirEntryAsyncIterator, + isDirectoryGetter, + ); + return (await pMap( + directories, + async (entry: Deno.DirEntry & { isDirectory: true }) => { + const potentialAppDir = `${appsDir}/${entry.name}` as AbsolutePath; + if (await directoryHasAnyConfigFile(potentialAppDir)) { + return [potentialAppDir]; + } + if (entry.name === "appdata") { + return []; + } + if (await directoryHasAnyDockerComposeFile(potentialAppDir)) { + return []; + } + return await findAppDirs(potentialAppDir); + }, + )).flat(); +} + +if (import.meta.main) { + const appsDir = new URL(Deno.args[0], import.meta.url).pathname; + if (!isAbsolutePath(appsDir)) { + throw new Error( + `Expected absolute path for apps-dir, but got ${s(appsDir)}`, + ); + } + console.dir(await findAppDirs(appsDir)); +} diff --git a/src/incus-app-container-files/cli/commands/setpoint/mod.ts b/src/incus-app-container-files/cli/commands/setpoint/mod.ts new file mode 100644 index 0000000..75f064d --- /dev/null +++ b/src/incus-app-container-files/cli/commands/setpoint/mod.ts @@ -0,0 +1,19 @@ +import { AbsolutePath } from "../../absolute-path.ts"; +import { calculateSetpoint } from "./calculate-setpoint.ts"; +import { SetpointInputOptions } from "../../config.ts"; + +/** + * Prints the current setpoint; the containers we want, according to configuration files. + * @param options + */ +export async function setpoint( + options: SetpointInputOptions, +): Promise { + console.log( + JSON.stringify( + await calculateSetpoint(options.appsDir), + null, + 2, + ), + ); +} diff --git a/src/incus-app-container-files/cli/commands/setpoint/setpoint.ts b/src/incus-app-container-files/cli/commands/setpoint/setpoint.ts new file mode 100644 index 0000000..3627c56 --- /dev/null +++ b/src/incus-app-container-files/cli/commands/setpoint/setpoint.ts @@ -0,0 +1,7 @@ +import { AbsolutePath } from "../../absolute-path.ts"; +import { CreateAppContainerOptions } from "../create-app-container/options.ts"; + +export type Setpoint = { + appsDir: AppsDir; + apps: Record>; +}; diff --git a/src/incus-app-container-files/cli/config.ts b/src/incus-app-container-files/cli/config.ts index 9ae9ba8..8aaf110 100644 --- a/src/incus-app-container-files/cli/config.ts +++ b/src/incus-app-container-files/cli/config.ts @@ -13,12 +13,14 @@ export type Config = & Partial> & Partial> & Partial> + & Partial> & Partial>; export type InputOptions = C extends "create" ? CreateInputOptions : C extends "list" ? ListInputOptions : C extends "setup-incus" ? SetupIncusOptions + : C extends "setpoint" ? SetpointInputOptions : never; export type InputOptionsPerCommand = { @@ -36,6 +38,10 @@ export type ListInputOptions = { format: OutputFormat; }; +export type SetpointInputOptions = { + appsDir: AppsDir; +}; + export async function getConfig< AppsDir extends AbsolutePath = AbsolutePath, >(): Promise< diff --git a/src/incus-app-container-files/cli/create-cli.ts b/src/incus-app-container-files/cli/create-cli.ts index b9c4a8c..461209f 100644 --- a/src/incus-app-container-files/cli/create-cli.ts +++ b/src/incus-app-container-files/cli/create-cli.ts @@ -1,4 +1,10 @@ -import { breadc, isString, optionalTypeGuard, run } from "../deps.ts"; +import { + breadc, + BreadcCommand, + isString, + optionalTypeGuard, + run, +} from "../deps.ts"; import { enforceType } from "../type-guard.ts"; import { AbsolutePath, isAbsolutePath } from "./absolute-path.ts"; import { DEFAULT_BRIDGE, isBridgeName } from "./bridge-name.ts"; @@ -8,6 +14,7 @@ import { isSize } from "./commands/create-app-container/size.ts"; import { isSshKey } from "./commands/create-app-container/ssh-key.ts"; import { listAppContainers } from "./commands/list-app-containers.ts"; import { setupIncus } from "./commands/setup-incus/mod.ts"; +import { setpoint } from "./commands/setpoint/mod.ts"; import { Config } from "./config.ts"; import { INCUS_CONTAINER_STATUS_CODES, @@ -25,6 +32,7 @@ export const COMMAND_NAMES = [ "create", "list", "setup-incus", + "setpoint", ] as const; export type CommandName = typeof COMMAND_NAMES[number]; @@ -42,6 +50,26 @@ export async function createCli< version: "0.0.0", }); + const appsDirOptionCast = await enforceType( + isAbsolutePath, + "an absolute path, for example /mnt/apps", + "apps-dir", + ); + const createAppsDirOption = ( + commandName: CommandName, + ): ReturnType => { + return [ + "--apps-dir ", + { + default: defaults?.[commandName]?.appsDir ?? defaults.appsDir ?? + "/mnt/apps", + description: + "Base directory for where all app containers' metadata and appdata are (to be) stored.", + cast: appsDirOptionCast, + }, + ]; + }; + cli .command("create ", "Create a new Incus app container.") .option( @@ -107,19 +135,7 @@ export async function createCli< default: defaults?.create?.diskSize ?? defaults.diskSize ?? "10GiB", }, ) - .option( - "--apps-dir ", - { - default: defaults?.create?.appsDir ?? defaults.appsDir ?? "/mnt/apps", - description: - "Base directory for where all app containers' metadata and appdata are (to be) stored.", - cast: await enforceType( - isAbsolutePath, - "an absolute path, for example /mnt/apps", - "apps-dir", - ), - }, - ) + .option(...createAppsDirOption("create")) .action( async (name: string, inputOptions) => { const options = await resolveCreateAppContainerOptions(inputOptions); @@ -191,5 +207,13 @@ export async function createCli< ) .action(setupIncus); + cli + .command( + "setpoint", + "Print the current setpoint; the containers we want, according to configuration files.", + ) + .option(...createAppsDirOption("setpoint")) + .action(setpoint); + return cli; } diff --git a/src/incus-app-container-files/deps.ts b/src/incus-app-container-files/deps.ts index 2130be7..9694e4f 100644 --- a/src/incus-app-container-files/deps.ts +++ b/src/incus-app-container-files/deps.ts @@ -1,6 +1,7 @@ // std export { basename } from "https://deno.land/std@0.222.1/path/basename.ts"; export { dirname } from "https://deno.land/std@0.222.1/path/dirname.ts"; +export { exists } from "https://deno.land/std@0.224.0/fs/exists.ts"; export { parse as parseToml } from "https://deno.land/std@0.222.1/toml/parse.ts"; export { parse as parseCsv, @@ -13,6 +14,11 @@ export { mapValues } from "https://deno.land/std@0.222.1/collections/map_values. // x export { fetch as fetchFile } from "https://deno.land/x/file_fetch@0.2.0/mod.ts"; export { default as camelCase } from "https://deno.land/x/case@2.2.0/camelCase.ts"; +export { + DEFAULT_FILE_LOADERS as DEFAULT_CONFIG_FILE_LOADERS, + DEFAULT_OPTIONS as DEFAULT_LOAD_CONFIG_FILES_OPTIONS, + loadConfig, +} from "https://deno.land/x/load_config_files@0.3.0/mod.ts"; // x/cliffy export { Select } from "https://deno.land/x/cliffy@v1.0.0-rc.4/prompt/select.ts"; @@ -37,6 +43,7 @@ export { startWith, unicode, } from "https://deno.land/x/fns@1.1.1/string/regex.ts"; +export { prop } from "https://deno.land/x/fns@1.1.1/object/prop.ts"; // x/fns@unstable export { optional as optionalTypeGuard } from "https://raw.githubusercontent.com/hugojosefson/fns/unstable/type-guard/optional.ts"; @@ -45,7 +52,15 @@ export { createDeepMapKeys } from "https://raw.githubusercontent.com/hugojosefso // npm export { default as split } from "npm:argv-split@2.0.1"; -export { breadc, ParseError } from "npm:breadc@0.9.7"; +export { + breadc, + type Command as BreadcCommand, + ParseError, +} from "npm:breadc@0.9.7"; + +// npm sindresorhus/p-* +export { default as pMap } from "npm:p-map@7.0.2"; +export { default as pFilter } from "npm:p-filter@4.1.0"; // npm ip-cidr export { type Address } from "npm:ip-cidr@4.0.0";