Skip to content

Commit eed7b87

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/) - 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 follows XDG Base Directory spec on Linux/macOS and is cross-platform compatible with Windows - 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 - 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 a4b4501 commit eed7b87

3 files changed

Lines changed: 532 additions & 1 deletion

File tree

src/devfile-registry/devfileRegistryWrapper.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ 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+
1317
export const DEVFILE_VERSION_LATEST: string = 'latest';
1418

1519
/**
@@ -180,6 +184,220 @@ export class DevfileRegistry {
180184
});
181185
}
182186

187+
/**
188+
* Download extra stack files (Dockerfile, Kubernetes manifests, etc.) referenced in the devfile.
189+
* These files are stored in the devfile registry GitHub repository alongside the devfile.
190+
*
191+
* Files are cached in the filesystem to avoid repeated downloads.
192+
*
193+
* @param registryUrl Devfile Registry URL
194+
* @param stackName Stack name (e.g., 'go', 'nodejs')
195+
* @param version Stack version (e.g., '2.6.0')
196+
* @param resolvedDevfile The resolved devfile (with parents merged)
197+
* @param projectPath The project path where files should be written
198+
* @returns Promise that resolves when all files are downloaded and written
199+
* @throws Error if any file download or write fails
200+
*/
201+
public async downloadStackExtraFiles(
202+
registryUrl: string,
203+
stackName: string,
204+
version: string,
205+
resolvedDevfile: any,
206+
projectPath: string
207+
): Promise<void> {
208+
const uriPaths = this.extractUriPaths(resolvedDevfile);
209+
210+
if (uriPaths.length === 0) {
211+
return;
212+
}
213+
214+
// Download and write each file, collecting any errors
215+
const results = await Promise.allSettled(
216+
uriPaths.map(async (uriPath) => {
217+
const content = await this.getStackFile(registryUrl, stackName, version, uriPath);
218+
if (!content) {
219+
throw new Error(`Failed to download ${uriPath} from ${stackName}:${version}`);
220+
}
221+
await this.writeStackFile(projectPath, uriPath, content);
222+
return uriPath;
223+
})
224+
);
225+
226+
// Check for failures and report them
227+
const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
228+
if (failures.length > 0) {
229+
const errorMessages = failures.map(f => f.reason?.message || String(f.reason)).join('; ');
230+
throw new Error(`Failed to download ${failures.length} file(s): ${errorMessages}`);
231+
}
232+
}
233+
234+
/**
235+
* Extract all URI paths from devfile components (dockerfile.uri, kubernetes.uri, etc.)
236+
*/
237+
private extractUriPaths(devfile: any): string[] {
238+
const uriPaths: string[] = [];
239+
240+
if (!devfile.components) {
241+
return uriPaths;
242+
}
243+
244+
for (const component of devfile.components) {
245+
// Check for dockerfile uri in image components
246+
if (component.image?.dockerfile?.uri) {
247+
uriPaths.push(component.image.dockerfile.uri);
248+
}
249+
250+
// Check for kubernetes/openshift uri
251+
if (component.kubernetes?.uri) {
252+
uriPaths.push(component.kubernetes.uri);
253+
}
254+
255+
if (component.openshift?.uri) {
256+
uriPaths.push(component.openshift.uri);
257+
}
258+
}
259+
260+
return uriPaths;
261+
}
262+
263+
/**
264+
* Get a stack file from cache or download from GitHub registry repository
265+
* @throws Error if download fails
266+
*/
267+
private async getStackFile(
268+
registryUrl: string,
269+
stackName: string,
270+
version: string,
271+
uriPath: string
272+
): Promise<string | null> {
273+
const cacheKey = ExecutionContext.key(`stack-file:${registryUrl}:${stackName}:${version}:${uriPath}`);
274+
275+
// Check memory cache first
276+
if (this.executionContext && this.executionContext.has(cacheKey)) {
277+
return this.executionContext.get(cacheKey);
278+
}
279+
280+
// Check filesystem cache
281+
const cachedContent = await this.getFromFileCache(stackName, version, uriPath);
282+
if (cachedContent) {
283+
this.executionContext.set(cacheKey, cachedContent);
284+
return cachedContent;
285+
}
286+
287+
// Download from GitHub registry repository
288+
try {
289+
const content = await this.downloadFromRegistryRepo(stackName, version, uriPath);
290+
291+
// Cache in memory and filesystem
292+
this.executionContext.set(cacheKey, content);
293+
await this.saveToFileCache(stackName, version, uriPath, content);
294+
295+
return content;
296+
} catch (error) {
297+
const errorMessage = error instanceof Error ? error.message : String(error);
298+
throw new Error(`Failed to download ${uriPath}: ${errorMessage}`);
299+
}
300+
}
301+
302+
/**
303+
* Download a file from the devfile registry GitHub repository
304+
*/
305+
private async downloadFromRegistryRepo(
306+
stackName: string,
307+
version: string,
308+
uriPath: string
309+
): Promise<string> {
310+
const url = `https://raw.githubusercontent.com/devfile/registry/main/stacks/${stackName}/${version}/${uriPath}`;
311+
return DevfileRegistry._get(url);
312+
}
313+
314+
/**
315+
* Get cached file content from filesystem.
316+
* Cache location follows XDG Base Directory spec on Linux/macOS,
317+
* and AppData on Windows (via os.homedir() + .local/state)
318+
*/
319+
private async getFromFileCache(
320+
stackName: string,
321+
version: string,
322+
uriPath: string
323+
): Promise<string | null> {
324+
try {
325+
const fs = await import('fs/promises');
326+
const path = await import('path');
327+
const os = await import('os');
328+
329+
// Use .local/state for consistency with extension's tools storage
330+
// This works cross-platform: ~/.local/state on Unix, %USERPROFILE%\.local\state on Windows
331+
const cacheDir = path.join(
332+
os.homedir(),
333+
'.local',
334+
'state',
335+
EXTENSION_CACHE_DIR,
336+
'devfile-registry-cache',
337+
stackName,
338+
version
339+
);
340+
const filePath = path.join(cacheDir, uriPath);
341+
342+
const content = await fs.readFile(filePath, 'utf-8');
343+
return content;
344+
} catch {
345+
return null;
346+
}
347+
}
348+
349+
/**
350+
* Save file content to filesystem cache.
351+
* Cache location follows XDG Base Directory spec on Linux/macOS,
352+
* and AppData on Windows (via os.homedir() + .local/state)
353+
*/
354+
private async saveToFileCache(
355+
stackName: string,
356+
version: string,
357+
uriPath: string,
358+
content: string
359+
): Promise<void> {
360+
try {
361+
const fs = await import('fs/promises');
362+
const path = await import('path');
363+
const os = await import('os');
364+
365+
// Use .local/state for consistency with extension's tools storage
366+
// This works cross-platform: ~/.local/state on Unix, %USERPROFILE%\.local\state on Windows
367+
const cacheDir = path.join(
368+
os.homedir(),
369+
'.local',
370+
'state',
371+
EXTENSION_CACHE_DIR,
372+
'devfile-registry-cache',
373+
stackName,
374+
version
375+
);
376+
const filePath = path.join(cacheDir, uriPath);
377+
378+
await fs.mkdir(path.dirname(filePath), { recursive: true });
379+
await fs.writeFile(filePath, content, 'utf-8');
380+
} catch {
381+
// Ignore cache write errors - cache is optional
382+
}
383+
}
384+
385+
/**
386+
* Write a stack file to the project directory
387+
*/
388+
private async writeStackFile(
389+
projectPath: string,
390+
uriPath: string,
391+
content: string
392+
): Promise<void> {
393+
const fs = await import('fs/promises');
394+
const path = await import('path');
395+
396+
const fullPath = path.join(projectPath, uriPath);
397+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
398+
await fs.writeFile(fullPath, content, 'utf-8');
399+
}
400+
183401
/**
184402
* Clears the Execution context as well as all cached data
185403
*/

src/devfile/init.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export async function odoInit(options: OdoInitOptions) {
5454
const resolvedDevfile = await resolveDevfile(workingDevfile);
5555

5656
logInfo(ctx, 'Devfile resolved');
57+
logInfo(ctx, 'Downloading stack extra files...');
58+
59+
await downloadStackExtraFiles(resolvedDevfile, acquired, ctx);
60+
61+
logInfo(ctx, 'Stack extra files downloaded');
5762
logInfo(ctx, 'Validating devfile...');
5863

5964
// analysis-only copy
@@ -684,7 +689,7 @@ function interpolateDevfileVariables(devfile: Devfile): Devfile {
684689
}
685690

686691
async function materializeZipStarter(url: string, projectPath: string) {
687-
const tmpZip = path.join(projectPath, '.odo-starter.zip');
692+
const tmpZip = path.join(projectPath, '.devfile-starter.zip');
688693

689694
try {
690695
await DownloadUtil.downloadFile(url, tmpZip);
@@ -694,3 +699,31 @@ async function materializeZipStarter(url: string, projectPath: string) {
694699
await fs.unlink(tmpZip).catch(() => undefined);
695700
}
696701
}
702+
703+
async function downloadStackExtraFiles(devfile: Devfile, acquired: AcquiredDevfile, ctx: InitContext) {
704+
// Only download extra files if the devfile came from a registry
705+
if (!acquired.provenance) {
706+
return;
707+
}
708+
709+
const { url: registryUrl, stack: stackName, version } = acquired.provenance;
710+
711+
try {
712+
logInfo(ctx, `Downloading stack files for ${stackName}:${version}`);
713+
714+
await DevfileRegistry.Instance.downloadStackExtraFiles(
715+
registryUrl,
716+
stackName,
717+
version,
718+
devfile,
719+
ctx.projectPath
720+
);
721+
722+
logInfo(ctx, 'Stack files downloaded successfully');
723+
} catch (error) {
724+
const errorMessage = error instanceof Error ? error.message : String(error);
725+
logError(ctx, `Failed to download stack extra files: ${errorMessage}`);
726+
logInfo(ctx, 'Component initialization will continue without extra files');
727+
// Don't fail the init - extra files are optional
728+
}
729+
}

0 commit comments

Comments
 (0)