Skip to content

Commit

Permalink
Porting delinquency work back to v10.x
Browse files Browse the repository at this point in the history
  • Loading branch information
sbosio committed Jun 12, 2024
1 parent 42572ca commit 4adfecb
Show file tree
Hide file tree
Showing 9 changed files with 529 additions and 16 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"heroku-client": "^3.1.0",
"http-call": "^5.2.4",
"netrc-parser": "^3.1.6",
"open": "^6.2.0",
"open": "^8.4.2",
"uuid": "^8.3.0"
},
"devDependencies": {
Expand All @@ -36,6 +36,7 @@
"nock": "^10.0.6",
"proxyquire": "^2.1.0",
"sinon": "^9.0.3",
"stdout-stderr": "^0.1.13",
"ts-node": "^8.1.0",
"tslint": "^6.1.3",
"typescript": "^4.8.4"
Expand Down
92 changes: 90 additions & 2 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as url from 'url'
import deps from './deps'
import {Login} from './login'
import {Mutex} from './mutex'
import {IDelinquencyConfig, IDelinquencyInfo, ParticleboardClient} from './particleboard-client'
import {RequestId, requestIdHeader} from './request-id'
import {vars} from './vars'

Expand Down Expand Up @@ -55,9 +56,11 @@ export class APIClient {
private readonly _login = new Login(this.config, this)
private _twoFactorMutex: Mutex<string> | undefined
private _auth?: string
private _particleboard!: ParticleboardClient

constructor(protected config: Interfaces.Config, public options: IOptions = {}) {
this.config = config

if (options.required === undefined) options.required = true
options.preauth = options.preauth !== false
this.options = options
Expand All @@ -75,6 +78,7 @@ export class APIClient {
...envHeaders,
},
}
const delinquencyConfig: IDelinquencyConfig = {fetch_delinquency: false, warning_shown: false}
this.http = class APIHTTPClient<T> extends deps.HTTP.HTTP.create(opts)<T> {
static async twoFactorRetry(
err: HTTPError,
Expand Down Expand Up @@ -107,17 +111,95 @@ export class APIClient {
}
}

static configDelinquency(url: string, opts: APIClient.Options): void {
if (opts.method?.toUpperCase() !== 'GET' || (opts.hostname && opts.hostname !== apiUrl.hostname)) {
delinquencyConfig.fetch_delinquency = false
return
}

if (/^\/account$/i.test(url)) {
delinquencyConfig.fetch_url = '/account'
delinquencyConfig.fetch_delinquency = true
delinquencyConfig.resource_type = 'account'
return
}

const match = url.match(/^\/teams\/([^#/?]+)/i)
if (match) {
delinquencyConfig.fetch_url = `/teams/${match[1]}`
delinquencyConfig.fetch_delinquency = true
delinquencyConfig.resource_type = 'team'
return
}

delinquencyConfig.fetch_delinquency = false
}

static notifyDelinquency(delinquencyInfo: IDelinquencyInfo): void {
const suspension = delinquencyInfo.scheduled_suspension_time ? Date.parse(delinquencyInfo.scheduled_suspension_time).valueOf() : undefined
const deletion = delinquencyInfo.scheduled_deletion_time ? Date.parse(delinquencyInfo.scheduled_deletion_time).valueOf() : undefined

if (!suspension && !deletion) return

const resource = delinquencyConfig.resource_type

if (suspension) {
const now = Date.now()

if (suspension > now) {
warn(`This ${resource} is delinquent with payment and we‘ll suspend it on ${new Date(suspension)}.`)
delinquencyConfig.warning_shown = true
return
}

if (deletion)
warn(`This ${resource} is delinquent with payment and we suspended it on ${new Date(suspension)}. If the ${resource} is still delinquent, we'll delete it on ${new Date(deletion)}.`)
} else if (deletion)
warn(`This ${resource} is delinquent with payment and we‘ll delete it on ${new Date(deletion)}.`)

delinquencyConfig.warning_shown = true
}

static async request<T>(url: string, opts: APIClient.Options = {}, retries = 3): Promise<APIHTTPClient<T>> {
opts.headers = opts.headers || {}
opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue

if (!Object.keys(opts.headers).find(h => h.toLowerCase() === 'authorization')) {
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
opts.headers.authorization = `Bearer ${self.auth}`
}

this.configDelinquency(url, opts)

retries--
try {
const response = await super.request<T>(url, opts)
let response: HTTP<T>
let particleboardResponse: HTTP<IDelinquencyInfo> | undefined
const particleboardClient: ParticleboardClient = self.particleboard

if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
self._particleboard.auth = self.auth
const settledResponses = await Promise.allSettled([
super.request<T>(url, opts),
particleboardClient.get<IDelinquencyInfo>(delinquencyConfig.fetch_url as string),
])

// Platform API request
if (settledResponses[0].status === 'fulfilled')
response = settledResponses[0].value
else
throw settledResponses[0].reason

// Particleboard request (ignore errors)
if (settledResponses[1].status === 'fulfilled') {
particleboardResponse = settledResponses[1].value
}
} else {
response = await super.request<T>(url, opts)
}

const delinquencyInfo: IDelinquencyInfo = particleboardResponse?.body || {}
this.notifyDelinquency(delinquencyInfo)

this.trackRequestIds<T>(response)
return response
} catch (error) {
Expand Down Expand Up @@ -145,6 +227,12 @@ export class APIClient {
}
}

get particleboard(): ParticleboardClient {
if (this._particleboard) return this._particleboard
this._particleboard = new deps.ParticleboardClient(this.config)
return this._particleboard
}

get twoFactorMutex(): Mutex<string> {
if (!this._twoFactorMutex) {
this._twoFactorMutex = new deps.Mutex()
Expand Down
4 changes: 4 additions & 0 deletions src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import file = require('./file')
import flags = require('./flags')
import git = require('./git')
import mutex = require('./mutex')
import particleboardClient = require('./particleboard-client')
import yubikey = require('./yubikey')

const {ux} = oclif
Expand All @@ -34,6 +35,9 @@ export const deps = {
get APIClient(): typeof apiClient.APIClient {
return fetch('./api-client').APIClient
},
get ParticleboardClient(): typeof particleboardClient.ParticleboardClient {
return fetch('./particleboard-client').ParticleboardClient
},
get file(): typeof file {
return fetch('./file')
},
Expand Down
4 changes: 2 additions & 2 deletions src/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Heroku from '@heroku-cli/schema'
import {Interfaces, ux} from '@oclif/core'
import HTTP from 'http-call'
import Netrc from 'netrc-parser'
import open = require('open')
import open from 'open'
import * as os from 'os'

import {APIClient, HerokuAPIError} from './api-client'
Expand Down Expand Up @@ -152,7 +152,7 @@ export class Login {
}

// ux.warn(`If browser does not open, visit ${color.greenBright(url)}`)
const cp = await open(url, {app: browser, wait: false})
const cp = await open(url, {wait: false, ...(browser ? {app: {name: browser}} : {})})
cp.on('error', err => {
ux.warn(err)
showUrl()
Expand Down
79 changes: 79 additions & 0 deletions src/particleboard-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {Interfaces} from '@oclif/core'
import {HTTP, HTTPRequestOptions} from 'http-call'
import * as url from 'url'

import deps from './deps'
import {RequestId, requestIdHeader} from './request-id'
import {vars} from './vars'

export interface IDelinquencyInfo {
scheduled_suspension_time?: string | null
scheduled_deletion_time?: string | null
}

export interface IDelinquencyConfig {
fetch_delinquency: boolean
warning_shown: boolean
resource_type?: 'account' | 'team'
fetch_url?: string
}

export class ParticleboardClient {
http: typeof HTTP
private _auth?: string

constructor(protected config: Interfaces.Config) {
this.config = config
const particleboardUrl = url.URL ? new url.URL(vars.particleboardUrl) : url.parse(vars.particleboardUrl)
const self = this as any
const envParticleboardHeaders = JSON.parse(process.env.HEROKU_PARTICLEBOARD_HEADERS || '{}')
const particleboardOpts = {
host: particleboardUrl.hostname,
port: particleboardUrl.port,
protocol: particleboardUrl.protocol,
headers: {
accept: 'application/vnd.heroku+json; version=3',
'user-agent': `heroku-cli/${self.config.version} ${self.config.platform}`,
...envParticleboardHeaders,
},
}
this.http = class ParticleboardHTTPClient<T> extends deps.HTTP.HTTP.create(particleboardOpts)<T> {
static trackRequestIds<T>(response: HTTP<T>) {
const responseRequestIdHeader = response.headers[requestIdHeader]
if (responseRequestIdHeader) {
const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(',')
RequestId.track(...requestIds)
}
}

static async request<T>(url: string, opts: HTTPRequestOptions = {}): Promise<ParticleboardHTTPClient<T>> {
opts.headers = opts.headers || {}
opts.headers[requestIdHeader] = RequestId.create() && RequestId.headerValue

if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
opts.headers.authorization = `Bearer ${self.auth}`
}

const response = await super.request<T>(url, opts)
this.trackRequestIds<T>(response)
return response
}
}
}

get auth(): string | undefined {
return this._auth
}

set auth(token: string | undefined) {
this._auth = token
}

get<T>(url: string, options: HTTPRequestOptions = {}) {
return this.http.get<T>(url, options)
}

get defaults(): typeof HTTP.defaults {
return this.http.defaults
}
}
13 changes: 13 additions & 0 deletions src/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ export class Vars {
get gitPrefixes(): string[] {
return [`git@${this.gitHost}:`, `ssh://git@${this.gitHost}/`, `https://${this.httpGitHost}/`]
}

get envParticleboardUrl(): string | undefined {
return process.env.HEROKU_PARTICLEBOARD_URL
}

// This should be fixed after we make our staging hostnames consistent throughout all services
// changing the staging cloud URL to `particleboard.staging.herokudev.com`.
get particleboardUrl(): string {
if (this.envParticleboardUrl) return this.envParticleboardUrl
return process.env.HEROKU_CLOUD === 'staging' ?
'https://particleboard-staging-cloud.herokuapp.com' :
'https://particleboard.heroku.com'
}
}

export const vars = new Vars()
Loading

0 comments on commit 4adfecb

Please sign in to comment.