diff --git a/catalog.yaml b/catalog.yaml index e5388e5..f079bfb 100644 --- a/catalog.yaml +++ b/catalog.yaml @@ -97,6 +97,21 @@ use_cases: - aggregate_name - client_match outputs: [volume_name, mount_path, export_policy, client_match] + go: + path: go/nfs_provision/main.go + command: "go run ." + cwd: go/nfs_provision + prerequisites: + env: + - ONTAP_HOST + - ONTAP_USER + - ONTAP_PASS + - SVM_NAME + - VOLUME_NAME + - AGGR_NAME + setup: "cd go && go mod download" + inputs: [svm, volume, size, aggregate, client_match] + outputs: [volume_name, mount_path, export_policy] - id: cifs-provision description: Create a CIFS share with FlexVol volume and share ACL @@ -161,6 +176,21 @@ use_cases: - acl_user - acl_permission outputs: [volume_name, mount_path, share_name, share_path] + go: + path: go/cifs_provision/main.go + command: "go run ." + cwd: go/cifs_provision + prerequisites: + env: + - ONTAP_HOST + - ONTAP_USER + - ONTAP_PASS + - SVM_NAME + - VOLUME_NAME + - AGGR_NAME + setup: "cd go && go mod download" + inputs: [svm, volume, size, aggregate, share_name, acl_user, acl_permission] + outputs: [volume_name, share_name, mount_path] - id: cluster-setup description: Create a storage cluster from two pre-cluster nodes diff --git a/go/cifs_provision/main.go b/go/cifs_provision/main.go new file mode 100644 index 0000000..dd54233 --- /dev/null +++ b/go/cifs_provision/main.go @@ -0,0 +1,280 @@ +// © 2026 NetApp, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// See the NOTICE file in the repo root for trademark and attribution details. + +// CIFS Provision — create a FlexVol (NTFS security style) and a CIFS share with ACL. +// +// Steps: +// +// 0 preflight — verify cluster connectivity; log cluster name + ONTAP version +// 1 ensureCIFSServer — confirm CIFS server on SVM; optionally create workgroup server +// 2 ensureVolume — create NTFS FlexVol if it does not exist; poll creation job +// 3 fetchSVMUUID — GET SVM UUID (required for share and ACL URL paths) +// 4 ensureCIFSShare — create CIFS share if it does not exist (404-safe check) +// 5 setShareACL — PATCH share ACL for the given user and permission +// 6 verifyShare — GET share and log every ACL entry for confirmation +// 7 summary — log share name, volume, mount path, and ACL +// +// The script is idempotent: re-running with the same parameters skips any step +// that is already complete. +// +// Prerequisites: +// 1. Go 1.22+ installed; run `cd go && go mod download` once to cache deps +// 2. ONTAP 9.8+ cluster with CIFS licence on the target SVM +// 3. Target SVM (SVM_NAME) already exists with a CIFS server configured, +// or set CREATE_CIFS_SERVER=true to auto-create a workgroup server +// 4. Target aggregate (AGGR_NAME) already online +// +// Usage: +// +// export ONTAP_HOST=10.x.x.x ONTAP_USER=admin ONTAP_PASS=secret +// export SVM_NAME=vs1 +// export VOLUME_NAME=vol_002 +// export VOLUME_SIZE=100MB +// export AGGR_NAME=aggr1 +// export SHARE_NAME=cifs_share_demo +// export ACL_USER=Everyone +// export ACL_PERMISSION=full_control +// go run . +// +// # To auto-create a workgroup CIFS server if none exists on the SVM: +// export CREATE_CIFS_SERVER=true CIFS_SERVER_NAME=ONTAP-CIFS CIFS_WORKGROUP=WORKGROUP +package main + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + ontapclient "github.com/netapp/pace/go/ontapclient" +) + +const ( + pathCIFSServices = "/protocols/cifs/services" + pathCIFSShares = "/protocols/cifs/shares" + pathSVMs = "/svm/svms" +) + +func main() { + log.SetFlags(log.LstdFlags) + loadDotEnv() + ctx := context.Background() + + svmName := mustEnv("SVM_NAME") + volumeName := mustEnv("VOLUME_NAME") + volumeSize := envOrDefault("VOLUME_SIZE", "100MB") + aggrName := mustEnv("AGGR_NAME") + shareName := envOrDefault("SHARE_NAME", "cifs_share_demo") + shareComment := envOrDefault("SHARE_COMMENT", "Provisioned by pace example") + aclUser := envOrDefault("ACL_USER", "Everyone") + aclPermission := envOrDefault("ACL_PERMISSION", "full_control") + createCIFSServer := envOrDefault("CREATE_CIFS_SERVER", "false") == "true" + cifsServerName := envOrDefault("CIFS_SERVER_NAME", "ONTAP-CIFS") + cifsWorkgroup := envOrDefault("CIFS_WORKGROUP", "WORKGROUP") + + client, err := ontapclient.FromEnv() + dieOnErr("init client", err) + defer client.Close() + + log.Printf("CIFS provision starting — SVM=%s | volume=%s | share=%s", + svmName, volumeName, shareName) + + log.Println("=== Step 0: Verify cluster connectivity ===") + cluster, err := client.Get(ctx, "/cluster", map[string]string{"fields": "name,version"}) + dieOnErr("get cluster", err) + log.Printf("CLUSTER | name=%s | ontap=%s", + ontapclient.NestedStr(cluster, "name"), + ontapclient.NestedStr(cluster, "version", "full")) + + log.Println("=== Step 1: Ensure CIFS server ===") + ensureCIFSServer(ctx, client, svmName, createCIFSServer, cifsServerName, cifsWorkgroup) + + log.Println("=== Step 2: Ensure volume ===") + ensureVolumeNTFS(ctx, client, svmName, volumeName, volumeSize, aggrName) + + log.Println("=== Step 3: Fetch SVM UUID ===") + svmUUID := fetchSVMUUID(ctx, client, svmName) + + log.Println("=== Step 4: Ensure CIFS share ===") + ensureCIFSShare(ctx, client, svmUUID, shareName, volumeName, svmName, shareComment) + + log.Println("=== Step 5: Set share ACL ===") + setShareACL(ctx, client, svmUUID, shareName, aclUser, aclPermission) + + log.Println("=== Step 6: Verify share ===") + verifyShare(ctx, client, svmUUID, shareName) + + log.Printf("=== CIFS SHARE PROVISIONED ===\n"+ + " SVM : %s\n"+ + " Volume : %s\n"+ + " Share : %s\n"+ + " Mount path : /%s\n"+ + " ACL : %s → %s", + svmName, volumeName, shareName, volumeName, aclUser, aclPermission) +} + +// ensureCIFSServer verifies a CIFS server exists on the SVM. +// If createServer is true and none is found, creates a workgroup CIFS server. +func ensureCIFSServer(ctx context.Context, client *ontapclient.Client, + svm string, createServer bool, serverName, workgroup string) { + + resp, err := client.Get(ctx, pathCIFSServices, map[string]string{ + "fields": "svm.name,enabled", + "max_records": "1", + ontapclient.KeySVMName: svm, + }) + dieOnErr("check cifs server", err) + + if ontapclient.NumRecords(resp) > 0 { + log.Printf("CIFS server confirmed on SVM '%s'", svm) + return + } + + if !createServer { + log.Fatalf("ABORTED — no CIFS server on SVM '%s'; set CREATE_CIFS_SERVER=true to auto-create one", svm) + } + + log.Printf("Creating workgroup CIFS server '%s' in workgroup '%s' on SVM '%s'…", + serverName, workgroup, svm) + createResp, err := client.Post(ctx, pathCIFSServices, nil, map[string]interface{}{ + "svm": map[string]string{"name": svm}, + "name": serverName, + "workgroup": workgroup, + "enabled": true, + }) + dieOnErr("create cifs server", err) + + if jobUUID := ontapclient.NestedStr(createResp, "job", "uuid"); jobUUID != "" { + log.Printf(" CIFS server creation job: %s", jobUUID) + if _, err := client.PollJob(ctx, jobUUID, 10*time.Second); err != nil { + log.Fatalf("CIFS server creation job failed: %v", err) + } + } + log.Printf("CIFS server '%s' created on SVM '%s'", serverName, svm) +} + +// ensureVolumeNTFS creates a FlexVol with NTFS security style if it does not exist. +// The volume is mounted at / to make it immediately accessible to CIFS clients. +func ensureVolumeNTFS(ctx context.Context, client *ontapclient.Client, svm, volume, size, aggr string) { + checkResp, err := client.Get(ctx, ontapclient.PathStorageVolumes, map[string]string{ + "fields": "name,uuid", + "max_records": "1", + "name": volume, + ontapclient.KeySVMName: svm, + }) + dieOnErr("check volume", err) + + if ontapclient.NumRecords(checkResp) > 0 { + log.Printf("Volume '%s' already exists — skipping create", volume) + return + } + + log.Printf("Creating NTFS volume '%s' (%s) on SVM '%s' aggregate '%s'…", volume, size, svm, aggr) + createResp, err := client.Post(ctx, ontapclient.PathStorageVolumes, nil, map[string]interface{}{ + "name": volume, + "svm": map[string]string{"name": svm}, + "aggregates": []map[string]string{ + {"name": aggr}, + }, + "size": size, + "nas": map[string]string{ + "security_style": "ntfs", + "path": fmt.Sprintf("/%s", volume), + }, + }) + dieOnErr("create volume", err) + + if jobUUID := ontapclient.NestedStr(createResp, "job", "uuid"); jobUUID != "" { + log.Printf(" volume creation job: %s", jobUUID) + if _, err := client.PollJob(ctx, jobUUID, 10*time.Second); err != nil { + log.Fatalf("volume creation job failed: %v", err) + } + } + log.Printf("Volume '%s' created successfully", volume) +} + +// fetchSVMUUID returns the UUID for the named SVM. +// The SVM UUID is required to construct the CIFS share and ACL REST URL paths. +func fetchSVMUUID(ctx context.Context, client *ontapclient.Client, svm string) string { + resp, err := client.Get(ctx, pathSVMs, map[string]string{ + "fields": "name,uuid", + "max_records": "1", + "name": svm, + }) + dieOnErr("fetch svm uuid", err) + records := ontapclient.Records(resp) + if len(records) == 0 { + log.Fatalf("ABORTED — SVM '%s' not found", svm) + } + uuid := ontapclient.NestedStr(records[0], "uuid") + log.Printf("SVM '%s' | uuid=%s", svm, uuid) + return uuid +} + +// ensureCIFSShare creates the CIFS share if it does not already exist. +// A GET to the specific share URL is used for the existence check; a 404 response +// means the share is absent and creation proceeds normally. +func ensureCIFSShare(ctx context.Context, client *ontapclient.Client, + svmUUID, shareName, volume, svm, comment string) { + + sharePath := fmt.Sprintf("%s/%s/%s", pathCIFSShares, svmUUID, shareName) + _, err := client.Get(ctx, sharePath, map[string]string{"fields": "name"}) + if err != nil { + var apiErr *ontapclient.OntapApiError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 { + dieOnErr("check cifs share", err) + } + // 404 — share does not exist; proceed to create. + log.Printf("Creating CIFS share '%s' on path '/%s'…", shareName, volume) + _, err = client.Post(ctx, pathCIFSShares, nil, map[string]interface{}{ + "name": shareName, + "path": fmt.Sprintf("/%s", volume), + "svm": map[string]string{"name": svm}, + "comment": comment, + }) + dieOnErr("create cifs share", err) + log.Printf("CIFS share '%s' created successfully", shareName) + return + } + log.Printf("CIFS share '%s' already exists — skipping create", shareName) +} + +// setShareACL PATCHes the share ACL entry for the given user with the specified permission. +func setShareACL(ctx context.Context, client *ontapclient.Client, + svmUUID, shareName, user, permission string) { + + aclPath := fmt.Sprintf("%s/%s/%s/acls/%s/windows", pathCIFSShares, svmUUID, shareName, user) + log.Printf("Setting ACL: %s → %s on share '%s'…", user, permission, shareName) + _, err := client.Patch(ctx, aclPath, nil, map[string]interface{}{ + "permission": permission, + }) + dieOnErr("set share acl", err) + log.Printf("ACL set: %s → %s", user, permission) +} + +// verifyShare GETs the share and logs every ACL entry for operator confirmation. +func verifyShare(ctx context.Context, client *ontapclient.Client, svmUUID, shareName string) { + sharePath := fmt.Sprintf("%s/%s/%s", pathCIFSShares, svmUUID, shareName) + resp, err := client.Get(ctx, sharePath, map[string]string{"fields": "name,path,acls"}) + dieOnErr("verify share", err) + + log.Printf("SHARE | name=%s | path=%s", + ontapclient.NestedStr(resp, "name"), + ontapclient.NestedStr(resp, "path")) + + acls, _ := resp["acls"].([]interface{}) + for _, a := range acls { + acl, _ := a.(map[string]interface{}) + log.Printf(" ACL | user=%s | type=%s | permission=%s", + ontapclient.NestedStr(acl, "user_or_group"), + ontapclient.NestedStr(acl, "type"), + ontapclient.NestedStr(acl, "permission")) + } +} + +func mustEnv(key string) string { return ontapclient.MustEnv(key) } +func envOrDefault(k, def string) string { return ontapclient.EnvOrDefault(k, def) } +func dieOnErr(op string, err error) { ontapclient.DieOnErr(op, err) } +func loadDotEnv() { ontapclient.LoadDotEnv() } diff --git a/go/nfs_provision/main.go b/go/nfs_provision/main.go new file mode 100644 index 0000000..150f3ae --- /dev/null +++ b/go/nfs_provision/main.go @@ -0,0 +1,248 @@ +// © 2026 NetApp, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// See the NOTICE file in the repo root for trademark and attribution details. + +// NFS Provision — create a FlexVol volume with a dedicated NFS export policy. +// +// Steps: +// +// 0 preflight — verify cluster connectivity; log cluster name + ONTAP version +// 1 ensureVolume — create FlexVol if it does not exist; poll creation job +// 2 ensureExportPolicy — create NFS export policy if it does not exist; fetch its ID +// 3 ensureClientRule — add a client-match rule to the policy (skip if already present) +// 4 assignPolicy — PATCH the volume to assign the export policy; poll job if async +// 5 summary — log mount path, export policy name, and client-match subnet +// +// The script is idempotent: re-running with the same parameters skips any step +// that is already complete. +// +// Prerequisites: +// 1. Go 1.22+ installed; run `cd go && go mod download` once to cache deps +// 2. ONTAP 9.8+ cluster with NFS licence enabled on the target SVM +// 3. Target SVM (SVM_NAME) already exists and has NFS configured +// 4. Target aggregate (AGGR_NAME) already online +// +// Usage: +// +// export ONTAP_HOST=10.x.x.x ONTAP_USER=admin ONTAP_PASS=secret +// export SVM_NAME=vs0 +// export VOLUME_NAME=vol_nfs_test_01 +// export VOLUME_SIZE=100MB +// export AGGR_NAME=aggr1 +// export CLIENT_MATCH=10.0.0.0/24 +// go run . +package main + +import ( + "context" + "fmt" + "log" + "time" + + ontapclient "github.com/netapp/pace/go/ontapclient" +) + +// pathExportPolicies is the ONTAP REST path for NFS export-policy operations. +const pathExportPolicies = "/protocols/nfs/export-policies" + +func main() { + log.SetFlags(log.LstdFlags) + loadDotEnv() + ctx := context.Background() + + svmName := mustEnv("SVM_NAME") + volumeName := mustEnv("VOLUME_NAME") + volumeSize := envOrDefault("VOLUME_SIZE", "100MB") + aggrName := mustEnv("AGGR_NAME") + clientMatch := envOrDefault("CLIENT_MATCH", "0.0.0.0/0") + + policyName := volumeName + "_export_policy" + + client, err := ontapclient.FromEnv() + dieOnErr("init client", err) + defer client.Close() + + log.Printf("NFS provision starting — SVM=%s | volume=%s | size=%s | aggr=%s", + svmName, volumeName, volumeSize, aggrName) + + log.Println("=== Step 0: Verify cluster connectivity ===") + cluster, err := client.Get(ctx, "/cluster", map[string]string{"fields": "name,version"}) + dieOnErr("get cluster", err) + log.Printf("CLUSTER | name=%s | ontap=%s", + ontapclient.NestedStr(cluster, "name"), + ontapclient.NestedStr(cluster, "version", "full")) + + log.Println("=== Step 1: Ensure volume ===") + volumeUUID := ensureVolume(ctx, client, svmName, volumeName, volumeSize, aggrName) + + log.Println("=== Step 2: Ensure export policy ===") + policyID := ensureExportPolicy(ctx, client, svmName, policyName) + + log.Println("=== Step 3: Ensure client rule ===") + ensureClientRule(ctx, client, policyID, clientMatch) + + log.Println("=== Step 4: Assign export policy to volume ===") + assignPolicy(ctx, client, volumeUUID, policyName) + + log.Printf("=== NFS VOLUME PROVISIONED ===\n"+ + " SVM : %s\n"+ + " Volume : %s\n"+ + " Mount path : /%s\n"+ + " Export policy: %s\n"+ + " Client match : %s", + svmName, volumeName, volumeName, policyName, clientMatch) +} + +// ensureVolume creates a FlexVol if it does not exist. +// The volume is mounted at / so NFS clients can access it immediately. +// Returns the volume UUID, which is required by assignPolicy. +func ensureVolume(ctx context.Context, client *ontapclient.Client, svm, volume, size, aggr string) string { + checkResp, err := client.Get(ctx, ontapclient.PathStorageVolumes, map[string]string{ + "fields": "name,uuid", + "max_records": "1", + "name": volume, + ontapclient.KeySVMName: svm, + }) + dieOnErr("check volume", err) + + if ontapclient.NumRecords(checkResp) == 0 { + log.Printf("Creating volume '%s' (%s) on SVM '%s' aggregate '%s'…", volume, size, svm, aggr) + createResp, err := client.Post(ctx, ontapclient.PathStorageVolumes, nil, map[string]interface{}{ + "name": volume, + "svm": map[string]string{"name": svm}, + "aggregates": []map[string]string{ + {"name": aggr}, + }, + "size": size, + "nas": map[string]string{ + "path": fmt.Sprintf("/%s", volume), + }, + }) + dieOnErr("create volume", err) + + if jobUUID := ontapclient.NestedStr(createResp, "job", "uuid"); jobUUID != "" { + log.Printf(" volume creation job: %s", jobUUID) + if _, err := client.PollJob(ctx, jobUUID, 10*time.Second); err != nil { + log.Fatalf("volume creation job failed: %v", err) + } + } + log.Printf("Volume '%s' created successfully", volume) + } else { + log.Printf("Volume '%s' already exists — skipping create", volume) + } + + // Always fetch the UUID so the return value is valid whether we just created it or not. + volResp, err := client.Get(ctx, ontapclient.PathStorageVolumes, map[string]string{ + "fields": "name,uuid", + "max_records": "1", + "name": volume, + ontapclient.KeySVMName: svm, + }) + dieOnErr("fetch volume uuid", err) + records := ontapclient.Records(volResp) + if len(records) == 0 { + log.Fatalf("ABORTED — volume '%s' not found on SVM '%s' after creation", volume, svm) + } + uuid := ontapclient.NestedStr(records[0], "uuid") + log.Printf("volume '%s' | uuid=%s", volume, uuid) + return uuid +} + +// ensureExportPolicy creates an NFS export policy if it does not exist. +// Returns the numeric policy ID, which is required to manage rules under the policy. +func ensureExportPolicy(ctx context.Context, client *ontapclient.Client, svm, policyName string) int64 { + checkResp, err := client.Get(ctx, pathExportPolicies, map[string]string{ + "fields": "name,id", + "max_records": "1", + "name": policyName, + ontapclient.KeySVMName: svm, + }) + dieOnErr("check export policy", err) + + if ontapclient.NumRecords(checkResp) == 0 { + log.Printf("Creating export policy '%s' on SVM '%s'…", policyName, svm) + _, err := client.Post(ctx, pathExportPolicies, nil, map[string]interface{}{ + "name": policyName, + "svm": map[string]string{"name": svm}, + }) + dieOnErr("create export policy", err) + log.Printf("Export policy '%s' created successfully", policyName) + } else { + log.Printf("Export policy '%s' already exists — skipping create", policyName) + } + + // Always fetch the policy ID regardless of whether it was just created or pre-existed. + policyResp, err := client.Get(ctx, pathExportPolicies, map[string]string{ + "fields": "name,id", + "max_records": "1", + "name": policyName, + ontapclient.KeySVMName: svm, + }) + dieOnErr("fetch export policy id", err) + pRecords := ontapclient.Records(policyResp) + if len(pRecords) == 0 { + log.Fatalf("ABORTED — export policy '%s' not found on SVM '%s' after creation", policyName, svm) + } + policyID := int64(ontapclient.NestedFloat(pRecords[0], "id")) + log.Printf("export policy '%s' | id=%d", policyName, policyID) + return policyID +} + +// ensureClientRule adds a client-match rule to the given export policy. +// Skips creation if a rule with an identical client-match entry already exists. +func ensureClientRule(ctx context.Context, client *ontapclient.Client, policyID int64, clientMatch string) { + rulesPath := fmt.Sprintf("%s/%d/rules", pathExportPolicies, policyID) + rulesResp, err := client.Get(ctx, rulesPath, map[string]string{ + "fields": "index,clients", + }) + dieOnErr("list export policy rules", err) + + for _, rule := range ontapclient.Records(rulesResp) { + clients, _ := rule["clients"].([]interface{}) + for _, c := range clients { + cm, _ := c.(map[string]interface{}) + if match, _ := cm["match"].(string); match == clientMatch { + log.Printf("Client rule '%s' already exists in policy — skipping", clientMatch) + return + } + } + } + + log.Printf("Adding client rule '%s' to policy id=%d…", clientMatch, policyID) + _, err = client.Post(ctx, rulesPath, nil, map[string]interface{}{ + "clients": []map[string]string{{"match": clientMatch}}, + "ro_rule": []string{"any"}, + "rw_rule": []string{"any"}, + "superuser": []string{"any"}, + }) + dieOnErr("add client rule", err) + log.Printf("Client rule '%s' added successfully", clientMatch) +} + +// assignPolicy PATCHes the volume to assign the named export policy. +// Polls the async job when ONTAP returns one rather than an immediate 200 OK. +func assignPolicy(ctx context.Context, client *ontapclient.Client, volumeUUID, policyName string) { + patchResp, err := client.Patch(ctx, + fmt.Sprintf("%s/%s", ontapclient.PathStorageVolumes, volumeUUID), + nil, + map[string]interface{}{ + "nas": map[string]interface{}{ + "export_policy": map[string]string{"name": policyName}, + }, + }, + ) + dieOnErr("assign export policy", err) + + if jobUUID := ontapclient.NestedStr(patchResp, "job", "uuid"); jobUUID != "" { + log.Printf(" assign policy job: %s", jobUUID) + if _, err := client.PollJob(ctx, jobUUID, 10*time.Second); err != nil { + log.Fatalf("assign policy job failed: %v", err) + } + } + log.Printf("Export policy '%s' assigned to volume uuid=%s", policyName, volumeUUID) +} + +func mustEnv(key string) string { return ontapclient.MustEnv(key) } +func envOrDefault(k, def string) string { return ontapclient.EnvOrDefault(k, def) } +func dieOnErr(op string, err error) { ontapclient.DieOnErr(op, err) } +func loadDotEnv() { ontapclient.LoadDotEnv() } \ No newline at end of file