diff --git a/packages/client/src/client.js b/packages/client/src/client.js index ad9f29881..5d8889394 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -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 } : {}), @@ -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. @@ -714,7 +729,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..f0fd3c1d4 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -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: [] @@ -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: { @@ -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: [] @@ -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: [] @@ -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' }] @@ -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' }] @@ -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, @@ -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: [] @@ -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: [] @@ -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: [] @@ -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(); @@ -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'); diff --git a/packages/env/src/environment.js b/packages/env/src/environment.js index 222b51d35..4fdb05fad 100644 --- a/packages/env/src/environment.js +++ b/packages/env/src/environment.js @@ -1,3 +1,4 @@ +import os from 'os'; import { getCommitData, getJenkinsSha, @@ -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; @@ -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; diff --git a/packages/env/test/environment.test.js b/packages/env/test/environment.test.js index e66a0dda5..92d7b0676 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,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' });