package main import ( "context" "flag" "fmt" "os" "sort" "strings" "text/tabwriter" "time" ) func runApps(args []string) error { // Accept an optional "list" subcommand: `tinyforge apps` == `tinyforge apps list`. if len(args) > 0 && args[0] == "list" { args = args[1:] } fs := flag.NewFlagSet("apps", flag.ExitOnError) g := addGlobalFlags(fs) fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: tinyforge apps [list] [--base-url URL]\n\nList apps (workloads with a source) and their container state.\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return err } sess, err := newSession(g) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var workloads []Workload if err := sess.client.doJSON(ctx, "GET", "/api/workloads", nil, &workloads); err != nil { return err } // One extra call fetches every container so state can be shown without an // N+1 per-app request. var containers []Container if err := sess.client.doJSON(ctx, "GET", "/api/containers", nil, &containers); err != nil { return err } byWorkload := map[string][]Container{} for _, c := range containers { byWorkload[c.WorkloadID] = append(byWorkload[c.WorkloadID], c) } apps := make([]Workload, 0, len(workloads)) for _, w := range workloads { if w.isApp() { apps = append(apps, w) } } sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name }) if len(apps) == 0 { fmt.Println("No apps yet. Create one in the web UI, then deploy with 'tinyforge deploy '.") return nil } tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) fmt.Fprintln(tw, "NAME\tSOURCE\tSTATE\tID") for _, w := range apps { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", w.Name, w.SourceKind, stateSummary(byWorkload[w.ID]), idShort(w.ID)) } return tw.Flush() } // stateSummary condenses a workload's containers into one status word. func stateSummary(cs []Container) string { if len(cs) == 0 { return "—" } running := 0 for _, c := range cs { if c.State == "running" { running++ } } switch { case running == len(cs): return "running" case running == 0: return cs[0].State // e.g. stopped / failed / missing default: return fmt.Sprintf("%d/%d running", running, len(cs)) } } // resolveApp maps a user-supplied reference (name, full id, or id prefix) to a // single app workload. Exact id wins, then exact name, then a unique id prefix. func resolveApp(ctx context.Context, c *Client, ref string) (Workload, error) { var workloads []Workload if err := c.doJSON(ctx, "GET", "/api/workloads", nil, &workloads); err != nil { return Workload{}, err } var byID, byName, byPrefix []Workload for _, w := range workloads { if !w.isApp() { continue } switch { case w.ID == ref: byID = append(byID, w) case strings.EqualFold(w.Name, ref): byName = append(byName, w) case len(ref) >= 6 && strings.HasPrefix(w.ID, ref): byPrefix = append(byPrefix, w) } } if len(byID) == 1 { return byID[0], nil } if len(byName) == 1 { return byName[0], nil } if len(byName) > 1 { return Workload{}, ambiguousErr(ref, byName) } if len(byPrefix) == 1 { return byPrefix[0], nil } if len(byPrefix) > 1 { return Workload{}, ambiguousErr(ref, byPrefix) } return Workload{}, fmt.Errorf("no app matching %q (try 'tinyforge apps list')", ref) } func ambiguousErr(ref string, matches []Workload) error { var b strings.Builder fmt.Fprintf(&b, "%q matches multiple apps; use the id:\n", ref) for _, w := range matches { fmt.Fprintf(&b, " %s %s\n", idShort(w.ID), w.Name) } return fmt.Errorf("%s", strings.TrimRight(b.String(), "\n")) } func idShort(id string) string { if len(id) > 8 { return id[:8] } return id }