mgr/dashboard : Support wildcard sans and zonegroup hostnames

fixes : https://tracker.ceph.com/issues/76795
Signed-off-by: Abhishek Desai <abhishek.desai1@ibm.com>
This commit is contained in:
Abhishek Desai 2026-05-26 13:18:40 +05:30
parent 4fdb541ea6
commit eee6a15dcd
4 changed files with 197 additions and 27 deletions

View File

@ -403,6 +403,37 @@
}
</ng-template>
</div>
@if (!rgwModuleEnabled) {
<cd-alert-panel type="warning"
spacingClass="mb-3"
actionName="Enable"
(action)="enableRgwModule()"
i18n-actionName
i18n>
The RGW mgr module must be enabled to configure S3 hostnames.
Enabling the module will cause temporary manager downtime while it loads.
</cd-alert-panel>
}
<div class="form-item">
<cds-checkbox i18n-label
formControlName="virtual_host_enabled">
Enable virtual-host style bucket access
<cd-help-text i18n>
Allows bucket access via hostnames such as mybucket.s3.example.com.
</cd-help-text>
</cds-checkbox>
</div>
@if (serviceForm.controls.virtual_host_enabled.value) {
<div class="form-item">
<cd-text-label-list formControlName="zonegroup_hostnames"
label="S3 hostname"
i18n-label
helperText="Domain names for S3 virtual-host bucket access (e.g., s3.example.com). Enables bucket URLs like bucket.s3.example.com instead of s3.example.com/bucket."
i18n-helperText>
</cd-text-label-list>
</div>
}
}
<!-- iSCSI -->
@ -1218,13 +1249,13 @@
<cds-radio-group formControlName="certificateType"
orientation="horizontal"
helperText="Select how certificates will be signed for this service. Choose internal to use the clusters CA, or external to upload certificates signed by your organization.">
<cds-radio value="internal"
(change)="onCertificateTypeChange('internal')"
<cds-radio [value]="CertificateType.internal"
(change)="onCertificateTypeChange(CertificateType.internal)"
i18n>
Internal
</cds-radio>
<cds-radio value="external"
(change)="onCertificateTypeChange('external')"
<cds-radio [value]="CertificateType.external"
(change)="onCertificateTypeChange(CertificateType.external)"
i18n>
External
</cds-radio>
@ -1239,7 +1270,7 @@
</cd-alert-panel>
}
@if (serviceForm.controls.certificateType.value === 'internal') {
@if (serviceForm.controls.certificateType.value === CertificateType.internal) {
<cd-alert-panel type="info"
spacingClass="mb-3"
i18n>
@ -1253,6 +1284,18 @@
i18n-helperText>
</cd-text-label-list>
</div>
@if (serviceForm.controls.service_type.value === 'rgw'
&& serviceForm.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>
}
}
</div>
}
@ -1288,7 +1331,7 @@
</ng-template>
</div>
<!-- ssl_ca_cert - Only show for NFS when SSL is enabled AND certificate type is external -->
@if (serviceForm.controls.ssl.value && serviceForm.controls.certificateType.value === 'external' && serviceForm.controls.service_type.value === 'nfs') {
@if (serviceForm.controls.ssl.value && serviceForm.controls.certificateType.value === CertificateType.external && serviceForm.controls.service_type.value === 'nfs') {
<div class="form-item">
<ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'ssl_ca_cert', title: 'CA Certificate Input', helperText: 'Uploaded files will populate the CA certificate details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a CA certificate file, or paste the CA certificate PEM content directly.', invalidTemplate: invalidSslCaCertError, isRequired: false }"></ng-container>
<ng-template #invalidSslCaCertError>

View File

@ -263,6 +263,64 @@ describe('ServiceFormComponent', () => {
});
});
it('should submit rgw with virtual-host style bucket access and SSL', () => {
formHelper.setValue('virtual_host_enabled', true);
formHelper.setValue('ssl', true);
formHelper.setValue('zonegroup_hostnames', ['s3.cephlab.com']);
formHelper.setValue('wildcard_enabled', true);
component.onSubmit();
expect(cephServiceService.create).toHaveBeenCalledWith({
service_type: 'rgw',
service_id: 'svc',
rgw_realm: null,
rgw_zone: null,
rgw_zonegroup: null,
placement: {},
unmanaged: false,
ssl: true,
certificate_source: 'cephadm-signed',
zonegroup_hostnames: ['s3.cephlab.com'],
wildcard_enabled: true
});
});
it('should submit rgw with SSL and without virtual-host style bucket access', () => {
formHelper.setValue('ssl', true);
component.onSubmit();
expect(cephServiceService.create).toHaveBeenCalledWith({
service_type: 'rgw',
service_id: 'svc',
rgw_realm: null,
rgw_zone: null,
rgw_zonegroup: null,
placement: {},
unmanaged: false,
ssl: true,
certificate_source: 'cephadm-signed'
});
});
it('should submit rgw with virtual-host style bucket access and SSL without wildcard certificate', () => {
formHelper.setValue('virtual_host_enabled', true);
formHelper.setValue('ssl', true);
formHelper.setValue('zonegroup_hostnames', ['s3.cephlab.com']);
formHelper.setValue('wildcard_enabled', false);
component.onSubmit();
expect(cephServiceService.create).toHaveBeenCalledWith({
service_type: 'rgw',
service_id: 'svc',
rgw_realm: null,
rgw_zone: null,
rgw_zonegroup: null,
placement: {},
unmanaged: false,
ssl: true,
certificate_source: 'cephadm-signed',
zonegroup_hostnames: ['s3.cephlab.com'],
wildcard_enabled: false
});
});
it('should submit valid rgw port (1)', () => {
formHelper.setValue('rgw_frontend_port', 1);
component.onSubmit();

View File

@ -18,6 +18,7 @@ import { HostService } from '~/app/shared/api/host.service';
import { PoolService } from '~/app/shared/api/pool.service';
import { RbdService } from '~/app/shared/api/rbd.service';
import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
import { RgwRealmService } from '~/app/shared/api/rgw-realm.service';
import { RgwZoneService } from '~/app/shared/api/rgw-zone.service';
import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
@ -37,6 +38,7 @@ import { Host } from '~/app/shared/models/host.interface';
import {
CephServiceCertificate,
CephServiceSpec,
CertificateType,
QatOptions,
QatSepcs,
CERTIFICATE_STATUS_ICON_MAP
@ -54,6 +56,7 @@ import { TimerService } from '~/app/shared/services/timer.service';
export class ServiceFormComponent extends CdForm implements OnInit {
public sub = new Subscription();
readonly CertificateType = CertificateType;
readonly MDS_SVC_ID_PATTERN = /^[a-zA-Z_.-][a-zA-Z0-9_.-]*$/;
readonly SNMP_DESTINATION_PATTERN = /^[^\:]+:[0-9]/;
readonly SNMP_ENGINE_ID_PATTERN = /^[0-9A-Fa-f]{10,64}/g;
@ -115,6 +118,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}));
showMgmtGatewayMessage: boolean = false;
showCertSourceChangeWarning: boolean = false;
rgwModuleEnabled = false;
qatCompressionOptions = [
{ value: QatOptions.hw, label: 'Hardware' },
{ value: QatOptions.sw, label: 'Software' },
@ -141,6 +145,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
public rgwZonegroupService: RgwZonegroupService,
public rgwZoneService: RgwZoneService,
public rgwMultisiteService: RgwMultisiteService,
private mgrModuleService: MgrModuleService,
private route: ActivatedRoute,
public modalService: ModalCdsService,
private location: Location
@ -422,7 +427,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'rgw',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.pemCert()]
),
@ -431,7 +436,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'iscsi',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslCert()]
),
@ -440,7 +445,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'ingress',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.pemCert()]
),
@ -449,7 +454,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'oauth2-proxy',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslCert()]
),
@ -458,7 +463,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'mgmt-gateway',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslCert()]
),
@ -467,7 +472,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'nfs',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.pemCert()]
)
@ -481,7 +486,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'iscsi',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslPrivKey()]
),
@ -490,7 +495,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'oauth2-proxy',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslPrivKey()]
),
@ -499,7 +504,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'mgmt-gateway',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslPrivKey()]
),
@ -508,14 +513,17 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'nfs',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.sslPrivKey()]
)
]
],
certificateType: ['internal'],
certificateType: [CertificateType.internal],
custom_sans: [null],
virtual_host_enabled: [false],
zonegroup_hostnames: [null],
wildcard_enabled: [true],
ssl_ca_cert: [
'',
[
@ -524,7 +532,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
service_type: 'nfs',
unmanaged: false,
ssl: true,
certificateType: 'external'
certificateType: CertificateType.external
},
[Validators.required, CdValidators.pemCert()]
)
@ -686,6 +694,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
this.open = true;
this.action = this.actionLabels.CREATE;
this.resolveRoute();
this.getRgwModuleStatus();
this.mgrModuleService.updateCompleted$.subscribe(() => this.getRgwModuleStatus());
this.cephServiceService
.list(new HttpParams({ fromObject: { limit: -1, offset: 0 } }))
@ -792,10 +802,21 @@ export class ServiceFormComponent extends CdForm implements OnInit {
response[0].spec?.qat
);
this.serviceForm.get('ssl').setValue(response[0].spec?.ssl);
if (response[0].spec?.zonegroup_hostnames?.length) {
this.serviceForm
.get('zonegroup_hostnames')
.setValue(response[0].spec.zonegroup_hostnames);
if (this.rgwModuleEnabled) {
this.serviceForm.get('virtual_host_enabled').setValue(true);
}
}
this.serviceForm
.get('wildcard_enabled')
.setValue(response[0].spec?.wildcard_enabled ?? true);
if (response[0].spec?.ssl) {
// Special case for rgw: if certificate_source is not cephadm-signed, set certificateType to external
if (response[0].spec?.certificate_source != 'cephadm-signed') {
this.serviceForm.get('certificateType').setValue('external');
this.serviceForm.get('certificateType').setValue(CertificateType.external);
}
let certValue = response[0].spec?.rgw_frontend_ssl_certificate || '';
if (response[0].spec?.ssl_cert) {
@ -805,6 +826,9 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}
}
this.serviceForm.get('ssl_cert').setValue(certValue);
if (response[0].spec?.custom_sans) {
this.serviceForm.get('custom_sans').setValue(response[0].spec.custom_sans);
}
}
break;
case 'ingress':
@ -830,7 +854,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
this.port = response[0].spec?.port;
this.serviceForm.get('ssl').setValue(true);
if (response[0].spec?.certificate_source !== 'cephadm-signed') {
this.serviceForm.get('certificateType').setValue('external');
this.serviceForm.get('certificateType').setValue(CertificateType.external);
}
if (response[0].spec?.ssl_protocols) {
let selectedValues: Array<ListItem> = [];
@ -1232,15 +1256,37 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}
}
onCertificateTypeChange(type: string) {
onCertificateTypeChange(type: CertificateType) {
this.serviceForm.get('certificateType').setValue(type);
if (this.editing && this.currentCertificate?.has_certificate) {
const originalSource =
this.currentSpecCertificateSource === 'cephadm-signed' ? 'internal' : 'external';
this.currentSpecCertificateSource === 'cephadm-signed'
? CertificateType.internal
: CertificateType.external;
this.showCertSourceChangeWarning = type !== originalSource;
}
}
private getRgwModuleStatus(): void {
this.rgwMultisiteService.getRgwModuleStatus().subscribe((enabled: boolean) => {
this.rgwModuleEnabled = enabled;
const virtualHostControl = this.serviceForm.get('virtual_host_enabled');
if (enabled) {
virtualHostControl.enable({ emitEvent: false });
if (this.serviceForm.get('zonegroup_hostnames').value?.length) {
virtualHostControl.setValue(true, { emitEvent: false });
}
} else {
virtualHostControl.setValue(false, { emitEvent: false });
virtualHostControl.disable({ emitEvent: false });
}
});
}
enableRgwModule(): void {
this.mgrModuleService.updateModuleState('rgw', false, null, '', $localize`Enabled RGW Module`);
}
prePopulateId() {
const control: AbstractControl = this.serviceForm.get('service_id');
const backendService = this.serviceForm.getValue('backend_service');
@ -1386,11 +1432,21 @@ export class ServiceFormComponent extends CdForm implements OnInit {
serviceSpec['rgw_frontend_port'] = values['rgw_frontend_port'];
}
serviceSpec['ssl'] = values['ssl'];
if (values['virtual_host_enabled'] && values['zonegroup_hostnames']?.length > 0) {
serviceSpec['zonegroup_hostnames'] = values['zonegroup_hostnames'];
}
if (values['ssl']) {
this.applySslCertificateConfig(serviceSpec, values, {
sslCertField: 'rgw_frontend_ssl_certificate',
includeSslKey: false
});
if (
values['certificateType'] === CertificateType.internal &&
values['virtual_host_enabled'] &&
values['zonegroup_hostnames']?.length > 0
) {
serviceSpec['wildcard_enabled'] = values['wildcard_enabled'];
}
}
break;
case 'iscsi':
@ -1538,7 +1594,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
get showExternalSslCert(): boolean {
const serviceType = this.serviceForm.controls.service_type?.value;
const isExternalCert = this.serviceForm.controls.certificateType?.value === 'external';
const isExternalCert =
this.serviceForm.controls.certificateType?.value === CertificateType.external;
const isSslEnabled = this.serviceForm.controls.ssl?.value;
if (serviceType === 'mgmt-gateway') {
@ -1551,7 +1608,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
get showExternalSslKey(): boolean {
const serviceType = this.serviceForm.controls.service_type?.value;
const isExternalCert = this.serviceForm.controls.certificateType?.value === 'external';
const isExternalCert =
this.serviceForm.controls.certificateType?.value === CertificateType.external;
const isSslEnabled = this.serviceForm.controls.ssl?.value;
const sslKeyServices = ['iscsi', 'grafana', 'oauth2-proxy', 'nvmeof', 'nfs', 'mgmt-gateway'];
@ -1585,13 +1643,16 @@ export class ServiceFormComponent extends CdForm implements OnInit {
} = options;
serviceSpec['certificate_source'] =
values['certificateType'] === 'internal' ? 'cephadm-signed' : 'inline';
values['certificateType'] === CertificateType.internal ? 'cephadm-signed' : 'inline';
if (values['certificateType'] === 'internal' && values['custom_sans']?.length > 0) {
if (
values['certificateType'] === CertificateType.internal &&
values['custom_sans']?.length > 0
) {
serviceSpec['custom_sans'] = values['custom_sans'];
}
if (values['certificateType'] === 'external') {
if (values['certificateType'] === CertificateType.external) {
serviceSpec[sslCertField] = values['ssl_cert']?.trim();
if (includeSslKey) {
serviceSpec[sslKeyField] = values['ssl_key']?.trim();

View File

@ -91,6 +91,9 @@ export interface CephServiceAdditionalSpec {
ssl_protocols: string[];
ssl_ciphers: string[];
certificate_source: string;
custom_sans?: string[];
zonegroup_hostnames?: string[];
wildcard_enabled?: boolean;
port: number;
initial_admin_password: string;
rgw_realm: string;
@ -123,6 +126,11 @@ export interface QatSepcs {
[key: string]: string;
}
export enum CertificateType {
internal = 'internal',
external = 'external'
}
export enum QatOptions {
hw = 'hw',
sw = 'sw',