From 4aa2a065034a59c06b1507825740d583723c441e Mon Sep 17 00:00:00 2001 From: Josh Spicer Date: Thu, 1 Aug 2024 17:03:27 +0000 Subject: [PATCH 1/2] add 'templates metadata' subcommand --- src/spec-node/devContainersSpecCLI.ts | 2 + src/spec-node/templatesCLI/metadata.ts | 74 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/spec-node/templatesCLI/metadata.ts diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 6f48f8df2..b4f71a801 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -44,6 +44,7 @@ import { readFeaturesConfig } from './featureUtils'; import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs'; import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -85,6 +86,7 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa y.command('templates', 'Templates commands', (y: Argv) => { y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler); y.command('publish ', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler); + y.command('metadata ', 'Fetch a published Template\'s metadata', templateMetadataOptions, templateMetadataHandler); y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler); }); y.command(restArgs ? ['exec', '*'] : ['exec [args..]'], 'Execute a command on a running dev container', execOptions, execHandler); diff --git a/src/spec-node/templatesCLI/metadata.ts b/src/spec-node/templatesCLI/metadata.ts new file mode 100644 index 000000000..38d3c88f7 --- /dev/null +++ b/src/spec-node/templatesCLI/metadata.ts @@ -0,0 +1,74 @@ +import { Argv } from 'yargs'; +import { LogLevel, mapLogLevel } from '../../spec-utils/log'; +import { getPackageConfig } from '../../spec-utils/product'; +import { createLog } from '../devContainers'; +import { fetchOCIManifestIfExists, getRef } from '../../spec-configuration/containerCollectionsOCI'; + +import { UnpackArgv } from '../devContainersSpecCLI'; + +export function templateMetadataOptions(y: Argv) { + return y + .options({ + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }, + }) + .positional('templateId', { type: 'string', demandOption: true, description: 'Template Identifier' }); +} + +export type TemplateMetadataArgs = UnpackArgv>; + +export function templateMetadataHandler(args: TemplateMetadataArgs) { + (async () => await templateMetadata(args))().catch(console.error); +} + +async function templateMetadata({ + 'log-level': inputLogLevel, + 'templateId': templateId, +}: TemplateMetadataArgs) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + + const pkg = getPackageConfig(); + + const output = createLog({ + logLevel: mapLogLevel(inputLogLevel), + logFormat: 'text', + log: (str) => process.stderr.write(str), + terminalDimensions: undefined, + }, pkg, new Date(), disposables); + + const params = { output, env: process.env }; + output.write(`Fetching metadata for ${templateId}`, LogLevel.Trace); + + const templateRef = getRef(output, templateId); + if (!templateRef) { + console.log(JSON.stringify({})); + process.exit(1); + } + + const manifestContainer = await fetchOCIManifestIfExists(params, templateRef, undefined); + if (!manifestContainer) { + console.log(JSON.stringify({})); + process.exit(1); + } + + const { manifestObj, canonicalId } = manifestContainer; + output.write(`Template '${templateId}' resolved to '${canonicalId}'`, LogLevel.Trace); + + // Templates must have been published with a CLI post commit + // https://github.com/devcontainers/cli/commit/6c6aebfa7b74aea9d67760fd1e74b09573d31536 + // in order to contain attached metadata. + const metadata = manifestObj.annotations?.['dev.containers.metadata']; + if (!metadata) { + output.write(`Template resolved to '${canonicalId}' but does not contain metadata on its manifest.`, LogLevel.Warning); + output.write(`Ask the Template owner to republish this Template to populate the manifest.`, LogLevel.Warning); + console.log(JSON.stringify({})); + process.exit(1); + } + + const unescaped = JSON.parse(metadata); + console.log(JSON.stringify(unescaped)); + await dispose(); + process.exit(); +} From a44bb9cb5409e6cb6e860014ea99af85734e53a4 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 9 Aug 2024 16:40:54 -0400 Subject: [PATCH 2/2] add test --- .../templatesCLICommands.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/test/container-templates/templatesCLICommands.test.ts b/src/test/container-templates/templatesCLICommands.test.ts index c4b563f19..1d3b86a8f 100644 --- a/src/test/container-templates/templatesCLICommands.test.ts +++ b/src/test/container-templates/templatesCLICommands.test.ts @@ -17,7 +17,7 @@ const pkg = require('../../../package.json'); describe('tests apply command', async function () { this.timeout('120s'); - const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp4')); + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp6')); const cli = `npx --prefix ${tmp} devcontainer`; before('Install', async () => { @@ -197,3 +197,39 @@ describe('tests generateTemplateDocumentation()', async function () { assert.isFalse(invalidDocsExists); }); }); + +describe('template metadata', async function () { + this.timeout('120s'); + + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp7')); + const cli = `npx --prefix ${tmp} devcontainer`; + + // https://github.com/codspace/templates/pkgs/container/templates%2Fmytemplate/255979159?tag=1.0.4 + const templateId = 'ghcr.io/codspace/templates/mytemplate@sha256:57cbf968907c74c106b7b2446063d114743ab3f63345f7c108c577915c535185'; + + before('Install', async () => { + await shellExec(`rm -rf ${tmp}/node_modules`); + await shellExec(`rm -rf ${tmp}/output`); + await shellExec(`mkdir -p ${tmp}`); + await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); + }); + + it('successfully fetches metdata off a published Template', async function () { + let success = false; + let result: ExecResult | undefined = undefined; + try { + result = await shellExec(`${cli} templates metadata ${templateId} --log-level trace`); + success = true; + + } catch (error) { + assert.fail('features test sub-command should not throw'); + } + + assert.isTrue(success); + assert.isDefined(result); + const json = JSON.parse(result.stdout); + assert.strictEqual('mytemplate', json.id); + assert.strictEqual('Simple test', json.description); + + }); +}); \ No newline at end of file