Skip to content

Commit 85b7ee6

Browse files
niladrihsushiMix
andauthored
feat: Add support to specify file permissions for pvc hostpaths (#264)
* feat: Add support to specify file permissions for pvc hostpaths Signed-off-by: sushiMix <[email protected]> * Update cmd/provisioner-localpv/app/config.go Co-authored-by: Niladri Halder <[email protected]> Signed-off-by: sushiMix <[email protected]> * Update docs/tutorials/hostpath/filepermissions.md Co-authored-by: Niladri Halder <[email protected]> Signed-off-by: sushiMix <[email protected]> * Update cmd/provisioner-localpv/app/provisioner_hostpath.go Co-authored-by: Niladri Halder <[email protected]> Signed-off-by: sushiMix <[email protected]> * Update cmd/provisioner-localpv/app/config.go Co-authored-by: Niladri Halder <[email protected]> Signed-off-by: sushiMix <[email protected]> * review Signed-off-by: sushiMix <[email protected]> * remove enabled flag Signed-off-by: sushiMix <[email protected]> * fix(provisioner): support integer values for FilePermisssions FsMode Signed-off-by: Niladri Halder <[email protected]> --------- Signed-off-by: sushiMix <[email protected]> Signed-off-by: Niladri Halder <[email protected]> Co-authored-by: sushiMix <[email protected]>
1 parent 604a495 commit 85b7ee6

File tree

5 files changed

+219
-30
lines changed

5 files changed

+219
-30
lines changed

cmd/provisioner-localpv/app/config.go

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package app
22

33
import (
44
"context"
5+
"gopkg.in/yaml.v3"
56
"strconv"
67
"strings"
78

89
mconfig "github.com/openebs/maya/pkg/apis/openebs.io/v1alpha1"
9-
cast "github.com/openebs/maya/pkg/castemplate/v1alpha1"
1010
hostpath "github.com/openebs/maya/pkg/hostpath/v1alpha1"
1111
"github.com/openebs/maya/pkg/util"
1212
"github.com/pkg/errors"
@@ -113,6 +113,17 @@ const (
113113

114114
KeyQuotaSoftLimit = "softLimitGrace"
115115
KeyQuotaHardLimit = "hardLimitGrace"
116+
117+
// FilePermissions allows to define the default directory mode
118+
// Exemple StorageClass snippet:
119+
// - name: FilePermissions
120+
// data:
121+
// mode: g+s
122+
// This is the cas-template key for all file permission 'data' keys
123+
KeyFilePermissions = "FilePermissions"
124+
125+
// FSMode defines the file permission mode of the shared directory
126+
KeyFsMode = "mode"
116127
)
117128

118129
const (
@@ -131,7 +142,7 @@ const (
131142
// default configuration of the provisioner.
132143
func (p *Provisioner) GetVolumeConfig(ctx context.Context, pvName string, pvc *corev1.PersistentVolumeClaim) (*VolumeConfig, error) {
133144

134-
pvConfig := p.defaultConfig
145+
var pvConfig []Config
135146

136147
//Fetch the SC
137148
scName := GetStorageClassName(pvc)
@@ -142,35 +153,36 @@ func (p *Provisioner) GetVolumeConfig(ctx context.Context, pvName string, pvc *c
142153

143154
// extract and merge the cas config from storageclass
144155
scCASConfigStr := sc.ObjectMeta.Annotations[string(mconfig.CASConfigKey)]
156+
var scConfig []Config
145157
klog.V(4).Infof("SC %v has config:%v", *scName, scCASConfigStr)
146158
if len(strings.TrimSpace(scCASConfigStr)) != 0 {
147-
scCASConfig, err := cast.UnMarshallToConfig(scCASConfigStr)
159+
err = yaml.Unmarshal([]byte(scCASConfigStr), &scConfig)
148160
if err == nil {
149-
pvConfig = cast.MergeConfig(scCASConfig, pvConfig)
161+
pvConfig = MergeConfigs(scConfig, pvConfig)
150162
} else {
151163
return nil, errors.Wrapf(err, "failed to get config: invalid sc config {%v}", scCASConfigStr)
152164
}
153165
}
154166

155-
// Extract and merge the cas config from persistentvolumeclaim.
156-
// TODO: Validation checks for what all cas-config options can be
157-
// set on the PVC.
158-
pvcCASConfigStr := pvc.Annotations[string(mconfig.CASConfigKey)]
167+
//TODO : extract and merge the cas volume config from pvc
168+
// This block can be added once validation checks are added
169+
// as to the type of config that can be passed via PVC
170+
var pvcConfig []Config
171+
pvcCASConfigStr := pvc.ObjectMeta.Annotations[string(mconfig.CASConfigKey)]
159172
klog.V(4).Infof("PVC %v has config:%v", pvc.Name, pvcCASConfigStr)
160173
if len(strings.TrimSpace(pvcCASConfigStr)) != 0 {
161-
pvcCASConfig, err := cast.UnMarshallToConfig(pvcCASConfigStr)
174+
err = yaml.Unmarshal([]byte(pvcCASConfigStr), &pvcConfig)
162175
if err == nil {
163-
// Config keys which already exist (SC config),
164-
// will be skipped
165-
// i.e. SC config will have precedence over PVC config,
166-
// if both have the same keys
167-
pvConfig = cast.MergeConfig(pvConfig, pvcCASConfig)
176+
pvConfig = MergeConfigs(pvConfig, pvcConfig)
168177
} else {
169-
return nil, errors.Wrapf(err, "failed to get config: invalid pvc config {%v}", pvcCASConfigStr)
178+
return nil, errors.Wrapf(err, "failed to get config: invalid config {%v}"+
179+
" in pvc {%v} in namespace {%v}",
180+
pvcCASConfigStr, pvc.Name, pvc.Namespace,
181+
)
170182
}
171183
}
172184

173-
pvConfigMap, err := cast.ConfigToMap(pvConfig)
185+
pvConfigMap, err := ConfigToMap(pvConfig)
174186
if err != nil {
175187
return nil, errors.Wrapf(err, "unable to read volume config: pvc {%v}", pvc.ObjectMeta.Name)
176188
}
@@ -332,6 +344,15 @@ func (c *VolumeConfig) IsExt4QuotaEnabled() bool {
332344
return enableExt4QuotaBool
333345
}
334346

347+
// GetFsMode fetches the file mode from PVC
348+
// or StorageClass annotation, if specified
349+
func (c *VolumeConfig) GetFsMode() string {
350+
configData := c.getDataField(KeyFilePermissions, KeyFsMode)
351+
352+
//Keep the original default mode
353+
return configData
354+
}
355+
335356
// getValue is a utility function to extract the value
336357
// of the `key` from the ConfigMap object - which is
337358
// map[string]interface{map[string][string]}
@@ -371,9 +392,9 @@ func (c *VolumeConfig) getEnabled(key string) string {
371392
// This gets the value for a specific
372393
// 'Data' parameter key-value pair.
373394
func (c *VolumeConfig) getDataField(key string, dataKey string) string {
374-
if configData, ok := util.GetNestedField(c.configData, key).(map[string]string); ok {
395+
if configData, ok := util.GetNestedField(c.configData, key).(map[string]RawLiteral); ok {
375396
if val, p := configData[dataKey]; p {
376-
return val
397+
return string(val)
377398
}
378399
}
379400
//Default case
@@ -464,7 +485,7 @@ func GetImagePullSecrets(s string) []corev1.LocalObjectReference {
464485
return list
465486
}
466487

467-
func dataConfigToMap(pvConfig []mconfig.Config) (map[string]interface{}, error) {
488+
func dataConfigToMap(pvConfig []Config) (map[string]interface{}, error) {
468489
m := map[string]interface{}{}
469490

470491
for _, configObj := range pvConfig {
@@ -486,7 +507,7 @@ func dataConfigToMap(pvConfig []mconfig.Config) (map[string]interface{}, error)
486507
return m, nil
487508
}
488509

489-
func listConfigToMap(pvConfig []mconfig.Config) (map[string]interface{}, error) {
510+
func listConfigToMap(pvConfig []Config) (map[string]interface{}, error) {
490511
m := map[string]interface{}{}
491512

492513
for _, configObj := range pvConfig {
@@ -507,3 +528,74 @@ func listConfigToMap(pvConfig []mconfig.Config) (map[string]interface{}, error)
507528

508529
return m, nil
509530
}
531+
532+
// A RawLiteral interprets characters as they are without assuming they are octal and translating them to ints.
533+
// This works when we want to pick up a yaml value as it is, whether it's wearing ” or "" or neither.
534+
type RawLiteral string
535+
536+
func (r *RawLiteral) UnmarshalYAML(node *yaml.Node) error {
537+
*r = RawLiteral(node.Value)
538+
return nil
539+
}
540+
541+
// Config holds a configuration element
542+
type Config struct {
543+
// Name of the config
544+
Name string `yaml:"name"`
545+
// Enabled flags if this config is enabled or disabled;
546+
// true indicates enabled while false indicates disabled
547+
Enabled string `yaml:"enabled"`
548+
// Value represents any specific value that is applicable
549+
// to this config
550+
Value string `yaml:"value"`
551+
// Data represents an arbitrary map of key value pairs
552+
Data map[string]RawLiteral `yaml:"data"`
553+
// List represents a JSON(YAML) array
554+
List []string `yaml:"list"`
555+
}
556+
557+
// MergeConfig will merge configuration fields
558+
// from lowPriority that are not present in
559+
// highPriority configuration and return the
560+
// resulting config
561+
func MergeConfigs(highPriority, lowPriority []Config) (final []Config) {
562+
var book []string
563+
for _, h := range highPriority {
564+
final = append(final, h)
565+
book = append(book, strings.TrimSpace(h.Name))
566+
}
567+
for _, l := range lowPriority {
568+
// include only if the config was not present
569+
// earlier in high priority configuration
570+
if !util.ContainsString(book, strings.TrimSpace(l.Name)) {
571+
final = append(final, l)
572+
}
573+
}
574+
return
575+
}
576+
577+
// ConfigToMap transforms CAS template config type
578+
// to a nested map
579+
func ConfigToMap(all []Config) (m map[string]interface{}, err error) {
580+
var configName string
581+
m = map[string]interface{}{}
582+
for _, config := range all {
583+
configName = strings.TrimSpace(config.Name)
584+
if len(configName) == 0 {
585+
err = errors.Errorf("failed to transform cas config to map: missing config name: %s", config)
586+
return nil, err
587+
}
588+
confHierarchy := map[string]interface{}{
589+
configName: map[string]string{
590+
"enabled": config.Enabled,
591+
"value": config.Value,
592+
},
593+
}
594+
isMerged := util.MergeMapOfObjects(m, confHierarchy)
595+
if !isMerged {
596+
err = errors.Errorf("failed to transform cas config to map: failed to merge: %s", config)
597+
return nil, err
598+
}
599+
}
600+
return
601+
}

cmd/provisioner-localpv/app/config_test.go

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"reflect"
2222
"testing"
2323

24-
mconfig "github.com/openebs/maya/pkg/apis/openebs.io/v1alpha1"
2524
corev1 "k8s.io/api/core/v1"
2625
)
2726

@@ -67,22 +66,22 @@ func TestGetImagePullSecrets(t *testing.T) {
6766
}
6867

6968
func TestDataConfigToMap(t *testing.T) {
70-
hostpathConfig := mconfig.Config{Name: "StorageType", Value: "hostpath"}
71-
xfsQuotaConfig := mconfig.Config{Name: "XFSQuota", Enabled: "true",
72-
Data: map[string]string{
69+
hostpathConfig := Config{Name: "StorageType", Value: "hostpath"}
70+
xfsQuotaConfig := Config{Name: "XFSQuota", Enabled: "true",
71+
Data: map[string]RawLiteral{
7372
"SoftLimitGrace": "20%",
7473
"HardLimitGrace": "80%",
7574
},
7675
}
7776

7877
testCases := map[string]struct {
79-
config []mconfig.Config
78+
config []Config
8079
expectedValue map[string]interface{}
8180
}{
8281
"nil 'Data' map": {
83-
config: []mconfig.Config{hostpathConfig, xfsQuotaConfig},
82+
config: []Config{hostpathConfig, xfsQuotaConfig},
8483
expectedValue: map[string]interface{}{
85-
"XFSQuota": map[string]string{
84+
"XFSQuota": map[string]RawLiteral{
8685
"SoftLimitGrace": "20%",
8786
"HardLimitGrace": "80%",
8887
},
@@ -105,14 +104,51 @@ func TestDataConfigToMap(t *testing.T) {
105104
}
106105
}
107106

107+
func TestPermissionConfigToMap(t *testing.T) {
108+
hostpathConfig := Config{Name: "StorageType", Value: "hostpath"}
109+
permissionConfig := Config{Name: "FilePermissions",
110+
Data: map[string]RawLiteral{
111+
"mode": "0750",
112+
},
113+
}
114+
115+
testCases := map[string]struct {
116+
config []Config
117+
expectedValue map[string]interface{}
118+
}{
119+
"nil 'Data' map": {
120+
config: []Config{hostpathConfig, permissionConfig},
121+
expectedValue: map[string]interface{}{
122+
"FilePermissions": map[string]RawLiteral{
123+
"mode": "0750",
124+
},
125+
},
126+
},
127+
}
128+
129+
for k, v := range testCases {
130+
v := v
131+
k := k
132+
t.Run(k, func(t *testing.T) {
133+
actualValue, err := dataConfigToMap(v.config)
134+
if err != nil {
135+
t.Errorf("expected error to be nil, but got %v", err)
136+
}
137+
if !reflect.DeepEqual(actualValue, v.expectedValue) {
138+
t.Errorf("expected %v, but got %v", v.expectedValue, actualValue)
139+
}
140+
})
141+
}
142+
}
143+
108144
func Test_listConfigToMap(t *testing.T) {
109145
tests := map[string]struct {
110-
pvConfig []mconfig.Config
146+
pvConfig []Config
111147
expectedValue map[string]interface{}
112148
wantErr bool
113149
}{
114150
"Valid list parameter": {
115-
pvConfig: []mconfig.Config{
151+
pvConfig: []Config{
116152
{Name: "StorageType", Value: "hostpath"},
117153
{Name: "NodeAffinityLabels", List: []string{"fake-node-label-key"}},
118154
},

cmd/provisioner-localpv/app/provisioner_hostpath.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ func (p *Provisioner) ProvisionHostPath(ctx context.Context, opts pvController.P
6363
klog.Infof("Creating volume %v at node with labels {%v}, path:%v,ImagePullSecrets:%v", name, nodeAffinityLabels, path, imagePullSecrets)
6464

6565
//Before using the path for local PV, make sure it is created.
66-
initCmdsForPath := []string{"mkdir", "-m", "0777", "-p"}
66+
fsMode := volumeConfig.GetFsMode()
67+
// Set default value if FilePermissions mode is not specified.
68+
if len(fsMode) == 0 {
69+
fsMode = "0777"
70+
}
71+
initCmdsForPath := []string{"mkdir", "-m", fsMode, "-p"}
6772
podOpts := &HelperPodOptions{
6873
cmdsForPath: initCmdsForPath,
6974
name: name,

docs/quickstart.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ You can provision LocalPV hostpath StorageType volumes dynamically using the def
6565
# hostpath directory
6666
#- name: BasePath
6767
# value: "/var/openebs/local"
68+
#Use this to set a specific mode for directory creation
69+
#- name: FilePermissions
70+
# data:
71+
# mode: "0770"
6872
provisioner: openebs.io/local
6973
reclaimPolicy: Delete
7074
#It is necessary to have volumeBindingMode as WaitForFirstConsumer
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# File permission tuning
2+
3+
Hostpath LocalPV will by default create folder with the following rights: `0777`. In some usecases, these rights are too wide and should be reduced.
4+
As an important point, when using hostpath the underlying PV will be a localpath whichs allows kubelet to chown the folder based on the [fsGroup](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#configure-volume-permission-and-ownership-change-policy-for-pods))
5+
6+
We allow to set file permissions using:
7+
8+
```yaml
9+
#This is a custom StorageClass template
10+
apiVersion: storage.k8s.io/v1
11+
kind: StorageClass
12+
metadata:
13+
name: custom-hostpath
14+
annotations:
15+
openebs.io/cas-type: local
16+
cas.openebs.io/config: |
17+
- name: StorageType
18+
value: "hostpath"
19+
- name: BasePath
20+
value: "/var/openebs/local"
21+
- name: FilePermissions
22+
data:
23+
mode: "0770"
24+
provisioner: openebs.io/local
25+
reclaimPolicy: Delete
26+
#It is necessary to have volumeBindingMode as WaitForFirstConsumer
27+
volumeBindingMode: WaitForFirstConsumer
28+
```
29+
30+
With such configuration the folder will be crated with `0770` rights for all the PVC using this storage class.
31+
32+
The same configuration is available at PVC level to have a more fined grained configuration capability (the Storage class configuration will always win against PVC one):
33+
34+
```yaml
35+
kind: PersistentVolumeClaim
36+
apiVersion: v1
37+
metadata:
38+
name: localpv-vol
39+
annotations:
40+
cas.openebs.io/config: |
41+
- name: FilePermissions
42+
data:
43+
mode: "0770"
44+
spec:
45+
#Change this name if you are using a custom StorageClass
46+
storageClassName: openebs-hostpath
47+
accessModes: ["ReadWriteOnce"]
48+
resources:
49+
requests:
50+
#Set capacity here
51+
storage: 5Gi
52+
```

0 commit comments

Comments
 (0)