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
19 changes: 17 additions & 2 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ export class PercyClient {
'skip-base-build': this.config.percy?.skipBaseBuild,
'testhub-build-uuid': this.env.testhubBuildUuid,
'testhub-build-run-id': this.env.testhubBuildRunId,
// machine identity for slow-build diagnostics (server-validated;
// percy-api may discard any of these)
...(this.env.machine?.id ? {
'machine-id': this.env.machine.id,
'machine-hostname': this.env.machine.hostname,
'machine-ci-run-url': this.env.machine.runUrl
} : {}),
...(dropinBaselineCandidate ? { 'dropin-baseline-candidate': true } : {}),
...(dropinBaselineSetup ? { 'dropin-baseline-setup': true } : {}),
...(visualConfig ? { 'visual-config': visualConfig } : {}),
Expand All @@ -405,13 +412,21 @@ export class PercyClient {
});
}

// Machine-identity header for per-machine liveness on parallel builds.
// Attached per-call (never in headers()) so it only ever reaches percy.io
// API endpoints — headers() is also used for off-domain requests.
machineHeaders() {
let id = this.env.machine?.id;
return id ? { 'X-Percy-Machine-Id': id } : {};
}

// Finalizes the active build. When `all` is true, `all-shards=true` is
// added as a query param so the API finalizes all other build shards.
async finalizeBuild(buildId, { all = false } = {}) {
validateId('build', buildId);
let qs = all ? 'all-shards=true' : '';
this.log.debug(`Finalizing build ${buildId}...`);
return this.post(`builds/${buildId}/finalize?${qs}`, {}, { identifier: 'build.finalze' });
return this.post(`builds/${buildId}/finalize?${qs}`, {}, { identifier: 'build.finalze' }, this.machineHeaders());
}

// Retrieves build data by id. Requires a read access token.
Expand Down Expand Up @@ -714,7 +729,7 @@ export class PercyClient {
}
}
}
}, { identifier: 'snapshot.post', ...meta });
}, { identifier: 'snapshot.post', ...meta }, this.machineHeaders());
}

// Finalizes a snapshot.
Expand Down
62 changes: 62 additions & 0 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
tags: []
Expand All @@ -257,6 +260,17 @@ describe('PercyClient', () => {
expect(api.requests['/builds'][0].body.data.attributes.priority).toBeUndefined();
});

it('omits machine attributes when no machine identity is available', async () => {
spyOnProperty(client.env, 'machine').and.returnValue({ id: null });

await client.createBuild();

let attributes = api.requests['/builds'][0].body.data.attributes;
expect(attributes['machine-id']).toBeUndefined();
expect(attributes['machine-hostname']).toBeUndefined();
expect(attributes['machine-ci-run-url']).toBeUndefined();
});

it('creates a new build with projectType passed as null', async () => {
await expectAsync(client.createBuild({ projectType: null })).toBeResolvedTo({
data: {
Expand Down Expand Up @@ -288,6 +302,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -373,6 +390,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -415,6 +435,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
tags: [{ id: null, name: 'tag1' }, { id: null, name: 'tag2' }]
Expand Down Expand Up @@ -458,6 +481,9 @@ describe('PercyClient', () => {
'cli-start-time': cliStartTime,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'auto_enabled_group',
partial: client.env.partial,
tags: [{ id: null, name: 'tag1' }, { id: null, name: 'tag2' }]
Expand Down Expand Up @@ -500,6 +526,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
'skip-base-build': true,
Expand Down Expand Up @@ -540,6 +569,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': 'test-uuid-123',
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -579,6 +611,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': 'test-run-id-123',
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -618,6 +653,9 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
'machine-id': client.env.machine.id,
'machine-hostname': client.env.machine.hostname,
'machine-ci-run-url': client.env.machine.runUrl,
source: 'bstack_sdk_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -1211,6 +1249,22 @@ describe('PercyClient', () => {
expect(api.requests['/builds/123/finalize']).toBeDefined();
});

it('sends the machine identity header for per-machine liveness', async () => {
await expectAsync(client.finalizeBuild(123)).toBeResolved();
expect(api.requests['/builds/123/finalize'][0].headers).toEqual(
jasmine.objectContaining({
'X-Percy-Machine-Id': client.env.machine.id
}));
});

it('omits the machine header when no machine identity is available', async () => {
spyOnProperty(client.env, 'machine').and.returnValue({ id: null });

await expectAsync(client.finalizeBuild(123)).toBeResolved();
expect(api.requests['/builds/123/finalize'][0].headers['X-Percy-Machine-Id'])
.toBeUndefined();
});

it('can finalize all shards of a build', async () => {
await expectAsync(client.finalizeBuild(123, { all: true })).toBeResolved();
expect(api.requests['/builds/123/finalize?all-shards=true']).toBeDefined();
Expand Down Expand Up @@ -1359,6 +1413,14 @@ describe('PercyClient', () => {
.toBeRejectedWithError('Invalid build ID');
});

it('sends the machine identity header for per-machine liveness', async () => {
await expectAsync(client.createSnapshot(123, { name: 'snap' })).toBeResolved();
expect(api.requests['/builds/123/snapshots'][0].headers).toEqual(
jasmine.objectContaining({
'X-Percy-Machine-Id': client.env.machine.id
}));
});

it('creates a snapshot', async () => {
spyOn(fs.promises, 'readFile')
.withArgs('foo/bar').and.resolveTo('bar');
Expand Down
47 changes: 46 additions & 1 deletion packages/env/src/environment.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os from 'os';
import {
getCommitData,
getJenkinsSha,
Expand Down Expand Up @@ -405,6 +406,48 @@ export class PercyEnv {
return !!partial && partial !== '0';
}

// machine identity for slow-build diagnostics (dead-CI-machine detection on
// parallel builds). percy-api validates all of these server-side and may
// discard any of them. Deliberately excluded from the getter debug logging
// below (like `token`) — env debug logs are uploaded with build logs, and
// hostnames should not ride along in them.
get machine() {
let hostname = null;
try { hostname = os.hostname() || null; } catch { hostname = null; }

let index = null;
let runUrl = null;
switch (this.ci) {
case 'circle':
index = this.vars.CIRCLE_NODE_INDEX ?? null;
runUrl = this.vars.CIRCLE_BUILD_URL || null;
break;
case 'buildkite':
index = this.vars.BUILDKITE_PARALLEL_JOB ?? null;
runUrl = this.vars.BUILDKITE_BUILD_URL || null;
break;
case 'github':
runUrl = (this.vars.GITHUB_SERVER_URL && this.vars.GITHUB_REPOSITORY && this.vars.GITHUB_RUN_ID)
? `${this.vars.GITHUB_SERVER_URL}/${this.vars.GITHUB_REPOSITORY}/actions/runs/${this.vars.GITHUB_RUN_ID}`
: null;
break;
case 'gitlab':
runUrl = this.vars.CI_JOB_URL || null;
break;
}

// stable id: sanitized hostname, suffixed with the CI node index when the
// provider exposes one (the same host can run multiple shards)
let id = hostname && hostname.replace(/[^A-Za-z0-9._-]/g, '-');
if (id && index != null && index !== '') id = `${id}.n${index}`;

return {
id: id || null,
hostname: hostname || null,
runUrl: runUrl || null
};
}

// percy token
get token() {
return this.vars.PERCY_TOKEN || null;
Expand Down Expand Up @@ -441,7 +484,9 @@ Object.defineProperties(PercyEnv.prototype, (
get() {
let value = get.call(this);
Object.defineProperty(this, key, { value });
if (key !== 'token') {
// `machine` carries a hostname and these debug logs are uploaded
// with build logs — keep it out, like the token.
if (key !== 'token' && key !== 'machine') {
this.log.debug(`Detected ${key} as ${JSON.stringify(value)}`);
}
return value;
Expand Down
102 changes: 102 additions & 0 deletions packages/env/test/environment.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os from 'os';
import PercyEnv from '@percy/env';

describe('PercyEnv', () => {
Expand All @@ -23,6 +24,107 @@ describe('PercyEnv', () => {
});
});

describe('machine', () => {
it('returns a sanitized hostname-based id and the hostname', () => {
let env = new PercyEnv({});
expect(env.machine.hostname).toEqual(jasmine.any(String));
expect(env.machine.id).toMatch(/^[A-Za-z0-9._-]+$/);
});

it('suffixes the CI node index and captures the run url on circle', () => {
let env = new PercyEnv({
CIRCLECI: 'true',
CIRCLE_NODE_INDEX: '2',
CIRCLE_BUILD_URL: 'https://app.circleci.com/pipelines/x/1'
});
expect(env.machine.id).toMatch(/\.n2$/);
expect(env.machine.runUrl).toEqual('https://app.circleci.com/pipelines/x/1');
});

it('composes the github actions run url', () => {
let env = new PercyEnv({
GITHUB_ACTIONS: 'true',
GITHUB_SERVER_URL: 'https://github.com',
GITHUB_REPOSITORY: 'org/repo',
GITHUB_RUN_ID: '123'
});
expect(env.machine.runUrl).toEqual('https://github.com/org/repo/actions/runs/123');
});

it('suffixes the parallel job index and captures the run url on buildkite', () => {
let env = new PercyEnv({
BUILDKITE: 'true',
BUILDKITE_PARALLEL_JOB: '3',
BUILDKITE_BUILD_URL: 'https://buildkite.com/org/pipe/builds/9'
});
expect(env.machine.id).toMatch(/\.n3$/);
expect(env.machine.runUrl).toEqual('https://buildkite.com/org/pipe/builds/9');
});

it('captures the job url on gitlab', () => {
let env = new PercyEnv({
GITLAB_CI: 'true',
CI_SERVER_VERSION: '16.0',
CI_JOB_URL: 'https://gitlab.com/org/repo/-/jobs/42'
});
expect(env.machine.runUrl).toEqual('https://gitlab.com/org/repo/-/jobs/42');
});

it('omits the index suffix when the provider exposes no node index', () => {
let env = new PercyEnv({ BUILDKITE: 'true' });
expect(env.machine.id).not.toMatch(/\.n/);
});

it('handles circle without a node index or build url', () => {
let env = new PercyEnv({ CIRCLECI: 'true' });
expect(env.machine.id).not.toMatch(/\.n/);
expect(env.machine.runUrl).toBeNull();
});

it('handles gitlab without a job url', () => {
let env = new PercyEnv({ GITLAB_CI: 'true', CI_SERVER_VERSION: '16.0' });
expect(env.machine.runUrl).toBeNull();
});

it('omits an incomplete github run url', () => {
let env = new PercyEnv({ GITHUB_ACTIONS: 'true', GITHUB_RUN_ID: '123' });
expect(env.machine.runUrl).toBeNull();
});

it('returns a null run url when the provider exposes none', () => {
let env = new PercyEnv({});
expect(env.machine.runUrl).toBeNull();
});

it('degrades to null identity when the hostname cannot be read', () => {
spyOn(os, 'hostname').and.throwError('EPERM');
let env = new PercyEnv({});
expect(env.machine.hostname).toBeNull();
expect(env.machine.id).toBeNull();
});

it('treats an empty hostname as absent', () => {
spyOn(os, 'hostname').and.returnValue('');
let env = new PercyEnv({});
expect(env.machine.hostname).toBeNull();
expect(env.machine.id).toBeNull();
});

it('sanitizes characters that are invalid in a machine id', () => {
spyOn(os, 'hostname').and.returnValue('host name/with:chars');
let env = new PercyEnv({});
expect(env.machine.id).toEqual('host-name-with-chars');
});

it('is excluded from getter debug logging', () => {
let env = new PercyEnv({});
env.ci; // eslint-disable-line babel/no-unused-expressions -- warm nested getters
spyOn(env.log, 'debug');
env.machine; // eslint-disable-line babel/no-unused-expressions
expect(env.log.debug).not.toHaveBeenCalled();
});
});

describe('testhubBuildUuid', () => {
it('should return TH_BUILD_UUID when it is set', () => {
let env = new PercyEnv({ TH_BUILD_UUID: 'test_id' });
Expand Down
Loading