cmd/gitmirror: add CSR support

Add support for CSR writes, disabled for now and basically untested.
This relies on modifying the global git config, so rather than using the
user's real home directory, use a temp dir instead. This also sets us up
for testing.

Also, write remotes into $GIT_DIR/remotes rather than the config file.
Much easier.

Change-Id: If45a468ad715ee24660d927b832096a70a0ffd4f
Reviewed-on: https://go-review.googlesource.com/c/build/+/324398
Trust: Heschi Kreinick <heschi@google.com>
Run-TryBot: Heschi Kreinick <heschi@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/gitmirror/gitmirror.go b/cmd/gitmirror/gitmirror.go
index 62315c5..38f3b8d 100644
--- a/cmd/gitmirror/gitmirror.go
+++ b/cmd/gitmirror/gitmirror.go
@@ -18,6 +18,7 @@
 	"net/http"
 	"os"
 	"os/exec"
+	"os/signal"
 	"path/filepath"
 	"runtime"
 	"sort"
@@ -66,18 +67,24 @@
 	if err != nil {
 		log.Fatalf("creating cache dir: %v", err)
 	}
+	credsDir, err := ioutil.TempDir("", "gitmirror-credentials")
+	if err != nil {
+		log.Fatalf("creating credentials dir: %v", err)
+	}
+	defer os.RemoveAll(credsDir)
 
 	m := &mirror{
 		mux:          http.DefaultServeMux,
 		repos:        map[string]*repo{},
 		cacheDir:     cacheDir,
+		homeDir:      credsDir,
 		gerritClient: gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth),
 	}
 	http.HandleFunc("/", m.handleRoot)
 
 	var eg errgroup.Group
-	for name := range repospkg.ByGerritProject {
-		r := m.addRepo(name)
+	for _, repo := range repospkg.ByGerritProject {
+		r := m.addRepo(repo)
 		eg.Go(r.init)
 	}
 	if err := eg.Wait(); err != nil {
@@ -85,11 +92,11 @@
 	}
 
 	if *flagMirror {
-		if err := writeCredentials(); err != nil {
-			log.Fatalf("writing ssh credentials: %v", err)
+		if err := writeCredentials(credsDir); err != nil {
+			log.Fatalf("writing git credentials: %v", err)
 		}
-		if err := m.runMirrors(); err != nil {
-			log.Fatalf("running mirror: %v", err)
+		if err := m.addMirrors(); err != nil {
+			log.Fatalf("configuring mirrors: %v", err)
 		}
 	}
 
@@ -99,38 +106,58 @@
 	go m.pollGerritAndTickle()
 	go m.subscribeToMaintnerAndTickleLoop()
 
-	select {}
+	shutdown := make(chan os.Signal, 1)
+	signal.Notify(shutdown, os.Interrupt)
+	<-shutdown
 }
 
-func writeCredentials() error {
+func writeCredentials(home string) error {
 	sc := secret.MustNewClient()
 	defer sc.Close()
 
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return err
-	}
-	sshDir := filepath.Join(home, ".ssh")
-	sshKey := filepath.Join(sshDir, "id_ed25519")
-	if _, err := os.Stat(sshKey); err == nil {
-		log.Printf("Using github ssh key at %v", sshKey)
-		return nil
-	}
-
 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer cancel()
 
+	sshConfig := &bytes.Buffer{}
+	gitConfig := &bytes.Buffer{}
+	sshConfigPath := filepath.Join(home, "ssh_config")
+	// ssh ignores $HOME in favor of /etc/passwd, so we need to override ssh_config explicitly.
+	fmt.Fprintf(gitConfig, "[core]\n  sshCommand=\"ssh -F %v\"\n", sshConfigPath)
+
+	// GitHub key, used as the default SSH private key.
 	privKey, err := sc.Retrieve(ctx, secret.NameGitHubSSHKey)
-	if err != nil || len(privKey) == 0 {
-		return fmt.Errorf("can't mirror to github without %q GCP secret manager or file %v", secret.NameGitHubSSHKey, sshKey)
+	if err != nil {
+		return fmt.Errorf("reading github key from secret manager: %v", err)
 	}
-	if err := os.MkdirAll(sshDir, 0700); err != nil {
+	privKeyPath := filepath.Join(home, secret.NameGitHubSSHKey)
+	if err := ioutil.WriteFile(privKeyPath, []byte(privKey+"\n"), 0600); err != nil {
 		return err
 	}
-	if err := ioutil.WriteFile(sshKey, []byte(privKey+"\n"), 0600); err != nil {
+	fmt.Fprintf(sshConfig, "Host github.com\n  IdentityFile %v\n", privKeyPath)
+
+	// gitmirror service key, used to gcloud auth for CSR writes.
+	serviceKey, err := sc.Retrieve(ctx, secret.NameGitMirrorServiceKey)
+	if err != nil {
+		return fmt.Errorf("reading service key from secret manager: %v", err)
+	}
+	serviceKeyPath := filepath.Join(home, secret.NameGitMirrorServiceKey)
+	if err := ioutil.WriteFile(serviceKeyPath, []byte(serviceKey), 0600); err != nil {
 		return err
 	}
-	log.Printf("Wrote %s from GCP secret manager.", sshKey)
+	gcloud := exec.CommandContext(ctx, "gcloud", "auth", "activate-service-account", "--key-file", serviceKeyPath)
+	gcloud.Env = append(os.Environ(), "HOME="+home)
+	if out, err := gcloud.CombinedOutput(); err != nil {
+		return fmt.Errorf("gcloud auth failed: %v\n%v", err, out)
+	}
+	fmt.Fprintf(gitConfig, "[credential \"https://source.developers.google.com\"]\n  helper=gcloud.sh\n")
+
+	if err := ioutil.WriteFile(filepath.Join(home, ".gitconfig"), gitConfig.Bytes(), 0600); err != nil {
+		return err
+	}
+	if err := ioutil.WriteFile(sshConfigPath, sshConfig.Bytes(), 0600); err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -163,16 +190,20 @@
 // A mirror watches Gerrit repositories, fetching the latest commits and
 // optionally mirroring them.
 type mirror struct {
-	mux          *http.ServeMux
-	repos        map[string]*repo
-	cacheDir     string
+	mux      *http.ServeMux
+	repos    map[string]*repo
+	cacheDir string
+	// homeDir is used as $HOME for all commands, allowing easy configuration overrides.
+	homeDir      string
 	gerritClient *gerrit.Client
 }
 
-func (m *mirror) addRepo(name string) *repo {
+func (m *mirror) addRepo(meta *repospkg.Repo) *repo {
+	name := meta.GoGerritProject
 	r := &repo{
 		name:    name,
 		url:     goBase + name,
+		meta:    meta,
 		root:    filepath.Join(m.cacheDir, name),
 		changed: make(chan bool, 1),
 		mirror:  m,
@@ -183,23 +214,18 @@
 	return r
 }
 
-// runMirrors sets up and starts mirroring for the repositories that are
-// configured to be mirrored.
-func (m *mirror) runMirrors() error {
-	for name, repo := range m.repos {
-		meta, ok := repospkg.ByGerritProject[name]
-		if !ok || !meta.MirrorToGitHub {
-			continue
+// addMirrors sets up mirroring for repositories that need it.
+func (m *mirror) addMirrors() error {
+	for _, repo := range m.repos {
+		if repo.meta.MirrorToGitHub {
+			if err := repo.addRemote("github", "git@github.com:"+repo.meta.GitHubRepo()+".git"); err != nil {
+				return fmt.Errorf("adding GitHub remote: %v", err)
+			}
 		}
-		if err := repo.addRemote("github", "git@github.com:"+meta.GitHubRepo()+".git",
-			// We want to include only the refs/heads/* and refs/tags/* namespaces
-			// in the mirrors. They correspond to published branches and tags.
-			// Leave out internal Gerrit namespaces such as refs/changes/*,
-			// refs/users/*, etc., because they're not helpful on other hosts.
-			"push = +refs/heads/*:refs/heads/*",
-			"push = +refs/tags/*:refs/tags/*",
-		); err != nil {
-			return fmt.Errorf("adding remote: %v", err)
+		if repo.meta.MirrorToCSR {
+			if err := repo.addRemote("csr", "https://source.developers.google.com/p/golang-org/r/"+repo.name); err != nil {
+				return fmt.Errorf("adding CSR remote: %v", err)
+			}
 		}
 	}
 	return nil
@@ -271,7 +297,8 @@
 type repo struct {
 	name    string
 	url     string
-	root    string    // on-disk location of the git repo, *cacheDir/name
+	root    string // on-disk location of the git repo, *cacheDir/name
+	meta    *repospkg.Repo
 	changed chan bool // sent to when a change comes in
 	status  statusRing
 	dests   []string // destination remotes to mirror to
@@ -331,6 +358,7 @@
 	stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
 	cmd := exec.CommandContext(ctx, "git", args...)
 	cmd.Dir = r.root
+	cmd.Env = append(os.Environ(), "HOME="+r.mirror.homeDir)
 	cmd.Stdout, cmd.Stderr = stdout, stderr
 	err := cmd.Run()
 	return stdout.Bytes(), stderr.Bytes(), err
@@ -386,32 +414,19 @@
 	r.status.add(status)
 }
 
-func (r *repo) addRemote(name, url string, opts ...string) error {
+func (r *repo) addRemote(name, url string) error {
 	r.dests = append(r.dests, name)
-	if _, _, err := r.runGitQuiet("remote", "remove", name); err != nil {
-		// Exit status 2 means not found, which is fine.
-		if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 2 {
-			return err
-		}
-	}
-	gitConfig := filepath.Join(r.root, "config")
-	f, err := os.OpenFile(gitConfig, os.O_WRONLY|os.O_APPEND, os.ModePerm)
-	if err != nil {
+	if err := os.MkdirAll(filepath.Join(r.root, "remotes"), 0777); err != nil {
 		return err
 	}
-	_, err = fmt.Fprintf(f, "\n[remote %q]\n\turl = %v\n", name, url)
-	if err != nil {
-		f.Close()
-		return err
-	}
-	for _, o := range opts {
-		_, err := fmt.Fprintf(f, "\t%s\n", o)
-		if err != nil {
-			f.Close()
-			return err
-		}
-	}
-	return f.Close()
+	// We want to include only the refs/heads/* and refs/tags/* namespaces
+	// in the mirrors. They correspond to published branches and tags.
+	// Leave out internal Gerrit namespaces such as refs/changes/*,
+	// refs/users/*, etc., because they're not helpful on other hosts.
+	remote := "URL: " + url + "\n" +
+		"Push: +refs/heads/*:refs/heads/*\n" +
+		"Push: +refs/tags/*:refs/tags/*\n"
+	return ioutil.WriteFile(filepath.Join(r.root, "remotes", name), []byte(remote), 0777)
 }
 
 // Loop continuously runs "git fetch" in the repo, checks for new
diff --git a/internal/secret/gcp_secret_manager.go b/internal/secret/gcp_secret_manager.go
index da02dff..5d1ec82 100644
--- a/internal/secret/gcp_secret_manager.go
+++ b/internal/secret/gcp_secret_manager.go
@@ -34,6 +34,9 @@
 	// NameGitHubSSHKey is the secret name for the GitHub SSH private key.
 	NameGitHubSSHKey = "github-ssh-private-key"
 
+	// NameGitMirrorServiceKey is the secret name for the gitmirror service account key.
+	NameGitMirrorServiceKey = "gitmirror-service-key"
+
 	// NameGobotPassword is the secret name for the Gobot password.
 	NameGobotPassword = "gobot-password"
 
diff --git a/repos/repos.go b/repos/repos.go
index a8aa2c3..5c81b7e 100644
--- a/repos/repos.go
+++ b/repos/repos.go
@@ -22,6 +22,12 @@
 	// gitHubRepo must both be defined.
 	MirrorToGitHub bool
 
+	// MirrorToCSR controls whether this repo is mirrored from
+	// Gerrit to Cloud Source Repositories. If true, GoGerritProject
+	// must be defined. It will be mirrored to a CSR repo with the
+	// same name as the Gerrit repo.
+	MirrorToCSR bool
+
 	// showOnDashboard is whether to show the repo on the bottom
 	// of build.golang.org in the repo overview section.
 	showOnDashboard bool
@@ -157,21 +163,14 @@
 }
 
 func add(r *Repo) {
-	if r.MirrorToGitHub {
-		if r.gitHubRepo == "" {
-			panic(fmt.Sprintf("project %+v has MirrorToGitHub but no gitHubRepo", r))
-		}
-		if r.GoGerritProject == "" {
-			panic(fmt.Sprintf("project %+v has MirrorToGitHub but no GoGerritProject", r))
-		}
+	if (r.MirrorToCSR || r.MirrorToGitHub || r.showOnDashboard) && r.GoGerritProject == "" {
+		panic(fmt.Sprintf("project %+v sets feature(s) that require a GoGerritProject, but has none", r))
 	}
-	if r.showOnDashboard {
-		if !r.CoordinatorCanBuild {
-			panic(fmt.Sprintf("project %+v is showOnDashboard but not marked buildable by coordinator", r))
-		}
-		if r.GoGerritProject == "" {
-			panic(fmt.Sprintf("project %+v is showOnDashboard but has no Gerrit project", r))
-		}
+	if r.MirrorToGitHub && r.gitHubRepo == "" {
+		panic(fmt.Sprintf("project %+v has MirrorToGitHub but no gitHubRepo", r))
+	}
+	if r.showOnDashboard && !r.CoordinatorCanBuild {
+		panic(fmt.Sprintf("project %+v is showOnDashboard but not marked buildable by coordinator", r))
 	}
 
 	if p := r.GoGerritProject; p != "" {