diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4828258..f5c0039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.24' - name: Format check run: | @@ -29,7 +29,7 @@ jobs: - name: Install golangci-lint run: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.61.0 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.8 echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - name: Lint Go code @@ -257,7 +257,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.24' - name: Install PostgreSQL run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59efda1..6d1456c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ radar uses the following exit codes: ### Prerequisites -- **Go**: 1.23 or higher +- **Go**: 1.24 or higher - **Git**: For version control - **golangci-lint**: For linting (install via `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`) - **Docker**: For integration testing (optional but recommended) diff --git a/DATA.md b/DATA.md index 9a45589..c234f1a 100644 --- a/DATA.md +++ b/DATA.md @@ -110,6 +110,67 @@ These collectors only run on Linux systems. | `system/tuned/tuned-list.out` | `tuned-adm list` | Available tuned profiles | | `system/vmstat-command.out` | `vmstat 1 10` | Virtual memory statistics (10 samples) | +### Cgroup v2 Resource Limits (Linux) + +Attempted on Linux (skipped if unavailable). Shows actual container resource limits instead of host values. + +| File | Source | Description | +|------|--------|-------------| +| `system/cgroup/cpu_max.out` | `/sys/fs/cgroup/cpu.max` | CPU bandwidth limit (quota/period) | +| `system/cgroup/cpu_weight.out` | `/sys/fs/cgroup/cpu.weight` | CPU weight (relative share) | +| `system/cgroup/cpuset_cpus_effective.out` | `/sys/fs/cgroup/cpuset.cpus.effective` | Effective CPU set | +| `system/cgroup/io_max.out` | `/sys/fs/cgroup/io.max` | I/O bandwidth limits | +| `system/cgroup/memory_current.out` | `/sys/fs/cgroup/memory.current` | Current memory usage | +| `system/cgroup/memory_max.out` | `/sys/fs/cgroup/memory.max` | Memory limit | +| `system/cgroup/memory_stat.out` | `/sys/fs/cgroup/memory.stat` | Detailed memory statistics | +| `system/cgroup/memory_swap_max.out` | `/sys/fs/cgroup/memory.swap.max` | Swap limit | +| `system/cgroup/pids_current.out` | `/sys/fs/cgroup/pids.current` | Current number of PIDs | +| `system/cgroup/pids_max.out` | `/sys/fs/cgroup/pids.max` | PID limit | + +### Cgroup v1 Resource Limits (Linux) + +Attempted on Linux (skipped if unavailable). Present on older kernels (pre-cgroup v2 unified hierarchy). + +| File | Source | Description | +|------|--------|-------------| +| `system/cgroup-v1/cpu_cfs_period_us.out` | `/sys/fs/cgroup/cpu/cpu.cfs_period_us` | CFS scheduling period | +| `system/cgroup-v1/cpu_cfs_quota_us.out` | `/sys/fs/cgroup/cpu/cpu.cfs_quota_us` | CFS CPU quota (-1 = unlimited) | +| `system/cgroup-v1/cpu_shares.out` | `/sys/fs/cgroup/cpu/cpu.shares` | CPU shares (relative weight) | +| `system/cgroup-v1/cpuset_cpus.out` | `/sys/fs/cgroup/cpuset/cpuset.cpus` | Allowed CPUs | +| `system/cgroup-v1/memory_limit_in_bytes.out` | `/sys/fs/cgroup/memory/memory.limit_in_bytes` | Memory limit | +| `system/cgroup-v1/memory_stat.out` | `/sys/fs/cgroup/memory/memory.stat` | Detailed memory statistics | +| `system/cgroup-v1/memory_usage_in_bytes.out` | `/sys/fs/cgroup/memory/memory.usage_in_bytes` | Current memory usage | + +### Cloud/Hardware Identity (Linux) + +Attempted on Linux (skipped if unavailable). Identifies cloud provider and instance type via DMI data. + +| File | Source | Description | +|------|--------|-------------| +| `system/cloud/bios_vendor.out` | `/sys/class/dmi/id/bios_vendor` | BIOS vendor (e.g. Amazon EC2, Google) | +| `system/cloud/chassis_asset_tag.out` | `/sys/class/dmi/id/chassis_asset_tag` | Chassis asset tag (e.g. AWS instance ID) | +| `system/cloud/product_name.out` | `/sys/class/dmi/id/product_name` | Product name (e.g. instance type) | +| `system/cloud/sys_vendor.out` | `/sys/class/dmi/id/sys_vendor` | System vendor | + +### Container Identity (Linux) + +Attempted on Linux (skipped if unavailable). Helps identify container runtime and environment. + +| File | Source | Description | +|------|--------|-------------| +| `system/container/cgroup_membership.out` | `/proc/1/cgroup` | Cgroup membership (container signatures) | +| `system/container/mountinfo.out` | `/proc/1/mountinfo` | PID 1 mount info (overlay detection) | + +### Container Detection (Linux, auto-detected) + +Only collected when running inside a container (Docker, Kubernetes, LXC, containerd). + +| File | Source | Description | +|------|--------|-------------| +| `system/container/dockerenv.out` | `test -f /.dockerenv` | Docker container detection | +| `system/container/environment.out` | `env \| grep` | Allowlisted env vars: `HOSTNAME`, `CONTAINER_ID`, `DOCKER_HOST`, `ECS_CLUSTER`, `ECS_CONTAINER_METADATA_URI`, `KUBERNETES_SERVICE_HOST`, `KUBERNETES_SERVICE_PORT`, `KUBERNETES_PORT` | +| `system/container/k8s_namespace.out` | `/run/secrets/.../namespace` | Kubernetes namespace | + --- ## macOS-Specific System Collectors diff --git a/README.md b/README.md index e22dbc9..d4f4ab3 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,10 @@ For a complete reference of all collected data, see [docs/data.md](docs/data.md) - **Operating system**: `apt`, `dnf`, `dpkg`, `hostname`, `hosts`, `limits.conf`, `locale`, `locale.conf`, `localectl`, `lsmod`, `lspci`, `machine-id`, `os-release`, `ps`, `rpm`, `system-release`, `systemctl`, `systemd-detect-virt`, `timedatectl`, `tuned-adm`, `uname`, `yum` - **Network**: `ifconfig`, `ip`, `netstat`, `resolv.conf`, `ss` - **Security**: `fips-mode-setup`, `openssl`, `sestatus`, `update-crypto-policies` +- **Cgroup v2**: `cpu.max`, `cpu.weight`, `cpuset.cpus.effective`, `io.max`, `memory.current`, `memory.max`, `memory.stat`, `memory.swap.max`, `pids.current`, `pids.max` +- **Cgroup v1**: `cpu.cfs_period_us`, `cpu.cfs_quota_us`, `cpu.shares`, `cpuset.cpus`, `memory.limit_in_bytes`, `memory.stat`, `memory.usage_in_bytes` +- **Cloud/hardware identity**: `bios_vendor`, `chassis_asset_tag`, `product_name`, `sys_vendor` (DMI) +- **Container detection** (auto-detected): cgroup membership, mount info, Docker detection, Kubernetes namespace, container environment variables **PostgreSQL Instance** @@ -219,7 +223,7 @@ radar-hostname-20260115-133700.zip - **System**: Linux with standard utilities (lsblk, mount, df, ps, etc.) - **PostgreSQL**: Version 12+ (some features require 13+, 14+, or 16+) -- **Go**: 1.23+ (for building from source - see [CONTRIBUTING.md](CONTRIBUTING.md)) +- **Go**: 1.24+ (for building from source - see [CONTRIBUTING.md](CONTRIBUTING.md)) ## Performance diff --git a/container_linux_test.go b/container_linux_test.go new file mode 100644 index 0000000..c8376ef --- /dev/null +++ b/container_linux_test.go @@ -0,0 +1,53 @@ +//go:build linux + +/*------------------------------------------------------------------------- + * + * radar + * + * Portions copyright (c) 2026, pgEdge, Inc. + * This software is released under The PostgreSQL License + * + *------------------------------------------------------------------------- + */ + +package main + +import ( + "os" + "testing" +) + +func TestIsContainer(t *testing.T) { + _, err := os.Stat("/.dockerenv") + dockerExists := err == nil + got := isContainer() + if dockerExists && !got { + t.Fatalf("isContainer() = false, want true when /.dockerenv exists") + } + if !dockerExists && got { + t.Fatalf("isContainer() = true, want false on non-container host") + } +} + +func TestContainerCommandTasksStructure(t *testing.T) { + for i, task := range containerCommandTasks { + if task.Name == "" { + t.Errorf("containerCommandTasks[%d] missing Name", i) + } + if task.ArchivePath == "" { + t.Errorf("containerCommandTasks[%d] (%s) missing ArchivePath", i, task.Name) + } + if task.Command == "" { + t.Errorf("containerCommandTasks[%d] (%s) missing Command", i, task.Name) + } + } +} + +func TestContainerCommandTasksAlphabeticalOrder(t *testing.T) { + for i := 1; i < len(containerCommandTasks); i++ { + if containerCommandTasks[i].Name < containerCommandTasks[i-1].Name { + t.Errorf("containerCommandTasks not alphabetically ordered: %q comes after %q", + containerCommandTasks[i].Name, containerCommandTasks[i-1].Name) + } + } +} diff --git a/docs/data.md b/docs/data.md index 9a45589..c234f1a 100644 --- a/docs/data.md +++ b/docs/data.md @@ -110,6 +110,67 @@ These collectors only run on Linux systems. | `system/tuned/tuned-list.out` | `tuned-adm list` | Available tuned profiles | | `system/vmstat-command.out` | `vmstat 1 10` | Virtual memory statistics (10 samples) | +### Cgroup v2 Resource Limits (Linux) + +Attempted on Linux (skipped if unavailable). Shows actual container resource limits instead of host values. + +| File | Source | Description | +|------|--------|-------------| +| `system/cgroup/cpu_max.out` | `/sys/fs/cgroup/cpu.max` | CPU bandwidth limit (quota/period) | +| `system/cgroup/cpu_weight.out` | `/sys/fs/cgroup/cpu.weight` | CPU weight (relative share) | +| `system/cgroup/cpuset_cpus_effective.out` | `/sys/fs/cgroup/cpuset.cpus.effective` | Effective CPU set | +| `system/cgroup/io_max.out` | `/sys/fs/cgroup/io.max` | I/O bandwidth limits | +| `system/cgroup/memory_current.out` | `/sys/fs/cgroup/memory.current` | Current memory usage | +| `system/cgroup/memory_max.out` | `/sys/fs/cgroup/memory.max` | Memory limit | +| `system/cgroup/memory_stat.out` | `/sys/fs/cgroup/memory.stat` | Detailed memory statistics | +| `system/cgroup/memory_swap_max.out` | `/sys/fs/cgroup/memory.swap.max` | Swap limit | +| `system/cgroup/pids_current.out` | `/sys/fs/cgroup/pids.current` | Current number of PIDs | +| `system/cgroup/pids_max.out` | `/sys/fs/cgroup/pids.max` | PID limit | + +### Cgroup v1 Resource Limits (Linux) + +Attempted on Linux (skipped if unavailable). Present on older kernels (pre-cgroup v2 unified hierarchy). + +| File | Source | Description | +|------|--------|-------------| +| `system/cgroup-v1/cpu_cfs_period_us.out` | `/sys/fs/cgroup/cpu/cpu.cfs_period_us` | CFS scheduling period | +| `system/cgroup-v1/cpu_cfs_quota_us.out` | `/sys/fs/cgroup/cpu/cpu.cfs_quota_us` | CFS CPU quota (-1 = unlimited) | +| `system/cgroup-v1/cpu_shares.out` | `/sys/fs/cgroup/cpu/cpu.shares` | CPU shares (relative weight) | +| `system/cgroup-v1/cpuset_cpus.out` | `/sys/fs/cgroup/cpuset/cpuset.cpus` | Allowed CPUs | +| `system/cgroup-v1/memory_limit_in_bytes.out` | `/sys/fs/cgroup/memory/memory.limit_in_bytes` | Memory limit | +| `system/cgroup-v1/memory_stat.out` | `/sys/fs/cgroup/memory/memory.stat` | Detailed memory statistics | +| `system/cgroup-v1/memory_usage_in_bytes.out` | `/sys/fs/cgroup/memory/memory.usage_in_bytes` | Current memory usage | + +### Cloud/Hardware Identity (Linux) + +Attempted on Linux (skipped if unavailable). Identifies cloud provider and instance type via DMI data. + +| File | Source | Description | +|------|--------|-------------| +| `system/cloud/bios_vendor.out` | `/sys/class/dmi/id/bios_vendor` | BIOS vendor (e.g. Amazon EC2, Google) | +| `system/cloud/chassis_asset_tag.out` | `/sys/class/dmi/id/chassis_asset_tag` | Chassis asset tag (e.g. AWS instance ID) | +| `system/cloud/product_name.out` | `/sys/class/dmi/id/product_name` | Product name (e.g. instance type) | +| `system/cloud/sys_vendor.out` | `/sys/class/dmi/id/sys_vendor` | System vendor | + +### Container Identity (Linux) + +Attempted on Linux (skipped if unavailable). Helps identify container runtime and environment. + +| File | Source | Description | +|------|--------|-------------| +| `system/container/cgroup_membership.out` | `/proc/1/cgroup` | Cgroup membership (container signatures) | +| `system/container/mountinfo.out` | `/proc/1/mountinfo` | PID 1 mount info (overlay detection) | + +### Container Detection (Linux, auto-detected) + +Only collected when running inside a container (Docker, Kubernetes, LXC, containerd). + +| File | Source | Description | +|------|--------|-------------| +| `system/container/dockerenv.out` | `test -f /.dockerenv` | Docker container detection | +| `system/container/environment.out` | `env \| grep` | Allowlisted env vars: `HOSTNAME`, `CONTAINER_ID`, `DOCKER_HOST`, `ECS_CLUSTER`, `ECS_CONTAINER_METADATA_URI`, `KUBERNETES_SERVICE_HOST`, `KUBERNETES_SERVICE_PORT`, `KUBERNETES_PORT` | +| `system/container/k8s_namespace.out` | `/run/secrets/.../namespace` | Kubernetes namespace | + --- ## macOS-Specific System Collectors diff --git a/docs/index.md b/docs/index.md index 32de879..a0e3b86 100644 --- a/docs/index.md +++ b/docs/index.md @@ -135,6 +135,10 @@ For a complete reference of all collected data, see [data.md](data.md). - **Operating system**: `apt`, `dnf`, `dpkg`, `hostname`, `hosts`, `limits.conf`, `locale`, `locale.conf`, `localectl`, `lsmod`, `lspci`, `machine-id`, `os-release`, `ps`, `rpm`, `system-release`, `systemctl`, `systemd-detect-virt`, `timedatectl`, `tuned-adm`, `uname`, `yum` - **Network**: `ifconfig`, `ip`, `netstat`, `resolv.conf`, `ss` - **Security**: `fips-mode-setup`, `openssl`, `sestatus`, `update-crypto-policies` +- **Cgroup v2**: `cpu.max`, `cpu.weight`, `cpuset.cpus.effective`, `io.max`, `memory.current`, `memory.max`, `memory.stat`, `memory.swap.max`, `pids.current`, `pids.max` +- **Cgroup v1**: `cpu.cfs_period_us`, `cpu.cfs_quota_us`, `cpu.shares`, `cpuset.cpus`, `memory.limit_in_bytes`, `memory.stat`, `memory.usage_in_bytes` +- **Cloud/hardware identity**: `bios_vendor`, `chassis_asset_tag`, `product_name`, `sys_vendor` (DMI) +- **Container detection** (auto-detected): cgroup membership, mount info, Docker detection, Kubernetes namespace, container environment variables **PostgreSQL Instance** diff --git a/go.mod b/go.mod index 17a86df..a241bb4 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,7 @@ module radar -go 1.23 +go 1.24 require github.com/lib/pq v1.10.9 -require github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect +require github.com/DATA-DOG/go-sqlmock v1.5.2 diff --git a/postgres.go b/postgres.go index 96fd82e..e333698 100644 --- a/postgres.go +++ b/postgres.go @@ -167,6 +167,7 @@ func execPGQueryOnDB(dbname string, cfg *Config, query string, w io.Writer) erro return rowsToTSV(rows, w) } +// printSummary logs the archive filename, size, and collector count. func printSummary(totalCollected int, outputFile string, cfg *Config) { stat, err := os.Stat(outputFile) if err != nil { diff --git a/radar.go b/radar.go index bc7d644..300462d 100644 --- a/radar.go +++ b/radar.go @@ -13,6 +13,7 @@ package main import ( "archive/zip" "database/sql" + "errors" "flag" "fmt" "io" @@ -20,6 +21,7 @@ import ( "os" "os/exec" "os/user" + "strconv" "strings" "time" @@ -87,6 +89,7 @@ type lazyZipWriter struct { writer io.Writer // nil until first Write() } +// Write defers ZIP entry creation until first non-empty write. func (w *lazyZipWriter) Write(p []byte) (int, error) { if len(p) == 0 { return 0, nil // Ignore empty writes @@ -101,6 +104,7 @@ func (w *lazyZipWriter) Write(p []byte) (int, error) { return w.writer.Write(p) } +// WroteAny returns true if any data was written. func (w *lazyZipWriter) WroteAny() bool { return w.writer != nil } @@ -110,6 +114,7 @@ type SkipError struct { Reason string } +// Error returns the skip reason. func (e SkipError) Error() string { return e.Reason } @@ -178,6 +183,7 @@ var ( errorLog = log.New(os.Stderr, "ERROR: ", 0) ) +// main is the radar entry point. func main() { cfg, err := parseConfig() if err != nil { @@ -242,15 +248,16 @@ func main() { os.Exit(ExitCollectError) } - // Print professional summary - printSummary(totalCollected, outputFile, cfg) - if totalCollected == 0 { errorLog.Println("No data collected - this may indicate a problem") os.Exit(ExitNoData) } + + // Print summary + printSummary(totalCollected, outputFile, cfg) } +// parseConfig parses command-line flags into a Config. func parseConfig() (*Config, error) { cfg := &Config{} @@ -283,6 +290,20 @@ func parseConfig() (*Config, error) { } } + portFlagSet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "p" { + portFlagSet = true + } + }) + if !portFlagSet { + if pgport := os.Getenv("PGPORT"); pgport != "" { + if p, err := strconv.Atoi(pgport); err == nil { + cfg.Port = p + } + } + } + if cfg.Username == "" { cfg.Username = os.Getenv("PGUSER") if cfg.Username == "" { @@ -296,6 +317,10 @@ func parseConfig() (*Config, error) { cfg.Password = os.Getenv("PGPASSWORD") + if cfg.Database == "" { + cfg.Database = os.Getenv("PGDATABASE") + } + // Validate skip flag combinations if cfg.SkipSystem && cfg.SkipPostgres { return nil, fmt.Errorf("cannot use --skip-system and --skip-postgres together (nothing would be collected)") @@ -314,6 +339,7 @@ func parseConfig() (*Config, error) { return cfg, nil } +// ConnectionString builds a PostgreSQL connection string. func (c *Config) ConnectionString() string { params := []string{ fmt.Sprintf("host=%s", c.Host), @@ -330,6 +356,7 @@ func (c *Config) ConnectionString() string { return strings.Join(params, " ") } +// initPostgreSQL opens and verifies the PostgreSQL connection. func initPostgreSQL(cfg *Config) error { db, err := sql.Open("postgres", cfg.ConnectionString()) if err != nil { @@ -337,12 +364,15 @@ func initPostgreSQL(cfg *Config) error { } if err := db.Ping(); err != nil { + closeErrCheck(db, "database connection") return err } cfg.DB = db return nil } + +// collectAll runs all collection tasks and writes results to the ZIP archive. func collectAll(cfg *Config, zipWriter *zip.Writer) int { collected := 0 @@ -395,9 +425,15 @@ func collect(cfg *Config, zipWriter *zip.Writer, tasks []CollectionTask) int { err := task.Collector(cfg, lazy) if err != nil { - // Silently skip - don't spam user with errors - if cfg.VeryVerbose { - infoLog.Printf("⊘ %s (unavailable)", task.Name) + var skipErr SkipError + if errors.As(err, &skipErr) { + // Unavailable (command not found, file missing, no data) + if cfg.VeryVerbose { + infoLog.Printf("⊘ %s (unavailable)", task.Name) + } + } else { + // Error (I/O, permission, SQL) + errorLog.Printf("✗ %s: %v", task.Name, err) } continue } diff --git a/radar_test.go b/radar_test.go index 8264530..97bca50 100644 --- a/radar_test.go +++ b/radar_test.go @@ -416,40 +416,64 @@ func TestCollect(t *testing.T) { } } -// Test collect with failures +// TestCollectWithFailures tests collect error handling func TestCollectWithFailures(t *testing.T) { - var buf bytes.Buffer - zipWriter := zip.NewWriter(&buf) - defer closeErrCheck(zipWriter, "zip writer") - - cfg := &Config{Verbose: false} - - tasks := []CollectionTask{ - { - Category: "test", - Name: "success", - ArchivePath: "test/success.out", - Collector: func(cfg *Config, w io.Writer) error { - _, err := w.Write([]byte("ok")) - return err - }, - }, - { - Category: "test", - Name: "failure", - ArchivePath: "test/fail.out", - Collector: func(cfg *Config, w io.Writer) error { - return bytes.ErrTooLarge - }, - }, - } + t.Run("skip error is silent", func(t *testing.T) { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + defer closeErrCheck(zipWriter, "zip writer") + + var errBuf bytes.Buffer + errorLog.SetOutput(&errBuf) + defer errorLog.SetOutput(os.Stderr) + + cfg := &Config{Verbose: false} + tasks := []CollectionTask{ + {Category: "test", Name: "skip_task", ArchivePath: "test/skip.out", + Collector: func(cfg *Config, w io.Writer) error { + return NewSkipError("command not found: fake") + }}, + } - collected := collect(cfg, zipWriter, tasks) + collected := collect(cfg, zipWriter, tasks) + if collected != 0 { + t.Errorf("expected 0 collected, got %d", collected) + } + if errBuf.Len() > 0 { + t.Errorf("SkipError should not be logged, got: %s", errBuf.String()) + } + }) + + t.Run("real error is logged", func(t *testing.T) { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + defer closeErrCheck(zipWriter, "zip writer") + + var errBuf bytes.Buffer + errorLog.SetOutput(&errBuf) + defer errorLog.SetOutput(os.Stderr) + + cfg := &Config{Verbose: false} + tasks := []CollectionTask{ + {Category: "test", Name: "real_error", ArchivePath: "test/fail.out", + Collector: func(cfg *Config, w io.Writer) error { + return bytes.ErrTooLarge + }}, + {Category: "test", Name: "success", ArchivePath: "test/ok.out", + Collector: func(cfg *Config, w io.Writer) error { + _, err := w.Write([]byte("ok")) + return err + }}, + } - if collected != 1 { - t.Errorf("expected 1 collected, got %d", collected) - } - // Note: We no longer track failed count separately - tasks that fail are simply not collected + collected := collect(cfg, zipWriter, tasks) + if collected != 1 { + t.Errorf("expected 1 collected, got %d", collected) + } + if !strings.Contains(errBuf.String(), "real_error") { + t.Errorf("real error should be logged, got: %s", errBuf.String()) + } + }) } // TestNoDuplicateSystemArchivePaths verifies no duplicate archive paths in system tasks @@ -595,6 +619,68 @@ func TestLazyZipWriterNoWrite(t *testing.T) { } } +// TestPGEnvFallbacks tests PGPORT and PGDATABASE environment variable fallbacks. +func TestPGEnvFallbacks(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + t.Run("PGPORT fallback", func(t *testing.T) { + t.Setenv("PGPORT", "5433") + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar", "--skip-system", "-d", "testdb"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Port != 5433 { + t.Errorf("expected port 5433, got %d", cfg.Port) + } + }) + + t.Run("PGPORT flag takes precedence", func(t *testing.T) { + t.Setenv("PGPORT", "5433") + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar", "--skip-system", "-d", "testdb", "-p", "5434"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Port != 5434 { + t.Errorf("expected port 5434, got %d", cfg.Port) + } + }) + + t.Run("PGDATABASE fallback", func(t *testing.T) { + t.Setenv("PGDATABASE", "envdb") + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Database != "envdb" { + t.Errorf("expected database 'envdb', got %q", cfg.Database) + } + }) + + t.Run("PGDATABASE flag takes precedence", func(t *testing.T) { + t.Setenv("PGDATABASE", "envdb") + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar", "-d", "flagdb"} + + cfg, err := parseConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Database != "flagdb" { + t.Errorf("expected database 'flagdb', got %q", cfg.Database) + } + }) +} + // TestSkipFlagValidation tests validation of skip flag combinations func TestSkipFlagValidation(t *testing.T) { // Save original os.Args and restore after test diff --git a/system.go b/system.go index 7f0ad72..cbab688 100644 --- a/system.go +++ b/system.go @@ -23,5 +23,8 @@ func getSystemTasks() []CollectionTask { tasks = append(tasks, buildCommandTasks("system", sharedCommandTasks)...) tasks = append(tasks, buildFileTasks("system", sharedFileTasks)...) + // Add container-specific tasks when running inside a container + tasks = append(tasks, getContainerTasks()...) + return tasks } diff --git a/system_tasks_darwin.go b/system_tasks_darwin.go index b4b5224..2879e2f 100644 --- a/system_tasks_darwin.go +++ b/system_tasks_darwin.go @@ -12,6 +12,11 @@ package main +// getContainerTasks is nil on macOS (container detection is Linux-only) +func getContainerTasks() []CollectionTask { + return nil +} + // macOS-specific command tasks (sorted alphabetically by name) var systemCommandTasks = []SimpleCommandTask{ { diff --git a/system_tasks_linux.go b/system_tasks_linux.go index 2127ab1..6d4ef07 100644 --- a/system_tasks_linux.go +++ b/system_tasks_linux.go @@ -12,6 +12,41 @@ package main +import ( + "os" + "strings" +) + +// getContainerTasks returns container-specific collection tasks if running inside a container +func getContainerTasks() []CollectionTask { + if !isContainer() { + return nil + } + return buildCommandTasks("system", containerCommandTasks) +} + +// isContainer returns true if radar is running inside a container +func isContainer() bool { + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := strings.ToLower(string(data)) + for _, sig := range []string{"docker", "kubepods", "containerd", "lxc"} { + if strings.Contains(content, sig) { + return true + } + } + } + + if os.Getenv("KUBERNETES_SERVICE_HOST") != "" { + return true + } + + return false +} + // System command tasks (sorted alphabetically by name) var systemCommandTasks = []SimpleCommandTask{ { @@ -312,6 +347,121 @@ var systemCommandTasks = []SimpleCommandTask{ // System file read tasks (sorted alphabetically by name) var systemFileTasks = []SimpleFileTask{ + { + Name: "cgroup-cpu-max", + ArchivePath: "system/cgroup/cpu_max.out", + Path: "/sys/fs/cgroup/cpu.max", + }, + { + Name: "cgroup-cpu-weight", + ArchivePath: "system/cgroup/cpu_weight.out", + Path: "/sys/fs/cgroup/cpu.weight", + }, + { + Name: "cgroup-cpuset-cpus", + ArchivePath: "system/cgroup/cpuset_cpus_effective.out", + Path: "/sys/fs/cgroup/cpuset.cpus.effective", + }, + { + Name: "cgroup-io-max", + ArchivePath: "system/cgroup/io_max.out", + Path: "/sys/fs/cgroup/io.max", + }, + { + Name: "cgroup-memory-current", + ArchivePath: "system/cgroup/memory_current.out", + Path: "/sys/fs/cgroup/memory.current", + }, + { + Name: "cgroup-memory-max", + ArchivePath: "system/cgroup/memory_max.out", + Path: "/sys/fs/cgroup/memory.max", + }, + { + Name: "cgroup-memory-stat", + ArchivePath: "system/cgroup/memory_stat.out", + Path: "/sys/fs/cgroup/memory.stat", + }, + { + Name: "cgroup-memory-swap-max", + ArchivePath: "system/cgroup/memory_swap_max.out", + Path: "/sys/fs/cgroup/memory.swap.max", + }, + { + Name: "cgroup-pids-current", + ArchivePath: "system/cgroup/pids_current.out", + Path: "/sys/fs/cgroup/pids.current", + }, + { + Name: "cgroup-pids-max", + ArchivePath: "system/cgroup/pids_max.out", + Path: "/sys/fs/cgroup/pids.max", + }, + { + Name: "cgroup-v1-cpu-cfs-period", + ArchivePath: "system/cgroup-v1/cpu_cfs_period_us.out", + Path: "/sys/fs/cgroup/cpu/cpu.cfs_period_us", + }, + { + Name: "cgroup-v1-cpu-cfs-quota", + ArchivePath: "system/cgroup-v1/cpu_cfs_quota_us.out", + Path: "/sys/fs/cgroup/cpu/cpu.cfs_quota_us", + }, + { + Name: "cgroup-v1-cpu-shares", + ArchivePath: "system/cgroup-v1/cpu_shares.out", + Path: "/sys/fs/cgroup/cpu/cpu.shares", + }, + { + Name: "cgroup-v1-cpuset-cpus", + ArchivePath: "system/cgroup-v1/cpuset_cpus.out", + Path: "/sys/fs/cgroup/cpuset/cpuset.cpus", + }, + { + Name: "cgroup-v1-memory-limit", + ArchivePath: "system/cgroup-v1/memory_limit_in_bytes.out", + Path: "/sys/fs/cgroup/memory/memory.limit_in_bytes", + }, + { + Name: "cgroup-v1-memory-stat", + ArchivePath: "system/cgroup-v1/memory_stat.out", + Path: "/sys/fs/cgroup/memory/memory.stat", + }, + { + Name: "cgroup-v1-memory-usage", + ArchivePath: "system/cgroup-v1/memory_usage_in_bytes.out", + Path: "/sys/fs/cgroup/memory/memory.usage_in_bytes", + }, + { + Name: "cloud-bios-vendor", + ArchivePath: "system/cloud/bios_vendor.out", + Path: "/sys/class/dmi/id/bios_vendor", + }, + { + Name: "cloud-chassis-asset-tag", + ArchivePath: "system/cloud/chassis_asset_tag.out", + Path: "/sys/class/dmi/id/chassis_asset_tag", + }, + { + Name: "cloud-product-name", + ArchivePath: "system/cloud/product_name.out", + Path: "/sys/class/dmi/id/product_name", + }, + { + Name: "cloud-sys-vendor", + ArchivePath: "system/cloud/sys_vendor.out", + Path: "/sys/class/dmi/id/sys_vendor", + }, + { + Name: "container-cgroup-membership", + ArchivePath: "system/container/cgroup_membership.out", + Path: "/proc/1/cgroup", + }, + { + Name: "container-mountinfo", + ArchivePath: "system/container/mountinfo.out", + Path: "/proc/1/mountinfo", + }, { Name: "cpuinfo", ArchivePath: "system/proc/cpuinfo.out", @@ -398,3 +548,19 @@ var systemFileTasks = []SimpleFileTask{ Path: "/etc/system-release", }, } + +// Container-only command tasks (only included when isContainer() returns true) +var containerCommandTasks = []SimpleCommandTask{ + { + Name: "container-env", + ArchivePath: "system/container/environment.out", + Command: "sh", + Args: []string{"-c", "env | grep -E '^(HOSTNAME|CONTAINER_ID|DOCKER_HOST|ECS_CLUSTER|ECS_CONTAINER_METADATA_URI|KUBERNETES_SERVICE_HOST|KUBERNETES_SERVICE_PORT|KUBERNETES_PORT)=' | sort || true"}, + }, + { + Name: "container-k8s-namespace", + ArchivePath: "system/container/k8s_namespace.out", + Command: "sh", + Args: []string{"-c", "cat /run/secrets/kubernetes.io/serviceaccount/namespace 2>/dev/null || true"}, + }, +}