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) {
516 goSDKVersionWithoutPrefix = ""
517 pythonSDKTimestamp = ""
519 _, err = prepareGitCheckout(hash)
524 // Get the git hash for the tree
525 gitHash, err = gitHashFull()
530 // Get the git timestamp and version string for the sdk/go directory
531 err = os.Chdir(theConfig.DirPath + "/sdk/go")
535 goSDKTimestamp, err = timestampFromGit()
539 goSDKVersionWithoutPrefix, err = versionFromGit("")
545 // Get the git timestamp and version string for the sdk/python directory
546 err = os.Chdir(theConfig.DirPath + "/sdk/python")
550 pythonSDKTimestamp, err = timestampFromGit()
559 func pythonSDKVersionCheck(pythonSDKTimestamp string) (err error) {
560 var packageTimestamp string
561 packageTimestamp, err = timestampFromGit()
566 if pythonSDKTimestamp > packageTimestamp {
567 err = os.Chdir(theConfig.DirPath + "/sdk/python")
575 func getPackageVersions(hash string) (versions map[string]map[string]string, gitHash string, err error) {
576 versions = make(map[string]map[string]string)
578 gitHash, goSDKTimestamp, goSDKVersionWithoutPrefix, pythonSDKTimestamp, err := getPackageVersionsWorker(hash)
583 for _, p := range theConfig.Packages {
584 err = os.Chdir(theConfig.DirPath + "/" + p.sourceDir)
586 // Skip those packages for which the source directory doesn't exist
587 // in this revision of the source tree.
593 var packageVersion string
595 if (p.versionType == "git") || (p.versionType == "go") {
596 packageVersion, err = versionFromGit(p.versionPrefix)
601 if p.versionType == "go" {
602 var packageTimestamp string
603 packageTimestamp, err = timestampFromGit()
608 if goSDKTimestamp > packageTimestamp {
609 packageVersion = p.versionPrefix + goSDKVersionWithoutPrefix
611 } else if p.versionType == "python" {
612 // Not all of our packages that use our python sdk are automatically
613 // getting rebuilt when sdk/python changes. Yet.
614 if p.name == "python-arvados-cwl-runner" {
615 err = pythonSDKVersionCheck(pythonSDKTimestamp)
621 packageVersion, err = pythonVersionFromGit(p.versionPrefix)
625 } else if p.versionType == "ruby" {
626 packageVersion, err = rubyVersionFromGit(p.versionPrefix)
630 } else if p.versionType == "docker" {
631 // the arvados/jobs image version is always the latest of the
632 // sdk/python and the sdk/cwl version
633 if p.name == "arvados/jobs" {
634 err = pythonSDKVersionCheck(pythonSDKTimestamp)
639 packageVersion, err = dockerVersionFromGit()
645 if versions[strings.Title(p.packageType)] == nil {
646 versions[strings.Title(p.packageType)] = make(map[string]string)
648 versions[strings.Title(p.packageType)][name] = packageVersion
654 func logError(m []string) {
655 log.Printf(string(marshal(report{"Error", strings.Join(m, " ")})))
658 func logNotice(m []string) {
659 log.Printf(string(marshal(report{"Notice", strings.Join(m, " ")})))
662 func marshal(message interface{}) (encoded []byte) {
663 encoded, err := json.Marshal(message)
665 // do not call logError here because that would create an infinite loop
666 fmt.Fprintln(os.Stderr, "{\"Error\": \"Unable to marshal message into json:", message, "\"}")
672 func marshalAndWrite(w io.Writer, message interface{}) {
673 b := marshal(message)
675 errorMessage := "{\n\"Error\": \"Unspecified error\"\n}"
676 _, err := io.WriteString(w, errorMessage)
678 // do not call logError (it calls marshal and that function has already failed at this point)
679 fmt.Fprintln(os.Stderr, "{\"Error\": \"Unable to write message to client\"}")
684 logError([]string{"Unable to write message to client:", string(b)})
689 func packageVersionHandler(w http.ResponseWriter, r *http.Request) {
691 w.Header().Set("Content-Type", "application/json; charset=utf-8")
693 var packageVersions map[string]map[string]string
696 // Sanity check the input RequestHash
697 match, err := regexp.MatchString("^([a-z0-9]+|)$", r.URL.Path[11:])
699 m := report{"Error", "Error matching RequestHash"}
700 marshalAndWrite(w, m)
704 m := report{"Error", "Invalid RequestHash"}
705 marshalAndWrite(w, m)
709 hash := r.URL.Path[11:]
711 // Empty hash or non-standard hash length? Normalize it.
712 if len(hash) != 7 && len(hash) != 40 {
713 hash, err = normalizeRequestedHash(hash)
715 m := report{"Error", err.Error()}
716 marshalAndWrite(w, m)
722 rs, err := lookupInCache(hash)
724 packageVersions = rs.Versions
728 packageVersions, gitHash, err = getPackageVersions(hash)
730 m := report{"Error", err.Error()}
731 marshalAndWrite(w, m)
734 m := result{"", gitHash, packageVersions, true, ""}
735 err = writeToCache(hash, m)
737 logError([]string{"Unable to save entry in cache directory", theConfig.CacheDirPath})
742 m := result{hash, gitHash, packageVersions, cached, fmt.Sprintf("%v", time.Since(start))}
743 marshalAndWrite(w, m)
746 func aboutHandler(w http.ResponseWriter, r *http.Request) {
747 w.Header().Set("Content-Type", "application/json; charset=utf-8")
748 m := about{"Arvados Version Server", "0.1", "https://arvados.org"}
749 marshalAndWrite(w, m)
752 func helpHandler(w http.ResponseWriter, r *http.Request) {
753 w.Header().Set("Content-Type", "application/json; charset=utf-8")
754 m := help{"GET /v1/commit/ or GET /v1/commit/git-commit or GET /v1/about or GET /v1/help"}
755 marshalAndWrite(w, m)
758 func parseFlags() (configPath *string) {
760 flags := flag.NewFlagSet("arvados-version-server", flag.ExitOnError)
761 flags.Usage = func() { usage(flags) }
763 configPath = flags.String(
766 "`path` to YAML configuration file")
768 // Parse args; omit the first arg which is the command name
769 err := flags.Parse(os.Args[1:])
771 logError([]string{"Unable to parse command line arguments:", err.Error()})
779 err := os.Setenv("TZ", "UTC")
781 logError([]string{"Error setting environment variable:", err.Error()})
785 configPath := parseFlags()
787 err = readConfig(&theConfig, *configPath, defaultConfigPath)
789 logError([]string{"Unable to start Arvados Version Server:", err.Error()})
793 theConfig.Packages = loadPackages()
795 if theConfig.DirPath == "" {
796 theConfig.DirPath = "/tmp/arvados-version-server-checkout"
799 if theConfig.CacheDirPath == "" {
800 theConfig.CacheDirPath = "/tmp/arvados-version-server-cache"
803 if theConfig.GitExecutablePath == "" {
804 theConfig.GitExecutablePath = "/usr/bin/git"
807 if theConfig.ListenPort == "" {
808 theConfig.ListenPort = "80"
811 http.HandleFunc("/v1/commit/", packageVersionHandler)
812 http.HandleFunc("/v1/about", aboutHandler)
813 http.HandleFunc("/v1/help", helpHandler)
814 http.HandleFunc("/v1", helpHandler)
815 http.HandleFunc("/", helpHandler)
816 logNotice([]string{"Arvados Version Server listening on port", theConfig.ListenPort})
818 listener, err = net.Listen("tcp", ":"+theConfig.ListenPort)
821 logError([]string{"Unable to start Arvados Version Server:", err.Error()})
825 // Shut down the server gracefully (by closing the listener)
826 // if SIGTERM is received.
827 term := make(chan os.Signal, 1)
828 go func(sig <-chan os.Signal) {
830 logError([]string{"caught signal"})
833 signal.Notify(term, syscall.SIGTERM)
834 signal.Notify(term, syscall.SIGINT)
836 // Start serving requests.
837 _ = http.Serve(listener, nil)
838 // http.Serve returns an error when it gets the term or int signal
840 logNotice([]string{"Arvados Version Server shutting down"})
844 func readConfig(cfg interface{}, path string, defaultConfigPath string) error {
845 err := config.LoadFile(cfg, path)
846 if err != nil && os.IsNotExist(err) && path == defaultConfigPath {
847 logNotice([]string{"Config not specified. Continue with default configuration."})