Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions base/cvd/cuttlefish/host/commands/cvd/cli/commands/ps.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ usage: cvd ps [--help]
)";
Comment thread
0405ysj marked this conversation as resolved.

const std::map<PsColumns, std::string_view> kHeaders = {
{PsColumns::kNames, "NAMES"}, {PsColumns::kId, "ID"},
{PsColumns::kStatus, "STATUS"}, {PsColumns::kCreated, "CREATED"},
{PsColumns::kPorts, "PORTS"}, {PsColumns::kWebAccess, "WEB_ACCESS"},
{PsColumns::kGroups, "GROUPS"},
{PsColumns::kNames, "NAMES"},
{PsColumns::kId, "ID"},
{PsColumns::kStatus, "STATUS"},
{PsColumns::kCreated, "CREATED"},
{PsColumns::kAdbSerial, "ADB_SERIAL"},
{PsColumns::kWebAccess, "WEB_ACCESS"},
};

void PrintTable(const std::map<PsColumns, std::string_view>& headers,
Expand Down Expand Up @@ -130,14 +134,14 @@ PsRow CvdPsCommandHandler::InstanceToRow(const LocalInstanceGroup& group,
LocalInstance& instance) const {
PsRow row;

row[PsColumns::kNames] =
fmt::format("{}/{}", group.GroupName(), instance.Name());
row[PsColumns::kGroups] = group.GroupName();
row[PsColumns::kNames] = instance.Name();
row[PsColumns::kId] = std::to_string(instance.Id());

// Fetch live status
const Result<Json::Value> status_json_res = instance.FetchStatus();
std::string status_str = "Unknown (Fetch Failed)";
std::string ports_str = "-";
std::string adb_serial_str = "-";
std::string web_access_str = "-";

if (status_json_res.ok()) {
Expand All @@ -146,7 +150,8 @@ PsRow CvdPsCommandHandler::InstanceToRow(const LocalInstanceGroup& group,

if (instance.IsActive()) {
if (status_json.isMember("adb_port")) {
ports_str = fmt::format("adb:{}", status_json["adb_port"].asInt());
adb_serial_str =
fmt::format("localhost:{}", status_json["adb_port"].asInt());
}
if (status_json.isMember("web_access")) {
web_access_str = status_json["web_access"].asString();
Expand All @@ -156,7 +161,7 @@ PsRow CvdPsCommandHandler::InstanceToRow(const LocalInstanceGroup& group,

row[PsColumns::kStatus] = status_str;
row[PsColumns::kCreated] = Format(group.StartTime());
row[PsColumns::kPorts] = ports_str;
row[PsColumns::kAdbSerial] = adb_serial_str;
row[PsColumns::kWebAccess] = web_access_str;

return row;
Expand Down
5 changes: 3 additions & 2 deletions base/cvd/cuttlefish/host/commands/cvd/cli/commands/ps.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@
namespace cuttlefish {

enum class PsColumns {
kNames = 0,
kGroups = 0,
kNames,
kId,
kStatus,
kCreated,
kPorts,
kAdbSerial,
kWebAccess,
};

Expand Down
115 changes: 108 additions & 7 deletions container/src/podcvd/internal/cvd.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ package internal
import (
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
"text/tabwriter"

"github.com/go-playground/validator/v10"
)
Expand Down Expand Up @@ -91,16 +94,114 @@ func UpdateCvdGroupJsonRaw(data any, podcvdHomeDir, ipAddr string) {
}
}

var cvdPathRegex = regexp.MustCompile(`^/var/tmp/cvd/[0-9]+/[0-9]+/home`)
func PrintPsOutputs(results []HostExecResult, out io.Writer) error {
var allRows []map[string]string
colMap := make(map[string]int)
for _, res := range results {
rows, cols, err := parsePsOutput(string(res.Stdout), res.IP)
if err != nil {
return fmt.Errorf("failed to parse cvd ps output from group %q: %w", res.GroupName, err)
}
allRows = append(allRows, rows...)
for _, col := range cols {
if _, exists := colMap[col.name]; !exists {
colMap[col.name] = col.start
}
}
}
var colNames []string
for name := range colMap {
colNames = append(colNames, name)
}
sort.Slice(colNames, func(i, j int) bool {
return colMap[colNames[i]] < colMap[colNames[j]]
})
return combinePsOutputs(allRows, colNames, out)
}

func updateStringOnCvdGroupJsonRaw(data, podcvdHomeDir, ipAddr string) string {
operatorEndpointOnHost := fmt.Sprintf("%s:%d", ipAddr, portOperatorHttpsOnHost)
func updateIPAndPortString(data, ipAddr string) string {
operatorIpAndPortOnHost := fmt.Sprintf("%s:%d", ipAddr, portOperatorHttpsOnHost)
for _, host := range []string{"0.0.0.0", "localhost", "127.0.0.1"} {
data = strings.ReplaceAll(data, fmt.Sprintf("%s:%d", host, portOperatorHttps), operatorEndpointOnHost)
data = strings.ReplaceAll(data, fmt.Sprintf("%s:%d", host, portOperatorHttps), operatorIpAndPortOnHost)
data = strings.ReplaceAll(data, host, ipAddr)
}
if cvdPathRegex.MatchString(data) {
data = cvdPathRegex.ReplaceAllString(data, podcvdHomeDir)
}
return data
}

var cvdPathRegex = regexp.MustCompile(`^/var/tmp/cvd/[0-9]+/[0-9]+/home`)

func updateStringOnCvdGroupJsonRaw(data, podcvdHomeDir, ipAddr string) string {
data = updateIPAndPortString(data, ipAddr)
return cvdPathRegex.ReplaceAllString(data, podcvdHomeDir)
}

type psColumn struct {
name string
start int
}

var psHeaderRegex = regexp.MustCompile(`\S+`)

func parsePsHeader(headerLine string) ([]psColumn, error) {
matches := psHeaderRegex.FindAllStringIndex(headerLine, -1)
if len(matches) == 0 {
return nil, fmt.Errorf("invalid header line %q: no column headers found", headerLine)
}
var cols []psColumn
for _, m := range matches {
cols = append(cols, psColumn{
name: headerLine[m[0]:m[1]],
start: m[0],
})
}
return cols, nil
}

func parsePsRow(line string, cols []psColumn, ipAddr string) map[string]string {
rowMap := make(map[string]string)
lineLen := len(line)
for i, col := range cols {
if col.start >= lineLen {
rowMap[col.name] = ""
continue
}
end := lineLen
if i < len(cols)-1 && cols[i+1].start < lineLen {
end = cols[i+1].start
}
rowMap[col.name] = updateIPAndPortString(strings.TrimSpace(line[col.start:end]), ipAddr)
}
return rowMap
}

func parsePsOutput(stdout, ipAddr string) ([]map[string]string, []psColumn, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is re-string parsing the human-readable output of cvd ps back into a data structure the best way to do this? The purpose of cvd ps is to grab all of the groups' information from instance_manager_ and convert it into human-readable output based on kHeaders. This seems to add a redundant step, when you could:

  • call cvd fleet and use standard JSON parsing to put it into a data structure, and then use kHeaders to format your output,
  • or possibly call into instance_manager_ yourself (not sure if this one is possible from podcvd).

But before that, do podcvd commands need to be formatted a specific way? If not, you may be able to just directly feed the output of cvd ps into stdout.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But before that, do podcvd commands need to be formatted a specific way? If not, you may be able to just directly feed the output of cvd ps into stdout.

The only requirement is podcvd ps should have same format as cvd ps. When multiple Cuttlefish instance groups exist, each group is in separated container instances. When executing cvd ps from containers, podcvd ps needs to parse the data in advance, and then reconstruct it.

Leaving a representative example why not parsing the human-readable output can be problematic, difference on the padding.

$ podman exec -it <container_name_for_cvd_1> cvd ps
GROUPS   NAMES   ID   STATUS    CREATED               ADB_SERIAL           WEB_ACCESS
cvd_1    1       1    Running   2026-07-13 01:16:15   192.168.112.1:6520   https://192.168.112.1:11443/devices/cvd_1-1-1/files/client.html
$ podman exec -it <container_name_for_cvd_11> cvd ps
GROUPS    NAMES   ID   STATUS    CREATED               ADB_SERIAL            WEB_ACCESS
cvd_11    1       1    Running   2026-07-13 01:16:27   192.168.112.11:6520   https://192.168.112.11:11443/devices/cvd_2-1-1/files/client.html

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I should have considered potential format changes in the future. In case of having long-running Cuttlefish instance group via podcvd, its container instance has older version of cvd and there can have another running container instance with newer version of cvd perhpas. Even for such case, podcvd ps should provide information as possible as cvd ps does.

From podcvd modification, you could see any header row(e.g. GROUPS NAMES ID ...) isn't hardcoded there.

lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) < 1 || lines[0] == "" {
return nil, nil, fmt.Errorf("empty cvd ps output: missing header")
}
cols, err := parsePsHeader(lines[0])
if err != nil {
return nil, nil, err
}
var rows []map[string]string
for _, line := range lines[1:] {
rows = append(rows, parsePsRow(line, cols, ipAddr))
}
return rows, cols, nil
}

func combinePsOutputs(allRows []map[string]string, colNames []string, out io.Writer) error {
w := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, strings.Join(colNames, "\t"))
for _, rowMap := range allRows {
var rowValues []string
for _, colName := range colNames {
rowValues = append(rowValues, rowMap[colName])
}
fmt.Fprintln(w, strings.Join(rowValues, "\t"))
}
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to flush output: %w", err)
}
return nil
}
54 changes: 54 additions & 0 deletions container/src/podcvd/internal/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@
package internal

import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net"
Expand All @@ -29,6 +32,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -117,6 +121,56 @@ func ExecFetchCmdOnDisposableHost(ccm CuttlefishContainerManager, cvdArgs *CvdAr
return nil
}

type HostExecResult struct {
GroupName string
IP string
Stdout []byte
}

func ExecOnAllCuttlefishHosts(ccm CuttlefishContainerManager, subcommandArgs []string, stderr io.Writer) ([]HostExecResult, error) {
groupNameIpAddrMap, err := Ipv4AddressesByGroupNames(ccm, false)
if err != nil {
return nil, fmt.Errorf("failed to get IPv4 addresses for group names: %w", err)
}

var wg sync.WaitGroup
wg.Add(len(groupNameIpAddrMap))
resCh := make(chan HostExecResult, len(groupNameIpAddrMap))
errCh := make(chan error, len(groupNameIpAddrMap))
for groupName, ip := range groupNameIpAddrMap {
go func(groupName, ip string) {
defer wg.Done()
containerName := ContainerName(groupName)
var stdoutBuf bytes.Buffer
if err := ccm.ExecOnContainer(context.Background(), containerName, subcommandArgs, nil, &stdoutBuf, stderr); err != nil {
errCh <- fmt.Errorf("failed to run %v on container %q: %w", subcommandArgs, containerName, err)
return
}
resCh <- HostExecResult{
GroupName: groupName,
IP: ip,
Stdout: stdoutBuf.Bytes(),
}
}(groupName, ip)
}
wg.Wait()
close(resCh)
close(errCh)

var errs []error
for err := range errCh {
errs = append(errs, err)
}
if err := errors.Join(errs...); err != nil {
return nil, err
}
var results []HostExecResult
for res := range resCh {
results = append(results, res)
}
return results, nil
}

func pullContainerImage(ccm CuttlefishContainerManager) error {
if exists, err := ccm.ImageExists(context.Background(), imageName); err != nil {
return err
Expand Down
82 changes: 39 additions & 43 deletions container/src/podcvd/internal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ func Main(args []string) error {
if err := fleetAllCuttlefishHosts(ccm); err != nil {
return err
}
case "ps":
if err := psAllCuttlefishHosts(ccm); err != nil {
return err
}
case "cache", "help", "lint", "login", "version":
if err := handleToolingSubcommands(ccm, cvdArgs); err != nil {
return err
Expand Down Expand Up @@ -285,57 +289,35 @@ func fleetAllCuttlefishHosts(ccm CuttlefishContainerManager) error {
Groups []any `json:"groups"`
}

groupNameIpAddrMap, err := Ipv4AddressesByGroupNames(ccm, false)
results, err := ExecOnAllCuttlefishHosts(ccm, []string{"cvd", "fleet"}, nil)
if err != nil {
return fmt.Errorf("failed to get IPv4 addresses for group names: %w", err)
}
var wg sync.WaitGroup
wg.Add(len(groupNameIpAddrMap))
resCh := make(chan cvdFleetResponse, len(groupNameIpAddrMap))
errCh := make(chan error, len(groupNameIpAddrMap))
for groupName, ip := range groupNameIpAddrMap {
go func(groupName, ip string) {
defer wg.Done()
containerName := ContainerName(groupName)
var stdoutBuf bytes.Buffer
if err := ccm.ExecOnContainer(context.Background(), containerName, []string{"cvd", "fleet"}, nil, &stdoutBuf, nil); err != nil {
errCh <- err
return
}
var res cvdFleetResponse
if err := json.Unmarshal(stdoutBuf.Bytes(), &res); err != nil {
errCh <- err
return
}
containerInfo, err := ccm.InspectContainer(context.Background(), containerName)
if err != nil {
errCh <- err
return
}
attemptID := containerInfo.Config.Labels["attempt_id"]
podcvdHomeDir := filepath.Join("/var/tmp/podcvd", strconv.Itoa(os.Getuid()), attemptID)
for idx := range res.Groups {
UpdateCvdGroupJsonRaw(res.Groups[idx], podcvdHomeDir, ip)
}
resCh <- res
}(groupName, ip)
return err
}
wg.Wait()
close(resCh)
close(errCh)

var errs []error
for err := range errCh {
errs = append(errs, err)
containers, err := ccm.ListContainers(context.Background(), false)
if err != nil {
return fmt.Errorf("failed to list containers: %w", err)
}
if err := errors.Join(errs...); err != nil {
return err
podcvdHomeDirMap := make(map[string]string)
uid := strconv.Itoa(os.Getuid())
for _, c := range containers {
if groupName, ok := c.Labels[labelGroupName]; ok {
podcvdHomeDirMap[groupName] = filepath.Join("/var/tmp/podcvd", uid, c.Labels[labelAttemptID])
}
}

combinedRes := cvdFleetResponse{
Groups: []any{},
}
for res := range resCh {
combinedRes.Groups = append(combinedRes.Groups, res.Groups...)
for _, res := range results {
var fleetRes cvdFleetResponse
if err := json.Unmarshal(res.Stdout, &fleetRes); err != nil {
return err
}
for idx := range fleetRes.Groups {
UpdateCvdGroupJsonRaw(fleetRes.Groups[idx], podcvdHomeDirMap[res.GroupName], res.IP)
}
combinedRes.Groups = append(combinedRes.Groups, fleetRes.Groups...)
}
combinedOutput, err := json.MarshalIndent(combinedRes, "", " ")
if err != nil {
Expand All @@ -345,6 +327,20 @@ func fleetAllCuttlefishHosts(ccm CuttlefishContainerManager) error {
return nil
}

func psAllCuttlefishHosts(ccm CuttlefishContainerManager) error {
results, err := ExecOnAllCuttlefishHosts(ccm, []string{"cvd", "ps"}, os.Stderr)
if err != nil {
return err
}
if len(results) > 0 {
return PrintPsOutputs(results, os.Stdout)
}
if err := CreateToolingHost(ccm); err != nil {
return err
}
return ccm.ExecOnContainer(context.Background(), ToolingContainerName, []string{"cvd", "ps"}, nil, os.Stdout, os.Stderr)
}

func handleToolingSubcommands(ccm CuttlefishContainerManager, cvdArgs *CvdArgs) error {
if err := CreateToolingHost(ccm); err != nil {
return err
Expand Down
Loading