Skip to content

Commit

Permalink
redis-v5(W-14165217): migrate redis:cli
Browse files Browse the repository at this point in the history
  • Loading branch information
justinwilaby committed Mar 15, 2024
1 parent de50cae commit 657b20f
Show file tree
Hide file tree
Showing 6 changed files with 755 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-unused-vars": "warn", // TODO: fix issues and turn this back on
"@typescript-eslint/no-unused-vars": ["warn", {"ignoreRestSiblings": true}], // TODO: fix issues and turn this back on
"camelcase":"off",
"import/no-unresolved": "error",
"indent": ["error", 2, {"MemberExpression": 1}],
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,16 @@
"node-fetch": "^2.6.7",
"open": "^8.4.2",
"phoenix": "^1.6.14",
"portfinder": "^1.0.32",
"printf": "0.6.1",
"psl": "^1.9.0",
"redis-parser": "^3.0.0",
"rollbar": "^2.26.2",
"semver": "5.6.0",
"shell-escape": "^0.2.0",
"shell-quote": "^1.8.1",
"sparkline": "^0.2.0",
"ssh2": "^1.15.0",
"stdout-stderr": "^0.1.13",
"strftime": "^0.10.0",
"strip-ansi": "^6",
Expand Down Expand Up @@ -103,8 +106,10 @@
"@types/phoenix": "^1.4.0",
"@types/proxyquire": "^1.3.28",
"@types/psl": "^1.1.3",
"@types/redis-parser": "^3.0.3",
"@types/shell-escape": "^0.2.0",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "^1.15.0",
"@types/std-mocks": "^1.0.4",
"@types/strftime": "^0.9.8",
"@types/supports-color": "^5.3.0",
Expand Down
219 changes: 219 additions & 0 deletions packages/cli/src/commands/redis/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import {APIClient, Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import * as readline from 'readline'
import {Client} from 'ssh2'
import Parser = require('redis-parser')
import type {Writable} from 'node:stream'
import portfinder = require('portfinder')
import {ClientRequestArgs} from 'node:http'
import {urlToHttpOptions} from 'url'
import {getRedisAddon, getRedisFormation, RedisFormation} from '../../lib/redis/utils'
import confirmApp from '../../lib/apps/confirm-app'
import * as tls from 'tls'
import type {Socket} from 'node:net'
import type {Duplex} from 'stream'
import {promisify} from 'node:util'
import * as net from 'net'

const REPLY_OK = 'OK'

async function redisCLI(uri: ClientRequestArgs, client: Writable): Promise<void> {
const io = readline.createInterface(process.stdin, process.stdout)
const reply = new Parser({
returnReply(reply) {
switch (state) {
case 'monitoring':
if (reply !== REPLY_OK) {
console.log(reply)
}

break
case 'subscriber':
if (Array.isArray(reply)) {
reply.forEach(function (value, i) {
console.log(`${i + 1}) ${value}`)
})
} else {
console.log(reply)
}

break
case 'connect':
if (reply !== REPLY_OK) {
console.log(reply)
}

state = 'normal'
io.prompt()
break
case 'closing':
if (reply !== REPLY_OK) {
console.log(reply)
}

break
default:
if (Array.isArray(reply)) {
reply.forEach(function (value, i) {
console.log(`${i + 1}) ${value}`)
})
} else {
console.log(reply)
}

io.prompt()
break
}
}, returnError(err) {
console.log(err.message)
io.prompt()
}, returnFatalError(err) {
client.emit('error', err)
console.dir(err)
},
})
let state = 'connect'
client.write(`AUTH ${uri.auth?.split(':')[1]}\n`)
io.setPrompt(uri.host + '> ')
io.on('line', function (line) {
switch (line.split(' ')[0]) {
case 'MONITOR':
state = 'monitoring'
break
case 'PSUBSCRIBE':
case 'SUBSCRIBE':
state = 'subscriber'
break
}

client.write(`${line}\n`)
})
io.on('close', function () {
state = 'closing'
client.write('QUIT\n')
})
client.on('data', function (data) {
reply.execute(data)
})
return new Promise((resolve, reject) => {
client.on('error', reject)
client.on('end', function () {
console.log('\nDisconnected from instance.')
io.close()
resolve()
})
})
}

async function bastionConnect(uri: URL, bastions: string, config: Record<string, unknown>, preferNativeTls: boolean) {
const tunnel: Client = await new Promise(resolve => {
const tunnel = new Client()
tunnel.on('ready', () => resolve(tunnel))
})
const localPort = await portfinder.getPortPromise({startPort: 49152, stopPort: 65535})
const stream: Duplex = await promisify(tunnel.forwardOut)('localhost', localPort, uri.hostname, Number.parseInt(uri.port, 10))

let client: Duplex = stream
if (preferNativeTls) {
client = tls.connect({
socket: stream as Socket,
port: Number.parseInt(uri.port, 10),
host: uri.hostname,
rejectUnauthorized: false,
})
}

stream.on('close', () => tunnel.end())
stream.on('end', () => client.end())

tunnel.connect({
host: bastions.split(',')[0],
username: 'bastion',
privateKey: match(config, /_BASTION_KEY/) ?? '',
})

return redisCLI(urlToHttpOptions(uri), client)
}

function match(config: Record<string, unknown>, lookup: RegExp): string | null {
for (const key in config) {
if (lookup.test(key)) {
return config[key] as string
}
}

return null
}

function maybeTunnel(redis: RedisFormation, config: Record<string, unknown>) {
const bastions = match(config, /_BASTIONS/)
const hobby = redis.plan.indexOf('hobby') === 0
const preferNativeTls = redis.prefer_native_tls
const uri = preferNativeTls && hobby ? new URL(match(config, /_TLS_URL/) ?? '') : new URL(redis.resource_url)

if (bastions !== null) {
return bastionConnect(uri, bastions, config, preferNativeTls)
}

let client
if (preferNativeTls) {
client = tls.connect({
port: Number.parseInt(uri.port, 10), host: uri.hostname, rejectUnauthorized: false,
})
} else if (hobby) {
client = net.connect({port: Number.parseInt(uri.port, 10), host: uri.hostname})
} else {
client = tls.connect({
port: Number.parseInt(uri.port, 10) + 1, host: uri.hostname, rejectUnauthorized: false,
})
}

return redisCLI(uri, client)
}

export default class Cli extends Command {
static topic = 'redis'
static description = 'opens a redis prompt'
static flags = {
confirm: flags.string({char: 'c'}),
app: flags.app({required: true}),
}

static args = {
database: Args.string(),
}

public async run(): Promise<void> {
const {flags, args} = await this.parse(Cli)
const addon = await getRedisAddon(flags.app, args.database, this.heroku)
const configVars = await getRedisConfigVars(addon, this.heroku)
const {body: redis} = await getRedisFormation(this.heroku, addon.name)
if (redis.plan.startsWith('shield-')) {
ux.error('\n Using redis:cli on Heroku Redis shield plans is not supported.\n Please see Heroku DevCenter for more details: https://devcenter.heroku.com/articles/shield-private-space#shield-features\n ', {exit: 1})
}

const hobby = redis.plan.indexOf('hobby') === 0
const prefer_native_tls = redis.prefer_native_tls
if (!prefer_native_tls && hobby) {
await confirmApp(flags.app, flags.confirm, 'WARNING: Insecure action.\nAll data, including the Redis password, will not be encrypted.')
}

const nonBastionVars = Object.keys(configVars)
.filter(function (configVar) {
return !(/(?:BASTIONS|BASTION_KEY|BASTION_REKEYS_AFTER)$/.test(configVar))
})
.join(', ')
ux.log(`Connecting to ${addon.name} (${nonBastionVars}):`)
return maybeTunnel(redis, configVars)
}
}

async function getRedisConfigVars(addon: Required<Heroku.AddOn>, heroku: APIClient): Promise<Record<string, unknown>> {
const {body: config} = await heroku.get<Record<string, unknown>>(`/apps/${addon.billing_entity.name}/config-vars`)
const redisConfigVars: Record<string, unknown> = {}
addon.config_vars.forEach(configVar => {
redisConfigVars[configVar] = config[configVar]
})
return redisConfigVars
}
142 changes: 142 additions & 0 deletions packages/cli/src/lib/redis/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use strict'
import {AddOn} from '@heroku-cli/schema'
import {APIClient} from '@heroku-cli/command'
import {HTTP} from 'http-call'
import {ux} from '@oclif/core'

const HOST = process.env.HEROKU_REDIS_HOST || 'api.data.heroku.com'
const ADDON = process.env.HEROKU_REDIS_ADDON_NAME || 'heroku-redis'

export interface RedisFormation {
addon_id: string
name: string
plan: string
created_at: string,
formation: {
id: string
primary: string
},
metaas_source: string,
port: number
resource_url: string
info: {name: string, values: string }[],
version: string
prefer_native_tls: boolean,
}

export function makeAddonsFilter(filter = '') {
const matcher = new RegExp(`/(${filter})/ig`)

function matches(addon: Required<AddOn>) {
for (let i = 0; i < addon.config_vars.length; i++) {
if (matcher.test(addon.config_vars[i])) {
return true
}
}

return matcher.test(addon.name)
}

function onResponse(addons: Required<AddOn>[]) {
const redisAddons = []
// eslint-disable-next-line unicorn/no-for-loop
for (let i = 0; i < addons.length; i++) {
const addon = addons[i]
const service = addon.addon_service.name ?? ''

if (service.indexOf(ADDON) === 0 && (!filter || matches(addon))) {
redisAddons.push(addon)
}
}

return redisAddons
}

return onResponse
}

export async function getRedisAddon(appId: string, database: string | undefined, heroku: APIClient, addonsRequest?: Promise<HTTP<Required<AddOn[]>>>) {
addonsRequest = addonsRequest || heroku.get(`/apps/${appId}/addons`)
const {body: addonsList} = await addonsRequest
const addonsFilter = makeAddonsFilter(database ?? '')
const addons = addonsFilter(addonsList as Required<AddOn>[])

if (addons.length === 0) {
ux.error('No Redis instances found.', {exit: 1})
} else if (addons.length > 1) {
const names = addons.map(function (addon) {
return addon.name
})
ux.error(`Please specify a single instance. Found: ${names.join(', ')}`, {exit: 1})
}

return addons[0]
}

type StyledRedisJson = Omit<RedisFormation, 'formation' | 'metaas_source' | 'port'> & Pick<Required<AddOn>, 'app' | 'config_vars'>
export async function getRedisFormation(heroku: APIClient, formationIdentifier: string): Promise<HTTP<RedisFormation>> {
return heroku.request<RedisFormation>(`/redis/v0/databases/${formationIdentifier}`, {hostname: HOST, port: 443})
}

export async function info(heroku: APIClient, appId: string, database: string, json: boolean) {
let {body: addons} = await heroku.get<Required<AddOn>[]>(`/apps/${appId}/addons`)
// filter out non-redis addons
addons = makeAddonsFilter(database)(addons)
// get info for each db
const dbs = await Promise.allSettled(addons.map(addon => getRedisFormation(heroku, addon.name)))
const databases = addons.map((addon, index) => {
const promiseSettledResult = dbs[index]
return {
addon: addon,
redis() {
if (promiseSettledResult.status === 'fulfilled') {
return promiseSettledResult.value.body
}

const {message, statusCode} = promiseSettledResult.reason as {message: string, statusCode: number}
if (statusCode !== 404) {
ux.error(message, {exit: 1})
}

return null
},
}
})

if (json) {
const redii: StyledRedisJson[] = []
for (const db of databases) {
const redis = db.redis()
// eslint-disable-next-line no-eq-null, eqeqeq
if (redis == null) {
continue
}

const {formation, metaas_source, port, ...others} = redis
const filteredRedis: StyledRedisJson = {...others, app: db.addon.app, config_vars: db.addon.config_vars}

redii.push(filteredRedis)
}

return ux.styledJSON(redii)
}

// print out the info of the addon and redis db info
for (const db of databases) {
const redis = db.redis()
if (redis === null) {
continue
}

ux.styledHeader(`${db.addon.name} (${db.addon.config_vars.join(', ')})`)
ux.styledObject(
redis.info.reduce((memo: Record<string, unknown>, row) => {
memo[row.name] = row.values
return memo
}, {}),
redis.info.map(function (row) {
return row.name
}),
)
}
}
Loading

0 comments on commit 657b20f

Please sign in to comment.