From 2e6889c5ffab43a55f2ab8fed312b3bdc8102f0f Mon Sep 17 00:00:00 2001 From: sbauer Date: Mon, 19 Oct 2026 23:07:53 +0000 Subject: [PATCH 1/9] git-mirror: fstrim the sticky disk before unmount at commit Deleted packs keep their RBD extents allocated on the backing store until the guest discards them. Run a best-effort fstrim on the mirror mount after sync and before unmount, only when the run will commit and only when the host enabled it: the VM agent returns GetStickyDiskResponse.fstrim_timeout_secs (fa live config guest_fstrim_timeout_secs), saved in action state at mount time; 0 means no trim. Any failure or timeout only logs. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- __test__/blacksmith-cache.test.ts | 17 +++++++ dist/index.js | 81 +++++++++++++++++++++++++++--- src/blacksmith-cache.ts | 82 +++++++++++++++++++++++++++++-- src/git-source-provider.ts | 3 ++ src/main.ts | 3 +- src/state-helper.ts | 16 ++++++ 6 files changed, 191 insertions(+), 11 deletions(-) diff --git a/__test__/blacksmith-cache.test.ts b/__test__/blacksmith-cache.test.ts index 5f50057..85dc9e3 100644 --- a/__test__/blacksmith-cache.test.ts +++ b/__test__/blacksmith-cache.test.ts @@ -21,6 +21,23 @@ jest.mock('../src/container-detector', () => ({ })) import * as blacksmithCache from '../src/blacksmith-cache' + +describe('parseFstrimTimeoutSecs', () => { + it('accepts the RPC bigint, a number, or the saved-state string', () => { + expect(blacksmithCache.parseFstrimTimeoutSecs(BigInt(5))).toBe(5) + expect(blacksmithCache.parseFstrimTimeoutSecs(5)).toBe(5) + expect(blacksmithCache.parseFstrimTimeoutSecs('5')).toBe(5) + expect(blacksmithCache.parseFstrimTimeoutSecs('2.9')).toBe(2) + }) + + it('treats anything unusable as disabled', () => { + expect(blacksmithCache.parseFstrimTimeoutSecs(BigInt(0))).toBe(0) + expect(blacksmithCache.parseFstrimTimeoutSecs(-1)).toBe(0) + expect(blacksmithCache.parseFstrimTimeoutSecs('')).toBe(0) + expect(blacksmithCache.parseFstrimTimeoutSecs(undefined)).toBe(0) + expect(blacksmithCache.parseFstrimTimeoutSecs('nope')).toBe(0) + }) +}) import {isRunningInContainer} from '../src/container-detector' const mockIsRunningInContainer = isRunningInContainer as jest.MockedFunction< diff --git a/dist/index.js b/dist/index.js index d1d1519..a926529 100644 --- a/dist/index.js +++ b/dist/index.js @@ -69,6 +69,7 @@ exports.dissociate = dissociate; exports.hasCommitGraph = hasCommitGraph; exports.markKeepPacks = markKeepPacks; exports.runMirrorMaintenance = runMirrorMaintenance; +exports.parseFstrimTimeoutSecs = parseFstrimTimeoutSecs; exports.cleanup = cleanup; const core = __importStar(__nccwpck_require__(2186)); const exec = __importStar(__nccwpck_require__(1514)); @@ -344,7 +345,8 @@ function setupCache(owner, repo) { hydrationInProgress: true, hydrationMessage, performedHydration: false, - commitEarlyDenyReason: '' + commitEarlyDenyReason: '', + fstrimTimeoutSecs: 0 }; } // Re-throw other errors @@ -365,6 +367,7 @@ function setupCache(owner, repo) { const commitEarlyDenyReason = response.commitEarlyDeny ? response.commitEarlyDenyReason || 'denied by host policy' : ''; + const fstrimTimeoutSecs = parseFstrimTimeoutSecs(response.fstrimTimeoutSecs); if (commitEarlyDenyReason) { core.notice(`[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.`); } @@ -387,7 +390,8 @@ function setupCache(owner, repo) { mirrorPath: getMirrorPath(owner, repo), hydrationInProgress: false, performedHydration: false, // Will be set by ensureMirror if we do initial clone - commitEarlyDenyReason + commitEarlyDenyReason, + fstrimTimeoutSecs }; }); } @@ -1936,17 +1940,56 @@ function flushBlockDevice(devicePath) { } }); } +/** + * Parse the host's fstrim budget (GetStickyDiskResponse.fstrim_timeout_secs, + * a protobuf int64 that arrives as a bigint, or the string read back from + * action state) into whole seconds. Anything unusable is 0, which disables + * the trim. + */ +function parseFstrimTimeoutSecs(value) { + const n = typeof value === 'bigint' ? Number(value) : Number(value !== null && value !== void 0 ? value : 0); + if (!Number.isFinite(n) || n <= 0) + return 0; + return Math.floor(n); +} +/** + * Best-effort `fstrim` of the mirror filesystem before it is unmounted and + * committed: deleted packs keep their RBD extents allocated on the backing + * store until the guest discards them. Bounded by the host-supplied budget; + * any failure (discard not negotiated, missing binary, timeout) only logs. + */ +function trimFilesystem(mountPoint, timeoutSecs) { + return __awaiter(this, void 0, void 0, function* () { + const start = Date.now(); + try { + const result = yield exec.getExecOutput('timeout', [String(timeoutSecs), 'sudo', 'fstrim', '-v', '--', mountPoint], { ignoreReturnCode: true, silent: true }); + const durationMs = Date.now() - start; + if (result.exitCode === 0) { + core.info(`[git-mirror] fstrim ${mountPoint}: ${result.stdout.trim() || 'done'} in ${durationMs}ms`); + } + else if (result.exitCode === TIMEOUT_EXIT_CODE) { + core.info(`[git-mirror] fstrim ${mountPoint}: timed out after ${timeoutSecs}s`); + } + else { + core.info(`[git-mirror] fstrim ${mountPoint}: failed in ${durationMs}ms: ${result.stderr.trim()}`); + } + } + catch (error) { + core.info(`[git-mirror] fstrim ${mountPoint}: failed: ${error.message}`); + } + }); +} /** * Cleanup: run mirror maintenance, sync, unmount, and commit the sticky disk. * - * Execution order: maintenance → sync → unmount (with retry) → flush → commit + * Execution order: maintenance → sync → fstrim → unmount (with retry) → flush → commit * If the mirror sync failed or timed out, shouldCommit is set to false. * Maintenance failure or timeout does not affect the commit. */ function cleanup(options) { return __awaiter(this, void 0, void 0, function* () { var _a; - const { exposeId, stickyDiskKey, repoName, mountPoint, mirrorPath, mirrorSyncFailed, mirrorSyncTimedOut, vmHydratedGitMirror } = options; + const { exposeId, stickyDiskKey, repoName, mountPoint, mirrorPath, mirrorSyncFailed, mirrorSyncTimedOut, vmHydratedGitMirror, fstrimTimeoutSecs } = options; let { shouldCommit } = options; const result = { // skipped until maintenance actually runs, so a mirror that is not @@ -1980,6 +2023,15 @@ function cleanup(options) { catch (_b) { core.warning('[git-mirror] Failed to sync filesystem'); } + // Discard freed blocks before unmount so the committed snapshot does not + // keep them allocated. Only worth it when the result is persisted, and only + // when the host has enabled it. + if (mountPoint && + shouldCommit && + fstrimTimeoutSecs && + fstrimTimeoutSecs > 0) { + yield trimFilesystem(mountPoint, fstrimTimeoutSecs); + } // Get device path before unmount for durability flush let devicePath = null; if (mountPoint) { @@ -3727,6 +3779,7 @@ function getSourceInner(settings, report, onHydrationReport) { stateHelper.setBlacksmithCacheMirrorPath(cacheInfo.mirrorPath); stateHelper.setBlacksmithCacheMountPoint(cacheInfo.mountPoint); stateHelper.setBlacksmithCacheCommitEarlyDenyReason(cacheInfo.commitEarlyDenyReason); + stateHelper.setBlacksmithCacheFstrimTimeoutSecs(cacheInfo.fstrimTimeoutSecs); const mirrorExisted = fsHelper.directoryExistsSync(cacheInfo.mirrorPath); const cloneStart = Date.now(); let performedHydration = false; @@ -4820,7 +4873,8 @@ function cleanup() { shouldCommit, vmHydratedGitMirror: performedHydration, mirrorSyncFailed, - mirrorSyncTimedOut + mirrorSyncTimedOut, + fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs }); } catch (error) { @@ -5524,7 +5578,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.BlacksmithCacheCommitEarlyDenyReason = exports.BlacksmithCacheVerbose = exports.BlacksmithCacheRepoUrl = exports.BlacksmithCacheMirrorSyncDeferred = exports.BlacksmithCacheMirrorSyncTimedOut = exports.BlacksmithCacheMirrorSyncFailed = exports.BlacksmithCacheMirrorChanged = exports.BlacksmithCachePerformedHydration = exports.BlacksmithCacheStickyDiskKey = exports.BlacksmithCacheRepoName = exports.BlacksmithCacheMountPoint = exports.BlacksmithCacheMirrorPath = exports.BlacksmithCacheExposeId = exports.SshKnownHostsPath = exports.SshKeyPath = exports.PostSetSafeDirectory = exports.RepositoryPath = exports.IsPost = void 0; +exports.BlacksmithCacheFstrimTimeoutSecs = exports.BlacksmithCacheCommitEarlyDenyReason = exports.BlacksmithCacheVerbose = exports.BlacksmithCacheRepoUrl = exports.BlacksmithCacheMirrorSyncDeferred = exports.BlacksmithCacheMirrorSyncTimedOut = exports.BlacksmithCacheMirrorSyncFailed = exports.BlacksmithCacheMirrorChanged = exports.BlacksmithCachePerformedHydration = exports.BlacksmithCacheStickyDiskKey = exports.BlacksmithCacheRepoName = exports.BlacksmithCacheMountPoint = exports.BlacksmithCacheMirrorPath = exports.BlacksmithCacheExposeId = exports.SshKnownHostsPath = exports.SshKeyPath = exports.PostSetSafeDirectory = exports.RepositoryPath = exports.IsPost = void 0; exports.setRepositoryPath = setRepositoryPath; exports.setSshKeyPath = setSshKeyPath; exports.setSshKnownHostsPath = setSshKnownHostsPath; @@ -5542,6 +5596,7 @@ exports.setBlacksmithCacheMirrorSyncDeferred = setBlacksmithCacheMirrorSyncDefer exports.setBlacksmithCacheRepoUrl = setBlacksmithCacheRepoUrl; exports.setBlacksmithCacheVerbose = setBlacksmithCacheVerbose; exports.setBlacksmithCacheCommitEarlyDenyReason = setBlacksmithCacheCommitEarlyDenyReason; +exports.setBlacksmithCacheFstrimTimeoutSecs = setBlacksmithCacheFstrimTimeoutSecs; const core = __importStar(__nccwpck_require__(2186)); /** * Indicates whether the POST action is running @@ -5617,6 +5672,14 @@ exports.BlacksmithCacheVerbose = core.getState('blacksmithCacheVerbose') === 'tr * (e.g. branch protection). Empty when no denial was reported. */ exports.BlacksmithCacheCommitEarlyDenyReason = core.getState('blacksmithCacheCommitEarlyDenyReason'); +/** + * Host-supplied budget, in whole seconds, for trimming the mirror filesystem + * before unmount at commit. 0 when the host has not enabled the trim. + */ +exports.BlacksmithCacheFstrimTimeoutSecs = (() => { + const secs = Number(core.getState('blacksmithCacheFstrimTimeoutSecs') || '0'); + return Number.isFinite(secs) && secs > 0 ? Math.floor(secs) : 0; +})(); /** * Save the repository path so the POST action can retrieve the value. */ @@ -5718,6 +5781,12 @@ function setBlacksmithCacheVerbose(verbose) { function setBlacksmithCacheCommitEarlyDenyReason(reason) { core.saveState('blacksmithCacheCommitEarlyDenyReason', reason); } +/** + * Save the host's fstrim budget so the POST action can trim before unmount. + */ +function setBlacksmithCacheFstrimTimeoutSecs(secs) { + core.saveState('blacksmithCacheFstrimTimeoutSecs', String(secs)); +} // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic. // This is necessary since we don't have a separate entry point. if (!exports.IsPost) { diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index f073efc..07ed5cc 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -116,6 +116,10 @@ export interface CacheInfo { // sticky disk commit will be denied (e.g. branch protection): the mirror can // be used but changes to it are discarded at VM teardown. commitEarlyDenyReason: string + // fstrimTimeoutSecs is the host's budget, in whole seconds, for trimming the + // mirror filesystem before unmount at commit; 0 when the host has not + // enabled the trim. + fstrimTimeoutSecs: number } /** @@ -364,7 +368,8 @@ export async function setupCache( hydrationInProgress: true, hydrationMessage, performedHydration: false, - commitEarlyDenyReason: '' + commitEarlyDenyReason: '', + fstrimTimeoutSecs: 0 } } // Re-throw other errors @@ -391,6 +396,9 @@ export async function setupCache( const commitEarlyDenyReason = response.commitEarlyDeny ? response.commitEarlyDenyReason || 'denied by host policy' : '' + const fstrimTimeoutSecs = parseFstrimTimeoutSecs( + (response as {fstrimTimeoutSecs?: bigint | number}).fstrimTimeoutSecs + ) if (commitEarlyDenyReason) { core.notice( `[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.` @@ -418,7 +426,8 @@ export async function setupCache( mirrorPath: getMirrorPath(owner, repo), hydrationInProgress: false, performedHydration: false, // Will be set by ensureMirror if we do initial clone - commitEarlyDenyReason + commitEarlyDenyReason, + fstrimTimeoutSecs } } @@ -2315,6 +2324,56 @@ async function flushBlockDevice(devicePath: string): Promise { } } +/** + * Parse the host's fstrim budget (GetStickyDiskResponse.fstrim_timeout_secs, + * a protobuf int64 that arrives as a bigint, or the string read back from + * action state) into whole seconds. Anything unusable is 0, which disables + * the trim. + */ +export function parseFstrimTimeoutSecs(value: unknown): number { + const n = typeof value === 'bigint' ? Number(value) : Number(value ?? 0) + if (!Number.isFinite(n) || n <= 0) return 0 + return Math.floor(n) +} + +/** + * Best-effort `fstrim` of the mirror filesystem before it is unmounted and + * committed: deleted packs keep their RBD extents allocated on the backing + * store until the guest discards them. Bounded by the host-supplied budget; + * any failure (discard not negotiated, missing binary, timeout) only logs. + */ +async function trimFilesystem( + mountPoint: string, + timeoutSecs: number +): Promise { + const start = Date.now() + try { + const result = await exec.getExecOutput( + 'timeout', + [String(timeoutSecs), 'sudo', 'fstrim', '-v', '--', mountPoint], + {ignoreReturnCode: true, silent: true} + ) + const durationMs = Date.now() - start + if (result.exitCode === 0) { + core.info( + `[git-mirror] fstrim ${mountPoint}: ${result.stdout.trim() || 'done'} in ${durationMs}ms` + ) + } else if (result.exitCode === TIMEOUT_EXIT_CODE) { + core.info( + `[git-mirror] fstrim ${mountPoint}: timed out after ${timeoutSecs}s` + ) + } else { + core.info( + `[git-mirror] fstrim ${mountPoint}: failed in ${durationMs}ms: ${result.stderr.trim()}` + ) + } + } catch (error) { + core.info( + `[git-mirror] fstrim ${mountPoint}: failed: ${(error as Error).message}` + ) + } +} + export interface CleanupOptions { exposeId: string stickyDiskKey: string @@ -2332,12 +2391,14 @@ export interface CleanupOptions { // Mirror sync outcome from the main step. mirrorSyncFailed?: boolean mirrorSyncTimedOut?: boolean + // Host-supplied fstrim budget in whole seconds; 0 or absent skips the trim. + fstrimTimeoutSecs?: number } /** * Cleanup: run mirror maintenance, sync, unmount, and commit the sticky disk. * - * Execution order: maintenance → sync → unmount (with retry) → flush → commit + * Execution order: maintenance → sync → fstrim → unmount (with retry) → flush → commit * If the mirror sync failed or timed out, shouldCommit is set to false. * Maintenance failure or timeout does not affect the commit. */ @@ -2350,7 +2411,8 @@ export async function cleanup(options: CleanupOptions): Promise { mirrorPath, mirrorSyncFailed, mirrorSyncTimedOut, - vmHydratedGitMirror + vmHydratedGitMirror, + fstrimTimeoutSecs } = options let {shouldCommit} = options @@ -2394,6 +2456,18 @@ export async function cleanup(options: CleanupOptions): Promise { core.warning('[git-mirror] Failed to sync filesystem') } + // Discard freed blocks before unmount so the committed snapshot does not + // keep them allocated. Only worth it when the result is persisted, and only + // when the host has enabled it. + if ( + mountPoint && + shouldCommit && + fstrimTimeoutSecs && + fstrimTimeoutSecs > 0 + ) { + await trimFilesystem(mountPoint, fstrimTimeoutSecs) + } + // Get device path before unmount for durability flush let devicePath: string | null = null if (mountPoint) { diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index f4c9d45..0f8db25 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -186,6 +186,9 @@ async function getSourceInner( stateHelper.setBlacksmithCacheCommitEarlyDenyReason( cacheInfo.commitEarlyDenyReason ) + stateHelper.setBlacksmithCacheFstrimTimeoutSecs( + cacheInfo.fstrimTimeoutSecs + ) const mirrorExisted = fsHelper.directoryExistsSync( cacheInfo.mirrorPath diff --git a/src/main.ts b/src/main.ts index f875dc9..d0b2497 100644 --- a/src/main.ts +++ b/src/main.ts @@ -154,7 +154,8 @@ async function cleanup(): Promise { shouldCommit, vmHydratedGitMirror: performedHydration, mirrorSyncFailed, - mirrorSyncTimedOut + mirrorSyncTimedOut, + fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs }) } catch (error) { core.warning( diff --git a/src/state-helper.ts b/src/state-helper.ts index e420115..98c7fad 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -106,6 +106,15 @@ export const BlacksmithCacheCommitEarlyDenyReason = core.getState( 'blacksmithCacheCommitEarlyDenyReason' ) +/** + * Host-supplied budget, in whole seconds, for trimming the mirror filesystem + * before unmount at commit. 0 when the host has not enabled the trim. + */ +export const BlacksmithCacheFstrimTimeoutSecs = (() => { + const secs = Number(core.getState('blacksmithCacheFstrimTimeoutSecs') || '0') + return Number.isFinite(secs) && secs > 0 ? Math.floor(secs) : 0 +})() + /** * Save the repository path so the POST action can retrieve the value. */ @@ -233,6 +242,13 @@ export function setBlacksmithCacheCommitEarlyDenyReason(reason: string) { core.saveState('blacksmithCacheCommitEarlyDenyReason', reason) } +/** + * Save the host's fstrim budget so the POST action can trim before unmount. + */ +export function setBlacksmithCacheFstrimTimeoutSecs(secs: number) { + core.saveState('blacksmithCacheFstrimTimeoutSecs', String(secs)) +} + // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic. // This is necessary since we don't have a separate entry point. if (!IsPost) { From c5646b8d2aeabfb8e29b96fc88f8039ce2e1e805 Mon Sep 17 00:00:00 2001 From: sbauer Date: Mon, 19 Oct 2026 23:16:52 +0000 Subject: [PATCH 2/9] git-mirror: read fstrim_timeout_secs from unknown field 8 when the proto package predates it The bundled GetStickyDiskResponse has no fstrim_timeout_secs yet, and protobuf-es keeps fields it does not know in the message's unknown-field list (the gRPC transport is binary), so decode field 8 from there when the typed field is absent. Releases built before the field existed therefore honour the host budget once the agent sends it. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- __test__/blacksmith-cache.test.ts | 32 +++++++++++++++++++++ dist/index.js | 33 +++++++++++++++++++++- src/blacksmith-cache.ts | 47 +++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/__test__/blacksmith-cache.test.ts b/__test__/blacksmith-cache.test.ts index 85dc9e3..5206e0c 100644 --- a/__test__/blacksmith-cache.test.ts +++ b/__test__/blacksmith-cache.test.ts @@ -21,6 +21,38 @@ jest.mock('../src/container-detector', () => ({ })) import * as blacksmithCache from '../src/blacksmith-cache' +import {proto3} from '@bufbuild/protobuf' + +describe('fstrimTimeoutSecsFrom', () => { + it('prefers the typed field when the proto package knows it', () => { + expect( + blacksmithCache.fstrimTimeoutSecsFrom({fstrimTimeoutSecs: BigInt(7)}, [ + {no: 8, wireType: 0, data: new Uint8Array([5])} + ]) + ).toBe(7) + }) + + it('falls back to unknown field 8 from an older proto package', () => { + // A message type that predates the field, like a bundled proto package + // built before fstrim_timeout_secs existed. + const OldResponse = proto3.makeMessageType('test.OldResponse', []) + const response = OldResponse.fromBinary(new Uint8Array([0x40, 0x05])) + const unknown = response.getType().runtime.bin.listUnknownFields(response) + expect(blacksmithCache.fstrimTimeoutSecsFrom(response, unknown)).toBe(5) + expect( + blacksmithCache.fstrimTimeoutSecsFrom({}, [ + {no: 8, wireType: 2, data: new Uint8Array([5])} + ]) + ).toBe(0) + expect(blacksmithCache.fstrimTimeoutSecsFrom({}, [])).toBe(0) + }) + + it('decodes varints and rejects malformed ones', () => { + expect(blacksmithCache.decodeVarint(new Uint8Array([0xac, 0x02]))).toBe(300) + expect(blacksmithCache.decodeVarint(new Uint8Array([0x80]))).toBeNull() + expect(blacksmithCache.decodeVarint(new Uint8Array([5, 5]))).toBeNull() + }) +}) describe('parseFstrimTimeoutSecs', () => { it('accepts the RPC bigint, a number, or the saved-state string', () => { diff --git a/dist/index.js b/dist/index.js index a926529..5498f0a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -70,6 +70,8 @@ exports.hasCommitGraph = hasCommitGraph; exports.markKeepPacks = markKeepPacks; exports.runMirrorMaintenance = runMirrorMaintenance; exports.parseFstrimTimeoutSecs = parseFstrimTimeoutSecs; +exports.decodeVarint = decodeVarint; +exports.fstrimTimeoutSecsFrom = fstrimTimeoutSecsFrom; exports.cleanup = cleanup; const core = __importStar(__nccwpck_require__(2186)); const exec = __importStar(__nccwpck_require__(1514)); @@ -367,7 +369,7 @@ function setupCache(owner, repo) { const commitEarlyDenyReason = response.commitEarlyDeny ? response.commitEarlyDenyReason || 'denied by host policy' : ''; - const fstrimTimeoutSecs = parseFstrimTimeoutSecs(response.fstrimTimeoutSecs); + const fstrimTimeoutSecs = fstrimTimeoutSecsFrom(response, response.getType().runtime.bin.listUnknownFields(response)); if (commitEarlyDenyReason) { core.notice(`[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.`); } @@ -1952,6 +1954,35 @@ function parseFstrimTimeoutSecs(value) { return 0; return Math.floor(n); } +const FSTRIM_TIMEOUT_FIELD_NO = 8; +const WIRE_TYPE_VARINT = 0; +/** Decodes an unsigned LEB128 varint; null when malformed or above 2^53. */ +function decodeVarint(data) { + let value = 0; + let shift = 0; + for (let i = 0; i < data.length; i++) { + const byte = data[i]; + value += (byte & 0x7f) * Math.pow(2, shift); + if ((byte & 0x80) === 0) { + return i === data.length - 1 && Number.isSafeInteger(value) ? value : null; + } + shift += 7; + } + return null; +} +/** + * GetStickyDiskResponse.fstrim_timeout_secs as whole seconds, whether the + * bundled proto package knows the field (typed int64) or predates it: the + * gRPC transport decodes binary and protobuf-es keeps unknown fields, so a + * release built before the field existed still honours the host budget. + */ +function fstrimTimeoutSecsFrom(response, unknownFields) { + const typed = response.fstrimTimeoutSecs; + if (typed !== undefined) + return parseFstrimTimeoutSecs(typed); + const field = unknownFields.find(f => f.no === FSTRIM_TIMEOUT_FIELD_NO && f.wireType === WIRE_TYPE_VARINT); + return field ? parseFstrimTimeoutSecs(decodeVarint(field.data)) : 0; +} /** * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index 07ed5cc..dd36a9a 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -396,8 +396,9 @@ export async function setupCache( const commitEarlyDenyReason = response.commitEarlyDeny ? response.commitEarlyDenyReason || 'denied by host policy' : '' - const fstrimTimeoutSecs = parseFstrimTimeoutSecs( - (response as {fstrimTimeoutSecs?: bigint | number}).fstrimTimeoutSecs + const fstrimTimeoutSecs = fstrimTimeoutSecsFrom( + response, + response.getType().runtime.bin.listUnknownFields(response) ) if (commitEarlyDenyReason) { core.notice( @@ -2336,6 +2337,48 @@ export function parseFstrimTimeoutSecs(value: unknown): number { return Math.floor(n) } +const FSTRIM_TIMEOUT_FIELD_NO = 8 +const WIRE_TYPE_VARINT = 0 + +export interface UnknownProtoField { + no: number + wireType: number + data: Uint8Array +} + +/** Decodes an unsigned LEB128 varint; null when malformed or above 2^53. */ +export function decodeVarint(data: Uint8Array): number | null { + let value = 0 + let shift = 0 + for (let i = 0; i < data.length; i++) { + const byte = data[i] + value += (byte & 0x7f) * 2 ** shift + if ((byte & 0x80) === 0) { + return i === data.length - 1 && Number.isSafeInteger(value) ? value : null + } + shift += 7 + } + return null +} + +/** + * GetStickyDiskResponse.fstrim_timeout_secs as whole seconds, whether the + * bundled proto package knows the field (typed int64) or predates it: the + * gRPC transport decodes binary and protobuf-es keeps unknown fields, so a + * release built before the field existed still honours the host budget. + */ +export function fstrimTimeoutSecsFrom( + response: object, + unknownFields: ReadonlyArray +): number { + const typed = (response as {fstrimTimeoutSecs?: unknown}).fstrimTimeoutSecs + if (typed !== undefined) return parseFstrimTimeoutSecs(typed) + const field = unknownFields.find( + f => f.no === FSTRIM_TIMEOUT_FIELD_NO && f.wireType === WIRE_TYPE_VARINT + ) + return field ? parseFstrimTimeoutSecs(decodeVarint(field.data)) : 0 +} + /** * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing From f36ea7ddc67d60cb1f5cfba8f01e879e9063065a Mon Sep 17 00:00:00 2001 From: sbauer Date: Mon, 19 Oct 2026 23:20:31 +0000 Subject: [PATCH 3/9] git-mirror: use readonly array syntax (eslint array-type) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/blacksmith-cache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index dd36a9a..2f12abd 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -2369,7 +2369,7 @@ export function decodeVarint(data: Uint8Array): number | null { */ export function fstrimTimeoutSecsFrom( response: object, - unknownFields: ReadonlyArray + unknownFields: readonly UnknownProtoField[] ): number { const typed = (response as {fstrimTimeoutSecs?: unknown}).fstrimTimeoutSecs if (typed !== undefined) return parseFstrimTimeoutSecs(typed) From 2a8ace21aa5321c331da810355b2e3443edf6f4d Mon Sep 17 00:00:00 2001 From: sbauer Date: Fri, 23 Oct 2026 00:08:58 +0000 Subject: [PATCH 4/9] git-mirror: read fstrim_timeout_secs from the regenerated proto package Bump @buf/blacksmith_vm-agent.bufbuild_es to a build that includes GetStickyDiskResponse.fstrim_timeout_secs (fa #6600) and drop the unknown-field-8 decoding. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- __test__/blacksmith-cache.test.ts | 32 ----- dist/index.js | 228 +++++++++++++++++++++++++----- package-lock.json | 18 +-- package.json | 4 +- src/blacksmith-cache.ts | 47 +----- 5 files changed, 208 insertions(+), 121 deletions(-) diff --git a/__test__/blacksmith-cache.test.ts b/__test__/blacksmith-cache.test.ts index 5206e0c..85dc9e3 100644 --- a/__test__/blacksmith-cache.test.ts +++ b/__test__/blacksmith-cache.test.ts @@ -21,38 +21,6 @@ jest.mock('../src/container-detector', () => ({ })) import * as blacksmithCache from '../src/blacksmith-cache' -import {proto3} from '@bufbuild/protobuf' - -describe('fstrimTimeoutSecsFrom', () => { - it('prefers the typed field when the proto package knows it', () => { - expect( - blacksmithCache.fstrimTimeoutSecsFrom({fstrimTimeoutSecs: BigInt(7)}, [ - {no: 8, wireType: 0, data: new Uint8Array([5])} - ]) - ).toBe(7) - }) - - it('falls back to unknown field 8 from an older proto package', () => { - // A message type that predates the field, like a bundled proto package - // built before fstrim_timeout_secs existed. - const OldResponse = proto3.makeMessageType('test.OldResponse', []) - const response = OldResponse.fromBinary(new Uint8Array([0x40, 0x05])) - const unknown = response.getType().runtime.bin.listUnknownFields(response) - expect(blacksmithCache.fstrimTimeoutSecsFrom(response, unknown)).toBe(5) - expect( - blacksmithCache.fstrimTimeoutSecsFrom({}, [ - {no: 8, wireType: 2, data: new Uint8Array([5])} - ]) - ).toBe(0) - expect(blacksmithCache.fstrimTimeoutSecsFrom({}, [])).toBe(0) - }) - - it('decodes varints and rejects malformed ones', () => { - expect(blacksmithCache.decodeVarint(new Uint8Array([0xac, 0x02]))).toBe(300) - expect(blacksmithCache.decodeVarint(new Uint8Array([0x80]))).toBeNull() - expect(blacksmithCache.decodeVarint(new Uint8Array([5, 5]))).toBeNull() - }) -}) describe('parseFstrimTimeoutSecs', () => { it('accepts the RPC bigint, a number, or the saved-state string', () => { diff --git a/dist/index.js b/dist/index.js index 5498f0a..24be13b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -70,8 +70,6 @@ exports.hasCommitGraph = hasCommitGraph; exports.markKeepPacks = markKeepPacks; exports.runMirrorMaintenance = runMirrorMaintenance; exports.parseFstrimTimeoutSecs = parseFstrimTimeoutSecs; -exports.decodeVarint = decodeVarint; -exports.fstrimTimeoutSecsFrom = fstrimTimeoutSecsFrom; exports.cleanup = cleanup; const core = __importStar(__nccwpck_require__(2186)); const exec = __importStar(__nccwpck_require__(1514)); @@ -369,7 +367,7 @@ function setupCache(owner, repo) { const commitEarlyDenyReason = response.commitEarlyDeny ? response.commitEarlyDenyReason || 'denied by host policy' : ''; - const fstrimTimeoutSecs = fstrimTimeoutSecsFrom(response, response.getType().runtime.bin.listUnknownFields(response)); + const fstrimTimeoutSecs = parseFstrimTimeoutSecs(response.fstrimTimeoutSecs); if (commitEarlyDenyReason) { core.notice(`[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.`); } @@ -1954,35 +1952,6 @@ function parseFstrimTimeoutSecs(value) { return 0; return Math.floor(n); } -const FSTRIM_TIMEOUT_FIELD_NO = 8; -const WIRE_TYPE_VARINT = 0; -/** Decodes an unsigned LEB128 varint; null when malformed or above 2^53. */ -function decodeVarint(data) { - let value = 0; - let shift = 0; - for (let i = 0; i < data.length; i++) { - const byte = data[i]; - value += (byte & 0x7f) * Math.pow(2, shift); - if ((byte & 0x80) === 0) { - return i === data.length - 1 && Number.isSafeInteger(value) ? value : null; - } - shift += 7; - } - return null; -} -/** - * GetStickyDiskResponse.fstrim_timeout_secs as whole seconds, whether the - * bundled proto package knows the field (typed int64) or predates it: the - * gRPC transport decodes binary and protobuf-es keeps unknown fields, so a - * release built before the field existed still honours the host budget. - */ -function fstrimTimeoutSecsFrom(response, unknownFields) { - const typed = response.fstrimTimeoutSecs; - if (typed !== undefined) - return parseFstrimTimeoutSecs(typed); - const field = unknownFields.find(f => f.no === FSTRIM_TIMEOUT_FIELD_NO && f.wireType === WIRE_TYPE_VARINT); - return field ? parseFstrimTimeoutSecs(decodeVarint(field.data)) : 0; -} /** * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing @@ -66139,6 +66108,8 @@ const CommitSkipReason = /*@__PURE__*/ proto3.makeEnum( {no: 4, name: "COMMIT_SKIP_REASON_CLEANUP_ERROR", localName: "CLEANUP_ERROR"}, {no: 5, name: "COMMIT_SKIP_REASON_AMBIGUOUS", localName: "AMBIGUOUS"}, {no: 6, name: "COMMIT_SKIP_REASON_NO_EXPOSE", localName: "NO_EXPOSE"}, + {no: 7, name: "COMMIT_SKIP_REASON_SERVER_DECLINED", localName: "SERVER_DECLINED"}, + {no: 8, name: "COMMIT_SKIP_REASON_HOOK_SKIPPED", localName: "HOOK_SKIPPED"}, ], ); @@ -66155,6 +66126,78 @@ const IntegrityOutcome = /*@__PURE__*/ proto3.makeEnum( ], ); +/** + * ContainerCacheSetupOutcome classifies how docker container cache + * activation ended for a job. Every non-ACTIVATED outcome falls back to + * uncached docker on the ephemeral root disk; the job still runs. + * + * @generated from enum stickydisk.v1.ContainerCacheSetupOutcome + */ +const ContainerCacheSetupOutcome = /*@__PURE__*/ proto3.makeEnum( + "stickydisk.v1.ContainerCacheSetupOutcome", + [ + {no: 0, name: "CONTAINER_CACHE_SETUP_OUTCOME_UNSPECIFIED", localName: "UNSPECIFIED"}, + {no: 1, name: "CONTAINER_CACHE_SETUP_OUTCOME_ACTIVATED", localName: "ACTIVATED"}, + {no: 2, name: "CONTAINER_CACHE_SETUP_OUTCOME_DISABLED", localName: "DISABLED"}, + {no: 3, name: "CONTAINER_CACHE_SETUP_OUTCOME_METADATA_UNAVAILABLE", localName: "METADATA_UNAVAILABLE"}, + {no: 4, name: "CONTAINER_CACHE_SETUP_OUTCOME_EXPOSE_FAILED", localName: "EXPOSE_FAILED"}, + {no: 5, name: "CONTAINER_CACHE_SETUP_OUTCOME_DEVICE_SIZE_TIMEOUT", localName: "DEVICE_SIZE_TIMEOUT"}, + {no: 6, name: "CONTAINER_CACHE_SETUP_OUTCOME_MOUNT_FAILED", localName: "MOUNT_FAILED"}, + {no: 7, name: "CONTAINER_CACHE_SETUP_OUTCOME_UNUSED", localName: "UNUSED"}, + ], +); + +/** + * ContainerCacheCommitReason is the structured reason behind the job's + * commit-or-elide decision for the container cache disk. + * + * @generated from enum stickydisk.v1.ContainerCacheCommitReason + */ +const ContainerCacheCommitReason = /*@__PURE__*/ proto3.makeEnum( + "stickydisk.v1.ContainerCacheCommitReason", + [ + {no: 0, name: "CONTAINER_CACHE_COMMIT_REASON_UNSPECIFIED", localName: "UNSPECIFIED"}, + {no: 1, name: "CONTAINER_CACHE_COMMIT_REASON_HASH_CHANGED", localName: "HASH_CHANGED"}, + {no: 2, name: "CONTAINER_CACHE_COMMIT_REASON_NO_STORED_HASH", localName: "NO_STORED_HASH"}, + {no: 3, name: "CONTAINER_CACHE_COMMIT_REASON_FORCED_MAINTENANCE", localName: "FORCED_MAINTENANCE"}, + {no: 4, name: "CONTAINER_CACHE_COMMIT_REASON_ELIDE_MAINTENANCE_FAILED", localName: "ELIDE_MAINTENANCE_FAILED"}, + {no: 5, name: "CONTAINER_CACHE_COMMIT_REASON_ELIDE_HASH_MATCH", localName: "ELIDE_HASH_MATCH"}, + {no: 6, name: "CONTAINER_CACHE_COMMIT_REASON_HASH_UNAVAILABLE", localName: "HASH_UNAVAILABLE"}, + {no: 7, name: "CONTAINER_CACHE_COMMIT_REASON_ELIDE_GC_TIMEOUT", localName: "ELIDE_GC_TIMEOUT"}, + ], +); + +/** + * ContainerCacheCommitPostOutcome is the result of the guest's + * /commit-docker-container POST to the host agent. + * + * @generated from enum stickydisk.v1.ContainerCacheCommitPostOutcome + */ +const ContainerCacheCommitPostOutcome = /*@__PURE__*/ proto3.makeEnum( + "stickydisk.v1.ContainerCacheCommitPostOutcome", + [ + {no: 0, name: "CONTAINER_CACHE_COMMIT_POST_OUTCOME_UNSPECIFIED", localName: "UNSPECIFIED"}, + {no: 1, name: "CONTAINER_CACHE_COMMIT_POST_OUTCOME_OK", localName: "OK"}, + {no: 2, name: "CONTAINER_CACHE_COMMIT_POST_OUTCOME_FAILED", localName: "FAILED"}, + {no: 3, name: "CONTAINER_CACHE_COMMIT_POST_OUTCOME_SKIPPED_MISSING_IDENTITY", localName: "SKIPPED_MISSING_IDENTITY"}, + ], +); + +/** + * ContainerServeClass classifies how a container's image was served. + * + * @generated from enum stickydisk.v1.ContainerServeClass + */ +const ContainerServeClass = /*@__PURE__*/ proto3.makeEnum( + "stickydisk.v1.ContainerServeClass", + [ + {no: 0, name: "CONTAINER_SERVE_CLASS_UNSPECIFIED", localName: "UNSPECIFIED"}, + {no: 1, name: "CONTAINER_SERVE_CLASS_CACHED", localName: "CACHED"}, + {no: 2, name: "CONTAINER_SERVE_CLASS_PULLED_THIS_JOB", localName: "PULLED_THIS_JOB"}, + {no: 3, name: "CONTAINER_SERVE_CLASS_UNKNOWN", localName: "UNKNOWN"}, + ], +); + /** * @generated from message stickydisk.v1.GetStickyDiskRequest */ @@ -66185,6 +66228,7 @@ const GetStickyDiskResponse = /*@__PURE__*/ proto3.makeMessageType( { no: 5, name: "buildkitd_config", kind: "message", T: BuildkitdConfig }, { no: 6, name: "commit_early_deny", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 7, name: "commit_early_deny_reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "fstrim_timeout_secs", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, ], ); @@ -66365,6 +66409,12 @@ const DockerJobLifecycle = /*@__PURE__*/ proto3.makeMessageType( { no: 19, name: "buildkitd_sigkill_used", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 20, name: "history_export_timed_out", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 21, name: "history_prune_failed", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 22, name: "timeline_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 23, name: "timeline_truncated", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 24, name: "history_export_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 25, name: "traces_dropped_oversize", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 26, name: "traces_dropped_payload_cap", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 27, name: "records_dropped_payload_cap", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, ], ); @@ -66378,6 +66428,103 @@ const ReportDockerBuildResponse = /*@__PURE__*/ proto3.makeMessageType( ], ); +/** + * ContainerStartSpan is one container's create/start/die timing from the + * docker events stream, the read-side cost signal of the cache: docker + * pulls before create, so create-to-start never includes registry time for + * either class; it is the time to bring the container up from the layers + * on disk. Cached layers are cold reads from the Ceph-backed cache disk, + * pulled ones were just written and are page-cache warm, so the + * cached-vs-pulled split isolates the cache disk's read tax (the avoided + * pull is sized separately from image bytes). + * + * @generated from message stickydisk.v1.ContainerStartSpan + */ +const ContainerStartSpan = /*@__PURE__*/ proto3.makeMessageType( + "stickydisk.v1.ContainerStartSpan", + () => [ + { no: 1, name: "container_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "image_ref", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "image_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "serve_class", kind: "enum", T: proto3.getEnumType(ContainerServeClass) }, + { no: 5, name: "created_at_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 6, name: "started_at_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 7, name: "died_at_unix_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + ], +); + +/** + * DockerContainerCacheLifecycle is the per-job container cache lifecycle + * summary: activation, boot-path costs, manifest maintenance results, + * working-set composition, shutdown, and the commit funnel. + * + * @generated from message stickydisk.v1.DockerContainerCacheLifecycle + */ +const DockerContainerCacheLifecycle = /*@__PURE__*/ proto3.makeMessageType( + "stickydisk.v1.DockerContainerCacheLifecycle", + () => [ + { no: 1, name: "setup_outcome", kind: "enum", T: proto3.getEnumType(ContainerCacheSetupOutcome) }, + { no: 2, name: "setup_duration_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 3, name: "first_format", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 4, name: "daemon_start_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 5, name: "cleanup_duration_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 6, name: "manifest_update_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 7, name: "manifest_update_failed", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 8, name: "manifest_rebuilt", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 9, name: "manifest_migrated", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 10, name: "overlay_gc_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 11, name: "overlay_gc_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 12, name: "shutdown_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 13, name: "working_set_images", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 14, name: "working_set_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 15, name: "probation_images", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 16, name: "probation_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 17, name: "promoted_images", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 18, name: "promoted_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 19, name: "evicted_images", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 20, name: "evicted_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 21, name: "evicted_probation_images", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 22, name: "evicted_probation_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 23, name: "reclaimable_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 24, name: "total_refs", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 25, name: "pruned_refs", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 26, name: "material_prune", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 27, name: "fs_used_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 28, name: "fs_size_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 29, name: "image_layer_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 31, name: "commit_reason", kind: "enum", T: proto3.getEnumType(ContainerCacheCommitReason) }, + { no: 32, name: "commit_post_outcome", kind: "enum", T: proto3.getEnumType(ContainerCacheCommitPostOutcome) }, + { no: 33, name: "current_container_hash", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 34, name: "stored_container_hash", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 35, name: "carryover_probation_images", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 36, name: "planned_prunes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 37, name: "maintenance_budget_exhausted", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 38, name: "used_log_truncated", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ], +); + +/** + * @generated from message stickydisk.v1.ReportDockerContainerCacheRequest + */ +const ReportDockerContainerCacheRequest = /*@__PURE__*/ proto3.makeMessageType( + "stickydisk.v1.ReportDockerContainerCacheRequest", + () => [ + { no: 1, name: "vm_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "expose_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "lifecycle", kind: "message", T: DockerContainerCacheLifecycle }, + { no: 4, name: "container_starts", kind: "message", T: ContainerStartSpan, repeated: true }, + { no: 5, name: "runner_step_timeline", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + ], +); + +/** + * @generated from message stickydisk.v1.ReportDockerContainerCacheResponse + */ +const ReportDockerContainerCacheResponse = /*@__PURE__*/ proto3.makeMessageType( + "stickydisk.v1.ReportDockerContainerCacheResponse", + [], +); + ;// CONCATENATED MODULE: ./node_modules/@bufbuild/protobuf/dist/esm/service-type.js // Copyright 2021-2024 Buf Technologies, Inc. @@ -66499,6 +66646,23 @@ const StickyDiskService = { O: ReportDockerBuildResponse, kind: MethodKind.Unary, }, + /** + * ReportDockerContainerCache is the structured docker container cache + * teardown report from the guest's job-completed hook: one lifecycle + * summary per job (setup/activation, manifest maintenance, eviction, + * shutdown, commit decision) plus per-container start spans captured from + * docker events. The guest ships only facts plus the expose_id from + * activation; all identity (sticky disk key, entity, installation, region, + * host, GitHub run/job IDs) is stamped host-side. + * + * @generated from rpc stickydisk.v1.StickyDiskService.ReportDockerContainerCache + */ + reportDockerContainerCache: { + name: "ReportDockerContainerCache", + I: ReportDockerContainerCacheRequest, + O: ReportDockerContainerCacheResponse, + kind: MethodKind.Unary, + }, } }; diff --git a/package-lock.json b/package-lock.json index 7129c5b..39bccd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "@actions/github": "^6.0.0", "@actions/io": "^1.1.3", "@actions/tool-cache": "^2.0.1", - "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20260904133914-c6beb298c70c.2", - "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20260904133914-c6beb298c70c.2", + "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20261022235850-0cca692559b2.2", + "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20261022235850-0cca692559b2.2", "@bufbuild/protobuf": "^1.9.0", "@connectrpc/connect": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", @@ -628,25 +628,25 @@ "dev": true }, "node_modules/@buf/blacksmith_vm-agent.bufbuild_es": { - "version": "1.9.0-20260904133914-c6beb298c70c.2", - "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.9.0-20260904133914-c6beb298c70c.2.tgz", + "version": "1.9.0-20261022235850-0cca692559b2.2", + "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.tgz", "peerDependencies": { "@bufbuild/protobuf": "^1.9.0" } }, "node_modules/@buf/blacksmith_vm-agent.connectrpc_es": { - "version": "1.6.1-20260904133914-c6beb298c70c.2", - "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20260904133914-c6beb298c70c.2.tgz", + "version": "1.6.1-20261022235850-0cca692559b2.2", + "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20261022235850-0cca692559b2.2.tgz", "dependencies": { - "@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20260904133914-c6beb298c70c.1" + "@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20261022235850-0cca692559b2.1" }, "peerDependencies": { "@connectrpc/connect": "^1.6.1" } }, "node_modules/@buf/blacksmith_vm-agent.connectrpc_es/node_modules/@buf/blacksmith_vm-agent.bufbuild_es": { - "version": "1.10.0-20260904133914-c6beb298c70c.1", - "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20260904133914-c6beb298c70c.1.tgz", + "version": "1.10.0-20261022235850-0cca692559b2.1", + "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.tgz", "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } diff --git a/package.json b/package.json index be0a7a6..2e4ca42 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "@actions/github": "^6.0.0", "@actions/io": "^1.1.3", "@actions/tool-cache": "^2.0.1", - "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20260904133914-c6beb298c70c.2", - "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20260904133914-c6beb298c70c.2", + "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20261022235850-0cca692559b2.2", + "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20261022235850-0cca692559b2.2", "@bufbuild/protobuf": "^1.9.0", "@connectrpc/connect": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index 2f12abd..4b1223d 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -396,10 +396,7 @@ export async function setupCache( const commitEarlyDenyReason = response.commitEarlyDeny ? response.commitEarlyDenyReason || 'denied by host policy' : '' - const fstrimTimeoutSecs = fstrimTimeoutSecsFrom( - response, - response.getType().runtime.bin.listUnknownFields(response) - ) + const fstrimTimeoutSecs = parseFstrimTimeoutSecs(response.fstrimTimeoutSecs) if (commitEarlyDenyReason) { core.notice( `[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.` @@ -2337,48 +2334,6 @@ export function parseFstrimTimeoutSecs(value: unknown): number { return Math.floor(n) } -const FSTRIM_TIMEOUT_FIELD_NO = 8 -const WIRE_TYPE_VARINT = 0 - -export interface UnknownProtoField { - no: number - wireType: number - data: Uint8Array -} - -/** Decodes an unsigned LEB128 varint; null when malformed or above 2^53. */ -export function decodeVarint(data: Uint8Array): number | null { - let value = 0 - let shift = 0 - for (let i = 0; i < data.length; i++) { - const byte = data[i] - value += (byte & 0x7f) * 2 ** shift - if ((byte & 0x80) === 0) { - return i === data.length - 1 && Number.isSafeInteger(value) ? value : null - } - shift += 7 - } - return null -} - -/** - * GetStickyDiskResponse.fstrim_timeout_secs as whole seconds, whether the - * bundled proto package knows the field (typed int64) or predates it: the - * gRPC transport decodes binary and protobuf-es keeps unknown fields, so a - * release built before the field existed still honours the host budget. - */ -export function fstrimTimeoutSecsFrom( - response: object, - unknownFields: readonly UnknownProtoField[] -): number { - const typed = (response as {fstrimTimeoutSecs?: unknown}).fstrimTimeoutSecs - if (typed !== undefined) return parseFstrimTimeoutSecs(typed) - const field = unknownFields.find( - f => f.no === FSTRIM_TIMEOUT_FIELD_NO && f.wireType === WIRE_TYPE_VARINT - ) - return field ? parseFstrimTimeoutSecs(decodeVarint(field.data)) : 0 -} - /** * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing From cfece2195adcf80fc62d1058df3626a33b5b76d9 Mon Sep 17 00:00:00 2001 From: sbauer Date: Fri, 23 Oct 2026 00:13:34 +0000 Subject: [PATCH 5/9] licenses: refresh cached records for the regenerated proto packages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ....10.0-20260904133914-c6beb298c70c.1.dep.yml | 17 ----------------- ....10.0-20261022235850-0cca692559b2.1.dep.yml | 10 ++++++++++ ...1.9.0-20260904133914-c6beb298c70c.2.dep.yml | 17 ----------------- ...1.9.0-20261022235850-0cca692559b2.2.dep.yml | 10 ++++++++++ .../blacksmith_vm-agent.connectrpc_es.dep.yml | 18 ++++++------------ 5 files changed, 26 insertions(+), 46 deletions(-) delete mode 100644 .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20260904133914-c6beb298c70c.1.dep.yml create mode 100644 .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml delete mode 100644 .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20260904133914-c6beb298c70c.2.dep.yml create mode 100644 .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20260904133914-c6beb298c70c.1.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20260904133914-c6beb298c70c.1.dep.yml deleted file mode 100644 index 888c671..0000000 --- a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20260904133914-c6beb298c70c.1.dep.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "@buf/blacksmith_vm-agent.bufbuild_es" -version: 1.10.0-20260904133914-c6beb298c70c.1 -type: npm -summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:c6beb298c70c4bfcb38cf72e37c67c91 - when generated with the plugin buf.build/bufbuild/es:v1.10.0 revision:1 -homepage: -license: apache-2.0 -licenses: -- sources: Auto-generated protobuf code from buf.build - Apache 2.0 licensed - text: | - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 -notices: [] diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml new file mode 100644 index 0000000..6862244 --- /dev/null +++ b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml @@ -0,0 +1,10 @@ +--- +name: "@buf/blacksmith_vm-agent.bufbuild_es" +version: 1.10.0-20261022235850-0cca692559b2.1 +type: npm +summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 + when generated with the plugin buf.build/bufbuild/es:v1.10.0 revision:1 +homepage: +license: none +licenses: [] +notices: [] diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20260904133914-c6beb298c70c.2.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20260904133914-c6beb298c70c.2.dep.yml deleted file mode 100644 index b491026..0000000 --- a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20260904133914-c6beb298c70c.2.dep.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "@buf/blacksmith_vm-agent.bufbuild_es" -version: 1.9.0-20260904133914-c6beb298c70c.2 -type: npm -summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:c6beb298c70c4bfcb38cf72e37c67c91 - when generated with the plugin buf.build/bufbuild/es:v1.9.0 revision:2 -homepage: -license: apache-2.0 -licenses: -- sources: Auto-generated protobuf code from buf.build - Apache 2.0 licensed - text: | - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 -notices: [] diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml new file mode 100644 index 0000000..e2cb08c --- /dev/null +++ b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml @@ -0,0 +1,10 @@ +--- +name: "@buf/blacksmith_vm-agent.bufbuild_es" +version: 1.9.0-20261022235850-0cca692559b2.2 +type: npm +summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 + when generated with the plugin buf.build/bufbuild/es:v1.9.0 revision:2 +homepage: +license: none +licenses: [] +notices: [] diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml index 8dbbec0..05d5fe1 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml @@ -1,17 +1,11 @@ --- name: "@buf/blacksmith_vm-agent.connectrpc_es" -version: 1.6.1-20260904133914-c6beb298c70c.2 +version: 1.6.1-20261022235850-0cca692559b2.2 type: npm -summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:c6beb298c70c4bfcb38cf72e37c67c91 +summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 when generated with the plugin buf.build/connectrpc/es:v1.6.1 revision:2 -homepage: -license: apache-2.0 -licenses: -- sources: Auto-generated protobuf code from buf.build - Apache 2.0 licensed - text: | - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +homepage: +license: none +review_changed_license: true +licenses: [] notices: [] From 8def6b602a7f7ba2bf39b71e0be96e76cd2f906f Mon Sep 17 00:00:00 2001 From: sbauer Date: Fri, 23 Oct 2026 00:18:28 +0000 Subject: [PATCH 6/9] licenses: carry the Apache-2.0 record over to the regenerated proto packages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...es-1.10.0-20261022235850-0cca692559b2.1.dep.yml | 13 ++++++++++--- ..._es-1.9.0-20261022235850-0cca692559b2.2.dep.yml | 13 ++++++++++--- .../@buf/blacksmith_vm-agent.connectrpc_es.dep.yml | 14 ++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml index 6862244..0cc26a0 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml @@ -4,7 +4,14 @@ version: 1.10.0-20261022235850-0cca692559b2.1 type: npm summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 when generated with the plugin buf.build/bufbuild/es:v1.10.0 revision:1 -homepage: -license: none -licenses: [] +homepage: +license: apache-2.0 +licenses: +- sources: Auto-generated protobuf code from buf.build - Apache 2.0 licensed + text: | + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 notices: [] diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml index e2cb08c..0f1d1c4 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml @@ -4,7 +4,14 @@ version: 1.9.0-20261022235850-0cca692559b2.2 type: npm summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 when generated with the plugin buf.build/bufbuild/es:v1.9.0 revision:2 -homepage: -license: none -licenses: [] +homepage: +license: apache-2.0 +licenses: +- sources: Auto-generated protobuf code from buf.build - Apache 2.0 licensed + text: | + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 notices: [] diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml index 05d5fe1..edd8c86 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml @@ -4,8 +4,14 @@ version: 1.6.1-20261022235850-0cca692559b2.2 type: npm summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 when generated with the plugin buf.build/connectrpc/es:v1.6.1 revision:2 -homepage: -license: none -review_changed_license: true -licenses: [] +homepage: +license: apache-2.0 +licenses: +- sources: Auto-generated protobuf code from buf.build - Apache 2.0 licensed + text: | + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 notices: [] From 4df0bc6d753a76600e5b50f60460c343d14cb56c Mon Sep 17 00:00:00 2001 From: sbauer Date: Fri, 23 Oct 2026 03:37:12 +0000 Subject: [PATCH 7/9] git-mirror: pass the host fstrim minimum free-extent size as fstrim -m GetStickyDiskResponse.fstrim_min_extent_bytes (fa #6823) is saved as action state next to the timeout budget and, when positive, becomes `fstrim -m ` so free extents below the host's threshold are not discarded. Zero or absent leaves fstrim at its default. Bumps the @buf vm-agent packages to the fa main build that carries field 9 and regenerates their licensed records. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...0.0-20261023032817-36eedc7ae378.1.dep.yml} | 4 +- ...9.0-20261023032817-36eedc7ae378.2.dep.yml} | 4 +- .../blacksmith_vm-agent.connectrpc_es.dep.yml | 4 +- __test__/blacksmith-cache.test.ts | 10 ++++ dist/index.js | 54 ++++++++++++++++--- package-lock.json | 18 +++---- package.json | 4 +- src/blacksmith-cache.ts | 45 +++++++++++++--- src/git-source-provider.ts | 3 ++ src/main.ts | 3 +- src/state-helper.ts | 16 ++++++ 11 files changed, 133 insertions(+), 32 deletions(-) rename .licenses/npm/@buf/{blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml => blacksmith_vm-agent.bufbuild_es-1.10.0-20261023032817-36eedc7ae378.1.dep.yml} (83%) rename .licenses/npm/@buf/{blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml => blacksmith_vm-agent.bufbuild_es-1.9.0-20261023032817-36eedc7ae378.2.dep.yml} (83%) diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261023032817-36eedc7ae378.1.dep.yml similarity index 83% rename from .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml rename to .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261023032817-36eedc7ae378.1.dep.yml index 0cc26a0..5a70426 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.10.0-20261023032817-36eedc7ae378.1.dep.yml @@ -1,8 +1,8 @@ --- name: "@buf/blacksmith_vm-agent.bufbuild_es" -version: 1.10.0-20261022235850-0cca692559b2.1 +version: 1.10.0-20261023032817-36eedc7ae378.1 type: npm -summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 +summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:36eedc7ae3784f628eaef54d72088d60 when generated with the plugin buf.build/bufbuild/es:v1.10.0 revision:1 homepage: license: apache-2.0 diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261023032817-36eedc7ae378.2.dep.yml similarity index 83% rename from .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml rename to .licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261023032817-36eedc7ae378.2.dep.yml index 0f1d1c4..8b16c1e 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.bufbuild_es-1.9.0-20261023032817-36eedc7ae378.2.dep.yml @@ -1,8 +1,8 @@ --- name: "@buf/blacksmith_vm-agent.bufbuild_es" -version: 1.9.0-20261022235850-0cca692559b2.2 +version: 1.9.0-20261023032817-36eedc7ae378.2 type: npm -summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 +summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:36eedc7ae3784f628eaef54d72088d60 when generated with the plugin buf.build/bufbuild/es:v1.9.0 revision:2 homepage: license: apache-2.0 diff --git a/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml b/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml index edd8c86..a095e0f 100644 --- a/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml +++ b/.licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml @@ -1,8 +1,8 @@ --- name: "@buf/blacksmith_vm-agent.connectrpc_es" -version: 1.6.1-20261022235850-0cca692559b2.2 +version: 1.6.1-20261023032817-36eedc7ae378.2 type: npm -summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:0cca692559b244a9abe9cefb5aadd294 +summary: This package contains generated code for the protobuf module buf.build/blacksmith/vm-agent:36eedc7ae3784f628eaef54d72088d60 when generated with the plugin buf.build/connectrpc/es:v1.6.1 revision:2 homepage: license: apache-2.0 diff --git a/__test__/blacksmith-cache.test.ts b/__test__/blacksmith-cache.test.ts index 85dc9e3..acc9b03 100644 --- a/__test__/blacksmith-cache.test.ts +++ b/__test__/blacksmith-cache.test.ts @@ -22,6 +22,16 @@ jest.mock('../src/container-detector', () => ({ import * as blacksmithCache from '../src/blacksmith-cache' +describe('parseFstrimMinExtentBytes', () => { + it('accepts the RPC bigint or saved-state string and maps junk to 0', () => { + expect(blacksmithCache.parseFstrimMinExtentBytes(BigInt(65536))).toBe(65536) + expect(blacksmithCache.parseFstrimMinExtentBytes('4194304')).toBe(4194304) + expect(blacksmithCache.parseFstrimMinExtentBytes(BigInt(0))).toBe(0) + expect(blacksmithCache.parseFstrimMinExtentBytes(-1)).toBe(0) + expect(blacksmithCache.parseFstrimMinExtentBytes(undefined)).toBe(0) + }) +}) + describe('parseFstrimTimeoutSecs', () => { it('accepts the RPC bigint, a number, or the saved-state string', () => { expect(blacksmithCache.parseFstrimTimeoutSecs(BigInt(5))).toBe(5) diff --git a/dist/index.js b/dist/index.js index 24be13b..0d031c6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -70,6 +70,7 @@ exports.hasCommitGraph = hasCommitGraph; exports.markKeepPacks = markKeepPacks; exports.runMirrorMaintenance = runMirrorMaintenance; exports.parseFstrimTimeoutSecs = parseFstrimTimeoutSecs; +exports.parseFstrimMinExtentBytes = parseFstrimMinExtentBytes; exports.cleanup = cleanup; const core = __importStar(__nccwpck_require__(2186)); const exec = __importStar(__nccwpck_require__(1514)); @@ -346,7 +347,8 @@ function setupCache(owner, repo) { hydrationMessage, performedHydration: false, commitEarlyDenyReason: '', - fstrimTimeoutSecs: 0 + fstrimTimeoutSecs: 0, + fstrimMinExtentBytes: 0 }; } // Re-throw other errors @@ -368,6 +370,7 @@ function setupCache(owner, repo) { ? response.commitEarlyDenyReason || 'denied by host policy' : ''; const fstrimTimeoutSecs = parseFstrimTimeoutSecs(response.fstrimTimeoutSecs); + const fstrimMinExtentBytes = parseFstrimMinExtentBytes(response.fstrimMinExtentBytes); if (commitEarlyDenyReason) { core.notice(`[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.`); } @@ -391,7 +394,8 @@ function setupCache(owner, repo) { hydrationInProgress: false, performedHydration: false, // Will be set by ensureMirror if we do initial clone commitEarlyDenyReason, - fstrimTimeoutSecs + fstrimTimeoutSecs, + fstrimMinExtentBytes }; }); } @@ -1952,17 +1956,33 @@ function parseFstrimTimeoutSecs(value) { return 0; return Math.floor(n); } +/** + * Parse the host's `fstrim --minimum` value + * (GetStickyDiskResponse.fstrim_min_extent_bytes, a bigint or saved-state + * string) into whole bytes. Anything unusable is 0: the fstrim default. + */ +function parseFstrimMinExtentBytes(value) { + return parseFstrimTimeoutSecs(value); +} /** * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing * store until the guest discards them. Bounded by the host-supplied budget; * any failure (discard not negotiated, missing binary, timeout) only logs. */ -function trimFilesystem(mountPoint, timeoutSecs) { +function trimFilesystem(mountPoint, timeoutSecs, minExtentBytes) { return __awaiter(this, void 0, void 0, function* () { const start = Date.now(); try { - const result = yield exec.getExecOutput('timeout', [String(timeoutSecs), 'sudo', 'fstrim', '-v', '--', mountPoint], { ignoreReturnCode: true, silent: true }); + const result = yield exec.getExecOutput('timeout', [ + String(timeoutSecs), + 'sudo', + 'fstrim', + '-v', + ...(minExtentBytes > 0 ? ['-m', String(minExtentBytes)] : []), + '--', + mountPoint + ], { ignoreReturnCode: true, silent: true }); const durationMs = Date.now() - start; if (result.exitCode === 0) { core.info(`[git-mirror] fstrim ${mountPoint}: ${result.stdout.trim() || 'done'} in ${durationMs}ms`); @@ -1989,7 +2009,7 @@ function trimFilesystem(mountPoint, timeoutSecs) { function cleanup(options) { return __awaiter(this, void 0, void 0, function* () { var _a; - const { exposeId, stickyDiskKey, repoName, mountPoint, mirrorPath, mirrorSyncFailed, mirrorSyncTimedOut, vmHydratedGitMirror, fstrimTimeoutSecs } = options; + const { exposeId, stickyDiskKey, repoName, mountPoint, mirrorPath, mirrorSyncFailed, mirrorSyncTimedOut, vmHydratedGitMirror, fstrimTimeoutSecs, fstrimMinExtentBytes } = options; let { shouldCommit } = options; const result = { // skipped until maintenance actually runs, so a mirror that is not @@ -2030,7 +2050,7 @@ function cleanup(options) { shouldCommit && fstrimTimeoutSecs && fstrimTimeoutSecs > 0) { - yield trimFilesystem(mountPoint, fstrimTimeoutSecs); + yield trimFilesystem(mountPoint, fstrimTimeoutSecs, fstrimMinExtentBytes !== null && fstrimMinExtentBytes !== void 0 ? fstrimMinExtentBytes : 0); } // Get device path before unmount for durability flush let devicePath = null; @@ -3780,6 +3800,7 @@ function getSourceInner(settings, report, onHydrationReport) { stateHelper.setBlacksmithCacheMountPoint(cacheInfo.mountPoint); stateHelper.setBlacksmithCacheCommitEarlyDenyReason(cacheInfo.commitEarlyDenyReason); stateHelper.setBlacksmithCacheFstrimTimeoutSecs(cacheInfo.fstrimTimeoutSecs); + stateHelper.setBlacksmithCacheFstrimMinExtentBytes(cacheInfo.fstrimMinExtentBytes); const mirrorExisted = fsHelper.directoryExistsSync(cacheInfo.mirrorPath); const cloneStart = Date.now(); let performedHydration = false; @@ -4874,7 +4895,8 @@ function cleanup() { vmHydratedGitMirror: performedHydration, mirrorSyncFailed, mirrorSyncTimedOut, - fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs + fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs, + fstrimMinExtentBytes: stateHelper.BlacksmithCacheFstrimMinExtentBytes }); } catch (error) { @@ -5578,7 +5600,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.BlacksmithCacheFstrimTimeoutSecs = exports.BlacksmithCacheCommitEarlyDenyReason = exports.BlacksmithCacheVerbose = exports.BlacksmithCacheRepoUrl = exports.BlacksmithCacheMirrorSyncDeferred = exports.BlacksmithCacheMirrorSyncTimedOut = exports.BlacksmithCacheMirrorSyncFailed = exports.BlacksmithCacheMirrorChanged = exports.BlacksmithCachePerformedHydration = exports.BlacksmithCacheStickyDiskKey = exports.BlacksmithCacheRepoName = exports.BlacksmithCacheMountPoint = exports.BlacksmithCacheMirrorPath = exports.BlacksmithCacheExposeId = exports.SshKnownHostsPath = exports.SshKeyPath = exports.PostSetSafeDirectory = exports.RepositoryPath = exports.IsPost = void 0; +exports.BlacksmithCacheFstrimMinExtentBytes = exports.BlacksmithCacheFstrimTimeoutSecs = exports.BlacksmithCacheCommitEarlyDenyReason = exports.BlacksmithCacheVerbose = exports.BlacksmithCacheRepoUrl = exports.BlacksmithCacheMirrorSyncDeferred = exports.BlacksmithCacheMirrorSyncTimedOut = exports.BlacksmithCacheMirrorSyncFailed = exports.BlacksmithCacheMirrorChanged = exports.BlacksmithCachePerformedHydration = exports.BlacksmithCacheStickyDiskKey = exports.BlacksmithCacheRepoName = exports.BlacksmithCacheMountPoint = exports.BlacksmithCacheMirrorPath = exports.BlacksmithCacheExposeId = exports.SshKnownHostsPath = exports.SshKeyPath = exports.PostSetSafeDirectory = exports.RepositoryPath = exports.IsPost = void 0; exports.setRepositoryPath = setRepositoryPath; exports.setSshKeyPath = setSshKeyPath; exports.setSshKnownHostsPath = setSshKnownHostsPath; @@ -5597,6 +5619,7 @@ exports.setBlacksmithCacheRepoUrl = setBlacksmithCacheRepoUrl; exports.setBlacksmithCacheVerbose = setBlacksmithCacheVerbose; exports.setBlacksmithCacheCommitEarlyDenyReason = setBlacksmithCacheCommitEarlyDenyReason; exports.setBlacksmithCacheFstrimTimeoutSecs = setBlacksmithCacheFstrimTimeoutSecs; +exports.setBlacksmithCacheFstrimMinExtentBytes = setBlacksmithCacheFstrimMinExtentBytes; const core = __importStar(__nccwpck_require__(2186)); /** * Indicates whether the POST action is running @@ -5680,6 +5703,14 @@ exports.BlacksmithCacheFstrimTimeoutSecs = (() => { const secs = Number(core.getState('blacksmithCacheFstrimTimeoutSecs') || '0'); return Number.isFinite(secs) && secs > 0 ? Math.floor(secs) : 0; })(); +/** + * The host's `fstrim -m` value in bytes, saved by the main action. 0 when + * the host left fstrim at its default minimum extent. + */ +exports.BlacksmithCacheFstrimMinExtentBytes = (() => { + const n = Number(core.getState('blacksmithCacheFstrimMinExtentBytes') || '0'); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +})(); /** * Save the repository path so the POST action can retrieve the value. */ @@ -5787,6 +5818,12 @@ function setBlacksmithCacheCommitEarlyDenyReason(reason) { function setBlacksmithCacheFstrimTimeoutSecs(secs) { core.saveState('blacksmithCacheFstrimTimeoutSecs', String(secs)); } +/** + * Save the host's `fstrim -m` value so the POST action can pass it along. + */ +function setBlacksmithCacheFstrimMinExtentBytes(bytes) { + core.saveState('blacksmithCacheFstrimMinExtentBytes', String(bytes)); +} // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic. // This is necessary since we don't have a separate entry point. if (!exports.IsPost) { @@ -66229,6 +66266,7 @@ const GetStickyDiskResponse = /*@__PURE__*/ proto3.makeMessageType( { no: 6, name: "commit_early_deny", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 7, name: "commit_early_deny_reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 8, name: "fstrim_timeout_secs", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 9, name: "fstrim_min_extent_bytes", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, ], ); diff --git a/package-lock.json b/package-lock.json index 39bccd0..f90321e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "@actions/github": "^6.0.0", "@actions/io": "^1.1.3", "@actions/tool-cache": "^2.0.1", - "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20261022235850-0cca692559b2.2", - "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20261022235850-0cca692559b2.2", + "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20261023032817-36eedc7ae378.2", + "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20261023032817-36eedc7ae378.2", "@bufbuild/protobuf": "^1.9.0", "@connectrpc/connect": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", @@ -628,25 +628,25 @@ "dev": true }, "node_modules/@buf/blacksmith_vm-agent.bufbuild_es": { - "version": "1.9.0-20261022235850-0cca692559b2.2", - "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.9.0-20261022235850-0cca692559b2.2.tgz", + "version": "1.9.0-20261023032817-36eedc7ae378.2", + "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.9.0-20261023032817-36eedc7ae378.2.tgz", "peerDependencies": { "@bufbuild/protobuf": "^1.9.0" } }, "node_modules/@buf/blacksmith_vm-agent.connectrpc_es": { - "version": "1.6.1-20261022235850-0cca692559b2.2", - "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20261022235850-0cca692559b2.2.tgz", + "version": "1.6.1-20261023032817-36eedc7ae378.2", + "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20261023032817-36eedc7ae378.2.tgz", "dependencies": { - "@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20261022235850-0cca692559b2.1" + "@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20261023032817-36eedc7ae378.1" }, "peerDependencies": { "@connectrpc/connect": "^1.6.1" } }, "node_modules/@buf/blacksmith_vm-agent.connectrpc_es/node_modules/@buf/blacksmith_vm-agent.bufbuild_es": { - "version": "1.10.0-20261022235850-0cca692559b2.1", - "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20261022235850-0cca692559b2.1.tgz", + "version": "1.10.0-20261023032817-36eedc7ae378.1", + "resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20261023032817-36eedc7ae378.1.tgz", "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } diff --git a/package.json b/package.json index 2e4ca42..81951dd 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "@actions/github": "^6.0.0", "@actions/io": "^1.1.3", "@actions/tool-cache": "^2.0.1", - "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20261022235850-0cca692559b2.2", - "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20261022235850-0cca692559b2.2", + "@buf/blacksmith_vm-agent.bufbuild_es": "1.9.0-20261023032817-36eedc7ae378.2", + "@buf/blacksmith_vm-agent.connectrpc_es": "1.6.1-20261023032817-36eedc7ae378.2", "@bufbuild/protobuf": "^1.9.0", "@connectrpc/connect": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index 4b1223d..2664275 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -120,6 +120,9 @@ export interface CacheInfo { // mirror filesystem before unmount at commit; 0 when the host has not // enabled the trim. fstrimTimeoutSecs: number + // fstrimMinExtentBytes is the host's `fstrim -m` value in bytes; 0 means + // the fstrim default (every free extent). + fstrimMinExtentBytes: number } /** @@ -369,7 +372,8 @@ export async function setupCache( hydrationMessage, performedHydration: false, commitEarlyDenyReason: '', - fstrimTimeoutSecs: 0 + fstrimTimeoutSecs: 0, + fstrimMinExtentBytes: 0 } } // Re-throw other errors @@ -397,6 +401,9 @@ export async function setupCache( ? response.commitEarlyDenyReason || 'denied by host policy' : '' const fstrimTimeoutSecs = parseFstrimTimeoutSecs(response.fstrimTimeoutSecs) + const fstrimMinExtentBytes = parseFstrimMinExtentBytes( + response.fstrimMinExtentBytes + ) if (commitEarlyDenyReason) { core.notice( `[git-mirror] Sticky disk changes will not be committed for this job (${commitEarlyDenyReason}). The git mirror cache is used as-is and any changes to it are discarded.` @@ -425,7 +432,8 @@ export async function setupCache( hydrationInProgress: false, performedHydration: false, // Will be set by ensureMirror if we do initial clone commitEarlyDenyReason, - fstrimTimeoutSecs + fstrimTimeoutSecs, + fstrimMinExtentBytes } } @@ -2334,6 +2342,15 @@ export function parseFstrimTimeoutSecs(value: unknown): number { return Math.floor(n) } +/** + * Parse the host's `fstrim --minimum` value + * (GetStickyDiskResponse.fstrim_min_extent_bytes, a bigint or saved-state + * string) into whole bytes. Anything unusable is 0: the fstrim default. + */ +export function parseFstrimMinExtentBytes(value: unknown): number { + return parseFstrimTimeoutSecs(value) +} + /** * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing @@ -2342,13 +2359,22 @@ export function parseFstrimTimeoutSecs(value: unknown): number { */ async function trimFilesystem( mountPoint: string, - timeoutSecs: number + timeoutSecs: number, + minExtentBytes: number ): Promise { const start = Date.now() try { const result = await exec.getExecOutput( 'timeout', - [String(timeoutSecs), 'sudo', 'fstrim', '-v', '--', mountPoint], + [ + String(timeoutSecs), + 'sudo', + 'fstrim', + '-v', + ...(minExtentBytes > 0 ? ['-m', String(minExtentBytes)] : []), + '--', + mountPoint + ], {ignoreReturnCode: true, silent: true} ) const durationMs = Date.now() - start @@ -2391,6 +2417,8 @@ export interface CleanupOptions { mirrorSyncTimedOut?: boolean // Host-supplied fstrim budget in whole seconds; 0 or absent skips the trim. fstrimTimeoutSecs?: number + // Host-supplied `fstrim -m` value in bytes; 0 or absent uses the default. + fstrimMinExtentBytes?: number } /** @@ -2410,7 +2438,8 @@ export async function cleanup(options: CleanupOptions): Promise { mirrorSyncFailed, mirrorSyncTimedOut, vmHydratedGitMirror, - fstrimTimeoutSecs + fstrimTimeoutSecs, + fstrimMinExtentBytes } = options let {shouldCommit} = options @@ -2463,7 +2492,11 @@ export async function cleanup(options: CleanupOptions): Promise { fstrimTimeoutSecs && fstrimTimeoutSecs > 0 ) { - await trimFilesystem(mountPoint, fstrimTimeoutSecs) + await trimFilesystem( + mountPoint, + fstrimTimeoutSecs, + fstrimMinExtentBytes ?? 0 + ) } // Get device path before unmount for durability flush diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 0f8db25..b14dfa3 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -189,6 +189,9 @@ async function getSourceInner( stateHelper.setBlacksmithCacheFstrimTimeoutSecs( cacheInfo.fstrimTimeoutSecs ) + stateHelper.setBlacksmithCacheFstrimMinExtentBytes( + cacheInfo.fstrimMinExtentBytes + ) const mirrorExisted = fsHelper.directoryExistsSync( cacheInfo.mirrorPath diff --git a/src/main.ts b/src/main.ts index d0b2497..ebb99fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -155,7 +155,8 @@ async function cleanup(): Promise { vmHydratedGitMirror: performedHydration, mirrorSyncFailed, mirrorSyncTimedOut, - fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs + fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs, + fstrimMinExtentBytes: stateHelper.BlacksmithCacheFstrimMinExtentBytes }) } catch (error) { core.warning( diff --git a/src/state-helper.ts b/src/state-helper.ts index 98c7fad..3f962ea 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -115,6 +115,15 @@ export const BlacksmithCacheFstrimTimeoutSecs = (() => { return Number.isFinite(secs) && secs > 0 ? Math.floor(secs) : 0 })() +/** + * The host's `fstrim -m` value in bytes, saved by the main action. 0 when + * the host left fstrim at its default minimum extent. + */ +export const BlacksmithCacheFstrimMinExtentBytes = (() => { + const n = Number(core.getState('blacksmithCacheFstrimMinExtentBytes') || '0') + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0 +})() + /** * Save the repository path so the POST action can retrieve the value. */ @@ -249,6 +258,13 @@ export function setBlacksmithCacheFstrimTimeoutSecs(secs: number) { core.saveState('blacksmithCacheFstrimTimeoutSecs', String(secs)) } +/** + * Save the host's `fstrim -m` value so the POST action can pass it along. + */ +export function setBlacksmithCacheFstrimMinExtentBytes(bytes: number) { + core.saveState('blacksmithCacheFstrimMinExtentBytes', String(bytes)) +} + // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic. // This is necessary since we don't have a separate entry point. if (!IsPost) { From dcf05dccf7eff0de7639aa16088f1eca363dab08 Mon Sep 17 00:00:00 2001 From: sbauer Date: Thu, 5 Nov 2026 21:14:12 +0000 Subject: [PATCH 8/9] git-mirror: run the fstrim timeout under sudo so it can stop fstrim An unprivileged timeout cannot signal the root-owned sudo/fstrim it spawned, so the host budget never bounded the trim and fstrim could outlive it and keep the mirror mount busy. sudo timeout fstrim gives timeout the privilege to kill fstrim; exit 124 still maps to the timeout outcome. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dist/index.js | 7 ++++--- src/blacksmith-cache.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/dist/index.js b/dist/index.js index 0d031c6..702611a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1968,15 +1968,16 @@ function parseFstrimMinExtentBytes(value) { * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing * store until the guest discards them. Bounded by the host-supplied budget; - * any failure (discard not negotiated, missing binary, timeout) only logs. + * `timeout` runs under sudo so it has the privilege to stop root's fstrim. + * Any failure (discard not negotiated, missing binary, timeout) only logs. */ function trimFilesystem(mountPoint, timeoutSecs, minExtentBytes) { return __awaiter(this, void 0, void 0, function* () { const start = Date.now(); try { - const result = yield exec.getExecOutput('timeout', [ + const result = yield exec.getExecOutput('sudo', [ + 'timeout', String(timeoutSecs), - 'sudo', 'fstrim', '-v', ...(minExtentBytes > 0 ? ['-m', String(minExtentBytes)] : []), diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index 2664275..3cd1057 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -2355,7 +2355,8 @@ export function parseFstrimMinExtentBytes(value: unknown): number { * Best-effort `fstrim` of the mirror filesystem before it is unmounted and * committed: deleted packs keep their RBD extents allocated on the backing * store until the guest discards them. Bounded by the host-supplied budget; - * any failure (discard not negotiated, missing binary, timeout) only logs. + * `timeout` runs under sudo so it has the privilege to stop root's fstrim. + * Any failure (discard not negotiated, missing binary, timeout) only logs. */ async function trimFilesystem( mountPoint: string, @@ -2365,10 +2366,10 @@ async function trimFilesystem( const start = Date.now() try { const result = await exec.getExecOutput( - 'timeout', + 'sudo', [ + 'timeout', String(timeoutSecs), - 'sudo', 'fstrim', '-v', ...(minExtentBytes > 0 ? ['-m', String(minExtentBytes)] : []), From 5580ba7a4c0de3b70999d9fb068f5ccf8610e760 Mon Sep 17 00:00:00 2001 From: sbauer Date: Thu, 5 Nov 2026 21:56:37 +0000 Subject: [PATCH 9/9] git-mirror: classify the trim outcome as ok/timeout/unsupported/failed Same vocabulary as setup-docker-builder and the agent's FstrimOutcome, so logs across the actions read alike and a discard that the host has not negotiated yet is not mixed in with real failures. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- dist/index.js | 35 +++++++++++++++++++++---------- src/blacksmith-cache.ts | 46 ++++++++++++++++++++++++++--------------- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/dist/index.js b/dist/index.js index 702611a..2df64f3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1974,6 +1974,8 @@ function parseFstrimMinExtentBytes(value) { function trimFilesystem(mountPoint, timeoutSecs, minExtentBytes) { return __awaiter(this, void 0, void 0, function* () { const start = Date.now(); + let outcome; + let detail; try { const result = yield exec.getExecOutput('sudo', [ 'timeout', @@ -1984,22 +1986,33 @@ function trimFilesystem(mountPoint, timeoutSecs, minExtentBytes) { '--', mountPoint ], { ignoreReturnCode: true, silent: true }); - const durationMs = Date.now() - start; - if (result.exitCode === 0) { - core.info(`[git-mirror] fstrim ${mountPoint}: ${result.stdout.trim() || 'done'} in ${durationMs}ms`); - } - else if (result.exitCode === TIMEOUT_EXIT_CODE) { - core.info(`[git-mirror] fstrim ${mountPoint}: timed out after ${timeoutSecs}s`); - } - else { - core.info(`[git-mirror] fstrim ${mountPoint}: failed in ${durationMs}ms: ${result.stderr.trim()}`); - } + detail = (result.stdout + result.stderr).trim(); + outcome = trimOutcome(result.exitCode, detail); } catch (error) { - core.info(`[git-mirror] fstrim ${mountPoint}: failed: ${error.message}`); + detail = error.message; + outcome = trimOutcome(error.code, detail); } + core.info(`[git-mirror] fstrim ${mountPoint}: ${outcome} in ${Date.now() - start}ms${detail ? `: ${detail}` : ''}`); }); } +// 124 is timeout(1)'s exit code; discard not negotiated by the hypervisor +// (EOPNOTSUPP) or no fstrim binary are "unsupported"; anything else "failed". +function trimOutcome(code, detail) { + if (code === 0) + return 'ok'; + if (code === TIMEOUT_EXIT_CODE) + return 'timeout'; + if (code === 127 || code === 'ENOENT') + return 'unsupported'; + const msg = detail.toLowerCase(); + if (msg.includes('not supported') || + msg.includes('command not found') || + msg.includes('no such file or directory')) { + return 'unsupported'; + } + return 'failed'; +} /** * Cleanup: run mirror maintenance, sync, unmount, and commit the sticky disk. * diff --git a/src/blacksmith-cache.ts b/src/blacksmith-cache.ts index 3cd1057..197dee6 100644 --- a/src/blacksmith-cache.ts +++ b/src/blacksmith-cache.ts @@ -2364,6 +2364,8 @@ async function trimFilesystem( minExtentBytes: number ): Promise { const start = Date.now() + let outcome: TrimOutcome + let detail: string try { const result = await exec.getExecOutput( 'sudo', @@ -2378,25 +2380,35 @@ async function trimFilesystem( ], {ignoreReturnCode: true, silent: true} ) - const durationMs = Date.now() - start - if (result.exitCode === 0) { - core.info( - `[git-mirror] fstrim ${mountPoint}: ${result.stdout.trim() || 'done'} in ${durationMs}ms` - ) - } else if (result.exitCode === TIMEOUT_EXIT_CODE) { - core.info( - `[git-mirror] fstrim ${mountPoint}: timed out after ${timeoutSecs}s` - ) - } else { - core.info( - `[git-mirror] fstrim ${mountPoint}: failed in ${durationMs}ms: ${result.stderr.trim()}` - ) - } + detail = (result.stdout + result.stderr).trim() + outcome = trimOutcome(result.exitCode, detail) } catch (error) { - core.info( - `[git-mirror] fstrim ${mountPoint}: failed: ${(error as Error).message}` - ) + detail = (error as Error).message + outcome = trimOutcome((error as {code?: unknown}).code, detail) + } + core.info( + `[git-mirror] fstrim ${mountPoint}: ${outcome} in ${Date.now() - start}ms${detail ? `: ${detail}` : ''}` + ) +} + +// Same vocabulary as setup-docker-builder and the agent's FstrimOutcome. +type TrimOutcome = 'ok' | 'timeout' | 'unsupported' | 'failed' + +// 124 is timeout(1)'s exit code; discard not negotiated by the hypervisor +// (EOPNOTSUPP) or no fstrim binary are "unsupported"; anything else "failed". +function trimOutcome(code: unknown, detail: string): TrimOutcome { + if (code === 0) return 'ok' + if (code === TIMEOUT_EXIT_CODE) return 'timeout' + if (code === 127 || code === 'ENOENT') return 'unsupported' + const msg = detail.toLowerCase() + if ( + msg.includes('not supported') || + msg.includes('command not found') || + msg.includes('no such file or directory') + ) { + return 'unsupported' } + return 'failed' } export interface CleanupOptions {