]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: NFS: Toggle visibility of CephFS snapshots
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Wed, 11 Feb 2026 10:55:43 +0000 (16:25 +0530)
committerDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Mon, 2 Mar 2026 05:42:22 +0000 (11:12 +0530)
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
Fixes: https://tracker.ceph.com/issues/74875
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-form/cephfs-subvolume-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-form/cephfs-subvolume-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-form/cephfs-subvolume-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolume.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index 92c3f3911b4972196ad28bde0041beb8dbbe1711..4c150bdc4b75349c04accef81588a3c532c76d71 100644 (file)
@@ -886,6 +886,68 @@ class CephFSSubvolume(RESTController):
             return False
         return True
 
+    @RESTController.Resource('GET', path='/snapshot-visibility')
+    def snapshot_visibility(
+        self,
+        vol_name: str,
+        subvol_name: str,
+        group_name: str = ''
+    ):
+        params = {
+            'vol_name': vol_name,
+            'sub_name': subvol_name
+        }
+
+        if group_name:
+            params['group_name'] = group_name
+
+        error_code, out, err = mgr.remote(
+            'volumes',
+            '_cmd_fs_subvolume_snapshot_visibility_get',
+            None,
+            params
+        )
+
+        if error_code != 0:
+            raise DashboardException(
+                f'Failed to get snapshot visibility for subvolume '
+                f'{subvol_name}: {err}'
+            )
+
+        return out
+
+    @RESTController.Resource('PUT', path='/snapshot-visibility')
+    def set_snapshot_visibility(
+        self,
+        vol_name: str,
+        subvol_name: str,
+        value: str,
+        group_name: str = ''
+    ):
+        params = {
+            'vol_name': vol_name,
+            'sub_name': subvol_name,
+            'value': value
+        }
+
+        if group_name:
+            params['group_name'] = group_name
+
+        error_code, out, err = mgr.remote(
+            'volumes',
+            '_cmd_fs_subvolume_snapshot_visibility_set',
+            None,
+            params
+        )
+
+        if error_code != 0:
+            raise DashboardException(
+                f'Failed to set snapshot visibility for subvolume '
+                f'{subvol_name}: {err}'
+            )
+
+        return out
+
 
 @APIRouter('/cephfs/subvolume/group', Scope.CEPHFS)
 @APIDoc("Cephfs Subvolume Group Management API", "CephfsSubvolumeGroup")
index be9d1ee05b7189fdab5af04e0904c3950a25416b..c28e1f541d7e21cddb4f3e8b81327da9bf6b5fb3 100644 (file)
           </ng-template>
         </div>
 
+        <cd-alert-panel *ngIf="!showSnapshotVisibility"
+                        type="warning"
+                        class="cds-mb-6"
+                        i18n>
+          To manage snapshot visibility, first enable
+          client_respect_subvolume_snapshot_visibility.
+        </cd-alert-panel>
+
+        <!-- Snapshot visibility -->
+        <div class="form-item">
+          <cds-checkbox id="snapshotVisibility"
+                        name="snapshotVisibility"
+                        formControlName="snapshotVisibility">
+            <span i18n>Allow snapshot browsing</span>
+            <cd-help-text>
+              <span i18n>
+                When enabled, snapshots will be visible inside the subvolume directory under ".snap".
+              </span>
+            </cd-help-text>
+          </cds-checkbox>
+        </div>
+
         <!-- CephFS Pools -->
         <div class="form-item">
           <cds-select label="CephFS Pools"
index b54590a0b702cfe16c6b3b34dce2128325e752d1..abcaa1e0af4753e0e5ae48d049779645c7cda703 100644 (file)
@@ -1,5 +1,6 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
 
 import { CephfsSubvolumeFormComponent } from './cephfs-subvolume-form.component';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@@ -9,6 +10,7 @@ import { RouterTestingModule } from '@angular/router/testing';
 import { ReactiveFormsModule } from '@angular/forms';
 import { FormHelper, configureTestBed } from '~/testing/unit-test-helper';
 import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
 import { CheckboxModule, InputModule, ModalModule, SelectModule } from 'carbon-components-angular';
 
 describe('CephfsSubvolumeFormComponent', () => {
@@ -41,8 +43,15 @@ describe('CephfsSubvolumeFormComponent', () => {
     component.pools = [];
     component.ngOnInit();
     formHelper = new FormHelper(component.subvolumeForm);
-    createSubVolumeSpy = spyOn(TestBed.inject(CephfsSubvolumeService), 'create').and.stub();
-    editSubVolumeSpy = spyOn(TestBed.inject(CephfsSubvolumeService), 'update').and.stub();
+    const subvolumeService = TestBed.inject(CephfsSubvolumeService);
+    createSubVolumeSpy = spyOn(subvolumeService, 'create').and.returnValue(of({ status: 200 }));
+    editSubVolumeSpy = spyOn(subvolumeService, 'update').and.returnValue(of({ status: 200 }));
+    spyOn(subvolumeService, 'setSnapshotVisibility').and.returnValue(of({ status: 200 }));
+    spyOn(subvolumeService, 'info').and.returnValue(
+      of({ bytes_quota: 'infinite', uid: 0, gid: 0, pool_namespace: false, mode: 755 } as any)
+    );
+    spyOn(subvolumeService, 'getSnapshotVisibility').and.returnValue(of('1'));
+    spyOn(TestBed.inject(ConfigurationService), 'filter').and.returnValue(of([]));
     fixture.detectChanges();
   });
 
index 37590a737863ef9391e545fb577b2c8160630e1b..813d64e53100944d80cac000183b608ec98266c6 100644 (file)
@@ -1,6 +1,7 @@
 import { Component, Inject, OnInit, Optional } from '@angular/core';
 import { FormControl, Validators } from '@angular/forms';
 import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { ConfigurationService } from '~/app/shared/api/configuration.service';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { FinishedTask } from '~/app/shared/models/finished-task';
@@ -9,13 +10,19 @@ import { Pool } from '../../pool/pool';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { CephfsSubvolumeInfo } from '~/app/shared/models/cephfs-subvolume.model';
+import {
+  CephfsSubvolumeInfo,
+  SNAPSHOT_VISIBILITY_CONFIG_NAME,
+  SNAPSHOT_VISIBILITY_CONFIG_SECTION
+} from '~/app/shared/models/cephfs-subvolume.model';
+import { ConfigFormModel } from '~/app/shared/components/config-option/config-option.model';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 import { OctalToHumanReadablePipe } from '~/app/shared/pipes/octal-to-human-readable.pipe';
 import { CdForm } from '~/app/shared/forms/cd-form';
 import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
 import { CephfsSubvolumeGroup } from '~/app/shared/models/cephfs-subvolume-group.model';
-import { Observable } from 'rxjs';
+import { Observable, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
 
 @Component({
   selector: 'cd-cephfs-subvolume-form',
@@ -42,11 +49,14 @@ export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
   };
   scopes: string[] = ['owner', 'group', 'others'];
 
+  showSnapshotVisibility = false;
+
   constructor(
     private actionLabels: ActionLabelsI18n,
     private taskWrapper: TaskWrapperService,
     private cephFsSubvolumeService: CephfsSubvolumeService,
     private cephFsSubvolumeGroupService: CephfsSubvolumeGroupService,
+    private configurationService: ConfigurationService,
     private formatter: FormatterService,
     private dimlessBinary: DimlessBinaryPipe,
     private octalToHumanReadable: OctalToHumanReadablePipe,
@@ -93,7 +103,35 @@ export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
     this.dataPools = this.pools.filter((pool) => pool.type === 'data');
     this.createForm();
 
-    this.isEdit ? this.populateForm() : this.loadingReady();
+    this.loadSnapshotVisibilityConfig().subscribe(() => {
+      this.isEdit ? this.populateForm() : this.loadingReady();
+    });
+  }
+
+  private loadSnapshotVisibilityConfig(): Observable<void> {
+    return this.configurationService.filter([SNAPSHOT_VISIBILITY_CONFIG_NAME]).pipe(
+      map((configOptions: ConfigFormModel[]) => {
+        const options = configOptions ?? [];
+        const option = options.find((opt) => opt.name === SNAPSHOT_VISIBILITY_CONFIG_NAME);
+        this.showSnapshotVisibility = this.isSnapshotVisibilityEnabled(option);
+        const visibilityControl = this.subvolumeForm?.get('snapshotVisibility');
+        if (!this.showSnapshotVisibility && visibilityControl) {
+          visibilityControl.disable();
+          visibilityControl.setValue(false);
+        }
+      }),
+      catchError(() => {
+        this.showSnapshotVisibility = false;
+        return of(undefined);
+      })
+    );
+  }
+
+  private isSnapshotVisibilityEnabled(option?: ConfigFormModel) {
+    const values = option?.value ?? [];
+    const clientValue = values.find((entry) => entry.section === SNAPSHOT_VISIBILITY_CONFIG_SECTION)
+      ?.value;
+    return String(clientValue).toLowerCase() === 'true';
   }
 
   createForm() {
@@ -122,7 +160,8 @@ export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
       uid: new FormControl(null),
       gid: new FormControl(null),
       mode: new FormControl({}),
-      isolatedNamespace: new FormControl(false)
+      isolatedNamespace: new FormControl(false),
+      snapshotVisibility: new FormControl(true)
     });
   }
 
@@ -149,10 +188,44 @@ export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
         this.subvolumeForm.get('isolatedNamespace').setValue(resp.pool_namespace);
         this.initialMode = this.octalToHumanReadable.transform(resp.mode, true);
 
-        this.loadingReady();
+        if (!this.showSnapshotVisibility) {
+          this.subvolumeForm.get('snapshotVisibility').setValue(false);
+        }
+        this.loadSnapshotVisibility();
+      });
+  }
+
+  private loadSnapshotVisibility() {
+    this.cephFsSubvolumeService
+      .getSnapshotVisibility(this.fsName, this.subVolumeName, this.subVolumeGroupName)
+      .subscribe({
+        next: (visibilityResp) => {
+          const visibility = String(visibilityResp) === '1';
+          this.subvolumeForm.get('snapshotVisibility').setValue(visibility);
+          this.loadingReady();
+        },
+        error: () => {
+          this.subvolumeForm.get('snapshotVisibility').setValue(true);
+          this.loadingReady();
+        }
       });
   }
 
+  private setSnapshotVisibility(
+    subVolumeName: string,
+    subVolumeGroupName: string,
+    visible: boolean
+  ) {
+    return this.showSnapshotVisibility
+      ? this.cephFsSubvolumeService.setSnapshotVisibility(
+          this.fsName,
+          subVolumeName,
+          visible,
+          subVolumeGroupName
+        )
+      : of({ status: 200 });
+  }
+
   submit() {
     const subVolumeName = this.subvolumeForm.getValue('subvolumeName');
     const subVolumeGroupName = this.subvolumeForm.getValue('subvolumeGroupName');
@@ -162,20 +235,24 @@ export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
     const gid = this.subvolumeForm.getValue('gid');
     const mode = this.formatter.toOctalPermission(this.subvolumeForm.getValue('mode'));
     const isolatedNamespace = this.subvolumeForm.getValue('isolatedNamespace');
+    const snapshotVisibility = this.subvolumeForm.getValue('snapshotVisibility');
 
     if (this.isEdit) {
       const editSize = size === 0 ? 'infinite' : size;
+      const visibilityCall = this.setSnapshotVisibility(
+        subVolumeName,
+        subVolumeGroupName,
+        snapshotVisibility
+      );
+
       this.taskWrapper
         .wrapTaskAroundCall({
           task: new FinishedTask('cephfs/subvolume/' + URLVerbs.EDIT, {
             subVolumeName: subVolumeName
           }),
-          call: this.cephFsSubvolumeService.update(
-            this.fsName,
-            subVolumeName,
-            String(editSize),
-            subVolumeGroupName
-          )
+          call: this.cephFsSubvolumeService
+            .update(this.fsName, subVolumeName, String(editSize), subVolumeGroupName)
+            .pipe(switchMap(() => visibilityCall))
         })
         .subscribe({
           error: () => {
@@ -191,17 +268,23 @@ export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
           task: new FinishedTask('cephfs/subvolume/' + URLVerbs.CREATE, {
             subVolumeName: subVolumeName
           }),
-          call: this.cephFsSubvolumeService.create(
-            this.fsName,
-            subVolumeName,
-            subVolumeGroupName,
-            pool,
-            String(size),
-            uid,
-            gid,
-            mode,
-            isolatedNamespace
-          )
+          call: this.cephFsSubvolumeService
+            .create(
+              this.fsName,
+              subVolumeName,
+              subVolumeGroupName,
+              pool,
+              String(size),
+              uid,
+              gid,
+              mode,
+              isolatedNamespace
+            )
+            .pipe(
+              switchMap(() =>
+                this.setSnapshotVisibility(subVolumeName, subVolumeGroupName, snapshotVisibility)
+              )
+            )
         })
         .subscribe({
           error: () => {
index 6dfa82c4234a8bb0911a80c0ade227494886aaf9..fae3e34d416ed96aa51519c113119fde144e43f5 100644 (file)
@@ -103,6 +103,28 @@ export class CephfsSubvolumeService {
     });
   }
 
+  getSnapshotVisibility(fsName: string, subVolumeName: string, groupName: string = '') {
+    return this.http.get<string | number>(`${this.baseURL}/${fsName}/snapshot-visibility`, {
+      params: {
+        subvol_name: subVolumeName,
+        group_name: groupName
+      }
+    });
+  }
+
+  setSnapshotVisibility(
+    fsName: string,
+    subVolumeName: string,
+    visible: boolean,
+    groupName: string = ''
+  ) {
+    return this.http.put(`${this.baseURL}/${fsName}/snapshot-visibility`, {
+      subvol_name: subVolumeName,
+      group_name: groupName,
+      value: visible.toString()
+    });
+  }
+
   getSnapshots(
     fsName: string,
     subVolumeName: string,
index 25a2a5acc7f49978415185da031d02f96acae75a..3f225a771892644aafc5bac93a73c37e236cdd1f 100644 (file)
@@ -26,3 +26,7 @@ export interface SubvolumeSnapshotInfo {
   created_at: string;
   has_pending_clones: string;
 }
+
+export const SNAPSHOT_VISIBILITY_CONFIG_NAME = 'client_respect_subvolume_snapshot_visibility';
+
+export const SNAPSHOT_VISIBILITY_CONFIG_SECTION = 'client';
index bd4ddc5f85fc203b8c7ffcebfd202cf864e47849..e92cee103b22489c8d4312f7ab9048cc27ce5b49 100644 (file)
@@ -472,6 +472,10 @@ export class TaskMessageService {
     'cephfs/subvolume/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
       this.subvolume(metadata)
     ),
+    'cephfs/subvolume/snapshot_visibility/set': this.newTaskMessage(
+      this.commonOperations.update,
+      (metadata) => $localize`subvolume snapshot visibility for '${metadata.subVolumeName}'`
+    ),
     'cephfs/subvolume/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
       this.subvolume(metadata)
     ),
index 5e1fc41fb3ae3b512953ea433be4714317b2f24b..860c7997039d96bb5ee72237ba6fb2b3b103d69d 100755 (executable)
@@ -4704,6 +4704,102 @@ paths:
       - jwt: []
       tags:
       - CephFSSubvolume
+  /api/cephfs/subvolume/{vol_name}/snapshot-visibility:
+    get:
+      parameters:
+      - in: path
+        name: vol_name
+        required: true
+        schema:
+          type: string
+      - in: query
+        name: subvol_name
+        required: true
+        schema:
+          type: string
+      - default: ''
+        in: query
+        name: group_name
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - CephFSSubvolume
+    put:
+      parameters:
+      - in: path
+        name: vol_name
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                group_name:
+                  default: ''
+                  type: string
+                subvol_name:
+                  type: string
+                value:
+                  type: string
+              required:
+              - subvol_name
+              - value
+              type: object
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - CephFSSubvolume
   /api/cephfs/{fs_id}:
     get:
       parameters: