Skip to content

Commit f8be3ea

Browse files
committed
Download devfile stack extra files during component initialization
Add functionality to download additional stack files (Dockerfile, Kubernetes manifests, etc.) when creating components from devfile registry sources. This brings odoInit to feature parity with the real odo CLI. Key Changes: - Add DevfileRegistry.downloadStackExtraFiles() API for downloading stack files referenced in devfile components (dockerfile.uri, kubernetes.uri) - Download files directly from GitHub devfile registry repository via HTTPS (no ORAS/OCI dependencies needed) - Implement two-level caching: in-memory (ExecutionContext) + filesystem (~/.local/state/vs-openshift-tools/devfile-registry-cache/) - Cache location configurable via VSCODE_OPENSHIFT_CACHE_ROOT environment variable for users with permission issues or custom cache locations - Robust cache validation with fallback to temp directory and user warnings - Integrate into odoInit to download files after devfile resolution - Add 5 comprehensive integration tests covering all scenarios Implementation Details: - Files are downloaded from: https://raw.githubusercontent.com/devfile/registry/main/stacks/{name}/{version}/{uri} - Cache location: * Default: <cache-root>/vs-openshift-tools/devfile-registry-cache/ where cache-root is ~/.local/state (XDG spec) * Override: Set VSCODE_OPENSHIFT_CACHE_ROOT=/custom/path to use /custom/path/vs-openshift-tools/devfile-registry-cache/ * Same env var can be used by other cache consumers (tools.ts, etc.) - Cache validation checks: directory exists, is writable, not a file - Fallback: If primary cache fails, falls back to os.tmpdir() - User warning: Console warning shown once if caching is unavailable - Downloads are optional and don't fail init if files are unavailable - Language-agnostic solution works for all stacks (Go, Python, Java, etc.) Testing: - Integration tests verify: basic download, caching, multiple files, error handling, and devfiles without extra files - Tests respect VSCODE_OPENSHIFT_CACHE_ROOT environment variable - All cross-platform path operations use Node.js standard APIs (path.join, os.homedir, os.tmpdir) Fixes the issue where components created with odoInit were missing deployment-related files that real odo init creates. Signed-off-by: Victor Rubezhny <vrubezhny@redhat.com> Assisted-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1ec7dbe commit f8be3ea

5 files changed

Lines changed: 629 additions & 19 deletions

File tree

src/devfile-registry/devfileRegistryWrapper.ts

Lines changed: 275 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,68 @@ import { OdoPreference } from '../odo/odoPreference';
1010
import { ExecutionContext } from '../util/utils';
1111
import { DevfileData, DevfileInfo } from './devfileInfo';
1212

13+
// Extension ID is used for cache directory naming
14+
// Using a constant allows easy updates if extension name changes
15+
const EXTENSION_CACHE_DIR = 'vs-openshift-tools';
16+
17+
/**
18+
* Get the base directory for devfile registry cache.
19+
* Can be overridden via VSCODE_OPENSHIFT_CACHE_ROOT environment variable.
20+
* Default: ~/.local/state/vs-openshift-tools/devfile-registry-cache
21+
*
22+
* Returns null if cache directory cannot be created or accessed.
23+
*/
24+
async function getDevfileCacheBaseDir(): Promise<string | null> {
25+
const os = require('os');
26+
const path = require('path');
27+
28+
// Get cache root - can be overridden via environment variable
29+
// This allows the same env var to be used by tools.ts and other cache consumers
30+
const cacheRoot = process.env.VSCODE_OPENSHIFT_CACHE_ROOT ||
31+
path.join(os.homedir(), '.local', 'state');
32+
33+
const cacheDir = path.join(
34+
cacheRoot,
35+
EXTENSION_CACHE_DIR,
36+
'devfile-registry-cache'
37+
);
38+
39+
// Validate cache directory
40+
try {
41+
const fs = await import('fs/promises');
42+
43+
// Try to create the directory if it doesn't exist
44+
await fs.mkdir(cacheDir, { recursive: true });
45+
46+
// Check if it's actually a directory
47+
const stats = await fs.stat(cacheDir);
48+
if (!stats.isDirectory()) {
49+
// Path exists but is a file, not a directory - try fallback
50+
const fallback = path.join(os.tmpdir(), EXTENSION_CACHE_DIR, 'devfile-registry-cache');
51+
await fs.mkdir(fallback, { recursive: true });
52+
return fallback;
53+
}
54+
55+
// Test write permissions by creating a temporary test file
56+
const testFile = path.join(cacheDir, '.write-test');
57+
await fs.writeFile(testFile, '', 'utf-8');
58+
await fs.unlink(testFile);
59+
60+
return cacheDir;
61+
} catch (error) {
62+
// Try fallback to temp directory
63+
try {
64+
const fallback = path.join(os.tmpdir(), EXTENSION_CACHE_DIR, 'devfile-registry-cache');
65+
const fs = await import('fs/promises');
66+
await fs.mkdir(fallback, { recursive: true });
67+
return fallback;
68+
} catch {
69+
// No cache available - return null
70+
return null;
71+
}
72+
}
73+
}
74+
1375
export const DEVFILE_VERSION_LATEST: string = 'latest';
1476

1577
/**
@@ -19,6 +81,7 @@ export class DevfileRegistry {
1981
private static instance: DevfileRegistry;
2082

2183
private executionContext: ExecutionContext = new ExecutionContext();
84+
private cacheWarningShown: boolean = false;
2285

2386
public static get Instance(): DevfileRegistry {
2487
if (!DevfileRegistry.instance) {
@@ -157,7 +220,8 @@ export class DevfileRegistry {
157220
const options = { signal, timeout };
158221
let result: string = '';
159222
request(url, options, (response) => {
160-
if (response.statusCode < 500) {
223+
// Only accept 2xx status codes as success
224+
if (response.statusCode >= 200 && response.statusCode < 300) {
161225
response.on('data', (d) => {
162226
result = result.concat(d);
163227
});
@@ -170,7 +234,7 @@ export class DevfileRegistry {
170234
}
171235
});
172236
} else {
173-
reject(new Error(`Connect error: ${response.statusMessage}`));
237+
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
174238
}
175239
}).on('error', (e) => {
176240
reject(new Error(`Connect error: ${e}`));
@@ -180,6 +244,215 @@ export class DevfileRegistry {
180244
});
181245
}
182246

247+
/**
248+
* Download extra stack files (Dockerfile, Kubernetes manifests, etc.) referenced in the devfile.
249+
* These files are stored in the devfile registry GitHub repository alongside the devfile.
250+
*
251+
* Files are cached in the filesystem to avoid repeated downloads.
252+
*
253+
* @param registryUrl Devfile Registry URL
254+
* @param stackName Stack name (e.g., 'go', 'nodejs')
255+
* @param version Stack version (e.g., '2.6.0')
256+
* @param resolvedDevfile The resolved devfile (with parents merged)
257+
* @param projectPath The project path where files should be written
258+
* @returns Promise that resolves when all files are downloaded and written
259+
* @throws Error if any file download or write fails
260+
*/
261+
public async downloadStackExtraFiles(
262+
registryUrl: string,
263+
stackName: string,
264+
version: string,
265+
resolvedDevfile: any,
266+
projectPath: string
267+
): Promise<void> {
268+
const uriPaths = this.extractUriPaths(resolvedDevfile);
269+
270+
if (uriPaths.length === 0) {
271+
return;
272+
}
273+
274+
// Download and write each file, collecting any errors
275+
const results = await Promise.allSettled(
276+
uriPaths.map(async (uriPath) => {
277+
const content = await this.getStackFile(registryUrl, stackName, version, uriPath);
278+
if (!content) {
279+
throw new Error(`Failed to download ${uriPath} from ${stackName}:${version}`);
280+
}
281+
await this.writeStackFile(projectPath, uriPath, content);
282+
return uriPath;
283+
})
284+
);
285+
286+
// Check for failures and report them
287+
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
288+
if (failures.length > 0) {
289+
const errorMessages = failures.map(f => f.reason?.message || String(f.reason)).join('; ');
290+
throw new Error(`Failed to download ${failures.length} file(s): ${errorMessages}`);
291+
}
292+
}
293+
294+
/**
295+
* Extract all URI paths from devfile components (dockerfile.uri, kubernetes.uri, etc.)
296+
*/
297+
private extractUriPaths(devfile: any): string[] {
298+
const uriPaths: string[] = [];
299+
300+
if (!devfile.components) {
301+
return uriPaths;
302+
}
303+
304+
for (const component of devfile.components) {
305+
// Check for dockerfile uri in image components
306+
if (component.image?.dockerfile?.uri) {
307+
uriPaths.push(component.image.dockerfile.uri);
308+
}
309+
310+
// Check for kubernetes/openshift uri
311+
if (component.kubernetes?.uri) {
312+
uriPaths.push(component.kubernetes.uri);
313+
}
314+
315+
if (component.openshift?.uri) {
316+
uriPaths.push(component.openshift.uri);
317+
}
318+
}
319+
320+
return uriPaths;
321+
}
322+
323+
/**
324+
* Get a stack file from cache or download from GitHub registry repository
325+
* @throws Error if download fails
326+
*/
327+
private async getStackFile(
328+
registryUrl: string,
329+
stackName: string,
330+
version: string,
331+
uriPath: string
332+
): Promise<string | null> {
333+
const cacheKey = ExecutionContext.key(`stack-file:${registryUrl}:${stackName}:${version}:${uriPath}`);
334+
335+
// Check memory cache first
336+
if (this.executionContext && this.executionContext.has(cacheKey)) {
337+
return this.executionContext.get(cacheKey);
338+
}
339+
340+
// Check filesystem cache
341+
const cachedContent = await this.getFromFileCache(stackName, version, uriPath);
342+
if (cachedContent) {
343+
this.executionContext.set(cacheKey, cachedContent);
344+
return cachedContent;
345+
}
346+
347+
// Download from GitHub registry repository
348+
try {
349+
const content = await this.downloadFromRegistryRepo(stackName, version, uriPath);
350+
351+
// Cache in memory and filesystem
352+
this.executionContext.set(cacheKey, content);
353+
await this.saveToFileCache(stackName, version, uriPath, content);
354+
355+
return content;
356+
} catch (error) {
357+
const errorMessage = error instanceof Error ? error.message : String(error);
358+
throw new Error(`Failed to download ${uriPath}: ${errorMessage}`);
359+
}
360+
}
361+
362+
/**
363+
* Download a file from the devfile registry GitHub repository
364+
*/
365+
private async downloadFromRegistryRepo(
366+
stackName: string,
367+
version: string,
368+
uriPath: string
369+
): Promise<string> {
370+
const url = `https://raw.githubusercontent.com/devfile/registry/main/stacks/${stackName}/${version}/${uriPath}`;
371+
return DevfileRegistry._get(url);
372+
}
373+
374+
/**
375+
* Get cached file content from filesystem.
376+
* Cache location can be overridden via VSCODE_OPENSHIFT_CACHE_ROOT env var.
377+
* Default: ~/.local/state/vs-openshift-tools/devfile-registry-cache
378+
*/
379+
private async getFromFileCache(
380+
stackName: string,
381+
version: string,
382+
uriPath: string
383+
): Promise<string | null> {
384+
try {
385+
const fs = await import('fs/promises');
386+
const path = await import('path');
387+
388+
const cacheBaseDir = await getDevfileCacheBaseDir();
389+
if (!cacheBaseDir) {
390+
// Warn user once that caching is unavailable
391+
if (!this.cacheWarningShown) {
392+
this.cacheWarningShown = true;
393+
// eslint-disable-next-line no-console
394+
console.warn('[vscode-openshift-tools] Devfile registry cache is unavailable. Files will be downloaded without caching. Set VSCODE_OPENSHIFT_CACHE_ROOT to a writable directory to enable caching.');
395+
}
396+
return null;
397+
}
398+
399+
const cacheDir = path.join(cacheBaseDir, stackName, version);
400+
const filePath = path.join(cacheDir, uriPath);
401+
402+
const content = await fs.readFile(filePath, 'utf-8');
403+
return content;
404+
} catch {
405+
return null;
406+
}
407+
}
408+
409+
/**
410+
* Save file content to filesystem cache.
411+
* Cache location can be overridden via VSCODE_OPENSHIFT_CACHE_ROOT env var.
412+
* Default: ~/.local/state/vs-openshift-tools/devfile-registry-cache
413+
*/
414+
private async saveToFileCache(
415+
stackName: string,
416+
version: string,
417+
uriPath: string,
418+
content: string
419+
): Promise<void> {
420+
try {
421+
const fs = await import('fs/promises');
422+
const path = await import('path');
423+
424+
const cacheBaseDir = await getDevfileCacheBaseDir();
425+
if (!cacheBaseDir) {
426+
// No cache available - silently skip (warning already shown in getFromFileCache)
427+
return;
428+
}
429+
430+
const cacheDir = path.join(cacheBaseDir, stackName, version);
431+
const filePath = path.join(cacheDir, uriPath);
432+
433+
await fs.mkdir(path.dirname(filePath), { recursive: true });
434+
await fs.writeFile(filePath, content, 'utf-8');
435+
} catch {
436+
// Ignore cache write errors - cache is optional
437+
}
438+
}
439+
440+
/**
441+
* Write a stack file to the project directory
442+
*/
443+
private async writeStackFile(
444+
projectPath: string,
445+
uriPath: string,
446+
content: string
447+
): Promise<void> {
448+
const fs = await import('fs/promises');
449+
const path = await import('path');
450+
451+
const fullPath = path.join(projectPath, uriPath);
452+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
453+
await fs.writeFile(fullPath, content, 'utf-8');
454+
}
455+
183456
/**
184457
* Clears the Execution context as well as all cached data
185458
*/

0 commit comments

Comments
 (0)