diff --git a/sources/src/develocity/build-scan.ts b/sources/src/develocity/build-scan.ts index 24118102..15a11a5d 100644 --- a/sources/src/develocity/build-scan.ts +++ b/sources/src/develocity/build-scan.ts @@ -1,8 +1,7 @@ import * as core from '@actions/core' import {DevelocityConfig} from '../configuration' -import {setupToken} from './short-lived-token' -export async function setup(config: DevelocityConfig): Promise { +export function setup(config: DevelocityConfig): void { maybeExportVariable('DEVELOCITY_INJECTION_INIT_SCRIPT_NAME', 'gradle-actions.inject-develocity.init.gradle') maybeExportVariable('DEVELOCITY_INJECTION_CUSTOM_VALUE', 'gradle-actions') @@ -39,12 +38,6 @@ export async function setup(config: DevelocityConfig): Promise { maybeExportVariable('DEVELOCITY_INJECTION_TERMS_OF_USE_URL', config.getTermsOfUseUrl()) maybeExportVariable('DEVELOCITY_INJECTION_TERMS_OF_USE_AGREE', config.getTermsOfUseAgree()) } - - return setupToken( - config.getDevelocityAccessKey(), - config.getDevelocityAllowUntrustedServer(), - config.getDevelocityTokenExpiry() - ) } function maybeExportVariable(variableName: string, value: unknown): void { diff --git a/sources/src/develocity/short-lived-token.ts b/sources/src/develocity/short-lived-token.ts index 9413cd93..d662fafd 100644 --- a/sources/src/develocity/short-lived-token.ts +++ b/sources/src/develocity/short-lived-token.ts @@ -3,28 +3,41 @@ import * as httpm from '@actions/http-client' import {DevelocityConfig} from '../configuration' import {recordDeprecation} from '../deprecation-collector' -export async function setupToken( - develocityAccessKey: string, - develocityAllowUntrustedServer: boolean | undefined, - develocityTokenExpiry: string -): Promise { - if (develocityAccessKey) { - try { - core.debug('Fetching short-lived token...') - const tokens = await getToken(develocityAccessKey, develocityAllowUntrustedServer, develocityTokenExpiry) - if (tokens != null && !tokens.isEmpty()) { - core.debug(`Got token(s), setting the access key env vars`) - const token = tokens.raw() - core.setSecret(token) - exportAccessKeyEnvVars(token) - } else { - handleMissingAccessToken() - } - } catch (e) { - handleMissingAccessToken() - core.warning(`Failed to fetch short-lived token, reason: ${e}`) - } +/** + * Exchange the configured Develocity access key(s) for short-lived tokens, export them as the access + * key env vars, and return the short-lived token matching the configured Develocity server URL (for + * use as the `develocityAccessToken` cache option). Returns `undefined` when there is no access key, + * token fetching fails, or no token matches the configured server. + */ +export async function setupToken(config: DevelocityConfig): Promise { + const develocityAccessKey = config.getDevelocityAccessKey() + if (!develocityAccessKey) { + return undefined } + try { + core.debug('Fetching short-lived token...') + const tokens = await getToken( + develocityAccessKey, + config.getDevelocityAllowUntrustedServer(), + config.getDevelocityTokenExpiry() + ) + if (tokens != null && !tokens.isEmpty()) { + core.debug(`Got token(s), setting the access key env vars`) + const token = tokens.raw() + core.setSecret(token) + exportAccessKeyEnvVars(token) + for (const k of tokens.keys) { + core.setSecret(k.key) + } + const serverUrl = config.getDevelocityUrl() + return serverUrl ? resolveTokenForServer(tokens, serverUrl) : undefined + } + handleMissingAccessToken() + } catch (e) { + handleMissingAccessToken() + core.warning(`Failed to fetch short-lived token, reason: ${e}`) + } + return undefined } function exportAccessKeyEnvVars(value: string): void { @@ -176,16 +189,11 @@ export class DevelocityAccessCredentials { } /** - * Resolve the access key that matches a given Develocity server, for use as the - * `develocityAccessToken` cache option. Returns `undefined` (fail-closed) when the access key is - * empty, the server URL is empty/unparseable, or no entry matches the server's host. - * - * Parses the `host=value;host=value` form leniently rather than via `DevelocityAccessCredentials`: - * at save time the value is typically a short-lived token (a JWT whose `.`/`-` characters the strict - * parser rejects), but it is still a valid access token for that host. + * Resolve the token whose hostname matches a given Develocity server URL. Returns `undefined` + * (fail-closed) when the server URL is empty or no token matches the server's host. */ -export function resolveAccessKeyForServer(accessKey: string, serverUrl: string): string | undefined { - if (!accessKey || !serverUrl) { +export function resolveTokenForServer(tokens: DevelocityAccessCredentials, serverUrl: string): string | undefined { + if (!serverUrl) { return undefined } let host: string @@ -194,14 +202,5 @@ export function resolveAccessKeyForServer(accessKey: string, serverUrl: string): } catch { host = serverUrl // tolerate a bare hostname (no scheme) } - for (const entry of accessKey.split(';')) { - const sep = entry.indexOf('=') - if (sep < 1) { - continue // skip blanks / malformed entries with no `host=` prefix - } - if (entry.slice(0, sep) === host) { - return entry.slice(sep + 1) || undefined - } - } - return undefined + return tokens.keys.find(k => k.hostname === host)?.key } diff --git a/sources/src/setup-gradle.ts b/sources/src/setup-gradle.ts index 5ea7d07d..a8255ae5 100644 --- a/sources/src/setup-gradle.ts +++ b/sources/src/setup-gradle.ts @@ -5,7 +5,7 @@ import * as path from 'path' import * as os from 'os' import * as jobSummary from './job-summary' import * as buildScan from './develocity/build-scan' -import {resolveAccessKeyForServer} from './develocity/short-lived-token' +import {setupToken} from './develocity/short-lived-token' import {loadBuildResults, markBuildResultsProcessed} from './build-results' import {getCacheService, getProviderNote} from './cache-service-loader' @@ -22,6 +22,8 @@ import {initializeGradleUserHome} from './gradle-user-home' const GRADLE_SETUP_VAR = 'GRADLE_BUILD_ACTION_SETUP_COMPLETED' const GRADLE_USER_HOME = 'GRADLE_USER_HOME' +// Short-lived Develocity token for the configured server, resolved during setup and reused on save. +const DEVELOCITY_CACHE_TOKEN = 'DEVELOCITY_CACHE_TOKEN' export async function setup( cacheConfig: CacheConfig, @@ -45,12 +47,18 @@ export async function setup( initializeGradleUserHome(userHome, gradleUserHome, cacheConfig.getCacheEncryptionKey()) + // Exchange the long-lived access key(s) for short-lived tokens, resolving the token for the + // configured Develocity server and retaining it for the post-action (save) step. + const develocityServerUrl = develocityConfig.getDevelocityUrl() || undefined + const cacheToken = await setupToken(develocityConfig) + core.saveState(DEVELOCITY_CACHE_TOKEN, cacheToken ?? '') + const cacheService = await getCacheService(cacheConfig) - await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig, develocityConfig)) + await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken)) await wrapperValidator.validateWrappers(wrapperValidationConfig, getWorkspaceDirectory(), gradleUserHome) - await buildScan.setup(develocityConfig) + buildScan.setup(develocityConfig) return true } @@ -69,11 +77,13 @@ export async function complete( const buildResults = loadBuildResults() const gradleUserHome = core.getState(GRADLE_USER_HOME) + const develocityServerUrl = develocityConfig.getDevelocityUrl() || undefined + const cacheToken = core.getState(DEVELOCITY_CACHE_TOKEN) || undefined const cacheService = await getCacheService(cacheConfig) const cacheReport = await cacheService.save( gradleUserHome, buildResults, - cacheOptionsFrom(cacheConfig, develocityConfig) + cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken) ) await jobSummary.generateJobSummary(buildResults, cacheReport, getProviderNote(cacheConfig), summaryConfig) @@ -84,12 +94,11 @@ export async function complete( return true } -function cacheOptionsFrom(config: CacheConfig, develocityConfig: DevelocityConfig): CacheOptions { - const develocityServerUrl = develocityConfig?.getDevelocityUrl() || undefined - const develocityAccessToken = - develocityConfig && develocityServerUrl - ? resolveAccessKeyForServer(develocityConfig.getDevelocityAccessKey(), develocityServerUrl) - : undefined +function cacheOptionsFrom( + config: CacheConfig, + develocityServerUrl: string | undefined, + develocityAccessToken: string | undefined +): CacheOptions { return { disabled: config.isCacheDisabled(), readOnly: config.isCacheReadOnly(), diff --git a/sources/test/jest/short-lived-token.test.ts b/sources/test/jest/short-lived-token.test.ts index 8f590924..faca95ac 100644 --- a/sources/test/jest/short-lived-token.test.ts +++ b/sources/test/jest/short-lived-token.test.ts @@ -1,7 +1,7 @@ import nock from "nock"; import {describe, expect, it} from '@jest/globals' -import {DevelocityAccessCredentials, getToken, resolveAccessKeyForServer} from "../../src/develocity/short-lived-token"; +import {DevelocityAccessCredentials, getToken, resolveTokenForServer} from "../../src/develocity/short-lived-token"; describe('short lived tokens', () => { it('parse valid access key should return an object', async () => { @@ -135,41 +135,41 @@ describe('short lived tokens with retry', () => { }) }) -describe('resolveAccessKeyForServer', () => { - it('returns the key matching the server host from a full URL', () => { - expect(resolveAccessKeyForServer('ge.example.com=key1;other=key2', 'https://ge.example.com')).toBe('key1') +describe('resolveTokenForServer', () => { + const credentials = (...pairs: [string, string][]): DevelocityAccessCredentials => + DevelocityAccessCredentials.of(pairs.map(([hostname, key]) => ({hostname, key}))) + + it('returns the token matching the server host from a full URL', () => { + const tokens = credentials(['ge.example.com', 'key1'], ['other', 'key2']) + expect(resolveTokenForServer(tokens, 'https://ge.example.com')).toBe('key1') }) it('matches on hostname, ignoring scheme, port and path', () => { - expect(resolveAccessKeyForServer('ge.example.com=key1', 'https://ge.example.com:8443/path')).toBe('key1') + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, 'https://ge.example.com:8443/path')).toBe('key1') }) it('tolerates a bare hostname with no scheme', () => { - expect(resolveAccessKeyForServer('ge.example.com=key1', 'ge.example.com')).toBe('key1') + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, 'ge.example.com')).toBe('key1') }) - it('selects the matching key when multiple are present', () => { - expect(resolveAccessKeyForServer('dev=key1;ge.example.com=key2', 'https://ge.example.com')).toBe('key2') + it('selects the matching token when multiple are present', () => { + const tokens = credentials(['dev', 'key1'], ['ge.example.com', 'key2']) + expect(resolveTokenForServer(tokens, 'https://ge.example.com')).toBe('key2') }) - it('accepts a short-lived token value (JWT with dots and dashes)', () => { - const token = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig-na_ture' - expect(resolveAccessKeyForServer(`ge.example.com=${token}`, 'https://ge.example.com')).toBe(token) - }) - - it('returns undefined when no key matches the server host', () => { - expect(resolveAccessKeyForServer('ge.example.com=key1', 'https://other.example.com')).toBeUndefined() + it('returns undefined when no token matches the server host', () => { + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, 'https://other.example.com')).toBeUndefined() }) it('returns undefined for an empty server URL', () => { - expect(resolveAccessKeyForServer('ge.example.com=key1', '')).toBeUndefined() + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, '')).toBeUndefined() }) - it('returns undefined for an empty access key', () => { - expect(resolveAccessKeyForServer('', 'https://ge.example.com')).toBeUndefined() - }) - - it('returns undefined for a malformed access key', () => { - expect(resolveAccessKeyForServer('not-a-valid-access-key', 'https://ge.example.com')).toBeUndefined() + it('returns undefined when there are no tokens', () => { + expect(resolveTokenForServer(credentials(), 'https://ge.example.com')).toBeUndefined() }) })