// 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 support-p4@perforce.com") }