From cd07ba87a3349afb18fab7c205e08d16f0cf796b Mon Sep 17 00:00:00 2001 From: YanaXu Date: Tue, 26 Sep 2023 16:15:15 +0800 Subject: [PATCH] Support MI for PS --- __tests__/PowerShell/AzPSLogin.test.ts | 68 +++++++++++++ .../PowerShell/AzPSScriptBuilder.test.ts | 99 +++++++++++++++++++ .../PowerShell/ServicePrinicipalLogin.test.ts | 44 --------- .../Utilities/ScriptBuilder.test.ts | 25 ----- __tests__/PowerShell/Utilities/Utils.test.ts | 45 --------- package.json | 1 - src/Cli/AzureCliLogin.ts | 20 ++-- src/PowerShell/AzPSConstants.ts | 11 +++ src/PowerShell/AzPSLogin.ts | 93 +++++++++++++++++ src/PowerShell/AzPSScriptBuilder.ts | 97 ++++++++++++++++++ src/PowerShell/Constants.ts | 13 --- src/PowerShell/IAzurePowerShellSession.ts | 4 - src/PowerShell/ServicePrincipalLogin.ts | 62 ------------ .../Utilities/PowerShellToolRunner.ts | 16 --- src/PowerShell/Utilities/ScriptBuilder.ts | 67 ------------- src/PowerShell/Utilities/Utils.ts | 61 ------------ src/common/LoginConfig.ts | 15 ++- src/main.ts | 15 +-- 18 files changed, 396 insertions(+), 360 deletions(-) create mode 100644 __tests__/PowerShell/AzPSLogin.test.ts create mode 100644 __tests__/PowerShell/AzPSScriptBuilder.test.ts delete mode 100644 __tests__/PowerShell/ServicePrinicipalLogin.test.ts delete mode 100644 __tests__/PowerShell/Utilities/ScriptBuilder.test.ts delete mode 100644 __tests__/PowerShell/Utilities/Utils.test.ts create mode 100644 src/PowerShell/AzPSConstants.ts create mode 100644 src/PowerShell/AzPSLogin.ts create mode 100644 src/PowerShell/AzPSScriptBuilder.ts delete mode 100644 src/PowerShell/Constants.ts delete mode 100644 src/PowerShell/IAzurePowerShellSession.ts delete mode 100644 src/PowerShell/ServicePrincipalLogin.ts delete mode 100644 src/PowerShell/Utilities/PowerShellToolRunner.ts delete mode 100644 src/PowerShell/Utilities/ScriptBuilder.ts delete mode 100644 src/PowerShell/Utilities/Utils.ts diff --git a/__tests__/PowerShell/AzPSLogin.test.ts b/__tests__/PowerShell/AzPSLogin.test.ts new file mode 100644 index 000000000..dbf634350 --- /dev/null +++ b/__tests__/PowerShell/AzPSLogin.test.ts @@ -0,0 +1,68 @@ +import * as os from 'os'; + +import { AzPSLogin } from '../../src/PowerShell/AzPSLogin'; +import { LoginConfig } from '../../src/common/LoginConfig'; +import AzPSConstants from '../../src/PowerShell/AzPSConstants'; + +let azpsLogin: AzPSLogin; + +beforeAll(() => { + var loginConfig = new LoginConfig(); + loginConfig.servicePrincipalId = "servicePrincipalID"; + loginConfig.servicePrincipalKey = "servicePrinicipalkey"; + loginConfig.tenantId = "tenantId"; + loginConfig.subscriptionId = "subscriptionId"; + azpsLogin = new AzPSLogin(loginConfig); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Testing login', () => { + let loginSpy; + + beforeEach(() => { + loginSpy = jest.spyOn(azpsLogin, 'login'); + }); + + test('ServicePrincipal login should pass', async () => { + loginSpy.mockImplementationOnce(() => Promise.resolve()); + await azpsLogin.login(); + expect(loginSpy).toHaveBeenCalled(); + }); +}); + +describe('Testing set module path', () => { + test('setDefaultPSModulePath should work', () => { + azpsLogin.setPSModulePathForGitHubRunner(); + const runner: string = process.env.RUNNER_OS || os.type(); + if(runner.toLowerCase() === "linux"){ + expect(process.env.PSModulePath).toContain(AzPSConstants.DEFAULT_AZ_PATH_ON_LINUX); + } + if(runner.toLowerCase().startsWith("windows")){ + expect(process.env.PSModulePath).toContain(AzPSConstants.DEFAULT_AZ_PATH_ON_WINDOWS); + } + }); + +}); + +describe('Testing runPSScript', () => { + test('Get PowerShell Version', async () => { + let script = `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + $output['${AzPSConstants.Success}'] = "true" + $output['${AzPSConstants.Result}'] = $PSVersionTable.PSVersion.ToString() + } + catch { + $output['${AzPSConstants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + + let psVersion: string = await AzPSLogin.runPSScript(script); + expect(psVersion === null).toBeFalsy(); + }); + +}); \ No newline at end of file diff --git a/__tests__/PowerShell/AzPSScriptBuilder.test.ts b/__tests__/PowerShell/AzPSScriptBuilder.test.ts new file mode 100644 index 000000000..f9a9415b8 --- /dev/null +++ b/__tests__/PowerShell/AzPSScriptBuilder.test.ts @@ -0,0 +1,99 @@ +import AzPSSCriptBuilder from "../../src/PowerShell/AzPSScriptBuilder"; +import { LoginConfig } from "../../src/common/LoginConfig"; + +describe("Getting AzLogin PS script", () => { + + function setEnv(name: string, value: string) { + process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] = value; + } + + function cleanEnv() { + for (const envKey in process.env) { + if (envKey.startsWith('INPUT_')) { + delete process.env[envKey] + } + } + } + + beforeEach(() => { + cleanEnv(); + }); + + test('PS script to get latest module path should work', () => { + expect(AzPSSCriptBuilder.getLatestModulePathScript("TestModule")).toContain("(Get-Module -Name 'TestModule' -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).ModuleBase"); + }); + + test('PS script should not set context while passing allowNoSubscriptionsLogin as true', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + let creds = { + 'clientId': 'client-id', + 'clientSecret': 'client-secret', + 'tenantId': 'tenanat-id', + 'subscriptionId': 'subscription-id' + } + setEnv('creds', JSON.stringify(creds)); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => { + expect(loginScript.includes("Connect-AzAccount -ServicePrincipal -Environment 'azurecloud' -Tenant 'tenanat-id' -Credential")).toBeTruthy(); + expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeFalsy; + }); + }); + + test('PS script should set context while passing allowNoSubscriptionsLogin as false', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'false'); + setEnv('auth-type', 'SERVICE_PRINCIPAL'); + let creds = { + 'clientId': 'client-id', + 'clientSecret': 'client-secret', + 'tenantId': 'tenanat-id', + 'subscriptionId': 'subscription-id' + } + setEnv('creds', JSON.stringify(creds)); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => { + expect(loginScript.includes("Connect-AzAccount -ServicePrincipal -Environment 'azurecloud' -Tenant 'tenanat-id' -Credential")).toBeTruthy(); + expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeTruthy(); + }); + }); + + test('PS script should use system managed identity to login when auth-type is IDENTITY', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'false'); + setEnv('subscription-id', 'subscription-id'); + setEnv('auth-type', 'IDENTITY'); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => { + expect(loginScript.includes("Connect-AzAccount -Identity -Environment 'azurecloud'")).toBeTruthy(); + expect(loginScript.includes("Connect-AzAccount -Identity -Environment 'azurecloud' -AccountId 'client-id'")).toBeFalsy(); + expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeTruthy(); + }); + }); + + test('PS script should use user managed identity to login when auth-type is IDENTITY', () => { + setEnv('environment', 'azurecloud'); + setEnv('enable-AzPSSession', 'true'); + setEnv('allow-no-subscriptions', 'true'); + setEnv('auth-type', 'IDENTITY'); + setEnv('client-id', 'client-id'); + + let loginConfig = new LoginConfig(); + loginConfig.initialize(); + return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => { + expect(loginScript.includes("Connect-AzAccount -Identity -Environment 'azurecloud' -AccountId 'client-id'")).toBeTruthy(); + expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeFalsy(); + }); + }); + +}); \ No newline at end of file diff --git a/__tests__/PowerShell/ServicePrinicipalLogin.test.ts b/__tests__/PowerShell/ServicePrinicipalLogin.test.ts deleted file mode 100644 index 95a93950b..000000000 --- a/__tests__/PowerShell/ServicePrinicipalLogin.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ServicePrincipalLogin } from '../../src/PowerShell/ServicePrincipalLogin'; -import { LoginConfig } from '../../src/common/LoginConfig'; - -jest.mock('../../src/PowerShell/Utilities/Utils'); -jest.mock('../../src/PowerShell/Utilities/PowerShellToolRunner'); -let spnlogin: ServicePrincipalLogin; - -beforeAll(() => { - var loginConfig = new LoginConfig(); - loginConfig.servicePrincipalId = "servicePrincipalID"; - loginConfig.servicePrincipalKey = "servicePrinicipalkey"; - loginConfig.tenantId = "tenantId"; - loginConfig.subscriptionId = "subscriptionId"; - spnlogin = new ServicePrincipalLogin(loginConfig); -}); - -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/ScriptBuilder.test.ts b/__tests__/PowerShell/Utilities/ScriptBuilder.test.ts deleted file mode 100644 index 291cbd049..000000000 --- a/__tests__/PowerShell/Utilities/ScriptBuilder.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import ScriptBuilder from "../../../src/PowerShell/Utilities/ScriptBuilder"; -import Constants from "../../../src/PowerShell/Constants"; - -describe("Getting AzLogin PS script" , () => { - const scheme = Constants.ServicePrincipal; - let args: any = { - servicePrincipalId: "service-principal-id", - servicePrincipalKey: "service-principal-key", - environment: "environment", - scopeLevel: Constants.Subscription, - subscriptionId: "subId", - allowNoSubscriptionsLogin: true - } - - test("PS script should not set context while passing allowNoSubscriptionsLogin as true", () => { - const loginScript = new ScriptBuilder().getAzPSLoginScript(scheme, "tenant-id", args); - expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeFalsy(); - }); - - test("PS script should set context while passing allowNoSubscriptionsLogin as false", () => { - args["allowNoSubscriptionsLogin"] = false; - const loginScript = new ScriptBuilder().getAzPSLoginScript(scheme, "tenant-id", args); - expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeTruthy(); - }); -}); \ No newline at end of file diff --git a/__tests__/PowerShell/Utilities/Utils.test.ts b/__tests__/PowerShell/Utilities/Utils.test.ts deleted file mode 100644 index f6e2825b4..000000000 --- a/__tests__/PowerShell/Utilities/Utils.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/package.json b/package.json index 72d5e7de3..52cdfb6fe 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "build": "tsc", "test": "jest" }, - "author": "Sumiran Aggarwal", "license": "MIT", "devDependencies": { "@types/jest": "^29.2.4", diff --git a/src/Cli/AzureCliLogin.ts b/src/Cli/AzureCliLogin.ts index f702d4d1e..b4490ea67 100644 --- a/src/Cli/AzureCliLogin.ts +++ b/src/Cli/AzureCliLogin.ts @@ -15,7 +15,7 @@ export class AzureCliLogin { } async login() { - console.log(`Running Azure CLI Login`); + core.info(`Running Azure CLI Login.`); this.azPath = await io.which("az", true); if (!this.azPath) { throw new Error("Azure CLI is not found in the runner."); @@ -37,7 +37,7 @@ export class AzureCliLogin { this.setAzurestackEnvIfNecessary(); await this.executeAzCliCommand(["cloud", "set", "-n", this.loginConfig.environment], false); - console.log(`Done setting cloud: "${this.loginConfig.environment}"`); + core.info(`Done setting cloud: "${this.loginConfig.environment}"`); if (this.loginConfig.authType == "service_principal") { let args = ["--service-principal", @@ -70,16 +70,16 @@ export class AzureCliLogin { throw new Error("resourceManagerEndpointUrl is a required parameter when environment is defined."); } - console.log(`Unregistering cloud: "${this.loginConfig.environment}" first if it exists`); + core.info(`Unregistering cloud: "${this.loginConfig.environment}" first if it exists`); try { await this.executeAzCliCommand(["cloud", "set", "-n", "AzureCloud"], true); await this.executeAzCliCommand(["cloud", "unregister", "-n", this.loginConfig.environment], false); } catch (error) { - console.log(`Ignore cloud not registered error: "${error}"`); + core.info(`Ignore cloud not registered error: "${error}"`); } - console.log(`Registering cloud: "${this.loginConfig.environment}" with ARM endpoint: "${this.loginConfig.resourceManagerEndpointUrl}"`); + core.info(`Registering cloud: "${this.loginConfig.environment}" with ARM endpoint: "${this.loginConfig.resourceManagerEndpointUrl}"`); try { let baseUri = this.loginConfig.resourceManagerEndpointUrl; if (baseUri.endsWith('/')) { @@ -95,11 +95,11 @@ export class AzureCliLogin { throw error; } - console.log(`Done registering cloud: "${this.loginConfig.environment}"`) + core.info(`Done registering cloud: "${this.loginConfig.environment}"`) } async loginWithSecret(args: string[]) { - console.log("Note: Azure/login action also supports OIDC login mechanism. Refer https://github.com/azure/login#configure-a-service-principal-with-a-federated-credential-to-use-oidc-based-authentication for more details.") + core.info("Note: Azure/login action also supports OIDC login mechanism. Refer https://github.com/azure/login#configure-a-service-principal-with-a-federated-credential-to-use-oidc-based-authentication for more details.") args.push(`--password=${this.loginConfig.servicePrincipalKey}`); await this.callCliLogin(args, 'service principal with secret'); } @@ -120,14 +120,14 @@ export class AzureCliLogin { } async callCliLogin(args: string[], methodName: string) { - console.log(`Attempting Azure CLI login by using ${methodName}...`); + core.info(`Attempting Azure CLI login by using ${methodName}...`); args.unshift("login"); if (this.loginConfig.allowNoSubscriptionsLogin) { args.push("--allow-no-subscriptions"); } await this.executeAzCliCommand(args, true, this.loginOptions); await this.setSubscription(); - console.log(`Azure CLI login succeed by using ${methodName}.`); + core.info(`Azure CLI login succeeds by using ${methodName}.`); } async setSubscription() { @@ -140,7 +140,7 @@ export class AzureCliLogin { } let args = ["account", "set", "--subscription", this.loginConfig.subscriptionId]; await this.executeAzCliCommand(args, true, this.loginOptions); - console.log("Subscription is set successfully."); + core.info("Subscription is set successfully."); } async executeAzCliCommand( diff --git a/src/PowerShell/AzPSConstants.ts b/src/PowerShell/AzPSConstants.ts new file mode 100644 index 000000000..261cec197 --- /dev/null +++ b/src/PowerShell/AzPSConstants.ts @@ -0,0 +1,11 @@ +export default class AzPSConstants { + static readonly DEFAULT_AZ_PATH_ON_LINUX: string = '/usr/share'; + static readonly DEFAULT_AZ_PATH_ON_WINDOWS: string = 'C:\\Modules'; + static readonly AzAccounts: string = "Az.Accounts"; + + static readonly PowerShell_CmdName = "pwsh"; + + static readonly Success: string = "Success"; + static readonly Error: string = "Error"; + static readonly Result: string = "Result"; +} diff --git a/src/PowerShell/AzPSLogin.ts b/src/PowerShell/AzPSLogin.ts new file mode 100644 index 000000000..aa7c5892d --- /dev/null +++ b/src/PowerShell/AzPSLogin.ts @@ -0,0 +1,93 @@ +import * as core from '@actions/core'; +import * as exec from '@actions/exec'; +import * as io from '@actions/io'; +import * as os from 'os'; +import * as path from 'path'; + +import AzPSScriptBuilder from './AzPSScriptBuilder'; +import AzPSConstants from './AzPSConstants'; +import { LoginConfig } from '../common/LoginConfig'; + +export class AzPSLogin { + loginConfig: LoginConfig; + + constructor(loginConfig: LoginConfig) { + this.loginConfig = loginConfig; + } + + async login() { + core.info(`Running Azure PowerShell Login.`); + this.setPSModulePathForGitHubRunner(); + await this.setPSModulePathForLatestAzAccounts(); + + const [loginMethod, loginScript] = await AzPSScriptBuilder.getAzPSLoginScript(this.loginConfig); + core.info(`Attempting Azure PowerShell login by using ${loginMethod}...`); + core.debug(`Azure PowerShell Login Script: ${loginScript}`); + await AzPSLogin.runPSScript(loginScript); + console.log(`Running Azure PowerShell Login successfully.`); + } + + setPSModulePathForGitHubRunner() { + const runner: string = process.env.RUNNER_OS || os.type(); + switch (runner.toLowerCase()) { + case "linux": + this.pushPSModulePath(AzPSConstants.DEFAULT_AZ_PATH_ON_LINUX); + break; + case "windows": + case "windows_nt": + this.pushPSModulePath(AzPSConstants.DEFAULT_AZ_PATH_ON_WINDOWS); + break; + case "macos": + case "darwin": + core.warning(`Skip setting the default PowerShell module path for OS ${runner.toLowerCase()}.`); + break; + default: + core.warning(`Skip setting the default PowerShell module path for unknown OS ${runner.toLowerCase()}.`); + break; + } + } + + async setPSModulePathForLatestAzAccounts() { + let getLatestAccountsScript: string = AzPSScriptBuilder.getLatestModulePathScript(AzPSConstants.AzAccounts); + core.debug(`The script to get the latest Az.Accounts path: ${getLatestAccountsScript}`); + let azAccountsLatestPath: string = await AzPSLogin.runPSScript(getLatestAccountsScript); + core.debug(`The latest Az.Accounts path used: ${azAccountsLatestPath}`); + this.pushPSModulePath(azAccountsLatestPath); + } + + pushPSModulePath(psModulePath: string) { + process.env.PSModulePath = `${psModulePath}${path.delimiter}${process.env.PSModulePath}`; + core.debug(`Set PSModulePath as ${process.env.PSModulePath}`); + } + + static async runPSScript(psScript: string): Promise { + let outputString: string = ""; + let commandStdErr = false; + const options: any = { + silent: true, + listeners: { + stdout: (data: Buffer) => { + outputString += data.toString(); + }, + stderr: (data: Buffer) => { + let error = data.toString(); + if (error && error.trim().length !== 0) { + commandStdErr = true; + core.error(error); + } + } + } + }; + + let psPath:string = await io.which(AzPSConstants.PowerShell_CmdName, true); + await exec.exec(`"${psPath}" -Command`, [psScript], options) + if (commandStdErr) { + throw new Error('Azure PowerShell login failed with errors.'); + } + const result: any = JSON.parse(outputString.trim()); + if (!(AzPSConstants.Success in result)) { + throw new Error(`Azure PowerShell login failed with error: ${result[AzPSConstants.Error]}`); + } + return result[AzPSConstants.Result]; + } +} \ No newline at end of file diff --git a/src/PowerShell/AzPSScriptBuilder.ts b/src/PowerShell/AzPSScriptBuilder.ts new file mode 100644 index 000000000..44ca5c647 --- /dev/null +++ b/src/PowerShell/AzPSScriptBuilder.ts @@ -0,0 +1,97 @@ +import AzPSConstants from "./AzPSConstants"; +import { LoginConfig } from '../common/LoginConfig'; + +export default class AzPSScriptBuilder { + + static getLatestModulePathScript(moduleName: string): string { + let script = `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + $latestModulePath = (Get-Module -Name '${moduleName}' -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).ModuleBase + $latestModulePath = Join-Path $latestModulePath ".." | Resolve-Path + $output['${AzPSConstants.Result}'] = $latestModulePath.ToString() + $output['${AzPSConstants.Success}'] = "true" + } + catch { + $output['${AzPSConstants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + + return script; + } + + static async getAzPSLoginScript(loginConfig: LoginConfig) { + let loginMethodName = ""; + let commands = 'Clear-AzContext -Scope Process; '; + commands += 'Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; '; + + if (loginConfig.environment.toLowerCase() == "azurestack") { + commands += `Add-AzEnvironment -Name '${loginConfig.environment}' -ARMEndpoint '${loginConfig.resourceManagerEndpointUrl}' | out-null;`; + } + if (loginConfig.authType === "service_principal") { + if (loginConfig.servicePrincipalKey) { + let servicePrincipalKey: string = loginConfig.servicePrincipalKey.split("'").join("''"); + commands += AzPSScriptBuilder.loginWithSecret(loginConfig.environment, loginConfig.tenantId, loginConfig.servicePrincipalId, servicePrincipalKey); + loginMethodName = 'service principal with secret'; + } else { + await loginConfig.getFederatedToken(); + commands += AzPSScriptBuilder.loginWithOIDC(loginConfig.environment, loginConfig.tenantId, loginConfig.servicePrincipalId, loginConfig.federatedToken); + loginMethodName = "OIDC"; + } + } else { + if (loginConfig.servicePrincipalId) { + commands += AzPSScriptBuilder.loginWithUserAssignedIdentity(loginConfig.environment, loginConfig.servicePrincipalId); + loginMethodName = 'user-assigned managed identity'; + } else { + commands += AzPSScriptBuilder.loginWithSystemAssignedIdentity(loginConfig.environment); + loginMethodName = 'system-assigned managed identity'; + } + } + + if (!loginConfig.allowNoSubscriptionsLogin && loginConfig.subscriptionId) { + if (loginConfig.tenantId) { + commands += `Set-AzContext -SubscriptionId '${loginConfig.subscriptionId}' -TenantId '${loginConfig.tenantId}' | out-null;`; + } else { + commands += `Set-AzContext -SubscriptionId '${loginConfig.subscriptionId}' | out-null;`; + } + } + + let script = `try { + $ErrorActionPreference = "Stop" + $WarningPreference = "SilentlyContinue" + $output = @{} + ${commands} + $output['${AzPSConstants.Success}'] = "true" + $output['${AzPSConstants.Result}'] = "" + } + catch { + $output['${AzPSConstants.Error}'] = $_.exception.Message + } + return ConvertTo-Json $output`; + + return [loginMethodName, script]; + } + + static loginWithSecret(environment: string, tenantId: string, servicePrincipalId: string, servicePrincipalKey: string): string { + let loginCmdlet = `$psLoginSecrets = ConvertTo-SecureString '${servicePrincipalKey}' -AsPlainText -Force; `; + loginCmdlet += `$psLoginCredential = New-Object System.Management.Automation.PSCredential('${servicePrincipalId}', $psLoginSecrets); `; + loginCmdlet += `Connect-AzAccount -ServicePrincipal -Environment '${environment}' -Tenant '${tenantId}' -Credential $psLoginCredential | out-null; `; //TODO: why not set environment + return loginCmdlet; + } + + static loginWithOIDC(environment: string, tenantId: string, servicePrincipalId: string, federatedToken: string) { + let loginCmdlet = `Connect-AzAccount -ServicePrincipal -ApplicationId '${servicePrincipalId}' -Tenant '${tenantId}' -FederatedToken '${federatedToken}' -Environment '${environment}' | out-null;`; + return loginCmdlet; + } + + static loginWithSystemAssignedIdentity(environment: string): string { + let loginCmdlet = `Connect-AzAccount -Identity -Environment '${environment}' | out-null;`; + return loginCmdlet; + } + + static loginWithUserAssignedIdentity(environment: string, servicePrincipalId: string): string { + let loginCmdlet = `Connect-AzAccount -Identity -Environment '${environment}' -AccountId '${servicePrincipalId}' | out-null;`; + return loginCmdlet; + } +} \ No newline at end of file diff --git a/src/PowerShell/Constants.ts b/src/PowerShell/Constants.ts deleted file mode 100644 index 543226df4..000000000 --- a/src/PowerShell/Constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -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"; -} diff --git a/src/PowerShell/IAzurePowerShellSession.ts b/src/PowerShell/IAzurePowerShellSession.ts deleted file mode 100644 index 544369d57..000000000 --- a/src/PowerShell/IAzurePowerShellSession.ts +++ /dev/null @@ -1,4 +0,0 @@ -interface IAzurePowerShellSession { - initialize(); - login(); -} \ No newline at end of file diff --git a/src/PowerShell/ServicePrincipalLogin.ts b/src/PowerShell/ServicePrincipalLogin.ts deleted file mode 100644 index e9b12ac72..000000000 --- a/src/PowerShell/ServicePrincipalLogin.ts +++ /dev/null @@ -1,62 +0,0 @@ -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'; -import { LoginConfig } from '../common/LoginConfig'; - -export class ServicePrincipalLogin implements IAzurePowerShellSession { - static readonly scopeLevel: string = Constants.Subscription; - static readonly scheme: string = Constants.ServicePrincipal; - loginConfig: LoginConfig; - - constructor(loginConfig: LoginConfig) { - this.loginConfig = loginConfig; - } - - 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 = ""; - let commandStdErr = false; - const options: any = { - listeners: { - stdout: (data: Buffer) => { - output += data.toString(); - }, - stderr: (data: Buffer) => { - let error = data.toString(); - if (error && error.trim().length !== 0) { - commandStdErr = true; - core.error(error); - } - } - } - }; - const args: any = { - servicePrincipalId: this.loginConfig.servicePrincipalId, - servicePrincipalKey: this.loginConfig.servicePrincipalKey, - federatedToken: this.loginConfig.federatedToken, - subscriptionId: this.loginConfig.subscriptionId, - environment: this.loginConfig.environment, - scopeLevel: ServicePrincipalLogin.scopeLevel, - allowNoSubscriptionsLogin: this.loginConfig.allowNoSubscriptionsLogin, - resourceManagerEndpointUrl: this.loginConfig.resourceManagerEndpointUrl - } - const script: string = new ScriptBuilder().getAzPSLoginScript(ServicePrincipalLogin.scheme, this.loginConfig.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 deleted file mode 100644 index 3bc01de42..000000000 --- a/src/PowerShell/Utilities/PowerShellToolRunner.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 = {}) { - //Options for error handling - 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 deleted file mode 100644 index 9b1a0e7df..000000000 --- a/src/PowerShell/Utilities/ScriptBuilder.ts +++ /dev/null @@ -1,67 +0,0 @@ -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) { - - if (args.environment.toLowerCase() == "azurestack") { - command += `Add-AzEnvironment -Name ${args.environment} -ARMEndpoint ${args.resourceManagerEndpointUrl} | out-null;`; - } - // Separate command script for OIDC and non-OIDC - if (!!args.federatedToken) { - command += `Connect-AzAccount -ServicePrincipal -ApplicationId '${args.servicePrincipalId}' -Tenant '${tenantId}' -FederatedToken '${args.federatedToken}' \ - -Environment '${args.environment}' | out-null;`; - } - else { - command += `Connect-AzAccount -ServicePrincipal -Tenant '${tenantId}' -Credential \ - (New-Object System.Management.Automation.PSCredential('${args.servicePrincipalId}',(ConvertTo-SecureString '${args.servicePrincipalKey.replace("'", "''")}' -AsPlainText -Force))) \ - -Environment '${args.environment}' | out-null;`; - } - // command to set the subscription - if (args.scopeLevel === Constants.Subscription && !args.allowNoSubscriptionsLogin) { - 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; - } - -} \ No newline at end of file diff --git a/src/PowerShell/Utilities/Utils.ts b/src/PowerShell/Utilities/Utils.ts deleted file mode 100644 index 8814ab1ff..000000000 --- a/src/PowerShell/Utilities/Utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -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/common/LoginConfig.ts b/src/common/LoginConfig.ts index 1f52dcf56..93259247d 100644 --- a/src/common/LoginConfig.ts +++ b/src/common/LoginConfig.ts @@ -49,18 +49,22 @@ export class LoginConfig { this.audience = core.getInput('audience', { required: false }); this.federatedToken = null; + + this.mask(this.servicePrincipalId); + this.mask(this.servicePrincipalKey); } async getFederatedToken() { try { this.federatedToken = await core.getIDToken(this.audience); + this.mask(this.federatedToken); } catch (error) { core.error(`Please make sure to give write permissions to id-token in the workflow.`); throw error; } let [issuer, subjectClaim] = await jwtParser(this.federatedToken); - console.log("Federated token details:\n issuer - " + issuer + "\n subject claim - " + subjectClaim); + core.info("Federated token details:\n issuer - " + issuer + "\n subject claim - " + subjectClaim); } async validate() { @@ -75,6 +79,15 @@ export class LoginConfig { throw new Error("Using auth-type: SERVICE_PRINCIPAL. Not all values are present in the credentials. Ensure clientId and tenantId are supplied."); } } + if(!this.allowNoSubscriptionsLogin && !this.subscriptionId){ + throw new Error("allow-no-subscriptions is set to false. subscription-id must be provided."); + } + } + + mask(parameterValue: string){ + if(parameterValue){ + core.setSecret(parameterValue); + } } } diff --git a/src/main.ts b/src/main.ts index 0d07be174..75d2d28f1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import * as core from '@actions/core'; -import { ServicePrincipalLogin } from './PowerShell/ServicePrincipalLogin'; +import { AzPSLogin } from './PowerShell/AzPSLogin'; import { LoginConfig } from './common/LoginConfig'; import { AzureCliLogin } from './Cli/AzureCliLogin'; @@ -26,19 +26,12 @@ async function main() { //login to Azure PowerShell if (loginConfig.enableAzPSSession) { - console.log(`Running Azure PS Login`); - //remove the following 'if session' once the code is ready - if (!loginConfig.servicePrincipalKey) { - await loginConfig.getFederatedToken(); - } - var spnlogin: ServicePrincipalLogin = new ServicePrincipalLogin(loginConfig); - await spnlogin.initialize(); - await spnlogin.login(); + var psLogin: AzPSLogin = new AzPSLogin(loginConfig); + await psLogin.login(); } - console.log("Login successful."); } catch (error) { - core.setFailed(`Login failed with ${error}. Please check the credentials and auth-type, and make sure 'az' is installed on the runner. For more information refer https://github.com/Azure/login#readme.`); + core.setFailed(`Login failed with ${error}. Make sure 'az' is installed on the runner. If 'enable-AzPSSession' is true, make sure 'pwsh' is installed on the runner together with Azure PowerShell module. Double check if the 'auth-type' is correct. Refer to https://github.com/Azure/login#readme for more information.`); core.debug(error.stack); } finally {