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
20 changes: 18 additions & 2 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,14 @@ 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,
'machine-ci-platform': this.env.machine.platform
} : {}),
...(dropinBaselineCandidate ? { 'dropin-baseline-candidate': true } : {}),
...(dropinBaselineSetup ? { 'dropin-baseline-setup': true } : {}),
...(visualConfig ? { 'visual-config': visualConfig } : {}),
Expand All @@ -405,13 +413,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 +730,7 @@ export class PercyClient {
}
}
}
}, { identifier: 'snapshot.post', ...meta });
}, { identifier: 'snapshot.post', ...meta }, this.machineHeaders());
}

// Finalizes a snapshot.
Expand Down
53 changes: 53 additions & 0 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ describe('PercyClient', () => {

describe('#createBuild()', () => {
let cliStartTime = new Date().toISOString();
// the wire mapping for machine identity, kept in one place so a renamed key
// is one edit rather than one per expectation
let machineAttrs = env => ({
'machine-id': env.machine.id,
'machine-hostname': env.machine.hostname,
'machine-ci-run-url': env.machine.runUrl,
'machine-ci-platform': env.machine.platform
});
beforeEach(() => {
delete process.env.PERCY_AUTO_ENABLED_GROUP_BUILD;
delete process.env.PERCY_ORIGINATED_SOURCE;
Expand Down Expand Up @@ -235,6 +243,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
tags: []
Expand All @@ -257,6 +266,18 @@ 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();
expect(attributes['machine-ci-platform']).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 +309,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -373,6 +395,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -415,6 +438,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
tags: [{ id: null, name: 'tag1' }, { id: null, name: 'tag2' }]
Expand Down Expand Up @@ -458,6 +482,7 @@ describe('PercyClient', () => {
'cli-start-time': cliStartTime,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'auto_enabled_group',
partial: client.env.partial,
tags: [{ id: null, name: 'tag1' }, { id: null, name: 'tag2' }]
Expand Down Expand Up @@ -500,6 +525,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
'skip-base-build': true,
Expand Down Expand Up @@ -540,6 +566,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': 'test-uuid-123',
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -579,6 +606,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': 'test-run-id-123',
...machineAttrs(client.env),
source: 'user_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -618,6 +646,7 @@ describe('PercyClient', () => {
'cli-start-time': null,
'testhub-build-uuid': client.env.testhubBuildUuid,
'testhub-build-run-id': client.env.testhubBuildRunId,
...machineAttrs(client.env),
source: 'bstack_sdk_created',
partial: client.env.partial,
tags: []
Expand Down Expand Up @@ -1211,6 +1240,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 +1404,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
79 changes: 78 additions & 1 deletion packages/env/src/environment.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import os from 'os';
import {
getCommitData,
getJenkinsSha,
github
} from './utils.js';
import logger from '@percy/logger';

// machine ids are capped and restricted to header-safe characters on both
// sides; percy-api rejects anything longer or outside this alphabet
const MACHINE_ID_MAX_LENGTH = 128;

function machineToken(value) {
if (value == null || value === '') return null;
return String(value).replace(/[^A-Za-z0-9._-]/g, '-');
}

export class PercyEnv {
constructor(vars = process.env) {
this.vars = vars;
Expand Down Expand Up @@ -405,6 +415,71 @@ 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; }

// the per-shard index for providers that can run several shards on one
// host; without it every shard on that host would share a machine id and a
// dead shard would look alive as long as any sibling kept uploading
let index = null;
let runUrl = null;
switch (this.ci) {
case 'circle':
index = this.vars.CIRCLE_NODE_INDEX;
runUrl = this.vars.CIRCLE_BUILD_URL;
break;
case 'buildkite':
index = this.vars.BUILDKITE_PARALLEL_JOB;
// the build url is shared by every parallel job; the job id anchor is
// what lets the "stopped responding" link land on the dead agent's log
runUrl = this.vars.BUILDKITE_BUILD_URL && this.vars.BUILDKITE_JOB_ID
? `${this.vars.BUILDKITE_BUILD_URL}#${this.vars.BUILDKITE_JOB_ID}`
: this.vars.BUILDKITE_BUILD_URL;
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':
index = this.vars.CI_NODE_INDEX;
runUrl = this.vars.CI_JOB_URL;
break;
case 'jenkins':
case 'jenkins-prb':
index = this.vars.EXECUTOR_NUMBER;
break;
}

// stable id: sanitized hostname, suffixed with the sanitized shard index.
// The id travels as an HTTP header value on every snapshot POST, so every
// part of it must be header-safe — an unsanitized index with a stray
// newline would reject the whole upload.
let id = machineToken(hostname);
let shard = machineToken(index);
if (id && shard) id = `${id}.n${shard}`;
if (id) id = id.slice(0, MACHINE_ID_MAX_LENGTH);
// a hostname with no ASCII alphanumerics sanitizes to dashes alone, which
// identifies nothing and would collide across hosts — better no id at all
if (id && !/[A-Za-z0-9]/.test(id)) id = null;

return {
id,
hostname,
runUrl: runUrl || null,
// which CI product the agent belongs to ("jenkins", "buildkite", ...);
// percy-web maps it to a display name in the stopped-responding copy.
// The generic CI/unknown marker is not a product and is dropped.
platform: this.ci && this.ci !== 'CI/unknown' ? this.ci : null
};
}

// percy token
get token() {
return this.vars.PERCY_TOKEN || null;
Expand Down Expand Up @@ -441,7 +516,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
Loading
Loading