-
Notifications
You must be signed in to change notification settings - Fork 870
Expand file tree
/
Copy pathjdk-resolution-cache.ts
More file actions
365 lines (327 loc) · 11.1 KB
/
Copy pathjdk-resolution-cache.ts
File metadata and controls
365 lines (327 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
import {createHash} from 'crypto';
import fs from 'fs';
import path from 'path';
import * as cache from '@actions/cache';
import * as core from '@actions/core';
import {
ChecksumMetadata,
JavaDownloadRelease
} from './distributions/base-models.js';
const STATE_JDK_RESOLUTIONS = 'jdk-resolutions';
const JDK_RESOLUTION_KEY_VERSION = 2;
const RESOLUTION_DIRECTORY = 'setup-java-jdk-resolution';
const RESOLUTION_FILE_NAME = 'release.json';
/**
* Everything that identifies a resolution request before any remote metadata is
* fetched. Distribution-specific inputs are already folded into `distribution`
* (for example Temurin's `jvm-impl`) or `packageType` (`jdk+jmods`), so these
* fields fully determine which artifact a distribution would resolve.
*/
export interface JdkResolutionRequest {
distribution: string;
packageType: string;
platform: string;
architecture: string;
versionSpec: string;
stable: boolean;
/**
* Immutable identity of a remotely resolved artifact. When present, even an
* older cache bucket is safe to reuse because changed bytes produce a
* different request identity.
*/
source?: string;
}
export interface RestoredJdkResolution {
release: JavaDownloadRelease;
/**
* Whether the entry was written within the current freshness window. A stale
* entry is only a fallback for the case where the vendor metadata API is
* unreachable, so a floating version spec cannot be pinned indefinitely.
*/
fresh: boolean;
}
interface JdkResolutionState {
key: string;
path: string;
/**
* The payload the key was computed for. A restore in a later step writes to
* the same path, so the post-job save rewrites the file from state instead of
* uploading whatever happens to be on disk.
*/
release: string;
}
const pendingResolutions: JdkResolutionState[] = [];
/**
* Restores a previously resolved release so a distribution can skip its vendor
* metadata API.
*
* The cache path deliberately excludes the freshness window: `@actions/cache`
* derives
* a cache version by hashing the requested paths, so a bucket-independent path
* is what allows the restore keys to fall back to an older bucket.
*/
export async function restoreJdkResolution(
request: JdkResolutionRequest
): Promise<RestoredJdkResolution | undefined> {
// Deliberately not `isCacheFeatureAvailable()`: this is an optional
// optimization, and the JDK cache already warns once when the service is
// unreachable.
if (!cache.isFeatureAvailable()) {
return undefined;
}
const cachePath = getResolutionCachePath(request);
if (!cachePath) {
return undefined;
}
const keyPrefix = getResolutionKeyPrefix(request);
const primaryKey = `${keyPrefix}${getFreshnessBucket()}`;
let matchedKey: string | undefined;
try {
matchedKey = await cache.restoreCache([cachePath], primaryKey, [keyPrefix]);
} catch (error) {
core.debug(
`Failed to restore the JDK resolution cache: ${getErrorMessage(error)}`
);
return undefined;
}
if (!matchedKey) {
return undefined;
}
let release: JavaDownloadRelease;
try {
const contents = fs.readFileSync(
path.join(cachePath, RESOLUTION_FILE_NAME),
'utf8'
);
release = parseResolvedRelease(contents);
} catch (error) {
core.debug(
`Ignoring the JDK resolution cache entry ${matchedKey}: ${getErrorMessage(error)}`
);
return undefined;
}
return {release, fresh: matchedKey === primaryKey};
}
/**
* Persists a freshly resolved release for later jobs. The entry is written to
* disk immediately and uploaded by the post-job step.
*/
export function registerJdkResolution(
request: JdkResolutionRequest,
release: JavaDownloadRelease
): void {
if (!cache.isFeatureAvailable()) {
return;
}
const cachePath = getResolutionCachePath(request);
if (!cachePath) {
return;
}
const payload = JSON.stringify(release);
try {
fs.mkdirSync(cachePath, {recursive: true});
fs.writeFileSync(path.join(cachePath, RESOLUTION_FILE_NAME), payload);
} catch (error) {
core.debug(
`Failed to record the JDK resolution cache entry: ${getErrorMessage(error)}`
);
return;
}
const key = `${getResolutionKeyPrefix(request)}${getFreshnessBucket()}`;
if (!pendingResolutions.some(item => item.key === key)) {
pendingResolutions.push({key, path: cachePath, release: payload});
}
core.saveState(STATE_JDK_RESOLUTIONS, JSON.stringify(pendingResolutions));
}
export async function saveJdkResolutionCaches(): Promise<void> {
const state = core.getState(STATE_JDK_RESOLUTIONS);
if (!state) {
return;
}
let resolutions: JdkResolutionState[];
try {
resolutions = parseJdkResolutionState(state);
} catch (error) {
core.debug(
`Invalid JDK resolution cache state, not saving: ${getErrorMessage(error)}`
);
return;
}
for (const resolution of resolutions) {
// A restore performed by a later step overwrites this path, so the payload
// the key was computed for is written again rather than trusted to still be
// on disk.
try {
fs.mkdirSync(resolution.path, {recursive: true});
fs.writeFileSync(
path.join(resolution.path, RESOLUTION_FILE_NAME),
resolution.release
);
} catch (error) {
core.debug(
`Failed to write the JDK resolution cache entry for the key ${resolution.key}: ${getErrorMessage(error)}`
);
continue;
}
try {
await cache.saveCache([resolution.path], resolution.key);
} catch (error) {
// A matrix of jobs resolving the same JDK races on the same daily key, so
// an already-reserved key is the expected outcome rather than a problem.
core.debug(
`Failed to save the JDK resolution cache with the key ${resolution.key}: ${getErrorMessage(error)}`
);
}
}
}
function getResolutionCachePath(
request: JdkResolutionRequest
): string | undefined {
const runnerTemp = process.env['RUNNER_TEMP'];
if (!runnerTemp) {
return undefined;
}
return path.join(
runnerTemp,
RESOLUTION_DIRECTORY,
getResolutionIdentity(request)
);
}
function getResolutionIdentity(request: JdkResolutionRequest): string {
const identity = JSON.stringify({
keyVersion: JDK_RESOLUTION_KEY_VERSION,
runnerOs: getRunnerOs(),
distribution: request.distribution.toLowerCase(),
packageType: request.packageType.toLowerCase(),
platform: request.platform.toLowerCase(),
architecture: request.architecture.toLowerCase(),
versionSpec: request.versionSpec,
stable: request.stable,
source: request.source
});
return createHash('sha256').update(identity).digest('hex');
}
function getResolutionKeyPrefix(request: JdkResolutionRequest): string {
const architecture = request.architecture.toLowerCase();
const digest = getResolutionIdentity(request);
return `setup-java-jdkres-v${JDK_RESOLUTION_KEY_VERSION}-${getRunnerOs()}-${architecture}-${digest}-`;
}
function getRunnerOs(): string {
return process.env['RUNNER_OS'] ?? process.platform;
}
/**
* Start of the seven-day window the entry was resolved in, which bounds how long
* a floating version spec such as `21` can keep resolving to an already known
* release.
*
* Seven days is the longest usable window: GitHub evicts cache entries that have
* not been accessed for seven days, so a longer one would mean the previous
* entry is already gone when the window rolls over, taking the stale-fallback
* path with it. It also comfortably covers the real release cadence, which is
* monthly at its fastest and usually quarterly.
*/
function getFreshnessBucket(): string {
const week = 7 * 24 * 60 * 60 * 1000;
return new Date(Math.floor(Date.now() / week) * week)
.toISOString()
.slice(0, 10);
}
/**
* The restored payload drives a download, so it is validated as untrusted input
* rather than trusted because it came back from the cache service.
*/
function parseResolvedRelease(contents: string): JavaDownloadRelease {
const value: unknown = JSON.parse(contents);
if (typeof value !== 'object' || value === null) {
throw new Error('The cached resolution is not an object.');
}
const candidate = value as Record<string, unknown>;
const version = candidate['version'];
const url = candidate['url'];
const signatureUrl = candidate['signatureUrl'];
const floating = candidate['floating'];
if (typeof version !== 'string' || !version) {
throw new Error('The cached resolution has no version.');
}
assertHttpsUrl(url, 'url');
if (signatureUrl !== undefined) {
assertHttpsUrl(signatureUrl, 'signatureUrl');
}
if (floating !== undefined && typeof floating !== 'boolean') {
throw new Error('The cached resolution has an invalid floating flag.');
}
const release: JavaDownloadRelease = {
version,
url: url as string
};
if (signatureUrl !== undefined) {
release.signatureUrl = signatureUrl as string;
}
if (floating !== undefined) {
release.floating = floating as boolean;
}
const checksum = candidate['checksum'];
if (checksum !== undefined) {
release.checksum = parseChecksum(checksum);
}
return release;
}
function parseChecksum(value: unknown): ChecksumMetadata {
if (typeof value !== 'object' || value === null) {
throw new Error('The cached checksum is not an object.');
}
const candidate = value as Record<string, unknown>;
const algorithm = candidate['algorithm'];
const checksumValue = candidate['value'];
const source = candidate['source'];
if (algorithm !== 'sha256' && algorithm !== 'sha512') {
throw new Error(`Unsupported cached checksum algorithm: ${algorithm}`);
}
if (typeof checksumValue !== 'string' || !checksumValue) {
throw new Error('The cached checksum has no value.');
}
if (source !== undefined && typeof source !== 'string') {
throw new Error('The cached checksum source is not a string.');
}
const checksum: ChecksumMetadata = {algorithm, value: checksumValue};
if (source !== undefined) {
checksum.source = source;
}
return checksum;
}
function assertHttpsUrl(value: unknown, field: string): void {
if (typeof value !== 'string' || !value) {
throw new Error(`The cached resolution has no ${field}.`);
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`The cached resolution has a malformed ${field}.`);
}
if (parsed.protocol !== 'https:') {
throw new Error(
`The cached resolution ${field} does not use HTTPS: ${parsed.protocol}`
);
}
}
function parseJdkResolutionState(state: string): JdkResolutionState[] {
const value: unknown = JSON.parse(state);
if (
!Array.isArray(value) ||
!value.every(
item =>
typeof item === 'object' &&
item !== null &&
typeof (item as JdkResolutionState).key === 'string' &&
typeof (item as JdkResolutionState).path === 'string' &&
typeof (item as JdkResolutionState).release === 'string'
)
) {
throw new Error('Invalid JDK resolution information retrieved from state.');
}
return value as JdkResolutionState[];
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}