Skip to content
Closed
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/metal/snap-ca/aaa.tar.zst',
'2026-08-20 00:00:00 10 org-x/metal/blobs/sha256/deadbeef',
'2026-08-20 00:00:00 10 org-x/snap-ca/fleet-only.tar.zst'
]),
NOW,
'org-x/metal/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 unsuffixed fleet 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/metal/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 || 'fleet'})`);
} catch (e) {
core.setFailed(`setup-docker-builder failed: ${e.message}`);
}
25 changes: 21 additions & 4 deletions src/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,27 @@ 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.
//
// LANE PREFIX (chart >=0.32.12). The fleet's buildkitd is ROOTLESS (uid 1000 + a userns, so a
// file a build stage creates as uid 999 lands on disk under a shifted host uid) while metal's
// runs as root in the kata guest (no shift). A snapshot committed by one lane and hydrated by
// the other therefore carries the WRONG on-disk ownership, and the next non-root stage that
// writes into it fails with EACCES (Wunderflats `api`: `/home/pptruser/.cache/yarn not
// writable`, prod 2026-09-03). The chart sets BK_CACHE_LANE on the metal path only, and this
// step commits under s3://<bucket>/<ns>/<lane>/ when it is set — the fleet key is unchanged, so
// fleet tenants keep their cache and moving a tenant between lanes is a cold start, not a broken
// build. The hydrate init container applies the same rule when it reads.
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 +338,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 +402,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 chart on the metal (root-in-guest buildkitd) path only; 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 +427,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