Skip to content

Commit 977f037

Browse files
committed
feat: enhance type safety and error handling in extension
Fix #194. - Updated package.json to include a typecheck script. - Updated build script to include typecheck strp. - Improved type definitions in extension.spec.ts and extension.ts for better type safety. - Added error handling for missing context, cluster, and user in OpenShift functions. - Refactored functions to ensure undefined checks and proper error messages. - Enhanced the getSandboxAPIUrl function to return undefined when no URL is configured. Signed-off-by: Denis Golovin <dgolovin@redhat.com>
1 parent 3808e1b commit 977f037

5 files changed

Lines changed: 77 additions & 46 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,11 @@
7474
}
7575
},
7676
"scripts": {
77-
"build": "vite build && node ./scripts/build.cjs",
77+
"build": "pnpm typecheck && vite build && node ./scripts/build.cjs",
7878
"watch": "vite build -w",
7979
"format:check": "prettier --check \"**/*.ts\" \"scripts/*.cjs\"",
8080
"format:fix": "prettier --write \"**/*.ts\" \"scripts/*.cjs\"",
81+
"typecheck": "tsc --noEmit",
8182
"test": "vitest run --coverage",
8283
"test:e2e:setup": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' --",
8384
"test:e2e": "pnpm run test:e2e:setup npx playwright test tests/src"

src/extension.spec.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { URI } from 'vscode-uri';
2525
import * as openshift from './openshift.js';
2626
import * as sandbox from './sandbox.js';
2727
import * as kubeconfig from './kubeconfig.js';
28-
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
28+
import { CoreV1Api, KubeConfig, type V1Secret } from '@kubernetes/client-node';
2929
import got from 'got';
3030

3131
const context: podmanDesktopApi.ExtensionContext = {
@@ -91,7 +91,7 @@ suite('kubernetes provider connection factory', () => {
9191
config: KubeConfig = new KubeConfig(),
9292
mockSandboxCalls?: () => void,
9393
mockGetPipelineServiceAccountToken?: () => void,
94-
): Promise<{ error: Error; provider: podmanDesktopApi.Provider } | undefined> {
94+
): Promise<{ error: Error | undefined; provider: podmanDesktopApi.Provider }> {
9595
const providerMock: podmanDesktopApi.Provider = {
9696
setKubernetesProviderConnectionFactory: vi.fn(),
9797
registerKubernetesProviderConnection: () => ({
@@ -106,7 +106,7 @@ suite('kubernetes provider connection factory', () => {
106106
} as unknown as podmanDesktopApi.AuthenticationSession);
107107
await extension.activate(context);
108108
const connectionFactory = vi.mocked(providerMock.setKubernetesProviderConnectionFactory).mock.calls[0][0];
109-
let verificationError: Error;
109+
let verificationError: Error | undefined = undefined;
110110
try {
111111
mockSandboxCalls?.();
112112
if (!mockSandboxCalls) {
@@ -125,9 +125,12 @@ suite('kubernetes provider connection factory', () => {
125125
vi.spyOn(openshift, 'whoami').mockResolvedValue('system:serviceaccount:username-dev:pipeline');
126126
vi.spyOn(kubeconfig, 'createOrLoadFromFile').mockReturnValue(config);
127127
vi.spyOn(kubeconfig, 'exportToFile').mockImplementation(vi.fn());
128-
await connectionFactory.create(params);
128+
129+
expect(connectionFactory).toBeDefined();
130+
expect(typeof connectionFactory?.create).toBe('function');
131+
await connectionFactory?.create?.(params);
129132
} catch (e) {
130-
verificationError = e;
133+
verificationError = e as Error;
131134
}
132135
return {
133136
error: verificationError,
@@ -139,7 +142,7 @@ suite('kubernetes provider connection factory', () => {
139142
const { error: verificationError } = await callCreate();
140143

141144
expect(verificationError).toBeDefined();
142-
expect(verificationError.message).is.equal('Context name is required.');
145+
expect(verificationError?.message).is.equal('Context name is required.');
143146
});
144147

145148
test('creates new context for sandbox with specified url/token and sets it as default context', async () => {
@@ -344,8 +347,8 @@ suite('kubernetes provider connection factory', () => {
344347
},
345348
],
346349
});
347-
vi.spyOn(CoreV1Api.prototype, 'createNamespacedSecret').mockResolvedValue(undefined);
348-
const responseError = new Error();
350+
vi.spyOn(CoreV1Api.prototype, 'createNamespacedSecret').mockResolvedValue({} as V1Secret);
351+
const responseError: any = new Error();
349352
responseError['response'] = {
350353
statusCode: 404,
351354
};
@@ -444,7 +447,7 @@ test('push image to sandbox does not change title after it is finished', async (
444447
return config;
445448
});
446449
const registerCommandMock = vi.mocked(podmanDesktopApi.commands.registerCommand);
447-
let commandCallback: (...args: any[]) => any;
450+
let commandCallback: ((...args: any[]) => any) | undefined;
448451
registerCommandMock.mockImplementation((commandId: string, callback: (...args: any[]) => any) => {
449452
if (commandId === 'sandbox.image.push.to.cluster') {
450453
commandCallback = callback;
@@ -456,7 +459,7 @@ test('push image to sandbox does not change title after it is finished', async (
456459
let registeredConnection: { status: () => any };
457460
const providerMock: podmanDesktopApi.Provider = {
458461
setKubernetesProviderConnectionFactory: vi.fn(),
459-
registerKubernetesProviderConnection: connection => {
462+
registerKubernetesProviderConnection: (connection: podmanDesktopApi.KubernetesProviderConnection) => {
460463
registeredConnection = connection;
461464
return {
462465
dispose: vi.fn(),
@@ -479,20 +482,20 @@ test('push image to sandbox does not change title after it is finished', async (
479482
const progress: podmanDesktopApi.Progress<{ message?: string; increment?: number }> = {
480483
report,
481484
};
482-
task(progress, undefined);
483-
return;
485+
task(progress, {} as podmanDesktopApi.CancellationToken);
486+
return Promise.resolve();
484487
},
485488
);
486-
let pushImageCallback: (name: string, data?: string) => void;
489+
let pushImageCallback: ((name: string, data?: string) => void) | undefined;
487490
vi.mocked(podmanDesktopApi.containerEngine.pushImage).mockImplementation(
488-
async (
491+
(async (
489492
_engineId: string,
490493
_imageId: string,
491-
callback: (name: string, data: string) => Promise<void>,
494+
callback: (name: string, data?: string) => void,
492495
_authInfo?: podmanDesktopApi.ContainerAuthInfo,
493496
) => {
494497
pushImageCallback = callback;
495-
},
498+
}) as any,
496499
);
497500
await extension.activate(context);
498501

@@ -504,14 +507,14 @@ test('push image to sandbox does not change title after it is finished', async (
504507

505508
const imageInfo = { engineId: 'podman', name: 'imageName', tag: 'registry-host/repository/image' };
506509

507-
await Promise.resolve(commandCallback(...[imageInfo]));
510+
await Promise.resolve(commandCallback?.(...[imageInfo]));
508511

509512
await vi.waitFor(async () => {
510513
expect(pushImageCallback).toBeDefined();
511514
}, 3000);
512515

513-
pushImageCallback('data', 'data-chunk-1');
514-
pushImageCallback('end');
516+
pushImageCallback?.('data', 'data-chunk-1');
517+
pushImageCallback?.('end');
515518

516519
expect(report).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'data-chunk-1' }));
517520
});

src/extension.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function pushImageToOpenShiftRegistry(image: ImageInfo): Promise<vo
5656
);
5757
return;
5858
}
59-
let targetSb: string;
59+
let targetSb: string | undefined;
6060
if (qp.length > 1) {
6161
targetSb = await extensionApi.window.showQuickPick(qp);
6262
if (!targetSb) {
@@ -76,8 +76,9 @@ export async function pushImageToOpenShiftRegistry(image: ImageInfo): Promise<vo
7676
progress.report({ increment: 25 });
7777
const registryInfo = await getOpenShiftInternalRegistryPublicHost(targetSb);
7878
progress.report({ increment: 50 });
79-
const lastIndexOfSlash = image.name.lastIndexOf('/');
80-
const imageShortName = lastIndexOfSlash !== -1 ? image.name.substring(lastIndexOfSlash + 1) : image.name;
79+
const imageName = image.name ?? '';
80+
const lastIndexOfSlash = imageName.lastIndexOf('/');
81+
const imageShortName = lastIndexOfSlash !== -1 ? imageName.substring(lastIndexOfSlash + 1) : imageName;
8182
const imageTagSuffix = image.tag ? `:${image.tag}` : ``;
8283
const localImageName = `${image.name}${imageTagSuffix}`;
8384
const remoteImageName = `${registryInfo.host}/${registryInfo.username}-dev/${imageShortName}${imageTagSuffix}`;
@@ -128,18 +129,23 @@ export async function pushImageToOpenShiftRegistry(image: ImageInfo): Promise<vo
128129
async function deleteContext(contextName: string): Promise<void> {
129130
const config = kubeconfig.createOrLoadFromFile(extensionApi.kubernetes.getKubeconfig().fsPath);
130131
const context = config.getContextObject(contextName);
132+
if (!context) return;
131133
const cluster = config.getCluster(context.cluster);
132134
const user = config.getUser(context.user);
133135
config.getContexts().splice(config.getContexts().indexOf(context), 1);
134-
config.getClusters().splice(config.getClusters().indexOf(cluster), 1);
135-
config.getUsers().splice(config.getUsers().indexOf(user), 1);
136+
if (cluster) {
137+
config.getClusters().splice(config.getClusters().indexOf(cluster), 1);
138+
}
139+
if (user) {
140+
config.getUsers().splice(config.getUsers().indexOf(user), 1);
141+
}
136142
kubeconfig.exportToFile(config, extensionApi.kubernetes.getKubeconfig().fsPath);
137143
}
138144

139145
function deleteConnection(contextName: string) {
140146
const deletedConnection = registeredConnections.get(contextName);
141147
registeredConnections.delete(contextName);
142-
deletedConnection.disposable.dispose();
148+
deletedConnection?.disposable?.dispose();
143149
}
144150

145151
async function deleteConnectionAndUpdateKubeconfig(contextName: string): Promise<void> {
@@ -152,7 +158,7 @@ async function registerConnection(contextName: string, apiURL: string, token: st
152158
// const status = await getConnectionStatus(apiURL, token);
153159
const connection = {
154160
name: contextName,
155-
status: () => registeredConnections.get(contextName).status,
161+
status: () => registeredConnections.get(contextName)?.status ?? UnknownStatus,
156162
endpoint: {
157163
apiURL,
158164
},
@@ -169,7 +175,7 @@ async function registerConnection(contextName: string, apiURL: string, token: st
169175
}
170176

171177
export async function getDevSandboxSignUpStatus(idToken: string): Promise<SBSignupResponse> {
172-
let status: SBSignupResponse;
178+
let status: SBSignupResponse | undefined;
173179
try {
174180
status = await getSignUpStatus(idToken);
175181
} catch (error) {
@@ -195,6 +201,10 @@ export async function getDevSandboxSignUpStatus(idToken: string): Promise<SBSign
195201
}
196202
}
197203

204+
if (!status) {
205+
throw new Error(`Couldn't get Developer Sandbox status.`);
206+
}
207+
198208
if (!status.status.ready) {
199209
// If Developer Sandbox is not ready
200210
if (status.status.verificationRequired) {
@@ -343,7 +353,7 @@ export function deactivate(): void {
343353
}
344354

345355
async function updateConnections(): Promise<void> {
346-
let config: KubeConfig;
356+
let config: KubeConfig | undefined;
347357
let attempts = 0;
348358
while (attempts < 5) {
349359
try {
@@ -367,16 +377,15 @@ async function updateConnections(): Promise<void> {
367377
contextName => !config.getContexts().find(context => context.name === contextName),
368378
);
369379
deletedConnections.forEach(contextName => {
370-
const deletedConnection = registeredConnections.get(contextName);
371380
deleteConnection(contextName);
372-
deletedConnection.disposable.dispose();
373381
});
374382

375-
// update status of existin connections
376383
const updateStatusRequests = Array.from(registeredConnections.keys()).map(contextName => {
377-
// get current token from config file
378-
const token = config.getUser(config.getContextObject(contextName).user).token;
384+
const contextObj = config.getContextObject(contextName);
385+
const userObj = contextObj ? config.getUser(contextObj.user) : undefined;
386+
const token = userObj?.token ?? '';
379387
const connectionData = registeredConnections.get(contextName);
388+
if (!connectionData) return Promise.resolve();
380389
return getConnectionStatus(connectionData.connection.endpoint.apiURL, token).then(status => {
381390
connectionData.status = status;
382391
});
@@ -391,9 +400,11 @@ async function updateConnections(): Promise<void> {
391400
.filter(context => context.cluster.startsWith('sandbox-cluster-'))
392401
.filter(context => !registeredConnections.get(context.name));
393402
await Promise.all(
394-
addedSandboxContexts.map(context => {
403+
addedSandboxContexts.map(async context => {
395404
const cluster = config.getCluster(context.cluster);
396-
return registerConnection(context.name, cluster.server, config.getUser(context.user).token);
405+
const user = config.getUser(context.user);
406+
if (!cluster || !user?.token) return;
407+
return registerConnection(context.name, cluster.server, user.token);
397408
}),
398409
);
399410
}

src/openshift.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,17 @@ export async function whoami(clusterUrl: string, token: string): Promise<string>
4949
export async function getOpenShiftInternalRegistryPublicHost(contextName: string): Promise<InternalRegistryInfo> {
5050
const config = kubeconfig.createOrLoadFromFile(extensionApi.kubernetes.getKubeconfig().fsPath);
5151
const context = config.getContextObject(contextName);
52+
if (!context) {
53+
throw new Error(`Context '${contextName}' not found in kubeconfig.`);
54+
}
5255
const cluster = config.getCluster(context.cluster);
56+
if (!cluster) {
57+
throw new Error(`Cluster for context '${contextName}' not found in kubeconfig.`);
58+
}
5359
const user = config.getUser(context.user);
60+
if (!user || !user.token) {
61+
throw new Error(`User or token for context '${contextName}' not found in kubeconfig.`);
62+
}
5463
const gotOptions = {
5564
headers: {
5665
Authorization: `Bearer ${user.token}`,
@@ -118,11 +127,15 @@ async function installPipelineSecretToken(
118127
pipelineServiceAccount: V1ServiceAccount,
119128
username: string,
120129
): Promise<V1Secret | undefined> {
130+
if (!pipelineServiceAccount.metadata?.name || !pipelineServiceAccount.metadata?.uid) {
131+
throw new Error('Service account is missing required metadata.');
132+
}
133+
const secretName = `pipeline-secret-${username}-dev`;
121134
const v1Secret = {
122135
apiVersion: 'v1',
123136
kind: 'Secret',
124137
metadata: {
125-
name: `pipeline-secret-${username}-dev`,
138+
name: secretName,
126139
annotations: {
127140
'kubernetes.io/service-account.name': pipelineServiceAccount.metadata.name,
128141
'kubernetes.io/service-account.uid': pipelineServiceAccount.metadata.uid,
@@ -132,19 +145,19 @@ async function installPipelineSecretToken(
132145
} as V1Secret;
133146

134147
await k8sApi.createNamespacedSecret({ namespace: `${username}-dev`, body: v1Secret });
135-
// wait for secret to be created
136148
const timeout = getRegistrationServiceTimeout();
137149
const startTime = Date.now();
138150
while (Date.now() - startTime < timeout) {
139151
try {
140152
const response = await k8sApi.readNamespacedSecret({
141-
name: v1Secret.metadata.name,
153+
name: secretName,
142154
namespace: `${username}-dev`,
143155
});
144-
return response; // Return the Secret object if found
156+
return response;
145157
} catch (error) {
146-
if (error.response && error.response.statusCode === 404) {
147-
console.error(`Cannot read created sandbox secret ${v1Secret.metadata.name}`);
158+
const err = error as { response?: { statusCode?: number } };
159+
if (err.response?.statusCode === 404) {
160+
console.error(`Cannot read created sandbox secret ${secretName}`);
148161
await delay(250);
149162
} else {
150163
console.error(String(error));
@@ -163,20 +176,23 @@ export async function getPipelineServiceAccountToken(
163176
const k8sApi = kcu.makeApiClient(CoreV1Api);
164177
const serviceAccounts = await k8sApi.listNamespacedServiceAccount({ namespace: `${username}-dev` });
165178
const pipelineServiceAccount = serviceAccounts.items.find(
166-
serviceAccount => serviceAccount.metadata.name === 'pipeline',
167-
);
179+
serviceAccount => serviceAccount.metadata?.name === 'pipeline',
180+
) as V1ServiceAccount | undefined;
168181
if (!pipelineServiceAccount) {
169182
throw new Error(`Couldn't find service account required to create Developer Sandbox connection.`);
170183
}
171184

172185
const secrets = await k8sApi.listNamespacedSecret({ namespace: `${username}-dev` });
173-
let pipelineTokenSecret = secrets?.items.find(secret => secret.metadata.name === `pipeline-secret-${username}-dev`);
186+
let pipelineTokenSecret = secrets?.items.find(secret => secret.metadata?.name === `pipeline-secret-${username}-dev`);
174187
if (!pipelineTokenSecret) {
175188
try {
176189
pipelineTokenSecret = await installPipelineSecretToken(k8sApi, pipelineServiceAccount, username);
177190
} catch (error) {
178191
throw new Error(`An error occurred when creating secret for Developer Sandbox connection.`);
179192
}
180193
}
194+
if (!pipelineTokenSecret?.data?.token) {
195+
throw new Error('Failed to get required service account token.');
196+
}
181197
return Buffer.from(pipelineTokenSecret.data.token, 'base64').toString();
182198
}

src/sandbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export interface OauthServerInfo {
5656
code_challenge_methods_supported: string[];
5757
}
5858

59-
export function getSandboxAPIUrl(): string {
59+
export function getSandboxAPIUrl(): string | undefined {
6060
return configuration.getConfiguration('redhat').get('sandbox.registrationServiceUrl');
6161
}
6262

0 commit comments

Comments
 (0)