Skip to content

Commit 32c1ced

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 32c1ced

3 files changed

Lines changed: 498 additions & 1 deletion

File tree

src/devfile-registry/devfileRegistryWrapper.ts

Lines changed: 208 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,210 @@ 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+
*/
200+
public async downloadStackExtraFiles(
201+
registryUrl: string,
202+
stackName: string,
203+
version: string,
204+
resolvedDevfile: any,
205+
projectPath: string
206+
): Promise<void> {
207+
const uriPaths = this.extractUriPaths(resolvedDevfile);
208+
209+
if (uriPaths.length === 0) {
210+
return;
211+
}
212+
213+
// Download and write each file
214+
await Promise.all(
215+
uriPaths.map(async (uriPath) => {
216+
const content = await this.getStackFile(registryUrl, stackName, version, uriPath);
217+
if (content) {
218+
await this.writeStackFile(projectPath, uriPath, content);
219+
}
220+
})
221+
);
222+
}
223+
224+
/**
225+
* Extract all URI paths from devfile components (dockerfile.uri, kubernetes.uri, etc.)
226+
*/
227+
private extractUriPaths(devfile: any): string[] {
228+
const uriPaths: string[] = [];
229+
230+
if (!devfile.components) {
231+
return uriPaths;
232+
}
233+
234+
for (const component of devfile.components) {
235+
// Check for dockerfile uri in image components
236+
if (component.image?.dockerfile?.uri) {
237+
uriPaths.push(component.image.dockerfile.uri);
238+
}
239+
240+
// Check for kubernetes/openshift uri
241+
if (component.kubernetes?.uri) {
242+
uriPaths.push(component.kubernetes.uri);
243+
}
244+
245+
if (component.openshift?.uri) {
246+
uriPaths.push(component.openshift.uri);
247+
}
248+
}
249+
250+
return uriPaths;
251+
}
252+
253+
/**
254+
* Get a stack file from cache or download from GitHub registry repository
255+
*/
256+
private async getStackFile(
257+
registryUrl: string,
258+
stackName: string,
259+
version: string,
260+
uriPath: string
261+
): Promise<string | null> {
262+
const cacheKey = ExecutionContext.key(`stack-file:${registryUrl}:${stackName}:${version}:${uriPath}`);
263+
264+
// Check memory cache first
265+
if (this.executionContext && this.executionContext.has(cacheKey)) {
266+
return this.executionContext.get(cacheKey);
267+
}
268+
269+
// Check filesystem cache
270+
const cachedContent = await this.getFromFileCache(stackName, version, uriPath);
271+
if (cachedContent) {
272+
this.executionContext.set(cacheKey, cachedContent);
273+
return cachedContent;
274+
}
275+
276+
// Download from GitHub registry repository
277+
try {
278+
const content = await this.downloadFromRegistryRepo(stackName, version, uriPath);
279+
280+
// Cache in memory and filesystem
281+
this.executionContext.set(cacheKey, content);
282+
await this.saveToFileCache(stackName, version, uriPath, content);
283+
284+
return content;
285+
} catch (error) {
286+
console.error(`Failed to download stack file ${uriPath}:`, error);
287+
return null;
288+
}
289+
}
290+
291+
/**
292+
* Download a file from the devfile registry GitHub repository
293+
*/
294+
private async downloadFromRegistryRepo(
295+
stackName: string,
296+
version: string,
297+
uriPath: string
298+
): Promise<string> {
299+
const url = `https://raw.githubusercontent.com/devfile/registry/main/stacks/${stackName}/${version}/${uriPath}`;
300+
return DevfileRegistry._get(url);
301+
}
302+
303+
/**
304+
* Get cached file content from filesystem.
305+
* Cache location follows XDG Base Directory spec on Linux/macOS,
306+
* and AppData on Windows (via os.homedir() + .local/state)
307+
*/
308+
private async getFromFileCache(
309+
stackName: string,
310+
version: string,
311+
uriPath: string
312+
): Promise<string | null> {
313+
try {
314+
const fs = await import('fs/promises');
315+
const path = await import('path');
316+
const os = await import('os');
317+
318+
// Use .local/state for consistency with extension's tools storage
319+
// This works cross-platform: ~/.local/state on Unix, %USERPROFILE%\.local\state on Windows
320+
const cacheDir = path.join(
321+
os.homedir(),
322+
'.local',
323+
'state',
324+
EXTENSION_CACHE_DIR,
325+
'devfile-registry-cache',
326+
stackName,
327+
version
328+
);
329+
const filePath = path.join(cacheDir, uriPath);
330+
331+
const content = await fs.readFile(filePath, 'utf-8');
332+
return content;
333+
} catch {
334+
return null;
335+
}
336+
}
337+
338+
/**
339+
* Save file content to filesystem cache.
340+
* Cache location follows XDG Base Directory spec on Linux/macOS,
341+
* and AppData on Windows (via os.homedir() + .local/state)
342+
*/
343+
private async saveToFileCache(
344+
stackName: string,
345+
version: string,
346+
uriPath: string,
347+
content: string
348+
): Promise<void> {
349+
try {
350+
const fs = await import('fs/promises');
351+
const path = await import('path');
352+
const os = await import('os');
353+
354+
// Use .local/state for consistency with extension's tools storage
355+
// This works cross-platform: ~/.local/state on Unix, %USERPROFILE%\.local\state on Windows
356+
const cacheDir = path.join(
357+
os.homedir(),
358+
'.local',
359+
'state',
360+
EXTENSION_CACHE_DIR,
361+
'devfile-registry-cache',
362+
stackName,
363+
version
364+
);
365+
const filePath = path.join(cacheDir, uriPath);
366+
367+
await fs.mkdir(path.dirname(filePath), { recursive: true });
368+
await fs.writeFile(filePath, content, 'utf-8');
369+
} catch (error) {
370+
// Ignore cache write errors
371+
console.error('Failed to cache stack file:', error);
372+
}
373+
}
374+
375+
/**
376+
* Write a stack file to the project directory
377+
*/
378+
private async writeStackFile(
379+
projectPath: string,
380+
uriPath: string,
381+
content: string
382+
): Promise<void> {
383+
const fs = await import('fs/promises');
384+
const path = await import('path');
385+
386+
const fullPath = path.join(projectPath, uriPath);
387+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
388+
await fs.writeFile(fullPath, content, 'utf-8');
389+
}
390+
183391
/**
184392
* Clears the Execution context as well as all cached data
185393
*/

src/devfile/init.ts

Lines changed: 28 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,25 @@ 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+
await DevfileRegistry.Instance.downloadStackExtraFiles(
713+
registryUrl,
714+
stackName,
715+
version,
716+
devfile,
717+
ctx.projectPath
718+
);
719+
} catch (error) {
720+
logError(ctx, `Failed to download stack extra files: ${error?.message || error}`);
721+
// Don't fail the init - extra files are optional
722+
}
723+
}

0 commit comments

Comments
 (0)