Skip to content
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
91 changes: 91 additions & 0 deletions examples/poll-for-airgap-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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 } from "../dist/releases";
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}`);
} 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
10 changes: 6 additions & 4 deletions src/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ describe("findChannelDetailsInOutput", () => {
appName: "relmatrix",
channelSlug: "stable",
name: "Stable",
releaseSequence: 1
releaseSequence: 1,
buildAirgapAutomatically: true
},
{
id: "channelid2",
Expand All @@ -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";
Expand All @@ -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 }
]
};

Expand Down
5 changes: 3 additions & 2 deletions src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class Channel {
id: string;
slug: string;
releaseSequence?: number;
buildAirgapAutomatically?: boolean;
}

export const exportedForTesting = {
Expand Down Expand Up @@ -98,10 +99,10 @@ 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}` });
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
29 changes: 28 additions & 1 deletion src/releases.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
});
});
63 changes: 63 additions & 0 deletions src/releases.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the changes be in channels.ts, as this is about a release promoted to a channel and not an "Application" release.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I have moved the function back to channels.ts

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { zonedTimeToUtc } from "date-fns-tz";
export interface Release {
sequence: string;
charts?: ReleaseChart[];
airgapBuildStatus?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might become confusing? The Release sequence is not the same as the Channel release sequence?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a problem. Let me think a way to reduce the confusion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added channelSequence to avoid the confusing part

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think is correct. A release can be promoted to multiple channels, and each channel maintains its own sequence numbers

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channelSequence is misunderstanding. I have renamed it to promotedChannelSequence.

Yes, release can be promoted to multiple channels. But in getAirgapBuildRelease function, we have filtered by channel ID. In this case, the promotedChannelSequence will be only one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example,
when we curl

https://api.replicated.com/vendor/v3/app/2S2KBYYp3fKkP42pb3eJYIYJNYv/channel/2pusnQ3dbpDq0SEVEE8LCeUCCwC/releases

we will get

{
    "releases": [
      {
            "sequence": 37,
            "channelId": "2pusnQ3dbpDq0SEVEE8LCeUCCwC",
            "channelName": "Unstable",
            "channelIcon": "",
            "channelSequence": 10,
            "semver": "0.2.5",
            "isRequired": false,
       }
}

That is where channelSequence comes from "channelSequence": 10,
But it is confusing. Do you have suggestions, or should we make it like

export interface Release {
  sequence: string;
  channels?: Channel[];
  charts?: ReleaseChart[];
  airgapBuildStatus?: string;
}

to make it same as current api

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be in channels.ts and not in releases.ts.
The class Channel should probably contain an array of ChannelReleases, and ChannelRelease should be a new class in channel.ts

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be in channels.ts and not in releases.ts.
The class Channel should probably contain an array of ChannelReleases, and ChannelRelease should be a new class in channel.ts

fwiw - this sounds right to me. A "channel release" is an object that belongs to "channel". It would also mirror what we've done in places like replicated cli -

https://github.com/replicatedhq/replicated/blob/af2aea0904ce8fdfe92f06f0bef674ceb5a51247/pkg/types/channel.go#L65

which is then used in the larger channel obj:

https://github.com/replicatedhq/replicated/blob/af2aea0904ce8fdfe92f06f0bef674ceb5a51247/pkg/types/channel.go#L27

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @jdewinne and @pandemicsyn! ChannelRelease makes more sense. I have added it into channel.ts

}

export interface ReleaseChart {
Expand Down Expand Up @@ -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<Release> {
const http = await vendorPortalApi.client();

Expand Down Expand Up @@ -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<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`);
}

async function getAirgapBuildRelease(vendorPortalApi: VendorPortalApi, appId: string, channelId: string, releaseSequence: number): Promise<Release> {
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
};
}