diff --git a/changelog.md b/changelog.md index c8b0549..281f78e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-06 - 1.5.0 - feat(build) +add support for selective builds, platform override and build timeout + +- Introduce IBuildCommandOptions with patterns, platform and timeout to control build behavior +- Allow manager.build() to accept options and build only matching Dockerfiles (including dependencies) preserving topological order +- Add CLI parsing for build/push to accept positional Dockerfile patterns and --platform/--timeout flags +- Support single-platform override via docker buildx and multi-platform buildx detection +- Implement streaming exec with timeout to kill long-running builds and surface timeout errors + ## 2026-02-04 - 1.4.3 - fix(dockerfile) fix matching of base images to local Dockerfiles by stripping registry prefixes when comparing image references diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7aef705..280d78f 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsdocker', - version: '1.4.3', + version: '1.5.0', description: 'develop npm modules cross platform with docker' } diff --git a/ts/classes.dockerfile.ts b/ts/classes.dockerfile.ts index 9346f1d..e523692 100644 --- a/ts/classes.dockerfile.ts +++ b/ts/classes.dockerfile.ts @@ -2,7 +2,7 @@ import * as plugins from './tsdocker.plugins.js'; import * as paths from './tsdocker.paths.js'; import { logger } from './tsdocker.logging.js'; import { DockerRegistry } from './classes.dockerregistry.js'; -import type { IDockerfileOptions, ITsDockerConfig } from './interfaces/index.js'; +import type { IDockerfileOptions, ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js'; import type { TsDockerManager } from './classes.tsdockermanager.js'; import * as fs from 'fs'; @@ -136,9 +136,12 @@ export class Dockerfile { /** * Builds the corresponding real docker image for each Dockerfile class instance */ - public static async buildDockerfiles(sortedArrayArg: Dockerfile[]): Promise { + public static async buildDockerfiles( + sortedArrayArg: Dockerfile[], + options?: { platform?: string; timeout?: number }, + ): Promise { for (const dockerfileArg of sortedArrayArg) { - await dockerfileArg.build(); + await dockerfileArg.build(options); } return sortedArrayArg; } @@ -362,15 +365,19 @@ export class Dockerfile { /** * Builds the Dockerfile */ - public async build(): Promise { + public async build(options?: { platform?: string; timeout?: number }): Promise { logger.log('info', 'now building Dockerfile for ' + this.cleanTag); const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef); const config = this.managerRef.config; + const platformOverride = options?.platform; + const timeout = options?.timeout; let buildCommand: string; - // Check if multi-platform build is needed - if (config.platforms && config.platforms.length > 1) { + if (platformOverride) { + // Single platform override via buildx + buildCommand = `docker buildx build --platform ${platformOverride} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; + } else if (config.platforms && config.platforms.length > 1) { // Multi-platform build using buildx const platformString = config.platforms.join(','); buildCommand = `docker buildx build --platform ${platformString} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; @@ -386,11 +393,27 @@ export class Dockerfile { buildCommand = `docker build --label="version=${versionLabel}" -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`; } - const result = await smartshellInstance.exec(buildCommand); - if (result.exitCode !== 0) { - logger.log('error', `Build failed for ${this.cleanTag}`); - console.log(result.stdout); - throw new Error(`Build failed for ${this.cleanTag}`); + if (timeout) { + // Use streaming execution with timeout + const streaming = await smartshellInstance.execStreaming(buildCommand); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + streaming.childProcess.kill(); + reject(new Error(`Build timed out after ${timeout}s for ${this.cleanTag}`)); + }, timeout * 1000); + }); + const result = await Promise.race([streaming.finalPromise, timeoutPromise]); + if (result.exitCode !== 0) { + logger.log('error', `Build failed for ${this.cleanTag}`); + throw new Error(`Build failed for ${this.cleanTag}`); + } + } else { + const result = await smartshellInstance.exec(buildCommand); + if (result.exitCode !== 0) { + logger.log('error', `Build failed for ${this.cleanTag}`); + console.log(result.stdout); + throw new Error(`Build failed for ${this.cleanTag}`); + } } logger.log('ok', `Built ${this.cleanTag}`); diff --git a/ts/classes.tsdockermanager.ts b/ts/classes.tsdockermanager.ts index 23851ed..9f56d20 100644 --- a/ts/classes.tsdockermanager.ts +++ b/ts/classes.tsdockermanager.ts @@ -4,7 +4,7 @@ import { logger } from './tsdocker.logging.js'; import { Dockerfile } from './classes.dockerfile.js'; import { DockerRegistry } from './classes.dockerregistry.js'; import { RegistryStorage } from './classes.registrystorage.js'; -import type { ITsDockerConfig } from './interfaces/index.js'; +import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js'; const smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash', @@ -90,9 +90,10 @@ export class TsDockerManager { } /** - * Builds all discovered Dockerfiles in dependency order + * Builds discovered Dockerfiles in dependency order. + * When options.patterns is provided, only matching Dockerfiles (and their dependencies) are built. */ - public async build(): Promise { + public async build(options?: IBuildCommandOptions): Promise { if (this.dockerfiles.length === 0) { await this.discoverDockerfiles(); } @@ -102,16 +103,63 @@ export class TsDockerManager { return []; } + // Determine which Dockerfiles to build + let toBuild = this.dockerfiles; + + if (options?.patterns && options.patterns.length > 0) { + // Filter to matching Dockerfiles + const matched = this.dockerfiles.filter((df) => { + const basename = plugins.path.basename(df.filePath); + return options.patterns!.some((pattern) => { + if (pattern.includes('*') || pattern.includes('?')) { + // Convert glob pattern to regex + const regexStr = '^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'; + return new RegExp(regexStr).test(basename); + } + return basename === pattern; + }); + }); + + if (matched.length === 0) { + logger.log('warn', `No Dockerfiles matched patterns: ${options.patterns.join(', ')}`); + return []; + } + + // Resolve dependency chain and preserve topological order + toBuild = this.resolveWithDependencies(matched, this.dockerfiles); + logger.log('info', `Matched ${matched.length} Dockerfile(s), building ${toBuild.length} (including dependencies)`); + } + // Check if buildx is needed - if (this.config.platforms && this.config.platforms.length > 1) { + if (options?.platform || (this.config.platforms && this.config.platforms.length > 1)) { await this.ensureBuildx(); } - logger.log('info', `Building ${this.dockerfiles.length} Dockerfiles...`); - await Dockerfile.buildDockerfiles(this.dockerfiles); + logger.log('info', `Building ${toBuild.length} Dockerfiles...`); + await Dockerfile.buildDockerfiles(toBuild, { + platform: options?.platform, + timeout: options?.timeout, + }); logger.log('success', 'All Dockerfiles built successfully'); - return this.dockerfiles; + return toBuild; + } + + /** + * Resolves a set of target Dockerfiles to include all their local base image dependencies, + * preserving the original topological build order. + */ + private resolveWithDependencies(targets: Dockerfile[], allSorted: Dockerfile[]): Dockerfile[] { + const needed = new Set(); + const addWithDeps = (df: Dockerfile) => { + if (needed.has(df)) return; + needed.add(df); + if (df.localBaseImageDependent && df.localBaseDockerfile) { + addWithDeps(df.localBaseDockerfile); + } + }; + for (const df of targets) addWithDeps(df); + return allSorted.filter((df) => needed.has(df)); } /** diff --git a/ts/interfaces/index.ts b/ts/interfaces/index.ts index 895b86a..f21ff00 100644 --- a/ts/interfaces/index.ts +++ b/ts/interfaces/index.ts @@ -68,3 +68,12 @@ export interface IPushResult { digest?: string; error?: string; } + +/** + * Options for the build command + */ +export interface IBuildCommandOptions { + patterns?: string[]; // Dockerfile name patterns (e.g., ['Dockerfile_base', 'Dockerfile_*']) + platform?: string; // Single platform override (e.g., 'linux/arm64') + timeout?: number; // Build timeout in seconds +} diff --git a/ts/tsdocker.cli.ts b/ts/tsdocker.cli.ts index 993b61b..12f8a6e 100644 --- a/ts/tsdocker.cli.ts +++ b/ts/tsdocker.cli.ts @@ -7,6 +7,7 @@ import * as DockerModule from './tsdocker.docker.js'; import { logger, ora } from './tsdocker.logging.js'; import { TsDockerManager } from './classes.tsdockermanager.js'; +import type { IBuildCommandOptions } from './interfaces/index.js'; const tsdockerCli = new plugins.smartcli.Smartcli(); @@ -23,14 +24,28 @@ export let run = () => { }); /** - * Build all Dockerfiles in dependency order + * Build Dockerfiles in dependency order + * Usage: tsdocker build [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600] */ tsdockerCli.addCommand('build').subscribe(async argvArg => { try { const config = await ConfigModule.run(); const manager = new TsDockerManager(config); await manager.prepare(); - await manager.build(); + + const buildOptions: IBuildCommandOptions = {}; + const patterns = argvArg._.slice(1) as string[]; + if (patterns.length > 0) { + buildOptions.patterns = patterns; + } + if (argvArg.platform) { + buildOptions.platform = argvArg.platform as string; + } + if (argvArg.timeout) { + buildOptions.timeout = Number(argvArg.timeout); + } + + await manager.build(buildOptions); logger.log('success', 'Build completed successfully'); } catch (err) { logger.log('error', `Build failed: ${(err as Error).message}`); @@ -40,6 +55,7 @@ export let run = () => { /** * Push built images to configured registries + * Usage: tsdocker push [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600] [--registry=url] */ tsdockerCli.addCommand('push').subscribe(async argvArg => { try { @@ -50,11 +66,24 @@ export let run = () => { // Login first await manager.login(); - // Build images first (if not already built) - await manager.build(); + // Parse build options from positional args and flags + const buildOptions: IBuildCommandOptions = {}; + const patterns = argvArg._.slice(1) as string[]; + if (patterns.length > 0) { + buildOptions.patterns = patterns; + } + if (argvArg.platform) { + buildOptions.platform = argvArg.platform as string; + } + if (argvArg.timeout) { + buildOptions.timeout = Number(argvArg.timeout); + } - // Get registry from arguments if specified - const registryArg = argvArg._[1]; // e.g., tsdocker push registry.gitlab.com + // Build images first (if not already built) + await manager.build(buildOptions); + + // Get registry from --registry flag + const registryArg = argvArg.registry as string | undefined; const registries = registryArg ? [registryArg] : undefined; await manager.push(registries);