diff --git a/modules/web/src/app/cluster/details/cluster/edit-cluster/component.spec.ts b/modules/web/src/app/cluster/details/cluster/edit-cluster/component.spec.ts index 8551af3f1e..9fd514c511 100644 --- a/modules/web/src/app/cluster/details/cluster/edit-cluster/component.spec.ts +++ b/modules/web/src/app/cluster/details/cluster/edit-cluster/component.spec.ts @@ -14,7 +14,7 @@ import {EventEmitter} from '@angular/core'; import {ComponentFixture, fakeAsync, flush, TestBed, tick, waitForAsync} from '@angular/core/testing'; -import {MatDialogRef} from '@angular/material/dialog'; +import {MatDialog, MatDialogRef} from '@angular/material/dialog'; import {BrowserModule} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Router} from '@angular/router'; @@ -42,7 +42,7 @@ import {ProjectMockService} from '@test/services/project-mock'; import {RouterStub} from '@test/services/router-stubs'; import {SettingsMockService} from '@test/services/settings-mock'; import {UserMockService} from '@test/services/user-mock'; -import {Subject} from 'rxjs'; +import {of, Subject} from 'rxjs'; import {AlibabaProviderSettingsComponent} from '../edit-provider-settings/alibaba-provider-settings/component'; import {AWSProviderSettingsComponent} from '../edit-provider-settings/aws-provider-settings/component'; import {AzureProviderSettingsComponent} from '../edit-provider-settings/azure-provider-settings/component'; @@ -89,6 +89,7 @@ describe('EditClusterComponent', () => { ], providers: [ {provide: DatacenterService, useClass: DatacenterMockService}, + {provide: MatDialog, useValue: {open: jest.fn().mockReturnValue({afterClosed: () => of(undefined)})}}, {provide: MatDialogRef, useClass: MatDialogRefMock}, {provide: ClusterService, useValue: clusterServiceMock}, {provide: AppConfigService, useClass: AppConfigMockService}, diff --git a/modules/web/src/app/cluster/details/cluster/edit-cluster/component.ts b/modules/web/src/app/cluster/details/cluster/edit-cluster/component.ts index a07083626e..a11b3433f7 100644 --- a/modules/web/src/app/cluster/details/cluster/edit-cluster/component.ts +++ b/modules/web/src/app/cluster/details/cluster/edit-cluster/component.ts @@ -14,7 +14,8 @@ import {ChangeDetectorRef, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {FormBuilder, FormControl, FormGroup, Validators} from '@angular/forms'; -import {MatDialogRef} from '@angular/material/dialog'; +import {MatDialog, MatDialogRef} from '@angular/material/dialog'; +import {MatSelectChange} from '@angular/material/select'; import {ClusterBackupService} from '@app/core/services/cluster-backup'; import {UserClusterConfigService} from '@app/core/services/user-cluster-config'; import {DynamicModule} from '@app/dynamic/module-registry'; @@ -58,7 +59,7 @@ import {IPV4_IPV6_CIDR_PATTERN} from '@shared/validators/others'; import {KmValidators} from '@shared/validators/validators'; import _ from 'lodash'; import {Observable, Subject} from 'rxjs'; -import {map, switchMap, take, takeUntil, tap} from 'rxjs/operators'; +import {filter, map, switchMap, take, takeUntil, tap} from 'rxjs/operators'; enum Controls { Name = 'name', @@ -127,6 +128,7 @@ export class EditClusterComponent implements OnInit, OnDestroy { enforcedAuditWebhookSettings: AuditLoggingWebhookBackend; backupStorageLocationsList: BackupStorageLocation[]; backupStorageLocationLabel: BSLListState = BSLListState.Ready; + readonly createBackupStorageLocationOptionValue = '__create_backup_storage_location__'; isAllowedIPRangeSupported: boolean; readonly isEnterpriseEdition = DynamicModule.isEnterpriseEdition; readonly CLUSTER_DEFAULT_NODE_SELECTOR_NAMESPACE = CLUSTER_DEFAULT_NODE_SELECTOR_NAMESPACE; @@ -157,6 +159,7 @@ export class EditClusterComponent implements OnInit, OnDestroy { private readonly _builder: FormBuilder, private readonly _clusterService: ClusterService, private readonly _datacenterService: DatacenterService, + private readonly _matDialog: MatDialog, private readonly _matDialogRef: MatDialogRef, private readonly _notificationService: NotificationService, private readonly _settingsService: SettingsService, @@ -529,11 +532,48 @@ export class EditClusterComponent implements OnInit, OnDestroy { .listBackupStorageLocation(projectID) .pipe(takeUntil(this._unsubscribe)) .subscribe(cbslList => { - this.backupStorageLocationsList = cbslList; - this.backupStorageLocationLabel = cbslList.length ? BSLListState.Ready : BSLListState.Empty; + this.backupStorageLocationsList = cbslList.filter(bsl => this._isBackupStorageLocationAvailable(bsl)); + this.backupStorageLocationLabel = this.backupStorageLocationsList.length + ? BSLListState.Ready + : BSLListState.Empty; + + const backupStorageLocationControl = this.form.get(Controls.BackupStorageLocation); + if ( + backupStorageLocationControl && + !this.backupStorageLocationsList.some(bsl => bsl.name === backupStorageLocationControl.value) + ) { + backupStorageLocationControl.reset(); + } }); } + async onBackupStorageLocationSelectionChange(event: MatSelectChange): Promise { + if (event.value !== this.createBackupStorageLocationOptionValue) { + return; + } + + const backupStorageLocationControl = this.form.get(Controls.BackupStorageLocation); + const previousValue = this.cluster.spec?.backupConfig?.backupStorageLocation?.name ?? ''; + backupStorageLocationControl.setValue(previousValue, {emitEvent: false}); + + const AddBackupStorageLocationDialogComponent = await DynamicModule.AddBackupStorageLocationDialogComponent; + if (!AddBackupStorageLocationDialogComponent) { + return; + } + this._matDialog + .open(AddBackupStorageLocationDialogComponent, { + data: {projectID: this.projectID}, + }) + .afterClosed() + .pipe(take(1), filter(Boolean)) + .subscribe(() => this._getCBSL(this.projectID)); + } + + private _isBackupStorageLocationAvailable(bsl: BackupStorageLocation): boolean { + const status = bsl.status?.phase || bsl.spec?.status || ''; + return status.toLowerCase() === 'available'; + } + getObservable(): Observable { const patch: ClusterPatch = { name: this.form.get(Controls.Name).value, diff --git a/modules/web/src/app/cluster/details/cluster/edit-cluster/template.html b/modules/web/src/app/cluster/details/cluster/edit-cluster/template.html index 0d2b34f7a5..2823f8d58c 100644 --- a/modules/web/src/app/cluster/details/cluster/edit-cluster/template.html +++ b/modules/web/src/app/cluster/details/cluster/edit-cluster/template.html @@ -391,22 +391,18 @@ } - @if (datacenter?.spec?.provider === NodeProvider.OPENSTACK) { - - Skip Router Reconciliation - - } - @if (!!form.get(Controls.ClusterBackup).value && isclusterBackupEnabled) { {{backupStorageLocationLabel}} @for (bsl of backupStorageLocationsList; track bsl) { {{bsl.displayName}} } + + Create Backup Storage Location @if (form.get(Controls.BackupStorageLocation).hasError('required')) { @@ -416,6 +412,12 @@ } + @if (datacenter?.spec?.provider === NodeProvider.OPENSTACK) { + + Skip Router Reconciliation + + } + (); + private readonly _dnsLabelRegex = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; readonly Controls = Controls; readonly veleroChecksumAlgorithms = Object.values(VeleroChecksumAlgorithm); form: FormGroup; @@ -108,12 +109,18 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro this._config.bslObject?.spec.backupSyncPeriod ?? '0', CBSL_SYNC_PERIOD ), - [Controls.Region]: this._builder.control(this._config.bslObject?.spec.config?.region ?? ''), - [Controls.Endpoints]: this._builder.control(this._config.bslObject?.spec.config?.s3Url ?? ''), + [Controls.Region]: this._builder.control(this._config.bslObject?.spec.config?.region ?? '', [ + this._dnsNameValidator(), + ]), + [Controls.Endpoints]: this._builder.control(this._config.bslObject?.spec.config?.s3Url ?? '', [ + this._endpointURLValidator(), + ]), [Controls.ChecksumAlgorithm]: this._builder.control(this._config.bslObject?.spec.config?.checksumAlgorithm ?? ''), [Controls.AddCustomConfig]: this._builder.control(false), }); + this._updateRegionAndEndpointValidators(this.form.get(Controls.AddCustomConfig).value); + if (this._config.bslObject) { this.form.get(Controls.Name).disable(); } else { @@ -132,6 +139,7 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro .get(Controls.AddCustomConfig) .valueChanges.pipe(takeUntil(this._unsubscribe)) .subscribe((value: boolean) => { + this._updateRegionAndEndpointValidators(value); let config: BackupStorageLocationConfig; if (this._config.bslObject?.name) { config = this._config.bslObject.spec.config; @@ -196,7 +204,7 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro this.form.get(Controls.BackupSyncPeriod).value === '' ? null : this.form.get(Controls.BackupSyncPeriod).value, config: { region: this.form.get(Controls.Region).value, - s3Url: this.form.get(Controls.Endpoints).value, + s3Url: this.form.get(Controls.Endpoints).value?.trim(), checksumAlgorithm: this.form.get(Controls.ChecksumAlgorithm).value || '', }, provider: SupportedBSLProviders.AWS, @@ -217,4 +225,64 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro } return bsl; } + + private _dnsNameValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value?.trim(); + if (!value) { + return null; + } + + const labels = value.split('.'); + return labels.every(label => this._dnsLabelRegex.test(label)) ? null : {invalidDnsName: true}; + }; + } + + private _endpointURLValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value?.trim(); + if (!value) { + return null; + } + + if (!/^https?:\/\//i.test(value)) { + return {invalidEndpointUrl: true}; + } + + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol)) { + return {invalidEndpointUrl: true}; + } + + if (!url.hostname) { + return {invalidEndpointUrl: true}; + } + + const labels = url.hostname.split('.'); + return labels.every(label => this._dnsLabelRegex.test(label)) ? null : {invalidEndpointUrl: true}; + } catch { + return {invalidEndpointUrl: true}; + } + }; + } + + private _updateRegionAndEndpointValidators(addCustomConfig: boolean): void { + const regionControl = this.form.get(Controls.Region); + const endpointControl = this.form.get(Controls.Endpoints); + + const regionValidators = [this._dnsNameValidator()]; + const endpointValidators = [this._endpointURLValidator()]; + + if (!addCustomConfig) { + regionValidators.unshift(Validators.required); + endpointValidators.unshift(Validators.required); + } + + regionControl.setValidators(regionValidators); + endpointControl.setValidators(endpointValidators); + + regionControl.updateValueAndValidity({emitEvent: false}); + endpointControl.updateValueAndValidity({emitEvent: false}); + } } diff --git a/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/add-dialog/template.html b/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/add-dialog/template.html index d167863b69..a0e7979faf 100644 --- a/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/add-dialog/template.html +++ b/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/add-dialog/template.html @@ -46,7 +46,7 @@ matInput required> The bucket in which to store backups. - @if (form.get(Controls.Name).hasError('required')) { + @if (form.get(Controls.Bucket).hasError('required')) { Required @@ -69,12 +69,14 @@ + Optional. Leave empty for anonymous S3 access. Secret Access Key + Optional. Leave empty for anonymous S3 access. Backup Sync Period @@ -103,12 +105,32 @@ The AWS region where the bucket is located. + @if (form.get(Controls.Region).hasError('required')) { + + Required + + } + @if (form.get(Controls.Region).hasError('invalidDnsName')) { + + Region must be a valid DNS name. + + } - Endpoints + Endpoint URL Specify the AWS S3 URL here. + @if (form.get(Controls.Endpoints).hasError('required')) { + + Required + + } + @if (form.get(Controls.Endpoints).hasError('invalidEndpointUrl')) { + + Endpoint URL must start with http:// or https:// and contain a valid DNS host. + + } Default Checksum Algorithm diff --git a/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/component.ts b/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/component.ts index c231b639d5..fd39b994e3 100644 --- a/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/component.ts +++ b/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/component.ts @@ -23,6 +23,7 @@ import {MatDialog, MatDialogConfig} from '@angular/material/dialog'; import {MatPaginator} from '@angular/material/paginator'; import {MatSort} from '@angular/material/sort'; import {MatTableDataSource} from '@angular/material/table'; +import {MatTooltip} from '@angular/material/tooltip'; import {ProjectService} from '@app/core/services/project'; import {BackupStorageLocation, BackupType} from '@app/shared/entity/backup'; import {Project} from '@app/shared/entity/project'; @@ -46,6 +47,7 @@ import {DISABLED_TOOLTIP_MESSAGE} from '@app/shared/constants/common'; }) export class BackupStorageLocationsListComponent implements OnInit, OnDestroy { private readonly _unsubscribe = new Subject(); + private readonly _copiedStatusMessageByBSL = new Map(); private _selectedProject: Project; private _user: Member; private _currentGroupConfig: GroupConfig; @@ -109,6 +111,25 @@ export class BackupStorageLocationsListComponent implements OnInit, OnDestroy { return getClusterBackupHealthStatus(phase).icon; } + getStatusTooltip(bsl: BackupStorageLocation): string { + return this._copiedStatusMessageByBSL.get(bsl.name) ? 'Error message copied' : bsl.status?.message; + } + + canCopyStatusMessage(bsl: BackupStorageLocation): boolean { + return !this._isBSLAvailable(bsl) && !!bsl.status?.message; + } + + copyStatusMessage(bsl: BackupStorageLocation, tooltip: MatTooltip): void { + if (!this.canCopyStatusMessage(bsl)) { + return; + } + + navigator.clipboard.writeText(bsl.status.message); + this._copiedStatusMessageByBSL.set(bsl.name, true); + tooltip.message = 'Error message copied'; + tooltip.show(0); + } + addBackupStorageLocation(): void { const config: MatDialogConfig = { data: { @@ -173,4 +194,9 @@ export class BackupStorageLocationsListComponent implements OnInit, OnDestroy { this.dataSource.data = this.backupStorageLocations; }); } + + private _isBSLAvailable(bsl: BackupStorageLocation): boolean { + const phase = bsl.status?.phase || ''; + return phase.toLowerCase() === 'available'; + } } diff --git a/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/template.html b/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/template.html index aee09f2f43..45cb584991 100644 --- a/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/template.html +++ b/modules/web/src/app/dynamic/enterprise/cluster-backups/list/backup-storage-location/template.html @@ -48,8 +48,11 @@ class="km-header-cell"> - diff --git a/modules/web/src/app/dynamic/module-registry.ce.ts b/modules/web/src/app/dynamic/module-registry.ce.ts index b3eb10883c..f1bb2d4b60 100644 --- a/modules/web/src/app/dynamic/module-registry.ce.ts +++ b/modules/web/src/app/dynamic/module-registry.ce.ts @@ -23,6 +23,7 @@ export namespace DynamicModule { export const Quotas = import('./community/quotas/module').then(module => module.QuotasModule); export const Group = import('./community/group/module').then(module => module.GroupModule); export const ClusterBackups = import('./community/cluster-backups/module').then(module => module.ClusterBackupsModule); + export const AddBackupStorageLocationDialogComponent = Promise.resolve(null); export const KyvernoPolicies = import('./community/kyverno-policies/module').then(module => module.KyvernoPoliciesModule); export const isEnterpriseEdition = false; } diff --git a/modules/web/src/app/dynamic/module-registry.ts b/modules/web/src/app/dynamic/module-registry.ts index 9d50effb44..c2df087f2c 100644 --- a/modules/web/src/app/dynamic/module-registry.ts +++ b/modules/web/src/app/dynamic/module-registry.ts @@ -28,6 +28,10 @@ export namespace DynamicModule { export const ClusterBackups = import('./enterprise/cluster-backups/module').then( module => module.ClusterBackupsModule ); + export const AddBackupStorageLocationDialogComponent = + import('./enterprise/cluster-backups/list/backup-storage-location/add-dialog/component').then( + module => module.AddBackupStorageLocationDialogComponent + ); export const KyvernoPolicies = import('./enterprise/kyverno-policies/module').then( module => module.KyvernoPoliciesModule ); diff --git a/modules/web/src/app/wizard/step/cluster/component.ts b/modules/web/src/app/wizard/step/cluster/component.ts index c636c6d290..b463d720ef 100644 --- a/modules/web/src/app/wizard/step/cluster/component.ts +++ b/modules/web/src/app/wizard/step/cluster/component.ts @@ -23,6 +23,7 @@ import { Validators, } from '@angular/forms'; import {MatDialog} from '@angular/material/dialog'; +import {MatSelectChange} from '@angular/material/select'; import {ApplicationService} from '@app/core/services/application'; import {ClusterBackupService} from '@app/core/services/cluster-backup'; import {FeatureGateService} from '@app/core/services/feature-gate'; @@ -170,6 +171,7 @@ enum Controls { standalone: false, }) export class ClusterStepComponent extends StepBase implements OnInit, ControlValueAccessor, Validator, OnDestroy { + readonly createBackupStorageLocationOptionValue = '__create_backup_storage_location__'; containerRuntime = ContainerRuntime; admissionPlugin = AdmissionPlugin; masterVersions: MasterVersion[] = []; @@ -217,6 +219,7 @@ export class ClusterStepComponent extends StepBase implements OnInit, ControlVal private _datacenterSpec: Datacenter; private _seedSettings: SeedSettings; private _settings: AdminSettings; + private _selectedProjectID: string; private _auditWebhookBackendChangesSubscription: Subscription; private readonly _minNameLength = 5; private readonly _canalDualStackMinimumSupportedVersion = '3.22.0'; @@ -289,9 +292,10 @@ export class ClusterStepComponent extends StepBase implements OnInit, ControlVal this.form.updateValueAndValidity(); }); - this._projectService.selectedProject - .pipe(takeUntil(this._unsubscribe)) - .subscribe(project => this._getCBSL(project.id)); + this._projectService.selectedProject.pipe(takeUntil(this._unsubscribe)).subscribe(project => { + this._selectedProjectID = project.id; + this._getCBSL(project.id); + }); this._fetchCNIPlugins(); @@ -1215,11 +1219,49 @@ export class ClusterStepComponent extends StepBase implements OnInit, ControlVal .listBackupStorageLocation(projectID) .pipe(takeUntil(this._unsubscribe)) .subscribe(cbslList => { - this.backupStorageLocationsList = cbslList; - this.backupStorageLocationLabel = cbslList.length ? BSLListState.Ready : BSLListState.Empty; + this.backupStorageLocationsList = cbslList.filter(bsl => this._isBackupStorageLocationAvailable(bsl)); + this.backupStorageLocationLabel = this.backupStorageLocationsList.length + ? BSLListState.Ready + : BSLListState.Empty; + + const backupStorageLocationControl = this.form.get(Controls.BackupStorageLocation); + if ( + backupStorageLocationControl && + !this.backupStorageLocationsList.some(bsl => bsl.name === backupStorageLocationControl.value) + ) { + backupStorageLocationControl.reset(); + } }); } + async onBackupStorageLocationSelectionChange(event: MatSelectChange): Promise { + if (event.value !== this.createBackupStorageLocationOptionValue) { + return; + } + + const backupStorageLocationControl = this.form.get(Controls.BackupStorageLocation); + const previousValue = this._clusterSpecService.cluster?.spec?.backupConfig?.backupStorageLocation?.name ?? ''; + backupStorageLocationControl.setValue(previousValue, {emitEvent: false}); + + const AddBackupStorageLocationDialogComponent = await DynamicModule.AddBackupStorageLocationDialogComponent; + if (!AddBackupStorageLocationDialogComponent) { + return; + } + this._matDialog + .open(AddBackupStorageLocationDialogComponent, { + data: {projectID: this._selectedProjectID}, + }) + .afterClosed() + .pipe(take(1)) + .pipe(filter(Boolean)) + .subscribe(() => this._getCBSL(this._selectedProjectID)); + } + + private _isBackupStorageLocationAvailable(bsl: BackupStorageLocation): boolean { + const status = bsl.status?.phase || bsl.spec?.status || ''; + return status.toLowerCase() === 'available'; + } + private _handleClusterBackupChange(): void { this.form .get(Controls.BackupStorageLocation) diff --git a/modules/web/src/app/wizard/step/cluster/template.html b/modules/web/src/app/wizard/step/cluster/template.html index 3c0698a6e5..8e3fb8cc13 100644 --- a/modules/web/src/app/wizard/step/cluster/template.html +++ b/modules/web/src/app/wizard/step/cluster/template.html @@ -494,11 +494,13 @@

IPv6

{{backupStorageLocationLabel}} @for (bsl of backupStorageLocationsList; track bsl) { {{bsl.displayName}} } + + Create Backup Storage Location @if (form.get(Controls.BackupStorageLocation).hasError('required')) {