├── .eslintignore ├── bin ├── run.cmd └── run ├── .envfile ├── .yarnrc.yml ├── src ├── examples │ ├── .envfile │ ├── visit.js │ ├── exampleOfScenario.js │ ├── envvar.config.envfile.yml │ ├── marmelab.en.js │ ├── marmelab.fr.js │ ├── greenframe2.js │ ├── envvar.config.isolated.yml │ ├── moviedb.js │ ├── envvar.config.envfile.js │ ├── envvar.config.isolated.js │ ├── envvar.inline.envfile.js │ ├── envvar.inline.isolated.js │ ├── commands │ ├── greenframeFail.js │ ├── greenframe.js │ ├── lavolpiliere.js │ ├── laneuvelotte.js │ ├── nextWebsite.js │ ├── playstation.js │ └── ra-demo.js ├── index.ts ├── status.ts ├── services │ ├── api │ │ ├── projects.js │ │ ├── instance.ts │ │ ├── scenarios.ts │ │ └── analyses.ts │ ├── errors │ │ ├── errorCodes.js │ │ ├── ScenarioError.js │ │ ├── ConfigurationError.js │ │ └── Sentry.js │ ├── docker │ │ └── index.js │ ├── __tests__ │ │ ├── readFileToString.js │ │ └── parseConfigFile.js │ ├── readFileToString.js │ ├── container │ │ ├── kubernetes │ │ │ ├── pods.ts │ │ │ ├── getContainerStats.ts │ │ │ ├── client.ts │ │ │ ├── cadvisor.ts │ │ │ ├── mergePodStatsWithNetworkStats.ts │ │ │ ├── stats.d.ts │ │ │ └── structureNodes.ts │ │ ├── getPodsStats.ts │ │ ├── __tests__ │ │ │ └── getContainerStats.ts │ │ ├── getContainerStats.js │ │ ├── execScenarioContainer.js │ │ └── index.ts │ ├── detectExecutablePath.ts │ ├── git │ │ ├── index.js │ │ ├── utils.js │ │ └── __tests__ │ │ │ ├── index.js │ │ │ └── utils.js │ ├── computeAnalysisResult.ts │ ├── computeScenarioResult.ts │ └── parseConfigFile.js ├── tasks │ ├── initializeKubeClient.ts │ ├── executeDistantAnalysis.js │ ├── detectDockerVersion.js │ ├── checkGreenFrameSecretToken.ts │ ├── retrieveGitInformations.ts │ ├── detectKubernetesVersion.ts │ ├── retrieveGreenFrameProject.js │ ├── deleteKubeGreenframeNamespace.ts │ ├── createNewAnalysis.js │ ├── addKubeGreenframeNamespace.ts │ ├── runScenariosAndSaveResult.ts │ ├── displayAnalysisResult.js │ └── addKubeGreenframeDaemonset.ts ├── model │ ├── index.ts │ ├── stat-tools │ │ ├── __tests__ │ │ │ ├── getConsumption.ts │ │ │ └── getAverageMilestones.ts │ │ ├── docker │ │ │ ├── readStats.ts │ │ │ ├── __tests__ │ │ │ │ └── computeStats.test.ts │ │ │ └── computeStats.ts │ │ ├── getAverageMilestones.ts │ │ ├── intervals.ts │ │ ├── mergeScore.ts │ │ ├── providers │ │ │ ├── kubernetes.ts │ │ │ └── docker.ts │ │ └── getAverageStats.ts │ └── stores │ │ ├── timeframeStore.ts │ │ ├── __tests__ │ │ ├── timeframeStore.test.ts │ │ └── statStore.test.ts │ │ └── statStore.ts ├── constants.ts ├── bash │ └── getHostIP.sh ├── global.d.ts ├── runner │ ├── index.js │ ├── scenarioWrapper.js │ └── scopedPage.ts └── commands │ ├── update.js │ ├── kube-config.ts │ └── open.js ├── .prettierrc ├── e2e ├── .greenframe.single.adblock.yml ├── .greenframe.single.en.yml ├── .greenframe.single.fr.yml ├── .greenframe.fullstack.emptyScenario.yml ├── .greenframe.fullstack.yml ├── .greenframe.fullstack.k8s.yml ├── .greenframe.fullstack.broken.yml ├── .greenframe.single.multiple.distant.yml ├── .greenframe.fullstack.multiple.yml ├── local │ ├── open.spec.js │ └── analyze.spec.js └── greenframe.io │ └── open.spec.js ├── jest.config.js ├── .gitignore ├── .github └── workflows │ ├── yarn-changes.yml │ └── deploy.yml ├── scripts ├── uploadInstallScript.js └── ressources │ ├── install-frpc.sh │ └── install.sh ├── .eslintrc ├── Makefile ├── LICENSE.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .github/ 3 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /.envfile: -------------------------------------------------------------------------------- 1 | GREENFRAME_MY_VAR_ONE=envfile_value_one 2 | GREENFRAME_MY_VAR_TWO=envfile_value_two -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.2.3.cjs 4 | -------------------------------------------------------------------------------- /src/examples/.envfile: -------------------------------------------------------------------------------- 1 | GREENFRAME_MY_VAR_ONE=envfile_value_one 2 | GREENFRAME_MY_VAR_TWO=envfile_value_two -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from '@oclif/core'; 2 | 3 | export * from './model/index'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "printWidth": 90 6 | } 7 | -------------------------------------------------------------------------------- /src/status.ts: -------------------------------------------------------------------------------- 1 | export const STATUS = { 2 | INITIAL: 'initial' as const, 3 | FINISHED: 'finished' as const, 4 | FAILED: 'failed' as const, 5 | }; 6 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core'); 4 | 5 | oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')); 6 | -------------------------------------------------------------------------------- /e2e/.greenframe.single.adblock.yml: -------------------------------------------------------------------------------- 1 | scenario: '../../src/examples/greenframe.js' 2 | baseURL: 'https://greenframe.io' 3 | samples: 2 4 | projectName: 'GreenFrame' 5 | useAdblock: true 6 | -------------------------------------------------------------------------------- /e2e/.greenframe.single.en.yml: -------------------------------------------------------------------------------- 1 | scenario: '../../src/examples/marmelab.en.js' 2 | baseURL: 'https://marmelab.com' 3 | samples: 2 4 | projectName: 'Marmelab' 5 | useAdblock: true 6 | locale: en-US 7 | -------------------------------------------------------------------------------- /e2e/.greenframe.single.fr.yml: -------------------------------------------------------------------------------- 1 | scenario: '../../src/examples/marmelab.fr.js' 2 | baseURL: 'https://marmelab.com' 3 | samples: 2 4 | projectName: 'Marmelab' 5 | useAdblock: true 6 | locale: fr-FR 7 | -------------------------------------------------------------------------------- /src/examples/visit.js: -------------------------------------------------------------------------------- 1 | const visit = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.scrollToEnd(); 6 | }; 7 | 8 | module.exports = visit; 9 | -------------------------------------------------------------------------------- /src/services/api/projects.js: -------------------------------------------------------------------------------- 1 | const instance = require('./instance'); 2 | 3 | const getProject = (name) => { 4 | return instance.get(`/projects/${name}`); 5 | }; 6 | 7 | module.exports = { getProject }; 8 | -------------------------------------------------------------------------------- /e2e/.greenframe.fullstack.emptyScenario.yml: -------------------------------------------------------------------------------- 1 | baseURL: 'https://greenframe.io' 2 | samples: 2 3 | projectName: 'GreenFrame' 4 | containers: 5 | - 'enterprise_api' 6 | databaseContainers: 7 | - 'enterprise_db' 8 | -------------------------------------------------------------------------------- /src/examples/exampleOfScenario.js: -------------------------------------------------------------------------------- 1 | const example = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.scrollToElement('footer'); 6 | }; 7 | 8 | module.exports = example; 9 | -------------------------------------------------------------------------------- /e2e/.greenframe.fullstack.yml: -------------------------------------------------------------------------------- 1 | scenario: '../src/examples/greenframe.js' 2 | baseURL: 'https://greenframe.io' 3 | samples: 2 4 | projectName: 'GreenFrame' 5 | containers: 6 | - 'enterprise_api' 7 | databaseContainers: 8 | - 'enterprise_db' 9 | -------------------------------------------------------------------------------- /e2e/.greenframe.fullstack.k8s.yml: -------------------------------------------------------------------------------- 1 | scenario: '../src/examples/greenframe.js' 2 | baseURL: 'https://greenframe.io' 3 | samples: 2 4 | projectName: 'GreenFrame' 5 | kubeContainers: 6 | - 'default:app=api' 7 | kubeDatabaseContainers: 8 | - 'default:app=db' 9 | -------------------------------------------------------------------------------- /src/examples/envvar.config.envfile.yml: -------------------------------------------------------------------------------- 1 | scenarios: 2 | - path: '../../src/examples/envvar.config.envfile.js' 3 | name: 'Visit' 4 | threshold: 0.03 5 | baseURL: 'https://www.google.fr' 6 | envFile: './src/examples/.envfile' 7 | projectName: 'test_visit' 8 | -------------------------------------------------------------------------------- /src/examples/marmelab.en.js: -------------------------------------------------------------------------------- 1 | const marmelabEN = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.scrollToElement("text=LET'S WORK TOGETHER ON YOUR NEXT PROJECT!"); 6 | }; 7 | 8 | module.exports = marmelabEN; 9 | -------------------------------------------------------------------------------- /src/examples/marmelab.fr.js: -------------------------------------------------------------------------------- 1 | const marmelabFR = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.scrollToElement('text=TRAVAILLONS ENSEMBLE SUR VOTRE PROCHAIN PROJET !'); 6 | }; 7 | 8 | module.exports = marmelabFR; 9 | -------------------------------------------------------------------------------- /src/services/errors/errorCodes.js: -------------------------------------------------------------------------------- 1 | const ERROR_CODES = { 2 | SCENARIO_FAILED: 'SCENARIO_FAILED', 3 | CONFIGURATION_ERROR: 'CONFIGURATION_ERROR', 4 | THRESHOLD_EXCEEDED: 'THRESHOLD_EXCEEDED', 5 | UNKNOWN_ERROR: 'UNKNOWN_ERROR', 6 | }; 7 | 8 | module.exports = ERROR_CODES; 9 | -------------------------------------------------------------------------------- /e2e/.greenframe.fullstack.broken.yml: -------------------------------------------------------------------------------- 1 | scenario: '../../src/examples/greenframe.js' 2 | baseURL: 'https://greenframe.io' 3 | samples: 2 4 | projectName: 'GreenFrame' 5 | containers: 6 | - 'enterprise_api' 7 | - 'container_broken' 8 | databaseContainers: 9 | - 'enterprise_db' 10 | -------------------------------------------------------------------------------- /src/examples/greenframe2.js: -------------------------------------------------------------------------------- 1 | const greenframe2 = async (page) => { 2 | await page.addMilestone('Go onepage'); 3 | await page.goto('/onepage', { 4 | waitUntil: 'networkidle', 5 | }); 6 | await page.waitForTimeout(2000); 7 | }; 8 | 9 | module.exports = greenframe2; 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: [ 5 | '**/e2e/*.js', 6 | '**/e2e/*.ts', 7 | '**/__tests__/*.js', 8 | '**/__tests__/*.ts', 9 | '**/?(*.)+(spec|test).+(ts|js)', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /src/tasks/initializeKubeClient.ts: -------------------------------------------------------------------------------- 1 | import { initKubeConfig } from '../services/container/kubernetes/client'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export default async (ctx: any) => { 5 | const { flags } = ctx; 6 | await initKubeConfig(flags.kubeConfig); 7 | }; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dump 3 | dist/ 4 | .env 5 | tmp 6 | ngrok.log 7 | ngrok.yml 8 | package-lock.json 9 | **/.vscode 10 | .envrc 11 | .env 12 | .greenframe 13 | 14 | .pnp.* 15 | .yarn/* 16 | **/.yarn/* 17 | !.yarn/patches 18 | !.yarn/plugins 19 | !.yarn/releases 20 | !.yarn/sdks 21 | !.yarn/versions 22 | -------------------------------------------------------------------------------- /src/services/docker/index.js: -------------------------------------------------------------------------------- 1 | const util = require('node:util'); 2 | const exec = util.promisify(require('node:child_process').exec); 3 | 4 | async function getDockerVersion() { 5 | const { stdout } = await exec('docker -v'); 6 | return stdout.trim(); 7 | } 8 | 9 | module.exports = { getDockerVersion }; 10 | -------------------------------------------------------------------------------- /src/examples/envvar.config.isolated.yml: -------------------------------------------------------------------------------- 1 | scenarios: 2 | - path: '../../src/examples/envvar.config.isolated.js' 3 | name: 'Visit' 4 | threshold: 0.03 5 | baseURL: 'https://www.google.fr' 6 | envVar: 7 | - GREENFRAME_MY_VAR_ONE 8 | - GREENFRAME_MY_VAR_TWO=${GREENFRAME_MY_VAR_TWO} 9 | projectName: 'test_visit' 10 | -------------------------------------------------------------------------------- /e2e/.greenframe.single.multiple.distant.yml: -------------------------------------------------------------------------------- 1 | scenarios: 2 | - path: '../src/examples/greenframe.js' 3 | name: 'Scenario 1' 4 | threshold: 0.1 5 | - path: '../src/examples/greenframe2.js' 6 | name: 'Scenario 2' 7 | threshold: 0.05 8 | baseURL: 'https://greenframe.io' 9 | samples: 2 10 | projectName: 'GreenFrame' 11 | -------------------------------------------------------------------------------- /src/examples/moviedb.js: -------------------------------------------------------------------------------- 1 | const moviedb = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.waitForTimeout(5000); 6 | await page.scrollToElement('text="Luca"'); 7 | await page.click('text="Luca"'); 8 | await page.waitForTimeout(3000); 9 | }; 10 | 11 | module.exports = moviedb; 12 | -------------------------------------------------------------------------------- /src/services/errors/ScenarioError.js: -------------------------------------------------------------------------------- 1 | const ERROR_CODES = require('./errorCodes'); 2 | 3 | class ScenarioError extends Error { 4 | constructor(message) { 5 | super(message); // (1) 6 | this.name = 'ScenarioError'; // (2) 7 | this.errorCode = ERROR_CODES.SCENARIO_FAILED; 8 | } 9 | } 10 | 11 | module.exports = ScenarioError; 12 | -------------------------------------------------------------------------------- /src/services/errors/ConfigurationError.js: -------------------------------------------------------------------------------- 1 | const ERROR_CODES = require('./errorCodes'); 2 | 3 | class ConfigurationError extends Error { 4 | constructor(message) { 5 | super(message); // (1) 6 | this.name = 'ConfigurationError'; // (2) 7 | this.errorCode = ERROR_CODES.CONFIGURATION_ERROR; 8 | } 9 | } 10 | 11 | module.exports = ConfigurationError; 12 | -------------------------------------------------------------------------------- /src/services/api/instance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | const baseURL = process.env.API_URL ? process.env.API_URL : 'https://api.greenframe.io'; 3 | const apiToken = process.env.GREENFRAME_SECRET_TOKEN; 4 | 5 | const instance = axios.create({ 6 | baseURL, 7 | headers: { 'Content-Type': 'application/json', authorization: `Bearer ${apiToken}` }, 8 | }); 9 | 10 | module.exports = instance; 11 | export default instance; 12 | -------------------------------------------------------------------------------- /e2e/.greenframe.fullstack.multiple.yml: -------------------------------------------------------------------------------- 1 | scenarios: 2 | - path: '../src/examples/greenframe.js' 3 | name: 'Scenario 1' 4 | threshold: 0.1 5 | - path: '../src/examples/greenframe2.js' 6 | name: 'Scenario 2' 7 | threshold: 0.05 8 | baseURL: 'https://greenframe.io' 9 | samples: 2 10 | projectName: 'GreenFrame' 11 | containers: 12 | - 'enterprise_api' 13 | databaseContainers: 14 | - 'enterprise_db' 15 | -------------------------------------------------------------------------------- /src/tasks/executeDistantAnalysis.js: -------------------------------------------------------------------------------- 1 | const { checkAnalysis } = require('../services/api/analyses'); 2 | 3 | const { findAllScenariosByAnalysisId } = require('../services/api/scenarios'); 4 | 5 | module.exports = async (ctx) => { 6 | const { analysisId } = ctx; 7 | const analysis = await checkAnalysis(analysisId); 8 | const { data: scenarios } = await findAllScenariosByAnalysisId(analysisId); 9 | ctx.result = { analysis, scenarios }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/examples/envvar.config.envfile.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('@playwright/test'); 2 | const myVar1 = process.env.GREENFRAME_MY_VAR_ONE; 3 | const myVar2 = process.env.GREENFRAME_MY_VAR_TWO; 4 | 5 | const visit = async (page) => { 6 | expect(myVar1).toBe('envfile_value_one'); 7 | expect(myVar2).toBe('envfile_value_two'); 8 | await page.goto('', { 9 | waitUntil: 'networkidle', 10 | }); 11 | await page.scrollToEnd(); 12 | }; 13 | 14 | module.exports = visit; 15 | -------------------------------------------------------------------------------- /src/examples/envvar.config.isolated.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('@playwright/test'); 2 | const myVar1 = process.env.GREENFRAME_MY_VAR_ONE; 3 | const myVar2 = process.env.GREENFRAME_MY_VAR_TWO; 4 | 5 | const visit = async (page) => { 6 | expect(myVar1).toBe('inline_value_one'); 7 | expect(myVar2).toBe('defined_value_two'); 8 | await page.goto('', { 9 | waitUntil: 'networkidle', 10 | }); 11 | await page.scrollToEnd(); 12 | }; 13 | 14 | module.exports = visit; 15 | -------------------------------------------------------------------------------- /src/examples/envvar.inline.envfile.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('@playwright/test'); 2 | const myVar1 = process.env.GREENFRAME_MY_VAR_ONE; 3 | const myVar2 = process.env.GREENFRAME_MY_VAR_TWO; 4 | 5 | const visit = async (page) => { 6 | expect(myVar1).toBe('envfile_value_one'); 7 | expect(myVar2).toBe('envfile_value_two'); 8 | await page.goto('', { 9 | waitUntil: 'networkidle', 10 | }); 11 | await page.scrollToEnd(); 12 | }; 13 | 14 | module.exports = visit; 15 | -------------------------------------------------------------------------------- /src/examples/envvar.inline.isolated.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('@playwright/test'); 2 | const myVar1 = process.env.GREENFRAME_MY_VAR_ONE; 3 | const myVar2 = process.env.GREENFRAME_MY_VAR_TWO; 4 | 5 | const visit = async (page) => { 6 | expect(myVar1).toBe('inline_value_one'); 7 | expect(myVar2).toBe('defined_value_two'); 8 | await page.goto('', { 9 | waitUntil: 'networkidle', 10 | }); 11 | await page.scrollToEnd(); 12 | }; 13 | 14 | module.exports = visit; 15 | -------------------------------------------------------------------------------- /src/examples/commands: -------------------------------------------------------------------------------- 1 | GREENFRAME_MY_VAR_ONE=inline_value_one greenframe analyze https://www.google.fr ../../src/examples/envvar.inline.isolated.js -e GREENFRAME_MY_VAR_ONE -e GREENFRAME_MY_VAR_TWO=${GREENFRAME_MY_VAR_TWO} 2 | 3 | greenframe analyze https://www.google.fr ../../src/examples/envvar.inline.envfile.js -E ./src/examples/.envfile 4 | 5 | GREENFRAME_MY_VAR_ONE=inline_value_one greenframe analyze -C ./src/examples/envvar.config.isolated.yml 6 | 7 | greenframe analyze -C ./src/examples/envvar.config.envfile.yml -------------------------------------------------------------------------------- /src/tasks/detectDockerVersion.js: -------------------------------------------------------------------------------- 1 | const { getDockerVersion } = require('../services/docker'); 2 | const ConfigurationError = require('../services/errors/ConfigurationError'); 3 | 4 | module.exports = async (_, task) => { 5 | try { 6 | task.title = await getDockerVersion(); 7 | } catch { 8 | throw new ConfigurationError( 9 | 'Docker is not installed or is not accessible on your machine. Check https://docs.greenframe.io for more informations.' 10 | ); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/services/errors/Sentry.js: -------------------------------------------------------------------------------- 1 | const Sentry = require('@sentry/node'); 2 | 3 | Sentry.init({ 4 | dsn: 'https://ef45583ebb964bc485f37ef92d01609f@o956285.ingest.sentry.io/5905652', 5 | 6 | // Set tracesSampleRate to 1.0 to capture 100% 7 | // of transactions for performance monitoring. 8 | // We recommend adjusting this value in production 9 | tracesSampleRate: 1, 10 | }); 11 | 12 | const logErrorOnSentry = (e) => { 13 | Sentry.captureException(e); 14 | }; 15 | 16 | module.exports = logErrorOnSentry; 17 | -------------------------------------------------------------------------------- /src/tasks/checkGreenFrameSecretToken.ts: -------------------------------------------------------------------------------- 1 | import ConfigurationError from '../services/errors/ConfigurationError'; 2 | 3 | export default async () => { 4 | if (!process.env.GREENFRAME_SECRET_TOKEN) { 5 | throw new ConfigurationError( 6 | 'You must provide a variable named "GREENFRAME_SECRET_TOKEN" to authenticate yourself to the API.\nIf you do not have a token, You can run a free analysis with the --free argument.\n\nMore information can be found here: https://docs.greenframe.io/commands#--free-option' 7 | ); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /src/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from './stores/statStore'; 2 | export * from './stores/timeframeStore'; 3 | export * from './stat-tools/getWh'; 4 | export * from './stat-tools/docker/computeStats'; 5 | export * from './stat-tools/docker/readStats'; 6 | export * from './stat-tools/intervals'; 7 | export * from './stat-tools/providers/docker'; 8 | export * from './stat-tools/providers/kubernetes'; 9 | export * from './stat-tools/getConsumption'; 10 | export * from './stat-tools/getAverageStats'; 11 | export * from './stat-tools/getAverageMilestones'; 12 | export * from './stat-tools/mergeScore'; 13 | -------------------------------------------------------------------------------- /src/model/stat-tools/__tests__/getConsumption.ts: -------------------------------------------------------------------------------- 1 | import { getStandardError } from '../getConsumption'; 2 | 3 | describe('getStandardError', () => { 4 | it('Should return good standard error in percentage (~5)', () => { 5 | const values = [12, 14, 14, 16]; 6 | const result = getStandardError(values, 14); 7 | expect(result).toBe(5.832_118_435_198_042); 8 | }); 9 | 10 | it('Should return good standard error in percentage (0)', () => { 11 | const values = [14, 14, 14]; 12 | const result = getStandardError(values, 14); 13 | expect(result).toBe(0); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/examples/greenframeFail.js: -------------------------------------------------------------------------------- 1 | const greenframeFail = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.addMilestone('Go Solutions'); 6 | await Promise.all([ 7 | page.waitForNavigation({ 8 | /* url: 'https://greenframe.io/', */ waitUntil: 'networkidle', 9 | }), 10 | page.scrollToElement('text=Tit now'), 11 | page.click('text=Tit now'), 12 | ]); 13 | await page.addMilestone('Go Back home'); 14 | 15 | await page.goto('', { 16 | waitUntil: 'networkidle', 17 | }); 18 | }; 19 | 20 | module.exports = greenframeFail; 21 | -------------------------------------------------------------------------------- /src/examples/greenframe.js: -------------------------------------------------------------------------------- 1 | const greenframe = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | await page.addMilestone('Go Solutions'); 6 | await Promise.all([ 7 | page.waitForNavigation({ 8 | /* url: 'https://greenframe.io/', */ waitUntil: 'networkidle', 9 | }), 10 | page.scrollToElement('text=Try it for free'), 11 | page.click('text=Try it for free'), 12 | ]); 13 | await page.addMilestone('Go Back home'); 14 | 15 | await page.goto('', { 16 | waitUntil: 'networkidle', 17 | }); 18 | }; 19 | 20 | module.exports = greenframe; 21 | -------------------------------------------------------------------------------- /src/tasks/retrieveGitInformations.ts: -------------------------------------------------------------------------------- 1 | import { retrieveGitInformations } from '../services/git'; 2 | 3 | export default async (ctx: any, task: any) => { 4 | try { 5 | const { flags } = ctx; 6 | ctx.gitInfos = await retrieveGitInformations( 7 | { 8 | commitMessage: flags.commitMessage, 9 | branchName: flags.branchName, 10 | commitId: flags.commitId, 11 | }, 12 | ctx.project?.defaultBranch 13 | ); 14 | } catch { 15 | task.title = 'The folder is not a git repository'; 16 | ctx.gitInfos = { 17 | commitMessage: `Analysis for ${ctx.args.baseURL}`, 18 | }; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/tasks/detectKubernetesVersion.ts: -------------------------------------------------------------------------------- 1 | import { TaskWrapper } from 'listr2/dist/lib/task-wrapper'; 2 | import { getKubernetesVersion } from '../services/container/kubernetes/client'; 3 | 4 | import ConfigurationError from '../services/errors/ConfigurationError'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export default async function (_: any, task: TaskWrapper) { 8 | try { 9 | task.title = await getKubernetesVersion(); 10 | } catch (error) { 11 | console.error(error); 12 | throw new ConfigurationError( 13 | 'Kubernetes is not installed or is not accessible on your machine. Check https://docs.greenframe.io for more informations.' 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SAMPLES = 3; 2 | export const CONTAINER_DEVICE_NAME = 'greenframe-runner'; 3 | export const GREENFRAME_NAMESPACE = 'greenframe'; 4 | 5 | export const CONTAINER_TYPES = { 6 | DEVICE: 'DEVICE' as const, 7 | SERVER: 'SERVER' as const, 8 | DATABASE: 'DATABASE' as const, 9 | NETWORK: 'NETWORK' as const, 10 | }; 11 | 12 | export const SCENARIO_STATUS = { 13 | INITIAL: 'initial' as const, 14 | FINISHED: 'finished' as const, 15 | FAILED: 'failed' as const, 16 | }; 17 | 18 | export const ERROR_CODES = { 19 | SCENARIO_FAILED: 'SCENARIO_FAILED' as const, 20 | CONFIGURATION_ERROR: 'CONFIGURATION_ERROR' as const, 21 | THRESHOLD_EXCEEDED: 'THRESHOLD_EXCEEDED' as const, 22 | UNKNOWN_ERROR: 'UNKNOWN_ERROR' as const, 23 | }; 24 | -------------------------------------------------------------------------------- /src/bash/getHostIP.sh: -------------------------------------------------------------------------------- 1 | # Script to get host ip address in order to be able to map localhost to our docker container 2 | # Our playwright container should be able to query localhost network by using this IP. 3 | 4 | if command -v ip 1> /dev/null; 5 | then 6 | if ip -4 addr show docker0 > /dev/null 2>&1 # Docker0 is network for docker under linux and OSX 7 | then 8 | printf $(ip -4 addr show docker0 | grep -oE '[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}' | head -1) 9 | else 10 | printf $(ip -4 addr show eth0 | grep -oE '[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}' | head -1) # eth0 is network for docker under wsl 11 | fi 12 | else 13 | printf $(ifconfig | grep 'inet ' | grep -v 127.0.0.1 | awk '{ print $2 }' | head -1 | sed -n 's/[^0-9]*\([0-9\.]*\)/\1/p') 14 | fi 15 | -------------------------------------------------------------------------------- /.github/workflows/yarn-changes.yml: -------------------------------------------------------------------------------- 1 | name: 📑 Yarn Lock Changes 2 | on: 3 | pull_request: {} 4 | permissions: 5 | actions: write 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | yarn-changes: 11 | name: 📑 Yarn Lock Changes 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 🛑 Cancel Previous Runs 15 | uses: styfle/cancel-workflow-action@0.9.1 16 | 17 | - name: ⬇️ Checkout repo 18 | uses: actions/checkout@v3 19 | 20 | - name: 📑 Yarn Lock Changes 21 | uses: Simek/yarn-lock-changes@main 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | collapsibleThreshold: 25 25 | failOnDowngrade: false 26 | path: yarn.lock 27 | updateComment: true 28 | -------------------------------------------------------------------------------- /src/services/__tests__/readFileToString.js: -------------------------------------------------------------------------------- 1 | jest.mock('node:fs', () => { 2 | return { 3 | readFile: jest.fn().mockReturnValue('content file'), 4 | }; 5 | }); 6 | 7 | const { readFile } = require('node:fs'); 8 | jest.mock('node:util', () => ({ 9 | promisify: (cb) => cb, 10 | })); 11 | const path = require('node:path'); 12 | const cwd = process.cwd(); 13 | const { readFileToString } = require('../readFileToString'); 14 | 15 | describe('#readFileToString', () => { 16 | test('Should call readFile with correctly resolved scenario path', () => { 17 | readFileToString('../fakeFolder/.greenframe.yml', 'scenarioFolder/scenario.js'); 18 | expect(readFile).toHaveBeenCalledTimes(1); 19 | expect(readFile).toHaveBeenCalledWith( 20 | path.resolve(cwd, '../fakeFolder/scenarioFolder/scenario.js') 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/services/readFileToString.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const util = require('node:util'); 3 | const readFile = util.promisify(fs.readFile); 4 | const path = require('node:path'); 5 | 6 | const readFileToString = async (configFilePath, scenarioPath) => { 7 | const configFileFolder = path.dirname(configFilePath); 8 | 9 | // Resolve path regarding where you launch the command and where the config file is located. 10 | const resolvedScenarioPath = path.resolve( 11 | process.cwd(), // Where the command is launched 12 | configFileFolder, // Relative path to config file 13 | scenarioPath // Relative path of scenario regarding to config file 14 | ); 15 | const scenarioBuffered = await readFile(resolvedScenarioPath); 16 | return Buffer.from(scenarioBuffered).toString(); 17 | }; 18 | 19 | module.exports = { 20 | readFileToString, 21 | }; 22 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/pods.ts: -------------------------------------------------------------------------------- 1 | import * as kube from '@kubernetes/client-node'; 2 | import { kubeApi } from './client'; 3 | 4 | export const getPodsByLabel = async (label: string, namespace = 'default') => { 5 | const response = await kubeApi.listNamespacedPod( 6 | namespace, 7 | undefined, 8 | false, 9 | undefined, 10 | undefined, 11 | label 12 | ); 13 | return response.body.items; 14 | }; 15 | 16 | export const getNodeName = (pod: kube.V1Pod): string => { 17 | const node = pod.spec?.nodeName; 18 | if (!node) { 19 | throw new Error('Cannot find node for pod'); 20 | } 21 | 22 | return node; 23 | }; 24 | 25 | export const getPodName = (pod: kube.V1Pod): string => { 26 | const name = pod.metadata?.name; 27 | if (!name) { 28 | throw new Error('Cannot find name for pod'); 29 | } 30 | 31 | return name; 32 | }; 33 | -------------------------------------------------------------------------------- /src/model/stat-tools/docker/readStats.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import type { 3 | DockerStatsJSON, 4 | Meta, 5 | IntervalJSON, 6 | ComputedStatWithMeta, 7 | } from '../../../types'; 8 | 9 | import { docker } from '../providers/docker'; 10 | import { computeStats } from './computeStats'; 11 | 12 | export const readDockerStats = (filename: string, meta: Meta): ComputedStatWithMeta[] => { 13 | const rawdata = fs.readFileSync(filename, 'utf8'); 14 | const { stats, intervals }: { stats: DockerStatsJSON[]; intervals: IntervalJSON[] } = 15 | JSON.parse(rawdata); 16 | const timeframes = intervals.map(({ started, ended, title }) => ({ 17 | start: new Date(started), 18 | end: new Date(ended), 19 | title, 20 | })); 21 | const computedStats = computeStats({ 22 | stats: docker.computeGenericStats(stats), 23 | timeframes, 24 | meta, 25 | }); 26 | return computedStats; 27 | }; 28 | -------------------------------------------------------------------------------- /src/services/api/scenarios.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import instance from './instance'; 3 | 4 | const debug = initDebug('greenframe:services:api:scenarios'); 5 | 6 | export const createScenario = async ({ 7 | analysisId, 8 | name, 9 | threshold, 10 | allContainers, 11 | allMilestones, 12 | errorCode, 13 | errorMessage, 14 | }: any) => { 15 | debug(`Post scenario to api \n`, { 16 | name, 17 | threshold, 18 | statsSize: allContainers.length, 19 | errorCode, 20 | errorMessage, 21 | }); 22 | return instance.post(`/analyses/${analysisId}/scenarios`, { 23 | name, 24 | threshold, 25 | allContainersStats: allContainers, 26 | milestones: allMilestones, 27 | errorCode, 28 | errorMessage, 29 | }); 30 | }; 31 | 32 | export const findAllScenariosByAnalysisId = async (analysisId: string) => 33 | instance.get(`/analyses/${analysisId}/scenarios`); 34 | -------------------------------------------------------------------------------- /src/examples/lavolpiliere.js: -------------------------------------------------------------------------------- 1 | const lavolpiliere = async (page) => { 2 | // Go to https://la-volpiliere.com/blog/chambre-dhotes/ 3 | await page.goto('/blog/chambre-dhotes/', { 4 | waitUntil: 'networkidle', 5 | }); 6 | // Click text=Le Blog 7 | await Promise.all([ 8 | page.waitForNavigation({ waitUntil: 'networkidle' }), 9 | page.scrollToElement('text=Le Blog'), 10 | page.click('text=Le Blog'), 11 | ]); 12 | // assert.equal(page.url(), 'https://la-volpiliere.com/blog/'); 13 | // Click #post-3142 >> text=1ère balade de l’année 2021 14 | await Promise.all([ 15 | page.waitForNavigation({ waitUntil: 'networkidle' }), 16 | page.scrollToElement('#post-3142 >> text=1ère balade de l’année 2021'), 17 | page.click('#post-3142 >> text=1ère balade de l’année 2021'), 18 | ]); 19 | // assert.equal(page.url(), 'https://la-volpiliere.com/blog/1ere-balade-de-lannee-2021/'); 20 | }; 21 | 22 | module.exports = lavolpiliere; 23 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { Page as PlaywrightPage } from 'playwright'; 2 | 3 | declare module 'playwright' { 4 | export interface Page extends PlaywrightPage { 5 | _milestones: Milestone[]; 6 | addMilestone(title: string): Page; 7 | getMilestones(): Milestone[]; 8 | waitForNavigation( 9 | options?: Parameters[0] & { 10 | path?: string; 11 | } 12 | ): ReturnType; 13 | 14 | waitForNetworkIdle( 15 | options?: Parameters[1] 16 | ): ReturnType; 17 | scrollToElement(selector: Parameters[0]): Promise; 18 | scrollToEnd: () => Promise; 19 | scrollByDistance(distance: number): Promise; 20 | } 21 | 22 | interface Milestone { 23 | title: string; 24 | timestamp: number; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/tasks/retrieveGreenFrameProject.js: -------------------------------------------------------------------------------- 1 | const ConfigurationError = require('../services/errors/ConfigurationError'); 2 | 3 | const { getProject } = require('../services/api/projects'); 4 | 5 | module.exports = async (ctx, task) => { 6 | const projectName = 7 | ctx.flags.projectName ?? 8 | process.env.GREENFRAME_PROJECT_NAME ?? 9 | process.cwd().split('/').slice(-1)[0]; 10 | 11 | if (!projectName) { 12 | throw new ConfigurationError('GreenFrame project name was not found.'); 13 | } 14 | 15 | ctx.projectName = projectName; 16 | try { 17 | const { data } = await getProject(projectName); 18 | ctx.project = data; 19 | } catch (error) { 20 | if (error.response?.status === 404) { 21 | task.title = `Creating a new project ${projectName}`; 22 | } else if (error.response?.status === 401) { 23 | throw new ConfigurationError( 24 | "Unauthorized access: Check your API TOKEN or your user's subscription." 25 | ); 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/services/detectExecutablePath.ts: -------------------------------------------------------------------------------- 1 | import { access } from 'node:fs'; 2 | import util from 'node:util'; 3 | import ConfigurationError from './errors/ConfigurationError'; 4 | 5 | const accessPromise = util.promisify(access); 6 | 7 | const PATHS = [ 8 | '/usr/bin/chromium', 9 | '/usr/bin/chromium-browser', 10 | '/usr/bin/google-chrome', 11 | '/usr/local/bin/chromium', 12 | '/usr/local/bin/chromium-browser', 13 | '/usr/local/bin/google-chrome', 14 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 15 | ]; 16 | 17 | export const detectExecutablePath = async () => { 18 | for (const path of PATHS) { 19 | try { 20 | await accessPromise(path); 21 | return path; 22 | } catch { 23 | // Do nothing; 24 | } 25 | } 26 | 27 | throw new ConfigurationError(`No Chromium browser found at the following paths:\n${PATHS.join( 28 | '\n' 29 | )}. 30 | Install one by typing: 31 | 'sudo apt-get install chromium-browser' (or 'brew install --cask chromium' for mac)`); 32 | }; 33 | -------------------------------------------------------------------------------- /src/examples/laneuvelotte.js: -------------------------------------------------------------------------------- 1 | const laneuvelotte = async (page) => { 2 | await page.goto('', { 3 | waitUntil: 'networkidle', 4 | }); 5 | // Click text=Actualités 6 | await Promise.all([ 7 | page.waitForNavigation({ waitUntil: 'networkidle' }), 8 | page.scrollToElement('text=Actualités'), 9 | page.click('text=Actualités'), 10 | ]); 11 | // Click text=Deuxième confinement 12 | await Promise.all([ 13 | page.waitForNavigation({ waitUntil: 'networkidle' }), 14 | page.scrollToElement('text=Deuxième confinement'), 15 | page.click('text=Deuxième confinement'), 16 | ]); 17 | // assert.equal(page.url(), 'https://laneuvelotte.fr/2020/11/deuxieme-confinement/'); 18 | // Click text=Voir tous les articles par Yann GENSOLLEN 19 | await Promise.all([ 20 | page.waitForNavigation({ waitUntil: 'networkidle' }), 21 | page.scrollToElement('text=Voir tous les articles par Yann GENSOLLEN'), 22 | page.click('text=Voir tous les articles par Yann GENSOLLEN'), 23 | ]); 24 | }; 25 | 26 | module.exports = laneuvelotte; 27 | -------------------------------------------------------------------------------- /src/model/stat-tools/getAverageMilestones.ts: -------------------------------------------------------------------------------- 1 | import { Milestone } from '../../types'; 2 | 3 | export const getAverageMilestones = (milestonesPerSamples: Milestone[][]) => { 4 | const samples = milestonesPerSamples.length; 5 | return milestonesPerSamples.reduce( 6 | (milestones: Milestone[], milestonesForOneSample: Milestone[]) => { 7 | milestonesForOneSample.forEach( 8 | ( 9 | { 10 | title, 11 | time, 12 | }: { 13 | title: string; 14 | time: number; 15 | }, 16 | index: number 17 | ) => { 18 | if (!milestones[index]) { 19 | milestones[index] = { title, time: time / samples }; 20 | } else { 21 | milestones[index].time += time / samples; 22 | } 23 | } 24 | ); 25 | return milestones; 26 | }, 27 | [] 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/model/stores/timeframeStore.ts: -------------------------------------------------------------------------------- 1 | import type { TimeFrameWithMeta } from '../../types'; 2 | 3 | // map: containerName -> TimeFrameWithMeta[] 4 | export type TimeFrameStore = Map; 5 | 6 | // add an entry 7 | export const add = (store: TimeFrameStore, value: TimeFrameWithMeta): void => { 8 | const key = value.meta.container; 9 | if (store.has(key)) { 10 | store.get(key)?.push(value); 11 | } else { 12 | store.set(key, [value]); 13 | } 14 | }; 15 | 16 | export const createTimeFrameStore = (values: TimeFrameWithMeta[]): TimeFrameStore => { 17 | const store: TimeFrameStore = new Map() as TimeFrameStore; 18 | for (const value of values) add(store, value); 19 | return store; 20 | }; 21 | 22 | // unordered array of unique titles 23 | export const getTitles = (store: TimeFrameStore): string[] => { 24 | const timeframes = [...store.values()].flat(); 25 | const titleSet = timeframes.reduce((acc: Set, value: TimeFrameWithMeta) => { 26 | acc.add(value.title); 27 | return acc; 28 | }, new Set()); 29 | return [...titleSet]; 30 | }; 31 | -------------------------------------------------------------------------------- /src/services/git/index.js: -------------------------------------------------------------------------------- 1 | const { 2 | getCommitMessage, 3 | getBranchName, 4 | getCommitId, 5 | getDirectCommitAncestor, 6 | getCommitAncestorWithDefaultBranch, 7 | } = require('./utils'); 8 | 9 | const retrieveGitInformations = async ( 10 | { commitMessage, branchName, commitId } = {}, 11 | defaultBranch 12 | ) => { 13 | // If we are on master (default branch), then commit reference is the N-1 14 | // Else commit reference is the commit origin of the current branch from master 15 | const defaultBranchCommitReference = 16 | defaultBranch && branchName !== defaultBranch 17 | ? await getCommitAncestorWithDefaultBranch(defaultBranch) 18 | : await getDirectCommitAncestor(); 19 | 20 | let gitInfos = { 21 | commitMessage: commitMessage ?? (await getCommitMessage()), 22 | branchName: branchName ?? (await getBranchName()), 23 | commitId: commitId ?? (await getCommitId()), 24 | defaultBranchCommitReference, 25 | }; 26 | 27 | return gitInfos; 28 | }; 29 | 30 | module.exports = { 31 | retrieveGitInformations, 32 | }; 33 | -------------------------------------------------------------------------------- /src/model/stat-tools/intervals.ts: -------------------------------------------------------------------------------- 1 | import type { TimeFrame } from '../../types'; 2 | 3 | // Intersection of two intervals [aStart,aEnd] and [bStart,bEnd] 4 | // Note that we use named-tuples here! 5 | export const intersection = ( 6 | a: [start: number, end: number], 7 | b: [start: number, end: number] 8 | ): [start: number, end: number] | undefined => { 9 | const [aStart, aEnd] = a; 10 | const [bStart, bEnd] = b; 11 | if (bStart > aEnd || aStart > bEnd) { 12 | return undefined; 13 | } 14 | 15 | return [Math.max(aStart, bStart), Math.min(aEnd, bEnd)]; 16 | }; 17 | 18 | // Returns the last timeframe (from timeframes) which intersects [prereadTime,readTime] 19 | export const getTimeframe = ( 20 | readTime: number, 21 | prereadTime: number, 22 | timeframes: TimeFrame[] 23 | ): TimeFrame | undefined => 24 | [...timeframes] 25 | .reverse() 26 | .find( 27 | (timeframe) => 28 | intersection( 29 | [timeframe.start.getTime(), timeframe.end.getTime()], 30 | [prereadTime, readTime] 31 | ) !== undefined 32 | ); 33 | -------------------------------------------------------------------------------- /e2e/local/open.spec.js: -------------------------------------------------------------------------------- 1 | const util = require('node:util'); 2 | const exec = util.promisify(require('node:child_process').exec); 3 | 4 | const BASE_COMMAND = `./bin/run open`; 5 | 6 | // This is disabled because popping chrome in CI doesn't seem to work as-is 7 | // the test works locally though 8 | // eslint-disable-next-line jest/no-disabled-tests 9 | describe.skip('[LOCAL] greenframe open', () => { 10 | describe('single page', () => { 11 | it('should run correctly', async () => { 12 | const { error, stdout } = await exec(`${BASE_COMMAND} https://greenframe.io`); 13 | 14 | expect(stdout).toContain('✅ main scenario:'); 15 | expect(stdout).toContain('GreenFrame scenarios finished successfully !'); 16 | expect(stdout).toContain( 17 | 'You can now run an analysis to estimate the consumption of your application' 18 | ); 19 | expect(error).toBeUndefined(); 20 | }); 21 | }); 22 | // we need to setup a mock dev environment to enable this test 23 | // eslint-disable-next-line jest/no-disabled-tests 24 | describe.skip('full stack', () => { 25 | // ... 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/tasks/deleteKubeGreenframeNamespace.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from '@kubernetes/client-node'; 2 | import { TaskWrapper } from 'listr2/dist/lib/task-wrapper'; 3 | import { GREENFRAME_NAMESPACE } from '../constants'; 4 | import { kubeApi } from '../services/container/kubernetes/client'; 5 | import ConfigurationError from '../services/errors/ConfigurationError'; 6 | 7 | export const deleteKubeGreenframeNamespace = async ( 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | _: any, 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | task: TaskWrapper 12 | ) => { 13 | try { 14 | const { body: existingNamespaces } = await kubeApi.listNamespace(); 15 | if ( 16 | existingNamespaces.items.some( 17 | (namespace) => namespace.metadata?.name === GREENFRAME_NAMESPACE 18 | ) 19 | ) { 20 | await kubeApi.deleteNamespace(GREENFRAME_NAMESPACE); 21 | return; 22 | } 23 | 24 | task.title = 'Greenframe namespace does not exists'; 25 | } catch (error) { 26 | throw new ConfigurationError( 27 | `Error when deleting namespace: ${(error as HttpError).body.message}` 28 | ); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/runner/index.js: -------------------------------------------------------------------------------- 1 | const minimist = require('minimist'); 2 | 3 | const executeScenario = require('./scenarioWrapper'); 4 | 5 | const getScenarioPath = (scenario) => { 6 | const scenarioPath = decodeURIComponent(scenario); 7 | 8 | if (scenarioPath.startsWith('./')) { 9 | return scenarioPath.replace('.', '/scenarios'); 10 | } 11 | 12 | return scenarioPath; 13 | }; 14 | 15 | (async () => { 16 | const args = minimist(process.argv.slice(2)); 17 | const scenarioPath = getScenarioPath(args.scenario); 18 | const scenarioFileContent = require(scenarioPath); 19 | const { timelines, milestones } = await executeScenario(scenarioFileContent, { 20 | baseUrl: decodeURIComponent(args.url), 21 | hostIP: process.env.HOSTIP, 22 | extraHosts: process.env.EXTRA_HOSTS ? process.env.EXTRA_HOSTS.split(',') : [], 23 | useAdblock: args.useAdblock, 24 | ignoreHTTPSErrors: args.ignoreHTTPSErrors, 25 | locale: args.locale, 26 | timezoneId: args.timezoneId, 27 | }); 28 | console.log('=====TIMELINES====='); 29 | console.log(JSON.stringify(timelines)); 30 | console.log('=====TIMELINES====='); 31 | console.log('=====MILESTONES====='); 32 | console.log(JSON.stringify(milestones)); 33 | console.log('=====MILESTONES====='); 34 | })(); 35 | -------------------------------------------------------------------------------- /src/tasks/createNewAnalysis.js: -------------------------------------------------------------------------------- 1 | const ConfigurationError = require('../services/errors/ConfigurationError'); 2 | 3 | const { createAnalysis } = require('../services/api/analyses'); 4 | const createNewAnalysis = async (ctx) => { 5 | const { args, flags } = ctx; 6 | 7 | try { 8 | const { data } = await createAnalysis({ 9 | scenarios: args.scenarios, 10 | baseURL: args.baseURL, 11 | threshold: flags.threshold, 12 | samples: flags.samples, 13 | useAdblock: flags.useAdblock, 14 | locale: flags.locale, 15 | timezoneId: flags.timezoneId, 16 | ignoreHTTPSErrors: flags.ignoreHTTPSErrors, 17 | projectName: ctx.projectName, 18 | gitInfos: ctx.gitInfos, 19 | }); 20 | ctx.analysisId = data.id; 21 | } catch (error_) { 22 | const error = 23 | error_.response?.status === 401 24 | ? new ConfigurationError( 25 | `Unauthorized access: ${ 26 | error_.response?.data || 27 | "Check your API TOKEN or your user's subscription." 28 | }` 29 | ) 30 | : error_; 31 | throw error; 32 | } 33 | }; 34 | 35 | module.exports = createNewAnalysis; 36 | -------------------------------------------------------------------------------- /src/tasks/addKubeGreenframeNamespace.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from '@kubernetes/client-node'; 2 | import { TaskWrapper } from 'listr2/dist/lib/task-wrapper'; 3 | import { GREENFRAME_NAMESPACE } from '../constants'; 4 | import { kubeApi } from '../services/container/kubernetes/client'; 5 | import ConfigurationError from '../services/errors/ConfigurationError'; 6 | 7 | export const addKubeGreenframeNamespace = async ( 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | _: any, 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | task: TaskWrapper 12 | ) => { 13 | const namespace = { 14 | metadata: { 15 | name: GREENFRAME_NAMESPACE, 16 | }, 17 | }; 18 | try { 19 | const { body: existingNamespaces } = await kubeApi.listNamespace(); 20 | if ( 21 | existingNamespaces.items.some( 22 | (namespace) => namespace.metadata?.name === GREENFRAME_NAMESPACE 23 | ) 24 | ) { 25 | task.title = 'Greenframe namespace already exists'; 26 | return; 27 | } 28 | 29 | await kubeApi.createNamespace(namespace); 30 | } catch (error) { 31 | throw new ConfigurationError( 32 | `Error when creating namespace: ${(error as HttpError).body.message}` 33 | ); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/getContainerStats.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import { getCadvisorMetrics } from './cadvisor'; 3 | import { CadvisorContainerStats } from './stats'; 4 | import { AugmentedPod } from './structureNodes'; 5 | 6 | const debug = initDebug('greenframe:services:container:kubernetes:getContainerStats'); 7 | export const getContainerStats = async ( 8 | cadvisorPod: AugmentedPod, 9 | pod: AugmentedPod 10 | ): Promise => { 11 | debug(`Getting stats for kubernetes container ${pod.fullName}`); 12 | const containerId = pod.networkContainerId 13 | ? pod.networkContainerId 14 | : pod.container 15 | ? pod.status?.containerStatuses 16 | ?.find((container) => container.name === pod.container) 17 | ?.containerID?.replace('containerd://', '') 18 | : ''; 19 | if (containerId == null) { 20 | throw new Error(`Cannot find container ID for container ${pod.fullName}`); 21 | } 22 | 23 | const fullContainerId = `/kubepods/${pod.status?.qosClass?.toLocaleLowerCase()}/pod${ 24 | pod.metadata?.uid 25 | }${containerId !== '' ? `/${containerId}` : ''}`; 26 | 27 | const rawStats = await getCadvisorMetrics( 28 | cadvisorPod.metadata?.name || 'cadvisor', 29 | fullContainerId 30 | ); 31 | 32 | rawStats.stats = rawStats.stats.slice(-2); 33 | return rawStats; 34 | }; 35 | -------------------------------------------------------------------------------- /src/model/stat-tools/mergeScore.ts: -------------------------------------------------------------------------------- 1 | import { MetricsContainer } from '../../types'; 2 | 3 | export const mergeScores = ( 4 | score: MetricsContainer, 5 | newScore: MetricsContainer 6 | ): MetricsContainer => { 7 | return { 8 | s: { 9 | cpu: score.s.cpu + newScore.s.cpu, 10 | screen: score.s.screen + newScore.s.screen, 11 | totalTime: score.s.totalTime + newScore.s.totalTime, 12 | }, 13 | gb: { 14 | mem: score.gb.mem + newScore.gb.mem, 15 | disk: score.gb.disk + newScore.gb.disk, 16 | network: score.gb.network + newScore.gb.network, 17 | }, 18 | wh: { 19 | cpu: score.wh.cpu + newScore.wh.cpu, 20 | mem: score.wh.mem + newScore.wh.mem, 21 | disk: score.wh.disk + newScore.wh.disk, 22 | total: score.wh.total + newScore.wh.total, 23 | screen: score.wh.screen + newScore.wh.screen, 24 | network: score.wh.network + newScore.wh.network, 25 | }, 26 | co2: { 27 | cpu: score.co2.cpu + newScore.co2.cpu, 28 | mem: score.co2.mem + newScore.co2.mem, 29 | disk: score.co2.disk + newScore.co2.disk, 30 | total: score.co2.total + newScore.co2.total, 31 | screen: score.co2.screen + newScore.co2.screen, 32 | network: score.co2.network + newScore.co2.network, 33 | }, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /scripts/uploadInstallScript.js: -------------------------------------------------------------------------------- 1 | const pjson = require('../package.json'); 2 | 3 | // Import required AWS SDK clients and commands for Node.js. 4 | const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); 5 | const path = require('node:path'); 6 | const fs = require('node:fs'); 7 | 8 | const files = ['./scripts/ressources/install.sh', './scripts/ressources/install-frpc.sh']; // Path to and name of object. For example '../myFiles/index.js'. 9 | 10 | // Set the AWS Region. 11 | const REGION = 'eu-west-3'; // e.g. "us-east-1" 12 | // Create an Amazon S3 service client object. 13 | const s3Client = new S3Client({ region: REGION }); 14 | 15 | const uploadFile = (file) => { 16 | const fileStream = fs.createReadStream(file); 17 | // Set the parameters 18 | const uploadParams = { 19 | Bucket: pjson.oclif.update.s3.bucket, 20 | // Add the required 'Key' parameter using the 'path' module. 21 | Key: path.basename(file), 22 | // Add the required 'Body' parameter 23 | Body: fileStream, 24 | }; 25 | return s3Client.send(new PutObjectCommand(uploadParams)); 26 | }; 27 | 28 | // Upload file to specified bucket. 29 | const run = async () => { 30 | try { 31 | files.forEach(async (file) => { 32 | await uploadFile(file); 33 | console.log('Success', file); 34 | }); 35 | } catch (error) { 36 | console.log('Error', error); 37 | } 38 | }; 39 | 40 | run(); 41 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/client.ts: -------------------------------------------------------------------------------- 1 | import * as kube from '@kubernetes/client-node'; 2 | import axios from 'axios'; 3 | import https from 'node:https'; 4 | 5 | export const kc = new kube.KubeConfig(); 6 | export let kubeClient: kube.KubernetesObjectApi; 7 | export let kubeApi: kube.CoreV1Api; 8 | export let exec: kube.Exec; 9 | const opts: https.AgentOptions = {}; 10 | let httpsAgent: https.Agent; 11 | 12 | export const initKubeConfig = async (configFile?: string) => { 13 | await (configFile ? kc.loadFromFile(configFile) : kc.loadFromDefault()); 14 | kubeClient = kube.KubernetesObjectApi.makeApiClient(kc); 15 | kubeApi = kc.makeApiClient(kube.CoreV1Api); 16 | exec = new kube.Exec(kc); 17 | await kc.applytoHTTPSOptions(opts); 18 | httpsAgent = new https.Agent(opts); 19 | }; 20 | 21 | export const getKubernetesVersion = async () => { 22 | const currentCluster = kc.getCurrentCluster(); 23 | if (!currentCluster) { 24 | throw new Error('No kubernetes cluster found, please check your configuration'); 25 | } 26 | 27 | const res = await axios.get<{ 28 | major: string; 29 | minor: string; 30 | gitVersion: string; 31 | gitCommit: string; 32 | gitTreeState: string; 33 | buildDate: string; 34 | goVersion: string; 35 | compiler: string; 36 | platform: string; 37 | }>(`${currentCluster.server}/version?timeout=32s`, { 38 | ...opts, 39 | httpsAgent, 40 | }); 41 | const data = res.data; 42 | return `Kubernetes version ${data.major}.${data.minor}, build ${data.gitVersion}, platform ${data.platform}`; 43 | }; 44 | -------------------------------------------------------------------------------- /scripts/ressources/install-frpc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echoerr() { echo -e "\n$@\n" 1>&2; } 6 | 7 | if [ -z ${HOME+x} ]; then 8 | echoerr "\$HOME is unset, you need to have this variable to use this installer."; 9 | exit 1 10 | fi 11 | 12 | if [ "$(uname)" == "Darwin" ]; then 13 | OS=darwin 14 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 15 | OS=linux 16 | else 17 | echoerr "This installer is only supported on Linux and MacOS" 18 | exit 1 19 | fi 20 | 21 | ARCH="" 22 | case $(uname -m) in 23 | i386) ARCH="386" ;; 24 | i686) ARCH="386" ;; 25 | x86_64) ARCH="amd64" ;; 26 | arm) dpkg --print-ARCH | grep -q "arm64" && ARCH="arm64" || ARCH="arm" ;; 27 | esac 28 | 29 | 30 | mkdir -p $HOME/.local/bin 31 | mkdir -p $HOME/.local/lib 32 | cd $HOME/.local/lib 33 | rm -rf $HOME/.local/lib/frpc 34 | 35 | FRP_VERSION="0.36.2" 36 | 37 | FRP_FILENAME="frp_${FRP_VERSION}_${OS}_${ARCH}" 38 | 39 | URL=https://github.com/fatedier/frp/releases/download/v$FRP_VERSION/$FRP_FILENAME.tar.gz 40 | 41 | echo "Installing FRPC from $URL" 42 | 43 | wget -q -O "./frp.tar.gz" "$URL" &>/dev/null 44 | 45 | tar -xzf "./frp.tar.gz" 46 | rm "./frp.tar.gz" 47 | mv "./$FRP_FILENAME" "./frpc" 48 | # delete old frpc bin if exists 49 | rm -f $HOME/.local/bin/frpc 50 | ln -s $HOME/.local/lib/frpc/frpc $HOME/.local/bin/frpc 51 | 52 | if [[ ! ":$PATH:" == *":$HOME/.local/bin:"* ]]; then 53 | echoerr "FRPC has been installed but your path is missing $HOME/.local/bin, you need to add this to use FRPC." 54 | else 55 | # test the CLI 56 | LOCATION=$(command -v frpc) 57 | echo "frpc installed to $LOCATION" 58 | frpc -v 59 | fi 60 | -------------------------------------------------------------------------------- /scripts/ressources/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echoerr() { echo -e "\n$@\n" 1>&2; } 5 | 6 | if [ -z ${HOME+x} ]; then 7 | echoerr "\$HOME is unset, you need to have this variable to use this installer."; 8 | exit 1 9 | fi 10 | 11 | if [ "$(uname)" == "Darwin" ]; then 12 | OS=darwin 13 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 14 | OS=linux 15 | else 16 | echoerr "This installer is only supported on Linux and MacOS" 17 | exit 1 18 | fi 19 | 20 | ARCH="$(uname -m)" 21 | if [ "$ARCH" == "x86_64" ]; then 22 | ARCH=x64 23 | fi 24 | 25 | mkdir -p $HOME/.local/bin 26 | mkdir -p $HOME/.local/lib 27 | cd $HOME/.local/lib 28 | rm -rf greenframe 29 | rm -rf ~/.local/share/greenframe/client 30 | 31 | URL=https://assets.greenframe.io/channels/stable/greenframe-$OS-$ARCH.tar.gz 32 | TAR_ARGS="xz" 33 | 34 | echo "Installing CLI from $URL" 35 | if [ $(command -v curl) ]; then 36 | curl "$URL" | tar "$TAR_ARGS" 37 | else 38 | wget -O- "$URL" | tar "$TAR_ARGS" 39 | fi 40 | # delete old greenframe bin if exists 41 | rm -f $(command -v greenframe) || true 42 | rm -f $HOME/.local/bin/greenframe 43 | ln -s $HOME/.local/lib/greenframe/bin/greenframe $HOME/.local/bin/greenframe 44 | 45 | # on alpine (and maybe others) the basic node binary does not work 46 | # remove our node binary and fall back to whatever node is on the PATH 47 | $HOME/.local/lib/greenframe/bin/node -v || rm $HOME/.local/lib/greenframe/bin/node 48 | 49 | if [[ ! ":$PATH:" == *":$HOME/.local/bin:"* ]]; then 50 | echoerr "GreenFrame has been installed but your path is missing $HOME/.local/bin, you need to add this to use GreenFrame CLI." 51 | else 52 | # test the CLI 53 | LOCATION=$(command -v greenframe) 54 | echo "GreenFrame installed to $LOCATION" 55 | greenframe -v 56 | fi 57 | 58 | -------------------------------------------------------------------------------- /src/examples/nextWebsite.js: -------------------------------------------------------------------------------- 1 | const nextWebsite = async (page) => { 2 | // Go to https://nextjs.org/ 3 | await page.goto('', { 4 | waitUntil: 'networkidle', 5 | }); 6 | // Click text=Read Case Study 7 | await Promise.all([ 8 | page.waitForNavigation({ waitUntil: 'networkidle' }), 9 | page.scrollToElement('text=Read Case Study'), 10 | page.click('text=Read Case Study'), 11 | ]); 12 | // Click text=Learn More 13 | await Promise.all([ 14 | page.waitForNavigation({ waitUntil: 'networkidle' }), 15 | page.scrollToElement('text=Learn More'), 16 | page.click('text=Learn More'), 17 | ]); 18 | // Click text=Start Now → 19 | await Promise.all([ 20 | page.waitForNavigation({ waitUntil: 'networkidle' }), 21 | page.scrollToElement('text=Start Now →'), 22 | page.click('text=Start Now →'), 23 | ]); 24 | // Click a[role="button"]:has-text("Next") 25 | await Promise.all([ 26 | page.scrollToElement('a[role="button"]:has-text("Next")'), 27 | page.click('a[role="button"]:has-text("Next")'), 28 | ]); 29 | // Click a[role="button"]:has-text("Next") 30 | await Promise.all([ 31 | page.scrollToElement('a[role="button"]:has-text("Next")'), 32 | page.click('a[role="button"]:has-text("Next")'), 33 | ]); 34 | // Click a[role="button"]:has-text("Next Lesson") 35 | await Promise.all([ 36 | page.scrollToElement('a[role="button"]:has-text("Next Lesson")'), 37 | page.click('a[role="button"]:has-text("Next Lesson")'), 38 | ]); 39 | // Click text=Start Now → 40 | await Promise.all([ 41 | page.scrollToElement('text=Start Now →'), 42 | page.click('text=Start Now →'), 43 | ]); 44 | }; 45 | 46 | module.exports = nextWebsite; 47 | -------------------------------------------------------------------------------- /src/services/git/utils.js: -------------------------------------------------------------------------------- 1 | const util = require('node:util'); 2 | const exec = util.promisify(require('node:child_process').exec); 3 | 4 | const getCommitMessage = async () => { 5 | try { 6 | const { stdout } = await exec('git log -1 --pretty=%B'); 7 | return stdout.trim(); 8 | } catch (error) { 9 | console.warn('getCommitMessage:', error.message); 10 | return 'defaultAnalyseName'; 11 | } 12 | }; 13 | 14 | const getBranchName = async () => { 15 | const { stdout } = await exec('git branch --show-current'); 16 | 17 | return stdout.trim(); 18 | }; 19 | 20 | const getCommitId = async () => { 21 | try { 22 | const { stdout } = await exec('git rev-parse HEAD'); 23 | return stdout.trim(); 24 | } catch (error) { 25 | console.warn('getCommitId:', error.message); 26 | return 'empty-----------------------------------'; 27 | } 28 | }; 29 | 30 | const getDirectCommitAncestor = async () => { 31 | try { 32 | const { stdout, stderr } = await exec(`printf $(git rev-parse HEAD^)`); 33 | if (stderr) { 34 | throw new Error(stderr); 35 | } 36 | 37 | return stdout; 38 | } catch { 39 | // Return nothing and do not fail the process. 40 | } 41 | }; 42 | 43 | const getCommitAncestorWithDefaultBranch = async (defaultBranch) => { 44 | try { 45 | const { stdout, stderr } = await exec( 46 | `printf $(git merge-base origin/${defaultBranch} HEAD)` 47 | ); 48 | if (stderr) { 49 | throw new Error(stderr); 50 | } 51 | 52 | return stdout; 53 | } catch { 54 | // Return nothing and do not fail the process. 55 | } 56 | }; 57 | 58 | module.exports = { 59 | getCommitMessage, 60 | getBranchName, 61 | getCommitId, 62 | getDirectCommitAncestor, 63 | getCommitAncestorWithDefaultBranch, 64 | }; 65 | -------------------------------------------------------------------------------- /e2e/greenframe.io/open.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-conditional-expect */ 2 | const util = require('node:util'); 3 | const exec = util.promisify(require('node:child_process').exec); 4 | 5 | const BASE_COMMAND = `GREENFRAME_SECRET_TOKEN=API_TOKEN API_URL=http://localhost:3006 ./bin/run open`; 6 | 7 | // we need to setup a mock greenframe.io environment to enable this test 8 | // eslint-disable-next-line jest/no-disabled-tests 9 | describe.skip('[GREENFRAME.IO] greenframe open', () => { 10 | describe('single page', () => { 11 | it('should raise and error on non HTTPS websites', async () => { 12 | expect.assertions(2); 13 | try { 14 | await exec(`${BASE_COMMAND} https://untrusted-root.badssl.com/`); 15 | } catch (error) { 16 | expect(error.stderr).toContain('❌ main scenario failed'); 17 | expect(error.stderr).toContain('net::ERR_CERT_AUTHORITY_INVALID'); 18 | } 19 | }); 20 | 21 | it('should work on non HTTPS websites with --ignoreHTTPSErrors flag', async () => { 22 | const { stdout } = await exec( 23 | `${BASE_COMMAND} https://untrusted-root.badssl.com/ --ignoreHTTPSErrors` 24 | ); 25 | expect(stdout).toContain('✅ main scenario completed'); 26 | }); 27 | 28 | it('should set greenframe browser locale right', async () => { 29 | const { stdout: enStdout } = await exec( 30 | `${BASE_COMMAND} -C ./e2e/.greenframe.single.en.yml` 31 | ); 32 | expect(enStdout).toContain('✅ main scenario completed'); 33 | const { stdout: frStdout } = await exec( 34 | `${BASE_COMMAND} -C ./e2e/.greenframe.single.fr.yml` 35 | ); 36 | expect(frStdout).toContain('✅ main scenario completed'); 37 | }); 38 | }); 39 | describe('full stack', () => { 40 | // ... 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/services/container/getPodsStats.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import { CONTAINER_TYPES } from '../../constants'; 3 | import type { ValueOf } from '../../types'; 4 | import { getContainerStats } from './kubernetes/getContainerStats'; 5 | import { CadvisorContainerStats } from './kubernetes/stats'; 6 | import { Nodes } from './kubernetes/structureNodes'; 7 | const debug = initDebug('greenframe:services:container:getPodsStats'); 8 | export type PodStat = { 9 | podName: string; 10 | podType: ValueOf; 11 | stats: CadvisorContainerStats[]; 12 | }; 13 | 14 | const getPodsStatsWithCadvisor = async ( 15 | nodeStructure: Nodes, 16 | node: string, 17 | podsStats: Record 18 | ) => { 19 | const [cadvisorPod, ...pods] = nodeStructure[node]; 20 | for (const pod of pods) { 21 | const podName = pod.greenframeId; 22 | if (!podsStats[podName]) { 23 | podsStats[podName] = { 24 | podName, 25 | podType: pod.type, 26 | stats: [], 27 | }; 28 | } 29 | 30 | debug(`Getting stats for pod ${podName}`); 31 | const podStats = await getContainerStats(cadvisorPod, pod); 32 | podsStats[podName].stats.push(podStats); 33 | } 34 | }; 35 | 36 | export const getPodsStats = (nodeStructure: Nodes) => { 37 | debug('Start observing pods stats'); 38 | const stats: Record = {}; 39 | // Read first immediately 40 | for (const node of Object.keys(nodeStructure)) { 41 | getPodsStatsWithCadvisor(nodeStructure, node, stats); 42 | } 43 | 44 | const interval = setInterval(() => { 45 | for (const node of Object.keys(nodeStructure)) { 46 | getPodsStatsWithCadvisor(nodeStructure, node, stats); 47 | } 48 | }, 1000); 49 | const stop = () => { 50 | debug('Stop observing pods stats'); 51 | clearInterval(interval); 52 | return stats; 53 | }; 54 | 55 | return stop; 56 | }; 57 | -------------------------------------------------------------------------------- /src/model/stat-tools/__tests__/getAverageMilestones.ts: -------------------------------------------------------------------------------- 1 | import { getAverageMilestones } from '../getAverageMilestones'; 2 | 3 | describe('#getAverageMilestones', () => { 4 | it('Should return min milestones for each samples', () => { 5 | const milestonesPerSamples = [ 6 | [ 7 | { 8 | title: 'Milestone 1', 9 | time: 1000, 10 | }, 11 | { 12 | title: 'Milestone 2', 13 | time: 1145, 14 | }, 15 | { 16 | title: 'Milestone 3', 17 | time: 12_678, 18 | }, 19 | ], 20 | [ 21 | { 22 | title: 'Milestone 1', 23 | time: 1500, 24 | }, 25 | { 26 | title: 'Milestone 2', 27 | time: 2047, 28 | }, 29 | { 30 | title: 'Milestone 3', 31 | time: 13_458, 32 | }, 33 | ], 34 | [ 35 | { 36 | title: 'Milestone 1', 37 | time: 2000, 38 | }, 39 | { 40 | title: 'Milestone 2', 41 | time: 2457, 42 | }, 43 | { 44 | title: 'Milestone 3', 45 | time: 15_000, 46 | }, 47 | ], 48 | ]; 49 | 50 | const milestones = getAverageMilestones(milestonesPerSamples); 51 | 52 | expect(milestones).toEqual([ 53 | { 54 | title: 'Milestone 1', 55 | time: 1500, 56 | }, 57 | { 58 | title: 'Milestone 2', 59 | time: 1883, 60 | }, 61 | { 62 | title: 'Milestone 3', 63 | time: 13_712, 64 | }, 65 | ]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/services/computeAnalysisResult.ts: -------------------------------------------------------------------------------- 1 | import { ERROR_CODES } from '../constants'; 2 | import { mergeScores } from '../index'; 3 | import { MetricsContainer, ValueOf } from '../types'; 4 | import { ScenarioResult } from './computeScenarioResult'; 5 | 6 | export type AnalysisResult = { 7 | score?: MetricsContainer; 8 | errorCode?: ValueOf; 9 | errorMessage?: string; 10 | scenarios: ScenarioResult[]; 11 | }; 12 | 13 | export const computeAnalysisResult = (scenarios: ScenarioResult[]): AnalysisResult => { 14 | // Compute all scenarios to get the final global score 15 | const { errorCode, score } = scenarios.reduce( 16 | ( 17 | acc: { errorCode?: ValueOf; score?: MetricsContainer }, 18 | scenario: ScenarioResult 19 | ) => { 20 | if (scenario.errorCode) { 21 | acc.errorCode = scenario.errorCode as 22 | | ValueOf 23 | | undefined; 24 | } 25 | 26 | if (scenario.score?.wh) { 27 | acc.score = acc.score 28 | ? mergeScores(acc.score, scenario.score) 29 | : scenario.score; 30 | } 31 | 32 | return acc; 33 | }, 34 | { 35 | score: { 36 | s: { cpu: 0, screen: 0, totalTime: 0 }, 37 | gb: { 38 | mem: 0, 39 | disk: 0, 40 | network: 0, 41 | }, 42 | wh: { 43 | cpu: 0, 44 | mem: 0, 45 | disk: 0, 46 | total: 0, 47 | screen: 0, 48 | network: 0, 49 | }, 50 | co2: { 51 | cpu: 0, 52 | mem: 0, 53 | disk: 0, 54 | total: 0, 55 | screen: 0, 56 | network: 0, 57 | }, 58 | }, 59 | } 60 | ); 61 | 62 | return { 63 | score, 64 | errorCode, 65 | scenarios, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "requireConfigFile": false, 9 | "ecmaVersion": 2020 10 | }, 11 | "extends": [ 12 | "oclif", 13 | "oclif-typescript", 14 | "eslint:recommended", 15 | "plugin:prettier/recommended", 16 | "prettier", 17 | "plugin:jest/recommended", 18 | "plugin:jest/style", 19 | "plugin:@typescript-eslint/recommended" 20 | ], 21 | "plugins": ["prettier", "@typescript-eslint"], 22 | "settings": { 23 | "jest": { 24 | "version": 26 25 | } 26 | }, 27 | "rules": { 28 | "no-return-await": "error", 29 | "unicorn/filename-case": "off", 30 | "node/no-extraneous-import": "off", 31 | "unicorn/no-process-exit": "off", 32 | "no-process-exit": "off", 33 | "no-eq-null": "off", 34 | "no-negated-condition": "off", 35 | "eqeqeq": ["error", "always", { "null": "ignore" }], 36 | "unicorn/prefer-logical-operator-over-ternary": "off", 37 | "unicorn/no-array-reduce": "off", 38 | // Check if we need this 39 | "unicorn/consistent-destructuring": "off", 40 | "no-await-in-loop": "off", 41 | "unicorn/no-object-as-default-parameter": "off", 42 | "default-param-last": "off", 43 | "node/no-missing-import": "off", 44 | "unicorn/prefer-spread": "off", 45 | "prefer-promise-reject-errors": "off", 46 | "camelcase": "off", 47 | // Migration to TypeScript 48 | "@typescript-eslint/no-var-requires": "off", 49 | "node/no-missing-require": "off", 50 | "unicorn/prefer-module": "off", 51 | // Skipped for now 52 | "unicorn/no-array-for-each": "off", 53 | "unicorn/expiring-todo-comments": "off", 54 | "array-callback-return": "off", 55 | "unicorn/no-new-array": "off", 56 | "unicorn/consistent-function-scoping": "off", 57 | "unicorn/prefer-top-level-await": "off", 58 | "no-promise-executor-return": "off", 59 | "node/no-extraneous-require": "off" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/cadvisor.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'node:stream'; 2 | import { GREENFRAME_NAMESPACE } from '../../../constants'; 3 | import { exec } from './client'; 4 | import { getPodsByLabel } from './pods'; 5 | import type { CadvisorContainerStats } from './stats'; 6 | 7 | const CADVISOR_LABEL = 'app=cadvisor'; 8 | 9 | export const getCadvisorPodNames = async () => { 10 | const pods = await getPodsByLabel(CADVISOR_LABEL, GREENFRAME_NAMESPACE); 11 | return pods 12 | .filter((item) => item.metadata) 13 | .map((item) => item.metadata?.name as string); 14 | }; 15 | 16 | export const getCadvisorPods = async () => { 17 | const pods = await getPodsByLabel(CADVISOR_LABEL, GREENFRAME_NAMESPACE); 18 | if (pods.length === 0) { 19 | throw new Error( 20 | 'Cannot find cadvisor pods, please make sure you have deployed cadvisor from greenframe.\n' + 21 | 'You can deploy cadvisor by running "greenframe kube-config"' 22 | ); 23 | } 24 | 25 | return pods; 26 | }; 27 | 28 | export const getCadvisorMetrics = ( 29 | cadvisorPodName: string, 30 | podName: string 31 | ): Promise => { 32 | const stdoutStream = new Writable(); 33 | const stdoutChunks: Uint8Array[] = []; 34 | stdoutStream._write = (chunk, encoding, callback) => { 35 | stdoutChunks.push(chunk); 36 | callback(); 37 | }; 38 | 39 | return new Promise((resolve, reject) => { 40 | exec.exec( 41 | GREENFRAME_NAMESPACE, 42 | cadvisorPodName, 43 | 'cadvisor', 44 | ['wget', '-O', '-', `localhost:8080/api/v1.0/containers/${podName}`], 45 | stdoutStream, 46 | null, 47 | null, 48 | false, 49 | (status) => { 50 | if (status.status !== 'Success') { 51 | reject(status); 52 | } else { 53 | const str = Buffer.concat(stdoutChunks).toString('utf8'); 54 | resolve(JSON.parse(str)); 55 | } 56 | } 57 | ).catch((error) => { 58 | console.error(error); 59 | reject(error); 60 | }); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/model/stores/__tests__/timeframeStore.test.ts: -------------------------------------------------------------------------------- 1 | import type { TimeFrameWithMeta } from '../../../types'; 2 | import type { TimeFrameStore } from '../timeframeStore'; 3 | import { createTimeFrameStore, getTitles } from '../timeframeStore'; 4 | 5 | const generator = [ 6 | [0, 0, '00:00:00Z', '00:00:01Z', 'title 0 0 - milestone 1'], 7 | [0, 0, '00:00:01Z', '00:00:02Z', 'title 0 0 - milestone 2'], 8 | [0, 0, '00:00:02Z', '00:00:03Z', 'title 0 0 - milestone 3'], 9 | [0, 1, '00:01:00Z', '00:01:01Z', 'title 0 1 - milestone 1'], 10 | [0, 1, '00:01:01Z', '00:01:02Z', 'title 0 1 - milestone 2'], 11 | [0, 1, '00:01:02Z', '00:01:03Z', 'title 0 1 - milestone 3'], 12 | [1, 0, '00:02:00Z', '00:02:01Z', 'title 1 0 - milestone 1'], 13 | [1, 0, '00:02:01Z', '00:02:02Z', 'title 1 0 - milestone 2'], 14 | [1, 0, '00:02:02Z', '00:02:03Z', 'title 1 0 - milestone 3'], 15 | [1, 1, '00:03:00Z', '00:03:01Z', 'title 1 1 - milestone 1'], 16 | [1, 1, '00:03:01Z', '00:03:02Z', 'title 1 1 - milestone 2'], 17 | [1, 1, '00:03:02Z', '00:03:03Z', 'title 1 1 - milestone 3'], 18 | ]; 19 | 20 | const data: TimeFrameWithMeta[] = generator.map( 21 | ([container, sample, start, end, title]) => 22 | ({ 23 | meta: { 24 | sample, 25 | container: `c${container}`, 26 | }, 27 | start: new Date(`2020-01-01T${start}`), 28 | end: new Date(`2020-01-01T${end}`), 29 | title, 30 | } as TimeFrameWithMeta) 31 | ); 32 | 33 | let store: TimeFrameStore; 34 | beforeEach(() => { 35 | store = createTimeFrameStore(data); 36 | }); 37 | 38 | test.each<[ReturnType]>([ 39 | [ 40 | [ 41 | 'title 0 0 - milestone 1', 42 | 'title 0 0 - milestone 2', 43 | 'title 0 0 - milestone 3', 44 | 'title 1 0 - milestone 1', 45 | 'title 1 0 - milestone 2', 46 | 'title 1 0 - milestone 3', 47 | 'title 0 1 - milestone 1', 48 | 'title 0 1 - milestone 2', 49 | 'title 0 1 - milestone 3', 50 | 'title 1 1 - milestone 1', 51 | 'title 1 1 - milestone 2', 52 | 'title 1 1 - milestone 3', 53 | ], 54 | ], 55 | ])('getTitles %#', (result) => { 56 | expect(new Set(getTitles(store))).toEqual(new Set(result)); 57 | }); 58 | -------------------------------------------------------------------------------- /src/services/api/analyses.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import { STATUS } from '../../status'; 3 | import { Analysis } from '../../types'; 4 | import { AnalysisResult } from '../computeAnalysisResult'; 5 | import instance from './instance'; 6 | 7 | const debug = initDebug('greenframe:services:api:analyses'); 8 | 9 | export const createAnalysis = async ({ 10 | scenarios, 11 | baseURL, 12 | samples, 13 | useAdblock, 14 | projectName, 15 | gitInfos, 16 | locale, 17 | timezoneId, 18 | ignoreHTTPSErrors, 19 | }: Analysis) => { 20 | const { commitMessage, branchName, commitId, defaultBranchCommitReference } = 21 | gitInfos; 22 | 23 | debug('createAnalysis'); 24 | return instance.post('/analyses', { 25 | scenarios, 26 | url: baseURL, 27 | samples, 28 | useAdblock, 29 | projectName, 30 | gitCommitMessage: commitMessage, 31 | gitBranchName: branchName, 32 | gitCommitId: commitId, 33 | gitDefaultBranchCommitReference: defaultBranchCommitReference, 34 | locale, 35 | timezoneId, 36 | ignoreHTTPSErrors, 37 | }); 38 | }; 39 | 40 | export const getAnalysis = (id: string) => { 41 | debug('getAnalysis', id); 42 | return instance.get(`/analyses/${id}`); 43 | }; 44 | 45 | export const checkAnalysis = (id: string) => { 46 | return new Promise((resolve) => { 47 | const interval = setInterval(async () => { 48 | const { data } = await getAnalysis(id); 49 | if (data.status !== STATUS.INITIAL) { 50 | clearInterval(interval); 51 | resolve(data); 52 | } 53 | }, 5000); 54 | }); 55 | }; 56 | 57 | export const saveFailedAnalysis = async ( 58 | analysisId: string, 59 | { errorCode, errorMessage }: { errorCode: string; errorMessage: string } 60 | ) => { 61 | debug('saveFailedAnalysis', analysisId); 62 | return instance.post(`/analyses/${analysisId}/failed`, { 63 | errorCode, 64 | errorMessage: errorMessage.toString(), 65 | }); 66 | }; 67 | 68 | export const saveFinishedAnalysis = async ( 69 | analysisId: string, 70 | analysisResult: AnalysisResult 71 | ) => { 72 | debug('saveFinishedAnalysis', analysisId); 73 | return instance.put(`/analyses/${analysisId}`, analysisResult); 74 | }; 75 | -------------------------------------------------------------------------------- /src/commands/update.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const util = require('node:util'); 3 | const exec = util.promisify(require('node:child_process').exec); 4 | 5 | const { Command } = require('@oclif/core'); 6 | class UpdateCommand extends Command { 7 | static args = [ 8 | { 9 | name: 'channel', 10 | description: 'Release channel', 11 | default: 'stable', 12 | }, 13 | ]; 14 | 15 | async run() { 16 | try { 17 | const { args } = await this.parse(UpdateCommand); 18 | const { version, platform, arch, bin } = this.config; 19 | const manifestUrl = this.config.s3Url( 20 | `channels/${args.channel}/${bin}-${platform}-${arch}-buildmanifest` 21 | ); 22 | 23 | const { data } = await axios.get(manifestUrl).catch(() => { 24 | throw new Error( 25 | 'Channel release was not found try with: greenframe update' 26 | ); 27 | }); 28 | 29 | if (data.version === version) { 30 | console.log(`${bin}-${version} ${platform}-${arch}`); 31 | console.log(`✅ Already up to date`); 32 | process.exit(0); 33 | } 34 | 35 | console.log(`Updating to ${data.version}...`); 36 | const { stderr } = await exec( 37 | `cd $HOME/.local/lib && 38 | rm -rf greenframe && 39 | rm -rf ~/.local/share/greenframe/client && 40 | curl -s ${data.gz} | tar xz && 41 | rm -f $(command -v greenframe) || true && 42 | rm -f $HOME/.local/bin/greenframe && 43 | ln -s $HOME/.local/lib/greenframe/bin/greenframe $HOME/.local/bin/greenframe 44 | `, 45 | { shell: true } 46 | ); 47 | if (stderr) { 48 | throw new Error(stderr); 49 | } 50 | 51 | console.log(`✅ Done !`); 52 | } catch (error) { 53 | console.error('\n❌ Update failed!'); 54 | console.error(error.message); 55 | process.exit(1); 56 | } 57 | } 58 | } 59 | 60 | UpdateCommand.description = `Update GreenFrame to the latest version 61 | ... 62 | greenframe update 63 | `; 64 | 65 | module.exports = UpdateCommand; 66 | -------------------------------------------------------------------------------- /src/services/container/__tests__/getContainerStats.ts: -------------------------------------------------------------------------------- 1 | jest.mock('node:http', () => ({ 2 | request: jest 3 | .fn() 4 | .mockImplementation( 5 | ( 6 | _: unknown, 7 | callback: (res: { statusCode: number; on: (data: any) => void }) => void 8 | ) => { 9 | callback({ statusCode: 200, on: jest.fn() }); 10 | return { 11 | on: jest.fn(), 12 | write: jest.fn(), 13 | end: jest.fn(), 14 | }; 15 | } 16 | ), 17 | })); 18 | 19 | import http from 'node:http'; 20 | import getContainerStatsIfRunning from '../getContainerStats'; 21 | 22 | describe('getContainerStats', () => { 23 | beforeEach(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | it('should use the dockerd options if given', async () => { 27 | await getContainerStatsIfRunning('containerId', { 28 | dockerdHost: 'localhost', 29 | dockerdPort: 1234, 30 | }); 31 | expect(http.request).toHaveBeenCalledTimes(2); 32 | expect(http.request).toHaveBeenNthCalledWith( 33 | 1, 34 | expect.objectContaining({ 35 | host: 'localhost', 36 | port: 1234, 37 | path: '/containers/containerId/json', 38 | }), 39 | expect.any(Function) 40 | ); 41 | expect(http.request).toHaveBeenNthCalledWith( 42 | 2, 43 | expect.objectContaining({ 44 | host: 'localhost', 45 | port: 1234, 46 | path: '/containers/containerId/stats', 47 | }), 48 | expect.any(Function) 49 | ); 50 | }); 51 | it('should use default socket if no dockerd options given', async () => { 52 | await getContainerStatsIfRunning('containerId'); 53 | expect(http.request).toHaveBeenCalledTimes(2); 54 | expect(http.request).toHaveBeenNthCalledWith( 55 | 1, 56 | expect.objectContaining({ 57 | socketPath: '/var/run/docker.sock', 58 | path: '/containers/containerId/json', 59 | }), 60 | expect.any(Function) 61 | ); 62 | expect(http.request).toHaveBeenNthCalledWith( 63 | 2, 64 | expect.objectContaining({ 65 | socketPath: '/var/run/docker.sock', 66 | path: '/containers/containerId/stats', 67 | }), 68 | expect.any(Function) 69 | ); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | 3 | PACKAGE_VERSION := $(shell node -p -e "require('./package.json').version") 4 | SHORT_HASH := $(shell git rev-parse --short HEAD) 5 | 6 | BUILD_TARGETS := linux-x64,linux-arm,win32-x64,darwin-x64,darwin-arm64 7 | DEPLOY_TARGETS := $(BUILD_TARGETS),wsl-x64 8 | 9 | help: 10 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | gawk 'match($$0, /(makefile:)?(.*):.*?## (.*)/, a) {printf "\033[36m%-30s\033[0m %s\n", a[2], a[3]}' 11 | 12 | 13 | install: ## Install dependencies 14 | yarn install 15 | 16 | clean-dist: ## Remove dist folder 17 | rm -rf ./dist 18 | 19 | compile: clean-dist ## Compile the project 20 | yarn build 21 | 22 | typecheck: ## Typecheck the project 23 | yarn typecheck 24 | 25 | build: clean-dist ## Create tarballs of CLI 26 | yarn build && yarn set version classic && npx oclif pack tarballs -t $(BUILD_TARGETS) 27 | $(MAKE) generate-wsl-cli 28 | 29 | generate-wsl-cli: ## Generate WSL version of CLI 30 | cp ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-linux-x64.tar.gz ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-wsl-x64.tar.gz 31 | cp ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-linux-x64.tar.xz ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-wsl-x64.tar.xz 32 | cp ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-linux-x64-buildmanifest ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-wsl-x64-buildmanifest 33 | sed -i 's/linux/wsl/g' ./dist/greenframe-v$(PACKAGE_VERSION)-$(SHORT_HASH)-wsl-x64-buildmanifest 34 | 35 | upload: ## Upload tarballs to S3 bucket 36 | npx oclif upload tarballs -t $(DEPLOY_TARGETS) 37 | 38 | promote-staging: ## Publish uploaded tarballs on a staging channel 39 | npx oclif promote --version $(PACKAGE_VERSION) --sha $(SHORT_HASH) --channel staging -t $(DEPLOY_TARGETS) && yarn set version stable 40 | 41 | promote-prerelease: ## Publish uploaded tarballs on a prerelease channel 42 | npx oclif promote --version $(PACKAGE_VERSION) --sha $(SHORT_HASH) --channel prerelease -t $(DEPLOY_TARGETS) && yarn set version stable 43 | 44 | upload-installation-scripts: ## Publish on the bucket installion bash scripts 45 | yarn upload-installation-scripts 46 | 47 | promote-production: upload-installation-scripts ## Publish uploaded tarballs on a stable channel 48 | npx oclif promote --version $(PACKAGE_VERSION) --sha $(SHORT_HASH) -t $(DEPLOY_TARGETS) && yarn set version stable 49 | 50 | test: test-unit test-e2e ## Launch all tests 51 | 52 | test-unit: ## Launch unit test 53 | yarn test-unit 54 | 55 | test-e2e: ## Launch e2e test 56 | yarn build 57 | yarn test-e2e 58 | 59 | test-watch: ## Launch unit test in watch mode 60 | yarn test-watch 61 | 62 | lint: ## Launch lint 63 | yarn lint 64 | -------------------------------------------------------------------------------- /src/services/computeScenarioResult.ts: -------------------------------------------------------------------------------- 1 | import { ERROR_CODES, SCENARIO_STATUS } from '../constants'; 2 | import { 3 | getAverageMilestones, 4 | getAverageStats, 5 | getScenarioConsumption, 6 | getStats, 7 | } from '../index'; 8 | import { MetricsContainer, Milestone, ValueOf } from '../types'; 9 | 10 | export type ScenarioResult = { 11 | name: string; 12 | status: ValueOf; 13 | threshold?: number; 14 | precision?: number; 15 | score?: MetricsContainer; 16 | containers?: { 17 | name: string; 18 | type: string; 19 | stats: any[]; 20 | score: MetricsContainer; 21 | }[]; 22 | milestones?: Milestone[]; 23 | errorCode?: string; 24 | errorMessage?: string; 25 | executionCount?: number; 26 | }; 27 | 28 | export const computeScenarioResult = ({ 29 | allContainersStats, 30 | milestones, 31 | threshold, 32 | name, 33 | errorCode, 34 | errorMessage, 35 | executionCount, 36 | }: { 37 | allContainersStats?: Parameters[0]; 38 | milestones?: Milestone[][]; 39 | threshold?: number; 40 | name: string; 41 | errorCode?: string; 42 | errorMessage?: string; 43 | executionCount?: number; 44 | }): ScenarioResult => { 45 | if (!errorCode && allContainersStats && milestones) { 46 | const stats = getStats(allContainersStats); 47 | 48 | const isMultiContainers = allContainersStats.length > 1; 49 | 50 | const { totalScore, precision, metricsPerContainer } = getScenarioConsumption( 51 | stats, 52 | isMultiContainers 53 | ); 54 | 55 | const isThresholdExceeded = threshold != null && totalScore.co2.total > threshold; 56 | 57 | const averageMilestones = getAverageMilestones(milestones); 58 | 59 | return { 60 | name, 61 | threshold, 62 | status: isThresholdExceeded 63 | ? SCENARIO_STATUS.FAILED 64 | : SCENARIO_STATUS.FINISHED, 65 | errorCode: isThresholdExceeded ? ERROR_CODES.THRESHOLD_EXCEEDED : undefined, 66 | precision, 67 | score: totalScore, 68 | milestones: averageMilestones, 69 | containers: allContainersStats.map((container) => ({ 70 | name: container.name, 71 | type: container.type, 72 | score: metricsPerContainer[container.name], 73 | stats: getAverageStats(container.computedStats), 74 | })), 75 | executionCount, 76 | }; 77 | } 78 | 79 | return { 80 | name, 81 | threshold, 82 | status: SCENARIO_STATUS.FAILED, 83 | errorCode, 84 | errorMessage, 85 | executionCount, 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /src/runner/scenarioWrapper.js: -------------------------------------------------------------------------------- 1 | import getScopedPage from './scopedPage'; 2 | 3 | const { chromium } = require('playwright'); 4 | const { PlaywrightBlocker } = require('@cliqz/adblocker-playwright'); 5 | const fetch = require('cross-fetch'); // required 'fetch' 6 | 7 | const SCENARIO_TIMEOUT = 2 * 60 * 1000; // Global timeout for executing a scenario 8 | 9 | const relativizeMilestoneSamples = (milestones, startTime) => 10 | milestones.map(({ timestamp, ...milestone }) => ({ 11 | ...milestone, 12 | time: timestamp - startTime, 13 | })); 14 | 15 | const executeScenario = async (scenario, options = {}) => { 16 | let args = ['--disable-web-security']; 17 | 18 | if (options.hostIP) { 19 | args.push(`--host-rules=MAP localhost ${options.hostIP}`); 20 | for (const extraHost of options.extraHosts) { 21 | args.push(`--host-rules=MAP ${extraHost} ${options.hostIP}`); 22 | } 23 | } 24 | 25 | const browser = await chromium.launch({ 26 | defaultViewport: { 27 | width: 900, 28 | height: 600, 29 | }, 30 | args, 31 | timeout: 10_000, 32 | headless: !options.debug, 33 | executablePath: options.executablePath, 34 | }); 35 | 36 | const context = await browser.newContext({ 37 | userAgent: 38 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.105 Safari/537.36', 39 | ignoreHTTPSErrors: options.ignoreHTTPSErrors, 40 | locale: options.locale, 41 | timezoneId: options.timezoneId, 42 | }); 43 | 44 | context.setDefaultTimeout(60_000); 45 | 46 | const page = getScopedPage(await context.newPage(), options.baseUrl); 47 | 48 | if (options.useAdblock) { 49 | const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch); 50 | blocker.enableBlockingInPage(page); 51 | } 52 | 53 | await page.waitForTimeout(2000); 54 | 55 | const start = new Date(); 56 | let success = false; 57 | try { 58 | const timeoutScenario = setTimeout(() => { 59 | throw new Error( 60 | `Timeout: Your scenario took more than ${SCENARIO_TIMEOUT / 1000}s` 61 | ); 62 | }, SCENARIO_TIMEOUT); 63 | 64 | await scenario(page); 65 | clearTimeout(timeoutScenario); 66 | 67 | success = true; 68 | } finally { 69 | if (!options.debug || success) { 70 | await browser.close(); 71 | } 72 | } 73 | 74 | const end = new Date(); 75 | 76 | return { 77 | timelines: { 78 | title: options.name, 79 | start: start.toISOString(), 80 | end: end.toISOString(), 81 | }, 82 | milestones: relativizeMilestoneSamples(page.getMilestones(), start.getTime()), 83 | }; 84 | }; 85 | 86 | process.on('uncaughtException', (err) => { 87 | throw err; 88 | }); 89 | 90 | process.on('unhandledRejection', (err) => { 91 | throw err; 92 | }); 93 | 94 | module.exports = executeScenario; 95 | -------------------------------------------------------------------------------- /src/examples/playstation.js: -------------------------------------------------------------------------------- 1 | const playstation = async (page) => { 2 | // Go to https://www.playstation.com/fr-fr/ 3 | await page.goto('/fr-fr/', { 4 | waitUntil: 'networkidle', 5 | }); 6 | // Click text=Accept 7 | await page.scrollToElement('text=Accept'); 8 | await page.click('text=Accept'); 9 | await page.waitForNetworkIdle(); 10 | // Click figcaption:has-text("Manette sans fil DualSense™") 11 | await page.scrollToElement('figcaption:has-text("Manette sans fil DualSense™")'); 12 | await page.click('figcaption:has-text("Manette sans fil DualSense™")'); 13 | // Click figcaption:has-text("Casque-micro sans fil PULSE 3D™") 14 | await page.waitForTimeout(500); 15 | 16 | await page.scrollToElement('figcaption:has-text("Casque-micro sans fil PULSE 3D™")'); 17 | await page.click('figcaption:has-text("Casque-micro sans fil PULSE 3D™")'); 18 | // Click figcaption:has-text("Télécommande multimédia") 19 | await page.waitForTimeout(500); 20 | 21 | await page.scrollToElement('figcaption:has-text("Télécommande multimédia")'); 22 | await page.click('figcaption:has-text("Télécommande multimédia")'); 23 | // Click figcaption:has-text("Caméra HD") 24 | await page.waitForTimeout(500); 25 | await page.scrollToElement('figcaption:has-text("Caméra HD")'); 26 | await page.click('figcaption:has-text("Caméra HD")'); 27 | // Click figcaption:has-text("Console PS5") 28 | await page.waitForTimeout(500); 29 | await page.scrollToElement('figcaption:has-text("Console PS5")'); 30 | await page.click('figcaption:has-text("Console PS5")'); 31 | // Click a[role="button"]:has-text("Plus d'infos") 32 | await page.waitForTimeout(500); 33 | await Promise.all([ 34 | page.waitForNavigation({ 35 | /* url: 'https://www.playstation.com/fr-fr/corporate/about-us/' */ waitUntil: 36 | 'networkidle', 37 | }), 38 | page.scrollToElement('a[role="button"]:has-text("Plus d\'infos")'), 39 | page.click('a[role="button"]:has-text("Plus d\'infos")'), 40 | ]); 41 | 42 | // assert.equal(page.url(), 'https://www.playstation.com/fr-fr/ps5/'); 43 | // Click .section--lightAlt div div:nth-child(3) .content-grid div .buttonblock .btn-block div .button .cta__primary 44 | // await Promise.all([ 45 | // page.waitForNavigation({ /* url: 'https://www.playstation.com/fr-fr/corporate/about-us/', */, waitUntil: 'networkidle' }), 46 | // page.click( 47 | // '.section--lightAlt div div:nth-child(3) .content-grid div .buttonblock .btn-block div .button .cta__primary' 48 | // ), 49 | // ]); 50 | 51 | // assert.equal(page.url(), 'https://www.playstation.com/fr-fr/games/ratchet-and-clank-rift-apart/'); 52 | // Click text=À propos 53 | await Promise.all([ 54 | page.waitForNavigation({ 55 | /* url: 'https://www.playstation.com/fr-fr/corporate/about-us/', */ waitUntil: 56 | 'networkidle', 57 | }), 58 | page.scrollToElement('text=À propos'), 59 | page.click('text=À propos'), 60 | ]); 61 | }; 62 | 63 | module.exports = playstation; 64 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/mergePodStatsWithNetworkStats.ts: -------------------------------------------------------------------------------- 1 | import { KubernetesRuns, kubernetesStats } from '..'; 2 | import { SubObjects } from '../../../types'; 3 | import { Network } from './stats'; 4 | import { Nodes } from './structureNodes'; 5 | 6 | /** 7 | * Merge pods stats with the network stats 8 | * Divide network stats by the number of linked containers 9 | * Delete the network stats 10 | * @param nodes 11 | * @param runs 12 | */ 13 | export const mergePodStatsWithNetworkStats = (nodes: Nodes, runs: KubernetesRuns) => { 14 | for (const node of Object.keys(nodes)) { 15 | const [, ...pods] = nodes[node]; 16 | for (const pod of pods) { 17 | if (pod.container === 'network') { 18 | const networkdStats = runs[pod.greenframeId].kubernetesStats; 19 | const numberOfLinkedContainers = pod.linkedContainers.length; 20 | for (const linkedContainer of pod.linkedContainers) { 21 | const lonePodStats = runs[linkedContainer].kubernetesStats; 22 | mergeStats(networkdStats, lonePodStats, numberOfLinkedContainers); 23 | delete runs[pod.greenframeId]; 24 | } 25 | } 26 | } 27 | } 28 | }; 29 | 30 | const mergeStats = ( 31 | networkStats: kubernetesStats, 32 | podStats: kubernetesStats, 33 | divideStatsBy: number 34 | ) => { 35 | for (const [i, podStat] of podStats.entries()) { 36 | for (let j = 0; j < podStat.stats.length; j++) { 37 | for (let k = 0; k < podStat.stats[j].stats.length; k++) { 38 | mergeNetwork( 39 | networkStats[i].stats[j].stats[k].network, 40 | podStat.stats[j].stats[k].network, 41 | divideStatsBy 42 | ); 43 | } 44 | } 45 | } 46 | }; 47 | 48 | const mergeNetwork = (network: Network, pod: Network, divideStatsBy: number) => { 49 | mergeObject(network, pod, divideStatsBy); 50 | }; 51 | 52 | const mergeObject = >( 53 | obj: T | SubObjects, 54 | pod: T | SubObjects, 55 | divideStatsBy: number 56 | ) => { 57 | for (const key of Object.keys(obj) as Array) { 58 | if (typeof obj[key] === 'object') { 59 | if (pod[key] === undefined) { 60 | pod[key] = {} as T[typeof key]; 61 | } 62 | 63 | mergeObject(obj[key], pod[key], divideStatsBy); 64 | } else if (Array.isArray(obj[key])) { 65 | if (pod[key] === undefined) { 66 | pod[key] = [] as T[typeof key]; 67 | } 68 | 69 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 70 | // @ts-ignore 71 | mergeObject(obj[key], pod[key], divideStatsBy); 72 | } else if (typeof obj[key] === 'number') { 73 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 74 | // @ts-ignore 75 | pod[key] = Math.floor(obj[key] / divideStatsBy); 76 | } else { 77 | pod[key] = obj[key]; 78 | } 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/services/git/__tests__/index.js: -------------------------------------------------------------------------------- 1 | jest.mock('../utils'); 2 | const { 3 | getCommitMessage, 4 | getBranchName, 5 | getCommitId, 6 | getCommitAncestorWithDefaultBranch, 7 | } = require('../utils'); 8 | 9 | const { retrieveGitInformations } = require('../index'); 10 | 11 | describe('#retrieveGitInformations', () => { 12 | beforeEach(() => { 13 | getCommitMessage.mockResolvedValue('DEFAULT COMMIT MESSAGE'); 14 | getBranchName.mockResolvedValue('default_branch_name'); 15 | getCommitId.mockResolvedValue('default-commit-id'); 16 | getCommitAncestorWithDefaultBranch.mockResolvedValue('default-branch-commit-id'); 17 | }); 18 | 19 | it('Should correctly retrieve commitMessage', async () => { 20 | const gitInfos = await retrieveGitInformations(); 21 | expect(getCommitMessage).toHaveBeenCalled(); 22 | expect(gitInfos.commitMessage).toBe('DEFAULT COMMIT MESSAGE'); 23 | }); 24 | 25 | it('Should correctly set commitMessage by default', async () => { 26 | const gitInfos = await retrieveGitInformations({ 27 | commitMessage: 'CUSTOM COMMIT MESSAGE', 28 | }); 29 | 30 | expect(getCommitMessage).not.toHaveBeenCalled(); 31 | expect(gitInfos.commitMessage).toBe('CUSTOM COMMIT MESSAGE'); 32 | }); 33 | 34 | it('Should correctly retrieve branchName', async () => { 35 | const gitInfos = await retrieveGitInformations(); 36 | expect(getBranchName).toHaveBeenCalled(); 37 | expect(gitInfos.branchName).toBe('default_branch_name'); 38 | }); 39 | 40 | it('Should correctly set branchName by default', async () => { 41 | const gitInfos = await retrieveGitInformations({ 42 | branchName: 'custom_branch_name', 43 | }); 44 | 45 | expect(getBranchName).not.toHaveBeenCalled(); 46 | expect(gitInfos.branchName).toBe('custom_branch_name'); 47 | }); 48 | 49 | it('Should correctly retrieve commitId', async () => { 50 | const gitInfos = await retrieveGitInformations(); 51 | expect(getCommitId).toHaveBeenCalled(); 52 | expect(gitInfos.commitId).toBe('default-commit-id'); 53 | }); 54 | 55 | it('Should correctly set commitId by default', async () => { 56 | const gitInfos = await retrieveGitInformations({ 57 | commitId: 'custom-commit-id', 58 | }); 59 | 60 | expect(getCommitId).not.toHaveBeenCalled(); 61 | expect(gitInfos.commitId).toBe('custom-commit-id'); 62 | }); 63 | 64 | it('Should correctly retrieve defaultBranchCommitReference', async () => { 65 | const gitInfos = await retrieveGitInformations(); 66 | expect(getCommitAncestorWithDefaultBranch).not.toHaveBeenCalled(); 67 | expect(gitInfos.defaultBranchCommitReference).toBeUndefined(); 68 | }); 69 | 70 | it('Should not retrieve defaultBranchCommitReference', async () => { 71 | const gitInfos = await retrieveGitInformations({}, 'default_branch'); 72 | expect(getCommitAncestorWithDefaultBranch).toHaveBeenCalled(); 73 | 74 | expect(gitInfos.defaultBranchCommitReference).toBe('default-branch-commit-id'); 75 | }); 76 | 77 | afterEach(() => { 78 | getCommitMessage.mockReset(); 79 | getBranchName.mockReset(); 80 | getCommitId.mockReset(); 81 | getCommitAncestorWithDefaultBranch.mockReset(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/services/container/getContainerStats.js: -------------------------------------------------------------------------------- 1 | const http = require('node:http'); 2 | const ConfigurationError = require('../errors/ConfigurationError'); 3 | const initDebug = require('debug'); 4 | 5 | const debug = initDebug('greenframe:services:contaiener:getContainerStats'); 6 | 7 | const getIsContainerRunning = (containerName, dockerdOptions) => { 8 | return new Promise((resolve, reject) => { 9 | const options = { 10 | path: `/containers/${containerName}/json`, 11 | method: 'GET', 12 | }; 13 | if (dockerdOptions && dockerdOptions.dockerdHost) { 14 | options.host = dockerdOptions.dockerdHost; 15 | options.port = dockerdOptions.dockerdPort || 2375; 16 | } else { 17 | options.socketPath = '/var/run/docker.sock'; 18 | } 19 | 20 | try { 21 | const callback = (res) => { 22 | if (res.statusCode !== 200) { 23 | reject( 24 | `${containerName} container may has encountered an issue. Status code:${res.statusCode}. Status message:${res.statusMessage}` 25 | ); 26 | } else { 27 | resolve(); 28 | } 29 | }; 30 | 31 | const clientRequest = http.request(options, callback).on('error', (error) => { 32 | debug('Error while requesting docker api', error); 33 | reject( 34 | `Could not connect to Docker daemon on ${options.host}:${options.port}` 35 | ); 36 | }); 37 | clientRequest.end(); 38 | } catch (error) { 39 | reject(error); 40 | } 41 | }); 42 | }; 43 | 44 | const getContainerStats = (containerName, dockerdOptions) => { 45 | const options = { 46 | path: `/containers/${containerName}/stats`, 47 | method: 'GET', 48 | }; 49 | if (dockerdOptions && dockerdOptions.dockerdHost) { 50 | options.host = dockerdOptions.dockerdHost; 51 | options.port = dockerdOptions.dockerdPort || 2375; 52 | } else { 53 | options.socketPath = '/var/run/docker.sock'; 54 | } 55 | 56 | const stats = []; 57 | const callback = (res) => { 58 | res.on('data', (data) => { 59 | try { 60 | const stat = JSON.parse(Buffer.from(data).toString()); 61 | stats.push(stat); 62 | } catch { 63 | // Stream ended, ignoring data 64 | } 65 | }); 66 | 67 | res.on('error', (e) => { 68 | // This code is fired when we call clientRequest.destroy(); 69 | if (e.code !== 'ECONNRESET') { 70 | console.error(e); 71 | } 72 | }); 73 | }; 74 | 75 | const clientRequest = http.request(options, callback); 76 | clientRequest.end(); 77 | 78 | const stopContainerStats = () => { 79 | clientRequest.destroy(); 80 | return stats; 81 | }; 82 | 83 | return stopContainerStats; 84 | }; 85 | 86 | const getContainerStatsIfRunning = async (containerName, dockerdOptions) => { 87 | try { 88 | await getIsContainerRunning(containerName, dockerdOptions); 89 | return getContainerStats(containerName, dockerdOptions); 90 | } catch (error) { 91 | throw new ConfigurationError(error); 92 | } 93 | }; 94 | 95 | module.exports = getContainerStatsIfRunning; 96 | -------------------------------------------------------------------------------- /src/model/stat-tools/docker/__tests__/computeStats.test.ts: -------------------------------------------------------------------------------- 1 | import { CONTAINER_TYPES } from '../../../../constants'; 2 | import { GenericStat } from '../../../../types'; 3 | import { computeStats } from '../computeStats'; 4 | 5 | test('computeStats', () => { 6 | const stats: GenericStat[] = [ 7 | { 8 | date: new Date('2020-11-04T10:07:40.940Z'), 9 | cpu: { 10 | availableSystemCpuUsage: 0, 11 | currentUsageInKernelMode: 0, 12 | currentUsageInUserMode: 0, 13 | }, 14 | io: { 15 | currentBytes: 0, 16 | }, 17 | memory: { 18 | usage: 0, 19 | }, 20 | network: { 21 | currentReceived: 0, 22 | currentTransmitted: 0, 23 | }, 24 | }, 25 | { 26 | date: new Date('2020-11-04T10:07:41.965Z'), 27 | cpu: { 28 | availableSystemCpuUsage: 0.89, 29 | currentUsageInKernelMode: 0.02, 30 | currentUsageInUserMode: 0.0525, 31 | }, 32 | io: { 33 | currentBytes: 303_104, 34 | }, 35 | network: { 36 | currentReceived: 0, 37 | currentTransmitted: 0, 38 | }, 39 | memory: { usage: 101_158_912 }, 40 | }, 41 | ]; 42 | 43 | const res = computeStats({ 44 | stats, 45 | timeframes: [ 46 | { 47 | start: new Date('2020-11-04T10:07:39.000Z'), 48 | end: new Date('2020-11-04T10:07:43.000Z'), 49 | title: 'title', 50 | }, 51 | ], 52 | meta: { 53 | sample: 0, 54 | container: 'c0', 55 | type: CONTAINER_TYPES.SERVER, 56 | }, 57 | }); 58 | 59 | expect(res).toHaveLength(1); 60 | 61 | expect(res).toEqual([ 62 | { 63 | meta: { sample: 0, container: 'c0', type: 'SERVER' }, 64 | active: true, 65 | time: 1.025, 66 | userTime: 1.025, 67 | date: new Date('2020-11-04T10:07:41.965Z'), 68 | ratio: 1, 69 | timeframe: { 70 | end: new Date('2020-11-04T10:07:43.000Z'), 71 | start: new Date('2020-11-04T10:07:39.000Z'), 72 | title: 'title', 73 | }, 74 | cpu: { 75 | availableSystemCpuUsage: 0.868_292_682_926_829_4, 76 | cpuPercentage: 5.898_876_404_494_381_6, 77 | totalUsageInUserMode: 0.0525, 78 | totalUsageInKernelMode: 0.02, 79 | currentUsageInUserMode: 0.051_219_512_195_121_955, 80 | // eslint-disable-next-line @typescript-eslint/no-loss-of-precision 81 | currentUsageInKernelMode: 0.019_512_195_121_951_223_2, 82 | }, 83 | io: { 84 | // eslint-disable-next-line @typescript-eslint/no-loss-of-precision 85 | currentBytes: 295_711.219_512_195_154, 86 | totalBytes: 303_104, 87 | }, 88 | network: { 89 | currentReceived: 0, 90 | currentTransmitted: 0, 91 | totalReceived: 0, 92 | totalTransmitted: 0, 93 | }, 94 | memory: { usage: 101_158_912 }, 95 | }, 96 | ]); 97 | }); 98 | -------------------------------------------------------------------------------- /src/commands/kube-config.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core'; 2 | import { Listr } from 'listr2'; 3 | import { parseConfigFile, resolveParams } from '../services/parseConfigFile'; 4 | import { addKubeGreenframeDaemonset } from '../tasks/addKubeGreenframeDaemonset'; 5 | import { addKubeGreenframeNamespace } from '../tasks/addKubeGreenframeNamespace'; 6 | import { deleteKubeGreenframeNamespace } from '../tasks/deleteKubeGreenframeNamespace'; 7 | import initializeKubeClient from '../tasks/initializeKubeClient'; 8 | 9 | class KubeConfigCommand extends Command { 10 | static args = []; 11 | 12 | static flags = { 13 | configFile: Flags.string({ 14 | char: 'C', 15 | description: 'Path to config file', 16 | required: false, 17 | }), 18 | kubeConfig: Flags.string({ 19 | char: 'K', 20 | description: 'Path to kubernetes client config file', 21 | required: false, 22 | }), 23 | delete: Flags.boolean({ 24 | char: 'D', 25 | description: 'Delete daemonset and namespace from kubernetes cluster', 26 | required: false, 27 | }), 28 | }; 29 | 30 | static defaultFlags = { 31 | configFile: './.greenframe.yml', 32 | }; 33 | 34 | async run() { 35 | const commandParams = await this.parse(KubeConfigCommand); 36 | const configFilePath = 37 | commandParams.flags.configFile ?? KubeConfigCommand.defaultFlags.configFile; 38 | 39 | const configFileParams = await parseConfigFile(configFilePath); 40 | 41 | const { flags } = resolveParams( 42 | KubeConfigCommand.defaultFlags, 43 | configFileParams, 44 | commandParams 45 | ); 46 | if (flags.delete) { 47 | await new Listr([ 48 | { 49 | title: 'Check configuration file', 50 | task: async (ctx) => { 51 | ctx.flags = flags; 52 | }, 53 | }, 54 | { 55 | title: 'Initialize kubernetes client', 56 | task: initializeKubeClient, 57 | }, 58 | { 59 | title: 'Deleting Greenframe namespace', 60 | task: deleteKubeGreenframeNamespace, 61 | }, 62 | ]).run(); 63 | return; 64 | } 65 | 66 | const tasks = new Listr([ 67 | { 68 | title: 'Check configuration file', 69 | task: async (ctx) => { 70 | ctx.flags = flags; 71 | }, 72 | }, 73 | { 74 | title: 'Intializing kubernetes client', 75 | task: initializeKubeClient, 76 | }, 77 | { 78 | title: 'Creating greenframe namespace', 79 | task: addKubeGreenframeNamespace, 80 | }, 81 | { 82 | title: 'Creating greenframe daemonset', 83 | task: addKubeGreenframeDaemonset, 84 | }, 85 | ]); 86 | await tasks.run(); 87 | console.info('\nKubernetes configuration complete !\n'); 88 | } 89 | } 90 | 91 | KubeConfigCommand.description = `Configure kubernetes cluster to collect greenframe metrics 92 | ... 93 | greenframe kube-config 94 | `; 95 | 96 | module.exports = KubeConfigCommand; 97 | -------------------------------------------------------------------------------- /src/runner/scopedPage.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright'; 2 | import { URL } from 'node:url'; 3 | import ConfigurationError from '../services/errors/ConfigurationError'; 4 | 5 | const getScopedPage = (page: Page, baseUrl: string) => { 6 | if (!baseUrl) { 7 | throw new ConfigurationError('You must provide a base url!'); 8 | } 9 | 10 | const waitForNetworkIdle = (options: Parameters[1] = {}) => 11 | page.waitForLoadState('networkidle', options); 12 | 13 | const scrollToElement = (element: Parameters[0]) => 14 | page.$eval(element, (element) => 15 | element.scrollIntoView({ 16 | behavior: 'smooth', 17 | block: 'center', 18 | }) 19 | ); 20 | 21 | const scrollByDistance = (distance: number) => 22 | page.evaluate( 23 | ([distance]) => { 24 | window.scrollTo({ 25 | top: window.scrollY + distance, 26 | behavior: 'smooth', 27 | }); 28 | }, 29 | [distance] 30 | ); 31 | 32 | const scrollToEnd = () => 33 | page.evaluate(async () => { 34 | const delay = (ms: number) => 35 | new Promise((resolve) => setTimeout(resolve, ms)); 36 | for (let i = 0; i < document.body.scrollHeight; i += 100) { 37 | window.scrollTo(0, i); 38 | await delay(25); 39 | } 40 | }); 41 | 42 | function addMilestone(this: Page, title: string) { 43 | if (this._milestones.some((m) => m.title === title)) { 44 | throw new Error(`Milestone "${title}" already exists`); 45 | } 46 | 47 | this._milestones.push({ title, timestamp: Date.now() }); 48 | return this; 49 | } 50 | 51 | function getMilestones(this: Page) { 52 | return this._milestones; 53 | } 54 | 55 | const resolveURL = (path = '') => { 56 | const url = new URL(path, baseUrl); 57 | return url.toString(); 58 | }; 59 | 60 | const scopedPage = page as Page; 61 | 62 | scopedPage._milestones = []; 63 | 64 | const originalGoTo = page.goto.bind(scopedPage); 65 | const originalWaitForNavigation = page.waitForNavigation.bind(scopedPage); 66 | 67 | // Playwright API without some methods 68 | 69 | for (const method of ['addInitScript', 'pdf', 'video', 'screenshot'] as const) { 70 | scopedPage[method] = () => { 71 | throw new Error(`Invalid method call "${method}"`); 72 | }; 73 | } 74 | 75 | // Overrided Playwright API 76 | 77 | scopedPage.goto = (path = '', options = {}) => 78 | originalGoTo(resolveURL(path), options); 79 | scopedPage.waitForNavigation = (options = {}) => 80 | originalWaitForNavigation({ 81 | ...options, 82 | url: options.path ? resolveURL(options.path) : undefined, 83 | }); 84 | 85 | // Custom Greenframe API 86 | 87 | scopedPage.waitForNetworkIdle = waitForNetworkIdle; 88 | scopedPage.scrollToElement = scrollToElement; 89 | scopedPage.scrollByDistance = scrollByDistance; 90 | scopedPage.addMilestone = addMilestone; 91 | scopedPage.scrollToEnd = scrollToEnd; 92 | 93 | // Internal Greenframe API 94 | 95 | scopedPage.getMilestones = getMilestones; 96 | 97 | return scopedPage; 98 | }; 99 | 100 | export default getScopedPage; 101 | -------------------------------------------------------------------------------- /src/tasks/runScenariosAndSaveResult.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import { saveFinishedAnalysis } from '../services/api/analyses'; 3 | 4 | import { computeScenarioResult, ScenarioResult } from '../services/computeScenarioResult'; 5 | import { executeScenarioAndGetContainerStats } from '../services/container'; 6 | import ConfigurationError from '../services/errors/ConfigurationError'; 7 | import ERROR_CODES from '../services/errors/errorCodes'; 8 | import { computeAnalysisResult } from '../services/computeAnalysisResult'; 9 | 10 | const debug = initDebug('greenframe:tasks:runScenarioAndSaveResults'); 11 | 12 | export default async (ctx: any) => { 13 | const { analysisId, args, flags } = ctx; 14 | const resultScenarios: ScenarioResult[] = []; 15 | for (let index = 0; index < args.scenarios.length; index++) { 16 | const scenario = args.scenarios[index]; 17 | 18 | debug(`Running scenario ${scenario.path}...`); 19 | 20 | try { 21 | const { allContainers, allMilestones } = 22 | await executeScenarioAndGetContainerStats({ 23 | scenario: scenario.path, 24 | url: args.baseURL, 25 | samples: flags.samples, 26 | useAdblock: flags.useAdblock, 27 | containers: flags.containers, 28 | databaseContainers: flags.databaseContainers, 29 | kubeContainers: flags.kubeContainers, 30 | kubeDatabaseContainers: flags.kubeDatabaseContainers, 31 | extraHosts: flags.extraHosts, 32 | envVars: flags.envVar, 33 | envFile: flags.envFile, 34 | dockerdHost: flags.dockerdHost, 35 | dockerdPort: flags.dockerdPort, 36 | ignoreHTTPSErrors: flags.ignoreHTTPSErrors, 37 | locale: flags.locale, 38 | timezoneId: flags.timezoneId, 39 | }); 40 | 41 | const data = computeScenarioResult({ 42 | allContainersStats: allContainers, 43 | milestones: allMilestones, 44 | threshold: scenario.threshold, 45 | name: scenario.name, 46 | executionCount: scenario.executionCount, 47 | }); 48 | resultScenarios.push(data); 49 | } catch (error) { 50 | // If the error is not due to scenario but a problem inside the configuration 51 | // Running others scenario is not useful, so let's stop the command. 52 | if (error instanceof ConfigurationError) { 53 | throw error; 54 | } 55 | 56 | if (error instanceof Error) { 57 | debug('Error while running scenario', error); 58 | const data = computeScenarioResult({ 59 | threshold: scenario.threshold, 60 | name: scenario.name, 61 | errorCode: ERROR_CODES.SCENARIO_FAILED, 62 | errorMessage: error.message, 63 | executionCount: scenario.executionCount, 64 | }); 65 | resultScenarios.push(data); 66 | } 67 | } 68 | } 69 | 70 | const result = computeAnalysisResult(resultScenarios); 71 | if (!ctx.isFree) { 72 | const { data: analysis } = await saveFinishedAnalysis(analysisId, result); 73 | ctx.result = { analysis, scenarios: resultScenarios, computed: result }; 74 | } else { 75 | ctx.result = { 76 | analysis: undefined, 77 | scenarios: resultScenarios, 78 | computed: result, 79 | }; 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /src/tasks/displayAnalysisResult.js: -------------------------------------------------------------------------------- 1 | const ERROR_CODES = require('../services/errors/errorCodes'); 2 | const STATUS = require('../status').STATUS; 3 | 4 | const APP_BASE_URL = process.env.APP_URL ?? 'https://app.greenframe.io'; 5 | 6 | const computeTotalMetric = (metric) => Math.round(metric * 1000) / 1000; 7 | const formatTotal = (total, unit) => { 8 | if (total >= 1_000_000 && unit === 'g') { 9 | return `${computeTotalMetric(total / 1_000_000)} t`; 10 | } 11 | 12 | if (total <= 100_000 && total >= 1000) { 13 | return `${computeTotalMetric(total / 1000)} k${unit}`; 14 | } 15 | 16 | if (total < 1) { 17 | return `${computeTotalMetric(total * 1000)} m${unit}`; 18 | } 19 | 20 | return `${computeTotalMetric(total)} ${unit}`; 21 | }; 22 | 23 | const displayAnalysisResults = (result, isFree) => { 24 | console.info('\nAnalysis complete !\n'); 25 | console.info('Result summary:'); 26 | let maximumPrecision = 0; 27 | for (const scenario of result.scenarios) { 28 | console.info('\n'); 29 | const totalCo2 = formatTotal(scenario.score?.co2?.total, 'g'); 30 | const totalMWh = formatTotal(scenario.score?.wh?.total, 'Wh'); 31 | const precision = Math.round(scenario.precision * 10) / 10; 32 | if (precision > maximumPrecision) { 33 | maximumPrecision = precision; 34 | } 35 | 36 | if (scenario.status === STATUS.FINISHED) { 37 | console.info(`✅ ${scenario.name} completed`); 38 | if (scenario.threshold) { 39 | console.info( 40 | `The estimated footprint at ${totalCo2} eq. co2 ± ${precision}% (${totalMWh}) is under the limit configured at ${scenario.threshold} g eq. co2.` 41 | ); 42 | } else { 43 | console.info( 44 | `The estimated footprint is ${totalCo2} eq. co2 ± ${precision}% (${totalMWh}).` 45 | ); 46 | } 47 | 48 | if (scenario.executionCount) { 49 | console.info( 50 | `For ${ 51 | scenario.executionCount 52 | } scenario executions, this represents ${formatTotal( 53 | (scenario.score?.co2?.total || 0) * scenario.executionCount, 54 | 'g' 55 | )} eq. co2` 56 | ); 57 | } 58 | } else { 59 | console.error(`❌ ${scenario.name} failed`); 60 | switch (scenario.errorCode) { 61 | case ERROR_CODES.SCENARIO_FAILED: 62 | console.error(`This scenario failed during the execution: 63 | ${scenario.errorMessage} 64 | 65 | Use greenframe open command to run your scenario in debug mode.`); 66 | break; 67 | case ERROR_CODES.THRESHOLD_EXCEEDED: 68 | console.error( 69 | `The estimated footprint at ${totalCo2} eq. co2 ± ${precision}% (${totalMWh}) passes the limit configured at ${scenario.threshold} g eq. co2.` 70 | ); 71 | break; 72 | } 73 | } 74 | } 75 | 76 | if (result.scenarios.length > 1) { 77 | const totalCo2 = formatTotal(result.computed.score?.co2?.total, 'g'); 78 | const totalMWh = formatTotal(result.computed.score?.wh?.total, 'Wh'); 79 | 80 | console.info( 81 | `\nThe sum of estimated footprint is ${totalCo2} eq. co2 ± ${maximumPrecision}% (${totalMWh}).` 82 | ); 83 | } 84 | 85 | if (!isFree) { 86 | console.info( 87 | `\nCheck the details of your analysis at ${APP_BASE_URL}/analyses/${result.analysis.id}` 88 | ); 89 | } 90 | 91 | /* prettier-ignore */ 92 | process.exit( 93 | result.computed.errorCode == null 94 | ? 0 95 | : 1 96 | ); 97 | }; 98 | 99 | module.exports = displayAnalysisResults; 100 | -------------------------------------------------------------------------------- /src/services/__tests__/parseConfigFile.js: -------------------------------------------------------------------------------- 1 | const { resolveParams } = require('../parseConfigFile'); 2 | 3 | describe('#resolveParams', () => { 4 | test('Should return flags default values', () => { 5 | const { flags } = resolveParams( 6 | { 7 | default: 'DEFAULT', 8 | samples: 3, 9 | }, 10 | { 11 | args: { 12 | scenarios: [{ path: 'PATH_TO_SCENARIO' }], 13 | baseURL: 'BASE_URL', 14 | }, 15 | flags: {}, 16 | }, 17 | { 18 | args: {}, 19 | flags: {}, 20 | } 21 | ); 22 | 23 | expect(flags).toEqual({ 24 | default: 'DEFAULT', 25 | samples: 3, 26 | }); 27 | }); 28 | 29 | test('Should not throw an error because no scenario has been provided', () => { 30 | expect(() => { 31 | resolveParams( 32 | { 33 | default: 'DEFAULT', 34 | samples: 3, 35 | }, 36 | { 37 | args: { 38 | baseURL: 'BASE_URL', 39 | }, 40 | flags: {}, 41 | }, 42 | { 43 | args: {}, 44 | flags: {}, 45 | } 46 | ); 47 | }).not.toThrow(); 48 | }); 49 | 50 | test('Should throw an error because no baseURL has been provided', () => { 51 | expect(() => { 52 | resolveParams( 53 | { 54 | default: 'DEFAULT', 55 | samples: 3, 56 | }, 57 | { 58 | args: { scenarios: [{ path: 'PATH_TO_SCENARIO' }] }, 59 | flags: {}, 60 | }, 61 | { 62 | args: {}, 63 | flags: {}, 64 | } 65 | ); 66 | }).toThrow('You must provide a "baseURL" argument.'); 67 | }); 68 | 69 | test('Should override defaultFlags by configFile flags', () => { 70 | const { args, flags } = resolveParams( 71 | { 72 | default: 'DEFAULT', 73 | samples: 3, 74 | }, 75 | { 76 | args: { 77 | scenarios: [{ path: 'PATH_TO_SCENARIO' }], 78 | baseURL: 'YOUR_BASE_URL', 79 | }, 80 | flags: { samples: 4 }, 81 | }, 82 | { 83 | args: {}, 84 | flags: {}, 85 | } 86 | ); 87 | 88 | expect(flags).toEqual({ 89 | default: 'DEFAULT', 90 | samples: 4, 91 | }); 92 | 93 | expect(args).toEqual({ 94 | scenarios: [{ path: 'PATH_TO_SCENARIO' }], 95 | baseURL: 'YOUR_BASE_URL', 96 | }); 97 | }); 98 | 99 | test('Should override configFile flags and args by command flags and args', () => { 100 | const { args, flags } = resolveParams( 101 | { 102 | default: 'DEFAULT', 103 | samples: 3, 104 | }, 105 | { 106 | args: { 107 | scenarios: [{ path: 'PATH_TO_SCENARIO' }], 108 | baseURL: 'YOUR_BASE_URL', 109 | }, 110 | flags: { samples: 4 }, 111 | }, 112 | { 113 | flags: {}, 114 | args: { 115 | baseURL: 'ANOTHER_BASE_URL', 116 | }, 117 | } 118 | ); 119 | 120 | expect(flags).toEqual({ 121 | default: 'DEFAULT', 122 | samples: 4, 123 | }); 124 | 125 | expect(args).toEqual({ 126 | scenarios: [{ path: 'PATH_TO_SCENARIO' }], 127 | baseURL: 'ANOTHER_BASE_URL', 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/commands/open.js: -------------------------------------------------------------------------------- 1 | const { Command, Flags } = require('@oclif/core'); 2 | const path = require('node:path'); 3 | 4 | const { parseConfigFile, resolveParams } = require('../services/parseConfigFile'); 5 | 6 | const executeScenario = require('../runner/scenarioWrapper.js'); 7 | 8 | const { detectExecutablePath } = require('../services/detectExecutablePath'); 9 | class OpenCommand extends Command { 10 | static args = [ 11 | { 12 | name: 'baseURL', // name of arg to show in help and reference with args[name] 13 | description: 'Your baseURL website', // help description 14 | }, 15 | { 16 | name: 'scenario', // name of arg to show in help and reference with args[name] 17 | description: 'Path to your GreenFrame scenario', // help description 18 | required: false, 19 | }, 20 | ]; 21 | 22 | static defaultFlags = { 23 | configFile: './.greenframe.yml', 24 | useAdblock: false, 25 | ignoreHTTPSErrors: false, 26 | }; 27 | 28 | static flags = { 29 | configFile: Flags.string({ 30 | char: 'C', 31 | description: 'Path to config file', 32 | required: false, 33 | }), 34 | useAdblock: Flags.boolean({ 35 | char: 'a', 36 | description: 'Use an adblocker during analysis', 37 | }), 38 | ignoreHTTPSErrors: Flags.boolean({ 39 | description: 'Ignore HTTPS errors during analysis', 40 | }), 41 | locale: Flags.boolean({ 42 | description: 'Set greenframe browser locale', 43 | }), 44 | timezoneId: Flags.boolean({ 45 | description: 'Set greenframe browser timezoneId', 46 | }), 47 | }; 48 | 49 | async run() { 50 | const commandParams = await this.parse(OpenCommand); 51 | const configFilePath = 52 | commandParams.flags.configFile ?? OpenCommand.defaultFlags.configFile; 53 | const configFileParams = await parseConfigFile(configFilePath); 54 | 55 | const { args, flags } = resolveParams( 56 | OpenCommand.defaultFlags, 57 | configFileParams, 58 | commandParams 59 | ); 60 | 61 | const executablePath = await detectExecutablePath(); 62 | 63 | console.info(`Running ${args.scenarios.length} scenarios...`); 64 | for (let index = 0; index < args.scenarios.length; index++) { 65 | const scenario = args.scenarios[index]; 66 | const scenarioPath = path.resolve(scenario.path); 67 | const scenarioFile = require(scenarioPath); 68 | try { 69 | const { timelines } = await executeScenario(scenarioFile, { 70 | debug: true, 71 | baseUrl: args.baseURL, 72 | executablePath, 73 | useAdblock: flags.useAdblock, 74 | extraHosts: args.extraHosts, 75 | ignoreHTTPSErrors: flags.ignoreHTTPSErrors, 76 | locale: flags.locale, 77 | timezoneId: flags.timezoneId, 78 | }); 79 | console.info( 80 | `✅ ${scenario.name}: ${ 81 | new Date(timelines.end).getTime() - 82 | new Date(timelines.start).getTime() 83 | } ms` 84 | ); 85 | } catch (error) { 86 | console.error(`❌ Error : ${scenario.name}`); 87 | console.error(error.message); 88 | process.exit(0); 89 | } 90 | } 91 | 92 | console.info(` 93 | GreenFrame scenarios finished successfully ! 94 | 95 | You can now run an analysis to estimate the consumption of your application. 96 | `); 97 | } 98 | } 99 | 100 | OpenCommand.description = `Open browser to develop your GreenFrame scenario 101 | ... 102 | greenframe analyze ./yourScenario.js https://greenframe.io 103 | `; 104 | 105 | module.exports = OpenCommand; 106 | -------------------------------------------------------------------------------- /src/services/git/__tests__/utils.js: -------------------------------------------------------------------------------- 1 | jest.mock('node:child_process', () => ({ 2 | exec: jest.fn(), 3 | })); 4 | 5 | jest.mock('node:util', () => ({ 6 | promisify: (cb) => cb, 7 | })); 8 | 9 | const { exec } = require('node:child_process'); 10 | 11 | const { 12 | getCommitMessage, 13 | getBranchName, 14 | getCommitId, 15 | getDirectCommitAncestor, 16 | getCommitAncestorWithDefaultBranch, 17 | } = require('../utils'); 18 | 19 | describe('#getCommitMessage', () => { 20 | it('Should call exec', async () => { 21 | exec.mockReturnValue({ stdout: 'COMMIT MESSAGE' }); 22 | const commitMessage = await getCommitMessage(); 23 | expect(exec).toHaveBeenCalledTimes(1); 24 | expect(exec).toHaveBeenCalledWith('git log -1 --pretty=%B'); 25 | expect(commitMessage).toBe('COMMIT MESSAGE'); 26 | }); 27 | afterEach(() => { 28 | exec.mockClear(); 29 | }); 30 | }); 31 | 32 | describe('#getBranchName', () => { 33 | it('Should call exec', async () => { 34 | exec.mockReturnValue({ stdout: 'BRANCH NAME' }); 35 | const branchMessage = await getBranchName(); 36 | expect(exec).toHaveBeenCalledTimes(1); 37 | expect(exec).toHaveBeenCalledWith('git branch --show-current'); 38 | expect(branchMessage).toBe('BRANCH NAME'); 39 | }); 40 | afterEach(() => { 41 | exec.mockClear(); 42 | }); 43 | }); 44 | 45 | describe('#getCommitId', () => { 46 | it('Should call exec', async () => { 47 | exec.mockReturnValue({ stdout: 'COMMIT ID' }); 48 | const commitMessage = await getCommitId(); 49 | expect(exec).toHaveBeenCalledTimes(1); 50 | expect(exec).toHaveBeenCalledWith('git rev-parse HEAD'); 51 | expect(commitMessage).toBe('COMMIT ID'); 52 | }); 53 | afterEach(() => { 54 | exec.mockClear(); 55 | }); 56 | }); 57 | 58 | describe('#getDirectCommitAncestor', () => { 59 | it('Should call exec', async () => { 60 | exec.mockReturnValue({ stdout: 'DIRECT COMMIT ANCESTOR' }); 61 | const commitMessage = await getDirectCommitAncestor(); 62 | expect(exec).toHaveBeenCalledTimes(1); 63 | expect(exec).toHaveBeenCalledWith('printf $(git rev-parse HEAD^)'); 64 | expect(commitMessage).toBe('DIRECT COMMIT ANCESTOR'); 65 | }); 66 | 67 | it('Should throw an error because exec print in stderr', async () => { 68 | exec.mockReturnValue({ 69 | stdout: 'DIRECT COMMIT ANCESTOR', 70 | stderr: 'SOMETHING WENT WRONG', 71 | }); 72 | const directCommitAncestor = await getDirectCommitAncestor(); 73 | expect(exec).toHaveBeenCalledTimes(1); 74 | expect(exec).toHaveBeenCalledWith('printf $(git rev-parse HEAD^)'); 75 | expect(directCommitAncestor).toBeUndefined(); 76 | }); 77 | 78 | afterEach(() => { 79 | exec.mockClear(); 80 | }); 81 | }); 82 | 83 | describe('#getCommitAncestorWithDefaultBranch', () => { 84 | it('Should call exec', async () => { 85 | exec.mockReturnValue({ stdout: 'COMMIT ANCESTOR' }); 86 | const commitMessage = await getCommitAncestorWithDefaultBranch('mybranch'); 87 | expect(exec).toHaveBeenCalledTimes(1); 88 | expect(exec).toHaveBeenCalledWith( 89 | 'printf $(git merge-base origin/mybranch HEAD)' 90 | ); 91 | expect(commitMessage).toBe('COMMIT ANCESTOR'); 92 | }); 93 | 94 | it('Should throw an error because exec print in stderr', async () => { 95 | exec.mockReturnValue({ 96 | stdout: 'COMMIT ANCESTOR', 97 | stderr: 'SOMETHING WENT WRONG', 98 | }); 99 | const directCommitAncestor = await getCommitAncestorWithDefaultBranch('mybranch'); 100 | expect(exec).toHaveBeenCalledTimes(1); 101 | expect(exec).toHaveBeenCalledWith( 102 | 'printf $(git merge-base origin/mybranch HEAD)' 103 | ); 104 | expect(directCommitAncestor).toBeUndefined(); 105 | }); 106 | 107 | afterEach(() => { 108 | exec.mockClear(); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/model/stat-tools/providers/kubernetes.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CadvisorContainerStats, 3 | GenericStat, 4 | IoServiceByte, 5 | Provider, 6 | } from '../../../types'; 7 | 8 | const computeGenericStats = (stats: CadvisorContainerStats[]): GenericStat[] => { 9 | const result: GenericStat[] = []; 10 | if (stats.length === 0) { 11 | return result; 12 | } 13 | 14 | let computed: GenericStat = { 15 | date: new Date(stats[0].stats[0].timestamp), 16 | cpu: { 17 | availableSystemCpuUsage: 0, 18 | currentUsageInUserMode: 0, 19 | currentUsageInKernelMode: 0, 20 | }, 21 | io: { currentBytes: 0 }, 22 | network: { 23 | currentReceived: 0, 24 | currentTransmitted: 0, 25 | }, 26 | memory: { usage: 0 }, 27 | }; 28 | 29 | for (const [i, stat] of stats.entries()) { 30 | if (i > 0) { 31 | computed = computeGenericStat(computed, stats[i - 1], stat); 32 | } 33 | 34 | result.push(computed); 35 | } 36 | 37 | return result; 38 | }; 39 | 40 | export const kubernetes: Provider = { 41 | computeGenericStats, 42 | }; 43 | 44 | const computeGenericStat = ( 45 | previousComputed: GenericStat, 46 | rawPreviousStat: CadvisorContainerStats, 47 | rawStat: CadvisorContainerStats 48 | ): GenericStat => { 49 | const previousStat = rawPreviousStat.stats[rawPreviousStat.stats.length - 1]; 50 | const stat = rawStat.stats[rawStat.stats.length - 1]; 51 | const cpuCores = rawStat.spec.cpu.limit; 52 | // the energy consumption of a chip corresponds to its number of builtin cpu 53 | const toSeconds = (cpu_time: number) => cpu_time / (cpuCores * 1_000_000_000); // Normalize to 1 cpu 54 | 55 | const date = new Date(stat.timestamp); 56 | 57 | const computeCurrentDelta = (previousStatValue: number, newStatValue: number) => 58 | newStatValue !== 0 ? Math.abs(newStatValue - previousStatValue) : 0; 59 | 60 | const currentUsageInUserMode = toSeconds( 61 | computeCurrentDelta(previousStat.cpu.usage.user, stat.cpu.usage.user) 62 | ); 63 | 64 | const currentUsageInKernelMode = toSeconds( 65 | computeCurrentDelta(previousStat.cpu.usage.system, stat.cpu.usage.system) 66 | ); 67 | 68 | const currentReceived = computeCurrentDelta( 69 | previousStat.network.rx_bytes, 70 | stat.network.rx_bytes 71 | ); 72 | 73 | const currentTransmitted = computeCurrentDelta( 74 | previousStat.network.tx_bytes, 75 | stat.network.tx_bytes 76 | ); 77 | 78 | const currentBytes = computeCurrentDelta( 79 | kubernetesSumBlkioStats(previousStat.diskio.io_service_bytes), 80 | kubernetesSumBlkioStats(stat.diskio.io_service_bytes) 81 | ); 82 | 83 | const availableSystemCpuUsage = toSeconds( 84 | computeCurrentDelta(previousStat.cpu.usage.system, stat.cpu.usage.system) 85 | ); 86 | 87 | const usage = Math.max(previousComputed.memory.usage, stat.memory.usage); 88 | 89 | const computed = { 90 | date, 91 | cpu: { 92 | availableSystemCpuUsage, 93 | currentUsageInUserMode, 94 | currentUsageInKernelMode, 95 | }, 96 | io: { currentBytes }, 97 | network: { currentReceived, currentTransmitted }, 98 | memory: { usage }, 99 | }; 100 | 101 | return computed; 102 | }; 103 | 104 | // exported for tests 105 | export const kubernetesSumBlkioStats = (blkioStats: IoServiceByte[] = []): number => { 106 | const blkioStatsByMajorAndMinor = new Map([]); 107 | 108 | for (const blkioStat of blkioStats) { 109 | blkioStatsByMajorAndMinor.set( 110 | [blkioStat.major, blkioStat.minor].toString(), 111 | blkioStat.stats.Total 112 | ); 113 | } 114 | 115 | return [...blkioStatsByMajorAndMinor.values()].reduce( 116 | (previousValue, currentValue) => { 117 | return previousValue + currentValue; 118 | }, 119 | 0 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /src/model/stat-tools/providers/docker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BlkioStatEntry, 3 | DockerStatsJSON, 4 | GenericStat, 5 | Provider, 6 | } from '../../../types'; 7 | 8 | const computeGenericStats = (stats: DockerStatsJSON[]): GenericStat[] => { 9 | const result: GenericStat[] = []; 10 | if (stats.length === 0) { 11 | return result; 12 | } 13 | 14 | let computed: GenericStat = { 15 | date: new Date(stats[0].read), 16 | cpu: { 17 | availableSystemCpuUsage: 0, 18 | currentUsageInUserMode: 0, 19 | currentUsageInKernelMode: 0, 20 | }, 21 | io: { currentBytes: 0 }, 22 | network: { 23 | currentReceived: 0, 24 | currentTransmitted: 0, 25 | }, 26 | memory: { usage: 0 }, 27 | }; 28 | 29 | for (const [i, stat] of stats.entries()) { 30 | if (i > 0) { 31 | computed = computeGenericStat(computed, stats[i - 1], stat); 32 | } 33 | 34 | result.push(computed); 35 | } 36 | 37 | return result; 38 | }; 39 | 40 | export const docker: Provider = { 41 | computeGenericStats, 42 | }; 43 | 44 | const computeGenericStat = ( 45 | previousComputed: GenericStat, 46 | previousStat: DockerStatsJSON, 47 | stat: DockerStatsJSON 48 | ): GenericStat => { 49 | // the energy consumption of a chip corresponds to its number of builtin cpu 50 | const toSeconds = (cpu_time: number) => 51 | cpu_time / (stat.cpu_stats.online_cpus * 1_000_000_000); // Normalize to 1 cpu 52 | 53 | const date = new Date(stat.read); 54 | 55 | const computeCurrentDelta = (previousStatValue: number, newStatValue: number) => 56 | Math.abs(newStatValue - previousStatValue); 57 | 58 | const currentUsageInUserMode = toSeconds( 59 | computeCurrentDelta( 60 | stat.precpu_stats.cpu_usage.usage_in_usermode, 61 | stat.cpu_stats.cpu_usage.usage_in_usermode 62 | ) 63 | ); 64 | 65 | const currentUsageInKernelMode = toSeconds( 66 | computeCurrentDelta( 67 | stat.precpu_stats.cpu_usage.usage_in_kernelmode, 68 | stat.cpu_stats.cpu_usage.usage_in_kernelmode 69 | ) 70 | ); 71 | 72 | const currentReceived = computeCurrentDelta( 73 | previousStat.networks.eth0.rx_bytes, 74 | stat.networks.eth0.rx_bytes 75 | ); 76 | 77 | const currentTransmitted = computeCurrentDelta( 78 | previousStat.networks.eth0.tx_bytes, 79 | stat.networks.eth0.tx_bytes 80 | ); 81 | 82 | const currentBytes = computeCurrentDelta( 83 | sumBlkioStats(previousStat.blkio_stats.io_service_bytes_recursive || []), 84 | sumBlkioStats(stat.blkio_stats.io_service_bytes_recursive || []) 85 | ); 86 | 87 | const availableSystemCpuUsage = toSeconds( 88 | computeCurrentDelta( 89 | stat.precpu_stats.system_cpu_usage, 90 | stat.cpu_stats.system_cpu_usage 91 | ) 92 | ); 93 | 94 | const usage = Math.max(previousComputed.memory.usage, stat.memory_stats.usage); 95 | 96 | const computed = { 97 | date, 98 | cpu: { 99 | availableSystemCpuUsage, 100 | currentUsageInUserMode, 101 | currentUsageInKernelMode, 102 | }, 103 | io: { currentBytes }, 104 | network: { currentReceived, currentTransmitted }, 105 | memory: { usage }, 106 | }; 107 | 108 | return computed; 109 | }; 110 | 111 | // exported for tests 112 | export const sumBlkioStats = (blkioStats: BlkioStatEntry[] = []): number => { 113 | const blkioStatsByMajorAndMinor = new Map([]); 114 | 115 | for (const blkioStat of blkioStats) { 116 | if (blkioStat.op === 'Total') { 117 | blkioStatsByMajorAndMinor.set( 118 | [blkioStat.major, blkioStat.minor].toString(), 119 | blkioStat.value 120 | ); 121 | } 122 | } 123 | 124 | return [...blkioStatsByMajorAndMinor.values()].reduce( 125 | (previousValue, currentValue) => { 126 | return previousValue + currentValue; 127 | }, 128 | 0 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /src/model/stores/__tests__/statStore.test.ts: -------------------------------------------------------------------------------- 1 | import { createStatStore, getComputedStat, getContainers } from '../statStore'; 2 | import type { StatStore } from '../statStore'; 3 | import { ComputedStatWithMeta } from '../../../types'; 4 | import { CONTAINER_TYPES } from '../../../constants'; 5 | 6 | const generator: Array<[number, number, string, number]> = [ 7 | [0, 0, '00Z', 1e3], 8 | [0, 0, '01Z', 2e3], 9 | [0, 0, '02Z', 3e3], 10 | [0, 1, '00Z', 1e4], 11 | [0, 1, '01Z', 2e4], 12 | [0, 1, '02Z', 3e4], 13 | [1, 0, '00Z', 1e5], 14 | [1, 0, '01Z', 2e5], 15 | [1, 0, '02Z', 3e5], 16 | [1, 1, '00Z', 1e6], 17 | [1, 1, '01Z', 2e6], 18 | [1, 1, '02Z', 3e6], 19 | ]; 20 | 21 | const stats: ComputedStatWithMeta[] = generator.map(([s, c, readend, value]) => ({ 22 | meta: { 23 | sample: s, 24 | container: `c${c}`, 25 | type: CONTAINER_TYPES.SERVER, 26 | }, 27 | date: new Date(`2020-01-01T00:00:${readend}`), 28 | time: value, 29 | userTime: value, 30 | active: false, 31 | timeframe: undefined, 32 | cpu: { 33 | availableSystemCpuUsage: 0, 34 | cpuPercentage: 0, 35 | currentUsageInKernelMode: 0, 36 | currentUsageInUserMode: 0, 37 | totalUsageInKernelMode: 0, 38 | totalUsageInUserMode: value, 39 | }, 40 | io: { 41 | currentBytes: value, 42 | totalBytes: value, 43 | }, 44 | network: { 45 | currentReceived: value, 46 | currentTransmitted: value, 47 | totalReceived: value, 48 | totalTransmitted: value, 49 | }, 50 | memory: { usage: value }, 51 | })); 52 | 53 | let store: StatStore; 54 | beforeEach(() => { 55 | stats.sort(() => { 56 | return Math.random() - 0.5; 57 | }); 58 | 59 | store = createStatStore(stats); 60 | }); 61 | 62 | type ComputedStatsProfile = [Parameters[1], number | undefined]; 63 | test.each([ 64 | [{ sample: 0, container: 'c0', type: CONTAINER_TYPES.SERVER }, 3e3 - 1e3], 65 | [{ sample: 0, container: 'c1', type: CONTAINER_TYPES.SERVER }, 3e4 - 1e4], 66 | [{ sample: 1, container: 'c0', type: CONTAINER_TYPES.SERVER }, 3e5 - 1e5], 67 | [{ sample: 1, container: 'c1', type: CONTAINER_TYPES.SERVER }, 3e6 - 1e6], 68 | ])('getCpuUsage %#', (meta, cpuUsageInUsermode) => { 69 | expect(getComputedStat(store, meta)?.cpuUsage).toEqual(cpuUsageInUsermode); 70 | }); 71 | 72 | test.each([ 73 | [{ sample: 0, container: 'c0', type: CONTAINER_TYPES.SERVER }, 3e3 - 1e3], 74 | [{ sample: 0, container: 'c1', type: CONTAINER_TYPES.SERVER }, 3e4 - 1e4], 75 | [{ sample: 1, container: 'c0', type: CONTAINER_TYPES.SERVER }, 3e5 - 1e5], 76 | [{ sample: 1, container: 'c1', type: CONTAINER_TYPES.SERVER }, 3e6 - 1e6], 77 | ])('getUserTime %#', (meta, systemCpuUsage) => { 78 | expect(getComputedStat(store, meta)?.userTime).toEqual(systemCpuUsage); 79 | }); 80 | 81 | test.each([ 82 | [{ container: 'c0', sample: 0, type: CONTAINER_TYPES.SERVER }, 3e3 / 1e9], 83 | [{ container: 'c1', sample: 0, type: CONTAINER_TYPES.SERVER }, 3e4 / 1e9], 84 | [{ container: 'c0', sample: 1, type: CONTAINER_TYPES.SERVER }, 3e5 / 1e9], 85 | [{ container: 'c1', sample: 1, type: CONTAINER_TYPES.SERVER }, 3e6 / 1e9], 86 | ])('getMem %#', (meta, result) => { 87 | expect(getComputedStat(store, meta)?.memoryUsage).toEqual(result); 88 | }); 89 | 90 | it.todo('getNetwork'); 91 | 92 | it.todo('getDisk'); 93 | 94 | test.each([ 95 | [{ sample: 0, container: 'c0', type: CONTAINER_TYPES.SERVER }, 3e3 - 1e3], 96 | [{ sample: 0, container: 'c1', type: CONTAINER_TYPES.SERVER }, 3e4 - 1e4], 97 | [{ sample: 1, container: 'c0', type: CONTAINER_TYPES.SERVER }, 3e5 - 1e5], 98 | [{ sample: 1, container: 'c1', type: CONTAINER_TYPES.SERVER }, 3e6 - 1e6], 99 | ])('getTime %#', (meta, systemCpuUsage) => { 100 | expect(getComputedStat(store, meta)?.time).toEqual(systemCpuUsage); 101 | }); 102 | 103 | test.each<[ReturnType]>([[['c0', 'c1']]])( 104 | 'getContainers %#', 105 | (result) => { 106 | expect(new Set(getContainers(store))).toEqual(new Set(result)); 107 | } 108 | ); 109 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/stats.d.ts: -------------------------------------------------------------------------------- 1 | export interface CadvisorContainerStats { 2 | id: string; 3 | name: string; 4 | aliases: string[]; 5 | subcontainers?: { name: string }[]; 6 | namespace: string; 7 | spec: Spec; 8 | stats: Stat[]; 9 | } 10 | 11 | export interface Spec { 12 | creation_time: string; 13 | labels: Labels; 14 | envs: Envs; 15 | has_cpu: boolean; 16 | cpu: SpecCPU; 17 | has_memory: boolean; 18 | memory: SpecMemory; 19 | has_hugetlb: boolean; 20 | has_network: boolean; 21 | has_processes: boolean; 22 | processes: SpecProcesses; 23 | has_filesystem: boolean; 24 | has_diskio: boolean; 25 | has_custom_metrics: boolean; 26 | image: string; 27 | } 28 | 29 | export interface SpecCPU { 30 | limit: number; 31 | max_limit: number; 32 | mask: string; 33 | period: number; 34 | } 35 | 36 | export interface Envs { 37 | PATH: string; 38 | } 39 | 40 | export interface Labels { 41 | app: string; 42 | [key: string]: string; 43 | } 44 | 45 | export interface SpecMemory { 46 | limit: number; 47 | reservation: number; 48 | swap_limit: number; 49 | } 50 | 51 | export interface SpecProcesses { 52 | limit: number; 53 | } 54 | 55 | export interface Stat { 56 | timestamp: string; 57 | cpu: StatCPU; 58 | diskio: Diskio; 59 | memory: StatMemory; 60 | network: Network; 61 | task_stats: TaskStats; 62 | processes: StatProcesses; 63 | resctrl: Diskio; 64 | cpuset: Cpuset; 65 | } 66 | 67 | export interface StatCPU { 68 | usage: Usage; 69 | cfs: Cfs; 70 | schedstat: Schedstat; 71 | load_average: number; 72 | } 73 | 74 | export interface Cfs { 75 | periods: number; 76 | throttled_periods: number; 77 | throttled_time: number; 78 | } 79 | 80 | export interface Schedstat { 81 | run_time: number; 82 | runqueue_time: number; 83 | run_periods: number; 84 | } 85 | 86 | export interface Usage { 87 | total: number; 88 | user: number; 89 | system: number; 90 | } 91 | 92 | export interface Cpuset { 93 | memory_migrate: number; 94 | } 95 | 96 | export interface DiskIoStats { 97 | Async: number; 98 | Discard: number; 99 | Read: number; 100 | Sync: number; 101 | Total: number; 102 | Write: number; 103 | } 104 | 105 | export interface IoServiceByte { 106 | device: string; 107 | major: number; 108 | minor: number; 109 | stats: DiskIoStats; 110 | } 111 | 112 | export interface Diskio { 113 | io_service_bytes: IoServiceByte[]; 114 | } 115 | 116 | export interface StatMemory { 117 | usage: number; 118 | max_usage: number; 119 | cache: number; 120 | rss: number; 121 | swap: number; 122 | mapped_file: number; 123 | working_set: number; 124 | failcnt: number; 125 | container_data: Data; 126 | hierarchical_data: Data; 127 | } 128 | 129 | export interface Data { 130 | pgfault: number; 131 | pgmajfault: number; 132 | numa_stats: Diskio; 133 | } 134 | 135 | export interface Network { 136 | name: string; 137 | rx_bytes: number; 138 | rx_packets: number; 139 | rx_errors: number; 140 | rx_dropped: number; 141 | tx_bytes: number; 142 | tx_packets: number; 143 | tx_errors: number; 144 | tx_dropped: number; 145 | interfaces: Interface[]; 146 | tcp: { [key: string]: number }; 147 | tcp6: { [key: string]: number }; 148 | udp: UDP; 149 | udp6: UDP; 150 | tcp_advanced: { [key: string]: number }; 151 | } 152 | 153 | export interface Interface { 154 | name: string; 155 | rx_bytes: number; 156 | rx_packets: number; 157 | rx_errors: number; 158 | rx_dropped: number; 159 | tx_bytes: number; 160 | tx_packets: number; 161 | tx_errors: number; 162 | tx_dropped: number; 163 | } 164 | 165 | export interface UDP { 166 | Listen: number; 167 | Dropped: number; 168 | RxQueued: number; 169 | TxQueued: number; 170 | } 171 | 172 | export interface StatProcesses { 173 | process_count: number; 174 | fd_count: number; 175 | socket_count: number; 176 | } 177 | 178 | export interface TaskStats { 179 | nr_sleeping: number; 180 | nr_running: number; 181 | nr_stopped: number; 182 | nr_uninterruptible: number; 183 | nr_io_wait: number; 184 | } 185 | -------------------------------------------------------------------------------- /src/model/stores/statStore.ts: -------------------------------------------------------------------------------- 1 | import { CONTAINER_TYPES } from '../../constants'; 2 | import type { Meta, ComputedStatWithMeta, ValueOf } from '../../types'; 3 | 4 | // map: containerName -> ComputedStatWithMeta[] 5 | export type StatStore = Map>; 6 | 7 | export const createStatStore = (values: ComputedStatWithMeta[]): StatStore => { 8 | const store: StatStore = new Map() as StatStore; 9 | for (const value of values) add(store, value); 10 | return store; 11 | }; 12 | 13 | // add an entry 14 | const add = (store: StatStore, value: ComputedStatWithMeta): void => { 15 | const key = value.meta.container; 16 | if (store.has(key)) { 17 | store.get(key)?.push(value); 18 | } else { 19 | store.set(key, [value]); 20 | } 21 | }; 22 | 23 | export const getOrderedStatsForContainer = ( 24 | store: StatStore, 25 | container: string 26 | ): ComputedStatWithMeta[] => { 27 | if (!store.has(container)) { 28 | return []; 29 | } 30 | 31 | const stats = store.get(container) as ComputedStatWithMeta[]; 32 | stats.sort((a, b) => a.date.getTime() - b.date.getTime()); 33 | return stats; 34 | }; 35 | 36 | export const getOrderedStatsForContainerSample = ( 37 | store: StatStore, 38 | { container, sample, timeFrameTitle }: Meta & { timeFrameTitle?: string } 39 | ): ComputedStatWithMeta[] => { 40 | if (!store.has(container)) { 41 | return []; 42 | } 43 | 44 | const statsForContainer = store.get(container) as ComputedStatWithMeta[]; 45 | const filteredStats = statsForContainer.filter( 46 | (entry) => 47 | entry.meta.sample === sample && 48 | (timeFrameTitle ? entry.timeframe?.title === timeFrameTitle : true) 49 | ); 50 | filteredStats.sort((a, b) => a.date.getTime() - b.date.getTime()); 51 | return filteredStats; 52 | }; 53 | 54 | export const getContainers = (store: StatStore): string[] => [...store.keys()]; 55 | 56 | export const getContainerType = ( 57 | store: StatStore, 58 | container: string 59 | ): ValueOf => { 60 | const orderedStats = getOrderedStatsForContainer(store, container); 61 | return orderedStats[0].meta.type; 62 | }; 63 | 64 | export const getSamples = (store: StatStore): number[] => { 65 | const stats = [...store.values()].flat(); 66 | const samples = stats.reduce((acc: Set, value: ComputedStatWithMeta) => { 67 | acc.add(value.meta.sample); 68 | return acc; 69 | }, new Set()); 70 | return [...samples]; 71 | }; 72 | 73 | export const getComputedStat = ( 74 | store: StatStore, 75 | { container, sample, type, timeFrameTitle }: Meta & { timeFrameTitle?: string } 76 | ): 77 | | { 78 | time: number; 79 | userTime: number; 80 | cpuUsage: number; 81 | memoryUsage: number; 82 | network: number; 83 | disk: number; 84 | } 85 | | undefined => { 86 | const orderedStats = getOrderedStatsForContainerSample(store, { 87 | container, 88 | type, 89 | sample, 90 | timeFrameTitle, 91 | }); 92 | const len = orderedStats.length; 93 | if (len === 0) { 94 | return undefined; 95 | } 96 | 97 | const first = orderedStats[0]; 98 | const last = orderedStats[len - 1]; 99 | return { 100 | time: last.time - first.time, // In s 101 | userTime: last.userTime - first.userTime, // In s 102 | cpuUsage: 103 | first.cpu.currentUsageInUserMode + 104 | first.cpu.currentUsageInKernelMode + 105 | last.cpu.totalUsageInUserMode + 106 | last.cpu.totalUsageInKernelMode - 107 | (first.cpu.totalUsageInUserMode + first.cpu.totalUsageInKernelMode), // In s 108 | memoryUsage: last.memory.usage / 1_000_000_000, // In GB 109 | network: 110 | (first.network.currentReceived + 111 | first.network.currentTransmitted + 112 | last.network.totalReceived + 113 | last.network.totalTransmitted - 114 | (first.network.totalReceived + first.network.totalTransmitted)) / 115 | 1_000_000_000, // In GB 116 | disk: 117 | (first.io.currentBytes + (last.io.totalBytes - first.io.totalBytes)) / 118 | 1_000_000_000, // In GB 119 | }; 120 | }; 121 | -------------------------------------------------------------------------------- /src/services/parseConfigFile.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const yaml = require('js-yaml'); 3 | const util = require('node:util'); 4 | const ConfigurationError = require('./errors/ConfigurationError'); 5 | const analyze = require('../commands/analyze'); 6 | const FILE_NOT_FOUND = 'ENOENT'; 7 | 8 | const readFile = util.promisify(fs.readFile); 9 | 10 | const isMissingDefaultConfigFile = (path, error) => { 11 | return path === analyze.DEFAULT_CONFIG_FILE && error.code === FILE_NOT_FOUND; 12 | }; 13 | 14 | const parseConfigFile = async (path) => { 15 | try { 16 | const file = await readFile(path, 'utf8'); 17 | let fileContent; 18 | 19 | if (file) { 20 | fileContent = yaml.load(file); 21 | } 22 | 23 | if (typeof fileContent !== 'object') { 24 | throw new yaml.YAMLException(`${path} is not a valid yaml`); 25 | } 26 | 27 | if (fileContent) { 28 | const { 29 | scenario, 30 | scenarios, 31 | baseURL, 32 | samples, 33 | useAdblock, 34 | threshold, 35 | projectName, 36 | containers, 37 | databaseContainers, 38 | kubeContainers, 39 | kubeDatabaseContainers, 40 | extraHosts, 41 | envVar, 42 | envFile, 43 | kubeConfig, 44 | dockerdHost, 45 | dockerdPort, 46 | ignoreHTTPSErrors, 47 | locale, 48 | timezoneId, 49 | } = fileContent; 50 | 51 | return { 52 | args: { 53 | scenarios, 54 | scenario, 55 | baseURL, 56 | }, 57 | flags: { 58 | samples, 59 | useAdblock, 60 | threshold, 61 | projectName, 62 | containers, 63 | databaseContainers, 64 | kubeContainers, 65 | kubeDatabaseContainers, 66 | extraHosts, 67 | envVar, 68 | envFile, 69 | kubeConfig, 70 | dockerdHost, 71 | dockerdPort, 72 | ignoreHTTPSErrors, 73 | locale, 74 | timezoneId, 75 | }, 76 | }; 77 | } 78 | } catch (error) { 79 | if (error.name === 'YAMLException') { 80 | throw new yaml.YAMLException(`${path} is not a valid yaml`); 81 | } else if (!isMissingDefaultConfigFile(path, error)) { 82 | throw error; 83 | } 84 | } 85 | }; 86 | 87 | const definedProps = (obj) => 88 | Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); 89 | 90 | const resolveParams = ( 91 | defaultFlags = {}, 92 | configFileParams = { args: {}, flags: {} }, 93 | commandParams = { args: {}, flags: {} } 94 | ) => { 95 | const flags = Object.assign( 96 | {}, 97 | defaultFlags, 98 | definedProps(configFileParams.flags), 99 | definedProps(commandParams.flags) 100 | ); 101 | 102 | const args = Object.assign( 103 | {}, 104 | definedProps(configFileParams.args), 105 | definedProps(commandParams.args) 106 | ); 107 | 108 | // Check the baseURL for retrocompatibility ( SCENARIO arg was before BASE_URL arg) 109 | // If baseURL ends with ".js", it must be the scenario file, so we switch the args 110 | if (args?.baseURL?.endsWith('.js')) { 111 | const scenario = args.baseURL; 112 | args.baseURL = args.scenario; 113 | args.scenario = scenario; 114 | } 115 | 116 | if (args.scenario) { 117 | args.scenarios = [ 118 | { 119 | path: args.scenario, 120 | name: 'main scenario', 121 | threshold: flags.threshold, 122 | }, 123 | ]; 124 | } 125 | 126 | if (!args.scenarios) { 127 | args.scenarios = [ 128 | { 129 | path: '../../src/examples/visit.js', 130 | name: 'main scenario', 131 | threshold: flags.threshold, 132 | }, 133 | ]; 134 | } 135 | 136 | if (!args.baseURL) { 137 | throw new ConfigurationError('You must provide a "baseURL" argument.'); 138 | } 139 | 140 | return { flags, args }; 141 | }; 142 | 143 | module.exports = { parseConfigFile, resolveParams }; 144 | -------------------------------------------------------------------------------- /src/examples/ra-demo.js: -------------------------------------------------------------------------------- 1 | const raDemo = async (page) => { 2 | // Go to http://localhost:3000/ 3 | await page.goto('#/login', { 4 | waitUntil: 'networkidle', 5 | }); 6 | await page.waitForTimeout(1000); 7 | 8 | await page.scrollToElement('input[name="username"]'); // Click input[name="username"] 9 | await page.click('input[name="username"]'); 10 | 11 | // Fill input[name="username"] 12 | await page.scrollToElement('input[name="username"]'); 13 | await page.type('input[name="username"]', 'demo'); 14 | 15 | await page.scrollToElement('input[name="password"]'); // Click input[name="password"] 16 | await page.click('input[name="password"]'); 17 | 18 | // Fill input[name="password"] 19 | await page.scrollToElement('input[name="password"]'); 20 | await page.type('input[name="password"]', 'demo'); 21 | 22 | await page.scrollToElement('button:has-text("Sign in")'); // Click button:has-text("Sign in") 23 | await page.click('button:has-text("Sign in")'); 24 | 25 | await Promise.all([ 26 | page.waitForNavigation({ waitUntil: 'networkidle' }), 27 | page.scrollToElement('text=PostersPosters'), 28 | page.click('text=PostersPosters'), 29 | ]); 30 | 31 | await page.waitForTimeout(1000); 32 | await page.scrollToElement('text=Aerial Coast'); 33 | await page.click('text=Aerial Coast'); 34 | 35 | await page.scrollToElement('text=Details'); 36 | await page.click('text=Details'); 37 | 38 | await page.scrollToElement('input[name="reference"]'); 39 | await page.click('input[name="reference"]'); 40 | 41 | await page.scrollToElement('input[name="reference"]'); 42 | await page.type('input[name="reference"]', ' Test'); 43 | 44 | await Promise.all([ 45 | page.waitForNavigation({ waitUntil: 'networkidle' }), 46 | page.scrollToElement('[aria-label="Save"]'), 47 | page.click('[aria-label="Save"]'), 48 | ]); 49 | 50 | await page.scrollToElement('input[name="q"]'); 51 | await page.click('input[name="q"]'); 52 | 53 | await Promise.all([ 54 | page.waitForNavigation({ waitUntil: 'networkidle' }), 55 | page.scrollToElement('input[name="q"]'), 56 | page.type('input[name="q"]', 'Aerial'), 57 | ]); 58 | 59 | await Promise.all([ 60 | page.waitForNavigation({ waitUntil: 'networkidle' }), 61 | page.scrollToElement('text=CategoriesCategories'), 62 | page.click('text=CategoriesCategories'), 63 | ]); 64 | 65 | await page.waitForTimeout(1000); 66 | 67 | await Promise.all([ 68 | page.waitForNavigation({ waitUntil: 'networkidle' }), 69 | page.scrollToElement('[aria-label="Edit"]'), 70 | page.click('[aria-label="Edit"]'), 71 | ]); 72 | 73 | await page.scrollToElement('input[name="name"]'); 74 | await page.click('input[name="name"]'); 75 | 76 | // Press a with modifiers 77 | await page.scrollToElement('input[name="name"]'); 78 | await page.press('input[name="name"]', 'Control+a'); 79 | 80 | await page.scrollToElement('input[name="name"]'); 81 | await page.type('input[name="name"]', 'Cats'); 82 | 83 | await Promise.all([ 84 | page.waitForNavigation({ waitUntil: 'networkidle' }), 85 | page.scrollToElement('[aria-label="Save"]'), 86 | page.click('[aria-label="Save"]'), 87 | ]); 88 | 89 | await Promise.all([ 90 | page.waitForNavigation({ waitUntil: 'networkidle' }), 91 | page.scrollToElement('text=CustomersCustomers'), 92 | page.click('text=CustomersCustomers'), 93 | ]); 94 | 95 | await page.scrollToElement('input[name="q"]'); // Click input[name="q"] 96 | await page.click('input[name="q"]'); 97 | 98 | // Fill input[name="q"] 99 | await Promise.all([ 100 | page.waitForNavigation({ 101 | /* url: 'http://localhost:3000/#/customers?filter=%7B%22q%22%3A%22H%22%7D&order=DESC&page=1&perPage=25&sort=last_seen', */ waitUntil: 102 | 'networkidle', 103 | }), 104 | page.scrollToElement('input[name="q"]'), 105 | page.type('input[name="q"]', 'H'), 106 | ]); 107 | 108 | await Promise.all([ 109 | page.waitForNavigation({ waitUntil: 'networkidle' }), 110 | page.scrollToElement('text=ReviewsReviews'), 111 | page.click('text=ReviewsReviews'), 112 | ]); 113 | 114 | await page.waitForTimeout(1000); 115 | 116 | await page.scrollToElement('input[type="checkbox"]'); 117 | await page.check('input[type="checkbox"]'); 118 | 119 | await page.scrollToElement('[aria-label="Delete"]'); 120 | await page.click('[aria-label="Delete"]'); 121 | await page.waitForNetworkIdle(); 122 | }; 123 | 124 | module.exports = raDemo; 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "greenframe-cli", 3 | "description": "Official GreenFrame CLI", 4 | "version": "2.0.1", 5 | "author": "Marmelab", 6 | "bin": { 7 | "greenframe": "./bin/run" 8 | }, 9 | "bugs": "https://github.com/marmelab/greenframe-cli/issues", 10 | "dependencies": { 11 | "@cliqz/adblocker-playwright": "^1.25.0", 12 | "@kubernetes/client-node": "^0.17.0", 13 | "@oclif/core": "^1.2.0", 14 | "@playwright/test": "^1.30.0", 15 | "@sentry/node": "^6.13.3", 16 | "axios": "^0.28.0", 17 | "core-js-pure": "^3.24.0", 18 | "cross-fetch": "^3.1.4", 19 | "env-ci": "^5.0.2", 20 | "js-yaml": "^4.1.0", 21 | "listr2": "^3.12.2", 22 | "lodash": "^4.17.21", 23 | "mathjs": "^9.5.0", 24 | "minimist": "^1.2.5", 25 | "oclif": "^2.4.3", 26 | "playwright": "1.30.0" 27 | }, 28 | "resolutions": { 29 | "oclif/**/isbinaryfile": "5.0.0" 30 | }, 31 | "devDependencies": { 32 | "@aws-sdk/client-s3": "^3.36.0", 33 | "@babel/core": "^7.15.8", 34 | "@babel/eslint-parser": "^7.15.8", 35 | "@types/axios": "^0.14.0", 36 | "@types/babel__core": "^7.1.19", 37 | "@types/debug": "^4.1.7", 38 | "@types/env-ci": "^3.1.1", 39 | "@types/eslint": "^8.4.5", 40 | "@types/eslint-plugin-prettier": "^3.1.0", 41 | "@types/jest": "^28.1.6", 42 | "@types/js-yaml": "^4.0.5", 43 | "@types/lodash": "^4.14.177", 44 | "@types/mathjs": "~9.3.2", 45 | "@types/minimist": "^1.2.2", 46 | "@types/prettier": "^2.6.3", 47 | "@types/wait-on": "^5.3.1", 48 | "@typescript-eslint/eslint-plugin": "^5.30.6", 49 | "@typescript-eslint/parser": "^5.30.6", 50 | "aws-sdk": "^2.1005.0", 51 | "eslint": "^8.19.0", 52 | "eslint-config-oclif": "^4.0.0", 53 | "eslint-config-oclif-typescript": "^1.0.2", 54 | "eslint-config-prettier": "^8.3.0", 55 | "eslint-plugin-jest": "^25.0.5", 56 | "eslint-plugin-prettier": "^4.0.0", 57 | "eslint-plugin-unicorn": "^43.0.2", 58 | "globby": "^10.0.2", 59 | "jest": "^28.1.3", 60 | "prettier": "^2.4.1", 61 | "trace-unhandled": "^2.0.1", 62 | "ts-jest": "^28.0.7", 63 | "ts-node-dev": "^2.0.0", 64 | "typescript": "^4.7.4", 65 | "wait-on": "^6.0.0", 66 | "xo": "^0.52.3" 67 | }, 68 | "engines": { 69 | "node": ">=18.0.0" 70 | }, 71 | "files": [ 72 | "/bin", 73 | "/npm-shrinkwrap.json", 74 | "/oclif.manifest.json", 75 | "/dist", 76 | "/src/examples" 77 | ], 78 | "homepage": "https://github.com/marmelab/greenframe", 79 | "keywords": [ 80 | "oclif" 81 | ], 82 | "license": "UNLICENSED", 83 | "exports": { 84 | ".": { 85 | "default": "./dist/index.js", 86 | "types": "./dist/index.d.ts" 87 | }, 88 | "./model": { 89 | "default": "./dist/model/index.js", 90 | "types": "./dist/model/index.d.ts" 91 | } 92 | }, 93 | "main": "dist/index.js", 94 | "types": "dist/index.d.ts", 95 | "oclif": { 96 | "commands": "./dist/commands", 97 | "dirname": "greenframe", 98 | "bin": "greenframe", 99 | "additionalHelpFlags": [ 100 | "-h" 101 | ], 102 | "additionalVersionFlags": [ 103 | "-v" 104 | ], 105 | "update": { 106 | "s3": { 107 | "bucket": "assets.greenframe.io", 108 | "host": "https://assets.greenframe.io" 109 | } 110 | } 111 | }, 112 | "repository": "marmelab/greenframe-cli", 113 | "scripts": { 114 | "watch": "rm -rf ./dist && mkdir -p ./dist/bash && cp -R ./src/bash ./dist/ && tsc --watch", 115 | "build": "rm -rf ./dist && tsc && mkdir -p ./dist/bash &&cp -R ./src/bash ./dist/", 116 | "dev": "ts-node-dev src/index.ts", 117 | "postpack": "rm -f oclif.manifest.json", 118 | "posttest": "eslint .", 119 | "prepack": "yarn build && oclif manifest && oclif readme", 120 | "test-unit": "jest ./src", 121 | "test-watch": "yarn test-unit --watch", 122 | "test-e2e": "jest ./e2e --testTimeout 500000", 123 | "version": "oclif readme && git add README.md", 124 | "pack": "oclif pack tarballs", 125 | "analyze": "API_URL=http://localhost:3006 APP_URL=http://localhost:3003 ./bin/run analyze", 126 | "open": "API_URL=http://localhost:3006 APP_URL=http://localhost:3003 ./bin/run open", 127 | "upload-installation-scripts": "node ./scripts/uploadInstallScript.js", 128 | "lint": "eslint .", 129 | "typecheck": "tsc --noEmit --pretty" 130 | }, 131 | "packageManager": "yarn@3.2.3" 132 | } 133 | -------------------------------------------------------------------------------- /src/model/stat-tools/getAverageStats.ts: -------------------------------------------------------------------------------- 1 | import { groupBy } from 'lodash'; 2 | 3 | export const getAverageStats = (computedStats: any) => { 4 | // Groupe stats by sample 5 | const statsBySample = groupBy(computedStats, (computed: any) => computed.meta.sample); 6 | 7 | // Compute the number of entries of the smallest sample 8 | const minSampleSize = Object.values(statsBySample).reduce( 9 | (minSize: any, sampleStats: any) => Math.min(minSize, sampleStats.length), 10 | Number.POSITIVE_INFINITY 11 | ); 12 | 13 | // Resize all samples by trimming bigger samples 14 | Object.keys(statsBySample).map((sample) => { 15 | statsBySample[sample].splice( 16 | minSampleSize, 17 | statsBySample[sample].length - minSampleSize 18 | ); 19 | }); 20 | 21 | // Initialize average stats 22 | const initialStat = { 23 | n: 0, 24 | date: new Date(), 25 | time: 0, 26 | userTime: 0, 27 | active: false, 28 | timeframe: undefined, 29 | cpu: { 30 | availableSystemCpuUsage: 0, 31 | cpuPercentage: 0, 32 | totalUsageInUserMode: 0, 33 | totalUsageInKernelMode: 0, 34 | currentUsageInUserMode: 0, 35 | currentUsageInKernelMode: 0, 36 | }, 37 | io: { 38 | currentBytes: 0, 39 | totalBytes: 0, 40 | }, 41 | network: { 42 | currentReceived: 0, 43 | currentTransmitted: 0, 44 | totalReceived: 0, 45 | totalTransmitted: 0, 46 | }, 47 | memory: { 48 | usage: 0, 49 | }, 50 | }; 51 | 52 | // For each entry, compute timeframe average and metrics average 53 | const averageStats = [...new Array(minSampleSize).keys()].map((index) => 54 | Object.values(statsBySample).reduce( 55 | (average: any, entries: any) => 56 | incrementalAverageStats(average, entries[index]), 57 | initialStat 58 | ) 59 | ); 60 | 61 | return averageStats; 62 | }; 63 | 64 | export const addAvg = (value: any, average: any, n: any) => 65 | ((Number.isNaN(value) ? 0 : value) + n * average) / (n + 1); 66 | 67 | export const incrementalAverageStats = (average: any, entry: any) => { 68 | const n = average.n; 69 | const date = n === 0 ? entry.date : average.date; 70 | const timeframe = average.timeframe 71 | ? average.timeframe 72 | : entry.timeframe 73 | ? entry.timeframe 74 | : undefined; 75 | 76 | return { 77 | n: n + 1, 78 | date, 79 | time: addAvg(entry.time, average.time, n), 80 | userTime: addAvg(entry.userTime, average.userTime, n), 81 | active: average.active || entry.active, 82 | timeframe, 83 | cpu: { 84 | availableSystemCpuUsage: addAvg( 85 | entry.cpu.availableSystemCpuUsage, 86 | average.cpu.availableSystemCpuUsage, 87 | n 88 | ), 89 | cpuPercentage: addAvg(entry.cpu.cpuPercentage, average.cpu.cpuPercentage, n), 90 | totalUsageInUserMode: addAvg( 91 | entry.cpu.totalUsageInUserMode, 92 | average.cpu.totalUsageInUserMode, 93 | n 94 | ), 95 | totalUsageInKernelMode: addAvg( 96 | entry.cpu.totalUsageInKernelMode, 97 | average.cpu.totalUsageInKernelMode, 98 | n 99 | ), 100 | currentUsageInUserMode: addAvg( 101 | entry.cpu.currentUsageInUserMode, 102 | average.cpu.currentUsageInUserMode, 103 | n 104 | ), 105 | currentUsageInKernelMode: addAvg( 106 | entry.cpu.currentUsageInKernelMode, 107 | average.cpu.currentUsageInKernelMode, 108 | n 109 | ), 110 | }, 111 | io: { 112 | currentBytes: addAvg(entry.io.currentBytes, average.io.currentBytes, n), 113 | totalBytes: addAvg(entry.io.totalBytes, average.io.totalBytes, n), 114 | }, 115 | network: { 116 | currentReceived: addAvg( 117 | entry.network.currentReceived, 118 | average.network.currentReceived, 119 | n 120 | ), 121 | currentTransmitted: addAvg( 122 | entry.network.currentTransmitted, 123 | average.network.currentTransmitted, 124 | n 125 | ), 126 | totalReceived: addAvg( 127 | entry.network.totalReceived, 128 | average.network.totalReceived, 129 | n 130 | ), 131 | totalTransmitted: addAvg( 132 | entry.network.totalTransmitted, 133 | average.network.totalTransmitted, 134 | n 135 | ), 136 | }, 137 | memory: { 138 | usage: addAvg(entry.memory.usage, average.memory.usage, n), 139 | }, 140 | }; 141 | }; 142 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - production 7 | pull_request: {} 8 | permissions: 9 | actions: write 10 | contents: write 11 | checks: write 12 | pull-requests: write 13 | 14 | jobs: 15 | lint: 16 | name: ⬣ ESLint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🛑 Cancel Previous Runs 20 | uses: styfle/cancel-workflow-action@0.9.1 21 | 22 | - name: ⬇️ Checkout repo 23 | uses: actions/checkout@v3 24 | 25 | - name: ⎔ Setup node 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 18 29 | 30 | - name: 📥 Download deps 31 | uses: bahmutov/npm-install@v1 32 | 33 | - name: 🔬 Lint 34 | uses: wearerequired/lint-action@v2 35 | with: 36 | eslint: true 37 | continue_on_error: false 38 | 39 | typecheck: 40 | name: ʦ TypeScript 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: 🛑 Cancel Previous Runs 44 | uses: styfle/cancel-workflow-action@0.9.1 45 | 46 | - name: ⬇️ Checkout repo 47 | uses: actions/checkout@v3 48 | 49 | - name: ⎔ Setup node 50 | uses: actions/setup-node@v3 51 | with: 52 | node-version: 18 53 | 54 | - name: 📥 Download deps 55 | uses: bahmutov/npm-install@v1 56 | 57 | - name: 🔎 Type check 58 | run: make typecheck 59 | 60 | test: 61 | name: 🔍 Test 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: 🛑 Cancel Previous Runs 65 | uses: styfle/cancel-workflow-action@0.9.1 66 | 67 | - name: ⬇️ Checkout repo 68 | uses: actions/checkout@v3 69 | 70 | - name: ⎔ Setup node 71 | uses: actions/setup-node@v3 72 | with: 73 | node-version: 18 74 | 75 | - name: 📥 Download deps 76 | uses: bahmutov/npm-install@v1 77 | 78 | - name: 🔍 Run test 79 | run: make test-unit 80 | 81 | e2e: 82 | name: ⚫️ E2E 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: 🛑 Cancel Previous Runs 86 | uses: styfle/cancel-workflow-action@0.9.1 87 | 88 | - name: ⬇️ Checkout repo 89 | uses: actions/checkout@v3 90 | 91 | - name: ⎔ Setup node 92 | uses: actions/setup-node@v3 93 | with: 94 | node-version: 18 95 | 96 | - name: 📥 Download deps 97 | uses: bahmutov/npm-install@v1 98 | 99 | - name: ⚙️ Build 100 | run: yarn run build 101 | 102 | - name: 🌳 E2E run 103 | run: yarn test-e2e 104 | 105 | deploy: 106 | name: 🚀 Deploy 107 | runs-on: ubuntu-latest 108 | timeout-minutes: 20 109 | needs: [lint, typecheck, test, e2e] 110 | # only build/deploy main branch on pushes 111 | if: ${{ ( github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production' ) && github.event_name == 'push' }} 112 | 113 | steps: 114 | - name: 🛑 Cancel Previous Runs 115 | uses: styfle/cancel-workflow-action@0.9.1 116 | 117 | - name: ⬇️ Checkout repo 118 | uses: actions/checkout@v3 119 | 120 | - name: ⎔ Setup node 121 | uses: actions/setup-node@v3 122 | with: 123 | node-version: 18 124 | 125 | - name: 📥 Download deps 126 | uses: bahmutov/npm-install@v1 127 | with: 128 | useLockFile: true 129 | 130 | - name: ⚙️ Build 131 | run: make build 132 | 133 | - name: 📨 Uploading assets 134 | run: make upload 135 | env: 136 | AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} 137 | AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} 138 | 139 | - name: 🚀 Deploy Staging 140 | run: make promote-staging 141 | if: ${{ github.ref == 'refs/heads/main' }} 142 | env: 143 | AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} 144 | AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} 145 | 146 | - name: 🚀 Deploy Production 147 | run: make promote-production 148 | if: ${{ github.ref == 'refs/heads/production' }} 149 | env: 150 | AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} 151 | AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} 152 | 153 | - name: 📜 Update readme 154 | uses: stefanzweifel/git-auto-commit-action@v4 155 | with: 156 | file_pattern: README.md 157 | commit_message: Update README [skip ci] 158 | -------------------------------------------------------------------------------- /src/services/container/execScenarioContainer.js: -------------------------------------------------------------------------------- 1 | const util = require('node:util'); 2 | const path = require('node:path'); 3 | const exec = util.promisify(require('node:child_process').exec); 4 | const { CONTAINER_DEVICE_NAME } = require('../../constants'); 5 | const ScenarioError = require('../errors/ScenarioError'); 6 | const initDebug = require('debug'); 7 | 8 | const PROJECT_ROOT = path.resolve(__dirname, '../../../'); 9 | const debug = initDebug('greenframe:services:container:execScenarioContainer'); 10 | 11 | const createContainer = async (extraHosts = [], envVars = [], envFile = '') => { 12 | const { stdout } = await exec(`${PROJECT_ROOT}/dist/bash/getHostIP.sh`); 13 | const HOSTIP = stdout; 14 | const extraHostsFlags = extraHosts 15 | .map((extraHost) => ` --add-host ${extraHost}:${HOSTIP}`) 16 | .join(''); 17 | 18 | const extraHostsEnv = 19 | extraHosts.length > 0 ? ` -e EXTRA_HOSTS=${extraHosts.join(',')}` : ''; 20 | 21 | const envString = buildEnvVarList(envVars, envFile); 22 | 23 | debug(`Creating container ${CONTAINER_DEVICE_NAME} with extraHosts: ${extraHosts}`); 24 | 25 | const dockerCleanPreviousCommand = `docker rm -f ${CONTAINER_DEVICE_NAME}`; 26 | const allEnvVars = ` -e HOSTIP=${HOSTIP}${extraHostsEnv}${envString}`; 27 | const volumeString = '-v "$(pwd)":/scenarios'; 28 | const dockerCreateCommand = `docker create --tty --name ${CONTAINER_DEVICE_NAME} --rm${allEnvVars} --add-host localhost:${HOSTIP} ${extraHostsFlags} ${volumeString} mcr.microsoft.com/playwright:v1.30.0-focal`; 29 | 30 | const dockerStatCommand = `${dockerCleanPreviousCommand} && ${dockerCreateCommand}`; 31 | debug(`Docker command: ${dockerStatCommand}`); 32 | await exec(dockerStatCommand); 33 | 34 | debug(`Container ${CONTAINER_DEVICE_NAME} created`); 35 | 36 | debug(`Copying greenframe files to container ${CONTAINER_DEVICE_NAME}`); 37 | // For some reason, mounting the volume when you're doing docker in docker doesn't work, but the copy command does. 38 | const dockerCopyCommand = `docker cp ${PROJECT_ROOT} ${CONTAINER_DEVICE_NAME}:/greenframe`; 39 | await exec(dockerCopyCommand); 40 | debug(`Files copied to container ${CONTAINER_DEVICE_NAME}`); 41 | }; 42 | 43 | const startContainer = async () => { 44 | const { stderr } = await exec(`docker start ${CONTAINER_DEVICE_NAME}`); 45 | if (stderr) { 46 | throw new Error(stderr); 47 | } 48 | 49 | return 'OK'; 50 | }; 51 | 52 | const execScenarioContainer = async ( 53 | scenario, 54 | url, 55 | { useAdblock, ignoreHTTPSErrors, locale, timezoneId } = {} 56 | ) => { 57 | try { 58 | let command = `docker exec ${CONTAINER_DEVICE_NAME} node /greenframe/dist/runner/index.js --scenario="${encodeURIComponent( 59 | scenario 60 | )}" --url="${encodeURIComponent(url)}"`; 61 | 62 | if (useAdblock) { 63 | command += ` --useAdblock`; 64 | } 65 | 66 | if (ignoreHTTPSErrors) { 67 | command += ` --ignoreHTTPSErrors`; 68 | } 69 | 70 | if (locale) { 71 | command += ` --locale=${locale}`; 72 | } 73 | 74 | if (timezoneId) { 75 | command += ` --timezoneId=${timezoneId}`; 76 | } 77 | 78 | const { stdout, stderr } = await exec(command); 79 | 80 | if (stderr) { 81 | throw new Error(stderr); 82 | } 83 | 84 | const timelines = JSON.parse(stdout.split('=====TIMELINES=====')[1]); 85 | const milestones = JSON.parse(stdout.split('=====MILESTONES=====')[1] || '[]'); 86 | 87 | return { timelines, milestones }; 88 | } catch (error) { 89 | throw new ScenarioError(error.stderr || error.message); 90 | } 91 | }; 92 | 93 | const stopContainer = async () => { 94 | try { 95 | // The container might take a while to stop. 96 | // We rename it to avoid conflicts when recreating it (if it is still removing while we try to create it again, it will fail). 97 | await exec( 98 | `docker rename ${CONTAINER_DEVICE_NAME} ${CONTAINER_DEVICE_NAME}-stopping && docker stop ${CONTAINER_DEVICE_NAME}-stopping` 99 | ); 100 | } catch { 101 | // Avoid Throwing error. 102 | // If container is not running this command throw an error. 103 | return false; 104 | } 105 | 106 | return 'OK'; 107 | }; 108 | 109 | const buildEnvVarList = (envVars = [], envFile = '') => { 110 | const envVarString = 111 | envVars.length > 0 112 | ? envVars.reduce((list, envVarName) => { 113 | if (envVarName.includes('=')) { 114 | return `${list} -e ${envVarName}`; 115 | } 116 | 117 | const envVarValue = process.env[envVarName]; 118 | return `${list} -e ${envVarName}=${envVarValue}`; 119 | }, '') 120 | : ''; 121 | 122 | const envVarFileString = envFile ? ` --env-file ${envFile}` : ''; 123 | 124 | return `${envVarString}${envVarFileString ? envVarFileString : ''}`; 125 | }; 126 | 127 | module.exports = { 128 | buildEnvVarList, 129 | createContainer, 130 | startContainer, 131 | execScenarioContainer, 132 | stopContainer, 133 | }; 134 | -------------------------------------------------------------------------------- /src/model/stat-tools/docker/computeStats.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ComputedStat, 3 | GenericStat, 4 | TimeFrame, 5 | Meta, 6 | ComputedStatWithMeta, 7 | } from '../../../types'; 8 | 9 | import { getTimeframe, intersection } from '../intervals'; 10 | 11 | const computeStat = ( 12 | previousComputed: ComputedStat, 13 | previousStat: GenericStat, 14 | stat: GenericStat, 15 | timeframes: TimeFrame[] 16 | ): ComputedStat => { 17 | const date = stat.date; 18 | const readTime = stat.date.getTime(); 19 | const prereadTime = previousStat.date.getTime(); 20 | 21 | const timeframe = getTimeframe(readTime, prereadTime, timeframes); 22 | const active = timeframe !== undefined; 23 | const intersectionInterval = timeframe 24 | ? intersection( 25 | [timeframe.start.getTime(), timeframe.end.getTime()], 26 | [prereadTime, readTime] 27 | ) 28 | : undefined; 29 | 30 | const statDuration = Math.abs(prereadTime - readTime) / 1000; // In s 31 | const activeDuration = intersectionInterval 32 | ? Math.abs(intersectionInterval[1] - intersectionInterval[0]) / 1000 33 | : 0; // Elapsed time for active intervals only 34 | 35 | const time = previousComputed.time + statDuration; // Elapsed time in seconds since beginning 36 | const userTime = previousComputed.userTime + activeDuration; 37 | 38 | const ratio = activeDuration / statDuration; 39 | // Over-approximation which ensures that we do not skip any important activity peak 40 | // This is why we do not apply ratio factor 41 | const applyActive = (duration: number) => (active ? duration : 0); 42 | 43 | // Current activity normalized per second 44 | const applyRatio = (duration: number) => 45 | activeDuration > 0 ? (1 / activeDuration) * ratio * duration : 0; 46 | 47 | const currentUsageInUserMode = applyRatio(stat.cpu.currentUsageInUserMode); 48 | const currentUsageInKernelMode = applyRatio(stat.cpu.currentUsageInKernelMode); 49 | const availableSystemCpuUsage = applyRatio(stat.cpu.availableSystemCpuUsage); 50 | const currentBytes = applyRatio(stat.io.currentBytes); 51 | const currentReceived = applyRatio(stat.network.currentReceived); 52 | const currentTransmitted = applyRatio(stat.network.currentTransmitted); 53 | 54 | // Time spent by tasks of the cgroup in user mode. 55 | const totalUsageInUserMode = 56 | previousComputed.cpu.totalUsageInUserMode + 57 | applyActive(stat.cpu.currentUsageInUserMode); 58 | 59 | // Time spent by tasks of the cgroup in kernel mode. 60 | const totalUsageInKernelMode = 61 | previousComputed.cpu.totalUsageInKernelMode + 62 | applyActive(stat.cpu.currentUsageInKernelMode); 63 | 64 | const cpuPercentage = (currentUsageInUserMode / availableSystemCpuUsage) * 100; // From 0 to 100% 65 | 66 | const totalBytes = previousComputed.io.totalBytes + applyActive(stat.io.currentBytes); 67 | 68 | const totalReceived = 69 | previousComputed.network.totalReceived + 70 | applyActive(stat.network.currentReceived); 71 | const totalTransmitted = 72 | previousComputed.network.totalTransmitted + 73 | applyActive(stat.network.currentTransmitted); 74 | 75 | const usage = Math.max(previousComputed.memory.usage, applyActive(stat.memory.usage)); 76 | 77 | const computed = { 78 | date, 79 | time, 80 | userTime, 81 | active, 82 | ratio, 83 | timeframe, 84 | cpu: { 85 | availableSystemCpuUsage, 86 | cpuPercentage, 87 | totalUsageInUserMode, 88 | totalUsageInKernelMode, 89 | currentUsageInUserMode, 90 | currentUsageInKernelMode, 91 | }, 92 | io: { currentBytes, totalBytes }, 93 | network: { currentReceived, currentTransmitted, totalReceived, totalTransmitted }, 94 | memory: { usage }, 95 | }; 96 | 97 | return computed; 98 | }; 99 | 100 | export const computeStats = ({ 101 | stats, 102 | timeframes, 103 | meta, 104 | }: { 105 | stats: GenericStat[]; 106 | timeframes: TimeFrame[]; 107 | meta: Meta; 108 | }): ComputedStatWithMeta[] => { 109 | const result: ComputedStatWithMeta[] = []; 110 | if (stats.length === 0) { 111 | return result; 112 | } 113 | 114 | let computed: ComputedStat = { 115 | date: stats[0].date, 116 | time: 0, 117 | userTime: 0, 118 | active: false, 119 | timeframe: undefined, 120 | cpu: { 121 | availableSystemCpuUsage: 0, 122 | cpuPercentage: 0, 123 | totalUsageInUserMode: 0, 124 | totalUsageInKernelMode: 0, 125 | currentUsageInUserMode: 0, 126 | currentUsageInKernelMode: 0, 127 | }, 128 | io: { currentBytes: 0, totalBytes: 0 }, 129 | network: { 130 | currentReceived: 0, 131 | currentTransmitted: 0, 132 | totalReceived: 0, 133 | totalTransmitted: 0, 134 | }, 135 | memory: { usage: 0 }, 136 | }; 137 | 138 | for (const [i, stat] of stats.entries()) { 139 | if (i === 0) { 140 | continue; // Skip first entry 141 | } 142 | 143 | computed = computeStat(computed, stats[i - 1], stat, timeframes); 144 | result.push({ 145 | meta, 146 | ...computed, 147 | }); 148 | } 149 | 150 | return result; 151 | }; 152 | -------------------------------------------------------------------------------- /src/tasks/addKubeGreenframeDaemonset.ts: -------------------------------------------------------------------------------- 1 | import { TaskWrapper } from 'listr2/dist/lib/task-wrapper'; 2 | import { GREENFRAME_NAMESPACE } from '../constants'; 3 | import { kubeClient } from '../services/container/kubernetes/client'; 4 | 5 | const greenframeDaemonset = { 6 | apiVersion: 'apps/v1', 7 | kind: 'DaemonSet', 8 | metadata: { 9 | annotations: { 10 | 'seccomp.security.alpha.kubernetes.io/pod': 'docker/default', 11 | }, 12 | labels: { 13 | app: 'cadvisor', 14 | }, 15 | name: 'cadvisor', 16 | namespace: GREENFRAME_NAMESPACE, 17 | }, 18 | spec: { 19 | selector: { 20 | matchLabels: { 21 | app: 'cadvisor', 22 | name: 'cadvisor', 23 | }, 24 | }, 25 | template: { 26 | metadata: { 27 | labels: { 28 | app: 'cadvisor', 29 | name: 'cadvisor', 30 | }, 31 | }, 32 | spec: { 33 | automountServiceAccountToken: false, 34 | containers: [ 35 | { 36 | args: [ 37 | '--housekeeping_interval=500ms', 38 | '--global_housekeeping_interval=500ms', 39 | '--allow_dynamic_housekeeping=false', 40 | '--disable_metrics=accelerator,advtcp,cpu_topology,cpuset,hugetlb,memory_numa,percpu,process,referenced_memory,resctrl,sched,tcp,udp', 41 | ], 42 | image: 'gcr.io/cadvisor/cadvisor:v0.39.3', 43 | name: 'cadvisor', 44 | ports: [ 45 | { 46 | containerPort: 8080, 47 | name: 'http', 48 | protocol: 'TCP', 49 | }, 50 | ], 51 | resources: { 52 | limits: { 53 | cpu: '900m', 54 | memory: '2Gi', 55 | }, 56 | requests: { 57 | cpu: '400m', 58 | memory: '400Mi', 59 | }, 60 | }, 61 | volumeMounts: [ 62 | { 63 | mountPath: '/rootfs', 64 | name: 'rootfs', 65 | readOnly: true, 66 | }, 67 | { 68 | mountPath: '/var/run', 69 | name: 'var-run', 70 | readOnly: true, 71 | }, 72 | { 73 | mountPath: '/sys', 74 | name: 'sys', 75 | readOnly: true, 76 | }, 77 | { 78 | mountPath: '/var/lib/docker', 79 | name: 'docker', 80 | readOnly: true, 81 | }, 82 | { 83 | mountPath: '/dev/disk', 84 | name: 'disk', 85 | readOnly: true, 86 | }, 87 | { 88 | name: 'containerd', 89 | mountPath: '/var/run/containerd/containerd.sock', 90 | readOnly: true, 91 | }, 92 | ], 93 | }, 94 | ], 95 | terminationGracePeriodSeconds: 30, 96 | volumes: [ 97 | { 98 | hostPath: { 99 | path: '/', 100 | }, 101 | name: 'rootfs', 102 | }, 103 | { 104 | hostPath: { 105 | path: '/var/run', 106 | }, 107 | name: 'var-run', 108 | }, 109 | { 110 | hostPath: { 111 | path: '/sys', 112 | }, 113 | name: 'sys', 114 | }, 115 | { 116 | hostPath: { 117 | path: '/var/lib/docker', 118 | }, 119 | name: 'docker', 120 | }, 121 | { 122 | hostPath: { 123 | path: '/dev/disk', 124 | }, 125 | name: 'disk', 126 | }, 127 | { 128 | hostPath: { 129 | path: 130 | process.env.CONTAINERD_SOCK || 131 | '/var/run/containerd/containerd.sock', 132 | }, 133 | name: 'containerd', 134 | }, 135 | ], 136 | }, 137 | }, 138 | }, 139 | }; 140 | 141 | export const addKubeGreenframeDaemonset = async ( 142 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 143 | _: any, 144 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 145 | task: TaskWrapper 146 | ) => { 147 | const { body } = await kubeClient.list('apps/v1', 'DaemonSet', GREENFRAME_NAMESPACE); 148 | if (body.items.some((item) => item.metadata?.name === 'cadvisor')) { 149 | task.title = 'Greenframe daemonset already exists'; 150 | return; 151 | } 152 | 153 | await kubeClient.create(greenframeDaemonset); 154 | }; 155 | -------------------------------------------------------------------------------- /src/services/container/kubernetes/structureNodes.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import { V1Pod } from '@kubernetes/client-node'; 3 | import { getCadvisorPods } from './cadvisor'; 4 | import { getPodName, getNodeName, getPodsByLabel } from './pods'; 5 | import { CONTAINER_TYPES } from '../../../constants'; 6 | import { getContainerStats } from './getContainerStats'; 7 | import { ValueOf } from '../../../types'; 8 | 9 | const debug = initDebug('greenframe:services:container:structureNodes'); 10 | 11 | export type AugmentedPod = V1Pod & { 12 | type: ValueOf; 13 | fullName: string; 14 | greenframeId: string; // Identifier for the pod or container in greenframe, must be unique 15 | container?: string; // Name of the container in the pod 16 | networkContainerId?: string; // Identifier of the network container in cgroup 17 | linkedContainers: string[]; // greenframeId of linked containers of the network container 18 | }; 19 | export type Nodes = Record; 20 | 21 | export const getNodes = async ( 22 | podsNamespacesAndLabels: string[] = [], 23 | databaseNamespacesAndLabels: string[] = [] 24 | ) => { 25 | debug('Get nodes for pods and databases'); 26 | const nodes = await initNodesWithCadvisorPod(); 27 | const observedNetworkPods = new Map(); 28 | const counterInstance = new Map(); 29 | 30 | await addPodNodes( 31 | nodes, 32 | podsNamespacesAndLabels, 33 | observedNetworkPods, 34 | counterInstance, 35 | CONTAINER_TYPES.SERVER 36 | ); 37 | await addPodNodes( 38 | nodes, 39 | databaseNamespacesAndLabels, 40 | observedNetworkPods, 41 | counterInstance, 42 | CONTAINER_TYPES.DATABASE 43 | ); 44 | 45 | // Remove nodes without pods (except cadvisor) 46 | for (const node of Object.keys(nodes)) { 47 | nodes[node].length === 1 && delete nodes[node]; 48 | } 49 | 50 | return nodes; 51 | }; 52 | 53 | export const initNodesWithCadvisorPod = async () => { 54 | const cadvisorNodes: Nodes = {}; 55 | const cadvisorPods = await getCadvisorPods(); 56 | for (const cadvisorPod of cadvisorPods) { 57 | const node = getNodeName(cadvisorPod); 58 | cadvisorNodes[node] = [ 59 | addTypeToPod( 60 | cadvisorPod, 61 | CONTAINER_TYPES.SERVER, 62 | getPodName(cadvisorPod), 63 | getPodName(cadvisorPod) 64 | ), 65 | ]; 66 | } 67 | 68 | return cadvisorNodes; 69 | }; 70 | 71 | const addTypeToPod = ( 72 | pod: V1Pod, 73 | type: ValueOf, 74 | fullName: string, 75 | greenframeId: string, 76 | container?: string, 77 | networkContainerId?: string 78 | ): AugmentedPod => { 79 | const augmentedPod = { 80 | type, 81 | fullName, 82 | greenframeId, 83 | container, 84 | networkContainerId, 85 | linkedContainers: [], 86 | ...pod, 87 | }; 88 | return augmentedPod; 89 | }; 90 | 91 | const addPodNodes = async ( 92 | nodes: Nodes, 93 | namespacesAndLabels: string[], 94 | observedNetworkPods: Map, 95 | counterInstance: Map, 96 | type: ValueOf 97 | ) => { 98 | for (const namespaceAndLabel of namespacesAndLabels) { 99 | debug(`Get pods for ${namespaceAndLabel}`); 100 | const [namespace, rest] = namespaceAndLabel.split(':'); 101 | const [label, container] = rest.split('/'); 102 | const pods = await getPodsByLabel(label, namespace); 103 | for (const pod of pods) { 104 | const node = getNodeName(pod); 105 | if (!nodes[node]) { 106 | throw new Error(`Cannot find cadvisor instance for node ${node}`); 107 | } 108 | 109 | const podName = getPodName(pod); 110 | 111 | const instanceNb = counterInstance.get(namespaceAndLabel) ?? 0; 112 | counterInstance.set(namespaceAndLabel, instanceNb + 1); 113 | 114 | const greenframeId = `${namespaceAndLabel}-${instanceNb}`; 115 | 116 | if (!observedNetworkPods.has(podName)) { 117 | const networkContainerId = await findNetworkContainer( 118 | nodes[node][0], 119 | pod 120 | ); 121 | const augmentedPod = addTypeToPod( 122 | pod, 123 | type, 124 | `${podName}/network`, 125 | `${namespace}:${label}/network-${instanceNb}`, 126 | 'network', 127 | networkContainerId 128 | ); 129 | nodes[node].push(augmentedPod); 130 | observedNetworkPods.set(podName, augmentedPod); 131 | } 132 | 133 | const networkContainer = observedNetworkPods.get(podName); 134 | networkContainer?.linkedContainers.push(greenframeId); 135 | nodes[node].push( 136 | addTypeToPod( 137 | pod, 138 | type, 139 | `${podName}${container != null ? `/${container}` : ''}`, 140 | greenframeId, 141 | container 142 | ) 143 | ); 144 | } 145 | } 146 | }; 147 | 148 | const findNetworkContainer = async (cadvisorPod: AugmentedPod, pod: V1Pod) => { 149 | const augmentedPod = addTypeToPod(pod, CONTAINER_TYPES.NETWORK, getPodName(pod), ''); 150 | const data = await getContainerStats(cadvisorPod, augmentedPod); 151 | if (!data.subcontainers) { 152 | throw new Error(`Cannot find containers for pod ${pod.metadata?.name}`); 153 | } 154 | 155 | for (const container of data.subcontainers) { 156 | const containerId = container.name.split('/').slice(-1)[0]; 157 | const augmentedContainerPod = addTypeToPod( 158 | pod, 159 | CONTAINER_TYPES.NETWORK, 160 | `${getPodName(pod)}/${containerId}`, 161 | '', 162 | undefined, 163 | containerId 164 | ); 165 | const containerData = await getContainerStats(cadvisorPod, augmentedContainerPod); 166 | if (containerData.spec.image?.includes('k8s.gcr.io/pause')) { 167 | return containerId; 168 | } 169 | } 170 | 171 | throw new Error(`Cannot find network container for pod ${getPodName(pod)}`); 172 | }; 173 | -------------------------------------------------------------------------------- /src/services/container/index.ts: -------------------------------------------------------------------------------- 1 | import initDebug from 'debug'; 2 | import { 3 | createContainer, 4 | execScenarioContainer, 5 | startContainer, 6 | stopContainer, 7 | } from './execScenarioContainer'; 8 | import getContainerStatsIfRunning from './getContainerStats'; 9 | 10 | import { CONTAINER_DEVICE_NAME, CONTAINER_TYPES, DEFAULT_SAMPLES } from '../../constants'; 11 | import type { ValueOf } from '../../types'; 12 | import { getPodsStats } from './getPodsStats'; 13 | import { mergePodStatsWithNetworkStats } from './kubernetes/mergePodStatsWithNetworkStats'; 14 | import { CadvisorContainerStats } from './kubernetes/stats'; 15 | import { getNodes } from './kubernetes/structureNodes'; 16 | 17 | const debug = initDebug('greenframe:services:container'); 18 | 19 | export type kubernetesStats = { 20 | stats: CadvisorContainerStats[]; 21 | sample: number; 22 | timelines: any; 23 | }[]; 24 | 25 | export type KubernetesRuns = { 26 | [key: string]: { 27 | name: string; 28 | type: ValueOf; 29 | kubernetesStats: kubernetesStats; 30 | }; 31 | }; 32 | 33 | export const executeScenarioAndGetContainerStats = async ({ 34 | scenario, 35 | url, 36 | samples = DEFAULT_SAMPLES, 37 | useAdblock, 38 | ignoreHTTPSErrors, 39 | locale, 40 | timezoneId, 41 | containers = [], 42 | databaseContainers = [], 43 | kubeContainers = [], 44 | kubeDatabaseContainers = [], 45 | extraHosts = [], 46 | envVars = [], 47 | envFile = '', 48 | dockerdHost, 49 | dockerdPort, 50 | }: { 51 | scenario: string; 52 | url: string; 53 | samples?: number; 54 | useAdblock?: boolean; 55 | ignoreHTTPSErrors?: boolean; 56 | locale?: string; 57 | timezoneId?: string; 58 | containers?: string[] | string; 59 | databaseContainers?: string[] | string; 60 | kubeContainers?: string[]; 61 | kubeDatabaseContainers?: string[]; 62 | extraHosts?: string[]; 63 | envVars?: string[]; 64 | envFile?: string; 65 | dockerdHost?: string; 66 | dockerdPort?: number; 67 | }) => { 68 | try { 69 | debug('Starting container'); 70 | await stopContainer(); 71 | await createContainer(extraHosts, envVars, envFile); 72 | await startContainer(); 73 | debug('Container started'); 74 | let allContainers: { 75 | name: string; 76 | type: ValueOf; 77 | kubernetesStats?: kubernetesStats; 78 | dockerStats?: any[]; 79 | stopContainerStats?: () => unknown; 80 | }[] = [ 81 | { 82 | name: CONTAINER_DEVICE_NAME, 83 | type: CONTAINER_TYPES.DEVICE, 84 | dockerStats: [], 85 | }, 86 | ]; 87 | 88 | if (typeof containers === 'string') { 89 | containers = containers.split(','); 90 | } 91 | 92 | const allMilestones = []; 93 | 94 | allContainers = allContainers.concat( 95 | containers.map((container) => ({ 96 | name: container, 97 | type: CONTAINER_TYPES.SERVER, 98 | dockerStats: [], 99 | })) 100 | ); 101 | 102 | if (typeof databaseContainers === 'string') { 103 | databaseContainers = databaseContainers.split(','); 104 | } 105 | 106 | allContainers = allContainers.concat( 107 | databaseContainers.map((container) => ({ 108 | name: container, 109 | type: CONTAINER_TYPES.DATABASE, 110 | dockerStats: [], 111 | })) 112 | ); 113 | 114 | const nodes = 115 | (kubeContainers && kubeContainers.length > 0) || 116 | (kubeDatabaseContainers && kubeDatabaseContainers.length > 0) 117 | ? await getNodes(kubeContainers, kubeDatabaseContainers) 118 | : {}; 119 | const kubernetesResults: { 120 | [key: string]: { 121 | name: string; 122 | type: ValueOf; 123 | kubernetesStats: kubernetesStats; 124 | }; 125 | } = {}; 126 | 127 | for (let sample = 1; sample <= samples; sample++) { 128 | debug('Getting stats for sample', sample); 129 | for (const container of allContainers) { 130 | const stopContainerStats = await getContainerStatsIfRunning( 131 | container.name, 132 | { dockerdHost, dockerdPort } 133 | ); 134 | container.stopContainerStats = stopContainerStats; 135 | } 136 | 137 | const stop = getPodsStats(nodes); 138 | 139 | const { timelines, milestones } = await execScenarioContainer(scenario, url, { 140 | useAdblock, 141 | ignoreHTTPSErrors, 142 | locale, 143 | timezoneId, 144 | }); 145 | 146 | allMilestones.push(milestones); 147 | 148 | allContainers = allContainers.map((container) => { 149 | if (!container.stopContainerStats) { 150 | throw new Error( 151 | `Can't stop container ${container.name}, command not found` 152 | ); 153 | } 154 | 155 | const containerStats = container.stopContainerStats(); 156 | container.stopContainerStats = undefined; 157 | container.dockerStats?.push({ 158 | sample, 159 | stats: containerStats, 160 | timelines, 161 | }); 162 | 163 | return container; 164 | }); 165 | 166 | const kubernetesStats = stop(); 167 | 168 | for (const result of Object.values(kubernetesStats)) { 169 | if (!kubernetesResults[result.podName]) { 170 | kubernetesResults[result.podName] = { 171 | name: result.podName, 172 | type: result.podType, 173 | kubernetesStats: [], 174 | }; 175 | } 176 | 177 | kubernetesResults[result.podName].kubernetesStats.push({ 178 | stats: result.stats, 179 | sample, 180 | timelines, 181 | }); 182 | } 183 | } 184 | 185 | mergePodStatsWithNetworkStats(nodes, kubernetesResults); 186 | allContainers = [...allContainers, ...Object.values(kubernetesResults)]; 187 | debug('Returning', allContainers.length); 188 | return { allContainers, allMilestones }; 189 | } catch (error) { 190 | debug('Error', error); 191 | throw error; 192 | } finally { 193 | await stopContainer(); 194 | } 195 | }; 196 | -------------------------------------------------------------------------------- /e2e/local/analyze.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-conditional-expect */ 2 | const util = require('node:util'); 3 | const exec = util.promisify(require('node:child_process').exec); 4 | 5 | const BASE_COMMAND = `./bin/run analyze`; 6 | 7 | describe('[LOCAL] greenframe analyze', () => { 8 | describe('single page', () => { 9 | describe('local analysis', () => { 10 | it('should raise and error on non HTTPS websites', async () => { 11 | expect.assertions(2); 12 | try { 13 | await exec(`${BASE_COMMAND} https://untrusted-root.badssl.com/`); 14 | } catch (error) { 15 | expect(error.stderr).toContain('❌ main scenario failed'); 16 | expect(error.stderr).toContain('net::ERR_CERT_AUTHORITY_INVALID'); 17 | } 18 | }); 19 | 20 | it('should work on non HTTPS websites with --ignoreHTTPSErrors flag', async () => { 21 | const { stdout } = await exec( 22 | `${BASE_COMMAND} https://untrusted-root.badssl.com/ --ignoreHTTPSErrors` 23 | ); 24 | expect(stdout).toContain('✅ main scenario completed'); 25 | }); 26 | 27 | it('should set greenframe browser locale right', async () => { 28 | const { stdout: enStdout } = await exec( 29 | `${BASE_COMMAND} -C ./e2e/.greenframe.single.en.yml` 30 | ); 31 | expect(enStdout).toContain('✅ main scenario completed'); 32 | const { stdout: frStdout } = await exec( 33 | `${BASE_COMMAND} -C ./e2e/.greenframe.single.fr.yml` 34 | ); 35 | expect(frStdout).toContain('✅ main scenario completed'); 36 | }); 37 | 38 | it('should run an analysis command with adblocker', async () => { 39 | const { error, stdout } = await exec( 40 | `${BASE_COMMAND} -C ./e2e/.greenframe.single.adblock.yml` 41 | ); 42 | expect(stdout).toContain('✅ main scenario completed'); 43 | expect(stdout).toContain('The estimated footprint is'); 44 | expect(error).toBeUndefined(); 45 | }); 46 | }); 47 | }); 48 | 49 | // we need to setup a mock dev environment to enable this test 50 | // eslint-disable-next-line jest/no-disabled-tests 51 | describe.skip('full stack', () => { 52 | describe('local analysis', () => { 53 | it('should run an analysis command correctly', async () => { 54 | const { error, stdout } = await exec( 55 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.yml` 56 | ); 57 | 58 | expect(stdout).toContain('✅ main scenario completed'); 59 | expect(stdout).toContain('The estimated footprint is'); 60 | expect(error).toBeUndefined(); 61 | }); 62 | 63 | it('should run an analysis command below a threshold', async () => { 64 | const { error, stdout } = await exec( 65 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.yml -t 0.1` 66 | ); 67 | expect(stdout).toContain('✅ main scenario completed'); 68 | expect(stdout).toContain('The estimated footprint at'); 69 | expect(stdout).toContain( 70 | 'is under the limit configured at 0.1 g eq. co2.' 71 | ); 72 | expect(error).toBeUndefined(); 73 | }); 74 | 75 | it('should run an analysis and fail with higher measure than threshold', async () => { 76 | expect.assertions(3); 77 | try { 78 | await exec( 79 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.yml -s 2 -t 0.001` 80 | ); 81 | } catch (error) { 82 | expect(error.stderr).toContain('❌ main scenario failed'); 83 | expect(error.stderr).toContain('The estimated footprint at'); 84 | expect(error.stderr).toContain( 85 | 'passes the limit configured at 0.001 g eq. co2.' 86 | ); 87 | } 88 | }); 89 | 90 | it('should run an analysis with multiple scenario', async () => { 91 | const { error, stdout } = await exec( 92 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.multiple.yml` 93 | ); 94 | expect(stdout).toContain('✅ Scenario 1 completed'); 95 | expect(stdout).toContain( 96 | 'is under the limit configured at 0.1 g eq. co2.' 97 | ); 98 | expect(stdout).toContain('✅ Scenario 2 completed'); 99 | expect(stdout).toContain( 100 | 'is under the limit configured at 0.05 g eq. co2.' 101 | ); 102 | expect(error).toBeUndefined(); 103 | }); 104 | 105 | it('should fail because "container_broken" is not a running container', async () => { 106 | expect.assertions(2); 107 | try { 108 | await exec( 109 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.broken.yml` 110 | ); 111 | } catch (error) { 112 | expect(error.stderr).toContain('❌ Failed!'); 113 | expect(error.stderr).toContain( 114 | 'container_broken container is not running.' 115 | ); 116 | } 117 | }); 118 | 119 | it('should fail because user has reached the project limit', async () => { 120 | expect.assertions(2); 121 | try { 122 | await exec( 123 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.yml -p NewProject` 124 | ); 125 | } catch (error) { 126 | expect(error.stderr).toContain('❌ Failed!'); 127 | expect(error.stderr).toContain( 128 | "Unauthorized access: You have reached your project's limit." 129 | ); 130 | } 131 | }); 132 | 133 | it('should run default analysis command correctly with empty scenario', async () => { 134 | const { error, stdout } = await exec( 135 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.emptyScenario.yml` 136 | ); 137 | 138 | expect(stdout).toContain('✅ main scenario completed'); 139 | expect(stdout).toContain('The estimated footprint is'); 140 | expect(error).toBeUndefined(); 141 | }); 142 | 143 | // This is disabled because it requires a kubernetes cluster to be running while testing 144 | // eslint-disable-next-line jest/no-disabled-tests 145 | it.skip('should run a k8s analysis command correctly', async () => { 146 | const { error, stdout } = await exec( 147 | `${BASE_COMMAND} -C ./e2e/.greenframe.fullstack.k8s.yml` 148 | ); 149 | 150 | expect(stdout).toContain('✅ main scenario completed'); 151 | expect(stdout).toContain('The estimated footprint is'); 152 | expect(error).toBeUndefined(); 153 | }); 154 | }); 155 | }); 156 | }); 157 | --------------------------------------------------------------------------------