Skip to content
Merged
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
27 changes: 27 additions & 0 deletions __tests__/refresh-policy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,30 @@ test('a threshold that would expire referenced layers is detectable', () => {
// ever raising BP_CACHE_REFRESH_AGE_DAYS.
assert.ok(worstCaseAgeDays(5, 2.37) > 7);
});

test('keyPrefix: keys are relative to the listed prefix (lane-scoped caches nest one level deeper)', () => {
const ages = parseS3ListAges(
ls([
'2026-08-20 00:00:00 10 org-x/lane-a/snap-ca/aaa.tar.zst',
'2026-08-20 00:00:00 10 org-x/lane-a/blobs/sha256/deadbeef',
'2026-08-20 00:00:00 10 org-x/snap-ca/unscoped.tar.zst'
]),
NOW,
'org-x/lane-a/snap-ca/'
);
// the legacy two-segment strip would have yielded "snap-ca/aaa.tar.zst" here — a key
// that never matches an --include pattern, so every object would refresh every build.
assert.deepEqual([...ages.keys()], ['aaa.tar.zst']);
});

test('keyPrefix: the unscoped prefix keeps its keys and ignores lane objects', () => {
const ages = parseS3ListAges(
ls([
'2026-08-20 00:00:00 10 org-x/blobs/sha256/deadbeef',
'2026-08-20 00:00:00 10 org-x/lane-a/blobs/sha256/cafebabe'
]),
NOW,
'org-x/blobs/'
);
assert.deepEqual([...ages.keys()], ['sha256/deadbeef']);
});
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/post.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ try {
}
core.saveState('bp_namespace', ns);
emitHydrateMetric(ns);
core.info(`BuildPulse docker builder ready (local buildkitd, tenant=${ns || 'unknown'})`);
core.info(`BuildPulse docker builder ready (local buildkitd, tenant=${ns || 'unknown'}, cache lane=${process.env.BK_CACHE_LANE || 'default'})`);
} catch (e) {
core.setFailed(`setup-docker-builder failed: ${e.message}`);
}
23 changes: 19 additions & 4 deletions src/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,25 @@ function s3Basenames(prefix, region) {
// at all (they may already be latently desynced).
// Reads run as the runner uid (commitToNvme handed it the cache); only fs/ of each snapshot
// is archived — the overlay work/ dir is transient and mode-0000.
//
// CACHE PREFIX SCOPE. A buildkitd running rootless (uid-shifted user namespace) and one
// running as root store the same layer under different on-disk ownership, so a snapshot
// committed by one and hydrated by the other can leave a non-root build stage unable to
// write into its own files (EACCES). When the runner sets BK_CACHE_LANE, this step commits
// under s3://<bucket>/<ns>/<lane>/ and the hydrate reads the same prefix; without it the key
// is unchanged, so existing tenants keep their cache and switching modes is a cold start, not
// a broken build.
const MANIFEST = 'bp-snap-manifest.json';
function commitToS3(bucket, ns, region) {
function cacheBase(bucket, ns, lane) {
if (!SAFE.test(bucket)) throw new Error(`refusing unsafe bucket name: ${bucket}`);
if (!SAFE.test(ns)) throw new Error(`refusing unsafe tenant namespace: ${ns}`);
if (lane && !SAFE.test(lane)) throw new Error(`refusing unsafe cache lane: ${lane}`);
const keyPrefix = lane ? `${ns}/${lane}/` : `${ns}/`;
return { base: `s3://${bucket}/${keyPrefix.replace(/\/$/, '')}`, keyPrefix };
}
function commitToS3(bucket, ns, region, lane) {
if (!SAFE.test(region)) throw new Error(`refusing unsafe region: ${region}`);
const base = `s3://${bucket}/${ns}`;
const { base, keyPrefix } = cacheBase(bucket, ns, lane);
const SRC = CACHE_SRC;
const snap = snapshotterDir(SRC);
const snapDir = `${SRC}/${snap}/snapshots/snapshots`;
Expand Down Expand Up @@ -323,7 +336,7 @@ function commitToS3(bucket, ns, region) {
const objectAgesDays = (pfx) => {
try {
const out = awsOut(['s3', 'ls', `${base}/${pfx}/`, '--recursive', '--region', region]).toString();
return parseS3ListAges(out, Date.now());
return parseS3ListAges(out, Date.now(), `${keyPrefix}${pfx}/`);
} catch (e) {
core.warning(`cache age listing (${pfx}) failed, refreshing every referenced object: ${e.message}`);
return null;
Expand Down Expand Up @@ -387,6 +400,8 @@ if (event === 'pull_request' && isolatePR) {

const region = process.env.AWS_REGION || 'us-west-2';
const ns = core.getState('bp_namespace') || process.env.POD_NAMESPACE || 'unknown';
// Set by the runner when its buildkitd runs in a different mode; see commitToS3.
const lane = process.env.BK_CACHE_LANE || '';

// Cap size, then Tier 1 — node-local NVMe. If tier 1 fails there is nothing to push.
pruneCache();
Expand All @@ -410,7 +425,7 @@ if (!bucket) {
const t0 = Date.now();
try {
if (!ns || ns === 'unknown') throw new Error('tenant namespace unknown');
const bytes = commitToS3(bucket, ns, region);
const bytes = commitToS3(bucket, ns, region, lane);
emitMetric('CacheCommitBytes', bytes, 'Bytes', ns, region);
emitMetric('CacheCommitSeconds', (Date.now() - t0) / 1000, 'Seconds', ns, region);
} catch (e) {
Expand Down
24 changes: 17 additions & 7 deletions src/refresh-policy.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,32 @@
* Lines look like:
* 2026-08-20 11:40:12 2023 <ns>/<prefix>/<key>
*
* The timestamp is UTC. Keys are returned relative to `<ns>/<prefix>/` so they
* match the `--include` patterns the caller builds. Unparseable lines are
* skipped rather than throwing: a listing that is partly unreadable should
* degrade to "refresh more than strictly necessary", never to a crash in a
* post-step that runs after a successful build.
* The timestamp is UTC. Keys are returned relative to the listed prefix so they
* match the `--include` patterns the caller builds: pass `keyPrefix` (e.g.
* `<ns>/snap-ca/`, or `<ns>/<lane>/snap-ca/` on a lane-scoped cache) and the
* remainder after it is the key; a line outside that prefix is skipped. Without
* `keyPrefix` the legacy two-segment strip (`<ns>/<prefix>/`) applies.
* Unparseable lines are skipped rather than throwing: a listing that is partly
* unreadable should degrade to "refresh more than strictly necessary", never to
* a crash in a post-step that runs after a successful build.
*
* @param {string} text raw stdout
* @param {number} nowMs Date.now() equivalent, injectable for tests
* @param {string} [keyPrefix] listed prefix relative to the bucket, with trailing slash
* @returns {Map<string, number>|null} null when nothing parsed (caller fails open)
*/
function parseS3ListAges(text, nowMs) {
function parseS3ListAges(text, nowMs, keyPrefix) {
const ages = new Map();
for (const line of String(text).split('\n')) {
const m = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\d+\s+(.+)$/);
if (!m) continue;
const key = m[2].split('/').slice(2).join('/'); // strip "<ns>/<prefix>/"
let key;
if (keyPrefix) {
if (!m[2].startsWith(keyPrefix)) continue;
key = m[2].slice(keyPrefix.length);
} else {
key = m[2].split('/').slice(2).join('/'); // strip "<ns>/<prefix>/"
}
if (!key) continue;
const t = Date.parse(m[1].replace(' ', 'T') + 'Z');
if (Number.isNaN(t)) continue;
Expand Down
Loading