Skip to content

Commit 175bbef

Browse files
authored
Support versioning when installing site extensions in AzureAppServiceManageV0 task (#21060)
* Support versioning when installing site extensions in AzureAppServiceManageV0 task * Fix the failing build and align test stub/spy method with other tasks * Update versions * Update readme removing statement about env variable for FF as untrue * Don't force installation when version is "latest" and site extension already installed with latest version * Do not install site extension if specified version is already installed
1 parent 5c77ece commit 175bbef

File tree

8 files changed

+275
-11
lines changed

8 files changed

+275
-11
lines changed

Tasks/AzureAppServiceManageV0/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ The task is used to manage an existing Azure App Service. The mandatory fields a
5353

5454
* **Install Extensions:** The task can also be used to [install site extensions](https://www.siteextensions.net/packages) on the App Service. Site Extensions run on Microsoft Azure App Service. You can install set of tools as site extension such as [PHP Composer](https://www.siteextensions.net/packages/ComposerExtension/) or the right version of [Python](https://www.siteextensions.net/packages?q=Python). The App Service will be restarted to make sure latest changes take effect. Please note that extensions are only supported only for Web App on Windows.
5555

56+
- **Versioned Extension Support (Preview):** You can now specify extensions as `name@version` (e.g., `[email protected]`). If a version is specified, that exact version will be installed. If you specify `latest` as the version, the latest available version will be installed (in case it already has the latest version, installation will be skipped). If no version is specified, the extension will only be installed if it is not already present. Example: `[email protected], OtherExtension@latest, LegacyExtension`.
57+
5658
## Output variable
5759
When provided a variable name, the variable will be populated with the the local installation path of the selected extension. In case of multiple extensions selected for installation, provide comma separated list of variables that saves the local path for each of the selected extension in the order it appears in the Install Extension field. Example: outputVariable1, outputVariable2
5860

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as assert from 'assert';
2+
import { KuduServiceUtils } from '../operations/KuduServiceUtils';
3+
4+
describe('KuduServiceUtils.installSiteExtensionsWithVersionSupport', () => {
5+
let kuduServiceMock: any;
6+
let utils: KuduServiceUtils;
7+
let calls: any;
8+
beforeEach(() => {
9+
calls = {
10+
getSiteExtensions: [],
11+
getAllSiteExtensions: [],
12+
installSiteExtension: [],
13+
installSiteExtensionWithVersion: []
14+
};
15+
kuduServiceMock = {
16+
getSiteExtensions: async function() { calls.getSiteExtensions.push([...arguments]); return []; },
17+
getAllSiteExtensions: async function() { calls.getAllSiteExtensions.push([...arguments]); return [
18+
{ id: 'ext1', title: 'ext1' },
19+
{ id: 'ext2', title: 'ext2' }
20+
]; },
21+
installSiteExtension: async function() { calls.installSiteExtension.push([...arguments]); return { id: 'ext1', local_path: 'foo' }; },
22+
installSiteExtensionWithVersion: async function() { calls.installSiteExtensionWithVersion.push([...arguments]); return { id: 'ext1', local_path: 'foo' }; }
23+
};
24+
utils = new KuduServiceUtils(kuduServiceMock);
25+
});
26+
it('calls installSiteExtensionWithVersion for versioned input', async () => {
27+
await utils.installSiteExtensionsWithVersion(['[email protected]']);
28+
assert.strictEqual(calls.installSiteExtensionWithVersion.length, 1, 'installSiteExtensionWithVersion should be called once');
29+
assert.deepStrictEqual(calls.installSiteExtensionWithVersion[0], ['ext1', '1.2.3']);
30+
});
31+
it('calls installSiteExtension for latest', async () => {
32+
await utils.installSiteExtensionsWithVersion(['ext1@latest']);
33+
assert.strictEqual(calls.installSiteExtension.length, 1, 'installSiteExtension should be called once');
34+
assert.deepStrictEqual(calls.installSiteExtension[0], ['ext1']);
35+
});
36+
it('calls installSiteExtension for no version if not installed', async () => {
37+
kuduServiceMock.getSiteExtensions = async function() { calls.getSiteExtensions.push([...arguments]); return []; };
38+
await utils.installSiteExtensionsWithVersion(['ext2']);
39+
assert.strictEqual(calls.installSiteExtension.length, 1, 'installSiteExtension should be called once');
40+
assert.deepStrictEqual(calls.installSiteExtension[0], ['ext2']);
41+
});
42+
it('does not call installSiteExtension for no version if already installed', async () => {
43+
kuduServiceMock.getSiteExtensions = async function() { calls.getSiteExtensions.push([...arguments]); return [{ id: 'ext2', local_path: 'bar' }]; };
44+
await utils.installSiteExtensionsWithVersion(['ext2']);
45+
assert.strictEqual(calls.installSiteExtension.length, 0, 'installSiteExtension should not be called');
46+
});
47+
it('skips installSiteExtension for @latest if local_is_latest_version is true', async () => {
48+
kuduServiceMock.getSiteExtensions = async function() {
49+
calls.getSiteExtensions.push([...arguments]);
50+
return [{ id: 'ext1', local_is_latest_version: true, local_path: 'foo' }];
51+
};
52+
await utils.installSiteExtensionsWithVersion(['ext1@latest']);
53+
assert.strictEqual(calls.installSiteExtension.length, 0, 'installSiteExtension should not be called');
54+
});
55+
it('calls installSiteExtension for @latest if local_is_latest_version is false', async () => {
56+
kuduServiceMock.getSiteExtensions = async function() {
57+
calls.getSiteExtensions.push([...arguments]);
58+
return [{ id: 'ext1', local_is_latest_version: false, local_path: 'foo' }];
59+
};
60+
await utils.installSiteExtensionsWithVersion(['ext1@latest']);
61+
assert.strictEqual(calls.installSiteExtension.length, 1, 'installSiteExtension should be called');
62+
assert.deepStrictEqual(calls.installSiteExtension[0], ['ext1']);
63+
});
64+
it('skips installSiteExtension for @latest if python-prefixed extension is at latest', async () => {
65+
kuduServiceMock.getSiteExtensions = async function() {
66+
calls.getSiteExtensions.push([...arguments]);
67+
return [{ id: 'azureappservice-ext1', local_is_latest_version: true, local_path: 'foo' }];
68+
};
69+
await utils.installSiteExtensionsWithVersion(['ext1@latest']);
70+
assert.strictEqual(calls.installSiteExtension.length, 0, 'installSiteExtension should not be called');
71+
});
72+
it('calls installSiteExtension for @latest if python-prefixed extension is not at latest', async () => {
73+
kuduServiceMock.getSiteExtensions = async function() {
74+
calls.getSiteExtensions.push([...arguments]);
75+
return [{ id: 'azureappservice-ext1', local_is_latest_version: false, local_path: 'foo' }];
76+
};
77+
await utils.installSiteExtensionsWithVersion(['ext1@latest']);
78+
assert.strictEqual(calls.installSiteExtension.length, 1, 'installSiteExtension should be called');
79+
assert.deepStrictEqual(calls.installSiteExtension[0], ['ext1']);
80+
});
81+
it('skips installSiteExtensionWithVersion for versioned input if already installed at that version', async () => {
82+
kuduServiceMock.getSiteExtensions = async function() {
83+
calls.getSiteExtensions.push([...arguments]);
84+
return [{ id: 'ext1', version: '1.2.3', local_path: 'foo' }];
85+
};
86+
await utils.installSiteExtensionsWithVersion(['[email protected]']);
87+
assert.strictEqual(calls.installSiteExtensionWithVersion.length, 0, 'installSiteExtensionWithVersion should not be called');
88+
});
89+
it('calls installSiteExtensionWithVersion for versioned input if already installed at different version', async () => {
90+
kuduServiceMock.getSiteExtensions = async function() {
91+
calls.getSiteExtensions.push([...arguments]);
92+
return [{ id: 'ext1', version: '1.0.0', local_path: 'foo' }];
93+
};
94+
await utils.installSiteExtensionsWithVersion(['[email protected]']);
95+
assert.strictEqual(calls.installSiteExtensionWithVersion.length, 1, 'installSiteExtensionWithVersion should be called');
96+
assert.deepStrictEqual(calls.installSiteExtensionWithVersion[0], ['ext1', '1.2.3']);
97+
});
98+
});

Tasks/AzureAppServiceManageV0/azureappservicemanage.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,12 @@ async function run() {
166166
}
167167
case "Install Extensions": {
168168
let extensionOutputVariablesArray = (extensionOutputVariables) ? extensionOutputVariables.split(',') : [];
169-
await kuduServiceUtils.installSiteExtensions(extensionList.split(','), extensionOutputVariablesArray);
169+
if (tl.getPipelineFeature('EnableExtensionVersionSupport')) {
170+
tl.debug('Installing site extensions with version support');
171+
await kuduServiceUtils.installSiteExtensionsWithVersion(extensionList.split(','), extensionOutputVariablesArray);
172+
} else {
173+
await kuduServiceUtils.installSiteExtensions(extensionList.split(','), extensionOutputVariablesArray);
174+
}
170175
break;
171176
}
172177
case "Enable Continuous Monitoring": {

Tasks/AzureAppServiceManageV0/operations/KuduServiceUtils.ts

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ export class KuduServiceUtils {
6363
if(allSiteExtensionMap[extensionID] && allSiteExtensionMap[extensionID].title == extensionID) {
6464
extensionID = allSiteExtensionMap[extensionID].id;
6565
}
66-
// Python extensions are moved to Nuget and the extensions IDs are changed. The belo check ensures that old extensions are mapped to new extension ID.
67-
if(siteExtensionMap[extensionID] || (extensionID.startsWith('python') && siteExtensionMap[pythonExtensionPrefix + extensionID])) {
68-
siteExtensionDetails = siteExtensionMap[extensionID] || siteExtensionMap[pythonExtensionPrefix + extensionID];
66+
const alreadyInstalled = this._getInstalledSiteExtension(extensionID, siteExtensionMap);
67+
if(alreadyInstalled) {
68+
siteExtensionDetails = alreadyInstalled;
6969
console.log(tl.loc('ExtensionAlreadyInstalled', extensionID));
7070
}
7171
else {
@@ -90,6 +90,85 @@ export class KuduServiceUtils {
9090
}
9191
}
9292

93+
/**
94+
* Installs site extensions with version support if specified (name@version).
95+
* Emits telemetry for attempts, successes, and failures.
96+
* Falls back to legacy behavior for non-versioned extensions.
97+
*/
98+
public async installSiteExtensionsWithVersion(extensionList: Array<string>, outputVariables?: Array<string>): Promise<void> {
99+
outputVariables = outputVariables ? outputVariables : [];
100+
var outputVariableIterator: number = 0;
101+
var siteExtensions = await this._appServiceKuduService.getSiteExtensions();
102+
var allSiteExtensions = await this._appServiceKuduService.getAllSiteExtensions();
103+
var anyExtensionInstalled: boolean = false;
104+
var siteExtensionMap = {};
105+
var allSiteExtensionMap = {};
106+
var extensionLocalPaths: string = "";
107+
for (var siteExtension of siteExtensions) {
108+
siteExtensionMap[siteExtension.id] = siteExtension;
109+
}
110+
for (var siteExtension of allSiteExtensions) {
111+
allSiteExtensionMap[siteExtension.id] = siteExtension;
112+
allSiteExtensionMap[siteExtension.title] = siteExtension;
113+
}
114+
for (const ext of extensionList) {
115+
const { name, version } = this._parseExtensionNameAndVersion(ext);
116+
if (!name) {
117+
tl.warning(`Malformed extension input: '${ext}'`);
118+
continue;
119+
}
120+
let extensionID = name;
121+
if (allSiteExtensionMap[extensionID] && allSiteExtensionMap[extensionID].title == extensionID) {
122+
extensionID = allSiteExtensionMap[extensionID].id;
123+
}
124+
try {
125+
let siteExtensionDetails = null;
126+
const alreadyInstalled = this._getInstalledSiteExtension(extensionID, siteExtensionMap);
127+
if (version) {
128+
if (version.toLowerCase() === 'latest') {
129+
if (alreadyInstalled && alreadyInstalled.local_is_latest_version !== false) {
130+
tl.debug(`Extension '${extensionID}' is already at latest version (local_is_latest_version: true), skipping install.`);
131+
siteExtensionDetails = alreadyInstalled;
132+
} else {
133+
siteExtensionDetails = await this._appServiceKuduService.installSiteExtension(extensionID);
134+
anyExtensionInstalled = true;
135+
}
136+
} else {
137+
if (alreadyInstalled && alreadyInstalled.version === version) {
138+
tl.debug(`Extension '${extensionID}' is already at specified version '${version}', skipping install.`);
139+
siteExtensionDetails = alreadyInstalled;
140+
} else {
141+
siteExtensionDetails = await this._appServiceKuduService.installSiteExtensionWithVersion(extensionID, version);
142+
anyExtensionInstalled = true;
143+
}
144+
}
145+
} else {
146+
if (alreadyInstalled) {
147+
siteExtensionDetails = alreadyInstalled;
148+
tl.debug('ExtensionAlreadyInstalled: ' + extensionID);
149+
} else {
150+
siteExtensionDetails = await this._appServiceKuduService.installSiteExtension(extensionID);
151+
anyExtensionInstalled = true;
152+
}
153+
}
154+
var extensionLocalPath: string = this._getExtensionLocalPath(siteExtensionDetails as any);
155+
extensionLocalPaths += extensionLocalPath + ",";
156+
if (outputVariableIterator < outputVariables.length) {
157+
tl.debug('Set output Variable ' + outputVariables[outputVariableIterator] + ' to value: ' + extensionLocalPath);
158+
tl.setVariable(outputVariables[outputVariableIterator], extensionLocalPath);
159+
outputVariableIterator += 1;
160+
}
161+
} catch (err) {
162+
tl.warning(`Failed to install extension ${name}${version ? '@' + version : ''}: ${err}`);
163+
}
164+
}
165+
tl.debug('Set output Variable LocalPathsForInstalledExtensions to value: ' + extensionLocalPaths.slice(0, -1));
166+
tl.setVariable("LocalPathsForInstalledExtensions", extensionLocalPaths.slice(0, -1));
167+
if (anyExtensionInstalled) {
168+
await this.restart();
169+
}
170+
}
171+
93172
public async restart() {
94173
try {
95174
console.log(tl.loc('RestartingKuduService'));
@@ -221,4 +300,50 @@ export class KuduServiceUtils {
221300
details : buildOrReleaseUrl
222301
};
223302
}
303+
304+
/**
305+
* Helper to parse extension name and version from a string like 'name@version'.
306+
* Returns { name: string, version: string|null }.
307+
* Handles edge cases: missing name, empty version, malformed input.
308+
*/
309+
private _parseExtensionNameAndVersion(extension: string): { name: string, version: string|null } {
310+
if (!extension || typeof extension !== 'string') {
311+
return { name: '', version: null };
312+
}
313+
const atIdx = extension.indexOf('@');
314+
if (atIdx === -1) {
315+
return { name: extension, version: null };
316+
}
317+
const name = extension.substring(0, atIdx).trim();
318+
const version = extension.substring(atIdx + 1).trim();
319+
if (!name) {
320+
return { name: '', version: null };
321+
}
322+
if (!version) {
323+
return { name, version: null };
324+
}
325+
return { name, version };
326+
}
327+
328+
/**
329+
* Checks if the versioned extension install feature flag is enabled.
330+
* Uses pipeline variable injection. Name: EnableExtensionVersionSupport
331+
*/
332+
public static isExtensionVersionSupportEnabled(): boolean {
333+
return tl.getPipelineFeature('EnableExtensionVersionSupport');
334+
}
335+
336+
/**
337+
* Returns the installed site extension details if present, including python-prefixed extensions.
338+
* Python extensions are moved to Nuget and the extensions IDs are changed. The below check ensures that old extensions are mapped to new extension ID.
339+
*/
340+
private _getInstalledSiteExtension(extensionID: string, siteExtensionMap: any): any {
341+
if (siteExtensionMap[extensionID]) {
342+
return siteExtensionMap[extensionID];
343+
}
344+
if (extensionID.startsWith('python') && siteExtensionMap[pythonExtensionPrefix + extensionID]) {
345+
return siteExtensionMap[pythonExtensionPrefix + extensionID];
346+
}
347+
return null;
348+
}
224349
}

Tasks/AzureAppServiceManageV0/package-lock.json

Lines changed: 38 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Tasks/AzureAppServiceManageV0/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@types/q": "1.0.7",
2323
"agent-base": "6.0.2",
2424
"azure-pipelines-task-lib": "^4.11.0",
25-
"azure-pipelines-tasks-azure-arm-rest": "^3.254.0",
25+
"azure-pipelines-tasks-azure-arm-rest": "^3.258.1",
2626
"azure-pipelines-tasks-utility-common": "^3.225.0",
2727
"q": "1.4.1",
2828
"xml2js": "^0.5.0"

Tasks/AzureAppServiceManageV0/task.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"demands": [],
1919
"version": {
2020
"Major": 0,
21-
"Minor": 255,
21+
"Minor": 258,
2222
"Patch": 0
2323
},
2424
"minimumAgentVersion": "1.102.0",

Tasks/AzureAppServiceManageV0/task.loc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"demands": [],
1919
"version": {
2020
"Major": 0,
21-
"Minor": 255,
21+
"Minor": 258,
2222
"Patch": 0
2323
},
2424
"minimumAgentVersion": "1.102.0",

0 commit comments

Comments
 (0)