From 2903ca430d32ba59fc662ed2a646fcb2aac6e6a0 Mon Sep 17 00:00:00 2001 From: Felipe Lalanne Date: Wed, 15 Jan 2025 17:18:19 -0300 Subject: [PATCH 1/2] Remove unused sw.os-image contracts Change-type: patch --- contracts/sw.os-image/balenaos/contract.json | 6 ------ contracts/sw.os/balenaos/contract.json | 3 +-- contracts/sw.os/resinos/contract.json | 4 ---- 3 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 contracts/sw.os-image/balenaos/contract.json diff --git a/contracts/sw.os-image/balenaos/contract.json b/contracts/sw.os-image/balenaos/contract.json deleted file mode 100644 index 2e4287c2..00000000 --- a/contracts/sw.os-image/balenaos/contract.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "slug": "balena-image", - "name": "Balena OS raw image", - "type": "sw.os-image", - "version": "1" -} diff --git a/contracts/sw.os/balenaos/contract.json b/contracts/sw.os/balenaos/contract.json index 04709436..d427ea54 100644 --- a/contracts/sw.os/balenaos/contract.json +++ b/contracts/sw.os/balenaos/contract.json @@ -13,7 +13,6 @@ { "type": "arch.sw", "slug": "i386" }, { "type": "arch.sw", "slug": "amd64" } ] - }, - { "type": "sw.os-image", "slug": "balena-image" } + } ] } diff --git a/contracts/sw.os/resinos/contract.json b/contracts/sw.os/resinos/contract.json index 4e77dac1..ff5530fe 100644 --- a/contracts/sw.os/resinos/contract.json +++ b/contracts/sw.os/resinos/contract.json @@ -8,10 +8,6 @@ { "type": "arch.sw", "slug": "armv7hf" - }, - { - "type": "sw.os-image", - "slug": "resinos" } ] } From 072ab7219d03952ed3c42cbd78eb397afdac4e97 Mon Sep 17 00:00:00 2001 From: Felipe Lalanne Date: Fri, 13 Dec 2024 15:43:29 -0300 Subject: [PATCH 2/2] Improve testing of contracts - Migrates contracts to typescript - Parallelizes scanning of folders and loading contracts - Adds validation for internal consistency of the contract universe Change-type: patch --- .eslintrc.yml | 7 -- .husky/pre-commit | 1 + .lintstagedrc | 5 ++ .npmrc | 1 + .prettierrc.js | 8 ++ package.json | 24 ++++-- scripts/check-contracts.js | 34 -------- scripts/utils.js | 43 ---------- tests/chai.ts | 8 ++ tests/contracts.spec.ts | 162 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 18 +++++ 11 files changed, 220 insertions(+), 91 deletions(-) delete mode 100644 .eslintrc.yml create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc create mode 100644 .npmrc create mode 100644 .prettierrc.js delete mode 100644 scripts/check-contracts.js delete mode 100644 scripts/utils.js create mode 100644 tests/chai.ts create mode 100644 tests/contracts.spec.ts create mode 100644 tsconfig.json diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 85161694..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,7 +0,0 @@ -env: - commonjs: true - es6: true - node: true -extends: 'standard' -parserOptions: - sourceType: 'script' diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..718da8a9 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx --no lint-staged diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 00000000..56513fbc --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,5 @@ +{ + "*.ts": [ + "balena-lint --fix" + ], +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..29e5d9bb --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,8 @@ +const fs = require('fs'); + +module.exports = JSON.parse( + fs.readFileSync( + __dirname + '/node_modules/@balena/lint/config/.prettierrc', + 'utf8', + ), +); diff --git a/package.json b/package.json index 4d4ccd05..5f886521 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,27 @@ }, "private": true, "scripts": { - "test": "eslint scripts && node scripts/check-contracts.js" + "test": "npm run lint && npm run test:node", + "lint": "balena-lint tests", + "lint-fix": "balena-lint --fix tests", + "test:node": "mocha -r ts-node/register --reporter spec tests/**/*.spec.ts --timeout 30000" }, "author": "Balena Inc. ", "license": "Apache-2.0", "devDependencies": { - "eslint": "^4.8.0", - "eslint-config-standard": "^10.2.1", - "eslint-plugin-import": "^2.7.0", - "eslint-plugin-node": "^5.2.0", - "eslint-plugin-promise": "^3.5.0", - "eslint-plugin-standard": "^3.0.1" + "@balena/contrato": "^0.12.0", + "@balena/lint": "^9.1.3", + "@types/chai": "^4.3.20", + "@types/chai-as-promised": "^7.1.4", + "@types/mocha": "^10.0.10", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "husky": "^9.1.7", + "lint-staged": "^15.2.11", + "mocha": "^11.0.1", + "p-map": "^7.0.3", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" }, "versionist": { "publishedAt": "2025-01-28T17:12:51.985Z" diff --git a/scripts/check-contracts.js b/scripts/check-contracts.js deleted file mode 100644 index 6e648820..00000000 --- a/scripts/check-contracts.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2019 Balena - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const path = require('path') -const utils = require('./utils') -const CONTRACTS_PATH = path.join(__dirname, '..', 'contracts') -let success = true - -for (const contract of utils.readContracts(CONTRACTS_PATH)) { - if (contract.source.type !== contract.type) { - success = false - console.error(contract.path) - console.error(` The contract type is ${contract.source.type}, but it lives inside ${contract.type}`) - } -} - -if (!success) { - process.exit(1) -} diff --git a/scripts/utils.js b/scripts/utils.js deleted file mode 100644 index 6b02c04c..00000000 --- a/scripts/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2019 Balena - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const fs = require('fs') -const path = require('path') - -const getAllFiles = dir => - fs.readdirSync(dir).reduce((files, file) => { - const name = path.join(dir, file) - const isDirectory = fs.statSync(name).isDirectory() - return isDirectory ? [...files, ...getAllFiles(name)] : [...files, name] - }, []) - -exports.readContracts = (dir) => { - const allFiles = getAllFiles(dir) - let contracts = [] - - allFiles.forEach((file) => { - if (path.extname(file) === '.json') { - contracts.push({ - type: path.basename(path.dirname(path.dirname(file))), - source: require(file), - path: file - }) - } - }) - return contracts -} diff --git a/tests/chai.ts b/tests/chai.ts new file mode 100644 index 00000000..8ca61947 --- /dev/null +++ b/tests/chai.ts @@ -0,0 +1,8 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); + +export default chai; + +export const { expect } = chai; diff --git a/tests/contracts.spec.ts b/tests/contracts.spec.ts new file mode 100644 index 00000000..6bfda4f7 --- /dev/null +++ b/tests/contracts.spec.ts @@ -0,0 +1,162 @@ +import { expect } from './chai'; +import * as path from 'path'; +import { promises as fs } from 'fs'; +import type { ContractObject } from '@balena/contrato'; +import { Contract, Universe, query } from '@balena/contrato'; + +const CONTRACTS_PATH = path.join(__dirname, '..', 'contracts'); + +async function findFiles( + dir: string, + filter: (filePath: string) => boolean = () => true, +): Promise { + const allFiles = await fs.readdir(dir, { recursive: true }); + const filePaths: string[] = []; + for (const fileName of allFiles) { + const filePath = path.join(dir, fileName); + const stat = await fs.stat(filePath); + if (!stat.isDirectory() && filter(filePath)) { + filePaths.push(filePath); + } + } + + return filePaths; +} + +type ContractMeta = { + type: string; + source: ContractObject; + path: string; +}; + +async function readContracts(dir: string): Promise { + const allFiles = await findFiles( + dir, + (fileName) => path.extname(fileName) === '.json', + ); + + const { default: pMap } = await import('p-map'); + + return ( + await pMap( + allFiles.values(), + async (file) => { + const contents = await fs.readFile(file, { encoding: 'utf8' }); + const source = JSON.parse(contents); + return { + type: path.basename(path.dirname(path.dirname(file))), + source, + path: file, + }; + }, + + { concurrency: 10 }, + ) + ).flat(); +} + +describe('Balena Base Contracts', function () { + let allContractsMeta: ContractMeta[]; + + before(async () => { + allContractsMeta = await readContracts(CONTRACTS_PATH); + }); + + it('contracts are stored in the right folder', function () { + for (const contractMeta of allContractsMeta) { + expect( + contractMeta.source.type, + `the contract type '${contractMeta.source.type}' does not match its parent folder '${contractMeta.type}'`, + ).to.equal(contractMeta.type); + } + }); + + it('all children in the contract universe are satisfied', function () { + const allContracts = allContractsMeta.flatMap(({ source }) => + Contract.build(source), + ); + + const universe = new Universe(); + universe.addChildren(allContracts); + + // The contracts universe is internally consistent + // if all the children requirements are satisfied + expect(universe.getAllNotSatisfiedChildRequirements()).to.deep.equal([]); + }); + + it('os/arch/device contract combinations are satisfied', function () { + const allContracts = allContractsMeta.flatMap(({ source }) => + Contract.build(source), + ); + + const universe = new Universe(); + universe.addChildren(allContracts); + + const contexts = query( + universe, + { + 'sw.os': { + cardinality: 1, + filter: { + type: 'object', + properties: { slug: { not: { enum: ['balena-os', 'resinos'] } } }, + }, + }, + 'arch.sw': 1, + 'sw.blob': '1+', + 'sw.stack-variant': 1, + 'hw.device-type': 1, + }, + { type: 'meta.context' }, + ); + + for (const context of contexts) { + const unmet = context.getAllNotSatisfiedChildRequirements(); + expect( + unmet, + 'Unsatisfied requirements for context: ' + + JSON.stringify(context, null, 2), + ).to.deep.equal([]); + } + }); + + // Skipped as it takes too much time to calculate all combinations + // TODO: unskip this once we can improve the efficiency + // of contrato + it.skip('os/arch/device/stack contract combinations are satisfied', function () { + const allContracts = allContractsMeta.flatMap(({ source }) => + Contract.build(source), + ); + + const universe = new Universe(); + universe.addChildren(allContracts); + + const contexts = query( + universe, + { + 'sw.os': { + cardinality: 1, + filter: { + type: 'object', + properties: { slug: { not: { enum: ['balena-os', 'resinos'] } } }, + }, + }, + 'arch.sw': 1, + 'sw.blob': '1+', + 'sw.stack': 1, + 'sw.stack-variant': 1, + 'hw.device-type': 1, + }, + { type: 'meta.context' }, + ); + + for (const context of contexts) { + const unmet = context.getAllNotSatisfiedChildRequirements(); + expect( + unmet, + 'Unsatisfied requirements for context: ' + + JSON.stringify(context, null, 2), + ).to.deep.equal([]); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..fe59f434 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "Node16", + "moduleResolution": "node16", + "outDir": "build", + "noUnusedParameters": true, + "noUnusedLocals": true, + "sourceMap": true, + "strict": true, + "target": "es2022", + "declaration": true, + "skipLibCheck": true + }, + "include": [ + "lib/**/*.ts", + "tests/**/*.ts" + ] +}