Skip to content

Commit

Permalink
feat: add extension/kubectl-cli to register kubectl CLI Tool (podman-…
Browse files Browse the repository at this point in the history
…desktop#4674)

The extension adds kubectl CLI Tool card to `Settings/CLI Tools` page if it can find tool location and extract version from version command output. 

Signed-off-by: Denis Golovin <[email protected]>
  • Loading branch information
dgolovin authored Nov 15, 2023
1 parent 75de50e commit 1326a9c
Show file tree
Hide file tree
Showing 11 changed files with 499 additions and 3 deletions.
4 changes: 4 additions & 0 deletions extensions/kubectl-cli/.extfiles
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist/**
node_modules/**
icon.png
package.json
3 changes: 3 additions & 0 deletions extensions/kubectl-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# kubectl CLI Tool for Podman Desktop

Podman-desktop kubectl CLI Tool extension repository.
Binary file added extensions/kubectl-cli/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions extensions/kubectl-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "kubectl-cli-tool",
"displayName": "kubectl CLI",
"description": "Install and update kubectl CLI Tools without leaving Podman Desktop",
"version": "0.0.1",
"icon": "icon.png",
"publisher": "redhat",
"license": "Apache-2.0",
"engines": {
"podman-desktop": "^0.0.1"
},
"main": "./dist/extension.js",
"scripts": {
"build": "vite build && node ./scripts/build.js",
"test": "vitest run --coverage",
"watch": "vite build -w",
"format:check": "prettier --check \"**/*.ts\" \"scripts/*.js\"",
"format:fix": "prettier --write \"**/*.ts\" \"scripts/*.js\""
},
"dependencies": {
"@podman-desktop/api": "^0.0.1"
},
"devDependencies": {
"byline": "^5.0.0",
"copyfiles": "^2.4.1",
"mkdirp": "^2.1.3",
"vitest": "^0.34.6",
"zip-local": "^0.3.5"
}
}
63 changes: 63 additions & 0 deletions extensions/kubectl-cli/scripts/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**********************************************************************
* Copyright (C) 2023 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
***********************************************************************/

const zipper = require('zip-local');
const path = require('path');
const package = require('../package.json');
const { mkdirp } = require('mkdirp');
const fs = require('fs');
const byline = require('byline');
const cp = require('copyfiles');

const destFile = path.resolve(__dirname, `../${package.name}.cdix`);
const builtinDirectory = path.resolve(__dirname, '../builtin');
const zipDirectory = path.resolve(builtinDirectory, `${package.name}.cdix`);
const extFiles = path.resolve(__dirname, '../.extfiles');
const fileStream = fs.createReadStream(extFiles, { encoding: 'utf8' });

const includedFiles = [];

// remove the .cdix file before zipping
if (fs.existsSync(destFile)) {
fs.rmSync(destFile);
}
// remove the builtin folder before zipping
if (fs.existsSync(builtinDirectory)) {
fs.rmSync(builtinDirectory, { recursive: true, force: true });
}

byline(fileStream)
.on('data', line => {
includedFiles.push(line);
})
.on('error', () => {
throw new Error('Error reading .extfiles');
})
.on('end', () => {
includedFiles.push(zipDirectory);
mkdirp.sync(zipDirectory);
console.log(`Copying files to ${zipDirectory}`);
cp(includedFiles, error => {
if (error) {
throw new Error('Error copying files', error);
}
console.log(`Zipping files to ${destFile}`);
zipper.sync.zip(zipDirectory).compress().save(destFile);
});
});
189 changes: 189 additions & 0 deletions extensions/kubectl-cli/src/extension.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**********************************************************************
* Copyright (C) 2023 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
***********************************************************************/
/* eslint-disable @typescript-eslint/no-explicit-any */

import { test, expect, vi } from 'vitest';
import * as extensionApi from '@podman-desktop/api';
import * as KubectlExtension from './extension';
import { afterEach } from 'node:test';

vi.mock('@podman-desktop/api', () => {
return {
cli: {
createCliTool: vi.fn(),
},
process: {
exec: vi.fn(),
},
};
});

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

const log = console.log;

const jsonStdout = {
clientVersion: {
major: '1',
minor: '28',
gitVersion: 'v1.28.3',
gitCommit: 'a8a1abc25cad87333840cd7d54be2efaf31a3177',
gitTreeState: 'clean',
buildDate: '2023-10-18T11:33:16Z',
goVersion: 'go1.20.10',
compiler: 'gc',
platform: 'darwin/arm64',
},
kustomizeVersion: 'v5.0.4-0.20230601165947-6ce0bf390ce3',
};

test('kubectl CLI tool registered when detected and extension is activated', async () => {
vi.mocked(extensionApi.process.exec)
.mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify(jsonStdout),
command: 'kubectl version --client=true -o=json',
})
.mockResolvedValueOnce({
stderr: '',
stdout: '/path1/to/kubectl\n/path2/to/kubectl',
command: 'kubectl version --client=true -o=json',
});

const deferred = new Promise<void>(resolve => {
vi.mocked(extensionApi.cli.createCliTool).mockImplementation(() => {
resolve();
return {} as extensionApi.CliTool;
});
});

KubectlExtension.activate();

return deferred.then(() => {
expect(extensionApi.cli.createCliTool).toBeCalled();
expect(extensionApi.cli.createCliTool).toBeCalledWith(
expect.objectContaining({
name: 'kubectl',
version: '1.28.3',
path: '/path1/to/kubectl',
}),
);
});
});

test('kubectl CLI tool not registered when not detected', async () => {
vi.mocked(extensionApi.process.exec).mockRejectedValue(new Error('Error running version command'));
const deferred = new Promise<void>(resolve => {
vi.spyOn(console, 'log').mockImplementation(() => {
resolve();
});
});

KubectlExtension.activate();

return deferred.then(() => {
expect(console.log).toBeCalled();
expect(console.log).toBeCalledWith(expect.stringContaining('Cannot detect kubectl CLI tool:'));
});
});

test('kubectl CLI tool not registered when version json stdout cannot be parsed', async () => {
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: `{${JSON.stringify(jsonStdout)}`,
command: 'kubectl version --client=true -o=json',
});

const deferred = new Promise<void>(resolve => {
vi.spyOn(console, 'log').mockImplementation((message: string) => {
log(message);
resolve();
});
});

KubectlExtension.activate();

return deferred.then(() => {
expect(console.log).toBeCalled();
expect(console.log).toBeCalledWith(
expect.stringContaining('Cannot detect kubectl CLI tool: SyntaxError: Unexpected token {'),
);
});
});

test('kubectl CLI tool not registered when version cannot be extracted from object', async () => {
const wrongJsonStdout = {
clientVersion: {
...jsonStdout.clientVersion,
},
};
delete (wrongJsonStdout.clientVersion as any).gitVersion;
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify(wrongJsonStdout),
command: 'kubectl version --client=true -o=json',
});

const deferred = new Promise<void>(resolve => {
vi.spyOn(console, 'log').mockImplementation((message: string) => {
log(message);
resolve();
});
});

KubectlExtension.activate();

return deferred.then(() => {
expect(console.log).toBeCalled();
expect(console.log).toBeCalledWith(
expect.stringContaining('Cannot detect kubectl CLI tool: Error: Cannot extract version from stdout'),
);
});
});

test('kubectl CLI tool not registered when path cannot be found', async () => {
vi.mocked(extensionApi.process.exec)
.mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify(jsonStdout),
command: 'kubectl version --client=true -o=json',
})
.mockResolvedValueOnce({
stderr: '',
stdout: '',
command: 'detect location command',
});

const deferred = new Promise<void>(resolve => {
vi.spyOn(console, 'log').mockImplementation((message: string) => {
log(message);
resolve();
});
});

KubectlExtension.activate();

return deferred.then(() => {
expect(console.log).toBeCalled();
expect(console.log).toBeCalledWith(
expect.stringContaining('Cannot detect kubectl CLI tool: Error: Cannot extract path form stdout'),
);
});
});
85 changes: 85 additions & 0 deletions extensions/kubectl-cli/src/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**********************************************************************
* Copyright (C) 2023 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 type { CliToolOptions } from '@podman-desktop/api';
import { cli, process as podmanProcess } from '@podman-desktop/api';

type KubectlInfo = Pick<CliToolOptions, 'version' | 'path'>;

interface KubectlVersionOutput {
clientVersion: {
major: string;
minor: string;
gitVersion: string;
gitCommit: string;
gitTreeState: string;
buildDate: string;
goVersion: string;
compiler: string;
platform: string;
};
kustomizeVersion: string;
}

export function activate() {
setTimeout(() => {
detectTool('kubectl', ['version', '--client=true', '-o=json'])
.then(result => registerTool(result))
.catch((error: unknown) => console.log(`Cannot detect kubectl CLI tool: ${String(error)}`));
});
}

function extractVersion(stdout: string): string {
const versionOutput = JSON.parse(stdout) as KubectlVersionOutput;
const version: string = versionOutput?.clientVersion?.gitVersion?.replace('v', '');
if (version) {
return version;
}
throw new Error('Cannot extract version from stdout');
}

function extractPath(stdout: string): string {
const location = stdout.split('\n')[0];
if (location) {
return location;
}
throw new Error('Cannot extract path form stdout');
}

async function detectTool(toolName: string, versionOptions: string[]): Promise<KubectlInfo> {
const version = await podmanProcess.exec(toolName, versionOptions).then(result => extractVersion(result.stdout));
const path = await podmanProcess
.exec(process.platform === 'win32' ? 'where' : 'which', [toolName])
.then(result => extractPath(result.stdout));
return { version, path };
}

const markdownDescription = `A command line tool for communicating with a Kubernetes cluster's control plane, using the Kubernetes API.\n\nMore information: [kubernetes.io](https://kubernetes.io/docs/reference/kubectl/)`;

async function registerTool(cliInfo: KubectlInfo) {
cli.createCliTool({
markdownDescription,
name: 'kubectl',
displayName: 'kubectl',
images: {
icon: 'icon.png',
},
version: cliInfo.version,
path: cliInfo.path,
});
}
11 changes: 11 additions & 0 deletions extensions/kubectl-cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"lib": ["ES2017", "webworker"],
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}
Loading

0 comments on commit 1326a9c

Please sign in to comment.