diff --git a/extensions/vscode-containers/package.json b/extensions/vscode-containers/package.json index ce771c37..87b0b84f 100644 --- a/extensions/vscode-containers/package.json +++ b/extensions/vscode-containers/package.json @@ -438,27 +438,27 @@ }, { "command": "vscode-containers.containers.composeGroup.logs", - "when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i", + "when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)", "group": "composeGroup_1_general@1" }, { "command": "vscode-containers.containers.composeGroup.start", - "when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i", + "when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)", "group": "composeGroup_1_general@2" }, { "command": "vscode-containers.containers.composeGroup.stop", - "when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i", + "when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)", "group": "composeGroup_1_general@3" }, { "command": "vscode-containers.containers.composeGroup.restart", - "when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i", + "when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)", "group": "composeGroup_2_destructive@1" }, { "command": "vscode-containers.containers.composeGroup.down", - "when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i", + "when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)", "group": "composeGroup_2_destructive@2" }, { @@ -1846,7 +1846,7 @@ "default": [ { "label": "Compose Logs", - "template": "${composeCommand} ${configurationFile} ${projectName} ${environmentFile} logs --tail 1000 -f" + "template": "${composeCommand} ${profileList} ${configurationFile} ${projectName} ${environmentFile} logs --tail 1000 -f ${serviceList}" } ], "description": "%vscode-containers.config.template.composeLogs.description%", diff --git a/extensions/vscode-containers/src/commands/containers/composeGroup.ts b/extensions/vscode-containers/src/commands/containers/composeGroup.ts index 17dc9729..acc58d76 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -1,145 +1,228 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.md in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IActionContext } from '@microsoft/vscode-azext-utils'; -import { CommonOrchestratorCommandOptions, IContainerOrchestratorClient, LogsCommandOptions, VoidCommandResponse } from '@microsoft/vscode-container-client'; -import * as path from 'path'; -import { l10n, Uri, workspace } from 'vscode'; -import { ext } from '../../extensionVariables'; -import { TaskCommandRunnerFactory } from '../../runtimes/runners/TaskCommandRunnerFactory'; -import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTreeItem'; -import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; -import { selectComposeLogsCommand } from '../selectCommandTemplate'; - -export async function composeGroupLogs(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, async (client, options) => { - const labels = await getComposeGroupLabels(node); - const workingDirectory = labels && getComposeWorkingDirectory(labels); - - if (!workingDirectory) { - context.errorHandling.suppressReportIssue = true; - throw new Error(l10n.t('Unable to determine compose project info for container group \'{0}\'.', node.label)); - } - - const folder = workspace.getWorkspaceFolder(Uri.file(workingDirectory)) ?? Uri.file(workingDirectory); - - const composeFilesString = options.files?.map(file => `-f "${file}"`).join(' '); - return selectComposeLogsCommand(context, folder, composeFilesString, options.projectName, options.environmentFile); - }, node, { follow: true, tail: 1000 }); -} -export async function composeGroupStart(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.start(options), node); -} - -export async function composeGroupStop(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.stop(options), node); -} - -export async function composeGroupRestart(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.restart(options), node); -} - -export async function composeGroupDown(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.down(options), node); -} - -type AdditionalOptions = Omit; - -async function composeGroup( - context: IActionContext, - composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, - node: ContainerGroupTreeItem, - additionalOptions?: AdditionalOptions -): Promise { - if (!node) { - await ext.containersTree.refresh(context); - node = await ext.containersTree.showTreeItemPicker(/composeGroup$/i, { - ...context, - noItemFoundErrorMessage: l10n.t('No compose projects are running.'), - }); - } - - const labels = await getComposeGroupLabels(node); - - const workingDirectory = labels && getComposeWorkingDirectory(labels); - const orchestratorFiles = labels && getComposeFiles(labels); - const projectName = labels && getComposeProjectName(labels); - const envFile = labels && getComposeEnvFile(labels); - - if (!workingDirectory || !orchestratorFiles || !projectName) { - context.errorHandling.suppressReportIssue = true; - throw new Error(l10n.t('Unable to determine compose project info for container group \'{0}\'.', node.label)); - } - - const options: TOptions = { - files: orchestratorFiles, - projectName: projectName, - environmentFile: envFile, - ...additionalOptions, - } as TOptions; - - const client = await ext.orchestratorManager.getClient(); - const taskCRF = new TaskCommandRunnerFactory({ - taskName: client.displayName, - cwd: workingDirectory, - }); - - await taskCRF.getCommandRunner()(composeCommandCallback(client, options)); -} - -/** - * Gets the accurate label map for a compose container group. - * - * The tree's list-derived labels (from `docker container ls`) join all labels into - * a single comma-separated string with no escaping, which corrupts any label *value* - * that itself contains commas--most importantly `com.docker.compose.project.config_files` - * when a project was started with multiple `-f` files. The label *keys* survive that - * parsing, so we can still locate a container in the group from the list labels, but we - * must `inspect` it to recover the accurate, verbatim label values (compose files, etc). - */ -async function getComposeGroupLabels(node: ContainerGroupTreeItem): Promise<{ [key: string]: string } | undefined> { - // Find a container in the group that carries the compose project config files label - const container = (node.ChildTreeItems as ContainerTreeItem[]).find(c => c.labels?.['com.docker.compose.project.config_files']); - - if (!container) { - return undefined; - } - - const inspectResult = await ext.runWithDefaults(client => - client.inspectContainers({ containers: [container.containerId] }) - ); - - return inspectResult?.[0]?.labels; -} - -// Exported only for unit testing; not intended to be called outside this module. -export function getComposeWorkingDirectory(labels: { [key: string]: string }): string | undefined { - // The `com.docker.compose.project.working_dir` label gives the working directory in which to execute the compose command - return labels['com.docker.compose.project.working_dir'] || undefined; -} - -// Exported only for unit testing; not intended to be called outside this module. -export function getComposeFiles(labels: { [key: string]: string }): string[] | undefined { - // The `com.docker.compose.project.config_files` label gives all the compose files (within the working directory) used to up this container - - // Paths may be subpaths, but working dir generally always directly contains the config files, so unless the file is already absolute, let's cut off the subfolder and get just the file name - // (In short, the working dir may not be the same as the cwd when the docker-compose up command was called, BUT the files are relative to that cwd) - // Note, it appears compose v2 *always* uses absolute paths, both for this and `working_dir` - return labels['com.docker.compose.project.config_files'] - ?.split(',') - ?.map(f => path.isAbsolute(f) ? f : path.parse(f).base); -} - -// Exported only for unit testing; not intended to be called outside this module. -export function getComposeProjectName(labels: { [key: string]: string }): string | undefined { - // The `com.docker.compose.project` label gives the project name - return labels['com.docker.compose.project'] || undefined; -} - -// Exported only for unit testing; not intended to be called outside this module. -export function getComposeEnvFile(labels: { [key: string]: string }): string | undefined { - // The `com.docker.compose.project.environment_file` label gives the environment file absolute path - return labels['com.docker.compose.project.environment_file'] || undefined; -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import { CommonOrchestratorCommandOptions, IContainerOrchestratorClient, LogsCommandOptions, VoidCommandResponse } from '@microsoft/vscode-container-client'; +import { l10n, Uri, workspace } from 'vscode'; +import { ext } from '../../extensionVariables'; +import { TaskCommandRunnerFactory } from '../../runtimes/runners/TaskCommandRunnerFactory'; +import { ComposeProfileGroupTreeItem } from '../../tree/containers/ComposeProfileGroupTreeItem'; +import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTreeItem'; +import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; +import { ComposeConfigFilesLabel, getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory } from '../../utils/composeLabels'; +import { selectComposeLogsCommand } from '../selectCommandTemplate'; + +type ComposeGroupNode = ContainerGroupTreeItem | ComposeProfileGroupTreeItem; + +export async function composeGroupLogs(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, async (client, options) => { + const labels = await getComposeGroupLabels(node); + const workingDirectory = labels && getComposeWorkingDirectory(labels); + + if (!workingDirectory) { + context.errorHandling.suppressReportIssue = true; + throw new Error(l10n.t('Unable to determine compose project info for group \'{0}\'.', getProjectLabel(node))); + } + + const folder = workspace.getWorkspaceFolder(Uri.file(workingDirectory)) ?? Uri.file(workingDirectory); + + const composeFilesString = options.files?.map(file => `-f "${file}"`).join(' '); + const profileListString = options.profiles?.map(profile => `--profile "${profile}"`).join(' '); + const serviceListString = options.services?.join(' '); + return selectComposeLogsCommand(context, folder, composeFilesString, options.projectName, options.environmentFile, profileListString, serviceListString); + }, node, 'logs', { follow: true, tail: 1000 }); +} + +export async function composeGroupStart(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.start(options), node, 'start'); +} + +export async function composeGroupStop(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.stop(options), node, 'stop'); +} + +export async function composeGroupRestart(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.restart(options), node, 'restart'); +} + +export async function composeGroupDown(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.down(options), node, 'down'); +} + +type AdditionalOptions = Omit; + +async function composeGroup( + context: IActionContext, + composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, + node: ComposeGroupNode, + commandName: string = '', + additionalOptions?: AdditionalOptions +): Promise { + if (!node) { + await ext.containersTree.refresh(context); + node = await ext.containersTree.showTreeItemPicker(/composeGroup$/i, { + ...context, + noItemFoundErrorMessage: l10n.t('No compose projects are running.'), + }); + } + + const labels = await getComposeGroupLabels(node); + + const workingDirectory = labels && getComposeWorkingDirectory(labels); + const orchestratorFiles = labels && getComposeFiles(labels); + const projectName = labels && getComposeProjectName(labels); + const envFile = labels && getComposeEnvFile(labels); + + if (!workingDirectory || !orchestratorFiles || !projectName) { + context.errorHandling.suppressReportIssue = true; + throw new Error(l10n.t('Unable to determine compose project info for group \'{0}\'.', getProjectLabel(node))); + } + + const { profileArg, servicesArg } = await resolveComposeProfileArguments(context, node, commandName); + + const options: TOptions = { + files: orchestratorFiles, + projectName: projectName, + environmentFile: envFile, + ...(profileArg ? { profiles: profileArg } : {}), + ...(servicesArg?.length ? { services: servicesArg } : {}), + ...additionalOptions, + } as TOptions; + + const client = await ext.orchestratorManager.getClient(); + const taskCRF = new TaskCommandRunnerFactory({ + taskName: client.displayName, + cwd: workingDirectory, + }); + + await taskCRF.getCommandRunner()(composeCommandCallback(client, options)); +} + +/** + * Gets the accurate label map for a compose container group. + * + * The tree's list-derived labels (from `docker container ls`) join all labels into + * a single comma-separated string with no escaping, which corrupts any label *value* + * that itself contains commas--most importantly `com.docker.compose.project.config_files` + * when a project was started with multiple `-f` files. The label *keys* survive that + * parsing, so we can still locate a container in the group from the list labels, but we + * must `inspect` it to recover the accurate, verbatim label values (compose files, etc). + */ +// Exported only for unit testing; not intended to be called outside this module. +export function getProjectLabel(node: ComposeGroupNode): string { + if (node instanceof ComposeProfileGroupTreeItem && node.parent?.label) { + return node.parent.label; + } + return node.label; +} + +// Exported only for unit testing; not intended to be called outside this module. +export function findContainerWithComposeConfig(node: ComposeGroupNode): ContainerTreeItem | undefined { + // Find a container in the group that carries the compose project config files label. + // For ComposeProfileGroupTreeItem the direct children are ContainerTreeItem instances. + // For ContainerGroupTreeItem with profile sub-groups the direct children may be + // ComposeProfileGroupTreeItem instances, so we search one level deeper in that case. + let container = (node.ChildTreeItems as ContainerTreeItem[]) + .find(c => c instanceof ContainerTreeItem && c.labels?.[ComposeConfigFilesLabel]) as ContainerTreeItem | undefined; + + if (!container && node instanceof ContainerGroupTreeItem) { + // ContainerGroupTreeItem may have ComposeProfileGroupTreeItem children; search their children too + for (const child of node.ChildTreeItems) { + if (child instanceof ComposeProfileGroupTreeItem) { + container = (child.ChildTreeItems as ContainerTreeItem[]) + .find(c => c instanceof ContainerTreeItem && c.labels?.[ComposeConfigFilesLabel]) as ContainerTreeItem | undefined; + if (container) { + break; + } + } + } + } + + return container; +} + +async function getComposeGroupLabels(node: ComposeGroupNode): Promise<{ [key: string]: string } | undefined> { + const container = findContainerWithComposeConfig(node); + if (!container) { + return undefined; + } + + const inspectResult = await ext.runWithDefaults(client => + client.inspectContainers({ containers: [container.containerId] }) + ); + + return inspectResult?.[0]?.labels; +} + +/** + * Prompts the user to choose how the compose action should apply to a profile. + * Returns 'profile' to use the --profile flag (includes default services too), + * 'services' to apply only to the specific services in this profile, + * or 'exclusive' to apply only to services that belong strictly to this profile. + */ +// Exported only for unit testing; not intended to be called outside this module. +export async function pickComposeProfileCommandScope(context: IActionContext, node: ComposeProfileGroupTreeItem, commandName: string): Promise<'profile' | 'services' | 'exclusive'> { + const exclusiveNames = node.getExclusiveServiceNames(); + + const picks: IAzureQuickPickItem<'profile' | 'services' | 'exclusive'>[] = [ + { + label: l10n.t('Apply to this profile and default services'), + description: l10n.t('Runs: docker compose --profile {0} {1}', node.label, commandName), + data: 'profile' + }, + { + label: l10n.t('Apply only to services in this profile'), + description: l10n.t('Runs: docker compose {0} {1}', commandName, node.getServiceNames().join(' ')), + data: 'services' + }, + { + label: l10n.t('Apply only to exclusive services'), + description: exclusiveNames.length + ? l10n.t('Runs: docker compose {0} {1}', commandName, exclusiveNames.join(' ')) + : l10n.t('No services are exclusive to this profile'), + data: 'exclusive' + }, + ]; + + const selection = await context.ui.showQuickPick(picks, { + placeHolder: l10n.t('How should this compose action apply to profile "{0}"?', node.label), + }); + + return selection.data; +} + +// Exported only for unit testing; not intended to be called outside this module. +export async function resolveComposeProfileArguments( + context: IActionContext, + node: ComposeGroupNode, + commandName: string +): Promise<{ profileArg?: string[]; servicesArg?: string[] }> { + let profileArg: string[] | undefined; + let servicesArg: string[] | undefined; + + if (node instanceof ComposeProfileGroupTreeItem && node.profileName) { + // Ask the user whether to apply the command with the profile flag (which includes default + // services too), only to the explicit service names in this profile (excluding defaults), + // or strictly to services exclusive to this profile. + const scope = await pickComposeProfileCommandScope(context, node, commandName); + if (scope === 'profile') { + // Use --profile flag: command affects both this profile's services AND default services + profileArg = [node.profileName]; + } else if (scope === 'exclusive') { + // Use explicit service list for EXCLUSIVE services only + servicesArg = node.getExclusiveServiceNames(); + if (servicesArg.length === 0) { + context.errorHandling.suppressReportIssue = true; + throw new Error(l10n.t('There are no services exclusive to the "{0}" profile.', node.label)); + } + } else { + // Use explicit service list: command affects only the services belonging to this profile + servicesArg = node.getServiceNames(); + } + } + + return { profileArg, servicesArg }; +} + diff --git a/extensions/vscode-containers/src/commands/selectCommandTemplate.ts b/extensions/vscode-containers/src/commands/selectCommandTemplate.ts index 34d00bb6..11b60b4a 100644 --- a/extensions/vscode-containers/src/commands/selectCommandTemplate.ts +++ b/extensions/vscode-containers/src/commands/selectCommandTemplate.ts @@ -79,7 +79,7 @@ export async function selectLogsCommand(context: IActionContext, containerName: ); } -export async function selectComposeLogsCommand(context: IActionContext, folder: vscode.WorkspaceFolder | vscode.Uri, configurationFile?: string, projectName?: string, envFile?: string): Promise { +export async function selectComposeLogsCommand(context: IActionContext, folder: vscode.WorkspaceFolder | vscode.Uri, configurationFile?: string, projectName?: string, envFile?: string, profileList?: string, serviceList?: string): Promise { const orchestratorClient = await ext.orchestratorManager.getClient(); let fullComposeCommand: string; if (isComposeV2ableOrchestratorClient(orchestratorClient) && orchestratorClient.composeV2) { @@ -99,7 +99,9 @@ export async function selectComposeLogsCommand(context: IActionContext, folder: 'configurationFile': configurationFile || '', 'projectName': projectName ? `-p "${projectName}"` : '', 'environmentFile': envFile ? `--env-file "${envFile}"` : '', - 'composeCommand': fullComposeCommand + 'composeCommand': fullComposeCommand, + 'profileList': profileList || '', + 'serviceList': serviceList || '' } ); } diff --git a/extensions/vscode-containers/src/test/commands/composeGroup.test.ts b/extensions/vscode-containers/src/test/commands/composeGroup.test.ts index ba540469..5e0e63ef 100644 --- a/extensions/vscode-containers/src/test/commands/composeGroup.test.ts +++ b/extensions/vscode-containers/src/test/commands/composeGroup.test.ts @@ -3,124 +3,147 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; import { expect } from 'chai'; -import { getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory } from '../../commands/containers/composeGroup'; +import { findContainerWithComposeConfig, getProjectLabel, pickComposeProfileCommandScope, resolveComposeProfileArguments } from '../../commands/containers/composeGroup'; +import { ComposeProfileGroupTreeItem } from '../../tree/containers/ComposeProfileGroupTreeItem'; +import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTreeItem'; +import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; +import { ComposeConfigFilesLabel } from '../../utils/composeLabels'; + +function createMockProfileGroup(profileName: string | undefined, services: string[], exclusiveServices: string[]): ComposeProfileGroupTreeItem { + const node = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem; + Object.defineProperty(node, 'profileName', { value: profileName }); + Object.defineProperty(node, 'label', { value: profileName ?? 'Default' }); + node.getServiceNames = () => services; + node.getExclusiveServiceNames = () => exclusiveServices; + return node; +} + +function createMockContext(pickIndex: number, verifyPicks?: (picks: IAzureQuickPickItem<'profile' | 'services' | 'exclusive'>[]) => void): IActionContext { + return { + errorHandling: {}, + ui: { + showQuickPick: async (picks: IAzureQuickPickItem<'profile' | 'services' | 'exclusive'>[]) => { + if (verifyPicks) { + verifyPicks(picks); + } + return picks[pickIndex]; + } + } + } as unknown as IActionContext; +} suite("(unit) composeGroup", () => { - suite("getComposeFiles", () => { - test("Returns all files when multiple absolute config files are present", () => { - // Regression test for https://github.com/microsoft/vscode-containers/issues/522 - const labels = { - 'com.docker.compose.project.config_files': '/abs/path/docker-compose.base.yml,/abs/path/docker-compose.local.yml', - }; - - const result = getComposeFiles(labels); - - expect(result).to.deep.equal([ - '/abs/path/docker-compose.base.yml', - '/abs/path/docker-compose.local.yml', - ]); - }); + suite("profile sub-group utilities", () => { + test("getProjectLabel returns parent label for ComposeProfileGroupTreeItem", () => { + const parent = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem; + Object.defineProperty(parent, 'label', { value: 'my-compose-project' }); - test("Returns three files when three absolute config files are present", () => { - const labels = { - 'com.docker.compose.project.config_files': '/a/one.yml,/a/two.yml,/a/three.yml', - }; + const profileNode = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem; + Object.defineProperty(profileNode, 'label', { value: 'dev-profile' }); + Object.defineProperty(profileNode, 'parent', { value: parent }); - const result = getComposeFiles(labels); - - expect(result).to.deep.equal(['/a/one.yml', '/a/two.yml', '/a/three.yml']); + expect(getProjectLabel(profileNode)).to.equal('my-compose-project'); + expect(getProjectLabel(parent)).to.equal('my-compose-project'); }); - test("Returns a single absolute file unchanged", () => { - const labels = { - 'com.docker.compose.project.config_files': '/abs/path/docker-compose.yml', - }; - - const result = getComposeFiles(labels); + test("findContainerWithComposeConfig searches direct children and profile sub-groups", () => { + const containerWithLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(containerWithLabels, 'labels', { + value: { [ComposeConfigFilesLabel]: '/path/to/docker-compose.yml' } + }); - expect(result).to.deep.equal(['/abs/path/docker-compose.yml']); - }); + const containerWithoutLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(containerWithoutLabels, 'labels', { value: {} }); - test("Reduces relative paths to their basename", () => { - const labels = { - 'com.docker.compose.project.config_files': 'subdir/docker-compose.base.yml,subdir/docker-compose.local.yml', - }; + const profileGroup = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem; + Object.defineProperty(profileGroup, 'ChildTreeItems', { value: [containerWithoutLabels, containerWithLabels] }); - const result = getComposeFiles(labels); + const rootGroup = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem; + Object.defineProperty(rootGroup, 'ChildTreeItems', { value: [profileGroup] }); - expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']); + const result = findContainerWithComposeConfig(rootGroup); + expect(result).to.equal(containerWithLabels); }); + }); - // Node's `path` resolves to `path.win32` only on Windows, so absolute/relative - // Windows-path handling in getComposeFiles is only correct on a Windows host - // (which is also the only host where a Windows docker engine emits such labels). - if (process.platform === 'win32') { - test("Returns all files when multiple absolute Windows config files are present", () => { - // Regression test for https://github.com/microsoft/vscode-containers/issues/522 - const labels = { - 'com.docker.compose.project.config_files': 'C:\\path\\docker-compose.base.yml,C:\\path\\docker-compose.local.yml', - }; - - const result = getComposeFiles(labels); - - expect(result).to.deep.equal([ - 'C:\\path\\docker-compose.base.yml', - 'C:\\path\\docker-compose.local.yml', - ]); + suite("profile scoping and argument resolution", () => { + test("pickComposeProfileCommandScope presents correct picks and descriptions with exclusive services", async () => { + const node = createMockProfileGroup('backend', ['api', 'worker', 'db'], ['worker', 'db']); + const context = createMockContext(0, (picks) => { + expect(picks.length).to.equal(3); + expect(picks[0].data).to.equal('profile'); + expect(picks[0].description).to.include('--profile backend'); + expect(picks[1].data).to.equal('services'); + expect(picks[1].description).to.include('api worker db'); + expect(picks[2].data).to.equal('exclusive'); + expect(picks[2].description).to.include('worker db'); }); - test("Returns a single absolute Windows file unchanged", () => { - const labels = { - 'com.docker.compose.project.config_files': 'C:\\path\\docker-compose.yml', - }; - - const result = getComposeFiles(labels); + const scope = await pickComposeProfileCommandScope(context, node, 'down'); + expect(scope).to.equal('profile'); + }); - expect(result).to.deep.equal(['C:\\path\\docker-compose.yml']); + test("pickComposeProfileCommandScope shows warning description when no exclusive services exist", async () => { + const node = createMockProfileGroup('frontend', ['web', 'proxy'], []); + const context = createMockContext(2, (picks) => { + expect(picks[2].description).to.include('No services are exclusive to this profile'); }); - test("Reduces relative Windows paths to their basename", () => { - const labels = { - 'com.docker.compose.project.config_files': 'subdir\\docker-compose.base.yml,subdir\\docker-compose.local.yml', - }; - - const result = getComposeFiles(labels); + const scope = await pickComposeProfileCommandScope(context, node, 'start'); + expect(scope).to.equal('exclusive'); + }); - expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']); + test("resolveComposeProfileArguments bypasses prompt for standard container groups", async () => { + const rootGroup = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem; + const context = createMockContext(0, () => { + expect.fail("Should not invoke QuickPick for standard container group"); }); - } - test("Returns undefined when the config files label is absent", () => { - const result = getComposeFiles({}); - - expect(result).to.be.undefined; + const result = await resolveComposeProfileArguments(context, rootGroup, 'down'); + expect(result.profileArg).to.be.undefined; + expect(result.servicesArg).to.be.undefined; }); - }); - suite("other label accessors", () => { - const labels = { - 'com.docker.compose.project': 'myproject', - 'com.docker.compose.project.working_dir': '/abs/path', - 'com.docker.compose.project.environment_file': '/abs/path/.env.local', - }; + test("resolveComposeProfileArguments resolves --profile scope", async () => { + const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']); + const context = createMockContext(0); // Pick 0: 'profile' - test("getComposeProjectName returns the project name", () => { - expect(getComposeProjectName(labels)).to.equal('myproject'); + const result = await resolveComposeProfileArguments(context, node, 'up'); + expect(result.profileArg).to.deep.equal(['debug']); + expect(result.servicesArg).to.be.undefined; }); - test("getComposeWorkingDirectory returns the working directory", () => { - expect(getComposeWorkingDirectory(labels)).to.equal('/abs/path'); + test("resolveComposeProfileArguments resolves specific profile services scope", async () => { + const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']); + const context = createMockContext(1); // Pick 1: 'services' + + const result = await resolveComposeProfileArguments(context, node, 'restart'); + expect(result.profileArg).to.be.undefined; + expect(result.servicesArg).to.deep.equal(['app', 'tester']); }); - test("getComposeEnvFile returns the environment file", () => { - expect(getComposeEnvFile(labels)).to.equal('/abs/path/.env.local'); + test("resolveComposeProfileArguments resolves exclusive services scope", async () => { + const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']); + const context = createMockContext(2); // Pick 2: 'exclusive' + + const result = await resolveComposeProfileArguments(context, node, 'stop'); + expect(result.profileArg).to.be.undefined; + expect(result.servicesArg).to.deep.equal(['tester']); }); - test("Accessors return undefined when their label is absent", () => { - expect(getComposeProjectName({})).to.be.undefined; - expect(getComposeWorkingDirectory({})).to.be.undefined; - expect(getComposeEnvFile({})).to.be.undefined; + test("resolveComposeProfileArguments throws error when picking exclusive scope with no exclusive services", async () => { + const node = createMockProfileGroup('shared-only', ['redis'], []); + const context = createMockContext(2); // Pick 2: 'exclusive' + + try { + await resolveComposeProfileArguments(context, node, 'down'); + expect.fail("Expected an error to be thrown for empty exclusive services"); + } catch (err: unknown) { + expect((err as Error).message).to.include('There are no services exclusive to the "shared-only" profile.'); + expect(context.errorHandling.suppressReportIssue).to.be.true; + } }); }); }); diff --git a/extensions/vscode-containers/src/test/tree/containers/composeProfiles.test.ts b/extensions/vscode-containers/src/test/tree/containers/composeProfiles.test.ts new file mode 100644 index 00000000..cee61738 --- /dev/null +++ b/extensions/vscode-containers/src/test/tree/containers/composeProfiles.test.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from 'chai'; +import { getComposeProfilesForContainer } from '../../../tree/containers/composeProfiles'; +import { ContainerTreeItem } from '../../../tree/containers/ContainerTreeItem'; +import { ComposeServiceLabel } from '../../../utils/composeLabels'; + +suite("(unit) composeProfiles", () => { + suite("getComposeProfilesForContainer", () => { + test("Returns profiles assigned to the container service", () => { + const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(container, 'labels', { + value: { [ComposeServiceLabel]: 'web' } + }); + + const serviceProfiles = new Map([ + ['web', ['frontend', 'debug']], + ['db', ['backend']] + ]); + + const profiles = getComposeProfilesForContainer(container, serviceProfiles); + expect(profiles).to.deep.equal(['frontend', 'debug']); + }); + + test("Returns empty array when service has no assigned profiles", () => { + const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(container, 'labels', { + value: { [ComposeServiceLabel]: 'cache' } + }); + + const serviceProfiles = new Map([ + ['web', ['frontend']] + ]); + + const profiles = getComposeProfilesForContainer(container, serviceProfiles); + expect(profiles).to.deep.equal([]); + }); + }); +}); diff --git a/extensions/vscode-containers/src/test/utils/composeLabels.test.ts b/extensions/vscode-containers/src/test/utils/composeLabels.test.ts new file mode 100644 index 00000000..81ba2f9e --- /dev/null +++ b/extensions/vscode-containers/src/test/utils/composeLabels.test.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from 'chai'; +import { getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeServiceName, getComposeWorkingDirectory } from '../../utils/composeLabels'; + +suite("(unit) composeLabels", () => { + suite("getComposeFiles", () => { + test("Returns all files when multiple absolute config files are present", () => { + // Regression test for https://github.com/microsoft/vscode-containers/issues/522 + const labels = { + 'com.docker.compose.project.config_files': '/abs/path/docker-compose.base.yml,/abs/path/docker-compose.local.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal([ + '/abs/path/docker-compose.base.yml', + '/abs/path/docker-compose.local.yml', + ]); + }); + + test("Returns three files when three absolute config files are present", () => { + const labels = { + 'com.docker.compose.project.config_files': '/a/one.yml,/a/two.yml,/a/three.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal(['/a/one.yml', '/a/two.yml', '/a/three.yml']); + }); + + test("Returns a single absolute file unchanged", () => { + const labels = { + 'com.docker.compose.project.config_files': '/abs/path/docker-compose.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal(['/abs/path/docker-compose.yml']); + }); + + test("Reduces relative paths to their basename", () => { + const labels = { + 'com.docker.compose.project.config_files': 'subdir/docker-compose.base.yml,subdir/docker-compose.local.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']); + }); + + // Node's `path` resolves to `path.win32` only on Windows, so absolute/relative + // Windows-path handling in getComposeFiles is only correct on a Windows host + // (which is also the only host where a Windows docker engine emits such labels). + if (process.platform === 'win32') { + test("Returns all files when multiple absolute Windows config files are present", () => { + // Regression test for https://github.com/microsoft/vscode-containers/issues/522 + const labels = { + 'com.docker.compose.project.config_files': 'C:\\path\\docker-compose.base.yml,C:\\path\\docker-compose.local.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal([ + 'C:\\path\\docker-compose.base.yml', + 'C:\\path\\docker-compose.local.yml', + ]); + }); + + test("Returns a single absolute Windows file unchanged", () => { + const labels = { + 'com.docker.compose.project.config_files': 'C:\\path\\docker-compose.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal(['C:\\path\\docker-compose.yml']); + }); + + test("Reduces relative Windows paths to their basename", () => { + const labels = { + 'com.docker.compose.project.config_files': 'subdir\\docker-compose.base.yml,subdir\\docker-compose.local.yml', + }; + + const result = getComposeFiles(labels); + + expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']); + }); + } + + test("Returns undefined when the config files label is absent or labels undefined", () => { + expect(getComposeFiles({})).to.be.undefined; + expect(getComposeFiles(undefined)).to.be.undefined; + }); + }); + + suite("other label accessors", () => { + const labels = { + 'com.docker.compose.project': 'myproject', + 'com.docker.compose.project.working_dir': '/abs/path', + 'com.docker.compose.project.environment_file': '/abs/path/.env.local', + 'com.docker.compose.service': 'web', + }; + + test("getComposeProjectName returns the project name", () => { + expect(getComposeProjectName(labels)).to.equal('myproject'); + }); + + test("getComposeWorkingDirectory returns the working directory", () => { + expect(getComposeWorkingDirectory(labels)).to.equal('/abs/path'); + }); + + test("getComposeEnvFile returns the environment file", () => { + expect(getComposeEnvFile(labels)).to.equal('/abs/path/.env.local'); + }); + + test("getComposeServiceName returns the service name", () => { + expect(getComposeServiceName(labels)).to.equal('web'); + }); + + test("Accessors return undefined when their label is absent or labels undefined", () => { + expect(getComposeProjectName({})).to.be.undefined; + expect(getComposeWorkingDirectory({})).to.be.undefined; + expect(getComposeEnvFile({})).to.be.undefined; + expect(getComposeServiceName({})).to.be.undefined; + expect(getComposeProjectName(undefined)).to.be.undefined; + expect(getComposeWorkingDirectory(undefined)).to.be.undefined; + expect(getComposeEnvFile(undefined)).to.be.undefined; + expect(getComposeServiceName(undefined)).to.be.undefined; + }); + }); +}); + diff --git a/extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts b/extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts new file mode 100644 index 00000000..0d180be0 --- /dev/null +++ b/extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzExtParentTreeItem, AzExtTreeItem, IActionContext } from "@microsoft/vscode-azext-utils"; +import { ThemeIcon, TreeItemCollapsibleState, l10n } from "vscode"; +import type { ContainerGroupTreeItem } from "./ContainerGroupTreeItem"; +import { ContainerTreeItem } from "./ContainerTreeItem"; +import { DockerContainerInfo } from "./ContainersTreeItem"; +import { getComposeProfilesForContainer } from "./composeProfiles"; +import { getComposeServiceName } from "../../utils/composeLabels"; + +/** + * A tree item that represents a Docker Compose profile group (or the "Default" group + * for services with no profile assignment) nested under a {@link ContainerGroupTreeItem}. + * + * Children are {@link ContainerTreeItem} instances for each container whose service + * belongs to this profile. + */ +export class ComposeProfileGroupTreeItem extends AzExtParentTreeItem { + public static readonly contextValue: string = 'composeProfileGroup'; + public readonly contextValue: string = ComposeProfileGroupTreeItem.contextValue; + public readonly canMultiSelect: boolean = true; + public childTypeLabel: string = 'container'; + public declare readonly initialCollapsibleState: TreeItemCollapsibleState | undefined; + + /** The profile name, or undefined if this is the "Default" group. */ + public readonly profileName: string | undefined; + + /** The container info items that belong to this group. */ + private readonly _items: DockerContainerInfo[]; + + /** Lazily-built container tree items. */ + private _childTreeItems: ContainerTreeItem[] | undefined; + + private readonly _serviceProfiles?: Map; + + public constructor( + parent: ContainerGroupTreeItem, + group: string, + items: DockerContainerInfo[], + profileName?: string, + serviceProfiles?: Map, + ) { + super(parent); + // Use a stable ID so the tree can diff updates correctly + this.id = `${parent.id}|profile:${group}`; + this._items = items; + this.profileName = profileName; + this._serviceProfiles = serviceProfiles; + this.initialCollapsibleState = TreeItemCollapsibleState.Expanded; + } + + public getExclusiveServiceNames(): string[] { + const names = this.getServiceNames(); + if (!this._serviceProfiles || !this.profileName) { + return names; // Default fallback + } + return names.filter(name => { + const profiles = this._serviceProfiles?.get(name); + return profiles && profiles.length === 1 && profiles[0] === this.profileName; + }); + } + + public get label(): string { + return this.profileName ?? l10n.t('Default'); + } + + public get iconPath(): ThemeIcon { + return new ThemeIcon('multiple-windows'); + } + + public get description(): string | undefined { + return this.profileName ? l10n.t('Profile') : l10n.t('Default services'); + } + + // ------------------------------------------------------------------------- + // Children + + public hasMoreChildrenImpl(): boolean { + return false; + } + + public async loadMoreChildrenImpl(_clearCache: boolean): Promise { + this._childTreeItems = this._items.map(item => new ContainerTreeItem(this, item)); + return this._childTreeItems; + } + + /** Returns the already-built child items without triggering an async load. */ + public get ChildTreeItems(): AzExtTreeItem[] { + if (!this._childTreeItems) { + this._childTreeItems = this._items.map(item => new ContainerTreeItem(this, item)); + } + return this._childTreeItems; + } + + // ------------------------------------------------------------------------- + // Helpers used by commands + + /** + * Returns the sorted list of unique Docker Compose service names for the containers + * in this profile group, for use with `docker compose `. + */ + public getServiceNames(): string[] { + const serviceNames = new Set(); + + for (const item of this._items) { + const serviceName = getComposeServiceName(item.labels); + if (serviceName) { + serviceNames.add(serviceName); + } + } + + return [...serviceNames].sort((a, b) => a.localeCompare(b)); + } + + public getProfilesForContainer(container: ContainerTreeItem): string[] { + if (!this._serviceProfiles) { + return []; + } + return getComposeProfilesForContainer(container, this._serviceProfiles) || []; + } + + // ------------------------------------------------------------------------- + // Tree item protocol + + public compareChildrenImpl(ti1: AzExtTreeItem, ti2: AzExtTreeItem): number { + return (this.parent as ContainerGroupTreeItem).compareChildrenImpl(ti1, ti2); + } + + public isAncestorOfImpl(expectedContextValue: string | RegExp): boolean { + // Containers are the only direct children; don't claim ancestry for things deeper than that + return this.ChildTreeItems.some(c => { + if (typeof expectedContextValue === 'string') { + return c.contextValue === expectedContextValue; + } + return expectedContextValue.test(c.contextValue); + }); + } + + public async deleteTreeItemImpl(context: IActionContext): Promise { + const errors: unknown[] = []; + + for (const container of this.ChildTreeItems) { + try { + await container.deleteTreeItem(context); + } catch (error) { + errors.push(error); + } + } + + if (errors.length > 0) { + throw new Error(errors.map(String).join('\n')); + } + } +} \ No newline at end of file diff --git a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts index 600036be..280881c6 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts @@ -5,16 +5,22 @@ import { AzExtTreeItem, IActionContext } from "@microsoft/vscode-azext-utils"; import { ThemeIcon, TreeItemCollapsibleState } from "vscode"; +import { ext } from '../../extensionVariables'; import { LocalGroupTreeItemBase } from "../LocalGroupTreeItemBase"; import { LocalRootTreeItemBase } from "../LocalRootTreeItemBase"; import { getCommonGroupIcon } from "../settings/CommonProperties"; +import { ComposeProfileGroupTreeItem } from './ComposeProfileGroupTreeItem'; +import { getComposeProfilesForContainer, getComposeServiceProfiles } from './composeProfiles'; +import { ComposeConfigFilesLabel, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory } from '../../utils/composeLabels'; import { ContainerProperty, getContainerStateIcon, NonComposeGroupName } from "./ContainerProperties"; import { DockerContainerInfo } from "./ContainersTreeItem"; +import { ContainerTreeItem } from './ContainerTreeItem'; export class ContainerGroupTreeItem extends LocalGroupTreeItemBase { public childTypeLabel: string = 'container'; public declare readonly initialCollapsibleState: TreeItemCollapsibleState | undefined; // TypeScript gets mad if we don't re-declare this here public readonly canMultiSelect: boolean = true; + private _profileChildren: AzExtTreeItem[] | undefined; public constructor(parent: LocalRootTreeItemBase, group: string, items: DockerContainerInfo[]) { super(parent, group, items); @@ -33,6 +39,10 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase { + if (clearCache) { + this._profileChildren = undefined; + } + + if (this.parent.groupBySetting !== 'Compose Project Name' || this.group === NonComposeGroupName) { + return super.loadMoreChildrenImpl(clearCache); + } + + const containers = super.ChildTreeItems as ContainerTreeItem[]; + const labels = await this.getComposeGroupLabels(containers); + const workingDirectory = getComposeWorkingDirectory(labels); + const composeFiles = getComposeFiles(labels); + const projectName = getComposeProjectName(labels); + + if (!workingDirectory || !composeFiles?.length) { + return super.loadMoreChildrenImpl(clearCache); + } + + const serviceProfiles = await getComposeServiceProfiles(workingDirectory, composeFiles, projectName); + if (!serviceProfiles) { + return super.loadMoreChildrenImpl(clearCache); + } + + const defaultContainers: ContainerTreeItem[] = []; + const profileContainers = new Map(); + + for (const container of containers) { + const profiles = getComposeProfilesForContainer(container, serviceProfiles); + if (!profiles.length) { + defaultContainers.push(container); + continue; + } + + for (const profile of profiles) { + const existing = profileContainers.get(profile) ?? []; + existing.push(container.containerItem as DockerContainerInfo); + profileContainers.set(profile, existing); + } + } + + if (profileContainers.size === 0) { + return containers; + } + + const children: AzExtTreeItem[] = []; + + children.push(...defaultContainers); + + for (const profile of [...profileContainers.keys()].sort((a, b) => a.localeCompare(b))) { + children.push(new ComposeProfileGroupTreeItem(this, profile, profileContainers.get(profile) ?? [], profile, serviceProfiles)); + } + + + if (children.length > 0) { + this._profileChildren = children; + return children; + } + + return containers; + } + public isAncestorOfImpl(expectedContextValue: string | RegExp): boolean { - return this.ChildTreeItems.some((container: AzExtTreeItem) => this.matchesValue(container, expectedContextValue)); + return this.ChildTreeItems.some((container: AzExtTreeItem) => this.matchesValueRecursive(container, expectedContextValue)); } private matchesValue(container: AzExtTreeItem, expectedContextValue: (string | RegExp)): boolean { @@ -62,6 +134,40 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase this.matchesValueRecursive(child, expectedContextValue)); + } + + return false; + } + public async deleteTreeItemImpl(context: IActionContext): Promise { const containers = this.ChildTreeItems; const errors = []; @@ -78,4 +184,17 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase { + const container = containers.find(c => c.labels?.[ComposeConfigFilesLabel]); + if (!container) { + return undefined; + } + + const inspectResult = await ext.runWithDefaults(client => + client.inspectContainers({ containers: [container.containerId] }) + ); + + return inspectResult?.[0]?.labels; + } } diff --git a/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts b/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts index 2abbe2fe..d15c4cd1 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerProperties.ts @@ -8,6 +8,7 @@ import { l10n, ThemeColor, ThemeIcon, workspace } from "vscode"; import { configPrefix } from "../../constants"; import { commonProperties, CommonProperty, getCommonPropertyValue } from "../settings/CommonProperties"; import { ITreePropertyInfo } from "../settings/ITreeSettingInfo"; +import { ComposeProjectNameLabel } from "../../utils/composeLabels"; export type ContainerProperty = Exclude | 'Image' | 'Compose Project Name' | 'ContainerId' | 'ContainerName' | 'Networks' | 'Ports' | 'State' | 'Status' | 'Label'; @@ -64,7 +65,7 @@ export function getContainerPropertyValue(item: ListContainersItem, property: Co // This normalizes things like "10 seconds" and "Less than a second" to "Less than a minute", meaning the refreshes don't happen constantly return item.status?.replace(/(\d+ seconds?)|(Less than a second)/i, l10n.t('Less than a minute')); case 'Compose Project Name': - return getLabelGroup(item, 'com.docker.compose.project', NonComposeGroupName); + return getLabelGroup(item, ComposeProjectNameLabel, NonComposeGroupName); case 'Image': return item.image.originalName; case 'Label': diff --git a/extensions/vscode-containers/src/tree/containers/ContainerTreeItem.ts b/extensions/vscode-containers/src/tree/containers/ContainerTreeItem.ts index 957de2cd..0310a844 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerTreeItem.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerTreeItem.ts @@ -14,6 +14,7 @@ import { ToolTipParentTreeItem } from '../ToolTipTreeItem'; import { resolveTooltipMarkdown } from '../resolveTooltipMarkdown'; import { getContainerStateIcon } from "./ContainerProperties"; import { DockerContainerInfo } from './ContainersTreeItem'; +import { ComposeProfileGroupTreeItem } from './ComposeProfileGroupTreeItem'; import { FilesTreeItem } from "./files/FilesTreeItem"; /** @@ -68,7 +69,18 @@ export class ContainerTreeItem extends ToolTipParentTreeItem implements MultiSel } public get description(): string | undefined { - return ext.containersRoot.getTreeItemDescription(this._item); + let desc = ext.containersRoot.getTreeItemDescription(this._item); + if (this.composeProfiles.length > 1) { + desc = desc ? vscode.l10n.t('{0} (Shared)', desc) : vscode.l10n.t('(Shared)'); + } + return desc; + } + + private get composeProfiles(): string[] { + if (this.parent instanceof ComposeProfileGroupTreeItem) { + return this.parent.getProfilesForContainer(this); + } + return []; } public get contextValue(): string { @@ -156,6 +168,7 @@ export class ContainerTreeItem extends ToolTipParentTreeItem implements MultiSel const handlebarsContext = { ...containerInspection, normalizedName: this.containerName, + composeProfiles: this.composeProfiles, }; return resolveTooltipMarkdown(containerTooltipTemplate, handlebarsContext); } @@ -210,4 +223,13 @@ _None_ {{else}} _None_ {{/if}} + +{{#if (nonEmptyArr composeProfiles)}} +--- + +#### Profiles +{{#each composeProfiles}} + - {{ this }} +{{/each}} +{{/if}} `; diff --git a/extensions/vscode-containers/src/tree/containers/composeProfiles.ts b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts new file mode 100644 index 00000000..2b38deb7 --- /dev/null +++ b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CommandLineArgs, ShellQuoting } from '@microsoft/vscode-processutils'; +import { ext } from '../../extensionVariables'; +import { isComposeV2ableOrchestratorClient } from '../../runtimes/OrchestratorRuntimeManager'; +import { getComposeServiceName } from '../../utils/composeLabels'; +import { execAsync } from '../../utils/execAsync'; +import { ContainerTreeItem } from './ContainerTreeItem'; + +/** + * Gets the compose service name from a ContainerTreeItem. + */ +export function getComposeContainerServiceName(container: ContainerTreeItem): string | undefined { + return getComposeServiceName(container.labels); +} + +/** + * Builds a map of service name -> list of profiles for a compose project. + * + * This uses `docker compose config --format json` to get the normalized compose + * configuration (including all profile assignments) without needing to parse + * YAML files directly. Falls back gracefully if the command fails or `--format json` + * isn't supported (Docker Compose < v2.15). + * + * @param workingDirectory The working directory to run the compose command from + * @param composeFiles Absolute paths to the compose files + * @param projectName The compose project name (used for `--project-name`) + * @returns Map of service name -> profile list, or undefined if no profiles are defined + * or if the config cannot be fetched (older compose versions, unsupported runtimes). + */ +export async function getComposeServiceProfiles( + workingDirectory: string, + composeFiles: string[], + projectName?: string, +): Promise | undefined> { + try { + const client = await ext.orchestratorManager.getClient(); + // Determine if the client uses the V2 `compose` subcommand style (e.g. `docker compose`) + const isV2 = isComposeV2ableOrchestratorClient(client) ? client.composeV2 : false; + + // Build the args for `docker compose config --format json` + // We strongly quote '*' to prevent the shell from expanding it to local filenames. + const args: CommandLineArgs = [ + // V2 clients (docker, podman) need the 'compose' subcommand inserted + ...(isV2 ? ['compose'] : []), + '--profile', { value: '*', quoting: ShellQuoting.Strong }, + ...composeFiles.flatMap(f => ['--file', f]), + ...(projectName ? ['--project-name', projectName] : []), + 'config', + '--format', + 'json', + ]; + + const { stdout } = await execAsync(client.commandName, args, { + cwd: workingDirectory, + allowUnsafeExecutablePath: true, + }); + + if (!stdout) { + return undefined; + } + + // Extract JSON to avoid SyntaxError if docker compose prints warnings (like 'Found orphan containers') to stdout + const jsonMatch = stdout.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + return undefined; + } + + const config = JSON.parse(jsonMatch[0]) as { + services?: { + [name: string]: { + profiles?: string[]; + }; + }; + }; + + if (!config.services) { + return undefined; + } + + const serviceProfiles = new Map(); + let foundProfiles = false; + + for (const [serviceName, serviceDef] of Object.entries(config.services)) { + const profiles = (serviceDef.profiles ?? []).filter(p => !!p); + serviceProfiles.set(serviceName, profiles); + if (profiles.length > 0) { + foundProfiles = true; + } + } + + return foundProfiles ? serviceProfiles : undefined; + } catch (err) { + ext.outputChannel.debug(`Failed to resolve compose profiles: ${String(err)}`); + // The `--format json` flag requires Docker Compose v2.15+; if it fails (older versions, + // unsupported runtimes, JSON parse errors, etc.) we fall back to flat service listing + // with no profile grouping. + return undefined; + } +} + +/** + * Returns the list of compose profiles for the service that backs a given container. + */ +export function getComposeProfilesForContainer(container: ContainerTreeItem, serviceProfiles: Map): string[] { + const serviceName = getComposeContainerServiceName(container); + if (!serviceName) { + return []; + } + + return serviceProfiles.get(serviceName) ?? []; +} diff --git a/extensions/vscode-containers/src/utils/composeLabels.ts b/extensions/vscode-containers/src/utils/composeLabels.ts new file mode 100644 index 00000000..74a3ac25 --- /dev/null +++ b/extensions/vscode-containers/src/utils/composeLabels.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; + +export const ComposeConfigFilesLabel = 'com.docker.compose.project.config_files'; +export const ComposeProjectNameLabel = 'com.docker.compose.project'; +export const ComposeWorkingDirLabel = 'com.docker.compose.project.working_dir'; +export const ComposeEnvFileLabel = 'com.docker.compose.project.environment_file'; +export const ComposeServiceLabel = 'com.docker.compose.service'; + +/** + * Gets the list of compose configuration source files from container labels. + * Normalized to filenames for relative paths to ensure consistency when running compose CLI commands. + */ +export function getComposeFiles(labels: { [key: string]: string } | undefined): string[] | undefined { + if (!labels) { + return undefined; + } + + return labels[ComposeConfigFilesLabel] + ?.split(',') + ?.map(f => path.isAbsolute(f) ? f : path.parse(f).base) + ?.filter(file => !!file); +} + +/** + * Gets the compose working directory from container labels. + */ +export function getComposeWorkingDirectory(labels: { [key: string]: string } | undefined): string | undefined { + return labels?.[ComposeWorkingDirLabel] || undefined; +} + +/** + * Gets the compose project name from container labels. + */ +export function getComposeProjectName(labels: { [key: string]: string } | undefined): string | undefined { + return labels?.[ComposeProjectNameLabel] || undefined; +} + +/** + * Gets the environment file path from container labels. + */ +export function getComposeEnvFile(labels: { [key: string]: string } | undefined): string | undefined { + return labels?.[ComposeEnvFileLabel] || undefined; +} + +/** + * Gets the compose service name from container labels. + */ +export function getComposeServiceName(labels: { [key: string]: string } | undefined): string | undefined { + return labels?.[ComposeServiceLabel] || undefined; +}