feat(ssh): create repo metadata on new repo push

Ayman Bagabas created

Change summary

server/git/daemon/daemon_test.go | 37 +++++++------
server/git/ssh/ssh.go            | 26 ++++++--
server/git/ssh/ssh_test.go       | 95 +++++++++++++++++++++++++++++----
3 files changed, 122 insertions(+), 36 deletions(-)

Detailed changes

server/git/daemon/daemon_test.go 🔗

@@ -6,11 +6,10 @@ import (
 	"log"
 	"net"
 	"os"
+	"strconv"
 	"testing"
 
-	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/charmbracelet/soft-serve/server/db/fakedb"
 	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 )
@@ -23,21 +22,13 @@ func TestMain(m *testing.M) {
 		log.Fatal(err)
 	}
 	defer os.RemoveAll(tmp)
-	cfg := &config.Config{
-		Host:       "",
-		DataPath:   tmp,
-		AnonAccess: proto.ReadOnlyAccess,
-		Git: config.GitConfig{
-			// Reduce the max read timeout to 3 second so we can test the timeout.
-			IdleTimeout: 3,
-			// Reduce the max timeout to 100 second so we can test the timeout.
-			MaxTimeout: 100,
-			// Reduce the max connections to 3 so we can test the timeout.
-			MaxConnections: 3,
-			Port:           9418,
-		},
-	}
-	cfg = cfg.WithDB(&fakedb.FakeDB{})
+	os.Setenv("SOFT_SERVE_DATA_PATH", tmp)
+	os.Setenv("SOFT_SERVE_ANON_ACCESS", "read-only")
+	os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3")
+	os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100")
+	os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "3")
+	os.Setenv("SOFT_SERVE_GIT_PORT", strconv.Itoa(randomPort()))
+	cfg := config.DefaultConfig()
 	d, err := NewDaemon(cfg)
 	if err != nil {
 		log.Fatal(err)
@@ -50,6 +41,12 @@ func TestMain(m *testing.M) {
 	}()
 	defer d.Close()
 	os.Exit(m.Run())
+	os.Unsetenv("SOFT_SERVE_DATA_PATH")
+	os.Unsetenv("SOFT_SERVE_ANON_ACCESS")
+	os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS")
+	os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT")
+	os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
+	os.Unsetenv("SOFT_SERVE_GIT_PORT")
 }
 
 func TestIdleTimeout(t *testing.T) {
@@ -94,3 +91,9 @@ func readPktline(c net.Conn) (string, error) {
 	}
 	return string(pktout.Bytes()), nil
 }
+
+func randomPort() int {
+	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
+	_ = addr.Close()
+	return addr.Addr().(*net.TCPAddr).Port
+}

server/git/ssh/ssh.go 🔗

@@ -12,12 +12,18 @@ import (
 	"github.com/gliderlabs/ssh"
 )
 
+// Auth is the interface that wraps both Access and Provider interfaces.
+type Auth interface {
+	proto.Access
+	proto.Provider
+}
+
 // Middleware adds Git server functionality to the ssh.Server. Repos are stored
 // in the specified repo directory. The provided Hooks implementation will be
 // checked for access on a per repo basis for a ssh.Session public key.
 // Hooks.Push and Hooks.Fetch will be called on successful completion of
 // their commands.
-func Middleware(repoDir string, auth proto.Access) wish.Middleware {
+func Middleware(repoDir string, auth Auth) wish.Middleware {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
 			func() {
@@ -27,24 +33,29 @@ func Middleware(repoDir string, auth proto.Access) wish.Middleware {
 					// repo should be in the form of "repo.git"
 					repo := strings.TrimPrefix(cmd[1], "/")
 					repo = filepath.Clean(repo)
+					name := repo
 					if strings.Contains(repo, "/") {
 						log.Printf("invalid repo: %s", repo)
 						Fatal(s, fmt.Errorf("%s: %s", git.ErrInvalidRepo, "user repos not supported"))
 						return
 					}
 					pk := s.PublicKey()
-					access := auth.AuthRepo(strings.TrimSuffix(repo, ".git"), pk)
+					access := auth.AuthRepo(name, pk)
 					// git bare repositories should end in ".git"
 					// https://git-scm.com/docs/gitrepository-layout
-					if !strings.HasSuffix(repo, ".git") {
-						repo += ".git"
-					}
+					repo = strings.TrimSuffix(repo, ".git") + ".git"
 					switch gc {
 					case "git-receive-pack":
 						switch access {
 						case proto.ReadWriteAccess, proto.AdminAccess:
-							err := git.GitPack(s, s, s.Stderr(), gc, repoDir, repo)
-							if err != nil {
+							if _, err := auth.Open(name); err != nil {
+								if err := auth.Create(name, "", "", false); err != nil {
+									log.Printf("failed to create repo: %s", err)
+									Fatal(s, err)
+									return
+								}
+							}
+							if err := git.GitPack(s, s, s.Stderr(), gc, repoDir, repo); err != nil {
 								Fatal(s, git.ErrSystemMalfunction)
 							}
 						default:
@@ -52,6 +63,7 @@ func Middleware(repoDir string, auth proto.Access) wish.Middleware {
 						}
 						return
 					case "git-upload-archive", "git-upload-pack":
+						log.Printf("access %s", access)
 						switch access {
 						case proto.ReadOnlyAccess, proto.ReadWriteAccess, proto.AdminAccess:
 							// try to upload <repo>.git first, then <repo>

server/git/ssh/ssh_test.go 🔗

@@ -3,6 +3,7 @@ package ssh
 import (
 	"fmt"
 	"net"
+	"net/mail"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -14,6 +15,7 @@ import (
 	"github.com/charmbracelet/soft-serve/proto"
 	"github.com/charmbracelet/wish"
 	"github.com/gliderlabs/ssh"
+	gossh "golang.org/x/crypto/ssh"
 )
 
 func TestGitMiddleware(t *testing.T) {
@@ -25,8 +27,6 @@ func TestGitMiddleware(t *testing.T) {
 
 	repoDir := t.TempDir()
 	hooks := &testHooks{
-		pushes:  []action{},
-		fetches: []action{},
 		access: []accessDetails{
 			{pubkey, "repo1", proto.AdminAccess},
 			{pubkey, "repo2", proto.AdminAccess},
@@ -53,7 +53,6 @@ func TestGitMiddleware(t *testing.T) {
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo1"))
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "master"))
-		requireHasAction(t, hooks.pushes, pubkey, "repo1")
 	})
 
 	t.Run("create repo on main", func(t *testing.T) {
@@ -62,7 +61,6 @@ func TestGitMiddleware(t *testing.T) {
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "remote", "add", "origin", remote+"/repo2"))
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "commit", "--allow-empty", "-m", "initial commit"))
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "push", "origin", "main"))
-		requireHasAction(t, hooks.pushes, pubkey, "repo2")
 	})
 
 	t.Run("create and clone repo", func(t *testing.T) {
@@ -74,9 +72,6 @@ func TestGitMiddleware(t *testing.T) {
 
 		cwd = t.TempDir()
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo3"))
-
-		requireHasAction(t, hooks.pushes, pubkey, "repo3")
-		requireHasAction(t, hooks.fetches, pubkey, "repo3")
 	})
 
 	t.Run("clone repo that doesn't exist", func(t *testing.T) {
@@ -106,9 +101,6 @@ func TestGitMiddleware(t *testing.T) {
 
 		cwd = t.TempDir()
 		requireNoError(t, runGitHelper(t, pkPath, cwd, "clone", remote+"/repo7"))
-
-		requireHasAction(t, hooks.pushes, pubkey, "repo7")
-		requireHasAction(t, hooks.fetches, pubkey, "repo7")
 	})
 }
 
@@ -153,6 +145,7 @@ func requireHasAction(t *testing.T, actions []action, key ssh.PublicKey, repo st
 	t.Helper()
 
 	for _, action := range actions {
+		t.Logf("action: %q", action.repo)
 		if repo == strings.TrimSuffix(action.repo, ".git") && ssh.KeysEqual(key, action.key) {
 			return
 		}
@@ -195,20 +188,98 @@ type testHooks struct {
 	access  []accessDetails
 }
 
+func (h *testHooks) Open(string) (proto.Repository, error) {
+	return nil, nil
+}
+
+func (h *testHooks) ListRepos() ([]proto.Metadata, error) {
+	return nil, nil
+}
+
 func (h *testHooks) AuthRepo(repo string, key ssh.PublicKey) proto.AccessLevel {
 	for _, dets := range h.access {
-		if dets.repo == repo && ssh.KeysEqual(key, dets.key) {
+		if strings.TrimSuffix(dets.repo, ".git") == repo && ssh.KeysEqual(key, dets.key) {
 			return dets.level
 		}
 	}
 	return proto.NoAccess
 }
 
+type testUser struct{}
+
+func (u *testUser) Name() string {
+	return "test"
+}
+
+func (u *testUser) Email() *mail.Address {
+	return &mail.Address{
+		Name:    "test",
+		Address: "test@wish",
+	}
+}
+
+func (u *testUser) IsAdmin() bool {
+	return false
+}
+
+func (u *testUser) Login() *string {
+	l := "test"
+	return &l
+}
+
+func (u *testUser) Password() *string {
+	return nil
+}
+
+func (u *testUser) PublicKeys() []gossh.PublicKey {
+	return nil
+}
+
+func (h *testHooks) User(pk ssh.PublicKey) (proto.User, error) {
+	return &testUser{}, nil
+}
+
+func (h *testHooks) IsAdmin(pk ssh.PublicKey) bool {
+	return false
+}
+
+func (h *testHooks) IsCollab(repo string, pk ssh.PublicKey) bool {
+	return false
+}
+
+func (h *testHooks) Create(name, projectName, description string, isPrivate bool) error {
+	return nil
+}
+
+func (h *testHooks) Delete(repo string) error {
+	return nil
+}
+
+func (h *testHooks) Rename(repo, name string) error {
+	return nil
+}
+
+func (h *testHooks) SetProjectName(repo, projectName string) error {
+	return nil
+}
+
+func (h *testHooks) SetDescription(repo, description string) error {
+	return nil
+}
+
+func (h *testHooks) SetPrivate(repo string, isPrivate bool) error {
+	return nil
+}
+
+func (h *testHooks) SetDefaultBranch(repo, branch string) error {
+	return nil
+}
+
 func (h *testHooks) Push(repo string, key ssh.PublicKey) {
 	h.Lock()
 	defer h.Unlock()
 
-	h.pushes = append(h.pushes, action{key, repo})
+	h.pushes = append(h.pushes, action{key, strings.TrimSuffix(repo, ".git")})
 }
 
 func (h *testHooks) Fetch(repo string, key ssh.PublicKey) {