From 1a0cee2396edc50c953f5be44e344329e192b088 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Thu, 27 Feb 2025 12:17:18 -0800 Subject: [PATCH 01/13] feat(release): add polling for get airgap build status --- src/releases.spec.ts | 29 +++++++++++++++++++- src/releases.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/releases.spec.ts b/src/releases.spec.ts index 6d74812..e921851 100644 --- a/src/releases.spec.ts +++ b/src/releases.spec.ts @@ -1,5 +1,5 @@ import { VendorPortalApi } from "./configuration"; -import { ReleaseChart, exportedForTesting, KotsSingleSpec, createReleaseFromChart, Release, CompatibilityResult } from "./releases"; +import { ReleaseChart, exportedForTesting, KotsSingleSpec, createReleaseFromChart, Release, CompatibilityResult, pollForAirgapReleaseStatus } from "./releases"; import * as mockttp from "mockttp"; import * as fs from "fs-extra"; import * as path from "path"; @@ -298,3 +298,30 @@ describe("createReleaseFromChart", () => { expect(release.charts?.length).toEqual(1); }); }); + +describe("pollForAirgapReleaseStatus", () => { + const mockServer = mockttp.getLocal(); + const apiClient = new VendorPortalApi(); + apiClient.apiToken = "abcd1234"; + apiClient.endpoint = "http://localhost:8080"; + // Start your mock server + beforeEach(() => { + mockServer.start(8080); + }); + afterEach(() => mockServer.stop()); + + it("poll for airgapped release status", async () => { + const data = { + releases: [ + { + sequence: 0, + airgapBuildStatus: "built" + } + ] + }; + await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(data)); + + const result = await pollForAirgapReleaseStatus(apiClient, "1234abcd", "1", 0, "built"); + expect(result).toEqual("built"); + }); +}); diff --git a/src/releases.ts b/src/releases.ts index fae7e94..17397ef 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -11,6 +11,7 @@ import { zonedTimeToUtc } from "date-fns-tz"; export interface Release { sequence: string; charts?: ReleaseChart[]; + airgapBuildStatus?: string; } export interface ReleaseChart { @@ -45,6 +46,15 @@ export const exportedForTesting = { reportCompatibilityResultByAppId }; +export class StatusError extends Error { + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + export async function createRelease(vendorPortalApi: VendorPortalApi, appSlug: string, yamlDir: string): Promise { const http = await vendorPortalApi.client(); @@ -341,3 +351,56 @@ async function reportCompatibilityResultByAppId(vendorPortalApi: VendorPortalApi // discard the response body await res.readBody(); } + +export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number, expectedStatus: string, timeout: number = 120, sleeptimeMs: number = 5000): Promise { + // get airgapped build release from the api, look for the status of the id to be ${status} + // if it's not ${status}, sleep for 5 seconds and try again + // if it is ${status}, return the release with that status + // iterate for timeout/sleeptime times + const iterations = (timeout * 1000) / sleeptimeMs; + for (let i = 0; i < iterations; i++) { + try { + const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); + if (release.airgapBuildStatus === expectedStatus) { + return release.airgapBuildStatus; + } + if (release.airgapBuildStatus === "failed") { + console.debug(`Airgapped build release ${releaseSequence} failed`); + return "failed"; + } + console.debug(`Airgapped build release ${releaseSequence} is not ready, sleeping for ${sleeptimeMs / 1000} seconds`); + await new Promise(f => setTimeout(f, sleeptimeMs)); + } catch (err) { + if (err instanceof StatusError) { + if (err.statusCode >= 500) { + // 5xx errors are likely transient, so we should retry + console.debug(`Got HTTP error with status ${err.statusCode}, sleeping for ${sleeptimeMs / 1000} seconds`); + } else { + console.debug(`Got HTTP error with status ${err.statusCode}, exiting`); + throw err; + } + } else { + throw err; + } + } + } +} + + +async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { + const http = await vendorPortalApi.client(); + const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; + const res = await http.get(uri); + if (res.message.statusCode != 200) { + // discard the response body + await res.readBody(); + throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`); + } + const body: any = JSON.parse(await res.readBody()); + console.debug(`Airgapped build release body: ${JSON.stringify(body)}`); + const release = body.releases.find((r: any) => r.sequence === releaseSequence); + return { + sequence: release.sequence, + airgapBuildStatus: release.airgapBuildStatus, + } +} From 518d35b845d285504059859d433ff8acb6075e83 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Thu, 27 Feb 2025 14:28:21 -0800 Subject: [PATCH 02/13] add buildAirgapAutomatically --- pacts/npm_consumer-vp_service.json | 2 ++ src/channels.spec.ts | 10 ++++++---- src/channels.ts | 5 +++-- src/index.ts | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pacts/npm_consumer-vp_service.json b/pacts/npm_consumer-vp_service.json index 57f500b..e0c1c2c 100644 --- a/pacts/npm_consumer-vp_service.json +++ b/pacts/npm_consumer-vp_service.json @@ -18,12 +18,14 @@ "body": { "channels": [ { + "buildAirgapAutomatically": true, "channelSlug": "stable", "id": "1234abcd", "name": "Stable", "releaseSequence": 1 }, { + "buildAirgapAutomatically": false, "channelSlug": "beta", "id": "5678efgh", "name": "Beta", diff --git a/src/channels.spec.ts b/src/channels.spec.ts index 29ae943..1bfa592 100644 --- a/src/channels.spec.ts +++ b/src/channels.spec.ts @@ -15,7 +15,8 @@ describe("findChannelDetailsInOutput", () => { appName: "relmatrix", channelSlug: "stable", name: "Stable", - releaseSequence: 1 + releaseSequence: 1, + buildAirgapAutomatically: true }, { id: "channelid2", @@ -24,7 +25,8 @@ describe("findChannelDetailsInOutput", () => { appName: "relmatrix", channelSlug: "ci-reliability-matrix", name: "ci-reliability-matrix", - releaseSequence: 2 + releaseSequence: 2, + buildAirgapAutomatically: false } ]; const channelSlug = "ci-reliability-matrix"; @@ -41,8 +43,8 @@ describe("ChannelsService", () => { test("should return channel", () => { const expectedChannels = { channels: [ - { id: "1234abcd", name: "Stable", channelSlug: "stable", releaseSequence: 1 }, - { id: "5678efgh", name: "Beta", channelSlug: "beta", releaseSequence: 2 } + { id: "1234abcd", name: "Stable", channelSlug: "stable", releaseSequence: 1, buildAirgapAutomatically: true }, + { id: "5678efgh", name: "Beta", channelSlug: "beta", releaseSequence: 2, buildAirgapAutomatically: false } ] }; diff --git a/src/channels.ts b/src/channels.ts index 4b105f1..61f0df2 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -6,6 +6,7 @@ export class Channel { id: string; slug: string; releaseSequence?: number; + buildAirgapAutomatically?: boolean; } export const exportedForTesting = { @@ -98,10 +99,10 @@ export async function archiveChannel(vendorPortalApi: VendorPortalApi, appSlug: async function findChannelDetailsInOutput(channels: any[], { slug, name }: ChannelIdentifier): Promise { for (const channel of channels) { if (slug && channel.channelSlug == slug) { - return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence }; + return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence, buildAirgapAutomatically: channel.buildAirgapAutomatically }; } if (name && channel.name == name) { - return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence }; + return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence, buildAirgapAutomatically: channel.buildAirgapAutomatically }; } } return Promise.reject({ channel: null, reason: `Could not find channel with slug ${slug} or name ${name}` }); diff --git a/src/index.ts b/src/index.ts index 29ff0b6..a6b47ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,4 @@ export { getApplicationDetails } from "./applications"; export { Channel, createChannel, getChannelDetails, archiveChannel } from "./channels"; export { ClusterVersion, createCluster, createClusterWithLicense, pollForStatus, getKubeconfig, removeCluster, upgradeCluster, getClusterVersions, createAddonObjectStore, pollForAddonStatus, exposeClusterPort } from "./clusters"; export { KubernetesDistribution, archiveCustomer, createCustomer, getUsedKubernetesDistributions } from "./customers"; -export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult } from "./releases"; +export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult, pollForAirgapReleaseStatus } from "./releases"; From 1f589c87d1e4f8213bd401a130aa883ecd146e82 Mon Sep 17 00:00:00 2001 From: Gerard Nguyen Date: Wed, 5 Mar 2025 16:59:00 +1100 Subject: [PATCH 03/13] run make prettier --- src/releases.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/releases.ts b/src/releases.ts index 17397ef..0286247 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -360,9 +360,9 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp const iterations = (timeout * 1000) / sleeptimeMs; for (let i = 0; i < iterations; i++) { try { - const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); - if (release.airgapBuildStatus === expectedStatus) { - return release.airgapBuildStatus; + const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); + if (release.airgapBuildStatus === expectedStatus) { + return release.airgapBuildStatus; } if (release.airgapBuildStatus === "failed") { console.debug(`Airgapped build release ${releaseSequence} failed`); @@ -386,7 +386,6 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp } } - async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { const http = await vendorPortalApi.client(); const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; @@ -401,6 +400,6 @@ async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: st const release = body.releases.find((r: any) => r.sequence === releaseSequence); return { sequence: release.sequence, - airgapBuildStatus: release.airgapBuildStatus, - } + airgapBuildStatus: release.airgapBuildStatus + }; } From c60f50e6eb006e36448591a04cbab175fcd17df3 Mon Sep 17 00:00:00 2001 From: Gerard Nguyen Date: Wed, 5 Mar 2025 17:49:52 +1100 Subject: [PATCH 04/13] update action text --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e732bc3..0e254a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: fail if files changed run: | if ! git diff --quiet --exit-code ; then - echo "Please run 'make package-all' and 'make readme-all' locally and commit the changes." + echo "Please run 'make build' and 'make Prettier' locally and commit the changes." exit 1 fi From f09e1e4c4af2cb4b2b90533aa7b6de4beede07e5 Mon Sep 17 00:00:00 2001 From: Gerard Nguyen Date: Thu, 6 Mar 2025 11:07:06 +1100 Subject: [PATCH 05/13] * wait when hit 500 * throw error when timedout --- .github/workflows/ci.yml | 2 +- src/releases.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e254a6..6576fd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: fail if files changed run: | if ! git diff --quiet --exit-code ; then - echo "Please run 'make build' and 'make Prettier' locally and commit the changes." + echo "Please run 'make build' and 'make prettier' locally and commit the changes." exit 1 fi diff --git a/src/releases.ts b/src/releases.ts index 0286247..fbb1850 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -375,6 +375,7 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp if (err.statusCode >= 500) { // 5xx errors are likely transient, so we should retry console.debug(`Got HTTP error with status ${err.statusCode}, sleeping for ${sleeptimeMs / 1000} seconds`); + await new Promise(f => setTimeout(f, sleeptimeMs)); } else { console.debug(`Got HTTP error with status ${err.statusCode}, exiting`); throw err; @@ -384,6 +385,7 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp } } } + throw new Error(`Airgapped build release ${releaseSequence} did not reach status ${expectedStatus} in ${timeout} seconds`); } async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { From 264a65104b35ceca493a771105ead993316d013c Mon Sep 17 00:00:00 2001 From: Gerard Nguyen Date: Thu, 6 Mar 2025 11:50:43 +1100 Subject: [PATCH 06/13] add examples --- examples/poll-for-airgap-build.ts | 91 +++++++++++++++++++++++++++++++ package.json | 3 +- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 examples/poll-for-airgap-build.ts diff --git a/examples/poll-for-airgap-build.ts b/examples/poll-for-airgap-build.ts new file mode 100644 index 0000000..0d4ba97 --- /dev/null +++ b/examples/poll-for-airgap-build.ts @@ -0,0 +1,91 @@ +// Example script to test the pollForAirgapReleaseStatus function +// Usage: node poll-for-airgap-build.js + +import { VendorPortalApi } from "../dist/configuration"; +import { pollForAirgapReleaseStatus } from "../dist/releases"; +import * as readline from 'readline'; + +// Function to get input from the user +async function getUserInput(prompt: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +async function main() { + try { + // Initialize the API client + const api = new VendorPortalApi(); + + // Get API token from environment variable + api.apiToken = process.env.REPLICATED_API_TOKEN || ""; + + if (!api.apiToken) { + throw new Error("REPLICATED_API_TOKEN environment variable is not set"); + } + + // Get parameters from command line arguments or prompt for them + let appId = process.argv[2]; + let channelId = process.argv[3]; + let releaseSequence = process.argv[4] ? parseInt(process.argv[4]) : undefined; + let expectedStatus = process.argv[5]; + + // If any parameters are missing, prompt for them + if (!appId) { + appId = await getUserInput("Enter Application ID: "); + } + + if (!channelId) { + channelId = await getUserInput("Enter Channel ID: "); + } + + if (!releaseSequence) { + const sequenceStr = await getUserInput("Enter Release Sequence: "); + releaseSequence = parseInt(sequenceStr); + } + + if (!expectedStatus) { + expectedStatus = await getUserInput("Enter Expected Status (e.g., 'built', 'warn', 'metadata'): "); + } + + // Validate inputs + if (isNaN(releaseSequence)) { + throw new Error("Release Sequence must be a number"); + } + + console.log(`\nPolling for airgap release status with the following parameters:`); + console.log(`- Application ID: ${appId}`); + console.log(`- Channel ID: ${channelId}`); + console.log(`- Release Sequence: ${releaseSequence}`); + console.log(`- Expected Status: ${expectedStatus}`); + console.log(`\nThis will poll until the release reaches the expected status or times out.`); + + console.log("\nStarting to poll for airgap release status..."); + + const status = await pollForAirgapReleaseStatus( + api, + appId, + channelId, + releaseSequence, + expectedStatus, + 60, // 1 minute timeout + 1000 // 1 second polling interval + ); + + console.log(`\nSuccess! Release ${releaseSequence} has reached status: ${status}`); + } catch (error) { + console.error(`\nError: ${error.message}`); + process.exit(1); + } +} + +// Run the main function +main(); diff --git a/package.json b/package.json index 934700a..0c898e8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "test": "npx jest --coverage --verbose --setupFiles ./pacts/configuration.ts", "create-object-store": "rm -rf examples/*.js && tsc examples/create-object-store.ts && node examples/create-object-store.js", "create-postgres": "rm -rf examples/*.js && tsc examples/create-postgres.ts && node examples/create-postgres.js", - "expose-port": "rm -rf examples/*.js && tsc examples/expose-port.ts && node examples/expose-port.js" + "expose-port": "rm -rf examples/*.js && tsc examples/expose-port.ts && node examples/expose-port.js", + "poll-airgap": "rm -rf examples/*.js && tsc examples/poll-for-airgap-build.ts && node examples/poll-for-airgap-build.js" }, "main": "dist/index.js", "types": "dist/index.d.ts", From 230693d9e6089b3ab335bead2200918ff323da03 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Tue, 11 Mar 2025 17:55:16 +1300 Subject: [PATCH 07/13] remove console debug printing the whole body of resp --- src/releases.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/releases.ts b/src/releases.ts index fbb1850..bcf7ebb 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -398,7 +398,6 @@ async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: st throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`); } const body: any = JSON.parse(await res.readBody()); - console.debug(`Airgapped build release body: ${JSON.stringify(body)}`); const release = body.releases.find((r: any) => r.sequence === releaseSequence); return { sequence: release.sequence, From 731dacb7bb1ee0f04d6069fb4c7f303bebb0bb32 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Fri, 14 Mar 2025 17:16:08 +1300 Subject: [PATCH 08/13] move airgap pull to channel.ts --- examples/poll-for-airgap-build.ts | 2 +- src/channels.spec.ts | 30 ++++++++++++++- src/channels.ts | 63 +++++++++++++++++++++++++++++++ src/index.ts | 4 +- src/releases.spec.ts | 29 +------------- src/releases.ts | 62 ------------------------------ 6 files changed, 96 insertions(+), 94 deletions(-) diff --git a/examples/poll-for-airgap-build.ts b/examples/poll-for-airgap-build.ts index 0d4ba97..658e92a 100644 --- a/examples/poll-for-airgap-build.ts +++ b/examples/poll-for-airgap-build.ts @@ -2,7 +2,7 @@ // Usage: node poll-for-airgap-build.js import { VendorPortalApi } from "../dist/configuration"; -import { pollForAirgapReleaseStatus } from "../dist/releases"; +import { pollForAirgapReleaseStatus } from "../dist/channels"; import * as readline from 'readline'; // Function to get input from the user diff --git a/src/channels.spec.ts b/src/channels.spec.ts index 1bfa592..17d0983 100644 --- a/src/channels.spec.ts +++ b/src/channels.spec.ts @@ -1,6 +1,7 @@ import { Interaction } from "@pact-foundation/pact"; -import { exportedForTesting } from "./channels"; +import { exportedForTesting, pollForAirgapReleaseStatus } from "./channels"; import { VendorPortalApi } from "./configuration"; +import * as mockttp from "mockttp"; const getChannelByApplicationId = exportedForTesting.getChannelByApplicationId; const findChannelDetailsInOutput = exportedForTesting.findChannelDetailsInOutput; @@ -81,3 +82,30 @@ describe("ChannelsService", () => { }); }); }); + +describe("pollForAirgapReleaseStatus", () => { + const mockServer = mockttp.getLocal(); + const apiClient = new VendorPortalApi(); + apiClient.apiToken = "abcd1234"; + apiClient.endpoint = "http://localhost:8080"; + // Start your mock server + beforeEach(() => { + mockServer.start(8080); + }); + afterEach(() => mockServer.stop()); + + it("poll for airgapped release status", async () => { + const data = { + releases: [ + { + sequence: 0, + airgapBuildStatus: "built" + } + ] + }; + await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(data)); + + const result = await pollForAirgapReleaseStatus(apiClient, "1234abcd", "1", 0, "built"); + expect(result).toEqual("built"); + }); +}); diff --git a/src/channels.ts b/src/channels.ts index 61f0df2..2fd7611 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -1,5 +1,6 @@ import { getApplicationDetails } from "./applications"; import { VendorPortalApi } from "./configuration"; +import { Release } from "./releases"; export class Channel { name: string; @@ -9,6 +10,15 @@ export class Channel { buildAirgapAutomatically?: boolean; } +export class StatusError extends Error { + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + export const exportedForTesting = { getChannelByApplicationId, findChannelDetailsInOutput @@ -107,3 +117,56 @@ async function findChannelDetailsInOutput(channels: any[], { slug, name }: Chann } return Promise.reject({ channel: null, reason: `Could not find channel with slug ${slug} or name ${name}` }); } + +export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number, expectedStatus: string, timeout: number = 120, sleeptimeMs: number = 5000): Promise { + // get airgapped build release from the api, look for the status of the id to be ${status} + // if it's not ${status}, sleep for 5 seconds and try again + // if it is ${status}, return the release with that status + // iterate for timeout/sleeptime times + const iterations = (timeout * 1000) / sleeptimeMs; + for (let i = 0; i < iterations; i++) { + try { + const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); + if (release.airgapBuildStatus === expectedStatus) { + return release.airgapBuildStatus; + } + if (release.airgapBuildStatus === "failed") { + console.debug(`Airgapped build release ${releaseSequence} failed`); + return "failed"; + } + console.debug(`Airgapped build release ${releaseSequence} is not ready, sleeping for ${sleeptimeMs / 1000} seconds`); + await new Promise(f => setTimeout(f, sleeptimeMs)); + } catch (err) { + if (err instanceof StatusError) { + if (err.statusCode >= 500) { + // 5xx errors are likely transient, so we should retry + console.debug(`Got HTTP error with status ${err.statusCode}, sleeping for ${sleeptimeMs / 1000} seconds`); + await new Promise(f => setTimeout(f, sleeptimeMs)); + } else { + console.debug(`Got HTTP error with status ${err.statusCode}, exiting`); + throw err; + } + } else { + throw err; + } + } + } + throw new Error(`Airgapped build release ${releaseSequence} did not reach status ${expectedStatus} in ${timeout} seconds`); +} + +async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { + const http = await vendorPortalApi.client(); + const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; + const res = await http.get(uri); + if (res.message.statusCode != 200) { + // discard the response body + await res.readBody(); + throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`); + } + const body: any = JSON.parse(await res.readBody()); + const release = body.releases.find((r: any) => r.sequence === releaseSequence); + return { + sequence: release.sequence, + airgapBuildStatus: release.airgapBuildStatus + }; +} diff --git a/src/index.ts b/src/index.ts index a6b47ad..9092ba0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { VendorPortalApi } from "./configuration"; export { getApplicationDetails } from "./applications"; -export { Channel, createChannel, getChannelDetails, archiveChannel } from "./channels"; +export { Channel, createChannel, getChannelDetails, archiveChannel, pollForAirgapReleaseStatus } from "./channels"; export { ClusterVersion, createCluster, createClusterWithLicense, pollForStatus, getKubeconfig, removeCluster, upgradeCluster, getClusterVersions, createAddonObjectStore, pollForAddonStatus, exposeClusterPort } from "./clusters"; export { KubernetesDistribution, archiveCustomer, createCustomer, getUsedKubernetesDistributions } from "./customers"; -export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult, pollForAirgapReleaseStatus } from "./releases"; +export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult } from "./releases"; diff --git a/src/releases.spec.ts b/src/releases.spec.ts index e921851..6d74812 100644 --- a/src/releases.spec.ts +++ b/src/releases.spec.ts @@ -1,5 +1,5 @@ import { VendorPortalApi } from "./configuration"; -import { ReleaseChart, exportedForTesting, KotsSingleSpec, createReleaseFromChart, Release, CompatibilityResult, pollForAirgapReleaseStatus } from "./releases"; +import { ReleaseChart, exportedForTesting, KotsSingleSpec, createReleaseFromChart, Release, CompatibilityResult } from "./releases"; import * as mockttp from "mockttp"; import * as fs from "fs-extra"; import * as path from "path"; @@ -298,30 +298,3 @@ describe("createReleaseFromChart", () => { expect(release.charts?.length).toEqual(1); }); }); - -describe("pollForAirgapReleaseStatus", () => { - const mockServer = mockttp.getLocal(); - const apiClient = new VendorPortalApi(); - apiClient.apiToken = "abcd1234"; - apiClient.endpoint = "http://localhost:8080"; - // Start your mock server - beforeEach(() => { - mockServer.start(8080); - }); - afterEach(() => mockServer.stop()); - - it("poll for airgapped release status", async () => { - const data = { - releases: [ - { - sequence: 0, - airgapBuildStatus: "built" - } - ] - }; - await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(data)); - - const result = await pollForAirgapReleaseStatus(apiClient, "1234abcd", "1", 0, "built"); - expect(result).toEqual("built"); - }); -}); diff --git a/src/releases.ts b/src/releases.ts index bcf7ebb..2a8f5ea 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -46,15 +46,6 @@ export const exportedForTesting = { reportCompatibilityResultByAppId }; -export class StatusError extends Error { - statusCode: number; - - constructor(message: string, statusCode: number) { - super(message); - this.statusCode = statusCode; - } -} - export async function createRelease(vendorPortalApi: VendorPortalApi, appSlug: string, yamlDir: string): Promise { const http = await vendorPortalApi.client(); @@ -351,56 +342,3 @@ async function reportCompatibilityResultByAppId(vendorPortalApi: VendorPortalApi // discard the response body await res.readBody(); } - -export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number, expectedStatus: string, timeout: number = 120, sleeptimeMs: number = 5000): Promise { - // get airgapped build release from the api, look for the status of the id to be ${status} - // if it's not ${status}, sleep for 5 seconds and try again - // if it is ${status}, return the release with that status - // iterate for timeout/sleeptime times - const iterations = (timeout * 1000) / sleeptimeMs; - for (let i = 0; i < iterations; i++) { - try { - const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); - if (release.airgapBuildStatus === expectedStatus) { - return release.airgapBuildStatus; - } - if (release.airgapBuildStatus === "failed") { - console.debug(`Airgapped build release ${releaseSequence} failed`); - return "failed"; - } - console.debug(`Airgapped build release ${releaseSequence} is not ready, sleeping for ${sleeptimeMs / 1000} seconds`); - await new Promise(f => setTimeout(f, sleeptimeMs)); - } catch (err) { - if (err instanceof StatusError) { - if (err.statusCode >= 500) { - // 5xx errors are likely transient, so we should retry - console.debug(`Got HTTP error with status ${err.statusCode}, sleeping for ${sleeptimeMs / 1000} seconds`); - await new Promise(f => setTimeout(f, sleeptimeMs)); - } else { - console.debug(`Got HTTP error with status ${err.statusCode}, exiting`); - throw err; - } - } else { - throw err; - } - } - } - throw new Error(`Airgapped build release ${releaseSequence} did not reach status ${expectedStatus} in ${timeout} seconds`); -} - -async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { - const http = await vendorPortalApi.client(); - const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; - const res = await http.get(uri); - if (res.message.statusCode != 200) { - // discard the response body - await res.readBody(); - throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`); - } - const body: any = JSON.parse(await res.readBody()); - const release = body.releases.find((r: any) => r.sequence === releaseSequence); - return { - sequence: release.sequence, - airgapBuildStatus: release.airgapBuildStatus - }; -} From c52de2ea34081bf1b940082dc9b6e6356d663817 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Mon, 17 Mar 2025 18:15:41 +1300 Subject: [PATCH 09/13] add download url and channelSequence --- examples/poll-for-airgap-build.ts | 8 +++++- src/channels.spec.ts | 47 +++++++++++++++++++++++++++---- src/channels.ts | 18 ++++++++++++ src/index.ts | 2 +- src/releases.ts | 1 + 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/examples/poll-for-airgap-build.ts b/examples/poll-for-airgap-build.ts index 658e92a..63264a1 100644 --- a/examples/poll-for-airgap-build.ts +++ b/examples/poll-for-airgap-build.ts @@ -2,7 +2,7 @@ // Usage: node poll-for-airgap-build.js import { VendorPortalApi } from "../dist/configuration"; -import { pollForAirgapReleaseStatus } from "../dist/channels"; +import { pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "../dist/channels"; import * as readline from 'readline'; // Function to get input from the user @@ -81,6 +81,12 @@ async function main() { ); console.log(`\nSuccess! Release ${releaseSequence} has reached status: ${status}`); + + if (status === "built") { + const downloadUrl = await getDownloadUrlAirgapBuildRelease(api, appId, channelId, releaseSequence); + console.log(`\nDownload URL: ${downloadUrl}`); + } + } catch (error) { console.error(`\nError: ${error.message}`); process.exit(1); diff --git a/src/channels.spec.ts b/src/channels.spec.ts index 17d0983..808e8b0 100644 --- a/src/channels.spec.ts +++ b/src/channels.spec.ts @@ -1,5 +1,5 @@ import { Interaction } from "@pact-foundation/pact"; -import { exportedForTesting, pollForAirgapReleaseStatus } from "./channels"; +import { exportedForTesting, pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "./channels"; import { VendorPortalApi } from "./configuration"; import * as mockttp from "mockttp"; @@ -94,18 +94,53 @@ describe("pollForAirgapReleaseStatus", () => { }); afterEach(() => mockServer.stop()); - it("poll for airgapped release status", async () => { - const data = { + it("should poll for airgapped release status until it reaches the expected status", async () => { + const releaseData = { releases: [ { sequence: 0, + channelSequence: 1, airgapBuildStatus: "built" } ] }; - await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(data)); - const result = await pollForAirgapReleaseStatus(apiClient, "1234abcd", "1", 0, "built"); - expect(result).toEqual("built"); + await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(releaseData)); + + const releaseResult = await pollForAirgapReleaseStatus(apiClient, "1234abcd", "1", 0, "built"); + expect(releaseResult).toEqual("built"); + }); +}); + +describe("getDownloadUrlAirgapBuildRelease", () => { + const mockServer = mockttp.getLocal(); + const apiClient = new VendorPortalApi(); + apiClient.apiToken = "abcd1234"; + apiClient.endpoint = "http://localhost:8081"; + // Start your mock server + beforeEach(() => { + mockServer.start(8081); + }); + afterEach(() => mockServer.stop()); + + it("should get the download URL for an airgap build release", async () => { + const releaseData = { + releases: [ + { + sequence: 0, + channelSequence: 1, + airgapBuildStatus: "built" + } + ] + }; + const downloadUrlData = { + url: "https://s3.amazonaws.com/airgap.replicated.com/xxxxxxxxx/7.airgap?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=xxxxxx%2F20250317%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=" + }; + + await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(releaseData)); + await mockServer.forGet("/app/1234abcd/channel/1/airgap/download-url").withQuery({ channelSequence: 1 }).thenReply(200, JSON.stringify(downloadUrlData)); + + const downloadUrlResult = await getDownloadUrlAirgapBuildRelease(apiClient, "1234abcd", "1", 0); + expect(downloadUrlResult).toEqual("https://s3.amazonaws.com/airgap.replicated.com/xxxxxxxxx/7.airgap?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=xxxxxx%2F20250317%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date="); }); }); diff --git a/src/channels.ts b/src/channels.ts index 2fd7611..68235e7 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -116,6 +116,7 @@ async function findChannelDetailsInOutput(channels: any[], { slug, name }: Chann } } return Promise.reject({ channel: null, reason: `Could not find channel with slug ${slug} or name ${name}` }); + } export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number, expectedStatus: string, timeout: number = 120, sleeptimeMs: number = 5000): Promise { @@ -154,6 +155,22 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp throw new Error(`Airgapped build release ${releaseSequence} did not reach status ${expectedStatus} in ${timeout} seconds`); } +export async function getDownloadUrlAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { + const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); + const http = await vendorPortalApi.client(); + const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/airgap/download-url?channelSequence=${release.channelSequence}`; + console.log(`Getting download url for airgapped build release ${releaseSequence} from ${uri}`); + const res = await http.get(uri); + + if (res.message.statusCode != 200) { + // discard the response body + await res.readBody(); + throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`); + } + const body: any = JSON.parse(await res.readBody()); + return body.url; +} + async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { const http = await vendorPortalApi.client(); const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; @@ -167,6 +184,7 @@ async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: st const release = body.releases.find((r: any) => r.sequence === releaseSequence); return { sequence: release.sequence, + channelSequence: release.channelSequence, airgapBuildStatus: release.airgapBuildStatus }; } diff --git a/src/index.ts b/src/index.ts index 9092ba0..d33d5db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { VendorPortalApi } from "./configuration"; export { getApplicationDetails } from "./applications"; -export { Channel, createChannel, getChannelDetails, archiveChannel, pollForAirgapReleaseStatus } from "./channels"; +export { Channel, createChannel, getChannelDetails, archiveChannel, pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "./channels"; export { ClusterVersion, createCluster, createClusterWithLicense, pollForStatus, getKubeconfig, removeCluster, upgradeCluster, getClusterVersions, createAddonObjectStore, pollForAddonStatus, exposeClusterPort } from "./clusters"; export { KubernetesDistribution, archiveCustomer, createCustomer, getUsedKubernetesDistributions } from "./customers"; export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult } from "./releases"; diff --git a/src/releases.ts b/src/releases.ts index 2a8f5ea..753d425 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -10,6 +10,7 @@ import { zonedTimeToUtc } from "date-fns-tz"; export interface Release { sequence: string; + channelSequence?: string; charts?: ReleaseChart[]; airgapBuildStatus?: string; } From b87bfd65a01e4327384d30c80d8c30c3b0ccbf8a Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Mon, 17 Mar 2025 18:16:08 +1300 Subject: [PATCH 10/13] prettier --- src/channels.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/channels.ts b/src/channels.ts index 68235e7..24121f9 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -116,7 +116,6 @@ async function findChannelDetailsInOutput(channels: any[], { slug, name }: Chann } } return Promise.reject({ channel: null, reason: `Could not find channel with slug ${slug} or name ${name}` }); - } export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number, expectedStatus: string, timeout: number = 120, sleeptimeMs: number = 5000): Promise { From d24ada6803e9291deee837a270037d3efd195ad2 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Tue, 18 Mar 2025 16:07:24 +1300 Subject: [PATCH 11/13] rename to promotedChannelSequence --- src/channels.ts | 5 ++--- src/releases.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/channels.ts b/src/channels.ts index 24121f9..f7d6288 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -157,8 +157,7 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp export async function getDownloadUrlAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); const http = await vendorPortalApi.client(); - const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/airgap/download-url?channelSequence=${release.channelSequence}`; - console.log(`Getting download url for airgapped build release ${releaseSequence} from ${uri}`); + const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/airgap/download-url?channelSequence=${release.promotedChannelSequence}`; const res = await http.get(uri); if (res.message.statusCode != 200) { @@ -183,7 +182,7 @@ async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: st const release = body.releases.find((r: any) => r.sequence === releaseSequence); return { sequence: release.sequence, - channelSequence: release.channelSequence, + promotedChannelSequence: release.channelSequence, airgapBuildStatus: release.airgapBuildStatus }; } diff --git a/src/releases.ts b/src/releases.ts index 753d425..1d27982 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -10,7 +10,7 @@ import { zonedTimeToUtc } from "date-fns-tz"; export interface Release { sequence: string; - channelSequence?: string; + promotedChannelSequence?: string; charts?: ReleaseChart[]; airgapBuildStatus?: string; } From 4d4e9605903dd03f2e40dae6fc1aabb28618f920 Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Wed, 19 Mar 2025 10:02:15 +1300 Subject: [PATCH 12/13] add AirgapBuildRelease --- src/channels.ts | 4 ++-- src/releases.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/channels.ts b/src/channels.ts index f7d6288..60b996c 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -1,6 +1,6 @@ import { getApplicationDetails } from "./applications"; import { VendorPortalApi } from "./configuration"; -import { Release } from "./releases"; +import { AirgapBuildRelease } from "./releases"; export class Channel { name: string; @@ -169,7 +169,7 @@ export async function getDownloadUrlAirgapBuildRelease(vendorPortalApi: VendorPo return body.url; } -async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { +async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { const http = await vendorPortalApi.client(); const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; const res = await http.get(uri); diff --git a/src/releases.ts b/src/releases.ts index 1d27982..24127f8 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -10,8 +10,12 @@ import { zonedTimeToUtc } from "date-fns-tz"; export interface Release { sequence: string; - promotedChannelSequence?: string; charts?: ReleaseChart[]; +} + +export interface AirgapBuildRelease { + sequence: string; + promotedChannelSequence?: string; airgapBuildStatus?: string; } From 6f527c812f2e43e4dbfe1a475da595ee123ca8ed Mon Sep 17 00:00:00 2001 From: Dexter Yan Date: Wed, 19 Mar 2025 11:05:57 +1300 Subject: [PATCH 13/13] create ChannelRelease --- src/channels.ts | 12 ++++++++---- src/releases.ts | 6 ------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/channels.ts b/src/channels.ts index 60b996c..238958e 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -1,6 +1,10 @@ import { getApplicationDetails } from "./applications"; import { VendorPortalApi } from "./configuration"; -import { AirgapBuildRelease } from "./releases"; +export interface ChannelRelease { + sequence: string; + channelSequence?: string; + airgapBuildStatus?: string; +} export class Channel { name: string; @@ -157,7 +161,7 @@ export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalAp export async function getDownloadUrlAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence); const http = await vendorPortalApi.client(); - const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/airgap/download-url?channelSequence=${release.promotedChannelSequence}`; + const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/airgap/download-url?channelSequence=${release.channelSequence}`; const res = await http.get(uri); if (res.message.statusCode != 200) { @@ -169,7 +173,7 @@ export async function getDownloadUrlAirgapBuildRelease(vendorPortalApi: VendorPo return body.url; } -async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { +async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise { const http = await vendorPortalApi.client(); const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`; const res = await http.get(uri); @@ -182,7 +186,7 @@ async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: st const release = body.releases.find((r: any) => r.sequence === releaseSequence); return { sequence: release.sequence, - promotedChannelSequence: release.channelSequence, + channelSequence: release.channelSequence, airgapBuildStatus: release.airgapBuildStatus }; } diff --git a/src/releases.ts b/src/releases.ts index 24127f8..fae7e94 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -13,12 +13,6 @@ export interface Release { charts?: ReleaseChart[]; } -export interface AirgapBuildRelease { - sequence: string; - promotedChannelSequence?: string; - airgapBuildStatus?: string; -} - export interface ReleaseChart { name: string; version: string;