Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5501f77805 | ||
|
|
75d69a6b9c | ||
|
|
beae60a76e | ||
|
|
3104f4b706 | ||
|
|
5141e4b138 | ||
|
|
a764cfacbc | ||
|
|
2e0465f10a | ||
|
|
cba65f5be7 | ||
|
|
6dba6bd93a |
No files matched your search
+104
@@ -13,11 +13,115 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| v0.6.0 | 4 | Multi-node replicas and fabric awareness | released |
|
||||
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
||||
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
|
||||
| v0.9.0 | — | Managing people and the tenancy tree | released |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## v0.9.1 — 2026-09-28
|
||||
|
||||
### The mirror page is one line per model
|
||||
|
||||
What the mirror holds used to take two table rows per file — seven columns and
|
||||
the address under them — so a sharded model filled the screen. Now each version
|
||||
is one line: model and version, the file (or "4 files" for a set), total size,
|
||||
and which catalog models use it. The address moved into a "copy address" button;
|
||||
the checksum is in the file's tooltip. A version already in use is not offered
|
||||
for serving again, and removing one warns that the catalog uses it. The header
|
||||
counts versions rather than files.
|
||||
|
||||
## v0.9.0 — 2026-09-28
|
||||
|
||||
### Managing people and the tenancy tree
|
||||
|
||||
Organizations, teams and projects can now be renamed, moved and removed, and an
|
||||
approved person can be moved to another project or removed — none of which
|
||||
could be done at all before, from the web or the CLI.
|
||||
|
||||
- **`/admin/users`** shows the whole tree, each project opening onto its people
|
||||
and keys, with approve, move, suspend, reinstate and remove for people,
|
||||
revoke for keys, and add, rename, move and remove at every level. A search
|
||||
over every account sits below it. Changes update the page in place (htmx,
|
||||
embedded in the binary) and it still works with scripts off.
|
||||
- **`llmgw org`** — `tree [--people]`, `add`, `rename`, `move`, `remove`.
|
||||
- **`llmgw users move`** and **`llmgw users delete`**.
|
||||
- **`llmgw keys list --all`** lists every key with its project and owner.
|
||||
- Every command that takes a project, team or organization accepts an id, a
|
||||
path such as `Acme/platform/gateway`, or a unique name, and `--project` may
|
||||
be omitted when there is only one. Users are named by id or email.
|
||||
|
||||
Moving a person takes the keys they made with them; keys an operator issued stay
|
||||
with the project. Only empty things are removed, and a project that ever had a
|
||||
key is never removed, because usage history refers to it.
|
||||
|
||||
### Changes
|
||||
|
||||
- **`--project` no longer defaults to `default`** in `keys create`, `keys list`,
|
||||
`users approve`, `users create`, `keys threads` and `keys memory`: a tree
|
||||
built from the web has no project of that name. With one project it is
|
||||
chosen; with several the command lists them.
|
||||
- **`keys limits --budget` is in whole units**, as the quotas page takes it,
|
||||
rather than micro-units. `--budget 50000000` now means fifty million units;
|
||||
divide existing scripts' figures by 1 000 000.
|
||||
|
||||
### Fixes
|
||||
|
||||
- `keys init` could silently move an existing team or project to another
|
||||
organization or team by redeclaring it. It now refuses.
|
||||
- An account rejected while pending and then reinstated became active with no
|
||||
project, and failed at its first key. Reinstating one is refused; approving
|
||||
gives it a project. Rejecting a signup now removes it.
|
||||
|
||||
## v0.8.8 — 2026-09-28
|
||||
|
||||
### Fixes
|
||||
|
||||
- **`server init --postgres` failed on Rocky Linux** with "postgresql-setup:
|
||||
executable file not found". The PostgreSQL project's own packages (PGDG)
|
||||
provide `postgresql-server` too, so dnf can answer that name with
|
||||
`postgresql16-server`, which names its unit `postgresql-16.service` and keeps
|
||||
its tools in `/usr/pgsql-16/bin`. init now finds the server unit systemd has,
|
||||
reads the data directory from it, and runs `initdb` itself when the directory
|
||||
is empty. A PostgreSQL that was already installed is used as it is.
|
||||
|
||||
### Known gaps
|
||||
|
||||
Not yet run on a real machine. Three arrangements are not handled: a
|
||||
PostgreSQL initialised with `ident` for TCP logins (the connection check fails
|
||||
and says so), one listening on a port other than 5432, and several PGDG
|
||||
versions where the highest is not the one on 5432.
|
||||
|
||||
## v0.8.7 — 2026-09-28
|
||||
|
||||
### PostgreSQL in one command
|
||||
|
||||
```
|
||||
sudo llmgw server init --postgres
|
||||
```
|
||||
|
||||
Installs PostgreSQL with the machine's own package manager when there is none,
|
||||
creates the `llmgw` role and database with a generated password kept in
|
||||
`/etc/llmgw/secrets/store-dsn`, and checks that it connects. On a control plane
|
||||
already running on SQLite it stops the gateway, copies the store across so
|
||||
every account and key survives, points `auth.store` at PostgreSQL and starts
|
||||
the gateway again; if the move fails, the gateway is started again on SQLite.
|
||||
Given on the first run, there is never a SQLite store.
|
||||
|
||||
`server.yaml` is edited in place with its comments intact, and the previous
|
||||
file is kept beside it. A PostgreSQL that init did not install is left sized as
|
||||
it was, since something else may depend on it.
|
||||
|
||||
The PostgreSQL installation itself has not yet run on a real machine; the move,
|
||||
the configuration edit and the rerun behaviour are tested.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **A new control plane with no models did not start.** A configuration with
|
||||
no static endpoints and no models was refused as having nothing to serve, so
|
||||
under systemd the unit restarted every two seconds and nothing listened on
|
||||
8080. It now runs as a control plane with an empty catalog.
|
||||
|
||||
## v0.8.6 — 2026-09-28
|
||||
|
||||
### Reverse proxy configurations
|
||||
|
||||
@@ -32,6 +32,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
|
||||
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
||||
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
|
||||
| v0.9.0 | — | Managing people and the tenancy tree | released |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
|
||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||
|
||||
@@ -141,7 +141,7 @@ not count the same free terabyte once per node.
|
||||
Set budgets and limits on day one, not later:
|
||||
|
||||
```
|
||||
llmgw keys limits --scope project --id <project> --rpm 60 --budget 50000000
|
||||
llmgw keys limits --scope project --id <project> --rpm 60 --budget 50
|
||||
```
|
||||
|
||||
The reason is not technical. A resource that appears free and unlimited becomes,
|
||||
|
||||
@@ -105,6 +105,10 @@ That is the whole step. In order, it:
|
||||
once**
|
||||
6. installs the systemd unit, starts it, and waits until `/health` answers
|
||||
|
||||
Add `--postgres` to keep accounts and keys in PostgreSQL rather than SQLite:
|
||||
init installs it and sets it up, and on a machine already running on SQLite it
|
||||
moves everything across. See [PostgreSQL](#postgresql-when-sqlite-is-no-longer-the-right-shape).
|
||||
|
||||
Every step looks before it acts. Running it again finishes an install that
|
||||
stopped halfway and changes nothing on one that did not; it never overwrites
|
||||
`server.yaml`, the authority, or an existing account's password.
|
||||
@@ -743,6 +747,20 @@ turn, which is the first per-request load this store has had; and SQLite takes
|
||||
one writer at a time, so those turns serialise. And a second control plane is
|
||||
impossible while the store is a file on one machine's disk.
|
||||
|
||||
On the control plane, one command does all of it — installs PostgreSQL if the
|
||||
machine has none, creates its role and database with a generated password kept
|
||||
in `/etc/llmgw/secrets/store-dsn`, stops the gateway, copies the SQLite store
|
||||
into it so every account and key survives, points `server.yaml` at it, and
|
||||
starts the gateway again:
|
||||
|
||||
```
|
||||
sudo llmgw server init --postgres
|
||||
```
|
||||
|
||||
It can be given on the first run too, and then there is never a SQLite store.
|
||||
The rest of this section is what that command does, for a PostgreSQL somewhere
|
||||
else.
|
||||
|
||||
The setting is the same one, and the address decides:
|
||||
|
||||
```yaml
|
||||
|
||||
+41
-11
@@ -88,11 +88,36 @@ means anything.
|
||||
If you have no structure yet, one of each is fine:
|
||||
|
||||
```
|
||||
llmgw keys init --store … --org mynodes --team default --project default
|
||||
llmgw org add org mynodes
|
||||
llmgw org add team default --in mynodes
|
||||
llmgw org add project default --in mynodes/default
|
||||
llmgw org tree --people
|
||||
```
|
||||
|
||||
It is idempotent: running it again renames rather than duplicating, so it can
|
||||
live in whatever provisions the machine.
|
||||
Everything that takes an organization, team or project — here, in `users` and
|
||||
in `keys` — accepts its id, its path (`mynodes/default/default`) or its name
|
||||
when the name is unique, so an id never has to be copied off a page. When there
|
||||
is only one project, `--project` can be left out.
|
||||
|
||||
The tree is not fixed once made:
|
||||
|
||||
```
|
||||
llmgw org rename project Acme/platform/gateway api-gateway
|
||||
llmgw org move project Acme/platform/gateway --to Scania/ml
|
||||
llmgw org remove team Acme/old # only when nothing is in it
|
||||
llmgw users move ayse@example.com --project Acme/ml/research
|
||||
llmgw users delete ayse@example.com --yes # signs out, revokes their keys
|
||||
llmgw keys list --all # every key, its project and owner
|
||||
```
|
||||
|
||||
Moving a person takes the keys they made with them, so they stop spending the
|
||||
old project's budget; keys an operator issued in the project stay with it. A
|
||||
project that ever had a key cannot be removed, because usage history refers to
|
||||
it — rename it instead.
|
||||
|
||||
`llmgw keys init` still works for provisioning scripts and is idempotent, but it
|
||||
will no longer move a team or project that already exists under another parent;
|
||||
it says so and stops.
|
||||
|
||||
Split it when you want separate limits or separate reporting, not before.
|
||||
|
||||
@@ -156,16 +181,18 @@ Allowlists compose by intersection: a key cannot add a model its team withheld.
|
||||
A model's canonical name and any of its aliases name the same thing, so either
|
||||
form works.
|
||||
|
||||
Budgets are counted in micro-units, and what a unit means is yours to decide.
|
||||
Self-hosted models have no per-token price, so it can be GPU-seconds, a notional
|
||||
rate, or 1 000 000 everywhere — which turns a budget into a plain token quota.
|
||||
Budgets are given in whole units, as the quotas page shows them, and what a unit
|
||||
means is yours to decide. Self-hosted models have no per-token price, so it can
|
||||
be GPU-seconds, a notional rate, or one unit per thousand tokens everywhere —
|
||||
a price of 1 000 000 in `auth.pricing`, which is in micro-units — and a budget
|
||||
becomes a plain quota of thousands of tokens.
|
||||
Models are priced in the server's `auth.pricing` section; a model priced at zero
|
||||
is exempt from budget checks, which is what lets a key with nothing left still be
|
||||
served a cheap fallback.
|
||||
|
||||
```
|
||||
llmgw keys limits --store … --scope project --id default \
|
||||
--budget 50000000 --period month # 50 units a month
|
||||
llmgw keys limits --store … --scope project --id Acme/platform/gateway \
|
||||
--budget 50 --period month # 50 units a month
|
||||
```
|
||||
|
||||
## The landing page
|
||||
@@ -286,9 +313,12 @@ page exists.
|
||||
|
||||
## Managing accounts from the browser
|
||||
|
||||
`/admin/users` lists who is waiting and who is active. Approving (into a project)
|
||||
and suspending are there, because that is the work an operator does constantly
|
||||
and it should not need a terminal.
|
||||
`/admin/users` is the tree: organizations, teams and projects, each project
|
||||
opening onto its people and its keys. From there an admin approves, moves,
|
||||
suspends and removes people, revokes keys, and adds, renames, moves and removes
|
||||
organizations, teams and projects. A searchable list of every account is at the
|
||||
bottom. The page works as plain forms; with scripts on, a change updates it in
|
||||
place and the projects you had open stay open.
|
||||
|
||||
Three things are deliberately not there:
|
||||
|
||||
|
||||
@@ -290,6 +290,14 @@ func (in *Installer) packages(ctx context.Context, byFamily map[string][]string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallPackages installs whichever of these packages the machine lacks,
|
||||
// from its own package sources, waiting out another package manager's lock.
|
||||
// byFamily is keyed by discovery.Host.Family: "deb" or "rpm".
|
||||
func (in *Installer) InstallPackages(ctx context.Context, byFamily map[string][]string) error {
|
||||
var st Status
|
||||
return in.packages(ctx, byFamily, &st)
|
||||
}
|
||||
|
||||
// lockWait bounds how long a package command waits for another to finish.
|
||||
const lockWait = 10 * time.Minute
|
||||
|
||||
|
||||
+72
-16
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -107,7 +108,11 @@ func newKeyCreateCmd(open opener) *cobra.Command {
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
secret, k, err := store.NewKey(randomID("key"), args[0], project)
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret, k, err := store.NewKey(randomID("key"), args[0], p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -117,8 +122,7 @@ func newKeyCreateCmd(open opener) *cobra.Command {
|
||||
k.ExpiresAt = k.CreatedAt.Add(expires)
|
||||
}
|
||||
if err := s.CreateKey(cmd.Context(), k); err != nil {
|
||||
return fmt.Errorf("creating the key (does project %q exist? run `llmgw keys init`): %w",
|
||||
project, err)
|
||||
return fmt.Errorf("creating the key in %s: %w", p.Path, err)
|
||||
}
|
||||
|
||||
// The one output that matters goes to stdout and everything else to
|
||||
@@ -141,13 +145,13 @@ func newKeyCreateCmd(open opener) *cobra.Command {
|
||||
link := strings.TrimRight(baseURL, "/") + "/claim/" + token
|
||||
fmt.Fprintln(out, link)
|
||||
cmd.PrintErrf("\nid %s\nproject %s\nexpires %s\n",
|
||||
k.ID, k.ProjectID, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339))
|
||||
k.ID, p.Path, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339))
|
||||
cmd.PrintErrln("\nSend the link. It works once, and the key is shown only to whoever opens it.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, secret)
|
||||
cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, k.ProjectID)
|
||||
cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, p.Path)
|
||||
if !k.ExpiresAt.IsZero() {
|
||||
cmd.PrintErrf("expires %s\n", k.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
@@ -155,7 +159,8 @@ func newKeyCreateCmd(open opener) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "default", "project the key belongs to")
|
||||
cmd.Flags().StringVar(&project, "project", "",
|
||||
"project the key belongs to: id, path (Acme/platform/gateway) or unique name")
|
||||
cmd.Flags().StringVar(&band, "band", "", "admission band; empty inherits")
|
||||
cmd.Flags().StringVar(&profile, "profile", "", "routing profile; empty inherits")
|
||||
cmd.Flags().DurationVar(&expires, "expires-in", 0, "lifetime; 0 never expires")
|
||||
@@ -174,9 +179,10 @@ func orDefault(d, fallback time.Duration) time.Duration {
|
||||
|
||||
func newKeyListCmd(open opener) *cobra.Command {
|
||||
var project string
|
||||
var all bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List a project's keys",
|
||||
Short: "List a project's keys, or with --all every key and whose it is",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
s, err := open(cmd.Context())
|
||||
@@ -185,12 +191,31 @@ func newKeyListCmd(open opener) *cobra.Command {
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
keys, err := s.Keys(cmd.Context(), project)
|
||||
if all {
|
||||
rows, err := s.AllKeys(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tKEY\tPROJECT\tOWNER\tSTATE\tLAST USED")
|
||||
now := time.Now()
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.Name, r.Prefix,
|
||||
r.ProjectPath, dash(r.OwnerEmail), keyState(r.Key, now), ago(r.LastUsedAt, now))
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := s.Keys(cmd.Context(), p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "no keys in project %q\n", project)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "no keys in %s\n", p.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,7 +229,8 @@ func newKeyListCmd(open opener) *cobra.Command {
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "default", "project to list")
|
||||
cmd.Flags().StringVar(&project, "project", "", "project to list: id, path or unique name")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "every key in every project, with its owner")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -235,7 +261,7 @@ func newKeyRevokeCmd(open opener) *cobra.Command {
|
||||
func newLimitsCmd(open opener) *cobra.Command {
|
||||
var scope, id, period string
|
||||
var rpm, tpm int
|
||||
var budget int64
|
||||
var budget float64
|
||||
var models []string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -252,7 +278,34 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
l := store.Limits{Scope: store.Scope(scope), ScopeID: id}
|
||||
scopeID := id
|
||||
switch store.Scope(scope) {
|
||||
case store.ScopeOrg:
|
||||
o, err := resolveOrg(cmd.Context(), s, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopeID = o.ID
|
||||
case store.ScopeTeam:
|
||||
t, err := resolveTeam(cmd.Context(), s, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopeID = t.ID
|
||||
case store.ScopeProject:
|
||||
p, err := resolveProject(cmd.Context(), s, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scopeID = p.ID
|
||||
case store.ScopeKey:
|
||||
if id == "" {
|
||||
return errors.New("--scope key needs --id, the key's id")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("--scope is org, team, project or key, not %q", scope)
|
||||
}
|
||||
l := store.Limits{Scope: store.Scope(scope), ScopeID: scopeID}
|
||||
if cmd.Flags().Changed("rpm") {
|
||||
l.RPM = &rpm
|
||||
}
|
||||
@@ -260,7 +313,10 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
l.TPM = &tpm
|
||||
}
|
||||
if cmd.Flags().Changed("budget") {
|
||||
l.Budget = &budget
|
||||
// Whole units, as the quotas page takes them; stored as
|
||||
// micro-units so repeated addition does not drift.
|
||||
micros := int64(math.Round(budget * 1e6))
|
||||
l.Budget = µs
|
||||
l.BudgetPeriod = store.Period(period)
|
||||
}
|
||||
if cmd.Flags().Changed("models") {
|
||||
@@ -274,15 +330,15 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
if err := s.PutLimits(cmd.Context(), l); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "limits set on %s %s\n", scope, id)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "limits set on %s %s\n", scope, scopeID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&scope, "scope", "project", "org | team | project | key")
|
||||
cmd.Flags().StringVar(&id, "id", "default", "id at that scope")
|
||||
cmd.Flags().StringVar(&id, "id", "", "which one: id, path or unique name (a key's id for --scope key)")
|
||||
cmd.Flags().IntVar(&rpm, "rpm", 0, "requests per minute")
|
||||
cmd.Flags().IntVar(&tpm, "tpm", 0, "tokens per minute")
|
||||
cmd.Flags().Int64Var(&budget, "budget", 0, "budget per period, in micro-units")
|
||||
cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them")
|
||||
cmd.Flags().StringVar(&period, "period", "month", "day | month")
|
||||
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
|
||||
return cmd
|
||||
|
||||
@@ -42,7 +42,12 @@ func newMemoryCmd(open opener) *cobra.Command {
|
||||
if off {
|
||||
keep = 0
|
||||
}
|
||||
if err := s.SetMemoryRetention(cmd.Context(), project, keep); err != nil {
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project = p.Path
|
||||
if err := s.SetMemoryRetention(cmd.Context(), p.ID, keep); err != nil {
|
||||
return err
|
||||
}
|
||||
if keep == 0 {
|
||||
@@ -55,7 +60,7 @@ func newMemoryCmd(open opener) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "default", "project id")
|
||||
cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name")
|
||||
cmd.Flags().IntVar(&hours, "retain-hours", 0, "how long a note is kept after it was last written")
|
||||
cmd.Flags().BoolVar(&off, "off", false, "stop keeping notes for this project")
|
||||
return cmd
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newOrgCmd() *cobra.Command {
|
||||
var sf storeFlags
|
||||
cmd := &cobra.Command{
|
||||
Use: "org",
|
||||
Short: "Organizations, teams and projects: see, add, rename, move, remove",
|
||||
Long: "The tenancy tree an account lives in. A person is in a project, a project\n" +
|
||||
"in a team, a team in an organization, and limits can be set on any of them.\n\n" +
|
||||
"Anything that takes one of them accepts its id, its path\n" +
|
||||
"(\"Acme/platform/gateway\") or its name when that name is unique.",
|
||||
}
|
||||
sf.bind(cmd)
|
||||
open := func(cmd *cobra.Command) (*store.DB, error) { return sf.open(cmd) }
|
||||
cmd.AddCommand(newOrgTreeCmd(open), newOrgAddCmd(open), newOrgRenameCmd(open),
|
||||
newOrgMoveCmd(open), newOrgRemoveCmd(open))
|
||||
return cmd
|
||||
}
|
||||
|
||||
type dbOpener func(*cobra.Command) (*store.DB, error)
|
||||
|
||||
func newOrgTreeCmd(open dbOpener) *cobra.Command {
|
||||
var people bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "tree",
|
||||
Short: "Show the whole tree, with how many accounts and keys each project has",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
tree, err := s.Tree(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var byProject map[string][]store.User
|
||||
if people {
|
||||
users, err := s.Users(cmd.Context(), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byProject = map[string][]store.User{}
|
||||
for _, u := range users {
|
||||
byProject[u.ProjectID] = append(byProject[u.ProjectID], u)
|
||||
}
|
||||
}
|
||||
printTree(cmd.OutOrStdout(), tree, byProject)
|
||||
if people && len(byProject[""]) > 0 {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\nwaiting for approval\n")
|
||||
for _, u := range byProject[""] {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " %s %s %s\n", u.Email, u.State, u.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&people, "people", false, "list the accounts in each project")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printTree(w io.Writer, tree []store.TreeOrg, people map[string][]store.User) {
|
||||
if len(tree) == 0 {
|
||||
fmt.Fprintln(w, "no organizations yet; `llmgw org add org <name>` starts one")
|
||||
return
|
||||
}
|
||||
for _, o := range tree {
|
||||
fmt.Fprintf(w, "%s (%s)\n", o.Name, o.ID)
|
||||
if len(o.Teams) == 0 {
|
||||
fmt.Fprintln(w, " no teams")
|
||||
}
|
||||
for _, t := range o.Teams {
|
||||
fmt.Fprintf(w, " %s (%s)\n", t.Name, t.ID)
|
||||
if len(t.Projects) == 0 {
|
||||
fmt.Fprintln(w, " no projects")
|
||||
}
|
||||
for _, p := range t.Projects {
|
||||
fmt.Fprintf(w, " %s (%s) %s, %s\n", p.Name, p.ID,
|
||||
countOf(p.Users, "account"), keysOf(p))
|
||||
for _, u := range people[p.ID] {
|
||||
admin := ""
|
||||
if u.IsAdmin {
|
||||
admin = " admin"
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s%s\n", u.Email, u.State, admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(p store.TreeProject) string {
|
||||
s := countOf(p.ActiveKeys, "key")
|
||||
if revoked := p.Keys - p.ActiveKeys; revoked > 0 {
|
||||
s += fmt.Sprintf(" (+%d revoked)", revoked)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func countOf(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, noun)
|
||||
}
|
||||
|
||||
func levelArg(arg string) (string, error) {
|
||||
switch arg {
|
||||
case "org", "organization":
|
||||
return "org", nil
|
||||
case "team", "project":
|
||||
return arg, nil
|
||||
}
|
||||
return "", fmt.Errorf("%q is not org, team or project", arg)
|
||||
}
|
||||
|
||||
func newOrgAddCmd(open dbOpener) *cobra.Command {
|
||||
var in string
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <org|team|project> <name>",
|
||||
Short: "Add an organization, a team to one, or a project to a team",
|
||||
Example: " llmgw org add org Acme\n" +
|
||||
" llmgw org add team platform --in Acme\n" +
|
||||
" llmgw org add project gateway --in Acme/platform",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
level, err := levelArg(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
ctx := cmd.Context()
|
||||
var id, where string
|
||||
switch level {
|
||||
case "org":
|
||||
id, err = s.CreateOrg(ctx, args[1])
|
||||
case "team":
|
||||
var o store.Org
|
||||
if o, err = resolveOrg(ctx, s, in); err == nil {
|
||||
id, err = s.CreateTeam(ctx, o.ID, args[1])
|
||||
where = " in " + o.Name
|
||||
}
|
||||
case "project":
|
||||
var t store.TeamPath
|
||||
if t, err = resolveTeam(ctx, s, in); err == nil {
|
||||
id, err = s.CreateProject(ctx, t.ID, args[1])
|
||||
where = " in " + t.OrgName + " / " + t.Name
|
||||
}
|
||||
}
|
||||
if errors.Is(err, store.ErrDuplicateName) {
|
||||
return fmt.Errorf("there is already a %s named %q there", level, args[1])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s %s%s (%s)\n", level, args[1], where, id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&in, "in", "", "the organization a team goes in, or the team a project goes in")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newOrgRenameCmd(open dbOpener) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rename <org|team|project> <which> <new name>",
|
||||
Short: "Rename an organization, team or project; ids and everything under it stay",
|
||||
Example: " llmgw org rename project Acme/platform/gateway api-gateway",
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
level, err := levelArg(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
id, err := resolveLevel(cmd.Context(), s, level, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch level {
|
||||
case "org":
|
||||
err = s.RenameOrg(cmd.Context(), id, args[2])
|
||||
case "team":
|
||||
err = s.RenameTeam(cmd.Context(), id, args[2])
|
||||
case "project":
|
||||
err = s.RenameProject(cmd.Context(), id, args[2])
|
||||
}
|
||||
if errors.Is(err, store.ErrDuplicateName) {
|
||||
return fmt.Errorf("there is already a %s named %q beside it", level, args[2])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s %s is now %q\n", level, id, args[2])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newOrgMoveCmd(open dbOpener) *cobra.Command {
|
||||
var to string
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <team|project> <which> --to <parent>",
|
||||
Short: "Put a team in another organization, or a project in another team",
|
||||
Long: "Everything under it moves too: projects, accounts and keys. From then on it\n" +
|
||||
"inherits the new parent's limits. Usage already recorded is not rewritten.",
|
||||
Example: " llmgw org move project Acme/platform/gateway --to Scania/ml\n" +
|
||||
" llmgw org move team platform --to Scania",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
ctx := cmd.Context()
|
||||
switch args[0] {
|
||||
case "team":
|
||||
t, err := resolveTeam(ctx, s, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o, err := resolveOrg(ctx, s, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.MoveTeam(ctx, t.ID, o.ID); err != nil {
|
||||
return dupAs(err, "team", t.Name)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "team %s is now in %s\n", t.Name, o.Name)
|
||||
case "project":
|
||||
p, err := resolveProject(ctx, s, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := resolveTeam(ctx, s, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.MoveProject(ctx, p.ID, t.ID); err != nil {
|
||||
return dupAs(err, "project", p.Path)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s / %s\n", p.Path, t.OrgName, t.Name)
|
||||
default:
|
||||
return fmt.Errorf("only a team or a project moves; to move an account, `llmgw users move`")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&to, "to", "", "the organization or team it goes to")
|
||||
_ = cmd.MarkFlagRequired("to")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func dupAs(err error, level, name string) error {
|
||||
if errors.Is(err, store.ErrDuplicateName) {
|
||||
return fmt.Errorf("the destination already has a %s named like %s", level, name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func newOrgRemoveCmd(open dbOpener) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <org|team|project> <which>",
|
||||
Short: "Remove an organization, team or project that nothing is in",
|
||||
Long: "Only something empty is removed: an organization with no teams, a team with\n" +
|
||||
"no projects, a project with no accounts and no keys. A project that ever had\n" +
|
||||
"a key keeps it, revoked, because usage history refers to it; rename such a\n" +
|
||||
"project instead. Its limits go with it.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
level, err := levelArg(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
id, err := resolveLevel(cmd.Context(), s, level, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch level {
|
||||
case "org":
|
||||
err = s.DeleteOrg(cmd.Context(), id)
|
||||
case "team":
|
||||
err = s.DeleteTeam(cmd.Context(), id)
|
||||
case "project":
|
||||
err = s.DeleteProject(cmd.Context(), id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s was not removed: %w", level, args[1], err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s %s removed\n", level, id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLevel(ctx context.Context, s *store.DB, level, arg string) (string, error) {
|
||||
switch level {
|
||||
case "org":
|
||||
o, err := resolveOrg(ctx, s, arg)
|
||||
return o.ID, err
|
||||
case "team":
|
||||
t, err := resolveTeam(ctx, s, arg)
|
||||
return t.ID, err
|
||||
default:
|
||||
p, err := resolveProject(ctx, s, arg)
|
||||
return p.ID, err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// run executes the CLI against one store file and returns stdout.
|
||||
func run(t *testing.T, db string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := Root()
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
// --config pointed nowhere, so a server.yaml on the test machine is never read.
|
||||
cmd.SetArgs(append(args, "--store", db, "--config", ""))
|
||||
err := cmd.ExecuteContext(context.Background())
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, db string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := run(t, db, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", args, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTheTreeIsManagedByNameNotId(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "s.db")
|
||||
mustRun(t, db, "org", "add", "org", "Acme")
|
||||
mustRun(t, db, "org", "add", "org", "Scania")
|
||||
mustRun(t, db, "org", "add", "team", "platform", "--in", "Acme")
|
||||
mustRun(t, db, "org", "add", "team", "platform", "--in", "Scania")
|
||||
|
||||
// "platform" is in both, so a project needs the path.
|
||||
_, err := run(t, db, "org", "add", "project", "gateway", "--in", "platform")
|
||||
if err == nil || !strings.Contains(err.Error(), "more than one team") {
|
||||
t.Fatalf("an ambiguous name was accepted: %v", err)
|
||||
}
|
||||
mustRun(t, db, "org", "add", "project", "gateway", "--in", "acme/Platform")
|
||||
mustRun(t, db, "org", "add", "project", "research", "--in", "Scania / platform")
|
||||
|
||||
mustRun(t, db, "users", "create", "ayse@example.com", "--password", "correct horse battery", "--project", "Acme/platform/gateway")
|
||||
mustRun(t, db, "keys", "create", "laptop", "--project", "gateway")
|
||||
|
||||
out := mustRun(t, db, "org", "tree", "--people")
|
||||
for _, want := range []string{"Acme", "gateway", "1 account, 1 key", "ayse@example.com"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("tree lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
out = mustRun(t, db, "users", "move", "ayse@example.com", "--project", "research")
|
||||
if !strings.Contains(out, "Scania / platform / research") {
|
||||
t.Errorf("move said: %s", out)
|
||||
}
|
||||
|
||||
mustRun(t, db, "org", "rename", "project", "research", "lab")
|
||||
mustRun(t, db, "org", "move", "project", "Acme/platform/gateway", "--to", "Scania/platform")
|
||||
|
||||
// gateway still has the operator's key, so it stays.
|
||||
if _, err := run(t, db, "org", "remove", "project", "gateway"); err == nil {
|
||||
t.Error("a project with a key was removed")
|
||||
}
|
||||
mustRun(t, db, "org", "remove", "team", "Acme/platform")
|
||||
mustRun(t, db, "org", "remove", "org", "Acme")
|
||||
|
||||
s, err := store.Open(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
paths, _ := s.AllProjects(context.Background())
|
||||
var got []string
|
||||
for _, p := range paths {
|
||||
got = append(got, p.Path)
|
||||
}
|
||||
if strings.Join(got, "|") != "Scania / platform / gateway|Scania / platform / lab" {
|
||||
t.Errorf("projects = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneProjectNeedsNoFlag(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "s.db")
|
||||
mustRun(t, db, "org", "add", "org", "Acme")
|
||||
mustRun(t, db, "org", "add", "team", "platform")
|
||||
mustRun(t, db, "org", "add", "project", "gateway")
|
||||
mustRun(t, db, "keys", "create", "laptop")
|
||||
if out := mustRun(t, db, "keys", "list"); !strings.Contains(out, "laptop") {
|
||||
t.Errorf("list: %s", out)
|
||||
}
|
||||
mustRun(t, db, "org", "add", "project", "search", "--in", "platform")
|
||||
if _, err := run(t, db, "keys", "create", "other"); err == nil ||
|
||||
!strings.Contains(err.Error(), "which project?") {
|
||||
t.Errorf("two projects and no flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBudgetsAreWholeUnits(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "s.db")
|
||||
mustRun(t, db, "org", "add", "org", "Acme")
|
||||
mustRun(t, db, "org", "add", "team", "platform")
|
||||
mustRun(t, db, "org", "add", "project", "gateway")
|
||||
mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--budget", "12.5")
|
||||
|
||||
s, err := store.Open(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
all, _ := s.AllLimits(context.Background())
|
||||
for _, l := range all {
|
||||
if l.Budget == nil || *l.Budget != 12_500_000 {
|
||||
t.Errorf("budget stored as %v micro-units", l.Budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// The tree can be built from the web, which mints ids like proj-3kq7..., or
|
||||
// from `keys init`, which makes slugs. A command that wanted the id made the
|
||||
// operator copy one off a page, and every --project defaulted to "default",
|
||||
// which a tree built from the web does not have. These take what a person
|
||||
// would type instead: an id, a path, or a name that is unique.
|
||||
|
||||
// normPath lowercases a path and spaces its separators the way the store joins
|
||||
// them, so "acme/platform/gateway" matches "Acme / platform / gateway".
|
||||
func normPath(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
for i := range parts {
|
||||
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
|
||||
}
|
||||
return strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
type candidate struct{ id, path string }
|
||||
|
||||
// pick finds the one candidate an argument names.
|
||||
func pick(kind, arg string, all []candidate) (candidate, error) {
|
||||
if arg == "" {
|
||||
if len(all) == 1 {
|
||||
return all[0], nil
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return candidate{}, fmt.Errorf("there is no %s yet; create one with `llmgw org add %s`", kind, kind)
|
||||
}
|
||||
return candidate{}, fmt.Errorf("which %s? give an id, a path or a name:\n%s", kind, listCandidates(all))
|
||||
}
|
||||
for _, c := range all {
|
||||
if c.id == arg {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
want := normPath(arg)
|
||||
var matches []candidate
|
||||
for _, c := range all {
|
||||
full := normPath(c.path)
|
||||
segs := strings.Split(full, " / ")
|
||||
if full == want || segs[len(segs)-1] == want {
|
||||
matches = append(matches, c)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
case 0:
|
||||
return candidate{}, fmt.Errorf("no %s %q; there is:\n%s", kind, arg, listCandidates(all))
|
||||
default:
|
||||
return candidate{}, fmt.Errorf("%q names more than one %s; give its path:\n%s",
|
||||
arg, kind, listCandidates(matches))
|
||||
}
|
||||
}
|
||||
|
||||
func listCandidates(cs []candidate) string {
|
||||
var b strings.Builder
|
||||
for _, c := range cs {
|
||||
fmt.Fprintf(&b, " %-40s %s\n", c.path, c.id)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func resolveProject(ctx context.Context, s *store.DB, arg string) (store.ProjectPath, error) {
|
||||
all, err := s.AllProjects(ctx)
|
||||
if err != nil {
|
||||
return store.ProjectPath{}, err
|
||||
}
|
||||
cs := make([]candidate, len(all))
|
||||
for i, p := range all {
|
||||
cs[i] = candidate{p.ID, p.Path}
|
||||
}
|
||||
c, err := pick("project", arg, cs)
|
||||
return store.ProjectPath{ID: c.id, Path: c.path}, err
|
||||
}
|
||||
|
||||
func resolveTeam(ctx context.Context, s *store.DB, arg string) (store.TeamPath, error) {
|
||||
all, err := s.AllTeams(ctx)
|
||||
if err != nil {
|
||||
return store.TeamPath{}, err
|
||||
}
|
||||
cs := make([]candidate, len(all))
|
||||
for i, t := range all {
|
||||
cs[i] = candidate{t.ID, t.OrgName + " / " + t.Name}
|
||||
}
|
||||
c, err := pick("team", arg, cs)
|
||||
if err != nil {
|
||||
return store.TeamPath{}, err
|
||||
}
|
||||
for _, t := range all {
|
||||
if t.ID == c.id {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return store.TeamPath{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func resolveOrg(ctx context.Context, s *store.DB, arg string) (store.Org, error) {
|
||||
all, err := s.AllOrgs(ctx)
|
||||
if err != nil {
|
||||
return store.Org{}, err
|
||||
}
|
||||
cs := make([]candidate, len(all))
|
||||
for i, o := range all {
|
||||
cs[i] = candidate{o.ID, o.Name}
|
||||
}
|
||||
c, err := pick("org", arg, cs)
|
||||
return store.Org{ID: c.id, Name: c.path}, err
|
||||
}
|
||||
|
||||
// resolveUser takes an account's id or its email.
|
||||
func resolveUser(ctx context.Context, s *store.DB, arg string) (store.User, error) {
|
||||
if u, err := s.UserByID(ctx, arg); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
u, err := s.UserByEmail(ctx, arg)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return u, fmt.Errorf("no account with id or email %q; `llmgw users list` shows them", arg)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func Root() *cobra.Command {
|
||||
newCtlCmd(),
|
||||
newTenancyCmd(),
|
||||
newUsersCmd(),
|
||||
newOrgCmd(),
|
||||
newMirrorCmd(),
|
||||
newSimEngineCmd(),
|
||||
)
|
||||
|
||||
@@ -66,6 +66,13 @@ type serverInit struct {
|
||||
user string
|
||||
hosts []string
|
||||
admin string
|
||||
// postgres keeps the store in a PostgreSQL on this machine, installed if
|
||||
// need be, moving whatever SQLite already holds.
|
||||
postgres bool
|
||||
// pgSetup prepares PostgreSQL and returns the store address; a seam for
|
||||
// tests. Nil does it for real.
|
||||
pgSetup func(context.Context) (string, error)
|
||||
pg pgServer
|
||||
|
||||
out io.Writer
|
||||
|
||||
@@ -88,6 +95,10 @@ func newServerInitCmd() *cobra.Command {
|
||||
"control plane's certificate for every address this machine has, a\n" +
|
||||
"configuration, an administrator account, and the systemd unit — then\n" +
|
||||
"starts it and waits until it answers.\n\n" +
|
||||
"With --postgres the store is a PostgreSQL database on this machine:\n" +
|
||||
"installed if there is none, with its own role and a generated password\n" +
|
||||
"kept in /etc/llmgw/secrets. An existing SQLite store is copied into it,\n" +
|
||||
"so accounts and keys survive, and server.yaml is pointed at it.\n\n" +
|
||||
"Each step is skipped when its result already exists, so it is safe to run\n" +
|
||||
"again: to finish an install that stopped, or with another --host to\n" +
|
||||
"reissue the certificate. It never overwrites server.yaml or the authority.\n\n" +
|
||||
@@ -110,6 +121,8 @@ func newServerInitCmd() *cobra.Command {
|
||||
"an address agents dial besides the ones found on this machine; repeatable")
|
||||
f.StringVar(&in.admin, "admin", "", "email of the first administrator; a password is generated and printed once")
|
||||
f.StringVar(&in.pkiDir, "pki", in.pkiDir, "directory holding the certificate authority")
|
||||
f.BoolVar(&in.postgres, "postgres", false,
|
||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -122,6 +135,7 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
{"directories", in.ensureDirs},
|
||||
{"certificates", in.ensureCerts},
|
||||
{"configuration", in.ensureConfig},
|
||||
{"database", in.ensureDatabase},
|
||||
{"store", in.ensureStore},
|
||||
}
|
||||
for _, s := range steps {
|
||||
@@ -351,7 +365,7 @@ func (in *serverInit) ensureConfig(context.Context) error {
|
||||
contact = "platform@example.com"
|
||||
}
|
||||
body := fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
|
||||
filepath.Join(in.stateDir, "llmgw.db"), contact)
|
||||
in.storeAddress(), contact)
|
||||
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -393,7 +407,7 @@ control:
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
store: %s
|
||||
store: "%s" # a SQLite path, or @file holding a PostgreSQL address
|
||||
signup_open: true
|
||||
# Applied to any key no scope gives a limit to. Without them, a project
|
||||
# created without limits is metered by nothing at all.
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// pgRole and pgDatabase are what init creates in a PostgreSQL it manages.
|
||||
const (
|
||||
pgRole = "llmgw"
|
||||
pgDatabase = "llmgw"
|
||||
)
|
||||
|
||||
// dsnPath is where the PostgreSQL address is kept, out of server.yaml, so the
|
||||
// password in it is not in a file that gets copied, diffed and pasted.
|
||||
func (in *serverInit) dsnPath() string { return filepath.Join(in.etcDir, "secrets", "store-dsn") }
|
||||
|
||||
// storeAddress is what auth.store says for the store init manages.
|
||||
func (in *serverInit) storeAddress() string {
|
||||
if in.postgres {
|
||||
return "@" + in.dsnPath()
|
||||
}
|
||||
return filepath.Join(in.stateDir, "llmgw.db")
|
||||
}
|
||||
|
||||
// ensureDatabase puts the store in PostgreSQL when asked to, moving whatever
|
||||
// the SQLite store already holds.
|
||||
//
|
||||
// The move is the point. Keys are stored hashed, so starting PostgreSQL empty
|
||||
// means reissuing every key and being unable to say which was which; and the
|
||||
// administrator init created a minute ago would be gone too.
|
||||
func (in *serverInit) ensureDatabase(ctx context.Context) (err error) {
|
||||
if !in.postgres {
|
||||
return nil
|
||||
}
|
||||
setup := in.pgSetup
|
||||
if setup == nil {
|
||||
setup = in.setupPostgres
|
||||
}
|
||||
target, err := setup(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := cfg.Auth.Store
|
||||
if current == target {
|
||||
in.kept("store in PostgreSQL (%s)", in.dsnPath())
|
||||
return nil
|
||||
}
|
||||
if isPostgresAddress(current) {
|
||||
// Two PostgreSQL stores, and a guess about which is meant would lose
|
||||
// one of them.
|
||||
return fmt.Errorf("server.yaml already names a PostgreSQL store (%s); "+
|
||||
"init moves SQLite into PostgreSQL, not one PostgreSQL into another", current)
|
||||
}
|
||||
|
||||
// Nothing may write the store while it is copied: a row landing after its
|
||||
// table has been passed would be absent, and nothing would say so.
|
||||
stopped, err := in.stopService(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
// A move that failed leaves the configuration naming SQLite, which
|
||||
// still holds everything: start the service on it again rather than
|
||||
// leave the gateway down over a database that did not work out.
|
||||
if err != nil && stopped {
|
||||
_ = exec.Command("systemctl", "start", "llmgw-server").Run()
|
||||
fmt.Fprintf(in.out, " .. llmgw-server started again on its SQLite store\n")
|
||||
}
|
||||
}()
|
||||
|
||||
if current != "" {
|
||||
if _, err := os.Stat(current); err == nil {
|
||||
if err := in.copyStore(ctx, current, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := setAuthStore(in.configPath(), target); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("server.yaml now keeps the store in PostgreSQL")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *serverInit) copyStore(ctx context.Context, from, to string) error {
|
||||
src, err := store.Open(ctx, from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", from, err)
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := store.Open(ctx, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening PostgreSQL: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
empty, err := dst.Empty(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !empty {
|
||||
// A previous run that copied and then stopped before changing the
|
||||
// configuration. The service was stopped for that copy too, so what
|
||||
// is there is what SQLite held.
|
||||
in.kept("PostgreSQL already holds a store; %s is left as it is", from)
|
||||
return nil
|
||||
}
|
||||
res, err := src.CopyTo(ctx, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copying %s into PostgreSQL: %w", from, err)
|
||||
}
|
||||
var rows int64
|
||||
for _, r := range res {
|
||||
rows += r.Rows
|
||||
}
|
||||
in.done("copied %d rows from %s into PostgreSQL (the SQLite file is kept)", rows, from)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPostgresAddress(a string) bool {
|
||||
return strings.HasPrefix(a, "@") || strings.HasPrefix(a, "postgres://") ||
|
||||
strings.HasPrefix(a, "postgresql://")
|
||||
}
|
||||
|
||||
// setAuthStore changes auth.store in server.yaml and nothing else.
|
||||
//
|
||||
// Edited as text rather than unmarshalled and written back, because writing
|
||||
// it back would drop every comment in a file people annotate. The old file is
|
||||
// kept beside it.
|
||||
func setAuthStore(path, address string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
value := fmt.Sprintf("%q", address)
|
||||
|
||||
authAt, storeAt, indent := -1, -1, " "
|
||||
for i, l := range lines {
|
||||
trimmed := strings.TrimSpace(l)
|
||||
top := l != "" && l[0] != ' ' && l[0] != '\t' && !strings.HasPrefix(trimmed, "#")
|
||||
if top {
|
||||
if authAt >= 0 && i > authAt {
|
||||
break // past the auth block
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "auth:") {
|
||||
authAt = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
if authAt >= 0 && strings.HasPrefix(trimmed, "store:") {
|
||||
storeAt = i
|
||||
indent = l[:len(l)-len(strings.TrimLeft(l, " \t"))]
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case storeAt >= 0:
|
||||
lines[storeAt] = indent + "store: " + value
|
||||
case authAt >= 0:
|
||||
lines = append(lines[:authAt+1], append([]string{indent + "store: " + value}, lines[authAt+1:]...)...)
|
||||
default:
|
||||
lines = append(lines, "auth:", " enabled: true", " store: "+value)
|
||||
}
|
||||
|
||||
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405"))
|
||||
if err := os.WriteFile(backup, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm())
|
||||
}
|
||||
|
||||
// pgServer is the PostgreSQL installation on this machine, as systemd and the
|
||||
// file system describe it.
|
||||
//
|
||||
// Found rather than assumed. A distribution's own package calls its unit
|
||||
// postgresql.service and puts initdb and psql on PATH; the PostgreSQL
|
||||
// project's packages (PGDG) call it postgresql-16.service and keep them in
|
||||
// /usr/pgsql-16/bin — and they provide "postgresql-server" too, so asking dnf
|
||||
// for that name can install or find one of them. Assuming the first found a
|
||||
// Rocky machine with the second unable to initialise the database it had.
|
||||
type pgServer struct {
|
||||
unit string // postgresql.service, postgresql-16.service
|
||||
pgdata string // "" when the unit does not say, which is Debian's case
|
||||
bin string // directory holding initdb and psql; "" means PATH
|
||||
}
|
||||
|
||||
func (p pgServer) tool(name string) string {
|
||||
if p.bin != "" {
|
||||
return filepath.Join(p.bin, name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// pickPostgresUnit chooses the server unit from systemctl's list of
|
||||
// postgresql* unit files: the plain one when there is one, else the highest
|
||||
// version. Templates (postgresql@.service) are not a server by themselves.
|
||||
func pickPostgresUnit(list string) string {
|
||||
best, bestVersion := "", -1
|
||||
for _, line := range strings.Split(list, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
unit := f[0]
|
||||
if !strings.HasSuffix(unit, ".service") || strings.Contains(unit, "@") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(unit, ".service")
|
||||
if name == "postgresql" {
|
||||
return unit
|
||||
}
|
||||
v := strings.TrimPrefix(name, "postgresql-")
|
||||
if n, err := strconv.Atoi(strings.SplitN(v, ".", 2)[0]); err == nil && n > bestVersion {
|
||||
best, bestVersion = unit, n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// pgdataFrom reads PGDATA out of `systemctl show -p Environment --value`.
|
||||
func pgdataFrom(env string) string {
|
||||
for _, kv := range strings.Fields(env) {
|
||||
if v, ok := strings.CutPrefix(kv, "PGDATA="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findPostgres(ctx context.Context) (pgServer, bool) {
|
||||
out, _ := exec.CommandContext(ctx, "systemctl", "list-unit-files", "--type=service",
|
||||
"--no-legend", "--plain", "postgresql*").Output()
|
||||
unit := pickPostgresUnit(string(out))
|
||||
if unit == "" {
|
||||
return pgServer{}, false
|
||||
}
|
||||
p := pgServer{unit: unit}
|
||||
env, _ := exec.CommandContext(ctx, "systemctl", "show", "-p", "Environment", "--value", unit).Output()
|
||||
p.pgdata = pgdataFrom(string(env))
|
||||
|
||||
if v, ok := strings.CutPrefix(strings.TrimSuffix(unit, ".service"), "postgresql-"); ok {
|
||||
if dir := "/usr/pgsql-" + v + "/bin"; isDir(dir) {
|
||||
p.bin = dir
|
||||
}
|
||||
if p.pgdata == "" && isDir("/var/lib/pgsql/"+v) {
|
||||
p.pgdata = "/var/lib/pgsql/" + v + "/data"
|
||||
}
|
||||
}
|
||||
if p.bin == "" {
|
||||
if _, err := exec.LookPath("psql"); err != nil {
|
||||
// Debian keeps the server's tools per version and puts only
|
||||
// wrappers on PATH; without even those, look where it keeps them.
|
||||
if dirs, _ := filepath.Glob("/usr/lib/postgresql/*/bin"); len(dirs) > 0 {
|
||||
p.bin = dirs[len(dirs)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
|
||||
// setupPostgres makes sure a PostgreSQL on this machine holds a database for
|
||||
// llmgw, installing the server if there is none, and returns the store
|
||||
// address.
|
||||
func (in *serverInit) setupPostgres(ctx context.Context) (string, error) {
|
||||
installed, err := in.installPostgres(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := in.waitForPostgres(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
exists, err := in.psqlBool(ctx, "SELECT 1 FROM pg_roles WHERE rolname = '"+pgRole+"'")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, statErr := os.Stat(in.dsnPath())
|
||||
if exists && statErr == nil {
|
||||
in.kept("PostgreSQL role %s", pgRole)
|
||||
} else {
|
||||
// A role with no address written down is one whose password nobody
|
||||
// knows; giving it a new one is the only way to use it.
|
||||
password := make([]byte, 24)
|
||||
_, _ = rand.Read(password)
|
||||
pass := hex.EncodeToString(password)
|
||||
verb := "CREATE ROLE"
|
||||
if exists {
|
||||
verb = "ALTER ROLE"
|
||||
}
|
||||
if _, err := in.psql(ctx, verb+" "+pgRole+" WITH LOGIN PASSWORD '"+pass+"'"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@127.0.0.1:5432/%s?sslmode=disable", pgRole, pass, pgDatabase)
|
||||
if err := in.writeDSN(dsn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL role %s, its address in %s", pgRole, in.dsnPath())
|
||||
}
|
||||
|
||||
exists, err = in.psqlBool(ctx, "SELECT 1 FROM pg_database WHERE datname = '"+pgDatabase+"'")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists {
|
||||
in.kept("PostgreSQL database %s", pgDatabase)
|
||||
} else {
|
||||
if _, err := in.psql(ctx, "CREATE DATABASE "+pgDatabase+" OWNER "+pgRole); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL database %s", pgDatabase)
|
||||
}
|
||||
|
||||
if installed {
|
||||
// Its defaults assume it owns the machine. A gateway swapped out to
|
||||
// give the database cache is worse than a smaller cache, and a store
|
||||
// whose working set is megabytes needs little. Only a PostgreSQL init
|
||||
// installed itself: one that was already here may serve something
|
||||
// else that needs its connections.
|
||||
for _, q := range []string{
|
||||
"ALTER SYSTEM SET shared_buffers = '96MB'",
|
||||
"ALTER SYSTEM SET max_connections = 40",
|
||||
} {
|
||||
if _, err := in.psql(ctx, q); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "systemctl", "restart", in.pg.unit).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("restarting PostgreSQL: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err := in.waitForPostgres(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL sized for a machine it shares (shared_buffers 96MB, 40 connections)")
|
||||
}
|
||||
|
||||
// Proof the address works, before anything is moved into it.
|
||||
address := "@" + in.dsnPath()
|
||||
s, err := store.Open(ctx, address)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting with the address in %s: %w (PostgreSQL's pg_hba.conf "+
|
||||
"must allow password logins from 127.0.0.1: scram-sha-256 or md5, not ident)", in.dsnPath(), err)
|
||||
}
|
||||
_ = s.Close()
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (in *serverInit) writeDSN(dsn string) error {
|
||||
dir := filepath.Dir(in.dsnPath())
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.chown(dir, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(in.dsnPath(), []byte(dsn+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Read by the service, which runs as llmgw; root reads it anyway.
|
||||
return in.chown(in.dsnPath(), true)
|
||||
}
|
||||
|
||||
// installPostgres installs the server when this machine has none, makes sure
|
||||
// its database is initialised and running, and says whether it installed it.
|
||||
func (in *serverInit) installPostgres(ctx context.Context) (bool, error) {
|
||||
host := discovery.DiscoverHost()
|
||||
pg, found := findPostgres(ctx)
|
||||
installed := false
|
||||
if found {
|
||||
in.kept("PostgreSQL server (%s)", pg.unit)
|
||||
} else {
|
||||
if host.Family() == "" {
|
||||
return false, fmt.Errorf("no PostgreSQL here and no package manager init knows (%s); "+
|
||||
"install PostgreSQL and run init again", host.OSID)
|
||||
}
|
||||
fmt.Fprintf(in.out, " .. installing PostgreSQL (a minute or two)\n")
|
||||
pi := &provision.Installer{Host: host, Log: slog.Default()}
|
||||
if err := pi.InstallPackages(ctx, map[string][]string{
|
||||
"deb": {"postgresql"},
|
||||
"rpm": {"postgresql-server", "postgresql"},
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if pg, found = findPostgres(ctx); !found {
|
||||
q := "rpm -qa 'postgresql*'"
|
||||
if host.Family() == "deb" {
|
||||
q = "dpkg -l 'postgresql*'"
|
||||
}
|
||||
have, _ := exec.CommandContext(ctx, "sh", "-c", q).CombinedOutput()
|
||||
return false, fmt.Errorf("PostgreSQL was installed but no postgresql service unit "+
|
||||
"appeared; what is installed:\n%s", strings.TrimSpace(string(have)))
|
||||
}
|
||||
installed = true
|
||||
in.done("PostgreSQL installed (%s)", pg.unit)
|
||||
}
|
||||
in.pg = pg
|
||||
|
||||
// Debian's package creates and starts a cluster itself. The RHEL family's,
|
||||
// and PGDG's, leave the data directory empty until someone runs initdb.
|
||||
if pg.pgdata != "" {
|
||||
if _, err := os.Stat(filepath.Join(pg.pgdata, "PG_VERSION")); err != nil {
|
||||
// Directly rather than through postgresql-setup, which is named
|
||||
// differently by every packager. Password logins over TCP are
|
||||
// asked for: RHEL's default for them is ident, which no service
|
||||
// account passes.
|
||||
if err := os.MkdirAll(pg.pgdata, 0o700); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "chown", "postgres:postgres", pg.pgdata).CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("chown %s: %v: %s", pg.pgdata, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "runuser", "-u", "postgres", "--", pg.tool("initdb"),
|
||||
"-D", pg.pgdata, "--auth-host=scram-sha-256", "--auth-local=peer")
|
||||
cmd.Dir = "/"
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("initdb %s: %v: %s", pg.pgdata, err, lastLines(out, 5))
|
||||
}
|
||||
in.done("PostgreSQL database cluster initialised in %s", pg.pgdata)
|
||||
}
|
||||
}
|
||||
if exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", pg.unit).Run() != nil {
|
||||
if out, err := exec.CommandContext(ctx, "systemctl", "enable", "--now", pg.unit).CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("starting %s: %v: %s", pg.unit, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
in.done("%s started", pg.unit)
|
||||
}
|
||||
return installed, nil
|
||||
}
|
||||
|
||||
func lastLines(b []byte, n int) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// psql runs one statement as the postgres superuser, over the local socket.
|
||||
func (in *serverInit) psql(ctx context.Context, query string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "runuser", "-u", "postgres", "--",
|
||||
in.pg.tool("psql"), "-v", "ON_ERROR_STOP=1", "-qAtX", "-c", query)
|
||||
// postgres cannot enter root's home, and psql says so on every call.
|
||||
cmd.Dir = "/"
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// The statement can hold a password; the error names only its verb.
|
||||
verb := strings.Join(strings.Fields(query)[:2], " ")
|
||||
return "", fmt.Errorf("psql (%s): %v: %s", verb, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func (in *serverInit) psqlBool(ctx context.Context, query string) (bool, error) {
|
||||
out, err := in.psql(ctx, query)
|
||||
return out == "1", err
|
||||
}
|
||||
|
||||
// waitForPostgres waits for a server that was just started or restarted to
|
||||
// take connections.
|
||||
func (in *serverInit) waitForPostgres(ctx context.Context) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for {
|
||||
_, err := in.psql(ctx, "SELECT 1")
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("PostgreSQL is not taking connections after 60s: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stopService stops the control plane if it runs, so the store is still.
|
||||
// It says whether it stopped it, so a failed move can start it again.
|
||||
func (in *serverInit) stopService(ctx context.Context) (bool, error) {
|
||||
if in.unitPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", "llmgw-server").Run() != nil {
|
||||
return false, nil
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "systemctl", "stop", "llmgw-server").CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("stopping llmgw-server to move its store: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
in.done("llmgw-server stopped while its store moves")
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestSetAuthStoreChangesOnlyAuthStore(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "server.yaml")
|
||||
body := `# my notes
|
||||
threads:
|
||||
store: /var/lib/llmgw/threads.db # not this one
|
||||
retain_hours: 24
|
||||
auth:
|
||||
enabled: true
|
||||
# the store
|
||||
store: /var/lib/llmgw/llmgw.db
|
||||
signup_open: true
|
||||
landing:
|
||||
contact: "x"
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(body), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setAuthStore(p, "@/etc/llmgw/secrets/store-dsn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(p)
|
||||
want := strings.Replace(body, " store: /var/lib/llmgw/llmgw.db",
|
||||
` store: "@/etc/llmgw/secrets/store-dsn"`, 1)
|
||||
if string(got) != want {
|
||||
t.Errorf("got:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.Store != "@/etc/llmgw/secrets/store-dsn" || cfg.Threads.Store != "/var/lib/llmgw/threads.db" {
|
||||
t.Errorf("auth.store %q, threads.store %q", cfg.Auth.Store, cfg.Threads.Store)
|
||||
}
|
||||
backups, _ := filepath.Glob(p + ".bak-*")
|
||||
if len(backups) != 1 {
|
||||
t.Errorf("the old file should be kept beside it, found %v", backups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAuthStoreAddsTheLineWhenAuthHasNone(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "server.yaml")
|
||||
if err := os.WriteFile(p, []byte("auth:\n enabled: true\nlanding:\n contact: x\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setAuthStore(p, "@/x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.Store != "@/x" || !cfg.Auth.Enabled {
|
||||
t.Errorf("auth = %+v", cfg.Auth)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePostgres stands in for setupPostgres the way it really answers: an
|
||||
// address file under /etc/llmgw/secrets, returned as "@file". What the file
|
||||
// holds is a SQLite path, so everything after the setup runs for real.
|
||||
func fakePostgres(t *testing.T, in *serverInit) (address, db string) {
|
||||
t.Helper()
|
||||
db = filepath.Join(t.TempDir(), "pg-standin.db")
|
||||
in.pgSetup = func(context.Context) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(in.dsnPath()), 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "@" + in.dsnPath(), os.WriteFile(in.dsnPath(), []byte(db+"\n"), 0o600)
|
||||
}
|
||||
return "@" + in.dsnPath(), db
|
||||
}
|
||||
|
||||
// Moving an install that began on SQLite: the administrator it created must
|
||||
// be in the new store, the configuration must name it, and the SQLite file
|
||||
// must still be there. The new store is SQLite too here — what PostgreSQL
|
||||
// adds is the setup, which needs a machine to run on.
|
||||
func TestInitMovesAnExistingStore(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
old := filepath.Join(in.stateDir, "llmgw.db")
|
||||
|
||||
in.postgres = true
|
||||
target, db := fakePostgres(t, in)
|
||||
out.Reset()
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out.String(), "copied") {
|
||||
t.Errorf("nothing was copied:\n%s", out)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.Store != target {
|
||||
t.Fatalf("server.yaml still names %q", cfg.Auth.Store)
|
||||
}
|
||||
s, err := store.Open(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := s.UserByEmail(context.Background(), "admin@example.com")
|
||||
s.Close()
|
||||
if err != nil || !u.IsAdmin {
|
||||
t.Fatalf("the administrator did not move: %v %+v", err, u)
|
||||
}
|
||||
if _, err := os.Stat(old); err != nil {
|
||||
t.Error("the SQLite store should be kept")
|
||||
}
|
||||
|
||||
// And once moved, again is nothing.
|
||||
in.changed = false
|
||||
out.Reset()
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if in.changed {
|
||||
t.Errorf("a run after the move changed something:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWritesAPostgresConfigurationFromTheStart(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
in.postgres = true
|
||||
target, _ := fakePostgres(t, in)
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
cfg, _ := config.Load(in.configPath())
|
||||
if cfg.Auth.Store != target {
|
||||
t.Errorf("auth.store = %q", cfg.Auth.Store)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(in.stateDir, "llmgw.db")); err == nil {
|
||||
t.Error("a PostgreSQL install should not leave a SQLite store behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWillNotMoveOnePostgresIntoAnother(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := "control:\n enabled: true\nauth:\n enabled: true\n store: postgres://elsewhere/llmgw\n"
|
||||
if err := os.WriteFile(in.configPath(), []byte(cfg), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.postgres = true
|
||||
in.pgSetup = func(context.Context) (string, error) { return "@/somewhere/else", nil }
|
||||
err := in.run(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "already names a PostgreSQL store") {
|
||||
t.Fatalf("expected a refusal, got %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The lists below are what `systemctl list-unit-files --type=service
|
||||
// --no-legend --plain 'postgresql*'` prints on each kind of machine.
|
||||
func TestPickPostgresUnit(t *testing.T) {
|
||||
for _, c := range []struct{ name, list, want string }{
|
||||
{"rocky, distribution package",
|
||||
"postgresql.service disabled disabled\npostgresql@.service disabled disabled\n", "postgresql.service"},
|
||||
{"rocky, PGDG package",
|
||||
"postgresql-16.service disabled disabled\n", "postgresql-16.service"},
|
||||
{"two PGDG versions",
|
||||
"postgresql-13.service enabled disabled\npostgresql-16.service disabled disabled\n", "postgresql-16.service"},
|
||||
{"debian",
|
||||
"postgresql.service enabled enabled\npostgresql@.service indirect enabled\n", "postgresql.service"},
|
||||
{"only the template", "postgresql@.service indirect enabled\n", ""},
|
||||
{"none", "", ""},
|
||||
} {
|
||||
if got := pickPostgresUnit(c.list); got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What `systemctl show -p Environment --value` prints for each unit, from
|
||||
// the unit files the packages ship.
|
||||
func TestPGDATAFromTheUnit(t *testing.T) {
|
||||
for env, want := range map[string]string{
|
||||
"PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj PG_OOM_ADJUST_VALUE=0 PGDATA=/var/lib/pgsql/data": "/var/lib/pgsql/data",
|
||||
"PGDATA=/var/lib/pgsql/16/data/ PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj": "/var/lib/pgsql/16/data/",
|
||||
"": "",
|
||||
} {
|
||||
if got := pgdataFrom(env); got != want {
|
||||
t.Errorf("%q: got %q, want %q", env, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,12 @@ func newThreadsCmd(open opener) *cobra.Command {
|
||||
if off {
|
||||
keep = 0
|
||||
}
|
||||
if err := s.SetThreadRetention(cmd.Context(), project, keep); err != nil {
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project = p.Path
|
||||
if err := s.SetThreadRetention(cmd.Context(), p.ID, keep); err != nil {
|
||||
return err
|
||||
}
|
||||
if keep == 0 {
|
||||
@@ -63,7 +68,7 @@ func newThreadsCmd(open opener) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "default", "project id")
|
||||
cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name")
|
||||
cmd.Flags().IntVar(&hours, "retain-hours", 0, "how long a conversation is kept after its last turn")
|
||||
cmd.Flags().BoolVar(&off, "off", false, "stop keeping conversations for this project")
|
||||
return cmd
|
||||
|
||||
+113
-23
@@ -33,6 +33,7 @@ func newUsersCmd() *cobra.Command {
|
||||
newUserStateCmd(open, "reinstate", store.UserActive,
|
||||
"Let a suspended account work again"),
|
||||
newUserCreateCmd(open), newUserResetPasswordCmd(open),
|
||||
newUserMoveCmd(open), newUserDeleteCmd(open),
|
||||
newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"),
|
||||
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"))
|
||||
return cmd
|
||||
@@ -88,7 +89,7 @@ func newUserListCmd(open userOpener) *cobra.Command {
|
||||
func newUserApproveCmd(open userOpener) *cobra.Command {
|
||||
var project, by string
|
||||
cmd := &cobra.Command{
|
||||
Use: "approve <user-id>",
|
||||
Use: "approve <user>",
|
||||
Short: "Let an account sign in and issue its own keys",
|
||||
Long: "The project is required: it decides which limits this person's keys\n" +
|
||||
"inherit. An account approved into no project would pass every check and\n" +
|
||||
@@ -101,27 +102,31 @@ func newUserApproveCmd(open userOpener) *cobra.Command {
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := s.ApproveUser(cmd.Context(), args[0], project, by, time.Now()); err != nil {
|
||||
return fmt.Errorf("approving %s: %w", args[0], err)
|
||||
}
|
||||
u, err := s.UserByID(cmd.Context(), args[0])
|
||||
u, err := resolveUser(cmd.Context(), s, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s approved into project %s\n", u.Email, project)
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ApproveUser(cmd.Context(), u.ID, p.ID, by, time.Now()); err != nil {
|
||||
return fmt.Errorf("approving %s: %w", u.Email, err)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s approved into %s\n", u.Email, p.Path)
|
||||
cmd.PrintErrln("They can sign in and create their own keys from /profile.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to")
|
||||
cmd.Flags().StringVar(&project, "project", "",
|
||||
"project the person's keys belong to: id, path (Acme/platform/gateway) or unique name")
|
||||
cmd.Flags().StringVar(&by, "by", "", "who approved, for the record")
|
||||
_ = cmd.MarkFlagRequired("project")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: verb + " <user-id>",
|
||||
Use: verb + " <user>",
|
||||
Short: short,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -131,18 +136,22 @@ func newUserStateCmd(open userOpener, verb string, state store.UserState, short
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := s.SetUserState(cmd.Context(), args[0], state); err != nil {
|
||||
u, err := resolveUser(cmd.Context(), s, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetUserState(cmd.Context(), u.ID, state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state == store.UserSuspended {
|
||||
// Otherwise the person stays signed in and suspension is
|
||||
// advisory until their session happens to expire.
|
||||
if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil {
|
||||
if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.PrintErrln("Their sessions were closed. Their API keys still work — revoke those separately.")
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", args[0], state)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", u.Email, state)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -150,7 +159,7 @@ func newUserStateCmd(open userOpener, verb string, state store.UserState, short
|
||||
|
||||
func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: verb + " <user-id>",
|
||||
Use: verb + " <user>",
|
||||
Short: short,
|
||||
Long: "The dashboard is read-only, so this grants looking and nothing else.\n" +
|
||||
"Everything that changes the cluster or issues a credential stays here on\n" +
|
||||
@@ -164,11 +173,11 @@ func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *co
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := s.SetAdmin(cmd.Context(), args[0], admin); err != nil {
|
||||
u, err := resolveUser(cmd.Context(), s, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.UserByID(cmd.Context(), args[0])
|
||||
if err != nil {
|
||||
if err := s.SetAdmin(cmd.Context(), u.ID, admin); err != nil {
|
||||
return err
|
||||
}
|
||||
if admin {
|
||||
@@ -186,7 +195,7 @@ func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *co
|
||||
func newUserResetPasswordCmd(open userOpener) *cobra.Command {
|
||||
var password string
|
||||
cmd := &cobra.Command{
|
||||
Use: "reset-password <user-id>",
|
||||
Use: "reset-password <user>",
|
||||
Short: "Set a new password for someone who lost theirs",
|
||||
Long: "There is no self-service reset, because this gateway sends no email and\n" +
|
||||
"a reset link with nowhere to send it is not a reset. Set one here, tell\n" +
|
||||
@@ -208,13 +217,17 @@ func newUserResetPasswordCmd(open userOpener) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetPassword(cmd.Context(), args[0], hash); err != nil {
|
||||
u, err := resolveUser(cmd.Context(), s, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil {
|
||||
if err := s.SetPassword(cmd.Context(), u.ID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", args[0])
|
||||
if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", u.Email)
|
||||
cmd.PrintErrln("Their sessions were closed. Their API keys still work.")
|
||||
return nil
|
||||
},
|
||||
@@ -241,6 +254,10 @@ func newUserCreateCmd(open userOpener) *cobra.Command {
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := account.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,15 +270,88 @@ func newUserCreateCmd(open userOpener) *cobra.Command {
|
||||
if err := s.CreateUser(cmd.Context(), u); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ApproveUser(cmd.Context(), id, project, "cli", time.Now()); err != nil {
|
||||
if err := s.ApproveUser(cmd.Context(), id, p.ID, "cli", time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", id)
|
||||
cmd.PrintErrf("%s created and approved into project %s\n", args[0], project)
|
||||
cmd.PrintErrf("%s created and approved into %s\n", args[0], p.Path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to")
|
||||
cmd.Flags().StringVar(&project, "project", "",
|
||||
"project the person's keys belong to: id, path or unique name (optional when there is one)")
|
||||
cmd.Flags().StringVar(&password, "password", "", "initial password")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUserMoveCmd(open userOpener) *cobra.Command {
|
||||
var project string
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <user> --project <project>",
|
||||
Short: "Put an approved account in another project, with the keys it made",
|
||||
Long: "The person's own keys go with them, so they stop spending the old project's\n" +
|
||||
"budget. Keys an operator issued in the old project stay there. Usage already\n" +
|
||||
"recorded is not rewritten. A running server picks the change up when its key\n" +
|
||||
"cache expires (auth.cache_ttl).",
|
||||
Example: " llmgw users move ayse@example.com --project Acme/ml/research",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
u, err := resolveUser(cmd.Context(), s, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := resolveProject(cmd.Context(), s, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moved, err := s.MoveUser(cmd.Context(), u.ID, p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s (%s moved with them)\n",
|
||||
u.Email, p.Path, countOf(moved, "key"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&project, "project", "", "where they go: id, path or unique name")
|
||||
_ = cmd.MarkFlagRequired("project")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newUserDeleteCmd(open userOpener) *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <user>",
|
||||
Short: "Remove an account for good",
|
||||
Long: "Signs them out, revokes every key they made, and removes the account. The\n" +
|
||||
"keys are kept, revoked and ownerless, because usage history refers to them.\n" +
|
||||
"To stop an account working and keep it, use suspend.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s, err := open(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
u, err := resolveUser(cmd.Context(), s, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return fmt.Errorf("this removes %s and revokes their keys; run again with --yes", u.Email)
|
||||
}
|
||||
if err := s.DeleteUser(cmd.Context(), u.ID, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", u.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&yes, "yes", false, "confirm")
|
||||
return cmd
|
||||
}
|
||||
@@ -540,8 +540,14 @@ func (s Server) Catalog() (*catalog.Catalog, error) {
|
||||
// A model with no profiles cannot be placed, so a configuration of bare names
|
||||
// plus a static endpoint list is the router-only mode used for testing against
|
||||
// sim-engines.
|
||||
//
|
||||
// With no static endpoints there is nothing a router-only process could serve,
|
||||
// so a configuration naming neither endpoints nor models is a control plane
|
||||
// with an empty catalog — which is what every new cluster is. It used to be
|
||||
// refused as having nothing to serve, and under systemd that is a unit
|
||||
// restarting every two seconds with nothing on 8080.
|
||||
func (s Server) ControlPlaneMode() bool {
|
||||
if s.Control.Enabled {
|
||||
if s.Control.Enabled || len(s.Endpoints) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, m := range s.Models {
|
||||
|
||||
@@ -155,19 +155,26 @@ func TestValidateAcceptsDistinctAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAControlPlaneCanBeAskedForWithNoModelsInTheFile(t *testing.T) {
|
||||
// Without this, a deployment whose catalog lives entirely in the database
|
||||
// cannot start: the mode is inferred from the file, an empty models block
|
||||
// reads as "router only", and the process refuses to start for want of
|
||||
// anything to serve — so the stored models it would have loaded are never
|
||||
// read at all.
|
||||
func TestAnEmptyConfigurationIsAControlPlane(t *testing.T) {
|
||||
// A new cluster has no models and no endpoints. Refusing it as having
|
||||
// nothing to serve left a systemd unit restarting every two seconds and
|
||||
// nothing listening on 8080 — on the first start anyone ever made.
|
||||
var s Server
|
||||
if !s.ControlPlaneMode() {
|
||||
t.Fatal("a configuration with no endpoints and no models should run a control plane")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticEndpointsAloneAreRouterOnly(t *testing.T) {
|
||||
var s Server
|
||||
s.Endpoints = []Endpoint{{ID: "e", URL: "http://127.0.0.1:1"}}
|
||||
s.Models = []catalog.Model{{Name: "m"}}
|
||||
if s.ControlPlaneMode() {
|
||||
t.Fatal("an empty configuration is not a control plane")
|
||||
t.Error("static endpoints and bare model names are the router-only mode")
|
||||
}
|
||||
s.Control.Enabled = true
|
||||
if !s.ControlPlaneMode() {
|
||||
t.Error("control.enabled should run the control plane with no models declared")
|
||||
t.Error("control.enabled should run the control plane alongside static endpoints")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,22 @@ func (s *Service) Declared(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sources maps each weights source in the catalog to the models that name it,
|
||||
// so a page listing files can say which of them are in use.
|
||||
func (s *Service) Sources(ctx context.Context) (map[string][]string, error) {
|
||||
c, err := s.Load(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, m := range c.Models() {
|
||||
if m.Source != "" {
|
||||
out[m.Source] = append(out[m.Source], m.Name)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Add puts a model in the catalog and keeps it across restarts.
|
||||
//
|
||||
// Validated by building the whole catalog before anything is written. A model
|
||||
|
||||
@@ -96,6 +96,13 @@ func (s *DB) CopyTo(ctx context.Context, dst *DB) ([]CopyResult, error) {
|
||||
}
|
||||
|
||||
// isEmpty reports whether any table has rows, and which one first does.
|
||||
// Empty reports whether the store holds no rows at all, which is what CopyTo
|
||||
// needs of its destination.
|
||||
func (s *DB) Empty(ctx context.Context) (bool, error) {
|
||||
empty, _, err := s.isEmpty(ctx)
|
||||
return empty, err
|
||||
}
|
||||
|
||||
func (s *DB) isEmpty(ctx context.Context) (bool, string, error) {
|
||||
for _, table := range copyOrder {
|
||||
var n int64
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotEmpty means something still hangs off what was to be deleted.
|
||||
var ErrNotEmpty = errors.New("not empty")
|
||||
|
||||
// TreeOrg, TreeTeam and TreeProject are the tenancy tree as one value, with
|
||||
// what hangs off each project counted, for a page or a command that shows it
|
||||
// whole.
|
||||
type TreeOrg struct {
|
||||
ID, Name string
|
||||
Teams []TreeTeam
|
||||
}
|
||||
|
||||
// TreeTeam is a team and its projects.
|
||||
type TreeTeam struct {
|
||||
ID, Name string
|
||||
Projects []TreeProject
|
||||
}
|
||||
|
||||
// TreeProject is a project and what uses it.
|
||||
type TreeProject struct {
|
||||
ID, Name string
|
||||
// Users counts accounts in the project, whatever their state.
|
||||
Users int
|
||||
// Keys counts every key ever issued in it; ActiveKeys those not revoked.
|
||||
Keys, ActiveKeys int
|
||||
}
|
||||
|
||||
// Tree reads the whole tenancy tree, organizations with no teams and teams
|
||||
// with no projects included.
|
||||
func (s *DB) Tree(ctx context.Context) ([]TreeOrg, error) {
|
||||
orgs, err := s.AllOrgs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := s.AllTeams(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type proj struct {
|
||||
TreeProject
|
||||
teamID string
|
||||
}
|
||||
projects, err := queryAll(ctx, s.db, `
|
||||
SELECT p.id, p.name, p.team_id,
|
||||
(SELECT COUNT(*) FROM users u WHERE u.project_id = p.id),
|
||||
(SELECT COUNT(*) FROM api_keys k WHERE k.project_id = p.id),
|
||||
(SELECT COUNT(*) FROM api_keys k WHERE k.project_id = p.id AND k.revoked_at IS NULL)
|
||||
FROM projects p ORDER BY p.name`, nil, func(rows *sql.Rows) (proj, error) {
|
||||
var p proj
|
||||
return p, rows.Scan(&p.ID, &p.Name, &p.teamID, &p.Users, &p.Keys, &p.ActiveKeys)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byTeam := map[string][]TreeProject{}
|
||||
for _, p := range projects {
|
||||
byTeam[p.teamID] = append(byTeam[p.teamID], p.TreeProject)
|
||||
}
|
||||
byOrg := map[string][]TreeTeam{}
|
||||
for _, t := range teams {
|
||||
byOrg[t.OrgID] = append(byOrg[t.OrgID], TreeTeam{ID: t.ID, Name: t.Name, Projects: byTeam[t.ID]})
|
||||
}
|
||||
out := make([]TreeOrg, 0, len(orgs))
|
||||
for _, o := range orgs {
|
||||
out = append(out, TreeOrg{ID: o.ID, Name: o.Name, Teams: byOrg[o.ID]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RenameOrg renames an organization. Names are unique among organizations.
|
||||
func (s *DB) RenameOrg(ctx context.Context, id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("a name cannot be empty")
|
||||
}
|
||||
if err := s.nameIsFree(ctx,
|
||||
`SELECT COUNT(*) FROM organizations WHERE name = ? AND id <> ?`, name, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateOne(ctx, `UPDATE organizations SET name = ? WHERE id = ?`, name, id)
|
||||
}
|
||||
|
||||
// RenameTeam renames a team. Names are unique within an organization.
|
||||
func (s *DB) RenameTeam(ctx context.Context, id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("a name cannot be empty")
|
||||
}
|
||||
if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM teams
|
||||
WHERE name = ? AND id <> ? AND org_id = (SELECT org_id FROM teams WHERE id = ?)`,
|
||||
name, id, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateOne(ctx, `UPDATE teams SET name = ? WHERE id = ?`, name, id)
|
||||
}
|
||||
|
||||
// RenameProject renames a project. Names are unique within a team.
|
||||
func (s *DB) RenameProject(ctx context.Context, id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("a name cannot be empty")
|
||||
}
|
||||
if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM projects
|
||||
WHERE name = ? AND id <> ? AND team_id = (SELECT team_id FROM projects WHERE id = ?)`,
|
||||
name, id, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateOne(ctx, `UPDATE projects SET name = ? WHERE id = ?`, name, id)
|
||||
}
|
||||
|
||||
// MoveTeam puts a team, and everything under it, in another organization.
|
||||
//
|
||||
// Its limits go with it, and from now on it inherits the new organization's.
|
||||
func (s *DB) MoveTeam(ctx context.Context, teamID, orgID string) error {
|
||||
if err := s.exists(ctx, "organization", `SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM teams
|
||||
WHERE org_id = ? AND id <> ? AND name = (SELECT name FROM teams WHERE id = ?)`,
|
||||
orgID, teamID, teamID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateOne(ctx, `UPDATE teams SET org_id = ? WHERE id = ?`, orgID, teamID)
|
||||
}
|
||||
|
||||
// MoveProject puts a project, with its people and keys, in another team.
|
||||
func (s *DB) MoveProject(ctx context.Context, projectID, teamID string) error {
|
||||
if err := s.exists(ctx, "team", `SELECT COUNT(*) FROM teams WHERE id = ?`, teamID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM projects
|
||||
WHERE team_id = ? AND id <> ? AND name = (SELECT name FROM projects WHERE id = ?)`,
|
||||
teamID, projectID, projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateOne(ctx, `UPDATE projects SET team_id = ? WHERE id = ?`, teamID, projectID)
|
||||
}
|
||||
|
||||
// DeleteOrg removes an organization with no teams, and its limits.
|
||||
func (s *DB) DeleteOrg(ctx context.Context, id string) error {
|
||||
return s.deleteEmpty(ctx, ScopeOrg, id, `DELETE FROM organizations WHERE id = ?`,
|
||||
emptyCheck{`SELECT COUNT(*) FROM teams WHERE org_id = ?`, "team"})
|
||||
}
|
||||
|
||||
// DeleteTeam removes a team with no projects, and its limits.
|
||||
func (s *DB) DeleteTeam(ctx context.Context, id string) error {
|
||||
return s.deleteEmpty(ctx, ScopeTeam, id, `DELETE FROM teams WHERE id = ?`,
|
||||
emptyCheck{`SELECT COUNT(*) FROM projects WHERE team_id = ?`, "project"})
|
||||
}
|
||||
|
||||
// DeleteProject removes a project nobody is in and no key was ever issued in.
|
||||
//
|
||||
// A revoked key still counts. Usage history names its key and project, and a
|
||||
// project deleted from under that history leaves rows that belong to nothing;
|
||||
// such a project can be renamed or left, not removed.
|
||||
func (s *DB) DeleteProject(ctx context.Context, id string) error {
|
||||
return s.deleteEmpty(ctx, ScopeProject, id, `DELETE FROM projects WHERE id = ?`,
|
||||
emptyCheck{`SELECT COUNT(*) FROM users WHERE project_id = ?`, "account"},
|
||||
emptyCheck{`SELECT COUNT(*) FROM api_keys WHERE project_id = ?`, "key"})
|
||||
}
|
||||
|
||||
type emptyCheck struct {
|
||||
query, noun string
|
||||
}
|
||||
|
||||
func (s *DB) deleteEmpty(ctx context.Context, scope Scope, id, del string, checks ...emptyCheck) error {
|
||||
return s.inTx(ctx, func(c conn) error {
|
||||
var busy []string
|
||||
for _, ch := range checks {
|
||||
var n int
|
||||
if err := c.QueryRowContext(ctx, ch.query, id).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
busy = append(busy, plural(n, ch.noun))
|
||||
}
|
||||
}
|
||||
if len(busy) > 0 {
|
||||
return fmt.Errorf("%w: it still has %s", ErrNotEmpty, strings.Join(busy, " and "))
|
||||
}
|
||||
if _, err := c.ExecContext(ctx,
|
||||
`DELETE FROM limits WHERE scope_type = ? AND scope_id = ?`, string(scope), id); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.ExecContext(ctx, del, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// MoveUser puts an approved account in another project, with the keys it
|
||||
// made for itself, and returns how many keys moved.
|
||||
//
|
||||
// The keys move because they are the person's: left behind they would keep
|
||||
// spending the old project's budget under the new project's name. Keys an
|
||||
// operator issued in the project belong to the project and stay. Usage already
|
||||
// recorded stays where it was spent.
|
||||
func (s *DB) MoveUser(ctx context.Context, userID, projectID string) (int, error) {
|
||||
if err := s.exists(ctx, "project", `SELECT COUNT(*) FROM projects WHERE id = ?`, projectID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var moved int
|
||||
err := s.inTx(ctx, func(c conn) error {
|
||||
var state string
|
||||
if err := c.QueryRowContext(ctx, `SELECT state FROM users WHERE id = ?`, userID).Scan(&state); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if UserState(state) == UserPending {
|
||||
return errors.New("this account is waiting for approval; approve it into a project instead")
|
||||
}
|
||||
if _, err := c.ExecContext(ctx, `UPDATE users SET project_id = ? WHERE id = ?`, projectID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.ExecContext(ctx, `UPDATE api_keys SET project_id = ? WHERE user_id = ?`, projectID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
moved = int(n)
|
||||
return nil
|
||||
})
|
||||
return moved, err
|
||||
}
|
||||
|
||||
// DeleteUser removes an account: its sessions end, its keys are revoked, and
|
||||
// the row goes.
|
||||
//
|
||||
// The keys stay, revoked and without an owner, because usage history refers
|
||||
// to them; the account itself is what the person asked to have removed.
|
||||
func (s *DB) DeleteUser(ctx context.Context, userID string, at time.Time) error {
|
||||
return s.inTx(ctx, func(c conn) error {
|
||||
if _, err := c.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.ExecContext(ctx, `UPDATE api_keys
|
||||
SET revoked_at = COALESCE(revoked_at, ?), user_id = NULL WHERE user_id = ?`,
|
||||
millis(at), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := c.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// KeyRow is a key with where it sits and who made it, for an administrator's
|
||||
// list of every key.
|
||||
type KeyRow struct {
|
||||
Key
|
||||
ProjectPath string
|
||||
// OwnerEmail is empty for a key an operator issued in the project.
|
||||
OwnerEmail string
|
||||
}
|
||||
|
||||
// AllKeys lists every key, newest first, with its project's path and owner.
|
||||
func (s *DB) AllKeys(ctx context.Context) ([]KeyRow, error) {
|
||||
cols := make([]string, 0, 11)
|
||||
for _, c := range strings.Split(keyColumns, ",") {
|
||||
cols = append(cols, "k."+strings.TrimSpace(c))
|
||||
}
|
||||
return queryAll(ctx, s.db, `
|
||||
SELECT `+strings.Join(cols, ", ")+`,
|
||||
o.name || ' / ' || t.name || ' / ' || p.name, COALESCE(u.email, '')
|
||||
FROM api_keys k
|
||||
JOIN projects p ON p.id = k.project_id
|
||||
JOIN teams t ON t.id = p.team_id
|
||||
JOIN organizations o ON o.id = t.org_id
|
||||
LEFT JOIN users u ON u.id = k.user_id
|
||||
ORDER BY k.created_at DESC`, nil, func(rows *sql.Rows) (KeyRow, error) {
|
||||
var r KeyRow
|
||||
k, err := scanKey(keyAndMore{rows, []any{&r.ProjectPath, &r.OwnerEmail}})
|
||||
r.Key = k
|
||||
return r, err
|
||||
})
|
||||
}
|
||||
|
||||
// keyAndMore lets scanKey read a row that carries columns after the key's.
|
||||
type keyAndMore struct {
|
||||
sc scanner
|
||||
more []any
|
||||
}
|
||||
|
||||
func (k keyAndMore) Scan(dest ...any) error { return k.sc.Scan(append(dest, k.more...)...) }
|
||||
|
||||
func (s *DB) updateOne(ctx context.Context, q string, args ...any) error {
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DB) exists(ctx context.Context, noun, q string, id string) error {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, q, id).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("no %s %q", noun, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plural(n int, noun string) string {
|
||||
if n == 1 {
|
||||
return "1 " + noun
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, noun)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tree builds Acme/platform/{gateway,search} and Scania/ml, and an active
|
||||
// user in gateway with one key of their own and one operator key there.
|
||||
type fixture struct {
|
||||
s *DB
|
||||
acme, scania string
|
||||
platform, ml string
|
||||
gateway, search, research string
|
||||
user, userKey, operatorKey string
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
f := fixture{s: tenancyStore(t)}
|
||||
must := func(id string, err error) string {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
f.acme = must(f.s.CreateOrg(ctx, "Acme"))
|
||||
f.scania = must(f.s.CreateOrg(ctx, "Scania"))
|
||||
f.platform = must(f.s.CreateTeam(ctx, f.acme, "platform"))
|
||||
f.ml = must(f.s.CreateTeam(ctx, f.scania, "ml"))
|
||||
f.gateway = must(f.s.CreateProject(ctx, f.platform, "gateway"))
|
||||
f.search = must(f.s.CreateProject(ctx, f.platform, "search"))
|
||||
f.research = must(f.s.CreateProject(ctx, f.ml, "research"))
|
||||
|
||||
f.user = "usr-1"
|
||||
if err := f.s.CreateUser(ctx, User{ID: f.user, Email: "a@x", Name: "a", PasswordHash: "h",
|
||||
State: UserPending, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.s.ApproveUser(ctx, f.user, f.gateway, "test", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, k, _ := NewKey("key-u", "mine", f.gateway)
|
||||
if err := f.s.CreateUserKey(ctx, f.user, k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.userKey = k.ID
|
||||
_, k, _ = NewKey("key-o", "ci", f.gateway)
|
||||
if err := f.s.CreateKey(ctx, k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.operatorKey = k.ID
|
||||
return f
|
||||
}
|
||||
|
||||
func TestTreeCountsWhatUsesEachProject(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
tree, err := f.s.Tree(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tree) != 2 || tree[0].Name != "Acme" || len(tree[0].Teams) != 1 {
|
||||
t.Fatalf("tree = %+v", tree)
|
||||
}
|
||||
gw := tree[0].Teams[0].Projects[0]
|
||||
if gw.Name != "gateway" || gw.Users != 1 || gw.Keys != 2 || gw.ActiveKeys != 2 {
|
||||
t.Errorf("gateway = %+v", gw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingAUserTakesTheirKeysAndLeavesTheProjects(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
moved, err := f.s.MoveUser(ctx, f.user, f.research)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved != 1 {
|
||||
t.Errorf("moved %d keys, want the one the person made", moved)
|
||||
}
|
||||
u, _ := f.s.UserByID(ctx, f.user)
|
||||
if u.ProjectID != f.research {
|
||||
t.Errorf("user is in %s", u.ProjectID)
|
||||
}
|
||||
keys, _ := f.s.Keys(ctx, f.gateway)
|
||||
if len(keys) != 1 || keys[0].ID != f.operatorKey {
|
||||
t.Errorf("the operator's key should stay in the project: %+v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPendingAccountIsApprovedNotMoved(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
_ = f.s.CreateUser(ctx, User{ID: "usr-2", Email: "b@x", Name: "b", PasswordHash: "h",
|
||||
State: UserPending, CreatedAt: time.Now()})
|
||||
if _, err := f.s.MoveUser(ctx, "usr-2", f.gateway); err == nil {
|
||||
t.Error("moving a pending account should be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountWithNoProjectCannotBeMadeActive(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
_ = f.s.CreateUser(ctx, User{ID: "usr-2", Email: "b@x", Name: "b", PasswordHash: "h",
|
||||
State: UserPending, CreatedAt: time.Now()})
|
||||
// Rejected, then reinstated: the path that used to produce an active
|
||||
// account whose first key failed.
|
||||
if err := f.s.SetUserState(ctx, "usr-2", UserSuspended); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.s.SetUserState(ctx, "usr-2", UserActive); err == nil {
|
||||
t.Error("an account with no project was made active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamingKeepsNamesUniqueAmongSiblingsOnly(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
if err := f.s.RenameProject(ctx, f.search, "gateway"); !errors.Is(err, ErrDuplicateName) {
|
||||
t.Errorf("a sibling's name was accepted: %v", err)
|
||||
}
|
||||
// research is in another team, so "gateway" is free there.
|
||||
if err := f.s.RenameProject(ctx, f.research, "gateway"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := f.s.RenameOrg(ctx, f.acme, "Acme AB"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := f.s.RenameTeam(ctx, f.platform, " "); err == nil {
|
||||
t.Error("an empty name was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovingATeamOrProjectChecksTheNewParent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
if err := f.s.MoveProject(ctx, f.search, f.ml); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, _ := f.s.AllProjects(ctx)
|
||||
var found bool
|
||||
for _, p := range projects {
|
||||
if p.ID == f.search && p.Path == "Scania / ml / search" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("search did not move: %+v", projects)
|
||||
}
|
||||
if err := f.s.MoveTeam(ctx, f.platform, "org-nope"); err == nil {
|
||||
t.Error("moved into an organization that does not exist")
|
||||
}
|
||||
// A team named ml already exists in Scania.
|
||||
if _, err := f.s.CreateTeam(ctx, f.acme, "ml"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
teams, _ := f.s.AllTeams(ctx)
|
||||
for _, tm := range teams {
|
||||
if tm.OrgID == f.acme && tm.Name == "ml" {
|
||||
if err := f.s.MoveTeam(ctx, tm.ID, f.scania); !errors.Is(err, ErrDuplicateName) {
|
||||
t.Errorf("two teams named ml in one organization: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyEmptyThingsAreDeleted(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
err := f.s.DeleteProject(ctx, f.gateway)
|
||||
if !errors.Is(err, ErrNotEmpty) || !strings.Contains(err.Error(), "1 account and 2 keys") {
|
||||
t.Errorf("deleting a used project: %v", err)
|
||||
}
|
||||
if err := f.s.DeleteTeam(ctx, f.platform); !errors.Is(err, ErrNotEmpty) {
|
||||
t.Errorf("deleting a team with projects: %v", err)
|
||||
}
|
||||
if err := f.s.DeleteProject(ctx, f.search); err != nil {
|
||||
t.Errorf("deleting an unused project: %v", err)
|
||||
}
|
||||
if err := f.s.DeleteProject(ctx, f.research); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.s.DeleteTeam(ctx, f.ml); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.s.DeleteOrg(ctx, f.scania); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.s.DeleteOrg(ctx, f.scania); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("deleting twice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingAUserRevokesTheirKeysAndKeepsThem(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
ctx := t.Context()
|
||||
if err := f.s.DeleteUser(ctx, f.user, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.s.UserByID(ctx, f.user); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("user still there: %v", err)
|
||||
}
|
||||
keys, _ := f.s.Keys(ctx, f.gateway)
|
||||
for _, k := range keys {
|
||||
if k.ID == f.userKey && k.RevokedAt.IsZero() {
|
||||
t.Error("the deleted person's key still works")
|
||||
}
|
||||
if k.ID == f.operatorKey && !k.RevokedAt.IsZero() {
|
||||
t.Error("an operator key was revoked with the person")
|
||||
}
|
||||
}
|
||||
if len(keys) != 2 {
|
||||
t.Errorf("keys are kept for usage history; have %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKeysNamesProjectAndOwner(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rows, err := f.s.AllKeys(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ProjectPath != "Acme / platform / gateway" {
|
||||
t.Errorf("path %q", r.ProjectPath)
|
||||
}
|
||||
if (r.ID == f.userKey) != (r.OwnerEmail == "a@x") {
|
||||
t.Errorf("owner of %s is %q", r.ID, r.OwnerEmail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeclaringATeamDoesNotMoveIt(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
err := f.s.PutTeam(context.Background(), Team{ID: f.platform, OrgID: f.scania, Name: "platform"})
|
||||
if err == nil {
|
||||
t.Fatal("PutTeam moved a team to another organization")
|
||||
}
|
||||
teams, _ := f.s.AllTeams(context.Background())
|
||||
for _, tm := range teams {
|
||||
if tm.ID == f.platform && tm.OrgID != f.acme {
|
||||
t.Error("the team changed owner")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,17 +96,31 @@ func (s *DB) PutOrg(ctx context.Context, o Org) error {
|
||||
}
|
||||
|
||||
// PutTeam upserts a team.
|
||||
//
|
||||
// It will rename a team but not move it: an upsert that rewrote the parent
|
||||
// let `keys init --org B --team platform` take organization A's team without
|
||||
// a word. Moving is MoveTeam, asked for by name.
|
||||
func (s *DB) PutTeam(ctx context.Context, t Team) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
var org string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT org_id FROM teams WHERE id = ?`, t.ID).Scan(&org)
|
||||
if err == nil && org != t.OrgID {
|
||||
return fmt.Errorf("team %s belongs to organization %s; move it rather than redeclare it", t.ID, org)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO teams (id, org_id, name) VALUES (?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET org_id = excluded.org_id, name = excluded.name`,
|
||||
t.ID, t.OrgID, t.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
// PutProject upserts a project.
|
||||
// PutProject upserts a project. Like PutTeam, it renames but never moves.
|
||||
func (s *DB) PutProject(ctx context.Context, p Project) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
var team string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM projects WHERE id = ?`, p.ID).Scan(&team)
|
||||
if err == nil && team != p.TeamID {
|
||||
return fmt.Errorf("project %s belongs to team %s; move it rather than redeclare it", p.ID, team)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`INSERT INTO projects (id, team_id, name) VALUES (?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET team_id = excluded.team_id, name = excluded.name`,
|
||||
p.ID, p.TeamID, p.Name)
|
||||
|
||||
@@ -120,7 +120,19 @@ func (s *DB) ApproveUser(ctx context.Context, id, projectID, by string, at time.
|
||||
}
|
||||
|
||||
// SetUserState suspends or reinstates an account.
|
||||
//
|
||||
// An account with no project cannot be made active. A pending account that
|
||||
// was rejected and then reinstated used to become exactly that: able to sign
|
||||
// in, and failing with "something went wrong" at the first key it made.
|
||||
// Approving is what gives it a project.
|
||||
func (s *DB) SetUserState(ctx context.Context, id string, state UserState) error {
|
||||
if state == UserActive {
|
||||
var project sql.NullString
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT project_id FROM users WHERE id = ?`, id).
|
||||
Scan(&project); err == nil && !project.Valid {
|
||||
return errors.New("this account has no project; approve it into one instead")
|
||||
}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET state = ? WHERE id = ?`, string(state), id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -70,6 +70,14 @@ type mirrorRow struct {
|
||||
// then not drawn at all: a progress bar that has to invent its own total is
|
||||
// worse than a byte count that only goes up.
|
||||
HasPercent bool
|
||||
|
||||
// Files is how many files a held version has. A version of several is one
|
||||
// row — a fifteen-shard model is one thing to serve or remove, and fifteen
|
||||
// rows of it pushed everything else off the page.
|
||||
Files int
|
||||
// UsedBy names the catalog models whose source is this row's address, so
|
||||
// a file that is in use does not look like one waiting to be served.
|
||||
UsedBy []string
|
||||
}
|
||||
|
||||
type adminMirrorData struct {
|
||||
@@ -276,25 +284,46 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
d.Base = got.Base
|
||||
var sources map[string][]string
|
||||
if p.catalog != nil {
|
||||
if sources, err = p.catalog.Sources(r.Context()); err != nil {
|
||||
p.log.Warn("could not read which models use the mirror", "err", err)
|
||||
}
|
||||
}
|
||||
held := map[string]int{} // model/version -> index of its row
|
||||
for _, st := range got.Models {
|
||||
row := mirrorRow{
|
||||
Status: st,
|
||||
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
|
||||
URL(got.Base),
|
||||
}
|
||||
if pc, ok := st.Percent(); ok {
|
||||
row.Percent, row.HasPercent = pc, true
|
||||
}
|
||||
switch st.State {
|
||||
case mirror.Fetching:
|
||||
d.Fetching++
|
||||
case mirror.Failed:
|
||||
d.Failed++
|
||||
default:
|
||||
d.Present++
|
||||
key := st.Model + "/" + st.Version
|
||||
if i, ok := held[key]; ok {
|
||||
// Another file of a version already shown: counted into it.
|
||||
d.Rows[i].Files++
|
||||
d.Rows[i].BytesDone += st.BytesDone
|
||||
d.Rows[i].URL = strings.TrimSuffix(
|
||||
mirror.Entry{Model: st.Model, Version: st.Version}.URL(got.Base), "/") + "/"
|
||||
continue
|
||||
}
|
||||
held[key] = len(d.Rows)
|
||||
d.Present++ // versions, not files: what the rows below count
|
||||
}
|
||||
row := mirrorRow{
|
||||
Status: st,
|
||||
Files: 1,
|
||||
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
|
||||
URL(got.Base),
|
||||
}
|
||||
if pc, ok := st.Percent(); ok {
|
||||
row.Percent, row.HasPercent = pc, true
|
||||
}
|
||||
d.Rows = append(d.Rows, row)
|
||||
}
|
||||
for i := range d.Rows {
|
||||
d.Rows[i].UsedBy = sources[d.Rows[i].URL]
|
||||
}
|
||||
|
||||
// The one row whose serve form is open, and only when the catalog can be
|
||||
// changed at all.
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestAFailedRowOffersRetryRatherThanRemove(t *testing.T) {
|
||||
if strings.Contains(body, "/admin/mirror/m/v/remove") {
|
||||
t.Error("a failed row should not offer a remove that can only 404")
|
||||
}
|
||||
if !strings.Contains(body, "Retry") {
|
||||
if !strings.Contains(body, ">retry<") {
|
||||
t.Error("a failed row should offer to start the download again")
|
||||
}
|
||||
if !strings.Contains(body, `value="http://upstream/model.gguf"`) {
|
||||
@@ -535,3 +535,35 @@ func TestAlternativeQuantisationsStaySeparateRows(t *testing.T) {
|
||||
t.Errorf("expected a form each plus the manual one, found %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldVersionIsOneLineAndSaysWhoUsesIt(t *testing.T) {
|
||||
m := &fakeMirror{models: []mirror.Status{
|
||||
{Model: "big", Version: "v1", File: "a-00001-of-00002.gguf", State: mirror.Present, BytesDone: 1 << 30},
|
||||
{Model: "big", Version: "v1", File: "a-00002-of-00002.gguf", State: mirror.Present, BytesDone: 1 << 30},
|
||||
{Model: "small", Version: "v1", File: "s.gguf", State: mirror.Present, BytesDone: 1 << 20},
|
||||
}}
|
||||
cat := &fakeCatalog{sources: map[string][]string{"http://mirror:8081/small/v1/s.gguf": {"small-chat"}}}
|
||||
body := mirrorGet(t, mirrorPageDeps(t, MirrorDeps{Mirror: m, Catalog: cat}, true),
|
||||
"/admin/mirror").Body.String()
|
||||
|
||||
if n := strings.Count(body, "/admin/mirror/big/v1/remove"); n != 1 {
|
||||
t.Errorf("a two-file version should be one row, found %d", n)
|
||||
}
|
||||
if !strings.Contains(body, "2 files") || !strings.Contains(body, "2.0 GiB") {
|
||||
t.Error("the row should count its files and add up their size")
|
||||
}
|
||||
if !strings.Contains(body, `data-url="http://mirror:8081/big/v1/"`) {
|
||||
t.Error("a set's address is its directory, trailing slash included")
|
||||
}
|
||||
if !strings.Contains(body, ">small-chat<") {
|
||||
t.Error("the model serving a file should be named on its row")
|
||||
}
|
||||
// Already served: offering to serve it again invites a second catalog
|
||||
// entry for the same weights.
|
||||
if strings.Contains(body, "serve=small:v1") {
|
||||
t.Error("a file in use should not be offered for serving")
|
||||
}
|
||||
if !strings.Contains(body, "serve=big:v1") {
|
||||
t.Error("a file nobody uses should be offered for serving")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
type CatalogAdmin interface {
|
||||
Add(ctx context.Context, m catalog.Model, by string) error
|
||||
Declared(name string) bool
|
||||
// Sources maps a weights source to the catalog models that use it.
|
||||
Sources(ctx context.Context) (map[string][]string, error)
|
||||
}
|
||||
|
||||
// serveForm is the offer to put one mirrored version into service.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type fakeCatalog struct {
|
||||
added []catalog.Model
|
||||
declared string
|
||||
sources map[string][]string
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -28,6 +29,9 @@ func (f *fakeCatalog) Add(_ context.Context, m catalog.Model, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeCatalog) Declared(name string) bool { return name == f.declared }
|
||||
func (f *fakeCatalog) Sources(context.Context) (map[string][]string, error) {
|
||||
return f.sources, nil
|
||||
}
|
||||
|
||||
// fleet reports a cluster with a given number of cards and engines.
|
||||
type fakeFleet struct {
|
||||
|
||||
@@ -60,6 +60,26 @@ func (f *fakeAdmin) CreateProject(_ context.Context, teamID, name string) (strin
|
||||
return "proj-1", f.err
|
||||
}
|
||||
|
||||
func (f *fakeAdmin) Tree(context.Context) ([]store.TreeOrg, error) {
|
||||
var out []store.TreeOrg
|
||||
for _, o := range f.orgs {
|
||||
out = append(out, store.TreeOrg{ID: o.ID, Name: o.Name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeAdmin) RenameOrg(context.Context, string, string) error { return f.err }
|
||||
func (f *fakeAdmin) RenameTeam(context.Context, string, string) error { return f.err }
|
||||
func (f *fakeAdmin) RenameProject(context.Context, string, string) error { return f.err }
|
||||
func (f *fakeAdmin) MoveTeam(context.Context, string, string) error { return f.err }
|
||||
func (f *fakeAdmin) MoveProject(context.Context, string, string) error { return f.err }
|
||||
func (f *fakeAdmin) DeleteOrg(context.Context, string) error { return f.err }
|
||||
func (f *fakeAdmin) DeleteTeam(context.Context, string) error { return f.err }
|
||||
func (f *fakeAdmin) DeleteProject(context.Context, string) error { return f.err }
|
||||
func (f *fakeAdmin) MoveUser(context.Context, string, string) (int, error) { return 0, f.err }
|
||||
func (f *fakeAdmin) DeleteUser(context.Context, string, time.Time) error { return f.err }
|
||||
func (f *fakeAdmin) AllKeys(context.Context) ([]store.KeyRow, error) { return nil, nil }
|
||||
func (f *fakeAdmin) RevokeKey(context.Context, string, time.Time) error { return f.err }
|
||||
|
||||
func tenancyPage(t *testing.T, s *fakeAdmin, admin bool) *http.ServeMux {
|
||||
t.Helper()
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: admin}}
|
||||
@@ -156,16 +176,17 @@ func TestThePageSaysWhatToDoWhenTheTreeIsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotasAreStillNotSettableFromTheBrowser(t *testing.T) {
|
||||
func TestThisPageSetsNoQuotas(t *testing.T) {
|
||||
body := mirrorGet(t, tenancyPage(t, &fakeAdmin{}, true), "/admin/users").Body.String()
|
||||
|
||||
// Making somewhere to put people is structural; raising what they may spend
|
||||
// is financial. A stolen admin session should be able to do the first and
|
||||
// not the second, and the page says so.
|
||||
if !strings.Contains(body, "llmgw keys limits") {
|
||||
t.Error("the page should still send quota changes to the shell")
|
||||
}
|
||||
// Quotas have their own page; this one arranges people. What stays off
|
||||
// the web entirely is what would hand a stolen session more access.
|
||||
if strings.Contains(body, `name="budget"`) {
|
||||
t.Error("a budget field has appeared on the accounts page")
|
||||
t.Error("a budget field has appeared on the people page")
|
||||
}
|
||||
for _, shell := range []string{"llmgw users promote", "reset-password", "llmgw keys create"} {
|
||||
if !strings.Contains(body, shell) {
|
||||
t.Errorf("the page should send %q to the shell", shell)
|
||||
}
|
||||
}
|
||||
}
|
||||
+256
-59
@@ -3,7 +3,9 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
@@ -26,36 +28,77 @@ type AccountAdmin interface {
|
||||
CreateOrg(ctx context.Context, name string) (string, error)
|
||||
CreateTeam(ctx context.Context, orgID, name string) (string, error)
|
||||
CreateProject(ctx context.Context, teamID, name string) (string, error)
|
||||
|
||||
// And keeping it in shape afterwards, which used to have no interface at
|
||||
// all: nothing could be renamed, moved or removed, and an approved person
|
||||
// stayed in the project they were first put in.
|
||||
Tree(ctx context.Context) ([]store.TreeOrg, error)
|
||||
RenameOrg(ctx context.Context, id, name string) error
|
||||
RenameTeam(ctx context.Context, id, name string) error
|
||||
RenameProject(ctx context.Context, id, name string) error
|
||||
MoveTeam(ctx context.Context, teamID, orgID string) error
|
||||
MoveProject(ctx context.Context, projectID, teamID string) error
|
||||
DeleteOrg(ctx context.Context, id string) error
|
||||
DeleteTeam(ctx context.Context, id string) error
|
||||
DeleteProject(ctx context.Context, id string) error
|
||||
MoveUser(ctx context.Context, userID, projectID string) (int, error)
|
||||
DeleteUser(ctx context.Context, userID string, at time.Time) error
|
||||
AllKeys(ctx context.Context) ([]store.KeyRow, error)
|
||||
RevokeKey(ctx context.Context, id string, at time.Time) error
|
||||
}
|
||||
|
||||
// userRow is an account as the page shows it: with where it is, not an id.
|
||||
type userRow struct {
|
||||
store.User
|
||||
ProjectPath string
|
||||
}
|
||||
|
||||
// keyRow is a key as the page shows it.
|
||||
type keyRow struct {
|
||||
store.KeyRow
|
||||
State string
|
||||
}
|
||||
|
||||
type adminUsersData struct {
|
||||
Pending []store.User
|
||||
Active []store.User
|
||||
Accounts []userRow
|
||||
Tree []store.TreeOrg
|
||||
// People and Keys are keyed by project id.
|
||||
People map[string][]userRow
|
||||
Keys map[string][]keyRow
|
||||
Projects []store.ProjectPath
|
||||
Orgs []store.Org
|
||||
Teams []store.TeamPath
|
||||
Message string
|
||||
Error string
|
||||
CSRF string
|
||||
Version string
|
||||
Now string
|
||||
// Open is the project whose section is shown expanded: the one just acted
|
||||
// on, so the result of a change is in view without the reader hunting for
|
||||
// it. Scripted pages keep every open section open; this is for the rest.
|
||||
Open string
|
||||
AdminID string
|
||||
Message string
|
||||
Error string
|
||||
CSRF string
|
||||
Version string
|
||||
Now string
|
||||
// HasMirror and HasQuotas keep the nav from offering a page that was never
|
||||
// mounted.
|
||||
HasMirror bool
|
||||
HasQuotas bool
|
||||
}
|
||||
|
||||
// RegisterAdminUsers mounts account administration under /admin/users.
|
||||
// RegisterAdminUsers mounts account and tenancy administration under
|
||||
// /admin/users.
|
||||
//
|
||||
// What is here and what is not is the whole design. Approving and suspending
|
||||
// are the work an operator does constantly and should not need a terminal for.
|
||||
// Making someone an admin, resetting a password and issuing keys on another
|
||||
// person's behalf are not here: each of them turns a stolen admin session into
|
||||
// more than it was — more access, or somebody else's account — and each is rare
|
||||
// enough that a shell is no hardship.
|
||||
// What is here and what is not is the whole design. Approving, suspending,
|
||||
// moving people and keeping the tree in shape are the work an operator does
|
||||
// constantly and should not need a terminal for. Making someone an admin,
|
||||
// resetting a password and issuing keys on another person's behalf are not
|
||||
// here: each of them turns a stolen admin session into more than it was —
|
||||
// more access, or somebody else's account — and each is rare enough that a
|
||||
// shell is no hardship. Revoking a key is here, because it only ever takes
|
||||
// access away.
|
||||
func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
||||
mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
p.renderUsers(w, r, s, "", "")
|
||||
p.renderUsers(w, r, s, admin, "", "", r.URL.Query().Get("open"))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s,
|
||||
@@ -67,7 +110,7 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
||||
if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "approved into " + project, nil
|
||||
return "approved into " + projectPath(ctx, s, project), nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s,
|
||||
@@ -88,17 +131,47 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
||||
return "suspended, and signed out everywhere", nil
|
||||
}))
|
||||
|
||||
// The tenancy tree. Creating a container is not granting access: nobody can
|
||||
// use one until an account is approved into it, which is the step above and
|
||||
// already lives here. What is still not here is limits — raising a budget
|
||||
// is a financial act, and a stolen session must not be able to perform one.
|
||||
mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s,
|
||||
func(ctx context.Context, r *http.Request) (string, error) {
|
||||
id, err := s.CreateOrg(ctx, r.FormValue("name"))
|
||||
mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s,
|
||||
func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) {
|
||||
if err := s.SetUserState(ctx, id, store.UserActive); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "reinstated", nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/move", p.adminAction(s,
|
||||
func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) {
|
||||
project := r.FormValue("project")
|
||||
if project == "" {
|
||||
return "", errors.New("choose the project they go to")
|
||||
}
|
||||
moved, err := s.MoveUser(ctx, id, project)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "organization " + r.FormValue("name") + " created as " + id, nil
|
||||
return fmt.Sprintf("moved to %s, with %s", projectPath(ctx, s, project), plural(moved, "key", "keys")), nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s,
|
||||
func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) {
|
||||
if id == admin.ID {
|
||||
return "", errors.New("you cannot remove your own account")
|
||||
}
|
||||
if err := s.DeleteUser(ctx, id, time.Now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "removed; their keys are revoked", nil
|
||||
}))
|
||||
|
||||
// The tenancy tree. Creating a container is not granting access: nobody can
|
||||
// use one until an account is approved into it.
|
||||
mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s,
|
||||
func(ctx context.Context, r *http.Request) (string, error) {
|
||||
_, err := s.CreateOrg(ctx, r.FormValue("name"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "organization " + r.FormValue("name") + " created", nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s,
|
||||
@@ -107,11 +180,11 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
||||
if org == "" {
|
||||
return "", errors.New("choose the organization this team belongs to")
|
||||
}
|
||||
id, err := s.CreateTeam(ctx, org, r.FormValue("name"))
|
||||
_, err := s.CreateTeam(ctx, org, r.FormValue("name"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "team " + r.FormValue("name") + " created as " + id, nil
|
||||
return "team " + r.FormValue("name") + " created", nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s,
|
||||
@@ -124,16 +197,104 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "project " + r.FormValue("name") + " created as " + id, nil
|
||||
r.Form.Set("open", id)
|
||||
return "project " + r.FormValue("name") + " created", nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s,
|
||||
func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) {
|
||||
if err := s.SetUserState(ctx, id, store.UserActive); err != nil {
|
||||
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s,
|
||||
func(ctx context.Context, r *http.Request) (string, error) {
|
||||
id, name := r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))
|
||||
var err error
|
||||
switch r.PathValue("kind") {
|
||||
case "org":
|
||||
err = s.RenameOrg(ctx, id, name)
|
||||
case "team":
|
||||
err = s.RenameTeam(ctx, id, name)
|
||||
case "project":
|
||||
err = s.RenameProject(ctx, id, name)
|
||||
default:
|
||||
return "", errors.New("nothing of that kind")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "reinstated", nil
|
||||
return "renamed to " + name, nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s,
|
||||
func(ctx context.Context, r *http.Request) (string, error) {
|
||||
id, to := r.PathValue("id"), r.FormValue("to")
|
||||
if to == "" {
|
||||
return "", errors.New("choose where it goes")
|
||||
}
|
||||
switch r.PathValue("kind") {
|
||||
case "team":
|
||||
if err := s.MoveTeam(ctx, id, to); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "team moved", nil
|
||||
case "project":
|
||||
if err := s.MoveProject(ctx, id, to); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "project moved to " + projectPath(ctx, s, id), nil
|
||||
}
|
||||
return "", errors.New("only a team or a project moves")
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s,
|
||||
func(ctx context.Context, r *http.Request) (string, error) {
|
||||
id := r.PathValue("id")
|
||||
var err error
|
||||
switch r.PathValue("kind") {
|
||||
case "org":
|
||||
err = s.DeleteOrg(ctx, id)
|
||||
case "team":
|
||||
err = s.DeleteTeam(ctx, id)
|
||||
case "project":
|
||||
err = s.DeleteProject(ctx, id)
|
||||
default:
|
||||
return "", errors.New("nothing of that kind")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.Form.Del("open")
|
||||
return "removed", nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s,
|
||||
func(ctx context.Context, r *http.Request) (string, error) {
|
||||
if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "key revoked; a running server stops accepting it when its cache expires", nil
|
||||
}))
|
||||
}
|
||||
|
||||
func projectPath(ctx context.Context, s AccountAdmin, id string) string {
|
||||
all, err := s.AllProjects(ctx)
|
||||
if err == nil {
|
||||
for _, p := range all {
|
||||
if p.ID == id {
|
||||
return p.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// plainError says what a store error means to the person who caused it.
|
||||
func plainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrDuplicateName):
|
||||
return errors.New("there is already one of those here")
|
||||
case errors.Is(err, store.ErrNotEmpty):
|
||||
return fmt.Errorf("not removed: %s", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": "))
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return errors.New("that is no longer there; the page was out of date")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// tenancyAction wraps a change to the tree: admin session, CSRF, audit, render.
|
||||
@@ -153,15 +314,15 @@ func (p *AccountPages) tenancyAction(s AccountAdmin,
|
||||
}
|
||||
msg, err := do(r.Context(), r)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDuplicateName) {
|
||||
err = errors.New("there is already one of those here")
|
||||
}
|
||||
p.log.Warn("a tenancy change was refused", "admin", admin.Email, "err", err)
|
||||
p.renderUsers(w, r, s, "", err.Error())
|
||||
err = plainError(err)
|
||||
p.log.Warn("a tenancy change was refused", "admin", admin.Email,
|
||||
"path", r.URL.Path, "err", err)
|
||||
p.renderUsers(w, r, s, admin, "", err.Error(), r.FormValue("open"))
|
||||
return
|
||||
}
|
||||
p.log.Info("the tenancy tree was changed", "admin", admin.Email, "what", msg)
|
||||
p.renderUsers(w, r, s, msg, "")
|
||||
p.log.Info("the tenancy tree was changed", "admin", admin.Email,
|
||||
"path", r.URL.Path, "what", msg)
|
||||
p.renderUsers(w, r, s, admin, msg, "", r.FormValue("open"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,7 +346,8 @@ func (p *AccountPages) adminOnly(
|
||||
}
|
||||
}
|
||||
|
||||
// adminAction wraps a state change: admin session, CSRF, audit line, redirect.
|
||||
// adminAction wraps a change to one account: admin session, CSRF, audit line,
|
||||
// render.
|
||||
func (p *AccountPages) adminAction(s AccountAdmin,
|
||||
do func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error),
|
||||
) http.HandlerFunc {
|
||||
@@ -204,51 +366,86 @@ func (p *AccountPages) adminAction(s AccountAdmin,
|
||||
|
||||
msg, err := do(r.Context(), admin, id, r)
|
||||
if err != nil {
|
||||
err = plainError(err)
|
||||
// Logged at warn whether it worked or not: a refused administrative
|
||||
// action is as worth knowing about as one that went through.
|
||||
p.log.Warn("an administrative action was refused",
|
||||
"admin", admin.Email, "subject", subject, "err", err)
|
||||
p.renderUsers(w, r, s, "", err.Error())
|
||||
p.renderUsers(w, r, s, admin, "", subject+": "+err.Error(), r.FormValue("open"))
|
||||
return
|
||||
}
|
||||
p.log.Info("an account was changed",
|
||||
"admin", admin.Email, "subject", subject, "what", msg)
|
||||
p.renderUsers(w, r, s, subject+" "+msg, "")
|
||||
p.renderUsers(w, r, s, admin, subject+" "+msg, "", r.FormValue("open"))
|
||||
})
|
||||
}
|
||||
|
||||
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
|
||||
s AccountAdmin, msg, errMsg string,
|
||||
s AccountAdmin, admin store.User, msg, errMsg, open string,
|
||||
) {
|
||||
ctx := r.Context()
|
||||
d := adminUsersData{
|
||||
Message: msg, Error: errMsg,
|
||||
Message: msg, Error: errMsg, Open: open, AdminID: admin.ID,
|
||||
CSRF: csrfFor(r),
|
||||
Version: p.version,
|
||||
HasMirror: p.hasMirror,
|
||||
HasQuotas: p.hasQuotas,
|
||||
Now: time.Now().UTC().Format(time.RFC3339),
|
||||
People: map[string][]userRow{},
|
||||
Keys: map[string][]keyRow{},
|
||||
}
|
||||
|
||||
all, err := s.Users(r.Context(), "")
|
||||
if err != nil {
|
||||
p.log.Error("could not list accounts", "err", err)
|
||||
d.Error = "could not read the accounts"
|
||||
}
|
||||
for _, u := range all {
|
||||
if u.State == store.UserPending {
|
||||
d.Pending = append(d.Pending, u)
|
||||
} else {
|
||||
d.Active = append(d.Active, u)
|
||||
var err error
|
||||
logErr := func(what string, err error) {
|
||||
if err != nil {
|
||||
p.log.Error("could not read "+what, "err", err)
|
||||
if d.Error == "" {
|
||||
d.Error = "could not read " + what
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.Projects, err = s.AllProjects(r.Context()); err != nil {
|
||||
p.log.Error("could not list projects", "err", err)
|
||||
|
||||
d.Projects, err = s.AllProjects(ctx)
|
||||
logErr("the projects", err)
|
||||
paths := make(map[string]string, len(d.Projects))
|
||||
for _, pp := range d.Projects {
|
||||
paths[pp.ID] = pp.Path
|
||||
}
|
||||
if d.Orgs, err = s.AllOrgs(r.Context()); err != nil {
|
||||
p.log.Error("could not list organizations", "err", err)
|
||||
|
||||
all, err := s.Users(ctx, "")
|
||||
logErr("the accounts", err)
|
||||
for _, u := range all {
|
||||
if u.State == store.UserPending && u.ProjectID == "" {
|
||||
d.Pending = append(d.Pending, u)
|
||||
continue
|
||||
}
|
||||
row := userRow{User: u, ProjectPath: paths[u.ProjectID]}
|
||||
d.Accounts = append(d.Accounts, row)
|
||||
d.People[u.ProjectID] = append(d.People[u.ProjectID], row)
|
||||
}
|
||||
if d.Teams, err = s.AllTeams(r.Context()); err != nil {
|
||||
p.log.Error("could not list teams", "err", err)
|
||||
|
||||
keys, err := s.AllKeys(ctx)
|
||||
logErr("the keys", err)
|
||||
now := time.Now()
|
||||
for _, k := range keys {
|
||||
d.Keys[k.ProjectID] = append(d.Keys[k.ProjectID], keyRow{KeyRow: k, State: keyState(k.Key, now)})
|
||||
}
|
||||
|
||||
d.Tree, err = s.Tree(ctx)
|
||||
logErr("the tree", err)
|
||||
d.Orgs, err = s.AllOrgs(ctx)
|
||||
logErr("the organizations", err)
|
||||
d.Teams, err = s.AllTeams(ctx)
|
||||
logErr("the teams", err)
|
||||
render(w, "admin_users", d)
|
||||
}
|
||||
|
||||
func keyState(k store.Key, now time.Time) string {
|
||||
switch {
|
||||
case !k.RevokedAt.IsZero():
|
||||
return "revoked"
|
||||
case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt):
|
||||
return "expired"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
@@ -317,3 +317,48 @@ tr.editing form { margin: .4rem 0; }
|
||||
difference has to survive a glance. */
|
||||
.chip.on { background: var(--accent); border-color: var(--accent); color: var(--bg);
|
||||
font-weight: 600; }
|
||||
|
||||
/* People: the tenancy tree with its people and keys. Indented by level and
|
||||
ruled on the left, so which team a project is in reads at a glance without
|
||||
a table per level. */
|
||||
.org { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: .9rem 1rem; margin: 0 0 1rem; }
|
||||
.team { margin: .8rem 0 0 .4rem; padding-left: .9rem; border-left: 2px solid var(--line); }
|
||||
.node-head { display: flex; align-items: center; gap: .4rem .9rem; flex-wrap: wrap; }
|
||||
.node-name { font-weight: 600; }
|
||||
.org > .node-head .node-name { font-size: 1.05rem; }
|
||||
.node-meta { color: var(--muted); font-size: .82rem; font-weight: 400; }
|
||||
details.edit { display: inline-block; font-size: .85rem; }
|
||||
details.edit > summary { cursor: pointer; color: var(--accent); list-style: none;
|
||||
text-decoration: underline; }
|
||||
details.edit > summary::-webkit-details-marker { display: none; }
|
||||
details.edit[open] > summary { color: var(--muted); }
|
||||
details.edit form { display: flex; gap: .4rem; align-items: center; margin: .35rem 0; }
|
||||
details.edit input[type=text] { width: 14rem; }
|
||||
details.project { margin: .45rem 0; border: 1px solid var(--line); border-radius: 6px;
|
||||
background: var(--bg); }
|
||||
details.project > summary { cursor: pointer; padding: .45rem .7rem; display: flex;
|
||||
gap: .8rem; align-items: baseline; }
|
||||
details.project[open] > summary { border-bottom: 1px solid var(--line); }
|
||||
.project-body { padding: .6rem .7rem .8rem; display: grid; gap: .7rem; }
|
||||
table.compact { font-size: .83rem; }
|
||||
table.compact th, table.compact td { padding: .35rem .55rem; }
|
||||
form.add { display: flex; gap: .4rem; margin: .5rem 0 0; }
|
||||
form.add input[type=text], details.edit input[type=text] { margin: 0; padding: .35rem .55rem; }
|
||||
form.add input[type=text] { max-width: 18rem; }
|
||||
form.add button, button.small { padding: .3rem .7rem; font-size: .82rem; }
|
||||
form.org-add { margin: 0 0 1rem; }
|
||||
td.actions { white-space: nowrap; }
|
||||
td.actions form + form { margin-left: .6rem; }
|
||||
.tag { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--accent); border: 1px solid var(--accent); border-radius: 4px;
|
||||
padding: 0 .3rem; margin-left: .3rem; }
|
||||
input.search { width: 100%; max-width: 26rem; margin: 0 0 .7rem; padding: .45rem .6rem;
|
||||
font: inherit; border: 1px solid var(--line); border-radius: 6px;
|
||||
background: var(--card); color: var(--fg); }
|
||||
tr[hidden] { display: none; }
|
||||
|
||||
/* The mirror's list of what it holds: one line per version. */
|
||||
table.held td.file { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
table.held td.actions > * + * { margin-left: .7rem; }
|
||||
table.held .tag { margin: 0 .3rem 0 0; text-transform: none; letter-spacing: 0; }
|
||||
+1
@@ -0,0 +1 @@
|
||||
var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/<hx-([a-z]+)((?:\s[^>]*)?)>/gi,'<template hx type="$1"$2>').replace(/<\/hx-[a-z]+>/gi,"</template>");const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){oLine truncated
|
||||
@@ -12,7 +12,7 @@
|
||||
{{if .Unreachable}}{{.Addr}} did not answer{{else}}{{.Present}} held ·
|
||||
{{.Fetching}} downloading · {{.Failed}} failed · {{.Addr}}{{end}}
|
||||
</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
|
||||
<a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
@@ -31,17 +31,15 @@ reachable from this machine.</p>
|
||||
|
||||
<h2>What the mirror holds</h2>
|
||||
{{if .Rows}}
|
||||
<table>
|
||||
<tr><th>Model</th><th>Version</th><th>File</th><th>State</th><th>Size</th>
|
||||
<th>Checksum</th><th></th></tr>
|
||||
<table class="compact held">
|
||||
<tr><th>Model</th><th>Files</th><th class="num">Size</th><th>In use</th><th></th></tr>
|
||||
{{range .Rows}}<tr>
|
||||
<td>{{.Model}}</td>
|
||||
<td>{{.Version}}</td>
|
||||
<td><code>{{.File}}</code></td>
|
||||
<td class="state state-{{.State}}">{{.State}}</td>
|
||||
<td><strong>{{.Model}}</strong> <span class="opt">{{.Version}}</span></td>
|
||||
<td class="file" title="{{.File}}{{if .SHA256}} · sha256 {{.SHA256}}{{end}}">
|
||||
{{if gt .Files 1}}{{.Files}} files{{else}}<code>{{.File}}</code>{{end}}</td>
|
||||
<td class="num">
|
||||
{{if eq (printf "%s" .State) "fetching"}}
|
||||
{{if .HasPercent}}{{bytes .BytesDone}} / {{bytes .BytesTotal}} ({{.Percent}}%)
|
||||
{{if .HasPercent}}{{.Percent}}% of {{bytes .BytesTotal}}
|
||||
{{else}}{{/* No total came from upstream, so no percentage is invented —
|
||||
a bar that guesses its own end and then stops moving is worse
|
||||
than a number that only goes up. */}}
|
||||
@@ -49,11 +47,14 @@ reachable from this machine.</p>
|
||||
{{else if eq (printf "%s" .State) "failed"}}—
|
||||
{{else}}{{bytes .BytesDone}}{{end}}
|
||||
</td>
|
||||
<td>{{if .SHA256}}<code title="{{.SHA256}}">{{slice .SHA256 0 12}}</code>{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if eq (printf "%s" .State) "fetching"}}
|
||||
<span class="note">downloading</span>
|
||||
{{else if eq (printf "%s" .State) "failed"}}
|
||||
{{if eq (printf "%s" .State) "fetching"}}<span class="state state-starting">downloading</span>
|
||||
{{else if eq (printf "%s" .State) "failed"}}<span class="state state-failed">failed</span>
|
||||
{{else if .UsedBy}}{{range .UsedBy}}<span class="tag">{{.}}</span>{{end}}
|
||||
{{else}}<span class="opt">not served</span>{{end}}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{{if eq (printf "%s" .State) "failed"}}
|
||||
{{/* Retry, not remove. A fetch that failed left nothing on disk, so the
|
||||
remove would come back "the mirror holds nothing for this" — a button
|
||||
whose only outcome is a confusing error. Starting the same download
|
||||
@@ -65,25 +66,24 @@ reachable from this machine.</p>
|
||||
<input type="hidden" name="model" value="{{.Model}}">
|
||||
<input type="hidden" name="version" value="{{.Version}}">
|
||||
<input type="hidden" name="file" value="{{.File}}">
|
||||
<button type="submit" class="link">Retry</button>
|
||||
<button type="submit" class="link">retry</button>
|
||||
</form>
|
||||
{{else}}
|
||||
{{if $.HasCatalog}}{{if .Serve}}<a href="/admin/mirror">close</a>
|
||||
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}
|
||||
{{end}}
|
||||
{{else if eq (printf "%s" .State) "present"}}
|
||||
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">copy address</button>
|
||||
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">close</a>
|
||||
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}{{end}}
|
||||
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
|
||||
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?')">
|
||||
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?{{if .UsedBy}} It is in use by the catalog.{{end}}')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Remove</button>
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .Err}}<tr><td colspan="7" class="error-row">{{.Err}}</td></tr>{{end}}
|
||||
{{if not .Err}}<tr><td colspan="7" class="url-row"><code>{{.URL}}</code></td></tr>{{end}}
|
||||
{{if .Err}}<tr><td colspan="5" class="error-row">{{.Err}}</td></tr>{{end}}
|
||||
|
||||
{{with .Serve}}
|
||||
<tr class="editing"><td colspan="7">
|
||||
<tr class="editing"><td colspan="5">
|
||||
{{if .Refusal}}
|
||||
{{/* Offered and then failing at placement is worse than not offered at all. */}}
|
||||
<p class="error">Cannot be served as it stands: {{.Refusal}}.</p>
|
||||
@@ -140,10 +140,22 @@ job by name if you would rather pin it.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="note">The address under each row is what a catalog entry should say.
|
||||
It is rendered here rather than assembled by hand because the mirror and the
|
||||
catalog have to spell the same file the same way — two spellings is what makes a
|
||||
file that is in use look unreferenced.</p>
|
||||
<p class="note">“copy address” gives what a catalog entry's <code>source</code>
|
||||
should say — the file, or for a version of several files the directory, with its
|
||||
trailing slash. It is rendered rather than assembled by hand because the mirror
|
||||
and the catalog have to spell a file the same way, which is also how this page
|
||||
knows which models use it.</p>
|
||||
<script>
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.copy-url');
|
||||
if (!b) return;
|
||||
var done = function (t) { var o = b.textContent; b.textContent = t; setTimeout(function () { b.textContent = o; }, 1500); };
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(b.dataset.url).then(function () { done('copied'); },
|
||||
function () { window.prompt('Address', b.dataset.url); });
|
||||
} else { window.prompt('Address', b.dataset.url); }
|
||||
});
|
||||
</script>
|
||||
{{else}}<p class="empty">The mirror is reachable and holds nothing yet.</p>{{end}}
|
||||
|
||||
{{if .HasRegistry}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<header>
|
||||
<h1>Quotas</h1>
|
||||
<p class="sub">What each organization, team and project may use · spend over the last {{.Since}}</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
|
||||
<a href="/admin/usage">usage</a> <a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
@@ -89,7 +89,7 @@ unaffected — they count requests and tokens, not money. Set prices in the
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="empty">Nothing to give a quota to yet. Make an organization on the
|
||||
<a href="/admin/users">accounts page</a> first.</p>
|
||||
<a href="/admin/users">people page</a> first.</p>
|
||||
{{end}}
|
||||
|
||||
<p class="note">Limits compose downward by taking the most restrictive value, so
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<header>
|
||||
<h1>Usage over time</h1>
|
||||
<p class="sub">Who is using the cluster, and when</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
|
||||
<a href="/admin/quotas">quotas</a> <a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{{define "admin_users"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Accounts · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
||||
<title>People · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
||||
{{/* Forms here post and get the whole page back, which works with no script at
|
||||
all. htmx, when it loads, swaps only #people from that page instead of
|
||||
reloading it, so an open project stays open and the page does not jump. */}}
|
||||
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false}'>
|
||||
<script src="/_static/htmx-2.0.11.min.js" defer></script>
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>Accounts</h1>
|
||||
<p class="sub">{{len .Pending}} waiting for approval · {{len .Active}} active</p>
|
||||
<h1>People</h1>
|
||||
<p class="sub">{{len .Pending}} waiting for approval · {{len .Accounts}} accounts · {{len .Projects}} projects</p>
|
||||
<nav><a href="/admin">cluster</a>
|
||||
{{if .HasMirror}} <a href="/admin/mirror">mirror</a>
|
||||
{{end}}{{if .HasQuotas}} <a href="/admin/quotas">quotas</a>
|
||||
@@ -13,111 +18,276 @@
|
||||
{{end}} <a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
|
||||
<div id="people" hx-boost="true" hx-target="#people" hx-select="#people"
|
||||
hx-swap="outerHTML" hx-push-url="false">
|
||||
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="ok" role="status">{{.Message}}</p>{{end}}
|
||||
|
||||
<h2>Waiting for approval</h2>
|
||||
{{if .Pending}}
|
||||
<table><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Project</th><th></th></tr>
|
||||
<h2>Waiting for approval</h2>
|
||||
<table><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Put them in</th><th></th></tr>
|
||||
{{range .Pending}}<tr>
|
||||
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
|
||||
<td colspan="2">
|
||||
<td>
|
||||
{{if $.Projects}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<select name="project">
|
||||
<select name="project" aria-label="Project for {{.Email}}">
|
||||
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Approve</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{.ID}}/suspend" class="inline">
|
||||
{{else}}<span class="note">Create a project first, below.</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/{{.ID}}/delete" class="inline"
|
||||
hx-confirm="Reject and remove {{.Email}}?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Reject</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p class="empty">Nobody is waiting.</p>{{end}}
|
||||
|
||||
<h2>Accounts</h2>
|
||||
{{if .Active}}
|
||||
<table><tr><th>Email</th><th>State</th><th>Admin</th><th>Project</th><th>Last login</th><th></th></tr>
|
||||
{{range .Active}}<tr>
|
||||
<td>{{.Email}}</td>
|
||||
<td class="state state-{{.State}}">{{.State}}</td>
|
||||
<td>{{if .IsAdmin}}yes{{else}}—{{end}}</td>
|
||||
<td>{{or .ProjectID "—"}}</td>
|
||||
<td>{{ago .LastLoginAt}}</td>
|
||||
<td>
|
||||
{{if eq (printf "%s" .State) "active"}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/suspend" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Suspend</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/reinstate" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link">Reinstate</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p class="empty">No accounts yet.</p>{{end}}
|
||||
|
||||
<h2>Organizations, teams and projects</h2>
|
||||
<p class="note">Where an account lives. A person is approved into a project, a
|
||||
project belongs to a team, and a team to an organization — and a quota can be
|
||||
written on any of the three, so the shape here is the shape the bill takes.</p>
|
||||
|
||||
<div class="field-row">
|
||||
<form method="post" action="/admin/tenancy/org">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>New organization
|
||||
<input type="text" name="name" required placeholder="Scania"></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/admin/tenancy/team">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>New team
|
||||
<input type="text" name="name" required placeholder="platform"></label>
|
||||
<label>in
|
||||
<select name="org" required>
|
||||
{{range .Orgs}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select></label>
|
||||
<button type="submit"{{if not .Orgs}} disabled{{end}}>Create</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/admin/tenancy/project">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>New project
|
||||
<input type="text" name="name" required placeholder="gateway"></label>
|
||||
<label>in
|
||||
<select name="team" required>
|
||||
{{range .Teams}}<option value="{{.ID}}">{{.OrgName}} / {{.Name}}</option>{{end}}
|
||||
</select></label>
|
||||
<button type="submit"{{if not .Teams}} disabled{{end}}>Create</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if not .Orgs}}
|
||||
<p class="empty">No organizations yet. Create one, then a team in it, then a
|
||||
project in the team — an account can only be approved into a project.</p>
|
||||
{{else}}
|
||||
{{/* One path column, not three: the store returns the chain already joined,
|
||||
which is the string the approval chooser shows, and three headers over one
|
||||
value line up with nothing. */}}
|
||||
<table><tr><th>Organization / team / project</th><th>ID</th></tr>
|
||||
{{range .Projects}}<tr><td>{{.Path}}</td><td><code>{{.ID}}</code></td></tr>{{end}}
|
||||
{{if not .Projects}}<tr><td colspan="2" class="empty">No projects yet, so nobody can be approved.</td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<p class="note">Four things are deliberately not here. Making someone an admin,
|
||||
resetting a password, issuing keys on another
|
||||
person's behalf and setting any quota all need a shell on the control plane — <code>llmgw users promote</code>,
|
||||
<code>reset-password</code>, <code>llmgw keys</code>, <code>llmgw keys limits</code>.
|
||||
A stolen admin session can approve, suspend and make somewhere to put people; it
|
||||
cannot hand itself more access, take over an account, or raise what anybody is
|
||||
allowed to spend.</p>
|
||||
<h2>Organizations, teams and projects</h2>
|
||||
<p class="note">A person is in a project, a project in a team, a team in an
|
||||
organization{{if .HasQuotas}}, and <a href="/admin/quotas">quotas</a> can be set
|
||||
on any of them{{end}}. Open a project to see its people and keys.</p>
|
||||
|
||||
{{if not .Tree}}
|
||||
<p class="empty">No organizations yet. Create one, then a team in it, then a
|
||||
project in the team — an account can only be approved into a project.</p>
|
||||
{{end}}
|
||||
|
||||
{{range $org := .Tree}}
|
||||
<section class="org">
|
||||
<div class="node-head">
|
||||
<strong class="node-name">{{$org.Name}}</strong>
|
||||
<span class="node-meta">{{len $org.Teams}} team{{if ne (len $org.Teams) 1}}s{{end}}</span>
|
||||
<details class="edit"><summary>rename</summary>
|
||||
<form method="post" action="/admin/tenancy/org/{{$org.ID}}/rename">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="text" name="name" value="{{$org.Name}}" required aria-label="New name">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
{{if not $org.Teams}}
|
||||
<form method="post" action="/admin/tenancy/org/{{$org.ID}}/delete" class="inline"
|
||||
hx-confirm="Remove the organization {{$org.Name}}?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{range $team := $org.Teams}}
|
||||
<div class="team">
|
||||
<div class="node-head">
|
||||
<span class="node-name">{{$team.Name}}</span>
|
||||
<span class="node-meta">{{len $team.Projects}} project{{if ne (len $team.Projects) 1}}s{{end}}</span>
|
||||
<details class="edit"><summary>rename</summary>
|
||||
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/rename">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="text" name="name" value="{{$team.Name}}" required aria-label="New name">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
{{if gt (len $.Orgs) 1}}
|
||||
<details class="edit"><summary>move</summary>
|
||||
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/move">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<select name="to" aria-label="Organization to move {{$team.Name}} to">
|
||||
{{range $.Orgs}}{{if ne .ID $org.ID}}<option value="{{.ID}}">{{.Name}}</option>{{end}}{{end}}
|
||||
</select>
|
||||
<button type="submit">Move</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if not $team.Projects}}
|
||||
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/delete" class="inline"
|
||||
hx-confirm="Remove the team {{$team.Name}}?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{range $p := $team.Projects}}
|
||||
<details class="project" id="p-{{$p.ID}}"{{if eq $.Open $p.ID}} open{{end}}>
|
||||
<summary>
|
||||
<span class="node-name">{{$p.Name}}</span>
|
||||
<span class="node-meta">{{$p.Users}} {{if eq $p.Users 1}}person{{else}}people{{end}}
|
||||
· {{$p.ActiveKeys}} key{{if ne $p.ActiveKeys 1}}s{{end}}{{if gt $p.Keys $p.ActiveKeys}} ({{sub $p.Keys $p.ActiveKeys}} revoked){{end}}</span>
|
||||
</summary>
|
||||
<div class="project-body">
|
||||
<div class="node-head">
|
||||
<details class="edit"><summary>rename</summary>
|
||||
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/rename">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{$p.ID}}">
|
||||
<input type="text" name="name" value="{{$p.Name}}" required aria-label="New name">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
{{if gt (len $.Teams) 1}}
|
||||
<details class="edit"><summary>move to another team</summary>
|
||||
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/move">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{$p.ID}}">
|
||||
<select name="to" aria-label="Team to move {{$p.Name}} to">
|
||||
{{range $.Teams}}{{if ne .ID $team.ID}}<option value="{{.ID}}">{{.OrgName}} / {{.Name}}</option>{{end}}{{end}}
|
||||
</select>
|
||||
<button type="submit">Move</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if and (eq $p.Users 0) (eq $p.Keys 0)}}
|
||||
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/delete" class="inline"
|
||||
hx-confirm="Remove the project {{$p.Name}}?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
{{else}}<span class="note">Removable once it has no people and never had a key.</span>{{end}}
|
||||
</div>
|
||||
|
||||
{{with index $.People $p.ID}}
|
||||
<table class="compact"><tr><th>Person</th><th>State</th><th>Last sign-in</th><th>Move to</th><th></th></tr>
|
||||
{{range .}}{{template "user-row-actions" (dict "U" . "D" $ "Open" $p.ID)}}{{end}}
|
||||
</table>
|
||||
{{else}}<p class="empty">Nobody is in this project.</p>{{end}}
|
||||
|
||||
{{with index $.Keys $p.ID}}
|
||||
<table class="compact"><tr><th>Key</th><th>Owner</th><th>State</th><th>Last used</th><th></th></tr>
|
||||
{{range .}}<tr>
|
||||
<td>{{.Name}} <code>{{.Prefix}}</code></td>
|
||||
<td>{{or .OwnerEmail "issued by an operator"}}</td>
|
||||
<td class="state state-{{.State}}">{{.State}}</td>
|
||||
<td>{{ago .LastUsedAt}}</td>
|
||||
<td>{{if eq .State "active"}}
|
||||
<form method="post" action="/admin/keys/{{.ID}}/revoke" class="inline"
|
||||
hx-confirm="Revoke the key {{.Name}}? Whatever uses it stops working.">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{$p.ID}}">
|
||||
<button type="submit" class="link danger">revoke</button>
|
||||
</form>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/tenancy/project" class="add">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="team" value="{{$team.ID}}">
|
||||
<input type="text" name="name" required placeholder="new project in {{$team.Name}}" aria-label="New project in {{$team.Name}}">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/tenancy/team" class="add team-add">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="org" value="{{$org.ID}}">
|
||||
<input type="text" name="name" required placeholder="new team in {{$org.Name}}" aria-label="New team in {{$org.Name}}">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/tenancy/org" class="add org-add">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="name" required placeholder="new organization" aria-label="New organization">
|
||||
<button type="submit">Add organization</button>
|
||||
</form>
|
||||
|
||||
<h2 id="accounts">All accounts</h2>
|
||||
{{if .Accounts}}
|
||||
<table id="account-list"><tr><th>Person</th><th>State</th><th>Project</th><th>Last sign-in</th><th>Move to</th><th></th></tr>
|
||||
{{range .Accounts}}{{template "user-row-actions" (dict "U" . "D" $ "Open" "" "Path" true)}}{{end}}
|
||||
</table>
|
||||
{{else}}<p class="empty">No accounts yet.</p>{{end}}
|
||||
|
||||
<p class="note">Not here on purpose: making someone an admin, resetting a password
|
||||
and issuing a key for someone else. Each turns a stolen admin session into more
|
||||
access than it had, so they need a shell on the control plane —
|
||||
<code>llmgw users promote</code>, <code>llmgw users reset-password</code>,
|
||||
<code>llmgw keys create</code>.</p>
|
||||
</div>
|
||||
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
||||
</div></body></html>{{end}}
|
||||
</div>
|
||||
<script>
|
||||
// Search the account list. Outside #people so what was typed survives a swap,
|
||||
// and applied again after every swap.
|
||||
(function () {
|
||||
var box = document.createElement('input');
|
||||
box.type = 'search'; box.placeholder = 'Find a person by email, name or project';
|
||||
box.className = 'search'; box.setAttribute('aria-label', 'Find a person');
|
||||
function apply() {
|
||||
var q = box.value.trim().toLowerCase();
|
||||
document.querySelectorAll('#account-list tr[data-q]').forEach(function (tr) {
|
||||
tr.hidden = q !== '' && tr.dataset.q.indexOf(q) < 0;
|
||||
});
|
||||
}
|
||||
box.addEventListener('input', apply);
|
||||
function place() {
|
||||
var h = document.getElementById('accounts');
|
||||
if (h && box.parentNode !== h.parentNode) h.after(box);
|
||||
apply();
|
||||
}
|
||||
place();
|
||||
// Projects the reader had open stay open across a change.
|
||||
var open = [];
|
||||
document.addEventListener('htmx:beforeSwap', function () {
|
||||
open = Array.prototype.map.call(document.querySelectorAll('details.project[open]'), function (d) { return d.id; });
|
||||
});
|
||||
document.addEventListener('htmx:afterSettle', function () {
|
||||
open.forEach(function (id) { var d = document.getElementById(id); if (d) d.open = true; });
|
||||
place();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body></html>{{end}}
|
||||
|
||||
{{define "user-row-actions"}}{{$u := .U}}{{$d := .D}}
|
||||
<tr data-q="{{lower $u.Email}} {{lower $u.Name}} {{lower $u.ProjectPath}}">
|
||||
<td>{{$u.Email}}{{if $u.IsAdmin}} <span class="tag">admin</span>{{end}}</td>
|
||||
<td class="state state-{{$u.State}}">{{$u.State}}</td>
|
||||
{{if .Path}}<td>{{or $u.ProjectPath "—"}}</td>{{end}}
|
||||
<td>{{ago $u.LastLoginAt}}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/{{$u.ID}}/move" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{.Open}}">
|
||||
<select name="project" aria-label="Project for {{$u.Email}}">
|
||||
{{range $d.Projects}}<option value="{{.ID}}"{{if eq .ID $u.ProjectID}} selected{{end}}>{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" class="small">Move</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="actions">
|
||||
{{if ne $u.ID $d.AdminID}}
|
||||
{{if eq (printf "%s" $u.State) "active"}}
|
||||
<form method="post" action="/admin/users/{{$u.ID}}/suspend" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{.Open}}">
|
||||
<button type="submit" class="link danger">suspend</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/users/{{$u.ID}}/reinstate" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{.Open}}">
|
||||
<button type="submit" class="link">reinstate</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/users/{{$u.ID}}/delete" class="inline"
|
||||
hx-confirm="Remove {{$u.Email}} for good? Their keys are revoked.">
|
||||
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
||||
<input type="hidden" name="open" value="{{.Open}}">
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
{{else}}<span class="note">you</span>{{end}}
|
||||
</td>
|
||||
</tr>{{end}}
|
||||
@@ -13,7 +13,7 @@
|
||||
<a href="/debug/pprof/">pprof</a>
|
||||
<a href="/api/v1/nodes">api</a>
|
||||
{{else}}
|
||||
<a href="/admin/users">accounts</a>
|
||||
<a href="/admin/users">people</a>
|
||||
<a href="/admin/usage">usage</a>
|
||||
{{if .Nav.Mirror}} <a href="/admin/mirror">mirror</a>
|
||||
{{end}}
|
||||
|
||||
@@ -36,6 +36,8 @@ var files embed.FS
|
||||
var staticFiles embed.FS
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"join": func(v []string) string {
|
||||
if len(v) == 0 {
|
||||
return "—"
|
||||
|
||||
Reference in new issue
Block a user