vwa(front): Prevent PVCs from being deleted when there is a corresponding notebook (kubeflow/kubeflow#6899)

* vwa(back): Append notebooks using each PVC

Append notebooks using each PVC when getting and parsing all PVCs.

Refs arrikto/dev#2017

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa(front): Create custom delete action column

Create custom delete column component that extends ActionComponent from
Kubeflow common code and adds the following functionalities:

* Disable the button when a row's notebooks array is not empty
* Display an appropriate message including the notebooks' names

Refs arrikto/dev#2017

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa(front): Add delete button component in index page

Implement custom delete column in the table in VWA's index page.

Refs arrikto/dev#2017

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa(front): Add Used by column in volumes table

Add a Used by column in volumes table of VWA's index page in order to
link to the notebooks that are using each PVC.

Refs arrikto/dev#2017

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa(front): Fix integration tests

Update mock request for PVCs

Refs arrikto/dev#2017
Refs arrikto/dev#2135

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
Reviewed-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa(front): Fix format error

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>

* web-apps(front): Fix linting errors

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>

---------

Signed-off-by: Orfeas Kourkakis <orfeas@arrikto.com>
This commit is contained in:
Orfeas Kourkakis 2023-01-30 12:02:05 +02:00 committed by GitHub
parent d2c76a0739
commit 6a02712226
25 changed files with 368 additions and 38 deletions

View File

@ -1,5 +1,12 @@
import { Component, Input, ComponentRef, OnInit } from '@angular/core';
import { ComponentValue } from '../types';
import {
Component,
Input,
ComponentRef,
OnInit,
Output,
EventEmitter,
} from '@angular/core';
import { ActionEvent, ComponentValue } from '../types';
import { ComponentPortal, Portal } from '@angular/cdk/portal';
export interface TableColumnComponent {
@ -32,6 +39,9 @@ export class ComponentValueComponent implements OnInit {
@Input() valueDescriptor: ComponentValue;
@Output()
emitter = new EventEmitter<ActionEvent>();
ngOnInit() {
this.portal = new ComponentPortal(this.valueDescriptor.component);
}
@ -39,5 +49,10 @@ export class ComponentValueComponent implements OnInit {
onAttach(ref: ComponentRef<TableColumnComponent>) {
this.componentRef = ref;
this.componentRef.instance.element = this.element;
/* eslint-disable */
if (this.componentRef.instance?.['emitter']) {
this.componentRef.instance['emitter'] = this.emitter;
}
/* eslint-enable */
}
}

View File

@ -77,6 +77,6 @@ import { RouterModule } from '@angular/router';
TableComponent,
ComponentValueComponent,
],
exports: [ResourceTableComponent, TableComponent],
exports: [ResourceTableComponent, TableComponent, ActionComponent],
})
export class ResourceTableModule {}

View File

@ -196,6 +196,7 @@
<lib-component-value
[element]="element"
[valueDescriptor]="col.value"
(emitter)="actionTriggered($event)"
></lib-component-value>
</td>
</ng-container>

View File

@ -594,7 +594,9 @@ export class TableComponent
public actionTriggered(e: ActionEvent) {
// Forward the emitted ActionEvent
this.actionsEmitter.emit(e);
if (e instanceof ActionEvent) {
this.actionsEmitter.emit(e);
}
}
public newButtonTriggered() {

View File

@ -108,3 +108,7 @@ export * from './lib/status-icon/status-icon.module';
export * from './lib/urls/urls.component';
export * from './lib/urls/urls.module';
export * from './lib/urls/types';
export * from './lib/icon/icon.component';
export * from './lib/icon/icon.module';
export * from './lib/resource-table/action/action.component';

View File

@ -3,7 +3,7 @@ from kubeflow.kubeflow.crud_backend import api, helpers
from . import status
def parse_pvc(pvc):
def parse_pvc(pvc, notebooks):
"""
pvc: client.V1PersistentVolumeClaim
@ -14,6 +14,7 @@ def parse_pvc(pvc):
except Exception:
capacity = pvc.spec.resources.requests["storage"]
notebooks = get_notebooks_using_pvc(pvc.metadata.name, notebooks)
parsed_pvc = {
"name": pvc.metadata.name,
"namespace": pvc.metadata.namespace,
@ -22,11 +23,44 @@ def parse_pvc(pvc):
"capacity": capacity,
"modes": pvc.spec.access_modes,
"class": pvc.spec.storage_class_name,
"notebooks": notebooks,
}
return parsed_pvc
def get_notebooks_using_pvc(pvc, notebooks):
"""Return a list of Notebooks that are using the given PVC."""
mounted_notebooks = []
for nb in notebooks:
pvcs = get_notebook_pvcs(nb)
if pvc in pvcs:
mounted_notebooks.append(nb["metadata"]["name"])
return mounted_notebooks
def get_notebook_pvcs(nb):
"""
Return a list of PVC names that the given notebook is using.
If it doesn't use any, then an empty list will be returned.
"""
pvcs = []
if not nb["spec"]["template"]["spec"]["volumes"]:
return []
vols = nb["spec"]["template"]["spec"]["volumes"]
for vol in vols:
# Check if the volume is a pvc
if not vol.get("persistentVolumeClaim"):
continue
pvcs.append(vol["persistentVolumeClaim"]["claimName"])
return pvcs
def get_pods_using_pvc(pvc, namespace):
"""
Return a list of Pods that are using the given PVC

View File

@ -10,23 +10,27 @@ log = logging.getLogger(__name__)
def get_pvcs(namespace):
# Return the list of PVCs
pvcs = api.list_pvcs(namespace)
content = [utils.parse_pvc(pvc) for pvc in pvcs.items]
notebooks = api.list_notebooks(namespace)["items"]
content = [utils.parse_pvc(pvc, notebooks) for pvc in pvcs.items]
return api.success_response("pvcs", content)
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>")
def get_pvc(namespace, pvc_name):
pvc = api.get_pvc(pvc_name, namespace)
return api.success_response("pvc", api.serialize(pvc))
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/pods")
def get_pvc_pods(namespace, pvc_name):
pods = utils.get_pods_using_pvc(pvc_name, namespace)
return api.success_response("pods", api.serialize(pods))
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/events")
def get_pvc_events(namespace, pvc_name):
events = api.list_pvc_events(namespace, pvc_name).items
return api.success_response("events", api.serialize(events))
return api.success_response("events", api.serialize(events))

View File

@ -21,21 +21,25 @@ def get_pvcs(namespace):
# Return the list of PVCs and the corresponding Viewer's state
pvcs = api.list_pvcs(namespace)
content = [utils.parse_pvc(pvc, viewers) for pvc in pvcs.items]
notebooks = api.list_notebooks(namespace)["items"]
content = [utils.parse_pvc(pvc, viewers, notebooks) for pvc in pvcs.items]
return api.success_response("pvcs", content)
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>")
def get_pvc(namespace, pvc_name):
pvc = api.get_pvc(pvc_name, namespace)
return api.success_response("pvc", api.serialize(pvc))
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/pods")
def get_pvc_pods(namespace, pvc_name):
pods = get_pods_using_pvc(pvc_name, namespace)
return api.success_response("pods", api.serialize(pods))
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc_name>/events")
def get_pvc_events(namespace, pvc_name):
events = api.list_pvc_events(namespace, pvc_name).items

View File

@ -39,7 +39,7 @@ def add_pvc_rok_annotations(pvc, body):
pvc.metadata.labels = labels
def parse_pvc(pvc, viewers):
def parse_pvc(pvc, viewers, notebooks):
"""
pvc: client.V1PersistentVolumeClaim
viewers: dict(Key:PVC Name, Value: Viewer's Status)
@ -47,7 +47,7 @@ def parse_pvc(pvc, viewers):
Process the PVC and format it as the UI expects it. If a Viewer is active
for that PVC, then include this information
"""
parsed_pvc = common_utils.parse_pvc(pvc)
parsed_pvc = common_utils.parse_pvc(pvc, notebooks)
parsed_pvc["viewer"] = viewers.get(pvc.metadata.name,
status.STATUS_PHASE.UNINITIALIZED)

View File

@ -7,6 +7,7 @@
"modes": ["ReadWriteOnce"],
"name": "a-pvc-phase-ready-viewer-ready",
"namespace": "kubeflow-user",
"notebooks": [],
"status": {
"message": "Bound",
"phase": "ready",
@ -21,6 +22,7 @@
"modes": ["ReadWriteOnce"],
"name": "a-pvc-phase-ready-viewer-uninitialized",
"namespace": "kubeflow-user",
"notebooks": [],
"status": {
"message": "Bound",
"phase": "ready",
@ -35,6 +37,7 @@
"modes": ["ReadWriteOnce"],
"name": "a-pvc-phase-ready-viewer-waiting",
"namespace": "kubeflow-user",
"notebooks": [],
"status": {
"message": "Bound",
"phase": "ready",
@ -49,6 +52,7 @@
"modes": ["ReadWriteOnce"],
"name": "a-pvc-phase-unvailable-viewer-unavailable",
"namespace": "kubeflow-user",
"notebooks": [],
"status": {
"message": "Pending: This volume will be bound when its first consumer is created. E.g., when you first browse its contents, or attach it to a notebook server",
"phase": "unavailable",
@ -63,6 +67,7 @@
"modes": ["ReadWriteOnce"],
"name": "a-pvc-phase-waiting-viewer-uninitialized",
"namespace": "kubeflow-user",
"notebooks": ["test-notebook-1", "test-notebook-2"],
"status": {
"message": "Bound",
"phase": "waiting",
@ -77,6 +82,7 @@
"modes": ["ReadWriteOnce"],
"name": "a-pvc-phase-warning-viewer-ready",
"namespace": "kubeflow-user",
"notebooks": [],
"status": {
"message": "Bound",
"phase": "warning",

View File

@ -24,6 +24,7 @@ import { IndexRokComponent } from './pages/index/index-rok/index-rok.component';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { VolumeDetailsPageModule } from './pages/volume-details-page/volume-details-page.module';
import { ColumnsModule } from './pages/index/columns/columns.module';
@NgModule({
declarations: [
@ -45,6 +46,7 @@ import { VolumeDetailsPageModule } from './pages/volume-details-page/volume-deta
KubeflowModule,
HttpClientModule,
VolumeDetailsPageModule,
ColumnsModule,
],
providers: [
{ provide: ErrorStateMatcher, useClass: ImmediateErrorStateMatcher },

View File

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { DeleteButtonComponent } from './delete-button/delete-button.component';
import { IconModule, KubeflowModule, UrlsModule } from 'kubeflow';
import { UsedByComponent } from './used-by/used-by.component';
@NgModule({
declarations: [DeleteButtonComponent, UsedByComponent],
imports: [
CommonModule,
MatTooltipModule,
IconModule,
KubeflowModule,
UrlsModule,
],
exports: [DeleteButtonComponent, UsedByComponent],
})
export class ColumnsModule {}

View File

@ -0,0 +1,33 @@
<div>
<!--Ready Phase-->
<button
*ngIf="isPhaseReady()"
mat-icon-button
matTooltip="{{ action.tooltip }}"
matTooltipClass="custom-tooltip"
[color]="action.color"
(click)="emitClickedEvent()"
>
<lib-icon [icon]="action.iconReady"></lib-icon>
</button>
<div
matTooltipClass="custom-tooltip"
[matTooltip]="'PVC is terminating at the moment.'"
matTooltipClass="custom-tooltip"
>
<button *ngIf="isPhaseTerminating()" mat-icon-button disabled>
<lib-icon [icon]="action.iconInit"></lib-icon>
</button>
</div>
<div
matTooltipClass="custom-tooltip"
[matTooltip]="getDisabledTooltip(element)"
matTooltipClass="custom-tooltip"
>
<button *ngIf="isPhaseUnavailable()" mat-icon-button disabled>
<lib-icon [icon]="action.iconInit"></lib-icon>
</button>
</div>
</div>

View File

@ -0,0 +1,41 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DeleteButtonComponent } from './delete-button.component';
const mockElement = {
age: 'Mon, 19 Sep 2022 16:39:10 GMT',
capacity: '5Gi',
class: 'rok',
modes: ['ReadWriteOnce'],
name: 'a0-new-image-workspace-d8pc2',
namespace: 'kubeflow-user',
notebooks: ['a0-new-image'],
status: {
message: 'Bound',
phase: 'ready',
state: '',
},
viewer: 'uninitialized',
};
describe('DeleteButtonComponent', () => {
let component: DeleteButtonComponent;
let fixture: ComponentFixture<DeleteButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DeleteButtonComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DeleteButtonComponent);
component = fixture.componentInstance;
component.element = mockElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,45 @@
import { Component, OnInit } from '@angular/core';
import { ActionComponent, ActionIconValue, STATUS_TYPE } from 'kubeflow';
import { TableColumnComponent } from 'kubeflow/lib/resource-table/component-value/component-value.component';
@Component({
selector: 'app-delete-button',
templateUrl: './delete-button.component.html',
styleUrls: ['./delete-button.component.scss'],
})
export class DeleteButtonComponent
extends ActionComponent
implements TableColumnComponent, OnInit {
set element(data: any) {
this.data = data;
}
get element() {
return this.data;
}
ngOnInit(): void {
this.action = new ActionIconValue({
name: 'delete',
tooltip: $localize`Delete Volume`,
color: 'warn',
field: 'deleteAction',
iconReady: 'material:delete',
});
}
isPhaseUnavailable(): boolean {
return this.status === STATUS_TYPE.UNAVAILABLE;
}
isPhaseTerminating(): boolean {
return this.status === STATUS_TYPE.TERMINATING;
}
getDisabledTooltip(element: any) {
let tooltip = `Cannot delete PVC because it is being used by the following notebooks:\n`;
for (const nb of element.notebooks) {
tooltip += `\n ${nb}`;
}
return tooltip;
}
}

View File

@ -0,0 +1,23 @@
<div class="used-by-container truncate">
<span
[libPopover]="popoverCard"
[libPopoverPosition]="'before'"
[libPopoverHideDelay]="100"
[libPopoverShowDelay]="100"
>
<lib-urls
*ngFor="let nb of element.notebooks"
[urlList]="[getUrlItem(nb, element)]"
>
</lib-urls>
</span>
</div>
<ng-template #popoverCard>
<div class="popoverCard">
<p>Notebooks</p>
<li *ngFor="let nb of element.notebooks">
<lib-urls [urlList]="[getUrlItem(nb, element)]"></lib-urls>
</li>
</div>
</ng-template>

View File

@ -0,0 +1,9 @@
.used-by-container {
margin-top: 10px;
margin-bottom: 10px;
}
.popoverCard {
max-width: 400px;
padding: 4px 12px 4px 4px;
}

View File

@ -0,0 +1,41 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UsedByComponent } from './used-by.component';
const mockElement = {
age: 'Mon, 19 Sep 2022 16:39:10 GMT',
capacity: '5Gi',
class: 'rok',
modes: ['ReadWriteOnce'],
name: 'a0-new-image-workspace-d8pc2',
namespace: 'kubeflow-user',
notebooks: ['a0-new-image'],
status: {
message: 'Bound',
phase: 'ready',
state: '',
},
viewer: 'uninitialized',
};
describe('UsedByComponent', () => {
let component: UsedByComponent;
let fixture: ComponentFixture<UsedByComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [UsedByComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(UsedByComponent);
component = fixture.componentInstance;
component.element = mockElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { Component, OnInit } from '@angular/core';
import { TableColumnComponent } from 'kubeflow/lib/resource-table/component-value/component-value.component';
@Component({
selector: 'app-used-by',
templateUrl: './used-by.component.html',
styleUrls: ['./used-by.component.scss'],
})
export class UsedByComponent implements TableColumnComponent, OnInit {
public data: any;
set element(data: any) {
this.data = data;
}
get element() {
return this.data;
}
constructor() {}
ngOnInit(): void {}
get pvcName() {
return this.element.name;
}
getUrlItem(nb: string, element: any) {
return {
name: nb,
url: `/jupyter/notebook/details/${element.namespace}/${nb}`,
};
}
}

View File

@ -5,8 +5,10 @@ import {
DateTimeValue,
LinkValue,
LinkType,
ComponentValue,
} from 'kubeflow';
import { quantityToScalar } from '@kubernetes/client-node/dist/util';
import { UsedByComponent } from './columns/used-by/used-by.component';
export const tableConfig: TableConfig = {
columns: [
@ -62,6 +64,17 @@ export const tableConfig: TableConfig = {
value: new PropertyValue({ field: 'class', truncate: true }),
sort: true,
},
{
matHeaderCellDef: $localize`Used by`,
matColumnDef: 'usedBy',
style: { 'max-width': '60px' },
value: new ComponentValue({
component: UsedByComponent,
}),
sort: true,
sortingPreprocessorFn: element => element.notebooks,
filteringPreprocessorFn: element => element.notebooks,
},
// the apps should import the actions they want
],

View File

@ -1,28 +1,19 @@
import {
ActionListValue,
ActionIconValue,
TableColumn,
TableConfig,
} from 'kubeflow';
import { TableColumn, TableConfig, ComponentValue } from 'kubeflow';
import { tableConfig } from '../config';
import { DeleteButtonComponent } from '../columns/delete-button/delete-button.component';
const actionsCol: TableColumn = {
const customDeleteCol: TableColumn = {
matHeaderCellDef: '',
matColumnDef: 'actions',
value: new ActionListValue([
new ActionIconValue({
name: 'delete',
tooltip: $localize`Delete Volume`,
color: 'warn',
field: 'deleteAction',
iconReady: 'material:delete',
}),
]),
matColumnDef: 'customDelete',
style: { width: '40px' },
value: new ComponentValue({
component: DeleteButtonComponent,
}),
};
export const defaultConfig: TableConfig = {
title: tableConfig.title,
dynamicNamespaceColumn: true,
newButtonText: tableConfig.newButtonText,
columns: tableConfig.columns.concat(actionsCol),
columns: tableConfig.columns.concat(customDeleteCol),
};

View File

@ -154,6 +154,10 @@ export class IndexDefaultComponent implements OnInit, OnDestroy {
}
public parseDeletionActionStatus(pvc: PVCProcessedObject) {
if (pvc.notebooks.length) {
return STATUS_TYPE.UNAVAILABLE;
}
if (pvc.status.phase !== STATUS_TYPE.TERMINATING) {
return STATUS_TYPE.READY;
}

View File

@ -3,12 +3,15 @@ import {
ActionIconValue,
TableColumn,
TableConfig,
ComponentValue,
} from 'kubeflow';
import { tableConfig } from '../config';
import { DeleteButtonComponent } from '../columns/delete-button/delete-button.component';
const actionsCol: TableColumn = {
const browseCol: TableColumn = {
matHeaderCellDef: '',
matColumnDef: 'actions',
matColumnDef: 'browse',
style: { 'padding-right': '0' },
value: new ActionListValue([
new ActionIconValue({
name: 'edit',
@ -18,19 +21,21 @@ const actionsCol: TableColumn = {
iconInit: 'material:folder',
iconReady: 'custom:folderSearch',
}),
new ActionIconValue({
name: 'delete',
tooltip: 'Delete Volume',
color: 'warn',
field: 'deleteAction',
iconReady: 'material:delete',
}),
]),
};
const customDeleteCol: TableColumn = {
matHeaderCellDef: ' ',
matColumnDef: 'customDelete',
style: { width: '40px', 'padding-left': '0' },
value: new ComponentValue({
component: DeleteButtonComponent,
}),
};
export const rokConfig: TableConfig = {
title: tableConfig.title,
dynamicNamespaceColumn: true,
newButtonText: tableConfig.newButtonText,
columns: tableConfig.columns.concat([actionsCol]),
columns: tableConfig.columns.concat([browseCol, customDeleteCol]),
};

View File

@ -21,6 +21,7 @@ export interface PVCResponseObject {
name: string;
namespace: string;
status: Status;
notebooks: string[];
}
export interface PVCProcessedObject extends PVCResponseObject {