p4survey.go #3

  • //
  • guest/
  • perforce_software/
  • p4survey/
  • p4survey.go
  • View
  • Commits
  • Open Download .zip Download (21 KB)
// This program is a utility to be provided to customers called P4Survey
// It produces a Markdown file with the results of the survey for sending to consulting or support.
// Works on Linux, MacOS and Windows
package main

import (
	"bytes"
	"fmt"
	"math"
	"os"
	"os/user"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/bitfield/script"
	"github.com/rcowham/kingpin"
	"github.com/sirupsen/logrus"
)

const description = `A survey tool for Perforce P4 servers.
Will report on the server configuration.

Must be run as a logged in superuser.
`
const VersionChange = "$Change: 2765127 $"

var GoVersion = runtime.Version()

// versionInfoTmpl contains the template used by Info.
var versionInfoTmpl = `
{{.program}}, version {{.version}} (change: {{.change}})
  go version:       {{.goVersion}}
`

// Print returns version information.
func VersionPrint(program string, version string) string {
	change := strings.TrimPrefix(VersionChange, "$Change: ")
	change = strings.TrimSuffix(change, " $")
	m := map[string]string{
		"program":   program,
		"version":   version,
		"change":    change,
		"goVersion": GoVersion,
	}
	t := template.Must(template.New("version").Parse(versionInfoTmpl))

	var buf bytes.Buffer
	if err := t.ExecuteTemplate(&buf, "version", m); err != nil {
		panic(err)
	}
	return strings.TrimSpace(buf.String())
}

// P4Survey structure
type P4Survey struct {
	logger             *logrus.Logger
	p4port             string
	p4user             string
	p4Cmd              string
	output             []string
	filteredDepotNames map[string]struct{}
	depotSummaryLine   string
}

type SurveyCommand struct {
	Display                 string
	Exec                    string
	StripHashComment        bool
	FormatDBStat            bool
	FormatDepots            bool
	FilterDiskspaceByDepots bool
}

// IsNumeric checks if a string can be parsed as a number
func IsNumeric(s string) bool {
	_, err := strconv.ParseFloat(s, 64)
	return err == nil
}

func removeDuplicates(slice []string) []string {
	seen := make(map[string]bool)
	result := make([]string, 0, len(slice))
	for _, item := range slice {
		if _, exists := seen[item]; !exists {
			seen[item] = true
			result = append(result, item)
		}
	}
	return result
}

func newP4Survey(logger *logrus.Logger) *P4Survey {
	return &P4Survey{
		logger:             logger,
		output:             make([]string, 0),
		filteredDepotNames: make(map[string]struct{}),
		depotSummaryLine:   "",
	}
}

func (s *P4Survey) Getenv() error {
	errbuf := new(bytes.Buffer)
	p := script.NewPipe().WithStderr(errbuf)
	cmd := "p4 set"
	s.logger.Debugf("cmd: %s", cmd)
	output, err := p.Exec(cmd).Slice()
	if err != nil {
		s.logger.Errorf("Error running %s: %v, err:%q", cmd, err, errbuf.String())
		return err
	}
	s.logger.Debugf("env: %v", output)
	for _, line := range output {
		pair := strings.SplitN(line, "=", 2)
		if len(pair) != 2 {
			pair = strings.SplitN(line, ":", 2)
		}
		if len(pair) != 2 {
			s.logger.Debugf("Skipping non key/value environment line: %s", line)
			continue
		}

		key := strings.TrimSpace(pair[0])
		val := strings.TrimSpace(pair[1])

		if key == "P4PORT" {
			s.p4port = val
			if idx := strings.Index(val, " (config"); idx != -1 {
				s.p4port = val[:idx]
			}
		}
		if key == "P4USER" {
			s.p4user = val
			if idx := strings.Index(val, " (config"); idx != -1 {
				s.p4user = val[:idx]
			}
		}
	}
	return nil
}

func (s *P4Survey) newP4CmdPipe(cmd string) (string, *bytes.Buffer, *script.Pipe) {
	errbuf := new(bytes.Buffer)
	p := script.NewPipe().WithStderr(errbuf)
	cmd = fmt.Sprintf("%s %s", s.p4Cmd, cmd)
	s.logger.Debugf("cmd: %s", cmd)
	return cmd, errbuf, p
}

// Helper function to check if a string contains a substring
func contains(s, substr string) bool {
	return strings.Contains(s, substr)
}

func (s *P4Survey) runCommand(cmd string) (string, string, error) {
	errbuf := new(bytes.Buffer)
	p := script.NewPipe().WithStderr(errbuf)
	s.logger.Debugf("exec: %s", cmd)
	out, err := p.Exec(cmd).String()
	return strings.TrimSpace(out), strings.TrimSpace(errbuf.String()), err
}

func removeHashCommentLines(text string) string {
	if text == "" {
		return text
	}

	lines := strings.Split(text, "\n")
	filtered := make([]string, 0, len(lines))
	for _, line := range lines {
		if strings.HasPrefix(strings.TrimSpace(line), "#") {
			continue
		}
		filtered = append(filtered, line)
	}

	return strings.TrimSpace(strings.Join(filtered, "\n"))
}

func humanizeBytes(size int64) string {
	if size < 1024 {
		return fmt.Sprintf("%d bytes", size)
	}

	units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
	value := float64(size)
	unitIdx := -1
	for value >= 1024 && unitIdx < len(units)-1 {
		value = value / 1024
		unitIdx++
	}

	if math.Mod(value, 1.0) == 0 {
		return fmt.Sprintf("%.0f %s", value, units[unitIdx])
	}
	return fmt.Sprintf("%.1f %s", value, units[unitIdx])
}

func formatDBStatOutput(text string) string {
	if strings.TrimSpace(text) == "" {
		return text
	}

	type dbstatLine struct {
		prefix string
		size   int64
	}

	entries := make([]dbstatLine, 0)
	otherLines := make([]string, 0)
	totalSize := int64(0)
	totalLines := int64(0)

	for _, line := range strings.Split(text, "\n") {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			continue
		}
		totalLines++

		fields := strings.Fields(trimmed)
		bytesPos := -1
		for i := 1; i < len(fields); i++ {
			if fields[i] == "bytes" && IsNumeric(fields[i-1]) {
				bytesPos = i
				break
			}
		}

		if bytesPos <= 0 {
			otherLines = append(otherLines, line)
			continue
		}

		size, err := strconv.ParseInt(fields[bytesPos-1], 10, 64)
		if err != nil {
			otherLines = append(otherLines, line)
			continue
		}

		entries = append(entries, dbstatLine{
			prefix: strings.Join(fields[:bytesPos-1], " "),
			size:   size,
		})
		totalSize += size
	}

	sort.Slice(entries, func(i, j int) bool {
		if entries[i].size == entries[j].size {
			return entries[i].prefix < entries[j].prefix
		}
		return entries[i].size < entries[j].size
	})

	start := 0
	if len(entries) > 20 {
		start = len(entries) - 20
	}
	shownEntries := entries[start:]

	out := make([]string, 0, len(shownEntries)+len(otherLines)+3)
	maxPrefixWidth := 0
	maxSizeWidth := 0
	humanSizes := make([]string, len(shownEntries))
	for i, entry := range shownEntries {
		human := humanizeBytes(entry.size)
		humanSizes[i] = human
		if len(entry.prefix) > maxPrefixWidth {
			maxPrefixWidth = len(entry.prefix)
		}
		if len(human) > maxSizeWidth {
			maxSizeWidth = len(human)
		}
	}
	for i, entry := range shownEntries {
		out = append(out, fmt.Sprintf("%-*s %*s", maxPrefixWidth, entry.prefix, maxSizeWidth, humanSizes[i]))
	}
	out = append(out, otherLines...)
	if len(entries) > 0 {
		out = append(out, fmt.Sprintf("Showing %d of %d dbstat size lines (largest files).", len(shownEntries), len(entries)))
	}
	out = append(out, fmt.Sprintf("Total non-empty dbstat lines: %d", totalLines))
	if len(entries) > 0 {
		out = append(out, fmt.Sprintf("Total size: %s", humanizeBytes(totalSize)))
	}

	return strings.TrimSpace(strings.Join(out, "\n"))
}

func firstLine(s string) string {
	line := strings.TrimSpace(strings.SplitN(strings.ReplaceAll(s, "\r\n", "\n"), "\n", 2)[0])
	return strings.Join(strings.Fields(line), " ")
}

type depot struct {
	name  string
	typev string
	depth string
	mapp  string
	desc  string
}

func parseDepotsOutput(text string) []depot {
	depots := make([]depot, 0)
	current := depot{}
	haveCurrent := false

	flushDepot := func() {
		if !haveCurrent || current.name == "" {
			return
		}
		current.desc = firstLine(current.desc)
		depots = append(depots, current)
		current = depot{}
		haveCurrent = false
	}

	for _, raw := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
		line := strings.TrimSpace(raw)
		if line == "" {
			flushDepot()
			continue
		}

		if !strings.HasPrefix(line, "... ") {
			if haveCurrent && current.desc != "" {
				current.desc = firstLine(current.desc)
			}
			continue
		}

		rest := strings.TrimPrefix(line, "... ")
		parts := strings.SplitN(rest, " ", 2)
		if len(parts) != 2 {
			continue
		}

		key := strings.TrimSpace(parts[0])
		value := strings.TrimSpace(parts[1])
		if key == "name" {
			if haveCurrent {
				flushDepot()
			}
			haveCurrent = true
			current.name = value
			continue
		}

		if !haveCurrent {
			continue
		}

		switch key {
		case "type":
			current.typev = value
		case "depth":
			current.depth = value
		case "map":
			current.mapp = value
		case "desc":
			if current.desc == "" {
				current.desc = value
			}
		}
	}
	flushDepot()

	return depots
}

func selectDepots(depots []depot) ([]depot, map[string]struct{}) {
	selected := depots
	filteredOut := make(map[string]struct{})

	if len(depots) > 20 {
		prioritized := make([]depot, 0, len(depots))
		common := make([]depot, 0, len(depots))
		for _, d := range depots {
			if d.typev == "local" || d.typev == "stream" {
				common = append(common, d)
			} else {
				prioritized = append(prioritized, d)
			}
		}

		selected = make([]depot, 0, 20)
		if len(prioritized) >= 20 {
			selected = append(selected, prioritized[:20]...)
		} else {
			selected = append(selected, prioritized...)
			remaining := 20 - len(selected)
			if remaining > len(common) {
				remaining = len(common)
			}
			selected = append(selected, common[:remaining]...)
		}

		selectedNames := make(map[string]struct{}, len(selected))
		for _, d := range selected {
			selectedNames[d.name] = struct{}{}
		}
		for _, d := range depots {
			if _, ok := selectedNames[d.name]; !ok {
				filteredOut[d.name] = struct{}{}
			}
		}
	}

	return selected, filteredOut
}

func formatDepotsOutput(text string) (string, map[string]struct{}, string) {
	if strings.TrimSpace(text) == "" {
		return text, make(map[string]struct{}), ""
	}

	depots := parseDepotsOutput(text)

	if len(depots) == 0 {
		return strings.TrimSpace(text), make(map[string]struct{}), ""
	}

	counts := make(map[string]int)
	for _, d := range depots {
		t := d.typev
		if t == "" {
			t = "unknown"
		}
		counts[t]++
	}

	selected, filteredOut := selectDepots(depots)

	out := make([]string, 0, len(selected)+4)
	for _, d := range selected {
		typev := d.typev
		if typev == "" {
			typev = "unknown"
		}
		desc := d.desc
		if desc == "" {
			desc = "(no description)"
		}
		out = append(out, fmt.Sprintf("name=%s type=%s map=%s desc=%s", d.name, typev, d.mapp, desc))
	}

	summaryLine := ""
	if len(depots) > 20 {
		summaryLine = fmt.Sprintf("Showing %d of %d depots (filtered local/stream where possible).", len(selected), len(depots))
		out = append(out, summaryLine)
	} else {
		summaryLine = fmt.Sprintf("Total depots: %d", len(depots))
		out = append(out, summaryLine)
	}

	types := make([]string, 0, len(counts))
	for t := range counts {
		types = append(types, t)
	}
	sort.Strings(types)
	summary := make([]string, 0, len(types))
	for _, t := range types {
		summary = append(summary, fmt.Sprintf("%s=%d", t, counts[t]))
	}
	out = append(out, fmt.Sprintf("Depot types: %s", strings.Join(summary, ", ")))

	return strings.Join(out, "\n"), filteredOut, summaryLine
}

func filterDiskspaceOutputByDepotNames(text string, filteredDepotNames map[string]struct{}, summaryLine string) string {
	if strings.TrimSpace(text) == "" || len(filteredDepotNames) == 0 {
		if summaryLine == "" {
			return text
		}
		if strings.TrimSpace(text) == "" {
			return summaryLine
		}
		return strings.TrimSpace(text) + "\n" + summaryLine
	}

	lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
	out := make([]string, 0, len(lines))

	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			out = append(out, line)
			continue
		}

		fields := strings.Fields(trimmed)
		if len(fields) == 0 {
			out = append(out, line)
			continue
		}

		token := strings.TrimSuffix(fields[0], ":")
		if slash := strings.Index(token, "/"); slash >= 0 {
			token = token[:slash]
		}

		if _, shouldFilter := filteredDepotNames[token]; shouldFilter {
			continue
		}

		out = append(out, line)
	}

	result := strings.TrimSpace(strings.Join(out, "\n"))
	if summaryLine == "" {
		return result
	}
	if result == "" {
		return summaryLine
	}
	return result + "\n" + summaryLine
}

func platformOverviewCommand() SurveyCommand {
	switch runtime.GOOS {
	case "linux":
		return SurveyCommand{Display: "OS Version", Exec: "cat /etc/os-release"}
	case "darwin":
		return SurveyCommand{Display: "OS Version", Exec: "sw_vers"}
	case "windows":
		return SurveyCommand{Display: "OS Version", Exec: "ver"}
	default:
		return SurveyCommand{Display: "OS Version", Exec: "uname -a"}
	}
}

func surveyCommands(p4Cmd string) []SurveyCommand {
	return []SurveyCommand{
		platformOverviewCommand(),
		{Display: "p4 -ztag info", Exec: fmt.Sprintf("%s -ztag info", p4Cmd)},
		{Display: "p4 license -u", Exec: fmt.Sprintf("%s license -u", p4Cmd)},
		{Display: "p4 -ztag depots", Exec: fmt.Sprintf("%s -ztag depots", p4Cmd), FormatDepots: true},
		{Display: "p4 diskspace", Exec: fmt.Sprintf("%s diskspace", p4Cmd), FilterDiskspaceByDepots: true},
		{Display: "p4 configure show", Exec: fmt.Sprintf("%s configure show", p4Cmd)},
		{Display: "p4 configure show allservers", Exec: fmt.Sprintf("%s configure show allservers", p4Cmd)},
		{Display: "p4 servers -J", Exec: fmt.Sprintf("%s servers -J", p4Cmd)},
		{Display: "p4 -ztag servers", Exec: fmt.Sprintf("%s -ztag servers", p4Cmd)},
		{Display: "p4 triggers -o", Exec: fmt.Sprintf("%s triggers -o", p4Cmd), StripHashComment: true},
		{Display: "p4 extension --list --type extensions", Exec: fmt.Sprintf("%s extension --list --type extensions", p4Cmd)},
		{Display: "p4 extension --list --type global", Exec: fmt.Sprintf("%s extension --list --type global", p4Cmd)},
		{Display: "p4 extension --list --type instance", Exec: fmt.Sprintf("%s extension --list --type instance", p4Cmd)},
		{Display: "p4 extension --list --type certs", Exec: fmt.Sprintf("%s extension --list --type certs", p4Cmd)},
		{Display: "p4 property -n P4.Swarm.URL -l", Exec: fmt.Sprintf("%s property -n P4.Swarm.URL -l", p4Cmd)},
		{Display: "p4 keys -e \"swarm-*\" -m 1", Exec: fmt.Sprintf("%s keys -e \"swarm-*\" -m 1", p4Cmd)},
		{Display: "p4 keys -e \"git-*\" -m 1", Exec: fmt.Sprintf("%s keys -e \"git-*\" -m 1", p4Cmd)},
		{Display: "p4 typemap -o", Exec: fmt.Sprintf("%s typemap -o", p4Cmd), StripHashComment: true},
		{Display: "p4 jobspec -o", Exec: fmt.Sprintf("%s jobspec -o", p4Cmd), StripHashComment: true},
		{Display: "p4 counter change", Exec: fmt.Sprintf("%s counter change", p4Cmd)},
		{Display: "p4 dbstat -s", Exec: fmt.Sprintf("%s dbstat -s", p4Cmd), FormatDBStat: true},
		{Display: "p4 journals -m 3", Exec: fmt.Sprintf("%s journals -m 3", p4Cmd)},
		{Display: "p4 protects -u remote", Exec: fmt.Sprintf("%s protects -u remote", p4Cmd)},
	}
}

// Run executes the P4Survey checks
func (s *P4Survey) Run() error {
	p4cmd, errbuf, p := s.newP4CmdPipe("-ztag info")
	if _, err := p.Exec(p4cmd).Slice(); err != nil {
		s.output = append(s.output, fmt.Sprintf("Error connecting to server %s: %v, err:%q", p4cmd, err, errbuf.String()))
		s.logger.Errorf("Error running %s: %v, err:%q", p4cmd, err, errbuf.String())
		return err
	}

	p4cmd, errbuf, p = s.newP4CmdPipe("login -s")
	if _, err := p.Exec(p4cmd).Slice(); err != nil {
		if strings.Contains(errbuf.String(), "Perforce password (P4PASSWD) invalid or unset") {
			s.output = append(s.output, fmt.Sprintf("Not logged in to server %q as user %q", s.p4port, s.p4user))
			return nil
		}
		s.output = append(s.output, fmt.Sprintf("Error connecting to server %s: %v, err:%q", p4cmd, err, errbuf.String()))
		s.logger.Errorf("Error running %s: %v, err:%q", p4cmd, err, errbuf.String())
		return err
	}

	p4cmd, errbuf, p = s.newP4CmdPipe("protects -m")
	privilege, err := p.Exec(p4cmd).String()
	if err != nil {
		s.output = append(s.output, fmt.Sprintf("Error checking protects %s: %v, err:%q", p4cmd, err, errbuf.String()))
		s.logger.Errorf("Error running %s: %v, err:%q", p4cmd, err, errbuf.String())
		return err
	}
	if strings.TrimSpace(privilege) != "super" {
		s.output = append(s.output, fmt.Sprintf("ERROR: user %q is not a superuser on P4PORT %q", s.p4user, s.p4port))
		return nil
	}

	hostname, err := os.Hostname()
	if err != nil {
		hostname = "unknown-host"
	}
	shortHost := strings.Split(hostname, ".")[0]
	timestamp := time.Now().Format("2006-01-02-150405")
	reportFile := fmt.Sprintf("p4survey.%s.%s.md", shortHost, timestamp)

	var md strings.Builder
	md.WriteString("# P4 Survey Report\n\n")
	md.WriteString(fmt.Sprintf("- Generated: %s\n", time.Now().Format(time.RFC3339)))
	md.WriteString(fmt.Sprintf("- Host: %s\n", hostname))
	md.WriteString(fmt.Sprintf("- P4PORT: %s\n", s.p4port))
	md.WriteString(fmt.Sprintf("- P4USER: %s\n\n", s.p4user))
	md.WriteString("## Command Results\n\n")

	commands := surveyCommands(s.p4Cmd)
	failures := 0

	for _, cmd := range commands {
		stdout, stderr, cmdErr := s.runCommand(cmd.Exec)
		if cmd.StripHashComment {
			stdout = removeHashCommentLines(stdout)
			stderr = removeHashCommentLines(stderr)
		}
		if cmd.FormatDBStat {
			stdout = formatDBStatOutput(stdout)
			stderr = formatDBStatOutput(stderr)
		}
		if cmd.FormatDepots {
			formattedStdout, filteredFromStdout, summaryFromStdout := formatDepotsOutput(stdout)
			formattedStderr, filteredFromStderr, summaryFromStderr := formatDepotsOutput(stderr)
			stdout = formattedStdout
			stderr = formattedStderr
			if summaryFromStdout != "" {
				s.depotSummaryLine = summaryFromStdout
			} else if summaryFromStderr != "" {
				s.depotSummaryLine = summaryFromStderr
			}
			for name := range filteredFromStdout {
				s.filteredDepotNames[name] = struct{}{}
			}
			for name := range filteredFromStderr {
				s.filteredDepotNames[name] = struct{}{}
			}
		}
		if cmd.FilterDiskspaceByDepots {
			stdout = filterDiskspaceOutputByDepotNames(stdout, s.filteredDepotNames, s.depotSummaryLine)
			stderr = filterDiskspaceOutputByDepotNames(stderr, s.filteredDepotNames, "")
		}
		if cmdErr != nil {
			failures++
		}

		md.WriteString(fmt.Sprintf("### %s\n\n", cmd.Display))
		if cmdErr != nil {
			md.WriteString("- Status: failed\n")
			md.WriteString(fmt.Sprintf("- Error: %s\n\n", cmdErr.Error()))
		} else {
			md.WriteString("- Status: ok\n\n")
		}

		if stdout != "" {
			md.WriteString("#### Stdout\n\n")
			md.WriteString("~~~text\n")
			md.WriteString(stdout)
			md.WriteString("\n~~~\n\n")
		}

		if stderr != "" {
			md.WriteString("#### Stderr\n\n")
			md.WriteString("~~~text\n")
			md.WriteString(stderr)
			md.WriteString("\n~~~\n\n")
		}

		if stdout == "" && stderr == "" {
			md.WriteString("_No output returned._\n\n")
		}
	}

	md.WriteString("## Notes\n\n")
	md.WriteString(fmt.Sprintf("- Total commands: %d\n", len(commands)))
	md.WriteString(fmt.Sprintf("- Failed commands: %d\n", failures))

	if err := os.WriteFile(reportFile, []byte(md.String()), 0644); err != nil {
		return err
	}

	s.output = append(s.output, fmt.Sprintf("Markdown report written: %s", reportFile))
	return nil
}

func main() {

	// Set up the command line arguments
	var (
		app    = kingpin.New("p4survey", description)
		p4user = app.Flag("p4user", "(Optional) Perforce user (default $P4USER)").String()
		p4port = app.Flag("p4port", "(Optional) Perforce server port (default $P4PORT)").String()
		p4bin  = app.Flag("p4bin", "(Optional) Full path of p4 CLI (default is 'p4' in $PATH)").String()
		debug  = app.Flag("debug", "Debug output").Bool()
	)
	app.Version(VersionPrint("p4survey", "v0.1"))
	app.HelpFlag.Short('h')
	app.VersionFlag.Short('V')
	app.Parse(os.Args[1:])

	// Set up the logger
	logger := logrus.New()
	// logger.SetFormatter(&logrus.TextFormatter{
	// 	FullTimestamp: true,
	// })
	logger.SetLevel(logrus.InfoLevel)
	if *debug {
		logger.Level = logrus.DebugLevel
	}
	logger.SetOutput(os.Stdout)
	logger.Debug("Debug output enabled")
	workingDir, err := os.Getwd()
	if err != nil {
		logger.Fatalf("Error getting working directory: %v\n", err)
	}
	logger.Debugf("Current working directory: %s\n", workingDir)

	// Create a new P4Survey instance
	ps := newP4Survey(logger)
	if *p4port == "" || *p4user == "" {
		// Try to get the P4PORT and P4USER from the environment
		ps.Getenv()
	}
	if *p4port != "" {
		ps.p4port = *p4port
	}
	if *p4user != "" {
		ps.p4user = *p4user
	}
	if ps.p4port == "" {
		logger.Info("P4PORT is not set - using default: perforce:1666")
	}
	if ps.p4user == "" {
		currentUser, err := user.Current()
		if err != nil {
			logger.Fatal(err.Error())
		}
		username := currentUser.Username
		logger.Infof("P4USER not set - using current user: %s", username)
		ps.p4user = username
	}
	bin := "p4"
	if *p4bin != "" {
		bin = *p4bin
	}
	ps.p4Cmd = fmt.Sprintf("\"%s\" -p \"%s\" -u %s", bin, ps.p4port, ps.p4user)
	fmt.Printf("Running p4survey analysis as user %q on P4PORT %q\n", ps.p4user, ps.p4port)

	// Otherwise run the analysis
	err = ps.Run()
	for _, line := range ps.output {
		fmt.Println(line)
	}
	if err != nil {
		logger.Fatal(err)
	}
	fmt.Println("If you have any questions, please contact [email protected]")
}
# Change User Description Committed
#3 32954 Robert Cowham Clarified instructions
#2 32952 Robert Cowham Tweak output formatting to filter lots of depots
#1 32951 Robert Cowham New p4survey tool