feat(build): add optional content-hash based build cache to skip rebuilding unchanged Dockerfiles
This commit is contained in:
117
ts/classes.tsdockercache.ts
Normal file
117
ts/classes.tsdockercache.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as plugins from './tsdocker.plugins.js';
|
||||
import * as paths from './tsdocker.paths.js';
|
||||
import { logger } from './tsdocker.logging.js';
|
||||
import type { ICacheData, ICacheEntry } from './interfaces/index.js';
|
||||
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
});
|
||||
|
||||
/**
|
||||
* Manages content-hash-based build caching for Dockerfiles.
|
||||
* Cache is stored in .nogit/tsdocker_support.json.
|
||||
*/
|
||||
export class TsDockerCache {
|
||||
private cacheFilePath: string;
|
||||
private data: ICacheData;
|
||||
|
||||
constructor() {
|
||||
this.cacheFilePath = path.join(paths.cwd, '.nogit', 'tsdocker_support.json');
|
||||
this.data = { version: 1, entries: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads cache data from disk. Falls back to empty cache on missing/corrupt file.
|
||||
*/
|
||||
public load(): void {
|
||||
try {
|
||||
const raw = fs.readFileSync(this.cacheFilePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.version === 1 && parsed.entries) {
|
||||
this.data = parsed;
|
||||
} else {
|
||||
logger.log('warn', '[cache] Cache file has unexpected format, starting fresh');
|
||||
this.data = { version: 1, entries: {} };
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt file — start fresh
|
||||
this.data = { version: 1, entries: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves cache data to disk. Creates .nogit directory if needed.
|
||||
*/
|
||||
public save(): void {
|
||||
const dir = path.dirname(this.cacheFilePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(this.cacheFilePath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of Dockerfile content.
|
||||
*/
|
||||
public computeContentHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a build can be skipped for the given Dockerfile.
|
||||
* Logs detailed diagnostics and returns true if the build should be skipped.
|
||||
*/
|
||||
public async shouldSkipBuild(cleanTag: string, content: string): Promise<boolean> {
|
||||
const contentHash = this.computeContentHash(content);
|
||||
const entry = this.data.entries[cleanTag];
|
||||
|
||||
if (!entry) {
|
||||
console.log(`[cache] ${cleanTag}:`);
|
||||
console.log(`[cache] Content hash: ${contentHash}`);
|
||||
console.log(`[cache] Stored hash: (none)`);
|
||||
console.log(`[cache] Hash match: no`);
|
||||
console.log(`→ Building ${cleanTag}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashMatch = entry.contentHash === contentHash;
|
||||
console.log(`[cache] ${cleanTag}:`);
|
||||
console.log(`[cache] Content hash: ${contentHash}`);
|
||||
console.log(`[cache] Stored hash: ${entry.contentHash}`);
|
||||
console.log(`[cache] Image ID: ${entry.imageId}`);
|
||||
console.log(`[cache] Hash match: ${hashMatch ? 'yes' : 'no'}`);
|
||||
|
||||
if (!hashMatch) {
|
||||
console.log(`→ Building ${cleanTag}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash matches — verify the image still exists locally
|
||||
const inspectResult = await smartshellInstance.exec(
|
||||
`docker image inspect ${entry.imageId} > /dev/null 2>&1`
|
||||
);
|
||||
const available = inspectResult.exitCode === 0;
|
||||
console.log(`[cache] Available: ${available ? 'yes' : 'no'}`);
|
||||
|
||||
if (available) {
|
||||
console.log(`→ Skipping build for ${cleanTag} (cache hit)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`→ Building ${cleanTag} (image no longer available)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful build in the cache.
|
||||
*/
|
||||
public recordBuild(cleanTag: string, content: string, imageId: string, buildTag: string): void {
|
||||
this.data.entries[cleanTag] = {
|
||||
contentHash: this.computeContentHash(content),
|
||||
imageId,
|
||||
buildTag,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user