diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..917eec60 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +on: + pull_request: + branches: + - master + push: + branches: + - master + +jobs: + build_test_job: + name: 'Build and test job' + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + steps: + + - name: 'Checking out repo code' + uses: actions/checkout@v2 + + - name: 'Validate build' + run: | + npm install + npm run build + + - name: 'Run L0 tests' + run: | + npm run test \ No newline at end of file diff --git a/__tests__/PowerShell/ServicePrinicipalLogin.test.ts b/__tests__/PowerShell/ServicePrinicipalLogin.test.ts new file mode 100644 index 00000000..f8fdbb24 --- /dev/null +++ b/__tests__/PowerShell/ServicePrinicipalLogin.test.ts @@ -0,0 +1,38 @@ +import { ServicePrincipalLogin } from '../../src/PowerShell/ServicePrincipalLogin'; + +jest.mock('../../src/PowerShell/Utilities/Utils'); +jest.mock('../../src/PowerShell/Utilities/PowerShellToolRunner'); +let spnlogin: ServicePrincipalLogin; + +beforeAll(() => { + spnlogin = new ServicePrincipalLogin("servicePrincipalID", "servicePrinicipalkey", "tenantId", "subscriptionId"); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Testing initialize', () => { + let initializeSpy; + + beforeEach(() => { + initializeSpy = jest.spyOn(spnlogin, 'initialize'); + }); + test('ServicePrincipalLogin initialize should pass', async () => { + await spnlogin.initialize(); + expect(initializeSpy).toHaveBeenCalled(); + }); +}); + +describe('Testing login', () => { + let loginSpy; + + beforeEach(() => { + loginSpy = jest.spyOn(spnlogin, 'login'); + }); + test('ServicePrincipal login should pass', async () => { + loginSpy.mockImplementationOnce(() => Promise.resolve()); + await spnlogin.login(); + expect(loginSpy).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/PowerShell/Utilities/Utils.test.ts b/__tests__/PowerShell/Utilities/Utils.test.ts new file mode 100644 index 00000000..f6e2825b --- /dev/null +++ b/__tests__/PowerShell/Utilities/Utils.test.ts @@ -0,0 +1,45 @@ +import Utils from '../../../src/PowerShell/Utilities/Utils'; + +const version: string = '9.0.0'; +const moduleName: string = 'az'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Testing isValidVersion', () => { + const validVersion: string = '1.2.4'; + const invalidVersion: string = 'a.bcd'; + + test('isValidVersion should be true', () => { + expect(Utils.isValidVersion(validVersion)).toBeTruthy(); + }); + test('isValidVersion should be false', () => { + expect(Utils.isValidVersion(invalidVersion)).toBeFalsy(); + }); +}); + +describe('Testing setPSModulePath', () => { + test('PSModulePath with azPSVersion non-empty', () => { + Utils.setPSModulePath(version); + expect(process.env.PSModulePath).toContain(version); + }); + test('PSModulePath with azPSVersion empty', () => { + const prevPSModulePath = process.env.PSModulePath; + Utils.setPSModulePath(); + expect(process.env.PSModulePath).not.toEqual(prevPSModulePath); + }); +}); + +describe('Testing getLatestModule', () => { + let getLatestModuleSpy; + + beforeEach(() => { + getLatestModuleSpy = jest.spyOn(Utils, 'getLatestModule'); + }); + test('getLatestModule should pass', async () => { + getLatestModuleSpy.mockImplementationOnce((_moduleName: string) => Promise.resolve(version)); + await Utils.getLatestModule(moduleName); + expect(getLatestModuleSpy).toHaveBeenCalled(); + }); +}); diff --git a/action.yml b/action.yml index 87cc563d..c166d10e 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,14 @@ # Login to Azure subscription name: 'Azure Login' -description: 'Login Azure wraps the az login, allowing Azure actions to log into Azure or to run Az CLI scripts. github.com/Azure/Actions' +description: 'Authenticate to Azure and run your Az CLI or Az PowerShell based Actions or scripts. github.com/Azure/Actions' inputs: creds: description: 'Paste output of `az ad sp create-for-rbac` as value of secret variable: AZURE_CREDENTIALS' required: true + enable-AzPSSession: + description: 'Set this value to true to enable Azure PowerShell Login in addition to Az CLI login' + required: false + default: false branding: icon: 'login.svg' color: 'blue' diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..6104d0f1 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + clearMocks: true, + moduleFileExtensions: ['js', 'ts'], + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + testRunner: 'jest-circus/runner', + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true +}; diff --git a/lib/PowerShell/Constants.js b/lib/PowerShell/Constants.js new file mode 100644 index 00000000..98f75ccf --- /dev/null +++ b/lib/PowerShell/Constants.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +class Constants { +} +exports.default = Constants; +Constants.prefix = "az_"; +Constants.moduleName = "Az.Accounts"; +Constants.versionPattern = /[0-9]\.[0-9]\.[0-9]/; +Constants.AzureCloud = "AzureCloud"; +Constants.Subscription = "Subscription"; +Constants.ServicePrincipal = "ServicePrincipal"; +Constants.Success = "Success"; +Constants.Error = "Error"; +Constants.AzVersion = "AzVersion"; diff --git a/lib/PowerShell/IAzurePowerShellSession.js b/lib/PowerShell/IAzurePowerShellSession.js new file mode 100644 index 00000000..e69de29b diff --git a/lib/PowerShell/ServicePrincipalLogin.js b/lib/PowerShell/ServicePrincipalLogin.js new file mode 100644 index 00000000..7a4be40b --- /dev/null +++ b/lib/PowerShell/ServicePrincipalLogin.js @@ -0,0 +1,73 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const Utils_1 = __importDefault(require("./Utilities/Utils")); +const PowerShellToolRunner_1 = __importDefault(require("./Utilities/PowerShellToolRunner")); +const ScriptBuilder_1 = __importDefault(require("./Utilities/ScriptBuilder")); +const Constants_1 = __importDefault(require("./Constants")); +class ServicePrincipalLogin { + constructor(servicePrincipalId, servicePrincipalKey, tenantId, subscriptionId) { + this.servicePrincipalId = servicePrincipalId; + this.servicePrincipalKey = servicePrincipalKey; + this.tenantId = tenantId; + this.subscriptionId = subscriptionId; + } + initialize() { + return __awaiter(this, void 0, void 0, function* () { + Utils_1.default.setPSModulePath(); + const azLatestVersion = yield Utils_1.default.getLatestModule(Constants_1.default.moduleName); + core.debug(`Az Module version used: ${azLatestVersion}`); + Utils_1.default.setPSModulePath(`${Constants_1.default.prefix}${azLatestVersion}`); + }); + } + login() { + return __awaiter(this, void 0, void 0, function* () { + let output = ""; + const options = { + listeners: { + stdout: (data) => { + output += data.toString(); + } + } + }; + const args = { + servicePrincipalId: this.servicePrincipalId, + servicePrincipalKey: this.servicePrincipalKey, + subscriptionId: this.subscriptionId, + environment: ServicePrincipalLogin.environment, + scopeLevel: ServicePrincipalLogin.scopeLevel + }; + const script = new ScriptBuilder_1.default().getAzPSLoginScript(ServicePrincipalLogin.scheme, this.tenantId, args); + yield PowerShellToolRunner_1.default.init(); + yield PowerShellToolRunner_1.default.executePowerShellScriptBlock(script, options); + const result = JSON.parse(output.trim()); + if (!(Constants_1.default.Success in result)) { + throw new Error(`Azure PowerShell login failed with error: ${result[Constants_1.default.Error]}`); + } + console.log(`Azure PowerShell session successfully initialized`); + }); + } +} +exports.ServicePrincipalLogin = ServicePrincipalLogin; +ServicePrincipalLogin.environment = Constants_1.default.AzureCloud; +ServicePrincipalLogin.scopeLevel = Constants_1.default.Subscription; +ServicePrincipalLogin.scheme = Constants_1.default.ServicePrincipal; diff --git a/lib/PowerShell/Utilities/PowerShellToolRunner.js b/lib/PowerShell/Utilities/PowerShellToolRunner.js new file mode 100644 index 00000000..2bee28fd --- /dev/null +++ b/lib/PowerShell/Utilities/PowerShellToolRunner.js @@ -0,0 +1,35 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const io = __importStar(require("@actions/io")); +const exec = __importStar(require("@actions/exec")); +class PowerShellToolRunner { + static init() { + return __awaiter(this, void 0, void 0, function* () { + if (!PowerShellToolRunner.psPath) { + PowerShellToolRunner.psPath = yield io.which("pwsh", true); + } + }); + } + static executePowerShellScriptBlock(scriptBlock, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + yield exec.exec(`${PowerShellToolRunner.psPath} -Command`, [scriptBlock], options); + }); + } +} +exports.default = PowerShellToolRunner; diff --git a/lib/PowerShell/Utilities/ScriptBuilder.js b/lib/PowerShell/Utilities/ScriptBuilder.js new file mode 100644 index 00000000..2b6cdcc1 --- /dev/null +++ b/lib/PowerShell/Utilities/ScriptBuilder.js @@ -0,0 +1,62 @@ +"use strict"; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const Constants_1 = __importDefault(require("../Constants")); +class ScriptBuilder { + constructor() { + this.script = ""; + } + getAzPSLoginScript(scheme, tenantId, args) { + let command = `Clear-AzContext -Scope Process; + Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue;`; + if (scheme === Constants_1.default.ServicePrincipal) { + command += `Connect-AzAccount -ServicePrincipal -Tenant ${tenantId} -Credential \ + (New-Object System.Management.Automation.PSCredential('${args.servicePrincipalId}',(ConvertTo-SecureString ${args.servicePrincipalKey} -AsPlainText -Force))) \ + -Environment ${args.environment} | out-null;`; + if (args.scopeLevel === Constants_1.default.Subscription) { + command += `Set-AzContext -SubscriptionId ${args.subscriptionId} -TenantId ${tenantId} | out-null;`; + } + } + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + ${command} + $output['${Constants_1.default.Success}'] = "true" + } + catch { + $output['${Constants_1.default.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`Azure PowerShell Login Script: ${this.script}`); + return this.script; + } + getLatestModuleScript(moduleName) { + const command = `Get-Module -Name ${moduleName} -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1`; + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + $data = ${command} + $output['${Constants_1.default.AzVersion}'] = $data.Version.ToString() + $output['${Constants_1.default.Success}'] = "true" + } + catch { + $output['${Constants_1.default.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`GetLatestModuleScript: ${this.script}`); + return this.script; + } +} +exports.default = ScriptBuilder; diff --git a/lib/PowerShell/Utilities/Utils.js b/lib/PowerShell/Utilities/Utils.js new file mode 100644 index 00000000..c3bc1d5b --- /dev/null +++ b/lib/PowerShell/Utilities/Utils.js @@ -0,0 +1,80 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const os = __importStar(require("os")); +const Constants_1 = __importDefault(require("../Constants")); +const ScriptBuilder_1 = __importDefault(require("./ScriptBuilder")); +const PowerShellToolRunner_1 = __importDefault(require("./PowerShellToolRunner")); +class Utils { + /** + * Add the folder path where Az modules are present to PSModulePath based on runner + * @param azPSVersion + * If azPSVersion is empty, folder path in which all Az modules are present are set + * If azPSVersion is not empty, folder path of exact Az module version is set + */ + static setPSModulePath(azPSVersion = "") { + let modulePath = ""; + const runner = process.env.RUNNER_OS || os.type(); + switch (runner.toLowerCase()) { + case "linux": + modulePath = `/usr/share/${azPSVersion}:`; + break; + case "windows": + case "windows_nt": + modulePath = `C:\\Modules\\${azPSVersion};`; + break; + case "macos": + case "darwin": + throw new Error(`OS not supported`); + default: + throw new Error(`Unknown os: ${runner.toLowerCase()}`); + } + process.env.PSModulePath = `${modulePath}${process.env.PSModulePath}`; + } + static getLatestModule(moduleName) { + return __awaiter(this, void 0, void 0, function* () { + let output = ""; + const options = { + listeners: { + stdout: (data) => { + output += data.toString(); + } + } + }; + yield PowerShellToolRunner_1.default.init(); + yield PowerShellToolRunner_1.default.executePowerShellScriptBlock(new ScriptBuilder_1.default() + .getLatestModuleScript(moduleName), options); + const result = JSON.parse(output.trim()); + if (!(Constants_1.default.Success in result)) { + throw new Error(result[Constants_1.default.Error]); + } + const azLatestVersion = result[Constants_1.default.AzVersion]; + if (!Utils.isValidVersion(azLatestVersion)) { + throw new Error(`Invalid AzPSVersion: ${azLatestVersion}`); + } + return azLatestVersion; + }); + } + static isValidVersion(version) { + return !!version.match(Constants_1.default.versionPattern); + } +} +exports.default = Utils; diff --git a/lib/main.js b/lib/main.js index 32c1936b..d9c3b873 100644 --- a/lib/main.js +++ b/lib/main.js @@ -21,16 +21,21 @@ const crypto = __importStar(require("crypto")); const exec = __importStar(require("@actions/exec")); const io = __importStar(require("@actions/io")); const actions_secret_parser_1 = require("actions-secret-parser"); +const ServicePrincipalLogin_1 = require("./PowerShell/ServicePrincipalLogin"); var azPath; var prefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : ""; +var azPSHostEnv = !!process.env.AZUREPS_HOST_ENVIRONMENT ? `${process.env.AZUREPS_HOST_ENVIRONMENT}` : ""; function main() { return __awaiter(this, void 0, void 0, function* () { try { - // Set user agent varable + // Set user agent variable + var isAzCLISuccess = false; let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex'); let actionName = 'AzureLogin'; - let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS_${actionName}_${usrAgentRepo}`; + let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; + let azurePSHostEnv = (!!azPSHostEnv ? `${azPSHostEnv}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azurePSHostEnv); azPath = yield io.which("az", true); yield executeAzCliCommand("--version"); let creds = core.getInput('creds', { required: true }); @@ -39,20 +44,36 @@ function main() { let servicePrincipalKey = secrets.getSecret("$.clientSecret", true); let tenantId = secrets.getSecret("$.tenantId", false); let subscriptionId = secrets.getSecret("$.subscriptionId", false); + const enableAzPSSession = core.getInput('enable-AzPSSession').toLowerCase() === "true"; if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !subscriptionId) { throw new Error("Not all values are present in the creds object. Ensure clientId, clientSecret, tenantId and subscriptionId are supplied."); } + // Attempting Az cli login yield executeAzCliCommand(`login --service-principal -u "${servicePrincipalId}" -p "${servicePrincipalKey}" --tenant "${tenantId}"`, true); yield executeAzCliCommand(`account set --subscription "${subscriptionId}"`, true); + isAzCLISuccess = true; + if (enableAzPSSession) { + // Attempting Az PS login + console.log(`Running Azure PS Login`); + const spnlogin = new ServicePrincipalLogin_1.ServicePrincipalLogin(servicePrincipalId, servicePrincipalKey, tenantId, subscriptionId); + yield spnlogin.initialize(); + yield spnlogin.login(); + } console.log("Login successful."); } catch (error) { - core.error("Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + if (!isAzCLISuccess) { + core.error("Az CLI Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + } + else { + core.error(`Azure PowerShell Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"`); + } core.setFailed(error); } finally { // Reset AZURE_HTTP_USER_AGENT core.exportVariable('AZURE_HTTP_USER_AGENT', prefix); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azPSHostEnv); } }); } @@ -66,4 +87,4 @@ function executeAzCliCommand(command, silent) { } }); } -main(); +main(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c3e3c9b5..a3ac44be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,35 @@ "xpath": "0.0.27" } }, + "asyncc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/asyncc/-/asyncc-2.0.4.tgz", + "integrity": "sha512-5ohHLrRC6ZJ7ypVlJh3XJlINBdErz7VeQnNNLXq2T9hVsph58x49oykgg+KNbpnOiMF0X2vDmR2i2LVtWBL7Mw==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -80,6 +109,38 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, "jsonpath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.0.2.tgz", @@ -99,6 +160,27 @@ "type-check": "~0.3.2" } }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -112,6 +194,23 @@ "word-wrap": "~1.2.3" } }, + "package-lock": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/package-lock/-/package-lock-1.0.3.tgz", + "integrity": "sha512-rEiz3fs0M8jyH1OtWE3TP7MdtWJnsE69qEOipZ4CDF3KoN3XY5mcDucXugTli6Lqig9rhQ/6ST2atxjU2/aIFQ==", + "requires": { + "asyncc": "^2.0.4", + "commander": "^5.0.0", + "glob": "^7.1.6", + "lodash": "^4.17.15", + "traverse": "^0.6.6" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -131,6 +230,11 @@ "escodegen": "^1.8.1" } }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -155,6 +259,11 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, "xmldom": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", diff --git a/package.json b/package.json index b0b60823..839ebba0 100644 --- a/package.json +++ b/package.json @@ -5,18 +5,23 @@ "main": "lib/main.js", "scripts": { "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest" }, "author": "Sumiran Aggarwal", "license": "MIT", "devDependencies": { + "@types/jest": "^25.1.4", "@types/node": "^12.7.11", + "jest": "^25.2.4", + "jest-circus": "^25.2.7", + "ts-jest": "^25.3.0", "typescript": "^3.6.3" }, "dependencies": { "@actions/core": "^1.1.3", "@actions/exec": "^1.0.1", "@actions/io": "^1.0.1", - "actions-secret-parser": "^1.0.2" + "actions-secret-parser": "^1.0.2", + "package-lock": "^1.0.3" } } diff --git a/src/PowerShell/Constants.ts b/src/PowerShell/Constants.ts new file mode 100644 index 00000000..9613db1d --- /dev/null +++ b/src/PowerShell/Constants.ts @@ -0,0 +1,13 @@ +export default class Constants { + static readonly prefix: string = "az_"; + static readonly moduleName: string = "Az.Accounts"; + static readonly versionPattern = /[0-9]\.[0-9]\.[0-9]/; + + static readonly AzureCloud: string = "AzureCloud"; + static readonly Subscription: string = "Subscription"; + static readonly ServicePrincipal: string = "ServicePrincipal"; + + static readonly Success: string = "Success"; + static readonly Error: string = "Error"; + static readonly AzVersion: string = "AzVersion"; +} \ No newline at end of file diff --git a/src/PowerShell/IAzurePowerShellSession.ts b/src/PowerShell/IAzurePowerShellSession.ts new file mode 100644 index 00000000..544369d5 --- /dev/null +++ b/src/PowerShell/IAzurePowerShellSession.ts @@ -0,0 +1,4 @@ +interface IAzurePowerShellSession { + initialize(); + login(); +} \ No newline at end of file diff --git a/src/PowerShell/ServicePrincipalLogin.ts b/src/PowerShell/ServicePrincipalLogin.ts new file mode 100644 index 00000000..8aa293a4 --- /dev/null +++ b/src/PowerShell/ServicePrincipalLogin.ts @@ -0,0 +1,57 @@ +import * as core from '@actions/core'; + +import Utils from './Utilities/Utils'; +import PowerShellToolRunner from './Utilities/PowerShellToolRunner'; +import ScriptBuilder from './Utilities/ScriptBuilder'; +import Constants from './Constants'; + +export class ServicePrincipalLogin implements IAzurePowerShellSession { + static readonly environment: string = Constants.AzureCloud; + static readonly scopeLevel: string = Constants.Subscription; + static readonly scheme: string = Constants.ServicePrincipal; + servicePrincipalId: string; + servicePrincipalKey: string; + tenantId: string; + subscriptionId: string; + + constructor(servicePrincipalId: string, servicePrincipalKey: string, tenantId: string, subscriptionId: string) { + this.servicePrincipalId = servicePrincipalId; + this.servicePrincipalKey = servicePrincipalKey; + this.tenantId = tenantId; + this.subscriptionId = subscriptionId; + } + + async initialize() { + Utils.setPSModulePath(); + const azLatestVersion: string = await Utils.getLatestModule(Constants.moduleName); + core.debug(`Az Module version used: ${azLatestVersion}`); + Utils.setPSModulePath(`${Constants.prefix}${azLatestVersion}`); + } + + async login() { + let output: string = ""; + const options: any = { + listeners: { + stdout: (data: Buffer) => { + output += data.toString(); + } + } + }; + const args: any = { + servicePrincipalId: this.servicePrincipalId, + servicePrincipalKey: this.servicePrincipalKey, + subscriptionId: this.subscriptionId, + environment: ServicePrincipalLogin.environment, + scopeLevel: ServicePrincipalLogin.scopeLevel + } + const script: string = new ScriptBuilder().getAzPSLoginScript(ServicePrincipalLogin.scheme, this.tenantId, args); + await PowerShellToolRunner.init(); + await PowerShellToolRunner.executePowerShellScriptBlock(script, options); + const result: any = JSON.parse(output.trim()); + if (!(Constants.Success in result)) { + throw new Error(`Azure PowerShell login failed with error: ${result[Constants.Error]}`); + } + console.log(`Azure PowerShell session successfully initialized`); + } + +} \ No newline at end of file diff --git a/src/PowerShell/Utilities/PowerShellToolRunner.ts b/src/PowerShell/Utilities/PowerShellToolRunner.ts new file mode 100644 index 00000000..dd08b2a2 --- /dev/null +++ b/src/PowerShell/Utilities/PowerShellToolRunner.ts @@ -0,0 +1,16 @@ +import * as io from '@actions/io'; +import * as exec from '@actions/exec'; + +export default class PowerShellToolRunner { + static psPath: string; + + static async init() { + if(!PowerShellToolRunner.psPath) { + PowerShellToolRunner.psPath = await io.which("pwsh", true); + } + } + + static async executePowerShellScriptBlock(scriptBlock: string, options: any = {}) { + await exec.exec(`${PowerShellToolRunner.psPath} -Command`, [scriptBlock], options) + } +} \ No newline at end of file diff --git a/src/PowerShell/Utilities/ScriptBuilder.ts b/src/PowerShell/Utilities/ScriptBuilder.ts new file mode 100644 index 00000000..a6871a46 --- /dev/null +++ b/src/PowerShell/Utilities/ScriptBuilder.ts @@ -0,0 +1,51 @@ +import * as core from '@actions/core'; + +import Constants from "../Constants"; + +export default class ScriptBuilder { + script: string = ""; + + getAzPSLoginScript(scheme: string, tenantId: string, args: any): string { + let command = `Clear-AzContext -Scope Process; + Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue;`; + if (scheme === Constants.ServicePrincipal) { + command += `Connect-AzAccount -ServicePrincipal -Tenant ${tenantId} -Credential \ + (New-Object System.Management.Automation.PSCredential('${args.servicePrincipalId}',(ConvertTo-SecureString ${args.servicePrincipalKey} -AsPlainText -Force))) \ + -Environment ${args.environment} | out-null;`; + if (args.scopeLevel === Constants.Subscription) { + command += `Set-AzContext -SubscriptionId ${args.subscriptionId} -TenantId ${tenantId} | out-null;`; + } + } + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + ${command} + $output['${Constants.Success}'] = "true" + } + catch { + $output['${Constants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`Azure PowerShell Login Script: ${this.script}`); + return this.script; + } + + getLatestModuleScript(moduleName: string): string { + const command: string = `Get-Module -Name ${moduleName} -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1`; + this.script += `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + $data = ${command} + $output['${Constants.AzVersion}'] = $data.Version.ToString() + $output['${Constants.Success}'] = "true" + } + catch { + $output['${Constants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + core.debug(`GetLatestModuleScript: ${this.script}`); + return this.script; + } +} diff --git a/src/PowerShell/Utilities/Utils.ts b/src/PowerShell/Utilities/Utils.ts new file mode 100644 index 00000000..8814ab1f --- /dev/null +++ b/src/PowerShell/Utilities/Utils.ts @@ -0,0 +1,61 @@ +import * as os from 'os'; + +import Constants from '../Constants'; +import ScriptBuilder from './ScriptBuilder'; +import PowerShellToolRunner from './PowerShellToolRunner'; + +export default class Utils { + /** + * Add the folder path where Az modules are present to PSModulePath based on runner + * @param azPSVersion + * If azPSVersion is empty, folder path in which all Az modules are present are set + * If azPSVersion is not empty, folder path of exact Az module version is set + */ + static setPSModulePath(azPSVersion: string = "") { + let modulePath: string = ""; + const runner: string = process.env.RUNNER_OS || os.type(); + switch (runner.toLowerCase()) { + case "linux": + modulePath = `/usr/share/${azPSVersion}:`; + break; + case "windows": + case "windows_nt": + modulePath = `C:\\Modules\\${azPSVersion};`; + break; + case "macos": + case "darwin": + throw new Error(`OS not supported`); + default: + throw new Error(`Unknown os: ${runner.toLowerCase()}`); + } + process.env.PSModulePath = `${modulePath}${process.env.PSModulePath}`; + } + + static async getLatestModule(moduleName: string): Promise { + let output: string = ""; + const options: any = { + listeners: { + stdout: (data: Buffer) => { + output += data.toString(); + } + } + }; + await PowerShellToolRunner.init(); + await PowerShellToolRunner.executePowerShellScriptBlock(new ScriptBuilder() + .getLatestModuleScript(moduleName), options); + const result = JSON.parse(output.trim()); + if (!(Constants.Success in result)) { + throw new Error(result[Constants.Error]); + } + const azLatestVersion: string = result[Constants.AzVersion]; + if (!Utils.isValidVersion(azLatestVersion)) { + throw new Error(`Invalid AzPSVersion: ${azLatestVersion}`); + } + return azLatestVersion; + } + + static isValidVersion(version: string): boolean { + return !!version.match(Constants.versionPattern); + } +} + diff --git a/src/main.ts b/src/main.ts index 2dfcd52e..cbe0b20e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,17 +4,22 @@ import * as exec from '@actions/exec'; import * as io from '@actions/io'; import { FormatType, SecretParser } from 'actions-secret-parser'; +import { ServicePrincipalLogin } from './PowerShell/ServicePrincipalLogin'; var azPath: string; var prefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : ""; +var azPSHostEnv = !!process.env.AZUREPS_HOST_ENVIRONMENT ? `${process.env.AZUREPS_HOST_ENVIRONMENT}` : ""; async function main() { - try{ - // Set user agent varable + try { + // Set user agent variable + var isAzCLISuccess = false; let usrAgentRepo = crypto.createHash('sha256').update(`${process.env.GITHUB_REPOSITORY}`).digest('hex'); let actionName = 'AzureLogin'; - let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS_${actionName}_${usrAgentRepo}`; + let userAgentString = (!!prefix ? `${prefix}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; + let azurePSHostEnv = (!!azPSHostEnv ? `${azPSHostEnv}+` : '') + `GITHUBACTIONS/${actionName}@v1_${usrAgentRepo}`; core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azurePSHostEnv); azPath = await io.which("az", true); await executeAzCliCommand("--version"); @@ -25,19 +30,33 @@ async function main() { let servicePrincipalKey = secrets.getSecret("$.clientSecret", true); let tenantId = secrets.getSecret("$.tenantId", false); let subscriptionId = secrets.getSecret("$.subscriptionId", false); + const enableAzPSSession = core.getInput('enable-AzPSSession').toLowerCase() === "true"; if (!servicePrincipalId || !servicePrincipalKey || !tenantId || !subscriptionId) { throw new Error("Not all values are present in the creds object. Ensure clientId, clientSecret, tenantId and subscriptionId are supplied."); } - + // Attempting Az cli login await executeAzCliCommand(`login --service-principal -u "${servicePrincipalId}" -p "${servicePrincipalKey}" --tenant "${tenantId}"`, true); await executeAzCliCommand(`account set --subscription "${subscriptionId}"`, true); + isAzCLISuccess = true; + if (enableAzPSSession) { + // Attempting Az PS login + console.log(`Running Azure PS Login`); + const spnlogin: ServicePrincipalLogin = new ServicePrincipalLogin(servicePrincipalId, servicePrincipalKey, tenantId, subscriptionId); + await spnlogin.initialize(); + await spnlogin.login(); + } console.log("Login successful."); } catch (error) { - core.error("Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + if (!isAzCLISuccess) { + core.error("Az CLI Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"); + } else { + core.error(`Azure PowerShell Login failed. Please check the credentials. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows"`); + } core.setFailed(error); } finally { // Reset AZURE_HTTP_USER_AGENT core.exportVariable('AZURE_HTTP_USER_AGENT', prefix); + core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azPSHostEnv); } } @@ -50,5 +69,4 @@ async function executeAzCliCommand(command: string, silent?: boolean) { } } - main(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 7a865ad1..90f8bf61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -56,5 +56,8 @@ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ - } -} + }, + "exclude" : [ + "./__tests__" + ] +} \ No newline at end of file