This commit is contained in:
PUJA SHAHU 2026-07-31 19:43:30 +00:00 committed by GitHub
commit 7158bf075d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 537 additions and 398 deletions

View File

@ -11,6 +11,7 @@ import { FeatureTogglesGuardService } from '~/app/shared/services/feature-toggle
import { ModuleStatusGuardService } from '~/app/shared/services/module-status-guard.service';
import { SharedModule } from '~/app/shared/shared.module';
import { TextLabelListComponent } from '~/app/shared/components/text-label-list/text-label-list.component';
import { CertificateAuthorityFormComponent } from '~/app/shared/components/certificate-authority-form/certificate-authority-form.component';
import { IscsiSettingComponent } from './iscsi-setting/iscsi-setting.component';
import { IscsiTabsComponent } from './iscsi-tabs/iscsi-tabs.component';
import { IscsiTargetDetailsComponent } from './iscsi-target-details/iscsi-target-details.component';
@ -152,7 +153,8 @@ import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/
ThemeModule,
NvmeofSetupCardsComponent,
NvmeofGatewayGroupFilterComponent,
TextLabelListComponent
TextLabelListComponent,
CertificateAuthorityFormComponent
],
declarations: [
RbdListComponent,

View File

@ -91,6 +91,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy, OnChanges
orchStatus: OrchestratorStatus | undefined;
private destroy$ = new Subject<void>();
private sub: Subscription | undefined;
private pendingHostReload = false;
constructor(
private authStorageService: AuthStorageService,
@ -303,9 +304,12 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy, OnChanges
this.tableContext =
context || this.tableContext || new CdTableFetchDataContext(() => undefined);
if (this.isLoadingHosts) {
// Edit preselection may arrive while the initial fetch is in-flight
this.pendingHostReload = true;
return;
}
this.isLoadingHosts = true;
this.pendingHostReload = false;
if (this.sub) {
this.sub.unsubscribe();
@ -323,6 +327,10 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy, OnChanges
.pipe(
finalize(() => {
this.isLoadingHosts = false;
if (this.pendingHostReload) {
this.pendingHostReload = false;
this.getHosts(this.tableContext);
}
})
)
.subscribe({
@ -452,9 +460,9 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy, OnChanges
},
{
label: $localize`Encryption`,
value: serviceSpec.spec?.enable_auth ? $localize`Enabled` : $localize`Disabled`,
value: serviceSpec.spec?.encryption_key ? $localize`Enabled` : $localize`Disabled`,
type: 'status',
statusIcon: serviceSpec.spec?.enable_auth ? 'success' : 'error'
statusIcon: serviceSpec.spec?.encryption_key ? 'success' : 'error'
},
{
label: $localize`mTLS`,

View File

@ -87,225 +87,125 @@
cdsCol
class="cds-pt-3 cds-pb-3"
>
<cd-nvmeof-gateway-node (hostsLoaded)="onHostsLoaded($event)"></cd-nvmeof-gateway-node>
<cd-nvmeof-gateway-node
[preSelectedHostnames]="preSelectedHostnames"
(hostsLoaded)="onHostsLoaded($event)"
></cd-nvmeof-gateway-node>
</div>
</div>
<!-- Encryption — same cdsRow/cdsCol layout as the nodes table for horizontal alignment -->
<div
cdsRow
class="form-item cds-mb-0"
class="form-item"
>
<div cdsCol>
<cds-tile [cdsLayer]="1">
<!-- Encryption -->
<div
cdsRow
class="form-item"
>
<div cdsCol>
<div class="cds--stack cds--stack-horizontal">
<label
class="cds--type-label-01"
for="enableEncryption"
i18n
>Enable encryption</label
>
<cds-tag
type="green"
size="sm"
i18n
>Recommended</cds-tag
>
</div>
<cds-checkbox
id="enableEncryption"
formControlName="enableEncryption"
>
<span
class="cds--type-body-01"
i18n
>Secures group metadata and unlocks advanced authentication features.</span
>
</cds-checkbox>
</div>
</div>
@if (!groupForm.controls.enableEncryption.value) {
<div
cdsRow
class="cds-mt-2"
>
<div cdsCol>
<cd-alert-panel
type="warning"
spacingClass="mb-0"
i18n
>
<span class="cds--type-heading-01"
>Encryption is required if you plan to use DH-CHAP or mTLS
authentication.</span
>
</cd-alert-panel>
</div>
</div>
}
@if (groupForm.controls.enableEncryption.value) {
<div
cdsRow
class="form-item"
>
<div cdsCol>
<cds-textarea-label
labelInputID="encryptionKey"
helperText="Provide the encryption key used for securing the gateway group."
i18n
i18n-helperText
>
Encryption key
<textarea
cdsTextArea
id="encryptionKey"
formControlName="encryptionKey"
rows="4"
cols="100"
cdsTheme="g10"
>
</textarea>
</cds-textarea-label>
<span
class="invalid-feedback"
*ngIf="groupForm.showError('encryptionKey', formDir, 'required')"
i18n
>This field is required.</span
>
</div>
</div>
}
</cds-tile>
<cds-tile
[cdsLayer]="1"
class="cds-mt-3"
<div
[cdsStack]="'horizontal'"
[gap]="3"
class="cds-mb-2"
>
<div
cdsRow
class="form-item"
<label
class="cds--type-label-01"
for="enableEncryption"
i18n
>Enable encryption</label
>
<div cdsCol>
<div class="cds--stack cds--stack-horizontal cds-mb-3">
<label
class="cds--type-label-01"
for="enableMtls"
i18n
>Enable Mutual TLS (mTLS)</label
>
</div>
<cds-checkbox
id="enableMtls"
formControlName="enableMtls"
>
<span
class="cds--type-body-01"
i18n
>Use mutual TLS (mTLS) to encrypt control and monitoring commands exchanged with
NVMe-oF gateways.</span
>
</cds-checkbox>
</div>
</div>
<cds-tag
type="green"
size="sm"
i18n
>Recommended</cds-tag
>
</div>
<cds-checkbox
id="enableEncryption"
formControlName="enableEncryption"
>
<span
class="cds--type-body-01 cds-mb-3"
i18n
>Secures group metadata and unlocks advanced authentication features.</span
>
</cds-checkbox>
@if (!groupForm.controls.enableMtls.value) {
<div
cdsRow
class="cds-mt-3"
@if (!groupForm.controls.enableEncryption.value) {
<cd-alert-panel
type="warning"
i18n
>
<span class="cds--type-heading-01"
>Encryption is required if you plan to use DH-CHAP or mTLS authentication.</span
>
<div cdsCol>
<cd-alert-panel
type="warning"
spacingClass="mb-0"
class="cds--type-heading-01"
i18n
>
Disabling mTLS leaves gateway control and monitoring communication unencrypted.
</cd-alert-panel>
</div>
</div>
}
</cd-alert-panel>
}
@if (groupForm.controls.enableMtls.value) {
<div
cdsRow
class="form-item"
@if (groupForm.controls.enableEncryption.value) {
<cds-textarea-label
labelInputID="encryptionKey"
helperText="Optional. Provide the encryption key used for securing the gateway group."
i18n
i18n-helperText
>
<textarea
class="w-100"
cdsTextArea
id="encryptionKey"
formControlName="encryptionKey"
rows="5"
cdsTheme="g10"
>
<div cdsCol>
<label
class="cds--label fw-bold"
for="certificateType"
i18n
>Choose Certificate Authority</label
>
<cds-radio-group
formControlName="certificateType"
orientation="horizontal"
helperText="Select how certificates will be signed for this service. Choose internal to use the cluster's CA, or external to upload certificates signed by your organization."
i18n-helperText
>
<cds-radio
value="internal"
[checked]="
groupForm.controls.certificateType.value === CertificateType.internal
"
(change)="onCertificateTypeChange(CertificateType.internal)"
i18n
>
Internal
</cds-radio>
<cds-radio
value="external"
[checked]="
groupForm.controls.certificateType.value === CertificateType.external
"
(change)="onCertificateTypeChange(CertificateType.external)"
i18n
>
External
</cds-radio>
</cds-radio-group>
</div>
</div>
}
</cds-tile>
</textarea>
</cds-textarea-label>
}
</div>
</div>
@if (
groupForm.controls.enableMtls.value &&
groupForm.controls.certificateType.value === CertificateType.internal
) {
<div
cdsRow
class="form-item"
>
<div cdsCol>
<!-- mTLS — same cdsRow/cdsCol layout as the nodes table for horizontal alignment -->
<div
cdsRow
class="form-item"
>
<div cdsCol>
<label
class="cds--type-label-01 cds-mb-3"
for="enableMtls"
i18n
>Enable Mutual TLS (mTLS)</label
>
<cds-checkbox
id="enableMtls"
formControlName="enableMtls"
>
<span
class="cds--type-body-01 cds-mb-3"
i18n
>Use mutual TLS (mTLS) to encrypt control and monitoring commands exchanged with
NVMe-oF gateways.</span
>
</cds-checkbox>
@if (!groupForm.controls.enableMtls.value) {
<cd-alert-panel
type="info"
spacingClass="mb-3"
type="warning"
class="cds--type-heading-01"
i18n
>
Certificate will be generated automatically by Cephadm CA for internal certificate
type.
Disabling mTLS leaves gateway control and monitoring communication unencrypted.
</cd-alert-panel>
<cd-text-label-list
formControlName="custom_sans"
label="Custom SAN Entries"
i18n-label
helperText="Optional list of Subject Alternative Names (hostnames, IPs, or DNS names) to include in the auto-generated certificate."
i18n-helperText
}
@if (groupForm.controls.enableMtls.value) {
<cd-certificate-authority-form
[formGroup]="groupForm"
[editing]="editing"
[currentCertificate]="currentCertificate"
[showCertSourceChangeWarning]="showCertSourceChangeWarning"
(certificateTypeChange)="onCertificateTypeChange($event)"
>
</cd-text-label-list>
</div>
</cd-certificate-authority-form>
}
</div>
}
</div>
@if (
groupForm.controls.enableMtls.value &&
@ -351,9 +251,9 @@
cdsTextArea
id="rootCACertText"
formControlName="rootCACert"
class="w-100"
placeholder="Paste certificate or private key PEM content"
i18n-placeholder
cols="100"
rows="4"
>
</textarea>
@ -407,9 +307,9 @@
cdsTextArea
id="clientCertText"
formControlName="clientCert"
class="w-100"
placeholder="Paste certificate or private key PEM content"
i18n-placeholder
cols="100"
rows="4"
>
</textarea>
@ -463,9 +363,9 @@
cdsTextArea
id="clientKeyText"
formControlName="clientKey"
class="w-100"
placeholder="Paste certificate or private key PEM content"
i18n-placeholder
cols="100"
rows="4"
>
</textarea>
@ -519,9 +419,9 @@
cdsTextArea
id="serverCertText"
formControlName="serverCert"
class="w-100"
placeholder="Paste certificate or private key PEM content"
i18n-placeholder
cols="100"
rows="4"
>
</textarea>
@ -575,9 +475,9 @@
cdsTextArea
id="serverKeyText"
formControlName="serverKey"
class="w-100"
placeholder="Paste certificate or private key PEM content"
i18n-placeholder
cols="100"
rows="4"
>
</textarea>

View File

@ -2,15 +2,17 @@ import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of } from 'rxjs';
import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { PaginateObservable } from '~/app/shared/api/paginate.model';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { SharedModule } from '~/app/shared/shared.module';
import { CertificateType } from '~/app/shared/models/service.interface';
import { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
import { CheckboxModule, GridModule, InputModule, SelectModule } from 'carbon-components-angular';
@ -32,7 +34,13 @@ describe('NvmeofGroupFormComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NvmeofGroupFormComponent],
providers: [NgbActiveModal],
providers: [
NgbActiveModal,
{
provide: ActivatedRoute,
useValue: { params: of({}) }
}
],
imports: [
HttpClientTestingModule,
NgbTypeaheadModule,
@ -132,7 +140,7 @@ describe('NvmeofGroupFormComponent', () => {
expect(cephServiceService.create).toHaveBeenCalledWith({
service_type: 'nvmeof',
service_id: 'nvmeof.default',
service_id: 'default',
group: 'default',
placement: {
hosts: ['host1', 'host2']
@ -196,7 +204,7 @@ describe('NvmeofGroupFormComponent', () => {
expect(cephServiceService.create).toHaveBeenCalledWith(
jasmine.objectContaining({
service_type: 'nvmeof',
service_id: 'nvmeof.mtls-internal',
service_id: 'mtls-internal',
ssl: true,
enable_auth: true,
certificate_source: 'cephadm-signed',
@ -226,7 +234,7 @@ describe('NvmeofGroupFormComponent', () => {
expect(cephServiceService.create).toHaveBeenCalledWith(
jasmine.objectContaining({
service_id: 'nvmeof.mtls-external',
service_id: 'mtls-external',
ssl: true,
enable_auth: true,
certificate_source: 'inline',
@ -251,4 +259,151 @@ describe('NvmeofGroupFormComponent', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
});
});
describe('edit mode', () => {
const mockCertificate = {
cert_name: 'nvmeof.Test1',
scope: 'service',
requires_certificate: true,
status: 'valid',
days_to_expiration: 90,
signed_by: 'cephadm',
has_certificate: true,
certificate_source: 'cephadm-signed',
expiry_date: '2026-12-01T00:00:00Z',
issuer: 'Cephadm',
common_name: 'nvmeof.Test1'
};
const mockService = {
service_name: 'nvmeof.Test1',
service_type: 'nvmeof',
service_id: 'Test1',
unmanaged: false,
certificate: mockCertificate,
placement: { hosts: ['ceph-node-01', 'ceph-node-02'] },
spec: {
group: 'Test1',
encryption_key: 'existing-encryption-key',
enable_auth: true,
ssl: true,
certificate_source: 'cephadm-signed',
custom_sans: ['gw1.local']
}
};
beforeEach(() => {
spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[mockService]]));
spyOn(cephServiceService, 'list').and.returnValue(
new PaginateObservable<any>(of([mockService]))
);
component.editing = true;
component.gatewayGroupName = 'Test1';
component.action = 'Edit';
component.createForm();
component.loadGatewayGroupData('Test1');
form = component.groupForm;
});
it('should resolve group by spec.group and preselect hosts', () => {
expect(nvmeofService.listGatewayGroups).toHaveBeenCalled();
expect(cephServiceService.list).toHaveBeenCalled();
expect(component.preSelectedHostnames).toEqual(['ceph-node-01', 'ceph-node-02']);
expect(form.controls.groupName.value).toBe('Test1');
});
it('should prepopulate encryption checkbox and key when encryption_key is present', () => {
expect(form.controls.enableEncryption.value).toBe(true);
expect(form.controls.encryptionKey.value).toBe('existing-encryption-key');
});
it('should prepopulate mTLS from enable_auth and load current certificate', () => {
expect(form.controls.enableMtls.value).toBe(true);
expect(form.controls.certificateType.value).toBe(CertificateType.internal);
expect(form.controls.custom_sans.value).toEqual(['gw1.local']);
expect(component.currentCertificate).toEqual(mockCertificate);
expect(component.currentSpecCertificateSource).toBe('cephadm-signed');
});
it('should prepopulate external cert fields when certificate_source is inline', () => {
const inlineService = {
...mockService,
certificate: {
...mockCertificate,
signed_by: 'external',
certificate_source: 'inline'
},
spec: {
...mockService.spec,
certificate_source: 'inline',
root_ca_cert: 'root-pem',
client_cert: 'client-pem',
client_key: 'client-key-pem',
server_cert: 'server-pem',
server_key: 'server-key-pem'
}
};
(nvmeofService.listGatewayGroups as jasmine.Spy).and.returnValue(of([[inlineService]]));
(cephServiceService.list as jasmine.Spy).and.returnValue(
new PaginateObservable<any>(of([inlineService]))
);
component.createForm();
component.loadGatewayGroupData('Test1');
expect(component.groupForm.controls.certificateType.value).toBe(CertificateType.external);
expect(component.groupForm.controls.rootCACert.value).toBe('root-pem');
expect(component.groupForm.controls.clientCert.value).toBe('client-pem');
expect(component.groupForm.controls.clientKey.value).toBe('client-key-pem');
expect(component.groupForm.controls.serverCert.value).toBe('server-pem');
expect(component.groupForm.controls.serverKey.value).toBe('server-key-pem');
});
it('should show cert source change warning when switching type on edit', () => {
component.onCertificateTypeChange(CertificateType.external);
expect(component.showCertSourceChangeWarning).toBe(true);
component.onCertificateTypeChange(CertificateType.internal);
expect(component.showCertSourceChangeWarning).toBe(false);
});
it('should keep form valid when encryption key is cleared or encryption is unchecked', () => {
form.controls.encryptionKey.setValue('');
expect(form.valid).toBe(true);
expect(component.isCreateDisabled).toBe(false);
form.controls.enableEncryption.setValue(false);
expect(form.controls.encryptionKey.value).toBeNull();
expect(form.valid).toBe(true);
expect(component.isCreateDisabled).toBe(false);
});
it('should update service on submit in edit mode using existing service_id', () => {
spyOn(cephServiceService, 'update').and.returnValue(of({}));
spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
spyOn(router, 'navigateByUrl');
component.gatewayNodeComponent = {
getSelectedHosts: (): any[] => [{ hostname: 'ceph-node-01' }],
getSelectedHostnames: (): string[] => ['ceph-node-01']
} as any;
component.onSubmit();
expect(cephServiceService.update).toHaveBeenCalledWith(
jasmine.objectContaining({
service_type: 'nvmeof',
service_id: 'Test1',
group: 'Test1',
placement: { hosts: ['ceph-node-01'] },
encryption_key: 'existing-encryption-key',
ssl: true,
enable_auth: true,
certificate_source: 'cephadm-signed'
})
);
expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
});
});
});

View File

@ -1,6 +1,10 @@
import { HttpParams } from '@angular/common/http';
import { Component, OnInit, ViewChild } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdForm } from '~/app/shared/forms/cd-form';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
@ -13,7 +17,11 @@ import { CephServiceService } from '~/app/shared/api/ceph-service.service';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { CdValidators } from '~/app/shared/forms/cd-validators';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { CertificateType } from '~/app/shared/models/service.interface';
import {
CephServiceCertificate,
CephServiceSpec,
CertificateType
} from '~/app/shared/models/service.interface';
@Component({
selector: 'cd-nvmeof-group-form',
@ -34,7 +42,11 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
hasAvailableNodes = true;
editing = false;
gatewayGroupName = '';
existingServiceData: any = null;
existingServiceData: CephServiceSpec | null = null;
preSelectedHostnames: string[] = [];
currentCertificate: CephServiceCertificate = null;
currentSpecCertificateSource = '';
showCertSourceChangeWarning = false;
constructor(
private authStorageService: AuthStorageService,
@ -97,11 +109,18 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
});
this.groupForm.get('enableEncryption')?.valueChanges.subscribe((enabled) => {
if (!enabled) {
return;
}
const encryptionConfigControl = this.groupForm.get('encryptionConfig');
const encryptionKeyControl = this.groupForm.get('encryptionKey');
if (!enabled) {
// Encryption is optional — clearing the toggle must not leave the form invalid
encryptionKeyControl?.setValue(null, { emitEvent: false });
encryptionConfigControl?.setValue(null, { emitEvent: false });
encryptionKeyControl?.setErrors(null);
encryptionConfigControl?.setErrors(null);
return;
}
if (!encryptionKeyControl?.value && encryptionConfigControl?.value) {
encryptionKeyControl.setValue(encryptionConfigControl.value, { emitEvent: false });
}
@ -112,43 +131,90 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
}
loadGatewayGroupData(groupName: string) {
this.nvmeofService.listGatewayGroups().subscribe(
(gatewayGroups: any) => {
const groups = gatewayGroups?.[0] ?? [];
const group = groups.find((g: any) => g.spec?.group === groupName);
if (group) {
this.existingServiceData = group;
const spec = group.spec || {};
this.groupForm.patchValue({
groupName: groupName,
unmanaged: spec.unmanaged || false,
enableEncryption: spec.ssl || false,
enableMtls: spec.ssl || false,
certificateType:
spec.certificate_source === 'inline'
? CertificateType.external
: CertificateType.internal,
encryptionKey: spec.encryption_key || '',
custom_sans: spec.custom_sans || []
});
if (spec.certificate_source === 'inline') {
this.groupForm.patchValue({
rootCACert: spec.root_ca_cert || null,
clientCert: spec.client_cert || null,
clientKey: spec.client_key || null,
serverCert: spec.server_cert || null,
serverKey: spec.server_key || null
});
// Resolve by spec.group — service_name may be nvmeof.<group> or nvmeof.<pool>.<group>
this.nvmeofService
.listGatewayGroups()
.pipe(
switchMap((gatewayGroups: CephServiceSpec[][]) => {
const groups = gatewayGroups?.[0] ?? [];
const group = groups.find((g: CephServiceSpec) => g.spec?.group === groupName);
if (!group) {
return of(null);
}
const serviceName = group.service_name;
if (!serviceName) {
return of(group);
}
// Enrich with certificate metadata (same as service edit form)
return this.cephServiceService
.list(new HttpParams({ fromObject: { limit: -1, offset: 0 } }), serviceName)
.observable.pipe(
catchError(() => of(group)),
switchMap((response: CephServiceSpec[] | CephServiceSpec) => {
if (Array.isArray(response) && response[0]) {
return of({
...group,
...response[0],
// Prefer placement/hosts from gateway-group listing
placement: group.placement || response[0].placement,
// Prefer enriched service spec (certs / encryption_key)
spec: { ...group.spec, ...response[0].spec }
} as CephServiceSpec);
}
return of(group);
})
);
})
)
.subscribe((group: CephServiceSpec | null) => {
if (!group) {
return;
}
},
(_error) => {
// Error loading gateway group data
}
);
this.populateFormFromService(group, groupName);
});
}
private populateFormFromService(group: CephServiceSpec, groupName: string) {
this.existingServiceData = group;
const spec = group.spec || ({} as any);
const encryptionKey = spec.encryption_key || '';
const enableMtls = !!spec.enable_auth;
this.preSelectedHostnames = group.placement?.hosts || [];
if (group.certificate) {
this.currentCertificate = group.certificate;
}
if (spec.certificate_source) {
this.currentSpecCertificateSource = spec.certificate_source;
}
this.groupForm.patchValue({
groupName: groupName,
unmanaged: group.unmanaged || false,
enableEncryption: !!encryptionKey,
encryptionKey: encryptionKey,
encryptionConfig: encryptionKey,
enableMtls: enableMtls,
certificateType:
enableMtls && spec.certificate_source !== 'cephadm-signed'
? CertificateType.external
: CertificateType.internal,
custom_sans: spec.custom_sans || []
});
// Cert PEM fields are optional on edit; prepopulate when present.
if (enableMtls) {
this.groupForm.patchValue({
rootCACert: spec.root_ca_cert || null,
clientCert: spec.client_cert || null,
clientKey: spec.client_key || null,
serverCert: spec.server_cert || null,
serverKey: spec.server_key || null
});
}
}
onHostsLoaded(count: number): void {
@ -156,7 +222,7 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
}
get isCreateDisabled(): boolean {
if (!this.hasAvailableNodes) {
if (!this.hasAvailableNodes && !(this.editing && this.preSelectedHostnames.length > 0)) {
return true;
}
if (!this.groupForm) {
@ -172,16 +238,22 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
if (errors && errors.cdSubmitButton) {
return true;
}
if (this.gatewayNodeComponent) {
const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
if (selected.length === 0) {
return true;
}
if (this.getSelectedOrPreselectedHosts().length === 0) {
return true;
}
return false;
}
private getSelectedOrPreselectedHosts(): string[] {
const selected = this.gatewayNodeComponent?.getSelectedHostnames?.() || [];
if (selected.length > 0) {
return selected;
}
// Edit: fall back to loaded placement while table selection syncs
return this.editing ? this.preSelectedHostnames : [];
}
onSubmit() {
if (this.groupForm.invalid) {
return;
@ -193,19 +265,24 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
}
const formValues = this.groupForm.getRawValue();
const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
const selectedHostnames = this.getSelectedOrPreselectedHosts();
if (selectedHostnames.length === 0) {
this.groupForm.setErrors({ cdSubmitButton: true });
return;
}
const groupName = this.editing ? this.gatewayGroupName : formValues.groupName;
const serviceName = `nvmeof.${groupName}`;
// Match service-form: service_id is the group name (not nvmeof.<group>)
const serviceId = this.editing ? this.existingServiceData?.service_id || groupName : groupName;
const serviceName =
this.editing && this.existingServiceData?.service_name
? this.existingServiceData.service_name
: `nvmeof.${serviceId}`;
const taskUrl = this.editing ? `service/${URLVerbs.EDIT}` : `service/${URLVerbs.CREATE}`;
const serviceSpec: Record<string, any> = {
service_type: 'nvmeof',
service_id: serviceName,
service_id: serviceId,
group: groupName,
placement: {
hosts: selectedHostnames
@ -234,11 +311,21 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
}
if (formValues.certificateType === CertificateType.external) {
serviceSpec['root_ca_cert'] = formValues.rootCACert;
serviceSpec['client_cert'] = formValues.clientCert;
serviceSpec['client_key'] = formValues.clientKey;
serviceSpec['server_cert'] = formValues.serverCert;
serviceSpec['server_key'] = formValues.serverKey;
if (formValues.rootCACert) {
serviceSpec['root_ca_cert'] = formValues.rootCACert;
}
if (formValues.clientCert) {
serviceSpec['client_cert'] = formValues.clientCert;
}
if (formValues.clientKey) {
serviceSpec['client_key'] = formValues.clientKey;
}
if (formValues.serverCert) {
serviceSpec['server_cert'] = formValues.serverCert;
}
if (formValues.serverKey) {
serviceSpec['server_key'] = formValues.serverKey;
}
}
}
@ -282,6 +369,15 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
onCertificateTypeChange(type: CertificateType): void {
this.groupForm.get('certificateType')?.setValue(type);
if (this.editing && this.currentCertificate?.has_certificate) {
const originalSource =
this.currentSpecCertificateSource === 'cephadm-signed'
? CertificateType.internal
: CertificateType.external;
this.showCertSourceChangeWarning = type !== originalSource;
}
if (type === CertificateType.internal) {
this.groupForm.patchValue({
rootCACert: null,

View File

@ -1,141 +1,118 @@
<div [formGroup]="formGroup">
<div
[formGroup]="formGroup"
[cdsStack]="'vertical'"
[gap]="5"
>
@if (editing && currentCertificate?.has_certificate) {
<div class="form-item">
<div
class="cds-mt-5"
[cdsStack]="'vertical'"
[gap]="3"
>
<span
class="cds--label cds--type-heading-compact-01"
class="cds--type-heading-compact-01"
i18n
>Current Certificate</span
>
<div
cdsRow
[condensed]="true"
[cdsStack]="'horizontal'"
[gap]="5"
>
<div
cdsCol
[columnNumbers]="[
['sm', 4],
['md', 4]
]"
[cdsStack]="'vertical'"
[gap]="1"
>
<div
cdsStack="vertical"
[gap]="1"
<span
class="cds--type-label-01"
i18n
>Certificate</span
>
<span
class="cds--type-body-compact-01"
[title]="currentCertificate.cert_name"
>{{ currentCertificate.cert_name }}</span
>
<span
class="cds--type-label-01"
i18n
>Certificate</span
>
<span class="cds--type-body-compact-01">{{ currentCertificate.cert_name }}</span>
</div>
</div>
<div
cdsCol
[columnNumbers]="[
['sm', 4],
['md', 4]
]"
[cdsStack]="'vertical'"
[gap]="1"
>
<div
cdsStack="vertical"
[gap]="1"
<span
class="cds--type-label-01"
i18n
>Valid Until</span
>
<span
class="cds--type-label-01"
i18n
>Valid Until</span
>
<span class="cds--type-body-compact-01">
{{ currentCertificate.expiry_date | cdDate }} •
{{ currentCertificate.days_to_expiration }}
<span i18n>days left</span>
</span>
</div>
<span class="cds--type-body-compact-01">
{{ currentCertificate.expiry_date | cdDate }} •
{{ currentCertificate.days_to_expiration }}
<span i18n>days left</span>
</span>
</div>
</div>
<div
cdsRow
[condensed]="true"
class="cds-mt-3"
>
<div
cdsCol
[columnNumbers]="[
['sm', 4],
['md', 4]
]"
[cdsStack]="'vertical'"
[gap]="1"
>
<div
cdsStack="vertical"
[gap]="1"
<span
class="cds--type-label-01"
i18n
>Status</span
>
<span
class="cds--type-label-01"
i18n
>Status</span
>
<div
cdsStack="horizontal"
[gap]="2"
>
<cd-icon
[type]="statusIconMap[currentCertificate.status] || statusIconMap['default']"
></cd-icon>
@switch (currentCertificate.status) {
@case ('valid') {
<span i18n>Valid</span>
}
@case ('expiring') {
<span i18n>Expiring soon</span>
}
@case ('expired') {
<span i18n>Expired</span>
}
@case ('not_configured') {
<span i18n>Not configured</span>
}
@case ('invalid') {
<span i18n>Invalid</span>
}
@default {
{{ currentCertificate.status }}
}
<div
[cdsStack]="'horizontal'"
[gap]="2"
>
<cd-icon
[type]="statusIconMap[currentCertificate.status] || statusIconMap['default']"
></cd-icon>
@switch (currentCertificate.status) {
@case ('valid') {
<span i18n>Valid</span>
}
</div>
@case ('expiring') {
<span i18n>Expiring soon</span>
}
@case ('expired') {
<span i18n>Expired</span>
}
@case ('not_configured') {
<span i18n>Not configured</span>
}
@case ('invalid') {
<span i18n>Invalid</span>
}
@default {
{{ currentCertificate.status }}
}
}
</div>
</div>
<div
cdsCol
[columnNumbers]="[
['sm', 4],
['md', 4]
]"
[cdsStack]="'vertical'"
[gap]="1"
>
<div
cdsStack="vertical"
[gap]="1"
<span
class="cds--type-label-01"
i18n
>Issuer</span
>
<span
class="cds--type-label-01"
i18n
>Issuer</span
>
<span class="cds--type-body-compact-01">
@if (currentCertificate.signed_by === 'cephadm') {
<span i18n>Internal (Cephadm CA)</span>
} @else {
{{ currentCertificate.issuer || 'External' }}
}
</span>
</div>
<span class="cds--type-body-compact-01">
@if (currentCertificate.signed_by === 'cephadm') {
<span i18n>Internal (Cephadm CA)</span>
} @else {
{{ currentCertificate.issuer || 'External' }}
}
</span>
</div>
</div>
</div>
}
<div class="form-item">
<div
[cdsStack]="'vertical'"
[gap]="2"
>
<label
class="cds--label cds--type-heading-compact-01"
class="cds--type-heading-compact-01 cds-mb-3"
for="certificateType"
i18n
>Choose Certificate Authority</label
@ -167,7 +144,7 @@
@if (showCertSourceChangeWarning) {
<cd-alert-panel
type="warning"
spacingClass="mb-3"
spacingClass="mb-0"
i18n
>
Changing the certificate source will redeploy the service daemons to apply the new certificate
@ -178,37 +155,33 @@
@if (formGroup.controls.certificateType.value === CertificateType.internal) {
<cd-alert-panel
type="info"
spacingClass="mb-3"
spacingClass="mb-0"
i18n
>
Certificate will be generated automatically by Cephadm CA for internal certificate type.
</cd-alert-panel>
<div class="form-item">
<cd-text-label-list
formControlName="custom_sans"
label="Custom SAN Entries"
i18n-label
helperText="Optional list of Subject Alternative Names (hostnames, IPs, or DNS names) to include in the auto-generated certificate."
i18n-helperText
>
</cd-text-label-list>
</div>
<cd-text-label-list
formControlName="custom_sans"
label="Custom SAN Entries"
i18n-label
helperText="Optional list of Subject Alternative Names (hostnames, IPs, or DNS names) to include in the auto-generated certificate."
i18n-helperText
>
</cd-text-label-list>
@if (
formGroup.controls.service_type?.value === 'rgw' &&
formGroup.controls.virtual_host_enabled?.value
) {
<div class="form-item">
<cds-checkbox
i18n-label
formControlName="wildcard_enabled"
>
Include wildcard certificate for bucket subdomains
<cd-help-text i18n>
Add wildcard certificates (*.domain) to allow SSL for all bucket subdomains. Required
for virtual-host style with SSL.
</cd-help-text>
</cds-checkbox>
</div>
<cds-checkbox
i18n-label
formControlName="wildcard_enabled"
>
Include wildcard certificate for bucket subdomains
<cd-help-text i18n>
Add wildcard certificates (*.domain) to allow SSL for all bucket subdomains. Required for
virtual-host style with SSL.
</cd-help-text>
</cds-checkbox>
}
}
</div>

View File

@ -4,6 +4,12 @@
i18n-helperText
i18n
>
@if ($index === 0) {
{{ label }}
} @else {
<!-- Invisible character keeps subsequent inputs aligned -->
&#8203;
}
<div class="cds-input-group">
<input
cdsText
@ -22,6 +28,5 @@
</cds-icon-button>
}
</div>
{{ $index === 0 ? label : '' }}
</cds-text-label>
}