Skip to content

Commit 4218e28

Browse files
committed
Deploy container image to cluster
- Add "Create Deployment from Container Image URL" wizard to project context menu - It uses the VS Code text input - It has a back button and remembers what you've entered when you go back, similar to what Victor did for the login workflow - Once the deployment's been created, the logs for the container are opened in the OpenShift Terminal - Add demo gif and walkthrough entry for "Create Deployment from Container Image URL" - Add "Delete" context menu item for Kubernetes objects (eg. Deployments) - Add "Watch logs" context menu item for Kubernetes objects (eg. Deployments) Closes #2418 Signed-off-by: David Thompson <davthomp@redhat.com>
1 parent b0ed57a commit 4218e28

File tree

7 files changed

+356
-5
lines changed

7 files changed

+356
-5
lines changed
6.49 MB
Loading

package.json

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@
364364
},
365365
{
366366
"command": "openshift.helm.openView",
367-
"title": "Open Helm View",
367+
"title": "Browse and Install Helm Charts",
368368
"category": "OpenShift"
369369
},
370370
{
@@ -755,11 +755,26 @@
755755
"title": "Create Operator-Backed Service",
756756
"category": "OpenShift"
757757
},
758+
{
759+
"command": "openshift.deployment.create.fromImageUrl",
760+
"title": "Create Deployment from Container Image URL",
761+
"category": "OpenShift"
762+
},
758763
{
759764
"command": "openshift.resource.load",
760765
"title": "Load",
761766
"category": "OpenShift"
762767
},
768+
{
769+
"command": "openshift.resource.delete",
770+
"title": "Delete",
771+
"category": "OpenShift"
772+
},
773+
{
774+
"command": "openshift.resource.watchLogs",
775+
"title": "Watch Logs",
776+
"category": "OpenShift"
777+
},
763778
{
764779
"command": "openshift.resource.unInstall",
765780
"title": "Uninstall",
@@ -944,7 +959,7 @@
944959
},
945960
{
946961
"id": "view/item/context/createService",
947-
"label": "Create Service"
962+
"label": "Create..."
948963
}
949964
],
950965
"viewsContainers": {
@@ -1242,6 +1257,14 @@
12421257
"command": "openshift.resource.load",
12431258
"when": "false"
12441259
},
1260+
{
1261+
"command": "openshift.resource.delete",
1262+
"when": "false"
1263+
},
1264+
{
1265+
"command": "openshift.resource.watchLogs",
1266+
"when": "false"
1267+
},
12451268
{
12461269
"command": "openshift.resource.unInstall",
12471270
"when": "false"
@@ -1494,6 +1517,11 @@
14941517
"command": "openshift.service.create",
14951518
"when": "view == openshiftProjectExplorer && isLoggedIn && viewItem =~ /openshift.project.*/i && showCreateService",
14961519
"group": "c2"
1520+
},
1521+
{
1522+
"command": "openshift.deployment.create.fromImageUrl",
1523+
"when": "view == openshiftProjectExplorer && isLoggedIn && viewItem =~ /openshift.project.*/i",
1524+
"group": "c2"
14971525
}
14981526
],
14991527
"view/item/context": [
@@ -1752,6 +1780,14 @@
17521780
"command": "openshift.resource.load",
17531781
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject || viewItem == openshift.k8sObject.route"
17541782
},
1783+
{
1784+
"command": "openshift.resource.delete",
1785+
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject || viewItem == openshift.k8sObject.route"
1786+
},
1787+
{
1788+
"command": "openshift.resource.watchLogs",
1789+
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject || viewItem == openshift.k8sObject.route"
1790+
},
17551791
{
17561792
"command": "openshift.resource.unInstall",
17571793
"when": "view == openshiftProjectExplorer && viewItem == openshift.k8sObject.helm"
@@ -1903,6 +1939,18 @@
19031939
"onCommand:openshift.helm.openView"
19041940
]
19051941
},
1942+
{
1943+
"id": "deployContainerImage",
1944+
"title": "Deploy Container Image from URL",
1945+
"description": "In the Application Explorer sidebar panel, expand the tree item corresponding to the OpenShift/Kubernetes cluster, then right click on the project, and select \"Create...\" > \"Create Deployment from Container Image URL\". You will be asked to enter the URL to the container image and enter a name for the deployment. Once you've submitted this information, the Deployment will be created, and the logs for the first container created as a part of the Deployment will be opened in the OpenShift Terminal.",
1946+
"media": {
1947+
"image": "images/walkthrough/deploy-a-container-image.gif",
1948+
"altText": "Creating a Deployment of the Docker Hub MongoDB container image called my-mongo-db using the steps from the description"
1949+
},
1950+
"completionEvents": [
1951+
"onCommand:openshift.deployment.create.fromImageUrl"
1952+
]
1953+
},
19061954
{
19071955
"id": "startDevComponent",
19081956
"title": "Start a component in development mode",

src/deployment.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*-----------------------------------------------------------------------------------------------
2+
* Copyright (c) Red Hat, Inc. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
4+
*-----------------------------------------------------------------------------------------------*/
5+
6+
import * as path from 'path';
7+
import validator from 'validator';
8+
import { Disposable, QuickInputButtons, ThemeIcon, TreeItem, window } from 'vscode';
9+
import { OpenShiftExplorer } from './explorer';
10+
import { Oc } from './oc/ocWrapper';
11+
import { validateRFC1123DNSLabel } from './openshift/nameValidator';
12+
import { quickBtn } from './util/inputValue';
13+
import { vsCommand } from './vscommand';
14+
15+
export class Deployment {
16+
17+
@vsCommand('openshift.deployment.create.fromImageUrl')
18+
static async createFromImageUrl(context: TreeItem): Promise<void> {
19+
20+
enum State {
21+
SelectImage, SelectName
22+
}
23+
24+
let state: State = State.SelectImage;
25+
let imageUrl: string;
26+
27+
while (state !== undefined) {
28+
29+
switch (state) {
30+
31+
case State.SelectImage: {
32+
33+
imageUrl = await Deployment.getImageUrl(false, imageUrl);
34+
35+
if (imageUrl === null || imageUrl === undefined) {
36+
return;
37+
}
38+
state = State.SelectName;
39+
break;
40+
}
41+
42+
case State.SelectName: {
43+
let cleanedUrl = imageUrl.startsWith('https://') ? imageUrl : `https://${imageUrl}`;
44+
if (cleanedUrl.lastIndexOf('/') > 0
45+
&& cleanedUrl.substring(cleanedUrl.lastIndexOf('/')).indexOf(':') >=0) {
46+
// it has a version tag, which we need to clean for the
47+
cleanedUrl = cleanedUrl.substring(0, cleanedUrl.lastIndexOf(':'));
48+
}
49+
const imageUrlAsUrl = new URL(cleanedUrl);
50+
const suggestedName = `my-${path.basename(imageUrlAsUrl.pathname)}`;
51+
52+
const deploymentName = await Deployment.getDeploymentName(suggestedName, true);
53+
54+
if (deploymentName === null) {
55+
return;
56+
} else if (deploymentName === undefined) {
57+
state = State.SelectImage;
58+
break;
59+
}
60+
61+
await Oc.Instance.createDeploymentFromImage(deploymentName, imageUrl);
62+
void window.showInformationMessage(`Created deployment '${deploymentName}' from image '${imageUrl}'`);
63+
OpenShiftExplorer.getInstance().refresh(context);
64+
65+
void OpenShiftExplorer.watchLogs({
66+
kind: 'Deployment',
67+
metadata: {
68+
name: deploymentName
69+
}
70+
});
71+
72+
return;
73+
}
74+
default:
75+
}
76+
77+
}
78+
79+
}
80+
81+
/**
82+
* Prompt the user for the URL of a container image.
83+
*
84+
* @returns the selected container image URL, or undefined if "back" was requested, and null if "cancel" was requested
85+
*/
86+
private static async getImageUrl(allowBack: boolean, initialValue?: string): Promise<string> {
87+
return new Promise<string | null | undefined>(resolve => {
88+
const disposables: Disposable[] = [];
89+
90+
const cancelBtn = new quickBtn(new ThemeIcon('close'), 'Cancel');
91+
const okBtn = new quickBtn(new ThemeIcon('check'), 'Ok');
92+
93+
const inputBox = window.createInputBox();
94+
inputBox.placeholder = 'docker.io/library/mongo';
95+
inputBox.title = 'Image URL';
96+
inputBox.value = initialValue;
97+
if (allowBack) {
98+
inputBox.buttons = [QuickInputButtons.Back, okBtn, cancelBtn];
99+
} else {
100+
inputBox.buttons = [okBtn, cancelBtn];
101+
}
102+
inputBox.ignoreFocusOut = true;
103+
104+
disposables.push(inputBox.onDidHide(() => resolve(null)));
105+
106+
disposables.push(inputBox.onDidChangeValue((e) => {
107+
if (validator.isURL(inputBox.value)) {
108+
inputBox.validationMessage = undefined;
109+
} else {
110+
inputBox.validationMessage = 'Please enter a valid URL';
111+
}
112+
}));
113+
114+
disposables.push(inputBox.onDidAccept((e) => {
115+
if (inputBox.validationMessage === undefined && inputBox.value !== undefined) {
116+
resolve(inputBox.value);
117+
inputBox.hide();
118+
disposables.forEach(disposable => {disposable.dispose()});
119+
}
120+
}));
121+
122+
disposables.push(inputBox.onDidTriggerButton((button) => {
123+
if (button === QuickInputButtons.Back) {
124+
inputBox.hide();
125+
resolve(undefined);
126+
} else if (button === cancelBtn) {
127+
inputBox.hide();
128+
resolve(null);
129+
} else if (button === okBtn) {
130+
if (inputBox.validationMessage === undefined && inputBox.value !== undefined) {
131+
inputBox.hide();
132+
resolve(inputBox.value);
133+
disposables.forEach(disposable => {disposable.dispose()});
134+
}
135+
}
136+
}));
137+
138+
inputBox.show();
139+
});
140+
}
141+
142+
/**
143+
* Prompt the user for the name of the deployment.
144+
*
145+
* @returns the selected deployment name, or undefined if "back" was requested, and null if "cancel" was requested
146+
*/
147+
private static async getDeploymentName(suggestedName: string, allowBack: boolean): Promise<string> {
148+
return new Promise<string | null | undefined>(resolve => {
149+
const disposables: Disposable[] = [];
150+
151+
const cancelBtn = new quickBtn(new ThemeIcon('close'), 'Cancel');
152+
const okBtn = new quickBtn(new ThemeIcon('check'), 'Ok');
153+
154+
const inputBox = window.createInputBox();
155+
inputBox.placeholder = suggestedName;
156+
inputBox.value = suggestedName;
157+
inputBox.title = 'Deployment Name';
158+
if (allowBack) {
159+
inputBox.buttons = [QuickInputButtons.Back, okBtn, cancelBtn];
160+
} else {
161+
inputBox.buttons = [okBtn, cancelBtn];
162+
}
163+
inputBox.ignoreFocusOut = true;
164+
165+
disposables.push(inputBox.onDidHide(() => resolve(null)));
166+
167+
disposables.push(inputBox.onDidChangeValue((e) => {
168+
if (inputBox.value === undefined) {
169+
inputBox.validationMessage = undefined;
170+
} else {
171+
inputBox.validationMessage = validateRFC1123DNSLabel('Must be a valid Kubernetes name', inputBox.value);
172+
if (inputBox.validationMessage.length === 0) {
173+
inputBox.validationMessage = undefined;
174+
}
175+
}
176+
}));
177+
178+
disposables.push(inputBox.onDidAccept((e) => {
179+
if (inputBox.validationMessage === undefined && inputBox.value !== undefined) {
180+
resolve(inputBox.value);
181+
inputBox.hide();
182+
disposables.forEach(disposable => {disposable.dispose()});
183+
}
184+
}));
185+
186+
disposables.push(inputBox.onDidTriggerButton((button) => {
187+
if (button === QuickInputButtons.Back) {
188+
inputBox.hide();
189+
resolve(undefined);
190+
} else if (button === cancelBtn) {
191+
inputBox.hide();
192+
resolve(null);
193+
} else if (button === okBtn) {
194+
if (inputBox.validationMessage === undefined && inputBox.value !== undefined) {
195+
resolve(inputBox.value);
196+
inputBox.hide();
197+
disposables.forEach(disposable => {disposable.dispose()});
198+
}
199+
}
200+
}));
201+
202+
inputBox.show();
203+
});
204+
}
205+
206+
}

src/explorer.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
version,
2222
window
2323
} from 'vscode';
24+
import { CommandText } from './base/command';
2425
import * as Helm from './helm/helm';
2526
import { HelmRepo } from './helm/helmChartType';
2627
import { Oc } from './oc/ocWrapper';
@@ -33,6 +34,7 @@ import { Progress } from './util/progress';
3334
import { FileContentChangeNotifier, WatchUtil } from './util/watch';
3435
import { vsCommand } from './vscommand';
3536
import { CustomResourceDefinitionStub } from './webview/common/createServiceTypes';
37+
import { OpenShiftTerminalManager } from './webview/openshift-terminal/openShiftTerminal';
3638

3739
type ExplorerItem = KubernetesObject | Helm.HelmRelease | Context | TreeItem | OpenShiftObject | HelmRepo;
3840

@@ -355,6 +357,35 @@ export class OpenShiftExplorer implements TreeDataProvider<ExplorerItem>, Dispos
355357
void commands.executeCommand('extension.vsKubernetesLoad', { namespace: component.metadata.namespace, kindName: `${component.kind}/${component.metadata.name}` });
356358
}
357359

360+
@vsCommand('openshift.resource.delete')
361+
public static async deleteResource(component: KubernetesObject) {
362+
await Oc.Instance.deleteKubernetesObject(component.kind, component.metadata.name);
363+
void window.showInformationMessage(`Deleted the '${component.kind}' named '${component.metadata.name}'`);
364+
OpenShiftExplorer.instance.refresh();
365+
}
366+
367+
@vsCommand('openshift.resource.watchLogs')
368+
public static async watchLogs(component: KubernetesObject) {
369+
// wait until logs are available before starting to stream them
370+
await Progress.execFunctionWithProgress(`Opening ${component.kind}/${component.metadata.name} logs...`, (_) => {
371+
return new Promise<void>(resolve => {
372+
373+
let intervalId: NodeJS.Timer = undefined;
374+
375+
function checkForPod() {
376+
void Oc.Instance.getLogs('Deployment', component.metadata.name).then((logs) => {
377+
clearInterval(intervalId);
378+
resolve();
379+
}).catch(_e => {});
380+
}
381+
382+
intervalId = setInterval(checkForPod, 200);
383+
});
384+
});
385+
386+
void OpenShiftTerminalManager.getInstance().createTerminal(new CommandText('oc', `logs -f ${component.kind}/${component.metadata.name}`), `Watching '${component.metadata.name}' logs`);
387+
}
388+
358389
@vsCommand('openshift.resource.unInstall')
359390
public static async unInstallHelmChart(release: Helm.HelmRelease) {
360391
return Progress.execFunctionWithProgress(`Uninstalling ${release.name}`, async () => {

src/extension.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export async function activate(extensionContext: ExtensionContext): Promise<unkn
9292
'./webview/devfile-registry/registryViewLoader',
9393
'./webview/helm-chart/helmChartLoader',
9494
'./webview/helm-manage-repository/manageRepositoryLoader',
95-
'./feedback'
95+
'./feedback',
96+
'./deployment'
9697
)),
9798
commands.registerCommand('clusters.openshift.useProject', (context) =>
9899
commands.executeCommand('extension.vsKubernetesUseNamespace', context),

0 commit comments

Comments
 (0)