Skip to content

Commit

Permalink
Merge pull request #866 from devcontainers/joshspicer/template-metadata
Browse files Browse the repository at this point in the history
add 'templates metadata' subcommand
  • Loading branch information
joshspicer authored Aug 12, 2024
2 parents 6c6aebf + a44bb9c commit ed43a5b
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 <target>', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler);
y.command('metadata <templateId>', 'Fetch a published Template\'s metadata', templateMetadataOptions, templateMetadataHandler);
y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler);
});
y.command(restArgs ? ['exec', '*'] : ['exec <cmd> [args..]'], 'Execute a command on a running dev container', execOptions, execHandler);
Expand Down
74 changes: 74 additions & 0 deletions src/spec-node/templatesCLI/metadata.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof templateMetadataOptions>>;

export function templateMetadataHandler(args: TemplateMetadataArgs) {
(async () => await templateMetadata(args))().catch(console.error);
}

async function templateMetadata({
'log-level': inputLogLevel,
'templateId': templateId,
}: TemplateMetadataArgs) {
const disposables: (() => Promise<unknown> | 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();
}
38 changes: 37 additions & 1 deletion src/test/container-templates/templatesCLICommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);

});
});

0 comments on commit ed43a5b

Please sign in to comment.