diff --git a/packages/client/src/client.js b/packages/client/src/client.js index ad9f29881..c07895392 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -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 } : {}), @@ -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. @@ -714,7 +730,7 @@ export class PercyClient { } } } - }, { identifier: 'snapshot.post', ...meta }); + }, { identifier: 'snapshot.post', ...meta }, this.machineHeaders()); } // Finalizes a snapshot. diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index bdc56f81e..d6b5a09ef 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -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; @@ -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: [] @@ -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: { @@ -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: [] @@ -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: [] @@ -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' }] @@ -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' }] @@ -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, @@ -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: [] @@ -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: [] @@ -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: [] @@ -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(); @@ -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'); diff --git a/packages/env/src/environment.js b/packages/env/src/environment.js index 222b51d35..d51ae1ae5 100644 --- a/packages/env/src/environment.js +++ b/packages/env/src/environment.js @@ -1,3 +1,4 @@ +import os from 'os'; import { getCommitData, getJenkinsSha, @@ -5,6 +6,15 @@ import { } 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; @@ -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; @@ -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; diff --git a/packages/env/test/environment.test.js b/packages/env/test/environment.test.js index e66a0dda5..da7c13cdc 100644 --- a/packages/env/test/environment.test.js +++ b/packages/env/test/environment.test.js @@ -1,3 +1,4 @@ +import os from 'os'; import PercyEnv from '@percy/env'; describe('PercyEnv', () => { @@ -23,6 +24,164 @@ 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 anchors the run url to the job on buildkite', () => { + let env = new PercyEnv({ + BUILDKITE: 'true', + BUILDKITE_PARALLEL_JOB: '3', + BUILDKITE_BUILD_URL: 'https://buildkite.com/org/pipe/builds/9', + BUILDKITE_JOB_ID: '0192a-job' + }); + expect(env.machine.id).toMatch(/\.n3$/); + expect(env.machine.runUrl).toEqual('https://buildkite.com/org/pipe/builds/9#0192a-job'); + }); + + it('falls back to the build url on buildkite without a job id', () => { + let env = new PercyEnv({ + BUILDKITE: 'true', + BUILDKITE_BUILD_URL: 'https://buildkite.com/org/pipe/builds/9' + }); + expect(env.machine.runUrl).toEqual('https://buildkite.com/org/pipe/builds/9'); + }); + + it('suffixes the parallel node index and captures the job url on gitlab', () => { + let env = new PercyEnv({ + GITLAB_CI: 'true', + CI_SERVER_VERSION: '16.0', + CI_NODE_INDEX: '2', + CI_JOB_URL: 'https://gitlab.com/org/repo/-/jobs/42' + }); + expect(env.machine.id).toMatch(/\.n2$/); + expect(env.machine.runUrl).toEqual('https://gitlab.com/org/repo/-/jobs/42'); + }); + + it('suffixes the executor number on jenkins so shards on one agent stay distinct', () => { + let env = new PercyEnv({ JENKINS_URL: 'http://jenkins.local/', EXECUTOR_NUMBER: '1' }); + expect(env.machine.id).toMatch(/\.n1$/); + }); + + it('keeps a zero shard index', () => { + let env = new PercyEnv({ CIRCLECI: 'true', CIRCLE_NODE_INDEX: '0' }); + expect(env.machine.id).toMatch(/\.n0$/); + }); + + it('sanitizes the shard index so the id stays header-safe', () => { + spyOn(os, 'hostname').and.returnValue('host'); + let env = new PercyEnv({ CIRCLECI: 'true', CIRCLE_NODE_INDEX: '2\n' }); + expect(env.machine.id).toEqual('host.n2-'); + }); + + 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('reports the CI platform when one is detected', () => { + let env = new PercyEnv({ JENKINS_URL: 'http://jenkins.local/' }); + expect(env.machine.platform).toEqual('jenkins'); + }); + + it('reports a null platform outside of CI', () => { + let env = new PercyEnv({}); + expect(env.machine.platform).toBeNull(); + }); + + it('reports a null platform for an unrecognized CI', () => { + let env = new PercyEnv({ CI: 'true' }); + expect(env.ci).toEqual('CI/unknown'); + expect(env.machine.platform).toBeNull(); + }); + + it('caps the id at the length the API accepts', () => { + spyOn(os, 'hostname').and.returnValue('h'.repeat(300)); + let env = new PercyEnv({}); + expect(env.machine.id).toHaveSize(128); + expect(env.machine.hostname).toHaveSize(300); + }); + + it('drops an id that sanitizes to nothing identifying', () => { + spyOn(os, 'hostname').and.returnValue('сервер'); + let env = new PercyEnv({}); + expect(env.machine.id).toBeNull(); + expect(env.machine.hostname).toEqual('сервер'); + }); + + 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' });