[ARVADOS-ORG] created: 00134fc1789aeb6c161e1ea8cd395f60bb52c952

Git user git at public.curoverse.com
Mon May 15 17:38:37 EDT 2017


        at  00134fc1789aeb6c161e1ea8cd395f60bb52c952 (commit)


commit 00134fc1789aeb6c161e1ea8cd395f60bb52c952
Author: Ward Vandewege <ward at jhvc.com>
Date:   Mon May 15 17:38:22 2017 -0400

    Initial commit.

diff --git a/registry/cmd/arv-registry/.gitignore b/registry/cmd/arv-registry/.gitignore
new file mode 100644
index 0000000..6d09ab8
--- /dev/null
+++ b/registry/cmd/arv-registry/.gitignore
@@ -0,0 +1 @@
+arv-registry
diff --git a/registry/cmd/arv-registry/arv-registry.go b/registry/cmd/arv-registry/arv-registry.go
new file mode 100644
index 0000000..f360525
--- /dev/null
+++ b/registry/cmd/arv-registry/arv-registry.go
@@ -0,0 +1,155 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package main
+
+import (
+	"bufio"
+	"flag"
+	"fmt"
+	"git.curoverse.com/arvados-org.git/registry"
+	"os"
+)
+
+var theCluster registry.ClusterInfo
+var clusterID string
+var registryURL string
+var checkFlag bool
+
+func parseFlags() (config registry.ClusterInfo) {
+
+	flags := flag.NewFlagSet("arv-registry", flag.ExitOnError)
+	flags.Usage = func() { usage(flags) }
+
+	flags.BoolVar(&checkFlag, "check", false, "Instead of registering, check if a cluster identifier is available.")
+
+	flags.StringVar(&clusterID,
+		"clusterID",
+		"",
+		"(required) the ClusterID to verify or register. Format: 5 characters, all lowercase. Position 1: a character from the set a-w or 0-9. Positions 2-5: a character from the set a-z or 0-9")
+
+	flags.StringVar(&config.Name,
+		"name",
+		"",
+		"(required) your name")
+
+	flags.StringVar(&config.Email,
+		"email",
+		"",
+		"(required) your e-mail address")
+
+	flags.StringVar(&config.Organization,
+		"organization",
+		"",
+		"(required) your organization")
+
+	flags.StringVar(&config.ClientSecret,
+		"clientSecret",
+		"",
+		"(required) a client secret, minimum 16 characters long")
+
+	flags.StringVar(&registryURL,
+		"registryURL",
+		"https://registry.arvadosapi.com",
+		"(optional) an alternative Arvados Registry URL")
+
+	// Parse args; omit the first arg which is the command name
+	err := flags.Parse(os.Args[1:])
+	if err != nil {
+		flags.Usage()
+		os.Exit(1)
+	}
+	if clusterID == "" {
+		flags.Usage()
+		os.Exit(1)
+	}
+	return
+}
+
+func usage(fs *flag.FlagSet) {
+	fmt.Fprintf(os.Stderr, `
+arv-registry is a cli client to interact with the Arvados Registry Daemon
+
+Arguments:
+`)
+	fs.PrintDefaults()
+	fmt.Println()
+}
+
+func main() {
+	registryURL = "https://registry.arvadosapi.com"
+
+	theCluster := parseFlags()
+
+	err := registry.SanityCheckClusterID(clusterID)
+	if err != nil {
+		fmt.Println("\nError:", err.Error(), "\n")
+		os.Exit(1)
+	}
+
+	result, err := registry.Lookup(registryURL, clusterID, theCluster)
+	if err != nil {
+		fmt.Println("\nError:", err.Error(), "\n")
+		os.Exit(1)
+	}
+
+	if result.Available {
+		if checkFlag != true {
+			scanner := bufio.NewScanner(os.Stdin)
+			if theCluster.Name == "" {
+				fmt.Print("Your name: ")
+				scanner.Scan()
+				theCluster.Name = scanner.Text()
+			}
+			if theCluster.Email == "" {
+				fmt.Print("Your e-mail address: ")
+				scanner.Scan()
+				theCluster.Email = scanner.Text()
+			}
+			if theCluster.Organization == "" {
+				fmt.Print("Your organization: ")
+				scanner.Scan()
+				theCluster.Organization = scanner.Text()
+			}
+			if theCluster.ClientSecret == "" {
+				fmt.Print("A secret string, minimum 16 characters: ")
+				scanner.Scan()
+				theCluster.ClientSecret = scanner.Text()
+			}
+			response, err := registry.Register(registryURL, clusterID, theCluster)
+			if err != nil {
+				fmt.Println("\nError: registration failed:", err.Error(), "\n")
+				os.Exit(1)
+			} else {
+				fmt.Println("\nRegistration successful.\n")
+				fmt.Println("Please record the following information, it will be needed to manage your cluster identifier:\n")
+				fmt.Println("Cluster identifier:  ", response.ClusterID)
+				fmt.Println("Contact name:        ", response.Data["Name"])
+				fmt.Println("Contact e-mail:      ", response.Data["Email"])
+				fmt.Println("Contact organization:", response.Data["Organization"])
+				fmt.Println("Registration Secret: ", theCluster.ClientSecret)
+				fmt.Println()
+			}
+		} else {
+			fmt.Println("\nResult:", clusterID, "is available for registration.\n")
+			os.Exit(0)
+		}
+	} else {
+		if checkFlag != true {
+			fmt.Println("\nError:", clusterID, "is not available for registration.\n")
+			os.Exit(2)
+		} else {
+			fmt.Println("\nResult:", clusterID, "is NOT available for registration.\n")
+			if len(result.Data) != 0 {
+				reply := "Additional Data for " + result.ClusterID + ":"
+				for k, v := range result.Data {
+					reply += "\n  " + k + ": " + v
+				}
+				fmt.Println(reply)
+				fmt.Println()
+			}
+			os.Exit(2)
+		}
+	}
+}
diff --git a/registry/registry.go b/registry/registry.go
new file mode 100644
index 0000000..cfd35b3
--- /dev/null
+++ b/registry/registry.go
@@ -0,0 +1,177 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package registry
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"git.curoverse.com/arvados.git/sdk/go/config"
+	"io"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"os"
+	"regexp"
+	"strings"
+	"time"
+)
+
+// Client-level types and functions
+
+type Result struct {
+	ClusterID            string
+	Available            bool
+	Registered           bool
+	RegisteredOn         *time.Time        `json:",omitempty"` // RegisteredOn is a pointer to make json's omitempty work
+	Data                 map[string]string `json:",omitempty"`
+	RequestAuthenticated bool
+	Elapsed              string
+	Type                 string `json:",omitempty"`
+	Msg                  string `json:",omitempty"`
+}
+
+// ClusterInfo structure
+type ClusterInfo struct {
+	Name         string
+	Email        string
+	Organization string
+	ClientSecret string `json:",omitempty"` // I'd use json:"-" here but that also eats the field when doing the import (Decode)
+}
+
+func Lookup(registryURL string, clusterID string, cluster ClusterInfo) (response Result, err error) {
+	url := registryURL + "/v1/check/" + clusterID
+
+	jsonStr, err := json.Marshal(cluster)
+	if err != nil {
+		err = errors.New("unable to create JSON request object")
+		return
+	}
+
+	req, err := http.NewRequest("GET", url, bytes.NewBuffer(jsonStr))
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		err = errors.New("error contacting Arvados Registry server")
+		return
+	}
+	defer resp.Body.Close()
+
+	//fmt.Println("response Status:", resp.Status)
+	//fmt.Println("response Headers:", resp.Header)
+	body, _ := ioutil.ReadAll(resp.Body)
+	//fmt.Println("response Body:", string(body))
+
+	err = json.Unmarshal([]byte(string(body)), &response)
+	if err != nil {
+		err = errors.New("invalid response from the Arvados Registry server")
+	}
+	return
+}
+
+func Register(registryURL string, clusterID string, cluster ClusterInfo) (response Result, err error) {
+	url := registryURL + "/v1/register/" + clusterID
+
+	jsonStr, err := json.Marshal(cluster)
+	if err != nil {
+		err = errors.New("unable to create JSON request object")
+		return
+	}
+	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		err = errors.New("error contacting Arvados Registry server")
+		return
+	}
+	defer resp.Body.Close()
+
+	//fmt.Println("response Status:", resp.Status)
+	//fmt.Println("response Headers:", resp.Header)
+	body, _ := ioutil.ReadAll(resp.Body)
+	//fmt.Println("response Body:", string(body))
+
+	err = json.Unmarshal([]byte(string(body)), &response)
+	if err != nil {
+		err = errors.New("invalid response from the Arvados Registry server")
+		return
+	}
+
+	if resp.Status != "200 OK" {
+		err = errors.New(response.Msg)
+	}
+
+	return
+}
+
+func SanityCheckClusterID(clusterIDToVerify string) (err error) {
+	// Sanity check the input RequestHash
+	// We do not permit 0-9 as the first character (often not permitted as the first character of an object name)
+	// We do not permit x-z as the first character (reserved for future use)
+	match, err := regexp.MatchString("^([a-w][a-z0-9]{4})$", clusterIDToVerify)
+	if err != nil {
+		return
+	}
+	if !match {
+		err = errors.New("invalid cluster identifier (must be 5 characters, lower case, a-z or 0-9 except for the first character, which must be a-w)")
+	}
+
+	return
+}
+
+// Server-level types and functions
+
+type Report struct {
+	Type string
+	Msg  string
+}
+
+func LogError(m []string) {
+	log.Printf(string(marshal(Report{"Error", strings.Join(m, " ")})))
+}
+
+func LogNotice(m []string) {
+	log.Printf(string(marshal(Report{"Notice", strings.Join(m, " ")})))
+}
+
+func ReadConfig(cfg interface{}, path string, defaultConfigPath string) error {
+	err := config.LoadFile(cfg, path)
+	if err != nil && os.IsNotExist(err) && path == defaultConfigPath {
+		LogNotice([]string{"Config not specified. Aborting."})
+	}
+	return err
+}
+
+func MarshalAndWrite(w io.Writer, message interface{}) {
+	b := marshal(message)
+	if b == nil {
+		errorMessage := "{\n\"Error\": \"Unspecified error\"\n}"
+		_, err := io.WriteString(w, errorMessage)
+		if err != nil {
+			// do not call arvadosClusterRegistryServer.LogError (it calls marshal and that function has already failed at this point)
+			fmt.Fprintln(os.Stderr, "{\"Error\": \"Unable to write message to client\"}")
+		}
+	} else {
+		_, err := w.Write(b)
+		if err != nil {
+			LogError([]string{"Unable to write message to client:", string(b)})
+		}
+	}
+}
+
+func marshal(message interface{}) (encoded []byte) {
+	encoded, err := json.Marshal(message)
+	if err != nil {
+		// do not call logError here because that would create an infinite loop
+		fmt.Fprintln(os.Stderr, "{\"Error\": \"Unable to marshal message into json:", message, "\"}")
+		return nil
+	}
+	return
+}
diff --git a/registry/server/jabber-bot/.gitignore b/registry/server/jabber-bot/.gitignore
new file mode 100644
index 0000000..76d8f29
--- /dev/null
+++ b/registry/server/jabber-bot/.gitignore
@@ -0,0 +1 @@
+jabber-bot
diff --git a/registry/server/jabber-bot/jabber-bot.go b/registry/server/jabber-bot/jabber-bot.go
new file mode 100644
index 0000000..d8ae134
--- /dev/null
+++ b/registry/server/jabber-bot/jabber-bot.go
@@ -0,0 +1,189 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package main
+
+import (
+	"flag"
+	"fmt"
+
+	"git.curoverse.com/arvados-org.git/registry"
+
+	"github.com/gabeguz/gobot/bot"
+	"github.com/gabeguz/gobot/bot/xmpp"
+
+	//  "encoding/json"
+	"os"
+	"regexp"
+	"strings"
+)
+
+// Config structure
+type Config struct {
+	registry.ClusterInfo
+	RegistryURL string
+	XmppHost    string
+	XmppUser    string
+	XmppPass    string
+	XmppRoom    string
+	XmppBotName string
+}
+
+var exampleConfigFile = []byte(`
+Name: "Curoverse Sysadmin"
+Email: "sysadmin at curoverse.com"
+Organization: "Curoverse, Inc."
+ClientSecret: "a-really-good-secret-minimum-16-characters"
+XmppHost: "jabber.your.domain:5222"
+XmppUser: "bot-jabber-username"
+XmppPass: "bot-jabber-password"
+XmppRoom: "#arvbottest at conference.your.domain"
+XmppBotName: "jabber-bot-nickname"
+`)
+
+func usage(fs *flag.FlagSet) {
+	fmt.Fprintf(os.Stderr, `
+Arvados Registry Bot is a Jabber bot that can interact with the Arvados Registry Daemon.
+
+Options:
+`)
+	fs.PrintDefaults()
+	fmt.Fprintf(os.Stderr, `
+Example config file:
+%s
+`, exampleConfigFile)
+}
+
+var theConfig Config
+
+const defaultConfigPath = "/etc/arvados/registry/jabber-bot.yml"
+
+type chatbot struct {
+	bot.Bot
+	plugins []bot.Plugin
+}
+
+func parseFlags() (configPath *string) {
+
+	flags := flag.NewFlagSet("arvados-registry-bot", flag.ExitOnError)
+	flags.Usage = func() { usage(flags) }
+
+	configPath = flags.String(
+		"config",
+		defaultConfigPath,
+		"`path` to YAML configuration file")
+
+	// Parse args; omit the first arg which is the command name
+	err := flags.Parse(os.Args[1:])
+	if err != nil {
+		registry.LogError([]string{"Unable to parse command line arguments:", err.Error()})
+		os.Exit(1)
+	}
+
+	return
+}
+
+func main() {
+	configPath := parseFlags()
+
+	err := registry.ReadConfig(&theConfig, *configPath, defaultConfigPath)
+	if err != nil {
+		registry.LogError([]string{"Unable to start Arvados Registry Bot:", err.Error()})
+		os.Exit(1)
+	}
+
+	if theConfig.RegistryURL == "" {
+		theConfig.RegistryURL = "https://registry.arvadosapi.com"
+	}
+
+	if theConfig.XmppHost == "" {
+		registry.LogError([]string{"Unable to start Arvados Registry Daemon: the configuration file needs to define an XMPP Host"})
+		os.Exit(1)
+	}
+
+	gobot := chatbot{
+		xmpp.New(theConfig.XmppHost, theConfig.XmppUser, theConfig.XmppPass, theConfig.XmppRoom, theConfig.XmppBotName),
+		[]bot.Plugin{},
+	}
+	err = gobot.Connect()
+	if err != nil {
+		registry.LogError([]string{"Unable to start bot:", err.Error()})
+		os.Exit(1)
+	}
+
+	var msg bot.Message
+	for msg = range gobot.Listen() {
+		go execute(msg, gobot)
+	}
+
+}
+
+func execute(msg bot.Message, bot bot.Bot) {
+	if msg.From() == bot.FullName() {
+		return
+	}
+
+	if strings.HasPrefix(msg.Body(), "!a help") {
+		bot.Send("I am the Arvados Registry Bot. I know these commands:")
+		bot.Send("!a help")
+		bot.Send("!a ch(eck) ClusterID")
+		bot.Send("!a reg(ister) ClusterID")
+	}
+
+	if strings.HasPrefix(msg.Body(), "!a ch") {
+		re := regexp.MustCompile(`ch(eck|) ([a-z0-9]{5})$`)
+		matches := re.FindStringSubmatch(msg.Body())
+		if matches != nil {
+			clusterID := re.FindStringSubmatch(msg.Body())[2]
+			response, err := registry.Lookup(theConfig.RegistryURL, clusterID, theConfig.ClusterInfo)
+			if err != nil {
+				bot.Send("I'm sorry, I encountered an internal error trying to look up " + clusterID)
+			} else {
+				var reply string
+				if response.Available {
+					reply = response.ClusterID + " is available for registration"
+				} else if response.Registered {
+					reply = response.ClusterID + " was registered on " + response.RegisteredOn.String()
+				} else {
+					reply = response.ClusterID + " is not available"
+				}
+				bot.Send(reply)
+				if len(response.Data) != 0 {
+					reply = "Additional Data for " + response.ClusterID + ":"
+					for k, v := range response.Data {
+						reply += "\n" + k + ": " + v
+					}
+					bot.Send(reply)
+				}
+				//        responseStr, err := json.Marshal(response)
+				//        if err != nil {
+				//          bot.Send("I'm sorry, I encountered an internal error trying to parse the response from the registration command for " + clusterID)
+				//        } else {
+				//          bot.Send(string(responseStr))
+				//        }
+			}
+		} else {
+			bot.Send("Syntax: ch(eck) <cluster_id>")
+		}
+	}
+
+	if strings.HasPrefix(msg.Body(), "!a reg") {
+		re := regexp.MustCompile(`reg(ister|) ([a-z0-9]{5})$`)
+		matches := re.FindStringSubmatch(msg.Body())
+		if matches != nil {
+			clusterID := re.FindStringSubmatch(msg.Body())[2]
+			response, err := registry.Register(theConfig.RegistryURL, clusterID, theConfig.ClusterInfo)
+
+			if err != nil {
+				bot.Send("Error: " + response.Msg)
+			} else {
+				bot.Send("I have registered " + clusterID + " for " + response.Data["Name"] + " <" + response.Data["Email"] + "> at " + response.Data["Organization"])
+			}
+
+		} else {
+			bot.Send("Syntax: reg(ister) <cluster_id>")
+		}
+	}
+	return
+}
diff --git a/registry/server/registryd/.gitignore b/registry/server/registryd/.gitignore
new file mode 100644
index 0000000..ce4f068
--- /dev/null
+++ b/registry/server/registryd/.gitignore
@@ -0,0 +1 @@
+registryd
diff --git a/registry/server/registryd/arvados-registryd.service b/registry/server/registryd/arvados-registryd.service
new file mode 100644
index 0000000..77f866c
--- /dev/null
+++ b/registry/server/registryd/arvados-registryd.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Arvados Registry Daemon
+Documentation=https://doc.arvados.org/
+After=network.target
+AssertPathExists=/etc/arvados/registry/registryd.yml
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/arvados-registryd
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/registry/server/registryd/registryd.go b/registry/server/registryd/registryd.go
new file mode 100644
index 0000000..e997a89
--- /dev/null
+++ b/registry/server/registryd/registryd.go
@@ -0,0 +1,447 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+
+  "git.curoverse.com/arvados-org.git/registry"
+
+	"net"
+	"net/http"
+	"os"
+	"os/signal"
+	"crypto/hmac"
+	"crypto/sha256"
+	"database/sql"
+	"encoding/base64"
+	_ "github.com/mattn/go-sqlite3"
+	"github.com/rubenv/sql-migrate"
+	"regexp"
+	"strings"
+	"syscall"
+	"time"
+)
+
+var listener net.Listener
+var db *sql.DB
+
+type bundle struct {
+	sourceDir     string
+	name          string
+	packageType   string
+	versionType   string
+	versionPrefix string
+}
+
+type result struct {
+	ClusterID            string
+	Available            bool
+	Registered           bool
+	RegisteredOn         *time.Time        `json:",omitempty"` // RegisteredOn is a pointer to make json's omitempty work
+	Data                 map[string]string `json:",omitempty"`
+	RequestAuthenticated bool
+	Elapsed              string
+}
+
+type about struct {
+	Name    string
+	Version string
+	URL     string
+}
+
+type help struct {
+	Usage string
+}
+
+type registerCluster struct {
+	Name         string
+	Email        string
+	Organization string
+	ClientSecret string `json:",omitempty"` // I'd use json:"-" here but that also eats the field when doing the import (Decode)
+}
+
+// Config structure
+type Config struct {
+	DatabaseAdapter string
+	Database        string
+	ServerSecret    string
+	ListenPort      string
+}
+
+var theConfig Config
+
+const defaultConfigPath = "/etc/arvados/registry/registryd.yml"
+
+func lookup(clusterID string) (available bool, registered bool, registeredOn time.Time, data map[string]string, secret string, err error) {
+	var dataStr string
+	err = db.QueryRow("SELECT registered, registered_on, data, secret FROM clusters WHERE identifier=?", clusterID).Scan(&registered, &registeredOn, &dataStr, &secret)
+
+	switch {
+	case err == sql.ErrNoRows:
+		registry.LogNotice([]string{"No cluster found with ID", clusterID})
+		available = true
+		err = nil
+	case err != nil:
+		registry.LogError([]string{"Unable to query database:", err.Error()})
+	default:
+		err = json.Unmarshal([]byte(dataStr), &data)
+		if err != nil {
+			registry.LogError([]string{"Unable to unmarshal data field", err.Error()})
+		}
+	}
+	return
+}
+
+func computeHmac(payload string) string {
+	mac := hmac.New(sha256.New, []byte(theConfig.ServerSecret))
+	mac.Write([]byte(payload))
+	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
+}
+
+func register(clusterID string, cluster registerCluster, ipAddress string) (err error) {
+	hmac := computeHmac(cluster.ClientSecret)
+
+	cluster.ClientSecret = "" // we don't want this in the db
+	dataStr, err := json.Marshal(cluster)
+	if err != nil {
+		return
+	}
+
+	_, err = db.Exec("INSERT INTO clusters (identifier,data,registered,registered_on,secret,ip_address) values (?,?,?,?,?,?)", clusterID, dataStr, true, time.Now(), hmac, ipAddress)
+	if err == nil {
+		registry.LogNotice([]string{"Registered ClusterID:", clusterID})
+	} else {
+		registry.LogError([]string{"Unable to register ClusterID:", clusterID, err.Error()})
+	}
+	return
+}
+
+func sanityCheckClusterID(clusterIDToVerify string) (clusterID string, errReport registry.Report) {
+	// Sanity check the input RequestHash
+	// We do not permit 0-9 as the first character (often not permitted as the first character of an object name)
+	// We do not permit x-z as the first character (reserved for future use)
+	match, err := regexp.MatchString("^([a-w][a-z0-9]{4})$", clusterIDToVerify)
+	if err != nil {
+		errReport = registry.Report{"Error", "Error matching ClusterID"}
+		return
+	}
+	if !match {
+		errReport = registry.Report{"Error", "Invalid ClusterID (must be 5 characters, lower case, a-z or 0-9 except for the first character, which must be a-w)"}
+	}
+	clusterID = clusterIDToVerify
+
+	return
+}
+
+func sanityCheckClientSecret(clientSecret string) (errReport registry.Report) {
+	// Sanity check the ClientSecret provided
+	match, err := regexp.MatchString("^.{16,}$", clientSecret)
+	if err != nil {
+		errReport = registry.Report{"Error", "Error verifying validity of ClientSecret"}
+		return
+	}
+	if !match {
+		errReport = registry.Report{"Error", "Invalid ClientSecret (min length: 16 characters)"}
+	}
+
+	return
+}
+
+func registerHandler(w http.ResponseWriter, r *http.Request) {
+	start := time.Now()
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+
+	if r.Body == nil {
+		w.WriteHeader(http.StatusBadRequest)
+		m := registry.Report{"Error", "Please send a request body as JSON with these keys: email, organization, clientSecret"}
+		registry.MarshalAndWrite(w, m)
+		return
+	}
+
+	clusterID, errReport := sanityCheckClusterID(r.URL.Path[13:])
+	if errReport != (registry.Report{}) {
+		w.WriteHeader(http.StatusBadRequest)
+		registry.MarshalAndWrite(w, errReport)
+		return
+	}
+
+	var cluster registerCluster
+	err := json.NewDecoder(r.Body).Decode(&cluster)
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		m := registry.Report{"Error", "Unable to decode request body as JSON: " + err.Error()}
+		registry.MarshalAndWrite(w, m)
+		return
+	}
+	errReport = sanityCheckClientSecret(cluster.ClientSecret)
+	if errReport != (registry.Report{}) {
+		w.WriteHeader(http.StatusBadRequest)
+		registry.MarshalAndWrite(w, errReport)
+		return
+	}
+
+  if cluster.Name == "" ||
+     cluster.Email == "" ||
+     cluster.Organization == "" {
+		errReport = registry.Report{"Error", "Missing fields: name, email, organization are required"}
+		w.WriteHeader(http.StatusBadRequest)
+		registry.MarshalAndWrite(w, errReport)
+		return
+  }
+
+	match, err := regexp.MatchString("^.+ at .+\\..+$", cluster.Email)
+	if err != nil {
+		errReport = registry.Report{"Error", "Internal error validating e-mail address format"}
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	if !match {
+		errReport = registry.Report{"Error", "Invalid e-mail address"}
+		w.WriteHeader(http.StatusBadRequest)
+		registry.MarshalAndWrite(w, errReport)
+		return
+  }
+
+	logCluster := cluster
+	logCluster.ClientSecret = "" // So as not to log it
+	encoded, _ := json.Marshal(logCluster)
+	registry.LogNotice([]string{"Received valid JSON request body:", string(encoded)})
+
+	var available, registered bool
+  var registeredOn time.Time
+	var data map[string]string
+
+	available, registered, registeredOn, data, _, err = lookup(clusterID)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		m := registry.Report{"Error", "Unspecified error"}
+		registry.MarshalAndWrite(w, m)
+		return
+	}
+
+	if available {
+    // Cf. https://en.wikipedia.org/wiki/X-Forwarded-For#Format
+    ipAddress := strings.Split(r.Header.Get("X-Forwarded-For"), ", ")[0]
+    if ipAddress == "" {
+      ipAddress, _, err = net.SplitHostPort(r.RemoteAddr)
+      if err != nil {
+        fmt.Fprintf(w, "userip: %q is not IP:port", r.RemoteAddr)
+      }
+    }
+		// register it
+		err := register(clusterID, cluster, ipAddress)
+		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+			m := registry.Report{"Error", "Unable to register ClusterID " + clusterID}
+			registry.MarshalAndWrite(w, m)
+			return
+		}
+		available, registered, registeredOn, data, _, err = lookup(clusterID)
+		if err != nil {
+			w.WriteHeader(http.StatusInternalServerError)
+			m := registry.Report{"Error", "Unspecified error registering ClusterID " + clusterID}
+			registry.MarshalAndWrite(w, m)
+			return
+		}
+	} else {
+		registry.LogNotice([]string{"Attempt to register ClusterID that is not available for registration:", clusterID})
+		w.WriteHeader(http.StatusBadRequest)
+		m := registry.Report{"Error", "This ClusterID is not available for registration."}
+		registry.MarshalAndWrite(w, m)
+		return
+	}
+
+	m := result{clusterID, available, registered, &registeredOn, data, true, fmt.Sprintf("%v", time.Since(start))}
+	registry.MarshalAndWrite(w, m)
+}
+
+func checkHandler(w http.ResponseWriter, r *http.Request) {
+	start := time.Now()
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+
+	var available, registered, requestAuthenticated bool
+  var registeredOn time.Time
+	var data map[string]string
+
+	clusterID, errReport := sanityCheckClusterID(r.URL.Path[10:])
+
+	available, registered, registeredOn, realData, secret, err := lookup(clusterID)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		m := registry.Report{"Error", "Unspecified error"}
+		registry.MarshalAndWrite(w, m)
+		return
+	}
+
+  // This ClusterID did not pass the sanity check, it is invalid
+	if errReport != (registry.Report{}) {
+    available = false
+	}
+
+	// An optional request body is permitted to send a ClientSecret
+	// If it matches what is in the database, the Data object will be
+	// included in the response.
+	if r.Body != nil {
+		var cluster registerCluster
+		err := json.NewDecoder(r.Body).Decode(&cluster)
+		if err == nil {
+			if computeHmac(cluster.ClientSecret) != secret {
+				registry.LogNotice([]string{"Invalid ClientSecret supplied, not returning Data object"})
+			} else {
+				data = realData
+				requestAuthenticated = true
+			}
+		}
+	}
+  registeredOnPtr := &registeredOn
+  if registered == false {
+    registeredOnPtr = nil
+  }
+
+	m := result{clusterID, available, registered, registeredOnPtr, data, requestAuthenticated, fmt.Sprintf("%v", time.Since(start))}
+	registry.MarshalAndWrite(w, m)
+}
+
+func aboutHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	m := about{"Arvados Registry Daemon", "0.1", "https://arvados.org"}
+	registry.MarshalAndWrite(w, m)
+}
+
+func helpHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	m := help{"GET /v1/check/cluster-id or GET /v1/about or GET /v1/help"}
+	registry.MarshalAndWrite(w, m)
+}
+
+func parseFlags() (configPath *string) {
+
+	flags := flag.NewFlagSet("arvados-cluster-registry", flag.ExitOnError)
+	flags.Usage = func() { usage(flags) }
+
+	configPath = flags.String(
+		"config",
+		defaultConfigPath,
+		"`path` to YAML configuration file")
+
+	// Parse args; omit the first arg which is the command name
+	err := flags.Parse(os.Args[1:])
+	if err != nil {
+		registry.LogError([]string{"Unable to parse command line arguments:", err.Error()})
+		os.Exit(1)
+	}
+
+	return
+}
+
+func loadDb() (db *sql.DB, err error) {
+	// Hardcoded migration strings in memory:
+	migrations := &migrate.MemoryMigrationSource{
+		Migrations: []*migrate.Migration{
+			{
+				Id:   "0",
+				Up:   []string{"CREATE TABLE clusters (identifier varchar(5), data text, registered boolean, registered_on timestamp, secret text, ip_address text, CONSTRAINT identifier_unique UNIQUE (identifier))"},
+				Down: []string{"DROP TABLE clusters"},
+			},
+			{
+				Id:   "1",
+				Up:   []string{"INSERT INTO clusters (identifier, data, registered, registered_on, secret) values ('qr1hi','{\"Name\":\"Ward Vandewege\",\"Email\":\"sysadmin at curoverse.com\",\"Organization\":\"Curoverse, Inc.\"}',1,'2013-09-01 00:00:00','')"},
+				Down: []string{"DELETE FROM clusters where identifier='qr1hi'"},
+			},
+			{
+				Id:   "2",
+				Up:   []string{"INSERT INTO clusters (identifier, data, registered, registered_on, secret) values ('su92l','{\"Name\":\"Ward Vandewege\",\"Email\":\"sysadmin at curoverse.com\",\"Organization\":\"Curoverse, Inc.\"}',1,'2013-09-01 00:00:00','')"},
+				Down: []string{"DELETE FROM clusters where identifier='su92l'"},
+			},
+		},
+	}
+	registry.LogNotice([]string{"Using", theConfig.DatabaseAdapter})
+	registry.LogNotice([]string{"Database", theConfig.Database})
+
+	db, err = sql.Open("sqlite3", theConfig.Database)
+	if err != nil {
+		return
+	}
+
+	n, err := migrate.Exec(db, "sqlite3", migrations, migrate.Up)
+	if err != nil {
+		return
+	}
+
+	if n > 0 {
+		registry.LogNotice([]string{"Applied", fmt.Sprintf("%d", n), "migration(s)"})
+	}
+	return
+}
+
+func main() {
+
+	configPath := parseFlags()
+
+	err := registry.ReadConfig(&theConfig, *configPath, defaultConfigPath)
+	if err != nil {
+		registry.LogError([]string{"Unable to start Arvados Registry Daemon:", err.Error()})
+		os.Exit(1)
+	}
+
+	if theConfig.DatabaseAdapter == "" {
+		theConfig.DatabaseAdapter = "sqlite3"
+	}
+
+	if theConfig.Database == "" {
+		registry.LogError([]string{"Unable to start Arvados Registry Daemon: the configuration file needs to define a database"})
+		os.Exit(1)
+	}
+
+	db, err = loadDb()
+	if err != nil {
+		registry.LogError([]string{"Unable to start Arvados Registry Daemon:", err.Error()})
+		os.Exit(1)
+	}
+
+	if theConfig.ServerSecret == "" {
+		registry.LogError([]string{"Unable to start Arvados Registry Daemon: the configuration file needs to define a serverSecret"})
+		os.Exit(1)
+	}
+
+	if theConfig.ListenPort == "" {
+		theConfig.ListenPort = "80"
+	}
+
+	http.HandleFunc("/v1/check/", checkHandler)
+	http.HandleFunc("/v1/register/", registerHandler)
+	http.HandleFunc("/v1/about", aboutHandler)
+	http.HandleFunc("/v1/help", helpHandler)
+	http.HandleFunc("/v1", helpHandler)
+	http.HandleFunc("/", helpHandler)
+	registry.LogNotice([]string{"Arvados Registry Daemon listening on port", theConfig.ListenPort})
+
+	listener, err = net.Listen("tcp", ":"+theConfig.ListenPort)
+
+	if err != nil {
+		registry.LogError([]string{"Unable to start Arvados Registry Daemon:", err.Error()})
+		os.Exit(1)
+	}
+
+	// Shut down the server gracefully (by closing the listener)
+	// if SIGTERM is received.
+	term := make(chan os.Signal, 1)
+	go func(sig <-chan os.Signal) {
+		<-sig
+		registry.LogError([]string{"caught signal"})
+		_ = listener.Close()
+	}(term)
+	signal.Notify(term, syscall.SIGTERM)
+	signal.Notify(term, syscall.SIGINT)
+
+	// Start serving requests.
+	_ = http.Serve(listener, nil)
+	// http.Serve returns an error when it gets the term or int signal
+
+	registry.LogNotice([]string{"Arvados Registry Daemon shutting down"})
+}
diff --git a/registry/server/registryd/registryd_test.go b/registry/server/registryd/registryd_test.go
new file mode 100644
index 0000000..d7b11db
--- /dev/null
+++ b/registry/server/registryd/registryd_test.go
@@ -0,0 +1,319 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package main
+
+import (
+  "bytes"
+  "fmt"
+  "git.curoverse.com/arvados-org.git/registry"
+  . "gopkg.in/check.v1"
+  "io/ioutil"
+  "log"
+  "net/http"
+  "os"
+  "testing"
+  "time"
+)
+
+// Hook gocheck into the "go test" runner.
+func Test(t *testing.T) { TestingT(t) }
+
+// Gocheck boilerplate
+var _ = Suite(&ServerRequiredSuite{})
+var _ = Suite(&ServerNotRequiredSuite{})
+
+type ServerRequiredSuite struct{}
+type ServerNotRequiredSuite struct{}
+
+var tmpConfigFileName string
+var tmpDBFileName string
+
+func closeListener() {
+  if listener != nil {
+    listener.Close()
+  }
+}
+
+func (s *ServerNotRequiredSuite) TestConfig(c *C) {
+  var config Config
+
+  // A specified but non-existing config path needs to result in an error
+  err := registry.ReadConfig(&config, "/nosuchdir89j7879/8hjwr7ojgyy7", defaultConfigPath)
+  c.Assert(err, NotNil)
+
+  // No configuration file and default configuration path specified, must error
+  err = registry.ReadConfig(&config, "/nosuchdir89j7879/8hjwr7ojgyy7", "/nosuchdir89j7879/8hjwr7ojgyy7")
+  c.Assert(err, NotNil)
+
+  // Test parsing of config data
+  tmpfile, err := ioutil.TempFile(os.TempDir(), "config")
+  c.Check(err, IsNil)
+  defer os.Remove(tmpfile.Name())
+
+  tmpdb, err := ioutil.TempFile(os.TempDir(), "db")
+  c.Check(err, IsNil)
+  defer os.Remove(tmpdb.Name())
+
+  argsS := `{"ServerSecret": "super_secret_sauce", "ListenPort": "12345", "Database": "` + tmpdb.Name() + `"}`
+  _, err = tmpfile.Write([]byte(argsS))
+  c.Check(err, IsNil)
+
+  err = registry.ReadConfig(&config, tmpfile.Name(), defaultConfigPath)
+  c.Assert(err, IsNil)
+
+  c.Check(config.ServerSecret, Equals, "super_secret_sauce")
+  c.Check(config.ListenPort, Equals, "12345")
+  c.Check(config.Database, Equals, tmpdb.Name())
+}
+
+func (s *ServerNotRequiredSuite) TestFlags(c *C) {
+  args := []string{"arvados-cluster-registry"}
+  os.Args = append(args)
+}
+
+func runServer(c *C) {
+  tmpfile, err := ioutil.TempFile(os.TempDir(), "config")
+  c.Check(err, IsNil)
+
+  tmpConfigFileName = tmpfile.Name()
+
+  tmpdb, err := ioutil.TempFile(os.TempDir(), "db")
+  c.Check(err, IsNil)
+
+  tmpDBFileName = tmpdb.Name()
+
+  argsS := `{"ServerSecret": "a_very_good_secret_this_is_not", "ListenPort": "12345", "Database": ` + tmpDBFileName + `}`
+  _, err = tmpfile.Write([]byte(argsS))
+  c.Check(err, IsNil)
+
+  args := []string{"arvados-cluster-registry"}
+  os.Args = append(args, "-config", tmpfile.Name())
+  listener = nil
+  go main()
+  waitForListener()
+}
+
+func waitForListener() {
+  const (
+    ms = 5
+  )
+  for i := 0; listener == nil && i < 10000; i += ms {
+    time.Sleep(ms * time.Millisecond)
+  }
+  if listener == nil {
+    log.Fatalf("Timed out waiting for listener to start")
+  }
+}
+
+func (s *ServerNotRequiredSuite) SetUpTest(c *C) {
+  // Discard standard log output
+  log.SetOutput(ioutil.Discard)
+}
+
+func (s *ServerRequiredSuite) SetUpTest(c *C) {
+  // Discard standard log output
+  log.SetOutput(ioutil.Discard)
+}
+
+func (s *ServerRequiredSuite) TearDownSuite(c *C) {
+  log.SetOutput(os.Stderr)
+}
+
+func (s *ServerNotRequiredSuite) TearDownSuite(c *C) {
+  log.SetOutput(os.Stderr)
+}
+
+func (s *ServerRequiredSuite) TestResults(c *C) {
+  runServer(c)
+  defer closeListener()
+  defer os.Remove(tmpDBFileName)
+  defer os.Remove(tmpConfigFileName)
+
+  // Test the about handler
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("GET",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/about"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 200)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*\"Name\":\"Arvados Registry Daemon\".*")
+  }
+
+  // Test the help handler
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("GET",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/help"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 200)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*GET /v1/about or GET /v1/help.*")
+  }
+
+  // Registration of a valid ClusterID with proper JSON body (must pass)
+  {
+    client := http.Client{}
+    var jsonStr = []byte(`{"clientSecret":"this_is_long_enough","name":"Ward Vandewege","email":"sysadmin at curoverse.com","organization":"Curoverse, Inc."}`)
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/abcde"),
+      bytes.NewBuffer(jsonStr))
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 200)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*\"ClusterID\":\"abcde\",\"Available\":false,\"Registered\":true.*")
+  }
+
+  // Check existence of a valid ClusterID (must pass)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("GET",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/check/abcde"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 200)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*\"ClusterID\":\"abcde\",\"Available\":false,\"Registered\":true.*")
+  }
+
+  // Check existence of an invalid ClusterID (must pass)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("GET",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/check/1bcde"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 200)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*\"ClusterID\":\"1bcde\",\"Available\":false,\"Registered\":false.*")
+  }
+
+  // Registration of a ClusterID with proper JSON body missing fields (must fail)
+  {
+    client := http.Client{}
+    var jsonStr = []byte(`{"clientSecret":"this_is_long_enough"}`)
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/abcde"),
+      bytes.NewBuffer(jsonStr))
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Missing fields.*")
+  }
+
+  // Registration of an existing ClusterID with proper JSON body (must fail)
+  {
+    client := http.Client{}
+    var jsonStr = []byte(`{"clientSecret":"this_is_long_enough","name":"Ward Vandewege","email":"sysadmin at curoverse.com","organization":"Curoverse, Inc."}`)
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/abcde"),
+      bytes.NewBuffer(jsonStr))
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*This ClusterID is not available for registration.*")
+  }
+
+  // Registration of a valid ClusterID with proper JSON body but clientSecret that is too short (must fail)
+  {
+    client := http.Client{}
+    var jsonStr = []byte(`{"clientSecret":"not_long_enough"}`)
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/abcde"),
+      bytes.NewBuffer(jsonStr))
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*min length: 16 characters.*")
+  }
+
+  // Registration of a valid ClusterID but without JSON body (must fail)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/abcde"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Unable to decode request body as JSON.*")
+  }
+
+  // Registration of an invalid ClusterID (must fail)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/1abcd"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Invalid ClusterID.*")
+  }
+
+  // Registration of an invalid ClusterID (must fail)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/xabcd"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Invalid ClusterID.*")
+  }
+
+  // Registration of an invalid ClusterID (must fail)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/yabcd"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Invalid ClusterID.*")
+  }
+
+  // Registration of an invalid ClusterID (must fail)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/zabcd"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Invalid ClusterID.*")
+  }
+
+  // Registration of an invalid ClusterID (must fail)
+  {
+    client := http.Client{}
+    req, err := http.NewRequest("POST",
+      fmt.Sprintf("http://%s/%s", listener.Addr().String(), "v1/register/ABCDE"),
+      nil)
+    resp, err := client.Do(req)
+    c.Check(err, Equals, nil)
+    c.Check(resp.StatusCode, Equals, 400)
+    body, err := ioutil.ReadAll(resp.Body)
+    c.Check(string(body), Matches, ".*Invalid ClusterID.*")
+  }
+}
diff --git a/registry/server/registryd/usage.go b/registry/server/registryd/usage.go
new file mode 100644
index 0000000..a0f0db7
--- /dev/null
+++ b/registry/server/registryd/usage.go
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+)
+
+var exampleConfigFile = []byte(`
+listenPort: 8080
+serverSecret: a_long_secret_string
+`)
+
+func usage(fs *flag.FlagSet) {
+	fmt.Fprintf(os.Stderr, `
+Arvados Registry Daemon is a JSON REST service that manages the global namespace 
+of Arvados cluster identifiers.
+
+Options:
+`)
+	fs.PrintDefaults()
+	fmt.Fprintf(os.Stderr, `
+Example config file:
+%s
+`, exampleConfigFile)
+}

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list