Merge branch '12573-crunch2-slurm-priority' closes #12573
authorPeter Amstutz <pamstutz@veritasgenetics.com>
Mon, 4 Dec 2017 21:32:04 +0000 (16:32 -0500)
committerPeter Amstutz <pamstutz@veritasgenetics.com>
Mon, 4 Dec 2017 21:32:11 +0000 (16:32 -0500)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <pamstutz@veritasgenetics.com>

33 files changed:
apps/workbench/test/unit/user_test.rb
build/run-library.sh
services/arv-git-httpd/main.go
services/crunch-dispatch-local/crunch-dispatch-local.go
services/crunch-dispatch-slurm/crunch-dispatch-slurm.go
services/crunch-run/crunchrun.go
services/crunch-run/crunchrun_test.go
services/crunchstat/crunchstat.go
services/fuse/arvados_fuse/command.py
services/health/main.go
services/keep-balance/main.go
services/keep-web/handler.go
services/keep-web/main.go
services/keep-web/status_test.go
services/keepproxy/keepproxy.go
services/keepstore/handlers.go
services/keepstore/keepstore.go
services/keepstore/pull_worker_test.go
services/nodemanager/arvnodeman/launcher.py
services/nodemanager/arvnodeman/status.py
services/nodemanager/tests/test_status.py
services/ws/main.go
services/ws/router.go
services/ws/server_test.go
tools/arv-sync-groups/arv-sync-groups.go
tools/crunchstat-summary/MANIFEST.in
tools/crunchstat-summary/crunchstat_summary/dygraphs.js
tools/crunchstat-summary/crunchstat_summary/dygraphs.py
tools/crunchstat-summary/crunchstat_summary/synchronizer.js [new file with mode: 0644]
tools/crunchstat-summary/crunchstat_summary/webchart.py
tools/keep-block-check/keep-block-check.go
tools/keep-exercise/keep-exercise.go
tools/keep-rsync/keep-rsync.go

index 1b887151c4e81f811af141b2908aca2871239f30..fa9a69d38097041b05c988eef8691a79d2182df3 100644 (file)
@@ -13,4 +13,18 @@ class UserTest < ActiveSupport::TestCase
       assert_nil user.first_name
     end
   end
+
+  test "User.current doesn't return anonymous user when using invalid token" do
+    # Set up anonymous user token
+    Rails.configuration.anonymous_user_token = api_fixture('api_client_authorizations')['anonymous']['api_token']
+    # First, try with a valid user
+    use_token :active
+    u = User.current
+    assert(find_fixture(User, "active").uuid == u.uuid)
+    # Next, simulate an invalid token
+    Thread.current[:arvados_api_token] = 'thistokenwontwork'
+    assert_raises(ArvadosApiClient::NotLoggedInException) do
+      User.current
+    end
+  end
 end
index 5fc494cdf5aad3608cd1f7b7eafb2c5bd19035d0..029fefc9bb3880fb9643e15ee108e6692c0211f0 100755 (executable)
@@ -132,7 +132,7 @@ package_go_binary() {
       return 1
     fi
 
-    go get "git.curoverse.com/arvados.git/$src_path"
+    go get -ldflags "-X main.version=${version}" "git.curoverse.com/arvados.git/$src_path"
 
     declare -a switches=()
     systemd_unit="$WORKSPACE/${src_path}/${prog}.service"
index 79a3eb3f7b85de9667c04fe1b24f2a5217db5772..74ac7ae55eea05aa396f9ff337a8dbed74505079 100644 (file)
@@ -7,6 +7,7 @@ package main
 import (
        "encoding/json"
        "flag"
+       "fmt"
        "log"
        "os"
        "regexp"
@@ -16,6 +17,8 @@ import (
        "github.com/coreos/go-systemd/daemon"
 )
 
+var version = "dev"
+
 // Server configuration
 type Config struct {
        Client          arvados.Client
@@ -50,6 +53,7 @@ func main() {
 
        cfgPath := flag.String("config", defaultCfgPath, "Configuration file `path`.")
        dumpConfig := flag.Bool("dump-config", false, "write current configuration to stdout and exit (useful for migrating from command line flags to config file)")
+       getVersion := flag.Bool("version", false, "print version information and exit.")
 
        flag.StringVar(&theConfig.ManagementToken, "management-token", theConfig.ManagementToken,
                "Authorization token to be included in all health check requests.")
@@ -57,6 +61,12 @@ func main() {
        flag.Usage = usage
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("arv-git-httpd %s\n", version)
+               return
+       }
+
        err := config.LoadFile(theConfig, *cfgPath)
        if err != nil {
                h := os.Getenv("ARVADOS_API_HOST")
@@ -84,6 +94,7 @@ func main() {
        if _, err := daemon.SdNotify(false, "READY=1"); err != nil {
                log.Printf("Error notifying init daemon: %v", err)
        }
+       log.Printf("arv-git-httpd %s started", version)
        log.Println("Listening at", srv.Addr)
        log.Println("Repository root", theConfig.RepoRoot)
        if err := srv.Wait(); err != nil {
index 888a2148c1797aa330167d6ca4b44c6a914e5000..279327ba18811ba8ad6339600cc124460f2fc35c 100644 (file)
@@ -9,6 +9,7 @@ package main
 import (
        "context"
        "flag"
+       "fmt"
        "log"
        "os"
        "os/exec"
@@ -22,6 +23,8 @@ import (
        "git.curoverse.com/arvados.git/sdk/go/dispatch"
 )
 
+var version = "dev"
+
 func main() {
        err := doMain()
        if err != nil {
@@ -49,9 +52,22 @@ func doMain() error {
                "/usr/bin/crunch-run",
                "Crunch command to run container")
 
+       getVersion := flags.Bool(
+               "version",
+               false,
+               "Print version information and exit.")
+
        // Parse args; omit the first arg which is the command name
        flags.Parse(os.Args[1:])
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("crunch-dispatch-local %s\n", version)
+               return nil
+       }
+
+       log.Printf("crunch-dispatch-local %s started", version)
+
        runningCmds = make(map[string]*exec.Cmd)
 
        arv, err := arvadosclient.MakeArvadosClient()
index 9dea3cbe9294666d9b2eb08c2bc6d2d9b052caab..3d094c7f6e3a68f745f02be1950cc76eb8fdecd2 100644 (file)
@@ -26,6 +26,8 @@ import (
        "github.com/coreos/go-systemd/daemon"
 )
 
+var version = "dev"
+
 // Config used by crunch-dispatch-slurm
 type Config struct {
        Client arvados.Client
@@ -69,10 +71,21 @@ func doMain() error {
                "dump-config",
                false,
                "write current configuration to stdout and exit")
-
+       getVersion := flags.Bool(
+               "version",
+               false,
+               "Print version information and exit.")
        // Parse args; omit the first arg which is the command name
        flags.Parse(os.Args[1:])
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("crunch-dispatch-slurm %s\n", version)
+               return nil
+       }
+
+       log.Printf("crunch-dispatch-slurm %s started", version)
+
        err := readConfig(&theConfig, *configPath)
        if err != nil {
                return err
index fc0dda718ceda7fddd2e1f41c704fa434fdb0034..f3f754b59d227c3d410ad1255a9a38da1dd9400b 100644 (file)
@@ -19,6 +19,7 @@ import (
        "os/signal"
        "path"
        "path/filepath"
+       "regexp"
        "runtime"
        "runtime/pprof"
        "sort"
@@ -39,6 +40,8 @@ import (
        dockerclient "github.com/docker/docker/client"
 )
 
+var version = "dev"
+
 // IArvadosClient is the minimal Arvados API methods used by crunch-run.
 type IArvadosClient interface {
        Create(resourceType string, parameters arvadosclient.Dict, output interface{}) error
@@ -228,12 +231,15 @@ func (runner *ContainerRunner) stopSignals() {
        }
 }
 
-var errorBlacklist = []string{"Cannot connect to the Docker daemon"}
+var errorBlacklist = []string{
+       "(?ms).*[Cc]annot connect to the Docker daemon.*",
+       "(?ms).*oci runtime error.*starting container process.*container init.*mounting.*to rootfs.*no such file or directory.*",
+}
 var brokenNodeHook *string = flag.String("broken-node-hook", "", "Script to run if node is detected to be broken (for example, Docker daemon is not running)")
 
 func (runner *ContainerRunner) checkBrokenNode(goterr error) bool {
        for _, d := range errorBlacklist {
-               if strings.Index(goterr.Error(), d) != -1 {
+               if m, e := regexp.MatchString(d, goterr.Error()); m && e == nil {
                        runner.CrunchLog.Printf("Error suggests node is unable to run containers: %v", goterr)
                        if *brokenNodeHook == "" {
                                runner.CrunchLog.Printf("No broken node hook provided, cannot mark node as broken.")
@@ -642,7 +648,7 @@ type infoCommand struct {
        cmd   []string
 }
 
-// Gather node information and store it on the log for debugging
+// LogNodeInfo gathers node information and store it on the log for debugging
 // purposes.
 func (runner *ContainerRunner) LogNodeInfo() (err error) {
        w := runner.NewLogWriter("node-info")
@@ -692,7 +698,7 @@ func (runner *ContainerRunner) LogNodeInfo() (err error) {
        return nil
 }
 
-// Get and save the raw JSON container record from the API server
+// LogContainerRecord gets and saves the raw JSON container record from the API server
 func (runner *ContainerRunner) LogContainerRecord() (err error) {
        w := &ArvLogWriter{
                ArvClient:     runner.ArvClient,
@@ -915,7 +921,7 @@ func (runner *ContainerRunner) StartContainer() error {
                dockertypes.ContainerStartOptions{})
        if err != nil {
                var advice string
-               if strings.Contains(err.Error(), "no such file or directory") {
+               if m, e := regexp.MatchString("(?ms).*(exec|System error).*(no such file or directory|file not found).*", err.Error()); m && e == nil {
                        advice = fmt.Sprintf("\nPossible causes: command %q is missing, the interpreter given in #! is missing, or script has Windows line endings.", runner.Container.Command[0])
                }
                return fmt.Errorf("could not start container: %v%s", err, advice)
@@ -1444,6 +1450,7 @@ func (runner *ContainerRunner) NewArvLogWriter(name string) io.WriteCloser {
 
 // Run the full container lifecycle.
 func (runner *ContainerRunner) Run() (err error) {
+       runner.CrunchLog.Printf("crunch-run %s started", version)
        runner.CrunchLog.Printf("Executing container '%s'", runner.Container.UUID)
 
        hostname, hosterr := os.Hostname()
@@ -1624,8 +1631,17 @@ func main() {
                `Set networking mode for container.  Corresponds to Docker network mode (--net).
        `)
        memprofile := flag.String("memprofile", "", "write memory profile to `file` after running container")
+       getVersion := flag.Bool("version", false, "Print version information and exit.")
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("crunch-run %s\n", version)
+               return
+       }
+
+       log.Printf("crunch-run %s started", version)
+
        containerId := flag.Arg(0)
 
        if *caCertsPath != "" {
index 97faa89fb134ae8cb12a1d36ce2ef458da9af020..e1d9fed730ea5ace0393b07e8b1fd8f1eaff65e4 100644 (file)
@@ -130,6 +130,19 @@ func (t *TestDockerClient) ContainerCreate(ctx context.Context, config *dockerco
 }
 
 func (t *TestDockerClient) ContainerStart(ctx context.Context, container string, options dockertypes.ContainerStartOptions) error {
+       if t.finish == 3 {
+               return errors.New(`Error response from daemon: oci runtime error: container_linux.go:247: starting container process caused "process_linux.go:359: container init caused \"rootfs_linux.go:54: mounting \\\"/tmp/keep453790790/by_id/99999999999999999999999999999999+99999/myGenome\\\" to rootfs \\\"/tmp/docker/overlay2/9999999999999999999999999999999999999999999999999999999999999999/merged\\\" at \\\"/tmp/docker/overlay2/9999999999999999999999999999999999999999999999999999999999999999/merged/keep/99999999999999999999999999999999+99999/myGenome\\\" caused \\\"no such file or directory\\\"\""`)
+       }
+       if t.finish == 4 {
+               return errors.New(`panic: standard_init_linux.go:175: exec user process caused "no such file or directory"`)
+       }
+       if t.finish == 5 {
+               return errors.New(`Error response from daemon: Cannot start container 41f26cbc43bcc1280f4323efb1830a394ba8660c9d1c2b564ba42bf7f7694845: [8] System error: no such file or directory`)
+       }
+       if t.finish == 6 {
+               return errors.New(`Error response from daemon: Cannot start container 58099cd76c834f3dc2a4fb76c8028f049ae6d4fdf0ec373e1f2cfea030670c2d: [8] System error: exec: "foobar": executable file not found in $PATH`)
+       }
+
        if container == "abcde" {
                // t.fn gets executed in ContainerWait
                return nil
@@ -1835,3 +1848,91 @@ func (s *TestSuite) TestFullBrokenDocker2(c *C) {
        c.Check(api.Logs["crunch-run"].String(), Matches, "(?ms).*unable to run containers.*")
        c.Check(api.Logs["crunch-run"].String(), Matches, "(?ms).*No broken node hook.*")
 }
+
+func (s *TestSuite) TestFullBrokenDocker3(c *C) {
+       ech := ""
+       brokenNodeHook = &ech
+
+       api, _, _ := FullRunHelper(c, `{
+    "command": ["echo", "hello world"],
+    "container_image": "d4ab34d3d4f8a72f5c4973051ae69fab+122",
+    "cwd": ".",
+    "environment": {},
+    "mounts": {"/tmp": {"kind": "tmp"} },
+    "output_path": "/tmp",
+    "priority": 1,
+    "runtime_constraints": {}
+}`, nil, 3, func(t *TestDockerClient) {
+               t.logWriter.Write(dockerLog(1, "hello world\n"))
+               t.logWriter.Close()
+       })
+
+       c.Check(api.CalledWith("container.state", "Cancelled"), NotNil)
+       c.Check(api.Logs["crunch-run"].String(), Matches, "(?ms).*unable to run containers.*")
+}
+
+func (s *TestSuite) TestBadCommand1(c *C) {
+       ech := ""
+       brokenNodeHook = &ech
+
+       api, _, _ := FullRunHelper(c, `{
+    "command": ["echo", "hello world"],
+    "container_image": "d4ab34d3d4f8a72f5c4973051ae69fab+122",
+    "cwd": ".",
+    "environment": {},
+    "mounts": {"/tmp": {"kind": "tmp"} },
+    "output_path": "/tmp",
+    "priority": 1,
+    "runtime_constraints": {}
+}`, nil, 4, func(t *TestDockerClient) {
+               t.logWriter.Write(dockerLog(1, "hello world\n"))
+               t.logWriter.Close()
+       })
+
+       c.Check(api.CalledWith("container.state", "Cancelled"), NotNil)
+       c.Check(api.Logs["crunch-run"].String(), Matches, "(?ms).*Possible causes:.*is missing.*")
+}
+
+func (s *TestSuite) TestBadCommand2(c *C) {
+       ech := ""
+       brokenNodeHook = &ech
+
+       api, _, _ := FullRunHelper(c, `{
+    "command": ["echo", "hello world"],
+    "container_image": "d4ab34d3d4f8a72f5c4973051ae69fab+122",
+    "cwd": ".",
+    "environment": {},
+    "mounts": {"/tmp": {"kind": "tmp"} },
+    "output_path": "/tmp",
+    "priority": 1,
+    "runtime_constraints": {}
+}`, nil, 5, func(t *TestDockerClient) {
+               t.logWriter.Write(dockerLog(1, "hello world\n"))
+               t.logWriter.Close()
+       })
+
+       c.Check(api.CalledWith("container.state", "Cancelled"), NotNil)
+       c.Check(api.Logs["crunch-run"].String(), Matches, "(?ms).*Possible causes:.*is missing.*")
+}
+
+func (s *TestSuite) TestBadCommand3(c *C) {
+       ech := ""
+       brokenNodeHook = &ech
+
+       api, _, _ := FullRunHelper(c, `{
+    "command": ["echo", "hello world"],
+    "container_image": "d4ab34d3d4f8a72f5c4973051ae69fab+122",
+    "cwd": ".",
+    "environment": {},
+    "mounts": {"/tmp": {"kind": "tmp"} },
+    "output_path": "/tmp",
+    "priority": 1,
+    "runtime_constraints": {}
+}`, nil, 6, func(t *TestDockerClient) {
+               t.logWriter.Write(dockerLog(1, "hello world\n"))
+               t.logWriter.Close()
+       })
+
+       c.Check(api.CalledWith("container.state", "Cancelled"), NotNil)
+       c.Check(api.Logs["crunch-run"].String(), Matches, "(?ms).*Possible causes:.*is missing.*")
+}
index cd84770e54e637bd9b195362d7dba7f14ff49601..ad433bb3b532fea36610e975a14b6ca750f5b353 100644 (file)
@@ -7,6 +7,7 @@ package main
 import (
        "bufio"
        "flag"
+       "fmt"
        "io"
        "log"
        "os"
@@ -23,6 +24,7 @@ const MaxLogLine = 1 << 14 // Child stderr lines >16KiB will be split
 var (
        signalOnDeadPPID  int = 15
        ppidCheckInterval     = time.Second
+       version               = "dev"
 )
 
 func main() {
@@ -36,9 +38,18 @@ func main() {
        flag.IntVar(&signalOnDeadPPID, "signal-on-dead-ppid", signalOnDeadPPID, "Signal to send child if crunchstat's parent process disappears (0 to disable)")
        flag.DurationVar(&ppidCheckInterval, "ppid-check-interval", ppidCheckInterval, "Time between checks for parent process disappearance")
        pollMsec := flag.Int64("poll", 1000, "Reporting interval, in milliseconds")
+       getVersion := flag.Bool("version", false, "Print version information and exit.")
 
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("crunchstat %s\n", version)
+               return
+       }
+
+       reporter.Logger.Printf("crunchstat %s started", version)
+
        if reporter.CgroupRoot == "" {
                reporter.Logger.Fatal("error: must provide -cgroup-root")
        } else if signalOnDeadPPID < 0 {
index 4dad90c86758edb118d7ab4b04958417533b9653..f174d1bb02c7ef1264a8d52a2079c11bb06778a7 100644 (file)
@@ -202,6 +202,7 @@ class Mount(object):
             logging.getLogger('arvados.collection').setLevel(logging.DEBUG)
             self.logger.debug("arv-mount debugging enabled")
 
+        self.logger.info("%s %s started", sys.argv[0], __version__)
         self.logger.info("enable write is %s", self.args.enable_write)
 
     def _setup_api(self):
index b6358deefcf8d333971435b2a9ede4021bc68f54..496fb884d433a5eea350d27818d5e72629e9242f 100644 (file)
@@ -2,6 +2,7 @@ package main
 
 import (
        "flag"
+       "fmt"
        "net/http"
 
        "git.curoverse.com/arvados.git/sdk/go/arvados"
@@ -10,13 +11,24 @@ import (
        log "github.com/Sirupsen/logrus"
 )
 
+var version = "dev"
+
 func main() {
        configFile := flag.String("config", arvados.DefaultConfigFile, "`path` to arvados configuration file")
+       getVersion := flag.Bool("version", false, "Print version information and exit.")
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("arvados-health %s\n", version)
+               return
+       }
+
        log.SetFormatter(&log.JSONFormatter{
                TimestampFormat: "2006-01-02T15:04:05.000000000Z07:00",
        })
+       log.Printf("arvados-health %s started", version)
+
        cfg, err := arvados.GetConfig(*configFile)
        if err != nil {
                log.Fatal(err)
index 8a938ccf5308393311a121835c1eaef631f194ca..947033564df01e479d05682617fc041417e5d54f 100644 (file)
@@ -7,6 +7,7 @@ package main
 import (
        "encoding/json"
        "flag"
+       "fmt"
        "log"
        "os"
        "os/signal"
@@ -17,6 +18,8 @@ import (
        "git.curoverse.com/arvados.git/sdk/go/config"
 )
 
+var version = "dev"
+
 const defaultConfigPath = "/etc/arvados/keep-balance/keep-balance.yml"
 
 // Config specifies site configuration, like API credentials and the
@@ -85,9 +88,16 @@ func main() {
        dumpConfig := flag.Bool("dump-config", false, "write current configuration to stdout and exit")
        dumpFlag := flag.Bool("dump", false, "dump details for each block to stdout")
        debugFlag := flag.Bool("debug", false, "enable debug messages")
+       getVersion := flag.Bool("version", false, "Print version information and exit.")
        flag.Usage = usage
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keep-balance %s\n", version)
+               return
+       }
+
        mustReadConfig(&cfg, *configPath)
        if *serviceListPath != "" {
                mustReadConfig(&cfg.KeepServiceList, *serviceListPath)
@@ -97,6 +107,8 @@ func main() {
                log.Fatal(config.DumpAndExit(cfg))
        }
 
+       log.Printf("keep-balance %s started", version)
+
        if *debugFlag {
                debugf = log.Printf
                if j, err := json.Marshal(cfg); err != nil {
index 4222e3822e14d9d35fbccc84c58b5f8f0ca45182..a1476d3a8eb1b62fad8ea519702ec290e7f3472c 100644 (file)
@@ -91,8 +91,10 @@ func (h *handler) setup() {
 func (h *handler) serveStatus(w http.ResponseWriter, r *http.Request) {
        status := struct {
                cacheStats
+               Version string
        }{
                cacheStats: h.Config.Cache.Stats(),
+               Version:    version,
        }
        json.NewEncoder(w).Encode(status)
 }
index 27ceb48c78bfebbc40aa9d3732cbcbba6ad63155..724af27c7e0e746b44218f5269d23b71228e6655 100644 (file)
@@ -6,6 +6,7 @@ package main
 
 import (
        "flag"
+       "fmt"
        "log"
        "os"
        "time"
@@ -17,6 +18,7 @@ import (
 
 var (
        defaultConfigPath = "/etc/arvados/keep-web/keep-web.yml"
+       version           = "dev"
 )
 
 // Config specifies server configuration.
@@ -85,9 +87,17 @@ func main() {
 
        dumpConfig := flag.Bool("dump-config", false,
                "write current configuration to stdout and exit")
+       getVersion := flag.Bool("version", false,
+               "print version information and exit.")
        flag.Usage = usage
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keep-web %s\n", version)
+               return
+       }
+
        if err := config.LoadFile(cfg, configPath); err != nil {
                if h := os.Getenv("ARVADOS_API_HOST"); h != "" && configPath == defaultConfigPath {
                        log.Printf("DEPRECATED: Using ARVADOS_API_HOST environment variable. Use config file instead.")
@@ -105,6 +115,8 @@ func main() {
                log.Fatal(config.DumpAndExit(cfg))
        }
 
+       log.Printf("keep-web %s started", version)
+
        os.Setenv("ARVADOS_API_HOST", cfg.Client.APIHost)
        srv := &server{Config: cfg}
        if err := srv.Start(); err != nil {
index 5f2d44cbe4d7ce4c12f86417763d02c28f0c5782..0a2b9eb988dce96c4791f7cbbaf0c602d5b16980 100644 (file)
@@ -31,6 +31,7 @@ func (s *UnitSuite) TestStatus(c *check.C) {
        err := json.NewDecoder(resp.Body).Decode(&status)
        c.Check(err, check.IsNil)
        c.Check(status["Cache.Requests"], check.Equals, float64(0))
+       c.Check(status["Version"], check.Not(check.Equals), "")
 }
 
 func (s *IntegrationSuite) TestNoStatusFromVHost(c *check.C) {
index e2a6221f10e28981ea0c3f64fa5ce6d52ac718ea..145b39d4c3d1e643983c6f517eb31ff2c8d417fd 100644 (file)
@@ -32,6 +32,8 @@ import (
        "github.com/gorilla/mux"
 )
 
+var version = "dev"
+
 type Config struct {
        Client          arvados.Client
        Listen          string
@@ -81,8 +83,15 @@ func main() {
        const defaultCfgPath = "/etc/arvados/keepproxy/keepproxy.yml"
        flagset.StringVar(&cfgPath, "config", defaultCfgPath, "Configuration file `path`")
        dumpConfig := flagset.Bool("dump-config", false, "write current configuration to stdout and exit")
+       getVersion := flagset.Bool("version", false, "Print version information and exit.")
        flagset.Parse(os.Args[1:])
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keepproxy %s\n", version)
+               return
+       }
+
        err := config.LoadFile(cfg, cfgPath)
        if err != nil {
                h := os.Getenv("ARVADOS_API_HOST")
@@ -106,6 +115,8 @@ func main() {
                log.Fatal(config.DumpAndExit(cfg))
        }
 
+       log.Printf("keepproxy %s started", version)
+
        arv, err := arvadosclient.New(&cfg.Client)
        if err != nil {
                log.Fatalf("Error setting up arvados client %s", err.Error())
index 2d90aba14e4d36ddf41f035282b66100967d783b..daf4fc69ddff2702f0da43fd0af4d4927374bb2c 100644 (file)
@@ -311,6 +311,7 @@ type NodeStatus struct {
        TrashQueue      WorkQueueStatus
        RequestsCurrent int
        RequestsMax     int
+       Version         string
 }
 
 var st NodeStatus
@@ -346,6 +347,7 @@ func (rtr *router) StatusHandler(resp http.ResponseWriter, req *http.Request) {
 
 // populate the given NodeStatus struct with current values.
 func (rtr *router) readNodeStatus(st *NodeStatus) {
+       st.Version = version
        vols := KeepVM.AllReadable()
        if cap(st.Volumes) < len(vols) {
                st.Volumes = make([]*volumeStatusEnt, len(vols))
index e422179f643e9cad438742cd2aa450d52ae84fa4..b8a0ffb1cba46777ff1e2d1c745eb8102ea5fa61 100644 (file)
@@ -22,6 +22,8 @@ import (
        "github.com/coreos/go-systemd/daemon"
 )
 
+var version = "dev"
+
 // A Keep "block" is 64MB.
 const BlockSize = 64 * 1024 * 1024
 
@@ -89,6 +91,7 @@ func main() {
        deprecated.beforeFlagParse(theConfig)
 
        dumpConfig := flag.Bool("dump-config", false, "write current configuration to stdout and exit (useful for migrating from command line flags to config file)")
+       getVersion := flag.Bool("version", false, "Print version information and exit.")
 
        defaultConfigPath := "/etc/arvados/keepstore/keepstore.yml"
        var configPath string
@@ -100,6 +103,12 @@ func main() {
        flag.Usage = usage
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keepstore %s\n", version)
+               return
+       }
+
        deprecated.afterFlagParse(theConfig)
 
        err := config.LoadFile(theConfig, configPath)
@@ -111,6 +120,8 @@ func main() {
                log.Fatal(config.DumpAndExit(theConfig))
        }
 
+       log.Printf("keepstore %s started", version)
+
        err = theConfig.Start()
        if err != nil {
                log.Fatal(err)
index 9e547f30d0c1c29cd8f510c5b72c2ae6ceba4acf..7a8297039b513d62b2f8a38ab81ab0f9bc89f187 100644 (file)
@@ -271,6 +271,7 @@ func (s *PullWorkerTestSuite) performTest(testData PullWorkerTestData, c *C) {
 
        c.Check(getStatusItem("PullQueue", "InProgress"), Equals, float64(0))
        c.Check(getStatusItem("PullQueue", "Queued"), Equals, float64(0))
+       c.Check(getStatusItem("Version"), Not(Equals), "")
 
        response := IssueRequest(&testData.req)
        c.Assert(response.Code, Equals, testData.responseCode)
index d85ef552c064ac0b9f4527c3b93194ede60c142f..888abf5a768d51cb34fe85b30ed9d1252b7dea4c 100644 (file)
@@ -125,7 +125,7 @@ def main(args=None):
 
     try:
         root_logger = setup_logging(config.get('Logging', 'file'), **config.log_levels())
-        root_logger.info("%s %s, libcloud %s", sys.argv[0], __version__, libcloud.__version__)
+        root_logger.info("%s %s started, libcloud %s", sys.argv[0], __version__, libcloud.__version__)
         node_setup, node_shutdown, node_update, node_monitor = \
             config.dispatch_classes()
         server_calculator = build_server_calculator(config)
index cfd611285cffc91933fefb15a2dee37e14995c7e..069bf168950ad4c33296876d1044b9eebea2a7a4 100644 (file)
@@ -11,6 +11,8 @@ import logging
 import socketserver
 import threading
 
+from ._version import __version__
+
 _logger = logging.getLogger('status.Handler')
 
 
@@ -74,10 +76,11 @@ class Tracker(object):
     def __init__(self):
         self._mtx = threading.Lock()
         self._latest = {}
+        self._version = {'Version' : __version__}
 
     def get_json(self):
         with self._mtx:
-            return json.dumps(self._latest)
+            return json.dumps(dict(self._latest, **self._version))
 
     def keys(self):
         with self._mtx:
index a236e4f0eecd1d2ba30c3e01adc227e245aba03e..23658667a61843147854bd97304107e416c0d568 100644 (file)
@@ -56,6 +56,7 @@ class StatusServerUpdates(unittest.TestCase):
                 resp = r.json()
                 self.assertEqual(n, resp['nodes_'+str(n)])
             self.assertEqual(1, resp['nodes_1'])
+            self.assertIn('Version', resp)
 
 
 class StatusServerDisabled(unittest.TestCase):
index db33cbfd004173ff787f9767eddd06a448744d63..a0006a4f8a8e0e70e7488f6ce4dee4ac4359984c 100644 (file)
@@ -13,15 +13,23 @@ import (
 )
 
 var logger = ctxlog.FromContext
+var version = "dev"
 
 func main() {
        log := logger(nil)
 
        configPath := flag.String("config", "/etc/arvados/ws/ws.yml", "`path` to config file")
        dumpConfig := flag.Bool("dump-config", false, "show current configuration and exit")
+       getVersion := flag.Bool("version", false, "Print version information and exit.")
        cfg := defaultConfig()
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("arvados-ws %s\n", version)
+               return
+       }
+
        err := config.LoadFile(&cfg, *configPath)
        if err != nil {
                log.Fatal(err)
@@ -39,7 +47,7 @@ func main() {
                return
        }
 
-       log.Info("started")
+       log.Printf("arvados-ws %s started", version)
        srv := &server{wsConfig: &cfg}
        log.Fatal(srv.Run())
 }
index 2b9bd66b1c466cfdee12d534cddc7eb11d4225e4..987c225eac8ba5a456aa0c2af2e7f56eede749be 100644 (file)
@@ -123,6 +123,7 @@ func (rtr *router) DebugStatus() interface{} {
 func (rtr *router) Status() interface{} {
        return map[string]interface{}{
                "Clients": atomic.LoadInt64(&rtr.status.ReqsActive),
+               "Version": version,
        }
 }
 
index c1caa2ad37d31d40f962fbc1af8d43f05f18a5a9..b1f943857a18f495f2777c24aa3627855aa0d9f6 100644 (file)
@@ -5,6 +5,7 @@
 package main
 
 import (
+       "encoding/json"
        "io/ioutil"
        "net/http"
        "sync"
@@ -90,6 +91,21 @@ func (s *serverSuite) TestHealth(c *check.C) {
        }
 }
 
+func (s *serverSuite) TestStatus(c *check.C) {
+       go s.srv.Run()
+       defer s.srv.Close()
+       s.srv.WaitReady()
+       req, err := http.NewRequest("GET", "http://"+s.srv.listener.Addr().String()+"/status.json", nil)
+       c.Assert(err, check.IsNil)
+       resp, err := http.DefaultClient.Do(req)
+       c.Check(err, check.IsNil)
+       c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+       var status map[string]interface{}
+       err = json.NewDecoder(resp.Body).Decode(&status)
+       c.Check(err, check.IsNil)
+       c.Check(status["Version"], check.Not(check.Equals), "")
+}
+
 func (s *serverSuite) TestHealthDisabled(c *check.C) {
        s.cfg.ManagementToken = ""
 
index d7efdefb6f68a791c1417c05357b5f2636227e84..6b4781c3549627f0f9874cc0be734b611b41c5dd 100644 (file)
@@ -19,6 +19,8 @@ import (
        "git.curoverse.com/arvados.git/sdk/go/arvados"
 )
 
+var version = "dev"
+
 type resourceList interface {
        Len() int
        GetItems() []interface{}
@@ -150,6 +152,10 @@ func ParseFlags(config *ConfigParams) error {
                "verbose",
                false,
                "Log informational messages. Off by default.")
+       getVersion := flags.Bool(
+               "version",
+               false,
+               "Print version information and exit.")
        parentGroupUUID := flags.String(
                "parent-group-uuid",
                "",
@@ -158,6 +164,12 @@ func ParseFlags(config *ConfigParams) error {
        // Parse args; omit the first arg which is the command name
        flags.Parse(os.Args[1:])
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("arv-sync-groups %s\n", version)
+               os.Exit(0)
+       }
+
        // Input file as a required positional argument
        if flags.NArg() == 0 {
                return fmt.Errorf("please provide a path to an input file")
@@ -276,7 +288,7 @@ func doMain(cfg *ConfigParams) error {
        }
        defer f.Close()
 
-       log.Printf("Group sync starting. Using %q as users id and parent group UUID %q", cfg.UserID, cfg.ParentGroupUUID)
+       log.Printf("arv-sync-groups %s started. Using %q as users id and parent group UUID %q", version, cfg.UserID, cfg.ParentGroupUUID)
 
        // Get the complete user list to minimize API Server requests
        allUsers := make(map[string]arvados.User)
index f87cccadbfac636f7fa4f9249363c842977f3446..d1c3037ca2b0adbe73f0019072fd7041659ebbe0 100644 (file)
@@ -4,4 +4,4 @@
 
 include agpl-3.0.txt
 include crunchstat_summary/dygraphs.js
-include crunchstat_summary/chartjs.js
+include crunchstat_summary/synchronizer.js
index 5c1d0994bf09c1376d5d6a4180503ffedc18334f..52e5534ef179f1124c90084e68596b8a43bf08e4 100644 (file)
@@ -72,6 +72,8 @@ window.onload = function() {
         });
     });
 
+    var sync = Dygraph.synchronize(Object.values(charts), {range: false});
+
     if (typeof window.debug === 'undefined')
         window.debug = {};
     window.debug.charts = charts;
index e72832eb5e005584969dd2982facb0b6b32c71cc..1314e9df35612817e260e6644212ef2e8a387bc3 100644 (file)
@@ -8,7 +8,7 @@ import crunchstat_summary.webchart
 class DygraphsChart(crunchstat_summary.webchart.WebChart):
     CSS = 'https://cdnjs.cloudflare.com/ajax/libs/dygraph/2.0.0/dygraph.min.css'
     JSLIB = 'https://cdnjs.cloudflare.com/ajax/libs/dygraph/2.0.0/dygraph.min.js'
-    JSASSET = 'dygraphs.js'
+    JSASSETS = ['synchronizer.js','dygraphs.js']
 
     def headHTML(self):
         return '<link rel="stylesheet" href="{}">\n'.format(self.CSS)
diff --git a/tools/crunchstat-summary/crunchstat_summary/synchronizer.js b/tools/crunchstat-summary/crunchstat_summary/synchronizer.js
new file mode 100644 (file)
index 0000000..78c8d42
--- /dev/null
@@ -0,0 +1,273 @@
+/**
+ * Synchronize zooming and/or selections between a set of dygraphs.
+ *
+ * Usage:
+ *
+ *   var g1 = new Dygraph(...),
+ *       g2 = new Dygraph(...),
+ *       ...;
+ *   var sync = Dygraph.synchronize(g1, g2, ...);
+ *   // charts are now synchronized
+ *   sync.detach();
+ *   // charts are no longer synchronized
+ *
+ * You can set options using the last parameter, for example:
+ *
+ *   var sync = Dygraph.synchronize(g1, g2, g3, {
+ *      selection: true,
+ *      zoom: true
+ *   });
+ *
+ * The default is to synchronize both of these.
+ *
+ * Instead of passing one Dygraph object as each parameter, you may also pass an
+ * array of dygraphs:
+ *
+ *   var sync = Dygraph.synchronize([g1, g2, g3], {
+ *      selection: false,
+ *      zoom: true
+ *   });
+ *
+ * You may also set `range: false` if you wish to only sync the x-axis.
+ * The `range` option has no effect unless `zoom` is true (the default).
+ *
+ * SPDX-License-Identifier: MIT
+ * Original source: https://github.com/danvk/dygraphs/blob/master/src/extras/synchronizer.js
+ * at commit b55a71d768d2f8de62877c32b3aec9e9975ac389
+ *
+ * Copyright (c) 2009 Dan Vanderkam
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ */
+(function() {
+/* global Dygraph:false */
+'use strict';
+
+var Dygraph;
+if (window.Dygraph) {
+  Dygraph = window.Dygraph;
+} else if (typeof(module) !== 'undefined') {
+  Dygraph = require('../dygraph');
+}
+
+var synchronize = function(/* dygraphs..., opts */) {
+  if (arguments.length === 0) {
+    throw 'Invalid invocation of Dygraph.synchronize(). Need >= 1 argument.';
+  }
+
+  var OPTIONS = ['selection', 'zoom', 'range'];
+  var opts = {
+    selection: true,
+    zoom: true,
+    range: true
+  };
+  var dygraphs = [];
+  var prevCallbacks = [];
+
+  var parseOpts = function(obj) {
+    if (!(obj instanceof Object)) {
+      throw 'Last argument must be either Dygraph or Object.';
+    } else {
+      for (var i = 0; i < OPTIONS.length; i++) {
+        var optName = OPTIONS[i];
+        if (obj.hasOwnProperty(optName)) opts[optName] = obj[optName];
+      }
+    }
+  };
+
+  if (arguments[0] instanceof Dygraph) {
+    // Arguments are Dygraph objects.
+    for (var i = 0; i < arguments.length; i++) {
+      if (arguments[i] instanceof Dygraph) {
+        dygraphs.push(arguments[i]);
+      } else {
+        break;
+      }
+    }
+    if (i < arguments.length - 1) {
+      throw 'Invalid invocation of Dygraph.synchronize(). ' +
+            'All but the last argument must be Dygraph objects.';
+    } else if (i == arguments.length - 1) {
+      parseOpts(arguments[arguments.length - 1]);
+    }
+  } else if (arguments[0].length) {
+    // Invoked w/ list of dygraphs, options
+    for (var i = 0; i < arguments[0].length; i++) {
+      dygraphs.push(arguments[0][i]);
+    }
+    if (arguments.length == 2) {
+      parseOpts(arguments[1]);
+    } else if (arguments.length > 2) {
+      throw 'Invalid invocation of Dygraph.synchronize(). ' +
+            'Expected two arguments: array and optional options argument.';
+    }  // otherwise arguments.length == 1, which is fine.
+  } else {
+    throw 'Invalid invocation of Dygraph.synchronize(). ' +
+          'First parameter must be either Dygraph or list of Dygraphs.';
+  }
+
+  if (dygraphs.length < 2) {
+    throw 'Invalid invocation of Dygraph.synchronize(). ' +
+          'Need two or more dygraphs to synchronize.';
+  }
+
+  var readycount = dygraphs.length;
+  for (var i = 0; i < dygraphs.length; i++) {
+    var g = dygraphs[i];
+    g.ready( function() {
+      if (--readycount == 0) {
+        // store original callbacks
+        var callBackTypes = ['drawCallback', 'highlightCallback', 'unhighlightCallback'];
+        for (var j = 0; j < dygraphs.length; j++) {
+          if (!prevCallbacks[j]) {
+            prevCallbacks[j] = {};
+          }
+          for (var k = callBackTypes.length - 1; k >= 0; k--) {
+            prevCallbacks[j][callBackTypes[k]] = dygraphs[j].getFunctionOption(callBackTypes[k]);
+          }
+        }
+
+        // Listen for draw, highlight, unhighlight callbacks.
+        if (opts.zoom) {
+          attachZoomHandlers(dygraphs, opts, prevCallbacks);
+        }
+
+        if (opts.selection) {
+          attachSelectionHandlers(dygraphs, prevCallbacks);
+        }
+      }
+    });
+  }
+
+  return {
+    detach: function() {
+      for (var i = 0; i < dygraphs.length; i++) {
+        var g = dygraphs[i];
+        if (opts.zoom) {
+          g.updateOptions({drawCallback: prevCallbacks[i].drawCallback});
+        }
+        if (opts.selection) {
+          g.updateOptions({
+            highlightCallback: prevCallbacks[i].highlightCallback,
+            unhighlightCallback: prevCallbacks[i].unhighlightCallback
+          });
+        }
+      }
+      // release references & make subsequent calls throw.
+      dygraphs = null;
+      opts = null;
+      prevCallbacks = null;
+    }
+  };
+};
+
+function arraysAreEqual(a, b) {
+  if (!Array.isArray(a) || !Array.isArray(b)) return false;
+  var i = a.length;
+  if (i !== b.length) return false;
+  while (i--) {
+    if (a[i] !== b[i]) return false;
+  }
+  return true;
+}
+
+function attachZoomHandlers(gs, syncOpts, prevCallbacks) {
+  var block = false;
+  for (var i = 0; i < gs.length; i++) {
+    var g = gs[i];
+    g.updateOptions({
+      drawCallback: function(me, initial) {
+        if (block || initial) return;
+        block = true;
+        var opts = {
+          dateWindow: me.xAxisRange()
+        };
+        if (syncOpts.range) opts.valueRange = me.yAxisRange();
+
+        for (var j = 0; j < gs.length; j++) {
+          if (gs[j] == me) {
+            if (prevCallbacks[j] && prevCallbacks[j].drawCallback) {
+              prevCallbacks[j].drawCallback.apply(this, arguments);
+            }
+            continue;
+          }
+
+          // Only redraw if there are new options
+          if (arraysAreEqual(opts.dateWindow, gs[j].getOption('dateWindow')) && 
+              arraysAreEqual(opts.valueRange, gs[j].getOption('valueRange'))) {
+            continue;
+          }
+
+          gs[j].updateOptions(opts);
+        }
+        block = false;
+      }
+    }, true /* no need to redraw */);
+  }
+}
+
+function attachSelectionHandlers(gs, prevCallbacks) {
+  var block = false;
+  for (var i = 0; i < gs.length; i++) {
+    var g = gs[i];
+
+    g.updateOptions({
+      highlightCallback: function(event, x, points, row, seriesName) {
+        if (block) return;
+        block = true;
+        var me = this;
+        for (var i = 0; i < gs.length; i++) {
+          if (me == gs[i]) {
+            if (prevCallbacks[i] && prevCallbacks[i].highlightCallback) {
+              prevCallbacks[i].highlightCallback.apply(this, arguments);
+            }
+            continue;
+          }
+          var idx = gs[i].getRowForX(x);
+          if (idx !== null) {
+            gs[i].setSelection(idx, seriesName);
+          }
+        }
+        block = false;
+      },
+      unhighlightCallback: function(event) {
+        if (block) return;
+        block = true;
+        var me = this;
+        for (var i = 0; i < gs.length; i++) {
+          if (me == gs[i]) {
+            if (prevCallbacks[i] && prevCallbacks[i].unhighlightCallback) {
+              prevCallbacks[i].unhighlightCallback.apply(this, arguments);
+            }
+            continue;
+          }
+          gs[i].clearSelection();
+        }
+        block = false;
+      }
+    }, true /* no need to redraw */);
+  }
+}
+
+Dygraph.synchronize = synchronize;
+
+})();
index 790b08da87f1b7894fc4e47511c63d44eda9604e..9d18883ce2506d71abe03e08abde2fee28006343 100644 (file)
@@ -10,7 +10,7 @@ import pkg_resources
 class WebChart(object):
     """Base class for a web chart.
 
-    Subclasses must assign JSLIB and JSASSET, and override the
+    Subclasses must assign JSLIB and JSASSETS, and override the
     chartdata() method.
     """
     JSLIB = None
@@ -33,7 +33,7 @@ class WebChart(object):
     def js(self):
         return 'var chartdata = {};\n{}'.format(
             json.dumps(self.sections()),
-            pkg_resources.resource_string('crunchstat_summary', self.JSASSET))
+            '\n'.join([pkg_resources.resource_string('crunchstat_summary', jsa) for jsa in self.JSASSETS]))
 
     def sections(self):
         return [
index 7dca3293d25fce94111eb1708fde156fc5c52387..2de7a96c9a2a81f85f79c6945935f36ae2f201c0 100644 (file)
@@ -20,6 +20,8 @@ import (
        "git.curoverse.com/arvados.git/sdk/go/keepclient"
 )
 
+var version = "dev"
+
 func main() {
        err := doMain(os.Args[1:])
        if err != nil {
@@ -62,9 +64,20 @@ func doMain(args []string) error {
                false,
                "Log progress of each block verification")
 
+       getVersion := flags.Bool(
+               "version",
+               false,
+               "Print version information and exit.")
+
        // Parse args; omit the first arg which is the command name
        flags.Parse(args)
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keep-block-check %s\n", version)
+               os.Exit(0)
+       }
+
        config, blobSigningKey, err := loadConfig(*configFile)
        if err != nil {
                return fmt.Errorf("Error loading configuration from file: %s", err.Error())
index 6c8a866291b10ca0efcff8e7d9f50ca6d727703b..6bf1abbba3865e602e7e81092b4152cedf0118be 100644 (file)
@@ -22,16 +22,20 @@ import (
        "crypto/rand"
        "encoding/binary"
        "flag"
+       "fmt"
        "io"
        "io/ioutil"
        "log"
        "net/http"
+       "os"
        "time"
 
        "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
        "git.curoverse.com/arvados.git/sdk/go/keepclient"
 )
 
+var version = "dev"
+
 // Command line config knobs
 var (
        BlockSize     = flag.Int("block-size", keepclient.BLOCKSIZE, "bytes per read/write op")
@@ -43,11 +47,20 @@ var (
        StatsInterval = flag.Duration("stats-interval", time.Second, "time interval between IO stats reports, or 0 to disable")
        ServiceURL    = flag.String("url", "", "specify scheme://host of a single keep service to exercise (instead of using all advertised services like normal clients)")
        ServiceUUID   = flag.String("uuid", "", "specify UUID of a single advertised keep service to exercise")
+       getVersion    = flag.Bool("version", false, "Print version information and exit.")
 )
 
 func main() {
        flag.Parse()
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keep-exercise %s\n", version)
+               os.Exit(0)
+       }
+
+       log.Printf("keep-exercise %s started", version)
+
        arv, err := arvadosclient.MakeArvadosClient()
        if err != nil {
                log.Fatal(err)
index a299d17febb1a46fc276ab6ed0fac5ee49ac09be..303f71f8fe77e2cb69d6b313e3a7e822fb96d5f4 100644 (file)
@@ -21,6 +21,8 @@ import (
        "git.curoverse.com/arvados.git/sdk/go/keepclient"
 )
 
+var version = "dev"
+
 func main() {
        err := doMain()
        if err != nil {
@@ -69,9 +71,20 @@ func doMain() error {
                0,
                "Lifetime of blob permission signatures on source keepservers. If not provided, this will be retrieved from the API server's discovery document.")
 
+       getVersion := flags.Bool(
+               "version",
+               false,
+               "Print version information and exit.")
+
        // Parse args; omit the first arg which is the command name
        flags.Parse(os.Args[1:])
 
+       // Print version information if requested
+       if *getVersion {
+               fmt.Printf("keep-rsync %s\n", version)
+               os.Exit(0)
+       }
+
        srcConfig, srcBlobSigningKey, err := loadConfig(*srcConfigFile)
        if err != nil {
                return fmt.Errorf("Error loading src configuration from file: %s", err.Error())