Skip to content
quarry Artifact [7dba95ebc4]
Login

Artifact [7dba95ebc4]

Artifact 7dba95ebc444058560ca4c8e4a6e90f4329882c5fda68a06b257b0e347b722c2:


package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// repoCmd is the client-side verb for managing repositories. Unlike
// `account` and `user`, which open the forge database directly, this
// talks to a RUNNING server over the API โ€” because registering a
// repository is not a config edit, it is a live operation: the server
// opens an index, starts a pipeline and begins serving. Two processes
// writing that registry would disagree about which repositories exist.
//
// It is therefore also the first proof that the API is sufficient: the
// CLI has no privileged path, so anything it can do, any client can.
func repoCmd(args []string) error {
	if len(args) < 1 {
		return fmt.Errorf("usage: quarry repo new|clone|list|remove [flags]")
	}
	verb, rest := args[0], args[1:]
	fs := flag.NewFlagSet("repo "+verb, flag.ContinueOnError)
	server := fs.String("server", envOr("QUARRY_SERVER", "http://127.0.0.1:8080"), "quarry base URL")
	user := fs.String("user", envOr("QUARRY_USER", ""), "admin account")
	password := fs.String("password", "", "password (or QUARRY_PASSWORD)")
	name := fs.String("name", "", "repository name")
	path := fs.String("path", "", "store directory (default: the server's repositories root)")
	desc := fs.String("description", "", "one-line summary")
	from := fs.String("from", "", "clone this URL")
	every := fs.Int("every", 0, "mirror interval in seconds (clone only; 0 = server default)")
	noMirror := fs.Bool("no-mirror", false, "do not record a mirror when cloning")
	if err := fs.Parse(rest); err != nil {
		return err
	}
	if r := fs.Args(); len(r) > 0 {
		return fmt.Errorf("unexpected argument %q", r[0])
	}
	pw := *password
	if pw == "" {
		pw = os.Getenv("QUARRY_PASSWORD")
	}

	c := &apiClient{base: strings.TrimRight(*server, "/"), user: *user, pass: pw}
	switch verb {
	case "list":
		var out struct {
			Repos []struct {
				Name, Description string
				Checkins          int
			} `json:"repos"`
		}
		if err := c.call("GET", "/api/v1/repos", nil, &out); err != nil {
			return err
		}
		for _, r := range out.Repos {
			fmt.Printf("%-24s %6d  %s\n", r.Name, r.Checkins, r.Description)
		}
		return nil

	case "new", "clone":
		if *name == "" {
			return fmt.Errorf("repo %s wants --name", verb)
		}
		if verb == "clone" && *from == "" {
			return fmt.Errorf("repo clone wants --from URL")
		}
		body := map[string]any{
			"name": *name, "path": *path, "description": *desc,
			"create": verb == "new", "clone": *from,
			"noMirror": *noMirror, "mirrorEvery": *every,
		}
		var out struct {
			Name, Path string
			Mirrored   bool
		}
		if err := c.call("POST", "/api/v1/repos", body, &out); err != nil {
			return err
		}
		fmt.Printf("%s created at %s\n", out.Name, out.Path)
		if out.Mirrored {
			fmt.Printf("mirroring %s\n", *from)
		}
		fmt.Printf("\n  zit clone %s/r/%s %s\n\n", c.base, out.Name, out.Name)
		return nil

	case "remove":
		if *name == "" {
			return fmt.Errorf("repo remove wants --name")
		}
		if err := c.call("POST", "/api/v1/repos/remove", map[string]any{"name": *name}, nil); err != nil {
			return err
		}
		fmt.Printf("%s is no longer served (its store on disk is untouched)\n", *name)
		return nil
	}
	return fmt.Errorf("unknown verb %q: want new|clone|list|remove", verb)
}

// accountAPICmd manages accounts through the API rather than the
// database. `quarry account` still opens OLTP directly โ€” it has to, so
// the FIRST account can be created before any server is running or any
// admin exists to authenticate as. Past that, the API is the surface,
// and driving it from here is what keeps it honest.
func accountAPICmd(args []string) error {
	if len(args) < 1 {
		return fmt.Errorf("usage: quarry accounts list|add|passwd|grant|revoke|delete [flags]")
	}
	verb, rest := args[0], args[1:]
	fs := flag.NewFlagSet("accounts "+verb, flag.ContinueOnError)
	server := fs.String("server", envOr("QUARRY_SERVER", "http://127.0.0.1:8080"), "quarry base URL")
	as := fs.String("as", envOr("QUARRY_USER", ""), "admin account to act as")
	asPassword := fs.String("as-password", "", "that account's password (or QUARRY_PASSWORD)")
	name := fs.String("user", "", "the account to operate on")
	password := fs.String("password", "", "new password (or QUARRY_NEW_PASSWORD)")
	admin := fs.Bool("admin", false, "make a site admin")
	repo := fs.String("repo", "", "repository, for grant and revoke")
	role := fs.String("role", "read", "read|write|admin")
	if err := fs.Parse(rest); err != nil {
		return err
	}
	if r := fs.Args(); len(r) > 0 {
		return fmt.Errorf("unexpected argument %q", r[0])
	}
	c := &apiClient{base: strings.TrimRight(*server, "/"), user: *as,
		pass: firstNonEmpty(*asPassword, os.Getenv("QUARRY_PASSWORD"))}

	switch verb {
	case "list":
		var out struct {
			Accounts []struct {
				Name    string
				Admin   bool
				Created string
				Grants  []struct{ Repo, Role string }
			}
		}
		if err := c.call("GET", "/api/v1/accounts", nil, &out); err != nil {
			return err
		}
		for _, a := range out.Accounts {
			kind := ""
			if a.Admin {
				kind = "  (site admin)"
			}
			fmt.Printf("%-24s%s\n", a.Name, kind)
			for _, g := range a.Grants {
				fmt.Printf("    %-20s %s\n", g.Repo, g.Role)
			}
		}
		return nil

	case "add", "passwd":
		if *name == "" {
			return fmt.Errorf("accounts %s wants --user", verb)
		}
		pw := firstNonEmpty(*password, os.Getenv("QUARRY_NEW_PASSWORD"))
		if pw == "" {
			return fmt.Errorf("no password: use --password or QUARRY_NEW_PASSWORD")
		}
		path, body := "/api/v1/accounts", map[string]any{"name": *name, "password": pw, "admin": *admin}
		if verb == "passwd" {
			path = "/api/v1/accounts/passwd"
			body = map[string]any{"name": *name, "password": pw}
		}
		if err := c.call("POST", path, body, nil); err != nil {
			return err
		}
		fmt.Printf("%s: password set\n", *name)
		return nil

	case "grant", "revoke":
		if *name == "" || *repo == "" {
			return fmt.Errorf("accounts %s wants --user and --repo", verb)
		}
		body := map[string]any{"name": *name, "repo": *repo}
		if verb == "grant" {
			body["role"] = *role
		}
		if err := c.call("POST", "/api/v1/accounts/"+verb, body, nil); err != nil {
			return err
		}
		if verb == "grant" {
			fmt.Printf("%s: %s on %s\n", *name, *role, *repo)
		} else {
			fmt.Printf("%s: no access to %s\n", *name, *repo)
		}
		return nil

	case "delete":
		if *name == "" {
			return fmt.Errorf("accounts delete wants --user")
		}
		if err := c.call("POST", "/api/v1/accounts/delete", map[string]any{"name": *name}, nil); err != nil {
			return err
		}
		fmt.Printf("%s deleted\n", *name)
		return nil
	}
	return fmt.Errorf("unknown verb %q: want list|add|passwd|grant|revoke|delete", verb)
}

func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}

func envOr(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

// apiClient is the smallest thing that can drive quarry's API with an
// admin credential. Basic auth because that is what the API already
// takes (0001 ยง7: one credential per user).
type apiClient struct {
	base, user, pass string
}

func (c *apiClient) call(method, path string, body any, out any) error {
	var rdr *strings.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rdr = strings.NewReader(string(raw))
	}
	var req *http.Request
	var err error
	if rdr != nil {
		req, err = http.NewRequest(method, c.base+path, rdr)
	} else {
		req, err = http.NewRequest(method, c.base+path, nil)
	}
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	if c.user != "" {
		req.SetBasicAuth(c.user, c.pass)
	}
	// Generous: a clone of a large repository is minutes, and the server
	// holds the request open for it.
	resp, err := (&http.Client{Timeout: 30 * time.Minute}).Do(req)
	if err != nil {
		return fmt.Errorf("%s: %w", urlOf(c.base, path), err)
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		var e struct {
			Error string `json:"error"`
		}
		json.NewDecoder(resp.Body).Decode(&e)
		if e.Error == "" {
			e.Error = resp.Status
		}
		return fmt.Errorf("%s", e.Error)
	}
	if out == nil {
		return nil
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

func urlOf(base, path string) string {
	u, err := url.Parse(base + path)
	if err != nil {
		return base + path
	}
	return u.String()
}