Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
156 changes: 97 additions & 59 deletions lib/Tests/azureAppServiceSettings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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",
Expand All @@ -53,80 +54,117 @@ 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'];
case 'mask-inputs': return jsonObject['mask-inputs'];
}
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');
});
});
77 changes: 77 additions & 0 deletions lib/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading