3
\$\begingroup\$

This is my first Go project. I've written a CLI app to provision a database cluster on docker containers. Since it's a distributed database, you can configure per-node services. For example: to configure a 3-node cluster (version 7.1.4) with kv on node1, query/index on node2, and analytics on node3, run the following:

 ./cluster -v 7.1.4 -s n0:kv,n1:n1ql+index,n2:cbas

// main.go
package main
import (
 "bytes"
 "fmt"
 "log"
 "net/http"
 "os"
 "os/exec"
 "regexp"
 "strings"
 flag "github.com/spf13/pflag"
)
func main() {
 log.SetFlags(0)
 _, err: = exec.LookPath("docker")
 if err != nil {
 log.Fatal("Please install docker. Go to https://docs.docker.com/engine/install/")
 }
 makeDockerNetwork()
 // TODO: support other linux OS besides ubuntu 20 for internal build
 // TODO: support arm arch
 version: = flag.StringP("version", "v", "", "CBS version")
 noinit: = flag.Bool("noinit", false, "Do not initialize node")
 services: = flag.StringP("services", "s", "", "Per node services")
 indexStorage: = flag.StringP("index", "i", "plasma", "Index storage mode - plasma, memory_optimized")
 username: = flag.StringP("username", "u", "admin", "Administrator username")
 password: = flag.StringP("password", "p", "password", "Administrator password")
 sampleBucket: = flag.StringP("bucket", "b", "", "Load sample bucket")
 flag.Parse()
 allNodeServices: = [] string {}
 if *version == "" {
 log.Fatal("Please provide CBS version")
 } else {
 re: = regexp.MustCompile(`^(\d\.\d\.\d)-(\d{4,5})$`)
 if re.MatchString(*version) {
 matches: = re.FindStringSubmatch(*version)
 buildImage(matches[1], matches[2])
 }
 }
 if !*noinit {
 if *services == "" {
 log.Fatal("Please specify per node services")
 } else {
 nodeServiceTuples: = strings.Split(*services, ",")
 for _, t: = range nodeServiceTuples {
 services: = strings.Replace(strings.Split(t, ":")[1], "+", ",", -1)
 allNodeServices = append(allNodeServices, services)
 }
 }
 }
 port: = getNextPort()
 name: = randomName()
 makeNode(port, name + "0", *version)
 waitTillNodeIsUp(port)
 if *noinit {
 os.Exit(0)
 }
 initFirstNode(port, allNodeServices[0], *username, *password, *indexStorage)
 // Provision other nodes
 if len(allNodeServices) > 1 {
 IP: = getIP(name + "0")
 allNodeStrings: = fmt.Sprintf("ns_1@%s,", IP)
 for nodePort, i := port + 1, 1; i < len(allNodeServices); i, nodePort = i + 1, nodePort + 1 {
 nodeServices: = allNodeServices[i]
 nodeString: = addNode(port, nodePort, fmt.Sprintf("%s%d", name, i), *version, nodeServices, *username, *password)
 allNodeStrings += nodeString
 }
 rebalance(port, allNodeStrings, *username, *password)
 }
 if *sampleBucket != "" {
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/sampleBuckets/install", port),
 bytes.NewBufferString(fmt.Sprintf("[\"%s\"]", *sampleBucket)),
 Options {
 user: *username,
 pass: *password,
 errorMessage: "install sample bucket failed",
 status: http.StatusAccepted,
 },
 )
 }
 log.Printf("First node of cluster - %s - is ready on port %d\n", name + "0", port)
}
// cluster.go
package main
import (
 "bytes"
 "encoding/json"
 "fmt"
 "io"
 "log"
 "net/http"
 "net/url"
 "os/exec"
 "strings"
 "time"
)
const (
 MAX_TRIES = 120
)
type Options struct {
 user, pass, errorMessage string
 status int
}
func httpDo(method, uri string, data *bytes.Buffer, opt Options)[] byte {
 // cleanup before panic
 defer func() {
 if p: = recover();
 p != nil {
 cleanupContainers()
 panic(p)
 }
 }()
 client: = & http.Client {}
 req, err: = http.NewRequest(method, uri, data)
 if err != nil {
 cleanupContainers()
 log.Fatalf("%s\nNewRequest failed for %+v: %v", opt.errorMessage, req, err)
 }
 req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 if opt.user != "" && opt.pass != "" {
 req.SetBasicAuth(opt.user, opt.pass)
 }
 req.Close = true
 resp, err: = client.Do(req)
 if err != nil {
 cleanupContainers()
 log.Fatalf("%s\nsend request failed %+v: %v", opt.errorMessage, req, err)
 }
 defer resp.Body.Close()
 respBody, err: = io.ReadAll(resp.Body)
 if err != nil {
 cleanupContainers()
 log.Fatalf("%s\ncould not read resp body for request %+v: %v", opt.errorMessage, req, err)
 }
 if resp.StatusCode != opt.status {
 rb, err: = req.GetBody()
 if err != nil {
 cleanupContainers()
 log.Fatalf("%s\ncould not get req body for request %+v: %v", opt.errorMessage, req, err)
 }
 reqBody, err: = io.ReadAll(rb)
 if err != nil {
 cleanupContainers()
 log.Fatalf("%s\ncould not read req body for request %+v: %v", opt.errorMessage, req, err)
 }
 cleanupContainers()
 log.Fatalf("%s\n%+v\nreqBody: %s\n%+v\nrespBody: %s", opt.errorMessage, req, string(reqBody), resp, string(respBody))
 }
 return respBody
}
func makeNode(port int, containerName string, version string) {
 cmd: = exec.Command(
 "docker", "run", "--rm", "-d",
 "-p", fmt.Sprintf("%d:8091", port),
 "-p", "8092-8096",
 "-p", "11207",
 "-p", "11210",
 "-p", "18091-18096",
 "--name", containerName,
 "--privileged",
 "--network=cb-net",
 fmt.Sprintf("couchbase/server:%s", version),
 )
 if err: = cmd.Run();err != nil {
 log.Fatalf("could not create node: %v", err)
 }
 runningContainers = append(runningContainers, containerName)
}
func waitTillNodeIsUp(port int) {
 url: = fmt.Sprintf("http://localhost:%d", port)
 for tries: = 1;tries <= MAX_TRIES;tries++{
 resp, err: = http.Get(url)
 if resp != nil && err != nil {
 log.Printf("HTTP GET for waitTillNodeIsUp failed: %v", err)
 time.Sleep(1 * time.Second)
 continue
 }
 if resp != nil && resp.StatusCode == http.StatusOK {
 return
 }
 time.Sleep(1 * time.Second)
 }
 cleanupContainers()
 log.Fatal("node is not up in 2 min")
}
func initFirstNode(port int, services, username, password, storageMode string) {
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/settings/indexes", port),
 bytes.NewBufferString(url.Values {
 "storageMode": {
 storageMode
 }
 }.Encode()),
 Options {
 errorMessage: "Index configuration failed",
 status: http.StatusOK,
 },
 )
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/node/controller/setupServices", port),
 bytes.NewBufferString(url.Values {
 "services": {
 services
 }
 }.Encode()),
 Options {
 errorMessage: "Setting up services failed",
 status: http.StatusOK,
 },
 )
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/settings/web", port),
 bytes.NewBufferString(url.Values {
 "username": {
 username
 },
 "password": {
 password
 },
 "port": {
 "8091"
 }
 }.Encode()),
 Options {
 errorMessage: "Setting up username and password failed",
 status: http.StatusOK,
 },
 )
 // Set memory quotas
 // n1ql and backup don't have quotas
 form: = url.Values {}
 form.Add("memoryQuota", "256")
 form.Add("indexMemoryQuota", "256")
 form.Add("cbasMemoryQuota", "1024")
 form.Add("eventingMemoryQuota", "256")
 form.Add("ftsMemoryQuota", "256")
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/pools/default", port),
 bytes.NewBufferString(form.Encode()),
 Options {
 user: username,
 pass: password,
 errorMessage: "Setting up memory quotas failed",
 status: http.StatusOK,
 },
 )
}
func addNode(port, nodePort int, hostname, version, services, username, password string) string {
 makeNode(nodePort, hostname, version)
 waitTillNodeIsUp(nodePort)
 IP: = getIP(hostname)
 form: = url.Values {}
 form.Add("hostname", IP)
 form.Add("user", username)
 form.Add("password", password)
 form.Add("services", services)
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/controller/addNode", port),
 bytes.NewBufferString(form.Encode()),
 Options {
 user: username,
 pass: password,
 errorMessage: "Add node failed",
 status: http.StatusOK,
 },
 )
 return fmt.Sprintf("ns_1@%s,", IP)
}
func rebalance(port int, knownNodes, username, password string) {
 nodes: = url.QueryEscape(strings.TrimSuffix(knownNodes, ","))
 form: = url.Values {}
 form.Add("knownNodes", nodes)
 httpDo(http.MethodPost,
 fmt.Sprintf("http://localhost:%d/controller/rebalance", port),
 bytes.NewBufferString(strings.ReplaceAll(form.Encode(), "%25", "%")),
 Options {
 user: username,
 pass: password,
 errorMessage: "Rebalance failed",
 status: http.StatusOK,
 },
 )
 for tries: = 1;tries <= MAX_TRIES;tries++{
 body: = httpDo(http.MethodGet,
 fmt.Sprintf("http://localhost:%d/pools/default/rebalanceProgress", port),
 bytes.NewBufferString(""),
 Options {
 user: username,
 pass: password,
 errorMessage: "Could not get rebalance progress",
 status: http.StatusOK,
 },
 )
 progress: = struct {
 Status string `json:"status"`
 } {}
 if err: = json.NewDecoder(strings.NewReader(string(body))).Decode( & progress);err != nil {
 cleanupContainers()
 log.Fatalf("Could not decode rebalance progress: %v", err)
 }
 if progress.Status == "none" {
 return
 }
 time.Sleep(1 * time.Second)
 }
 cleanupContainers()
 log.Fatal("Could not rebalance in 2 min")
}
// misc.go
package main
import (
 "fmt"
 "log"
 "math/rand"
 "os/exec"
 "regexp"
 "strconv"
 "strings"
 "time"
)
var (
 runningContainers = [] string {}
)
func randomName() string {
 rand.Seed(time.Now().UnixNano())
 return words[rand.Intn(len(words))]
}
func getNextPort() int {
 out, err: = exec.Command("docker",
 "container",
 "ls",
 "--format",
 "{{.Ports}}",
 "--filter",
 "status=running").Output()
 if err != nil {
 cleanupContainers()
 log.Fatalf("could not list running containers: %v", err)
 }
 lastPort: = 9000
 re: = regexp.MustCompile(`:9\d{3}`)
 for _, line: = range strings.Split(string(out), "\n") {
 matches: = re.FindStringSubmatch(line)
 if len(matches) > 0 {
 port, err: = strconv.Atoi(matches[0][1: ])
 if err != nil {
 continue // silently ignore error
 }
 if port > lastPort {
 lastPort = port
 }
 }
 }
 return lastPort + 1
}
func getIP(containerID string) string {
 out, err: = exec.Command("docker",
 "inspect",
 "-f",
 "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", containerID).Output()
 if err != nil {
 cleanupContainers()
 log.Fatalf("could not get IP of container %s: %v", containerID, err)
 }
 return fmt.Sprint(strings.TrimSpace(string(out)))
}
// if network cb-net does not exist, create it
func makeDockerNetwork() {
 cmd: = exec.Command("docker", "network", "inspect", "cb-net")
 if err: = cmd.Run();err != nil {
 cmd: = exec.Command("docker", "network", "create", "cb-net")
 if err: = cmd.Run();err != nil {
 log.Fatalf("could not create docker network cb-net: %v", err)
 }
 log.Println("created docker network cb-net")
 }
}
func cleanupContainers() {
 containers: = strings.Join(runningContainers[: ], " ")
 cmd: = exec.Command(
 "docker",
 "stop",
 containers,
 )
 if err: = cmd.Run();err != nil {
 log.Fatalf("could not remove containers %s:\n%v", containers, err)
 }
 }
// internal_build.go
package main
import (
 "fmt"
 "io"
 "log"
 "os"
 "os/exec"
 "strings"
 "text/template"
)
func buildImage(version, build string) {
 output, err: = exec.Command("docker", "image", "ls").Output()
 if err != nil {
 log.Fatalf("could not list docker images: %s", err)
 }
 tempFile, err: = os.CreateTemp("/tmp/", "docker.file.*")
 if err != nil {
 log.Fatalf("could not create temp file: %s", err)
 }
 defer os.Remove(tempFile.Name())
 if !strings.Contains(string(output), "couchbase/server") || !strings.Contains(string(output), fmt.Sprintf("%s-%s", version, build)) {
 writeDockerfile(version, build, tempFile)
 cmd: = exec.Command("docker", "build", ".", "-f", tempFile.Name(), "-t", fmt.Sprintf("couchbase/server:%s-%s", version, build))
 stderr, err: = cmd.StderrPipe()
 if err != nil {
 log.Fatalf("could not build docker image: %s", err)
 }
 if err: = cmd.Start();
 err != nil {
 log.Fatalf("could not build docker image: %s", err)
 }
 errMessage, err: = io.ReadAll(stderr)
 if err != nil {
 log.Fatalf("could not build docker image: %s", err)
 }
 log.Println(string(errMessage))
 if err: = cmd.Wait();
 err != nil {
 log.Fatalf("could not build docker image: %s", err)
 }
 }
}
func writeDockerfile(version, buildNum string, file * os.File) {
 vars: = struct {
 Package string
 URL string
 } {
 Package: fmt.Sprintf("couchbase-server-enterprise_%s-%s-ubuntu20.04_amd64.deb", version, buildNum),
 URL: fmt.Sprintf("http://builds.com/%s/%s", version, buildNum),
 }
 const dockerFile = `
 FROM ubuntu:20.04
 
 ENV PATH=$PATH:/opt/couchbase/bin:/opt/couchbase/bin/tools:/opt/couchbase/bin/install
 
 RUN set -x && \
 apt-get update && \
 apt-get install -yq runit wget chrpath tzdata \
 lsof lshw sysstat net-tools numactl bzip2 libtinfo5 && \
 apt-get autoremove && apt-get clean && \
 rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
 
 RUN if [ ! -x /usr/sbin/runsvdir-start ]; then \
 cp -a /etc/runit/2 /usr/sbin/runsvdir-start; \
 fi
 
 RUN groupadd -g 1000 couchbase && useradd couchbase -u 1000 -g couchbase -M
 
 RUN set -x && \
 export INSTALL_DONT_START_SERVER=1 && \
 wget {{.URL}}/{{.Package}} && \
 apt install -y ./{{.Package}} && \
 rm -f ./{{.Package}} && \
 apt-get autoremove && apt-get clean && \
 rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
 
 RUN mkdir -p /etc/service/couchbase-server/
 
 RUN printf "#!/bin/sh \n\
 exec 2>&1 \n\
 cd /opt/couchbase \n\
 mkdir -p var/lib/couchbase \n\
 var/lib/couchbase/config \n\
 var/lib/couchbase/data \n\
 var/lib/couchbase/stats \n\
 var/lib/couchbase/logs \n\
 var/lib/moxi \n\
 \n\
 chown -R couchbase:couchbase var \n\
 if [ \"\$(whoami)\" = \"couchbase\" ]; then \n\
 exec /opt/couchbase/bin/couchbase-server -- -kernel global_enable_tracing false -noinput \n\
 else \n\
 exec chpst -ucouchbase /opt/couchbase/bin/couchbase-server -- -kernel global_enable_tracing false -noinput \n\
 fi" > /etc/service/couchbase-server/run
 
 RUN chmod a+x /etc/service/couchbase-server/run
 RUN chown -R couchbase:couchbase /etc/service
 
 RUN mkdir -p /etc/runit/runsvdir/default/couchbase-server/supervise \
 && chown -R couchbase:couchbase \
 /etc/service \
 /etc/runit/runsvdir/default/couchbase-server/supervise
 
 RUN printf "#!/bin/sh \
 echo \"Running in Docker container - 0ドル not available\"" > /usr/local/bin/dummy.sh
 
 RUN chmod a+x /usr/local/bin/dummy.sh
 
 RUN ln -s dummy.sh /usr/local/bin/iptables-save && \
 ln -s dummy.sh /usr/local/bin/lvdisplay && \
 ln -s dummy.sh /usr/local/bin/vgdisplay && \
 ln -s dummy.sh /usr/local/bin/pvdisplay 
 
 RUN printf "#!/bin/bash \n\
 set -e \n\
 \
 staticConfigFile=/opt/couchbase/etc/couchbase/static_config \n\
 restPortValue=8091 \n\
 function overridePort() { \n\
 portName=\1ドル \n\
portNameUpper=\$(echo \$portName | awk '{print toupper(\0ドル)}') \n\
portValue=\${!portNameUpper} \n\
 if [ \"\$portValue\" != \"\" ]; then \n\
if grep -Fq \"{\${portName},\" \${staticConfigFile} \n\
then \n\
echo \"Don't override port \${portName} because already available in \$staticConfigFile\" \n\
else \
echo \"Override port '\$portName' with value '\$portValue'\" \n\
echo \"{\$portName, \$portValue}.\" >> \${staticConfigFile} \n\
\
if [ \${portName} == \"rest_port\" ]; then \n\
restPortValue=\${portValue} \n\
fi \n\
fi \n\
fi \n\
} \n\
\n\
overridePort \"rest_port\" \n\
overridePort \"mccouch_port\" \n\
overridePort \"memcached_port\" \n\
overridePort \"query_port\" \n\
overridePort \"ssl_query_port\" \n\
overridePort \"fts_http_port\" \n\
overridePort \"moxi_port\" \n\
overridePort \"ssl_rest_port\" \n\
overridePort \"ssl_capi_port\" \n\
overridePort \"ssl_proxy_downstream_port\" \n\
overridePort \"ssl_proxy_upstream_port\" \n\
\n\
[[ \"\$1\" == \"couchbase-server\" ]] && { \n\
 \n\
 if [ \"\$(whoami)\" = \"couchbase\" ]; then \n\
\n\
if [ ! -w /opt/couchbase/var -o \n\
\$(find /opt/couchbase/var -maxdepth 0 -printf '%%u') != \"couchbase\" ]; then \n\
 echo \"/opt/couchbase/var is not owned and writable by UID 1000\" \n\
 echo \"Aborting as Couchbase Server will likely not run\" \n\
 exit 1 \n\
 fi \n\
 fi \n\
 echo \"Starting Couchbase Server -- Web UI available at http://ip:\$restPortValue\" \n\
echo \"and logs available in /opt/couchbase/var/lib/couchbase/logs\" \n\
exec /usr/sbin/runsvdir-start \n\ 
} \n\
\n\
exec \"\$@\" "> /entrypoint.sh
 
 RUN chmod a+x /entrypoint.sh
 ENTRYPOINT ["/entrypoint.sh"]
 CMD ["couchbase-server"]
 `
 tmpl,
 err: = template.New("internal").Parse(dockerFile)
 if err != nil {
 log.Fatalf("could not create template: %s", err)
 }
 err = tmpl.Execute(file, vars)
 if err != nil {
 log.Fatalf("could not execute template: %s", err)
 }
}
 // words.go
 package main
 var words = [...] string {
 "amber",
 "apron",
 "arise",
 "beach",
 "beard",
 "beast",
 "blaze",
 "blend",
 "bless",
 "bloom",
 "bonus",
 "braid",
 "brick",
 "brush",
 "bumpy",
 "bunny",
 "chaos",
 "chart",
 "chess",
 "chill",
 "choir",
 "clerk",
 "coast",
 "comet",
 "coral",
 "crate",
 "crisp",
 "ditch",
 "drown",
 "equip",
 "erase",
 "evoke",
 "feast",
 "fever",
 "flame",
 "flute",
 "forge",
 "glide",
 "globe",
 "grape",
 "jeans",
 "juice",
 "lemon",
 "lodge",
 "logic",
 "magic",
 "march",
 "opera",
 "panda",
 "panic",
 "plump",
 "pride",
 "prize",
 "proof",
 "proud",
 "quota",
 "radar",
 "ridge",
 "rifle",
 "route",
 "sable",
 "salad",
 "scoop",
 "seize",
 "shame",
 "shine",
 "sloth",
 "smile",
 "spade",
 "spark",
 "spike",
 "spoil",
 "sport",
 "spray",
 "stall",
 "straw",
 "swamp",
 "swarm",
 "sweep",
 "swing",
 "thick",
 "thigh",
 "thorn",
 "thump",
 "tiger",
 "toast",
 "torch",
 "towel",
 "trust",
 "virus",
 "vocal",
 "watch",
 "weave",
 "whale",
 "whisk",
 "wrist",
 "yacht",
 }
```
Toby Speight
87.1k14 gold badges104 silver badges322 bronze badges
asked May 31, 2023 at 21:10
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Please post the entire code in the original post. Links are not permissible; they eventually go stale. \$\endgroup\$ Commented Jun 1, 2023 at 0:04
  • \$\begingroup\$ Removed github link and posted the entire code. \$\endgroup\$ Commented Jun 1, 2023 at 3:10

0

Know someone who can answer? Share a link to this question via email, Twitter, or Facebook.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.