From 99221c88bb3f48c0c65e288c70b7595a35566b15 Mon Sep 17 00:00:00 2001 From: h3110Fr13nd Date: Sun, 2 Aug 2026 05:02:23 +0530 Subject: [PATCH 1/4] feat: add Compose profiles support to containers view Implements compose profile scope prompts, optimizes tree item sorting and UI formatting, and adds exclusive service scoping for Docker Compose profile actions. Signed-off-by: h3110Fr13nd --- extensions/vscode-containers/package.json | 10 +- .../src/commands/containers/composeGroup.ts | 374 +++++++++++------- .../containers/ComposeProfileGroupTreeItem.ts | 156 ++++++++ .../tree/containers/ContainerGroupTreeItem.ts | 124 +++++- .../src/tree/containers/ContainerTreeItem.ts | 24 +- .../src/tree/containers/composeProfiles.ts | 133 +++++++ 6 files changed, 669 insertions(+), 152 deletions(-) create mode 100644 extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts create mode 100644 extensions/vscode-containers/src/tree/containers/composeProfiles.ts diff --git a/extensions/vscode-containers/package.json b/extensions/vscode-containers/package.json index ce771c37..165b21d7 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" }, { diff --git a/extensions/vscode-containers/src/commands/containers/composeGroup.ts b/extensions/vscode-containers/src/commands/containers/composeGroup.ts index 17dc9729..fbbd609a 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -1,145 +1,229 @@ -/*--------------------------------------------------------------------------------------------- - * 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 * as path from 'path'; +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 { 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 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 }, 'logs'); +} + +export async function composeGroupStart(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.start(options), node, undefined, 'start'); +} + +export async function composeGroupStop(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.stop(options), node, undefined, 'stop'); +} + +export async function composeGroupRestart(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.restart(options), node, undefined, 'restart'); +} + +export async function composeGroupDown(context: IActionContext, node: ComposeGroupNode): Promise { + return composeGroup(context, (client, options) => client.down(options), node, undefined, 'down'); +} + +type AdditionalOptions = Omit; + +async function composeGroup( + context: IActionContext, + composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, + node: ComposeGroupNode, + additionalOptions?: AdditionalOptions, + commandName: string = '' +): 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)); + } + + 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(); + } + } + + 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). + */ +async function getComposeGroupLabels(node: ComposeGroupNode): Promise<{ [key: string]: string } | 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?.['com.docker.compose.project.config_files']) 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?.['com.docker.compose.project.config_files']) as ContainerTreeItem | undefined; + if (container) { + break; + } + } + } + } + + 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. + */ +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 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; +} 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..d534720f --- /dev/null +++ b/extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * 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"; + +/** + * 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 = item.labels?.['com.docker.compose.service']; + 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..87e04fcc 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts @@ -5,16 +5,21 @@ 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, getComposeSourceFiles } from './composeProfiles'; 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 +38,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 = labels && labels['com.docker.compose.project.working_dir']; + const composeFiles = labels ? getComposeSourceFiles(labels) : undefined; + const projectName = labels?.['com.docker.compose.project']; + + ext.outputChannel.appendLine(`[DEBUG] ContainerGroupTreeItem: workingDirectory=${workingDirectory}, composeFiles=${JSON.stringify(composeFiles)}`); + + 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) { + if (!profileContainers.has(profile)) { + profileContainers.set(profile, [container.containerItem as DockerContainerInfo]); + } else { + profileContainers.set(profile, [...profileContainers.get(profile)!, container.containerItem as DockerContainerInfo]); + } + } + } + + 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 +137,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 +187,17 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase { + const container = containers.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; + } } diff --git a/extensions/vscode-containers/src/tree/containers/ContainerTreeItem.ts b/extensions/vscode-containers/src/tree/containers/ContainerTreeItem.ts index 957de2cd..eccab911 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 ? `${desc} (Shared)` : '(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..f9cca3e7 --- /dev/null +++ b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { execAsync } from '../../utils/execAsync'; +import { ContainerTreeItem } from './ContainerTreeItem'; + +/** + * Extracts the list of compose config source files from a container's labels. + * Uses the `com.docker.compose.project.config_files` label which Docker Compose sets + * on all containers it manages. + */ +export function getComposeSourceFiles(labels: { [key: string]: string }): string[] | undefined { + return labels['com.docker.compose.project.config_files'] + ?.split(',') + ?.filter(file => !!file); +} + +/** + * Gets the compose service name from a ContainerTreeItem. + */ +export function getComposeContainerServiceName(container: ContainerTreeItem): string | undefined { + return container.labels?.['com.docker.compose.service'] || undefined; +} + +/** + * 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', + ]; + + ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles args: ${client.commandName} ${JSON.stringify(args)} in cwd: ${workingDirectory}`); + + const { stdout } = await execAsync(client.commandName, args, { + cwd: workingDirectory, + allowUnsafeExecutablePath: true, + }); + + ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles stdout: ${stdout?.substring(0, 200)}...`); + + if (!stdout) { + ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles stdout was empty.`); + 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) { + ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles failed to find JSON in stdout.`); + return undefined; + } + + const config = JSON.parse(jsonMatch[0]) as { + services?: { + [name: string]: { + profiles?: string[]; + }; + }; + }; + + if (!config.services) { + ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles JSON did not contain 'services' property.`); + 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; + } + } + + ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles foundProfiles: ${foundProfiles}`); + return foundProfiles ? serviceProfiles : undefined; + } catch (err) { + ext.outputChannel.error(`getComposeServiceProfiles failed: ${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) ?? []; +} From ce9cef99937d1d218e65604a4c69e4d80c8dd56e Mon Sep 17 00:00:00 2001 From: h3110Fr13nd Date: Tue, 4 Aug 2026 14:00:42 +0530 Subject: [PATCH 2/4] refactor(compose): refine profile handling and add unit tests Refines Compose profile behavior: - Localize user-facing shared profile indicator in container description. - Re-order command arguments in composeGroup to simplify call sites. - Use gated debug logging and prevent raw output secret exposure. - Resolve relative compose config file paths to basenames. - Add comprehensive unit tests for profile scoping and file helpers. Signed-off-by: h3110Fr13nd --- .../src/commands/containers/composeGroup.ts | 34 ++++++++--- .../src/test/commands/composeGroup.test.ts | 38 +++++++++++- .../src/test/tree/composeProfiles.test.ts | 61 +++++++++++++++++++ .../tree/containers/ContainerGroupTreeItem.ts | 10 ++- .../src/tree/containers/ContainerTreeItem.ts | 2 +- .../src/tree/containers/composeProfiles.ts | 16 ++--- 6 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 extensions/vscode-containers/src/test/tree/composeProfiles.test.ts diff --git a/extensions/vscode-containers/src/commands/containers/composeGroup.ts b/extensions/vscode-containers/src/commands/containers/composeGroup.ts index fbbd609a..4149bee5 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -23,30 +23,30 @@ export async function composeGroupLogs(context: IActionContext, node: ComposeGro if (!workingDirectory) { context.errorHandling.suppressReportIssue = true; - throw new Error(l10n.t('Unable to determine compose project info for container group \'{0}\'.', node.label)); + 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(' '); return selectComposeLogsCommand(context, folder, composeFilesString, options.projectName, options.environmentFile); - }, node, { follow: true, tail: 1000 }, 'logs'); + }, node, 'logs', { follow: true, tail: 1000 }); } export async function composeGroupStart(context: IActionContext, node: ComposeGroupNode): Promise { - return composeGroup(context, (client, options) => client.start(options), node, undefined, 'start'); + 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, undefined, 'stop'); + 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, undefined, 'restart'); + 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, undefined, 'down'); + return composeGroup(context, (client, options) => client.down(options), node, 'down'); } type AdditionalOptions = Omit; @@ -55,8 +55,8 @@ async function composeGroup( context: IActionContext, composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, node: ComposeGroupNode, - additionalOptions?: AdditionalOptions, - commandName: string = '' + commandName: string = '', + additionalOptions?: AdditionalOptions ): Promise { if (!node) { await ext.containersTree.refresh(context); @@ -75,7 +75,7 @@ async function composeGroup( 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)); + throw new Error(l10n.t('Unable to determine compose project info for group \'{0}\'.', getProjectLabel(node))); } let profileArg: string[] | undefined; @@ -130,7 +130,16 @@ async function composeGroup( * 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: ComposeGroupNode): Promise<{ [key: string]: string } | undefined> { +// 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 @@ -151,6 +160,11 @@ async function getComposeGroupLabels(node: ComposeGroupNode): Promise<{ [key: st } } + return container; +} + +async function getComposeGroupLabels(node: ComposeGroupNode): Promise<{ [key: string]: string } | undefined> { + const container = findContainerWithComposeConfig(node); if (!container) { return undefined; } diff --git a/extensions/vscode-containers/src/test/commands/composeGroup.test.ts b/extensions/vscode-containers/src/test/commands/composeGroup.test.ts index ba540469..e2ac3d37 100644 --- a/extensions/vscode-containers/src/test/commands/composeGroup.test.ts +++ b/extensions/vscode-containers/src/test/commands/composeGroup.test.ts @@ -4,7 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { expect } from 'chai'; -import { getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory } from '../../commands/containers/composeGroup'; +import { findContainerWithComposeConfig, getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory, getProjectLabel } from '../../commands/containers/composeGroup'; +import { ComposeProfileGroupTreeItem } from '../../tree/containers/ComposeProfileGroupTreeItem'; +import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTreeItem'; +import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; suite("(unit) composeGroup", () => { suite("getComposeFiles", () => { @@ -123,4 +126,37 @@ suite("(unit) composeGroup", () => { expect(getComposeEnvFile({})).to.be.undefined; }); }); + + 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' }); + + const profileNode = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem; + Object.defineProperty(profileNode, 'label', { value: 'dev-profile' }); + Object.defineProperty(profileNode, 'parent', { value: parent }); + + expect(getProjectLabel(profileNode)).to.equal('my-compose-project'); + expect(getProjectLabel(parent)).to.equal('my-compose-project'); + }); + + test("findContainerWithComposeConfig searches direct children and profile sub-groups", () => { + const containerWithLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(containerWithLabels, 'labels', { + value: { 'com.docker.compose.project.config_files': '/path/to/docker-compose.yml' } + }); + + const containerWithoutLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(containerWithoutLabels, 'labels', { value: {} }); + + const profileGroup = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem; + Object.defineProperty(profileGroup, 'ChildTreeItems', { value: [containerWithoutLabels, containerWithLabels] }); + + const rootGroup = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem; + Object.defineProperty(rootGroup, 'ChildTreeItems', { value: [profileGroup] }); + + const result = findContainerWithComposeConfig(rootGroup); + expect(result).to.equal(containerWithLabels); + }); + }); }); diff --git a/extensions/vscode-containers/src/test/tree/composeProfiles.test.ts b/extensions/vscode-containers/src/test/tree/composeProfiles.test.ts new file mode 100644 index 00000000..f3cc186d --- /dev/null +++ b/extensions/vscode-containers/src/test/tree/composeProfiles.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * 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, getComposeSourceFiles } from '../../tree/containers/composeProfiles'; +import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; + +suite("(unit) composeProfiles", () => { + suite("getComposeSourceFiles", () => { + 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 = getComposeSourceFiles(labels); + expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']); + }); + + test("Returns absolute paths unchanged", () => { + const labels = { + 'com.docker.compose.project.config_files': '/abs/docker-compose.yml', + }; + + const result = getComposeSourceFiles(labels); + expect(result).to.deep.equal(['/abs/docker-compose.yml']); + }); + }); + + suite("getComposeProfilesForContainer", () => { + test("Returns profiles assigned to the container service", () => { + const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; + Object.defineProperty(container, 'labels', { + value: { 'com.docker.compose.service': '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: { 'com.docker.compose.service': 'cache' } + }); + + const serviceProfiles = new Map([ + ['web', ['frontend']] + ]); + + const profiles = getComposeProfilesForContainer(container, serviceProfiles); + expect(profiles).to.deep.equal([]); + }); + }); +}); diff --git a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts index 87e04fcc..14ffdddb 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts @@ -77,7 +77,7 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase 1) { - desc = desc ? `${desc} (Shared)` : '(Shared)'; + desc = desc ? vscode.l10n.t('{0} (Shared)', desc) : vscode.l10n.t('(Shared)'); } return desc; } diff --git a/extensions/vscode-containers/src/tree/containers/composeProfiles.ts b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts index f9cca3e7..6c290831 100644 --- a/extensions/vscode-containers/src/tree/containers/composeProfiles.ts +++ b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; import { CommandLineArgs, ShellQuoting } from '@microsoft/vscode-processutils'; import { ext } from '../../extensionVariables'; import { isComposeV2ableOrchestratorClient } from '../../runtimes/OrchestratorRuntimeManager'; @@ -17,6 +18,7 @@ import { ContainerTreeItem } from './ContainerTreeItem'; export function getComposeSourceFiles(labels: { [key: string]: string }): string[] | undefined { return labels['com.docker.compose.project.config_files'] ?.split(',') + ?.map(f => path.isAbsolute(f) ? f : path.parse(f).base) ?.filter(file => !!file); } @@ -64,24 +66,24 @@ export async function getComposeServiceProfiles( 'json', ]; - ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles args: ${client.commandName} ${JSON.stringify(args)} in cwd: ${workingDirectory}`); + ext.outputChannel.debug(`getComposeServiceProfiles args: ${client.commandName} ${JSON.stringify(args)} in cwd: ${workingDirectory}`); const { stdout } = await execAsync(client.commandName, args, { cwd: workingDirectory, allowUnsafeExecutablePath: true, }); - ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles stdout: ${stdout?.substring(0, 200)}...`); + ext.outputChannel.debug(`getComposeServiceProfiles stdout length: ${stdout?.length ?? 0}`); if (!stdout) { - ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles stdout was empty.`); + ext.outputChannel.debug('getComposeServiceProfiles stdout was empty.'); 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) { - ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles failed to find JSON in stdout.`); + ext.outputChannel.debug('getComposeServiceProfiles failed to find JSON in stdout.'); return undefined; } @@ -94,7 +96,7 @@ export async function getComposeServiceProfiles( }; if (!config.services) { - ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles JSON did not contain 'services' property.`); + ext.outputChannel.debug('getComposeServiceProfiles JSON did not contain \'services\' property.'); return undefined; } @@ -109,10 +111,10 @@ export async function getComposeServiceProfiles( } } - ext.outputChannel.appendLine(`[DEBUG] getComposeServiceProfiles foundProfiles: ${foundProfiles}`); + ext.outputChannel.debug(`getComposeServiceProfiles foundProfiles: ${foundProfiles}`); return foundProfiles ? serviceProfiles : undefined; } catch (err) { - ext.outputChannel.error(`getComposeServiceProfiles failed: ${err}`); + ext.outputChannel.debug(`getComposeServiceProfiles failed: ${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. From 1669a2a466e09fc8c4f6e9179eaf9b1b6140dc80 Mon Sep 17 00:00:00 2001 From: h3110Fr13nd Date: Wed, 5 Aug 2026 00:36:45 +0530 Subject: [PATCH 3/4] refactor(compose): centralize label utilities and enhance test coverage Consolidates Docker Compose label parsing, cleans up logging, and refines scoping: - Create central utils/composeLabels module for reusable label accessors and constants. - Eliminate duplicated getComposeFiles and getComposeSourceFiles functions across modules. - Extract profile command argument resolution into testable resolveComposeProfileArguments helper. - Remove verbose debug trace logging during routine container tree view loads. - Align unit test structure by relocating compose profiles tests under test/tree/containers/. - Add exhaustive unit tests for label normalization and profile action scoping. Signed-off-by: h3110Fr13nd --- .../src/commands/containers/composeGroup.ts | 85 +++---- .../src/test/commands/composeGroup.test.ts | 225 +++++++++--------- .../{ => containers}/composeProfiles.test.ts | 29 +-- .../src/test/utils/composeLabels.test.ts | 136 +++++++++++ .../containers/ComposeProfileGroupTreeItem.ts | 3 +- .../tree/containers/ContainerGroupTreeItem.ts | 13 +- .../tree/containers/ContainerProperties.ts | 3 +- .../src/tree/containers/composeProfiles.ts | 26 +- .../src/utils/composeLabels.ts | 55 +++++ 9 files changed, 349 insertions(+), 226 deletions(-) rename extensions/vscode-containers/src/test/tree/{ => containers}/composeProfiles.test.ts (58%) create mode 100644 extensions/vscode-containers/src/test/utils/composeLabels.test.ts create mode 100644 extensions/vscode-containers/src/utils/composeLabels.ts diff --git a/extensions/vscode-containers/src/commands/containers/composeGroup.ts b/extensions/vscode-containers/src/commands/containers/composeGroup.ts index 4149bee5..55e78835 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -5,13 +5,13 @@ import { IActionContext, IAzureQuickPickItem } 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 { 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; @@ -78,29 +78,7 @@ async function composeGroup( throw new Error(l10n.t('Unable to determine compose project info for group \'{0}\'.', getProjectLabel(node))); } - 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(); - } - } + const { profileArg, servicesArg } = await resolveComposeProfileArguments(context, node, commandName); const options: TOptions = { files: orchestratorFiles, @@ -145,14 +123,14 @@ export function findContainerWithComposeConfig(node: ComposeGroupNode): Containe // 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?.['com.docker.compose.project.config_files']) as ContainerTreeItem | undefined; + .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?.['com.docker.compose.project.config_files']) as ContainerTreeItem | undefined; + .find(c => c instanceof ContainerTreeItem && c.labels?.[ComposeConfigFilesLabel]) as ContainerTreeItem | undefined; if (container) { break; } @@ -182,7 +160,8 @@ async function getComposeGroupLabels(node: ComposeGroupNode): Promise<{ [key: st * 'services' to apply only to the specific services in this profile, * or 'exclusive' to apply only to services that belong strictly to this profile. */ -async function pickComposeProfileCommandScope(context: IActionContext, node: ComposeProfileGroupTreeItem, commandName: string): Promise<'profile' | 'services' | 'exclusive'> { +// 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'>[] = [ @@ -213,31 +192,35 @@ async function pickComposeProfileCommandScope(context: IActionContext, node: Com } // 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; -} +export async function resolveComposeProfileArguments( + context: IActionContext, + node: ComposeGroupNode, + commandName: string +): Promise<{ profileArg?: string[]; servicesArg?: string[] }> { + let profileArg: string[] | undefined; + let servicesArg: string[] | 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); -} + 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(); + } + } -// 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; + return { profileArg, servicesArg }; } -// 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; -} diff --git a/extensions/vscode-containers/src/test/commands/composeGroup.test.ts b/extensions/vscode-containers/src/test/commands/composeGroup.test.ts index e2ac3d37..5e0e63ef 100644 --- a/extensions/vscode-containers/src/test/commands/composeGroup.test.ts +++ b/extensions/vscode-containers/src/test/commands/composeGroup.test.ts @@ -3,130 +3,38 @@ * 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 { findContainerWithComposeConfig, getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory, getProjectLabel } 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'; - -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', - ]); - }); - - 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']); - }); +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; +} - test("Returns undefined when the config files label is absent", () => { - const result = getComposeFiles({}); - - expect(result).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("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("Accessors return undefined when their label is absent", () => { - expect(getComposeProjectName({})).to.be.undefined; - expect(getComposeWorkingDirectory({})).to.be.undefined; - expect(getComposeEnvFile({})).to.be.undefined; - }); - }); - +suite("(unit) composeGroup", () => { suite("profile sub-group utilities", () => { test("getProjectLabel returns parent label for ComposeProfileGroupTreeItem", () => { const parent = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem; @@ -143,7 +51,7 @@ suite("(unit) composeGroup", () => { test("findContainerWithComposeConfig searches direct children and profile sub-groups", () => { const containerWithLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; Object.defineProperty(containerWithLabels, 'labels', { - value: { 'com.docker.compose.project.config_files': '/path/to/docker-compose.yml' } + value: { [ComposeConfigFilesLabel]: '/path/to/docker-compose.yml' } }); const containerWithoutLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; @@ -159,4 +67,83 @@ suite("(unit) composeGroup", () => { expect(result).to.equal(containerWithLabels); }); }); + + 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'); + }); + + const scope = await pickComposeProfileCommandScope(context, node, 'down'); + expect(scope).to.equal('profile'); + }); + + 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'); + }); + + const scope = await pickComposeProfileCommandScope(context, node, 'start'); + expect(scope).to.equal('exclusive'); + }); + + 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"); + }); + + const result = await resolveComposeProfileArguments(context, rootGroup, 'down'); + expect(result.profileArg).to.be.undefined; + expect(result.servicesArg).to.be.undefined; + }); + + test("resolveComposeProfileArguments resolves --profile scope", async () => { + const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']); + const context = createMockContext(0); // Pick 0: 'profile' + + const result = await resolveComposeProfileArguments(context, node, 'up'); + expect(result.profileArg).to.deep.equal(['debug']); + expect(result.servicesArg).to.be.undefined; + }); + + 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("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("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/composeProfiles.test.ts b/extensions/vscode-containers/src/test/tree/containers/composeProfiles.test.ts similarity index 58% rename from extensions/vscode-containers/src/test/tree/composeProfiles.test.ts rename to extensions/vscode-containers/src/test/tree/containers/composeProfiles.test.ts index f3cc186d..cee61738 100644 --- a/extensions/vscode-containers/src/test/tree/composeProfiles.test.ts +++ b/extensions/vscode-containers/src/test/tree/containers/composeProfiles.test.ts @@ -4,35 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { expect } from 'chai'; -import { getComposeProfilesForContainer, getComposeSourceFiles } from '../../tree/containers/composeProfiles'; -import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem'; +import { getComposeProfilesForContainer } from '../../../tree/containers/composeProfiles'; +import { ContainerTreeItem } from '../../../tree/containers/ContainerTreeItem'; +import { ComposeServiceLabel } from '../../../utils/composeLabels'; suite("(unit) composeProfiles", () => { - suite("getComposeSourceFiles", () => { - 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 = getComposeSourceFiles(labels); - expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']); - }); - - test("Returns absolute paths unchanged", () => { - const labels = { - 'com.docker.compose.project.config_files': '/abs/docker-compose.yml', - }; - - const result = getComposeSourceFiles(labels); - expect(result).to.deep.equal(['/abs/docker-compose.yml']); - }); - }); - suite("getComposeProfilesForContainer", () => { test("Returns profiles assigned to the container service", () => { const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; Object.defineProperty(container, 'labels', { - value: { 'com.docker.compose.service': 'web' } + value: { [ComposeServiceLabel]: 'web' } }); const serviceProfiles = new Map([ @@ -47,7 +28,7 @@ suite("(unit) composeProfiles", () => { test("Returns empty array when service has no assigned profiles", () => { const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem; Object.defineProperty(container, 'labels', { - value: { 'com.docker.compose.service': 'cache' } + value: { [ComposeServiceLabel]: 'cache' } }); const serviceProfiles = new Map([ 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 index d534720f..0d180be0 100644 --- a/extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts +++ b/extensions/vscode-containers/src/tree/containers/ComposeProfileGroupTreeItem.ts @@ -9,6 +9,7 @@ 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 @@ -105,7 +106,7 @@ export class ComposeProfileGroupTreeItem extends AzExtParentTreeItem { const serviceNames = new Set(); for (const item of this._items) { - const serviceName = item.labels?.['com.docker.compose.service']; + const serviceName = getComposeServiceName(item.labels); if (serviceName) { serviceNames.add(serviceName); } diff --git a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts index 14ffdddb..280881c6 100644 --- a/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts +++ b/extensions/vscode-containers/src/tree/containers/ContainerGroupTreeItem.ts @@ -10,7 +10,8 @@ import { LocalGroupTreeItemBase } from "../LocalGroupTreeItemBase"; import { LocalRootTreeItemBase } from "../LocalRootTreeItemBase"; import { getCommonGroupIcon } from "../settings/CommonProperties"; import { ComposeProfileGroupTreeItem } from './ComposeProfileGroupTreeItem'; -import { getComposeProfilesForContainer, getComposeServiceProfiles, getComposeSourceFiles } from './composeProfiles'; +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'; @@ -73,11 +74,9 @@ export class ContainerGroupTreeItem extends LocalGroupTreeItemBase { - const container = containers.find(c => c.labels?.['com.docker.compose.project.config_files']); + const container = containers.find(c => c.labels?.[ComposeConfigFilesLabel]); if (!container) { return undefined; } 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/composeProfiles.ts b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts index 6c290831..2b38deb7 100644 --- a/extensions/vscode-containers/src/tree/containers/composeProfiles.ts +++ b/extensions/vscode-containers/src/tree/containers/composeProfiles.ts @@ -3,30 +3,18 @@ * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; 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'; -/** - * Extracts the list of compose config source files from a container's labels. - * Uses the `com.docker.compose.project.config_files` label which Docker Compose sets - * on all containers it manages. - */ -export function getComposeSourceFiles(labels: { [key: string]: string }): string[] | undefined { - return labels['com.docker.compose.project.config_files'] - ?.split(',') - ?.map(f => path.isAbsolute(f) ? f : path.parse(f).base) - ?.filter(file => !!file); -} - /** * Gets the compose service name from a ContainerTreeItem. */ export function getComposeContainerServiceName(container: ContainerTreeItem): string | undefined { - return container.labels?.['com.docker.compose.service'] || undefined; + return getComposeServiceName(container.labels); } /** @@ -66,24 +54,18 @@ export async function getComposeServiceProfiles( 'json', ]; - ext.outputChannel.debug(`getComposeServiceProfiles args: ${client.commandName} ${JSON.stringify(args)} in cwd: ${workingDirectory}`); - const { stdout } = await execAsync(client.commandName, args, { cwd: workingDirectory, allowUnsafeExecutablePath: true, }); - ext.outputChannel.debug(`getComposeServiceProfiles stdout length: ${stdout?.length ?? 0}`); - if (!stdout) { - ext.outputChannel.debug('getComposeServiceProfiles stdout was empty.'); 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) { - ext.outputChannel.debug('getComposeServiceProfiles failed to find JSON in stdout.'); return undefined; } @@ -96,7 +78,6 @@ export async function getComposeServiceProfiles( }; if (!config.services) { - ext.outputChannel.debug('getComposeServiceProfiles JSON did not contain \'services\' property.'); return undefined; } @@ -111,10 +92,9 @@ export async function getComposeServiceProfiles( } } - ext.outputChannel.debug(`getComposeServiceProfiles foundProfiles: ${foundProfiles}`); return foundProfiles ? serviceProfiles : undefined; } catch (err) { - ext.outputChannel.debug(`getComposeServiceProfiles failed: ${String(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. 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; +} From 72ddfa1b5243f9691556c05fb4c728cfe5ba3ea7 Mon Sep 17 00:00:00 2001 From: h3110Fr13nd Date: Thu, 6 Aug 2026 01:11:58 +0530 Subject: [PATCH 4/4] feat(compose): add profile and service scoping to logs command Enhances the Compose logs command template and handler to support profile and service filtering: - Update composeLogs command template in package.json to include ${profileList} and ${serviceList} variables. - Pass profile (--profile) and service arguments to selectComposeLogsCommand in composeGroupLogs handler. - Update selectComposeLogsCommand parameter list and command template variables to interpolate profiles and services. Signed-off-by: h3110Fr13nd --- extensions/vscode-containers/package.json | 2 +- .../src/commands/containers/composeGroup.ts | 4 +++- .../vscode-containers/src/commands/selectCommandTemplate.ts | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/vscode-containers/package.json b/extensions/vscode-containers/package.json index 165b21d7..87b0b84f 100644 --- a/extensions/vscode-containers/package.json +++ b/extensions/vscode-containers/package.json @@ -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 55e78835..acc58d76 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -29,7 +29,9 @@ export async function composeGroupLogs(context: IActionContext, node: ComposeGro 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); + 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 }); } 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 || '' } ); }