// 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]")
}