diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e732bc3..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 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 diff --git a/examples/poll-for-airgap-build.ts b/examples/poll-for-airgap-build.ts new file mode 100644 index 0000000..63264a1 --- /dev/null +++ b/examples/poll-for-airgap-build.ts @@ -0,0 +1,97 @@ +// Example script to test the pollForAirgapReleaseStatus function +// Usage: node poll-for-airgap-build.js + +import { VendorPortalApi } from "../dist/configuration"; +import { pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "../dist/channels"; +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}`); + + 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); + } +} + +// 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", 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..808e8b0 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, getDownloadUrlAirgapBuildRelease } from "./channels"; import { VendorPortalApi } from "./configuration"; +import * as mockttp from "mockttp"; const getChannelByApplicationId = exportedForTesting.getChannelByApplicationId; const findChannelDetailsInOutput = exportedForTesting.findChannelDetailsInOutput; @@ -15,7 +16,8 @@ describe("findChannelDetailsInOutput", () => { appName: "relmatrix", channelSlug: "stable", name: "Stable", - releaseSequence: 1 + releaseSequence: 1, + buildAirgapAutomatically: true }, { id: "channelid2", @@ -24,7 +26,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 +44,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 } ] }; @@ -79,3 +82,65 @@ 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("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(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 4b105f1..238958e 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -1,11 +1,26 @@ import { getApplicationDetails } from "./applications"; import { VendorPortalApi } from "./configuration"; +export interface ChannelRelease { + sequence: string; + channelSequence?: string; + airgapBuildStatus?: string; +} export class Channel { name: string; id: string; slug: string; releaseSequence?: number; + buildAirgapAutomatically?: boolean; +} + +export class StatusError extends Error { + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } } export const exportedForTesting = { @@ -98,11 +113,80 @@ 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}` }); } + +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`); +} + +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}`; + 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`; + 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, + channelSequence: release.channelSequence, + airgapBuildStatus: release.airgapBuildStatus + }; +} diff --git a/src/index.ts b/src/index.ts index 29ff0b6..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 } 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";