Skip to content
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
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ export default [
},
// Ignore patterns (in addition to shared ignores)
{
ignores: ['**/test/**/*.js', '**/*.d.ts', '.github/**'],
ignores: ['**/test/**/*.js', '**/*.d.ts', '.github/**', 'tmp/**/*'],
},
]
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 26 additions & 15 deletions src/commands/pipelines/create.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import {Command, flags} from '@heroku-cli/command'
import {StageCompletion} from '@heroku-cli/command/lib/completions.js'
import * as color from '@heroku/heroku-cli-util/color'
import {createPlatformClient} from '@heroku/sdk/platform'
import {PipelineCreateOpts} from '@heroku/types/3.sdk'
import {Args, ux} from '@oclif/core'
import {type Answers, type InputQuestion, type ListQuestion} from 'inquirer'

import {
createCoupling,
createPipeline,
getAccountInfo,
getTeam,
Owner,
} from '../../lib/api.js'
import {getGenerationByAppId} from '../../lib/apps/generation.js'
import {lazyModuleLoader} from '../../lib/lazy-module-loader.js'
import infer from '../../lib/pipelines/infer.js'
import {inferrableStageNames as stages} from '../../lib/pipelines/stages.js'

// The Heroku API accepts an undocumented `generation` field on POST /pipelines
// that is not declared in heroku/api's schema, so the @heroku/types
// PipelineCreateOpts shape doesn't include it.
type PipelineCreateBody = PipelineCreateOpts & {
generation?: {name: string}
}

export default class Create extends Command {
static args = {
name: Args.string({
Expand Down Expand Up @@ -54,7 +56,6 @@ export default class Create extends Command {

let name
let stage
let owner: Owner
const guesses = infer(app)
const questions: (InputQuestion | ListQuestion)[] = []

Expand Down Expand Up @@ -83,24 +84,34 @@ export default class Create extends Command {

const ownerType = teamName ? 'team' : 'user'

// If team or org is not specified, we assign ownership to the user creating
const response = teamName ? await getTeam(this.heroku, teamName) : await getAccountInfo(this.heroku)
owner = response.body
const ownerID = owner.id
const heroku = createPlatformClient()

owner = {id: ownerID, type: ownerType}
// If team or org is not specified, we assign ownership to the user creating
const ownerRecord = teamName
? await heroku.team.info(teamName)
: await heroku.account.infoByUser('~')
const ownerID = ownerRecord.id!

const answers: Answers = await inquirer.prompt(questions)
if (answers.name) name = answers.name
if (answers.stage) stage = answers.stage

ux.action.start(`Creating ${name} pipeline`)
const generation = await getGenerationByAppId(app, this.heroku)
const {body: pipeline} = await createPipeline(this.heroku, name, owner, generation)
const body: PipelineCreateBody = {
generation: {name: generation ?? 'cedar'},
name,
owner: {id: ownerID, type: ownerType},
}
const pipeline = await heroku.pipeline.create(body)
ux.action.stop()

ux.action.start(`Adding ${color.app(app)} to ${color.pipeline(pipeline.name || '')} pipeline as ${stage}`)
await createCoupling(this.heroku, pipeline, app, stage)
await heroku.pipelineCoupling.create({
app,
pipeline: pipeline.id!,
stage,
})
ux.action.stop()
}
}
5 changes: 3 additions & 2 deletions src/commands/pipelines/destroy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {Command} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import * as color from '@heroku/heroku-cli-util/color'
import {createPlatformClient} from '@heroku/sdk/platform'
import {Args, ux} from '@oclif/core'

import {destroyPipeline} from '../../lib/api.js'
import disambiguate from '../../lib/pipelines/disambiguate.js'

export default class PipelinesDestroy extends Command {
Expand All @@ -23,7 +23,8 @@ export default class PipelinesDestroy extends Command {
const pipeline: Heroku.Pipeline = await disambiguate(this.heroku, args.pipeline)

ux.action.start(`Destroying ${color.pipeline(pipeline.name!)} pipeline`)
await destroyPipeline(this.heroku, pipeline.name, pipeline.id)
const heroku = createPlatformClient()
await heroku.pipeline.delete(pipeline.id!)
ux.action.stop()
}
}
4 changes: 2 additions & 2 deletions src/commands/pipelines/diff.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Command, flags} from '@heroku-cli/command'
import {color, hux} from '@heroku/heroku-cli-util'
import {HTTP} from '@heroku/http-call'
import {listPipelineApps} from '@heroku/sdk/compositions/pipeline'
import {ux} from '@oclif/core/ux'

import type {OciImage, PipelineCoupling, Slug} from '../../lib/types/fir.js'
Expand All @@ -10,7 +11,6 @@ import {
getCoupling,
getPipeline,
getReleases,
listPipelineApps,
SDK_HEADER,
} from '../../lib/api.js'
import {GenerationKind, getGeneration} from '../../lib/apps/generation.js'
Expand Down Expand Up @@ -87,7 +87,7 @@ export default class PipelinesDiff extends Command {
const generation = getGeneration(pipeline)!

ux.action.start('Fetching apps from pipeline')
const allApps = await listPipelineApps(this.heroku, coupling!.pipeline!.id!)
const allApps = await listPipelineApps(coupling!.pipeline!.id!)
ux.action.stop()

const sourceStage = coupling.stage
Expand Down
5 changes: 3 additions & 2 deletions src/commands/pipelines/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {color, hux} from '@heroku/heroku-cli-util'
import {createPlatformClient} from '@heroku/sdk/platform'
import {ux} from '@oclif/core/ux'

export default class Pipelines extends Command {
Expand All @@ -15,7 +15,8 @@ export default class Pipelines extends Command {
async run() {
const {flags} = await this.parse(Pipelines)

const {body: pipelines} = await this.heroku.get<Heroku.Pipeline[]>('/pipelines')
const heroku = createPlatformClient()
const pipelines = await heroku.pipeline.list()

if (flags.json) {
hux.styledJSON(pipelines)
Expand Down
4 changes: 2 additions & 2 deletions src/commands/pipelines/info.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {color, hux} from '@heroku/heroku-cli-util'
import {listPipelineApps} from '@heroku/sdk/compositions/pipeline'
import {Args} from '@oclif/core'

import {listPipelineApps} from '../../lib/api.js'
import disambiguate from '../../lib/pipelines/disambiguate.js'
import renderPipeline from '../../lib/pipelines/render-pipeline.js'

Expand Down Expand Up @@ -31,7 +31,7 @@ export default class PipelinesInfo extends Command {
async run() {
const {args, flags} = await this.parse(PipelinesInfo)
const pipeline: Heroku.Pipeline = await disambiguate(this.heroku, args.pipeline)
const pipelineApps = await listPipelineApps(this.heroku, pipeline.id!)
const pipelineApps = await listPipelineApps(pipeline.id!)

if (flags.json) {
// eslint-disable-next-line perfectionist/sort-objects
Expand Down
163 changes: 34 additions & 129 deletions src/commands/pipelines/promote.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {APIClient, Command, flags} from '@heroku-cli/command'
import * as Heroku from '@heroku-cli/schema'
import {color, hux} from '@heroku/heroku-cli-util'
import {
type AppWithPipelineCoupling,
listPipelineApps,
promotePipeline,
type ReleaseStreamContext,
} from '@heroku/sdk/compositions/pipeline'
import {ux} from '@oclif/core/ux'
import fetch from 'node-fetch'
import assert from 'node:assert'
import * as Stream from 'node:stream'
import {promisify} from 'node:util'

import {AppWithPipelineCoupling, listPipelineApps} from '../../lib/api.js'
import keyBy from '../../lib/pipelines/key-by.js'

function assertNotPromotingToSelf(source: string, target: string) {
Expand All @@ -23,8 +25,6 @@ function findAppInPipeline(apps: Array<AppWithPipelineCoupling>, target: string)

const PROMOTION_ORDER = ['development', 'staging', 'production']

const wait = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms))

export default class Promote extends Command {
static description = 'promote the latest release of this app to its downstream app(s)'
static examples = [
Expand All @@ -40,17 +40,16 @@ export default class Promote extends Command {
description: 'comma separated list of apps to promote to',
}),
}

public static sleep(time: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, time))
}
// Static reference so tests can stub the SDK call without changing the
// command's behavior in production.
public static promotePipeline = promotePipeline

async run() {
const {flags} = await this.parse(Promote)
const appNameOrId = flags.app
const coupling = await getCoupling(this.heroku, appNameOrId)
ux.stdout(`Fetching apps from ${color.pipeline(coupling.pipeline!.name)}...`)
const allApps = await listPipelineApps(this.heroku, coupling.pipeline!.id!)
const allApps = await listPipelineApps(coupling.pipeline!.id!)
const sourceStage = coupling.stage

let promotionActionName = ''
Expand Down Expand Up @@ -87,21 +86,36 @@ export default class Promote extends Command {
promotionActionName = `Starting promotion to ${targetStage}`
}

const promotion = await promote(this.heroku, promotionActionName, coupling.pipeline!.id!, coupling.app!.id!, targetApps)

const pollLoop = pollPromotionStatus(this.heroku, promotion.id!, true)
ux.stdout(`${promotionActionName}...`)
ux.stdout('Waiting for promotion to complete...')
let promotionTargets = await pollLoop

try {
promotionTargets = await streamReleaseCommand(this.heroku, promotionTargets, promotion)
} catch (error: any) {
ux.error(error)
let releaseStreamError: unknown
const onReleaseStream = async ({stream}: ReleaseStreamContext) => {
ux.stdout('Running release command...')
try {
await stream.pipeTo(new WritableStream({
write(chunk) {
process.stdout.write(Buffer.from(chunk))
},
}))
} catch (error) {
releaseStreamError = error
}
}

const {targets: promotionTargets} = await Promote.promotePipeline({
pipeline: {id: coupling.pipeline!.id!},
source: {app: {id: coupling.app!.id!}},
targets: targetApps.map(app => ({app: {id: app.id}})),
}, {onReleaseStream})

if (releaseStreamError) {
ux.error(releaseStreamError as Error)
}

const appsByID = keyBy(allApps, 'id')

const styledTargets = promotionTargets.reduce((memo: Heroku.App, target: Heroku.App) => {
const styledTargets = promotionTargets.reduce((memo: Heroku.App, target: any) => {
const app = appsByID[target.app.id]
const details = [target.status]

Expand Down Expand Up @@ -141,119 +155,10 @@ async function getCoupling(heroku: APIClient, app: string): Promise<Heroku.Pipel
return coupling
}

async function getRelease(heroku: APIClient, app: string, releaseId: string): Promise<Heroku.Release> {
ux.stdout('Fetching release info...')
const {body: release} = await heroku.get<Heroku.Release>(`/apps/${app}/releases/${releaseId}`)
return release
}

function isComplete(promotionTarget: Heroku.PipelinePromotionTarget) {
return promotionTarget.status !== 'pending'
}

function isFailed(promotionTarget: Heroku.PipelinePromotionTarget) {
return promotionTarget.status === 'failed'
}

function isSucceeded(promotionTarget: Heroku.PipelinePromotionTarget) {
return promotionTarget.status === 'succeeded'
}

function pollPromotionStatus(heroku: APIClient, id: string, needsReleaseCommand: boolean): Promise<Array<Heroku.PipelinePromotionTarget>> {
return heroku.get<Array<Heroku.PipelinePromotionTarget>>(`/pipeline-promotions/${id}/promotion-targets`).then(({body: targets}) => {
if (targets.every(isComplete)) {
return targets
}

//
// With only one target, we can return as soon as the release is created.
// The command will then read the release phase output
//
// `needsReleaseCommand` allows us to keep polling, as it can take a few
// seconds to get the release to succeeded after the release command
// finished.
//
if (needsReleaseCommand && targets.length === 1 && targets[0].release !== null) {
return targets
}

return wait(1000).then(pollPromotionStatus.bind(null, heroku, id, needsReleaseCommand))
})
}

async function promote(heroku: APIClient, label: string, id: string, sourceAppId: string, targetApps: Array<AppWithPipelineCoupling>, secondFactor?: string): Promise<Heroku.PipelinePromotion> {
const options = {
body: {
pipeline: {id},
source: {app: {id: sourceAppId}},
targets: targetApps.map(app => ({app: {id: app.id}})),
},
headers: {},
}

if (secondFactor) {
options.headers = {'Heroku-Two-Factor-Code': secondFactor}
}

try {
ux.stdout(`${label}...`)
const {body: promotions} = await heroku.post<Heroku.PipelinePromotion>('/pipeline-promotions', options)
return promotions
} catch (error: any) {
if (!error.body || error.body.id !== 'two_factor') {
throw error
}

const secondFactor = await heroku.twoFactorPrompt()
return promote(heroku, label, id, sourceAppId, targetApps, secondFactor)
}
}

async function streamReleaseCommand(heroku: APIClient, targets: Array<Heroku.App>, promotion: any) {
if (targets.length !== 1 || targets.every(isComplete)) {
return pollPromotionStatus(heroku, promotion.id, false)
}

const target = targets[0]
const release = await getRelease(heroku, target.app.id, target.release.id)

if (!release.output_stream_url) {
return pollPromotionStatus(heroku, promotion.id, false)
}

ux.stdout('Running release command...')

async function streamReleaseOutput(releaseStreamUrl: string) {
const finished = promisify(Stream.finished)
const fetchResponse = await fetch(releaseStreamUrl)

if (fetchResponse.status >= 400) {
throw new Error('stream release output not available')
}

fetchResponse.body.pipe(process.stdout)

await finished(fetchResponse.body)
}

async function retry(maxAttempts: number, fn: () => Promise<any>) {
let currentAttempt = 0

while (true) {
try {
await fn()
return
} catch (error) {
if (++currentAttempt === maxAttempts) {
throw error
}

await Promote.sleep(1000)
}
}
}

await retry(100, () => streamReleaseOutput(release.output_stream_url!))

return pollPromotionStatus(heroku, promotion.id, false)
}
Loading
Loading