Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jw/redis v5/migrate redis cli #2718

Merged
merged 6 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
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
222 changes: 222 additions & 0 deletions packages/cli/src/commands/redis/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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 {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: URL, 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.password}\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 ssh2 = new Client()
resolve(ssh2)
ssh2.once('ready', () => resolve(ssh2))
ssh2.connect({
host: bastions.split(',')[0],
username: 'bastion',
privateKey: match(config, /_BASTION_KEY/) ?? '',
})
})
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())

return redisCLI(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
}

async 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(),
}

static examples = [
'$ heroku redis:cli --app=my-app my-database',
'$ heroku redis:cli --app=my-app --confirm my-database',
]

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.id)
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
}
137 changes: 137 additions & 0 deletions packages/cli/src/lib/redis/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 = []
for (const addon of addons) {
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(addon => 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
}),
)
}
}
justinwilaby marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading