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
67 changes: 67 additions & 0 deletions __tests__/aws-env.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert');
const { cacheRegion, awsOpts, DEFAULT_REGION } = require('../src/aws-env');

const FILE = '/opt/example/aws/credentials';

test('region prefers the dedicated variable over the ambient one', () => {
assert.equal(
cacheRegion({ BP_CACHE_AWS_REGION: 'eu-central-1', AWS_REGION: 'us-east-1' }),
'eu-central-1'
);
});

test('region falls back to the ambient variable, then to the default', () => {
assert.equal(cacheRegion({ AWS_REGION: 'us-east-1' }), 'us-east-1');
assert.equal(cacheRegion({}), DEFAULT_REGION);
});

test('with no dedicated credentials file the options are handed back untouched', () => {
const opts = { stdio: 'ignore', timeout: 15000 };
// Identity, not just equality: the spawn must be exactly what it was before, with
// no env key introduced that would override an inherited environment.
assert.strictEqual(awsOpts(opts, { AWS_REGION: 'us-east-1' }), opts);
assert.deepEqual(awsOpts(undefined, {}), {});
});

test('the credentials file and profile are set on the child env', () => {
const out = awsOpts({ stdio: 'ignore' }, {
BP_CACHE_AWS_CREDENTIALS_FILE: FILE,
BP_CACHE_AWS_PROFILE: 'cache-profile',
PATH: '/usr/bin',
});
assert.equal(out.stdio, 'ignore');
assert.equal(out.env.AWS_SHARED_CREDENTIALS_FILE, FILE);
assert.equal(out.env.AWS_PROFILE, 'cache-profile');
// The rest of the environment still reaches the child — sudo -E has nothing to
// preserve if we hand it a stripped env.
assert.equal(out.env.PATH, '/usr/bin');
});

test('profile defaults when only the file is supplied', () => {
const out = awsOpts(undefined, { BP_CACHE_AWS_CREDENTIALS_FILE: FILE });
assert.equal(out.env.AWS_PROFILE, 'default');
});

test('the caller environment is never mutated', () => {
// The whole point: these two variables are global to every AWS CLI process, so
// writing them to process.env would redirect the job's own later `aws` calls.
const env = { BP_CACHE_AWS_CREDENTIALS_FILE: FILE, PATH: '/usr/bin' };
const before = { ...env };
awsOpts({ stdio: 'inherit' }, env);
assert.deepEqual(env, before);
assert.equal(env.AWS_SHARED_CREDENTIALS_FILE, undefined);
assert.equal(env.AWS_PROFILE, undefined);
});

test('an explicit env on the incoming options wins over the ambient one', () => {
const out = awsOpts({ env: { ONLY: 'this' } }, {
BP_CACHE_AWS_CREDENTIALS_FILE: FILE,
PATH: '/usr/bin',
});
assert.equal(out.env.ONLY, 'this');
assert.equal(out.env.PATH, undefined);
assert.equal(out.env.AWS_SHARED_CREDENTIALS_FILE, FILE);
});
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.

52 changes: 52 additions & 0 deletions src/aws-env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

// Where the cache's own AWS calls get their credentials and region — extracted so it
// can be tested, and so the two entry points cannot drift apart.
//
// Historically both were ambient: the region came from a bare AWS_REGION, and the
// credentials from whatever the default chain resolved, which on a runner means the
// shared credentials file found by expanding $HOME. Both are shared with the job, so a
// build that configures AWS for its own purposes silently redirects the cache commit
// too — and both are being withdrawn from the environment.
//
// The environment now supplies a dedicated, absolute-path credentials file plus the
// profile and region to use with it. Prefer those; fall back to the old ambient values
// so nothing breaks where they are not set yet.

const DEFAULT_REGION = 'us-west-2';

/** Region for the cache's AWS calls: dedicated, then ambient, then the default. */
function cacheRegion(env) {
return env.BP_CACHE_AWS_REGION || env.AWS_REGION || DEFAULT_REGION;
}

/**
* Child-process options for an `aws` invocation, carrying the dedicated credentials.
*
* The variables are placed ONLY on the returned options' `env`. Callers must never
* assign them to process.env: AWS_SHARED_CREDENTIALS_FILE and AWS_PROFILE are global
* to every AWS CLI/SDK process, so mutating the process environment would also
* redirect the `aws` calls the job itself makes later in the build.
*
* With no dedicated credentials file configured, the options are returned untouched so
* the spawn is exactly what it was before — the fallback is "behave as we always did",
* not "behave with an empty credentials file".
*
* @param {object|undefined} opts child_process options to extend
* @param {object} env process.env equivalent, injectable for tests
*/
function awsOpts(opts, env) {
const base = opts || {};
const file = env.BP_CACHE_AWS_CREDENTIALS_FILE || '';
if (!file) return base;
return {
...base,
env: {
...(base.env || env),
AWS_SHARED_CREDENTIALS_FILE: file,
AWS_PROFILE: env.BP_CACHE_AWS_PROFILE || 'default',
},
};
}

module.exports = { cacheRegion, awsOpts, DEFAULT_REGION };
11 changes: 9 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
const core = require('@actions/core');
const { execSync, execFileSync } = require('child_process');
const fs = require('fs');
const { cacheRegion, awsOpts: awsOptsFor } = require('./aws-env');

const SOCK = 'unix:///run/buildkit/buildkitd.sock';

function sh(cmd) { execSync(cmd, { stdio: 'inherit' }); }

// Region + credentials for the cache's own AWS calls (see src/aws-env.js). The dedicated
// credentials go on the SPAWNED CHILD's env only — never on process.env, which would
// redirect the job's own later `aws` calls.
const CACHE_REGION = cacheRegion(process.env);
const awsOpts = (opts) => awsOptsFor(opts, process.env);

// Best-effort cache hit-rate signal (P1 observability): 1 = the node-local cache was
// warm when this build started, 0 = cold. Averaging the metric gives the hit rate.
function emitHydrateMetric(ns) {
Expand All @@ -21,8 +28,8 @@ function emitHydrateMetric(ns) {
'--namespace', 'BP/Runners', '--metric-name', 'CacheHydrate',
'--unit', 'Count', '--value', String(warm),
'--dimensions', `Tenant=${ns || 'unknown'}`,
'--region', process.env.AWS_REGION || 'us-west-2'],
{ stdio: 'ignore', timeout: 15000 });
'--region', CACHE_REGION],
awsOpts({ stdio: 'ignore', timeout: 15000 }));
} catch (_) { /* metrics are best-effort */ }
}

Expand Down
23 changes: 15 additions & 8 deletions src/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// before they reach the S3 URI, so a hostile value can neither inject nor traverse.
const core = require('@actions/core');
const { parseS3ListAges, nearExpiry } = require('./refresh-policy');
const { cacheRegion, awsOpts: awsOptsFor } = require('./aws-env');
const fs = require('fs');
const { execFileSync } = require('child_process');

Expand All @@ -25,21 +26,26 @@ const MAX_CACHE_GB = parseInt(process.env.BP_MAX_CACHE_GB || '10', 10) || 10;

function run(file, args, opts) { execFileSync(file, args, { stdio: 'inherit', ...(opts || {}) }); }

// Credentials for the cache's own AWS calls (see src/aws-env.js). They go on the
// SPAWNED CHILD's env only — never on process.env, which would redirect the job's own
// later `aws` calls.
const awsOpts = (opts) => awsOptsFor(opts, process.env);

// Persistent-root mode (runners#72, chart >=0.14.4 with buildkitBuilder.persistentRoot):
// the buildkit --root IS the per-tenant NVMe dir — there is no per-pod copy, so the
// NVMe commit tier disappears and the S3 commit reads the LIVE root. The live root is
// daemon-owned (uid 1000), so aws reads run privileged with the Pod-Identity env
// preserved (-E: AWS_CONTAINER_CREDENTIALS_FULL_URI + token file, root-readable).
// daemon-owned (uid 1000), so aws reads run privileged with the credential env
// preserved (-E), which is also how the above two variables reach the privileged aws.
const PERSISTENT = (process.env.BP_PERSISTENT_BK_ROOT || '') === 'true';
const CACHE_SRC = PERSISTENT ? '/home/runner/buildkit-root' : '/nvme-cache';
function runAws(args) {
if (PERSISTENT) run('sudo', ['-n', '-E', 'aws', ...args]);
else run('aws', args);
if (PERSISTENT) run('sudo', ['-n', '-E', 'aws', ...args], awsOpts());
else run('aws', args, awsOpts());
}
function awsOut(args) {
return PERSISTENT
? execFileSync('sudo', ['-n', '-E', 'aws', ...args])
: execFileSync('aws', args);
? execFileSync('sudo', ['-n', '-E', 'aws', ...args], awsOpts())
: execFileSync('aws', args, awsOpts());
}

// List a directory as root. The snapshotter's snapshot dirs are daemon-owned and can be
Expand All @@ -64,7 +70,7 @@ function emitMetric(name, value, unit, ns, region) {
'--namespace', 'BP/Runners', '--metric-name', name,
'--unit', unit, '--value', String(value),
'--dimensions', `Tenant=${ns}`, '--region', region],
{ stdio: 'ignore', timeout: 15000 });
awsOpts({ stdio: 'ignore', timeout: 15000 }));
} catch (_) { /* metrics are best-effort */ }
}

Expand Down Expand Up @@ -398,7 +404,8 @@ if (event === 'pull_request' && isolatePR) {
process.exit(0);
}

const region = process.env.AWS_REGION || 'us-west-2';
// Dedicated region first, then the ambient one, then the default (see src/aws-env.js).
const region = cacheRegion(process.env);
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 || '';
Expand Down
Loading