-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathextension.ts
More file actions
440 lines (393 loc) · 16.2 KB
/
Copy pathextension.ts
File metadata and controls
440 lines (393 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
/**********************************************************************
* Copyright (C) 2022 - 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 { KubeConfig } from '@kubernetes/client-node';
import * as extensionApi from '@podman-desktop/api';
import got from 'got';
import * as kubeconfig from './kubeconfig.js';
import { getOpenShiftInternalRegistryPublicHost, getPipelineServiceAccountToken } from './openshift.js';
import { getSignUpStatus, SBSignupResponse, signUp } from './sandbox.js';
const ProvideDisplayName = 'Developer Sandbox';
const TelemetryLogger = extensionApi.env.createTelemetryLogger();
export const LoginCommandParam = 'redhat.sandbox.login.command';
export const ContextNameParam = 'redhat.sandbox.context.name';
export const DefaultContextParam = 'redhat.sandbox.context.default';
interface ConnectionData {
disposable?: extensionApi.Disposable;
connection: extensionApi.KubernetesProviderConnection;
status: extensionApi.ProviderConnectionStatus;
error?: string;
}
const StartedStatus: extensionApi.ProviderConnectionStatus = 'started';
const UnknownStatus: extensionApi.ProviderConnectionStatus = 'unknown';
let provider: extensionApi.Provider;
let updateConnectionTimeout: NodeJS.Timeout;
let registeredConnections: Map<string, ConnectionData> = new Map<string, ConnectionData>();
type ImageInfo = { engineId: string; name?: string; tag?: string };
export async function pushImageToOpenShiftRegistry(image: ImageInfo): Promise<void> {
const qp = Array.from(registeredConnections.values())
.filter(connection => connection.status === 'started')
.map(connection => connection.connection.name);
if (!qp.length) {
extensionApi.window.showInformationMessage(
'You have no running Developer Sandbox connections. Please create new one and try again.',
);
return;
}
let targetSb: string;
if (qp.length > 1) {
targetSb = await extensionApi.window.showQuickPick(qp);
if (!targetSb) {
return;
}
} else {
targetSb = qp[0];
}
let pushError: any;
await extensionApi.window.withProgress(
{
location: extensionApi.ProgressLocation.TASK_WIDGET,
title: `Pushing image '${image.name}:${image.tag}' to Developer Sandbox '${targetSb}'`,
},
async (progress, token) => {
try {
progress.report({ increment: 25 });
const registryInfo = await getOpenShiftInternalRegistryPublicHost(targetSb);
progress.report({ increment: 50 });
const lastIndexOfSlash = image.name.lastIndexOf('/');
const imageShortName = lastIndexOfSlash !== -1 ? image.name.substring(lastIndexOfSlash + 1) : image.name;
const imageTagSuffix = image.tag ? `:${image.tag}` : ``;
const localImageName = `${image.name}${imageTagSuffix}`;
const remoteImageName = `${registryInfo.host}/${registryInfo.username}-dev/${imageShortName}${imageTagSuffix}`;
if (localImageName !== remoteImageName) {
await extensionApi.containerEngine.tagImage(
image.engineId,
image.name + imageTagSuffix,
`${registryInfo.host}/${registryInfo.username}-dev/${imageShortName}`,
image.tag,
);
}
progress.report({ increment: 75 });
await new Promise(async (resolve, reject) => {
try {
await extensionApi.containerEngine.pushImage(
image.engineId,
remoteImageName,
name => {
if (name === 'end') {
resolve(undefined);
}
},
{ username: registryInfo.username, password: registryInfo.token, serveraddress: registryInfo.host },
);
} catch (err: unknown) {
reject(err);
}
});
progress.report({ increment: 100 });
if (localImageName !== remoteImageName) {
await extensionApi.window.showInformationMessage(
`The image '${image.name}:${image.tag}' has been successfully pushed to to Developer Sandbox cluster '${targetSb}'. A new tag '${registryInfo.host}/${registryInfo.username}-dev/${imageShortName}${imageTagSuffix}' has been created for this image; you must use this image tag when deploying to Developer Sandbox`,
);
} else {
await extensionApi.window.showInformationMessage(
`The image '${image.name}:${image.tag}' has been successfully pushed to to Developer Sandbox cluster '${targetSb}'.`,
);
}
} catch (err) {
await extensionApi.window.showErrorMessage(
`An error occurred while pushing the image '${image.name}:${image.tag}' to Developer Sandbox cluster '${targetSb}'. ${err}`,
);
}
},
);
}
async function deleteContext(contextName: string): Promise<void> {
const config = kubeconfig.createOrLoadFromFile(extensionApi.kubernetes.getKubeconfig().fsPath);
const context = config.getContextObject(contextName);
const cluster = config.getCluster(context.cluster);
const user = config.getUser(context.user);
config.getContexts().splice(config.getContexts().indexOf(context), 1);
config.getClusters().splice(config.getClusters().indexOf(cluster), 1);
config.getUsers().splice(config.getUsers().indexOf(user), 1);
kubeconfig.exportToFile(config, extensionApi.kubernetes.getKubeconfig().fsPath);
}
function deleteConnection(contextName: string) {
const deletedConnection = registeredConnections.get(contextName);
registeredConnections.delete(contextName);
deletedConnection.disposable.dispose();
}
async function deleteConnectionAndUpdateKubeconfig(contextName: string): Promise<void> {
deleteConnection(contextName);
deleteContext(contextName);
}
async function registerConnection(contextName: string, apiURL: string, token: string): Promise<ConnectionData> {
const connection: extensionApi.KubernetesProviderConnection = {
name: contextName,
status: () => registeredConnections.get(contextName)?.status ?? 'unknown',
endpoint: {
apiURL,
},
lifecycle: {
delete: async () => {
return deleteConnectionAndUpdateKubeconfig(contextName);
},
},
get error() {
return registeredConnections.get(contextName)?.error;
},
};
const connectionData: ConnectionData = { connection, status: UnknownStatus };
registeredConnections.set(contextName, connectionData);
connectionData.disposable = provider.registerKubernetesProviderConnection(connection);
// Check initial connection status
try {
const statusResult = await getConnectionStatus(apiURL, token);
connectionData.status = statusResult.status;
connectionData.error = statusResult.error;
} catch (error) {
console.error('Failed to get initial connection status:', error);
connectionData.error = `Failed to verify connection: ${String(error)}`;
}
return connectionData;
}
export async function getDevSandboxSignUpStatus(idToken: string): Promise<SBSignupResponse> {
let status: SBSignupResponse;
try {
status = await getSignUpStatus(idToken);
} catch (error) {
// User has not signed up for Developer Sandbox trial
console.error(`Couldn't get Developer Sandbox status. Sending sign up request for a trial.`);
}
if (!status) {
// If status is undefined, attempt to activate the Developer Sandbox trial
try {
await signUp(idToken); // Try to activate it
} catch (error) {
throw new Error(
`There is no active Developer Sandbox instance and sign up request for a trial failed: ${String(error)}`,
);
}
try {
status = await getSignUpStatus(idToken);
} catch (error) {
throw new Error(
`Couldn't get Developer Sandbox status after successfully signing you up for a trial. Please try again later.: ${String(error)}`,
);
}
}
if (!status.status.ready) {
// If Developer Sandbox is not ready
if (status.status.verificationRequired) {
throw new Error(
'Developer Sandbox account verification is required. Please open Developer Sandbox page using link below and click `Try it` button to go through verification process.',
);
} else {
if (status.status.reason === 'PendingApproval') {
throw new Error('Developer Sandbox instance provisioning is waiting for approval. Please try again later.');
} else {
throw new Error('Developer Sandbox is not provisioned yet. Please try again later.');
}
}
}
return status;
}
export async function activate(extensionContext: extensionApi.ExtensionContext): Promise<void> {
console.log('starting extension redhat-developer-sandbox');
let status: extensionApi.ProviderStatus = 'ready';
const icon = './icon.png';
const providerOptions: extensionApi.ProviderOptions = {
name: ProvideDisplayName,
id: 'redhat.sandbox',
status,
images: {
icon: {
dark: icon,
light: icon,
fontId: 'sandbox-icon',
},
logo: {
dark: icon,
light: icon,
},
},
emptyConnectionMarkdownDescription:
'A free, private OpenShift environment including one project and a resource quota of 14 GB RAM, and 40 GB storage. It lasts 30 days.\n\nSign up at [https://developers.redhat.com/developer-sandbox](https://developers.redhat.com/developer-sandbox/?sc_cid=7013a000003SUmgAAG).',
};
extensionContext.subscriptions.push(
extensionApi.commands.registerCommand('sandbox.open.login.url', () => {
extensionApi.env
.openExternal(extensionApi.Uri.parse('https://console.redhat.com/openshift/sandbox?sc_cid=7013a000003SUmgAAG'))
.then(successful => {
TelemetryLogger.logUsage('sandboxOpenLoginUrlRequest', { successful });
});
}),
);
provider = extensionApi.provider.createProvider(providerOptions);
const kubeconfigUri = extensionApi.kubernetes.getKubeconfig();
const kubeconfigFile = kubeconfigUri.fsPath;
console.log('Config file location', kubeconfigFile);
const disposable = provider.setKubernetesProviderConnectionFactory({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
create: async (
params: { [key: string]: any },
_logger?: extensionApi.Logger,
_token?: extensionApi.CancellationToken,
) => {
// check if context name is provided
if (!params[ContextNameParam]) {
throw new Error('Context name is required.');
}
// first verify context name does not exists yet in kubeconfig
const config = kubeconfig.createOrLoadFromFile(extensionApi.kubernetes.getKubeconfig().fsPath);
if (config['contexts'].find(context => context['name'] === params[ContextNameParam])) {
throw new Error(`Context ${params[ContextNameParam]} already exists, please choose a different name.`);
}
// use existing SSO session or request to login
const ssoSession = await extensionApi.authentication.getSession('redhat.authentication-provider', ['openid'], {
createIfNone: true,
});
// check Developer Sandbox status and sign up for it if possible
let status: SBSignupResponse = await getDevSandboxSignUpStatus((ssoSession as any).idToken);
// get pipeline service account token or create new one
const token = await getPipelineServiceAccountToken(
status.proxyURL,
status.compliantUsername,
(ssoSession as any).idToken,
);
const suffix = Math.random().toString(36).substring(7);
const clusterName = `sandbox-cluster-${suffix}`; // has unique name
const userName = `sandbox-user-${suffix}`; // generate a unique name for the user
config.addCluster({
server: status.apiEndpoint,
name: clusterName,
skipTLSVerify: false,
});
config.addUser({
name: userName,
token,
});
config.addContext({
cluster: clusterName,
user: userName,
name: params[ContextNameParam],
namespace: `${status.compliantUsername}-dev`,
});
if (params[DefaultContextParam]) {
config.setCurrentContext(params[ContextNameParam]);
}
kubeconfig.exportToFile(config, kubeconfigFile);
await registerConnection(params[ContextNameParam], status.apiEndpoint, token);
},
creationDisplayName: ProvideDisplayName,
});
extensionContext.subscriptions.push(
extensionApi.commands.registerCommand('sandbox.image.push.to.cluster', image => {
pushImageToOpenShiftRegistry(image);
}),
);
extensionContext.subscriptions.push(provider);
extensionContext.subscriptions.push(disposable);
updateConnectionsPeriodically();
}
function updateConnectionsPeriodically(): void {
updateConnections().then(() => {
updateConnectionTimeout = setTimeout(updateConnectionsPeriodically, 2000);
});
}
export function deactivate(): void {
console.log('deactivating redhat-developer-sandbox extension');
if (updateConnectionTimeout) {
clearTimeout(updateConnectionTimeout);
}
}
async function updateConnections(): Promise<void> {
let config: KubeConfig;
let attempts = 0;
while (attempts < 5) {
try {
config = kubeconfig.createOrLoadFromFile(extensionApi.kubernetes.getKubeconfig().fsPath);
break;
} catch (err) {
console.error('Failed to load kubeconfig:', err);
}
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
// TODO: Inform user that kubeconfig cannot be loaded
if (!config) {
console.error('Failed to load kubeconfig');
return;
}
// delete connections that are not in kubeconfig anymore
const deletedConnections = Array.from(registeredConnections.keys()).filter(
contextName => !config.getContexts().find(context => context.name === contextName),
);
deletedConnections.forEach(contextName => {
const deletedConnection = registeredConnections.get(contextName);
deleteConnection(contextName);
deletedConnection.disposable.dispose();
});
// update status of existin connections
const updateStatusRequests = Array.from(registeredConnections.keys()).map(contextName => {
// get current token from config file
const token = config.getUser(config.getContextObject(contextName).user).token;
const connectionData = registeredConnections.get(contextName);
return getConnectionStatus(connectionData.connection.endpoint.apiURL, token).then(result => {
connectionData.status = result.status;
connectionData.error = result.error;
});
});
// what if connection is not responding?
await Promise.all(updateStatusRequests);
// add connections that are in kubeconfig but not registered
const addedSandboxContexts = config
.getContexts()
.filter(context => context.cluster.startsWith('sandbox-cluster-'))
.filter(context => !registeredConnections.get(context.name));
await Promise.all(
addedSandboxContexts.map(context => {
const cluster = config.getCluster(context.cluster);
return registerConnection(context.name, cluster.server, config.getUser(context.user).token);
}),
);
}
async function getConnectionStatus(
apiURL: string,
token: string,
): Promise<{ status: extensionApi.ProviderConnectionStatus; error?: string }> {
return isTokenValid(apiURL, token)
.then(() => {
return { status: StartedStatus };
})
.catch(error => {
console.error('Failed to connect to cluster:', error);
return { status: UnknownStatus, error: String(error) };
});
}
async function isTokenValid(apiURL: string, token: string): Promise<void> {
const usersApiURL = `${apiURL}/apis/user.openshift.io/v1/users/~`;
return got(usersApiURL, { headers: { Authorization: `Bearer ${token}` } }).then(response => {
if (response.statusCode === 200) {
const responseObj = JSON.parse(response.body);
if (responseObj.kind === 'User') {
return;
}
}
throw new Error('Token has expired.');
});
}