diff --git a/package-lock.json b/package-lock.json index 9c20c27..85ac3d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@types/cytoscape": "^3.19.9", "@vscode/l10n": "^0.0.13", + "compare-versions": "^5.0.3", "cross-fetch": "^3.1.5", "outscale-api": "^0.11.0", "rxjs": "^7.5.7", @@ -1398,8 +1399,7 @@ "node_modules/compare-versions": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.3.tgz", - "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==", - "dev": true + "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==" }, "node_modules/concat-map": { "version": "0.0.1", @@ -6331,8 +6331,7 @@ "compare-versions": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.3.tgz", - "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==", - "dev": true + "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==" }, "concat-map": { "version": "0.0.1", diff --git a/package.json b/package.json index 5c9dccb..4696059 100644 --- a/package.json +++ b/package.json @@ -413,6 +413,7 @@ "dependencies": { "@types/cytoscape": "^3.19.9", "@vscode/l10n": "^0.0.13", + "compare-versions": "^5.0.3", "cross-fetch": "^3.1.5", "outscale-api": "^0.11.0", "rxjs": "^7.5.7", diff --git a/package.nls.fr.json b/package.nls.fr.json index 942664c..a10f8c1 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -29,5 +29,7 @@ "osc.teardownNet": "Détruire", "osc.retrieveAdminPassword": "Copier le mot de passe administrateur", "osc.showNetworkView": "(Beta) Afficher la vue VPC", - "viewsWelcome.text": "Pour utiliser l'extension 3DS Outscale, vous devez configurer des profiles dans le fichier de configuration.\n[Ouvrir le fichier de configuration](command:profile.configure)\n[Ajouter un profile](command:profile.addEntry)\n" + "viewsWelcome.text": "Pour utiliser l'extension 3DS Outscale, vous devez configurer des profiles dans le fichier de configuration.\n[Ouvrir le fichier de configuration](command:profile.configure)\n[Ajouter un profile](command:profile.addEntry)\n", + "osc-viewer.costEstimation.enabled": "Calcul l'estimation du coût des comptes Cloud.", + "osc-viewer.costEstimation.oscCostPath": "Chemin du binaire osc-cost." } \ No newline at end of file diff --git a/package.nls.json b/package.nls.json index 79207b6..e2b3580 100644 --- a/package.nls.json +++ b/package.nls.json @@ -29,5 +29,7 @@ "osc.teardownNet": "Tear down", "osc.retrieveAdminPassword": "Copy Administrator password", "osc.showNetworkView": "(Beta) Show VPC View", - "viewsWelcome.text": "In order to use 3DS Outscale plugin, you have to configure profiles the configuration file.\n[Open configuration file](command:profile.configure)\n[Add a profile](command:profile.addEntry)\n" + "viewsWelcome.text": "In order to use 3DS Outscale plugin, you have to configure profiles the configuration file.\n[Open configuration file](command:profile.configure)\n[Add a profile](command:profile.addEntry)\n", + "osc-viewer.costEstimation.enabled": "Compute cost estimation of profiles.", + "osc-viewer.costEstimation.oscCostPath": "Path of the binary osc-cost." } \ No newline at end of file diff --git a/src/components/osc_cost.ts b/src/components/osc_cost.ts index 9ce4856..256401c 100644 --- a/src/components/osc_cost.ts +++ b/src/components/osc_cost.ts @@ -1,43 +1,26 @@ import * as vscode from 'vscode'; import { getConfigurationParameter, OSC_COST_PARAMETER } from "../configuration/utils"; import { pathExists } from "../config_file/utils"; -import { ACCESSKEY_FOLDER_NAME } from "../flat/folders/simple/node.folder.accesskey"; -import { APIACCESSRULES_FOLDER_NAME } from "../flat/folders/simple/node.folder.apiaccessrule"; -import { CA_FOLDER_NAME } from "../flat/folders/simple/node.folder.ca"; -import { CLIENTGATEWAYS_FOLDER_NAME } from "../flat/folders/simple/node.folder.clientgateway"; -import { DHCPOPTIONS_FOLDER_NAME } from "../flat/folders/simple/node.folder.dhcpoption"; -import { DIRECTLINKS_FOLDER_NAME } from "../flat/folders/simple/node.folder.directlink"; -import { DIRECTLINKINTERFACES_FOLDER_NAME } from "../flat/folders/simple/node.folder.directlinkinterface"; -import { IMAGES_FOLDER_NAME } from "../flat/folders/simple/node.folder.image"; -import { KEYPAIRS_FOLDER_NAME } from "../flat/folders/simple/node.folder.keypair"; import { LOADBALANCER_FOLDER_NAME } from "../flat/folders/simple/node.folder.loadbalancer"; import { NATSERVICES_FOLDER_NAME } from "../flat/folders/simple/node.folder.natservice"; -import { NETACCESSPOINTS_FOLDER_NAME } from "../flat/folders/simple/node.folder.netaccesspoint"; -import { NETPEERINGS_FOLDER_NAME } from "../flat/folders/simple/node.folder.netpeering"; import { SNAPSHOTS_FOLDER_NAME } from "../flat/folders/simple/node.folder.snapshot"; -import { SUBNETS_FOLDER_NAME } from "../flat/folders/simple/node.folder.subnet"; import { VPNCONNECTIONS_FOLDER_NAME } from "../flat/folders/simple/node.folder.vpnconnection"; import { FLEXIBLEGPUS_FOLDER_NAME } from "../flat/folders/specific/node.folder.flexiblegpu"; -import { INTERNETSERVICES_FOLDER_NAME } from "../flat/folders/specific/node.folder.internetservice"; -import { NET_FOLDER_NAME } from "../flat/folders/specific/node.folder.net"; -import { NICS_FOLDER_NAME } from "../flat/folders/specific/node.folder.nic"; import { PUBLICIP_FOLDER_NAME } from "../flat/folders/specific/node.folder.publicip"; -import { ROUTETABLES_FOLDER_NAME } from "../flat/folders/specific/node.folder.routetable"; -import { SECURITYGROUPS_FOLDER_NAME } from "../flat/folders/specific/node.folder.securitygroup"; -import { VIRTUALGATEWAYS_FOLDER_NAME } from "../flat/folders/specific/node.folder.virtualgateway"; import { VM_FOLDER_NAME } from "../flat/folders/specific/node.folder.vm"; import { VOLUME_FOLDER_NAME } from "../flat/folders/specific/node.folder.volume"; import { Profile, ResourceNodeType } from "../flat/node"; import { OutputChannel } from "../logs/output_channel"; import { shell } from "./shell"; +import { satisfies } from 'compare-versions'; export type ResourceCost = number; export type ResourcesTypeCost = { globalPrice: number, values: Map }; -const DEFAULT_OPTIONS_OSC_COST = new Map([ - ['v0.1.0', '--format json'], - ['v0.2.0', '--format json --skip-resource Oos'], - ]); +const DEFAULT_OPTIONS_OSC_COST: [string, string][] = [ + ['=v0.2.0', '--format json --skip-resource Oos'], // Skipe OOS to reduce the latency +]; export class AccountCost { accountCost: number; @@ -100,7 +83,7 @@ function formatPrice(price: number, currency: string): string { return "~" + price.toFixed(2) + currency; } -export function getCurrency(region: string): string { +function getCurrency(region: string): string { switch (region) { case "eu-west-2": case "cloudgouv-eu-west-1": @@ -130,30 +113,10 @@ function folderNameToOscCostResourceType(folderName: string): string | undefined case FLEXIBLEGPUS_FOLDER_NAME: return "FlexibleGpu"; case VPNCONNECTIONS_FOLDER_NAME: - return "FlexibleGpu"; + return "Vpn"; case NATSERVICES_FOLDER_NAME: return "NatServices"; - case ACCESSKEY_FOLDER_NAME: - case CLIENTGATEWAYS_FOLDER_NAME: - case IMAGES_FOLDER_NAME: - case INTERNETSERVICES_FOLDER_NAME: - case KEYPAIRS_FOLDER_NAME: - case NET_FOLDER_NAME: - case NICS_FOLDER_NAME: - case ROUTETABLES_FOLDER_NAME: - case SECURITYGROUPS_FOLDER_NAME: - case SUBNETS_FOLDER_NAME: - case VIRTUALGATEWAYS_FOLDER_NAME: - case DHCPOPTIONS_FOLDER_NAME: - case DIRECTLINKS_FOLDER_NAME: - case DIRECTLINKINTERFACES_FOLDER_NAME: - case NETPEERINGS_FOLDER_NAME: - case APIACCESSRULES_FOLDER_NAME: - case NETACCESSPOINTS_FOLDER_NAME: - case CA_FOLDER_NAME: - return undefined; default: - OutputChannel.getInstance().appendLine(`The folder '${folderName}' is not handle for osc-cost conversion. Report it to the developpers`); return undefined; } } @@ -173,30 +136,10 @@ function resourceNodeTypeToOscCostResourceType(resourceNodeType: ResourceNodeTyp case 'FlexibleGpu': return "FlexibleGpu"; case 'VpnConnection': - return "FlexibleGpu"; + return "Vpn"; case 'NatService': return "NatServices"; - case 'AccessKey': - case 'ClientGateway': - case 'omis': - case 'InternetService': - case 'keypairs': - case 'vpc': - case 'Nic': - case 'routetables': - case 'securitygroups': - case 'Subnet': - case 'VirtualGateway': - case 'DhcpOption': - case 'DirectLink': - case 'DirectLinkInterface': - case 'NetPeering': - case 'ApiAccessRule': - case 'NetAccessPoint': - case 'Ca': - return undefined; default: - OutputChannel.getInstance().appendLine(`The resourceNodeType '${resourceNodeType}' is not handle for osc-cost conversion. Report it to the developpers`); return undefined; } } @@ -205,7 +148,6 @@ function resourceNodeTypeToOscCostResourceType(resourceNodeType: ResourceNodeTyp function jsonToAccountCost(oscCostOutput: string): AccountCost | undefined { const accountCost = new AccountCost(); for (const jsonString of oscCostOutput.split('\n')) { - OutputChannel.getInstance().appendLine(`The jsonString is ${jsonString}`); let json; try { json = JSON.parse(jsonString); @@ -276,12 +218,26 @@ export async function fetchAccountCost(profile: Profile): Promise { + const res = await shell.exec(`${oscCostPath} --version`); + if (typeof res === "undefined") { + return res; + } + // version is like "osc-cost X.Y.Z" + return res.split(" ")[1].trim(); + +} + +function getDefaultOptions(version: string): string | undefined { + const options = DEFAULT_OPTIONS_OSC_COST.filter((val) => { + return satisfies(version, val[0]); + }); + + if (options.length > 1) { + OutputChannel.getInstance().appendLine("Got multiple default options possible, rejecting all of them"); + return undefined; + } + + if (options.length === 0) { + OutputChannel.getInstance().appendLine("Got none default option"); + return undefined; + } + + return options[0][1]; +} diff --git a/src/components/shell.ts b/src/components/shell.ts index 99559c6..1b6027f 100644 --- a/src/components/shell.ts +++ b/src/components/shell.ts @@ -1,131 +1,28 @@ 'use strict'; -import * as vscode from 'vscode'; import * as shelljs from 'shelljs'; -import { ChildProcess } from 'child_process'; -export enum Platform { - // eslint-disable-next-line @typescript-eslint/naming-convention - Windows, - // eslint-disable-next-line @typescript-eslint/naming-convention - MacOS, - // eslint-disable-next-line @typescript-eslint/naming-convention - Linux, - // eslint-disable-next-line @typescript-eslint/naming-convention - Unsupported, // shouldn't happen! -} +import util = require('util'); +import _exec = require('child_process'); +const innerExec = util.promisify(_exec.exec); + export interface Shell { - isWindows(): boolean; - isUnix(): boolean; - platform(): Platform; - execOpts(): any; - exec(cmd: string, stdin?: string): Promise; + exec(cmd: string): Promise; which(bin: string): string | null; } export const shell: Shell = { - isWindows: isWindows, - isUnix: isUnix, - platform: platform, - execOpts: execOpts, exec: exec, which: which, }; -const WINDOWS = 'win32'; - -export interface ShellResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; -} - -export type ShellHandler = (code: number, stdout: string, stderr: string) => void; - -function isWindows(): boolean { - return (process.platform === WINDOWS); -} - -function isUnix(): boolean { - return !isWindows(); -} - -function platform(): Platform { - switch (process.platform) { - case 'win32': return Platform.Windows; - case 'darwin': return Platform.MacOS; - case 'linux': return Platform.Linux; - default: return Platform.Unsupported; - } +async function exec(cmd: string): Promise { + const { stdout } = await innerExec(cmd); + return stdout; } -function concatIfSafe(homeDrive: string | undefined, homePath: string | undefined): string | undefined { - if (homeDrive && homePath) { - const safe = !homePath.toLowerCase().startsWith('\\windows\\system32'); - if (safe) { - return homeDrive.concat(homePath); - } - } - - return undefined; -} - -function home(): string { - return process.env['HOME'] || - concatIfSafe(process.env['HOMEDRIVE'], process.env['HOMEPATH']) || - process.env['USERPROFILE'] || - ''; -} - -function execOpts(): any { - let env = process.env; - if (isWindows()) { - // eslint-disable-next-line @typescript-eslint/naming-convention - env = Object.assign({}, env, { HOME: home() }); - } - env = shellEnvironment(env); - - const opts = { - cwd: typeof vscode.workspace.workspaceFolders === 'undefined' ? undefined : vscode.workspace.workspaceFolders[0], - env: env, - async: true - }; - return opts; -} - -async function exec(cmd: string, stdin?: string): Promise { - try { - return await execCore(cmd, execOpts(), null, stdin); - } catch (ex) { - vscode.window.showErrorMessage(`${ex}`); - return undefined; - } -} - -function execCore(cmd: string, opts: any, callback?: ((proc: ChildProcess) => void) | null, stdin?: string): Promise { - return new Promise((resolve) => { - const proc = shelljs.exec(cmd, opts, (code, stdout, stderr) => resolve({ code: code, stdout: stdout, stderr: stderr })); - if (stdin && proc.stdin !== null) { - proc.stdin.end(stdin); - } - if (callback) { - callback(proc); - } - }); -} function which(bin: string): string | null { return shelljs.which(bin); -} - -export function shellEnvironment(baseEnvironment: any): any { - const env = Object.assign({}, baseEnvironment); - return env; -} - -const SAFE_CHARS_REGEX = /^[-,._+:@%/\w]*$/; - -export function isSafe(s: string): boolean { - return SAFE_CHARS_REGEX.test(s); -} +} \ No newline at end of file diff --git a/src/explorer.ts b/src/explorer.ts index 749e4c4..beeb0fb 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -11,26 +11,39 @@ export class OscExplorer implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - refresh(): void { this._onDidChangeTreeData.fire(); } - getTreeItem(element: ExplorerNode): Thenable { + getTreeItem(element: ExplorerNode): vscode.TreeItem { return element.getTreeItem(); } - async getChildren(element?: ExplorerNode): Promise { + getChildren(element?: ExplorerNode): Thenable { if (element) { return element.getChildren(); } else { - const toExplorerNode = async (profileName: string, definition: any): Promise => { + const toExplorerNode = (profileName: string, definition: any): ProfileNode => { const profile = jsonToProfile(profileName, definition); + const profileObj = new ProfileNode(profile); + if (isOscCostEnabled()) { - profile.oscCost = await this.retrieveAccountCost(profile); - OutputChannel.getInstance().appendLine(`Retrieve the cost for ${profile.name}: ${profile.oscCost?.accountCost}`); + // Do not wait for completion but only fire a refresh on the node only if the data is available + this.retrieveAccountCost(profile).then( + (res: AccountCost | undefined) => { + if (typeof res === 'undefined') { + vscode.window.showErrorMessage(vscode.l10n.t(`Retrieve the cost for ${profile.name} fails: undefined`)); + return; + } + profileObj.profile.oscCost = res; + this._onDidChangeTreeData.fire(profileObj); + OutputChannel.getInstance().appendLine(`Retrieve the cost for ${profile.name}: ${profile.oscCost?.accountCost}`); + }, + (reason: any) => { + vscode.window.showErrorMessage(vscode.l10n.t(`Retrieval the cost for ${profile.name} fails: ${reason}`)); + }); } - return Promise.resolve(new ProfileNode(profile)); + return profileObj; }; const oscConfigObject = readConfigFile(); @@ -38,8 +51,7 @@ export class OscExplorer implements vscode.TreeDataProvider { vscode.window.showErrorMessage(vscode.l10n.t('No config file found')); return Promise.resolve([]); } - const explorerNodes = await Promise.all(Object.keys(oscConfigObject).map(async (dep) => await toExplorerNode(dep, oscConfigObject[dep]))); - + const explorerNodes = Object.keys(oscConfigObject).map(dep => toExplorerNode(dep, oscConfigObject[dep])); return Promise.resolve(explorerNodes); } diff --git a/src/flat/node.profile.ts b/src/flat/node.profile.ts index 556a3f1..60821eb 100644 --- a/src/flat/node.profile.ts +++ b/src/flat/node.profile.ts @@ -47,7 +47,7 @@ export class ProfileNode implements ExplorerProfileNode { return treeItem; } - async getChildren(): Promise { + getChildren(): Thenable { const resources = [ [ACCESSKEY_FOLDER_NAME, new AccessKeysFolderNode(this.profile)], [APIACCESSRULES_FOLDER_NAME, new ApiAccessRulesFolderNode(this.profile)],