diff --git a/installer/setup.ps1 b/installer/setup.ps1 index 74c34a0..5ba6c2e 100644 --- a/installer/setup.ps1 +++ b/installer/setup.ps1 @@ -27,8 +27,6 @@ function Invoke-Native { } } -# Like Invoke-Native but never throws — for teardown, where a missing/stopped -# service must not abort the uninstall. function Invoke-NativeSoft { param([string]$File, [string[]]$Arguments) Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " ")) diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 547f165..c2d8eba 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -1,7 +1,3 @@ -// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform -// Result. It has no dependency on models or pb so it can be duplicated verbatim -// into the agent module (agent-run monitors) — callers map their own monitor -// representation onto Spec. package checker import ( @@ -16,7 +12,7 @@ import ( "time" ) -// Check types (mirror models.Monitor* constants). + const ( TypeHTTP = "http" TypeTCP = "tcp" @@ -24,7 +20,7 @@ const ( TypeTLS = "tls" ) -// Spec is a self-contained description of a single check. + type Spec struct { Type string URL string @@ -34,11 +30,11 @@ type Spec struct { ExpectedStatus int Keyword string TLSWarnDays int - Insecure bool // skip TLS certificate verification (HTTP checks) + Insecure bool TimeoutSec int } -// Result is the uniform outcome of running a check. + type Result struct { Up bool LatencyMs int @@ -54,7 +50,7 @@ func (s Spec) timeout() time.Duration { return time.Duration(t) * time.Second } -// Run executes the check described by s. + func Run(ctx context.Context, s Spec) Result { switch s.Type { case TypeHTTP: @@ -81,7 +77,7 @@ func runHTTP(ctx context.Context, s Spec) Result { } client := &http.Client{Timeout: s.timeout()} if s.Insecure { - client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } start := time.Now() req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) @@ -160,9 +156,9 @@ func runTLS(ctx context.Context, s Spec) Result { func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) } -// runICMP sends a single ICMP echo request and waits for the reply. Requires -// raw-socket privileges (the agent and server run as root). Returns down with a -// descriptive message when the socket cannot be opened or no reply arrives. + + + func runICMP(ctx context.Context, s Spec) Result { dst, err := net.ResolveIPAddr("ip4", s.Host) if err != nil { @@ -192,18 +188,18 @@ func runICMP(ctx context.Context, s Spec) Result { if err != nil { return Result{LatencyMs: msSince(start), Message: "no reply"} } - // Skip the IPv4 header (20 bytes) to reach the ICMP message. + if n < 28 || peer.String() != dst.String() { continue } - if reply[20] == 0 { // ICMP echo reply type + if reply[20] == 0 { return Result{Up: true, LatencyMs: msSince(start)} } } } func icmpEcho(id, seq int) []byte { - // Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload. + b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)} cs := icmpChecksum(b) b[2] = byte(cs >> 8) diff --git a/internal/config/config.go b/internal/config/config.go index 1718438..c238722 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,7 +9,7 @@ import ( "gopkg.in/yaml.v3" ) -// ConfigDir returns the platform-specific config directory. + func ConfigDir() string { if runtime.GOOS == "windows" { base := os.Getenv("ProgramData") diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 44bc506..979ecbd 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -14,9 +14,9 @@ import ( "github.com/mrhid6/vantage/agent/internal/grpc/pb" ) -// streamWriter forwards every write to emit() as an ordered chunk. Used as both -// Stdout and Stderr so output interleaves in real execution order. The mutex -// ensures a single stdout/stderr write is not interleaved mid-slice with another. + + + type streamWriter struct { mu sync.Mutex seq uint64 @@ -35,21 +35,21 @@ func (w *streamWriter) Write(p []byte) (int, error) { return len(p), nil } -// WorkspacePath returns the per-run working directory for a workspace id. The -// same id always maps to the same path so RunStep and the cleanup command agree. + + func WorkspacePath(workspaceID string) string { return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID) } -// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for -// the script to append KEY=value output to, executes it under the requested -// interpreter, and streams output via emit, returning the terminal result -// with empty stdout/stderr but populated exit_code/output_env. -// -// When the command carries a WorkspaceId the step runs with that per-run working -// directory as its cwd (created here if missing); the server removes it once the -// run finishes. The script and env files always live in a private temp dir so -// they never leak into the shared workspace. + + + + + + + + + func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult { res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}} @@ -102,7 +102,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes } } c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath) - default: // "bash" + default: scriptPath = filepath.Join(dir, "step.sh") if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil { res.ExitCode = 1 @@ -126,7 +126,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes c.Stderr = sw runErr := c.Run() - // stdout/stderr are streamed via emit, not returned in the result. + if ctx.Err() == context.DeadlineExceeded { res.ExitCode = 124 res.Stderr = "[vantage] step timed out" @@ -141,8 +141,8 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes return res } -// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines -// without '=' are ignored. + + func parseEnvFile(path string) map[string]string { out := map[string]string{} f, err := os.Open(path) diff --git a/internal/grpc/client.go b/internal/grpc/client.go index 9f07404..9f872fb 100644 --- a/internal/grpc/client.go +++ b/internal/grpc/client.go @@ -27,8 +27,8 @@ func New(serverURL string, useTLS bool) (*Client, error) { serverURL = strings.TrimPrefix(serverURL, "https://") serverURL = strings.TrimPrefix(serverURL, "http://") - // Send a ping every 30s so proxies with a 60s idle timeout don't kill the - // long-lived CommandStream when no commands are flowing. + + dialOpts := []grpc.DialOption{ grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 30 * time.Second, @@ -150,8 +150,8 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes return err } -// CommandStream opens a long-lived bidirectional stream for server-pushed commands. -// The caller controls the stream lifetime via ctx. + + func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) { return c.client.CommandStream(ctx) } diff --git a/internal/grpc/pb/vantage.pb.go b/internal/grpc/pb/vantage.pb.go index 2cb692d..11f9cdb 100644 --- a/internal/grpc/pb/vantage.pb.go +++ b/internal/grpc/pb/vantage.pb.go @@ -1,4 +1,4 @@ -// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec). + package pb @@ -44,7 +44,7 @@ type UploadKeyResponse struct { KeyId string `json:"key_id"` } -// CommandStream message types + type PackageUpdate struct { Name string `json:"name"` @@ -60,7 +60,7 @@ type ReportUpdatesRequest struct { type ReportUpdatesResponse struct{} -// Inventory report message types + type CPUReport struct { Model string `json:"model,omitempty"` @@ -92,7 +92,7 @@ type InventoryReport struct { } type InventoryReportResponse struct{} -// Monitor sync / check report message types + type MonitorSpec struct { MonitorId string `json:"monitor_id"` @@ -141,8 +141,8 @@ type ServerCommand struct { CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"` } -// CleanupWorkspaceCmd tells the agent to recursively remove the run's working -// directory once all steps on that server have finished. + + type CleanupWorkspaceCmd struct { WorkspaceId string `json:"workspace_id"` } @@ -186,8 +186,8 @@ type RunStepCmd struct { Script string `json:"script"` Env map[string]string `json:"env,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` - // WorkspaceId names the per-run working directory the agent creates and uses - // as the step's cwd. Empty means run in the agent's default directory. + + WorkspaceId string `json:"workspace_id,omitempty"` } @@ -206,7 +206,7 @@ type StepOutputChunk struct { Eof bool `json:"eof,omitempty"` } -// CommandStream client-side interface + type Vantage_CommandStreamClient interface { Send(*AgentMessage) error @@ -230,7 +230,7 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) { return m, nil } -// CommandStream server-side interface (included for completeness) + type Vantage_CommandStreamServer interface { Send(*ServerCommand) error diff --git a/internal/inventory/collect_linux.go b/internal/inventory/collect_linux.go index 39d0daf..58f9797 100644 --- a/internal/inventory/collect_linux.go +++ b/internal/inventory/collect_linux.go @@ -1,4 +1,4 @@ -//go:build linux + package inventory @@ -42,11 +42,11 @@ func cpuSample() (idle, total uint64) { defer f.Close() sc := bufio.NewScanner(f) if sc.Scan() { - fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ... + fields := strings.Fields(sc.Text()) for i, v := range fields[1:] { n, _ := strconv.ParseUint(v, 10, 64) total += n - if i == 3 { // idle + if i == 3 { idle = n } } diff --git a/internal/inventory/collect_other.go b/internal/inventory/collect_other.go index a293e32..59c3c1b 100644 --- a/internal/inventory/collect_other.go +++ b/internal/inventory/collect_other.go @@ -1,8 +1,8 @@ -//go:build !linux + package inventory import "github.com/mrhid6/vantage/agent/internal/grpc/pb" -// collect is a no-op best-effort stub on non-Linux platforms. + func collect(r *pb.InventoryReport, includeStatic bool) {} diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go index 238c29d..fbb898f 100644 --- a/internal/inventory/inventory.go +++ b/internal/inventory/inventory.go @@ -2,8 +2,8 @@ package inventory import "github.com/mrhid6/vantage/agent/internal/grpc/pb" -// Collect gathers metrics always and static hardware info when includeStatic. -// Platform specifics are provided by collect_linux.go / collect_other.go. + + func Collect(includeStatic bool) *pb.InventoryReport { r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}} collect(r, includeStatic) diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 1e01ae2..674fc3c 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -96,16 +96,16 @@ func fingerprint(pubKey string) string { return "MD5:" + strings.Join(pairs, ":") } -// KeyGenOptions controls how ssh-keygen is invoked. + type KeyGenOptions struct { - KeyType string // ed25519 (default), rsa, ecdsa - KeySize int // bits; used for rsa and ecdsa - Passphrase string // empty = no passphrase - Comment string // embedded in the public key + KeyType string + KeySize int + Passphrase string + Comment string } -// GenerateKeyPair generates an SSH keypair and returns the public key. -// The private key is written to keyPath; keyPath+".pub" holds the public key. + + func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) { if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { return "", err @@ -139,8 +139,8 @@ func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) { return strings.TrimSpace(string(pubData)), nil } -// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed -// vantage.conf include file, and ensures ~/.ssh/config includes it. + + func AddSSHIdentity(keyPath string) error { if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil { return fmt.Errorf("mkdir .ssh: %w", err) @@ -150,7 +150,7 @@ func AddSSHIdentity(keyPath string) error { return err } - // Read existing managed config (it may not exist yet). + var existing string data, err := os.ReadFile(managedConfigPath) if err != nil && !os.IsNotExist(err) { @@ -161,7 +161,7 @@ func AddSSHIdentity(keyPath string) error { line := "IdentityFile " + keyPath for _, l := range strings.Split(existing, "\n") { if strings.TrimSpace(l) == line { - return nil // already present + return nil } } @@ -176,7 +176,7 @@ func AddSSHIdentity(keyPath string) error { return nil } -// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config. + func RemoveSSHIdentity(keyPath string) error { data, err := os.ReadFile(managedConfigPath) if os.IsNotExist(err) { @@ -204,9 +204,9 @@ func RemoveSSHIdentity(keyPath string) error { return nil } -// ensureIncludeDirective adds "Include /root/.ssh/vantage.conf" to the top -// of ~/.ssh/config if it is not already present. The Include must appear before -// any Host stanzas to be effective for all connections. + + + func ensureIncludeDirective() error { data, err := os.ReadFile(sshConfigPath) if err != nil && !os.IsNotExist(err) { @@ -215,11 +215,11 @@ func ensureIncludeDirective() error { for _, l := range strings.Split(string(data), "\n") { if strings.TrimSpace(l) == includeDirective { - return nil // already present + return nil } } - // Prepend the Include directive so it takes effect before any Host blocks. + updated := includeDirective + "\n" + string(data) if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil { return fmt.Errorf("write %s: %w", sshConfigPath, err) diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 242259e..77ec420 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -1,6 +1,6 @@ -// Package monitors runs agent-side service checks. It polls the server for the -// monitors assigned to this agent (SyncMonitors), runs each on its own interval -// using the local checker package, and reports results back (ReportChecks). + + + package monitors import ( @@ -15,7 +15,7 @@ import ( "github.com/mrhid6/vantage/agent/internal/grpc/pb" ) -// syncInterval controls how often the agent re-fetches its assigned monitors. + const syncInterval = 30 * time.Second type runner struct { @@ -23,13 +23,13 @@ type runner struct { cancel context.CancelFunc } -// Run starts the agent monitor loop and blocks until ctx is cancelled. + func Run(ctx context.Context, cfg *config.Config) { active := map[string]*runner{} var mu sync.Mutex - // results is a shared channel every check writes to; a single reporter - // goroutine batches and ships them so we make one ReportChecks call per tick. + + results := make(chan pb.CheckResult, 64) go reporter(ctx, cfg, results) @@ -128,7 +128,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) { } } -// reporter batches results on a short interval and ships each batch in one call. + func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) { t := time.NewTicker(5 * time.Second) defer t.Stop() diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 9e69169..8a31683 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -34,7 +34,7 @@ func Run(ctx context.Context, cfg *config.Config, version string) error { } defer client.Close() - // Register if we have a pre-reg token + if cfg.PreRegToken != "" { log.Println("registering with server...") hostname, _ := os.Hostname() @@ -61,25 +61,25 @@ func Run(ctx context.Context, cfg *config.Config, version string) error { } if cfg.AgentToken == "" { - return fmt.Errorf("no agent token available — registration required") + return fmt.Errorf("no agent token available registration required") } - // Start the command stream alongside the poll loop. + go runCommandStream(ctx, cfg) - // Check for OS updates on startup and then hourly. + go runUpdateCheck(ctx, cfg) - // Report host inventory: metrics every 30s, full static snapshot every 15 min. + go runInventory(ctx, cfg) - // Run agent-side service monitors assigned to this server. + go monitors.Run(ctx, cfg) ticker := time.NewTicker(cfg.PollInterval) defer ticker.Stop() - // Run immediately on startup + if err := poll(client, cfg, version); err != nil { log.Printf("poll error: %v", err) } @@ -102,7 +102,6 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error { return fmt.Errorf("SyncKeys: %w", err) } - // Windows agents register and heartbeat only — no authorized_keys management. if runtime.GOOS != "linux" { return nil } @@ -124,8 +123,8 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error { return nil } -// runCommandStream maintains a persistent bidirectional stream with the server -// for instant command delivery. Reconnects with exponential backoff on failure. + + func runCommandStream(ctx context.Context, cfg *config.Config) { backoff := time.Second const maxBackoff = 2 * time.Minute @@ -178,9 +177,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { log.Println("command stream connected") - // grpc streams are not safe for concurrent Send; RunStep results are sent - // from per-command goroutines, so all sends on this stream must go through - // this mutex-protected helper. + + + var sendMu sync.Mutex send := func(msg *pb.AgentMessage) error { sendMu.Lock() @@ -220,7 +219,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { } res := agentexec.RunStep(rc, emit) res.CommandId = cid - // Final eof marker so the server closes the log file. + _ = send(&pb.AgentMessage{ ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, @@ -280,8 +279,8 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) { } } -// runInventory reports host metrics every 30s and a full static snapshot every -// 15 min (and once immediately on startup so static fields populate without delay). + + func runInventory(ctx context.Context, cfg *config.Config) { client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { @@ -299,7 +298,7 @@ func runInventory(ctx context.Context, cfg *config.Config) { } } - report(true) // full snapshot on startup + report(true) ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() tick := 0 @@ -309,7 +308,7 @@ func runInventory(ctx context.Context, cfg *config.Config) { return case <-ticker.C: tick++ - report(tick%30 == 0) // every 30th tick = 15 min → include static + report(tick%30 == 0) } } } @@ -322,7 +321,7 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) { } log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId) - // Re-report the (now empty) update list so the server reflects the new state. + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { return @@ -364,21 +363,21 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { } u := cmd.UpdateAgent - arch := runtime.GOARCH // "amd64" or "arm64" + arch := runtime.GOARCH tag := "agent%2Fv" + u.Version binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch) checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag) log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) - // Download binary + tmpBin := "/tmp/vantage-agent-update" if err := downloadFile(binaryURL, tmpBin); err != nil { log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err) return } - // Download and verify checksum + checksumData, err := httpGetBytes(checksumURL) if err != nil { log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err) @@ -403,11 +402,11 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { exec.Command("systemctl", "restart", "vantage-agent").Run() } -// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to -// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so -// that when the upgrade stops the VantageAgent service, nssm's process-tree -// kill of this agent does not also kill the installer mid-flight. Config -// (server_id, agent_token) is preserved by setup.ps1 on upgrade. + + + + + func handleUpdateAgentWindows(cmd *pb.ServerCommand) { u := cmd.UpdateAgent tag := "agent%2Fv" + u.Version @@ -435,8 +434,8 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) { logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log") log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId) - // "start" detaches msiexec from this process tree so the service stop - // during the upgrade does not terminate the installer. + + up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath) if err := up.Start(); err != nil { log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err) @@ -445,7 +444,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) { } func downloadFile(url, dest string) error { - resp, err := http.Get(url) //nolint:gosec + resp, err := http.Get(url) if err != nil { return err } @@ -463,7 +462,7 @@ func downloadFile(url, dest string) error { } func httpGetBytes(url string) ([]byte, error) { - resp, err := http.Get(url) //nolint:gosec + resp, err := http.Get(url) if err != nil { return nil, err } @@ -555,7 +554,7 @@ func localIP() string { return "" } -// GenerateAndUpload generates an SSH keypair and uploads the public key to the server. + func GenerateAndUpload(cfg *config.Config, label string) error { client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) if err != nil { diff --git a/internal/updates/updates.go b/internal/updates/updates.go index a2eecf7..8a1ce12 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -27,8 +27,8 @@ func detectPM() string { return "" } -// CheckAvailable returns the list of packages with available upgrades. -// Returns nil, nil when no supported package manager is found. + + func CheckAvailable() ([]PackageUpdate, error) { switch detectPM() { case "apt": @@ -48,11 +48,11 @@ func CheckAvailable() ([]PackageUpdate, error) { } } -// ApplyAll runs a full non-interactive upgrade using the detected package manager. + func ApplyAll() error { switch detectPM() { case "apt": - // Refresh lists first, then upgrade. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil { @@ -77,8 +77,8 @@ func ApplyAll() error { func checkApt() ([]PackageUpdate, error) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - // Best-effort refresh; ignore errors (cached data is fine). - exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck + + exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() out, err := exec.Command("apt", "list", "--upgradable").Output() if err != nil { @@ -88,7 +88,7 @@ func checkApt() ([]PackageUpdate, error) { scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := scanner.Text() - // Format: package/suite version arch [upgradable from: old-ver] + if !strings.Contains(line, "[upgradable from:") { continue } @@ -111,7 +111,6 @@ func checkApt() ([]PackageUpdate, error) { func checkDnfYum(pm string) ([]PackageUpdate, error) { cmd := exec.Command(pm, "check-update") out, err := cmd.Output() - // Exit code 100 means updates are available — not an error. if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 { err = nil } @@ -133,7 +132,7 @@ func checkDnfYum(pm string) ([]PackageUpdate, error) { if len(parts) < 2 { continue } - // name.arch new-version repo + name := strings.SplitN(parts[0], ".", 2)[0] updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]}) } @@ -146,7 +145,7 @@ func checkPacman() ([]PackageUpdate, error) { scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { parts := strings.Fields(scanner.Text()) - // Format: package old-version -> new-version + if len(parts) < 4 { continue } @@ -164,7 +163,7 @@ func checkZypper() ([]PackageUpdate, error) { scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := scanner.Text() - // Data rows start with "v |" (available) or "i |" (installed but updatable). + if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") { continue }