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 members map[string][]string var alone []store.User if people { users, err := s.Users(cmd.Context(), "") if err != nil { return err } all, err := s.AllMemberships(cmd.Context()) if err != nil { return err } email := map[string]string{} for _, u := range users { email[u.ID] = u.Email + " " + string(u.State) if u.IsAdmin { email[u.ID] += " admin" } } members = map[string][]string{} in := map[string]bool{} for _, m := range all { members[string(m.Scope)+":"+m.ScopeID] = append(members[string(m.Scope)+":"+m.ScopeID], email[m.UserID]) in[m.UserID] = true } for _, u := range users { if !in[u.ID] { alone = append(alone, u) } } } printTree(cmd.OutOrStdout(), tree, members) if people && len(alone) > 0 { fmt.Fprintf(cmd.OutOrStdout(), "\non their own\n") for _, u := range alone { 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, members map[string][]string) { if len(tree) == 0 { fmt.Fprintln(w, "no organizations yet; `llmgw org add org ` starts one") return } list := func(indent, key string) { for _, m := range members[key] { fmt.Fprintf(w, "%s%s\n", indent, m) } } for _, o := range tree { fmt.Fprintf(w, "%s (%s)%s\n", o.Name, o.ID, membersNote(o.Members)) list(" · ", "org:"+o.ID) if len(o.Teams) == 0 { fmt.Fprintln(w, " no teams") } for _, t := range o.Teams { fmt.Fprintf(w, " %s (%s)%s\n", t.Name, t.ID, membersNote(t.Members)) list(" · ", "team:"+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, "member"), keysOf(p)) list(" · ", "project:"+p.ID) } } } } func membersNote(n int) string { if n == 0 { return "" } return " " + countOf(n, "member") } 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 ", 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 ", 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 --to ", 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 ", 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 } }