Compare commits
1
Commits
main
...
ha/gofmt-config
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c52d1b5f |
@@ -0,0 +1,161 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// core.go is the transport-agnostic business logic for git's control plane —
|
||||
// the ONE implementation each of create / list / get / delete / usage. The REST
|
||||
// handlers (git.go) and the ZAP procedures (zap.go) are both THIN ADAPTERS over
|
||||
// these funcs: a transport resolves the tenant + decodes its own wire shape,
|
||||
// calls the core, and maps the returned error to its own status vocabulary.
|
||||
//
|
||||
// This is the "one and only one way" rule made concrete: one core func, two
|
||||
// transports. The business rules (name validation, project sub-scope, conflict
|
||||
// vs not-found, storage provisioning, usage measurement) live here once and can
|
||||
// never drift between the HTTP and ZAP paths.
|
||||
//
|
||||
// Errors are typed sentinels so each transport maps them independently:
|
||||
// - errBadInput → 400 (HTTP) / dispatch error (ZAP)
|
||||
// - errConflict → 409
|
||||
// - errNotFound → 404
|
||||
// A wrapped errBadInput carries a human message for the client.
|
||||
|
||||
// errBadInput marks a caller-supplied validation failure (bad name, oversized
|
||||
// description, malformed project). Transports answer 400. errConflict /
|
||||
// errNotFound are defined in store.go and reused verbatim.
|
||||
var errBadInput = errors.New("git: invalid input")
|
||||
|
||||
// badInput wraps a message as an errBadInput so a transport can surface the
|
||||
// reason while still matching errors.Is(err, errBadInput).
|
||||
func badInput(format string, a ...any) error {
|
||||
return fmt.Errorf("%w: %s", errBadInput, fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// coreCreate provisions a repo for (org, project) and returns its view. org is
|
||||
// the validated tenant; project defaults to the caller's sub-scope. It is the
|
||||
// ONE create path — REST POST /v1/git/repos and the ZAP createRepo procedure
|
||||
// both call it. Maps to errBadInput / errConflict for the transport.
|
||||
func (s *svc) coreCreate(ctx context.Context, org, headerProject string, in createReq) (repoView, error) {
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return repoView{}, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
name := normalizeName(in.Name)
|
||||
if name == "" {
|
||||
return repoView{}, badInput("name is required")
|
||||
}
|
||||
if !nameRE.MatchString(name) {
|
||||
return repoView{}, badInput("name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
}
|
||||
// Project sub-scope: an explicit body value wins, else the transport's
|
||||
// header/context sub-scope.
|
||||
project := strings.TrimSpace(in.Project)
|
||||
if project == "" {
|
||||
project = headerProject
|
||||
} else if !projectRE.MatchString(project) {
|
||||
return repoView{}, badInput("project must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
}
|
||||
if len(in.Description) > 4096 {
|
||||
return repoView{}, badInput("description too large (max 4KiB)")
|
||||
}
|
||||
|
||||
id, err := genID("repo")
|
||||
if err != nil {
|
||||
return repoView{}, fmt.Errorf("rng: %w", err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
r := Repo{
|
||||
ID: id, Org: org, Project: project, Name: name,
|
||||
Description: strings.TrimSpace(in.Description), DefaultBranch: defaultBranchName,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.provision(ctx, store, r); err != nil {
|
||||
if errors.Is(err, errConflict) {
|
||||
return repoView{}, errConflict
|
||||
}
|
||||
return repoView{}, fmt.Errorf("provision: %w", err)
|
||||
}
|
||||
r.SizeBytes = s.recordUsage(ctx, org, project, name)
|
||||
return s.toView(r, nil, ""), nil
|
||||
}
|
||||
|
||||
// coreList returns the repos for (org, project), most-recently-updated first.
|
||||
func (s *svc) coreList(ctx context.Context, org, project string) ([]repoView, error) {
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
rows, err := store.List(ctx, org, project)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list: %w", err)
|
||||
}
|
||||
out := make([]repoView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, s.toView(r, nil, ""))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// coreGet returns one repo's detail view (branches + resolved HEAD), or
|
||||
// errNotFound.
|
||||
func (s *svc) coreGet(ctx context.Context, org, project, name string) (repoView, error) {
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return repoView{}, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
name = normalizeName(name)
|
||||
r, err := store.Get(ctx, org, project, name)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return repoView{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return repoView{}, fmt.Errorf("get: %w", err)
|
||||
}
|
||||
branches, head := s.refState(org, project, name)
|
||||
return s.toView(r, branches, head), nil
|
||||
}
|
||||
|
||||
// coreDelete removes a repo + purges its storage. Returns errNotFound when no
|
||||
// row went. Storage purge failure is logged, never fatal (metadata is the
|
||||
// source of truth for existence).
|
||||
func (s *svc) coreDelete(ctx context.Context, org, project, name string) error {
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
name = normalizeName(name)
|
||||
deleted, err := store.Delete(ctx, org, project, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
if !deleted {
|
||||
return errNotFound
|
||||
}
|
||||
if err := s.storage.remove(org, project, name); err != nil {
|
||||
s.log.Warn("purge repo storage failed (continuing)", "org", org, "project", project, "repo", name, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coreUsage returns the org-wide per-repo + total storage rollup.
|
||||
func (s *svc) coreUsage(ctx context.Context, org string) (usageView, error) {
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return usageView{}, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
rows, err := store.ListOrg(ctx, org)
|
||||
if err != nil {
|
||||
return usageView{}, fmt.Errorf("usage: %w", err)
|
||||
}
|
||||
out := usageView{Org: org, Repos: make([]usageRepo, 0, len(rows))}
|
||||
for _, r := range rows {
|
||||
out.Repos = append(out.Repos, usageRepo{Name: r.Name, Project: r.Project, SizeBytes: r.SizeBytes})
|
||||
out.TotalBytes += r.SizeBytes
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+87
-87
@@ -72,6 +72,9 @@ type svc struct {
|
||||
storage *storage
|
||||
log luxlog.Logger
|
||||
domain string // for cloneUrl construction
|
||||
sshHost string // for sshUrl construction (e.g. "git.hanzo.ai")
|
||||
ssh *sshServer
|
||||
keys *keyStore // SSH public-key registry (global fingerprint index)
|
||||
}
|
||||
|
||||
// storeFor resolves the caller's org-scoped repo-metadata store, opening the
|
||||
@@ -95,6 +98,7 @@ type repoView struct {
|
||||
Branches []string `json:"branches,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
CloneURL string `json:"cloneUrl"`
|
||||
SSHURL string `json:"sshUrl"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
@@ -115,11 +119,23 @@ func (s *svc) cloneURL(org, name string) string {
|
||||
return fmt.Sprintf("https://%s/v1/git/%s/%s.git", host, org, name)
|
||||
}
|
||||
|
||||
// sshURL is the scp-style Git SSH remote: git@<sshHost>:<org>/<repo>.git. The
|
||||
// colon (not slash) after the host is the canonical scp-like syntax `git clone`
|
||||
// accepts; the org/repo tail is the same path the SSH exec handler parses.
|
||||
func (s *svc) sshURL(org, name string) string {
|
||||
host := s.sshHost
|
||||
if host == "" {
|
||||
host = defaultSSHHost(s.domain)
|
||||
}
|
||||
return fmt.Sprintf("git@%s:%s/%s.git", host, org, name)
|
||||
}
|
||||
|
||||
func (s *svc) toView(r Repo, branches []string, head string) repoView {
|
||||
return repoView{
|
||||
ID: r.ID, Org: r.Org, Project: r.Project, Name: r.Name, Description: r.Description,
|
||||
DefaultBranch: r.DefaultBranch, Branches: branches, Head: head,
|
||||
CloneURL: s.cloneURL(r.Org, r.Name),
|
||||
SSHURL: s.sshURL(r.Org, r.Name),
|
||||
SizeBytes: r.SizeBytes, CreatedAt: rfc3339(r.CreatedAt), UpdatedAt: rfc3339(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
@@ -137,15 +153,22 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
if deps.DataDir == "" {
|
||||
return fmt.Errorf("git.Mount: empty DataDir")
|
||||
}
|
||||
st, err := newStorage(filepath.Join(deps.DataDir, "git"))
|
||||
gitRoot := filepath.Join(deps.DataDir, "git")
|
||||
st, err := newStorage(gitRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git.Mount: open storage: %w", err)
|
||||
}
|
||||
keys, err := openKeyStore(filepath.Join(gitRoot, "ssh_keys.db"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("git.Mount: open ssh key store: %w", err)
|
||||
}
|
||||
s := &svc{
|
||||
stores: cloud.NewTenantStore(deps.DataDir, "git", openStore),
|
||||
storage: st,
|
||||
log: log,
|
||||
domain: deps.Domain,
|
||||
sshHost: gitSSHHost(deps.Domain),
|
||||
keys: keys,
|
||||
}
|
||||
mounted = s
|
||||
|
||||
@@ -156,6 +179,13 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
app.Get("/v1/git/usage", s.usage)
|
||||
app.Get("/v1/git/repos/:name", s.get)
|
||||
app.Delete("/v1/git/repos/:name", s.del)
|
||||
// Push generated files without a local git client (hanzo.app builder).
|
||||
// A distinct trailing segment, so it never shadows the :org/:repo routes.
|
||||
app.Post("/v1/git/repos/:name/push", s.pushFiles)
|
||||
// SSH public-key registry (per-user keys for `git clone git@…`).
|
||||
app.Post("/v1/git/keys", s.registerKey)
|
||||
app.Get("/v1/git/keys", s.listKeys)
|
||||
app.Delete("/v1/git/keys/:id", s.deleteKey)
|
||||
// Mirror an external repo into <org>/:name (creates the repo on first use).
|
||||
// A distinct trailing segment, so it never shadows the :org/:repo smart-HTTP
|
||||
// routes below.
|
||||
@@ -167,7 +197,26 @@ func Mount(app *zip.App, deps cloud.Deps) error {
|
||||
app.Post("/v1/git/:org/:repo/git-upload-pack", s.uploadPack)
|
||||
app.Post("/v1/git/:org/:repo/git-receive-pack", s.receivePack)
|
||||
|
||||
log.Info("git mounted", "brand", deps.Brand, "storage", "osfs", "root", filepath.Join(deps.DataDir, "git"))
|
||||
// ZAP transport (WebSocket) — the SAME control-plane core, reachable by
|
||||
// browsers/services that speak ZAP instead of REST. Mounts at /zap. See zap.go.
|
||||
if err := s.mountZAP(app); err != nil {
|
||||
return fmt.Errorf("git.Mount: mount ZAP: %w", err)
|
||||
}
|
||||
|
||||
// SSH transport: `git clone git@<sshHost>:<org>/<repo>.git`. The listener is
|
||||
// a per-process goroutine started here and stopped by Shutdown. The host key
|
||||
// is loaded from KMS/env or generated + persisted under the git data root.
|
||||
sshSrv, err := newSSHServer(s, sshConfig(deps, gitRoot))
|
||||
if err != nil {
|
||||
return fmt.Errorf("git.Mount: init ssh: %w", err)
|
||||
}
|
||||
s.ssh = sshSrv
|
||||
if err := sshSrv.start(); err != nil {
|
||||
return fmt.Errorf("git.Mount: start ssh: %w", err)
|
||||
}
|
||||
|
||||
log.Info("git mounted", "brand", deps.Brand, "storage", "osfs", "root", gitRoot,
|
||||
"sshHost", s.sshHost, "sshAddr", sshSrv.addr(), "zap", "/zap")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -188,51 +237,28 @@ func (s *svc) create(c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
var body createReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
name := normalizeName(body.Name)
|
||||
if name == "" {
|
||||
return zip.ErrBadRequest("name is required")
|
||||
}
|
||||
if !nameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
}
|
||||
// Project sub-scope: explicit body value wins, else the header sub-scope.
|
||||
project := strings.TrimSpace(body.Project)
|
||||
if project == "" {
|
||||
project = projectScope(c)
|
||||
} else if !projectRE.MatchString(project) {
|
||||
return zip.ErrBadRequest("project must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
}
|
||||
if len(body.Description) > 4096 {
|
||||
return zip.ErrBadRequest("description too large (max 4KiB)")
|
||||
}
|
||||
|
||||
id, err := genID("repo")
|
||||
view, err := s.coreCreate(c.Context(), org, projectScope(c), body)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
return createErr(err)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
r := Repo{
|
||||
ID: id, Org: org, Project: project, Name: name,
|
||||
Description: strings.TrimSpace(body.Description), DefaultBranch: defaultBranchName,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
return c.JSON(http.StatusCreated, view)
|
||||
}
|
||||
|
||||
// createErr maps a coreCreate error to its HTTP status. The ONE mapping the REST
|
||||
// adapter applies; the ZAP adapter maps the SAME sentinels to its own vocabulary.
|
||||
func createErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, errBadInput):
|
||||
return zip.ErrBadRequest(strings.TrimPrefix(err.Error(), "git: invalid input: "))
|
||||
case errors.Is(err, errConflict):
|
||||
return zip.ErrConflict("repo name already exists in this scope")
|
||||
default:
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
if err := s.provision(c.Context(), store, r); err != nil {
|
||||
if errors.Is(err, errConflict) {
|
||||
return zip.ErrConflict("repo name already exists in this scope")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "provision: %v", err)
|
||||
}
|
||||
// Record initial storage size (billing hook: an empty bare repo is a few KiB).
|
||||
r.SizeBytes = s.recordUsage(c.Context(), org, project, name)
|
||||
return c.JSON(http.StatusCreated, s.toView(r, nil, ""))
|
||||
}
|
||||
|
||||
// provision materializes a repo: its metadata row plus an empty bare repo on
|
||||
@@ -256,17 +282,9 @@ func (s *svc) list(c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
out, err := s.coreList(c.Context(), org, projectScope(c))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
rows, err := store.List(c.Context(), org, projectScope(c))
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list: %v", err)
|
||||
}
|
||||
out := make([]repoView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, s.toView(r, nil, ""))
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
@@ -276,21 +294,14 @@ func (s *svc) get(c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
name := normalizeName(c.Param("name"))
|
||||
project := projectScope(c)
|
||||
r, err := store.Get(c.Context(), org, project, name)
|
||||
view, err := s.coreGet(c.Context(), org, projectScope(c), c.Param("name"))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("repo not found")
|
||||
}
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "get: %v", err)
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
branches, head := s.refState(org, project, name)
|
||||
return c.JSON(http.StatusOK, s.toView(r, branches, head))
|
||||
return c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *svc) del(c *zip.Ctx) error {
|
||||
@@ -298,23 +309,12 @@ func (s *svc) del(c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
name := normalizeName(c.Param("name"))
|
||||
project := projectScope(c)
|
||||
deleted, err := store.Delete(c.Context(), org, project, name)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
err := s.coreDelete(c.Context(), org, projectScope(c), c.Param("name"))
|
||||
if errors.Is(err, errNotFound) {
|
||||
return zip.ErrNotFound("repo not found")
|
||||
}
|
||||
// Purge storage. Metadata is already gone, so a purge failure must not
|
||||
// resurrect the repo — log and continue.
|
||||
if err := s.storage.remove(org, project, name); err != nil {
|
||||
s.log.Warn("purge repo storage failed (continuing)", "org", org, "project", project, "repo", name, "err", err)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -341,18 +341,9 @@ func (s *svc) usage(c *zip.Ctx) error {
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
out, err := s.coreUsage(c.Context(), org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
rows, err := store.ListOrg(c.Context(), org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "usage: %v", err)
|
||||
}
|
||||
out := usageView{Org: org, Repos: make([]usageRepo, 0, len(rows))}
|
||||
for _, r := range rows {
|
||||
out.Repos = append(out.Repos, usageRepo{Name: r.Name, Project: r.Project, SizeBytes: r.SizeBytes})
|
||||
out.TotalBytes += r.SizeBytes
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -453,12 +444,21 @@ func genID(prefix string) (string, error) {
|
||||
return prefix + "_" + hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// Shutdown closes every open per-org git store. Idempotent.
|
||||
// Shutdown stops the SSH listener and closes every open store (per-org repo
|
||||
// metadata + the SSH key registry). Idempotent.
|
||||
func Shutdown() error {
|
||||
if mounted == nil {
|
||||
return nil
|
||||
}
|
||||
if mounted.ssh != nil {
|
||||
mounted.ssh.stop()
|
||||
}
|
||||
err := mounted.stores.CloseAll()
|
||||
if mounted.keys != nil {
|
||||
if kerr := mounted.keys.Close(); kerr != nil && err == nil {
|
||||
err = kerr
|
||||
}
|
||||
}
|
||||
mounted = nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
"github.com/go-git/go-billy/v5/memfs"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
@@ -22,9 +21,10 @@ import (
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/zap-proto/zip"
|
||||
"github.com/valyala/fasthttp"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
var testCfg = fiber.TestConfig{Timeout: 10 * time.Second, FailOnTimeout: true}
|
||||
@@ -63,6 +63,10 @@ func asTenant(org string) { asOrg.Lock(); asOrg.org = org; asOrg.Unlock() }
|
||||
|
||||
func mountApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
// Bind the SSH listener to an ephemeral loopback port so parallel/sequential
|
||||
// tests never collide on the default :2222 (each Mount gets its own port +
|
||||
// host key under its own TempDir).
|
||||
t.Setenv("CLOUD_GIT_SSH_ADDR", "127.0.0.1:0")
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Domain: "api.hanzo.test"}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// keys.go is the control-plane surface for the SSH public-key registry:
|
||||
//
|
||||
// POST /v1/git/keys register a key (title + openssh pubkey) -> keyView (201)
|
||||
// GET /v1/git/keys list the tenant's keys -> {data:[keyView]}
|
||||
// DELETE /v1/git/keys/:id remove a key -> 204
|
||||
//
|
||||
// These are thin adapters over keystore.go, tenant-scoped identically to the
|
||||
// repo routes (principal.Tenant → X-Org-Id). A key is stored with its SHA256
|
||||
// fingerprint as the global unique handle; SSH auth (ssh.go) resolves a
|
||||
// presented key to its owner by that fingerprint.
|
||||
|
||||
type registerKeyReq struct {
|
||||
Title string `json:"title"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
|
||||
// registerKey validates an OpenSSH public key, computes its fingerprint, and
|
||||
// stores it under the caller's org + user. The full key round-trips (it is
|
||||
// public); the fingerprint is the auth lookup key. A key already registered
|
||||
// (to this or any org — fingerprint is globally unique) yields 409.
|
||||
func (s *svc) registerKey(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
var body registerKeyReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
raw := strings.TrimSpace(body.PublicKey)
|
||||
if raw == "" {
|
||||
return zip.ErrBadRequest("publicKey is required")
|
||||
}
|
||||
title := strings.TrimSpace(body.Title)
|
||||
if len(title) > 256 {
|
||||
return zip.ErrBadRequest("title too long (max 256)")
|
||||
}
|
||||
// Parse the authorized-key line to validate it and canonicalize the stored
|
||||
// form + fingerprint. A malformed key is a 400, never stored.
|
||||
pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(raw))
|
||||
if err != nil {
|
||||
return zip.ErrBadRequest("invalid openssh public key")
|
||||
}
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(comment) // fall back to the key comment as the label
|
||||
}
|
||||
// Canonical authorized-key line (type + base64), no trailing newline.
|
||||
canonical := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
|
||||
fp := ssh.FingerprintSHA256(pub)
|
||||
|
||||
id, err := genID("gitkey")
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
|
||||
}
|
||||
row := sshKey{
|
||||
ID: id, Org: org, UserID: strings.TrimSpace(c.User()), Title: title,
|
||||
PublicKey: canonical, Fingerprint: fp, CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
if err := s.keys.Add(c.Context(), row); err != nil {
|
||||
if errors.Is(err, errKeyConflict) {
|
||||
return zip.ErrConflict("this ssh key is already registered")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "register key: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, row.view())
|
||||
}
|
||||
|
||||
// listKeys returns the caller org's registered keys.
|
||||
func (s *svc) listKeys(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
rows, err := s.keys.List(c.Context(), org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "list keys: %v", err)
|
||||
}
|
||||
out := make([]keyView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.view())
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// deleteKey removes a key by id, scoped to the caller's org (a tenant can only
|
||||
// delete its own keys).
|
||||
func (s *svc) deleteKey(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
return zip.ErrBadRequest("key id required")
|
||||
}
|
||||
if err := s.keys.Delete(c.Context(), org, id); err != nil {
|
||||
if errors.Is(err, errKeyNotFound) {
|
||||
return zip.ErrNotFound("key not found")
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "delete key: %v", err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/hanzoai/sqlite" // registers the "sqlite" driver
|
||||
)
|
||||
|
||||
// keystore.go is the SSH public-key registry: the ONE place a presented SSH key
|
||||
// is resolved to a (tenant, user). It is a SINGLE global SQLite file
|
||||
// ({DataDir}/git/ssh_keys.db) keyed by the key fingerprint, NOT a per-org file
|
||||
// like the repo store — because the SSH PublicKeyCallback runs BEFORE any org is
|
||||
// known: the ONLY thing the server has is the presented key. Auth is therefore a
|
||||
// single fingerprint lookup, and the row carries the org the key belongs to.
|
||||
//
|
||||
// The fingerprint (SHA256, RFC 4253 form) is the PRIMARY KEY, so a given public
|
||||
// key belongs to exactly one org: re-registering the same key under a different
|
||||
// org is refused (errKeyConflict). Listing + deletion filter by org, so a tenant
|
||||
// only ever sees/removes its own keys.
|
||||
//
|
||||
// What is stored: the key TITLE (a human label), the full authorized-key line
|
||||
// (the public key — public keys are NOT secrets, and the full bytes are needed
|
||||
// to match a presented key exactly), the SHA256 fingerprint (the lookup key),
|
||||
// the owning org, and the registering user id. No private material ever touches
|
||||
// this store; there is nothing here to hash — a fingerprint is already a hash.
|
||||
|
||||
var (
|
||||
// errKeyConflict is returned when a fingerprint is already registered (to
|
||||
// this or another org). Handlers map it to 409.
|
||||
errKeyConflict = errors.New("git: ssh key already registered")
|
||||
// errKeyNotFound is returned when a delete misses. Handlers map it to 404.
|
||||
errKeyNotFound = errors.New("git: ssh key not found")
|
||||
)
|
||||
|
||||
// sshKey is one registered public key row.
|
||||
type sshKey struct {
|
||||
ID string
|
||||
Org string
|
||||
UserID string
|
||||
Title string
|
||||
PublicKey string // full authorized-key line, e.g. "ssh-ed25519 AAAA... comment"
|
||||
Fingerprint string // SHA256:... (RFC 4253 form from ssh.FingerprintSHA256)
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// keyStore is the global SSH-key registry DB. MaxOpenConns(1) serializes writes
|
||||
// against the file lock, matching the repo store's discipline.
|
||||
type keyStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// openKeyStore opens (creating if absent) the global SSH-key registry and runs
|
||||
// its migration.
|
||||
func openKeyStore(path string) (*keyStore, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open ssh key db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1) // serialize writes against the file lock (same discipline as TenantDB)
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ssh key db pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
ks := &keyStore{db: db}
|
||||
if err := ks.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ks, nil
|
||||
}
|
||||
|
||||
func (k *keyStore) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
public_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_ssh_keys_org ON ssh_keys(org, created_at);
|
||||
`
|
||||
if _, err := k.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("migrate ssh keys: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (k *keyStore) Close() error { return k.db.Close() }
|
||||
|
||||
// Add inserts a new key row. Returns errKeyConflict when the fingerprint is
|
||||
// already registered (UNIQUE on fingerprint — a key belongs to exactly one org).
|
||||
func (k *keyStore) Add(ctx context.Context, key sshKey) error {
|
||||
_, err := k.db.ExecContext(ctx,
|
||||
`INSERT INTO ssh_keys (id,org,user_id,title,public_key,fingerprint,created_at) VALUES (?,?,?,?,?,?,?)`,
|
||||
key.ID, key.Org, key.UserID, key.Title, key.PublicKey, key.Fingerprint, key.CreatedAt)
|
||||
if err != nil {
|
||||
if isUnique(err) {
|
||||
return errKeyConflict
|
||||
}
|
||||
return fmt.Errorf("insert ssh key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByFingerprint resolves a presented key's fingerprint to its row — the auth
|
||||
// lookup the SSH PublicKeyCallback runs. errKeyNotFound (fail CLOSED) when the
|
||||
// key was never registered.
|
||||
func (k *keyStore) ByFingerprint(ctx context.Context, fp string) (sshKey, error) {
|
||||
row := k.db.QueryRowContext(ctx,
|
||||
`SELECT id,org,user_id,title,public_key,fingerprint,created_at FROM ssh_keys WHERE fingerprint=?`, fp)
|
||||
return scanKey(row)
|
||||
}
|
||||
|
||||
// List returns an org's registered keys, most-recent first. Filtered by org so a
|
||||
// tenant never sees another's keys.
|
||||
func (k *keyStore) List(ctx context.Context, org string) ([]sshKey, error) {
|
||||
rows, err := k.db.QueryContext(ctx,
|
||||
`SELECT id,org,user_id,title,public_key,fingerprint,created_at FROM ssh_keys WHERE org=? ORDER BY created_at DESC, id ASC`, org)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list ssh keys: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []sshKey
|
||||
for rows.Next() {
|
||||
key, err := scanKey(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan ssh key: %w", err)
|
||||
}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes an org's key by id. Scoped by org so a tenant can only delete
|
||||
// its OWN keys (an id from another org matches no row). Returns errKeyNotFound
|
||||
// when nothing went.
|
||||
func (k *keyStore) Delete(ctx context.Context, org, id string) error {
|
||||
res, err := k.db.ExecContext(ctx, `DELETE FROM ssh_keys WHERE org=? AND id=?`, org, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete ssh key: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errKeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanKey(sc interface{ Scan(...any) error }) (sshKey, error) {
|
||||
var key sshKey
|
||||
err := sc.Scan(&key.ID, &key.Org, &key.UserID, &key.Title, &key.PublicKey, &key.Fingerprint, &key.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return sshKey{}, errKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return sshKey{}, fmt.Errorf("scan ssh key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// keyView is the API shape for a registered key. The full public key is echoed
|
||||
// (it is public); the fingerprint is the stable handle a UI shows.
|
||||
type keyView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (key sshKey) view() keyView {
|
||||
return keyView{
|
||||
ID: key.ID, Title: key.Title, PublicKey: key.PublicKey,
|
||||
Fingerprint: key.Fingerprint, CreatedAt: time.Unix(key.CreatedAt, 0).UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
)
|
||||
|
||||
// errBadPack marks a client-side protocol error (a malformed upload-pack or
|
||||
// receive-pack request body) so a transport adapter can answer 400 rather than
|
||||
// 500. Wrapped by serveUploadPack / serveReceivePack around the decode step.
|
||||
var errBadPack = errors.New("git: malformed pack request")
|
||||
|
||||
// readerOnly hides an io.Reader's Closer (if any) from go-git's pack decoders.
|
||||
// The SSH channel is an io.ReadWriteCloser; go-git captures the decode reader as
|
||||
// the packfile reader and Closes it after unpacking — which on a raw channel
|
||||
// would close the WRITE side too. Wrapping the read side in a plain io.Reader
|
||||
// means that Close becomes a no-op (io.NopCloser), leaving the channel writable
|
||||
// for the response/report.
|
||||
type readerOnly struct{ r io.Reader }
|
||||
|
||||
func (ro readerOnly) Read(p []byte) (int, error) { return ro.r.Read(p) }
|
||||
|
||||
// pack.go is the ONE Git pack code path. Both transports — smart-HTTP
|
||||
// (clients/git/smart_http.go) and SSH (clients/git/ssh.go) — drive clone/fetch
|
||||
// and push through the SAME two funcs here, so there is exactly one place that
|
||||
// decodes a request, runs the go-git server session, records usage, and fires
|
||||
// the push-to-deploy hook. The transports differ only in framing: HTTP carries
|
||||
// the request/response as a body/response byte stream; SSH carries it as a
|
||||
// channel's stdin/stdout. Both are io.Reader (in) + io.Writer (out), so the
|
||||
// pack logic never knows which transport called it (DRY — Rich Hickey's
|
||||
// "one fact, one place").
|
||||
//
|
||||
// The org/project/name are already resolved + tenant-checked by the caller
|
||||
// (the HTTP handler from X-Org-Id, the SSH session from the presented key's
|
||||
// bound org), so these funcs are transport-agnostic and take plain strings —
|
||||
// never a *zip.Ctx or an *ssh.Session.
|
||||
|
||||
// serveUploadPack runs the upload-pack (clone/fetch) exchange for one repo,
|
||||
// reading the client's wants/haves from in and writing the packfile result to
|
||||
// out. The caller has verified the repo exists and the tenant owns it.
|
||||
func (s *svc) serveUploadPack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
|
||||
req := packp.NewUploadPackRequest()
|
||||
if err := req.Decode(in); err != nil {
|
||||
return fmt.Errorf("%w: decode upload-pack request: %v", errBadPack, err)
|
||||
}
|
||||
sess, err := s.uploadSession(org, project, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git session: %w", err)
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
|
||||
resp, err := sess.UploadPack(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload-pack: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Close() }()
|
||||
|
||||
if err := resp.Encode(out); err != nil {
|
||||
return fmt.Errorf("encode upload-pack: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveReceivePack runs the receive-pack (push) exchange for one repo, reading
|
||||
// the ref-update commands + packfile from in and writing the report-status to
|
||||
// out. On success it re-measures storage (billing) and fires the push-to-deploy
|
||||
// build hook — the SAME side effects a smart-HTTP push produces — so a push over
|
||||
// SSH and a push over HTTPS are indistinguishable downstream.
|
||||
//
|
||||
// The caller has verified the repo exists and the tenant owns it. usage +
|
||||
// build-trigger are best-effort by contract: the push has already landed, so a
|
||||
// metering miss or trigger failure is never surfaced to the client.
|
||||
func (s *svc) serveReceivePack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
|
||||
req := packp.NewReferenceUpdateRequest()
|
||||
if err := req.Decode(in); err != nil {
|
||||
return fmt.Errorf("%w: decode receive-pack request: %v", errBadPack, err)
|
||||
}
|
||||
sess, err := s.receiveSession(org, project, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git session: %w", err)
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
|
||||
report, err := sess.ReceivePack(ctx, req)
|
||||
if report == nil && err != nil {
|
||||
return fmt.Errorf("receive-pack: %w", err)
|
||||
}
|
||||
|
||||
// Re-measure + fire builds on a cancel-immune context: the client's transport
|
||||
// may close the instant the report is flushed, but the push already committed,
|
||||
// so the metering + build-trigger must still run to completion.
|
||||
bg := context.WithoutCancel(ctx)
|
||||
s.recordUsage(bg, org, project, name)
|
||||
s.firePushBuilds(bg, org, project, name, req)
|
||||
|
||||
if report != nil {
|
||||
if err := report.Encode(out); err != nil {
|
||||
return fmt.Errorf("encode report: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshUploadPack drives the FULL native-git-protocol upload-pack (clone/fetch)
|
||||
// exchange over an SSH channel: it advertises refs first (native protocol has no
|
||||
// separate info/refs request the way smart-HTTP does), THEN reads the client's
|
||||
// wants/haves and streams the packfile — all on ONE go-git session, over the
|
||||
// channel's stdin (in) / stdout (out). This is the SSH framing wrapper around the
|
||||
// SAME go-git upload-pack session the smart-HTTP path uses.
|
||||
func (s *svc) sshUploadPack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
|
||||
sess, err := s.uploadSession(org, project, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git session: %w", err)
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
|
||||
// 1) Advertise refs (native protocol — no "# service=" prefix). An empty repo
|
||||
// still advertises capabilities + a flush so the client can proceed / stop.
|
||||
ar, err := sess.AdvertisedReferencesContext(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, transport.ErrEmptyRemoteRepository) {
|
||||
// Empty repo: send a bare capabilities advertisement + flush so the
|
||||
// client cleanly observes "nothing to fetch" instead of hanging.
|
||||
ar = packp.NewAdvRefs()
|
||||
} else {
|
||||
return fmt.Errorf("advertise refs: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ar.Encode(out); err != nil {
|
||||
return fmt.Errorf("encode refs: %w", err)
|
||||
}
|
||||
|
||||
// 2) Read the client's wants/haves. An empty request (client had everything /
|
||||
// nothing to want) is a clean end, not an error. Read-only view so a decoder
|
||||
// Close cannot tear down the channel's write side (see readerOnly).
|
||||
req := packp.NewUploadPackRequest()
|
||||
if err := req.Decode(readerOnly{in}); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: decode upload-pack request: %v", errBadPack, err)
|
||||
}
|
||||
if req.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := sess.UploadPack(ctx, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, transport.ErrEmptyUploadPackRequest) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("upload-pack: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Close() }()
|
||||
if err := resp.Encode(out); err != nil {
|
||||
return fmt.Errorf("encode upload-pack: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshReceivePack drives the FULL native-git-protocol receive-pack (push) exchange
|
||||
// over an SSH channel: advertise refs first, THEN read the ref-update commands +
|
||||
// packfile and apply them, then write the report-status — on ONE session, over
|
||||
// the channel's stdin/stdout. It records usage + fires the build hook on success,
|
||||
// the SAME side effects the smart-HTTP push produces.
|
||||
func (s *svc) sshReceivePack(ctx context.Context, org, project, name string, in io.Reader, out io.Writer) error {
|
||||
sess, err := s.receiveSession(org, project, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git session: %w", err)
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
|
||||
ar, err := sess.AdvertisedReferencesContext(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("advertise refs: %w", err)
|
||||
}
|
||||
if err := ar.Encode(out); err != nil {
|
||||
return fmt.Errorf("encode refs: %w", err)
|
||||
}
|
||||
|
||||
// Decode from a READ-ONLY view of the channel. go-git's ReferenceUpdateRequest
|
||||
// captures the reader AS the packfile reader and calls Close() on it after
|
||||
// unpacking; if that reader were the ssh.Channel itself (an io.ReadWriteCloser),
|
||||
// Close would tear down the WHOLE channel — including the write side we still
|
||||
// need to send the report-status on. readerOnly hides the Closer so the pack
|
||||
// unpack cannot close our write side.
|
||||
req := packp.NewReferenceUpdateRequest()
|
||||
if err := req.Decode(readerOnly{in}); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil // client disconnected without pushing — clean end
|
||||
}
|
||||
return fmt.Errorf("%w: decode receive-pack request: %v", errBadPack, err)
|
||||
}
|
||||
|
||||
report, err := sess.ReceivePack(ctx, req)
|
||||
if report == nil && err != nil {
|
||||
return fmt.Errorf("receive-pack: %w", err)
|
||||
}
|
||||
|
||||
bg := context.WithoutCancel(ctx)
|
||||
s.recordUsage(bg, org, project, name)
|
||||
s.firePushBuilds(bg, org, project, name, req)
|
||||
|
||||
if report != nil {
|
||||
if err := report.Encode(out); err != nil {
|
||||
return fmt.Errorf("encode report: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refExists reports whether refs/heads/<branch> resolves in the repo — used by
|
||||
// the REST push path to decide create-vs-update semantics for the build trigger.
|
||||
func (s *svc) refExists(org, project, name, branch string) (plumbing.Hash, bool) {
|
||||
st, err := s.storage.storer(org, project, name)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, false
|
||||
}
|
||||
ref, err := st.Reference(plumbing.NewBranchReferenceName(branch))
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, false
|
||||
}
|
||||
return ref.Hash(), true
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
|
||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// push.go adds POST /v1/git/repos/:name/push — a client-less push. The hanzo.app
|
||||
// builder (and any caller without a local git binary) posts a set of files and
|
||||
// the server builds the tree + commit, updates the branch ref, and fires the
|
||||
// EXACT same push-to-deploy hook a real receive-pack fires (smart_http.go
|
||||
// firePushBuilds), so a client-less push is indistinguishable downstream from a
|
||||
// `git push`.
|
||||
//
|
||||
// It is tenant-scoped identically to the other routes (principal.Tenant →
|
||||
// X-Org-Id). It composes the SAME provision() a create does when the repo is
|
||||
// absent, so there is one way a repo comes into being.
|
||||
|
||||
type pushFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Encoding string `json:"encoding"` // "" | "utf-8" | "base64"
|
||||
}
|
||||
|
||||
type pushReq struct {
|
||||
Branch string `json:"branch"`
|
||||
Message string `json:"message"`
|
||||
Files []pushFile `json:"files"`
|
||||
}
|
||||
|
||||
type pushResp struct {
|
||||
Commit string `json:"commit"`
|
||||
Branch string `json:"branch"`
|
||||
CloneURL string `json:"cloneUrl"`
|
||||
SSHURL string `json:"sshUrl"`
|
||||
}
|
||||
|
||||
// maxPushFiles / maxPushFileBytes bound a single client-less push so a hostile
|
||||
// body cannot exhaust memory. A real monorepo push uses git-receive-pack (chunked
|
||||
// negotiation) — this endpoint is for the builder's generated-file set.
|
||||
const (
|
||||
maxPushFiles = 5000
|
||||
maxPushFileBytes = 32 << 20 // 32 MiB per file
|
||||
)
|
||||
|
||||
// pushFiles handles POST /v1/git/repos/:name/push.
|
||||
func (s *svc) pushFiles(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
name := normalizeName(c.Param("name"))
|
||||
if name == "" || !nameRE.MatchString(name) {
|
||||
return zip.ErrBadRequest("invalid repo name")
|
||||
}
|
||||
project := projectScope(c)
|
||||
|
||||
var body pushReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
commit, branch, err := s.corePush(c.Context(), org, project, name, body)
|
||||
switch {
|
||||
case errors.Is(err, errBadInput):
|
||||
return zip.ErrBadRequest(strings.TrimPrefix(err.Error(), "git: invalid input: "))
|
||||
case err != nil:
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, pushResp{
|
||||
Commit: commit, Branch: branch,
|
||||
CloneURL: s.cloneURL(org, name), SSHURL: s.sshURL(org, name),
|
||||
})
|
||||
}
|
||||
|
||||
// corePush is the transport-agnostic client-less push: it ensures the repo
|
||||
// exists, materializes the files into a tree merged onto the branch tip, writes
|
||||
// a commit, advances refs/heads/<branch> (and HEAD on a fresh branch), records
|
||||
// usage, and fires the build hook. Returns the new commit hash + resolved branch.
|
||||
func (s *svc) corePush(ctx context.Context, org, project, name string, in pushReq) (commitHash, branch string, err error) {
|
||||
branch = strings.TrimSpace(in.Branch)
|
||||
if branch == "" {
|
||||
branch = defaultBranchName
|
||||
}
|
||||
if !branchRE.MatchString(branch) {
|
||||
return "", "", badInput("branch must match ^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
||||
}
|
||||
if len(in.Files) == 0 {
|
||||
return "", "", badInput("files is required (at least one)")
|
||||
}
|
||||
if len(in.Files) > maxPushFiles {
|
||||
return "", "", badInput("too many files (max %d)", maxPushFiles)
|
||||
}
|
||||
|
||||
// Decode + validate files first, so a bad file fails BEFORE we touch storage.
|
||||
blobs, err := decodePushFiles(in.Files)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Ensure the repo exists (create on first push, composing the ONE provision).
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
if _, gerr := store.Get(ctx, org, project, name); errors.Is(gerr, errNotFound) {
|
||||
id, ierr := genID("repo")
|
||||
if ierr != nil {
|
||||
return "", "", fmt.Errorf("rng: %w", ierr)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
r := Repo{ID: id, Org: org, Project: project, Name: name, DefaultBranch: branch, CreatedAt: now, UpdatedAt: now}
|
||||
if perr := s.provision(ctx, store, r); perr != nil && !errors.Is(perr, errConflict) {
|
||||
return "", "", fmt.Errorf("provision: %w", perr)
|
||||
}
|
||||
} else if gerr != nil {
|
||||
return "", "", fmt.Errorf("get: %w", gerr)
|
||||
}
|
||||
|
||||
st, err := s.storage.storer(org, project, name)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("open storer: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the current branch tip (parent commit + base tree), if any.
|
||||
branchRef := plumbing.NewBranchReferenceName(branch)
|
||||
var parents []plumbing.Hash
|
||||
var baseTree *object.Tree
|
||||
if ref, rerr := st.Reference(branchRef); rerr == nil && ref.Hash() != plumbing.ZeroHash {
|
||||
parents = append(parents, ref.Hash())
|
||||
if pc, cerr := object.GetCommit(st, ref.Hash()); cerr == nil {
|
||||
if bt, terr := pc.Tree(); terr == nil {
|
||||
baseTree = bt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the new root tree = base tree with the pushed files added/overwritten,
|
||||
// then write the commit.
|
||||
rootHash, err := writeTreeWithFiles(st, baseTree, blobs)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("build tree: %w", err)
|
||||
}
|
||||
msg := strings.TrimSpace(in.Message)
|
||||
if msg == "" {
|
||||
msg = "push via /v1/git/repos/" + name + "/push"
|
||||
}
|
||||
now := time.Now()
|
||||
commit := &object.Commit{
|
||||
Author: object.Signature{Name: "hanzo-cloud", Email: "git@hanzo.ai", When: now},
|
||||
Committer: object.Signature{Name: "hanzo-cloud", Email: "git@hanzo.ai", When: now},
|
||||
Message: msg,
|
||||
TreeHash: rootHash,
|
||||
ParentHashes: parents,
|
||||
}
|
||||
commitObj := st.NewEncodedObject()
|
||||
if err := commit.Encode(commitObj); err != nil {
|
||||
return "", "", fmt.Errorf("encode commit: %w", err)
|
||||
}
|
||||
ch, err := st.SetEncodedObject(commitObj)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("store commit: %w", err)
|
||||
}
|
||||
|
||||
// Advance the branch ref. On a brand-new branch that is the repo default,
|
||||
// point HEAD at it too (matching `git push` making the first branch the head).
|
||||
newRef := plumbing.NewHashReference(branchRef, ch)
|
||||
if err := st.SetReference(newRef); err != nil {
|
||||
return "", "", fmt.Errorf("update ref: %w", err)
|
||||
}
|
||||
if _, herr := st.Reference(plumbing.HEAD); herr != nil {
|
||||
_ = st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, branchRef))
|
||||
}
|
||||
|
||||
// Same side effects as a receive-pack push: meter storage + fire the build.
|
||||
bg := context.WithoutCancel(ctx)
|
||||
s.recordUsage(bg, org, project, name)
|
||||
s.firePushBuilds(bg, org, project, name, syntheticUpdate(branchRef, parents, ch))
|
||||
|
||||
return ch.String(), branch, nil
|
||||
}
|
||||
|
||||
// blob is a decoded file ready to be written: its repo-relative path + content.
|
||||
type blob struct {
|
||||
path string
|
||||
content []byte
|
||||
}
|
||||
|
||||
// decodePushFiles validates + decodes the request files (utf-8 default, or
|
||||
// base64 when Encoding=="base64"), rejecting empty/absolute/traversing paths.
|
||||
func decodePushFiles(files []pushFile) ([]blob, error) {
|
||||
out := make([]blob, 0, len(files))
|
||||
seen := make(map[string]struct{}, len(files))
|
||||
for i, f := range files {
|
||||
p := cleanRepoPath(f.Path)
|
||||
if p == "" {
|
||||
return nil, badInput("files[%d].path is invalid (empty, absolute, or traversing)", i)
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
return nil, badInput("files[%d].path %q is duplicated", i, p)
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
|
||||
var content []byte
|
||||
switch strings.ToLower(strings.TrimSpace(f.Encoding)) {
|
||||
case "", "utf-8", "utf8", "text":
|
||||
content = []byte(f.Content)
|
||||
case "base64":
|
||||
b, err := base64.StdEncoding.DecodeString(f.Content)
|
||||
if err != nil {
|
||||
return nil, badInput("files[%d].content is not valid base64", i)
|
||||
}
|
||||
content = b
|
||||
default:
|
||||
return nil, badInput("files[%d].encoding must be utf-8 or base64", i)
|
||||
}
|
||||
if len(content) > maxPushFileBytes {
|
||||
return nil, badInput("files[%d] exceeds %d bytes", i, maxPushFileBytes)
|
||||
}
|
||||
out = append(out, blob{path: p, content: content})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cleanRepoPath normalizes a file path to a safe repo-relative slash path, or ""
|
||||
// if it is empty, absolute, or escapes the root. This is the traversal guard on
|
||||
// the client-less push boundary.
|
||||
func cleanRepoPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.TrimPrefix(p, "./")
|
||||
if p == "" || strings.HasPrefix(p, "/") {
|
||||
return ""
|
||||
}
|
||||
// Reject any traversal or empty segments.
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// treeNode is a mutable tree used to accumulate blobs into a hierarchy before
|
||||
// serializing bottom-up. subDirs keeps child directories; files keeps leaf blob
|
||||
// hashes.
|
||||
type treeNode struct {
|
||||
subDirs map[string]*treeNode
|
||||
files map[string]plumbing.Hash
|
||||
}
|
||||
|
||||
func newTreeNode() *treeNode {
|
||||
return &treeNode{subDirs: map[string]*treeNode{}, files: map[string]plumbing.Hash{}}
|
||||
}
|
||||
|
||||
// writeTreeWithFiles builds the new root tree: it seeds from baseTree (so a push
|
||||
// adds/overwrites onto the existing content), writes each file's blob, layers the
|
||||
// files into a directory hierarchy, then serializes the trees bottom-up and
|
||||
// returns the root tree hash.
|
||||
func writeTreeWithFiles(st storer.EncodedObjectStorer, baseTree *object.Tree, blobs []blob) (plumbing.Hash, error) {
|
||||
root := newTreeNode()
|
||||
// Seed from the base tree so unchanged files survive the push.
|
||||
if baseTree != nil {
|
||||
if err := seedFromTree(st, root, baseTree, ""); err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
}
|
||||
// Write each pushed blob and place it in the hierarchy (overwriting).
|
||||
for _, b := range blobs {
|
||||
h, err := writeBlob(st, b.content)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
placeFile(root, strings.Split(b.path, "/"), h)
|
||||
}
|
||||
return writeTreeNode(st, root)
|
||||
}
|
||||
|
||||
// seedFromTree copies the base tree's entries into the mutable node hierarchy so
|
||||
// a push merges onto existing content rather than replacing it.
|
||||
func seedFromTree(st storer.EncodedObjectStorer, node *treeNode, t *object.Tree, prefix string) error {
|
||||
for _, e := range t.Entries {
|
||||
if e.Mode == filemode.Dir {
|
||||
sub, err := object.GetTree(st, e.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
child := newTreeNode()
|
||||
node.subDirs[e.Name] = child
|
||||
if err := seedFromTree(st, child, sub, prefix+e.Name+"/"); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
node.files[e.Name] = e.Hash
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeFile inserts a file hash at the given path segments, creating intermediate
|
||||
// directories. A file overwrites any prior file of the same name.
|
||||
func placeFile(node *treeNode, segs []string, h plumbing.Hash) {
|
||||
if len(segs) == 1 {
|
||||
delete(node.subDirs, segs[0]) // a file replaces a dir of the same name
|
||||
node.files[segs[0]] = h
|
||||
return
|
||||
}
|
||||
dir := segs[0]
|
||||
child, ok := node.subDirs[dir]
|
||||
if !ok {
|
||||
child = newTreeNode()
|
||||
node.subDirs[dir] = child
|
||||
}
|
||||
delete(node.files, dir) // a dir replaces a file of the same name
|
||||
placeFile(child, segs[1:], h)
|
||||
}
|
||||
|
||||
// writeBlob stores a blob object and returns its hash.
|
||||
func writeBlob(st storer.EncodedObjectStorer, content []byte) (plumbing.Hash, error) {
|
||||
obj := st.NewEncodedObject()
|
||||
obj.SetType(plumbing.BlobObject)
|
||||
w, err := obj.Writer()
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
_ = w.Close()
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
return st.SetEncodedObject(obj)
|
||||
}
|
||||
|
||||
// writeTreeNode serializes a mutable tree node bottom-up (children first) into a
|
||||
// git tree object and returns its hash. Entries are sorted per the git tree
|
||||
// ordering contract (object.TreeEntrySorter).
|
||||
func writeTreeNode(st storer.EncodedObjectStorer, node *treeNode) (plumbing.Hash, error) {
|
||||
tree := &object.Tree{}
|
||||
for name, h := range node.files {
|
||||
tree.Entries = append(tree.Entries, object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: h})
|
||||
}
|
||||
for name, child := range node.subDirs {
|
||||
ch, err := writeTreeNode(st, child)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
tree.Entries = append(tree.Entries, object.TreeEntry{Name: name, Mode: filemode.Dir, Hash: ch})
|
||||
}
|
||||
sort.Sort(object.TreeEntrySorter(tree.Entries))
|
||||
obj := st.NewEncodedObject()
|
||||
if err := tree.Encode(obj); err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
return st.SetEncodedObject(obj)
|
||||
}
|
||||
|
||||
// syntheticUpdate builds a ReferenceUpdateRequest with the one command this push
|
||||
// performed, so firePushBuilds sees the SAME shape a real receive-pack produces
|
||||
// (old→new on the branch ref) and triggers exactly as it would for `git push`.
|
||||
func syntheticUpdate(ref plumbing.ReferenceName, parents []plumbing.Hash, newHash plumbing.Hash) *packp.ReferenceUpdateRequest {
|
||||
old := plumbing.ZeroHash
|
||||
if len(parents) > 0 {
|
||||
old = parents[0]
|
||||
}
|
||||
req := packp.NewReferenceUpdateRequest()
|
||||
req.Commands = []*packp.Command{{Name: ref, Old: old, New: newHash}}
|
||||
return req
|
||||
}
|
||||
|
||||
// branchRE constrains a push branch name to a safe ref — nested (feature/foo)
|
||||
// allowed, so it mirrors nameRE plus "/". The ref-update path uses it verbatim,
|
||||
// so this is the traversal/injection guard on the branch name.
|
||||
var branchRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
@@ -0,0 +1,211 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-git/go-billy/v5/memfs"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// TestRESTPushCreatesCommit proves the client-less push: POST files, a commit is
|
||||
// built + the ref advanced, a second push updates the ref (with the first as
|
||||
// parent), and the build hook fires on each push. Then a real git clone sees the
|
||||
// pushed content — proving corePush wrote a valid commit/tree/blob graph.
|
||||
func TestRESTPushCreatesCommit(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var events []cloud.GitPushEvent
|
||||
cloud.RegisterPushBuilder(func(_ context.Context, ev cloud.GitPushEvent) error {
|
||||
mu.Lock()
|
||||
events = append(events, ev)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(func() { cloud.RegisterPushBuilder(nil) })
|
||||
|
||||
app := mountApp(t)
|
||||
base := liveServer(t, app)
|
||||
|
||||
if code, b := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
|
||||
map[string]any{"name": "app"}); code != http.StatusCreated {
|
||||
t.Fatalf("create repo: %d %s", code, b)
|
||||
}
|
||||
|
||||
// First push: two files, one nested, one base64.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/git/repos/app/push", "acme", map[string]any{
|
||||
"branch": "main",
|
||||
"message": "initial",
|
||||
"files": []map[string]any{
|
||||
{"path": "README.md", "content": "# hello\n"},
|
||||
{"path": "src/app.js", "content": "console.log(1)\n"},
|
||||
{"path": "logo.bin", "content": base64.StdEncoding.EncodeToString([]byte{0, 1, 2, 3}), "encoding": "base64"},
|
||||
},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("push want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var r1 pushResp
|
||||
if err := json.Unmarshal(body, &r1); err != nil {
|
||||
t.Fatalf("push resp json: %v (%s)", err, body)
|
||||
}
|
||||
if r1.Commit == "" || r1.Branch != "main" {
|
||||
t.Fatalf("unexpected push resp: %+v", r1)
|
||||
}
|
||||
if r1.CloneURL != "https://api.hanzo.test/v1/git/acme/app.git" {
|
||||
t.Fatalf("unexpected cloneUrl: %q", r1.CloneURL)
|
||||
}
|
||||
if r1.SSHURL != "git@git.hanzo.test:acme/app.git" {
|
||||
t.Fatalf("unexpected sshUrl: %q", r1.SSHURL)
|
||||
}
|
||||
|
||||
// Second push: updates one file — the ref advances, first commit is the parent.
|
||||
code, body = do(t, app, http.MethodPost, "/v1/git/repos/app/push", "acme", map[string]any{
|
||||
"branch": "main",
|
||||
"message": "update",
|
||||
"files": []map[string]any{{"path": "README.md", "content": "# hello v2\n"}},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("second push want 200, got %d (%s)", code, body)
|
||||
}
|
||||
var r2 pushResp
|
||||
_ = json.Unmarshal(body, &r2)
|
||||
if r2.Commit == r1.Commit {
|
||||
t.Fatalf("second push must produce a new commit, got same %s", r2.Commit)
|
||||
}
|
||||
|
||||
// Both pushes fired the build hook, with the right branch + tip commit.
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("want 2 build events, got %d: %+v", len(events), events)
|
||||
}
|
||||
if events[0].Repo != "app" || events[0].Branch != "main" || events[0].Commit != r1.Commit {
|
||||
t.Fatalf("event[0] = %+v (want commit %s)", events[0], r1.Commit)
|
||||
}
|
||||
if events[1].Commit != r2.Commit {
|
||||
t.Fatalf("event[1].Commit = %s, want %s", events[1].Commit, r2.Commit)
|
||||
}
|
||||
|
||||
// A real clone sees the merged content: updated README + the untouched nested
|
||||
// file + the base64 file survive the second push (merge, not replace).
|
||||
cloneURL := base + "/v1/git/acme/app.git"
|
||||
asTenant("acme")
|
||||
cloned, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: cloneURL})
|
||||
if err != nil {
|
||||
t.Fatalf("clone: %v", err)
|
||||
}
|
||||
head, err := cloned.Head()
|
||||
if err != nil {
|
||||
t.Fatalf("head: %v", err)
|
||||
}
|
||||
if head.Hash().String() != r2.Commit {
|
||||
t.Fatalf("cloned HEAD %s != last push %s", head.Hash(), r2.Commit)
|
||||
}
|
||||
commit, err := cloned.CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
t.Fatalf("commit obj: %v", err)
|
||||
}
|
||||
tree, err := commit.Tree()
|
||||
if err != nil {
|
||||
t.Fatalf("tree: %v", err)
|
||||
}
|
||||
// README updated.
|
||||
readme, err := tree.File("README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("README.md missing after 2nd push: %v", err)
|
||||
}
|
||||
rc, _ := readme.Contents()
|
||||
if rc != "# hello v2\n" {
|
||||
t.Fatalf("README content = %q, want updated", rc)
|
||||
}
|
||||
// Nested file from the FIRST push survived (merge onto base tree).
|
||||
if _, err := tree.File("src/app.js"); err != nil {
|
||||
t.Fatalf("src/app.js should survive the second push (merge): %v", err)
|
||||
}
|
||||
// Base64 file decoded to raw bytes.
|
||||
logo, err := tree.File("logo.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("logo.bin missing: %v", err)
|
||||
}
|
||||
lc, _ := logo.Contents()
|
||||
if lc != string([]byte{0, 1, 2, 3}) {
|
||||
t.Fatalf("logo.bin content = %q, want raw bytes", lc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTPushCreatesRepoOnFirstPush proves a push to a not-yet-existing repo
|
||||
// provisions it (composing the ONE provision path), and the repo is then listed.
|
||||
func TestRESTPushCreatesRepoOnFirstPush(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
|
||||
code, body := do(t, app, http.MethodPost, "/v1/git/repos/fresh/push", "acme", map[string]any{
|
||||
"files": []map[string]any{{"path": "main.go", "content": "package main\n"}},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("push to fresh repo want 200, got %d (%s)", code, body)
|
||||
}
|
||||
// The repo now exists in the tenant listing.
|
||||
code, body = do(t, app, http.MethodGet, "/v1/git/repos", "acme", nil)
|
||||
var listed struct {
|
||||
Data []repoView `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &listed)
|
||||
if code != http.StatusOK || len(listed.Data) != 1 || listed.Data[0].Name != "fresh" {
|
||||
t.Fatalf("fresh repo not created by push: %d %+v", code, listed.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTPushValidation covers the input guards: empty files, bad branch,
|
||||
// traversing path, and bad base64.
|
||||
func TestRESTPushValidation(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
|
||||
map[string]any{"name": "v"}); code != http.StatusCreated {
|
||||
t.Fatal("setup create failed")
|
||||
}
|
||||
|
||||
cases := []map[string]any{
|
||||
{"files": []map[string]any{}}, // empty files
|
||||
{"branch": "bad branch", "files": []map[string]any{{"path": "a", "content": "x"}}}, // bad branch
|
||||
{"files": []map[string]any{{"path": "../escape", "content": "x"}}}, // traversal
|
||||
{"files": []map[string]any{{"path": "a", "content": "!!!", "encoding": "base64"}}}, // bad base64
|
||||
}
|
||||
for i, c := range cases {
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/git/repos/v/push", "acme", c); code != http.StatusBadRequest {
|
||||
t.Fatalf("case %d want 400, got %d (%s)", i, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTPushCrossTenantIsolation proves a push is org-scoped: beta pushing to a
|
||||
// repo name acme owns creates beta's OWN repo, never touching acme's.
|
||||
func TestRESTPushCrossTenantIsolation(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
// acme owns "shared".
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
|
||||
map[string]any{"name": "shared"}); code != http.StatusCreated {
|
||||
t.Fatal("acme create failed")
|
||||
}
|
||||
// beta pushes to "shared" — provisions beta's own, does not reach acme's.
|
||||
if code, b := do(t, app, http.MethodPost, "/v1/git/repos/shared/push", "beta", map[string]any{
|
||||
"files": []map[string]any{{"path": "b.txt", "content": "beta\n"}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("beta push want 200, got %d %s", code, b)
|
||||
}
|
||||
// acme's repo is still empty (no HEAD) — beta never wrote into it.
|
||||
branches, head := mounted.refState("acme", "", "shared")
|
||||
if head != "" || len(branches) != 0 {
|
||||
t.Fatalf("acme repo must be untouched, got head=%q branches=%v", head, branches)
|
||||
}
|
||||
// beta's repo has the commit.
|
||||
_, betaHead := mounted.refState("beta", "", "shared")
|
||||
if betaHead == "" {
|
||||
t.Fatalf("beta repo should have a HEAD after its push")
|
||||
}
|
||||
}
|
||||
+47
-89
@@ -3,6 +3,7 @@ package git
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -92,126 +93,83 @@ func (s *svc) infoRefs(c *zip.Ctx) error {
|
||||
return c.Bytes(http.StatusOK, buf.Bytes())
|
||||
}
|
||||
|
||||
// uploadPack serves POST /git-upload-pack — the clone/fetch phase. It decodes
|
||||
// the client's wants/haves, runs the upload-pack session, and streams the
|
||||
// packfile response.
|
||||
// uploadPack serves POST /git-upload-pack — the clone/fetch phase. It is a thin
|
||||
// HTTP adapter over the shared pack driver (pack.go serveUploadPack): resolve +
|
||||
// tenant-check, then hand the request body + response writer to the ONE pack
|
||||
// code path SSH also uses.
|
||||
func (s *svc) uploadPack(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
name, err := repoNameParam(c)
|
||||
org, project, name, err := s.resolvePackRepo(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project := projectScope(c)
|
||||
if p := c.Param("org"); p != "" && p != org {
|
||||
return zip.ErrForbidden("org path does not match authenticated tenant")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
if _, err := store.Get(c.Context(), org, project, name); err != nil {
|
||||
return zip.ErrNotFound("repo not found")
|
||||
}
|
||||
|
||||
body := c.Body()
|
||||
if int64(len(body)) > maxBody {
|
||||
return zip.Errorf(http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", maxBody)
|
||||
}
|
||||
req := packp.NewUploadPackRequest()
|
||||
if err := req.Decode(bytes.NewReader(body)); err != nil {
|
||||
return zip.ErrBadRequest("decode upload-pack request: " + err.Error())
|
||||
}
|
||||
|
||||
sess, err := s.uploadSession(org, project, name)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "git session: %v", err)
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
|
||||
resp, err := sess.UploadPack(c.Context(), req)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "upload-pack: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Close() }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := resp.Encode(&buf); err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "encode upload-pack: %v", err)
|
||||
if err := s.serveUploadPack(c.Context(), org, project, name, bytes.NewReader(body), &buf); err != nil {
|
||||
if errors.Is(err, errBadPack) {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/x-git-upload-pack-result")
|
||||
c.SetHeader("Cache-Control", "no-cache")
|
||||
return c.Bytes(http.StatusOK, buf.Bytes())
|
||||
}
|
||||
|
||||
// receivePack serves POST /git-receive-pack — the push phase. It decodes the
|
||||
// ref-update commands + packfile, applies them to the storer, records the new
|
||||
// storage size (billing hook), and returns the report-status.
|
||||
// receivePack serves POST /git-receive-pack — the push phase. Thin HTTP adapter
|
||||
// over the shared pack driver (pack.go serveReceivePack), which applies the
|
||||
// pack, records usage, and fires the push-to-deploy hook. SSH pushes take the
|
||||
// SAME serveReceivePack path.
|
||||
func (s *svc) receivePack(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
name, err := repoNameParam(c)
|
||||
org, project, name, err := s.resolvePackRepo(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project := projectScope(c)
|
||||
if p := c.Param("org"); p != "" && p != org {
|
||||
return zip.ErrForbidden("org path does not match authenticated tenant")
|
||||
}
|
||||
store, err := s.storeFor(org)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "open store: %v", err)
|
||||
}
|
||||
if _, err := store.Get(c.Context(), org, project, name); err != nil {
|
||||
return zip.ErrNotFound("repo not found")
|
||||
}
|
||||
|
||||
body := c.Body()
|
||||
if int64(len(body)) > maxBody {
|
||||
return zip.Errorf(http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", maxBody)
|
||||
}
|
||||
req := packp.NewReferenceUpdateRequest()
|
||||
if err := req.Decode(bytes.NewReader(body)); err != nil {
|
||||
return zip.ErrBadRequest("decode receive-pack request: " + err.Error())
|
||||
}
|
||||
|
||||
sess, err := s.receiveSession(org, project, name)
|
||||
if err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "git session: %v", err)
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
|
||||
report, err := sess.ReceivePack(c.Context(), req)
|
||||
if report == nil && err != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "receive-pack: %v", err)
|
||||
}
|
||||
|
||||
// Push landed (or partially landed with a report): re-measure and record the
|
||||
// tenant's storage size so commerce/o11y meter the new bytes. Best-effort —
|
||||
// a metering miss must never fail the push the client already committed.
|
||||
s.recordUsage(context.WithoutCancel(c.Context()), org, project, name)
|
||||
|
||||
// git-push-to-deploy: trigger a build for every branch ref this push advanced.
|
||||
// Best-effort by contract — the push already landed, so a build-trigger failure
|
||||
// is logged, never surfaced to the client (build.go OnGitPush is a no-op when
|
||||
// the platform subsystem is not co-resident).
|
||||
s.firePushBuilds(context.WithoutCancel(c.Context()), org, project, name, req)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if report != nil {
|
||||
if encErr := report.Encode(&buf); encErr != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "encode report: %v", encErr)
|
||||
if err := s.serveReceivePack(c.Context(), org, project, name, bytes.NewReader(body), &buf); err != nil {
|
||||
if errors.Is(err, errBadPack) {
|
||||
return zip.ErrBadRequest(err.Error())
|
||||
}
|
||||
return zip.Errorf(http.StatusInternalServerError, "%v", err)
|
||||
}
|
||||
c.SetHeader("Content-Type", "application/x-git-receive-pack-result")
|
||||
c.SetHeader("Cache-Control", "no-cache")
|
||||
return c.Bytes(http.StatusOK, buf.Bytes())
|
||||
}
|
||||
|
||||
// resolvePackRepo is the shared front-half of every smart-HTTP pack handler:
|
||||
// resolve the tenant from X-Org-Id, validate the repo name, enforce the URL org
|
||||
// segment matches the authenticated tenant (path-vs-identity guard), and confirm
|
||||
// the repo exists. Returns the (org, project, name) the pack driver operates on.
|
||||
func (s *svc) resolvePackRepo(c *zip.Ctx) (org, project, name string, err error) {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return "", "", "", zip.ErrForbidden("X-Org-Id required")
|
||||
}
|
||||
name, nerr := repoNameParam(c)
|
||||
if nerr != nil {
|
||||
return "", "", "", nerr
|
||||
}
|
||||
project = projectScope(c)
|
||||
if p := c.Param("org"); p != "" && p != org {
|
||||
return "", "", "", zip.ErrForbidden("org path does not match authenticated tenant")
|
||||
}
|
||||
store, serr := s.storeFor(org)
|
||||
if serr != nil {
|
||||
return "", "", "", zip.Errorf(http.StatusInternalServerError, "open store: %v", serr)
|
||||
}
|
||||
if _, gerr := store.Get(c.Context(), org, project, name); gerr != nil {
|
||||
return "", "", "", zip.ErrNotFound("repo not found")
|
||||
}
|
||||
return org, project, name, nil
|
||||
}
|
||||
|
||||
// firePushBuilds triggers a platform build for every branch ref this push
|
||||
// advanced (created or updated). Tags and deletes are ignored. Best-effort and
|
||||
// non-fatal: the push has already landed, so a trigger failure is logged, never
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ssh.go serves the Git SSH transport so `git clone git@<sshHost>:<org>/<repo>.git`
|
||||
// works alongside smart-HTTP. It uses golang.org/x/crypto/ssh (already a direct
|
||||
// dependency — no new heavy dep) directly rather than a wrapper.
|
||||
//
|
||||
// Auth is per-user public key. The PublicKeyCallback fingerprints the presented
|
||||
// key and resolves it, via the global key registry (keystore.go), to the org +
|
||||
// user that registered it — failing CLOSED on any unknown key. The resolved
|
||||
// identity is stashed in ssh.Permissions.Extensions and read back by the session
|
||||
// handler, which sets the SAME (org) tenancy the HTTP path derives from
|
||||
// X-Org-Id. So an SSH request and an HTTP request converge on the ONE pack code
|
||||
// path (pack.go) with the ONE tenant scope.
|
||||
//
|
||||
// The session handler accepts exactly two exec commands —
|
||||
// `git-upload-pack '<org>/<repo>.git'` (clone/fetch) and
|
||||
// `git-receive-pack '<org>/<repo>.git'` (push) — parses the org/repo, enforces
|
||||
// that the path org equals the key-bound org (no cross-tenant), confirms the
|
||||
// repo exists, and drives the shared pack driver on the channel's stdin/stdout.
|
||||
|
||||
// sshConf carries the SSH server's runtime configuration, resolved from deps/env
|
||||
// in sshConfig. Kept small: the listen address, the host-key source, and a
|
||||
// back-reference to the git service the sessions operate on.
|
||||
type sshConf struct {
|
||||
addr string // listen address, e.g. ":2222"
|
||||
hostKeyPEM []byte // KMS/env-provided host private key (PEM), or nil
|
||||
hostKeyPath string // on-disk host key path; generated + persisted if absent
|
||||
}
|
||||
|
||||
// sshServer owns the SSH listener and its lifecycle.
|
||||
type sshServer struct {
|
||||
svc *svc
|
||||
cfg *ssh.ServerConfig
|
||||
listen string
|
||||
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
closed bool
|
||||
wg sync.WaitGroup
|
||||
boundTCP string // actual bound address (resolves :0 to the ephemeral port for tests)
|
||||
}
|
||||
|
||||
// gitSSHHost resolves the SSH host advertised in sshUrl. CLOUD_GIT_SSH_HOST wins;
|
||||
// else it is derived from the deployment domain (api.hanzo.ai → git.hanzo.ai);
|
||||
// else "git.hanzo.ai".
|
||||
func gitSSHHost(domain string) string {
|
||||
if h := strings.TrimSpace(os.Getenv("CLOUD_GIT_SSH_HOST")); h != "" {
|
||||
return h
|
||||
}
|
||||
return defaultSSHHost(domain)
|
||||
}
|
||||
|
||||
// defaultSSHHost derives the git SSH host from the primary domain: the registered
|
||||
// domain with a "git." prefix (api.hanzo.ai → git.hanzo.ai). Falls back to
|
||||
// "git.hanzo.ai" when the domain is empty or unparseable.
|
||||
func defaultSSHHost(domain string) string {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" {
|
||||
return "git.hanzo.ai"
|
||||
}
|
||||
// Strip a leading "api." (the common cloud host) and prefix "git.".
|
||||
base := strings.TrimPrefix(domain, "api.")
|
||||
return "git." + base
|
||||
}
|
||||
|
||||
// sshConfig resolves the SSH runtime config from deps/env. The host key is
|
||||
// sourced KMS-first (CLOUD_GIT_SSH_HOST_KEY, a PEM the operator syncs from KMS),
|
||||
// falling back to an on-disk key under the git data root that is generated +
|
||||
// persisted 0600 on first boot. The listen address is CLOUD_GIT_SSH_ADDR
|
||||
// (default :2222 — real :22 is fronted by a k8s TCP LoadBalancer).
|
||||
func sshConfig(deps cloud.Deps, gitRoot string) sshConf {
|
||||
addr := strings.TrimSpace(os.Getenv("CLOUD_GIT_SSH_ADDR"))
|
||||
if addr == "" {
|
||||
addr = ":2222"
|
||||
}
|
||||
var pemKey []byte
|
||||
if p := strings.TrimSpace(os.Getenv("CLOUD_GIT_SSH_HOST_KEY")); p != "" {
|
||||
pemKey = []byte(p)
|
||||
}
|
||||
return sshConf{
|
||||
addr: addr,
|
||||
hostKeyPEM: pemKey,
|
||||
hostKeyPath: filepath.Join(gitRoot, "ssh_host_ed25519_key"),
|
||||
}
|
||||
}
|
||||
|
||||
// newSSHServer builds the SSH server: it resolves the host key, wires the
|
||||
// public-key auth callback (fail-closed key→tenant resolution), and prepares the
|
||||
// ServerConfig. It does NOT yet listen — call start.
|
||||
func newSSHServer(s *svc, conf sshConf) (*sshServer, error) {
|
||||
signer, err := loadOrCreateHostKey(conf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host key: %w", err)
|
||||
}
|
||||
srv := &sshServer{svc: s, listen: conf.addr}
|
||||
cfg := &ssh.ServerConfig{
|
||||
// PublicKeyCallback is the ONLY accepted auth. It fingerprints the
|
||||
// presented key and resolves it to a tenant; an unknown key returns an
|
||||
// error, so auth fails CLOSED — there is no password / keyboard-interactive
|
||||
// / none fallback.
|
||||
PublicKeyCallback: srv.authPublicKey,
|
||||
}
|
||||
cfg.AddHostKey(signer)
|
||||
srv.cfg = cfg
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// authPublicKey resolves a presented SSH key to its owning (org, user) via the
|
||||
// global key registry, keyed by the SHA256 fingerprint. Unknown key → error →
|
||||
// auth rejected (fail closed). On success the resolved identity rides in
|
||||
// Permissions.Extensions so the session handler can scope the tenant WITHOUT a
|
||||
// second lookup.
|
||||
func (srv *sshServer) authPublicKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
fp := ssh.FingerprintSHA256(key)
|
||||
row, err := srv.svc.keys.ByFingerprint(context.Background(), fp)
|
||||
if err != nil {
|
||||
// Unknown or errored key: reject. Never leak whether the fingerprint
|
||||
// exists — a single opaque "unknown key" for every failure.
|
||||
return nil, fmt.Errorf("ssh: unknown key")
|
||||
}
|
||||
return &ssh.Permissions{
|
||||
Extensions: map[string]string{
|
||||
"git-org": row.Org,
|
||||
"git-user-id": row.UserID,
|
||||
"git-key-id": row.ID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// start binds the listener and accepts connections in a background goroutine.
|
||||
func (srv *sshServer) start() error {
|
||||
ln, err := net.Listen("tcp", srv.listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh listen %q: %w", srv.listen, err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
srv.ln = ln
|
||||
srv.boundTCP = ln.Addr().String()
|
||||
srv.mu.Unlock()
|
||||
|
||||
srv.wg.Add(1)
|
||||
go srv.acceptLoop(ln)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addr returns the bound listen address (the ephemeral port resolved, for tests).
|
||||
func (srv *sshServer) addr() string {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
if srv.boundTCP != "" {
|
||||
return srv.boundTCP
|
||||
}
|
||||
return srv.listen
|
||||
}
|
||||
|
||||
// stop closes the listener and waits for in-flight connections to drain.
|
||||
// Idempotent.
|
||||
func (srv *sshServer) stop() {
|
||||
srv.mu.Lock()
|
||||
if srv.closed {
|
||||
srv.mu.Unlock()
|
||||
return
|
||||
}
|
||||
srv.closed = true
|
||||
ln := srv.ln
|
||||
srv.mu.Unlock()
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
srv.wg.Wait()
|
||||
}
|
||||
|
||||
func (srv *sshServer) acceptLoop(ln net.Listener) {
|
||||
defer srv.wg.Done()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
srv.mu.Lock()
|
||||
closed := srv.closed
|
||||
srv.mu.Unlock()
|
||||
if closed {
|
||||
return // listener closed by stop() — clean shutdown
|
||||
}
|
||||
// Transient accept error; keep serving.
|
||||
srv.svc.log.Warn("git ssh accept failed", "err", err)
|
||||
continue
|
||||
}
|
||||
srv.wg.Add(1)
|
||||
go func() {
|
||||
defer srv.wg.Done()
|
||||
srv.handleConn(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn performs the SSH handshake (auth via authPublicKey) and services
|
||||
// the connection's session channels.
|
||||
func (srv *sshServer) handleConn(nConn net.Conn) {
|
||||
defer func() { _ = nConn.Close() }()
|
||||
sconn, chans, reqs, err := ssh.NewServerConn(nConn, srv.cfg)
|
||||
if err != nil {
|
||||
// Handshake / auth failure — normal for probes + rejected keys.
|
||||
return
|
||||
}
|
||||
defer func() { _ = sconn.Close() }()
|
||||
go ssh.DiscardRequests(reqs) // reject global out-of-band requests
|
||||
|
||||
org := sconn.Permissions.Extensions["git-org"]
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "only session channels are supported")
|
||||
continue
|
||||
}
|
||||
ch, chReqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go srv.handleSession(org, ch, chReqs)
|
||||
}
|
||||
}
|
||||
|
||||
// execPayload is the wire shape of an SSH "exec" request: a single
|
||||
// length-prefixed command string (RFC 4254 §6.5).
|
||||
type execPayload struct {
|
||||
Command string
|
||||
}
|
||||
|
||||
// handleSession services one session channel: it waits for the "exec" request
|
||||
// carrying the git command, runs it, and closes the channel. Any other request
|
||||
// type (shell, pty) is rejected — this is a git-only endpoint.
|
||||
func (srv *sshServer) handleSession(org string, ch ssh.Channel, reqs <-chan *ssh.Request) {
|
||||
defer func() { _ = ch.Close() }()
|
||||
for req := range reqs {
|
||||
switch req.Type {
|
||||
case "exec":
|
||||
var p execPayload
|
||||
if err := ssh.Unmarshal(req.Payload, &p); err != nil {
|
||||
_ = req.Reply(false, nil)
|
||||
srv.exit(ch, 1)
|
||||
return
|
||||
}
|
||||
_ = req.Reply(true, nil)
|
||||
code := srv.runGitCommand(org, p.Command, ch)
|
||||
srv.exit(ch, code)
|
||||
return
|
||||
case "shell", "pty-req", "env":
|
||||
// A bare `ssh git@host` (shell) or env/pty setup: git-only endpoint,
|
||||
// so reject shell/pty but ack env silently (git sets GIT_PROTOCOL).
|
||||
_ = req.Reply(req.Type == "env", nil)
|
||||
if req.Type == "shell" {
|
||||
_, _ = io.WriteString(ch.Stderr(), "Hi! This is the Hanzo git SSH endpoint; interactive shells are not available.\n")
|
||||
srv.exit(ch, 1)
|
||||
return
|
||||
}
|
||||
default:
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitCmdRE parses `git-upload-pack '<path>'` / `git-receive-pack '<path>'`,
|
||||
// accepting single-quoted or bare paths (git quotes; some clients don't).
|
||||
var gitCmdRE = regexp.MustCompile(`^(git-upload-pack|git-receive-pack) '?([^']+?)'?$`)
|
||||
|
||||
// runGitCommand parses the exec command, enforces tenancy (path org == key org),
|
||||
// confirms the repo exists, and drives the shared pack code path on the channel's
|
||||
// stdin/stdout. Returns the exit code the session reports to the client.
|
||||
func (srv *sshServer) runGitCommand(keyOrg, command string, ch ssh.Channel) int {
|
||||
m := gitCmdRE.FindStringSubmatch(strings.TrimSpace(command))
|
||||
if m == nil {
|
||||
_, _ = io.WriteString(ch.Stderr(), "unsupported command; only git-upload-pack / git-receive-pack are allowed\n")
|
||||
return 1
|
||||
}
|
||||
service, path := m[1], m[2]
|
||||
|
||||
pathOrg, name, err := parseRepoPath(path)
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(ch.Stderr(), "invalid repo path: "+err.Error()+"\n")
|
||||
return 1
|
||||
}
|
||||
// Tenancy: the key's bound org is authoritative; the path org must match it.
|
||||
// A key for org A can never reach org B's namespace even if it crafts the path.
|
||||
if keyOrg == "" || pathOrg != keyOrg {
|
||||
_, _ = io.WriteString(ch.Stderr(), "access denied: repository is outside your organization\n")
|
||||
return 1
|
||||
}
|
||||
// SSH has no project sub-scope (no X-Project-Id) — org-level repos only.
|
||||
const project = ""
|
||||
|
||||
store, err := srv.svc.storeFor(keyOrg)
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(ch.Stderr(), "internal error\n")
|
||||
return 1
|
||||
}
|
||||
if _, err := store.Get(context.Background(), keyOrg, project, name); err != nil {
|
||||
_, _ = io.WriteString(ch.Stderr(), "repository not found\n")
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
switch service {
|
||||
case svcUploadPack:
|
||||
if err := srv.svc.sshUploadPack(ctx, keyOrg, project, name, ch, ch); err != nil {
|
||||
srv.svc.log.Warn("git ssh upload-pack failed", "org", keyOrg, "repo", name, "err", err)
|
||||
return 1
|
||||
}
|
||||
case svcReceivePack:
|
||||
if err := srv.svc.sshReceivePack(ctx, keyOrg, project, name, ch, ch); err != nil {
|
||||
srv.svc.log.Warn("git ssh receive-pack failed", "org", keyOrg, "repo", name, "err", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exit sends the exit-status request and closes the write side, mirroring what a
|
||||
// real git server does so the client sees a clean end.
|
||||
func (srv *sshServer) exit(ch ssh.Channel, code int) {
|
||||
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
|
||||
}
|
||||
|
||||
// repoPathRE validates the "<org>/<repo>" tail of a git SSH path. Both segments
|
||||
// are safe identifiers (mirrors nameRE); a leading slash is tolerated (git may
|
||||
// send an absolute-looking path).
|
||||
var repoPathRE = regexp.MustCompile(`^/?([A-Za-z0-9][A-Za-z0-9._-]{0,63})/([A-Za-z0-9][A-Za-z0-9._-]{0,63})$`)
|
||||
|
||||
// parseRepoPath extracts (org, repo) from a git SSH path like "acme/code.git"
|
||||
// or "/acme/code.git". The trailing ".git" is stripped, and both segments are
|
||||
// validated as safe identifiers so the path can never traverse storage.
|
||||
func parseRepoPath(path string) (org, repo string, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimSuffix(path, ".git")
|
||||
m := repoPathRE.FindStringSubmatch(path)
|
||||
if m == nil {
|
||||
return "", "", errors.New("path must be <org>/<repo>.git")
|
||||
}
|
||||
return m[1], m[2], nil
|
||||
}
|
||||
|
||||
// loadOrCreateHostKey resolves the SSH host key signer: an operator-provided PEM
|
||||
// (KMS/env) wins; else an on-disk key is loaded, or generated (ed25519) and
|
||||
// persisted 0600 on first boot. NEVER hardcoded.
|
||||
func loadOrCreateHostKey(conf sshConf) (ssh.Signer, error) {
|
||||
if len(conf.hostKeyPEM) > 0 {
|
||||
signer, err := ssh.ParsePrivateKey(conf.hostKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CLOUD_GIT_SSH_HOST_KEY: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
if b, err := os.ReadFile(conf.hostKeyPath); err == nil {
|
||||
signer, err := ssh.ParsePrivateKey(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse host key %q: %w", conf.hostKeyPath, err)
|
||||
}
|
||||
return signer, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read host key %q: %w", conf.hostKeyPath, err)
|
||||
}
|
||||
// First boot: generate an ed25519 host key and persist it 0600.
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(priv, "hanzo-git-host")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal host key: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(conf.hostKeyPath), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("mkdir host key dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(conf.hostKeyPath, pem.EncodeToMemory(block), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("persist host key %q: %w", conf.hostKeyPath, err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signer from host key: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-billy/v5/memfs"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
xssh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// genClientKey returns a fresh ed25519 keypair as (privatePEM, authorizedKeyLine).
|
||||
// The private PEM feeds the go-git SSH client; the authorized-key line is what a
|
||||
// user registers via POST /v1/git/keys.
|
||||
func genClientKey(t *testing.T) (privPEM []byte, authLine string) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
block, err := xssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal priv: %v", err)
|
||||
}
|
||||
pub, err := xssh.NewPublicKey(priv.Public())
|
||||
if err != nil {
|
||||
t.Fatalf("pub: %v", err)
|
||||
}
|
||||
return pem.EncodeToMemory(block), strings.TrimSpace(string(xssh.MarshalAuthorizedKey(pub)))
|
||||
}
|
||||
|
||||
// sshClientAuth builds a go-git SSH auth method from a client private PEM,
|
||||
// ignoring the host key (the in-process listener's host key is ephemeral).
|
||||
func sshClientAuth(t *testing.T, privPEM []byte) *gitssh.PublicKeys {
|
||||
t.Helper()
|
||||
auth, err := gitssh.NewPublicKeys("git", privPEM, "")
|
||||
if err != nil {
|
||||
t.Fatalf("client auth: %v", err)
|
||||
}
|
||||
auth.HostKeyCallback = xssh.InsecureIgnoreHostKey()
|
||||
return auth
|
||||
}
|
||||
|
||||
// TestSSHKeyRegistrationAndAuth proves the fingerprint→tenant resolution used by
|
||||
// the PublicKeyCallback: a registered key resolves to its org; an unregistered
|
||||
// key fails closed; and a key belongs to exactly one org (no cross-tenant).
|
||||
func TestSSHKeyRegistrationAndAuth(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
|
||||
_, authLine := genClientKey(t)
|
||||
|
||||
// Register the key for acme via the control plane.
|
||||
code, body := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
|
||||
map[string]any{"title": "laptop", "publicKey": authLine})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("register key want 201, got %d (%s)", code, body)
|
||||
}
|
||||
var kv keyView
|
||||
if err := json.Unmarshal(body, &kv); err != nil {
|
||||
t.Fatalf("key view json: %v (%s)", err, body)
|
||||
}
|
||||
if kv.Fingerprint == "" || !strings.HasPrefix(kv.Fingerprint, "SHA256:") {
|
||||
t.Fatalf("unexpected fingerprint: %q", kv.Fingerprint)
|
||||
}
|
||||
|
||||
// The registered key resolves to acme via the auth callback path.
|
||||
pub, _, _, _, err := xssh.ParseAuthorizedKey([]byte(authLine))
|
||||
if err != nil {
|
||||
t.Fatalf("parse authline: %v", err)
|
||||
}
|
||||
perms, err := mounted.ssh.authPublicKey(fakeConnMeta{}, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("registered key must authenticate, got: %v", err)
|
||||
}
|
||||
if perms.Extensions["git-org"] != "acme" {
|
||||
t.Fatalf("resolved org = %q, want acme", perms.Extensions["git-org"])
|
||||
}
|
||||
|
||||
// An UNREGISTERED key fails closed.
|
||||
otherPub, _ := genClientKeyPub(t)
|
||||
if _, err := mounted.ssh.authPublicKey(fakeConnMeta{}, otherPub); err == nil {
|
||||
t.Fatalf("unregistered key must be rejected")
|
||||
}
|
||||
|
||||
// The SAME key cannot be re-registered under a DIFFERENT org (fingerprint is
|
||||
// globally unique — a key belongs to exactly one tenant).
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/git/keys", "beta",
|
||||
map[string]any{"title": "steal", "publicKey": authLine}); code != http.StatusConflict {
|
||||
t.Fatalf("cross-org re-register want 409, got %d", code)
|
||||
}
|
||||
|
||||
// beta lists ZERO keys — never acme's.
|
||||
code, body = do(t, app, http.MethodGet, "/v1/git/keys", "beta", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("beta list keys want 200, got %d", code)
|
||||
}
|
||||
var lst struct {
|
||||
Data []keyView `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &lst)
|
||||
if len(lst.Data) != 0 {
|
||||
t.Fatalf("beta must see zero keys, got %+v", lst.Data)
|
||||
}
|
||||
|
||||
// Delete the key, then it no longer authenticates (fail closed again).
|
||||
if code, _ := do(t, app, http.MethodDelete, "/v1/git/keys/"+kv.ID, "acme", nil); code != http.StatusNoContent {
|
||||
t.Fatalf("delete key want 204, got %d", code)
|
||||
}
|
||||
if _, err := mounted.ssh.authPublicKey(fakeConnMeta{}, pub); err == nil {
|
||||
t.Fatalf("deleted key must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHClonePushRoundTrip is the end-to-end SSH proof: register a client key,
|
||||
// create a repo, push over SSH, then clone over SSH in a fresh client and SEE the
|
||||
// pushed commit — all through the in-process SSH listener, driving the SAME pack
|
||||
// code path smart-HTTP uses.
|
||||
func TestSSHClonePushRoundTrip(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
|
||||
privPEM, authLine := genClientKey(t)
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
|
||||
map[string]any{"title": "ci", "publicKey": authLine}); code != http.StatusCreated {
|
||||
t.Fatalf("register key: %d %s", code, body)
|
||||
}
|
||||
if code, body := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
|
||||
map[string]any{"name": "code"}); code != http.StatusCreated {
|
||||
t.Fatalf("create repo: %d %s", code, body)
|
||||
}
|
||||
|
||||
sshURL := fmt.Sprintf("ssh://git@%s/acme/code.git", mounted.ssh.addr())
|
||||
auth := sshClientAuth(t, privPEM)
|
||||
|
||||
// Build a local repo, commit, and push over SSH.
|
||||
fs := memfs.New()
|
||||
local, err := gogit.Init(memory.NewStorage(), fs)
|
||||
if err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
wt, _ := local.Worktree()
|
||||
f, _ := fs.Create("README.md")
|
||||
_, _ = f.Write([]byte("# ssh native\n"))
|
||||
_ = f.Close()
|
||||
if _, err := wt.Add("README.md"); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
commit, err := wt.Commit("via ssh", &gogit.CommitOptions{
|
||||
Author: &object.Signature{Name: "hanzo-dev", Email: "dev@hanzo.ai", When: time.Now()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
if _, err := local.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{sshURL}}); err != nil {
|
||||
t.Fatalf("remote: %v", err)
|
||||
}
|
||||
if err := local.Push(&gogit.PushOptions{
|
||||
RemoteName: "origin",
|
||||
RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"},
|
||||
Auth: auth,
|
||||
}); err != nil {
|
||||
t.Fatalf("ssh push: %v", err)
|
||||
}
|
||||
|
||||
// Fresh clone over SSH — the pushed commit must be there.
|
||||
cloned, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: sshURL, Auth: auth})
|
||||
if err != nil {
|
||||
t.Fatalf("ssh clone: %v", err)
|
||||
}
|
||||
head, err := cloned.Head()
|
||||
if err != nil {
|
||||
t.Fatalf("head: %v", err)
|
||||
}
|
||||
if head.Hash() != commit {
|
||||
t.Fatalf("cloned HEAD %s != pushed %s", head.Hash(), commit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHCrossTenantRejected proves a key bound to acme cannot reach beta's
|
||||
// namespace even when it crafts a beta path — the SSH tenancy guard.
|
||||
func TestSSHCrossTenantRejected(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
|
||||
privPEM, authLine := genClientKey(t)
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
|
||||
map[string]any{"publicKey": authLine}); code != http.StatusCreated {
|
||||
t.Fatal("register acme key failed")
|
||||
}
|
||||
// beta owns a repo the acme key must NOT reach.
|
||||
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "beta",
|
||||
map[string]any{"name": "secret"}); code != http.StatusCreated {
|
||||
t.Fatal("create beta repo failed")
|
||||
}
|
||||
|
||||
sshURL := fmt.Sprintf("ssh://git@%s/beta/secret.git", mounted.ssh.addr())
|
||||
auth := sshClientAuth(t, privPEM)
|
||||
// acme's key authenticates, but the beta path is outside its org → the exec
|
||||
// handler denies and the clone fails.
|
||||
_, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: sshURL, Auth: auth})
|
||||
if err == nil {
|
||||
t.Fatalf("acme key cloning beta repo must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHParseRepoPath covers the path parser's accept/reject cases directly.
|
||||
func TestSSHParseRepoPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, org, repo string
|
||||
ok bool
|
||||
}{
|
||||
{"acme/code.git", "acme", "code", true},
|
||||
{"/acme/code.git", "acme", "code", true},
|
||||
{"acme/code", "acme", "code", true},
|
||||
{"../etc/passwd", "", "", false},
|
||||
{"acme/../beta/x.git", "", "", false},
|
||||
{"acme", "", "", false},
|
||||
{"a/b/c.git", "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
org, repo, err := parseRepoPath(tc.in)
|
||||
if tc.ok {
|
||||
if err != nil || org != tc.org || repo != tc.repo {
|
||||
t.Fatalf("parseRepoPath(%q) = (%q,%q,%v), want (%q,%q,nil)", tc.in, org, repo, err, tc.org, tc.repo)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatalf("parseRepoPath(%q) should have failed, got (%q,%q)", tc.in, org, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
// fakeConnMeta is a minimal ssh.ConnMetadata for exercising authPublicKey
|
||||
// directly (the callback only reads the presented key, not the conn).
|
||||
type fakeConnMeta struct{}
|
||||
|
||||
func (fakeConnMeta) User() string { return "git" }
|
||||
func (fakeConnMeta) SessionID() []byte { return nil }
|
||||
func (fakeConnMeta) ClientVersion() []byte { return []byte("SSH-2.0-test") }
|
||||
func (fakeConnMeta) ServerVersion() []byte { return []byte("SSH-2.0-hanzo") }
|
||||
func (fakeConnMeta) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (fakeConnMeta) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
|
||||
// genClientKeyPub returns just an ssh.PublicKey for the unregistered-key case.
|
||||
func genClientKeyPub(t *testing.T) (xssh.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("gen key: %v", err)
|
||||
}
|
||||
pub, err := xssh.NewPublicKey(priv.Public())
|
||||
if err != nil {
|
||||
t.Fatalf("pub: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// per-org SQLite files land where the path convention says.
|
||||
func mountAppDir(t *testing.T, dir string) *zip.App {
|
||||
t.Helper()
|
||||
t.Setenv("CLOUD_GIT_SSH_ADDR", "127.0.0.1:0") // ephemeral SSH port (no :2222 collisions)
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: dir, Domain: "api.hanzo.test"}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// zap.go is git's ZAP transport — the SECOND transport over the ONE control-plane
|
||||
// core (core.go). It establishes the canonical pattern every Hanzo subsystem
|
||||
// copies to "go ZAP": there is no per-service ZAP server and no gRPC.
|
||||
//
|
||||
// # THE STANDARD (copy this for the next service)
|
||||
//
|
||||
// The cloud binary already serves ONE ZAP-over-WebSocket plane: zapface.Handler,
|
||||
// mounted at /zap in serve.go. It is a pure transport bridge — it replays every
|
||||
// inbound ZAP frame as an in-process HTTP request against the SAME Fiber app,
|
||||
// so ANY /v1 route is automatically reachable as a ZAP procedure. A service does
|
||||
// NOT stand up its own zapclient.Server; doing so would be a redundant parallel
|
||||
// path (one-and-only-one-way).
|
||||
//
|
||||
// The zapface bridge unwraps a `{status, msg, data}` envelope as the ZAP result.
|
||||
// A service's REST handlers return RAW JSON (the git CLI + REST clients want the
|
||||
// resource verbatim), so a service exposes ZAP procedures as a THIN, envelope-
|
||||
// shaping adapter layer at:
|
||||
//
|
||||
// POST /v1/<service>/zap/<procedure>
|
||||
//
|
||||
// mapped by zapface from the ZAP method "<service>/zap/<procedure>". Each adapter
|
||||
// resolves the tenant EXACTLY as the REST handler does (principal.Tenant →
|
||||
// X-Org-Id, minted by the identity middleware from the browser's replayed
|
||||
// credential), then calls the SAME core func the REST handler calls, and wraps
|
||||
// the result in the envelope. So the REST handler and the ZAP procedure are two
|
||||
// thin adapters over ONE core func each — the business logic lives once.
|
||||
//
|
||||
// git's procedures (all over the core in core.go):
|
||||
//
|
||||
// git/zap/createRepo -> coreCreate git/zap/deleteRepo -> coreDelete
|
||||
// git/zap/listRepos -> coreList git/zap/usage -> coreUsage
|
||||
// git/zap/getRepo -> coreGet
|
||||
//
|
||||
// The next service (e.g. crm, prompts) copies this file's shape: one mountZAP
|
||||
// registering /v1/<service>/zap/<proc> envelope adapters over its own core funcs.
|
||||
// Nothing else — the /zap plane does the rest.
|
||||
|
||||
// mountZAP registers git's ZAP procedure adapters. Called from Mount. The
|
||||
// procedures are ordinary /v1 routes; the shared /zap plane turns them into ZAP
|
||||
// procedures for the browser/service ZAP client.
|
||||
func (s *svc) mountZAP(app *zip.App) error {
|
||||
if app == nil {
|
||||
return errors.New("git.mountZAP: nil app")
|
||||
}
|
||||
app.Post("/v1/git/zap/createRepo", s.zapCreate)
|
||||
app.Post("/v1/git/zap/listRepos", s.zapList)
|
||||
app.Post("/v1/git/zap/getRepo", s.zapGet)
|
||||
app.Post("/v1/git/zap/deleteRepo", s.zapDelete)
|
||||
app.Post("/v1/git/zap/usage", s.zapUsage)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- envelope ----
|
||||
|
||||
// okEnvelope is the success shape the zapface bridge unwraps (data → the ZAP
|
||||
// result). It is the SAME shape every /v1 ZAP-facing handler returns, so the
|
||||
// bridge stays fully generic.
|
||||
func okEnvelope(c *zip.Ctx, data any) error {
|
||||
return c.JSON(http.StatusOK, map[string]any{"status": "ok", "msg": "", "data": data})
|
||||
}
|
||||
|
||||
// errEnvelope reports a handler error in the envelope with the given HTTP status
|
||||
// (the bridge maps non-ok status → a ZAP dispatch error the client observes).
|
||||
func errEnvelope(c *zip.Ctx, status int, msg string) error {
|
||||
return c.JSON(status, map[string]any{"status": "error", "msg": msg})
|
||||
}
|
||||
|
||||
// zapErr maps a core sentinel error to an envelope response — the ZAP twin of
|
||||
// the REST status mapping (createErr etc.), so both transports agree on which
|
||||
// failure is a 400/404/409 while returning their own wire shape.
|
||||
func zapErr(c *zip.Ctx, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, errBadInput):
|
||||
return errEnvelope(c, http.StatusBadRequest, strings.TrimPrefix(err.Error(), "git: invalid input: "))
|
||||
case errors.Is(err, errConflict):
|
||||
return errEnvelope(c, http.StatusConflict, "repo name already exists in this scope")
|
||||
case errors.Is(err, errNotFound):
|
||||
return errEnvelope(c, http.StatusNotFound, "repo not found")
|
||||
default:
|
||||
return errEnvelope(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- procedure adapters (thin — one core call each) ----
|
||||
|
||||
// zapProcReq is the JSON body a ZAP client sends to a git procedure. Which
|
||||
// fields matter depends on the procedure (createRepo reads all; listRepos reads
|
||||
// none; getRepo/deleteRepo read name). Tenant + project scope come from the
|
||||
// request identity, NEVER the body — the body cannot widen the caller's org.
|
||||
type zapProcReq struct {
|
||||
Name string `json:"name"`
|
||||
Project string `json:"project"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *svc) zapCreate(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
|
||||
}
|
||||
var body zapProcReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return errEnvelope(c, http.StatusBadRequest, "invalid body")
|
||||
}
|
||||
view, err := s.coreCreate(c.Context(), org, projectScope(c), createReq{
|
||||
Name: body.Name, Project: body.Project, Description: body.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return zapErr(c, err)
|
||||
}
|
||||
return okEnvelope(c, view)
|
||||
}
|
||||
|
||||
func (s *svc) zapList(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
|
||||
}
|
||||
out, err := s.coreList(c.Context(), org, projectScope(c))
|
||||
if err != nil {
|
||||
return zapErr(c, err)
|
||||
}
|
||||
return okEnvelope(c, out)
|
||||
}
|
||||
|
||||
func (s *svc) zapGet(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
|
||||
}
|
||||
var body zapProcReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return errEnvelope(c, http.StatusBadRequest, "invalid body")
|
||||
}
|
||||
view, err := s.coreGet(c.Context(), org, projectScope(c), body.Name)
|
||||
if err != nil {
|
||||
return zapErr(c, err)
|
||||
}
|
||||
return okEnvelope(c, view)
|
||||
}
|
||||
|
||||
func (s *svc) zapDelete(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
|
||||
}
|
||||
var body zapProcReq
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return errEnvelope(c, http.StatusBadRequest, "invalid body")
|
||||
}
|
||||
if err := s.coreDelete(c.Context(), org, projectScope(c), body.Name); err != nil {
|
||||
return zapErr(c, err)
|
||||
}
|
||||
return okEnvelope(c, map[string]any{"deleted": true, "name": normalizeName(body.Name)})
|
||||
}
|
||||
|
||||
func (s *svc) zapUsage(c *zip.Ctx) error {
|
||||
org, ok := tenant(c)
|
||||
if !ok {
|
||||
return errEnvelope(c, http.StatusForbidden, "X-Org-Id required")
|
||||
}
|
||||
out, err := s.coreUsage(c.Context(), org)
|
||||
if err != nil {
|
||||
return zapErr(c, err)
|
||||
}
|
||||
return okEnvelope(c, out)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
zap "github.com/zap-proto/go"
|
||||
zaprpc "github.com/zap-proto/go/rpc"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/zapface"
|
||||
luxlog "github.com/luxfi/log"
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// zapface reply field offsets — the wire contract zapface/wire.go encodes. Kept
|
||||
// here (not imported — they are internal to zapface) so the test asserts the
|
||||
// exact bytes a ZAP client reads.
|
||||
const (
|
||||
zapReplyOkOff = 0
|
||||
zapReplyStatusOff = 4
|
||||
zapReplyResultOff = 8
|
||||
zapReplyErrorJSONOff = 16
|
||||
zapReqMethodOff = 0
|
||||
zapReqPayloadOff = 8
|
||||
zapReqFixedSize = 16
|
||||
)
|
||||
|
||||
// mountZapApp mounts git AND the shared zapface /zap plane on one app, with a
|
||||
// tiny identity shim that maps `Authorization: Bearer test-<org>` to the
|
||||
// X-Org-Id / X-User-Id headers the gateway/identity middleware would mint in
|
||||
// production. This is the honest stand-in for the gateway: the ZAP frame really
|
||||
// traverses the /zap plane into /v1/git/zap/*, and the tenant is resolved by the
|
||||
// SAME principal.Tenant the REST path uses.
|
||||
func mountZapApp(t *testing.T) (base string, stop func()) {
|
||||
t.Helper()
|
||||
t.Setenv("CLOUD_GIT_SSH_ADDR", "127.0.0.1:0")
|
||||
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
||||
|
||||
// Identity shim (stands in for the gateway + SanitizeIdentity middleware):
|
||||
// derive X-Org-Id/X-User-Id from a "Bearer test-<org>" credential, written
|
||||
// onto the REQUEST headers so the downstream handler's principal.Tenant (which
|
||||
// reads X-Org-Id/X-User-Id off the request) sees a validated principal. The
|
||||
// zapface bridge replays the WS-upgrade Authorization header on every /v1
|
||||
// dispatch, so this runs for ZAP calls exactly as for REST. Uses UseFiber to
|
||||
// reach the raw request headers (zip.Ctx only exposes response-header writes).
|
||||
app.UseFiber(func(c fiber.Ctx) error {
|
||||
if h := c.Get("Authorization"); strings.HasPrefix(h, "Bearer test-") {
|
||||
org := strings.TrimPrefix(h, "Bearer test-")
|
||||
c.Request().Header.Set("X-Org-Id", org)
|
||||
c.Request().Header.Set("X-User-Id", "u_"+org)
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir(), Domain: "api.hanzo.test"}); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
// The shared ZAP-over-WebSocket plane — the SAME one serve.go mounts. It
|
||||
// bridges ZAP frames onto this app's /v1 routes, so git/zap/* are procedures.
|
||||
app.Get("/zap", zapface.Handler(app.Fiber(), zapface.Options{Logger: luxlog.New("test")}))
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
go func() { _ = app.Fiber().Listener(ln) }()
|
||||
addr := ln.Addr().String()
|
||||
waitTCP(t, addr)
|
||||
t.Cleanup(func() { _ = Shutdown() })
|
||||
return addr, func() { _ = app.Fiber().Shutdown() }
|
||||
}
|
||||
|
||||
func waitTCP(t *testing.T, addr string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond); err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("listener %s never came up", addr)
|
||||
}
|
||||
|
||||
// zapReply is the decoded reply the client reads off the wire.
|
||||
type zapReply struct {
|
||||
ok bool
|
||||
status uint32
|
||||
result string
|
||||
errorJSON string
|
||||
}
|
||||
|
||||
// zapCall drives one ZAP procedure over the WebSocket and returns the decoded
|
||||
// reply — the browser/service ZAP client path, byte-for-byte.
|
||||
func zapCall(t *testing.T, c *websocket.Conn, ctx context.Context, method string, input any, promiseID uint32) zapReply {
|
||||
t.Helper()
|
||||
var payload string
|
||||
if input != nil {
|
||||
j, _ := json.Marshal(input)
|
||||
payload = `{"json":` + string(j) + `}` // SuperJSON wrap
|
||||
}
|
||||
inner := buildZapInner(method, payload)
|
||||
frame := zaprpc.BuildRequest(zaprpc.Call{Method: 1, PromiseID: promiseID, Payload: inner})
|
||||
if err := c.Write(ctx, websocket.MessageBinary, frame); err != nil {
|
||||
t.Fatalf("ws write: %v", err)
|
||||
}
|
||||
_, data, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ws read: %v", err)
|
||||
}
|
||||
resp, err := zaprpc.ParseResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse: %v", err)
|
||||
}
|
||||
if resp.PromiseID != promiseID {
|
||||
t.Fatalf("promiseID echo = %d, want %d", resp.PromiseID, promiseID)
|
||||
}
|
||||
m, err := zap.Parse(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("parse reply body: %v", err)
|
||||
}
|
||||
r := m.Root()
|
||||
return zapReply{
|
||||
ok: r.Bool(zapReplyOkOff),
|
||||
status: r.Uint32(zapReplyStatusOff),
|
||||
result: r.Text(zapReplyResultOff),
|
||||
errorJSON: r.Text(zapReplyErrorJSONOff),
|
||||
}
|
||||
}
|
||||
|
||||
// buildZapInner builds the inner {method, payload} request object zapface
|
||||
// decodes (wire.go reqMethodOff=0 / reqPayloadOff=8 / fixed 16).
|
||||
func buildZapInner(method, payload string) []byte {
|
||||
b := zap.NewBuilder(len(method) + len(payload) + zapReqFixedSize + 64)
|
||||
ob := b.StartObject(zapReqFixedSize)
|
||||
ob.SetText(zapReqMethodOff, method)
|
||||
ob.SetText(zapReqPayloadOff, payload)
|
||||
ob.FinishAsRoot()
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
// unwrapResult unwraps the SuperJSON {"json":V} the reply result carries.
|
||||
func unwrapResult(s string) []byte {
|
||||
var env struct {
|
||||
JSON json.RawMessage `json:"json"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &env); err == nil && len(env.JSON) > 0 {
|
||||
return env.JSON
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// TestZAPControlPlaneRoundTrip is the ZAP proof: createRepo + listRepos over the
|
||||
// shared /zap WebSocket plane hit the SAME core funcs the REST handlers call, and
|
||||
// a ZAP-created repo is then visible over the REST list — one implementation,
|
||||
// two transports.
|
||||
func TestZAPControlPlaneRoundTrip(t *testing.T) {
|
||||
base, stop := mountZapApp(t)
|
||||
defer stop()
|
||||
wsURL := fmt.Sprintf("ws://%s/zap", base)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": []string{"Bearer test-acme"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ws dial: %v", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
|
||||
// 1) createRepo over ZAP.
|
||||
rep := zapCall(t, c, ctx, "git/zap/createRepo", map[string]any{"name": "zapsvc"}, 1)
|
||||
if !rep.ok || rep.status != http.StatusOK {
|
||||
t.Fatalf("createRepo: ok=%v status=%d err=%s", rep.ok, rep.status, rep.errorJSON)
|
||||
}
|
||||
var created repoView
|
||||
if err := json.Unmarshal(unwrapResult(rep.result), &created); err != nil {
|
||||
t.Fatalf("decode createRepo result: %v (%s)", err, rep.result)
|
||||
}
|
||||
if created.Org != "acme" || created.Name != "zapsvc" {
|
||||
t.Fatalf("unexpected repo: %+v", created)
|
||||
}
|
||||
if created.CloneURL != "https://api.hanzo.test/v1/git/acme/zapsvc.git" {
|
||||
t.Fatalf("unexpected cloneUrl: %q", created.CloneURL)
|
||||
}
|
||||
if !strings.HasPrefix(created.SSHURL, "git@git.hanzo.test:acme/zapsvc.git") {
|
||||
t.Fatalf("unexpected sshUrl: %q", created.SSHURL)
|
||||
}
|
||||
|
||||
// 2) listRepos over ZAP — sees the ZAP-created repo.
|
||||
rep = zapCall(t, c, ctx, "git/zap/listRepos", map[string]any{}, 2)
|
||||
if !rep.ok {
|
||||
t.Fatalf("listRepos !ok: %s", rep.errorJSON)
|
||||
}
|
||||
var listed []repoView
|
||||
if err := json.Unmarshal(unwrapResult(rep.result), &listed); err != nil {
|
||||
t.Fatalf("decode listRepos: %v (%s)", err, rep.result)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].Name != "zapsvc" {
|
||||
t.Fatalf("listRepos over ZAP = %+v", listed)
|
||||
}
|
||||
|
||||
// 3) Cross-transport proof: the ZAP-created repo is present in the SAME store
|
||||
// the REST handler reads, via the SAME core func (coreList) the REST list
|
||||
// handler calls. One implementation, two transports.
|
||||
out, err := mounted.coreList(context.Background(), "acme", "")
|
||||
if err != nil {
|
||||
t.Fatalf("core list: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Name != "zapsvc" {
|
||||
t.Fatalf("REST/core view of ZAP-created repo = %+v", out)
|
||||
}
|
||||
|
||||
// 4) A bad createRepo over ZAP maps the core error to a non-ok reply (the ZAP
|
||||
// twin of the REST 400) — same core validation, different wire shape.
|
||||
rep = zapCall(t, c, ctx, "git/zap/createRepo", map[string]any{"name": "bad name!"}, 3)
|
||||
if rep.ok {
|
||||
t.Fatalf("createRepo with invalid name should not be ok: %+v", rep)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user