From bdccf70d51543b1207cda94d100341aae70acf8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Novotn=C3=BD?= <33942303+dragonraid@users.noreply.github.com> Date: Sat, 12 Dec 2020 10:41:55 +0100 Subject: [PATCH] Add support for hemfile (#1) --- .dockeringnore | 7 ++ .gitignore | 3 +- Dockerfile | 17 +++++ README.md | 53 ++++++++----- action.yml | 3 +- package.json | 8 +- src/github.js | 4 +- src/handlers.js | 56 ++++++++++++++ src/index.js | 112 ++++++++-------------------- src/{ => processors}/file.js | 45 +++-------- src/{ => processors}/file.test.js | 27 +++---- src/processors/helmfile.js | 79 ++++++++++++++++++++ src/processors/helmfile.test.js | 24 ++++++ src/{ => processors}/ubuntu.js | 56 +++++++------- src/{ => processors}/ubuntu.test.js | 15 +++- 15 files changed, 321 insertions(+), 188 deletions(-) create mode 100644 .dockeringnore create mode 100644 Dockerfile create mode 100644 src/handlers.js rename src/{ => processors}/file.js (57%) rename src/{ => processors}/file.test.js (73%) create mode 100644 src/processors/helmfile.js create mode 100644 src/processors/helmfile.test.js rename src/{ => processors}/ubuntu.js (66%) rename src/{ => processors}/ubuntu.test.js (77%) diff --git a/.dockeringnore b/.dockeringnore new file mode 100644 index 0000000..2899c6b --- /dev/null +++ b/.dockeringnore @@ -0,0 +1,7 @@ +node_modules +.env* +action.yml +LICENSE +README.md +.gitignore +.eslintrc.yml diff --git a/.gitignore b/.gitignore index ad29183..6a0d87e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -test.sh node_modules -.env +.env* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..daa44cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:14 + +WORKDIR /app + +ARG HELMFILE_VERSION=v0.135.0 + +ADD https://github.com/roboll/helmfile/releases/download/${HELMFILE_VERSION}/helmfile_linux_amd64 \ + /tmp/ + +COPY . . + +RUN npm install \ + && mv /tmp/helmfile_linux_amd64 /usr/local/bin/helmfile \ + && chmod +x /usr/local/bin/helmfile \ + && curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash + +ENTRYPOINT [ "node", "/app/src/index.js" ] diff --git a/README.md b/README.md index 507b276..3a9daee 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,6 @@ It does so by reading specified file in your repository updating respective dependency/version and opening pull request with this update to against default branch. See _Types_ section to see, what can this action update. -> NOTE: This action is not using standard github action execution with `uses` keyword, because that -> would require to check in node_modules. Instead, it clones this action and then runs npm install -> and npm start - ## Inputs Environment variables are used for inputs instead of actual github action inputs, @@ -55,7 +51,7 @@ By default this type returns latest image based on your inputs: | INSTANCE_TYPE | Virtualization details, varies based on cloud | `hvm-ssd` | no | | RELEASE | release (do not supply if you want latest) | `20200924` | no | -## Example +#### Example ```yaml name: update ubuntu base image @@ -68,17 +64,11 @@ jobs: update: runs-on: ubuntu-20.04 steps: - - name: checkout dragonraid/deployment-bumper - uses: actions/checkout@v2 - with: - repository: dragonraid/deployment-bumper - ref: refs/heads/main - path: ./.github/actions/deployment-bumper - - name: run deployment-bumper - working-directory: ./.github/actions/deployment-bumper + - name: update ubuntu AMI + uses: dragonraid/deployment-bumper env: TYPE: ubuntu - FILE: packer.json + FILE: ami.json REPOSITORY: dragonraid/test USERNAME: ${{ secrets.username }} PASSWORD: ${{ secrets.password }} @@ -88,7 +78,36 @@ jobs: VERSION: '20.04' ARCHITECTURE: amd64 INSTANCE_TYPE: hvm-ssd - run: | - npm install --only=prod - npm start +``` + +### Helmfile lock + +With this type you can update [helmfile](https://github.com/roboll/helmfile) lock files. +Under the hood this type runs [helmfile deps](https://github.com/roboll/helmfile#deps) command. + +| Input | Description | Example | Required | +| :---------- | --------------------------------------: | ------: | -------: | +| ENVIRONMENT | helmfile global options `--environment` | `myEnv` | no | + +#### Example + +```yaml +name: update helmfile lock + +on: + schedule: + - cron: '0 0 1 * *' + +jobs: + update: + runs-on: ubuntu-20.04 + steps: + - name: update helmfile.lock + uses: dragonraid/deployment-bumper + env: + TYPE: helmfile + FILE: helmfile.yaml + USERNAME: ${{ secrets.username }} + PASSWORD: ${{ secrets.password }} + ENVIRONMENT: staging ``` diff --git a/action.yml b/action.yml index ccae8c1..6f4fad3 100644 --- a/action.yml +++ b/action.yml @@ -1,7 +1,8 @@ name: 'Deployment Bumper' description: 'Update deployment related stuff' runs: - using: 'node12' + using: 'docker' + image: 'Dockerfile' branding: icon: 'edit' color: blue diff --git a/package.json b/package.json index e131158..4df4b64 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,11 @@ "main": "src/index.js", "scripts": { "cleanup": "rm -fr /tmp/dragonraid", - "dev": "npm run cleanup && node src/index.js", - "start": "NODE_ENV=production node src/index.js", - "test": "jest ." + "dev-ubuntu": "npm run cleanup && DOTENV_CONFIG_PATH='./.envUbuntu' node -r dotenv/config src/index.js", + "dev-helmfile": "npm run cleanup && DOTENV_CONFIG_PATH='./.envHelmfile' node -r dotenv/config src/index.js", + "start": "node src/index.js", + "test": "jest .", + "lint": "eslint ." }, "author": { "name": "Lukas Novotny", diff --git a/src/github.js b/src/github.js index fdb9f48..31eb498 100644 --- a/src/github.js +++ b/src/github.js @@ -14,14 +14,14 @@ class Repository { * @param {string} username - github username * @param {string} password - github personal access token * @param {string} path - where to clone repository - * @param {string} branchNamePrefix - branch name prefix + * @param {string} branchName - branch name prefix * @param {string} pathPrefix - local repository path prefix */ constructor({ repository, + username, password, path, - username, branchName, pathPrefix = '/tmp/', }) { diff --git a/src/handlers.js b/src/handlers.js new file mode 100644 index 0000000..ebd730f --- /dev/null +++ b/src/handlers.js @@ -0,0 +1,56 @@ +const { Helmfile } = require('./processors/helmfile'); +const { Ubuntu } = require('./processors/ubuntu'); + +/** + * Ubuntu processor handler. Edits specified value in specified file + * based on input parameters + * @param {object} filePath - full path to file + */ +const handleUbuntu = async (filePath) => { + const filterValues = { + cloud: process.env.CLOUD || null, + zone: process.env.ZONE || null, + version: process.env.VERSION || null, + architecture: process.env.ARCHITECTURE || null, + instanceType: process.env.INSTANCE_TYPE || null, + release: process.env.RELEASE || null, + }; + + const rawKeys = process.env.KEYS; + let keys; + if (rawKeys) { + keys = rawKeys.split(','); + } else { + throw new Error('KEYS must be supplied!'); + } + + const filter = {}; + Object.keys(filterValues).forEach((value) => { + if (filterValues[value]) filter[value] = filterValues[value]; + }); + const ubuntu = new Ubuntu(filter, filePath, keys); + await ubuntu.run(); + console.log(`File "${filePath}" has been successfully updated.`); +}; + +/** + * Helmfile processor handler. Updates helmfiles lock file. + * @param {object} filePath - full path to file + */ +const handleHelmfile = async (filePath) => { + const helmfileArgs = { + file: filePath, + }; + if (process.env.ENVIRONMENT) { + helmfileArgs.environment = process.env.ENVIRONMENT; + } + + const helmfile = new Helmfile(helmfileArgs); + await helmfile.run(); + console.log(`File "${filePath}" has been successfully updated.`); +}; + +module.exports = { + handleUbuntu, + handleHelmfile, +}; diff --git a/src/index.js b/src/index.js index 6668531..cb5d2ca 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,12 @@ -const { File } = require('./file'); const { Repository } = require('./github'); -const { Ubuntu } = require('./ubuntu'); +const handlers = require('./handlers'); -// load environment variables from .env file if not running in production -if (process.env.NODE_ENV !== 'production') { - require('dotenv').config(); -} - -// Check if environment variable exists, otherwise set default +/** + * Raw configuration. + */ const RAW_CONFIG = { TYPE: process.env.TYPE || null, FILE: process.env.FILE || null, - KEYS: process.env.KEYS || null, BRANCH_NAME: process.env.BRANCH_NAME || process.env.TYPE, BRANCH_PREFIX: process.env.BRANCH_PREFIX || 'update', REPOSITORY: process.env.REPOSITORY || process.env.GITHUB_REPOSITORY, @@ -30,45 +25,22 @@ const CONFIG = {}; const processConfig = () => { for (const [key, value] of Object.entries(RAW_CONFIG)) { if (!value) { - if (value === 'USERNAME' || value === 'PASSWORD') continue; + if (key === 'USERNAME' || key === 'PASSWORD') { + throw new Error(`Invalid USERNAME or PASSWORD!`); + }; throw new Error( `Invalid configuration value: ${value} for ${key}.`, ); } - switch (key) { - case 'KEYS': - CONFIG[key] = value.split(','); - break; - default: - CONFIG[key] = value; - } + CONFIG[key] = value; } }; -const handleUbuntu = async () => { - const filterValues = { - cloud: process.env.CLOUD || null, - zone: process.env.ZONE || null, - version: process.env.VERSION || null, - architecture: process.env.ARCHITECTURE || null, - instanceType: process.env.INSTANCE_TYPE || null, - release: process.env.RELEASE || null, - }; - - const filter = {}; - Object.keys(filterValues).forEach((value) => { - if (filterValues[value]) filter[value] = filterValues[value]; - }); - const ubuntu = new Ubuntu(filter); - const latestUbuntu = await ubuntu.latest; - const keyValuePairs = {}; - CONFIG.KEYS.forEach((key) => { - keyValuePairs[key] = latestUbuntu.id; - }); - - return keyValuePairs; -}; - +/** + * Clone repository and checkout the feature branch + * @param {string} branchName - feature branch name + * @return {object} + */ const initializeRepo = async (branchName) => { const repository = new Repository({ repository: CONFIG.REPOSITORY, @@ -81,57 +53,37 @@ const initializeRepo = async (branchName) => { return repository; }; -const throwError = (resolved) => { - resolved.forEach((process) => { - if (process.status === 'rejected') { - console.error('An error has occurred.'); - throw new Error(JSON.stringify(process.reason)); - } - }); -}; - -const TYPE_PROCESSORS = { - ubuntu: handleUbuntu, +/** + * Define handle types + */ +const PROCESSOR_TYPES = { + ubuntu: handlers.handleUbuntu, + helmfile: handlers.handleHelmfile, }; -const main = async () => { +/** + * Main function + */ +(async () => { try { processConfig(); } catch (err) { console.error('Config processor has failed!', err); process.exit(1); } - const processor = await Promise.allSettled([ - TYPE_PROCESSORS[CONFIG.TYPE](), - // TODO: option for supplying custom branch name and custom prefix - initializeRepo(`${CONFIG.BRANCH_PREFIX}/${CONFIG.BRANCH_NAME}`), - ]); - throwError(processor); - console.log(`Successfully executed processor ${CONFIG.TYPE}.`); - - const repository = processor[1].value; - const filePath = `${repository.path}/${CONFIG.FILE}`; - const keyValuePairs = processor[0].value; - - try { - const file = new File({ filePath, keyValuePairs }); - await file.run(); - } catch (err) { - console.error('Updating file has failed!', err); - process.exit(1); - } - console.log(`File "${filePath}" has been successfully updated.`); try { + const repository = await initializeRepo( + `${CONFIG.BRANCH_PREFIX}/${CONFIG.BRANCH_NAME}`, + ); + const fullFilePath = `${repository.path}/${CONFIG.FILE}`; + await PROCESSOR_TYPES[CONFIG.TYPE](fullFilePath); + console.log(`Successfully executed processor ${CONFIG.TYPE}.`); await repository.push(`update ${CONFIG.TYPE}`); await repository.createPullRequest(); + console.log('Pull-request created.'); } catch (err) { - console.error(`Opening pull request with changes has failed!`, err); + console.error(`${CONFIG.TYPE} processor failed.`, err); process.exit(1); } - console.log( - `Successfully pushed branch "${repository.branchName}" to remote.`, - ); -}; - -main(); +})(); diff --git a/src/file.js b/src/processors/file.js similarity index 57% rename from src/file.js rename to src/processors/file.js index eb6a20a..7178e03 100644 --- a/src/file.js +++ b/src/processors/file.js @@ -24,50 +24,26 @@ const FILE_TYPE_PROCESSORS = { */ class File { /** - * @param {string} file - file to be edited - * @param {object} keyValuePairs - key-value pairs to be edited + * @param {string} filePath - file to be edited */ - constructor({ - filePath, - keyValuePairs, - }) { + constructor(filePath) { this.filePath = null; - this.keyValuePairs = null; this.type = null; - this.init(filePath, keyValuePairs); + this.init(filePath); } /** * Initiates properties by parsing descriptor * @param {string} filePath - file to be edited - * @param {object} keyValuePairs - key-value pairs to be edited */ - init(filePath, keyValuePairs) { + init(filePath) { + if (!filePath) { + throw new Error('filePath cannot be empty!'); + } const fileSplit = filePath.split('.'); const fileType = fileSplit[fileSplit.length - 1]; this.filePath = filePath; this.type = FILE_TYPES[fileType.toLocaleLowerCase()]; - - if (typeof keyValuePairs !== 'object') { - throw new Error( - `"keyValuePairs" must be an object. Got ${keyValuePairs}`, - ); - } - // TODO: add validation for key value - this.keyValuePairs = keyValuePairs; - } - - - /** - * Run main logic - * @param {string} value - value of specified key will be changed - * TODO: implement multiple values for multiple keys - */ - async run() { - const data = await this.read(); - const editedData = this.edit(data); - await this.write(editedData); - // if changed write, else do nothing } /** @@ -90,11 +66,12 @@ class File { /** * Edit file data and return edited data - * @param {object} data - parsed file data + * @param {object} data - parsed file data + * @param {object} keyValuePairs - key-values pair that edits data * @return {object} */ - edit(data) { - for (const [key, value] of Object.entries(this.keyValuePairs)) { + edit(data, keyValuePairs) { + for (const [key, value] of Object.entries(keyValuePairs)) { _.set(data, key, value); } return data; diff --git a/src/file.test.js b/src/processors/file.test.js similarity index 73% rename from src/file.test.js rename to src/processors/file.test.js index a142f69..943c04a 100644 --- a/src/file.test.js +++ b/src/processors/file.test.js @@ -2,7 +2,6 @@ const { File } = require('./file'); const fs = require('fs').promises; const yaml = require('js-yaml'); - const filePathJson = './test_file.JSON'; const filePathYaml = './test_file.yaml'; const keyValuePairs = { @@ -17,8 +16,14 @@ const resultedFileData = { keyN: 'valueN', }; -const jsonFile = new File({ filePath: filePathJson, keyValuePairs }); -const yamlFile = new File({ filePath: filePathYaml, keyValuePairs }); +const jsonFile = new File(filePathJson); +const yamlFile = new File(filePathYaml); + +describe('File', () => { + test('throws an error if not properly initialized', () => { + expect(() => new File()).toThrow(); + }); +}); describe('JSON file', () => { beforeEach(async () => { @@ -45,13 +50,6 @@ describe('JSON file', () => { const data = JSON.parse(content); expect(data).toMatchObject(resultedFileData); }); - - test('is able to execute run() function', async () => { - await jsonFile.run(); - const content = await fs.readFile(filePathJson, 'utf8'); - const data = JSON.parse(content); - expect(data).toMatchObject(resultedFileData); - }); }); describe('YAML file', () => { @@ -79,18 +77,11 @@ describe('YAML file', () => { const data = yaml.safeLoad(content); expect(data).toMatchObject(resultedFileData); }); - - test('is able to execute run() function', async () => { - await yamlFile.run(); - const content = await fs.readFile(filePathYaml, 'utf8'); - const data = yaml.safeLoad(content); - expect(data).toMatchObject(resultedFileData); - }); }); describe('File', () => { test('can be edited', () => { - const editedData = jsonFile.edit(origFileData); + const editedData = jsonFile.edit(origFileData, keyValuePairs); expect(editedData).toMatchObject(resultedFileData); }); }); diff --git a/src/processors/helmfile.js b/src/processors/helmfile.js new file mode 100644 index 0000000..e7bda69 --- /dev/null +++ b/src/processors/helmfile.js @@ -0,0 +1,79 @@ +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +const HELMFILE_BIN_PATH = '/usr/local/bin/helmfile'; +const HELMFILE_DEPS_COMMAND = 'deps'; + +const GLOBAL_ARGUMENTS = { + file: '-f', + environment: '-e', +}; + +/** + * Process helmfile https://github.com/roboll/helmfile + */ +class Helmfile { + /** + * Runs helmfile deps command to update chart dependencies + * @param {object} globalArgs - global arguments + */ + constructor(globalArgs) { + this.helmfile = HELMFILE_BIN_PATH; + this.globalArgs = null; + this.init(globalArgs); + } + + /** + * Constructs global arguments of helmfile + * @param {array} args - arguments i.e [{key1: val1},.. {keyN: valN}] + */ + init(args) { + let argString = ''; + const globalArgsKeys = Object.keys(GLOBAL_ARGUMENTS); + const argKeys = Object.keys(args); + argKeys.forEach((argKey) => { + if (globalArgsKeys.includes(argKey)) { + const fullArg = `${GLOBAL_ARGUMENTS[argKey]} ${args[argKey]} `; + argString = argString.concat(fullArg); + } else { + console.log(`Helmfile: unknown global argument ${argKey}`); + } + }); + this.globalArgs = argString.trim(); + } + + /** + * Execute helmfile command with args + * @param {string} args - command argument + * @return {object} + */ + async execute(args) { + const command = `${this.helmfile} ${this.globalArgs} ${args}`; + let stdout; + let stderr; + try { + ({ stdout, stderr } = await exec(command)); + } catch (err) { + console.error('Helmfile execute exits with an error', err); + throw err; + }; + + return { stdout, stderr }; + } + + /** + * Runs helmfile deps command to update dependencies. + */ + async run() { + const { stdout, stderr } = await this.execute(HELMFILE_DEPS_COMMAND); + if (stderr) { + // eslint-disable-next-line max-len + console.log(`helmfile ${HELMFILE_DEPS_COMMAND} stderr:\n ${stderr}`); + } + console.log(`helmfile ${HELMFILE_DEPS_COMMAND} stdout:\n ${stdout}`); + } +} + +module.exports = { + Helmfile, +}; diff --git a/src/processors/helmfile.test.js b/src/processors/helmfile.test.js new file mode 100644 index 0000000..804a872 --- /dev/null +++ b/src/processors/helmfile.test.js @@ -0,0 +1,24 @@ +const { Helmfile } = require('./helmfile'); + +describe('Helmfile can', () => { + test('construct global arguments', () => { + const globalArgsInput = { + file: '/tmp/helmfile.yaml', + environment: 'default', + }; + const globalArgsOutput = '-f /tmp/helmfile.yaml -e default'; + const helmfile = new Helmfile(globalArgsInput); + expect(helmfile.globalArgs).toBe(globalArgsOutput); + }); + + test('construct only global arguments', () => { + const globalArgsInput = { + file: '/tmp/helmfile.yaml', + environment: 'default', + foo: 'bar', + }; + const globalArgsOutput = '-f /tmp/helmfile.yaml -e default'; + const helmfile = new Helmfile(globalArgsInput); + expect(helmfile.globalArgs).toBe(globalArgsOutput); + }); +}); diff --git a/src/ubuntu.js b/src/processors/ubuntu.js similarity index 66% rename from src/ubuntu.js rename to src/processors/ubuntu.js index e809b89..1dbbafd 100644 --- a/src/ubuntu.js +++ b/src/processors/ubuntu.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const { File } = require('./file'); const UBUNTU_IMAGE_FINDER_URL = 'https://cloud-images.ubuntu.com/locator/releasesTable?_=1598175887311'; const FIELD_MAPPINGS = { @@ -16,42 +17,30 @@ const LINK_REGEX = /(.*\")(.*)(\">)(.*)(<.*)/; /** * Linux ubuntu handler */ -class Ubuntu { +class Ubuntu extends File { /** * Represents ubuntu image - * @param {string} criteria - object with filter properties - * example: { - * cloud: 'Amazon AWS', - * zone: 'us-east-1', - * name: 'bionic', - * version: '18.04', - * architecture: 'amd64', - * instanceType: 'hvm-ssd', - * release: '20201204' - * } + * @param {object} criteria - object with filter properties. For example: + * { + * cloud: 'Amazon AWS', + * zone: 'us-east-1', + * name: 'bionic', + * version: '18.04', + * architecture: 'amd64', + * instanceType: 'hvm-ssd', + * release: '20201204' + * } + * @param {string} filePath - path to file + * @param {array} keys - string keys to be replaced */ - constructor(criteria) { + constructor(criteria, filePath, keys) { + super(filePath); this.criteria = criteria; + this.keys = keys; this.allImages = null; this.processedImages = null; } - /** - * Gets latest image. - */ - get latest() { - if (!this.processedImages) { - // Workaround since you cannot have async getter - return (async () => { - await this.run(); - })().then(() => { - return this._constructImageProperties(this.processedImages[0]); - }); - } else { - return this.processedImages[0]; - } - } - /** * Gets images from UBUNTU_IMAGE_FINDER_URL and makes them parsable */ @@ -78,6 +67,7 @@ class Ubuntu { ); }); + images = images.map(this._constructImageProperties); this.processedImages = images.sort( (a, b) => ( // eslint-disable-next-line max-len @@ -105,11 +95,19 @@ class Ubuntu { } /** - * Runs ubuntu image retrieval + * Runs, initializes and edits file */ async run() { await this.getImages(); this.filterImages(); + const latest = this.processedImages[0]; + const keyValuePairs = {}; + this.keys.forEach((key) => { + keyValuePairs[key] = latest.id; + }); + const data = await this.read(); + const editedData = this.edit(data, keyValuePairs); + await this.write(editedData); } } diff --git a/src/ubuntu.test.js b/src/processors/ubuntu.test.js similarity index 77% rename from src/ubuntu.test.js rename to src/processors/ubuntu.test.js index e51d806..5153658 100644 --- a/src/ubuntu.test.js +++ b/src/processors/ubuntu.test.js @@ -1,7 +1,11 @@ const axios = require('axios'); +const fs = require('fs'); const { Ubuntu } = require('./ubuntu'); jest.mock('axios'); +jest.mock('fs', () => ({ + promises: {}, +})); describe('Ubuntu can', () => { test('determine latest image', async () => { @@ -17,6 +21,8 @@ describe('Ubuntu can', () => { ["Amazon AWS", "sa-east-1", "focal", "20.04", "amd64", "hvm-ssd", "20190907", "ami-0f4c19e1a758f4efe"], ["Amazon AWS", "us-west-2", "focal", "18.04", "amd64", "hvm-ssd", "20200907", "ami-0f4c19e1a758f4eff"],]}`, }; + const fileContent = 'foo: ami-xxxxx'; + const filePath = 'foo.yaml'; const expected = { cloud: 'Amazon AWS', zone: 'sa-east-1', @@ -30,8 +36,13 @@ describe('Ubuntu can', () => { }; axios.get.mockResolvedValue(axiosResponse); - const ubuntu = new Ubuntu(criteria); - expect(await ubuntu.latest).toMatchObject(expected); + fs.promises.readFile = jest.fn().mockResolvedValue( + Buffer.from(fileContent), + ); + fs.promises.writeFile = jest.fn().mockImplementation(); + const ubuntu = new Ubuntu(criteria, filePath, ['foo']); + await ubuntu.run(); + expect(ubuntu.processedImages[0]).toMatchObject(expected); // TODO: add tests for other providers }); });