diff --git a/commands/config/add-profile.ts b/commands/config/add-profile.ts index b7f93fa..4236b52 100644 --- a/commands/config/add-profile.ts +++ b/commands/config/add-profile.ts @@ -7,12 +7,12 @@ import { getPaths } from "../../util/paths.ts"; import { standardAction, Subcommand } from "../_helpers.ts"; import { requestProfileInfo, writeProfile } from "./_shared.ts"; -const desc = +const desc = `Adds a new profile to a Render CLI config file.`; export const configAddProfileCommand = new Subcommand() - .name('init') + .name('add-profile') .description(desc) .option("-f, --force", "overwrites existing profile if found.") .arguments("") diff --git a/commands/config/index.ts b/commands/config/index.ts index 5a98a43..403c5e5 100644 --- a/commands/config/index.ts +++ b/commands/config/index.ts @@ -3,6 +3,7 @@ import { configAddProfileCommand } from "./add-profile.ts"; import { configInitCommand } from "./init.ts"; import { configProfilesCommand } from "./profiles.ts"; import { configSchemaCommand } from "./schema.ts"; +import { configUpgradeCommand } from "./upgrade.ts"; const desc = `Commands for interacting with the render-cli configuration.`; @@ -18,5 +19,6 @@ export const configCommand = .command("init", configInitCommand) .command("add-profile", configAddProfileCommand) .command("profiles", configProfilesCommand) + .command("upgrade", configUpgradeCommand) .command("schema", configSchemaCommand) ; diff --git a/commands/config/init.ts b/commands/config/init.ts index f68a40d..1f878d3 100644 --- a/commands/config/init.ts +++ b/commands/config/init.ts @@ -1,3 +1,4 @@ +import { FALLBACK_CONFIG } from "../../config/index.ts"; import { ConfigLatest } from "../../config/types/index.ts"; import { Cliffy, Log, YAML } from "../../deps.ts"; import { ForceRequiredError } from "../../errors.ts"; @@ -6,7 +7,7 @@ import { getPaths } from "../../util/paths.ts"; import { standardAction, Subcommand } from "../_helpers.ts"; import { chmodConfigIfPossible, requestProfileInfo, writeProfile } from "./_shared.ts"; -const desc = +const desc = `Interactively creates a Render CLI config file.`; export const configInitCommand = @@ -39,7 +40,7 @@ export const configInitCommand = ]); const cfg: ConfigLatest = { - version: 1, + version: FALLBACK_CONFIG.version, sshPreserveHosts, profiles: { default: defaultProfile, diff --git a/commands/config/upgrade.ts b/commands/config/upgrade.ts new file mode 100644 index 0000000..073c485 --- /dev/null +++ b/commands/config/upgrade.ts @@ -0,0 +1,31 @@ +import { getConfig } from "../../config/index.ts"; +import { ConfigAny } from "../../config/types/index.ts"; +import { YAML } from "../../deps.ts"; +import { getLogger } from "../../util/logging.ts"; +import { getPaths } from "../../util/paths.ts"; +import { Subcommand } from "../_helpers.ts"; + +const desc = +`Upgrades a Render CLI config file to the latest version.`; + +export const configUpgradeCommand = + new Subcommand() + .name('upgrade') + .description(desc) + .action(async (opts) => { + const logger = await getLogger(); + const { configFile } = await getPaths(); + const config = await getConfig(); + + const currentConfig = YAML.load(await Deno.readTextFile(configFile)) as ConfigAny; + + if (currentConfig.version === config.fullConfig.version) { + logger.info("Config is already up to date. No upgrades needed."); + } else { + logger.info(`Upgrading config from version ${currentConfig.version} to ${config.fullConfig.version}.`); + await Deno.copyFile(configFile, `${configFile}.${currentConfig.version}.bak`); + await Deno.writeTextFile(configFile, YAML.dump(config.fullConfig)); + + logger.info("Config upgrade complete. Thanks for using Render!"); + } + }); diff --git a/config/index.ts b/config/index.ts index 87fc8ca..c1f6531 100644 --- a/config/index.ts +++ b/config/index.ts @@ -1,26 +1,35 @@ import { Log, YAML, } from "../deps.ts"; import { ajv } from "../util/ajv.ts"; -import { identity } from "../util/fn.ts"; import { getPaths } from "../util/paths.ts"; -import { APIKeyRequired } from '../errors.ts'; +import { APIKeyRequired, RenderCLIError } from '../errors.ts'; import { ALL_REGIONS, Region } from "./types/enums.ts"; import { assertValidRegion } from "./types/enums.ts"; -import { ConfigAny, ConfigLatest, ProfileLatest, RuntimeConfiguration } from "./types/index.ts"; -import { getLogger } from "../util/logging.ts"; +import { ConfigAny, ConfigLatest, ProfileLatest, RuntimeConfiguration, RuntimeProfile } from "./types/index.ts"; +import { getLogger, NON_INTERACTIVE } from "../util/logging.ts"; + +const CONFIG_VERSION_WARN_ENV_VAR = "RENDERCLI_CONFIG_IGNORE_UPGRADE"; let config: RuntimeConfiguration | null = null; -// TODO: smarten this up with type checks -const CONFIG_UPGRADE_MAPS = { - 1: identity, +function upgradeConfigs(cfg: ConfigAny): ConfigLatest { + switch (cfg.version) { + case 1: + return { + ...cfg, + version: 2, + }; + case 2: // == ConfigLatest + return cfg; + } } + const FALLBACK_PROFILE: Partial = { defaultRegion: 'oregon', // mimics dashboard behavior }; -const FALLBACK_CONFIG: ConfigLatest = { - version: 1, +export const FALLBACK_CONFIG: ConfigLatest = { + version: 2, profiles: {}, } @@ -46,16 +55,6 @@ export async function withConfig(fn: (cfg: RuntimeConfiguration) => T | Promi return fn(cfg); } -function upgradeConfigFile(config: ConfigAny): ConfigLatest { - const upgradePath = CONFIG_UPGRADE_MAPS[config.version]; - - if (!upgradePath) { - throw new Error(`Unrecognized version, cannot upgrade (is render-cli too old?): ${config.version}`); - } - - return upgradePath(config); -} - async function parseConfig(content: string): Promise { const data = await YAML.load(content); const ret = { @@ -66,7 +65,25 @@ async function parseConfig(content: string): Promise { await ajv.validate(ConfigAny, ret); if (!ajv.errors) { - return upgradeConfigFile(ret as ConfigAny); + const upgraded = upgradeConfigs(ret as ConfigAny); + + if (ret.version !== upgraded.version) { + const warnOnUpgrade = await Deno.env.get(CONFIG_VERSION_WARN_ENV_VAR); + if (warnOnUpgrade !== '1') { + const logger = await getLogger(); + logger.warning('Your Render CLI configuration file appears to be out of date. We don\'t upgrade your'); + logger.warning('configuration file automatically in case you need to revert to an older version, but'); + logger.warning('we also can\'t guarantee permanent forward compatibility for old configuration files.'); + logger.warning(''); + logger.warning('Please run `render config upgrade` to update it automatically to the most recent'); + logger.warning('configuration file format.'); + logger.warning(''); + logger.warning('If you don\'t want to upgrade and don\'t want to see this message, you can set the'); + logger.warning(`${CONFIG_VERSION_WARN_ENV_VAR} environment variable to "1".`); + } + } + + return upgraded; } throw new Error(`Config validation error: ${Deno.inspect(ajv.errors)}`); @@ -88,28 +105,59 @@ async function fetchAndParseConfig(): Promise { } } +async function getProfileCredentials(profile: ProfileLatest): Promise { + const apiKeyOverride = await Deno.env.get("RENDERCLI_APIKEY"); + + if (apiKeyOverride) { + return apiKeyOverride; + } + + if (typeof(profile.apiKey) === 'string') { + return profile.apiKey; + } + + const cmd = profile.apiKey.run; + + const process = Deno.run({ + cmd, + stdout: 'piped', + stderr: 'inherit', + stdin: NON_INTERACTIVE ? 'null' : 'inherit', + }); + + const { code } = await process.status(); + if (code != 0) { + throw new RenderCLIError(`CLI credentials runner failed. \`${cmd.join(' ')}\` exit code: ${code}`); + } + + const output = await process.output(); + const decoder = new TextDecoder(); + const apiKey = decoder.decode(output).trim(); + + return apiKey; +} + async function buildRuntimeProfile( cfg: ConfigLatest, -): Promise<{ profile: ProfileLatest, profileName: string }> { +): Promise<{ profile: RuntimeProfile, profileName: string }> { const logger = await getLogger(); - const profileFromEnv = Deno.env.get("RENDERCLI_PROFILE"); + const profileFromEnv = await Deno.env.get("RENDERCLI_PROFILE"); const profileName = profileFromEnv ?? 'default'; logger.debug(`Using profile '${profileName}' (env: ${profileFromEnv})`); const profile = cfg.profiles[profileName] ?? {}; - const ret: ProfileLatest = { + const ret: RuntimeProfile = { ...FALLBACK_PROFILE, ...profile, - } + apiKey: await getProfileCredentials(profile), + apiHost: await Deno.env.get("RENDERCLI_APIHOST") ?? profile.apiHost, + }; - const actualRegion = Deno.env.get("RENDERCLI_REGION") ?? ret.defaultRegion; + const actualRegion = await Deno.env.get("RENDERCLI_REGION") ?? ret.defaultRegion; assertValidRegion(actualRegion); // TODO: clean this up - the assertion should be making the cast unnecessary, but TS disagrees ret.defaultRegion = actualRegion as Region; - ret.apiKey = Deno.env.get("RENDERCLI_APIKEY") ?? ret.apiKey; - ret.apiHost = Deno.env.get("RENDERCLI_APIHOST") ?? ret.apiHost; - if (!ret.apiKey) { throw new APIKeyRequired(); } diff --git a/config/types/index.ts b/config/types/index.ts index 8f736f8..cecde09 100644 --- a/config/types/index.ts +++ b/config/types/index.ts @@ -1,22 +1,29 @@ import { Type } from "../../deps.ts"; -import { ConfigV1, ProfileV1 } from './v1.ts'; +import { ConfigV1 } from './v1.ts'; +import { ConfigV2, ProfileV2 } from "./v2.ts"; export type ConfigAny = - | ConfigV1; + | ConfigV1 + | ConfigV2; export const ConfigAny = Type.Union([ ConfigV1, + ConfigV2, ]); -export type ConfigLatest = ConfigV1; -export const ConfigLatest = ConfigV1; +export type ConfigLatest = ConfigV2; +export const ConfigLatest = ConfigV2; -export type ProfileLatest = ProfileV1; -export const ProfileLatest = ProfileV1; +export type ProfileLatest = ProfileV2; +export const ProfileLatest = ProfileV2; export type UpgradeFn = (cfg: T) => ConfigLatest; +export type RuntimeProfile = + & Omit + & { apiKey: string }; + export type RuntimeConfiguration = { fullConfig: ConfigLatest; profileName: string; - profile: ProfileLatest; + profile: RuntimeProfile; } diff --git a/config/types/v2.ts b/config/types/v2.ts new file mode 100644 index 0000000..0099b9d --- /dev/null +++ b/config/types/v2.ts @@ -0,0 +1,34 @@ +import { + Static, + Type +} from '../../deps.ts'; +import { Region } from "./enums.ts"; + +export const APIKeyV2 = Type.String({ + pattern: 'rnd_[0-9a-zA-Z\_]+', + description: "Your Render API key. Will begin with 'rnd_'.", +}); +export type APIKeyV2 = Static; + +export const APIKeyGetCommand = Type.Object({ + run: Type.Array(Type.String(), { + description: 'A command to execute to get the API key. The command should output the API key to stdout.' + }), +}); +export type APIKeyGetCommand = Static; + +export const ProfileV2 = Type.Object({ + apiKey: Type.Union([APIKeyV2, APIKeyGetCommand]), + apiHost: Type.Optional(Type.String()), + defaultRegion: Region, +}); +export type ProfileV2 = Static; + +export const ConfigV2 = Type.Object({ + version: Type.Literal(2), + sshPreserveHosts: Type.Optional(Type.Boolean({ + description: "If true, render-cli will not keep ~/.ssh/known_hosts up to date with current public keys.", + })), + profiles: Type.Record(Type.String(), ProfileV2), +}); +export type ConfigV2 = Static;