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 /fake-r2.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 'fake-r2.go')
| -rw-r--r-- | fake-r2.go | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/fake-r2.go b/fake-r2.go new file mode 100644 index 0000000..6bfdb3f --- /dev/null +++ b/fake-r2.go @@ -0,0 +1,161 @@ +//go:build ignore + +package main + +// A stand-in for the object handler in worker.js, backed by a directory, so +// the image can be driven end to end without a Cloudflare account. +// +// go run fake-r2.go -dir /tmp/bucket -listen :9000 + +import ( + "encoding/json" + "flag" + "io" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +var dir string + +func main() { + listen := flag.String("listen", ":9000", "address to serve on") + flag.StringVar(&dir, "dir", "", "directory to keep objects in") + flag.Parse() + if dir == "" { + log.Fatal("-dir is required") + } + if err := os.MkdirAll(dir, 0700); err != nil { + log.Fatal(err) + } + log.Printf("fake r2 on %s backed by %s", *listen, dir) + log.Fatal(http.ListenAndServe(*listen, http.HandlerFunc(serve))) +} + +// path keeps a key inside the directory. +func path(key string) (string, bool) { + if !filepath.IsLocal(key) { + return "", false + } + return filepath.Join(dir, filepath.FromSlash(key)), true +} + +func serve(w http.ResponseWriter, r *http.Request) { + key := strings.TrimPrefix(r.URL.Path, "/") + // A post with no key is the log host; the lines are logged for the test to + // assert on. + if r.Method == http.MethodPost && key == "" { + body, _ := io.ReadAll(r.Body) + for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") { + if line != "" { + log.Printf("container: %s", line) + } + } + w.WriteHeader(http.StatusNoContent) + return + } + // Logged, so the test can assert an idle sweep writes nothing. + log.Printf("%s %s", r.Method, key) + if r.Method == http.MethodGet && key == "" { + list(w, r) + return + } + file, ok := path(key) + if !ok { + http.Error(w, "bad key", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + f, err := os.Open(file) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + _, _ = io.Copy(w, f) + case http.MethodPut: + if r.ContentLength < 0 { + http.Error(w, "content-length required", http.StatusLengthRequired) + return + } + if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tmp, err := os.CreateTemp(filepath.Dir(file), ".put*") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, r.Body); err != nil { + tmp.Close() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tmp.Close() + if err := os.Rename(tmp.Name(), file); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + _ = os.Remove(file) + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "bad method", http.StatusMethodNotAllowed) + } +} + +// Real r2 pages at a thousand; ten makes the container follow a cursor. +const page = 10 + +func list(w http.ResponseWriter, r *http.Request) { + prefix := r.URL.Query().Get("prefix") + var keys []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(dir, p) + if err != nil { + return err + } + key := filepath.ToSlash(rel) + if strings.HasPrefix(key, prefix) && !strings.HasPrefix(filepath.Base(key), ".put") { + keys = append(keys, key) + } + return nil + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + slices.Sort(keys) + if keys == nil { + keys = []string{} // an empty listing is [], as the worker's map gives + } + from := 0 + if c := r.URL.Query().Get("cursor"); c != "" { + from, _ = strconv.Atoi(c) + } + to := min(from+page, len(keys)) + next := "" + if to < len(keys) { + next = strconv.Itoa(to) + } + _ = json.NewEncoder(w).Encode(map[string]any{"keys": keys[from:to], "cursor": next}) +} |