Skip to content

Commit

Permalink
Image metadata from features
Browse files Browse the repository at this point in the history
  • Loading branch information
chrmarti committed Sep 26, 2022
1 parent 1e80fd4 commit 00ad110
Show file tree
Hide file tree
Showing 13 changed files with 343 additions and 82 deletions.
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
"cwd": "${workspaceFolder}",
// "envFile": "${workspaceFolder}/.env", // Create .env file in the workspace folder to pass environment variables to the CLI.
"args": [
"up",
"build",
"--workspace-folder",
"${workspaceFolder}/src/test/configs/example", // Edit this example config to test the CLI against a custom configuration.
"${workspaceFolder}/../../smktst", // Edit this example config to test the CLI against a custom configuration.
"--log-level",
"debug",
],
Expand Down
1 change: 1 addition & 0 deletions src/spec-common/injectHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface ResolverParameters {
buildxPush: boolean;
skipFeatureAutoMapping: boolean;
skipPostAttach: boolean;
experimentalImageMetadata: boolean;
}

export interface PostCreate {
Expand Down
2 changes: 2 additions & 0 deletions src/spec-configuration/containerFeaturesConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ COPY --from=dev_containers_feature_content_source {contentSourceRootPath} /tmp/b
ARG _DEV_CONTAINERS_IMAGE_USER=root
USER $_DEV_CONTAINERS_IMAGE_USER
#{devcontainerMetadata}
`;
}

Expand Down
33 changes: 20 additions & 13 deletions src/spec-node/containerFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import { LogLevel, makeLog, toErrorText } from '../spec-utils/log';
import { FeaturesConfig, getContainerFeaturesFolder, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, getSourceInfoString, collapseFeaturesConfig, Feature, multiStageBuildExploration, V1_DEVCONTAINER_FEATURES_FILE_NAME } from '../spec-configuration/containerFeaturesConfiguration';
import { readLocalFile } from '../spec-utils/pfs';
import { includeAllConfiguredFeatures } from '../spec-utils/product';
import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, inspectDockerImage, getEmptyContextFolder } from './utils';
import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder } from './utils';
import { isEarlierVersion, parseVersion } from '../spec-common/commonUtils';
import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo } from './imageMetadata';

// Escapes environment variable keys.
//
Expand All @@ -27,20 +28,18 @@ export const getSafeId = (str: string) => str
.replace(/^[\d_]+/g, '_')
.toUpperCase();

export async function extendImage(params: DockerResolverParameters, config: DevContainerConfig, imageName: string, pullImageOnError: boolean) {
let cache: Promise<ImageDetails> | undefined;
export async function extendImage(params: DockerResolverParameters, config: DevContainerConfig, imageName: string) {
const { common } = params;
const { cliHost, output } = common;
const imageDetails = () => cache || (cache = inspectDockerImage(params, imageName, pullImageOnError));

const imageUser = async () => (await imageDetails()).Config.User || 'root';
const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageUser);
const imageBuildInfo = await getImageBuildInfoFromImage(params, imageName, common.experimentalImageMetadata);
const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo);
if (!extendImageDetails || !extendImageDetails.featureBuildInfo) {
// no feature extensions - return
return {
updatedImageName: [imageName],
collapsedFeaturesConfig: undefined,
imageDetails
imageMetadata: imageBuildInfo.metadata,
imageDetails: async () => imageBuildInfo.imageDetails,
};
}
const { featureBuildInfo, collapsedFeaturesConfig } = extendImageDetails;
Expand Down Expand Up @@ -87,10 +86,17 @@ export async function extendImage(params: DockerResolverParameters, config: DevC
const infoParams = { ...toExecParameters(params), output: makeLog(output, LogLevel.Info), print: 'continuous' as 'continuous' };
await dockerCLI(infoParams, ...args);
}
return { updatedImageName:[updatedImageName], collapsedFeaturesConfig, imageDetails };
return {
updatedImageName: [ updatedImageName ],
imageMetadata: [
...imageBuildInfo.metadata,
...getDevcontainerMetadata(collapsedFeaturesConfig.allFeatures),
],
imageDetails: async () => imageBuildInfo.imageDetails,
};
}

export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: DevContainerConfig, baseName: string, imageUser: () => Promise<string>) {
export async function getExtendImageBuildInfo(params: DockerResolverParameters, config: DevContainerConfig, baseName: string, imageBuildInfo: ImageBuildInfo) {

// Creates the folder where the working files will be setup.
const tempFolder = await createFeaturesTempFolder(params.common);
Expand All @@ -106,7 +112,7 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters,

// Generates the end configuration.
const collapsedFeaturesConfig = collapseFeaturesConfig(featuresConfig);
const featureBuildInfo = await getContainerFeaturesBuildInfo(params, featuresConfig, baseName, imageUser);
const featureBuildInfo = await getContainerFeaturesBuildInfo(params, featuresConfig, baseName, imageBuildInfo);
if (!featureBuildInfo) {
return null;
}
Expand Down Expand Up @@ -174,7 +180,7 @@ async function createLocalFeatures(params: DockerResolverParameters, dstFolder:
await createExit; // Allow errors to surface.
}

async function getContainerFeaturesBuildInfo(params: DockerResolverParameters, featuresConfig: FeaturesConfig, baseName: string, imageUser: () => Promise<string>): Promise<{ dstFolder: string; dockerfileContent: string; overrideTarget: string; dockerfilePrefixContent: string; buildArgs: Record<string, string>; buildKitContexts: Record<string, string> } | null> {
async function getContainerFeaturesBuildInfo(params: DockerResolverParameters, featuresConfig: FeaturesConfig, baseName: string, imageBuildInfo: ImageBuildInfo): Promise<{ dstFolder: string; dockerfileContent: string; overrideTarget: string; dockerfilePrefixContent: string; buildArgs: Record<string, string>; buildKitContexts: Record<string, string> } | null> {
const { common } = params;
const { cliHost, output } = common;
const { dstFolder } = featuresConfig;
Expand Down Expand Up @@ -219,6 +225,7 @@ async function getContainerFeaturesBuildInfo(params: DockerResolverParameters, f
.replace('#{featureLayer}', getFeatureLayers(featuresConfig))
.replace('#{containerEnv}', generateContainerEnvs(featuresConfig))
.replace('#{copyFeatureBuildStages}', getCopyFeatureBuildStages(featuresConfig, buildStageScripts))
.replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageBuildInfo.metadata, featuresConfig, common.experimentalImageMetadata))
;
const dockerfilePrefixContent = `${useBuildKitBuildContexts ? '# syntax=docker/dockerfile:1.4' : ''}
ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
Expand Down Expand Up @@ -297,7 +304,7 @@ ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
dockerfilePrefixContent,
buildArgs: {
_DEV_CONTAINERS_BASE_IMAGE: baseName,
_DEV_CONTAINERS_IMAGE_USER: await imageUser(),
_DEV_CONTAINERS_IMAGE_USER: imageBuildInfo.user,
_DEV_CONTAINERS_FEATURE_CONTENT_SOURCE: buildContentImageName,
},
buildKitContexts: useBuildKitBuildContexts ? { dev_containers_feature_content_source: dstFolder } : {},
Expand Down
2 changes: 2 additions & 0 deletions src/spec-node/devContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface ProvisionOptions {
buildxPush: boolean;
skipFeatureAutoMapping: boolean;
skipPostAttach: boolean;
experimentalImageMetadata: boolean;
}

export async function launch(options: ProvisionOptions, disposables: (() => Promise<unknown> | undefined)[]) {
Expand Down Expand Up @@ -124,6 +125,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables:
buildxPush: options.buildxPush,
skipFeatureAutoMapping: options.skipFeatureAutoMapping,
skipPostAttach: options.skipPostAttach,
experimentalImageMetadata: options.experimentalImageMetadata,
};

const dockerPath = options.dockerPath || 'docker';
Expand Down
54 changes: 49 additions & 5 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ import { bailOut, buildNamedImageAndExtend, findDevContainer, hostFolderLabel }
import { extendImage } from './containerFeatures';
import { DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils';
import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig } from './dockerCompose';
import { getDockerComposeFilePaths } from '../spec-configuration/configuration';
import { DevContainerConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration';
import { workspaceFromPath } from '../spec-utils/workspaces';
import { readDevContainerConfigFile } from './configContainer';
import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils';
import { getCLIHost } from '../spec-common/cliHost';
import { loadNativeModule } from '../spec-common/commonUtils';
import { generateFeaturesConfig, getContainerFeaturesFolder } from '../spec-configuration/containerFeaturesConfiguration';
import { FeaturesConfig, generateFeaturesConfig, getContainerFeaturesFolder } from '../spec-configuration/containerFeaturesConfiguration';
import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test';
import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package';
import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish';
import { featuresInfoHandler, featuresInfoOptions } from './featuresCLI/info';
import { containerSubstitute } from '../spec-common/variableSubstitution';
import { getPackageConfig } from '../spec-utils/product';
import { getPackageConfig, PackageConfiguration } from '../spec-utils/product';
import { getImageBuildInfo } from './imageMetadata';

const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';
const experimentalImageMetadataDefault = true; // TODO

const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,external=(true|false))?$/;

Expand Down Expand Up @@ -102,6 +104,7 @@ function provisionOptions(y: Argv) {
'buildkit': { choices: ['auto' as 'auto', 'never' as 'never'], default: 'auto' as 'auto', description: 'Control whether BuildKit should be used' },
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
'skip-post-attach': { type: 'boolean', default: false, description: 'Do not run postAttachCommand.' },
'experimental-image-metadata': { type: 'boolean', default: experimentalImageMetadataDefault, hidden: true, description: 'Temporary option for testing.' },
})
.check(argv => {
const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined;
Expand Down Expand Up @@ -162,6 +165,7 @@ async function provision({
'buildkit': buildkit,
'skip-feature-auto-mapping': skipFeatureAutoMapping,
'skip-post-attach': skipPostAttach,
'experimental-image-metadata': experimentalImageMetadata,
}: ProvisionArgs) {

const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined;
Expand Down Expand Up @@ -207,6 +211,7 @@ async function provision({
buildxPush: false,
skipFeatureAutoMapping,
skipPostAttach,
experimentalImageMetadata,
};

const result = await doProvision(options);
Expand Down Expand Up @@ -267,6 +272,7 @@ function buildOptions(y: Argv) {
'platform': { type: 'string', description: 'Set target platforms.' },
'push': { type: 'boolean', default: false, description: 'Push to a container registry.' },
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
'experimental-image-metadata': { type: 'boolean', default: experimentalImageMetadataDefault, hidden: true, description: 'Temporary option for testing.' },
});
}

Expand Down Expand Up @@ -298,6 +304,7 @@ async function doBuild({
'platform': buildxPlatform,
'push': buildxPush,
'skip-feature-auto-mapping': skipFeatureAutoMapping,
'experimental-image-metadata': experimentalImageMetadata,
}: BuildArgs) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
Expand Down Expand Up @@ -339,6 +346,7 @@ async function doBuild({
buildxPush,
skipFeatureAutoMapping,
skipPostAttach: true,
experimentalImageMetadata,
}, disposables);

const { common, dockerCLI, dockerComposeCLI } = params;
Expand Down Expand Up @@ -411,7 +419,7 @@ async function doBuild({
} else {

await dockerPtyCLI(params, 'pull', config.image);
const { updatedImageName } = await extendImage(params, config, config.image, 'image' in config);
const { updatedImageName } = await extendImage(params, config, config.image);

if (buildxPlatform || buildxPush) {
throw new ContainerError({ description: '--platform or --push require dockerfilePath.' });
Expand Down Expand Up @@ -471,6 +479,7 @@ function runUserCommandsOptions(y: Argv) {
'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' },
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
'skip-post-attach': { type: 'boolean', default: false, description: 'Do not run postAttachCommand.' },
'experimental-image-metadata': { type: 'boolean', default: experimentalImageMetadataDefault, hidden: true, description: 'Temporary option for testing.' },
})
.check(argv => {
const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined;
Expand Down Expand Up @@ -521,6 +530,7 @@ async function doRunUserCommands({
'remote-env': addRemoteEnv,
'skip-feature-auto-mapping': skipFeatureAutoMapping,
'skip-post-attach': skipPostAttach,
'experimental-image-metadata': experimentalImageMetadata,
}: RunUserCommandsArgs) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
Expand Down Expand Up @@ -563,6 +573,7 @@ async function doRunUserCommands({
buildxPush: false,
skipFeatureAutoMapping,
skipPostAttach,
experimentalImageMetadata,
}, disposables);

const { common } = params;
Expand Down Expand Up @@ -627,6 +638,7 @@ function readConfigurationOptions(y: Argv) {
'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' },
'include-features-configuration': { type: 'boolean', default: false, description: 'Include features configuration.' },
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
'experimental-image-metadata': { type: 'boolean', default: experimentalImageMetadataDefault, hidden: true, description: 'Temporary option for testing.' },
})
.check(argv => {
const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined;
Expand Down Expand Up @@ -659,6 +671,7 @@ async function readConfiguration({
'terminal-columns': terminalColumns,
'include-features-configuration': includeFeaturesConfig,
'skip-feature-auto-mapping': skipFeatureAutoMapping,
'experimental-image-metadata': experimentalImageMetadata,
}: ReadConfigurationArgs) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
Expand Down Expand Up @@ -711,7 +724,7 @@ async function readConfiguration({
configuration = containerSubstitute(cliHost.platform, configuration.configFilePath, envListToObj(container.Config.Env), configuration);
}

const featuresConfiguration = includeFeaturesConfig ? await generateFeaturesConfig({ extensionPath, cwd, output, env: cliHost.env, skipFeatureAutoMapping }, (await createFeaturesTempFolder({ cliHost, package: pkg })), configs.config, getContainerFeaturesFolder) : undefined;
const featuresConfiguration = includeFeaturesConfig ? await readFeaturesConfig(params, pkg, configs.config, extensionPath, skipFeatureAutoMapping, experimentalImageMetadata) : undefined;
await new Promise<void>((resolve, reject) => {
process.stdout.write(JSON.stringify({
configuration,
Expand All @@ -732,6 +745,34 @@ async function readConfiguration({
process.exit(0);
}

async function readFeaturesConfig(params: DockerCLIParameters, pkg: PackageConfiguration, config: DevContainerConfig, extensionPath: string, skipFeatureAutoMapping: boolean, experimentalImageMetadata: boolean): Promise<FeaturesConfig | undefined> {
const { cliHost, output } = params;
const { cwd, env } = cliHost;
const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg });
const configs = await generateFeaturesConfig({ extensionPath, cwd, output, env, skipFeatureAutoMapping }, featuresTmpFolder, config, getContainerFeaturesFolder);
if (!experimentalImageMetadata) {
return configs;
}
// TODO: Move to separate property or command. For demoing experimental image metadata.
const imageBuildInfo = await getImageBuildInfo(params, config, experimentalImageMetadata);
if (!imageBuildInfo.metadata.length) {
return configs;
}
return {
...configs,
featureSets: [
{
features: imageBuildInfo.metadata as any,
sourceInformation: {
type: 'local-cache',
userFeatureId: 'none'
}
},
...configs?.featureSets || [],
]
};
}

function execOptions(y: Argv) {
return y.options({
'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' },
Expand All @@ -752,6 +793,7 @@ function execOptions(y: Argv) {
'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' },
'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' },
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
'experimental-image-metadata': { type: 'boolean', default: experimentalImageMetadataDefault, hidden: true, description: 'Temporary option for testing.' },
})
.positional('cmd', {
type: 'string',
Expand Down Expand Up @@ -809,6 +851,7 @@ export async function doExec({
'default-user-env-probe': defaultUserEnvProbe,
'remote-env': addRemoteEnv,
'skip-feature-auto-mapping': skipFeatureAutoMapping,
'experimental-image-metadata': experimentalImageMetadata,
_: restArgs,
}: ExecArgs & { _?: string[] }) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
Expand Down Expand Up @@ -853,6 +896,7 @@ export async function doExec({
buildxPush: false,
skipFeatureAutoMapping,
skipPostAttach: false,
experimentalImageMetadata,
}, disposables);

const { common } = params;
Expand Down
Loading

0 comments on commit 00ad110

Please sign in to comment.