feat: cancel git clone ops gracefully #546
| @ -1,7 +1,10 @@ | |||||||
| package git | package git | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"fmt" | ||||||
| 	"os" | 	"os" | ||||||
|  | 	"os/signal" | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"coopcloud.tech/abra/pkg/log" | 	"coopcloud.tech/abra/pkg/log" | ||||||
| @ -22,12 +25,28 @@ func gitCloneIgnoreErr(err error) bool { | |||||||
| 	return false | 	return false | ||||||
| } | } | ||||||
|  |  | ||||||
| // Clone runs a git clone which accounts for different default branches. | // Clone runs a git clone which accounts for different default branches. This | ||||||
|  | // function respects Ctrl+C (SIGINT) calls from the user, cancelling the | ||||||
|  | // context and deleting the (typically) half-baked clone of the repository. | ||||||
|  | // This avoids broken state for future clone / recipe ops. | ||||||
| func Clone(dir, url string) error { | func Clone(dir, url string) error { | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	ctx, cancelCtx := context.WithCancel(ctx) | ||||||
|  |  | ||||||
|  | 	sigIntCh := make(chan os.Signal, 1) | ||||||
|  | 	signal.Notify(sigIntCh, os.Interrupt) | ||||||
|  | 	defer func() { | ||||||
|  | 		signal.Stop(sigIntCh) | ||||||
|  | 		cancelCtx() | ||||||
|  | 	}() | ||||||
|  |  | ||||||
|  | 	errCh := make(chan error) | ||||||
|  |  | ||||||
|  | 	go func() { | ||||||
| 		if _, err := os.Stat(dir); os.IsNotExist(err) { | 		if _, err := os.Stat(dir); os.IsNotExist(err) { | ||||||
| 			log.Debugf("git clone: %s", url) | 			log.Debugf("git clone: %s", url) | ||||||
|  |  | ||||||
| 		_, err := git.PlainClone(dir, false, &git.CloneOptions{ | 			_, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ | ||||||
| 				URL:           url, | 				URL:           url, | ||||||
| 				Tags:          git.AllTags, | 				Tags:          git.AllTags, | ||||||
| 				ReferenceName: plumbing.ReferenceName("refs/heads/main"), | 				ReferenceName: plumbing.ReferenceName("refs/heads/main"), | ||||||
| @ -36,13 +55,17 @@ func Clone(dir, url string) error { | |||||||
|  |  | ||||||
| 			if err != nil && gitCloneIgnoreErr(err) { | 			if err != nil && gitCloneIgnoreErr(err) { | ||||||
| 				log.Debugf("git clone: %s cloned successfully", dir) | 				log.Debugf("git clone: %s cloned successfully", dir) | ||||||
| 			return nil | 				errCh <- nil | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			if err := ctx.Err(); err != nil { | ||||||
|  | 				errCh <- fmt.Errorf("git clone %s: cancelled due to interrupt", dir) | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				log.Debug("git clone: main branch failed, attempting master branch") | 				log.Debug("git clone: main branch failed, attempting master branch") | ||||||
|  |  | ||||||
| 			_, err := git.PlainClone(dir, false, &git.CloneOptions{ | 				_, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ | ||||||
| 					URL:           url, | 					URL:           url, | ||||||
| 					Tags:          git.AllTags, | 					Tags:          git.AllTags, | ||||||
| 					ReferenceName: plumbing.ReferenceName("refs/heads/master"), | 					ReferenceName: plumbing.ReferenceName("refs/heads/master"), | ||||||
| @ -51,11 +74,11 @@ func Clone(dir, url string) error { | |||||||
|  |  | ||||||
| 				if err != nil && gitCloneIgnoreErr(err) { | 				if err != nil && gitCloneIgnoreErr(err) { | ||||||
| 					log.Debugf("git clone: %s cloned successfully", dir) | 					log.Debugf("git clone: %s cloned successfully", dir) | ||||||
| 				return nil | 					errCh <- nil | ||||||
| 				} | 				} | ||||||
|  |  | ||||||
| 				if err != nil { | 				if err != nil { | ||||||
| 				return err | 					errCh <- err | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| @ -64,5 +87,20 @@ func Clone(dir, url string) error { | |||||||
| 			log.Debugf("git clone: %s already exists", dir) | 			log.Debugf("git clone: %s already exists", dir) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		errCh <- nil | ||||||
|  | 	}() | ||||||
|  |  | ||||||
|  | 	select { | ||||||
|  | 	case <-sigIntCh: | ||||||
|  | 		cancelCtx() | ||||||
|  | 		fmt.Println() // NOTE(d1): newline after ^C | ||||||
|  | 		if err := os.RemoveAll(dir); err != nil { | ||||||
|  | 			return fmt.Errorf("unable to clean up git clone of %s: %s", dir, err) | ||||||
|  | 		} | ||||||
|  | 		return fmt.Errorf("git clone %s: cancelled due to interrupt", dir) | ||||||
|  | 	case err := <-errCh: | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										48
									
								
								pkg/git/clone_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								pkg/git/clone_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | |||||||
|  | package git | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"os" | ||||||
|  | 	"path" | ||||||
|  | 	"syscall" | ||||||
|  | 	"testing" | ||||||
|  |  | ||||||
|  | 	"coopcloud.tech/abra/pkg/config" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestClone(t *testing.T) { | ||||||
|  | 	dir := path.Join(config.RECIPES_DIR, "gitea") | ||||||
|  | 	os.RemoveAll(dir) | ||||||
|  |  | ||||||
|  | 	gitURL := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "gitea") | ||||||
|  | 	if err := Clone(dir, gitURL); err != nil { | ||||||
|  | 		t.Fatalf("unable to git clone gitea: %s", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) { | ||||||
|  | 		t.Fatal("gitea repo was not cloned successfully") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestCancelGitClone(t *testing.T) { | ||||||
|  | 	dir := path.Join(config.RECIPES_DIR, "gitea") | ||||||
|  | 	os.RemoveAll(dir) | ||||||
|  |  | ||||||
|  | 	go func() { | ||||||
|  | 		p, err := os.FindProcess(os.Getpid()) | ||||||
|  | 		if err != nil { | ||||||
|  | 			t.Fatalf("unable to find current process: %s", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		p.Signal(syscall.SIGINT) | ||||||
|  | 	}() | ||||||
|  |  | ||||||
|  | 	gitURL := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "gitea") | ||||||
|  | 	if err := Clone(dir, gitURL); err == nil { | ||||||
|  | 		t.Fatal("cloning should have been interrupted") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if _, err := os.Stat(dir); err != nil && !os.IsNotExist(err) { | ||||||
|  | 		t.Fatal("recipe repo was not deleted") | ||||||
|  | 	} | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user