cert_scope = CertificateScope(cert_config.get('scope', CertificateScope.SERVICE.value))
cert_ls_data = CertificateService.fetch_certificates_for_service(
- orch, service_type, user_cert_name, cephadm_cert_name
+ orch, service_type, user_cert_name, cephadm_cert_name, service_name_full
)
daemon_hostnames, _ = CertificateService.get_daemon_hostnames(orch, service_name_full)
LayoutModule,
NumberModule,
FileUploaderModule,
- TabsModule
+ TabsModule,
+ RadioModule
} from 'carbon-components-angular';
import Analytics from '@carbon/icons/es/analytics/16';
import CloseFilled from '@carbon/icons/es/close--filled/16';
SelectModule,
LayoutModule,
NumberModule,
- FileUploaderModule
+ FileUploaderModule,
+ RadioModule
],
declarations: [
MonitorComponent,
</span>
<ng-container *ngTemplateOutlet="statusTemplate; context: { status: certificate?.status }"></ng-container>
</div>
- <!-- icon -->
+ @if (SERVICES_SUPPORTING_CERT_EDIT.includes(serviceType)) {
<cds-icon-button kind="ghost"
size="sm"
(click)="onEdit()"
<cd-icon type="edit">
</cd-icon>
</cds-icon-button>
+ }
</div>
<div cdsStack="vertical"
import { By } from '@angular/platform-browser';
import { expect as jestExpect } from '@jest/globals';
+import { ComponentsModule } from '~/app/shared/components/components.module';
import { IconComponent } from '~/app/shared/components/icon/icon.component';
import {
CephCertificateStatus,
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ServiceCertificateDetailsComponent, IconComponent],
+ imports: [ComponentsModule],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
it('should emit editService with service identifiers', () => {
component.serviceName = 'svc-name';
- component.serviceType = 'svc-type';
+ component.serviceType = 'rgw';
+ component.certificate = baseCert;
fixture.detectChanges();
const emitSpy = jest.spyOn(component.editService, 'emit');
const button = fixture.debugElement.query(By.css('cds-icon-button'));
+ jestExpect(button).not.toBeNull();
button.triggerEventHandler('click', {});
- jestExpect(emitSpy).toHaveBeenCalledWith({ serviceName: 'svc-name', serviceType: 'svc-type' });
+ jestExpect(emitSpy).toHaveBeenCalledWith({ serviceName: 'svc-name', serviceType: 'rgw' });
});
it('should show success icon and text for valid status', () => {
import {
CephCertificateStatus,
- CephServiceCertificate
+ CephServiceCertificate,
+ CERTIFICATE_STATUS_ICON_MAP
} from '~/app/shared/models/service.interface';
import { Component, EventEmitter, Input, Output } from '@angular/core';
-import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
@Component({
@Output() editService = new EventEmitter<{ serviceName?: string; serviceType?: string }>();
- readonly statusIconMap: Record<string, keyof typeof ICON_TYPE> = {
- valid: 'success',
- expiring: 'warning',
- expiring_soon: 'warning',
- expired: 'danger',
- default: 'warning'
- };
+ readonly SERVICES_SUPPORTING_CERT_EDIT = [
+ 'rgw',
+ 'ingress',
+ 'iscsi',
+ 'oauth2-proxy',
+ 'mgmt-gateway',
+ 'nvmeof',
+ 'nfs'
+ ];
+ statusIconMap = CERTIFICATE_STATUS_ICON_MAP;
constructor(private cdDatePipe: CdDatePipe) {}
if (!cert || !cert.requires_certificate || !cert.status) {
return '-';
}
-
const formattedDate = this.formatDate(cert.expiry_date);
switch (cert.status) {
case CephCertificateStatus.valid:
return formattedDate ? `Valid - ${formattedDate}` : 'Valid';
+ case CephCertificateStatus.expiring:
case CephCertificateStatus.expiringSoon:
return formattedDate ? `Expiring soon - ${formattedDate}` : 'Expiring soon';
case CephCertificateStatus.expired:
</cds-text-label>
</div>
}
- <!-- RGW, Ingress, iSCSI, Oauth2-proxy & mgmt-gateway -->
+ <!-- RGW, Ingress, iSCSI, Oauth2-proxy, NFS & mgmt-gateway -->
@if (!serviceForm.controls.unmanaged.value && ['rgw', 'iscsi', 'ingress', 'oauth2-proxy',
- 'mgmt-gateway'].includes(serviceForm.controls.service_type.value)) {
+ 'mgmt-gateway', 'nfs'].includes(serviceForm.controls.service_type.value)) {
<!-- ssl -->
@if (!['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) {
<div class="form-item">
</cds-checkbox>
</div>
}
- <!-- ssl_cert -->
- @if (serviceForm.controls.ssl.value || ['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) {
- <div class="form-item">
- <cds-textarea-label helperText="The SSL certificate in PEM format."
- i18n
- [invalid]="serviceForm.controls.ssl_cert.invalid && serviceForm.controls.ssl_cert.dirty"
- [invalidText]="invalidSslCertError">Certificate
- <div class="cd-cl-form-input">
- <textarea cdsTextArea
- id="ssl_cert"
- formControlName="ssl_cert"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.ssl_cert.invalid && serviceForm.controls.ssl_cert.dirty">
- </textarea>
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'ssl_cert')"
- (removeFile)="clearText()"></cds-file-uploader>
+ <!-- Certificate Management UI -->
+ @if (serviceForm.controls.ssl.value && ['rgw', 'ingress', 'iscsi', 'grafana', 'oauth2-proxy', 'mgmt-gateway', 'nvmeof', 'nfs'].includes(serviceForm.controls.service_type.value)) {
+ <div>
+ <!-- Current Certificate Section - Only shown in Edit mode when certificate exists -->
+ @if (editing && currentCertificate?.has_certificate) {
+ <div class="form-item">
+ <label class="cds--label fw-bold"
+ i18n>Current Certificate</label>
+ <div class="row">
+ <div class="col-6">
+ <label class="cds--label"
+ i18n>Certificate</label>
+ <div>{{ currentCertificate.cert_name }}</div>
+ </div>
+ <div class="col-6">
+ <label class="cds--label"
+ i18n>Valid Until</label>
+ <div>{{ currentCertificate.expiry_date | cdDate }} • {{ currentCertificate.days_to_expiration }} <span i18n>days left</span></div>
+ </div>
+ </div>
+ <div class="row mt-3">
+ <div class="col-6">
+ <label class="cds--label"
+ i18n>Status</label>
+ <div class="align-items-center">
+ <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>
+ </div>
+ <div class="col-6">
+ <label class="cds--label"
+ i18n>Issuer</label>
+ <div>
+ @if (currentCertificate.signed_by === 'cephadm') {
+ <span i18n>Internal (Cephadm CA)</span>
+ } @else {
+ {{ currentCertificate.issuer || 'External' }}
+ }
+ </div>
+ </div>
</div>
- </cds-textarea-label>
+ </div>
+ }
+
+ <!-- Certificate Authority Selection -->
+ <div class="form-item">
+ <label class="cds--label fw-bold"
+ 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.">
+ <cds-radio value="internal"
+ (change)="onCertificateTypeChange('internal')"
+ i18n>
+ Internal
+ </cds-radio>
+ <cds-radio value="external"
+ (change)="onCertificateTypeChange('external')"
+ i18n>
+ External
+ </cds-radio>
+ </cds-radio-group>
+ </div>
+
+ @if (showCertSourceChangeWarning) {
+ <cd-alert-panel type="warning"
+ spacingClass="mb-3"
+ i18n>
+ Changing the certificate source will redeploy the service daemons to apply the new certificate configuration.
+ </cd-alert-panel>
+ }
+
+ @if (serviceForm.controls.certificateType.value === 'internal') {
+ <cd-alert-panel type="info"
+ spacingClass="mb-3"
+ 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>
+ }
+ </div>
+ }
+
+ <!-- ssl_cert - Only show when SSL is enabled AND certificate type is external -->
+ @if (showExternalSslCert) {
+ <div class="form-item">
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'ssl_cert', title: 'Certificate Input', helperText: 'Uploaded files will populate the certificate details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a .crt file, or paste the certificate PEM content directly.', invalidTemplate: invalidSslCertError, isRequired: false }"></ng-container>
<ng-template #invalidSslCertError>
@if (serviceForm.showError('ssl_cert', frm, 'required')) {
<span class="invalid-feedback"
</ng-template>
</div>
}
- <!-- ssl_key -->
- @if ((serviceForm.controls.ssl.value && !(['rgw', 'ingress'].includes(serviceForm.controls.service_type.value)))
- || ['mgmt-gateway'].includes(serviceForm.controls.service_type.value)) {
+ @if (showExternalSslKey) {
<div class="form-item">
- <cds-textarea-label helperText="The SSL certificate in PEM format."
- i18n
- [invalid]="serviceForm.controls.ssl_key.invalid && serviceForm.controls.ssl_key.dirty"
- [invalidText]="invalidSslKeyError">Private key
- <div class="cd-col-form-input">
- <textarea cdsTextArea
- id="ssl_key"
- formControlName="ssl_key"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.ssl_key.invalid && serviceForm.controls.ssl_key.dirty">
- </textarea>
-
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'ssl_key')"
- (removeFile)="clearText()"></cds-file-uploader>
- </div>
- </cds-textarea-label>
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'ssl_key', title: 'Private Key Input', helperText: 'Uploaded files will populate the private key details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a .key file, or paste the private key PEM content directly.', invalidTemplate: invalidSslKeyError, isRequired: false }"></ng-container>
<ng-template #invalidSslKeyError>
@if (serviceForm.showError('ssl_key', frm, 'required')) {
<span class="invalid-feedback"
}
</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') {
+ <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>
+ @if (serviceForm.showError('ssl_ca_cert', frm, 'required')) {
+ <span class="invalid-feedback"
+ i18n>This field is required.</span>
+ }
+ @if (serviceForm.showError('ssl_ca_cert', frm, 'pattern')) {
+ <span class="invalid-feedback"
+ i18n>Invalid CA certificate.</span>
+ }
+ </ng-template>
+ </div>
+ }
}
<!-- RGW QAT Compression -->
@if (serviceForm.controls.enable_mtls.value) {
<!-- root_ca_cert -->
<div class="form-item">
- <cds-textarea-label cdRequiredField="Root CA certificate"
- [invalid]="serviceForm.controls.root_ca_cert.invalid && serviceForm.controls.root_ca_cert.dirty"
- [invalidText]="invalidRootCaCertError">Root CA certificate
- <textarea cdsTextArea
- id="root_ca_cert"
- formControlName="root_ca_cert"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.root_ca_cert.invalid && serviceForm.controls.root_ca_cert.dirty">
- </textarea>
-
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'root_ca_cert')"
- (removeFile)="clearText()"></cds-file-uploader>
- </cds-textarea-label>
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'root_ca_cert', title: 'Root CA Certificate Input', helperText: 'Uploaded files will populate the Root CA certificate details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a Root CA certificate file, or paste the Root CA certificate PEM content directly.', invalidTemplate: invalidRootCaCertError, isRequired: true }"></ng-container>
<ng-template #invalidRootCaCertError>
@if (serviceForm.showError('root_ca_cert', frm, 'required')) {
<span class="invalid-feedback"
<!-- client_cert -->
<div class="form-item">
- <cds-textarea-label cdRequiredField="Client CA certificate"
- [invalid]="serviceForm.controls.client_cert.invalid && serviceForm.controls.client_cert.dirty"
- [invalidText]="invalidClientCertError">Client CA certificate
- <textarea cdsTextArea
- id="client_cert"
- formControlName="client_cert"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.client_cert.invalid && serviceForm.controls.client_cert.dirty">
- </textarea>
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'client_cert')"
- (removeFile)="clearText()"></cds-file-uploader>
- </cds-textarea-label>
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'client_cert', title: 'Client Certificate Input', helperText: 'Uploaded files will populate the client certificate details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a client certificate file, or paste the client certificate PEM content directly.', invalidTemplate: invalidClientCertError, isRequired: true }"></ng-container>
<ng-template #invalidClientCertError>
@if (serviceForm.showError('client_cert', frm, 'required')) {
<span class="invalid-feedback"
<!-- client_key -->
<div class="form-item">
- <cds-textarea-label cdRequiredField="Client key"
- [invalid]="serviceForm.controls.client_key.invalid && serviceForm.controls.client_key.dirty"
- [invalidText]="invalidClientKeyError">Client key
- <textarea cdsTextArea
- id="client_key"
- formControlName="client_key"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.client_key.invalid && serviceForm.controls.client_key.dirty">
- </textarea>
-
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'client_key')"
- (removeFile)="clearText()"></cds-file-uploader>
- </cds-textarea-label>
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'client_key', title: 'Client Key Input', helperText: 'Uploaded files will populate the client key details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a client key file, or paste the client key PEM content directly.', invalidTemplate: invalidClientKeyError, isRequired: true }"></ng-container>
<ng-template #invalidClientKeyError>
@if (serviceForm.showError('client_key', frm, 'required')) {
<span class="invalid-feedback"
<!-- server_cert -->
<div class="form-item">
- <cds-textarea-label cdRequiredField="Gateway server certificate"
- [invalid]="serviceForm.controls.server_cert.invalid && serviceForm.controls.server_cert.dirty"
- [invalidText]="invalidServerCertError">Gateway server certificate
- <textarea cdsTextArea
- id="server_cert"
- formControlName="server_cert"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.server_cert.invalid && serviceForm.controls.server_cert.dirty">
- </textarea>
-
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'server_cert')"
- (removeFile)="clearText()"></cds-file-uploader>
- </cds-textarea-label>
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'server_cert', title: 'Gateway Server Certificate Input', helperText: 'Uploaded files will populate the gateway server certificate details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a gateway server certificate file, or paste the certificate PEM content directly.', invalidTemplate: invalidServerCertError, isRequired: true }"></ng-container>
<ng-template #invalidServerCertError>
@if (serviceForm.showError('server_cert', frm, 'required')) {
<span class="invalid-feedback"
<!-- server_key -->
<div class="form-item">
- <cds-textarea-label cdRequiredField="Gateway server key"
- [invalid]="serviceForm.controls.server_key.invalid && serviceForm.controls.server_key.dirty"
- [invalidText]="invalidServerKeyError">Gateway server key
- <textarea cdsTextArea
- id="server_key"
- formControlName="server_key"
- cols="100"
- rows="4"
- [invalid]="serviceForm.controls.server_key.invalid && serviceForm.controls.server_key.dirty">
- </textarea>
-
- <cds-file-uploader buttonText="Choose file"
- i18n-buttonText
- buttonType="secondary"
- [multiple]="false"
- size="sm"
- (filesChange)="fileUpload($event, 'server_key')"
- (removeFile)="clearText()"></cds-file-uploader>
- </cds-textarea-label>
+ <ng-container *ngTemplateOutlet="fileUploaderTextarea; context: { controlName: 'server_key', title: 'Gateway Server Key Input', helperText: 'Uploaded files will populate the gateway server key details automatically. Or paste the PEM content directly in the text area.', description: 'Upload a gateway server key file, or paste the key PEM content directly.', invalidTemplate: invalidServerKeyError, isRequired: true }"></ng-container>
<ng-template #invalidServerKeyError>
@if (serviceForm.showError('server_key', frm, 'required')) {
<span class="invalid-feedback"
target="_blank">https://ssl-config.mozilla.org/#server=nginx</a>
</span>
</ng-template>
+
+<ng-template #fileUploaderTextarea
+ let-controlName="controlName"
+ let-title="title"
+ let-helperText="helperText"
+ let-description="description"
+ let-invalidTemplate="invalidTemplate"
+ let-isRequired="isRequired">
+ <cds-textarea-label [attr.cdRequiredField]="isRequired ? title : null"
+ [helperText]="helperText"
+ [invalid]="serviceForm.controls[controlName].invalid && serviceForm.controls[controlName].dirty"
+ [invalidText]="invalidTemplate">
+ <cds-file-uploader [title]="title"
+ buttonText="Upload File"
+ i18n-buttonText
+ buttonType="tertiary"
+ [multiple]="false"
+ size="md"
+ [description]="description"
+ (filesChange)="fileUpload($event, controlName)"
+ (removeFile)="clearText()"></cds-file-uploader>
+ <textarea cdsTextArea
+ [id]="controlName"
+ [theme]="'dark'"
+ placeholder="Paste certificate or private key PEM content"
+ [formControl]="serviceForm.controls[controlName]"
+ cols="50"
+ rows="5"
+ [invalid]="serviceForm.controls[controlName].invalid && serviceForm.controls[controlName].dirty">
+ </textarea>
+ </cds-textarea-label>
+</ng-template>
import { configureTestBed, FormHelper, Mocks } from '~/testing/unit-test-helper';
import { ServiceFormComponent } from './service-form.component';
import { PoolService } from '~/app/shared/api/pool.service';
+import { TextLabelListComponent } from '~/app/shared/components/text-label-list/text-label-list.component';
import { USER } from '~/app/shared/constants/app.constants';
import {
CheckboxModule,
InputModule,
ModalModule,
NumberModule,
+ RadioModule,
SelectModule
} from 'carbon-components-angular';
SelectModule,
NumberModule,
ModalModule,
- CheckboxModule
+ CheckboxModule,
+ RadioModule,
+ TextLabelListComponent
]
});
placement: {},
unmanaged: false,
rgw_frontend_port: 1234,
- rgw_frontend_ssl_certificate: '',
- ssl: true
+ ssl: true,
+ certificate_source: 'cephadm-signed'
});
});
api_user: USER,
api_password: 'password',
api_secure: true,
- ssl_cert: '',
- ssl_key: '',
+ certificate_source: 'cephadm-signed',
trusted_ip_list: ['172.16.0.5', '192.1.1.10']
});
});
import { Location } from '@angular/common';
import { HttpParams } from '@angular/common/http';
-import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { AbstractControl, UntypedFormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { CdValidators } from '~/app/shared/forms/cd-validators';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { Host } from '~/app/shared/models/host.interface';
-import { CephServiceSpec, QatOptions, QatSepcs } from '~/app/shared/models/service.interface';
+import {
+ CephServiceCertificate,
+ CephServiceSpec,
+ QatOptions,
+ QatSepcs,
+ CERTIFICATE_STATUS_ICON_MAP
+} from '~/app/shared/models/service.interface';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { TimerService } from '~/app/shared/services/timer.service';
@Input() serviceType: string;
+ @Output() serviceUpdated = new EventEmitter<void>();
+
serviceForm: CdFormGroup;
action: string;
resource: string;
selected: false
}));
showMgmtGatewayMessage: boolean = false;
+ showCertSourceChangeWarning: boolean = false;
qatCompressionOptions = [
{ value: QatOptions.hw, label: 'Hardware' },
{ value: QatOptions.sw, label: 'Software' },
];
open: boolean = false;
hostsAndLabels$: Observable<{ hosts: { content: string }[]; labels: { content: string }[] }>;
+ currentCertificate: CephServiceCertificate = null;
+ currentSpecCertificateSource: string = null;
+ statusIconMap = CERTIFICATE_STATUS_ICON_MAP;
constructor(
public actionLabels: ActionLabelsI18n,
{
service_type: 'rgw',
unmanaged: false,
- ssl: true
+ ssl: true,
+ certificateType: 'external'
},
[Validators.required, CdValidators.pemCert()]
),
{
service_type: 'iscsi',
unmanaged: false,
- ssl: true
+ ssl: true,
+ certificateType: 'external'
},
[Validators.required, CdValidators.sslCert()]
),
{
service_type: 'ingress',
unmanaged: false,
- ssl: true
+ ssl: true,
+ certificateType: 'external'
},
[Validators.required, CdValidators.pemCert()]
),
{
service_type: 'oauth2-proxy',
unmanaged: false,
- ssl: true
+ ssl: true,
+ certificateType: 'external'
},
[Validators.required, CdValidators.sslCert()]
),
{
service_type: 'mgmt-gateway',
unmanaged: false,
- ssl: false
+ ssl: true,
+ certificateType: 'external'
+ },
+ [Validators.required, CdValidators.sslCert()]
+ ),
+ CdValidators.composeIf(
+ {
+ service_type: 'nfs',
+ unmanaged: false,
+ ssl: true,
+ certificateType: 'external'
},
- [CdValidators.sslCert()]
+ [Validators.required, CdValidators.pemCert()]
)
]
],
{
service_type: 'iscsi',
unmanaged: false,
- ssl: true
+ ssl: true,
+ certificateType: 'external'
},
[Validators.required, CdValidators.sslPrivKey()]
),
{
service_type: 'oauth2-proxy',
unmanaged: false,
- ssl: true
+ ssl: true,
+ certificateType: 'external'
},
[Validators.required, CdValidators.sslPrivKey()]
),
{
service_type: 'mgmt-gateway',
unmanaged: false,
- ssl: false
+ ssl: true,
+ certificateType: 'external'
+ },
+ [Validators.required, CdValidators.sslPrivKey()]
+ ),
+ CdValidators.composeIf(
+ {
+ service_type: 'nfs',
+ unmanaged: false,
+ ssl: true,
+ certificateType: 'external'
},
- [CdValidators.sslPrivKey()]
+ [Validators.required, CdValidators.sslPrivKey()]
+ )
+ ]
+ ],
+ certificateType: ['internal'],
+ custom_sans: [null],
+ ssl_ca_cert: [
+ '',
+ [
+ CdValidators.composeIf(
+ {
+ service_type: 'nfs',
+ unmanaged: false,
+ ssl: true,
+ certificateType: 'external'
+ },
+ [Validators.required, CdValidators.pemCert()]
)
]
],
formKeys.forEach((keys) => {
this.serviceForm.get(keys).setValue(response[0][keys]);
});
+ if (response[0].certificate) {
+ this.currentCertificate = response[0].certificate;
+ }
+ if (response[0].spec?.certificate_source) {
+ this.currentSpecCertificateSource = response[0].spec.certificate_source;
+ }
if (!response[0]['unmanaged']) {
const placementKey = Object.keys(response[0]['placement'])[0];
let placementValue: string;
);
this.serviceForm.get('ssl').setValue(response[0].spec?.ssl);
if (response[0].spec?.ssl) {
- this.serviceForm
- .get('ssl_cert')
- .setValue(response[0].spec?.rgw_frontend_ssl_certificate);
+ // 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');
+ }
+ let certValue = response[0].spec?.rgw_frontend_ssl_certificate || '';
+ if (response[0].spec?.ssl_cert) {
+ certValue = response[0].spec.ssl_cert;
+ if (response[0].spec?.ssl_key) {
+ certValue = response[0].spec.ssl_cert + '\n' + response[0].spec.ssl_key;
+ }
+ }
+ this.serviceForm.get('ssl_cert').setValue(certValue);
}
break;
case 'ingress':
let hrefSplitted = window.location.href.split(':');
this.currentURL = hrefSplitted[0] + hrefSplitted[1];
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');
+ }
if (response[0].spec?.ssl_protocols) {
let selectedValues: Array<ListItem> = [];
for (const value of response[0].spec.ssl_protocols) {
if (selectedServiceType === 'mgmt-gateway') {
let hrefSplitted = window.location.href.split(':');
this.currentURL = hrefSplitted[0] + hrefSplitted[1];
+ // mgmt-gateway is always SSL
+ this.serviceForm.get('ssl').setValue(true);
// mgmt-gateway lacks HA for now
this.serviceForm.get('count').disable();
} else {
}
}
+ onCertificateTypeChange(type: string) {
+ this.serviceForm.get('certificateType').setValue(type);
+ if (this.editing && this.currentCertificate?.has_certificate) {
+ const originalSource =
+ this.currentSpecCertificateSource === 'cephadm-signed' ? 'internal' : 'external';
+ this.showCertSourceChangeWarning = type !== originalSource;
+ }
+ }
+
prePopulateId() {
const control: AbstractControl = this.serviceForm.get('service_id');
const backendService = this.serviceForm.getValue('backend_service');
}
serviceSpec['ssl'] = values['ssl'];
if (values['ssl']) {
- serviceSpec['rgw_frontend_ssl_certificate'] = values['ssl_cert']?.trim();
+ this.applySslCertificateConfig(serviceSpec, values, {
+ sslCertField: 'rgw_frontend_ssl_certificate',
+ includeSslKey: false
+ });
}
break;
case 'iscsi':
serviceSpec['api_password'] = values['api_password'];
serviceSpec['api_secure'] = values['ssl'];
if (values['ssl']) {
- serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
- serviceSpec['ssl_key'] = values['ssl_key']?.trim();
+ this.applySslCertificateConfig(serviceSpec, values);
}
break;
case 'ingress':
serviceSpec['ssl'] = values['ssl'];
if (values['ssl']) {
- serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
- serviceSpec['ssl_key'] = values['ssl_key']?.trim();
+ this.applySslCertificateConfig(serviceSpec, values);
}
if (values['virtual_interface_networks']) {
serviceSpec['virtual_interface_networks'] = values['virtual_interface_networks']
);
}
if (values['ssl']) {
- serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
- serviceSpec['ssl_key'] = values['ssl_key']?.trim();
+ this.applySslCertificateConfig(serviceSpec, values);
+ }
+ break;
+ case 'nfs':
+ if (values['ssl']) {
+ serviceSpec['ssl'] = values['ssl'];
+ this.applySslCertificateConfig(serviceSpec, values, { includeCaCert: true });
}
break;
}
}
+ const apiCall$ = this.editing
+ ? this.cephServiceService.update(serviceSpec)
+ : this.cephServiceService.create(serviceSpec);
+
this.taskWrapperService
.wrapTaskAroundCall({
task: new FinishedTask(taskUrl, {
service_name: serviceName
}),
- call: this.editing
- ? this.cephServiceService.update(serviceSpec)
- : this.cephServiceService.create(serviceSpec)
+ call: apiCall$
})
.subscribe({
error() {
self.serviceForm.setErrors({ cdSubmitButton: true });
},
complete: () => {
+ this.serviceUpdated.emit();
this.closeModal();
}
});
);
}
+ get showExternalSslCert(): boolean {
+ const serviceType = this.serviceForm.controls.service_type?.value;
+ const isExternalCert = this.serviceForm.controls.certificateType?.value === 'external';
+ const isSslEnabled = this.serviceForm.controls.ssl?.value;
+
+ if (serviceType === 'mgmt-gateway') {
+ return isExternalCert;
+ }
+
+ const sslCertServices = ['rgw', 'ingress', 'iscsi', 'grafana', 'oauth2-proxy', 'nvmeof', 'nfs'];
+ return isSslEnabled && isExternalCert && sslCertServices.includes(serviceType);
+ }
+
+ get showExternalSslKey(): boolean {
+ const serviceType = this.serviceForm.controls.service_type?.value;
+ const isExternalCert = this.serviceForm.controls.certificateType?.value === 'external';
+ const isSslEnabled = this.serviceForm.controls.ssl?.value;
+
+ const sslKeyServices = ['iscsi', 'grafana', 'oauth2-proxy', 'nvmeof', 'nfs', 'mgmt-gateway'];
+ return isSslEnabled && isExternalCert && sslKeyServices.includes(serviceType);
+ }
+
closeModal(): void {
if (this.pageURL === 'services') {
this.location.back();
this.modalService.dismissAll();
}
}
+
+ applySslCertificateConfig(
+ serviceSpec: object,
+ values: object,
+ options: {
+ sslCertField?: string;
+ sslKeyField?: string;
+ includeSslKey?: boolean;
+ includeCaCert?: boolean;
+ } = {}
+ ): void {
+ const {
+ sslCertField = 'ssl_cert',
+ sslKeyField = 'ssl_key',
+ includeSslKey = true,
+ includeCaCert = false
+ } = options;
+
+ serviceSpec['certificate_source'] =
+ values['certificateType'] === 'internal' ? 'cephadm-signed' : 'inline';
+
+ if (values['certificateType'] === 'internal' && values['custom_sans']?.length > 0) {
+ serviceSpec['custom_sans'] = values['custom_sans'];
+ }
+
+ if (values['certificateType'] === 'external') {
+ serviceSpec[sslCertField] = values['ssl_cert']?.trim();
+ if (includeSslKey) {
+ serviceSpec[sslKeyField] = values['ssl_key']?.trim();
+ }
+ if (includeCaCert) {
+ serviceSpec['ssl_ca_cert'] = values['ssl_ca_cert']?.trim();
+ }
+ }
+ }
}
[hasDetails]="hasDetails"
[serverSide]="true"
[count]="count"
+ updateExpandedOnRefresh="always"
(setExpandedRow)="setExpandedRow($event)"
(updateSelection)="updateSelection($event)">
<cd-table-actions class="table-actions"
<ng-template #certificateStatusTpl
let-row="data.row">
- @if (row.certificate) {
+@if (row.certificate?.requires_certificate && row.certificate?.status && row.certificate?.status !== 'not_configured') {
+ <span class="d-flex align-items-center gap-1">
+ <cd-icon [type]="statusIconMap[row.certificate.status] || statusIconMap['default']"></cd-icon>
{{ formatCertificateStatus(row.certificate) }}
- } @else {
- -
- }
+ </span>
+} @else {
+ -
+}
</ng-template>
import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
-
import { delay } from 'rxjs/operators';
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
import {
CephCertificateStatus,
CephServiceCertificate,
- CephServiceSpec
+ CephServiceSpec,
+ CERTIFICATE_STATUS_ICON_MAP
} from '~/app/shared/models/service.interface';
import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
import { RelativeDatePipe } from '~/app/shared/pipes/relative-date.pipe';
icons = Icons;
serviceUrls = { grafana: '', prometheus: '', alertmanager: '' };
isMgmtGateway: boolean = false;
+ statusIconMap = CERTIFICATE_STATUS_ICON_MAP;
constructor(
private actionLabels: ActionLabelsI18n,
});
let modalRef = this.cdsModalService.show(ServiceFormComponent);
Object.assign(modalRef, initialState);
+ modalRef.serviceUpdated.subscribe(() => {
+ this.table?.reloadData();
+ });
}
}
-import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
+import {
+ Component,
+ Input,
+ OnChanges,
+ OnInit,
+ SimpleChanges,
+ ViewEncapsulation
+} from '@angular/core';
import { ICON_TYPE, Icons, IconSize } from '../../enum/icons.enum';
@Component({
standalone: false,
encapsulation: ViewEncapsulation.None
})
-export class IconComponent implements OnInit {
+export class IconComponent implements OnInit, OnChanges {
@Input() type!: keyof typeof ICON_TYPE;
@Input() size: IconSize = IconSize.size16;
icon: string;
ngOnInit() {
+ this.updateIcon();
+ }
+
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes['type']) {
+ this.updateIcon();
+ }
+ }
+
+ private updateIcon() {
this.icon = Icons[this.type];
}
}
invalid = 'invalid'
}
+export const CERTIFICATE_STATUS_ICON_MAP: Record<string, string> = {
+ valid: 'success',
+ expiring: 'warning',
+ expiring_soon: 'warning',
+ expired: 'danger',
+ not_configured: 'warning',
+ invalid: 'danger',
+ default: 'warning'
+};
+
export interface CephServiceCertificate {
cert_name: string;
scope: string;
ssl_certificate_key: string;
ssl_protocols: string[];
ssl_ciphers: string[];
+ certificate_source: string;
port: number;
initial_admin_password: string;
rgw_realm: string;
:return: Tuple of (cert_details, target_key, cert_name, actual_scope)
"""
user_cert_name = f"{service_type.replace('-', '_')}_ssl_cert"
- cephadm_cert_name = f"cephadm-signed_{service_type}_cert"
+ cephadm_cert_name_by_service = f"cephadm-signed_{service_name}_cert"
+ cephadm_cert_name_by_type = f"cephadm-signed_{service_type}_cert"
cert_details = None
target_key = None
cert_name = user_cert_name
cert_name = user_cert_name
# If user-provided cert not found, try cephadm-signed certificate
- if not cert_details and cert_ls_data and cephadm_cert_name in cert_ls_data:
+ # First try by service_name (e.g., cephadm-signed_rgw.test-rgw_cert)
+ if not cert_details and cert_ls_data and cephadm_cert_name_by_service in cert_ls_data:
cert_details, target_key = _find_certificate_in_data(
- cert_ls_data, cephadm_cert_name, CertificateScope.HOST,
+ cert_ls_data, cephadm_cert_name_by_service, CertificateScope.HOST,
service_name, daemon_hostnames)
if cert_details:
- cert_name = cephadm_cert_name
+ cert_name = cephadm_cert_name_by_service
+ actual_scope = CertificateScope.HOST.value.upper()
+
+ # Then try by service_type (e.g., cephadm-signed_grafana_cert)
+ if not cert_details and cert_ls_data and cephadm_cert_name_by_type in cert_ls_data:
+ cert_details, target_key = _find_certificate_in_data(
+ cert_ls_data, cephadm_cert_name_by_type, CertificateScope.HOST,
+ service_name, daemon_hostnames)
+ if cert_details:
+ cert_name = cephadm_cert_name_by_type
actual_scope = CertificateScope.HOST.value.upper()
return (cert_details, target_key, cert_name, actual_scope)
@staticmethod
def fetch_certificates_for_service(orch: OrchClient, service_type: str,
- user_cert_name: str, cephadm_cert_name: str
+ user_cert_name: str, cephadm_cert_name: str,
+ service_name: Optional[str] = None
) -> Dict[str, Any]:
"""
Fetch certificates for a specific service, including missing ones.
:param orch: Orchestrator client instance
:param service_type: Service type for filter pattern
:param user_cert_name: User-provided certificate name
- :param cephadm_cert_name: Cephadm-signed certificate name
+ :param cephadm_cert_name: Cephadm-signed certificate name (by service_type)
+ :param service_name: Optional service name for cephadm-signed cert by service_name
:return: Dictionary of certificate data
"""
service_type_for_filter = service_type.replace('-', '_')
)
cert_ls_data = cert_ls_result or {}
+ # Build list of cert names to check
+ # Cephadm-signed certs may use service_name (e.g., rgw.test-rgw) or service_type
+ cephadm_cert_name_by_service = \
+ f"cephadm-signed_{service_name}_cert" if service_name else None
+
missing_certs: List[str] = []
if user_cert_name not in cert_ls_data:
missing_certs.append(user_cert_name)
if cephadm_cert_name not in cert_ls_data:
missing_certs.append(cephadm_cert_name)
+ if cephadm_cert_name_by_service and cephadm_cert_name_by_service not in cert_ls_data:
+ missing_certs.append(cephadm_cert_name_by_service)
# Fetch any missing certificates individually
for cert_name in missing_certs:
response.days_to_expiration = remaining_days
response.signed_by = signed_by
response.has_certificate = True
- response.certificate_source = 'reference'
+ is_cephadm_signed = CEPHADM_SIGNED_CERT in cert_name
+ response.certificate_source = 'cephadm-signed' if is_cephadm_signed else 'inline'
response.expiry_date = expiry_date
response.issuer = issuer_str
response.common_name = common_name