diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | 3284b1c4906b90ca2e3864937c66d0fa30772ebf (patch) | |
| tree | ad799407ac7b0a2589a344751f4c5b3cf575d5c5 /main.go | |
| download | git-forge-cloudflare-3284b1c4906b90ca2e3864937c66d0fa30772ebf.tar.gz | |
Runs git-forge on Cloudflare Containers with R2 for durable storage. A
Worker routes every request to one container through a Durable Object.
Inside it, syncd mirrors the data directory to the bucket and restores
it at boot, holding a write until it is durable.
git-forge is neither modified nor vendored: the image builds it from
its own repository at the commit named in forge.rev.
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 160 |
1 files changed, 160 insertions, 0 deletions
@@ -0,0 +1,160 @@ +package main + +// syncd fronts git-forge in the container. Container disk is wiped whenever +// the instance sleeps, so the datadir is restored before git-forge starts, +// mirrored while it runs, and flushed on the way out. + +import ( + "bytes" + "context" + "errors" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "syscall" + "time" +) + +const ( + datadir = "/var/lib/git-forge" + forgeAddr = "127.0.0.1:8080" + listen = ":8081" + + sweepInterval = 15 * time.Second + + // Cloudflare's interception ca is per instance and the container is not + // root, so SSL_CERT_FILE names a merged copy. + cfCert = "/etc/cloudflare/certs/cloudflare-containers-ca.crt" + sysCert = "/etc/ssl/certs/ca-certificates.crt" + bundle = "/tmp/ca-bundle.crt" + + revFile = "/etc/git-forge.rev" // written by the Containerfile from fetch-forge +) + +// Package scope because fatal flushes it before exiting. +var ship *shipper + +func main() { + // First, so a failure during startup is shipped too. + ship = newShipper(os.Getenv("SYNCD_LOGS")) + log.SetFlags(0) + log.SetPrefix("syncd: ") + log.SetOutput(ship) + + endpoint := os.Getenv("SYNCD_R2") + if endpoint == "" { + fatal("SYNCD_R2 is not set, so there is nowhere durable to write") + } + // Unset, git-forge mints one into a datadir that does not survive a + // restart. + if len(os.Getenv("GIT_FORGE_COOKIE_KEY")) < 32 { + fatal("GIT_FORGE_COOKIE_KEY must be set to at least 32 characters, " + + "because this datadir does not survive a restart: wrangler secret put GIT_FORGE_COOKIE_KEY") + } + + rev, err := os.ReadFile(revFile) + if err != nil { + rev = []byte("unknown") + } + log.Printf("git-forge %s", bytes.TrimSpace(rev)) + + s := newSync(newR2(endpoint), datadir) + + if err := caBundle(sysCert, cfCert, bundle); err != nil { + // Not fatal: only outbound mail needs the intercepted ca. + log.Printf("no ca bundle, outbound mail over tls will fail: %v", err) + } + + start := time.Now() + if err := s.restore(); err != nil { + // An empty datadir would be flushed over the real one. + fatal("restore: %v", err) + } + log.Printf("restored config and %d repository directories in %s", + len(s.manifest), time.Since(start).Round(time.Millisecond)) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + forge := exec.Command("git-forge") + // A later entry wins: git-forge has to listen where the proxy sends and + // write where syncd mirrors. + forge.Env = append(os.Environ(), + "SSL_CERT_FILE="+bundle, + "GIT_FORGE_LISTEN="+forgeAddr, + "GIT_FORGE_DATADIR="+datadir, + ) + forge.Stdout, forge.Stderr = ship, ship + if err := forge.Start(); err != nil { + fatal("git-forge: %v", err) + } + exited := make(chan error, 1) + go func() { exited <- forge.Wait() }() + + srv := &http.Server{ + Addr: listen, + Handler: s, + // No read or write deadline: a push sends a whole packfile in one + // request and a clone streams one back. + ReadHeaderTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + } + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + fatal("listen: %v", err) + } + }() + go s.sweep(ctx) + + code := 0 + select { + case err := <-exited: + // Exit nonzero and take the container down. + log.Printf("git-forge exited: %v", err) + code = 1 + case <-ctx.Done(): + log.Print("draining") + _ = forge.Process.Signal(syscall.SIGTERM) + <-exited + } + + // Only meaningful now that git-forge is stopped and cannot write again. + shut, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(shut); err != nil { + log.Printf("shutdown: %v", err) + } + + if err := s.flush(); err != nil { + log.Printf("final flush: %v", err) + code = 1 + } else { + log.Print("flushed and stopping") + } + ship.close() + os.Exit(code) +} + +// Not log.Fatal, which exits with the line still queued in the shipper. +func fatal(format string, args ...any) { + log.Printf(format, args...) + ship.close() + os.Exit(1) +} + +// caBundle appends the interception ca to the system roots, so oidc still +// verifies against the real ones. +func caBundle(sys, cf, out string) error { + roots, err := os.ReadFile(sys) + if err != nil { + return err + } + intercept, err := os.ReadFile(cf) + if err != nil { + // No interception configured. + return os.WriteFile(out, roots, 0600) + } + return os.WriteFile(out, append(append(roots, '\n'), intercept...), 0600) +} |