Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7591e0ed90 | |||
| d2c2a4c4dd | |||
| 89cd93cdff | |||
| 10aee5d4c5 |
16
changelog.md
16
changelog.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-07 - 1.17.1 - fix(registrycopy)
|
||||
add fetchWithRetry wrapper to apply timeouts, retries with exponential backoff, and token cache handling; use it for registry HTTP requests
|
||||
|
||||
- Introduces fetchWithRetry(url, options, timeoutMs, maxRetries) to wrap fetch with AbortSignal timeout, exponential backoff retries, and retry behavior only for network errors and 5xx responses
|
||||
- Replaces direct fetch calls for registry /v2 checks, token requests, and blob uploads with fetchWithRetry (30s for auth/token checks, 300s for blob operations)
|
||||
- Clears token cache entry when a 401 response is received so the next attempt re-authenticates
|
||||
- Adds logging on retry attempts and backoff delays to improve robustness and observability
|
||||
|
||||
## 2026-02-07 - 1.17.0 - feat(tsdocker)
|
||||
add Dockerfile filtering, optional skip-build flow, and fallback Docker config credential loading
|
||||
|
||||
- Add TsDockerManager.filterDockerfiles(patterns) to filter discovered Dockerfiles by glob-style patterns and warn when no matches are found
|
||||
- Allow skipping image build with --no-build (argvArg.build === false): discover Dockerfiles and apply filters without performing build
|
||||
- Fallback to load Docker registry credentials from ~/.docker/config.json via RegistryCopy.getDockerConfigCredentials when env vars do not provide credentials
|
||||
- Import RegistryCopy and add info/warn logs when credentials are loaded or missing
|
||||
|
||||
## 2026-02-07 - 1.16.0 - feat(core)
|
||||
Introduce per-invocation TsDockerSession and session-aware local registry and build orchestration; stream and parse buildx output for improved logging and visibility; detect Docker topology and add CI-safe cleanup; update README with multi-arch, parallel-build, caching, and local registry usage and new CLI flags.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@git.zone/tsdocker",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.1",
|
||||
"private": false,
|
||||
"description": "develop npm modules cross platform with docker",
|
||||
"main": "dist_ts/index.js",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tsdocker',
|
||||
version: '1.16.0',
|
||||
version: '1.17.1',
|
||||
description: 'develop npm modules cross platform with docker'
|
||||
}
|
||||
|
||||
@@ -20,6 +20,43 @@ interface ITokenCache {
|
||||
export class RegistryCopy {
|
||||
private tokenCache: ITokenCache = {};
|
||||
|
||||
/**
|
||||
* Wraps fetch() with timeout (via AbortSignal) and retry with exponential backoff.
|
||||
* Retries on network errors and 5xx; does NOT retry on 4xx client errors.
|
||||
* On 401, clears the token cache entry so the next attempt re-authenticates.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit & { duplex?: string },
|
||||
timeoutMs: number = 300_000,
|
||||
maxRetries: number = 3,
|
||||
): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
...options,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
// Retry on 5xx server errors (but not 4xx)
|
||||
if (resp.status >= 500 && attempt < maxRetries) {
|
||||
logger.log('warn', `Request to ${url} returned ${resp.status}, retrying (${attempt}/${maxRetries})...`);
|
||||
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
|
||||
continue;
|
||||
}
|
||||
return resp;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
if (attempt < maxRetries) {
|
||||
const delay = 1000 * Math.pow(2, attempt - 1);
|
||||
logger.log('warn', `fetch failed (attempt ${attempt}/${maxRetries}): ${lastError.message}, retrying in ${delay}ms...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Docker credentials from ~/.docker/config.json for a given registry.
|
||||
* Supports base64-encoded "auth" field in the config.
|
||||
@@ -109,7 +146,7 @@ export class RegistryCopy {
|
||||
}
|
||||
|
||||
try {
|
||||
const checkResp = await fetch(`${apiBase}/v2/`, { method: 'GET' });
|
||||
const checkResp = await this.fetchWithRetry(`${apiBase}/v2/`, { method: 'GET' }, 30_000);
|
||||
if (checkResp.ok) return null; // No auth needed
|
||||
|
||||
const wwwAuth = checkResp.headers.get('www-authenticate') || '';
|
||||
@@ -131,7 +168,7 @@ export class RegistryCopy {
|
||||
headers['Authorization'] = 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64');
|
||||
}
|
||||
|
||||
const tokenResp = await fetch(tokenUrl.toString(), { headers });
|
||||
const tokenResp = await this.fetchWithRetry(tokenUrl.toString(), { headers }, 30_000);
|
||||
if (!tokenResp.ok) {
|
||||
const body = await tokenResp.text();
|
||||
throw new Error(`Token request failed (${tokenResp.status}): ${body}`);
|
||||
@@ -189,7 +226,15 @@ export class RegistryCopy {
|
||||
fetchOptions.duplex = 'half'; // Required for streaming body in Node
|
||||
}
|
||||
|
||||
return fetch(url, fetchOptions);
|
||||
const resp = await this.fetchWithRetry(url, fetchOptions, 300_000);
|
||||
|
||||
// Token expired — clear cache so next call re-authenticates
|
||||
if (resp.status === 401 && token) {
|
||||
const cacheKey = `${registry}/${`repository:${repo}:${actions}`}`;
|
||||
delete this.tokenCache[cacheKey];
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,11 +365,11 @@ export class RegistryCopy {
|
||||
putHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const putResp = await fetch(putUrl, {
|
||||
const putResp = await this.fetchWithRetry(putUrl, {
|
||||
method: 'PUT',
|
||||
headers: putHeaders,
|
||||
body: blobData,
|
||||
});
|
||||
}, 300_000);
|
||||
|
||||
if (!putResp.ok) {
|
||||
const body = await putResp.text();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RegistryStorage } from './classes.registrystorage.js';
|
||||
import { TsDockerCache } from './classes.tsdockercache.js';
|
||||
import { DockerContext } from './classes.dockercontext.js';
|
||||
import { TsDockerSession } from './classes.tsdockersession.js';
|
||||
import { RegistryCopy } from './classes.registrycopy.js';
|
||||
import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
|
||||
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
@@ -76,6 +77,22 @@ export class TsDockerManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check ~/.docker/config.json if env vars didn't provide credentials
|
||||
if (!this.registryStorage.getRegistryByUrl(registryUrl)) {
|
||||
const dockerConfigCreds = RegistryCopy.getDockerConfigCredentials(registryUrl);
|
||||
if (dockerConfigCreds) {
|
||||
const registry = new DockerRegistry({
|
||||
registryUrl,
|
||||
username: dockerConfigCreds.username,
|
||||
password: dockerConfigCreds.password,
|
||||
});
|
||||
this.registryStorage.addRegistry(registry);
|
||||
logger.log('info', `Loaded credentials for ${registryUrl} from ~/.docker/config.json`);
|
||||
} else {
|
||||
logger.log('warn', `No credentials found for ${registryUrl} (checked env vars and ~/.docker/config.json)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +127,27 @@ export class TsDockerManager {
|
||||
return this.dockerfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters discovered Dockerfiles by name patterns (glob-style).
|
||||
* Mutates this.dockerfiles in place.
|
||||
*/
|
||||
public filterDockerfiles(patterns: string[]): void {
|
||||
const matched = this.dockerfiles.filter((df) => {
|
||||
const basename = plugins.path.basename(df.filePath);
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.includes('*') || pattern.includes('?')) {
|
||||
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: ${patterns.join(', ')}`);
|
||||
}
|
||||
this.dockerfiles = matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds discovered Dockerfiles in dependency order.
|
||||
* When options.patterns is provided, only matching Dockerfiles (and their dependencies) are built.
|
||||
|
||||
@@ -110,8 +110,15 @@ export let run = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Build images first (if not already built)
|
||||
// Build images first, unless --no-build is set
|
||||
if (argvArg.build === false) {
|
||||
await manager.discoverDockerfiles();
|
||||
if (buildOptions.patterns?.length) {
|
||||
manager.filterDockerfiles(buildOptions.patterns);
|
||||
}
|
||||
} else {
|
||||
await manager.build(buildOptions);
|
||||
}
|
||||
|
||||
// Get registry from --registry flag
|
||||
const registryArg = argvArg.registry as string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user