diff --git a/__tests__/AzModuleInstaller.test.ts b/__tests__/AzModuleInstaller.test.ts new file mode 100644 index 00000000..01149fa7 --- /dev/null +++ b/__tests__/AzModuleInstaller.test.ts @@ -0,0 +1,123 @@ +import { AzModuleInstaller } from "../src/AzModuleInstaller"; +import FileUtils from "../src/Utilities/FileUtils"; +import Utils from "../src/Utilities/Utils"; + +jest.mock("@actions/core"); +jest.mock("@actions/tool-cache"); +jest.mock("../src/Utilities/ArchiveTools"); + +const mockPathExists = jest.fn(); +FileUtils.pathExists = mockPathExists; +FileUtils.deleteFile = jest.fn(); + +const mockIsHostedAgent = jest.fn(); +Utils.isHostedAgent = mockIsHostedAgent; +Utils.saveAzModule = jest.fn(); + +describe("Testing AzModuleInstaller", () => { + let savedRunnerOS: string; + beforeAll(() => { + savedRunnerOS = process.env.RUNNER_OS; + process.env.RUNNER_OS = "Windows"; + }); + afterAll(() => { + process.env.RUNNER_OS = savedRunnerOS; + }); + + beforeEach(() => { + jest.resetAllMocks(); + mockIsHostedAgent.mockReturnValue(true); + mockPathExists.mockReturnValue(false); + }); + + test("install in case of private agent", async () => { + mockIsHostedAgent.mockReturnValue(false); + const installer = new AzModuleInstaller("latest"); + const mockTry = jest.fn(); + installer["tryInstallingLatest"] = mockTry; + installer["tryInstallFromFolder"] = mockTry; + installer["tryInstallFromZip"] = mockTry; + installer["tryInstallFromGHRelease"] = mockTry; + installer["tryInstallFromPSGallery"] = mockTry; + const result = await installer.install(); + expect(result).toEqual({ isInstalled: false, moduleSource: "privateAgent" }); + expect(mockTry).not.toBeCalled(); + }); + test("install with latest version", async () => { + const installer = new AzModuleInstaller("latest"); + const spyTryInstallingLatest = jest.spyOn(installer, "tryInstallingLatest"); + const mockTryInstalledTrue = jest.fn(async () => expect(installer["installResult"]["isInstalled"]).toBeTruthy()); + installer["tryInstallFromFolder"] = mockTryInstalledTrue; + installer["tryInstallFromZip"] = mockTryInstalledTrue; + installer["tryInstallFromGHRelease"] = mockTryInstalledTrue; + installer["tryInstallFromPSGallery"] = mockTryInstalledTrue; + const result = await installer.install(); + expect(result).toEqual({ isInstalled: true, moduleSource: "hostedAgentFolder" }); + expect(spyTryInstallingLatest).toBeCalledTimes(1); + expect(mockTryInstalledTrue).toBeCalledTimes(4); + }); + test("install with version 1.1.1 available as folder", async () => { + mockPathExists.mockImplementation((path) => path === "C:\\Modules\\az_1.1.1"); + const installer = new AzModuleInstaller("1.1.1"); + const spyTryInstallingLatest = jest.spyOn(installer, "tryInstallingLatest"); + const spyTryInstallFromFolder = jest.spyOn(installer, "tryInstallFromFolder"); + const mockTryInstalledTrue = jest.fn(async () => expect(installer["installResult"]["isInstalled"]).toBeTruthy()); + installer["tryInstallFromZip"] = mockTryInstalledTrue; + installer["tryInstallFromGHRelease"] = mockTryInstalledTrue; + installer["tryInstallFromPSGallery"] = mockTryInstalledTrue; + const result = await installer.install(); + expect(result).toEqual({ isInstalled: true, moduleSource: "hostedAgentFolder" }); + expect(spyTryInstallingLatest).toBeCalledTimes(1); + expect(spyTryInstallFromFolder).toBeCalledTimes(1); + expect(mockTryInstalledTrue).toBeCalledTimes(3); + }); + test("install with version 1.1.1 available as zip", async () => { + mockPathExists.mockImplementation((path) => path === "C:\\Modules\\az_1.1.1.zip"); + const installer = new AzModuleInstaller("1.1.1"); + const spyTryInstallingLatest = jest.spyOn(installer, "tryInstallingLatest"); + const spyTryInstallFromFolder = jest.spyOn(installer, "tryInstallFromFolder"); + const spyTryInstallFromZip = jest.spyOn(installer, "tryInstallFromZip"); + const mockTryInstalledTrue = jest.fn(async () => expect(installer["installResult"]["isInstalled"]).toBeTruthy()); + installer["tryInstallFromGHRelease"] = mockTryInstalledTrue; + installer["tryInstallFromPSGallery"] = mockTryInstalledTrue; + const result = await installer.install(); + expect(result).toEqual({ isInstalled: true, moduleSource: "hostedAgentZip" }); + expect(spyTryInstallingLatest).toBeCalledTimes(1); + expect(spyTryInstallFromFolder).toBeCalledTimes(1); + expect(spyTryInstallFromZip).toBeCalledTimes(1); + expect(mockTryInstalledTrue).toBeCalledTimes(2); + }); + test("install with version 1.1.1 from GHRelease", async () => { + const installer = new AzModuleInstaller("1.1.1"); + installer["getDownloadUrlFromGHRelease"] = jest.fn().mockReturnValue("downloadUrl"); + const spyTryInstallingLatest = jest.spyOn(installer, "tryInstallingLatest"); + const spyTryInstallFromFolder = jest.spyOn(installer, "tryInstallFromFolder"); + const spyTryInstallFromZip = jest.spyOn(installer, "tryInstallFromZip"); + const spyTryInstallFromGHRelease = jest.spyOn(installer, "tryInstallFromGHRelease"); + const mockTryInstalledTrue = jest.fn(async () => expect(installer["installResult"]["isInstalled"]).toBeTruthy()); + installer["tryInstallFromPSGallery"] = mockTryInstalledTrue; + const result = await installer.install(); + expect(result).toEqual({ isInstalled: true, moduleSource: "hostedAgentGHRelease" }); + expect(spyTryInstallingLatest).toBeCalledTimes(1); + expect(spyTryInstallFromFolder).toBeCalledTimes(1); + expect(spyTryInstallFromZip).toBeCalledTimes(1); + expect(spyTryInstallFromGHRelease).toBeCalledTimes(1); + expect(mockTryInstalledTrue).toBeCalledTimes(1); + }); + test("install with version 1.1.1 from PSGallery", async () => { + const installer = new AzModuleInstaller("1.1.1"); + installer["getDownloadUrlFromGHRelease"] = jest.fn().mockRejectedValue(new Error("Error getting versions manifest.")); + const spyTryInstallingLatest = jest.spyOn(installer, "tryInstallingLatest"); + const spyTryInstallFromFolder = jest.spyOn(installer, "tryInstallFromFolder"); + const spyTryInstallFromZip = jest.spyOn(installer, "tryInstallFromZip"); + const spyTryInstallFromGHRelease = jest.spyOn(installer, "tryInstallFromGHRelease"); + const spyTryInstallFromPSGallery = jest.spyOn(installer, "tryInstallFromPSGallery"); + const result = await installer.install(); + expect(result).toEqual({ isInstalled: true, moduleSource: "hostedAgentPSGallery" }); + expect(spyTryInstallingLatest).toBeCalledTimes(1); + expect(spyTryInstallFromFolder).toBeCalledTimes(1); + expect(spyTryInstallFromZip).toBeCalledTimes(1); + expect(spyTryInstallFromGHRelease).toBeCalledTimes(1); + expect(spyTryInstallFromPSGallery).toBeCalledTimes(1); + }); +}); diff --git a/__tests__/Utilities/ArchiveTools.test.ts b/__tests__/Utilities/ArchiveTools.test.ts new file mode 100644 index 00000000..c149d101 --- /dev/null +++ b/__tests__/Utilities/ArchiveTools.test.ts @@ -0,0 +1,22 @@ +import { ArchiveTools } from "../../src/Utilities/ArchiveTools"; + +jest.mock("@actions/core"); + +describe('Testing ArchiveTools', () => { + test('unzip using powershell to extract', async () => { + const archiveTool = new ArchiveTools(); + archiveTool["unzipUsingPowerShell"] = jest.fn(); + archiveTool["unzipUsing7Zip"] = jest.fn(); + await archiveTool.unzip("/usr/share/az_1.1.1.zip", "/usr/share"); + expect(archiveTool["unzipUsingPowerShell"]).toHaveBeenCalledTimes(1); + expect(archiveTool["unzipUsing7Zip"]).not.toHaveBeenCalled(); + }); + test('unzip using 7zip to extract', async () => { + const archiveTool = new ArchiveTools(true); + archiveTool["unzipUsingPowerShell"] = jest.fn(); + archiveTool["unzipUsing7Zip"] = jest.fn(); + await archiveTool.unzip("/usr/share/az_1.1.1.zip", "/usr/share"); + expect(archiveTool["unzipUsingPowerShell"]).not.toHaveBeenCalled(); + expect(archiveTool["unzipUsing7Zip"]).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/__tests__/Utilities/Utils.test.ts b/__tests__/Utilities/Utils.test.ts index ca5df2b4..4977565f 100644 --- a/__tests__/Utilities/Utils.test.ts +++ b/__tests__/Utilities/Utils.test.ts @@ -1,4 +1,18 @@ import Utils from '../../src/Utilities/Utils'; +import PowerShellToolRunner from '../../src/Utilities/PowerShellToolRunner'; + +jest.mock('../../src/Utilities/PowerShellToolRunner'); +const mockPowerShellToolRunnerInit = jest.fn(); +const mockExecutePowerShellCommand = jest.fn(); +let mockExecutePowerShellCommandOutput = ""; +mockExecutePowerShellCommand.mockImplementation((_script, options) => { + options.listeners.stdout(Buffer.from(mockExecutePowerShellCommandOutput)); +}); +let mockExecutePowerShellScriptBlockExitCode = 0; +const mockExecutePowerShellScriptBlock = jest.fn(async (_script) => mockExecutePowerShellScriptBlockExitCode); +PowerShellToolRunner.init = mockPowerShellToolRunnerInit; +PowerShellToolRunner.executePowerShellCommand = mockExecutePowerShellCommand; +PowerShellToolRunner.executePowerShellScriptBlock = mockExecutePowerShellScriptBlock; const version: string = '9.0.0'; const moduleName: string = 'az'; @@ -20,15 +34,30 @@ describe('Testing isValidVersion', () => { }); describe('Testing setPSModulePath', () => { + let savedRunnerOS: string; + beforeAll(() => { + savedRunnerOS = process.env.RUNNER_OS; + }); + afterAll(() => { + process.env.RUNNER_OS = savedRunnerOS; + }); + test('PSModulePath with azPSVersion non-empty', () => { + process.env.RUNNER_OS = "Windows"; Utils.setPSModulePath(version); expect(process.env.PSModulePath).toContain(version); }); test('PSModulePath with azPSVersion empty', () => { + process.env.RUNNER_OS = "Linux"; const prevPSModulePath = process.env.PSModulePath; Utils.setPSModulePath(); expect(process.env.PSModulePath).not.toEqual(prevPSModulePath); }); + test('setPSModulePath should throw for MacOS', () => { + process.env.RUNNER_OS = "Darwin"; + expect(() => Utils.setPSModulePath()).toThrow(); + expect(() => Utils.setPSModulePath(version)).toThrow(); + }); }); describe('Testing getLatestModule', () => { @@ -55,3 +84,65 @@ describe('Testing checkModuleVersion', () => { expect(checkModuleVersionSpy).toHaveBeenCalled(); }); }); + +describe('Testing isHostedAgent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Should return true when file layout check script returns true', async () => { + mockExecutePowerShellCommandOutput = "True"; + const isHostedAgentResult = await Utils.isHostedAgent("/usr/share"); + expect(mockPowerShellToolRunnerInit).toHaveBeenCalledTimes(1); + expect(mockExecutePowerShellCommand).toHaveBeenCalledTimes(1); + expect(mockExecutePowerShellCommand.mock.calls[0][0]).toBe('Test-Path (Join-Path "/usr/share" "az_*")'); + expect(isHostedAgentResult).toBeTruthy(); + }); + test('Should return false when file layout check script returns false', async () => { + mockExecutePowerShellCommandOutput = "False"; + const isHostedAgentResult = await Utils.isHostedAgent("/usr/share"); + expect(isHostedAgentResult).toBeFalsy(); + }); +}); + +describe('Testing isGhes', () => { + let savedGhUrl: string; + beforeAll(() => { + savedGhUrl = process.env['GITHUB_SERVER_URL']; + }); + afterAll(() => { + process.env['GITHUB_SERVER_URL'] = savedGhUrl; + }); + + test('Should return false when server url is github.com', () => { + process.env['GITHUB_SERVER_URL'] = "https://github.com"; + expect(Utils.isGhes()).toBeFalsy(); + }); + test('Should return false when server url is not available', () => { + process.env['GITHUB_SERVER_URL'] = ""; + expect(Utils.isGhes()).toBeFalsy(); + }) + test('Should return true when server url is not github.com', () => { + process.env['GITHUB_SERVER_URL'] = "https://github.contoso.com"; + expect(Utils.isGhes()).toBeTruthy(); + }); +}); + +describe('Testing saveAzModule', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Should run without throwing when script succeeds with exit code 0', async () => { + mockExecutePowerShellScriptBlockExitCode = 0; + await Utils.saveAzModule("1.1.1", "/usr/share/az_1.1.1"); + expect(mockPowerShellToolRunnerInit).toHaveBeenCalledTimes(1); + expect(mockExecutePowerShellScriptBlock).toHaveBeenCalledTimes(1); + expect(mockExecutePowerShellScriptBlock.mock.calls[0][0]).toContain( + "Save-Module -Path /usr/share/az_1.1.1 -Name Az -RequiredVersion 1.1.1 -Force -ErrorAction Stop"); + }); + test('Should throw when script fails with non-zero exit code', async () => { + mockExecutePowerShellScriptBlockExitCode = 1; + expect(Utils.saveAzModule("1.1.1", "/usr/share/az_1.1.1")).rejects.toThrow(); + }); +}); diff --git a/action.yml b/action.yml index c1ae95cd..3fc74b28 100644 --- a/action.yml +++ b/action.yml @@ -16,6 +16,9 @@ inputs: description: 'If this is true, this task will fail if any errors are written to the error pipeline, or if any data is written to the Standard Error stream.' required: false default: 'false' + githubToken: + description: Used to pull Az module from Azure/az-ps-module-versions. Since there's a default, this is typically not supplied by the user. + default: ${{ github.token }} branding: icon: 'login.svg' color: 'blue' diff --git a/lib/AzModuleInstaller.js b/lib/AzModuleInstaller.js new file mode 100644 index 00000000..e9c813af --- /dev/null +++ b/lib/AzModuleInstaller.js @@ -0,0 +1,180 @@ +"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 tc = __importStar(require("@actions/tool-cache")); +const os = __importStar(require("os")); +const ArchiveTools_1 = require("./Utilities/ArchiveTools"); +const FileUtils_1 = __importDefault(require("./Utilities/FileUtils")); +const Utils_1 = __importDefault(require("./Utilities/Utils")); +exports.AzModuleSource = { + PrivateAgent: "privateAgent", + Folder: "hostedAgentFolder", + Zip: "hostedAgentZip", + GHRelease: "hostedAgentGHRelease", + PSGallery: "hostedAgentPSGallery" +}; +class AzModuleInstaller { + constructor(version, githubAuth) { + var _a; + this.isWin = false; + this.version = version; + this.githubAuth = githubAuth; + this.installResult = { + moduleSource: "Others", + isInstalled: false + }; + const platform = (_a = (process.env.RUNNER_OS || os.type())) === null || _a === void 0 ? void 0 : _a.toLowerCase(); + core.debug(`Platform: ${platform}`); + switch (platform) { + case "windows": + case "windows_nt": + this.isWin = true; + this.moduleRoot = "C:\\Modules"; + this.modulePath = `${this.moduleRoot}\\az_${this.version}`; + break; + case "linux": + this.moduleRoot = "/usr/share"; + this.modulePath = `${this.moduleRoot}/az_${this.version}`; + break; + default: + throw `OS ${platform} not supported`; + } + this.moduleZipPath = `${this.modulePath}.zip`; + } + install() { + return __awaiter(this, void 0, void 0, function* () { + if (Utils_1.default.isHostedAgent(this.moduleRoot)) { + yield this.tryInstallingLatest(); + yield this.tryInstallFromFolder(); + yield this.tryInstallFromZip(); + yield this.tryInstallFromGHRelease(); + yield this.tryInstallFromPSGallery(); + } + else { + core.debug("File layout is not like hosted agent, skippig module install."); + this.installResult = { + isInstalled: false, + moduleSource: exports.AzModuleSource.PrivateAgent + }; + } + return this.installResult; + }); + } + tryInstallingLatest() { + return __awaiter(this, void 0, void 0, function* () { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallingLatest`); + return; + } + if (this.version === "latest") { + core.debug("Latest selected, will use latest Az module available in agent as folder."); + this.installResult = { + isInstalled: true, + moduleSource: exports.AzModuleSource.Folder + }; + } + }); + } + tryInstallFromFolder() { + return __awaiter(this, void 0, void 0, function* () { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromFolder`); + return; + } + if (FileUtils_1.default.pathExists(this.modulePath)) { + core.debug(`Az ${this.version} present at ${this.modulePath} as folder.`); + this.installResult = { + isInstalled: true, + moduleSource: exports.AzModuleSource.Folder + }; + } + }); + } + tryInstallFromZip() { + return __awaiter(this, void 0, void 0, function* () { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromZip`); + return; + } + if (FileUtils_1.default.pathExists(this.moduleZipPath)) { + core.debug(`Az ${this.version} present at ${this.moduleZipPath} as zip, expanding it.`); + yield new ArchiveTools_1.ArchiveTools(this.isWin).unzip(this.moduleZipPath, this.moduleRoot); + yield FileUtils_1.default.deleteFile(this.moduleZipPath); + this.installResult = { + isInstalled: true, + moduleSource: exports.AzModuleSource.Zip + }; + } + }); + } + tryInstallFromGHRelease() { + return __awaiter(this, void 0, void 0, function* () { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromGHRelease`); + return; + } + try { + const downloadUrl = yield this.getDownloadUrlFromGHRelease(); + core.debug(`Downloading Az ${this.version} from GHRelease using url ${downloadUrl}`); + yield tc.downloadTool(downloadUrl, this.moduleZipPath, this.githubAuth); + core.debug(`Expanding Az ${this.version} downloaded at ${this.moduleZipPath} as zip.`); + yield new ArchiveTools_1.ArchiveTools(this.isWin).unzip(this.moduleZipPath, this.moduleRoot); + yield FileUtils_1.default.deleteFile(this.moduleZipPath); + this.installResult = { + isInstalled: true, + moduleSource: exports.AzModuleSource.GHRelease + }; + } + catch (err) { + core.debug(err); + console.log("Download from GHRelease failed, will fallback to PSGallery"); + } + }); + } + tryInstallFromPSGallery() { + return __awaiter(this, void 0, void 0, function* () { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromPSGallery`); + return; + } + yield Utils_1.default.saveAzModule(this.version, this.modulePath); + this.installResult = { + isInstalled: true, + moduleSource: exports.AzModuleSource.PSGallery + }; + }); + } + getDownloadUrlFromGHRelease() { + var _a; + return __awaiter(this, void 0, void 0, function* () { + core.debug("Getting versions manifest from GHRelease."); + const releases = yield tc.getManifestFromRepo("Azure", "az-ps-module-versions", this.githubAuth, "main"); + core.debug(JSON.stringify(releases)); + const releaseInfo = (_a = releases.filter(release => release.version === this.version)) === null || _a === void 0 ? void 0 : _a[0]; + if (!releaseInfo || releaseInfo.files.length === 0) { + throw new Error(`Version ${this.version} not present in versions manifest of GHRelease.`); + } + return releaseInfo.files[0].download_url; + }); + } +} +exports.AzModuleInstaller = AzModuleInstaller; diff --git a/lib/Utilities/ArchiveTools.js b/lib/Utilities/ArchiveTools.js new file mode 100644 index 00000000..1bb911d9 --- /dev/null +++ b/lib/Utilities/ArchiveTools.js @@ -0,0 +1,59 @@ +"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 __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core_1 = require("@actions/core"); +const exec_1 = require("@actions/exec"); +const io_1 = require("@actions/io"); +const PowerShellToolRunner_1 = __importDefault(require("./PowerShellToolRunner")); +class ArchiveTools { + constructor(use7Zip = false) { + this.use7Zip = use7Zip; + } + unzip(zipPath, destination) { + return __awaiter(this, void 0, void 0, function* () { + if (this.use7Zip) { + yield this.unzipUsing7Zip(zipPath, destination); + } + else { + yield this.unzipUsingPowerShell(zipPath, destination); + } + }); + } + unzipUsing7Zip(zipPath, destination) { + return __awaiter(this, void 0, void 0, function* () { + core_1.debug(`Using 7zip to extract ${zipPath} to ${destination}`); + const path7Zip = yield io_1.which("7z.exe", true); + const exitCode = yield exec_1.exec(`${path7Zip} x -o${destination} ${zipPath}`); + if (exitCode != 0) { + throw new Error(`Extraction using 7zip failed from ${zipPath} to ${destination}`); + } + }); + } + unzipUsingPowerShell(zipPath, destination) { + return __awaiter(this, void 0, void 0, function* () { + core_1.debug(`Using powershell Expand-Archive cmdlet to extract ${zipPath} to ${destination}`); + const script = ` + $prevProgressPref = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Expand-Archive -Path ${zipPath} -DestinationPath ${destination} + $ProgressPreference = $prevProgressPref`; + yield PowerShellToolRunner_1.default.init(); + const exitCode = yield PowerShellToolRunner_1.default.executePowerShellScriptBlock(script); + if (exitCode != 0) { + throw new Error(`Extraction using Expand-Archive cmdlet failed from ${zipPath} to ${destination}`); + } + }); + } +} +exports.ArchiveTools = ArchiveTools; diff --git a/lib/Utilities/FileUtils.js b/lib/Utilities/FileUtils.js index d1411fd7..a00785f9 100644 --- a/lib/Utilities/FileUtils.js +++ b/lib/Utilities/FileUtils.js @@ -45,6 +45,9 @@ class FileUtils { } }); } + static pathExists(path) { + return fs.existsSync(path); + } } exports.default = FileUtils; FileUtils.tempDirectory = process.env.RUNNER_TEMP || os.tmpdir(); diff --git a/lib/Utilities/Utils.js b/lib/Utilities/Utils.js index 48b9ee2f..6591300c 100644 --- a/lib/Utilities/Utils.js +++ b/lib/Utilities/Utils.js @@ -102,5 +102,39 @@ class Utils { static isValidVersion(version) { return !!version.match(Constants_1.default.versionPattern); } + static isHostedAgent(moduleContainerPath) { + return __awaiter(this, void 0, void 0, function* () { + const script = `Test-Path (Join-Path "${moduleContainerPath}" "az_*")`; + let output = ""; + const options = { + listeners: { + stdout: (data) => { + output += data.toString(); + } + } + }; + yield PowerShellToolRunner_1.default.init(); + yield PowerShellToolRunner_1.default.executePowerShellCommand(script, options); + return output.trim().toLowerCase() === "true"; + }); + } + static isGhes() { + const ghUrl = new URL(process.env['GITHUB_SERVER_URL'] || 'https://github.com'); + return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM'; + } + static saveAzModule(version, modulePath) { + return __awaiter(this, void 0, void 0, function* () { + const script = ` + $prevProgressPref = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Save-Module -Path ${modulePath} -Name Az -RequiredVersion ${version} -Force -ErrorAction Stop + $ProgressPreference = $prevProgressPref`; + yield PowerShellToolRunner_1.default.init(); + const exitCode = yield PowerShellToolRunner_1.default.executePowerShellScriptBlock(script); + if (exitCode != 0) { + throw new Error(`Download from PSGallery failed for Az ${version} to ${modulePath}`); + } + }); + } } exports.default = Utils; diff --git a/lib/main.js b/lib/main.js index 1488087e..e1506574 100644 --- a/lib/main.js +++ b/lib/main.js @@ -25,6 +25,7 @@ const Utils_1 = __importDefault(require("./Utilities/Utils")); const FileUtils_1 = __importDefault(require("./Utilities/FileUtils")); const ScriptRunner_1 = __importDefault(require("./ScriptRunner")); const InitializeAzure_1 = __importDefault(require("./InitializeAzure")); +const AzModuleInstaller_1 = require("./AzModuleInstaller"); const errorActionPrefValues = new Set(['STOP', 'CONTINUE', 'SILENTLYCONTINUE']); let azPSVersion; let userAgentPrefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USER_AGENT}` : ""; @@ -40,8 +41,12 @@ function main() { azPSVersion = core.getInput('azPSVersion', { required: true }).trim().toLowerCase(); const errorActionPreference = core.getInput('errorActionPreference'); const failOnStandardError = core.getInput('failOnStandardError').trim().toLowerCase() === "true"; + const githubToken = core.getInput('githubToken'); console.log(`Validating inputs`); validateInputs(inlineScript, errorActionPreference); + const githubAuth = !githubToken || Utils_1.default.isGhes() ? undefined : `token ${githubToken}`; + const installResult = yield new AzModuleInstaller_1.AzModuleInstaller(azPSVersion, githubAuth).install(); + console.log(`Module Az ${azPSVersion} installed from ${installResult.moduleSource}`); console.log(`Initializing Az Module`); yield InitializeAzure_1.default.importAzModule(azPSVersion); console.log(`Initializing Az Module Complete`); diff --git a/package-lock.json b/package-lock.json index 82df999b..b48f7ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,39 @@ "@actions/io": "^1.0.1" } }, + "@actions/http-client": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.9.tgz", + "integrity": "sha512-0O4SsJ7q+MK0ycvXPl2e6bMXV7dxAXOGjrXS1eTF9s2S401Tp6c/P3c3Joz04QefC1J6Gt942Wl2jbm3f4mLcg==", + "requires": { + "tunnel": "0.0.6" + } + }, "@actions/io": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.0.2.tgz", "integrity": "sha512-J8KuFqVPr3p6U8W93DOXlXW6zFvrQAJANdS+vw0YhusLIq+bszW8zmK2Fh1C2kDPX8FMvwIl1OUcFgvJoXLbAg==" }, + "@actions/tool-cache": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@actions/tool-cache/-/tool-cache-1.6.1.tgz", + "integrity": "sha512-F+vwEDwfqcHMKuSkj79pihOnsAMv23EkG76nMpc82UsnXwyQdyEsktGxrB0SNtm7pRqTXEIOoAPTgrSQclXYTg==", + "requires": { + "@actions/core": "^1.2.6", + "@actions/exec": "^1.0.0", + "@actions/http-client": "^1.0.8", + "@actions/io": "^1.0.1", + "semver": "^6.1.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, "@babel/code-frame": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", @@ -3761,8 +3789,7 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" }, "set-blocking": { "version": "2.0.0", @@ -4278,6 +4305,11 @@ "yargs-parser": "18.x" } }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==" + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 3f4f8b85..a36316c0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@actions/core": "^1.2.2", "@actions/exec": "^1.0.3", "@actions/io": "^1.0.2", + "@actions/tool-cache": "^1.6.1", "fs": "0.0.1-security", "os": "^0.1.1", "path": "^0.12.7", diff --git a/src/AzModuleInstaller.ts b/src/AzModuleInstaller.ts new file mode 100644 index 00000000..ec7b02d3 --- /dev/null +++ b/src/AzModuleInstaller.ts @@ -0,0 +1,172 @@ +import * as core from '@actions/core'; +import * as tc from '@actions/tool-cache'; +import * as os from 'os'; +import { ArchiveTools } from './Utilities/ArchiveTools'; +import FileUtils from './Utilities/FileUtils'; +import Utils from './Utilities/Utils'; + +export interface InstallResult { + moduleSource: string; + isInstalled: boolean; +} + +export const AzModuleSource = { + PrivateAgent: "privateAgent", + Folder: "hostedAgentFolder", + Zip: "hostedAgentZip", + GHRelease: "hostedAgentGHRelease", + PSGallery: "hostedAgentPSGallery" +} + +export class AzModuleInstaller { + private version: string; + private githubAuth: string; + private moduleRoot: string; + private modulePath: string; + private moduleZipPath: string; + private installResult: InstallResult; + private isWin = false; + + public constructor(version: string, githubAuth?: string) { + this.version = version; + this.githubAuth = githubAuth; + this.installResult = { + moduleSource: "Others", + isInstalled: false + }; + const platform = (process.env.RUNNER_OS || os.type())?.toLowerCase(); + core.debug(`Platform: ${platform}`); + switch(platform) { + case "windows": + case "windows_nt": + this.isWin = true; + this.moduleRoot = "C:\\Modules"; + this.modulePath = `${this.moduleRoot}\\az_${this.version}` + break; + case "linux": + this.moduleRoot = "/usr/share"; + this.modulePath = `${this.moduleRoot}/az_${this.version}` + break; + default: + throw `OS ${platform} not supported`; + } + this.moduleZipPath = `${this.modulePath}.zip`; + } + + public async install(): Promise { + if (Utils.isHostedAgent(this.moduleRoot)) { + await this.tryInstallingLatest(); + await this.tryInstallFromFolder(); + await this.tryInstallFromZip(); + await this.tryInstallFromGHRelease(); + await this.tryInstallFromPSGallery(); + } else { + core.debug("File layout is not like hosted agent, skippig module install."); + this.installResult = { + isInstalled: false, + moduleSource: AzModuleSource.PrivateAgent + }; + } + + return this.installResult; + } + + private async tryInstallingLatest() { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallingLatest`); + return; + } + + if (this.version === "latest") { + core.debug("Latest selected, will use latest Az module available in agent as folder."); + this.installResult = { + isInstalled: true, + moduleSource: AzModuleSource.Folder + }; + } + } + + private async tryInstallFromFolder() { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromFolder`); + return; + } + + if (FileUtils.pathExists(this.modulePath)) { + core.debug(`Az ${this.version} present at ${this.modulePath} as folder.`); + this.installResult = { + isInstalled: true, + moduleSource: AzModuleSource.Folder + }; + } + } + + private async tryInstallFromZip() { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromZip`); + return; + } + + if (FileUtils.pathExists(this.moduleZipPath)) { + core.debug(`Az ${this.version} present at ${this.moduleZipPath} as zip, expanding it.`); + await new ArchiveTools(this.isWin).unzip(this.moduleZipPath, this.moduleRoot); + await FileUtils.deleteFile(this.moduleZipPath); + this.installResult = { + isInstalled: true, + moduleSource: AzModuleSource.Zip + }; + } + } + + private async tryInstallFromGHRelease() { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromGHRelease`); + return; + } + + try { + const downloadUrl = await this.getDownloadUrlFromGHRelease(); + core.debug(`Downloading Az ${this.version} from GHRelease using url ${downloadUrl}`); + await tc.downloadTool(downloadUrl, this.moduleZipPath, this.githubAuth); + core.debug(`Expanding Az ${this.version} downloaded at ${this.moduleZipPath} as zip.`); + await new ArchiveTools(this.isWin).unzip(this.moduleZipPath, this.moduleRoot); + await FileUtils.deleteFile(this.moduleZipPath); + this.installResult = { + isInstalled: true, + moduleSource: AzModuleSource.GHRelease + }; + } catch (err) { + core.debug(err); + console.log("Download from GHRelease failed, will fallback to PSGallery"); + } + } + + private async tryInstallFromPSGallery() { + if (this.installResult.isInstalled) { + core.debug(`Module already installed skipping tryInstallFromPSGallery`); + return; + } + + await Utils.saveAzModule(this.version, this.modulePath); + this.installResult = { + isInstalled: true, + moduleSource: AzModuleSource.PSGallery + }; + } + + private async getDownloadUrlFromGHRelease() { + core.debug("Getting versions manifest from GHRelease."); + const releases = await tc.getManifestFromRepo( + "Azure", + "az-ps-module-versions", + this.githubAuth, + "main"); + core.debug(JSON.stringify(releases)); + const releaseInfo = releases.filter(release => release.version === this.version)?.[0]; + if (!releaseInfo || releaseInfo.files.length === 0) { + throw new Error(`Version ${this.version} not present in versions manifest of GHRelease.`); + } + + return releaseInfo.files[0].download_url; + } +} diff --git a/src/Utilities/ArchiveTools.ts b/src/Utilities/ArchiveTools.ts new file mode 100644 index 00000000..47f420f3 --- /dev/null +++ b/src/Utilities/ArchiveTools.ts @@ -0,0 +1,43 @@ +import { debug } from "@actions/core"; +import { exec } from "@actions/exec"; +import { which } from "@actions/io"; +import PowerShellToolRunner from "./PowerShellToolRunner"; + +export class ArchiveTools { + private use7Zip: boolean; + + constructor(use7Zip = false) { + this.use7Zip = use7Zip; + } + + public async unzip(zipPath: string, destination: string) { + if (this.use7Zip) { + await this.unzipUsing7Zip(zipPath, destination); + } else { + await this.unzipUsingPowerShell(zipPath, destination) + } + } + + private async unzipUsing7Zip(zipPath: string, destination: string) { + debug(`Using 7zip to extract ${zipPath} to ${destination}`); + const path7Zip = await which("7z.exe", true); + const exitCode = await exec(`${path7Zip} x -o${destination} ${zipPath}`); + if (exitCode != 0) { + throw new Error(`Extraction using 7zip failed from ${zipPath} to ${destination}`); + } + } + + private async unzipUsingPowerShell(zipPath: string, destination: string) { + debug(`Using powershell Expand-Archive cmdlet to extract ${zipPath} to ${destination}`); + const script = ` + $prevProgressPref = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Expand-Archive -Path ${zipPath} -DestinationPath ${destination} + $ProgressPreference = $prevProgressPref`; + await PowerShellToolRunner.init(); + const exitCode = await PowerShellToolRunner.executePowerShellScriptBlock(script); + if (exitCode != 0) { + throw new Error(`Extraction using Expand-Archive cmdlet failed from ${zipPath} to ${destination}`); + } + } +} diff --git a/src/Utilities/FileUtils.ts b/src/Utilities/FileUtils.ts index 80f25e05..2ef3072d 100644 --- a/src/Utilities/FileUtils.ts +++ b/src/Utilities/FileUtils.ts @@ -28,4 +28,8 @@ export default class FileUtils { } } } + + static pathExists(path: string) { + return fs.existsSync(path); + } } \ No newline at end of file diff --git a/src/Utilities/Utils.ts b/src/Utilities/Utils.ts index 6aaafccf..273d47d1 100644 --- a/src/Utilities/Utils.ts +++ b/src/Utilities/Utils.ts @@ -83,5 +83,38 @@ export default class Utils { return !!version.match(Constants.versionPattern); } -} + static async isHostedAgent(moduleContainerPath: string): Promise { + const script = `Test-Path (Join-Path "${moduleContainerPath}" "az_*")`; + let output: string = ""; + const options: any = { + listeners: { + stdout: (data: Buffer) => { + output += data.toString(); + } + } + }; + await PowerShellToolRunner.init(); + await PowerShellToolRunner.executePowerShellCommand(script, options); + return output.trim().toLowerCase() === "true"; + } + + static isGhes(): boolean { + const ghUrl = new URL( + process.env['GITHUB_SERVER_URL'] || 'https://github.com' + ); + return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM'; + } + static async saveAzModule(version: string, modulePath: string): Promise { + const script = ` + $prevProgressPref = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + Save-Module -Path ${modulePath} -Name Az -RequiredVersion ${version} -Force -ErrorAction Stop + $ProgressPreference = $prevProgressPref`; + await PowerShellToolRunner.init(); + const exitCode = await PowerShellToolRunner.executePowerShellScriptBlock(script); + if (exitCode != 0) { + throw new Error(`Download from PSGallery failed for Az ${version} to ${modulePath}`); + } + } +} diff --git a/src/main.ts b/src/main.ts index 5e7ee108..d6048b4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import Utils from './Utilities/Utils'; import FileUtils from './Utilities/FileUtils'; import ScriptRunner from './ScriptRunner'; import InitializeAzure from './InitializeAzure'; +import { AzModuleInstaller } from './AzModuleInstaller'; const errorActionPrefValues = new Set(['STOP', 'CONTINUE', 'SILENTLYCONTINUE']); let azPSVersion: string; @@ -22,9 +23,14 @@ async function main() { azPSVersion = core.getInput('azPSVersion', { required: true }).trim().toLowerCase(); const errorActionPreference: string = core.getInput('errorActionPreference'); const failOnStandardError = core.getInput('failOnStandardError').trim().toLowerCase() === "true"; + const githubToken = core.getInput('githubToken'); console.log(`Validating inputs`); validateInputs(inlineScript, errorActionPreference); + const githubAuth = !githubToken || Utils.isGhes() ? undefined : `token ${githubToken}`; + const installResult = await new AzModuleInstaller(azPSVersion, githubAuth).install(); + console.log(`Module Az ${azPSVersion} installed from ${installResult.moduleSource}`); + console.log(`Initializing Az Module`); await InitializeAzure.importAzModule(azPSVersion); console.log(`Initializing Az Module Complete`);