Skip to content

Commit 86b110c

Browse files
authored
Remaining connectToGitHub command implementation (#355)
1 parent 46f001a commit 86b110c

9 files changed

+244
-14
lines changed

package.nls.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"containerApps.openConsoleInPortal": "Open Console in Portal",
2828
"containerApps.editScalingRange": "Edit Scaling Range...",
2929
"containerApps.addScaleRule": "Add Scale Rule...",
30-
"containerApps.connectToGitHub": "Connect to a GitHub Repository...",
30+
"containerApps.connectToGitHub": "Connect to GitHub Repository...",
3131
"containerApps.startStreamingLogs": "Start Streaming Logs...",
3232
"containerApps.stopStreamingLogs": "Stop Streaming Logs..."
3333
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { AzureWizardPromptStep } from "@microsoft/vscode-azext-utils";
7+
import { localize } from "../../utils/localize";
8+
import { validateUtils } from "../../utils/validateUtils";
9+
import type { IConnectToGitHubContext } from "./IConnectToGitHubContext";
10+
11+
export class DockerfileLocationInputStep extends AzureWizardPromptStep<IConnectToGitHubContext> {
12+
public async prompt(context: IConnectToGitHubContext): Promise<void> {
13+
context.dockerfilePath = (await context.ui.showInputBox({
14+
value: './Dockerfile',
15+
prompt: localize('dockerfileLocationPrompt', "Enter the relative location of the Dockerfile in the repository."),
16+
validateInput: this.validateInput
17+
})).trim();
18+
}
19+
20+
public shouldPrompt(context: IConnectToGitHubContext): boolean {
21+
return !context.dockerfilePath;
22+
}
23+
24+
private validateInput(dockerfilePath: string): string | undefined {
25+
dockerfilePath = dockerfilePath ? dockerfilePath.trim() : '';
26+
return !validateUtils.isValidLength(dockerfilePath) ? validateUtils.getInvalidLengthMessage() : undefined;
27+
}
28+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import type { ContainerAppsAPIClient, SourceControl } from "@azure/arm-appcontainers";
7+
import { AzureWizardExecuteStep, nonNullProp } from "@microsoft/vscode-azext-utils";
8+
import type { Progress } from "vscode";
9+
import { ext } from "../../extensionVariables";
10+
import { createContainerAppsClient } from "../../utils/azureClients";
11+
import { localize } from "../../utils/localize";
12+
import { listCredentialsFromRegistry } from "../imageSource/containerRegistry/acr/listCredentialsFromRegistry";
13+
import type { IConnectToGitHubContext } from "./IConnectToGitHubContext";
14+
15+
export class GitHubRepositoryConnectStep extends AzureWizardExecuteStep<IConnectToGitHubContext> {
16+
public priority: number = 300;
17+
18+
public async execute(context: IConnectToGitHubContext, progress: Progress<{ message?: string | undefined; increment?: number | undefined }>): Promise<void> {
19+
const client: ContainerAppsAPIClient = await createContainerAppsClient(context, context.subscription);
20+
const { username, password } = await listCredentialsFromRegistry(context, nonNullProp(context, 'registry'));
21+
22+
const rgName: string = context.targetContainer.resourceGroup;
23+
const caName: string = context.targetContainer.name;
24+
25+
const scName: string = 'current';
26+
const scEnvelope: SourceControl = {
27+
repoUrl: context.gitHubRepositoryUrl,
28+
branch: context.gitHubBranch,
29+
githubActionConfiguration: {
30+
contextPath: context.dockerfilePath,
31+
image: context.repositoryName,
32+
azureCredentials: {
33+
clientId: context.servicePrincipalId,
34+
clientSecret: context.servicePrincipalSecret,
35+
tenantId: context.subscription.tenantId
36+
},
37+
registryInfo: {
38+
registryUrl: context.registry?.loginServer,
39+
registryUserName: username,
40+
registryPassword: password.value
41+
}
42+
}
43+
};
44+
const requestOptions = {
45+
customHeaders: {
46+
'x-ms-github-auxiliary': nonNullProp(context, 'gitHubAccessToken')
47+
}
48+
};
49+
50+
const connecting: string = localize('connectingRepository', 'Connecting "{0}"...', context.gitHubRepository);
51+
progress.report({ message: connecting });
52+
53+
await client.containerAppsSourceControls.beginCreateOrUpdateAndWait(rgName, caName, scName, scEnvelope, { requestOptions });
54+
ext.state.notifyChildrenChanged(context.targetContainer.id);
55+
56+
const gitHubRepository: string = `${context.gitHubOrg || context.gitHubRepositoryOwner}/${context.gitHubRepository}`;
57+
const connected: string = localize('connectedRepository', 'Connected repository "{0}" to container app "{1}".', gitHubRepository, context.targetContainer.name);
58+
ext.outputChannel.appendLog(connected);
59+
}
60+
61+
public shouldExecute(): boolean {
62+
return true;
63+
}
64+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { AzureWizardPromptStep } from "@microsoft/vscode-azext-utils";
7+
import { localize } from "../../utils/localize";
8+
import { validateUtils } from "../../utils/validateUtils";
9+
import type { IConnectToGitHubContext } from "./IConnectToGitHubContext";
10+
11+
export class ServicePrincipalIdInputStep extends AzureWizardPromptStep<IConnectToGitHubContext> {
12+
public async prompt(context: IConnectToGitHubContext): Promise<void> {
13+
context.servicePrincipalId = (await context.ui.showInputBox({
14+
prompt: localize('servicePrincipalIdPrompt', 'Enter the service principal ID'),
15+
validateInput: this.validateInput
16+
})).trim();
17+
context.valuesToMask.push(context.servicePrincipalId);
18+
}
19+
20+
public shouldPrompt(context: IConnectToGitHubContext): boolean {
21+
return !context.servicePrincipalId;
22+
}
23+
24+
private validateInput(id: string): string | undefined {
25+
id = id ? id.trim() : '';
26+
return !validateUtils.isValidLength(id) ? validateUtils.getInvalidLengthMessage() : undefined;
27+
}
28+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { AzureWizardPromptStep } from "@microsoft/vscode-azext-utils";
7+
import { localize } from "../../utils/localize";
8+
import { validateUtils } from "../../utils/validateUtils";
9+
import type { IConnectToGitHubContext } from "./IConnectToGitHubContext";
10+
11+
export class ServicePrincipalSecretInputStep extends AzureWizardPromptStep<IConnectToGitHubContext> {
12+
public async prompt(context: IConnectToGitHubContext): Promise<void> {
13+
context.servicePrincipalSecret = (await context.ui.showInputBox({
14+
prompt: localize('servicePrincipalSecretPrompt', 'Enter the service principal secret'),
15+
validateInput: this.validateInput
16+
})).trim();
17+
context.valuesToMask.push(context.servicePrincipalSecret);
18+
}
19+
20+
public shouldPrompt(context: IConnectToGitHubContext): boolean {
21+
return !context.servicePrincipalSecret;
22+
}
23+
24+
private validateInput(secret: string): string | undefined {
25+
secret = secret ? secret.trim() : '';
26+
return !validateUtils.isValidLength(secret) ? validateUtils.getInvalidLengthMessage() : undefined;
27+
}
28+
}

src/commands/connectToGitHub/connectToGitHub.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@ import { GitHubBranchListStep } from "../../gitHub/GitHubBranchListStep";
88
import { GitHubOrgListStep } from "../../gitHub/GitHubOrgListStep";
99
import { GitHubRepositoryListStep } from "../../gitHub/GitHubRepositoryListStep";
1010
import { getGitHubAccessToken } from "../../gitHub/getGitHubAccessToken";
11-
import { ContainerAppItem } from "../../tree/ContainerAppItem";
11+
import type { ContainerAppItem } from "../../tree/ContainerAppItem";
1212
import { createActivityContext } from "../../utils/activityUtils";
1313
import { localize } from "../../utils/localize";
1414
import { pickContainerApp } from "../../utils/pickContainerApp";
15-
import { IConnectToGitHubContext } from "./IConnectToGitHubContext";
15+
import { AcrListStep } from "../imageSource/containerRegistry/acr/AcrListStep";
16+
import { AcrRepositoriesListStep } from "../imageSource/containerRegistry/acr/AcrRepositoriesListStep";
17+
import { DockerfileLocationInputStep } from "./DockerfileLocationInputStep";
18+
import { GitHubRepositoryConnectStep } from "./GitHubRepositoryConnectStep";
19+
import type { IConnectToGitHubContext } from "./IConnectToGitHubContext";
20+
import { ServicePrincipalIdInputStep } from "./ServicePrincipalIdInputStep";
21+
import { ServicePrincipalSecretInputStep } from "./ServicePrincipalSecretInputStep";
22+
import { isGitHubConnected } from "./isGitHubConnected";
1623

1724
export async function connectToGitHub(context: ITreeItemPickerContext & Partial<IConnectToGitHubContext>, node?: ContainerAppItem): Promise<void> {
1825
if (!node) {
@@ -31,21 +38,25 @@ export async function connectToGitHub(context: ITreeItemPickerContext & Partial<
3138
gitHubAccessToken: await getGitHubAccessToken()
3239
};
3340

34-
const title: string = localize('connectGitHubRepository', 'Connect a GitHub repository');
41+
if (await isGitHubConnected(wizardContext)) {
42+
throw new Error(localize('gitHubAlreadyConnected', '"{0}" is already connected to a GitHub repository.', containerApp.name));
43+
}
44+
45+
const title: string = localize('connectGitHubRepository', 'Connect a GitHub repository to "{0}"', containerApp.name);
3546

3647
const promptSteps: AzureWizardPromptStep<IConnectToGitHubContext>[] = [
3748
new GitHubOrgListStep(),
3849
new GitHubRepositoryListStep(),
39-
new GitHubBranchListStep()
40-
// new DockerfileLocationInputStep(),
41-
// new AcrListStep(),
42-
// new AcrRepositoriesListStep(),
43-
// new ServicePrincipalIdInputStep(),
44-
// new ServicePrincipalSecretInputStep()
50+
new GitHubBranchListStep(),
51+
new DockerfileLocationInputStep(),
52+
new AcrListStep(),
53+
new AcrRepositoriesListStep(),
54+
new ServicePrincipalIdInputStep(),
55+
new ServicePrincipalSecretInputStep()
4556
];
4657

4758
const executeSteps: AzureWizardExecuteStep<IConnectToGitHubContext>[] = [
48-
// new GithubActionCreateStep()
59+
new GitHubRepositoryConnectStep()
4960
];
5061

5162
const wizard: AzureWizard<IConnectToGitHubContext> = new AzureWizard(wizardContext, {
@@ -56,8 +67,6 @@ export async function connectToGitHub(context: ITreeItemPickerContext & Partial<
5667
});
5768

5869
await wizard.prompt();
59-
// await wizard.execute();
60-
61-
throw new Error("'connectToGitHub' is not fully implemented yet.");
70+
await wizard.execute();
6271
}
6372

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import type { ContainerAppsAPIClient, SourceControl } from "@azure/arm-appcontainers";
7+
import { uiUtils } from "@microsoft/vscode-azext-azureutils";
8+
import { IActionContext, createSubscriptionContext } from "@microsoft/vscode-azext-utils";
9+
import type { AzureSubscription } from "@microsoft/vscode-azureresources-api";
10+
import type { ContainerAppModel } from "../../tree/ContainerAppItem";
11+
import { createContainerAppsAPIClient } from "../../utils/azureClients";
12+
13+
export async function getContainerAppSourceControl(context: IActionContext, subscription: AzureSubscription, containerApp: ContainerAppModel): Promise<SourceControl | undefined> {
14+
const client: ContainerAppsAPIClient = await createContainerAppsAPIClient([context, createSubscriptionContext(subscription)]);
15+
const sourceControlsIterator = client.containerAppsSourceControls.listByContainerApp(containerApp.resourceGroup, containerApp.name);
16+
return (await uiUtils.listAllIterator(sourceControlsIterator))[0];
17+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import type { IActionContext } from "@microsoft/vscode-azext-utils";
7+
import type { AzureSubscription } from "@microsoft/vscode-azureresources-api";
8+
import type { ContainerAppModel } from "../../tree/ContainerAppItem";
9+
import { getContainerAppSourceControl } from "./getContainerAppSourceControl";
10+
11+
export async function isGitHubConnected(context: IActionContext & { subscription: AzureSubscription, targetContainer: ContainerAppModel }): Promise<boolean> {
12+
return !!await getContainerAppSourceControl(context, context.subscription, context.targetContainer);
13+
}

src/utils/validateUtils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { localize } from "./localize";
7+
8+
export namespace validateUtils {
9+
const thirtyTwoBitMaxSafeInteger: number = 2147483647;
10+
// Estimated using UTF-8 encoding, where a character can be up to ~4 bytes long
11+
const maxSafeCharacterLength: number = thirtyTwoBitMaxSafeInteger / 32;
12+
13+
/**
14+
* Validates that the given input string is the appropriate length as determined by the optional lower and upper limit parameters
15+
*/
16+
export function isValidLength(value: string, lowerLimitIncl?: number, upperLimitIncl?: number): boolean {
17+
lowerLimitIncl ??= 1;
18+
upperLimitIncl = (!upperLimitIncl || upperLimitIncl > maxSafeCharacterLength) ? maxSafeCharacterLength : upperLimitIncl;
19+
20+
if (lowerLimitIncl > upperLimitIncl || value.length < lowerLimitIncl || value.length > upperLimitIncl) {
21+
return false;
22+
} else {
23+
return true;
24+
}
25+
}
26+
27+
/**
28+
* Provides a message that can be used to inform the user of invalid input lengths as determined by the optional lower and upper limit parameters
29+
*/
30+
export const getInvalidLengthMessage = (lowerLimitIncl?: number, upperLimitIncl?: number): string => {
31+
if (!lowerLimitIncl && !upperLimitIncl) {
32+
// Could technically also correspond to a 'maxSafeCharacterLength' overflow (see 'isValidLength'),
33+
// but extremely unlikely that a user would ever reach that limit naturally unless intentionally trying to break the extension
34+
return localize('invalidInputLength', 'A valid input value is required to proceed.');
35+
} else if (lowerLimitIncl && !upperLimitIncl) {
36+
return localize('inputLengthTooShort', 'The input value must be {0} characters or greater.', lowerLimitIncl);
37+
} else if (!lowerLimitIncl && upperLimitIncl) {
38+
return localize('inputLengthTooLong', 'The input value must be {0} characters or less.', upperLimitIncl);
39+
} else {
40+
return localize('invalidBetweenInputLength', 'The input value must be between {0} and {1} characters long.', lowerLimitIncl, upperLimitIncl);
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)