diff --git a/package.json b/package.json index ea1e3cb..da87ccd 100644 --- a/package.json +++ b/package.json @@ -31,5 +31,5 @@ "peerDependencies": { "typescript": "^5.6.2" }, - "version": "0.0.38" + "version": "0.0.48" } \ No newline at end of file diff --git a/src/helpers/errors.ts b/src/helpers/errors.ts index b8fc809..5c72c55 100644 --- a/src/helpers/errors.ts +++ b/src/helpers/errors.ts @@ -23,3 +23,7 @@ export function failedToConnect(): never { "Failed to connect to the server. Please check your internet connection and try again.", ); } + +export function unreachable(): never { + throw new Error("unreachable code"); +} diff --git a/src/helpers/price.ts b/src/helpers/price.ts index b1c0551..abd7ac7 100644 --- a/src/helpers/price.ts +++ b/src/helpers/price.ts @@ -1,23 +1,25 @@ -export function pricePerGPUHourToTotalPrice( - pricePerGPUHourInCenticents: number, +import type { Cents } from "./units"; + +export function pricePerGPUHourToTotalPriceCents( + pricePerGPUHourCents: Cents, durationSeconds: number, nodes: number, gpusPerNode: number, -) { - return Math.ceil( - ((pricePerGPUHourInCenticents * durationSeconds) / 3600) * - nodes * - gpusPerNode, - ); +): Cents { + const totalGPUs = nodes * gpusPerNode; + const totalHours = durationSeconds / 3600; + + return Math.ceil(pricePerGPUHourCents * totalGPUs * totalHours); } export function totalPriceToPricePerGPUHour( - totalPriceInCenticents: number, + priceCents: number, durationSeconds: number, nodes: number, gpusPerNode: number, -) { - return ( - totalPriceInCenticents / nodes / gpusPerNode / (durationSeconds / 3600) - ); +): Cents { + const totalGPUs = nodes * gpusPerNode; + const totalHours = durationSeconds / 3600; + + return priceCents / totalGPUs / totalHours; } diff --git a/src/helpers/test/units.test.ts b/src/helpers/test/units.test.ts index 10886a7..84d0d09 100644 --- a/src/helpers/test/units.test.ts +++ b/src/helpers/test/units.test.ts @@ -1,102 +1,92 @@ import { describe, expect, test } from "bun:test"; import { - type Centicents, - centicentsToDollarsFormatted, - priceWholeToCenticents, + type Cents, + centsToDollarsFormatted, + priceWholeToCents, } from "../units"; describe("units", () => { - test("price whole to centicents", () => { + test("price whole to cents", () => { const inputToExpectedValids = [ // formatted as USD ["$0", 0], - ["$1", 10_000], - ["$10", 100_000], - ["$100", 1_000_000], + ["$1", 100], + ["$10", 10_00], + ["$100", 100_00], ["$0.0", 0], ["$0.00", 0], ["$0.000", 0], - ["$1.0", 10_000], - ["$1.00", 10_000], - ["$1.000", 10_000], + ["$1.0", 100], + ["$1.00", 100], + ["$1.000", 100], - ["$1.23", 12_300], - ["$1.234", 12_340], - ["$1.2345", 12_345], - ["$$1.2345", 12_345], + ["$1.23", 123], + ["$1.234", 123.4], // formatted as numbers ["0", 0], - ["1", 10_000], - ["10", 100_000], - ["100", 1_000_000], + ["1", 100], + ["10", 10_00], + ["100", 100_00], - ["1.23", 12_300], - ["1.234", 12_340], - ["1.2345", 12_345], + ["1.23", 123], + ["1.234", 123.4], // nested quotes (double) ['"$0"', 0], - ['"$1"', 10_000], - ['"$10"', 100_000], + ['"$1"', 100], + ['"$10"', 10_00], ['"0"', 0], - ['"1"', 10_000], - ['"10"', 100_000], + ['"1"', 100], + ['"10"', 10_00], // nested quotes (single) ["'$0'", 0], - ["'$1'", 10_000], - ["'$10'", 100_000], + ["'$1'", 100], + ["'$10'", 10_00], ["'$0'", 0], - ["'$1'", 10_000], - ["'$10'", 100_000], + ["'$1'", 100], + ["'$10'", 10_00], ]; - for (const [input, centicentsExpected] of inputToExpectedValids) { - const { centicents, invalid } = priceWholeToCenticents(input); + for (const [input, centsExpected] of inputToExpectedValids) { + const { cents, invalid } = priceWholeToCents(input); - expect(centicents).not.toBeNull(); - expect(centicents).toEqual(centicentsExpected as number); + expect(cents).not.toBeNull(); + expect(cents).toEqual(centsExpected as number); expect(invalid).toBe(false); } const invalidPrices = [null, undefined, [], {}]; for (const input of invalidPrices) { - const { centicents, invalid } = priceWholeToCenticents(input as any); + const { cents, invalid } = priceWholeToCents(input as any); - expect(centicents).toBeNull(); + expect(cents).toBeNull(); expect(invalid).toBeTrue(); } }); - test("centicents to dollars formatted", () => { + test("cents to dollars formatted", () => { const inputToExpectedValids = [ // whole [0, "$0.00"], - [10_000, "$1.00"], - [100_000, "$10.00"], - [1_000_000, "$100.00"], + [100, "$1.00"], + [10_00, "$10.00"], + [100_00, "$100.00"], - [99_910, "$9.99"], + [9_99, "$9.99"], // with cents - [100, "$0.01"], - [200, "$0.02"], - [1_000, "$0.10"], - [9000, "$0.90"], - - // rounding - [1, "$0.00"], - [49, "$0.00"], - [50, "$0.01"], - [99, "$0.01"], - [100, "$0.01"], + [1, "$0.01"], + [2, "$0.02"], + [10, "$0.10"], + [90, "$0.90"], ]; for (const [input, expected] of inputToExpectedValids) { - const result = centicentsToDollarsFormatted(input as Centicents); + const result = centsToDollarsFormatted(input as Cents); expect(result).toEqual(expected as string); } diff --git a/src/helpers/units.ts b/src/helpers/units.ts index 610209e..22d408e 100644 --- a/src/helpers/units.ts +++ b/src/helpers/units.ts @@ -39,45 +39,52 @@ function roundEpochUpToHour(epoch: number): number { // -- currency export type Cents = number; -export type Centicents = number; -interface PriceWholeToCenticentsReturn { - centicents: Nullable; +interface PriceWholeToCentsReturn { + cents: Nullable; invalid: boolean; } -export function priceWholeToCenticents( +export function priceWholeToCents( price: string | number, -): PriceWholeToCenticentsReturn { +): PriceWholeToCentsReturn { if ( price === null || price === undefined || (typeof price !== "number" && typeof price !== "string") ) { - return { centicents: null, invalid: true }; + return { cents: null, invalid: true }; } if (typeof price === "number") { if (price < 0) { - return { centicents: null, invalid: true }; + return { cents: null, invalid: true }; } - return { centicents: price * 10_000, invalid: false }; + return { cents: price * 100, invalid: false }; } else if (typeof price === "string") { // remove any whitespace, dollar signs, negative signs, single and double quotes const priceCleaned = price.replace(/[\s\$\-\'\"]/g, ""); if (priceCleaned === "") { - return { centicents: null, invalid: true }; + return { cents: null, invalid: true }; } const parsedPrice = Number.parseFloat(priceCleaned); - return { centicents: parsedPrice * 10_000, invalid: false }; + return { cents: parsedPrice * 100, invalid: false }; } // default invalid - return { centicents: null, invalid: true }; + return { cents: null, invalid: true }; } -export function centicentsToDollarsFormatted(centicents: Centicents): string { - return `$${(centicents / 10_000).toFixed(2)}`; +export function centsToDollarsFormatted(cents: Cents): string { + return `$${centsToDollars(cents).toFixed(2)}`; +} + +export function centsToDollars(cents: Cents): number { + return cents / 100; +} + +export function dollarsToCents(dollars: number): Cents { + return Math.ceil(dollars * 100); } diff --git a/src/lib/balance.ts b/src/lib/balance.ts index d2d72e9..19843c2 100644 --- a/src/lib/balance.ts +++ b/src/lib/balance.ts @@ -8,7 +8,7 @@ import { logLoginMessageAndQuit, logSessionTokenExpiredAndQuit, } from "../helpers/errors"; -import type { Centicents } from "../helpers/units"; +import type { Cents } from "../helpers/units"; const usdFormatter = new Intl.NumberFormat("en-US", { style: "currency", @@ -22,19 +22,19 @@ export function registerBalance(program: Command) { .option("--json", "Output in JSON format") .action(async (options) => { const { - available: { whole: availableWhole, centicents: availableCenticents }, - reserved: { whole: reservedWhole, centicents: reservedCenticents }, + available: { whole: availableWhole, cents: availableCents }, + reserved: { whole: reservedWhole, cents: reservedCents }, } = await getBalance(); if (options.json) { const jsonOutput = { available: { whole: availableWhole, - centicents: availableCenticents, + cents: availableCents, }, reserved: { whole: reservedWhole, - centicents: reservedCenticents, + cents: reservedCents, }, }; console.log(JSON.stringify(jsonOutput, null, 2)); @@ -43,11 +43,7 @@ export function registerBalance(program: Command) { const formattedReserved = usdFormatter.format(reservedWhole); const table = new Table({ - head: [ - chalk.gray("Type"), - chalk.gray("Amount"), - chalk.gray("Centicents (1/100th of a cent)"), - ], + head: [chalk.gray("Type"), chalk.gray("Amount"), chalk.gray("Cents")], colWidths: [15, 15, 35], }); @@ -55,12 +51,12 @@ export function registerBalance(program: Command) { [ "Available", chalk.green(formattedAvailable), - chalk.green(availableCenticents.toLocaleString()), + chalk.green(availableCents.toLocaleString()), ], [ "Reserved", chalk.gray(formattedReserved), - chalk.gray(reservedCenticents.toLocaleString()), + chalk.gray(reservedCents.toLocaleString()), ], ); @@ -71,18 +67,18 @@ export function registerBalance(program: Command) { }); } -export type BalanceUsdCenticents = { - available: { centicents: Centicents; whole: number }; - reserved: { centicents: Centicents; whole: number }; +export type BalanceUsdCents = { + available: { cents: Cents; whole: number }; + reserved: { cents: Cents; whole: number }; }; -export async function getBalance(): Promise { +export async function getBalance(): Promise { const loggedIn = await isLoggedIn(); if (!loggedIn) { logLoginMessageAndQuit(); return { - available: { centicents: 0, whole: 0 }, - reserved: { centicents: 0, whole: 0 }, + available: { cents: 0, whole: 0 }, + reserved: { cents: 0, whole: 0 }, }; } const client = await apiClient(); @@ -126,12 +122,12 @@ export async function getBalance(): Promise { return { available: { - centicents: available, - whole: available / 10_000, + cents: available, + whole: available / 100, }, reserved: { - centicents: reserved, - whole: reserved / 10_000, + cents: reserved, + whole: reserved / 100, }, }; } diff --git a/src/lib/buy.ts b/src/lib/buy.ts index fb7b49d..d35932a 100644 --- a/src/lib/buy.ts +++ b/src/lib/buy.ts @@ -15,13 +15,13 @@ import { } from "../helpers/errors"; import { getContract } from "../helpers/fetchers"; import { - pricePerGPUHourToTotalPrice, + pricePerGPUHourToTotalPriceCents, totalPriceToPricePerGPUHour, } from "../helpers/price"; import { - type Centicents, - centicentsToDollarsFormatted, - priceWholeToCenticents, + type Cents, + centsToDollarsFormatted, + priceWholeToCents, roundEndDate, roundStartDate, } from "../helpers/units"; @@ -106,21 +106,21 @@ async function buyOrderAction(options: SfBuyOptions) { const quantity = Math.ceil(accelerators / GPUS_PER_NODE); // parse price - let priceCenticents: Nullable = null; + let priceCents: Nullable = null; if (options.price) { - const { centicents: priceParsed, invalid: priceInputInvalid } = - priceWholeToCenticents(options.price); + const { cents: priceCentsParsed, invalid: priceInputInvalid } = + priceWholeToCents(options.price); if (priceInputInvalid) { return logAndQuit(`Invalid price: ${options.price}`); } - priceCenticents = priceParsed; + priceCents = priceCentsParsed; } // Convert the price to the total price of the contract // (price per gpu hour * gpus per node * quantity * duration in hours) - if (priceCenticents) { - priceCenticents = pricePerGPUHourToTotalPrice( - priceCenticents, + if (priceCents) { + priceCents = pricePerGPUHourToTotalPriceCents( + priceCents, durationSeconds, quantity, GPUS_PER_NODE, @@ -148,9 +148,9 @@ async function buyOrderAction(options: SfBuyOptions) { return logAndQuit("Not enough data exists to quote this order."); } - const priceLabelUsd = c.green(centicentsToDollarsFormatted(quote.price)); + const priceLabelUsd = c.green(centsToDollarsFormatted(quote.price)); const priceLabelPerGPUHour = c.green( - centicentsToDollarsFormatted( + centsToDollarsFormatted( totalPriceToPricePerGPUHour( quote.price, durationSeconds, @@ -165,7 +165,7 @@ async function buyOrderAction(options: SfBuyOptions) { ); } else { // quote if no price was provided - if (!priceCenticents) { + if (!priceCents) { const quote = await getQuote({ instanceType: options.type, quantity: quantity, @@ -184,7 +184,7 @@ async function buyOrderAction(options: SfBuyOptions) { return process.exit(1); } - priceCenticents = quote.price; + priceCents = quote.price; durationSeconds = quote.duration; startDate = new Date(quote.start_at); } @@ -192,8 +192,7 @@ async function buyOrderAction(options: SfBuyOptions) { if (!durationSeconds) { throw new Error("unexpectly no duration provided"); } - - if (!priceCenticents) { + if (!priceCents) { throw new Error("unexpectly no price provided"); } @@ -206,7 +205,7 @@ async function buyOrderAction(options: SfBuyOptions) { if (confirmWithUser) { const confirmationMessage = confirmPlaceOrderMessage({ instanceType: options.type, - priceCenticents, + priceCents, quantity, startsAt: startDate, endsAt: endDate, @@ -226,7 +225,7 @@ async function buyOrderAction(options: SfBuyOptions) { const res = await placeBuyOrder({ instanceType: options.type, - priceCenticents, + priceCents, quantity, // round start date again because the user might have taken a long time to confirm // most of the time this will do nothing, but when it does it will move the start date forwrd one minute @@ -288,7 +287,7 @@ async function buyOrderAction(options: SfBuyOptions) { } function confirmPlaceOrderMessage(options: BuyOptions) { - if (!options.priceCenticents) { + if (!options.priceCents) { return ""; } @@ -317,21 +316,20 @@ function confirmPlaceOrderMessage(options: BuyOptions) { timeDescription = `from ${startAtLabel} (${c.green(fromNowTime)}) until ${endsAtLabel}`; } - const durationInSeconds = - options.endsAt.getTime() / 1000 - options.startsAt.getTime() / 1000; + const durationInSeconds = Math.ceil( + (options.endsAt.getTime() - options.startsAt.getTime()) / 1000, + ); const pricePerGPUHour = totalPriceToPricePerGPUHour( - options.priceCenticents, + options.priceCents, durationInSeconds, options.quantity, GPUS_PER_NODE, ); - const pricePerHourLabel = c.green( - centicentsToDollarsFormatted(pricePerGPUHour), - ); + const pricePerHourLabel = c.green(centsToDollarsFormatted(pricePerGPUHour)); const topLine = `${totalNodesLabel} ${instanceTypeLabel} ${nodesLabel} (${GPUS_PER_NODE * options.quantity} GPUs) at ${pricePerHourLabel} per GPU hour for ${c.green(durationHumanReadable)} ${timeDescription}`; - const dollarsLabel = c.green(centicentsToDollarsFormatted(pricePerGPUHour)); + const dollarsLabel = c.green(centsToDollarsFormatted(pricePerGPUHour)); const gpusLabel = c.green(options.quantity * GPUS_PER_NODE); @@ -342,7 +340,7 @@ function confirmPlaceOrderMessage(options: BuyOptions) { type BuyOptions = { instanceType: string; - priceCenticents: number; + priceCents: number; quantity: number; startsAt: Date; endsAt: Date; @@ -360,8 +358,7 @@ export async function placeBuyOrder(options: BuyOptions) { // round start date again because the user might take a long time to confirm start_at: roundStartDate(options.startsAt).toISOString(), end_at: options.endsAt.toISOString(), - price: options.priceCenticents, - colocate_with: options.colocate_with, + price: options.priceCents, }, }); diff --git a/src/lib/orders.ts b/src/lib/orders.ts index 9ff0ef3..0727fe9 100644 --- a/src/lib/orders.ts +++ b/src/lib/orders.ts @@ -138,12 +138,12 @@ function printAsTable(orders: Array) { order.id, order.side, order.instance_type, - usdFormatter.format(order.price / 10000), + usdFormatter.format(order.price / 100), order.quantity.toString(), duration, startDate.toLocaleString(), status, - executionPrice ? usdFormatter.format(executionPrice / 10000) : "-", + executionPrice ? usdFormatter.format(executionPrice / 100) : "-", ]); } } @@ -161,8 +161,8 @@ export function registerOrders(program: Command) { .option("--side ", "Filter by order side (buy or sell)") .option("-t, --type ", "Filter by instance type") .option("--public", "Include public orders") - .option("--min-price ", "Filter by minimum price (in Centicents)") - .option("--max-price ", "Filter by maximum price (in Centicents)") + .option("--min-price ", "Filter by minimum price (in cents)") + .option("--max-price ", "Filter by maximum price (in cents)") .option( "--min-start ", "Filter by minimum start date (ISO 8601 datestring)", @@ -198,11 +198,11 @@ export function registerOrders(program: Command) { ) .option( "--min-fill-price ", - "Filter by minimum fill price (in Centicents)", + "Filter by minimum fill price (in cents)", ) .option( "--max-fill-price ", - "Filter by maximum fill price (in Centicents)", + "Filter by maximum fill price (in cents)", ) .option("--exclude-cancelled", "Exclude cancelled orders") .option("--only-cancelled", "Show only cancelled orders") diff --git a/src/lib/sell.ts b/src/lib/sell.ts index 0a0c022..96fe1f3 100644 --- a/src/lib/sell.ts +++ b/src/lib/sell.ts @@ -10,9 +10,9 @@ import { logSessionTokenExpiredAndQuit, } from "../helpers/errors"; import { getContract } from "../helpers/fetchers"; -import { pricePerGPUHourToTotalPrice } from "../helpers/price"; +import { pricePerGPUHourToTotalPriceCents } from "../helpers/price"; import { - priceWholeToCenticents, + priceWholeToCents, roundEndDate, roundStartDate, } from "../helpers/units"; @@ -72,10 +72,8 @@ async function placeSellOrder(options: { return logLoginMessageAndQuit(); } - const { centicents: priceCenticents, invalid } = priceWholeToCenticents( - options.price, - ); - if (invalid || !priceCenticents) { + const { cents: priceCents, invalid } = priceWholeToCents(options.price); + if (invalid || !priceCents) { return logAndQuit(`Invalid price: ${options.price}`); } @@ -131,8 +129,8 @@ async function placeSellOrder(options: { const totalDurationSecs = dayjs(endDate).diff(startDate, "s"); const nodes = Math.ceil(options.accelerators / GPUS_PER_NODE); - const totalPrice = pricePerGPUHourToTotalPrice( - priceCenticents, + const totalPrice = pricePerGPUHourToTotalPriceCents( + priceCents, totalDurationSecs, nodes, GPUS_PER_NODE, diff --git a/src/lib/ssh.ts b/src/lib/ssh.ts index b99c5a3..13007d1 100644 --- a/src/lib/ssh.ts +++ b/src/lib/ssh.ts @@ -1,10 +1,84 @@ -import { $ } from "bun"; +import { expect } from "bun:test"; +import os from "node:os"; +import path from "node:path"; +import util from "node:util"; +import type { SpawnOptions, Subprocess, SyncSubprocess } from "bun"; import type { Command } from "commander"; import { apiClient } from "../apiClient"; import { isLoggedIn } from "../helpers/config"; -import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors"; +import { + logAndQuit, + logLoginMessageAndQuit, + unreachable, +} from "../helpers/errors"; import { getInstances } from "./instances"; +// openssh-client doesn't check $HOME while homedir() does. This function is to +// make it easy to fix if it causes issues. +function sshHomedir(): string { + return os.homedir(); +} + +// Bun 1.1.29 does not handle empty arguments properly, for now use an `sh -c` +// wrapper. Due to using `sh` as a wrapper it won't throw an error for unfound +// executables, but will instead have an exitCode of 127 (as per usual shell +// handling). +function spawnWrapper( + cmds: string[], + options?: Opts, +): SpawnOptions.OptionsToSubprocess { + let shCmd = ""; + for (const cmd of cmds) { + if (shCmd.length > 0) { + shCmd += " "; + } + shCmd += '"'; + + // utf-16 code points are fine as we will ignore surrogates, and we don't + // care about anything other than characters that don't require surrogates. + for (const c of cmd) { + switch (c) { + case "$": + case "\\": + case "`": + // @ts-ignore + // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional fallthrough + case '"': { + shCmd += "\\"; + // fallthrough + } + default: { + shCmd += c; + break; + } + } + } + shCmd += '"'; + } + return Bun.spawn(["sh", "-c", shCmd], options); +} + +// Returns an absolute path (symbolic links, ".", and ".." are left +// unnormalized). +function normalizeSshConfigPath(sshPath: string): string { + if (sshPath.length === 0) { + throw new Error('invalid ssh config path ""'); + } else if (sshPath[0] === "/") { + return sshPath; + } else if (sshPath[0] === "~") { + if (sshPath.length === 1 || sshPath[1] === "/") { + return path.join(sshHomedir(), sshPath.slice(1)); + } else { + // i.e. try `~root/foo` in your terminal and see how it handles it (same + // behavior as ssh client). + throw new Error("unimplemented"); + } + } else { + // Are they relative to ~/.ssh or to the cwd for things listed in ssh -G ? + throw new Error("unimplemented"); + } +} + function isPubkey(key: string): boolean { const pubKeyPattern = /^ssh-/; return pubKeyPattern.test(key); @@ -34,6 +108,174 @@ async function readFileOrKey(keyOrFile: string): Promise { } } +// This attempts to find the user's default ssh public key (or generate one), +// and returns its value. Errors out and prints a message to the user if unable +// to find, or generate one. +async function findDefaultKey(): Promise { + // 1. Attempt to find the first identityfile within `ssh -G "" | grep + // identityfile` that exists. + // 2. If step 1 found no entries for `ssh -G` (and `ssh -V` succeeds) then use + // the hardcoded list of identity files while printing a warning. + // 3. If no key was found in step 1, and if applicable step 2 then generate a + // key for the user using `ssh-keygen`. + // 4. Now that we have a key and a public key, return the public key. + + // The default keys for openssh client version "OpenSSH_9.2p1 + // Debian-2+deb12u3, OpenSSL 3.0.14 4 Jun 2024". + const hardcodedPrivKeys: string[] = [ + "~/.ssh/id_rsa", + "~/.ssh/id_ecdsa", + "~/.ssh/id_ecdsa_sk", + "~/.ssh/id_ed25519", + "~/.ssh/id_ed25519_sk", + "~/.ssh/id_xmss", + "~/.ssh/id_dsa", + ]; + + { + let proc: Subprocess; + try { + proc = Bun.spawn(["ssh", "-V"], { + stdin: null, + stdout: null, + stderr: null, + }); + } catch (e) { + if (e instanceof TypeError) { + logAndQuit( + "The ssh command is not installed, please install it before trying again.", + ); + } else { + throw e; + } + } + await proc.exited; + if (proc.exitCode !== 0) { + logAndQuit("The ssh command is not functioning as expected."); + } + } + + let identityFile: string | null = null; + // If we found at least 1 identityfile (if not assume that our gross parsing + // failed and log a warning message). + let sshGParsedSuccess = false; + + // If we believe key types to be supported by the ssh client. + let keySupportedEd25519 = false; + let keySupportedRsa = false; + + const proc = spawnWrapper(["ssh", "-G", ""], { + stdin: null, + stdout: "pipe", + stderr: null, + }); + const stdout = await Bun.readableStreamToArrayBuffer(proc.stdout); + await proc.exited; + if (proc.exitCode === 0) { + const decoder = new TextDecoder("utf-8", { fatal: true }); + let stdoutStr: string | null; + try { + stdoutStr = decoder.decode(stdout); + } catch (e) { + logAndQuit("The ssh command returned invalid utf-8"); + } + + for (const line of stdoutStr.split("\n")) { + const prefix = "identityfile "; + if (line.startsWith(prefix)) { + const lineSuffix = line.slice(prefix.length); + if ( + lineSuffix === "~/.ssh/id_ed25519" || + lineSuffix === path.join(sshHomedir(), ".ssh/id_ed25519") + ) { + keySupportedEd25519 = true; + } + if ( + lineSuffix === "~/.ssh/id_rsa" || + lineSuffix === path.join(sshHomedir(), ".ssh/id_rsa") + ) { + keySupportedRsa = true; + } + const potentialIdentityFile = normalizeSshConfigPath( + lineSuffix + ".pub", + ); + sshGParsedSuccess = true; + if (await Bun.file(potentialIdentityFile).exists()) { + identityFile = potentialIdentityFile; + break; + } + } + } + } + + if (!sshGParsedSuccess) { + expect(identityFile === null); + + console.log( + "Warning: failed finding default ssh keys (checking hardcoded list)", + ); + keySupportedEd25519 = true; + keySupportedRsa = true; + for (const hardcodedPrivKey of hardcodedPrivKeys) { + const potentialIdentityFile = normalizeSshConfigPath( + hardcodedPrivKey + ".pub", + ); + if (await Bun.file(potentialIdentityFile).exists()) { + identityFile = potentialIdentityFile; + break; + } + } + } + + if (identityFile === null) { + console.log("Unable to find SSH key (generating new key)"); + + const sshDir: string = path.join(sshHomedir(), ".ssh"); + let privSshKeyPath: string; + let extraSshOptions: string[]; + if (keySupportedEd25519) { + extraSshOptions = ["-t", "ed25519"]; + privSshKeyPath = path.join(sshDir, "id_ed25519"); + } else if (keySupportedRsa) { + extraSshOptions = ["-t", "rsa", "-b", "4096"]; + privSshKeyPath = path.join(sshDir, "id_rsa"); + } else { + logAndQuit( + "Unable to generate SSH key (neither rsa, nor ed25519 appear supported)", + ); + } + + const proc = spawnWrapper( + ["ssh-keygen", "-N", "", "-q", "-f", privSshKeyPath].concat( + extraSshOptions, + ), + { + stdin: null, + stdout: null, + stderr: null, + }, + ); + await proc.exited; + if (proc.exitCode === 0) { + // Success + } else if (proc.exitCode === 127) { + // Gross as technically ssh-keyen could also exit with 127. Remove once no + // longer using spawnWrapper. + logAndQuit( + "The ssh-keygen command is not installed, please install it before trying again.", + ); + } else { + logAndQuit("The ssh-keygen command did not execute successfully."); + } + console.log(util.format("Generated key %s", privSshKeyPath)); + identityFile = privSshKeyPath + ".pub"; + } + + console.log(util.format("Using ssh key %s", identityFile)); + const file = Bun.file(identityFile); + return (await file.text()).trim(); +} + export function registerSSH(program: Command) { const cmd = program .command("ssh") @@ -44,6 +286,7 @@ export function registerSSH(program: Command) { "Specify the username associated with the pubkey", "ubuntu", ) + .option("--init", "Attempt to automatically add the first default ssh key") .argument("[name]", "The name of the node to SSH into"); cmd.action(async (name, options) => { @@ -57,11 +300,16 @@ export function registerSSH(program: Command) { return; } - if (options.add && name) { + if (options.init && options.add) { + logAndQuit("--init is not compatible with --add"); + } + + if ((options.add || options.init) && name) { logAndQuit("You can only add a key to all nodes at once"); } if (name) { + let procResult: SyncSubprocess<"inherit", "inherit">; const instances = await getInstances({ clusterId: undefined }); const instance = instances.find((instance) => instance.id === name); if (!instance) { @@ -69,26 +317,46 @@ export function registerSSH(program: Command) { } if (instance.ip.split(":").length === 2) { const [ip, port] = instance.ip.split(":"); - await $`ssh -p ${port} ${options.user}@${ip}`; + procResult = Bun.spawnSync( + ["ssh", "-p", port, util.format("%s@%s", options.user, ip)], + { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); } else { - await $`ssh ${options.user}@${instance.ip}`; + procResult = Bun.spawnSync( + ["ssh", util.format("%s@%s", options.user, instance.ip)], + { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ); + } + if (procResult.exitCode === 255) { + console.log( + "The ssh command appears to possibly have failed. To set up ssh keys please run `sf ssh --init`.", + ); } process.exit(0); } - if (options.add) { - if (!options.user) { - logAndQuit( - "Username is required when adding an SSH key (add it with --user )", - ); + if (options.init || options.add) { + let pubkey: string; + if (options.init) { + pubkey = await findDefaultKey(); + } else if (options.add) { + pubkey = await readFileOrKey(options.add); + } else { + unreachable(); } - const key = await readFileOrKey(options.add); - const api = await apiClient(); await api.POST("/v0/credentials", { body: { - pubkey: key, + pubkey, username: options.user, }, }); diff --git a/src/lib/updown.ts b/src/lib/updown.ts index 5bc834e..ef6b232 100644 --- a/src/lib/updown.ts +++ b/src/lib/updown.ts @@ -4,7 +4,11 @@ import type { Command } from "commander"; import parseDuration from "parse-duration"; import { apiClient } from "../apiClient"; import { logAndQuit } from "../helpers/errors"; -import { centicentsToDollarsFormatted } from "../helpers/units"; +import { + type Cents, + centsToDollarsFormatted, + dollarsToCents, +} from "../helpers/units"; import { getBalance } from "./balance"; import { getQuote } from "./buy"; import { formatDuration } from "./orders"; @@ -22,7 +26,7 @@ export function registerUp(program: Command) { .option("-d, --duration ", "Specify the minimum duration") .option( "-p, --price ", - "Specify the maximum price per node per hour", + "Specify the maximum price per node-hour, in dollars", ); cmd.action(async (options) => { @@ -41,12 +45,12 @@ export function registerDown(program: Command) { }); } -const DEFAULT_PRICE_PER_NODE_HOUR_IN_CENTICENTS = 2.65 * 8 * 10_000; +const DEFAULT_PRICE_PER_NODE_HOUR_IN_CENTS = 2.65 * 8 * 100; async function getDefaultProcurementOptions(props: { duration?: string; n?: string; - pricePerNodeHour?: string; + pricePerNodeHourDollars?: string; type?: string; }) { // Minimum block duration is 2 hours @@ -69,38 +73,35 @@ async function getDefaultProcurementOptions(props: { }); // Eventually we should replace this price with yesterday's index price - let quotePrice = DEFAULT_PRICE_PER_NODE_HOUR_IN_CENTICENTS; + let quotePrice = DEFAULT_PRICE_PER_NODE_HOUR_IN_CENTS; if (quote) { - // per hour price + // per hour price in cents quotePrice = quote.price / durationHours; } - const pricePerNodeHourInDollars = props.pricePerNodeHour - ? Number.parseInt(props.pricePerNodeHour) + const pricePerNodeHourInCents = props.pricePerNodeHourDollars + ? dollarsToCents(Number.parseFloat(props.pricePerNodeHourDollars)) : quotePrice; - const pricePerNodeHourInCenticents = Math.ceil(pricePerNodeHourInDollars); - const totalPriceInCenticents = - pricePerNodeHourInCenticents * - Number.parseInt(props.n ?? "1") * - durationHours; + const totalPriceInCents = + pricePerNodeHourInCents * Number.parseInt(props.n ?? "1") * durationHours; return { durationHours, - pricePerNodeHourInCenticents, + pricePerNodeHourInCents, n, type, - totalPriceInCenticents, + totalPriceInCents, }; } // Instruct the user to set a price that's lower function getSuggestedCommandWhenBalanceLow(props: { durationHours: number; - pricePerNodeHourInCenticents: number; + pricePerNodeHourInCents: Cents; n: number; - totalPriceInCenticents: number; - balance: number; + totalPriceInCents: Cents; + balance: Cents; }) { const affordablePrice = props.balance / 100 / (props.n * props.durationHours); @@ -110,9 +111,9 @@ function getSuggestedCommandWhenBalanceLow(props: { function confirmPlaceOrderMessage(options: { durationHours: number; - pricePerNodeHourInCenticents: number; + pricePerNodeHourInCents: number; n: number; - totalPriceInCenticents: number; + totalPriceInCents: number; type: string; }) { const totalNodesLabel = c.green(options.n); @@ -125,7 +126,7 @@ function confirmPlaceOrderMessage(options: { const topLine = `Turning on ${totalNodesLabel} ${instanceTypeLabel} ${nodesLabel} continuously for ${c.green(formatDuration(durationInMilliseconds))} ${timeDescription}`; const dollarsLabel = c.green( - centicentsToDollarsFormatted(options.pricePerNodeHourInCenticents), + centsToDollarsFormatted(options.pricePerNodeHourInCents), ); const priceLine = `\n Pay ${dollarsLabel} per node hour?`; @@ -142,13 +143,8 @@ async function up(props: { }) { const client = await apiClient(); - const { - durationHours, - n, - type, - pricePerNodeHourInCenticents, - totalPriceInCenticents, - } = await getDefaultProcurementOptions(props); + const { durationHours, n, type, pricePerNodeHourInCents, totalPriceInCents } = + await getDefaultProcurementOptions(props); if (durationHours && durationHours < 1) { console.error("Minimum duration is 1 hour"); @@ -158,9 +154,9 @@ async function up(props: { if (!props.y) { const confirmationMessage = confirmPlaceOrderMessage({ durationHours, - pricePerNodeHourInCenticents, + pricePerNodeHourInCents, n, - totalPriceInCenticents, + totalPriceInCents, type, }); const confirmed = await confirm({ @@ -175,15 +171,15 @@ async function up(props: { const balance = await getBalance(); - if (balance.available.centicents < totalPriceInCenticents) { + if (balance.available.cents < totalPriceInCents) { console.log( - `You can't afford this. Available balance: $${(balance.available.centicents / 1000000).toFixed(2)}, Minimum price: $${(totalPriceInCenticents / 1000000).toFixed(2)}\n`, + `You can't afford this. Available balance: $${(balance.available.cents / 100).toFixed(2)}, Minimum price: $${(totalPriceInCents / 100).toFixed(2)}\n`, ); const cmd = getSuggestedCommandWhenBalanceLow({ durationHours, - pricePerNodeHourInCenticents, + pricePerNodeHourInCents, n, - totalPriceInCenticents, + totalPriceInCents, balance: balance.available.whole, }); console.log(cmd); @@ -213,7 +209,7 @@ async function up(props: { // we only update the duration & price if it's set min_duration_in_hours: props.duration ? durationHours : undefined, max_price_per_node_hour: props.price - ? pricePerNodeHourInCenticents + ? pricePerNodeHourInCents : undefined, }, }); @@ -225,7 +221,7 @@ async function up(props: { body: { instance_type: type, quantity: n, - max_price_per_node_hour: pricePerNodeHourInCenticents, + max_price_per_node_hour: pricePerNodeHourInCents, min_duration_in_hours: Math.max(durationHours, 1), }, }); diff --git a/src/schema.ts b/src/schema.ts index 1fd22fc..8a1489b 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -254,6 +254,7 @@ export interface operations { /** @description Whether there was no price data for this period. */ no_data: boolean; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -276,6 +277,7 @@ export interface operations { /** @description Whether there was no price data for this period. */ no_data: boolean; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -298,6 +300,7 @@ export interface operations { /** @description Whether there was no price data for this period. */ no_data: boolean; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -543,17 +546,34 @@ export interface operations { getV0Orders: { parameters: { query?: { + side?: "buy" | "sell"; + /** @description The instance type. */ instance_type?: string; - limit?: string; - offset?: string; + include_public?: string | boolean; + min_price?: string | number; + max_price?: string | number; min_start_date?: string; max_start_date?: string; - min_duration?: string; - max_duration?: string; - min_quantity?: string; - max_quantity?: string; - side?: string; - include_public?: boolean; + min_duration?: string | number; + max_duration?: string | number; + min_quantity?: string | number; + max_quantity?: string | number; + contract_id?: string; + only_open?: string | boolean; + exclude_filled?: string | boolean; + only_filled?: string | boolean; + min_filled_at?: string; + max_filled_at?: string; + min_fill_price?: string | number; + max_fill_price?: string | number; + exclude_cancelled?: string | boolean; + only_cancelled?: string | boolean; + min_cancelled_at?: string; + max_cancelled_at?: string; + min_placed_at?: string; + max_placed_at?: string; + limit?: string | number; + offset?: string | number; }; header?: { /** @description Generate a bearer token with `$ sf tokens create`. */ @@ -628,6 +648,19 @@ export interface operations { /** @description If true, this is an immediate-or-cancel order. */ ioc?: boolean; }; + reprice?: { + /** + * @description Adjust this order's price linearly from adjustment start to end. + * @constant + */ + strategy: "linear"; + /** @description For sell orders, the floor (lowest) price the order can be adjusted to, in centicents. For buy orders, the ceiling (highest) price the order can be adjusted to. */ + limit: number; + /** @description When to start adjusting the order’s price. If this date is in the past, it will be clamped such that the adjustment starts immediately. */ + start_at?: string; + /** @description When to stop adjusting the order’s price. If this date is past the order’s end time, it will be clamped such that the adjustment ends at the order’s end time. */ + end_at?: string; + }; }; "multipart/form-data": | { @@ -673,6 +706,19 @@ export interface operations { /** @description If true, this is an immediate-or-cancel order. */ ioc?: boolean; }; + reprice?: { + /** + * @description Adjust this order's price linearly from adjustment start to end. + * @constant + */ + strategy: "linear"; + /** @description For sell orders, the floor (lowest) price the order can be adjusted to, in centicents. For buy orders, the ceiling (highest) price the order can be adjusted to. */ + limit: number; + /** @description When to start adjusting the order’s price. If this date is in the past, it will be clamped such that the adjustment starts immediately. */ + start_at?: string; + /** @description When to stop adjusting the order’s price. If this date is past the order’s end time, it will be clamped such that the adjustment ends at the order’s end time. */ + end_at?: string; + }; }; "text/plain": | { @@ -718,6 +764,19 @@ export interface operations { /** @description If true, this is an immediate-or-cancel order. */ ioc?: boolean; }; + reprice?: { + /** + * @description Adjust this order's price linearly from adjustment start to end. + * @constant + */ + strategy: "linear"; + /** @description For sell orders, the floor (lowest) price the order can be adjusted to, in centicents. For buy orders, the ceiling (highest) price the order can be adjusted to. */ + limit: number; + /** @description When to start adjusting the order’s price. If this date is in the past, it will be clamped such that the adjustment starts immediately. */ + start_at?: string; + /** @description When to stop adjusting the order’s price. If this date is past the order’s end time, it will be clamped such that the adjustment ends at the order’s end time. */ + end_at?: string; + }; }; }; }; @@ -864,10 +923,11 @@ export interface operations { ioc?: boolean; }; executed: boolean; + executed_at?: string; + /** @description Execution price in Centicents (1/100th of a cent, One Centicent = $0.0001) */ + execution_price?: number; cancelled: boolean; - executed_at: string | null; - execution_price: number | null; - cancelled_at: string | null; + cancelled_at?: string; colocate_with?: string[]; created_at: string; }; @@ -902,10 +962,11 @@ export interface operations { ioc?: boolean; }; executed: boolean; + executed_at?: string; + /** @description Execution price in Centicents (1/100th of a cent, One Centicent = $0.0001) */ + execution_price?: number; cancelled: boolean; - executed_at: string | null; - execution_price: number | null; - cancelled_at: string | null; + cancelled_at?: string; colocate_with?: string[]; created_at: string; }; @@ -940,10 +1001,11 @@ export interface operations { ioc?: boolean; }; executed: boolean; + executed_at?: string; + /** @description Execution price in Centicents (1/100th of a cent, One Centicent = $0.0001) */ + execution_price?: number; cancelled: boolean; - executed_at: string | null; - execution_price: number | null; - cancelled_at: string | null; + cancelled_at?: string; colocate_with?: string[]; created_at: string; }; @@ -1135,8 +1197,14 @@ export interface operations { name: string; type: string; ip: string; - status: "healthy" | "starting" | "unreachable" | "unhealthy"; + status: + | "healthy" + | "starting" + | "unreachable" + | "unhealthy" + | "waiting"; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1148,8 +1216,14 @@ export interface operations { name: string; type: string; ip: string; - status: "healthy" | "starting" | "unreachable" | "unhealthy"; + status: + | "healthy" + | "starting" + | "unreachable" + | "unhealthy" + | "waiting"; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1161,8 +1235,14 @@ export interface operations { name: string; type: string; ip: string; - status: "healthy" | "starting" | "unreachable" | "unhealthy"; + status: + | "healthy" + | "starting" + | "unreachable" + | "unhealthy" + | "waiting"; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1258,7 +1338,12 @@ export interface operations { name: string; type: string; ip: string; - status: "healthy" | "starting" | "unreachable" | "unhealthy"; + status: + | "healthy" + | "starting" + | "unreachable" + | "unhealthy" + | "waiting"; }; "multipart/form-data": { /** @constant */ @@ -1267,7 +1352,12 @@ export interface operations { name: string; type: string; ip: string; - status: "healthy" | "starting" | "unreachable" | "unhealthy"; + status: + | "healthy" + | "starting" + | "unreachable" + | "unhealthy" + | "waiting"; }; "text/plain": { /** @constant */ @@ -1276,7 +1366,12 @@ export interface operations { name: string; type: string; ip: string; - status: "healthy" | "starting" | "unreachable" | "unhealthy"; + status: + | "healthy" + | "starting" + | "unreachable" + | "unhealthy" + | "waiting"; }; }; }; @@ -1369,6 +1464,7 @@ export interface operations { pubkey: string; username: string; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1380,6 +1476,7 @@ export interface operations { pubkey: string; username: string; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1391,6 +1488,7 @@ export interface operations { pubkey: string; username: string; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1629,6 +1727,7 @@ export interface operations { id: string; } )[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1660,6 +1759,7 @@ export interface operations { id: string; } )[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -1691,6 +1791,7 @@ export interface operations { id: string; } )[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -2078,13 +2179,14 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ instance_type: string; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -2095,13 +2197,14 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ instance_type: string; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -2112,13 +2215,14 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ instance_type: string; }[]; + has_more: boolean; /** @constant */ object: "list"; }; @@ -2174,21 +2278,21 @@ export interface operations { instance_type: string; quantity: number; max_price_per_node_hour: number; - block_duration_in_hours: number; + min_duration_in_hours: number; }; "multipart/form-data": { /** @description The instance type. */ instance_type: string; quantity: number; max_price_per_node_hour: number; - block_duration_in_hours: number; + min_duration_in_hours: number; }; "text/plain": { /** @description The instance type. */ instance_type: string; quantity: number; max_price_per_node_hour: number; - block_duration_in_hours: number; + min_duration_in_hours: number; }; }; }; @@ -2204,8 +2308,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2217,8 +2321,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2230,8 +2334,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2294,8 +2398,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2307,8 +2411,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2320,8 +2424,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2375,22 +2479,22 @@ export interface operations { content: { "application/json": { quantity?: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price?: number; + /** @description The price (in centicents) per node per hour */ + max_price_per_node_hour?: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours?: number; }; "multipart/form-data": { quantity?: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price?: number; + /** @description The price (in centicents) per node per hour */ + max_price_per_node_hour?: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours?: number; }; "text/plain": { quantity?: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price?: number; + /** @description The price (in centicents) per node per hour */ + max_price_per_node_hour?: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours?: number; }; @@ -2408,8 +2512,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2421,8 +2525,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */ @@ -2434,8 +2538,8 @@ export interface operations { instance_group: string; /** @description The quantity of the procurement */ quantity: number; - /** @description The TOTAL price (in centicents) to buy the duration */ - max_price: number; + /** @description The price per hour per node */ + max_price_per_node_hour: number; /** @description The block duration of the procurement in hours */ min_duration_in_hours: number; /** @description The instance type. */