diff --git a/extension.bundle.ts b/extension.bundle.ts index 433f0e7ab..594cd00fc 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -18,6 +18,10 @@ export * from '@microsoft/vscode-azext-utils'; // Export activate/deactivate for main.js export * from './src/commands/deployWorkspaceProject/DeployWorkspaceProjectContext'; export * from './src/commands/deployWorkspaceProject/getDefaultValues/DefaultResourcesNameStep'; +export * from './src/commands/ingress/IngressContext'; +export * from './src/commands/ingress/IngressPromptStep'; +export * from './src/commands/ingress/editTargetPort/getDefaultPort'; +export * from './src/commands/ingress/tryGetDockerfileExposePorts'; export { activate, deactivate } from './src/extension'; export * from './src/extensionVariables'; export * from './src/utils/validateUtils'; diff --git a/gulpfile.ts b/gulpfile.ts index 3e69c1ffd..60b03dabe 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -26,6 +26,13 @@ async function cleanReadme(): Promise { await fse.writeFile(readmePath, data); } +async function preTest(): Promise { + const fromPath: string = path.join(__dirname, 'test', 'ingress', 'dockerfileSamples'); + const toPath: string = path.join(__dirname, 'dist', 'test', 'ingress', 'dockerfileSamples'); + fse.copySync(fromPath, toPath); +} + exports['webpack-dev'] = gulp.series(prepareForWebpack, () => gulp_webpack('development')); exports['webpack-prod'] = gulp.series(prepareForWebpack, () => gulp_webpack('production')); exports.cleanReadme = cleanReadme; +exports.preTest = preTest; diff --git a/package.json b/package.json index 749f76c1d..9b88235b1 100644 --- a/package.json +++ b/package.json @@ -507,7 +507,8 @@ "package": "vsce package --githubBranch main --no-dependencies", "lint": "eslint --ext .ts .", "lint-fix": "eslint --ext .ts . --fix", - "test": "node ./out/test/runTest.js", + "pretest": "gulp preTest", + "test": "node ./dist/test/runTest.js", "webpack": "tsc && gulp webpack-dev", "webpack-profile": "webpack --profile --json --mode production > webpack-stats.json && echo Use http://webpack.github.io/analyse to analyze the stats", "prepare": "husky install" diff --git a/src/commands/createContainerApp/createContainerApp.ts b/src/commands/createContainerApp/createContainerApp.ts index 1f6568813..0ca9e30c5 100644 --- a/src/commands/createContainerApp/createContainerApp.ts +++ b/src/commands/createContainerApp/createContainerApp.ts @@ -30,6 +30,7 @@ export async function createContainerApp(context: IActionContext & Partial { public async prompt(context: IngressContext): Promise { @@ -17,6 +20,10 @@ export class IngressPromptStep extends AzureWizardPromptStep { { placeHolder: localize('enableIngress', 'Enable ingress for applications that need an HTTP endpoint.') })).data; } + public async configureBeforePrompt(context: IngressContext): Promise { + await tryConfigureIngressUsingDockerfile(context); + } + public shouldPrompt(context: IngressContext): boolean { return context.enableIngress === undefined; } @@ -37,3 +44,42 @@ export class IngressPromptStep extends AzureWizardPromptStep { return { promptSteps, executeSteps }; } } + +export async function tryConfigureIngressUsingDockerfile(context: IngressContext): Promise { + if (!context.dockerfilePath) { + return; + } + + context.dockerfileExposePorts = await tryGetDockerfileExposePorts(context.dockerfilePath); + + if (context.alwaysPromptIngress) { + return; + } + + if (!context.dockerfileExposePorts) { + context.enableIngress = false; + context.enableExternal = false; + } else if (context.dockerfileExposePorts) { + context.enableIngress = true; + context.enableExternal = true; + context.targetPort = getDefaultPort(context); + } + + // If a container app already exists, activity children will be added automatically in later execute steps + // if (!context.containerApp) { + // context.activityChildren?.push( + // new GenericTreeItem(undefined, { + // contextValue: createActivityChildContext(['ingressPromptStep', activitySuccessContext]), + // label: context.enableIngress ? + // localize('ingressEnableLabel', 'Enable ingress on port {0} (found Dockerfile configuration)', context.targetPort) : + // localize('ingressDisableLabel', 'Disable ingress (found Dockerfile configuration)'), + // iconPath: activitySuccessIcon + // }) + // ); + // } + + ext.outputChannel.appendLog(context.enableIngress ? + localize('ingressEnabledLabel', 'Detected ingress on port {0} using Dockerfile configuration.', context.targetPort) : + localize('ingressDisabledLabel', 'Detected no ingress using Dockerfile configuration.') + ); +} diff --git a/src/commands/ingress/editTargetPort/getDefaultPort.ts b/src/commands/ingress/editTargetPort/getDefaultPort.ts index 34e72285a..f1e207600 100644 --- a/src/commands/ingress/editTargetPort/getDefaultPort.ts +++ b/src/commands/ingress/editTargetPort/getDefaultPort.ts @@ -6,5 +6,17 @@ import type { IngressContext } from "../IngressContext"; export function getDefaultPort(context: IngressContext, fallbackPort: number = 80): number { - return context.containerApp?.configuration?.ingress?.targetPort || fallbackPort; + const currentDeploymentPort: number | undefined = context.containerApp?.configuration?.ingress?.targetPort; + + let dockerfilePortSuggestion: number | undefined; + if ( + // If there's already a deployment port, don't suggest a new port if it's already a port within range of the current Dockerfile expose ports + (currentDeploymentPort && context.dockerfileExposePorts && !context.dockerfileExposePorts.some(p => p.includes(currentDeploymentPort))) || + // If no deployment port but we found expose ports + (!currentDeploymentPort && context.dockerfileExposePorts) + ) { + dockerfilePortSuggestion = context.dockerfileExposePorts[0].start; + } + + return dockerfilePortSuggestion || currentDeploymentPort || fallbackPort; } diff --git a/src/commands/ingress/tryGetDockerfileExposePorts.ts b/src/commands/ingress/tryGetDockerfileExposePorts.ts new file mode 100644 index 000000000..f7f86b096 --- /dev/null +++ b/src/commands/ingress/tryGetDockerfileExposePorts.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import { AzExtFsExtra } from "@microsoft/vscode-azext-utils"; + +export async function tryGetDockerfileExposePorts(dockerfilePath: string): Promise { + if (!await AzExtFsExtra.pathExists(dockerfilePath)) { + return undefined; + } + + const content: string = await AzExtFsExtra.readFile(dockerfilePath); + const lines: string[] = content.split('\n'); + + const portRanges: PortRange[] = []; + for (const line of lines) { + if (!/^EXPOSE/i.test(line.trim())) { + continue; + } + + // Identify all single port numbers that aren't for udp + // Example formats: `3000` or `3000/tcp` but not `3000/udp` + // Note: (?<=\s) prevents the last number in a range 3000-3010 from being selected + const singlePorts: string[] = line.match(/(?<=\s)\d{2,5}(?!(\-)|(\/udp))\b/g) ?? []; + for (const sp of singlePorts) { + portRanges.push(new PortRange(parseInt(sp))); + } + + // Identify all port ranges + // Example format: `3000-3010` + const portRange: string[] = line.match(/\d{2,5}\-\d{2,5}/g) ?? []; + for (const pr of portRange) { + const [start, end] = pr.split('-'); + portRanges.push(new PortRange(parseInt(start), parseInt(end))); + } + } + + return portRanges.length ? portRanges : undefined; +} + +export class PortRange { + private readonly _start: number; + private readonly _end: number; + + constructor(start: number, end?: number) { + this._start = start; + this._end = end ? end : start; + } + + get start(): number { + return this._start; + } + + get end(): number { + return this._end; + } + + includes(port: number): boolean { + return port >= this.start && port <= this.end; + } +} diff --git a/test/ingress/IngressPromptStep.test.ts b/test/ingress/IngressPromptStep.test.ts new file mode 100644 index 000000000..e37825b70 --- /dev/null +++ b/test/ingress/IngressPromptStep.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzExtFsExtra } from "@microsoft/vscode-azext-utils"; +import * as assert from "assert"; +import * as path from "path"; +import { IngressContext, tryConfigureIngressUsingDockerfile } from "../../extension.bundle"; +import type { MockIngressContext } from "./MockIngressContext"; +import { expectedSamplePorts } from "./tryGetDockerfileExposePorts.test"; + +suite('IngressPromptStep', async () => { + test('tryConfigureIngressUsingDockerfile', async () => { + const dockerfileSamplesPath: string = path.join(__dirname, 'dockerfileSamples'); + const dockerfileSamples = await AzExtFsExtra.readDirectory(dockerfileSamplesPath); + + const expectedResult: MockIngressContext[] = [ + { enableIngress: true, enableExternal: true, dockerfileExposePorts: expectedSamplePorts[0], targetPort: 443 }, + { enableIngress: undefined, enableExternal: undefined, dockerfileExposePorts: undefined, targetPort: undefined }, // no dockerfilePath + { enableIngress: true, enableExternal: true, dockerfileExposePorts: expectedSamplePorts[2], targetPort: 80 }, + { enableIngress: undefined, enableExternal: undefined, dockerfileExposePorts: expectedSamplePorts[3], targetPort: undefined }, // alwaysPromptIngress=true + { enableIngress: true, enableExternal: true, dockerfileExposePorts: expectedSamplePorts[4], targetPort: 443 }, + { enableIngress: true, enableExternal: true, dockerfileExposePorts: expectedSamplePorts[5], targetPort: 80 }, + { enableIngress: false, enableExternal: false, dockerfileExposePorts: undefined, targetPort: undefined }, // no expose + ]; + + for (const [i, ds] of dockerfileSamples.entries()) { + const context: MockIngressContext = { + dockerfilePath: i === 1 ? undefined : ds.fsPath, + alwaysPromptIngress: i === 3 + }; + + await tryConfigureIngressUsingDockerfile(context as IngressContext); + + assert.deepStrictEqual({ + enableIngress: context.enableIngress, + enableExternal: context.enableExternal, + dockerfileExposePortsLength: context.dockerfileExposePorts?.length, + targetPort: context.targetPort + }, { + enableIngress: expectedResult[i].enableIngress, + enableExternal: expectedResult[i].enableExternal, + dockerfileExposePortsLength: expectedResult[i].dockerfileExposePorts?.length, + targetPort: expectedResult[i].targetPort + }); + } + }); +}); diff --git a/test/ingress/MockIngressContext.ts b/test/ingress/MockIngressContext.ts new file mode 100644 index 000000000..edef7bdca --- /dev/null +++ b/test/ingress/MockIngressContext.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { PortRange } from "../../extension.bundle"; + +export interface MockIngressContext { + containerApp?: { configuration: { ingress: { targetPort: number } } }; + + enableIngress?: boolean; + enableExternal?: boolean; + + targetPort?: number; + + dockerfilePath?: string; + dockerfileExposePorts?: PortRange[]; + alwaysPromptIngress?: boolean; +} diff --git a/test/ingress/dockerfileSamples/sample1.Dockerfile b/test/ingress/dockerfileSamples/sample1.Dockerfile new file mode 100644 index 000000000..52433ad26 --- /dev/null +++ b/test/ingress/dockerfileSamples/sample1.Dockerfile @@ -0,0 +1,7 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install +EXPOSE 443 80 +CMD ["node", "/src/server.js"] diff --git a/test/ingress/dockerfileSamples/sample2.Dockerfile b/test/ingress/dockerfileSamples/sample2.Dockerfile new file mode 100644 index 000000000..55b2a9379 --- /dev/null +++ b/test/ingress/dockerfileSamples/sample2.Dockerfile @@ -0,0 +1,8 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install +EXPOSE 80 +EXPOSE 443 +CMD ["node", "/src/server.js"] diff --git a/test/ingress/dockerfileSamples/sample3.Dockerfile b/test/ingress/dockerfileSamples/sample3.Dockerfile new file mode 100644 index 000000000..fa50467af --- /dev/null +++ b/test/ingress/dockerfileSamples/sample3.Dockerfile @@ -0,0 +1,7 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install +EXPOSE 80 8080-8090 +CMD ["node", "/src/server.js"] diff --git a/test/ingress/dockerfileSamples/sample4.Dockerfile b/test/ingress/dockerfileSamples/sample4.Dockerfile new file mode 100644 index 000000000..9255b76df --- /dev/null +++ b/test/ingress/dockerfileSamples/sample4.Dockerfile @@ -0,0 +1,7 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install +EXPOSE 8080-8090 80 +CMD ["node", "/src/server.js"] diff --git a/test/ingress/dockerfileSamples/sample5.Dockerfile b/test/ingress/dockerfileSamples/sample5.Dockerfile new file mode 100644 index 000000000..64f436f33 --- /dev/null +++ b/test/ingress/dockerfileSamples/sample5.Dockerfile @@ -0,0 +1,8 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install +EXPOSE 443/tcp +EXPOSE 5000/udp +CMD ["node", "/src/server.js"] diff --git a/test/ingress/dockerfileSamples/sample6.Dockerfile b/test/ingress/dockerfileSamples/sample6.Dockerfile new file mode 100644 index 000000000..aa6a518e8 --- /dev/null +++ b/test/ingress/dockerfileSamples/sample6.Dockerfile @@ -0,0 +1,9 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install + +## Extra unrealistic expose scenario used to double-check formatting logic +EXPOSE 80 443/tcp 8080-8090 5000/udp +CMD ["node", "/src/server.js"] diff --git a/test/ingress/dockerfileSamples/sample7.Dockerfile b/test/ingress/dockerfileSamples/sample7.Dockerfile new file mode 100644 index 000000000..f6d2199a3 --- /dev/null +++ b/test/ingress/dockerfileSamples/sample7.Dockerfile @@ -0,0 +1,7 @@ +## Modified example from Azure-Samples/acr-build-helloworld-node +FROM node:lts-alpine + +COPY . /src +RUN cd /src && npm install + +CMD ["node", "/src/server.js"] diff --git a/test/ingress/getDefaultPort.test.ts b/test/ingress/getDefaultPort.test.ts new file mode 100644 index 000000000..3d8c31c0e --- /dev/null +++ b/test/ingress/getDefaultPort.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from "assert"; +import { IngressContext, PortRange, getDefaultPort } from "../../extension.bundle"; +import type { MockIngressContext } from "./MockIngressContext"; + +suite('getDefaultPort', async () => { + test('Correctly suggests a new port when Dockerfile expose ports are detected with no existing container app port', async () => { + const context: MockIngressContext = { + dockerfileExposePorts: [new PortRange(443), new PortRange(8080, 8090)] + }; + assert.equal(getDefaultPort(context as IngressContext), 443); + }); + + test('Correctly suggests deployed port when Dockerfile expose ports are detected that overlap with existing container app port', async () => { + const context: MockIngressContext = { + containerApp: { configuration: { ingress: { targetPort: 8081 } } }, + dockerfileExposePorts: [new PortRange(80), new PortRange(443), new PortRange(8080, 8090)] + }; + assert.equal(getDefaultPort(context as IngressContext), 8081); + }); + + test('Correctly suggests existing deploy port when no expose ports are detected', async () => { + const context: MockIngressContext = { + containerApp: { configuration: { ingress: { targetPort: 3000 } } }, + }; + assert.equal(getDefaultPort(context as IngressContext), 3000); + }); + + test('Correctly suggests fallback port when no other ports are available', async () => { + assert.equal(getDefaultPort({} as IngressContext), 80); + }); +}); diff --git a/test/ingress/tryGetDockerfileExposePorts.test.ts b/test/ingress/tryGetDockerfileExposePorts.test.ts new file mode 100644 index 000000000..347f3a261 --- /dev/null +++ b/test/ingress/tryGetDockerfileExposePorts.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from "assert"; +import * as path from "path"; +import { AzExtFsExtra, PortRange, tryGetDockerfileExposePorts } from "../../extension.bundle"; + +/** + * Expected port values for the ingress Dockerfile test samples + */ +export const expectedSamplePorts: PortRange[][] = [ + [new PortRange(443), new PortRange(80)], + [new PortRange(80), new PortRange(443)], + [new PortRange(80), new PortRange(8080, 8090)], + [new PortRange(80), new PortRange(8080, 8090)], + [new PortRange(443)], + [new PortRange(80), new PortRange(443), new PortRange(8080, 8090)], + [] +]; + +suite('tryGetDockerfileExposePorts', async () => { + test('Correctly detects all Dockerfile sample expose ports', async () => { + const dockerfileSamplesPath: string = path.join(__dirname, 'dockerfileSamples'); + const dockerfileSamples = await AzExtFsExtra.readDirectory(dockerfileSamplesPath); + + for (const [i, ds] of dockerfileSamples.entries()) { + const portRange: PortRange[] = await tryGetDockerfileExposePorts(ds.fsPath) ?? []; + + for (const [j, pr] of portRange.entries()) { + assert.equal(pr.start, expectedSamplePorts[i][j].start); + assert.equal(pr.end, expectedSamplePorts[i][j].end); + } + } + }); +}); diff --git a/test/validateUtils.test.ts b/test/validateUtils.test.ts index 1f972fb61..9a1baa4d3 100644 --- a/test/validateUtils.test.ts +++ b/test/validateUtils.test.ts @@ -1,3 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + import * as assert from 'assert'; import { validateUtils } from '../extension.bundle';