Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions examples/poll-for-airgap-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Example script to test the pollForAirgapReleaseStatus function
// Usage: node poll-for-airgap-build.js <appId> <channelId> <releaseSequence> <expectedStatus>

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<string> {
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();
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions pacts/npm_consumer-vp_service.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
"body": {
"channels": [
{
"buildAirgapAutomatically": true,
"channelSlug": "stable",
"id": "1234abcd",
"name": "Stable",
"releaseSequence": 1
},
{
"buildAirgapAutomatically": false,
"channelSlug": "beta",
"id": "5678efgh",
"name": "Beta",
Expand Down
75 changes: 70 additions & 5 deletions src/channels.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +16,8 @@ describe("findChannelDetailsInOutput", () => {
appName: "relmatrix",
channelSlug: "stable",
name: "Stable",
releaseSequence: 1
releaseSequence: 1,
buildAirgapAutomatically: true
},
{
id: "channelid2",
Expand All @@ -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";
Expand All @@ -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 }
]
};

Expand Down Expand Up @@ -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=");
});
});
88 changes: 86 additions & 2 deletions src/channels.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -98,11 +113,80 @@ export async function archiveChannel(vendorPortalApi: VendorPortalApi, appSlug:
async function findChannelDetailsInOutput(channels: any[], { slug, name }: ChannelIdentifier): Promise<Channel> {
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<string> {
// 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<string> {
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<ChannelRelease> {
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
};
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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";