diff --git a/README.md b/README.md index f82701a6..e1cfdee3 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,45 @@ jobs: az logout ``` +### Sample workflow to configure settings from multiple secrets or variables +You can provide multiple JSON payloads in the same input by separating each payload with a line containing `---`. + +- For `app-settings-json` and `connection-strings-json`, each payload must be a JSON array. +- For `general-settings-json`, each payload must be a JSON object. +- Duplicate names/keys are resolved with last-write-wins. + +```yaml +# .github/workflows/configureAppSettings.yml +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: azure/login@v1 + with: + creds: '${{ secrets.AZURE_CREDENTIALS }}' + - uses: azure/appservice-settings@v1 + with: + app-name: 'my-app' + app-settings-json: | + ${{ secrets.APP_SETTINGS_BASE }} + --- + ${{ secrets.APP_SETTINGS_FEATURE_FLAGS }} + connection-strings-json: | + ${{ secrets.CONNECTION_STRINGS_SHARED }} + --- + ${{ secrets.CONNECTION_STRINGS_STAGING }} + general-settings-json: | + ${{ vars.GENERAL_SETTINGS_BASE }} + --- + ${{ vars.GENERAL_SETTINGS_OVERRIDE }} + id: settings + - run: echo "The webapp-url is ${{ steps.settings.outputs.webapp-url }}" + - run: | + az logout +``` + Azure App Service Settings Action is supported for the Azure public cloud as well as Azure government clouds ('AzureUSGovernment' or 'AzureChinaCloud') and Azure Stack ('AzureStack') Hub. Before running this action, login to the respective Azure Cloud using [Azure Login](https://github.com/Azure/login) by setting appropriate value for the `environment` parameter. # Contributing diff --git a/action.yml b/action.yml index 72df6b6a..62a64781 100644 --- a/action.yml +++ b/action.yml @@ -9,13 +9,13 @@ inputs: description: 'Name of an existing slot other than the production slot. Default value is production' required: false app-settings-json: #id of input - description: 'Application settings using the JSON syntax set as value of secret variable: APP_SETTINGS' + description: 'Application settings JSON. Supports one JSON array or multiple JSON arrays separated by --- lines. Duplicate setting names use last-write-wins.' required: false connection-strings-json: #id of input - description: 'Connection Strings using the JSON syntax set as value of secret variable: CONNECTION_STRINGS' + description: 'Connection strings JSON. Supports one JSON array or multiple JSON arrays separated by --- lines. Duplicate connection string names use last-write-wins.' required: false general-settings-json: #id of input - description: 'General configuration settings using dictionary syntax - Key Value pairs' + description: 'General configuration settings JSON object. Supports one JSON object or multiple JSON objects separated by --- lines. Duplicate keys use last-write-wins.' required: false mask-inputs: description: 'Set it to false if you want to provide input jsons as plain text/you do not want input json values to be masked. This will apply to app-settings-json and connection-strings-json. Default is true' diff --git a/lib/Tests/azureAppServiceSettings.test.js b/lib/Tests/azureAppServiceSettings.test.js index befa1a3f..2c0b3faf 100644 --- a/lib/Tests/azureAppServiceSettings.test.js +++ b/lib/Tests/azureAppServiceSettings.test.js @@ -31,6 +31,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const core = __importStar(require("@actions/core")); const main_1 = require("../main"); const Utils_1 = require("../Utils"); +const AuthorizerFactory_1 = require("azure-actions-webclient/AuthorizerFactory"); const AzureResourceFilterUtility_1 = require("azure-actions-appservice-rest/Utilities/AzureResourceFilterUtility"); const AzureAppServiceUtility_1 = require("azure-actions-appservice-rest/Utilities/AzureAppServiceUtility"); jest.mock('@actions/core'); @@ -39,11 +40,11 @@ jest.mock('azure-actions-webclient/AuthorizerFactory'); jest.mock('azure-actions-appservice-rest/Utilities/AzureResourceFilterUtility'); jest.mock('azure-actions-webclient/Authorizer/IAuthorizer'); jest.mock('azure-actions-appservice-rest/Utilities/AzureAppServiceUtility'); -var jsonObject = { +const jsonObject = { 'app-name': 'MOCK_APP_NAME', 'resource-group-name': 'MOCK_RESOURCE_GROUP', - 'mask-inputs': 'false', 'app-kind': 'MOCK_APP_KIND', + 'mask-inputs': 'false', 'app-settings-json': `[ { "name": "key2", @@ -53,56 +54,31 @@ var jsonObject = { ]`, 'connection-strings-json': `[ { - "name": "key1", - "value": "valueabcd", - "type": "MySql", - "slotSetting": false + "name": "key1", + "value": "valueabcd", + "type": "MySql", + "slotSetting": false } ]` }; describe('Test Azure App Service Settings', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(AuthorizerFactory_1.AuthorizerFactory, 'getAuthorizer').mockResolvedValue({}); + jest.spyOn(AzureResourceFilterUtility_1.AzureResourceFilterUtility, 'getAppDetails').mockResolvedValue({ + resourceGroupName: jsonObject['resource-group-name'], + kind: jsonObject['app-kind'] + }); + jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'getApplicationURL').mockResolvedValue('http://testurl'); + jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'updateAndMonitorAppSettings').mockResolvedValue(undefined); + jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'updateConnectionStrings').mockResolvedValue(undefined); + jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'updateConfigurationSettings').mockResolvedValue(undefined); }); afterEach(() => { jest.restoreAllMocks(); }); - it("Get all variables as input", () => __awaiter(void 0, void 0, void 0, function* () { - let getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name, options) => { - switch (name) { - case 'app-name': return jsonObject['app-name']; - case 'connection-strings-json': return jsonObject['connection-strings-json']; - } - return ''; - }); - let appDetails = jest.spyOn(AzureResourceFilterUtility_1.AzureResourceFilterUtility, 'getAppDetails').mockResolvedValue({ - resourceGroupName: jsonObject['resource-group-name'], - kind: jsonObject['app-kind'] - }); - let getApplicationURLSpy = jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'getApplicationURL').mockResolvedValue('http://testurl'); - try { - yield main_1.main(); - } - catch (e) { - console.log(e); - } - expect(getInputSpy).toHaveBeenCalledTimes(6); - expect(appDetails).toHaveBeenCalled(); - expect(getApplicationURLSpy).toHaveBeenCalled(); - })); - it('Checks valid json', () => __awaiter(void 0, void 0, void 0, function* () { - const validateSettings = jest.fn(); - try { - let connectionStrings = validateSettings(JSON.stringify(jsonObject['connection-strings-json'])); - let appSettings = validateSettings(JSON.stringify(jsonObject['app-settings-json'])); - } - catch (e) { - } - expect(validateSettings).toHaveBeenCalledTimes(2); - expect(validateSettings).toHaveReturnedTimes(2); - })); - it("do not set inputs as secrets if mask-inputs is false", () => __awaiter(void 0, void 0, void 0, function* () { - let getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name, options) => { + it('keeps single JSON behavior in main flow', () => __awaiter(void 0, void 0, void 0, function* () { + let getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name) => { switch (name) { case 'app-name': return jsonObject['app-name']; case 'connection-strings-json': return jsonObject['connection-strings-json']; @@ -110,23 +86,85 @@ describe('Test Azure App Service Settings', () => { } return ''; }); - let appDetails = jest.spyOn(AzureResourceFilterUtility_1.AzureResourceFilterUtility, 'getAppDetails').mockResolvedValue({ - resourceGroupName: jsonObject['resource-group-name'], - kind: jsonObject['app-kind'] - }); - let getApplicationURLSpy = jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'getApplicationURL').mockResolvedValue('http://testurl'); - let validateSettingsSpy = jest.spyOn(Utils_1.Utils, 'validateSettings'); - let maskValuesSpy = jest.spyOn(Utils_1.Utils, 'maskValues'); - try { - yield main_1.main(); - } - catch (e) { - console.log(e); - } + let updateConnectionStringsSpy = jest.spyOn(AzureAppServiceUtility_1.AzureAppServiceUtility.prototype, 'updateConnectionStrings'); + yield main_1.main(); expect(getInputSpy).toHaveBeenCalledTimes(6); - expect(appDetails).toHaveBeenCalled(); - expect(getApplicationURLSpy).toHaveBeenCalled(); - expect(validateSettingsSpy).toHaveBeenCalled(); - expect(maskValuesSpy).not.toHaveBeenCalled(); + expect(updateConnectionStringsSpy).toHaveBeenCalledWith([ + { + name: 'key1', + value: 'valueabcd', + type: 'MySql', + slotSetting: false + } + ]); })); + it('merges multiple app settings documents and keeps last value on duplicate name', () => { + const input = `[ + { "name": "A", "value": "1", "slotSetting": false }, + { "name": "B", "value": "2", "slotSetting": false } +] +--- +[ + { "name": "A", "value": "3", "slotSetting": true } +]`; + const merged = Utils_1.Utils.getMergedAppSettings(input, 'false'); + expect(merged).toEqual([ + { name: 'B', value: '2', slotSetting: false }, + { name: 'A', value: '3', slotSetting: true } + ]); + }); + it('merges multiple connection strings documents and keeps last value on duplicate name', () => { + const input = `[ + { "name": "ConnA", "value": "old", "type": "MySql", "slotSetting": false } +] +--- +[ + { "name": "ConnA", "value": "new", "type": "SQLAzure", "slotSetting": true }, + { "name": "ConnB", "value": "next", "type": "PostgreSQL", "slotSetting": false } +]`; + const merged = Utils_1.Utils.getMergedConnectionStrings(input, 'false'); + expect(merged).toEqual([ + { name: 'ConnA', value: 'new', type: 'SQLAzure', slotSetting: true }, + { name: 'ConnB', value: 'next', type: 'PostgreSQL', slotSetting: false } + ]); + }); + it('merges multiple general settings objects and keeps last value on duplicate key', () => { + const input = `{ + "alwaysOn": true, + "http20Enabled": false +} +--- +{ + "alwaysOn": false, + "webSocketsEnabled": true +}`; + const merged = Utils_1.Utils.getMergedConfigurationSettings(input); + expect(merged).toEqual({ + alwaysOn: false, + http20Enabled: false, + webSocketsEnabled: true + }); + }); + it('masks all merged app setting values when mask-inputs is true', () => { + const setSecretSpy = jest.spyOn(core, 'setSecret'); + const input = `[ + { "name": "A", "value": "first", "slotSetting": false } +] +--- +[ + { "name": "B", "value": "second", "slotSetting": false } +]`; + Utils_1.Utils.getMergedAppSettings(input, 'true'); + expect(setSecretSpy).toHaveBeenCalledTimes(2); + expect(setSecretSpy).toHaveBeenNthCalledWith(1, 'first'); + expect(setSecretSpy).toHaveBeenNthCalledWith(2, 'second'); + }); + it('throws a clear error when one of the documents is invalid json', () => { + const input = `[ + { "name": "A", "value": "ok", "slotSetting": false } +] +--- +not-a-json`; + expect(() => Utils_1.Utils.getMergedAppSettings(input, 'false')).toThrow('Invalid JSON in app-settings-json document at index 2'); + }); }); diff --git a/lib/Utils.js b/lib/Utils.js index 8b16011e..0f87277d 100644 --- a/lib/Utils.js +++ b/lib/Utils.js @@ -22,6 +22,83 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.Utils = void 0; const core = __importStar(require("@actions/core")); class Utils { + static parseJsonDocuments(customSettings, settingType) { + try { + return [JSON.parse(customSettings)]; + } + catch (error) { + const chunks = customSettings + .split(/\r?\n\s*---\s*\r?\n/g) + .map(chunk => chunk.trim()) + .filter(chunk => chunk.length > 0); + if (chunks.length <= 1) { + throw new Error('Given Settings object is not a valid JSON'); + } + return chunks.map((chunk, index) => { + try { + return JSON.parse(chunk); + } + catch (parseError) { + throw new Error(`Invalid JSON in ${settingType} document at index ${index + 1}`); + } + }); + } + } + static dedupeByName(entries) { + const seenNames = new Set(); + const dedupedEntries = []; + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]; + const entryName = entry && entry.name; + if (!entryName || seenNames.has(entryName)) { + continue; + } + seenNames.add(entryName); + dedupedEntries.push(entry); + } + return dedupedEntries.reverse(); + } + static getMergedAppSettings(customSettings, maskInputs) { + const documents = Utils.parseJsonDocuments(customSettings, 'app-settings-json'); + const mergedSettings = []; + for (let index = 0; index < documents.length; index++) { + if (!Array.isArray(documents[index])) { + throw new Error(`App settings document at index ${index + 1} must be a JSON array`); + } + mergedSettings.push(...documents[index]); + } + const finalSettings = Utils.dedupeByName(mergedSettings); + if (maskInputs !== undefined && maskInputs !== "false") { + Utils.maskValues(finalSettings); + } + return finalSettings; + } + static getMergedConnectionStrings(customSettings, maskInputs) { + const documents = Utils.parseJsonDocuments(customSettings, 'connection-strings-json'); + const mergedSettings = []; + for (let index = 0; index < documents.length; index++) { + if (!Array.isArray(documents[index])) { + throw new Error(`Connection strings document at index ${index + 1} must be a JSON array`); + } + mergedSettings.push(...documents[index]); + } + const finalSettings = Utils.dedupeByName(mergedSettings); + if (maskInputs !== undefined && maskInputs !== "false") { + Utils.maskValues(finalSettings); + } + return finalSettings; + } + static getMergedConfigurationSettings(customSettings) { + const documents = Utils.parseJsonDocuments(customSettings, 'general-settings-json'); + let mergedSettings = {}; + for (let index = 0; index < documents.length; index++) { + if (Array.isArray(documents[index]) || documents[index] === null || typeof documents[index] !== 'object') { + throw new Error(`General settings document at index ${index + 1} must be a JSON object`); + } + mergedSettings = Object.assign(Object.assign({}, mergedSettings), documents[index]); + } + return mergedSettings; + } static validateSettings(customSettings, maskInputs) { try { var customParsedSettings = JSON.parse(customSettings); diff --git a/lib/main.js b/lib/main.js index a6e670f7..57feda61 100644 --- a/lib/main.js +++ b/lib/main.js @@ -64,15 +64,15 @@ function main() { let appService = new azure_app_service_1.AzureAppService(endpoint, resourceGroupName, webAppName, slotName); let appServiceUtility = new AzureAppServiceUtility_1.AzureAppServiceUtility(appService); if (AppSettings) { - let customApplicationSettings = Utils_1.Utils.validateSettings(AppSettings, maskInputs); + let customApplicationSettings = Utils_1.Utils.getMergedAppSettings(AppSettings, maskInputs); yield appServiceUtility.updateAndMonitorAppSettings(customApplicationSettings, null); } if (ConnectionStrings) { - let customConnectionStrings = Utils_1.Utils.validateSettings(ConnectionStrings, maskInputs); + let customConnectionStrings = Utils_1.Utils.getMergedConnectionStrings(ConnectionStrings, maskInputs); yield appServiceUtility.updateConnectionStrings(customConnectionStrings); } if (ConfigurationSettings) { - let customConfigurationSettings = Utils_1.Utils.validateSettings(ConfigurationSettings); + let customConfigurationSettings = Utils_1.Utils.getMergedConfigurationSettings(ConfigurationSettings); yield appServiceUtility.updateConfigurationSettings(customConfigurationSettings); } applicationURL = yield appServiceUtility.getApplicationURL(); diff --git a/package-lock.json b/package-lock.json index b6873f96..b6911007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1048,6 +1048,7 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz", "integrity": "sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw==", "dev": true, + "license": "MIT", "dependencies": { "jest-diff": "^25.2.1", "pretty-format": "^25.2.1" @@ -2664,6 +2665,21 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -9606,6 +9622,13 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", diff --git a/src/Tests/azureAppServiceSettings.test.ts b/src/Tests/azureAppServiceSettings.test.ts index c836b0e0..d9b29227 100644 --- a/src/Tests/azureAppServiceSettings.test.ts +++ b/src/Tests/azureAppServiceSettings.test.ts @@ -1,9 +1,10 @@ import * as core from "@actions/core"; import { main } from "../main"; -import { Utils } from "../Utils"; +import { Utils } from "../Utils"; -import { AzureResourceFilterUtility } from 'azure-actions-appservice-rest/Utilities/AzureResourceFilterUtility'; -import { AzureAppServiceUtility } from 'azure-actions-appservice-rest/Utilities/AzureAppServiceUtility'; +import { AuthorizerFactory } from "azure-actions-webclient/AuthorizerFactory"; +import { AzureResourceFilterUtility } from "azure-actions-appservice-rest/Utilities/AzureResourceFilterUtility"; +import { AzureAppServiceUtility } from "azure-actions-appservice-rest/Utilities/AzureAppServiceUtility"; jest.mock('@actions/core'); jest.mock('azure-actions-appservice-rest/Arm/azure-app-service'); @@ -12,11 +13,11 @@ jest.mock('azure-actions-appservice-rest/Utilities/AzureResourceFilterUtility'); jest.mock('azure-actions-webclient/Authorizer/IAuthorizer'); jest.mock('azure-actions-appservice-rest/Utilities/AzureAppServiceUtility'); -var jsonObject = { +const jsonObject = { 'app-name': 'MOCK_APP_NAME', - 'resource-group-name' : 'MOCK_RESOURCE_GROUP', + 'resource-group-name': 'MOCK_RESOURCE_GROUP', + 'app-kind': 'MOCK_APP_KIND', 'mask-inputs': 'false', - 'app-kind' : 'MOCK_APP_KIND', 'app-settings-json': `[ { "name": "key2", @@ -24,12 +25,12 @@ var jsonObject = { "slotSetting": true } ]`, - 'connection-strings-json' : `[ + 'connection-strings-json': `[ { - "name": "key1", - "value": "valueabcd", - "type": "MySql", - "slotSetting": false + "name": "key1", + "value": "valueabcd", + "type": "MySql", + "slotSetting": false } ]` }; @@ -38,87 +39,142 @@ describe('Test Azure App Service Settings', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(AuthorizerFactory, 'getAuthorizer').mockResolvedValue({} as any); + jest.spyOn(AzureResourceFilterUtility, 'getAppDetails').mockResolvedValue({ + resourceGroupName: jsonObject['resource-group-name'], + kind: jsonObject['app-kind'] + }); + jest.spyOn(AzureAppServiceUtility.prototype, 'getApplicationURL').mockResolvedValue('http://testurl'); + jest.spyOn(AzureAppServiceUtility.prototype, 'updateAndMonitorAppSettings').mockResolvedValue(undefined as any); + jest.spyOn(AzureAppServiceUtility.prototype, 'updateConnectionStrings').mockResolvedValue(undefined as any); + jest.spyOn(AzureAppServiceUtility.prototype, 'updateConfigurationSettings').mockResolvedValue(undefined as any); }); afterEach(() => { jest.restoreAllMocks(); - }) - - it("Get all variables as input", async () => { - - let getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name, options) => { - switch(name) { + }); + + it('keeps single JSON behavior in main flow', async () => { + let getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name) => { + switch (name) { case 'app-name': return jsonObject['app-name']; - case 'connection-strings-json' : return jsonObject['connection-strings-json']; + case 'connection-strings-json': return jsonObject['connection-strings-json']; + case 'mask-inputs': return jsonObject['mask-inputs']; } return ''; }); - let appDetails = jest.spyOn(AzureResourceFilterUtility, 'getAppDetails').mockResolvedValue({ - resourceGroupName: jsonObject['resource-group-name'], - kind: jsonObject['app-kind'] - }); - - let getApplicationURLSpy = jest.spyOn(AzureAppServiceUtility.prototype, 'getApplicationURL').mockResolvedValue('http://testurl'); + let updateConnectionStringsSpy = jest.spyOn(AzureAppServiceUtility.prototype, 'updateConnectionStrings'); - try { - await main(); - } - catch(e) { - console.log(e); - } + await main(); expect(getInputSpy).toHaveBeenCalledTimes(6); - expect(appDetails).toHaveBeenCalled(); - expect(getApplicationURLSpy).toHaveBeenCalled(); + expect(updateConnectionStringsSpy).toHaveBeenCalledWith([ + { + name: 'key1', + value: 'valueabcd', + type: 'MySql', + slotSetting: false + } + ]); }); - it('Checks valid json', async() => { - const validateSettings = jest.fn(); - - try { - let connectionStrings = validateSettings(JSON.stringify(jsonObject['connection-strings-json'])); - let appSettings = validateSettings(JSON.stringify(jsonObject['app-settings-json'])); - } - catch(e) { - } + it('merges multiple app settings documents and keeps last value on duplicate name', () => { + const input = `[ + { "name": "A", "value": "1", "slotSetting": false }, + { "name": "B", "value": "2", "slotSetting": false } +] +--- +[ + { "name": "A", "value": "3", "slotSetting": true } +]`; + + const merged = Utils.getMergedAppSettings(input, 'false'); + + expect(merged).toEqual([ + { name: 'B', value: '2', slotSetting: false }, + { name: 'A', value: '3', slotSetting: true } + ]); + }); - expect(validateSettings).toHaveBeenCalledTimes(2); - expect(validateSettings).toHaveReturnedTimes(2); + it('merges multiple connection strings documents and keeps last value on duplicate name', () => { + const input = `[ + { "name": "ConnA", "value": "old", "type": "MySql", "slotSetting": false } +] +--- +[ + { "name": "ConnA", "value": "new", "type": "SQLAzure", "slotSetting": true }, + { "name": "ConnB", "value": "next", "type": "PostgreSQL", "slotSetting": false } +]`; + + const merged = Utils.getMergedConnectionStrings(input, 'false'); + + expect(merged).toEqual([ + { name: 'ConnA', value: 'new', type: 'SQLAzure', slotSetting: true }, + { name: 'ConnB', value: 'next', type: 'PostgreSQL', slotSetting: false } + ]); }); - it("do not set inputs as secrets if mask-inputs is false", async () => { - - let getInputSpy = jest.spyOn(core, 'getInput').mockImplementation((name, options) => { - switch(name) { - case 'app-name': return jsonObject['app-name']; - case 'connection-strings-json' : return jsonObject['connection-strings-json']; - case 'mask-inputs': return jsonObject['mask-inputs']; - } - return ''; + it('merges multiple general settings objects and keeps last value on duplicate key', () => { + const input = `{ + "alwaysOn": true, + "http20Enabled": false +} +--- +{ + "alwaysOn": false, + "webSocketsEnabled": true +}`; + + const merged = Utils.getMergedConfigurationSettings(input); + + expect(merged).toEqual({ + alwaysOn: false, + http20Enabled: false, + webSocketsEnabled: true }); + }); - let appDetails = jest.spyOn(AzureResourceFilterUtility, 'getAppDetails').mockResolvedValue({ - resourceGroupName: jsonObject['resource-group-name'], - kind: jsonObject['app-kind'] - }); + it('masks all merged app setting values when mask-inputs is true', () => { + const setSecretSpy = jest.spyOn(core, 'setSecret'); + const input = `[ + { "name": "A", "value": "first", "slotSetting": false } +] +--- +[ + { "name": "B", "value": "second", "slotSetting": false } +]`; + + Utils.getMergedAppSettings(input, 'true'); + + expect(setSecretSpy).toHaveBeenCalledTimes(2); + expect(setSecretSpy).toHaveBeenNthCalledWith(1, 'first'); + expect(setSecretSpy).toHaveBeenNthCalledWith(2, 'second'); + }); - let getApplicationURLSpy = jest.spyOn(AzureAppServiceUtility.prototype, 'getApplicationURL').mockResolvedValue('http://testurl'); - let validateSettingsSpy = jest.spyOn(Utils, 'validateSettings'); - let maskValuesSpy = jest.spyOn(Utils, 'maskValues'); + it('does not mask merged app setting values when mask-inputs is false', () => { + const setSecretSpy = jest.spyOn(core, 'setSecret'); + const input = `[ + { "name": "A", "value": "first", "slotSetting": false } +] +--- +[ + { "name": "B", "value": "second", "slotSetting": false } +]`; - try { - await main(); - } - catch(e) { - console.log(e); - } + Utils.getMergedAppSettings(input, 'false'); - expect(getInputSpy).toHaveBeenCalledTimes(6); - expect(appDetails).toHaveBeenCalled(); - expect(getApplicationURLSpy).toHaveBeenCalled(); - expect(validateSettingsSpy).toHaveBeenCalled(); - expect(maskValuesSpy).not.toHaveBeenCalled(); + expect(setSecretSpy).not.toHaveBeenCalled(); + }); + + it('throws a clear error when one of the documents is invalid json', () => { + const input = `[ + { "name": "A", "value": "ok", "slotSetting": false } +] +--- +not-a-json`; + + expect(() => Utils.getMergedAppSettings(input, 'false')).toThrow('Invalid JSON in app-settings-json document at index 2'); }); -}); \ No newline at end of file +}); diff --git a/src/Utils.ts b/src/Utils.ts index e4d7654b..5c1ef9c7 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -1,6 +1,108 @@ import * as core from '@actions/core'; export class Utils { + private static parseJsonDocuments(customSettings: string, settingType: string): any[] { + try { + return [JSON.parse(customSettings)]; + } + catch (error) { + const chunks = customSettings + .split(/\r?\n\s*---\s*\r?\n/g) + .map(chunk => chunk.trim()) + .filter(chunk => chunk.length > 0); + + if (chunks.length <= 1) { + throw new Error('Given Settings object is not a valid JSON'); + } + + return chunks.map((chunk, index) => { + try { + return JSON.parse(chunk); + } + catch (parseError) { + throw new Error(`Invalid JSON in ${settingType} document at index ${index + 1}`); + } + }); + } + } + + private static dedupeByName(entries: any[]): any[] { + const seenNames = new Set(); + const dedupedEntries: any[] = []; + + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]; + const entryName = entry && entry.name; + + if (!entryName || seenNames.has(entryName)) { + continue; + } + + seenNames.add(entryName); + dedupedEntries.push(entry); + } + + return dedupedEntries.reverse(); + } + + static getMergedAppSettings(customSettings: string, maskInputs?: string): any[] { + const documents = Utils.parseJsonDocuments(customSettings, 'app-settings-json'); + const mergedSettings: any[] = []; + + for (let index = 0; index < documents.length; index++) { + if (!Array.isArray(documents[index])) { + throw new Error(`App settings document at index ${index + 1} must be a JSON array`); + } + + mergedSettings.push(...documents[index]); + } + + const finalSettings = Utils.dedupeByName(mergedSettings); + if (maskInputs !== undefined && maskInputs !== "false") { + Utils.maskValues(finalSettings); + } + + return finalSettings; + } + + static getMergedConnectionStrings(customSettings: string, maskInputs?: string): any[] { + const documents = Utils.parseJsonDocuments(customSettings, 'connection-strings-json'); + const mergedSettings: any[] = []; + + for (let index = 0; index < documents.length; index++) { + if (!Array.isArray(documents[index])) { + throw new Error(`Connection strings document at index ${index + 1} must be a JSON array`); + } + + mergedSettings.push(...documents[index]); + } + + const finalSettings = Utils.dedupeByName(mergedSettings); + if (maskInputs !== undefined && maskInputs !== "false") { + Utils.maskValues(finalSettings); + } + + return finalSettings; + } + + static getMergedConfigurationSettings(customSettings: string): any { + const documents = Utils.parseJsonDocuments(customSettings, 'general-settings-json'); + let mergedSettings = {}; + + for (let index = 0; index < documents.length; index++) { + if (Array.isArray(documents[index]) || documents[index] === null || typeof documents[index] !== 'object') { + throw new Error(`General settings document at index ${index + 1} must be a JSON object`); + } + + mergedSettings = { + ...mergedSettings, + ...documents[index] + }; + } + + return mergedSettings; + } + static validateSettings(customSettings: string, maskInputs?: string) { try { var customParsedSettings = JSON.parse(customSettings); diff --git a/src/main.ts b/src/main.ts index 1573d425..5ee0fac3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,17 +43,17 @@ export async function main() { let appServiceUtility: AzureAppServiceUtility = new AzureAppServiceUtility(appService); if(AppSettings) { - let customApplicationSettings = Utils.validateSettings(AppSettings, maskInputs); + let customApplicationSettings = Utils.getMergedAppSettings(AppSettings, maskInputs); await appServiceUtility.updateAndMonitorAppSettings(customApplicationSettings, null); } if(ConnectionStrings) { - let customConnectionStrings = Utils.validateSettings(ConnectionStrings, maskInputs); + let customConnectionStrings = Utils.getMergedConnectionStrings(ConnectionStrings, maskInputs); await appServiceUtility.updateConnectionStrings(customConnectionStrings); } if(ConfigurationSettings) { - let customConfigurationSettings = Utils.validateSettings(ConfigurationSettings); + let customConfigurationSettings = Utils.getMergedConfigurationSettings(ConfigurationSettings); await appServiceUtility.updateConfigurationSettings(customConfigurationSettings); }