-
Notifications
You must be signed in to change notification settings - Fork 63
Expand file tree
/
Copy pathdevfileRegistryWrapper.ts
More file actions
411 lines (368 loc) · 15.3 KB
/
Copy pathdevfileRegistryWrapper.ts
File metadata and controls
411 lines (368 loc) · 15.3 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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
/*-----------------------------------------------------------------------------------------------
* Copyright (c) Red Hat, Inc. All rights reserved.
* Licensed under the MIT License. See LICENSE file in the project root for license information.
*-----------------------------------------------------------------------------------------------*/
import { get as httpGet } from 'http';
import { get as httpsGet } from 'https';
import * as YAML from 'js-yaml';
import { Registry } from '../odo/componentType';
import { OdoPreference } from '../odo/odoPreference';
import { ExecutionContext } from '../util/utils';
import { DevfileData, DevfileInfo } from './devfileInfo';
// Extension ID is used for cache directory naming
// Using a constant allows easy updates if extension name changes
const EXTENSION_CACHE_DIR = 'vs-openshift-tools';
export const DEVFILE_VERSION_LATEST: string = 'latest';
/**
* Wraps some the Devfile Registry REST API calls.
*/
export class DevfileRegistry {
private static instance: DevfileRegistry;
private executionContext: ExecutionContext = new ExecutionContext();
public static get Instance(): DevfileRegistry {
if (!DevfileRegistry.instance) {
DevfileRegistry.instance = new DevfileRegistry();
}
return DevfileRegistry.instance;
}
private constructor() {
// no state
}
/**
* Get list of Devfile Infos from the specified Registry.
*
* GET http://{registry host}/v2index/all
*
* @param url Devfile Registry URL
* @param abortTimeout (Optional) If provided, allow cancelling the operation by timeout
* @param abortController (Optional) If provided, allows cancelling the operation by signal
*/
public async getDevfileInfoList(url: string, abortTimeout?: number, abortController?: AbortController): Promise<DevfileInfo[]> {
const requestUrl = `${url}/v2index/all`;
const key = ExecutionContext.key(requestUrl);
if (this.executionContext && this.executionContext.has(key)) {
return this.executionContext.get(key);
}
const rawList = await DevfileRegistry._get(`${url}/v2index/all`, abortTimeout, abortController);
const jsonList = JSON.parse(rawList);
this.executionContext.set(key, jsonList);
return jsonList;
}
/**
* Get Devfile of specified version from Registry.
*
* GET http://{registry host}/devfiles/{stack}/{version}
*
* @param url Devfile Registry URL
* @param stack Devfile stack
* @param version (Optional) If specified, the version of Devfile to be received, otherwize 'latest' version is requested
* @param abortTimeout (Optional) If provided, allow cancelling the operation by timeout
* @param abortController (Optional) If provided, allows cancelling the operation by signal
*/
private async _getDevfile(url: string, stack: string, version?: string, abortTimeout?: number, abortController?: AbortController): Promise<string> {
const requestUrl = `${url}/devfiles/${stack}/${version ? version : DEVFILE_VERSION_LATEST}`;
const key = ExecutionContext.key(requestUrl);
if (this.executionContext && this.executionContext.has(key)) {
return this.executionContext.get(key);
}
const devfile = DevfileRegistry._get(
`${url}/devfiles/${stack}/${version ? version : DEVFILE_VERSION_LATEST}`,
abortTimeout,
abortController);
this.executionContext.set(key, devfile);
return devfile;
}
/**
* Returns a list of the devfile registries from ODO preferences.
*
* @returns a list of the devfile registries
*/
public async getRegistries(registryUrl?: string): Promise<Registry[]> {
// Return only registries registered for user (from ODO preferences)
// and filter by registryUrl (if provided)
let registries: Registry[] = [];
const key = ExecutionContext.key('getRegistries');
if (this.executionContext && !this.executionContext.has(key)) {
registries = await OdoPreference.Instance.getRegistries();
this.executionContext.set(key, registries);
} else {
registries = this.executionContext.get(key);
}
return !registries ? [] :
registries.filter((reg) => {
if (registryUrl) {
return (reg.url === registryUrl)
}
return true;
});
}
/**
* Returns a list of the devfile infos for the specified registry or all the
* registries, if not specified.
*
* @returns a list of the devfile infos
*/
public async getRegistryDevfileInfos(registryUrl?: string): Promise<DevfileInfo[]> {
const registries: Registry[] = await this.getRegistries(registryUrl);
if (!registries || registries.length === 0) {
// TODO: should throw 'new Error('No Devfile registries available. Default registry is missing');'
// here so we can report this to users when a webview is open
return [];
}
const devfiles: DevfileInfo[] = [];
await Promise.all(registries
.map(async (registry): Promise<void> => {
const devfileInfoList = (await this.getDevfileInfoList(registry.url))
.filter((devfileInfo) => 'stack' === devfileInfo.type.toLowerCase());
devfileInfoList.forEach((devfileInfo) => {
devfileInfo.registry = registry;
});
devfiles.push(...devfileInfoList);
}));
return devfiles.sort((a, b) => (a.name < b.name ? -1 : 1));
}
/**
* Returns a devfile data with the raw devfile text attached
*
* @returns a devfile data with raw devfile text attached
*/
public async getRegistryDevfile(registryUrl: string, name: string, version?: string): Promise<DevfileData> {
const rawDevfile = await this._getDevfile(registryUrl, name, version ? version : 'latest');
const devfile = YAML.load(rawDevfile) as DevfileData;
devfile.yaml = rawDevfile;
return devfile;
}
private static async _get(url: string, abortTimeout?: number, abortController?: AbortController): Promise<string> {
return new Promise<string>((resolve, reject) => {
let request = httpGet;
try {
request = new URL(url).protocol.startsWith('https') ? httpsGet : httpGet;
} catch (err) {
// continue
}
const signal = abortController?.signal;
const timeout = abortTimeout ? abortTimeout : 5000;
const options = { signal, timeout };
let result: string = '';
request(url, options, (response) => {
if (response.statusCode < 500) {
response.on('data', (d) => {
result = result.concat(d);
});
response.resume();
response.on('end', () => {
if (!response.complete) {
reject(new Error(`The connection was terminated while the message was still being sent: ${response.statusMessage}`));
} else {
resolve(result);
}
});
} else {
reject(new Error(`Connect error: ${response.statusMessage}`));
}
}).on('error', (e) => {
reject(new Error(`Connect error: ${e}`));
}).on('success', (s) => {
resolve(result);
});
});
}
/**
* Download extra stack files (Dockerfile, Kubernetes manifests, etc.) referenced in the devfile.
* These files are stored in the devfile registry GitHub repository alongside the devfile.
*
* Files are cached in the filesystem to avoid repeated downloads.
*
* @param registryUrl Devfile Registry URL
* @param stackName Stack name (e.g., 'go', 'nodejs')
* @param version Stack version (e.g., '2.6.0')
* @param resolvedDevfile The resolved devfile (with parents merged)
* @param projectPath The project path where files should be written
* @returns Promise that resolves when all files are downloaded and written
* @throws Error if any file download or write fails
*/
public async downloadStackExtraFiles(
registryUrl: string,
stackName: string,
version: string,
resolvedDevfile: any,
projectPath: string
): Promise<void> {
const uriPaths = this.extractUriPaths(resolvedDevfile);
if (uriPaths.length === 0) {
return;
}
// Download and write each file, collecting any errors
const results = await Promise.allSettled(
uriPaths.map(async (uriPath) => {
const content = await this.getStackFile(registryUrl, stackName, version, uriPath);
if (!content) {
throw new Error(`Failed to download ${uriPath} from ${stackName}:${version}`);
}
await this.writeStackFile(projectPath, uriPath, content);
return uriPath;
})
);
// Check for failures and report them
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
if (failures.length > 0) {
const errorMessages = failures.map(f => f.reason?.message || String(f.reason)).join('; ');
throw new Error(`Failed to download ${failures.length} file(s): ${errorMessages}`);
}
}
/**
* Extract all URI paths from devfile components (dockerfile.uri, kubernetes.uri, etc.)
*/
private extractUriPaths(devfile: any): string[] {
const uriPaths: string[] = [];
if (!devfile.components) {
return uriPaths;
}
for (const component of devfile.components) {
// Check for dockerfile uri in image components
if (component.image?.dockerfile?.uri) {
uriPaths.push(component.image.dockerfile.uri);
}
// Check for kubernetes/openshift uri
if (component.kubernetes?.uri) {
uriPaths.push(component.kubernetes.uri);
}
if (component.openshift?.uri) {
uriPaths.push(component.openshift.uri);
}
}
return uriPaths;
}
/**
* Get a stack file from cache or download from GitHub registry repository
* @throws Error if download fails
*/
private async getStackFile(
registryUrl: string,
stackName: string,
version: string,
uriPath: string
): Promise<string | null> {
const cacheKey = ExecutionContext.key(`stack-file:${registryUrl}:${stackName}:${version}:${uriPath}`);
// Check memory cache first
if (this.executionContext && this.executionContext.has(cacheKey)) {
return this.executionContext.get(cacheKey);
}
// Check filesystem cache
const cachedContent = await this.getFromFileCache(stackName, version, uriPath);
if (cachedContent) {
this.executionContext.set(cacheKey, cachedContent);
return cachedContent;
}
// Download from GitHub registry repository
try {
const content = await this.downloadFromRegistryRepo(stackName, version, uriPath);
// Cache in memory and filesystem
this.executionContext.set(cacheKey, content);
await this.saveToFileCache(stackName, version, uriPath, content);
return content;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to download ${uriPath}: ${errorMessage}`);
}
}
/**
* Download a file from the devfile registry GitHub repository
*/
private async downloadFromRegistryRepo(
stackName: string,
version: string,
uriPath: string
): Promise<string> {
const url = `https://raw.githubusercontent.com/devfile/registry/main/stacks/${stackName}/${version}/${uriPath}`;
return DevfileRegistry._get(url);
}
/**
* Get cached file content from filesystem.
* Cache location follows XDG Base Directory spec on Linux/macOS,
* and AppData on Windows (via os.homedir() + .local/state)
*/
private async getFromFileCache(
stackName: string,
version: string,
uriPath: string
): Promise<string | null> {
try {
const fs = await import('fs/promises');
const path = await import('path');
const os = await import('os');
// Use .local/state for consistency with extension's tools storage
// This works cross-platform: ~/.local/state on Unix, %USERPROFILE%\.local\state on Windows
const cacheDir = path.join(
os.homedir(),
'.local',
'state',
EXTENSION_CACHE_DIR,
'devfile-registry-cache',
stackName,
version
);
const filePath = path.join(cacheDir, uriPath);
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch {
return null;
}
}
/**
* Save file content to filesystem cache.
* Cache location follows XDG Base Directory spec on Linux/macOS,
* and AppData on Windows (via os.homedir() + .local/state)
*/
private async saveToFileCache(
stackName: string,
version: string,
uriPath: string,
content: string
): Promise<void> {
try {
const fs = await import('fs/promises');
const path = await import('path');
const os = await import('os');
// Use .local/state for consistency with extension's tools storage
// This works cross-platform: ~/.local/state on Unix, %USERPROFILE%\.local\state on Windows
const cacheDir = path.join(
os.homedir(),
'.local',
'state',
EXTENSION_CACHE_DIR,
'devfile-registry-cache',
stackName,
version
);
const filePath = path.join(cacheDir, uriPath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
} catch {
// Ignore cache write errors - cache is optional
}
}
/**
* Write a stack file to the project directory
*/
private async writeStackFile(
projectPath: string,
uriPath: string,
content: string
): Promise<void> {
const fs = await import('fs/promises');
const path = await import('path');
const fullPath = path.join(projectPath, uriPath);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, content, 'utf-8');
}
/**
* Clears the Execution context as well as all cached data
*/
public clearCache() {
if (this.executionContext) {
this.executionContext.clear();
}
this.executionContext = new ExecutionContext();
}
}