| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package main |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "io/ioutil" |
| "os" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "testing" |
| "time" |
| |
| "infra/tools/git/state" |
| |
| "go.chromium.org/luci/common/errors" |
| "go.chromium.org/luci/common/logging" |
| "go.chromium.org/luci/common/retry" |
| "go.chromium.org/luci/common/system/environ" |
| "go.chromium.org/luci/common/system/filesystem" |
| |
| . "github.com/smartystreets/goconvey/convey" |
| ) |
| |
| const testAgentFailedReturnCode = 128 |
| |
| type testAgentRequest struct { |
| // ReturnCode is the return code that the agent should return with on success. |
| ReturnCode int |
| |
| // ReadStdin, if true, instructs the Agent to read from STDIN and reflect it |
| // in its response. |
| ReadStdin bool |
| |
| // Stdout is the content that will be printed to STDOUT. |
| Stdout []byte |
| |
| // Stderr is the content that will be printed to STDERR. |
| Stderr []byte |
| |
| // BlockIndefinitely, if true, instructs the process to block indefinitely. |
| BlockIndefinitely bool |
| |
| // CreateDirectory instructs the agent to create a new directory during |
| // execution. |
| CreateDirectory string |
| // CreateDirectoryExistsReturnCode is the return code that will be returned |
| // if CreateDirectory already exists. Otherwise, ReturnCode will be returned. |
| CreateDirectoryExistsReturnCode int |
| } |
| |
| type testAgentResponse struct { |
| // Args is the set of arguments received by the test agent. |
| Args []string |
| |
| // Env is the initial sorted environment received by the agent. |
| Env []string |
| |
| // Incomplete indicates that the Agent's execution was incomplete. |
| // |
| // When the Agent starts, it will write a response with Incomplete set to |
| // true. After it intentionally exits, it will write a second response with |
| // Incomplete set to false. |
| Incomplete bool |
| |
| // Stdin is the contents of STDIN, if ReadStdin was true. |
| Stdin []byte |
| } |
| |
| type testAgent struct { |
| inPath string |
| outPath string |
| |
| in testAgentRequest |
| out testAgentResponse |
| } |
| |
| func TestGitCommand(t *testing.T) { |
| // Special testing bootstrap case. |
| const runTestAgentENV = "INFRA_TOOLS_GIT__GIT_TEST__TESTING_AGENT" |
| testRunnerArgs := []string{"-test.run", "^TestGitCommand$", "--"} |
| if tb := os.Getenv(runTestAgentENV); tb != "" { |
| c := baseTestContext() |
| |
| if err := os.Unsetenv(runTestAgentENV); err != nil { |
| logging.Errorf(c, "Failed to clear %q: %s", runTestAgentENV, err) |
| os.Exit(testAgentFailedReturnCode) |
| } |
| |
| // Invoke our main agent entry point. |
| // |
| // Note that we cut off the executable and injected "-test.run" arguments. |
| ta := makeTestAgent(tb) |
| os.Exit(ta.run(c, os.Args[1+len(testRunnerArgs):])) |
| return |
| } |
| |
| // Begin: actual test code. |
| t.Parallel() |
| |
| executable, err := os.Executable() |
| if err != nil { |
| t.Fatalf("failed to get self executable: %s", err) |
| } |
| |
| Convey(`Using a test setup for "Git" command`, t, func() { |
| tdir, err := ioutil.TempDir(t.TempDir(), "git_command") |
| So(err, ShouldBeNil) |
| |
| var in testAgentRequest |
| var out testAgentResponse |
| gc := GitCommand{ |
| State: state.State{ |
| GitPath: executable, |
| }, |
| WorkDir: tdir, |
| |
| // Skip arguments added in "runAgent". |
| testParseSkipArgs: len(testRunnerArgs), |
| } |
| |
| env := environ.New(nil) |
| ta := makeTestAgent(filepath.Join(tdir, "agent_params.json")) |
| |
| writeRequest := func() { |
| if err := atomicWriteJSON(&in, ta.inPath); err != nil { |
| t.Fatalf("Failed to write agent request JSON: %s", err) |
| } |
| } |
| |
| encodeStateENV := func(st state.State) string { |
| return strings.Join([]string{gitWrapperENV, st.ToENV()}, "=") |
| } |
| |
| prepareAgentENV := func(vars ...string) []string { |
| return environ.New(vars).Sorted() |
| } |
| |
| var args []string |
| runAgent := func(c context.Context) (int, error) { |
| writeRequest() |
| |
| // Clone inputs so we can modify them for the test harness. |
| env2 := env.Clone() |
| args2 := append(append([]string(nil), testRunnerArgs...), args...) |
| env2.Set(runTestAgentENV, ta.inPath) |
| |
| rc, err := gc.Run(c, args2, env2) |
| if err == nil && rc == testAgentFailedReturnCode { |
| t.Fatalf("Test agent failed: %d", rc) |
| } |
| |
| // Read our output JSON. Since we move it on atomic completion, if it is |
| // present, it is expected to be valid. |
| switch _, err = os.Stat(ta.outPath); { |
| case err == nil: |
| resp, err := ioutil.ReadFile(ta.outPath) |
| if err != nil { |
| t.Fatalf("Failed to read agent JSON response: %s", err) |
| } |
| if err := json.Unmarshal(resp, &out); err != nil { |
| t.Fatalf("Failed to unmarshal agent params: %s\n%s", err, resp) |
| } |
| |
| case os.IsNotExist(err): |
| t.Logf("WARNING: No agent output file produced.") |
| |
| default: |
| t.Fatalf("Failed to stat agent output file: %s", err) |
| } |
| |
| return rc, err |
| } |
| |
| c := baseTestContext() |
| |
| Convey(`Testing GitCommandDirect`, func() { |
| args = []string{"status"} |
| |
| Convey(`Can perform a basic execution`, func() { |
| var stdin = []byte("o hai there!") |
| |
| in.ReturnCode = 123 |
| in.ReadStdin = true |
| |
| env.Set("FOO", "BAR") |
| gc.LowSpeedLimit = 1000 |
| gc.LowSpeedTime = 10 * time.Second |
| gc.Stdin = bytes.NewReader(stdin) |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| |
| So(out, ShouldResemble, testAgentResponse{ |
| Args: []string{"status"}, |
| Env: prepareAgentENV( |
| "FOO=BAR", |
| "GIT_HTTP_LOW_SPEED_LIMIT=1000", |
| "GIT_HTTP_LOW_SPEED_TIME=10", |
| encodeStateENV(gc.State), |
| ), |
| Stdin: stdin, |
| }) |
| }) |
| Convey(`Won't override environments`, func() { |
| var stdin = []byte("o hai there!") |
| |
| in.ReturnCode = 123 |
| in.ReadStdin = true |
| |
| env.Set("GIT_HTTP_LOW_SPEED_LIMIT", "0") |
| gc.LowSpeedLimit = 1000 |
| gc.LowSpeedTime = 10 * time.Second |
| gc.Stdin = bytes.NewReader(stdin) |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| |
| So(out, ShouldResemble, testAgentResponse{ |
| Args: []string{"status"}, |
| Env: prepareAgentENV( |
| "GIT_HTTP_LOW_SPEED_LIMIT=0", |
| "GIT_HTTP_LOW_SPEED_TIME=10", |
| encodeStateENV(gc.State), |
| ), |
| Stdin: stdin, |
| }) |
| }) |
| }) |
| |
| Convey(`Testing GitCommandRetry.`, func() { |
| args = []string{"clone", "<repo>"} |
| |
| Convey(`Can perform a basic execution`, func() { |
| var stdin = []byte("o hai there!") |
| var stdout, stderr bytes.Buffer |
| |
| in.ReturnCode = 123 |
| in.ReadStdin = true |
| in.Stdout = []byte("foo\nbar\nbaz") |
| in.Stderr = []byte("qux\npants\nshirt") |
| |
| env.Set("FOO", "BAR") |
| gc.LowSpeedLimit = 1000 |
| gc.LowSpeedTime = 10 * time.Second |
| gc.Stdin = bytes.NewReader(stdin) |
| gc.Stdout = &stdout |
| gc.Stderr = &stderr |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| |
| st := gc.State |
| st.Retrying = true |
| |
| So(out, ShouldResemble, testAgentResponse{ |
| Args: []string{"clone", "<repo>"}, |
| Env: prepareAgentENV( |
| "FOO=BAR", |
| "GIT_HTTP_LOW_SPEED_LIMIT=1000", |
| "GIT_HTTP_LOW_SPEED_TIME=10", |
| encodeStateENV(st), |
| ), |
| Stdin: stdin, |
| }) |
| So(stdout.Bytes(), ShouldResemble, in.Stdout) |
| So(stderr.Bytes(), ShouldResemble, in.Stderr) |
| }) |
| |
| Convey(`Will fail if Git target does not exist`, func() { |
| gc.State.GitPath = filepath.Join(tdir, "nonexist") |
| |
| _, err := runAgent(c) |
| So(err, ShouldNotBeNil) |
| }) |
| |
| Convey(`When configured with a retry regexp`, func() { |
| const numRetries = 10 |
| counter := 0 |
| var onNext func() |
| gc.RetryList = []*regexp.Regexp{ |
| regexp.MustCompile("foo.*bar.*baz"), |
| } |
| gc.Retry = func() retry.Iterator { return &countingRetryIterator{&counter, numRetries, &onNext} } |
| |
| for _, tc := range []struct { |
| name string |
| installTo *[]byte |
| }{ |
| {"stdout", &in.Stdout}, |
| {"stderr", &in.Stderr}, |
| } { |
| Convey(fmt.Sprintf(`When configured to emit that regexp to %s.`, tc.name), func() { |
| *tc.installTo = []byte(strings.Join([]string{ |
| "nothing to see here", |
| "splitting: foo bar", |
| "baz end split", |
| "one line foo bar baz end line", |
| "tail", |
| }, "\n")) |
| |
| Convey(`Will not retry if the process returns zero.`, func() { |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(counter, ShouldEqual, 0) |
| }) |
| |
| Convey(`Will not retry if already retrying.`, func() { |
| gc.State.Retrying = true |
| in.ReturnCode = 42 |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(counter, ShouldEqual, 0) |
| }) |
| |
| Convey(`Will retry if the process returns non-zero.`, func() { |
| in.ReturnCode = 42 |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(counter, ShouldEqual, numRetries+1) |
| }) |
| |
| Convey(`Will stop retrying if a subsequent attempt returns zero.`, func() { |
| in.ReturnCode = 42 |
| onNext = func() { |
| if counter == numRetries-1 { |
| // The next time we run, we will return 0. |
| in.ReturnCode = 0 |
| writeRequest() |
| } |
| } |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, 0) |
| So(counter, ShouldEqual, numRetries-1) |
| }) |
| |
| Convey(`Will stop retrying if a subsequent does not include a retry string.`, func() { |
| in.ReturnCode = 42 |
| onNext = func() { |
| if counter == numRetries-1 { |
| // The next time we run, we will not output a transient error |
| // string. |
| *tc.installTo = []byte("does not match the regex") |
| writeRequest() |
| } |
| } |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(counter, ShouldEqual, numRetries-1) |
| }) |
| }) |
| |
| Convey(fmt.Sprintf(`Will not retry if that regexp is not encountered (%s).`, tc.name), func() { |
| in.ReturnCode = 42 |
| *tc.installTo = []byte(strings.Join([]string{ |
| "nothing to see here", |
| "splitting: foo bar", |
| "baz end split", |
| "tail", |
| }, "\n")) |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(counter, ShouldEqual, 0) |
| }) |
| } |
| |
| Convey(`Will retry if the regexp is encountered on both STDOUT and STDERR.`, func() { |
| in.ReturnCode = 42 |
| in.Stdout = []byte("ohai there foo, how are bar and baz?") |
| in.Stderr = []byte("there once was a dog named foo, who passed the bar a bazillion times.") |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(counter, ShouldEqual, 11) |
| }) |
| }) |
| |
| Convey(`Can be terminated by cancelling the Context.`, func() { |
| c, cancelFunc := context.WithCancel(c) |
| defer cancelFunc() |
| |
| // The process will block indefinitely. After it has successfully started, |
| // cancel out Context to terminate it. |
| in.BlockIndefinitely = true |
| |
| // Poll for our incomplete output file, then cancel. |
| gc.testOnStart = func() { |
| for { |
| t.Logf("Polling for output file [%s]...", ta.outPath) |
| switch _, err := os.Stat(ta.outPath); { |
| case err == nil: |
| cancelFunc() |
| return |
| |
| case os.IsNotExist(err): |
| time.Sleep(10 * time.Millisecond) |
| |
| default: |
| t.Fatalf("Failed to poll for output file [%q]: %s", ta.outPath, err) |
| } |
| } |
| } |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldNotEqual, 0) |
| So(out.Incomplete, ShouldBeTrue) |
| }) |
| }) |
| |
| Convey(`Testing GitCommandAugmentVersion`, func() { |
| args = []string{"version"} |
| |
| var stdout bytes.Buffer |
| gc.Stdout = &stdout |
| |
| Convey(`If the Agent emits a single line, will augment it with our version.`, func() { |
| in.Stdout = []byte("foo\n") |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, 0) |
| So(out.Args, ShouldResemble, args) |
| |
| v := stdout.String() |
| So(v, ShouldStartWith, "foo") |
| So(v, ShouldContainSubstring, "/ Infra wrapper") |
| So(v, ShouldEndWith, "\n") |
| }) |
| |
| Convey(`If the Agent emits no newline, will not augment anything.`, func() { |
| in.Stdout = []byte("foo") |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, 0) |
| So(stdout.Bytes(), ShouldResemble, in.Stdout) |
| }) |
| }) |
| |
| Convey(`Testing "clone" subcommand directory deletion`, func() { |
| dest := filepath.Join(tdir, "destination") |
| args = []string{"clone", "https://foo.example.com/something.git", dest} |
| |
| // Configure semantics of "clone", which is to create a new directory |
| // on execution and fail if the destination directory already exists. |
| in.CreateDirectory = dest |
| in.CreateDirectoryExistsReturnCode = 2 |
| |
| // Configure the process to transiently retry indefinitely. By default, if |
| // the return code is 0, this won't actually result in any retries. We can |
| // trigger this behavior by setting the return code to non-zero. |
| const numRetries = 10 |
| counter := 0 |
| var onNext func() |
| gc.RetryList = []*regexp.Regexp{ |
| regexp.MustCompile("transient"), |
| } |
| gc.Retry = func() retry.Iterator { return &countingRetryIterator{&counter, numRetries, &onNext} } |
| in.Stdout = []byte("transient") |
| |
| Convey(`Can successfully execute the command.`, func() { |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, 0) |
| So(out.Args, ShouldResemble, args) |
| So(counter, ShouldEqual, 0) |
| |
| // After all of the retries, the path should exist. |
| So(pathExists(dest), ShouldBeTrue) |
| }) |
| |
| Convey(`If the command fails, will recreate and retry, deleting the directory in between.`, func() { |
| in.ReturnCode = 1 |
| |
| Convey(`Relative directory`, func() { |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(out.Args, ShouldResemble, args) |
| So(counter, ShouldEqual, numRetries+1) |
| |
| // After all of the retries, the path should still exist. |
| So(pathExists(dest), ShouldBeTrue) |
| }) |
| |
| Convey(`Honors the "-C" Git flag`, func() { |
| // Using "dest", so no need to update agent arguments. |
| args = []string{ |
| "-C", filepath.Dir(dest), |
| "clone", "https://foo.example.com/something.git", filepath.Base(dest), |
| } |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.ReturnCode) |
| So(out.Args, ShouldResemble, args) |
| So(counter, ShouldEqual, numRetries+1) |
| |
| // After all of the retries, the path should still exist. |
| So(pathExists(dest), ShouldBeTrue) |
| }) |
| }) |
| |
| Convey(`If the command permanently fails, the diectory will remain.`, func() { |
| in.ReturnCode = 1 |
| |
| onNext = func() { |
| if counter == numRetries-1 { |
| // The next time we run, we will return 0. |
| in.ReturnCode = 2 |
| in.Stdout = nil |
| writeRequest() |
| } |
| } |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, 2) |
| So(out.Args, ShouldResemble, args) |
| So(counter, ShouldEqual, numRetries-1) |
| |
| // After all of the retries, the path should still exist. |
| So(pathExists(dest), ShouldBeTrue) |
| }) |
| |
| Convey(`If the directory already existed, we won't delete it on transient failure.`, func() { |
| in.ReturnCode = 1 |
| if err := filesystem.MakeDirs(dest); err != nil { |
| t.Fatalf("failed to create directory: %s", err) |
| } |
| |
| rc, err := runAgent(c) |
| So(err, ShouldBeNil) |
| So(rc, ShouldEqual, in.CreateDirectoryExistsReturnCode) |
| So(out.Args, ShouldResemble, args) |
| So(counter, ShouldEqual, numRetries+1) |
| |
| // After all of the retries, the path should still exist. |
| So(pathExists(dest), ShouldBeTrue) |
| }) |
| }) |
| }) |
| |
| Convey(`Unittests`, t, func() { |
| Convey(`For windows`, func() { |
| gitPathPrefix := "cool/path/to/git" |
| gr := &gitRunner{ |
| GitCommand: &GitCommand{ |
| State: state.State{}, |
| }, |
| testGOOS: "windows", |
| } |
| |
| Convey(`We properly escape the '^' symbol when invoking a batfile`, func() { |
| gitPath := gitPathPrefix + ".Bat" |
| gr.GitCommand.State.GitPath = gitPath |
| gr.Args = []string{"diff-tree", "HEAD^!"} |
| cmd := gr.setupCommand(context.Background()) |
| So(cmd.Args, ShouldResemble, []string{gitPath, "diff-tree", "HEAD^^^^!"}) |
| }) |
| |
| Convey(`Should leave things alone when invoking an exe`, func() { |
| gitPath := gitPathPrefix + ".exe" |
| gr.GitCommand.State.GitPath = gitPath |
| gr.Args = []string{"diff-tree", "HEAD^!"} |
| cmd := gr.setupCommand(context.Background()) |
| So(cmd.Args, ShouldResemble, []string{gitPath, "diff-tree", "HEAD^!"}) |
| }) |
| }) |
| }) |
| } |
| |
| func makeTestAgent(inPath string) *testAgent { |
| return &testAgent{ |
| inPath: inPath, |
| outPath: inPath + ".out", |
| } |
| } |
| |
| func (ta *testAgent) readRequest() error { |
| // Read in our request. |
| d, err := ioutil.ReadFile(ta.inPath) |
| if err != nil { |
| return errors.Annotate(err, "failed to read input params").Err() |
| } |
| |
| if err := json.Unmarshal(d, &ta.in); err != nil { |
| return errors.Annotate(err, "failed to unmarshal input params").Err() |
| } |
| |
| return nil |
| } |
| |
| func (ta *testAgent) writeResponse() (err error) { |
| if err := atomicWriteJSON(&ta.out, ta.outPath); err != nil { |
| return errors.Annotate(err, "failed to write output JSON").Err() |
| } |
| |
| return nil |
| } |
| |
| func (ta *testAgent) run(c context.Context, args []string) int { |
| env := environ.System() |
| |
| // On Windows exec.Cmd forcefully appends SYSTEMROOT even if we don't pass it |
| // explicitly. We don't care about it in this test. |
| env.Remove("SYSTEMROOT") |
| |
| ta.out = testAgentResponse{ |
| Args: args, |
| Env: env.Sorted(), |
| Incomplete: true, |
| } |
| |
| // Always emit our output params ("incomplete"). |
| if err := ta.writeResponse(); err != nil { |
| errors.Log(c, err) |
| return testAgentFailedReturnCode |
| } |
| |
| // Read our input request JSON. |
| if err := ta.readRequest(); err != nil { |
| errors.Log(c, err) |
| return testAgentFailedReturnCode |
| } |
| |
| // Process our request, update our response. |
| rc := ta.in.ReturnCode |
| if err := ta.processRequest(c, args, &rc); err != nil { |
| errors.Log(c, err) |
| return testAgentFailedReturnCode |
| } |
| |
| // Write our final response ("complete") output on completion. |
| ta.out.Incomplete = false |
| if err := ta.writeResponse(); err != nil { |
| errors.Log(c, err) |
| return testAgentFailedReturnCode |
| } |
| |
| return rc |
| } |
| |
| // testGitCommandAgent is the TestGitCommand testing agent entry point. |
| func (ta *testAgent) processRequest(c context.Context, args []string, rc *int) error { |
| if ta.in.ReadStdin { |
| var err error |
| if ta.out.Stdin, err = ioutil.ReadAll(os.Stdin); err != nil { |
| return errors.Annotate(err, "failed to read STDIN").Err() |
| } |
| } |
| |
| // Write STDOUT / STDERR. |
| if _, err := os.Stdout.Write(ta.in.Stdout); err != nil { |
| return errors.Annotate(err, "failed to write STDOUT").Err() |
| } |
| if _, err := os.Stderr.Write(ta.in.Stderr); err != nil { |
| return errors.Annotate(err, "failed to write STDERR").Err() |
| } |
| |
| if d := ta.in.CreateDirectory; d != "" { |
| if pathExists(d) { |
| *rc = ta.in.CreateDirectoryExistsReturnCode |
| return nil |
| } |
| |
| if err := filesystem.MakeDirs(d); err != nil { |
| return errors.Annotate(err, "failed to create directory").Err() |
| } |
| } |
| |
| if ta.in.BlockIndefinitely { |
| for { |
| time.Sleep(time.Second) |
| } |
| } |
| |
| return nil |
| } |
| |
| type countingRetryIterator struct { |
| counter *int |
| retries int |
| |
| onNext *func() |
| } |
| |
| func (it *countingRetryIterator) Next(context.Context, error) time.Duration { |
| *it.counter++ |
| if it.retries == 0 { |
| return retry.Stop |
| } |
| it.retries-- |
| |
| if fn := *it.onNext; fn != nil { |
| fn() |
| } |
| |
| return 0 |
| } |
| |
| func atomicWriteJSON(obj interface{}, path string) (err error) { |
| fd, err := ioutil.TempFile(filepath.Dir(path), filepath.Base(path)) |
| if err != nil { |
| return errors.Annotate(err, "failed to create output tempfile").Err() |
| } |
| |
| if err := json.NewEncoder(fd).Encode(obj); err != nil { |
| fd.Close() |
| return errors.Annotate(err, "failed to encode JSON").Err() |
| } |
| |
| if err := fd.Close(); err != nil { |
| return errors.Annotate(err, "failed to close output file").Err() |
| } |
| |
| if err := os.Rename(fd.Name(), path); err != nil { |
| return errors.Annotate(err, "failed to install output file").Err() |
| } |
| |
| return nil |
| } |
| |
| func pathExists(path string) bool { |
| _, err := os.Stat(path) |
| return err == nil |
| } |