Skip to content

Commit

Permalink
fix: try to search kubectl on the user path first
Browse files Browse the repository at this point in the history
register/set version/path from existing binary first

fixes #5216
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
  • Loading branch information
benoitf committed Dec 13, 2023
1 parent ac1ae06 commit 39c0e53
Show file tree
Hide file tree
Showing 2 changed files with 235 additions and 33 deletions.
174 changes: 160 additions & 14 deletions extensions/kubectl-cli/src/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as extensionApi from '@podman-desktop/api';
import * as KubectlExtension from './extension';
import { afterEach } from 'node:test';
import type { Configuration } from '@podman-desktop/api';
import * as path from 'node:path';

const extensionContext = {
subscriptions: [],
Expand All @@ -37,6 +38,9 @@ vi.mock('@podman-desktop/api', () => {
exec: vi.fn(),
},
env: {
isMac: true,
isWindows: false,
isLinux: false,
createTelemetryLogger: vi.fn(),
},
configuration: {
Expand All @@ -59,6 +63,7 @@ beforeEach(() => {
vi.mocked(extensionApi.configuration.getConfiguration).mockReturnValue({
update: vi.fn(),
} as unknown as Configuration);
vi.mocked(extensionApi.process.exec).mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -112,18 +117,16 @@ test('kubectl CLI tool registered when detected and extension is activated', asy
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, 'error').mockImplementation(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {
resolve();
});
});

await KubectlExtension.activate(extensionContext);

return deferred.then(() => {
expect(console.error).toBeCalled();
expect(console.error).toBeCalledWith(
expect.stringContaining('Error getting kubectl version: Error: Error running version command'),
);
expect(console.warn).toBeCalled();
expect(console.warn).toBeCalledWith(expect.stringContaining('Error running version command'));
});
});

Expand All @@ -135,7 +138,7 @@ test('kubectl CLI tool not registered when version json stdout cannot be parsed'
});

const deferred = new Promise<void>(resolve => {
vi.spyOn(console, 'error').mockImplementation((message: string) => {
vi.spyOn(console, 'warn').mockImplementation((message: string) => {
log(message);
resolve();
});
Expand All @@ -144,9 +147,11 @@ test('kubectl CLI tool not registered when version json stdout cannot be parsed'
await KubectlExtension.activate(extensionContext);

return deferred.then(() => {
expect(console.error).toBeCalled();
expect(console.error).toBeCalledWith(
expect.stringContaining('Error getting kubectl version: SyntaxError: Unexpected token { in JSON at position 1'),
expect(console.warn).toBeCalled();
expect(console.warn).toBeCalledWith(
expect.stringContaining(
'Error getting kubectl from user PATH: SyntaxError: Unexpected token { in JSON at position 1',
),
);
});
});
Expand All @@ -165,7 +170,7 @@ test('kubectl CLI tool not registered when version cannot be extracted from obje
});

const deferred = new Promise<void>(resolve => {
vi.spyOn(console, 'error').mockImplementation((message: string) => {
vi.spyOn(console, 'warn').mockImplementation((message: string) => {
log(message);
resolve();
});
Expand All @@ -174,9 +179,150 @@ test('kubectl CLI tool not registered when version cannot be extracted from obje
await KubectlExtension.activate(extensionContext);

return deferred.then(() => {
expect(console.error).toBeCalled();
expect(console.error).toBeCalledWith(
expect.stringContaining('Error getting kubectl version: Error: Cannot extract version from stdout'),
);
expect(console.warn).toBeCalled();
expect(console.warn).toBeCalledWith(expect.stringContaining('Error: Cannot extract version from stdout'));
});
});

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).mockResolvedValue({
stderr: '',
stdout: JSON.stringify(wrongJsonStdout),
command: 'kubectl version --client=true -o=json',
});

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

await KubectlExtension.activate(extensionContext);

return deferred.then(() => {
expect(console.warn).toBeCalled();
expect(console.warn).toBeCalledWith(expect.stringContaining('Error: Cannot extract version from stdout'));
});
});

test('getStorageKubectlPath', async () => {
// get current directory
const currentDirectory = process.cwd();

const extensionContext = {
storagePath: currentDirectory,
} as unknown as extensionApi.ExtensionContext;

const storagePath = KubectlExtension.getStorageKubectlPath(extensionContext);
expect(storagePath).toContain(path.resolve(currentDirectory, 'bin', 'kubectl'));
});

test('findKubeCtl with global kubectl being installed on macOS', async () => {
// get current directory
const currentDirectory = process.cwd();

const extensionContext = {
storagePath: currentDirectory,
} as unknown as extensionApi.ExtensionContext;

// first call is replying the kubectl version
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify(jsonStdout),
command: 'kubectl version --client=true -o=json',
});

//
vi.mocked(extensionApi.env).isMac = true;

// second call is replying the path to kubectl
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: '/fake/directory/kubectl',
command: 'which kubectl',
});

const { version, path } = await KubectlExtension.findKubeCtl(extensionContext);
expect(version).toBe('1.28.3');
expect(path).toBe('/fake/directory/kubectl');

// expect we call with which
expect(extensionApi.process.exec).toBeCalledWith('which', expect.anything());
});

test('findKubeCtl with global kubectl being installed on Windows', async () => {
// get current directory
const currentDirectory = process.cwd();

const extensionContext = {
storagePath: currentDirectory,
} as unknown as extensionApi.ExtensionContext;

// first call is replying the kubectl version
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify(jsonStdout),
command: 'kubectl version --client=true -o=json',
});

//
vi.mocked(extensionApi.env).isMac = false;
vi.mocked(extensionApi.env).isLinux = false;
vi.mocked(extensionApi.env).isWindows = true;

// second call is replying the path to kubectl
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: '/fake/directory/kubectl',
command: 'which kubectl',
});

const { version, path } = await KubectlExtension.findKubeCtl(extensionContext);
expect(version).toBe('1.28.3');
expect(path).toBe('/fake/directory/kubectl');

// expect we call with which
expect(extensionApi.process.exec).toBeCalledWith('where', expect.anything());
});

test('findKubeCtl not global kubectl but in storage installed on macOS', async () => {
// get current directory
const fakeStorageDirectory = '/fake/directory';

const extensionContext = {
storagePath: fakeStorageDirectory,
} as unknown as extensionApi.ExtensionContext;

// first call is replying the kubectl version
vi.mocked(extensionApi.process.exec).mockRejectedValueOnce(new Error('Error running kubectl command'));

//
vi.mocked(extensionApi.env).isMac = true;

// second call is replying the path to storage kubectl
vi.mocked(extensionApi.process.exec).mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify(jsonStdout),
command: 'kubectl version --client=true -o=json',
});

// mock extension storage path
vi.spyOn(KubectlExtension, 'getStorageKubectlPath').mockReturnValue('/fake/directory/kubectl');

const { version, path } = await KubectlExtension.findKubeCtl(extensionContext);
expect(version).toBe('1.28.3');
expect(path).toContain('fake');
expect(path).toContain('directory');
expect(path).toContain('bin');
expect(path).toContain('kubectl');

// expect no call with which
expect(extensionApi.process.exec).not.toBeCalledWith('which', expect.anything());
});
94 changes: 75 additions & 19 deletions extensions/kubectl-cli/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export function initTelemetryLogger(): void {
}

const kubectlCliName = 'kubectl';
const kubectlExecutableName = extensionApi.env.isWindows ? kubectlCliName + '.exe' : kubectlCliName;

const kubectlCliDisplayName = 'kubectl';
const kubectlCliDescription = `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/)`;
const imageLocation = './icon.png';
Expand Down Expand Up @@ -232,50 +234,104 @@ export async function activate(extensionContext: extensionApi.ExtensionContext):
}, 0);
}

// Activate the CLI tool (check version, etc) and register the CLi so it does not block activation.
async function postActivate(
extensionContext: extensionApi.ExtensionContext,
kubectlDownload: KubectlDownload,
): Promise<void> {
// The location of the binary (local storage folder)
const binaryPath = path.join(
extensionContext.storagePath,
'bin',
os.isWindows() ? kubectlCliName + '.exe' : kubectlCliName,
);
interface CliFinder {
version: string;
path: string;
}

export function getStorageKubectlPath(extensionContext: extensionApi.ExtensionContext): string {
// The location of the binary (using system path)
return path.join(extensionContext.storagePath, 'bin', kubectlExecutableName);
}

export async function findKubeCtl(extensionContext: extensionApi.ExtensionContext): Promise<CliFinder> {
let binaryVersion = '';
let binaryPath = '';

// Retrieve the version of the binary by running exec with --short
// Retrieve the version of the binary by running exec with --client
try {
const result = await extensionApi.process.exec(binaryPath, ['version', '--client', 'true', '-o', 'json']);
const result = await extensionApi.process.exec(kubectlExecutableName, [
'version',
'--client',
'true',
'-o',
'json',
]);
binaryVersion = extractVersion(result.stdout);

// grab full path for Linux and mac
if (extensionApi.env.isLinux || extensionApi.env.isMac) {
try {
const { stdout: fullPath } = await extensionApi.process.exec('which', [kubectlExecutableName]);
binaryPath = fullPath;
} catch (err) {
console.warn('Error getting kubectl full path', err);
}
} else if (extensionApi.env.isWindows) {
// grab full path for Windows
try {
const { stdout: fullPath } = await extensionApi.process.exec('where', [kubectlExecutableName]);
// remove all line break/carriage return characters from full path
const withoutCR = fullPath.replace(/(\r\n|\n|\r)/gm, '');
binaryPath = withoutCR;
} catch (err) {
console.warn('Error getting kubectl full path', err);
}
}

if (!binaryPath) {
binaryPath = 'kubectl';
}
} catch (e) {
console.error(`Error getting kubectl version: ${e}`);
console.warn(`Error getting kubectl from user PATH: ${e}, trying from extension storage path`);
try {
const result = await extensionApi.process.exec(getStorageKubectlPath(extensionContext), [
'version',
'--client',
'true',
'-o',
'json',
]);
binaryVersion = extractVersion(result.stdout);
binaryPath = getStorageKubectlPath(extensionContext);
} catch (error) {
console.warn('Error getting kubectl version system from extension storage path', error);
}
}

return { version: binaryVersion, path: binaryPath };
}

// Activate the CLI tool (check version, etc) and register the CLi so it does not block activation.
async function postActivate(
extensionContext: extensionApi.ExtensionContext,
kubectlDownload: KubectlDownload,
): Promise<void> {
const { version, path } = await findKubeCtl(extensionContext);

// Register the CLI tool so it appears in the preferences page. We will detect which version is being ran by
// checking the local storage folder for the binary. If it exists, we will run `--version` and parse the information.
// checking the binary. If it exists, we will run `--version` and parse the information.
kubectlCliTool = extensionApi.cli.createCliTool({
name: kubectlCliName,
displayName: kubectlCliDisplayName,
markdownDescription: kubectlCliDescription,
images: {
icon: imageLocation,
},
version: binaryVersion,
path: binaryPath,
version,
path,
});

// check if there is a new version to be installed and register the updater
const lastReleaseMetadata = await kubectlDownload.getLatestVersionAsset();
const lastReleaseVersion = lastReleaseMetadata.tag.slice(1);
if (lastReleaseVersion !== binaryVersion) {
if (lastReleaseVersion !== version) {
kubectlCliToolUpdaterDisposable = kubectlCliTool.registerUpdate({
version: lastReleaseVersion,
doUpdate: async _logger => {
// download, install system wide and update cli version
await kubectlDownload.download(lastReleaseMetadata);
await installBinaryToSystem(binaryPath, 'kubectl');
await installBinaryToSystem(path, 'kubectl');
kubectlCliTool.updateVersion({
version: lastReleaseVersion,
});
Expand Down

0 comments on commit 39c0e53

Please sign in to comment.