Skip to content

Commit 8579700

Browse files
authoredSep 16, 2024
fix: update cli management in kind extension (podman-desktop#8655)
* fix: update cli management in kind extension Signed-off-by: lstocchi <lstocchi@redhat.com> * fix: add tests Signed-off-by: lstocchi <lstocchi@redhat.com> * fix: prettier Signed-off-by: lstocchi <lstocchi@redhat.com> * fix: prevent throwing if system-wide installation fails Signed-off-by: lstocchi <lstocchi@redhat.com> --------- Signed-off-by: lstocchi <lstocchi@redhat.com>
1 parent 26ca672 commit 8579700

7 files changed

+3190
-238
lines changed
 

‎extensions/kind/src/extension.spec.ts

+341-12
Large diffs are not rendered by default.

‎extensions/kind/src/extension.ts

+149-48
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,32 @@
1616
* SPDX-License-Identifier: Apache-2.0
1717
***********************************************************************/
1818

19+
import * as fs from 'node:fs';
1920
import * as path from 'node:path';
2021

21-
import type { AuditRequestItems, CancellationToken, CliTool, Logger, StatusBarItem } from '@podman-desktop/api';
22+
import { Octokit } from '@octokit/rest';
23+
import type { AuditRequestItems, CancellationToken, CliTool, Logger } from '@podman-desktop/api';
2224
import * as extensionApi from '@podman-desktop/api';
23-
import { ProgressLocation, window } from '@podman-desktop/api';
2425

2526
import { connectionAuditor, createCluster } from './create-cluster';
2627
import type { ImageInfo } from './image-handler';
2728
import { ImageHandler } from './image-handler';
29+
import type { KindGithubReleaseArtifactMetadata } from './kind-installer';
2830
import { KindInstaller } from './kind-installer';
29-
import { getKindBinaryInfo, getKindPath, getSystemBinaryPath } from './util';
30-
31+
import {
32+
getKindBinaryInfo,
33+
getKindPath,
34+
getSystemBinaryPath,
35+
installBinaryToSystem,
36+
removeVersionPrefix,
37+
} from './util';
38+
39+
const KIND_CLI_NAME = 'kind';
40+
const KIND_DISPLAY_NAME = 'Kind';
3141
const KIND_MARKDOWN = `Podman Desktop can help you run Kind-powered local Kubernetes clusters on a container engine, such as Podman.\n\nMore information: [Podman Desktop Documentation](https://podman-desktop.io/docs/kind)`;
3242

3343
const API_KIND_INTERNAL_API_PORT = 6443;
3444

35-
const KIND_INSTALL_COMMAND = 'kind.install';
36-
3745
const KIND_MOVE_IMAGE_COMMAND = 'kind.image.move';
3846
let imagesPushInProgressToKind: string[] = [];
3947

@@ -270,7 +278,7 @@ export async function createProvider(
270278
telemetryLogger.logUsage('moveImage');
271279

272280
return extensionApi.window.withProgress(
273-
{ location: ProgressLocation.TASK_WIDGET, title: `Loading ${image.name} to kind.` },
281+
{ location: extensionApi.ProgressLocation.TASK_WIDGET, title: `Loading ${image.name} to kind.` },
274282
async progress => await moveImage(progress, image),
275283
);
276284
}),
@@ -326,7 +334,8 @@ export async function moveImage(
326334

327335
export async function activate(extensionContext: extensionApi.ExtensionContext): Promise<void> {
328336
const telemetryLogger = extensionApi.env.createTelemetryLogger();
329-
const installer = new KindInstaller(extensionContext.storagePath, telemetryLogger);
337+
const octokit = new Octokit();
338+
const installer = new KindInstaller(extensionContext.storagePath, telemetryLogger, octokit);
330339

331340
let binary: { path: string; version: string } | undefined = undefined;
332341
let installationSource: extensionApi.CliToolInstallationSource | undefined;
@@ -342,7 +351,7 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
342351
// if not installed system-wide: let's try to check in the extension storage if kind is not available system-wide
343352
if (!binary) {
344353
try {
345-
binary = await getKindBinaryInfo(installer.getInternalDestinationPath());
354+
binary = await getKindBinaryInfo(installer.getKindCliStoragePath());
346355
installationSource = 'extension';
347356
} catch (err: unknown) {
348357
console.error(err);
@@ -359,13 +368,13 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
359368
}
360369
// we register it
361370
kindCli = extensionApi.cli.createCliTool({
362-
name: 'kind',
371+
name: KIND_CLI_NAME,
363372
images: {
364373
icon: './icon.png',
365374
},
366375
version: binaryVersion,
367376
path: binaryPath,
368-
displayName: 'kind',
377+
displayName: KIND_DISPLAY_NAME,
369378
markdownDescription: KIND_MARKDOWN,
370379
installationSource,
371380
});
@@ -374,51 +383,143 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
374383
await createProvider(extensionContext, telemetryLogger);
375384

376385
// if we do not have anything installed, let's add it to the status bar
377-
if (!binaryVersion && (await installer.isAvailable())) {
378-
const statusBarItem = extensionApi.window.createStatusBarItem();
379-
statusBarItem.text = 'Kind';
380-
statusBarItem.tooltip = 'Kind not found on your system, click to download and install it';
381-
statusBarItem.command = KIND_INSTALL_COMMAND;
382-
statusBarItem.iconClass = 'fa fa-exclamation-triangle';
383-
extensionContext.subscriptions.push(
384-
extensionApi.commands.registerCommand(KIND_INSTALL_COMMAND, () => kindInstall(installer, statusBarItem)),
385-
statusBarItem,
386-
);
387-
statusBarItem.show();
386+
let releaseToInstall: KindGithubReleaseArtifactMetadata | undefined;
387+
let releaseVersionToInstall: string | undefined;
388+
kindCli.registerInstaller({
389+
selectVersion: async () => {
390+
const selected = await installer.promptUserForVersion();
391+
releaseToInstall = selected;
392+
releaseVersionToInstall = removeVersionPrefix(selected.tag);
393+
return releaseVersionToInstall;
394+
},
395+
doInstall: async _logger => {
396+
if (binaryVersion ?? binaryPath) {
397+
throw new Error(
398+
`Cannot install ${KIND_CLI_NAME}. Version ${binaryVersion} in ${binaryPath} is already installed.`,
399+
);
400+
}
401+
if (!releaseToInstall || !releaseVersionToInstall) {
402+
throw new Error(`Cannot install ${KIND_CLI_NAME}. No release selected.`);
403+
}
404+
405+
// download, install system wide and update cli version
406+
await installer.download(releaseToInstall);
407+
const cliPath = installer.getKindCliStoragePath();
408+
409+
try {
410+
await installBinaryToSystem(cliPath, KIND_CLI_NAME);
411+
} catch (err: unknown) {
412+
console.log(`${KIND_CLI_NAME} not installed system-wide. Error: ${String(err)}`);
413+
}
414+
415+
kindCli?.updateVersion({
416+
version: releaseVersionToInstall,
417+
installationSource: 'extension',
418+
});
419+
binaryVersion = releaseVersionToInstall;
420+
binaryPath = cliPath;
421+
kindPath = cliPath;
422+
releaseVersionToInstall = undefined;
423+
releaseToInstall = undefined;
424+
},
425+
doUninstall: async _logger => {
426+
if (!binaryVersion) {
427+
throw new Error(`Cannot uninstall ${KIND_CLI_NAME}. No version detected.`);
428+
}
429+
430+
// delete the executable stored in the storage folder
431+
const storagePath = installer.getKindCliStoragePath();
432+
await deleteFile(storagePath);
433+
434+
// delete the executable in the system path
435+
const systemPath = getSystemBinaryPath(KIND_CLI_NAME);
436+
await deleteFile(systemPath);
437+
438+
// update the version and path to undefined
439+
binaryVersion = undefined;
440+
binaryPath = undefined;
441+
kindPath = undefined;
442+
},
443+
});
444+
445+
extensionContext.subscriptions.push(kindCli);
446+
447+
// if the tool has been installed by the user we do not register the updater/installer
448+
if (installationSource === 'external') {
449+
return;
388450
}
451+
// register the updater to allow users to upgrade/downgrade their cli
452+
let releaseToUpdateTo: KindGithubReleaseArtifactMetadata | undefined;
453+
let releaseVersionToUpdateTo: string | undefined;
454+
455+
kindCli.registerUpdate({
456+
selectVersion: async () => {
457+
const selected = await installer.promptUserForVersion(binaryVersion);
458+
releaseToUpdateTo = selected;
459+
releaseVersionToUpdateTo = removeVersionPrefix(selected.tag);
460+
return releaseVersionToUpdateTo;
461+
},
462+
doUpdate: async _logger => {
463+
if (!binaryVersion || !binaryPath) {
464+
throw new Error(`Cannot update ${KIND_CLI_NAME}. No cli tool installed.`);
465+
}
466+
if (!releaseToUpdateTo || !releaseVersionToUpdateTo) {
467+
throw new Error(`Cannot update ${binaryPath} version ${binaryVersion}. No release selected.`);
468+
}
469+
470+
// download, install system wide and update cli version
471+
await installer.download(releaseToUpdateTo);
472+
const cliPath = installer.getKindCliStoragePath();
473+
try {
474+
await installBinaryToSystem(cliPath, KIND_CLI_NAME);
475+
} catch (err: unknown) {
476+
console.log(`${KIND_CLI_NAME} not updated system-wide. Error: ${String(err)}`);
477+
}
478+
kindCli?.updateVersion({
479+
version: releaseVersionToUpdateTo,
480+
installationSource: 'extension',
481+
});
482+
binaryVersion = releaseVersionToUpdateTo;
483+
releaseVersionToInstall = undefined;
484+
releaseToInstall = undefined;
485+
},
486+
});
389487
}
390488

391-
/**
392-
* Install the kind binary in the extension storage, and optionally system-wide
393-
* @param installer
394-
* @param statusBarItem
395-
* @param extensionContext
396-
* @param telemetryLogger
397-
*/
398-
async function kindInstall(installer: KindInstaller, statusBarItem: StatusBarItem): Promise<void> {
399-
if (kindPath) throw new Error('kind cli is already registered');
400-
401-
let path: string;
402-
try {
403-
path = await installer.performInstall();
404-
} catch (err: unknown) {
405-
window.showErrorMessage('Kind installation failed ' + err).catch((error: unknown) => {
406-
console.error('show error went wrong', error);
407-
});
408-
return;
489+
async function deleteFile(filePath: string): Promise<void> {
490+
if (filePath && fs.existsSync(filePath)) {
491+
try {
492+
await fs.promises.unlink(filePath);
493+
} catch (error: unknown) {
494+
if (
495+
error &&
496+
typeof error === 'object' &&
497+
'code' in error &&
498+
(error.code === 'EACCES' || error.code === 'EPERM')
499+
) {
500+
await deleteFileAsAdmin(filePath);
501+
} else {
502+
throw error;
503+
}
504+
}
409505
}
506+
}
410507

411-
statusBarItem.dispose();
412-
const binaryInfo = await getKindBinaryInfo(path);
508+
async function deleteFileAsAdmin(filePath: string): Promise<void> {
509+
const system = process.platform;
413510

414-
kindPath = path;
415-
kindCli?.updateVersion({
416-
version: binaryInfo.version,
417-
path: binaryInfo.path,
418-
});
511+
const args: string[] = [filePath];
512+
const command = system === 'win32' ? 'del' : 'rm';
513+
514+
try {
515+
// Use admin prileges
516+
await extensionApi.process.exec(command, args, { isAdmin: true });
517+
} catch (error) {
518+
console.error(`Failed to uninstall '${filePath}': ${error}`);
519+
throw error;
520+
}
419521
}
420522

421523
export function deactivate(): void {
422524
console.log('stopping kind extension');
423-
kindCli?.dispose();
424525
}

‎extensions/kind/src/kind-installer.spec.ts

+264-63
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@
1616
* SPDX-License-Identifier: Apache-2.0
1717
***********************************************************************/
1818

19-
import { Octokit } from '@octokit/rest';
19+
import * as fs from 'node:fs';
20+
import * as os from 'node:os';
21+
import * as path from 'node:path';
22+
23+
import type { Octokit } from '@octokit/rest';
2024
import * as extensionApi from '@podman-desktop/api';
2125
import { tmpName } from 'tmp-promise';
22-
import { beforeEach, expect, test, vi } from 'vitest';
26+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2327

28+
import type { KindGithubReleaseArtifactMetadata } from './kind-installer';
2429
import { KindInstaller } from './kind-installer';
25-
import { installBinaryToSystem } from './util';
30+
import * as util from './util';
2631

2732
let installer: KindInstaller;
2833

@@ -33,6 +38,7 @@ vi.mock('@podman-desktop/api', async () => {
3338
showErrorMessage: vi.fn(),
3439
withProgress: vi.fn(),
3540
showNotification: vi.fn(),
41+
showQuickPick: vi.fn(),
3642
},
3743
ProgressLocation: {
3844
APP_ICON: 1,
@@ -48,29 +54,25 @@ vi.mock('@podman-desktop/api', async () => {
4854
};
4955
});
5056

51-
vi.mock('sudo-prompt', () => {
57+
vi.mock('node:os', async () => {
5258
return {
53-
exec: vi
54-
.fn()
55-
.mockImplementation(
56-
(
57-
cmd: string,
58-
options?:
59-
| ((error?: Error, stdout?: string | Buffer, stderr?: string | Buffer) => void)
60-
| { name?: string; icns?: string; env?: { [key: string]: string } },
61-
callback?: (error?: Error, stdout?: string | Buffer, stderr?: string | Buffer) => void,
62-
) => {
63-
callback?.(undefined);
64-
},
65-
),
59+
platform: vi.fn(),
60+
arch: vi.fn(),
61+
homedir: vi.fn(),
6662
};
6763
});
6864

69-
vi.mock('@octokit/rest', () => {
70-
return {
71-
Octokit: vi.fn(),
72-
};
73-
});
65+
const listReleasesMock = vi.fn();
66+
const listReleaseAssetsMock = vi.fn();
67+
const getReleaseAssetMock = vi.fn();
68+
69+
const octokitMock: Octokit = {
70+
repos: {
71+
listReleases: listReleasesMock,
72+
listReleaseAssets: listReleaseAssetsMock,
73+
getReleaseAsset: getReleaseAssetMock,
74+
},
75+
} as unknown as Octokit;
7476

7577
const telemetryLogUsageMock = vi.fn();
7678
const telemetryLogErrorMock = vi.fn();
@@ -80,10 +82,9 @@ const telemetryLoggerMock = {
8082
} as unknown as extensionApi.TelemetryLogger;
8183

8284
beforeEach(() => {
83-
installer = new KindInstaller('.', telemetryLoggerMock);
85+
installer = new KindInstaller('.', telemetryLoggerMock, octokitMock);
8486
vi.resetAllMocks();
8587

86-
vi.mocked(extensionApi.window.showInformationMessage).mockReturnValue(Promise.resolve('Yes'));
8788
(extensionApi.env.isLinux as unknown as boolean) = false;
8889
(extensionApi.env.isWindows as unknown as boolean) = false;
8990
(extensionApi.env.isMac as unknown as boolean) = false;
@@ -96,59 +97,259 @@ test.skip('expect installBinaryToSystem to succesfully pass with a binary', asyn
9697
const filename = await tmpName();
9798

9899
// "Install" the binary, this should pass sucessfully
99-
try {
100-
await installBinaryToSystem(filename, 'tmpBinary');
101-
} catch (err) {
102-
expect(err).toBeUndefined();
103-
}
100+
await expect(() => util.installBinaryToSystem(filename, 'tmpBinary')).rejects.toThrowError();
104101
});
105102

106103
test('error: expect installBinaryToSystem to fail with a non existing binary', async () => {
107-
(extensionApi.env.isLinux as unknown as boolean) = false;
104+
(extensionApi.env.isLinux as unknown as boolean) = true;
108105

109106
vi.spyOn(extensionApi.process, 'exec').mockRejectedValue(new Error('test error'));
110107

111-
await expect(() => installBinaryToSystem('test', 'tmpBinary')).rejects.toThrowError('test error');
108+
await expect(() => util.installBinaryToSystem('test', 'tmpBinary')).rejects.toThrowError('test error');
112109
});
113110

114-
test('expect showNotification to be called', async () => {
115-
(extensionApi.env.isLinux as unknown as boolean) = true;
111+
describe('grabLatestsReleasesMetadata', () => {
112+
test('return latest 5 releases', async () => {
113+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
114+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
116115

117-
vi.mocked(Octokit).mockReturnValue({
118-
repos: {
119-
getReleaseAsset: vi.fn().mockReturnValue({ name: 'kind', data: [] }),
120-
},
121-
} as unknown as Octokit);
116+
// mock the result of listReleases REST API
117+
const resultREST = JSON.parse(
118+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-all.json'), 'utf8'),
119+
);
120+
listReleasesMock.mockImplementation(() => {
121+
return { data: resultREST };
122+
});
123+
const releases = await installer.grabLatestsReleasesMetadata();
124+
expect(releases).toBeDefined();
125+
expect(releases.length).toBe(5);
126+
});
127+
});
122128

123-
const progress = {
124-
// eslint-disable-next-line @typescript-eslint/no-empty-function
125-
report: (): void => {},
126-
};
127-
vi.spyOn(extensionApi.window, 'withProgress').mockImplementation((options, task) => {
128-
return task(progress, {} as extensionApi.CancellationToken);
129+
describe('promptUserForVersion', () => {
130+
test('return selected version', async () => {
131+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
132+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
133+
134+
// mock the result of listReleases REST API
135+
const resultREST: KindGithubReleaseArtifactMetadata[] = JSON.parse(
136+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-all.json'), 'utf8'),
137+
);
138+
listReleasesMock.mockImplementation(() => {
139+
return { data: resultREST };
140+
});
141+
const showQuickPickMock = vi.spyOn(extensionApi.window, 'showQuickPick').mockResolvedValue(resultREST[0]);
142+
const release = await installer.promptUserForVersion();
143+
144+
expect(showQuickPickMock).toBeCalledWith(expect.any(Array), {
145+
placeHolder: 'Select Kind version to download',
146+
});
147+
expect(release).toBeDefined();
148+
expect(release.id).toBe(resultREST[0].id);
149+
});
150+
test('throw error if no version is selected', async () => {
151+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
152+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
153+
154+
// mock the result of listReleases REST API
155+
const resultREST: KindGithubReleaseArtifactMetadata[] = JSON.parse(
156+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-all.json'), 'utf8'),
157+
);
158+
listReleasesMock.mockImplementation(() => {
159+
return { data: resultREST };
160+
});
161+
vi.spyOn(extensionApi.window, 'showQuickPick').mockResolvedValue(undefined);
162+
await expect(() => installer.promptUserForVersion()).rejects.toThrowError('No version selected');
163+
});
164+
});
165+
166+
describe('getReleaseAssetId', () => {
167+
beforeEach(async () => {
168+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
169+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
170+
171+
// mock the result of listReleaseAssetsMock REST API
172+
const resultREST = JSON.parse(
173+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-assets.json'), 'utf8'),
174+
);
175+
176+
listReleaseAssetsMock.mockImplementation(() => {
177+
return { data: resultREST };
178+
});
179+
});
180+
181+
test('macOS x86_64', async () => {
182+
const result = await installer.getReleaseAssetId(170076920, 'darwin', 'x64');
183+
expect(result).toBeDefined();
184+
expect(result).toBe(186178216);
185+
});
186+
187+
test('macOS arm64', async () => {
188+
const result = await installer.getReleaseAssetId(170076920, 'darwin', 'arm64');
189+
expect(result).toBeDefined();
190+
expect(result).toBe(186178219);
191+
});
192+
193+
test('windows x86_64', async () => {
194+
const result = await installer.getReleaseAssetId(170076920, 'win32', 'x64');
195+
expect(result).toBeDefined();
196+
expect(result).toBe(186178238);
197+
});
198+
199+
test('windows arm64', async () => {
200+
await expect(installer.getReleaseAssetId(170076920, 'win32', 'arm64')).rejects.toThrow();
201+
});
202+
203+
test('linux x86_64', async () => {
204+
const result = await installer.getReleaseAssetId(170076920, 'linux', 'x64');
205+
expect(result).toBeDefined();
206+
expect(result).toBe(186178226);
207+
});
208+
209+
test('linux arm64', async () => {
210+
const result = await installer.getReleaseAssetId(170076920, 'linux', 'arm64');
211+
expect(result).toBeDefined();
212+
expect(result).toBe(186178234);
213+
});
214+
215+
test('invalid', async () => {
216+
await expect(installer.getReleaseAssetId(170076920, 'invalid', 'invalid')).rejects.toThrow();
217+
});
218+
});
219+
220+
describe('getKindCliStoragePath', () => {
221+
test('return kind.exe path for windows', async () => {
222+
(extensionApi.env.isWindows as unknown as boolean) = true;
223+
const path = installer.getKindCliStoragePath();
224+
expect(path.endsWith('kind.exe')).toBeTruthy();
225+
});
226+
test('return kind path for mac', async () => {
227+
(extensionApi.env.isMac as unknown as boolean) = true;
228+
const path = installer.getKindCliStoragePath();
229+
expect(path.endsWith('kind')).toBeTruthy();
230+
});
231+
test('return kind path for linux', async () => {
232+
(extensionApi.env.isLinux as unknown as boolean) = true;
233+
const path = installer.getKindCliStoragePath();
234+
expect(path.endsWith('kind')).toBeTruthy();
235+
});
236+
});
237+
238+
describe('install', () => {
239+
beforeEach(async () => {
240+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
241+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
242+
243+
// mock the result of listReleaseAssetsMock REST API
244+
const resultREST = JSON.parse(
245+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-assets.json'), 'utf8'),
246+
);
247+
248+
listReleaseAssetsMock.mockImplementation(() => {
249+
return { data: resultREST };
250+
});
129251
});
130-
vi.spyOn(installer, 'getAssetInfo').mockReturnValue(Promise.resolve({ id: 0, name: 'kind' }));
131-
// eslint-disable-next-line @typescript-eslint/no-empty-function
132-
const spy = vi.spyOn(extensionApi.window, 'showNotification').mockImplementation(() => {
133-
return {
134-
// eslint-disable-next-line @typescript-eslint/no-empty-function
135-
dispose: (): void => {},
136-
};
252+
test('should download file on win system', async () => {
253+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
254+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
255+
256+
// mock the result of listReleases REST API
257+
const resultREST: KindGithubReleaseArtifactMetadata[] = JSON.parse(
258+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-all.json'), 'utf8'),
259+
);
260+
listReleasesMock.mockImplementation(() => {
261+
return { data: resultREST };
262+
});
263+
vi.mocked(os.platform).mockReturnValue('win32');
264+
vi.mocked(os.arch).mockReturnValue('x64');
265+
vi.mock('node:fs');
266+
vi.mocked(fs.existsSync).mockReturnValue(true);
267+
const chmodMock = vi.spyOn(fs.promises, 'chmod');
268+
const downloadReleaseAssetMock = vi
269+
.spyOn(installer, 'downloadReleaseAsset')
270+
.mockImplementation(() => Promise.resolve());
271+
await installer.download(resultREST[0]);
272+
expect(downloadReleaseAssetMock).toBeCalledWith(186178238, expect.any(String));
273+
expect(chmodMock).not.toBeCalled();
274+
});
275+
test('should download and set permissions on file on non-win system', async () => {
276+
(extensionApi.env.isMac as unknown as boolean) = true;
277+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
278+
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');
279+
280+
// mock the result of listReleases REST API
281+
const resultREST: KindGithubReleaseArtifactMetadata[] = JSON.parse(
282+
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/kind-github-release-all.json'), 'utf8'),
283+
);
284+
listReleasesMock.mockImplementation(() => {
285+
return { data: resultREST };
286+
});
287+
vi.mocked(os.platform).mockReturnValue('darwin');
288+
vi.mocked(os.arch).mockReturnValue('x64');
289+
vi.mock('node:fs');
290+
vi.mocked(fs.existsSync).mockReturnValue(true);
291+
const chmodMock = vi.spyOn(fs.promises, 'chmod');
292+
const downloadReleaseAssetMock = vi
293+
.spyOn(installer, 'downloadReleaseAsset')
294+
.mockImplementation(() => Promise.resolve());
295+
await installer.download(resultREST[0]);
296+
expect(downloadReleaseAssetMock).toBeCalledWith(186178216, expect.any(String));
297+
expect(chmodMock).toBeCalledWith(expect.any(String), 0o755);
137298
});
299+
});
138300

139-
// Check that install passes
140-
const result = await installer.performInstall();
141-
expect(telemetryLogErrorMock).not.toBeCalled();
142-
expect(telemetryLogUsageMock).toHaveBeenNthCalledWith(1, 'install-kind-prompt');
143-
expect(telemetryLogUsageMock).toHaveBeenNthCalledWith(2, 'install-kind-prompt-yes');
144-
expect(telemetryLogUsageMock).toHaveBeenNthCalledWith(3, 'install-kind-downloaded');
301+
describe('downloadReleaseAsset', () => {
302+
test('should download the file if parent folder does exist', async () => {
303+
vi.mock('node:fs');
145304

146-
expect(result).toBeDefined();
147-
expect(result).toBeTruthy();
305+
getReleaseAssetMock.mockImplementation(() => {
306+
return { data: 'foo' };
307+
});
148308

149-
// Check that showNotification is called
150-
expect(spy).toBeCalled();
309+
// mock fs
310+
const existSyncSpy = vi.spyOn(fs, 'existsSync').mockImplementation(() => {
311+
return true;
312+
});
313+
314+
const writeFileSpy = vi.spyOn(fs.promises, 'writeFile').mockResolvedValue();
315+
316+
// generate a temporary file
317+
const destFile = '/fake/path/to/file';
318+
await installer.downloadReleaseAsset(123, destFile);
319+
// check that parent director has been checked
320+
expect(existSyncSpy).toBeCalledWith('/fake/path/to');
321+
322+
// check that we've written the file
323+
expect(writeFileSpy).toBeCalledWith(destFile, Buffer.from('foo'));
324+
});
151325

152-
// Expect showInformationMessage to be shown and be asking for installing it system wide
153-
expect(extensionApi.window.showInformationMessage).toBeCalled();
326+
test('should download the file if parent folder does not exist', async () => {
327+
vi.mock('node:fs');
328+
329+
getReleaseAssetMock.mockImplementation(() => {
330+
return { data: 'foo' };
331+
});
332+
333+
// mock fs
334+
const existSyncSpy = vi.spyOn(fs, 'existsSync').mockImplementation(() => {
335+
return false;
336+
});
337+
const mkdirSpy = vi.spyOn(fs.promises, 'mkdir').mockImplementation(async () => {
338+
return '';
339+
});
340+
341+
const writeFileSpy = vi.spyOn(fs.promises, 'writeFile').mockResolvedValue();
342+
343+
// generate a temporary file
344+
const destFile = '/fake/path/to/file';
345+
await installer.downloadReleaseAsset(123, destFile);
346+
// check that parent director has been checked
347+
expect(existSyncSpy).toBeCalledWith('/fake/path/to');
348+
349+
// check that we've created the parent folder
350+
expect(mkdirSpy).toBeCalledWith('/fake/path/to', { recursive: true });
351+
352+
// check that we've written the file
353+
expect(writeFileSpy).toBeCalledWith(destFile, Buffer.from('foo'));
354+
});
154355
});

‎extensions/kind/src/kind-installer.ts

+117-115
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,19 @@ import * as fs from 'node:fs';
1919
import * as os from 'node:os';
2020
import * as path from 'node:path';
2121

22-
import type { components } from '@octokit/openapi-types';
23-
import { Octokit } from '@octokit/rest';
22+
import type { Octokit } from '@octokit/rest';
2423
import * as extensionApi from '@podman-desktop/api';
25-
import { ProgressLocation } from '@podman-desktop/api';
26-
27-
import { installBinaryToSystem } from './util';
28-
29-
const githubOrganization = 'kubernetes-sigs';
30-
const githubRepo = 'kind';
31-
32-
type GitHubRelease = components['schemas']['release'];
3324

3425
export interface AssetInfo {
3526
id: number;
3627
name: string;
3728
}
3829

30+
export interface KindGithubReleaseArtifactMetadata extends extensionApi.QuickPickItem {
31+
tag: string;
32+
id: number;
33+
}
34+
3935
const WINDOWS_X64_PLATFORM = 'win32-x64';
4036

4137
const LINUX_X64_PLATFORM = 'linux-x64';
@@ -57,13 +53,14 @@ const MACOS_X64_ASSET_NAME = 'kind-darwin-amd64';
5753
const MACOS_ARM64_ASSET_NAME = 'kind-darwin-arm64';
5854

5955
export class KindInstaller {
56+
private readonly KIND_GITHUB_OWNER = 'kubernetes-sigs';
57+
private readonly KIND_GITHUB_REPOSITORY = 'kind';
6058
private assetNames = new Map<string, string>();
6159

62-
private assetPromise: Promise<AssetInfo | undefined> | undefined;
63-
6460
constructor(
6561
private readonly storagePath: string,
6662
private telemetryLogger: extensionApi.TelemetryLogger,
63+
private readonly octokit: Octokit,
6764
) {
6865
this.assetNames.set(WINDOWS_X64_PLATFORM, WINDOWS_X64_ASSET_NAME);
6966
this.assetNames.set(LINUX_X64_PLATFORM, LINUX_X64_ASSET_NAME);
@@ -72,121 +69,126 @@ export class KindInstaller {
7269
this.assetNames.set(MACOS_ARM64_PLATFORM, MACOS_ARM64_ASSET_NAME);
7370
}
7471

75-
findAssetInfo(data: GitHubRelease[], assetName: string): AssetInfo | undefined {
76-
for (const release of data) {
77-
for (const asset of release.assets) {
78-
if (asset.name === assetName) {
79-
return {
80-
id: asset.id,
81-
name: assetName,
82-
};
83-
}
84-
}
85-
}
86-
return undefined;
72+
// Provides last 5 majors releases from GitHub using the GitHub API
73+
// return name, tag and id of the release
74+
async grabLatestsReleasesMetadata(): Promise<KindGithubReleaseArtifactMetadata[]> {
75+
// Grab last 5 majors releases from GitHub using the GitHub API
76+
77+
const lastReleases = await this.octokit.repos.listReleases({
78+
owner: this.KIND_GITHUB_OWNER,
79+
repo: this.KIND_GITHUB_REPOSITORY,
80+
});
81+
82+
// keep only releases and not pre-releases
83+
lastReleases.data = lastReleases.data.filter(release => !release.prerelease);
84+
85+
// keep only the last 5 releases
86+
lastReleases.data = lastReleases.data.slice(0, 5);
87+
88+
return lastReleases.data.map(release => {
89+
return {
90+
label: release.name ?? release.tag_name,
91+
tag: release.tag_name,
92+
id: release.id,
93+
};
94+
});
8795
}
8896

89-
async getAssetInfo(): Promise<AssetInfo | undefined> {
90-
if (!(await this.assetPromise)) {
91-
const assetName = this.assetNames.get(os.platform().concat('-').concat(os.arch()));
92-
if (assetName === undefined) {
93-
return undefined;
94-
}
95-
const octokit = new Octokit();
96-
this.assetPromise = octokit.repos
97-
.listReleases({ owner: githubOrganization, repo: githubRepo })
98-
.then(response => this.findAssetInfo(response.data, assetName))
99-
.catch((error: unknown) => {
100-
console.error(error);
101-
return undefined;
102-
});
97+
async promptUserForVersion(currentKindTag?: string): Promise<KindGithubReleaseArtifactMetadata> {
98+
// Get the latest releases
99+
let lastReleasesMetadata = await this.grabLatestsReleasesMetadata();
100+
// if the user already has an installed version, we remove it from the list
101+
if (currentKindTag) {
102+
lastReleasesMetadata = lastReleasesMetadata.filter(release => release.tag.slice(1) !== currentKindTag);
103103
}
104-
return this.assetPromise;
105-
}
106104

107-
async isAvailable(): Promise<boolean> {
108-
const assetInfo = await this.getAssetInfo();
109-
return assetInfo !== undefined;
105+
// Show the quickpick
106+
const selectedRelease = await extensionApi.window.showQuickPick(lastReleasesMetadata, {
107+
placeHolder: 'Select Kind version to download',
108+
});
109+
110+
if (selectedRelease) {
111+
return selectedRelease;
112+
} else {
113+
throw new Error('No version selected');
114+
}
110115
}
111116

112-
protected async withConfirmation(text: string): Promise<boolean> {
113-
const result = await extensionApi.window.showInformationMessage(text, 'Yes', 'Cancel');
114-
return result === 'Yes';
117+
// Get the asset id of a given release number for a given operating system and architecture
118+
// operatingSystem: win32, darwin, linux (see os.platform())
119+
// arch: x64, arm64 (see os.arch())
120+
async getReleaseAssetId(releaseId: number, operatingSystem: string, arch: string): Promise<number> {
121+
if (operatingSystem === 'win32') {
122+
operatingSystem = 'windows';
123+
}
124+
if (arch === 'x64') {
125+
arch = 'amd64';
126+
}
127+
128+
const listOfAssets = await this.octokit.repos.listReleaseAssets({
129+
owner: this.KIND_GITHUB_OWNER,
130+
repo: this.KIND_GITHUB_REPOSITORY,
131+
release_id: releaseId,
132+
});
133+
134+
const searchedAssetName = `kind-${operatingSystem}-${arch}`;
135+
136+
// search for the right asset
137+
const asset = listOfAssets.data.find(asset => searchedAssetName === asset.name);
138+
if (!asset) {
139+
throw new Error(`No asset found for ${operatingSystem} and ${arch}`);
140+
}
141+
142+
return asset.id;
115143
}
116144

117-
getInternalDestinationPath(): string {
118-
return path.resolve(this.storagePath, extensionApi.env.isWindows ? 'kind.exe' : 'kind');
145+
getKindCliStoragePath(): string {
146+
const storageBinFolder = path.resolve(this.storagePath, 'bin');
147+
let fileExtension = '';
148+
if (extensionApi.env.isWindows) {
149+
fileExtension = '.exe';
150+
}
151+
return path.resolve(storageBinFolder, `kind${fileExtension}`);
119152
}
120153

121-
/**
122-
* (1) Download the latest binary in the extension storage path.
123-
* (2) Ask the user if they want to install system-wide
124-
* @return the path where the binary is installed
125-
*/
126-
async performInstall(): Promise<string> {
127-
this.telemetryLogger.logUsage('install-kind-prompt');
128-
const confirm = await this.withConfirmation(
129-
'The kind binary is required for local Kubernetes development, would you like to download it?',
130-
);
131-
if (!confirm) {
132-
this.telemetryLogger.logUsage('install-kind-prompt-no');
133-
throw new Error('user cancel installation');
154+
async download(release: KindGithubReleaseArtifactMetadata): Promise<void> {
155+
// Get asset id
156+
const assetId = await this.getReleaseAssetId(release.id, os.platform(), os.arch());
157+
158+
// Get the storage and check to see if it exists before we download Kind
159+
const storageBinFolder = path.resolve(this.storagePath, 'bin');
160+
if (!fs.existsSync(storageBinFolder)) {
161+
await fs.promises.mkdir(storageBinFolder, { recursive: true });
134162
}
135163

136-
this.telemetryLogger.logUsage('install-kind-prompt-yes');
137-
138-
return extensionApi.window.withProgress<string>(
139-
{ location: ProgressLocation.TASK_WIDGET, title: 'Installing kind' },
140-
async progress => {
141-
progress.report({ increment: 5 });
142-
143-
const assetInfo = await this.getAssetInfo();
144-
if (!assetInfo) throw new Error('cannot find assets for kind');
145-
146-
const octokit = new Octokit();
147-
const asset = await octokit.repos.getReleaseAsset({
148-
owner: githubOrganization,
149-
repo: githubRepo,
150-
asset_id: assetInfo.id,
151-
headers: {
152-
accept: 'application/octet-stream',
153-
},
154-
});
155-
if (!asset) throw new Error(`cannot get release asset for ${assetInfo.id}`);
156-
157-
progress.report({ increment: 80 });
158-
const destFile = this.getInternalDestinationPath();
159-
if (!fs.existsSync(this.storagePath)) {
160-
fs.mkdirSync(this.storagePath);
161-
}
162-
fs.appendFileSync(destFile, Buffer.from(asset.data as unknown as ArrayBuffer));
163-
if (!extensionApi.env.isWindows) {
164-
const stat = fs.statSync(destFile);
165-
fs.chmodSync(destFile, stat.mode | fs.constants.S_IXUSR);
166-
}
167-
168-
// Explain to the user that the binary has been successfully installed to the storage path
169-
// prompt and ask if they want to install it system-wide (copied to /usr/bin/, or AppData for Windows)
170-
const result = await this.withConfirmation(
171-
`Kind binary has been successfully downloaded to ${destFile}.\n\nWould you like to install it system-wide for accessibility on the command line? This will require administrative privileges.`,
172-
);
173-
if (!result) {
174-
return destFile;
175-
}
176-
177-
try {
178-
// Move the binary file to the system from destFile and rename to 'kind'
179-
const systemPath = await installBinaryToSystem(destFile, 'kind');
180-
await extensionApi.window.showInformationMessage('Kind binary has been successfully installed system-wide.');
181-
this.telemetryLogger.logUsage('install-kind-downloaded');
182-
extensionApi.window.showNotification({ body: 'Kind is successfully installed.' });
183-
return systemPath;
184-
} catch (error) {
185-
console.error(error);
186-
await extensionApi.window.showErrorMessage(`Unable to install kind binary: ${error}`);
187-
throw error;
188-
}
164+
const kindDownloadLocation = this.getKindCliStoragePath();
165+
166+
// Download the asset and make it executable
167+
await this.downloadReleaseAsset(assetId, kindDownloadLocation);
168+
// make executable
169+
if (extensionApi.env.isLinux || extensionApi.env.isMac) {
170+
// eslint-disable-next-line sonarjs/file-permissions
171+
await fs.promises.chmod(kindDownloadLocation, 0o755);
172+
}
173+
}
174+
175+
async downloadReleaseAsset(assetId: number, destination: string): Promise<void> {
176+
const asset = await this.octokit.repos.getReleaseAsset({
177+
owner: this.KIND_GITHUB_OWNER,
178+
repo: this.KIND_GITHUB_REPOSITORY,
179+
asset_id: assetId,
180+
headers: {
181+
accept: 'application/octet-stream',
189182
},
190-
);
183+
});
184+
185+
// check the parent folder exists
186+
const parentFolder = path.dirname(destination);
187+
188+
if (!fs.existsSync(parentFolder)) {
189+
await fs.promises.mkdir(parentFolder, { recursive: true });
190+
}
191+
// write the file
192+
await fs.promises.writeFile(destination, Buffer.from(asset.data as unknown as ArrayBuffer));
191193
}
192194
}

‎extensions/kind/src/util.ts

+4
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,7 @@ export async function getMemTotalInfo(socketPath: string): Promise<number> {
193193
});
194194
});
195195
}
196+
197+
export function removeVersionPrefix(version: string): string {
198+
return version.replace('v', '').trim();
199+
}

‎extensions/kind/tests/resources/kind-github-release-all.json

+1,970
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
[
2+
{
3+
"//": "",
4+
"//GENERATED": "Generated from https://api.github.com/repos/kubernetes-sigs/kind/releases/170076920/assets",
5+
"//": "",
6+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178216",
7+
"id": 186178216,
8+
"node_id": "RA_kwDOCNqhD84LGNqo",
9+
"name": "kind-darwin-amd64",
10+
"label": null,
11+
"uploader": {
12+
"login": "BenTheElder",
13+
"id": 917931,
14+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
15+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
16+
"gravatar_id": "",
17+
"url": "https://api.github.com/users/BenTheElder",
18+
"html_url": "https://github.com/BenTheElder",
19+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
20+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
21+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
22+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
23+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
24+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
25+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
26+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
27+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
28+
"type": "User",
29+
"site_admin": false
30+
},
31+
"content_type": "application/octet-stream",
32+
"state": "uploaded",
33+
"size": 10080240,
34+
"download_count": 471,
35+
"created_at": "2024-08-15T16:36:25Z",
36+
"updated_at": "2024-08-15T16:36:27Z",
37+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-darwin-amd64"
38+
},
39+
{
40+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178218",
41+
"id": 186178218,
42+
"node_id": "RA_kwDOCNqhD84LGNqq",
43+
"name": "kind-darwin-amd64.sha256sum",
44+
"label": null,
45+
"uploader": {
46+
"login": "BenTheElder",
47+
"id": 917931,
48+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
49+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
50+
"gravatar_id": "",
51+
"url": "https://api.github.com/users/BenTheElder",
52+
"html_url": "https://github.com/BenTheElder",
53+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
54+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
55+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
56+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
57+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
58+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
59+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
60+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
61+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
62+
"type": "User",
63+
"site_admin": false
64+
},
65+
"content_type": "application/octet-stream",
66+
"state": "uploaded",
67+
"size": 84,
68+
"download_count": 111,
69+
"created_at": "2024-08-15T16:36:27Z",
70+
"updated_at": "2024-08-15T16:36:27Z",
71+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-darwin-amd64.sha256sum"
72+
},
73+
{
74+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178219",
75+
"id": 186178219,
76+
"node_id": "RA_kwDOCNqhD84LGNqr",
77+
"name": "kind-darwin-arm64",
78+
"label": null,
79+
"uploader": {
80+
"login": "BenTheElder",
81+
"id": 917931,
82+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
83+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
84+
"gravatar_id": "",
85+
"url": "https://api.github.com/users/BenTheElder",
86+
"html_url": "https://github.com/BenTheElder",
87+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
88+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
89+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
90+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
91+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
92+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
93+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
94+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
95+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
96+
"type": "User",
97+
"site_admin": false
98+
},
99+
"content_type": "application/octet-stream",
100+
"state": "uploaded",
101+
"size": 9697458,
102+
"download_count": 1667,
103+
"created_at": "2024-08-15T16:36:27Z",
104+
"updated_at": "2024-08-15T16:36:28Z",
105+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-darwin-arm64"
106+
},
107+
{
108+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178225",
109+
"id": 186178225,
110+
"node_id": "RA_kwDOCNqhD84LGNqx",
111+
"name": "kind-darwin-arm64.sha256sum",
112+
"label": null,
113+
"uploader": {
114+
"login": "BenTheElder",
115+
"id": 917931,
116+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
117+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
118+
"gravatar_id": "",
119+
"url": "https://api.github.com/users/BenTheElder",
120+
"html_url": "https://github.com/BenTheElder",
121+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
122+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
123+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
124+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
125+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
126+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
127+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
128+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
129+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
130+
"type": "User",
131+
"site_admin": false
132+
},
133+
"content_type": "application/octet-stream",
134+
"state": "uploaded",
135+
"size": 84,
136+
"download_count": 114,
137+
"created_at": "2024-08-15T16:36:28Z",
138+
"updated_at": "2024-08-15T16:36:29Z",
139+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-darwin-arm64.sha256sum"
140+
},
141+
{
142+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178226",
143+
"id": 186178226,
144+
"node_id": "RA_kwDOCNqhD84LGNqy",
145+
"name": "kind-linux-amd64",
146+
"label": null,
147+
"uploader": {
148+
"login": "BenTheElder",
149+
"id": 917931,
150+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
151+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
152+
"gravatar_id": "",
153+
"url": "https://api.github.com/users/BenTheElder",
154+
"html_url": "https://github.com/BenTheElder",
155+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
156+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
157+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
158+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
159+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
160+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
161+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
162+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
163+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
164+
"type": "User",
165+
"site_admin": false
166+
},
167+
"content_type": "application/octet-stream",
168+
"state": "uploaded",
169+
"size": 9930525,
170+
"download_count": 67587,
171+
"created_at": "2024-08-15T16:36:29Z",
172+
"updated_at": "2024-08-15T16:36:30Z",
173+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-linux-amd64"
174+
},
175+
{
176+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178233",
177+
"id": 186178233,
178+
"node_id": "RA_kwDOCNqhD84LGNq5",
179+
"name": "kind-linux-amd64.sha256sum",
180+
"label": null,
181+
"uploader": {
182+
"login": "BenTheElder",
183+
"id": 917931,
184+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
185+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
186+
"gravatar_id": "",
187+
"url": "https://api.github.com/users/BenTheElder",
188+
"html_url": "https://github.com/BenTheElder",
189+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
190+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
191+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
192+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
193+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
194+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
195+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
196+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
197+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
198+
"type": "User",
199+
"site_admin": false
200+
},
201+
"content_type": "application/octet-stream",
202+
"state": "uploaded",
203+
"size": 83,
204+
"download_count": 10716,
205+
"created_at": "2024-08-15T16:36:30Z",
206+
"updated_at": "2024-08-15T16:36:30Z",
207+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-linux-amd64.sha256sum"
208+
},
209+
{
210+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178234",
211+
"id": 186178234,
212+
"node_id": "RA_kwDOCNqhD84LGNq6",
213+
"name": "kind-linux-arm64",
214+
"label": null,
215+
"uploader": {
216+
"login": "BenTheElder",
217+
"id": 917931,
218+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
219+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
220+
"gravatar_id": "",
221+
"url": "https://api.github.com/users/BenTheElder",
222+
"html_url": "https://github.com/BenTheElder",
223+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
224+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
225+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
226+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
227+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
228+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
229+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
230+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
231+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
232+
"type": "User",
233+
"site_admin": false
234+
},
235+
"content_type": "application/octet-stream",
236+
"state": "uploaded",
237+
"size": 9545487,
238+
"download_count": 827,
239+
"created_at": "2024-08-15T16:36:30Z",
240+
"updated_at": "2024-08-15T16:36:32Z",
241+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-linux-arm64"
242+
},
243+
{
244+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178237",
245+
"id": 186178237,
246+
"node_id": "RA_kwDOCNqhD84LGNq9",
247+
"name": "kind-linux-arm64.sha256sum",
248+
"label": null,
249+
"uploader": {
250+
"login": "BenTheElder",
251+
"id": 917931,
252+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
253+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
254+
"gravatar_id": "",
255+
"url": "https://api.github.com/users/BenTheElder",
256+
"html_url": "https://github.com/BenTheElder",
257+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
258+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
259+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
260+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
261+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
262+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
263+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
264+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
265+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
266+
"type": "User",
267+
"site_admin": false
268+
},
269+
"content_type": "application/octet-stream",
270+
"state": "uploaded",
271+
"size": 83,
272+
"download_count": 164,
273+
"created_at": "2024-08-15T16:36:31Z",
274+
"updated_at": "2024-08-15T16:36:32Z",
275+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-linux-arm64.sha256sum"
276+
},
277+
{
278+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178238",
279+
"id": 186178238,
280+
"node_id": "RA_kwDOCNqhD84LGNq-",
281+
"name": "kind-windows-amd64",
282+
"label": null,
283+
"uploader": {
284+
"login": "BenTheElder",
285+
"id": 917931,
286+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
287+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
288+
"gravatar_id": "",
289+
"url": "https://api.github.com/users/BenTheElder",
290+
"html_url": "https://github.com/BenTheElder",
291+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
292+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
293+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
294+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
295+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
296+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
297+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
298+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
299+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
300+
"type": "User",
301+
"site_admin": false
302+
},
303+
"content_type": "application/octet-stream",
304+
"state": "uploaded",
305+
"size": 10144768,
306+
"download_count": 4624,
307+
"created_at": "2024-08-15T16:36:32Z",
308+
"updated_at": "2024-08-15T16:36:33Z",
309+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-windows-amd64"
310+
},
311+
{
312+
"url": "https://api.github.com/repos/kubernetes-sigs/kind/releases/assets/186178242",
313+
"id": 186178242,
314+
"node_id": "RA_kwDOCNqhD84LGNrC",
315+
"name": "kind-windows-amd64.sha256sum",
316+
"label": null,
317+
"uploader": {
318+
"login": "BenTheElder",
319+
"id": 917931,
320+
"node_id": "MDQ6VXNlcjkxNzkzMQ==",
321+
"avatar_url": "https://avatars.githubusercontent.com/u/917931?v=4",
322+
"gravatar_id": "",
323+
"url": "https://api.github.com/users/BenTheElder",
324+
"html_url": "https://github.com/BenTheElder",
325+
"followers_url": "https://api.github.com/users/BenTheElder/followers",
326+
"following_url": "https://api.github.com/users/BenTheElder/following{/other_user}",
327+
"gists_url": "https://api.github.com/users/BenTheElder/gists{/gist_id}",
328+
"starred_url": "https://api.github.com/users/BenTheElder/starred{/owner}{/repo}",
329+
"subscriptions_url": "https://api.github.com/users/BenTheElder/subscriptions",
330+
"organizations_url": "https://api.github.com/users/BenTheElder/orgs",
331+
"repos_url": "https://api.github.com/users/BenTheElder/repos",
332+
"events_url": "https://api.github.com/users/BenTheElder/events{/privacy}",
333+
"received_events_url": "https://api.github.com/users/BenTheElder/received_events",
334+
"type": "User",
335+
"site_admin": false
336+
},
337+
"content_type": "application/octet-stream",
338+
"state": "uploaded",
339+
"size": 85,
340+
"download_count": 1284,
341+
"created_at": "2024-08-15T16:36:33Z",
342+
"updated_at": "2024-08-15T16:36:33Z",
343+
"browser_download_url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.24.0/kind-windows-amd64.sha256sum"
344+
}
345+
]

0 commit comments

Comments
 (0)
Please sign in to comment.