1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
11 "git.curoverse.com/arvados.git/sdk/go/config"
26 var listener net.Listener
44 Versions map[string]map[string]string
63 GitExecutablePath string
71 const defaultConfigPath = "/etc/arvados/version-server/version-server.yml"
73 func loadPackages() (packages []bundle) {
78 packageType: "distribution",
83 sourceDir: "apps/workbench",
84 name: "arvados-workbench",
85 packageType: "distribution",
91 name: "python-arvados-cwl-runner",
92 packageType: "distribution",
93 versionType: "python",
98 name: "arvados-cwl-runner",
99 packageType: "python",
100 versionType: "python",
101 versionPrefix: "1.0",
104 sourceDir: "sdk/cwl",
105 name: "arvados/jobs",
106 packageType: "docker",
107 versionType: "docker",
111 sourceDir: "sdk/go/crunchrunner",
112 name: "crunchrunner",
113 packageType: "distribution",
115 versionPrefix: "0.1",
118 sourceDir: "sdk/pam",
119 name: "libpam-arvados",
120 packageType: "distribution",
121 versionType: "python",
122 versionPrefix: "0.1",
125 sourceDir: "sdk/pam",
127 packageType: "python",
128 versionType: "python",
129 versionPrefix: "0.1",
132 sourceDir: "sdk/python",
133 name: "python-arvados-python-client",
134 packageType: "distribution",
135 versionType: "python",
136 versionPrefix: "0.1",
139 sourceDir: "sdk/python",
140 name: "arvados-python-client",
141 packageType: "python",
142 versionType: "python",
143 versionPrefix: "0.1",
146 sourceDir: "services/api",
147 name: "arvados-api-server",
148 packageType: "distribution",
150 versionPrefix: "0.1",
153 sourceDir: "services/arv-git-httpd",
154 name: "arvados-git-httpd",
155 packageType: "distribution",
157 versionPrefix: "0.1",
160 sourceDir: "services/crunch-dispatch-local",
161 name: "crunch-dispatch-local",
162 packageType: "distribution",
164 versionPrefix: "0.1",
167 sourceDir: "services/crunch-dispatch-slurm",
168 name: "crunch-dispatch-slurm",
169 packageType: "distribution",
171 versionPrefix: "0.1",
174 sourceDir: "services/crunch-run",
176 packageType: "distribution",
178 versionPrefix: "0.1",
181 sourceDir: "services/crunchstat",
183 packageType: "distribution",
185 versionPrefix: "0.1",
188 sourceDir: "services/dockercleaner",
189 name: "arvados-docker-cleaner",
190 packageType: "distribution",
191 versionType: "python",
192 versionPrefix: "0.1",
195 sourceDir: "services/fuse",
196 name: "python-arvados-fuse",
197 packageType: "distribution",
198 versionType: "python",
199 versionPrefix: "0.1",
202 sourceDir: "services/fuse",
203 name: "arvados_fuse",
204 packageType: "python",
205 versionType: "python",
206 versionPrefix: "0.1",
209 sourceDir: "services/keep-balance",
210 name: "keep-balance",
211 packageType: "distribution",
213 versionPrefix: "0.1",
216 sourceDir: "services/keepproxy",
218 packageType: "distribution",
220 versionPrefix: "0.1",
223 sourceDir: "services/keepstore",
225 packageType: "distribution",
227 versionPrefix: "0.1",
230 sourceDir: "services/keep-web",
232 packageType: "distribution",
234 versionPrefix: "0.1",
237 sourceDir: "services/nodemanager",
238 name: "arvados-node-manager",
239 packageType: "distribution",
240 versionType: "python",
241 versionPrefix: "0.1",
244 sourceDir: "services/nodemanager",
245 name: "arvados-node-manager",
246 packageType: "python",
247 versionType: "python",
248 versionPrefix: "0.1",
251 sourceDir: "services/ws",
253 packageType: "distribution",
255 versionPrefix: "0.1",
258 sourceDir: "tools/crunchstat-summary",
259 name: "crunchstat-summary",
260 packageType: "distribution",
262 versionPrefix: "0.1",
265 sourceDir: "tools/keep-block-check",
266 name: "keep-block-check",
267 packageType: "distribution",
269 versionPrefix: "0.1",
272 sourceDir: "tools/keep-exercise",
273 name: "keep-exercise",
274 packageType: "distribution",
276 versionPrefix: "0.1",
279 sourceDir: "tools/keep-rsync",
281 packageType: "distribution",
283 versionPrefix: "0.1",
286 sourceDir: "sdk/ruby",
290 versionPrefix: "0.1",
293 sourceDir: "sdk/cli",
297 versionPrefix: "0.1",
300 sourceDir: "services/login-sync",
301 name: "arvados-login-sync",
304 versionPrefix: "0.1",
310 func lookupInCache(hash string) (result, error) {
311 statData, err := os.Stat(theConfig.CacheDirPath)
312 if os.IsNotExist(err) {
313 err = os.MkdirAll(theConfig.CacheDirPath, 0700)
315 logError([]string{"Error creating directory", theConfig.CacheDirPath, ":", err.Error()})
318 if !statData.IsDir() {
319 logError([]string{"The path", theConfig.CacheDirPath, "is not a directory"})
320 return result{}, fmt.Errorf("The path %s is not a directory", theConfig.CacheDirPath)
323 file, e := ioutil.ReadFile(theConfig.CacheDirPath + "/" + hash)
325 return result{}, fmt.Errorf("File error: %v\n", e)
328 err = json.Unmarshal(file, &m)
332 func writeToCache(hash string, data result) (err error) {
333 statData, err := os.Stat(theConfig.CacheDirPath)
334 if os.IsNotExist(err) {
335 err = os.MkdirAll(theConfig.CacheDirPath, 0700)
337 logError([]string{"Error creating directory", theConfig.CacheDirPath, ":", err.Error()})
340 if !statData.IsDir() {
341 logError([]string{"The path", theConfig.CacheDirPath, "is not a directory"})
342 return fmt.Errorf("The path %s is not a directory", theConfig.CacheDirPath)
346 jsonData, err := json.Marshal(data)
350 err = ioutil.WriteFile(theConfig.CacheDirPath+"/"+hash, jsonData, 0644)
354 func prepareGitPath(hash string) error {
355 statData, err := os.Stat(theConfig.DirPath)
356 if os.IsNotExist(err) {
357 err = os.MkdirAll(theConfig.DirPath, 0700)
359 logError([]string{"Error creating directory", theConfig.DirPath, ":", err.Error()})
360 return fmt.Errorf("Error creating directory %s", theConfig.DirPath)
362 cmdArgs := []string{"clone", "https://github.com/curoverse/arvados.git", theConfig.DirPath}
363 if _, err = exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output(); err != nil {
364 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
365 return fmt.Errorf("There was an error cloning the repository")
368 if !statData.IsDir() {
369 logError([]string{"The path", theConfig.DirPath, "is not a directory"})
370 return fmt.Errorf("The path %s is not a directory", theConfig.DirPath)
376 func prepareGitCheckout(hash string) (string, error) {
377 err := prepareGitPath(hash)
381 err = os.Chdir(theConfig.DirPath)
383 logError([]string{"Error changing directory to", theConfig.DirPath})
384 return "", fmt.Errorf("Error changing directory to %s", theConfig.DirPath)
386 cmdArgs := []string{"fetch", "--all"}
387 if _, err := exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output(); err != nil {
388 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
389 return "", fmt.Errorf("There was an error fetching all remotes")
394 cmdArgs = []string{"checkout", hash}
395 if _, err := exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output(); err != nil {
396 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
397 return "", fmt.Errorf("There was an error checking out the requested revision")
399 if hash == "master" {
400 cmdArgs := []string{"reset", "--hard", "origin/master"}
401 if _, err := exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output(); err != nil {
402 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
403 return "", fmt.Errorf("There was an error fetching all remotes")
409 // Generates the hash for the latest git commit for the current working directory
410 func gitHashFull() (string, error) {
411 cmdArgs := []string{"log", "-n1", "--first-parent", "--max-count=1", "--format=format:%H", "."}
412 cmdOut, err := exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output()
414 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
415 return "", fmt.Errorf("There was an error getting the git hash for this revision")
417 return string(cmdOut), nil
420 // Generates a version number from the git log for the current working directory
421 func versionFromGit(prefix string) (string, error) {
422 gitTs, err := getGitTs()
426 cmdArgs := []string{"log", "-n1", "--first-parent", "--max-count=1", "--format=format:%h", "."}
427 gitHash, err := exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output()
429 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
430 return "", fmt.Errorf("There was an error getting the git hash for this revision")
432 cmdName := "/bin/date"
433 cmdArgs = []string{"-ud", "@" + string(gitTs), "+%Y%m%d%H%M%S"}
434 date, err := exec.Command(cmdName, cmdArgs...).Output()
436 logError([]string{"There was an error running the command ", cmdName, strings.Join(cmdArgs, " "), err.Error()})
437 return "", fmt.Errorf("There was an error converting the datestamp for this revision")
440 return fmt.Sprintf("%s.%s.%s", strings.TrimSpace(prefix), strings.TrimSpace(string(date)), strings.TrimSpace(string(gitHash))), nil
443 // Generates a python package version number from the git log for the current working directory
444 func rubyVersionFromGit(prefix string) (string, error) {
445 gitTs, err := getGitTs()
449 cmdName := "/bin/date"
450 cmdArgs := []string{"-ud", "@" + string(gitTs), "+%Y%m%d%H%M%S"}
451 date, err := exec.Command(cmdName, cmdArgs...).Output()
453 logError([]string{"There was an error running the command ", cmdName, strings.Join(cmdArgs, " "), err.Error()})
454 return "", fmt.Errorf("There was an error converting the datestamp for this revision")
457 return fmt.Sprintf("%s.%s", strings.TrimSpace(prefix), strings.TrimSpace(string(date))), nil
460 // Generates a python package version number from the git log for the current working directory
461 func pythonVersionFromGit(prefix string) (string, error) {
462 rv, err := rubyVersionFromGit(prefix)
469 // Generates a docker image version number from the git log for the current working directory
470 func dockerVersionFromGit() (string, error) {
471 rv, err := gitHashFull()
478 func getGitTs() (gitTs []byte, err error) {
479 cmdArgs := []string{"log", "-n1", "--first-parent", "--max-count=1", "--format=format:%ct", "."}
480 gitTs, err = exec.Command(theConfig.GitExecutablePath, cmdArgs...).Output()
482 logError([]string{"There was an error running the command ", theConfig.GitExecutablePath, strings.Join(cmdArgs, " "), err.Error()})
483 return nil, fmt.Errorf("There was an error getting the git hash for this revision")
488 // Generates a timestamp from the git log for the current working directory
489 func timestampFromGit() (string, error) {
490 gitTs, err := getGitTs()
494 return fmt.Sprintf("%s", strings.TrimSpace(string(gitTs))), nil
497 func normalizeRequestedHash(hash string) (string, error) {
498 _, err := prepareGitCheckout(hash)
503 // Get the git hash for the tree
505 gitHash, err = gitHashFull()
513 func getPackageVersionsWorker(hash string) (gitHash string, goSDKTimestamp string, goSDKVersionWithoutPrefix string, pythonSDKTimestamp string, err error) {
514 _, err = prepareGitCheckout(hash)
516 return "", "", "", "", err
519 // Get the git hash for the tree
520 gitHash, err = gitHashFull()
522 return "", "", "", "", err
525 // Get the git timestamp and version string for the sdk/go directory
526 err = os.Chdir(theConfig.DirPath + "/sdk/go")
529 goSDKVersionWithoutPrefix = ""
532 goSDKTimestamp, err = timestampFromGit()
534 return "", "", "", "", err
536 goSDKVersionWithoutPrefix, err = versionFromGit("")
538 return "", "", "", "", err
542 // Get the git timestamp and version string for the sdk/python directory
543 err = os.Chdir(theConfig.DirPath + "/sdk/python")
545 pythonSDKTimestamp = ""
548 pythonSDKTimestamp, err = timestampFromGit()
550 return "", "", "", "", err
557 func pythonSDKVersionCheck(pythonSDKTimestamp string) (err error) {
558 var packageTimestamp string
559 packageTimestamp, err = timestampFromGit()
564 if pythonSDKTimestamp > packageTimestamp {
565 err = os.Chdir(theConfig.DirPath + "/sdk/python")
573 func getPackageVersions(hash string) (versions map[string]map[string]string, gitHash string, err error) {
574 versions = make(map[string]map[string]string)
576 gitHash, goSDKTimestamp, goSDKVersionWithoutPrefix, pythonSDKTimestamp, err := getPackageVersionsWorker(hash)
581 for _, p := range theConfig.Packages {
582 err = os.Chdir(theConfig.DirPath + "/" + p.sourceDir)
584 // Skip those packages for which the source directory doesn't exist
585 // in this revision of the source tree.
591 var packageVersion string
593 if (p.versionType == "git") || (p.versionType == "go") {
594 packageVersion, err = versionFromGit(p.versionPrefix)
599 if p.versionType == "go" {
600 var packageTimestamp string
601 packageTimestamp, err = timestampFromGit()
606 if goSDKTimestamp > packageTimestamp {
607 packageVersion = p.versionPrefix + goSDKVersionWithoutPrefix
609 } else if p.versionType == "python" {
610 // Not all of our packages that use our python sdk are automatically
611 // getting rebuilt when sdk/python changes. Yet.
612 if p.name == "python-arvados-cwl-runner" {
613 err = pythonSDKVersionCheck(pythonSDKTimestamp)
619 packageVersion, err = pythonVersionFromGit(p.versionPrefix)
623 } else if p.versionType == "ruby" {
624 packageVersion, err = rubyVersionFromGit(p.versionPrefix)
628 } else if p.versionType == "docker" {
629 // the arvados/jobs image version is always the latest of the
630 // sdk/python and the sdk/cwl version
631 if p.name == "arvados/jobs" {
632 err = pythonSDKVersionCheck(pythonSDKTimestamp)
637 packageVersion, err = dockerVersionFromGit()
643 if versions[strings.Title(p.packageType)] == nil {
644 versions[strings.Title(p.packageType)] = make(map[string]string)
646 versions[strings.Title(p.packageType)][name] = packageVersion
652 func logError(m []string) {
653 log.Printf(string(marshal(report{"Error", strings.Join(m, " ")})))
656 func logNotice(m []string) {
657 log.Printf(string(marshal(report{"Notice", strings.Join(m, " ")})))
660 func marshal(message interface{}) (encoded []byte) {
661 encoded, err := json.Marshal(message)
663 // do not call logError here because that would create an infinite loop
664 fmt.Fprintln(os.Stderr, "{\"Error\": \"Unable to marshal message into json:", message, "\"}")
670 func marshalAndWrite(w io.Writer, message interface{}) {
671 b := marshal(message)
673 errorMessage := "{\n\"Error\": \"Unspecified error\"\n}"
674 _, err := io.WriteString(w, errorMessage)
676 // do not call logError (it calls marshal and that function has already failed at this point)
677 fmt.Fprintln(os.Stderr, "{\"Error\": \"Unable to write message to client\"}")
682 logError([]string{"Unable to write message to client:", string(b)})
687 func packageVersionHandler(w http.ResponseWriter, r *http.Request) {
689 w.Header().Set("Content-Type", "application/json; charset=utf-8")
691 var packageVersions map[string]map[string]string
694 // Sanity check the input RequestHash
695 match, err := regexp.MatchString("^([a-z0-9]+|)$", r.URL.Path[11:])
697 m := report{"Error", "Error matching RequestHash"}
698 marshalAndWrite(w, m)
702 m := report{"Error", "Invalid RequestHash"}
703 marshalAndWrite(w, m)
707 hash := r.URL.Path[11:]
709 // Empty hash or non-standard hash length? Normalize it.
710 if len(hash) != 7 && len(hash) != 40 {
711 hash, err = normalizeRequestedHash(hash)
713 m := report{"Error", err.Error()}
714 marshalAndWrite(w, m)
720 rs, err := lookupInCache(hash)
722 packageVersions = rs.Versions
726 packageVersions, gitHash, err = getPackageVersions(hash)
728 m := report{"Error", err.Error()}
729 marshalAndWrite(w, m)
732 m := result{"", gitHash, packageVersions, true, ""}
733 err = writeToCache(hash, m)
735 logError([]string{"Unable to save entry in cache directory", theConfig.CacheDirPath})
740 m := result{hash, gitHash, packageVersions, cached, fmt.Sprintf("%v", time.Since(start))}
741 marshalAndWrite(w, m)
744 func aboutHandler(w http.ResponseWriter, r *http.Request) {
745 w.Header().Set("Content-Type", "application/json; charset=utf-8")
746 m := about{"Arvados Version Server", "0.1", "https://arvados.org"}
747 marshalAndWrite(w, m)
750 func helpHandler(w http.ResponseWriter, r *http.Request) {
751 w.Header().Set("Content-Type", "application/json; charset=utf-8")
752 m := help{"GET /v1/commit/ or GET /v1/commit/git-commit or GET /v1/about or GET /v1/help"}
753 marshalAndWrite(w, m)
756 func parseFlags() (configPath *string) {
758 flags := flag.NewFlagSet("arvados-version-server", flag.ExitOnError)
759 flags.Usage = func() { usage(flags) }
761 configPath = flags.String(
764 "`path` to YAML configuration file")
766 // Parse args; omit the first arg which is the command name
767 err := flags.Parse(os.Args[1:])
769 logError([]string{"Unable to parse command line arguments:", err.Error()})
777 err := os.Setenv("TZ", "UTC")
779 logError([]string{"Error setting environment variable:", err.Error()})
783 configPath := parseFlags()
785 err = readConfig(&theConfig, *configPath, defaultConfigPath)
787 logError([]string{"Unable to start Arvados Version Server:", err.Error()})
791 theConfig.Packages = loadPackages()
793 if theConfig.DirPath == "" {
794 theConfig.DirPath = "/tmp/arvados-version-server-checkout"
797 if theConfig.CacheDirPath == "" {
798 theConfig.CacheDirPath = "/tmp/arvados-version-server-cache"
801 if theConfig.GitExecutablePath == "" {
802 theConfig.GitExecutablePath = "/usr/bin/git"
805 if theConfig.ListenPort == "" {
806 theConfig.ListenPort = "80"
809 http.HandleFunc("/v1/commit/", packageVersionHandler)
810 http.HandleFunc("/v1/about", aboutHandler)
811 http.HandleFunc("/v1/help", helpHandler)
812 http.HandleFunc("/v1", helpHandler)
813 http.HandleFunc("/", helpHandler)
814 logNotice([]string{"Arvados Version Server listening on port", theConfig.ListenPort})
816 listener, err = net.Listen("tcp", ":"+theConfig.ListenPort)
819 logError([]string{"Unable to start Arvados Version Server:", err.Error()})
823 // Shut down the server gracefully (by closing the listener)
824 // if SIGTERM is received.
825 term := make(chan os.Signal, 1)
826 go func(sig <-chan os.Signal) {
828 logError([]string{"caught signal"})
831 signal.Notify(term, syscall.SIGTERM)
832 signal.Notify(term, syscall.SIGINT)
834 // Start serving requests.
835 _ = http.Serve(listener, nil)
836 // http.Serve returns an error when it gets the term or int signal
838 logNotice([]string{"Arvados Version Server shutting down"})
842 func readConfig(cfg interface{}, path string, defaultConfigPath string) error {
843 err := config.LoadFile(cfg, path)
844 if err != nil && os.IsNotExist(err) && path == defaultConfigPath {
845 logNotice([]string{"Config not specified. Continue with default configuration."})