Skip to content
This repository was archived by the owner on Nov 19, 2024. It is now read-only.

Credential fetching via command exec #49

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions commands/config/add-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<profileName:string>")
Expand Down
2 changes: 2 additions & 0 deletions commands/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand All @@ -18,5 +19,6 @@ export const configCommand =
.command("init", configInitCommand)
.command("add-profile", configAddProfileCommand)
.command("profiles", configProfilesCommand)
.command("upgrade", configUpgradeCommand)
.command("schema", configSchemaCommand)
;
5 changes: 3 additions & 2 deletions commands/config/init.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 =
Expand Down Expand Up @@ -39,7 +40,7 @@ export const configInitCommand =
]);

const cfg: ConfigLatest = {
version: 1,
version: FALLBACK_CONFIG.version,
sshPreserveHosts,
profiles: {
default: defaultProfile,
Expand Down
31 changes: 31 additions & 0 deletions commands/config/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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!");
}
});
104 changes: 76 additions & 28 deletions config/index.ts
Original file line number Diff line number Diff line change
@@ -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<ProfileLatest> = {
defaultRegion: 'oregon', // mimics dashboard behavior
};

const FALLBACK_CONFIG: ConfigLatest = {
version: 1,
export const FALLBACK_CONFIG: ConfigLatest = {
version: 2,
profiles: {},
}

Expand All @@ -46,16 +55,6 @@ export async function withConfig<T>(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<ConfigLatest> {
const data = await YAML.load(content);
const ret = {
Expand All @@ -66,7 +65,25 @@ async function parseConfig(content: string): Promise<ConfigLatest> {

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)}`);
Expand All @@ -88,28 +105,59 @@ async function fetchAndParseConfig(): Promise<ConfigLatest> {
}
}

async function getProfileCredentials(profile: ProfileLatest): Promise<string> {
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();
}
Expand Down
21 changes: 14 additions & 7 deletions config/types/index.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (cfg: T) => ConfigLatest;

export type RuntimeProfile =
& Omit<ProfileLatest, 'apiKey'>
& { apiKey: string };

export type RuntimeConfiguration = {
fullConfig: ConfigLatest;
profileName: string;
profile: ProfileLatest;
profile: RuntimeProfile;
}
34 changes: 34 additions & 0 deletions config/types/v2.ts
Original file line number Diff line number Diff line change
@@ -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<typeof APIKeyV2>;

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<typeof APIKeyGetCommand>;

export const ProfileV2 = Type.Object({
apiKey: Type.Union([APIKeyV2, APIKeyGetCommand]),
apiHost: Type.Optional(Type.String()),
defaultRegion: Region,
});
export type ProfileV2 = Static<typeof ProfileV2>;

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<typeof ConfigV2>;