Skip to content

Commit e01682d

Browse files
DexterYannvanthao
andauthored
feat(release): add polling status for airgap build release (#53)
* feat(release): add polling for get airgap build status --------- Co-authored-by: Gerard Nguyen <[email protected]>
1 parent a8eda32 commit e01682d

File tree

7 files changed

+259
-10
lines changed

7 files changed

+259
-10
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: fail if files changed
2626
run: |
2727
if ! git diff --quiet --exit-code ; then
28-
echo "Please run 'make package-all' and 'make readme-all' locally and commit the changes."
28+
echo "Please run 'make build' and 'make prettier' locally and commit the changes."
2929
exit 1
3030
fi
3131

examples/poll-for-airgap-build.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Example script to test the pollForAirgapReleaseStatus function
2+
// Usage: node poll-for-airgap-build.js <appId> <channelId> <releaseSequence> <expectedStatus>
3+
4+
import { VendorPortalApi } from "../dist/configuration";
5+
import { pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "../dist/channels";
6+
import * as readline from 'readline';
7+
8+
// Function to get input from the user
9+
async function getUserInput(prompt: string): Promise<string> {
10+
const rl = readline.createInterface({
11+
input: process.stdin,
12+
output: process.stdout
13+
});
14+
15+
return new Promise((resolve) => {
16+
rl.question(prompt, (answer) => {
17+
rl.close();
18+
resolve(answer);
19+
});
20+
});
21+
}
22+
23+
async function main() {
24+
try {
25+
// Initialize the API client
26+
const api = new VendorPortalApi();
27+
28+
// Get API token from environment variable
29+
api.apiToken = process.env.REPLICATED_API_TOKEN || "";
30+
31+
if (!api.apiToken) {
32+
throw new Error("REPLICATED_API_TOKEN environment variable is not set");
33+
}
34+
35+
// Get parameters from command line arguments or prompt for them
36+
let appId = process.argv[2];
37+
let channelId = process.argv[3];
38+
let releaseSequence = process.argv[4] ? parseInt(process.argv[4]) : undefined;
39+
let expectedStatus = process.argv[5];
40+
41+
// If any parameters are missing, prompt for them
42+
if (!appId) {
43+
appId = await getUserInput("Enter Application ID: ");
44+
}
45+
46+
if (!channelId) {
47+
channelId = await getUserInput("Enter Channel ID: ");
48+
}
49+
50+
if (!releaseSequence) {
51+
const sequenceStr = await getUserInput("Enter Release Sequence: ");
52+
releaseSequence = parseInt(sequenceStr);
53+
}
54+
55+
if (!expectedStatus) {
56+
expectedStatus = await getUserInput("Enter Expected Status (e.g., 'built', 'warn', 'metadata'): ");
57+
}
58+
59+
// Validate inputs
60+
if (isNaN(releaseSequence)) {
61+
throw new Error("Release Sequence must be a number");
62+
}
63+
64+
console.log(`\nPolling for airgap release status with the following parameters:`);
65+
console.log(`- Application ID: ${appId}`);
66+
console.log(`- Channel ID: ${channelId}`);
67+
console.log(`- Release Sequence: ${releaseSequence}`);
68+
console.log(`- Expected Status: ${expectedStatus}`);
69+
console.log(`\nThis will poll until the release reaches the expected status or times out.`);
70+
71+
console.log("\nStarting to poll for airgap release status...");
72+
73+
const status = await pollForAirgapReleaseStatus(
74+
api,
75+
appId,
76+
channelId,
77+
releaseSequence,
78+
expectedStatus,
79+
60, // 1 minute timeout
80+
1000 // 1 second polling interval
81+
);
82+
83+
console.log(`\nSuccess! Release ${releaseSequence} has reached status: ${status}`);
84+
85+
if (status === "built") {
86+
const downloadUrl = await getDownloadUrlAirgapBuildRelease(api, appId, channelId, releaseSequence);
87+
console.log(`\nDownload URL: ${downloadUrl}`);
88+
}
89+
90+
} catch (error) {
91+
console.error(`\nError: ${error.message}`);
92+
process.exit(1);
93+
}
94+
}
95+
96+
// Run the main function
97+
main();

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"test": "npx jest --coverage --verbose --setupFiles ./pacts/configuration.ts",
99
"create-object-store": "rm -rf examples/*.js && tsc examples/create-object-store.ts && node examples/create-object-store.js",
1010
"create-postgres": "rm -rf examples/*.js && tsc examples/create-postgres.ts && node examples/create-postgres.js",
11-
"expose-port": "rm -rf examples/*.js && tsc examples/expose-port.ts && node examples/expose-port.js"
11+
"expose-port": "rm -rf examples/*.js && tsc examples/expose-port.ts && node examples/expose-port.js",
12+
"poll-airgap": "rm -rf examples/*.js && tsc examples/poll-for-airgap-build.ts && node examples/poll-for-airgap-build.js"
1213
},
1314
"main": "dist/index.js",
1415
"types": "dist/index.d.ts",

pacts/npm_consumer-vp_service.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
"body": {
1919
"channels": [
2020
{
21+
"buildAirgapAutomatically": true,
2122
"channelSlug": "stable",
2223
"id": "1234abcd",
2324
"name": "Stable",
2425
"releaseSequence": 1
2526
},
2627
{
28+
"buildAirgapAutomatically": false,
2729
"channelSlug": "beta",
2830
"id": "5678efgh",
2931
"name": "Beta",

src/channels.spec.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Interaction } from "@pact-foundation/pact";
2-
import { exportedForTesting } from "./channels";
2+
import { exportedForTesting, pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "./channels";
33
import { VendorPortalApi } from "./configuration";
4+
import * as mockttp from "mockttp";
45

56
const getChannelByApplicationId = exportedForTesting.getChannelByApplicationId;
67
const findChannelDetailsInOutput = exportedForTesting.findChannelDetailsInOutput;
@@ -15,7 +16,8 @@ describe("findChannelDetailsInOutput", () => {
1516
appName: "relmatrix",
1617
channelSlug: "stable",
1718
name: "Stable",
18-
releaseSequence: 1
19+
releaseSequence: 1,
20+
buildAirgapAutomatically: true
1921
},
2022
{
2123
id: "channelid2",
@@ -24,7 +26,8 @@ describe("findChannelDetailsInOutput", () => {
2426
appName: "relmatrix",
2527
channelSlug: "ci-reliability-matrix",
2628
name: "ci-reliability-matrix",
27-
releaseSequence: 2
29+
releaseSequence: 2,
30+
buildAirgapAutomatically: false
2831
}
2932
];
3033
const channelSlug = "ci-reliability-matrix";
@@ -41,8 +44,8 @@ describe("ChannelsService", () => {
4144
test("should return channel", () => {
4245
const expectedChannels = {
4346
channels: [
44-
{ id: "1234abcd", name: "Stable", channelSlug: "stable", releaseSequence: 1 },
45-
{ id: "5678efgh", name: "Beta", channelSlug: "beta", releaseSequence: 2 }
47+
{ id: "1234abcd", name: "Stable", channelSlug: "stable", releaseSequence: 1, buildAirgapAutomatically: true },
48+
{ id: "5678efgh", name: "Beta", channelSlug: "beta", releaseSequence: 2, buildAirgapAutomatically: false }
4649
]
4750
};
4851

@@ -79,3 +82,65 @@ describe("ChannelsService", () => {
7982
});
8083
});
8184
});
85+
86+
describe("pollForAirgapReleaseStatus", () => {
87+
const mockServer = mockttp.getLocal();
88+
const apiClient = new VendorPortalApi();
89+
apiClient.apiToken = "abcd1234";
90+
apiClient.endpoint = "http://localhost:8080";
91+
// Start your mock server
92+
beforeEach(() => {
93+
mockServer.start(8080);
94+
});
95+
afterEach(() => mockServer.stop());
96+
97+
it("should poll for airgapped release status until it reaches the expected status", async () => {
98+
const releaseData = {
99+
releases: [
100+
{
101+
sequence: 0,
102+
channelSequence: 1,
103+
airgapBuildStatus: "built"
104+
}
105+
]
106+
};
107+
108+
await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(releaseData));
109+
110+
const releaseResult = await pollForAirgapReleaseStatus(apiClient, "1234abcd", "1", 0, "built");
111+
expect(releaseResult).toEqual("built");
112+
});
113+
});
114+
115+
describe("getDownloadUrlAirgapBuildRelease", () => {
116+
const mockServer = mockttp.getLocal();
117+
const apiClient = new VendorPortalApi();
118+
apiClient.apiToken = "abcd1234";
119+
apiClient.endpoint = "http://localhost:8081";
120+
// Start your mock server
121+
beforeEach(() => {
122+
mockServer.start(8081);
123+
});
124+
afterEach(() => mockServer.stop());
125+
126+
it("should get the download URL for an airgap build release", async () => {
127+
const releaseData = {
128+
releases: [
129+
{
130+
sequence: 0,
131+
channelSequence: 1,
132+
airgapBuildStatus: "built"
133+
}
134+
]
135+
};
136+
const downloadUrlData = {
137+
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="
138+
};
139+
140+
await mockServer.forGet("/app/1234abcd/channel/1/releases").thenReply(200, JSON.stringify(releaseData));
141+
await mockServer.forGet("/app/1234abcd/channel/1/airgap/download-url").withQuery({ channelSequence: 1 }).thenReply(200, JSON.stringify(downloadUrlData));
142+
143+
const downloadUrlResult = await getDownloadUrlAirgapBuildRelease(apiClient, "1234abcd", "1", 0);
144+
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=");
145+
});
146+
});

src/channels.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
import { getApplicationDetails } from "./applications";
22
import { VendorPortalApi } from "./configuration";
3+
export interface ChannelRelease {
4+
sequence: string;
5+
channelSequence?: string;
6+
airgapBuildStatus?: string;
7+
}
38

49
export class Channel {
510
name: string;
611
id: string;
712
slug: string;
813
releaseSequence?: number;
14+
buildAirgapAutomatically?: boolean;
15+
}
16+
17+
export class StatusError extends Error {
18+
statusCode: number;
19+
20+
constructor(message: string, statusCode: number) {
21+
super(message);
22+
this.statusCode = statusCode;
23+
}
924
}
1025

1126
export const exportedForTesting = {
@@ -98,11 +113,80 @@ export async function archiveChannel(vendorPortalApi: VendorPortalApi, appSlug:
98113
async function findChannelDetailsInOutput(channels: any[], { slug, name }: ChannelIdentifier): Promise<Channel> {
99114
for (const channel of channels) {
100115
if (slug && channel.channelSlug == slug) {
101-
return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence };
116+
return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence, buildAirgapAutomatically: channel.buildAirgapAutomatically };
102117
}
103118
if (name && channel.name == name) {
104-
return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence };
119+
return { name: channel.name, id: channel.id, slug: channel.channelSlug, releaseSequence: channel.releaseSequence, buildAirgapAutomatically: channel.buildAirgapAutomatically };
105120
}
106121
}
107122
return Promise.reject({ channel: null, reason: `Could not find channel with slug ${slug} or name ${name}` });
108123
}
124+
125+
export async function pollForAirgapReleaseStatus(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number, expectedStatus: string, timeout: number = 120, sleeptimeMs: number = 5000): Promise<string> {
126+
// get airgapped build release from the api, look for the status of the id to be ${status}
127+
// if it's not ${status}, sleep for 5 seconds and try again
128+
// if it is ${status}, return the release with that status
129+
// iterate for timeout/sleeptime times
130+
const iterations = (timeout * 1000) / sleeptimeMs;
131+
for (let i = 0; i < iterations; i++) {
132+
try {
133+
const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence);
134+
if (release.airgapBuildStatus === expectedStatus) {
135+
return release.airgapBuildStatus;
136+
}
137+
if (release.airgapBuildStatus === "failed") {
138+
console.debug(`Airgapped build release ${releaseSequence} failed`);
139+
return "failed";
140+
}
141+
console.debug(`Airgapped build release ${releaseSequence} is not ready, sleeping for ${sleeptimeMs / 1000} seconds`);
142+
await new Promise(f => setTimeout(f, sleeptimeMs));
143+
} catch (err) {
144+
if (err instanceof StatusError) {
145+
if (err.statusCode >= 500) {
146+
// 5xx errors are likely transient, so we should retry
147+
console.debug(`Got HTTP error with status ${err.statusCode}, sleeping for ${sleeptimeMs / 1000} seconds`);
148+
await new Promise(f => setTimeout(f, sleeptimeMs));
149+
} else {
150+
console.debug(`Got HTTP error with status ${err.statusCode}, exiting`);
151+
throw err;
152+
}
153+
} else {
154+
throw err;
155+
}
156+
}
157+
}
158+
throw new Error(`Airgapped build release ${releaseSequence} did not reach status ${expectedStatus} in ${timeout} seconds`);
159+
}
160+
161+
export async function getDownloadUrlAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise<string> {
162+
const release = await getAirgapBuildRelease(vendorPortalApi, appId, channelId, releaseSequence);
163+
const http = await vendorPortalApi.client();
164+
const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/airgap/download-url?channelSequence=${release.channelSequence}`;
165+
const res = await http.get(uri);
166+
167+
if (res.message.statusCode != 200) {
168+
// discard the response body
169+
await res.readBody();
170+
throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`);
171+
}
172+
const body: any = JSON.parse(await res.readBody());
173+
return body.url;
174+
}
175+
176+
async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise<ChannelRelease> {
177+
const http = await vendorPortalApi.client();
178+
const uri = `${vendorPortalApi.endpoint}/app/${appId}/channel/${channelId}/releases`;
179+
const res = await http.get(uri);
180+
if (res.message.statusCode != 200) {
181+
// discard the response body
182+
await res.readBody();
183+
throw new Error(`Failed to get airgap build release: Server responded with ${res.message.statusCode}`);
184+
}
185+
const body: any = JSON.parse(await res.readBody());
186+
const release = body.releases.find((r: any) => r.sequence === releaseSequence);
187+
return {
188+
sequence: release.sequence,
189+
channelSequence: release.channelSequence,
190+
airgapBuildStatus: release.airgapBuildStatus
191+
};
192+
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { VendorPortalApi } from "./configuration";
22
export { getApplicationDetails } from "./applications";
3-
export { Channel, createChannel, getChannelDetails, archiveChannel } from "./channels";
3+
export { Channel, createChannel, getChannelDetails, archiveChannel, pollForAirgapReleaseStatus, getDownloadUrlAirgapBuildRelease } from "./channels";
44
export { ClusterVersion, createCluster, createClusterWithLicense, pollForStatus, getKubeconfig, removeCluster, upgradeCluster, getClusterVersions, createAddonObjectStore, pollForAddonStatus, exposeClusterPort } from "./clusters";
55
export { KubernetesDistribution, archiveCustomer, createCustomer, getUsedKubernetesDistributions } from "./customers";
66
export { Release, CompatibilityResult, createRelease, createReleaseFromChart, promoteRelease, reportCompatibilityResult } from "./releases";

0 commit comments

Comments
 (0)