import { ModuleStatusGuardService } from './shared/services/module-status-guard.service';
import { NoSsoGuardService } from './shared/services/no-sso-guard.service';
import { CephfsVolumeFormComponent } from './ceph/cephfs/cephfs-form/cephfs-form.component';
+import { UpgradeComponent } from './ceph/cluster/upgrade/upgrade.component';
@Injectable()
export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
}
]
},
+ {
+ path: 'upgrade',
+ component: UpgradeComponent,
+ data: { breadcrumbs: 'Cluster/Upgrade' }
+ },
{
path: 'perf_counters/:type/:id',
component: PerformanceCounterComponent,
import { ServiceFormComponent } from './services/service-form/service-form.component';
import { ServicesComponent } from './services/services.component';
import { TelemetryComponent } from './telemetry/telemetry.component';
+import { UpgradeComponent } from './upgrade/upgrade.component';
@NgModule({
imports: [
OsdFlagsIndivModalComponent,
PlacementPipe,
CreateClusterComponent,
- CreateClusterReviewComponent
+ CreateClusterReviewComponent,
+ UpgradeComponent
],
providers: [NgbActiveModal]
})
--- /dev/null
+<ng-container *ngIf="{upgradeInfo: upgradeInfo$ | async, error: upgradeInfoError$ } as upgrade">
+ <div class="row h-25 ms-1"
+ *ngIf="!upgrade.upgradeInfoError && upgrade.upgradeInfo as upgradeInfo; else checkUpgrade">
+ <ng-container *ngIf="healthData$ | async as healthData">
+ <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+ <span class="bold">Current Version</span>
+ <span class="mt-1">{{ version }}</span>
+ </div>
+ <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+ <span class="bold">Cluster Status</span>
+ <ng-template #healthChecks>
+ <ul>
+ <li *ngFor="let check of healthData.health.checks">
+ <span [ngStyle]="check.severity | healthColor"
+ [class.health-warn-description]="check.severity === 'HEALTH_WARN'">
+ {{ check.type }}</span>: {{ check.summary.message }}
+ </li>
+ </ul>
+ </ng-template>
+ <div class="info-card-content-clickable mt-1"
+ [ngStyle]="healthData.health.status | healthColor"
+ [ngbPopover]="healthChecks"
+ popoverClass="info-card-popover-cluster-status">
+ {{ healthData.health.status | healthLabel | uppercase }}
+ <i *ngIf="healthData.health?.status !== 'HEALTH_OK'"
+ class="fa fa-exclamation-triangle"></i>
+ </div>
+ </div>
+ <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+ <span class="bold">Upgrade Status</span>
+ <ng-container *ngIf="upgradeInfo.versions.length > 0; else noUpgradesAvailable">
+ <span class="mt-2"
+ i18n>
+ <i [ngClass]="[icons.up]"
+ class="text-info"></i>
+ Upgrade available</span>
+ <div i18n-ngbTooltip
+ [ngbTooltip]="(healthData.mgr_map | mgrSummary).total <= 1 ? 'To upgrade, you need minimum 2 mgr daemons.' : ''">
+ <button class="btn btn-accent mt-2"
+ id="upgrade"
+ aria-label="Upgrade now"
+ [disabled]="(healthData.mgr_map | mgrSummary).total <= 1"
+ i18n>Upgrade now</button>
+ </div>
+ </ng-container>
+ </div>
+ <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+ <span class="bold">MGR Count</span>
+ <span class="mt-1">
+ <i class="text-success"
+ [ngClass]="[icons.success]"
+ *ngIf="(healthData.mgr_map | mgrSummary).total > 1; else warningIcon">
+ </i>
+ {{ (healthData.mgr_map | mgrSummary).total }}
+ </span>
+ </div>
+
+ <div class="d-flex mt-3 p-0">
+ <dl class="w-50"
+ *ngIf="fsid$ | async as fsid">
+ <dt class="bold mt-5"
+ i18n>Cluster FSID</dt>
+ <dd class="mt-2">{{ fsid }}</dd>
+ <dt class="bold mt-5"
+ i18n>Release Image</dt>
+ <dd class="mt-2">{{ upgradeInfo.image }}</dd>
+ <dt class="bold mt-5"
+ i18n>Registry</dt>
+ <dd class="mt-2">{{ upgradeInfo.registry }}</dd>
+ </dl>
+ </div>
+ </ng-container>
+ </div>
+</ng-container>
+
+<ng-template #checkUpgrade>
+ <div class="row h-75 justify-content-center align-items-center">
+ <h3 class="mt-1 bold text-center"
+ id="checking-for-upgrades"
+ i18n>Checking for upgrades
+ <i [ngClass]="[icons.spin, icons.spinner]"></i>
+ </h3>
+ </div>
+</ng-template>
+
+<ng-template #noUpgradesAvailable>
+ <span class="mt-1"
+ id="no-upgrades-available"
+ i18n>
+ <i [ngClass]="[icons.success]"
+ class="text-success"></i>
+ Cluster is up-to-date
+ </span>
+</ng-template>
+
+<ng-template #warningIcon>
+ <i class="text-warning"
+ [ngClass]="[icons.warning]"
+ title="To upgrade, you need minimum 2 mgr daemons.">
+ </i>
+</ng-template>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { UpgradeComponent } from './upgrade.component';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { BehaviorSubject, of } from 'rxjs';
+import { UpgradeService } from '~/app/shared/api/upgrade.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { UpgradeInfoInterface } from '~/app/shared/models/upgrade.interface';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { HealthService } from '~/app/shared/api/health.service';
+import { SharedModule } from '~/app/shared/shared.module';
+
+export class SummaryServiceMock {
+ summaryDataSource = new BehaviorSubject({
+ version:
+ 'ceph version 17.0.0-12222-gcd0cd7cb ' +
+ '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) quincy (dev)'
+ });
+ summaryData$ = this.summaryDataSource.asObservable();
+
+ subscribe(call: any) {
+ return this.summaryData$.subscribe(call);
+ }
+}
+
+describe('UpgradeComponent', () => {
+ let component: UpgradeComponent;
+ let fixture: ComponentFixture<UpgradeComponent>;
+ let upgradeInfoSpy: jasmine.Spy;
+ let getHealthSpy: jasmine.Spy;
+
+ const healthPayload: Record<string, any> = {
+ health: { status: 'HEALTH_OK' },
+ mon_status: { monmap: { mons: [] }, quorum: [] },
+ osd_map: { osds: [] },
+ mgr_map: { active_name: 'test_mgr', standbys: [] },
+ hosts: 0,
+ rgw: 0,
+ fs_map: { filesystems: [], standbys: [] },
+ iscsi_daemons: 1,
+ client_perf: {},
+ scrub_status: 'Inactive',
+ pools: [],
+ df: { stats: {} },
+ pg_info: { object_stats: { num_objects: 1 } }
+ };
+
+ configureTestBed({
+ imports: [HttpClientTestingModule, SharedModule],
+ schemas: [NO_ERRORS_SCHEMA],
+ declarations: [UpgradeComponent],
+ providers: [UpgradeService, { provide: SummaryService, useClass: SummaryServiceMock }]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(UpgradeComponent);
+ component = fixture.componentInstance;
+ upgradeInfoSpy = spyOn(TestBed.inject(UpgradeService), 'list');
+ getHealthSpy = spyOn(TestBed.inject(HealthService), 'getMinimalHealth');
+ getHealthSpy.and.returnValue(of(healthPayload));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should load the view once check for upgrade is done', () => {
+ const upgradeInfoPayload = {
+ image: 'quay.io/ceph-test/ceph',
+ registry: 'quay.io',
+ versions: ['18.1.0', '18.1.1', '18.1.2']
+ };
+ upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+ component.ngOnInit();
+ fixture.detectChanges();
+ const firstCellSpan = fixture.debugElement.nativeElement.querySelector('span');
+ expect(firstCellSpan.textContent).toBe('Current Version');
+ });
+
+ it('should show button to Upgrade if a new version is available', () => {
+ const upgradeInfoPayload = {
+ image: 'quay.io/ceph-test/ceph',
+ registry: 'quay.io',
+ versions: ['18.1.0', '18.1.1', '18.1.2']
+ };
+ upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+ component.ngOnInit();
+ fixture.detectChanges();
+ const upgradeNowBtn = fixture.debugElement.nativeElement.querySelector('#upgrade');
+ expect(upgradeNowBtn).not.toBeNull();
+ });
+
+ it('should not show the upgrade button if there are no new version available', () => {
+ const upgradeInfoPayload: UpgradeInfoInterface = {
+ image: 'quay.io/ceph-test/ceph',
+ registry: 'quay.io',
+ versions: []
+ };
+ upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+ component.ngOnInit();
+ fixture.detectChanges();
+ const noUpgradesSpan = fixture.debugElement.nativeElement.querySelector(
+ '#no-upgrades-available'
+ );
+ expect(noUpgradesSpan.textContent).toBe(' Cluster is up-to-date ');
+ });
+
+ it('should show the loading screen while the api call is pending', () => {
+ const loading = fixture.debugElement.nativeElement.querySelector('h3');
+ expect(loading.textContent).toBe('Checking for upgrades ');
+ });
+
+ it('should upgrade only when there are more than 1 mgr', () => {
+ const upgradeInfoPayload = {
+ image: 'quay.io/ceph-test/ceph',
+ registry: 'quay.io',
+ versions: ['18.1.0', '18.1.1', '18.1.2']
+ };
+ upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+ component.ngOnInit();
+ fixture.detectChanges();
+ const upgradeBtn = fixture.debugElement.nativeElement.querySelector('#upgrade');
+ expect(upgradeBtn.disabled).toBeTruthy();
+
+ // Add a standby mgr to the payload
+ const healthPayload2: Record<string, any> = {
+ health: { status: 'HEALTH_OK' },
+ mon_status: { monmap: { mons: [] }, quorum: [] },
+ osd_map: { osds: [] },
+ mgr_map: { active_name: 'test_mgr', standbys: ['mgr1'] },
+ hosts: 0,
+ rgw: 0,
+ fs_map: { filesystems: [], standbys: [] },
+ iscsi_daemons: 1,
+ client_perf: {},
+ scrub_status: 'Inactive',
+ pools: [],
+ df: { stats: {} },
+ pg_info: { object_stats: { num_objects: 1 } }
+ };
+
+ getHealthSpy.and.returnValue(of(healthPayload2));
+ component.ngOnInit();
+ fixture.detectChanges();
+ expect(upgradeBtn.disabled).toBeFalsy();
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { catchError, ignoreElements } from 'rxjs/operators';
+import { HealthService } from '~/app/shared/api/health.service';
+import { UpgradeService } from '~/app/shared/api/upgrade.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { Permission } from '~/app/shared/models/permissions';
+import { UpgradeInfoInterface } from '~/app/shared/models/upgrade.interface';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+
+@Component({
+ selector: 'cd-upgrade',
+ templateUrl: './upgrade.component.html',
+ styleUrls: ['./upgrade.component.scss']
+})
+export class UpgradeComponent implements OnInit {
+ version: string;
+ upgradeInfo$: Observable<UpgradeInfoInterface>;
+ upgradeInfoError$: Observable<any>;
+ permission: Permission;
+ healthData$: Observable<any>;
+ fsid$: Observable<any>;
+
+ icons = Icons;
+
+ constructor(
+ private summaryService: SummaryService,
+ private upgradeService: UpgradeService,
+ private authStorageService: AuthStorageService,
+ private healthService: HealthService
+ ) {
+ this.permission = this.authStorageService.getPermissions().configOpt;
+ }
+
+ ngOnInit(): void {
+ this.summaryService.subscribe((summary) => {
+ const version = summary.version.replace('ceph version ', '').split('-');
+ this.version = version[0];
+ });
+ this.upgradeInfo$ = this.upgradeService.list();
+ this.upgradeInfoError$ = this.upgradeInfo$?.pipe(
+ ignoreElements(),
+ catchError((error) => of(error))
+ );
+ this.healthData$ = this.healthService.getMinimalHealth();
+ this.fsid$ = this.healthService.getClusterFsid();
+ }
+}
class="badge badge-warning ms-1">{{ prometheusAlertService.activeWarningAlerts }}</small>
</a>
</li>
+ <li routerLinkActive="active"
+ class="tc_submenuitem tc_submenuitem_upgrade"
+ *ngIf="permissions.configOpt.read">
+ <a i18n
+ routerLink="/upgrade">Upgrade</a>
+ </li>
</ul>
</li>
[['osd'], ['.tc_submenuitem_osds', '.tc_submenuitem_crush']],
[
['configOpt'],
- ['.tc_submenuitem_configuration', '.tc_submenuitem_modules', '.tc_submenuitem_users']
+ [
+ '.tc_submenuitem_configuration',
+ '.tc_submenuitem_modules',
+ '.tc_submenuitem_users',
+ '.tc_submenuitem_upgrade'
+ ]
],
[['log'], ['.tc_submenuitem_log']],
[['prometheus'], ['.tc_submenuitem_monitoring']],
--- /dev/null
+import { UpgradeService } from './upgrade.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { SummaryService } from '../services/summary.service';
+import { BehaviorSubject } from 'rxjs';
+
+export class SummaryServiceMock {
+ summaryDataSource = new BehaviorSubject({
+ version:
+ 'ceph version 18.1.3-12222-gcd0cd7cb ' +
+ '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) reef (dev)'
+ });
+ summaryData$ = this.summaryDataSource.asObservable();
+
+ subscribe(call: any) {
+ return this.summaryData$.subscribe(call);
+ }
+}
+
+describe('UpgradeService', () => {
+ let service: UpgradeService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [UpgradeService, { provide: SummaryService, useClass: SummaryServiceMock }]
+ });
+
+ beforeEach(() => {
+ service = TestBed.inject(UpgradeService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call upgrade list', () => {
+ service.list().subscribe();
+ const req = httpTesting.expectOne('api/cluster/upgrade');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should not show any version if the registry versions are older than the cluster version', () => {
+ const upgradeInfoPayload = {
+ image: 'quay.io/ceph-test/ceph',
+ registry: 'quay.io',
+ versions: ['18.1.0', '18.1.1', '18.1.2']
+ };
+ const expectedVersions: string[] = [];
+ expect(service.versionAvailableForUpgrades(upgradeInfoPayload).versions).toEqual(
+ expectedVersions
+ );
+ });
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ApiClient } from './api-client';
+import { map } from 'rxjs/operators';
+import { SummaryService } from '../services/summary.service';
+import { UpgradeInfoInterface } from '../models/upgrade.interface';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class UpgradeService extends ApiClient {
+ baseURL = 'api/cluster/upgrade';
+
+ constructor(private http: HttpClient, private summaryService: SummaryService) {
+ super();
+ }
+
+ list() {
+ return this.http.get(this.baseURL).pipe(
+ map((resp: UpgradeInfoInterface) => {
+ return this.versionAvailableForUpgrades(resp);
+ })
+ );
+ }
+
+ // Filter out versions that are older than the current cluster version
+ // Only allow upgrades to the same major version
+ versionAvailableForUpgrades(upgradeInfo: UpgradeInfoInterface): UpgradeInfoInterface {
+ let version = '';
+ this.summaryService.subscribe((summary) => {
+ version = summary.version.replace('ceph version ', '').split('-')[0];
+ });
+
+ const upgradableVersions = upgradeInfo.versions.filter((targetVersion) => {
+ const cVersion = version.split('.');
+ const tVersion = targetVersion.split('.');
+ return (
+ cVersion[0] === tVersion[0] && (cVersion[1] < tVersion[1] || cVersion[2] < tVersion[2])
+ );
+ });
+ upgradeInfo.versions = upgradableVersions;
+ return upgradeInfo;
+ }
+}
--- /dev/null
+export interface UpgradeInfoInterface {
+ image: string;
+ registry: string;
+ versions: string[];
+}