sdk/internal/recording/server.go (384 lines of code) (raw):

//go:build go1.18 // +build go1.18 // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package recording import ( "archive/tar" "archive/zip" "compress/gzip" "fmt" "io" "log" "math/rand" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) const proxyManualStartEnv = "PROXY_MANUAL_START" type TestProxyInstance struct { Cmd *exec.Cmd Options *RecordingOptions } func getTestProxyDownloadFile() (string, error) { switch { case runtime.GOOS == "windows": // No ARM binaries for Windows, so return x64 return "test-proxy-standalone-win-x64.zip", nil case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": return "test-proxy-standalone-linux-x64.tar.gz", nil case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": return "test-proxy-standalone-linux-arm64.tar.gz", nil case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": return "test-proxy-standalone-osx-x64.zip", nil case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": return "test-proxy-standalone-osx-arm64.zip", nil default: return "", fmt.Errorf("unsupported OS/Arch combination: %s/%s", runtime.GOOS, runtime.GOARCH) } } // Modified from https://stackoverflow.com/a/24792688 func extractTestProxyZip(src string, dest string) error { r, err := zip.OpenReader(src) if err != nil { return err } defer func() { if err := r.Close(); err != nil { panic(err) } }() if err := os.MkdirAll(dest, 0755); err != nil { return err } // Closure to address file descriptors issue with all the deferred .Close() methods extractAndWriteFile := func(f *zip.File) error { rc, err := f.Open() if err != nil { return err } defer func() { if err := rc.Close(); err != nil { panic(err) } }() path := filepath.Join(dest, f.Name) log.Println("Extracting", path) // Check for ZipSlip (Directory traversal) if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("illegal file path: %s", path) } if f.FileInfo().IsDir() { if err := os.MkdirAll(path, f.Mode()); err != nil { return err } } else { if err := os.MkdirAll(filepath.Dir(path), f.Mode()); err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } defer func() { if err := f.Close(); err != nil { panic(err) } }() _, err = io.Copy(f, rc) if err != nil { return err } } return nil } for _, f := range r.File { err := extractAndWriteFile(f) if err != nil { return err } } return nil } func extractTestProxyArchive(archivePath string, outputDir string) error { log.Printf("Extracting %s\n", archivePath) file, err := os.Open(archivePath) if err != nil { return err } defer file.Close() gzipReader, err := gzip.NewReader(file) if err != nil { return err } defer gzipReader.Close() tarReader := tar.NewReader(gzipReader) for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return err } targetPath := filepath.Join(outputDir, header.Name) if !strings.HasPrefix(targetPath, filepath.Clean(outputDir)) { return fmt.Errorf("illegal file path: %q", header.Name) } log.Println("Extracting", targetPath) switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(targetPath, 0755); err != nil { return err } case tar.TypeReg: file, err := os.Create(targetPath) if err != nil { return err } defer file.Close() if _, err := io.Copy(file, tarReader); err != nil { return err } default: log.Printf("Unable to extract type %c in file %s\n", header.Typeflag, header.Name) } } return nil } func installTestProxy(archivePath string, outputDir string, proxyPath string) error { var err error if filepath.Ext(archivePath) == ".zip" { err = extractTestProxyZip(archivePath, outputDir) } else { err = extractTestProxyArchive(archivePath, outputDir) } if err != nil { return err } err = os.Chmod(proxyPath, 0755) if err != nil { return err } err = os.Remove(archivePath) if err != nil { return err } return nil } func restoreRecordings(proxyPath string, pathToRecordings string) error { if pathToRecordings == "" { return nil } absAssetLocation, _, err := getAssetsConfigLocation(pathToRecordings) if err != nil { return err } log.Printf("Running test proxy command: %s restore -a %s\n", proxyPath, absAssetLocation) cmd := exec.Command(proxyPath, "restore", "-a", absAssetLocation) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } return cmd.Wait() } func ensureTestProxyInstalled(proxyVersion string, proxyPath string, proxyDir string, pathToRecordings string) error { lockFile := filepath.Join(os.TempDir(), "test-proxy-install.lock") log.Printf("Waiting to acquire test proxy install lock %s\n", lockFile) maxTries := 600 // Wait 1 minute var i int for i = 0; i < maxTries; i++ { lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_EXCL, 0600) if err != nil { time.Sleep(100 * time.Millisecond) continue } // NOTE: the lockfile will not be removed on ctrl-c during download. // Go test seems to send an os.Interrupt signal on test setup completion, so if we // call os.Exit(1) on ctrl-c the tests will never run. If we don't call os.Exit(1), // the tests cannot be canceled. // Therefore, if ctrl-c is pressed during download, the user will have to manually // remove the lockfile in order to get the tests running again. defer func() { lock.Close() os.Remove(lockFile) }() break } if i >= maxTries { return fmt.Errorf("timed out waiting to acquire test proxy install lock. Ensure %s does not exist", lockFile) } cmd := exec.Command(proxyPath, "--version") out, err := cmd.Output() if err != nil { log.Printf("Test proxy not detected at %s, downloading...\n", proxyPath) } else { // TODO: fix proxy CLI tool versioning output to match the actual version we download installedVersion := "1.0.0-dev." + strings.TrimSpace(string(out)) if installedVersion == proxyVersion { log.Printf("Test proxy version %s already installed\n", proxyVersion) return restoreRecordings(proxyPath, pathToRecordings) } else { log.Printf("Test proxy version %s does not match required version %s\n", installedVersion, proxyVersion) } } proxyFile, err := getTestProxyDownloadFile() if err != nil { return err } proxyDownloadPath := filepath.Join(proxyDir, proxyFile) archive, err := os.Create(proxyDownloadPath) if err != nil { return err } log.Printf("Downloading test proxy version %s to %s for %s/%s\n", proxyVersion, proxyPath, runtime.GOOS, runtime.GOARCH) proxyUrl := fmt.Sprintf("https://github.com/Azure/azure-sdk-tools/releases/download/Azure.Sdk.Tools.TestProxy_%s/%s", proxyVersion, proxyFile) resp, err := http.Get(proxyUrl) if err != nil { return err } _, err = io.Copy(archive, resp.Body) if err != nil { return err } err = resp.Body.Close() if err != nil { return err } err = archive.Close() if err != nil { return err } err = installTestProxy(proxyDownloadPath, proxyDir, proxyPath) if err != nil { return err } return restoreRecordings(proxyPath, pathToRecordings) } func getProxyLog() (*os.File, error) { rng := rand.New(rand.NewSource(time.Now().UnixNano())) const letters = "abcdefghijklmnopqrstuvwxyz" suffix := make([]byte, 6) for i := range suffix { suffix[i] = letters[rng.Intn(len(letters))] } proxyLogName := fmt.Sprintf("test-proxy.log.%s", suffix) proxyLog, err := os.Create(filepath.Join(os.TempDir(), proxyLogName)) if err != nil { return nil, err } return proxyLog, nil } func getProxyVersion(gitRoot string) (string, error) { proxyVersionConfig := filepath.Join(gitRoot, "eng/common/testproxy/target_version.txt") overrideProxyVersionConfig := filepath.Join(gitRoot, "eng/target_proxy_version.txt") if _, err := os.Stat(overrideProxyVersionConfig); err == nil { version, err := os.ReadFile(overrideProxyVersionConfig) if err == nil { proxyVersion := strings.TrimSpace(string(version)) return proxyVersion, nil } } version, err := os.ReadFile(proxyVersionConfig) if err != nil { return "", err } proxyVersion := strings.TrimSpace(string(version)) return proxyVersion, nil } func setTestProxyEnv(gitRoot string) { devCertPath := filepath.Join(gitRoot, "eng/common/testproxy/dotnet-devcert.pfx") os.Setenv("ASPNETCORE_Kestrel__Certificates__Default__Path", devCertPath) os.Setenv("ASPNETCORE_Kestrel__Certificates__Default__Password", "password") } func waitForProxyStart(cmd *exec.Cmd, options *RecordingOptions) (*TestProxyInstance, error) { maxTries := 50 // Extend sleep time in devops pipeline, proxy takes longer to start up if os.Getenv("SYSTEM_TEAMPROJECTID") != "" { maxTries = 200 } log.Printf("Started test proxy instance (PID %d) on %s\n", cmd.Process.Pid, options.baseURL()) client, _ := GetHTTPClient(nil) client.Timeout = 1 * time.Second log.Printf("Waiting up to %d seconds for test-proxy server to respond...\n", (maxTries / 10)) var i int for i = 0; i < maxTries; i++ { uri := fmt.Sprintf("https://localhost:%d/Admin/IsAlive", options.ProxyPort) req, _ := http.NewRequest("GET", uri, nil) req.Close = true resp, err := client.Do(req) if resp != nil && resp.Body != nil { resp.Body.Close() } if err != nil { time.Sleep(100 * time.Millisecond) continue } return &TestProxyInstance{Cmd: cmd, Options: options}, nil } return nil, fmt.Errorf("test proxy server did not become available in the allotted time") } func inCI() bool { return os.Getenv("TF_BUILD") != "" || os.Getenv("GITHUB_ACTIONS") != "" } func StartTestProxy(pathToRecordings string, options *RecordingOptions) (*TestProxyInstance, error) { manualStart := strings.ToLower(os.Getenv(proxyManualStartEnv)) if manualStart == "true" && !inCI() { log.Printf("%s env variable is set to true, not starting test proxy...\n", proxyManualStartEnv) return nil, nil } cwd, err := os.Getwd() if err != nil { return nil, err } gitRoot, err := getGitRoot(cwd) if err != nil { return nil, err } proxyVersion, err := getProxyVersion(gitRoot) if err != nil { return nil, err } proxyDir := filepath.Join(gitRoot, ".proxy") if err := os.MkdirAll(proxyDir, 0755); err != nil { return nil, err } proxyPath := filepath.Join(proxyDir, "Azure.Sdk.Tools.TestProxy") if runtime.GOOS == "windows" { proxyPath += ".exe" } err = ensureTestProxyInstalled(proxyVersion, proxyPath, proxyDir, pathToRecordings) if err != nil { return nil, err } proxyLog, err := getProxyLog() if err != nil { return nil, err } defer proxyLog.Close() setTestProxyEnv(gitRoot) if options == nil { options = defaultOptions() } insecure := "" if options.insecure { insecure = "--insecure" } args := []string{"start", "--storage-location", gitRoot, insecure, "--", "--urls=" + options.baseURL()} log.Printf("Running test proxy command: %s %s", proxyPath, strings.Join(args, " ")) log.Printf("Test proxy log location: %s\n", proxyLog.Name()) cmd := exec.Command(proxyPath, args...) cmd.Stdout = proxyLog cmd.Stderr = proxyLog if err := cmd.Start(); err != nil { return nil, err } done := make(chan error, 1) go func() { done <- cmd.Wait() }() return waitForProxyStart(cmd, options) } // NOTE: The process will be killed if the user hits ctrl-c mid-way through tests, as go will // kill child processes when the main process does not exit cleanly. No os.Interrupt handlers // need to be added after starting the proxy server in tests. func StopTestProxy(proxyInstance *TestProxyInstance) error { if proxyInstance == nil || proxyInstance.Cmd == nil || proxyInstance.Cmd.Process == nil { return nil } log.Printf("Stopping test proxy instance (PID %d) on %s\n", proxyInstance.Cmd.Process.Pid, proxyInstance.Options.baseURL()) err := proxyInstance.Cmd.Process.Kill() if err != nil { return err } return nil }