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

Delinquency notifications #129

Merged
merged 7 commits into from
Jun 12, 2024
Merged
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
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.3.0",
"netrc-parser": "^3.1.6",
"open": "^6.2.0",
"open": "^8.4.2",
"uuid": "^8.3.0"
},
"devDependencies": {
Expand Down Expand Up @@ -43,6 +43,7 @@
"np": "^7.7.0",
"proxyquire": "^2.1.3",
"sinon": "^14.0.2",
"stdout-stderr": "^0.1.13",
"ts-node": "^10.9.1",
"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 @@ -9,6 +9,7 @@
import {Mutex} from './mutex'
import {RequestId, requestIdHeader} from './request-id'
import {vars} from './vars'
import {ParticleboardClient, IDelinquencyInfo, IDelinquencyConfig} from './particleboard-client'

export namespace APIClient {
export interface Options extends HTTPRequestOptions {
Expand Down Expand Up @@ -55,13 +56,15 @@
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
const apiUrl = url.URL ? new url.URL(vars.apiUrl) : url.parse(vars.apiUrl)

Check warning on line 67 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 16.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 67 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 16.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 67 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (macos-latest, 16.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead
const envHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}')
this.preauthPromises = {}
const self = this as any
Expand All @@ -75,6 +78,7 @@
...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 @@
}
}

static configDelinquency(url: string, opts: APIClient.Options): void {
sbosio marked this conversation as resolved.
Show resolved Hide resolved
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)
sbosio marked this conversation as resolved.
Show resolved Hide resolved
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 @@
}
}

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 @@ -4,6 +4,7 @@
import netrc = require('netrc-parser')

import apiClient = require('./api-client')
import particleboardClient = require('./particleboard-client')
import file = require('./file')
import flags = require('./flags')
import git = require('./git')
Expand Down Expand Up @@ -34,6 +35,9 @@
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 All @@ -49,7 +53,7 @@

function fetch(s: string) {
if (!cache[s]) {
cache[s] = require(s)

Check warning on line 56 in src/deps.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 16.x)

Do not use "require"

Check warning on line 56 in src/deps.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 16.x)

Do not use "require"

Check warning on line 56 in src/deps.ts

View workflow job for this annotation

GitHub Actions / test (macos-latest, 16.x)

Do not use "require"
}

return cache[s]
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'
sbosio marked this conversation as resolved.
Show resolved Hide resolved
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
Loading