Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: register cli tool #78

Merged
merged 2 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/download.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import * as fs from 'node:fs';
import * as path from 'node:path';
import { beforeEach } from 'node:test';

import type * as extensionApi from '@podman-desktop/api';
import { afterEach, expect, test, vi } from 'vitest';

import type { MinikubeGithubReleaseArtifactMetadata } from './download';
import { MinikubeDownload } from './download';
import type { Octokit } from '@octokit/rest';

// Create the OS class as well as fake extensionContext
const extensionContext: extensionApi.ExtensionContext = {
storagePath: '/fake/path',
subscriptions: [],
} as unknown as extensionApi.ExtensionContext;

// We are also testing fs, but we need fs for reading the JSON file, so we will use "vi.importActual"
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const fsActual = await vi.importActual<typeof import('node:fs')>('node:fs');

const releases: MinikubeGithubReleaseArtifactMetadata[] = [
JSON.parse(
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/minikube-github-release-all.json'), 'utf8'),
),
].map((release: { name: string; tag_name: string; id: number }) => {
return {
label: release.name || release.tag_name,
tag: release.tag_name,
id: release.id,
};
});

const listReleaseAssetsMock = vi.fn();
const listReleasesMock = vi.fn();
const getReleaseAssetMock = vi.fn();
const octokitMock: Octokit = {
repos: {
listReleases: listReleasesMock,
listReleaseAssets: listReleaseAssetsMock,
getReleaseAsset: getReleaseAssetMock,
},
} as unknown as Octokit;

beforeEach(() => {
vi.resetAllMocks();
});

afterEach(() => {
vi.resetAllMocks();
vi.restoreAllMocks();
});

test('expect getLatestVersionAsset to return the first release from a list of releases', async () => {
// Expect the test to return the first release from the list (as the function simply returns the first one)
const minikubeDownload = new MinikubeDownload(extensionContext, octokitMock);
vi.spyOn(minikubeDownload, 'grabLatestsReleasesMetadata').mockResolvedValue(releases);
const result = await minikubeDownload.getLatestVersionAsset();
expect(result).toBeDefined();
expect(result).toEqual(releases[0]);
});

test('get release asset id should return correct id', async () => {
const resultREST = JSON.parse(
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/minikube-github-release-assets.json'), 'utf8'),
);

listReleaseAssetsMock.mockImplementation(() => {
return { data: resultREST };
});

const minikubeDownload = new MinikubeDownload(extensionContext, octokitMock);
const assetId = await minikubeDownload.getReleaseAssetId(167707968, 'linux', 'x64');

expect(assetId).equals(167708030);
});

test('throw if there is no release asset for that os and arch', async () => {
const resultREST = JSON.parse(
fsActual.readFileSync(path.resolve(__dirname, '../tests/resources/minikube-github-release-assets.json'), 'utf8'),
);

listReleaseAssetsMock.mockImplementation(() => {
return { data: resultREST };
});

const minikubeDownload = new MinikubeDownload(extensionContext, octokitMock);
await expect(minikubeDownload.getReleaseAssetId(167707968, 'windows', 'x64')).rejects.toThrowError(
'No asset found for windows and amd64',
);
});

test('test download of minikube passes and that mkdir and executable mocks are called', async () => {
const minikubeDownload = new MinikubeDownload(extensionContext, octokitMock);

vi.spyOn(minikubeDownload, 'getReleaseAssetId').mockResolvedValue(167707925);
vi.spyOn(minikubeDownload, 'downloadReleaseAsset').mockResolvedValue();
vi.spyOn(minikubeDownload, 'makeExecutable').mockResolvedValue();
const makeExecutableMock = vi.spyOn(minikubeDownload, 'makeExecutable');
const mkdirMock = vi.spyOn(fs.promises, 'mkdir');

// Mock that the storage path does not exist
vi.mock('node:fs');
vi.spyOn(fs, 'existsSync').mockImplementation(() => {
return false;
});

// Mock the mkdir to return "success"
mkdirMock.mockResolvedValue(undefined);

await minikubeDownload.download(releases[0]);

// Expect the mkdir and executables to have been called
expect(mkdirMock).toHaveBeenCalled();
expect(makeExecutableMock).toHaveBeenCalled();
});
154 changes: 154 additions & 0 deletions src/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { existsSync, promises } from 'node:fs';
import { arch, platform } from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';

import type * as extensionApi from '@podman-desktop/api';

import type { Octokit } from '@octokit/rest';

export interface MinikubeGithubReleaseArtifactMetadata {
tag: string;
id: number;
}

const githubOrganization = 'kubernetes';
const githubRepo = 'minikube';

export class MinikubeDownload {
constructor(
private readonly extensionContext: extensionApi.ExtensionContext,
private readonly octokit: Octokit,
) {}

// Provides last 5 majors releases from GitHub using the GitHub API
// return name, tag and id of the release
async grabLatestsReleasesMetadata(): Promise<MinikubeGithubReleaseArtifactMetadata[]> {
// Grab last 5 majors releases from GitHub using the GitHub API
const lastReleases = await this.octokit.repos.listReleases({
owner: githubOrganization,
repo: githubRepo,
per_page: 10,
});

return lastReleases.data
.filter(release => !release.prerelease)
.map(release => {
return {
label: release.name ?? release.tag_name,
tag: release.tag_name,
id: release.id,
};
})
.slice(0, 5);
}

async getLatestVersionAsset(): Promise<MinikubeGithubReleaseArtifactMetadata> {
const latestReleases = await this.grabLatestsReleasesMetadata();
return latestReleases[0];
}

// Download minikube from the artifact metadata: MinikubeGithubReleaseArtifactMetadata
// this will download it to the storage bin folder as well as make it executable
// return the path where the file has been downloaded
async download(release: MinikubeGithubReleaseArtifactMetadata): Promise<string> {
// Get asset id
const assetId = await this.getReleaseAssetId(release.id, platform(), arch());

// Get the storage and check to see if it exists before we download kubectl
const storageData = this.extensionContext.storagePath;
const storageBinFolder = path.resolve(storageData, 'bin');
if (!existsSync(storageBinFolder)) {
await promises.mkdir(storageBinFolder, { recursive: true });
}

// Correct the file extension and path resolution
let fileExtension = '';
if (process.platform === 'win32') {
fileExtension = '.exe';
}
const minikubeDownloadLocation = path.resolve(storageBinFolder, `minikube${fileExtension}`);

// Download the asset and make it executable
await this.downloadReleaseAsset(assetId, minikubeDownloadLocation);
await this.makeExecutable(minikubeDownloadLocation);

return minikubeDownloadLocation;
}

async makeExecutable(filePath: string): Promise<void> {
if (process.platform === 'darwin' || process.platform === 'linux') {
await promises.chmod(filePath, 0o755);
}
}

// Get the asset id of a given release number for a given operating system and architecture
// operatingSystem: win32, darwin, linux (see os.platform())
// arch: x64, arm64 (see os.arch())
async getReleaseAssetId(releaseId: number, operatingSystem: string, arch: string): Promise<number> {
let extension = '';
if (operatingSystem === 'win32') {
operatingSystem = 'windows';
extension = '.exe';
}
if (arch === 'x64') {
arch = 'amd64';
}

const listOfAssets = await this.octokit.repos.listReleaseAssets({
owner: githubOrganization,
repo: githubRepo,
release_id: releaseId,
per_page: 60,
});

const searchedAssetName = `minikube-${operatingSystem}-${arch}${extension}`;

// search for the right asset
const asset = listOfAssets.data.find(asset => searchedAssetName === asset.name);
if (!asset) {
throw new Error(`No asset found for ${operatingSystem} and ${arch}`);
}

return asset.id;
}

// download the given asset id
async downloadReleaseAsset(assetId: number, destination: string): Promise<void> {
const asset = await this.octokit.repos.getReleaseAsset({
owner: githubOrganization,
repo: githubRepo,
asset_id: assetId,
headers: {
accept: 'application/octet-stream',
},
});

// check the parent folder exists
const parentFolder = path.dirname(destination);

if (!fs.existsSync(parentFolder)) {
await fs.promises.mkdir(parentFolder, { recursive: true });
}
// write the file
await fs.promises.writeFile(destination, Buffer.from(asset.data as unknown as ArrayBuffer));
}
}
Loading