Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .licenses/npm/@buf/blacksmith_vm-agent.connectrpc_es.dep.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions __test__/blacksmith-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ 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)
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<
Expand Down
314 changes: 308 additions & 6 deletions dist/index.js

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-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",
Expand Down
113 changes: 109 additions & 4 deletions src/blacksmith-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ 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
// fstrimMinExtentBytes is the host's `fstrim -m` value in bytes; 0 means
// the fstrim default (every free extent).
fstrimMinExtentBytes: number
}

/**
Expand Down Expand Up @@ -364,7 +371,9 @@ export async function setupCache(
hydrationInProgress: true,
hydrationMessage,
performedHydration: false,
commitEarlyDenyReason: ''
commitEarlyDenyReason: '',
fstrimTimeoutSecs: 0,
fstrimMinExtentBytes: 0
}
}
// Re-throw other errors
Expand All @@ -391,6 +400,10 @@ export async function setupCache(
const commitEarlyDenyReason = response.commitEarlyDeny
? 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.`
Expand Down Expand Up @@ -418,7 +431,9 @@ 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,
fstrimMinExtentBytes
}
}

Expand Down Expand Up @@ -2315,6 +2330,74 @@ async function flushBlockDevice(devicePath: string): Promise<void> {
}
}

/**
* 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)
}

/**
* 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
* 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,
minExtentBytes: number
): Promise<void> {
const start = Date.now()
try {
const result = await 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`
)
} 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
Expand All @@ -2332,12 +2415,16 @@ 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
// Host-supplied `fstrim -m` value in bytes; 0 or absent uses the default.
fstrimMinExtentBytes?: 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.
*/
Expand All @@ -2350,7 +2437,9 @@ export async function cleanup(options: CleanupOptions): Promise<CleanupResult> {
mirrorPath,
mirrorSyncFailed,
mirrorSyncTimedOut,
vmHydratedGitMirror
vmHydratedGitMirror,
fstrimTimeoutSecs,
fstrimMinExtentBytes
} = options
let {shouldCommit} = options

Expand Down Expand Up @@ -2394,6 +2483,22 @@ export async function cleanup(options: CleanupOptions): Promise<CleanupResult> {
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,
fstrimMinExtentBytes ?? 0
)
}

// Get device path before unmount for durability flush
let devicePath: string | null = null
if (mountPoint) {
Expand Down
6 changes: 6 additions & 0 deletions src/git-source-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ async function getSourceInner(
stateHelper.setBlacksmithCacheCommitEarlyDenyReason(
cacheInfo.commitEarlyDenyReason
)
stateHelper.setBlacksmithCacheFstrimTimeoutSecs(
cacheInfo.fstrimTimeoutSecs
)
stateHelper.setBlacksmithCacheFstrimMinExtentBytes(
cacheInfo.fstrimMinExtentBytes
)

const mirrorExisted = fsHelper.directoryExistsSync(
cacheInfo.mirrorPath
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ async function cleanup(): Promise<void> {
shouldCommit,
vmHydratedGitMirror: performedHydration,
mirrorSyncFailed,
mirrorSyncTimedOut
mirrorSyncTimedOut,
fstrimTimeoutSecs: stateHelper.BlacksmithCacheFstrimTimeoutSecs,
fstrimMinExtentBytes: stateHelper.BlacksmithCacheFstrimMinExtentBytes
})
} catch (error) {
core.warning(
Expand Down
32 changes: 32 additions & 0 deletions src/state-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ 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
})()

/**
* 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.
*/
Expand Down Expand Up @@ -233,6 +251,20 @@ 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))
}

/**
* 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) {
Expand Down
Loading