Skip to content
quarry Artifact [6afad67ab0]
Login

Artifact [6afad67ab0]

Artifact 6afad67ab09f5c32de2046ba310db444c1d98dc65bf32447b94b0dd7d8d73e44:


// quarry is a multi-repository forge for zit. See docs/rfcs/.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/events"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ingest"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/web"
	"craftdesign.group/quarry/internal/xfer"
	"craftdesign.group/quarry/internal/zitcli"
)

// version is stamped at build time; every page footer and the API report it
// (0001 Principle 1).
var version = "0.0.0-dev"

func main() {
	if len(os.Args) > 1 {
		switch os.Args[1] {
		case "version":
			fmt.Println("quarry", version)
			return
		case "spec":
			if err := printSpec(os.Stdout); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		case "serve":
			if err := serveCmd(os.Args[2:]); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		case "reindex":
			if err := reindexCmd(os.Args[2:]); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		case "user":
			if err := userCmd(os.Args[2:]); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		}
	}
	fmt.Fprintln(os.Stderr, "usage: quarry version|spec|serve|reindex|user")
	os.Exit(2)
}

// userCmd manages sync users (0001 §7 v1 auth): `quarry user add`
// derives the RFC §6.3 shared secret from the store's project code and
// stores it in forge OLTP — plaintext is never persisted. A repo with
// at least one sync user requires authenticated pushes.
func userCmd(args []string) error {
	if len(args) < 1 || args[0] != "add" {
		return fmt.Errorf("usage: quarry user add --repo name=path --user U [--password P] [--data DIR]")
	}
	fs := flag.NewFlagSet("user add", flag.ContinueOnError)
	repos := repoFlags{}
	fs.Var(repos, "repo", "repository as name=path")
	user := fs.String("user", "", "sync login name")
	password := fs.String("password", "", "password (omit to read from QUARRY_PASSWORD)")
	data := fs.String("data", "", "derived-data directory (default: .quarry beside the repo)")
	if err := fs.Parse(args[1:]); err != nil {
		return err
	}
	if len(repos) != 1 || *user == "" {
		return fmt.Errorf("user add wants exactly one --repo and a --user")
	}
	pw := *password
	if pw == "" {
		pw = os.Getenv("QUARRY_PASSWORD")
	}
	if pw == "" {
		return fmt.Errorf("no password: use --password or QUARRY_PASSWORD")
	}
	var name, path string
	for n, p := range repos {
		name, path = n, p
	}
	dir := *data
	if dir == "" {
		dir = filepath.Join(path, ".quarry")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	code, err := xfer.ProjectCode(path)
	if err != nil {
		return fmt.Errorf("project code of %s: %w", name, err)
	}
	db, err := oltp.Open(filepath.Join(dir, "oltp.db"))
	if err != nil {
		return err
	}
	defer db.Close()
	if err := db.SetSyncUser(name, *user, xfer.SharedSecret(code, *user, pw)); err != nil {
		return err
	}
	fmt.Printf("sync user %q added to %s; pushes now require authentication\n", *user, name)
	return nil
}

// reindexCmd is 0001 §5's full rebuild as a verb: for each --repo, drop
// the derived tables and cursor, then replay the store. Run it with the
// same --repo/--data flags as serve, while serve is stopped (the
// embedded NATS store directory admits one process).
func reindexCmd(args []string) error {
	fs := flag.NewFlagSet("reindex", flag.ContinueOnError)
	repos := repoFlags{}
	fs.Var(repos, "repo", "repository as name=path (repeatable; bare path uses its basename)")
	data := fs.String("data", "", "derived-data directory (default: .quarry beside the first repo)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if len(repos) == 0 {
		return fmt.Errorf("reindex wants at least one --repo")
	}
	dir := *data
	if dir == "" {
		for _, p := range repos {
			dir = filepath.Join(p, ".quarry")
			break
		}
	}
	ev, err := events.Start(filepath.Join(dir, "nats"))
	if err != nil {
		return fmt.Errorf("%w (is serve still running against this data dir?)", err)
	}
	defer ev.Close()
	for name, path := range repos {
		if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
			return err
		}
		ix, err := index.Open(filepath.Join(dir, name, "index.db"))
		if err != nil {
			return err
		}
		n, err := ingest.NewPipeline(zitcli.New(path), ev, ix, name).Reindex()
		ix.Close()
		if err != nil {
			return fmt.Errorf("reindex %s: %w", name, err)
		}
		fmt.Printf("reindexed %s: %d artifacts replayed\n", name, n)
	}
	return nil
}

// repoFlags collects repeatable --repo name=path values (bare paths take
// their directory basename as the repository name).
type repoFlags map[string]string

func (r repoFlags) String() string { return fmt.Sprint(map[string]string(r)) }

func (r repoFlags) Set(v string) error {
	name, path, found := strings.Cut(v, "=")
	if !found {
		path = v
		name = filepath.Base(strings.TrimSuffix(v, "/"))
	}
	if name == "" || path == "" {
		return fmt.Errorf("--repo wants name=path or path, got %q", v)
	}
	if !repoName.MatchString(name) {
		return fmt.Errorf("repository name %q: use letters, digits, dot, dash, underscore", name)
	}
	if _, dup := r[name]; dup {
		return fmt.Errorf("repository %q registered twice", name)
	}
	r[name] = path
	return nil
}

var repoName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)

// serveCmd runs the HTTP surface: the op-generated API plus the HTML
// pages, both consumers of one registry. Each --repo gets its own index
// and watcher pipeline; one embedded NATS carries every store's events
// under zit.evt.<name>.* (0001 §4: a watcher per registered store).
func serveCmd(args []string) error {
	fs := flag.NewFlagSet("serve", flag.ContinueOnError)
	addr := fs.String("addr", "127.0.0.1:8080", "listen address")
	repos := repoFlags{}
	fs.Var(repos, "repo", "repository to serve as name=path (repeatable; bare path uses its basename)")
	data := fs.String("data", "", "derived-data directory (default: .quarry beside the first repo)")
	interval := fs.Duration("sync-interval", 2*time.Second, "store poll interval")
	if err := fs.Parse(args); err != nil {
		return err
	}

	deps := api.Deps{Version: version, Repos: map[string]*api.Repo{}}
	var notifier web.Notifier
	var fronter *xfer.Fronter
	if len(repos) > 0 {
		fronter = xfer.New()
		defer fronter.Close()
		for name, path := range repos {
			fronter.Register(name, path)
		}
	}
	if len(repos) > 0 {
		dir := *data
		if dir == "" {
			for _, p := range repos {
				dir = filepath.Join(p, ".quarry")
				break
			}
		}
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
		odb, err := oltp.Open(filepath.Join(dir, "oltp.db"))
		if err != nil {
			return err
		}
		defer odb.Close()
		fronter.SetAuth(odb)

		ev, err := events.Start(filepath.Join(dir, "nats"))
		if err != nil {
			return err
		}
		defer ev.Close()
		notifier = ev

		for name, path := range repos {
			if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
				return err
			}
			ix, err := index.Open(filepath.Join(dir, name, "index.db"))
			if err != nil {
				return err
			}
			defer ix.Close()
			cli := zitcli.New(path)
			deps.Repos[name] = &api.Repo{Index: ix, Zit: cli}

			pipe := ingest.NewPipeline(cli, ev, ix, name)
			if n, err := pipe.Sync(); err != nil {
				return fmt.Errorf("initial ingest of %s: %w", name, err)
			} else {
				fmt.Printf("ingested %d artifacts from %s (%s)\n", n, name, path)
			}
			go func(name string, pipe *ingest.Pipeline) {
				for range time.Tick(*interval) {
					if _, err := pipe.Sync(); err != nil {
						log.Printf("ingest %s: %v", name, err)
					}
				}
			}(name, pipe)
		}
	}

	mux, err := buildHandler(deps, notifier)
	if err != nil {
		return err
	}
	if fronter != nil {
		mount := http.NewServeMux()
		// Sync fronting (0001 §7): zit clients POST the repo URL
		// verbatim; /xfer is the alias zit serve itself answers on.
		mount.Handle("POST /r/{repo}", fronter)
		mount.Handle("POST /r/{repo}/xfer", fronter)
		mount.Handle("/", mux)
		mux = mount
	}
	fmt.Println("quarry", version, "listening on", *addr)
	return http.ListenAndServe(*addr, mux)
}

// buildHandler wires the two registry consumers onto one mux. notifier
// may be nil (no live pages).
func buildHandler(deps api.Deps, notifier web.Notifier) (http.Handler, error) {
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, deps); err != nil {
		return nil, err
	}
	apiRouter, err := api.Router(r, deps.Version)
	if err != nil {
		return nil, err
	}
	mux := http.NewServeMux()
	mux.Handle("/api/", apiRouter)
	mux.Handle("/", web.Handler(r, notifier))
	return mux, nil
}

// printSpec emits the OpenAPI document generated from the full registry —
// the same registry serve will run, so the spec cannot drift (0001 §9).
func printSpec(w *os.File) error {
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: version}); err != nil {
		return err
	}
	spec, err := ops.Spec(r, "quarry", version)
	if err != nil {
		return err
	}
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(spec)
}