Skip to content

Commit

Permalink
Add implementations of service/profile expansion for default commands (
Browse files Browse the repository at this point in the history
  • Loading branch information
danegsta authored Jan 14, 2025
1 parent fed028e commit d0a2744
Show file tree
Hide file tree
Showing 2 changed files with 161 additions and 4 deletions.
45 changes: 41 additions & 4 deletions src/commands/compose/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
*--------------------------------------------------------------------------------------------*/

import { IActionContext, UserCancelledError } from '@microsoft/vscode-azext-utils';
import { VoidCommandResponse } from '@microsoft/vscode-container-client';
import * as vscode from 'vscode';
import { ext } from '../../extensionVariables';
import { TaskCommandRunnerFactory } from '../../runtimes/runners/TaskCommandRunnerFactory';
import { Item, createFileItem, quickPickDockerComposeFileItem } from '../../utils/quickPickFile';
import { quickPickWorkspaceFolder } from '../../utils/quickPickWorkspaceFolder';
import { selectComposeCommand } from '../selectCommandTemplate';
import { getComposeProfileList, getComposeProfilesOrServices, getComposeServiceList } from './getComposeSubsetList';
import { getComposeProfileList, getComposeProfilesOrServices, getComposeServiceList, getDefaultCommandComposeProfilesOrServices } from './getComposeSubsetList';

async function compose(context: IActionContext, commands: ('up' | 'down' | 'upSubset')[], message: string, dockerComposeFileUri?: vscode.Uri | string, selectedComposeFileUris?: vscode.Uri[], preselectedServices?: string[], preselectedProfiles?: string[]): Promise<void> {
if (!vscode.workspace.isTrusted) {
Expand Down Expand Up @@ -53,7 +54,7 @@ async function compose(context: IActionContext, commands: ('up' | 'down' | 'upSu
}

for (const item of selectedItems) {
const terminalCommand = await selectComposeCommand(
let terminalCommand = await selectComposeCommand(
context,
folder,
command,
Expand All @@ -62,8 +63,12 @@ async function compose(context: IActionContext, commands: ('up' | 'down' | 'upSu
build
);

// Add the service list if needed
terminalCommand.command = await addServicesOrProfilesIfNeeded(context, folder, terminalCommand.command, preselectedServices, preselectedProfiles);
if (!terminalCommand.args?.length) {
// Add the service list if needed
terminalCommand.command = await addServicesOrProfilesIfNeeded(context, folder, terminalCommand.command, preselectedServices, preselectedProfiles);
} else {
terminalCommand = await addDefaultCommandServicesOrProfilesIfNeeded(context, folder, terminalCommand, preselectedServices, preselectedProfiles);
}

const client = await ext.orchestratorManager.getClient();
const taskCRF = new TaskCommandRunnerFactory({
Expand Down Expand Up @@ -96,6 +101,38 @@ export async function composeRestart(context: IActionContext, dockerComposeFileU

const serviceListPlaceholder = /\${serviceList}/i;
const profileListPlaceholder = /\${profileList}/i;

async function addDefaultCommandServicesOrProfilesIfNeeded(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, command: VoidCommandResponse, preselectedServices: string[], preselectedProfiles: string[]): Promise<VoidCommandResponse> {
const commandWithoutPlaceholders = {
...command,
args: command.args.filter(arg => typeof arg === 'string' ? !serviceListPlaceholder.test(arg) && !profileListPlaceholder.test(arg) : !serviceListPlaceholder.test(arg.value) && !profileListPlaceholder.test(arg.value)),
};

const { services, profiles } = await getDefaultCommandComposeProfilesOrServices(context, workspaceFolder, commandWithoutPlaceholders, preselectedServices, preselectedProfiles);

// Replace the placeholder args with the actual service and profile arguments
return {
...command,
args: command.args.flatMap(arg => {
if (typeof arg === 'string') {
if (serviceListPlaceholder.test(arg)) {
return services;
} else if (profileListPlaceholder.test(arg)) {
return profiles;
}
} else {
if (serviceListPlaceholder.test(arg.value)) {
return services;
} else if (profileListPlaceholder.test(arg.value)) {
return profiles;
}
}

return [arg];
}),
};
}

async function addServicesOrProfilesIfNeeded(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, command: string, preselectedServices: string[], preselectedProfiles: string[]): Promise<string> {
const commandWithoutPlaceholders = command.replace(serviceListPlaceholder, '').replace(profileListPlaceholder, '');

Expand Down
120 changes: 120 additions & 0 deletions src/commands/compose/getComposeSubsetList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,54 @@
*--------------------------------------------------------------------------------------------*/

import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils';
import { CommandLineArgs, PromiseCommandResponse, quoted, VoidCommandResponse } from '@microsoft/vscode-container-client';
import * as vscode from 'vscode';
import { ext } from '../../extensionVariables';
import { runWithDefaults } from '../../runtimes/runners/runWithDefaults';
import { execAsync } from '../../utils/execAsync';

// Matches an `up` or `down` and everything after it--so that it can be replaced with `config --services`, to get a service list using all of the files originally part of the compose command
const composeCommandReplaceRegex = /(\b(up|down)\b).*$/i;

type SubsetType = 'services' | 'profiles';

// We special case the default compose commands into a full VoidCommandResponse object with command and args populated (to help with shell escaping). This method will be called in those cases to get the service or profile lists for a given command.
export async function getDefaultCommandComposeProfilesOrServices(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, composeCommand: VoidCommandResponse, preselectedServices?: string[], preselectedProfiles?: string[]): Promise<{ services: CommandLineArgs, profiles: CommandLineArgs }> {
const profiles = await getDefaultCommandServiceSubsets(workspaceFolder, composeCommand, 'profiles');

if (preselectedServices?.length && preselectedProfiles?.length) {
throw new Error(vscode.l10n.t('Cannot specify both services and profiles to start. Please choose one or the other.'));
}

// If there are any profiles, we need to ask the user whether they want profiles or services, since they are mutually exclusive to use
// Otherwise, if there are no profiles, we'll automatically assume services
let useProfiles = false;
if (preselectedProfiles?.length) {
useProfiles = true;
} else if (preselectedServices?.length) {
useProfiles = false;
} else if (profiles?.length) {
const profilesOrServices: IAzureQuickPickItem<SubsetType>[] = [
{
label: vscode.l10n.t('Services'),
data: 'services'
},
{
label: vscode.l10n.t('Profiles'),
data: 'profiles'
}
];

useProfiles = 'profiles' === (await context.ui.showQuickPick(profilesOrServices, { placeHolder: vscode.l10n.t('Do you want to start services or profiles?') })).data;
}

return {
profiles: useProfiles ? await getDefaultCommandComposeProfileList(context, workspaceFolder, composeCommand, profiles, preselectedProfiles) : [],
services: !useProfiles ? await getDefaultCommandComposeServiceList(context, workspaceFolder, composeCommand, preselectedServices) : [],
};
}

// In the event that a user has customized a compose command, we treat it as a full command string instead of parsing to command and args like the default command version. This method handles replacing service and profile arguments in that case.
export async function getComposeProfilesOrServices(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, composeCommand: string, preselectedServices?: string[], preselectedProfiles?: string[]): Promise<{ services: string | undefined, profiles: string | undefined }> {
const profiles = await getServiceSubsets(workspaceFolder, composeCommand, 'profiles');

Expand Down Expand Up @@ -48,6 +87,46 @@ export async function getComposeProfilesOrServices(context: IActionContext, work
};
}

// Default command version of the compose profile list method; returns CommandLineArgs instead of a string
async function getDefaultCommandComposeProfileList(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, composeCommand: VoidCommandResponse, prefetchedProfiles?: string[], preselectedProfiles?: string[]): Promise<CommandLineArgs> {
const profiles = prefetchedProfiles ?? await getDefaultCommandServiceSubsets(workspaceFolder, composeCommand, 'profiles');

if (!profiles?.length) {
// No profiles or isn't supported, nothing to do
return [];
}

// Fetch the previously chosen profiles list. By default, all will be selected.
const workspaceProfileListKey = `vscode-docker.composeProfiles.${workspaceFolder.name}`;
const previousChoices = ext.context.workspaceState.get<string[]>(workspaceProfileListKey, profiles);
const result = preselectedProfiles?.length ? preselectedProfiles : await pickSubsets(context, 'profiles', profiles, previousChoices);

// Update the cache
await ext.context.workspaceState.update(workspaceProfileListKey, result);

return result.flatMap(p => ['--profile', quoted(p)]);
}

// Default command version of the compose service list method; returns CommandLineArgs instead of a string
async function getDefaultCommandComposeServiceList(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, composeCommand: VoidCommandResponse, preselectedServices?: string[]): Promise<CommandLineArgs> {
const services = await getDefaultCommandServiceSubsets(workspaceFolder, composeCommand, 'services');

if (!services?.length) {
context.errorHandling.suppressReportIssue = true;
throw new Error(vscode.l10n.t('No services were found in the compose document(s). Did you mean to use profiles instead?'));
}

// Fetch the previously chosen services list. By default, all will be selected.
const workspaceServiceListKey = `vscode-docker.composeServices.${workspaceFolder.name}`;
const previousChoices = ext.context.workspaceState.get<string[]>(workspaceServiceListKey, services);
const result = preselectedServices?.length ? preselectedServices : await pickSubsets(context, 'services', services, previousChoices);

// Update the cache
await ext.context.workspaceState.update(workspaceServiceListKey, result);

return result.map(p => quoted(p));
}

export async function getComposeProfileList(context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, composeCommand: string, prefetchedProfiles?: string[], preselectedProfiles?: string[]): Promise<string> {
const profiles = prefetchedProfiles ?? await getServiceSubsets(workspaceFolder, composeCommand, 'profiles');

Expand Down Expand Up @@ -112,6 +191,47 @@ async function pickSubsets(context: IActionContext, type: SubsetType, allChoices
return chosenSubsets.map(c => c.data);
}

async function getDefaultCommandServiceSubsets(workspaceFolder: vscode.WorkspaceFolder, composeCommand: VoidCommandResponse, type: SubsetType): Promise<string[] | undefined> {
// TODO: if there are any profiles, then only services with no profiles show up when you query `config --services`. This makes for a lousy UX.
// Bug for that is https://github.com/docker/compose-cli/issues/1964

const configCommand: PromiseCommandResponse<string[]> = {
command: composeCommand.command,
args: [],
parse: (output) => {
// The output of the config command is a list of services / profiles, one per line
// Split them up and remove empty entries
return Promise.resolve(output.split(/\r?\n/im).filter(l => { return l; }));
},
};

const index = composeCommand.args.findIndex(arg => {
if (typeof arg === 'string') {
if (composeCommandReplaceRegex.test(arg)) {
return true;
}
} else if (composeCommandReplaceRegex.test(arg.value)) {
return true;
}

return false;
});

configCommand.args = composeCommand.args.slice(0, index);
configCommand.args.push('config', `--${type}`);

try {
return await runWithDefaults(() => configCommand, { cwd: workspaceFolder.uri?.fsPath });
} catch (err) {
// Profiles is not yet widely supported, so those errors will be eaten--otherwise, rethrow
if (type === 'profiles') {
return undefined;
} else {
throw err;
}
}
}

async function getServiceSubsets(workspaceFolder: vscode.WorkspaceFolder, composeCommand: string, type: SubsetType): Promise<string[] | undefined> {
// TODO: if there are any profiles, then only services with no profiles show up when you query `config --services`. This makes for a lousy UX.
// Bug for that is https://github.com/docker/compose-cli/issues/1964
Expand Down

0 comments on commit d0a2744

Please sign in to comment.