Skip to content

Commit 901c900

Browse files
committed
feat: add Go workflow scripts for ONTAP automation
Add Go implementations of the existing Python, Ansible, and Terraform workflows.
1 parent 51b3ccd commit 901c900

8 files changed

Lines changed: 2136 additions & 0 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,7 @@ terraform.tfvars
5151

5252
# POC
5353
poc/
54+
55+
# Go build outputs
56+
go/**/*.exe
57+
go/**/*.test

go/cluster_setup_basic/main.go

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// © 2026 NetApp, Inc. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
// See the NOTICE file in the repo root for trademark and attribution details.
4+
5+
// Cluster Setup — create a storage cluster from two pre-cluster nodes (ONTAP 9 unified).
6+
//
7+
// Steps:
8+
//
9+
// 1 discoverNodes — GET /cluster/nodes (membership=available, retry 3x/30s)
10+
// 2 discoverLocal — isolate the local node (management_interfaces != null)
11+
// 3 discoverPartner — isolate the partner node (exclude local node UUID)
12+
// 4 createCluster — POST /cluster
13+
// 5 trackJob — switch to cluster credentials, poll job until complete
14+
//
15+
// Prerequisites:
16+
// 1. Two ONTAP 9 nodes in pre-cluster state (factory default or freshly wiped)
17+
// 2. Both nodes reachable at their management IPs
18+
// 3. Node 1 (ONTAP_HOST) must have at least one cluster interface already configured
19+
//
20+
// Usage:
21+
//
22+
// export ONTAP_HOST=10.x.x.x ONTAP_USER=admin ONTAP_PASS=
23+
// export CLUSTER_NAME=mycluster CLUSTER_PASS=secret
24+
// export CLUSTER_MGMT_IP=10.x.x.x CLUSTER_NETMASK=255.255.192.0 CLUSTER_GATEWAY=10.x.x.1
25+
// export PARTNER_MGMT_IP=10.x.x.y
26+
// go run .
27+
package main
28+
29+
import (
30+
"fmt"
31+
"log"
32+
"os"
33+
"strings"
34+
"time"
35+
36+
ontapclient "github.com/netapp/pace/go/ontapclient"
37+
)
38+
39+
// ---------------------------------------------------------------------------
40+
41+
const nodeFields = "name,uuid,model,state,ha,version,serial_number,membership," +
42+
"cluster_interfaces,management_interfaces,metrocluster"
43+
44+
const clusterNodesPath = "/cluster/nodes"
45+
46+
func main() {
47+
log.SetFlags(log.LstdFlags)
48+
loadDotEnv()
49+
50+
host := mustEnv("ONTAP_HOST")
51+
user := envOrDefault("ONTAP_USER", "admin")
52+
pass := envOrDefault("ONTAP_PASS", "") // empty on pre-cluster nodes
53+
54+
log.Printf("Cluster setup starting — connecting to %s", host)
55+
56+
client := ontapclient.New(host, user, pass, false)
57+
defer client.Close()
58+
59+
// Step 1: Discover available nodes (retry 3x)
60+
log.Println("=== Step 1: Discover nodes ===")
61+
discoverNodes(client, 3, 30)
62+
63+
// Step 2: Find local node
64+
log.Println("=== Step 2: Discover local node ===")
65+
localNode := discoverLocal(client)
66+
localUUID := ontapclient.NestedStr(localNode, "uuid")
67+
68+
// Step 3: Find partner node
69+
log.Println("=== Step 3: Discover partner node ===")
70+
partnerNode := discoverPartner(client, localUUID)
71+
72+
// Step 4: Create cluster
73+
log.Println("=== Step 4: Create cluster ===")
74+
jobUUID := createCluster(client, localNode, partnerNode)
75+
76+
// Step 5: Track job — switch to cluster credentials first
77+
log.Println("=== Step 5: Track cluster creation job ===")
78+
clusterPass := mustEnv("CLUSTER_PASS")
79+
clusterMgmtIP := mustEnv("CLUSTER_MGMT_IP")
80+
trackJob(host, user, clusterPass, jobUUID)
81+
82+
log.Printf("=== CLUSTER CREATED ===\n"+
83+
" Name : %s\n"+
84+
" UI : https://%s\n"+
85+
" Login : %s / %s",
86+
mustEnv("CLUSTER_NAME"), clusterMgmtIP, user, clusterPass)
87+
}
88+
89+
// discoverNodes GETs /cluster/nodes with membership=available, retrying up to maxAttempts times.
90+
func discoverNodes(client *ontapclient.Client, maxAttempts, delaySecs int) {
91+
var lastErr error
92+
for attempt := 1; attempt <= maxAttempts; attempt++ {
93+
resp, err := client.Get(clusterNodesPath, map[string]string{
94+
"fields": nodeFields,
95+
"membership": "available",
96+
})
97+
if err == nil {
98+
log.Printf("discover_nodes — %d node(s) found", ontapclient.NumRecords(resp))
99+
return
100+
}
101+
lastErr = err
102+
if attempt < maxAttempts {
103+
log.Printf("discover_nodes failed (attempt %d/%d), retrying in %ds — %v",
104+
attempt, maxAttempts, delaySecs, err)
105+
time.Sleep(time.Duration(delaySecs) * time.Second)
106+
}
107+
}
108+
log.Fatalf("discover_nodes failed after %d attempts: %v", maxAttempts, lastErr)
109+
}
110+
111+
// discoverLocal finds the local node (the one with management_interfaces set).
112+
// Returns the first matching node record.
113+
func discoverLocal(client *ontapclient.Client) map[string]interface{} {
114+
resp, err := client.Get(clusterNodesPath, map[string]string{
115+
"fields": nodeFields,
116+
"membership": "available",
117+
"management_interfaces": "!null",
118+
})
119+
dieOnErr("discover_local", err)
120+
nodes := ontapclient.Records(resp)
121+
if len(nodes) == 0 {
122+
log.Fatal("discover_local: no local node returned")
123+
}
124+
log.Printf("discover_local — %s", ontapclient.NestedStr(nodes[0], "name"))
125+
return nodes[0]
126+
}
127+
128+
// discoverPartner finds the partner node by excluding the local node UUID.
129+
// Returns the first matching node record.
130+
func discoverPartner(client *ontapclient.Client, localUUID string) map[string]interface{} {
131+
resp, err := client.Get(clusterNodesPath, map[string]string{
132+
"fields": nodeFields,
133+
"membership": "available",
134+
"uuid": "!" + localUUID,
135+
})
136+
dieOnErr("discover_partner", err)
137+
nodes := ontapclient.Records(resp)
138+
if len(nodes) == 0 {
139+
log.Fatal("discover_partner: no partner node returned")
140+
}
141+
log.Printf("discover_partner — %s", ontapclient.NestedStr(nodes[0], "name"))
142+
return nodes[0]
143+
}
144+
145+
// createCluster POSTs /cluster to create the cluster; returns the job UUID.
146+
func createCluster(client *ontapclient.Client, localNode, partnerNode map[string]interface{}) string {
147+
clusterName := mustEnv("CLUSTER_NAME")
148+
clusterPass := mustEnv("CLUSTER_PASS")
149+
clusterMgmtIP := mustEnv("CLUSTER_MGMT_IP")
150+
clusterNetmask := mustEnv("CLUSTER_NETMASK")
151+
clusterGateway := mustEnv("CLUSTER_GATEWAY")
152+
ontapHost := mustEnv("ONTAP_HOST")
153+
partnerMgmtIP := mustEnv("PARTNER_MGMT_IP")
154+
155+
localClusterIP := clusterIfaceIP(localNode)
156+
partnerClusterIP := clusterIfaceIP(partnerNode)
157+
158+
if localClusterIP == "" {
159+
log.Fatal("ABORTED — local node has no cluster interface IP")
160+
}
161+
if partnerClusterIP == "" {
162+
log.Fatal("ABORTED — partner node has no cluster interface IP")
163+
}
164+
165+
body := map[string]interface{}{
166+
"name": clusterName,
167+
"password": clusterPass,
168+
"management_interface": map[string]interface{}{
169+
"ip": map[string]string{
170+
"address": clusterMgmtIP,
171+
"netmask": clusterNetmask,
172+
"gateway": clusterGateway,
173+
},
174+
},
175+
"nodes": []map[string]interface{}{
176+
{
177+
"name": fmt.Sprintf("%s-01", clusterName),
178+
"management_interface": map[string]interface{}{
179+
"ip": map[string]string{"address": ontapHost},
180+
},
181+
"cluster_interface": map[string]interface{}{
182+
"ip": map[string]string{"address": localClusterIP},
183+
},
184+
},
185+
{
186+
"name": fmt.Sprintf("%s-02", clusterName),
187+
"management_interface": map[string]interface{}{
188+
"ip": map[string]string{"address": partnerMgmtIP},
189+
},
190+
"cluster_interface": map[string]interface{}{
191+
"ip": map[string]string{"address": partnerClusterIP},
192+
},
193+
},
194+
},
195+
"name_servers": map[string]interface{}{},
196+
"ntp_servers": map[string]interface{}{},
197+
"dns_domains": map[string]interface{}{},
198+
"configuration_backup": map[string]interface{}{},
199+
}
200+
201+
resp, err := client.Post("/cluster?keep_precluster_config=true", body)
202+
dieOnErr("create_cluster", err)
203+
204+
jobUUID := ontapclient.JobUUID(resp)
205+
log.Printf("create_cluster — job %s", jobUUID)
206+
return jobUUID
207+
}
208+
209+
// trackJob switches to cluster credentials then polls the job until complete.
210+
// After POST /cluster the node switches to full cluster mode and requires CLUSTER_PASS.
211+
func trackJob(host, user, clusterPass, jobUUID string) {
212+
clusterClient := ontapclient.New(host, user, clusterPass, false)
213+
defer clusterClient.Close()
214+
215+
if _, err := clusterClient.PollJob(jobUUID, 10); err != nil {
216+
log.Fatalf("track_job: %v", err)
217+
}
218+
}
219+
220+
// clusterIfaceIP extracts the IP address of the first cluster interface from a node record.
221+
func clusterIfaceIP(node map[string]interface{}) string {
222+
ifaces, _ := node["cluster_interfaces"].([]interface{})
223+
if len(ifaces) == 0 {
224+
return ""
225+
}
226+
iface, _ := ifaces[0].(map[string]interface{})
227+
return ontapclient.NestedStr(iface, "ip", "address")
228+
}
229+
230+
func mustEnv(key string) string {
231+
if v := os.Getenv(key); v != "" {
232+
return v
233+
}
234+
log.Fatalf("'%s' is required — set it in go/.env or as an environment variable", key)
235+
return ""
236+
}
237+
238+
func envOrDefault(key, defaultVal string) string {
239+
if v := os.Getenv(key); v != "" {
240+
return v
241+
}
242+
return defaultVal
243+
}
244+
245+
func dieOnErr(context string, err error) {
246+
if err != nil {
247+
log.Fatalf("%s: %v", context, err)
248+
}
249+
}
250+
251+
// loadDotEnv reads a .env file from the current directory and exports each
252+
// KEY=VALUE pair as an environment variable (only if not already set).
253+
// The file is gitignored — safe to store credentials there for local testing.
254+
func loadDotEnv() {
255+
data, err := os.ReadFile(".env")
256+
if err != nil {
257+
return
258+
}
259+
for _, line := range strings.Split(string(data), "\n") {
260+
line = strings.TrimSpace(line)
261+
if line == "" || strings.HasPrefix(line, "#") {
262+
continue
263+
}
264+
k, v, ok := strings.Cut(line, "=")
265+
if !ok {
266+
continue
267+
}
268+
if os.Getenv(strings.TrimSpace(k)) == "" {
269+
_ = os.Setenv(strings.TrimSpace(k), strings.TrimSpace(v))
270+
}
271+
}
272+
}

go/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/netapp/pace/go
2+
3+
go 1.22

0 commit comments

Comments
 (0)