git/orchard/main
tui/internal/plan/plan.go
282 lines9 KB last change raw · history
// Package plan is the Go side of the contract with the Python backend.
//
// Go deliberately does not read the pacman databases itself. The Python side
// already does that through libalpm and is verified against `pacman -Q*` on six
// dimensions; a second implementation here would eventually disagree with it,
// and two tools reporting different orphan lists is worse than one slow one.
//
// So this package's whole job is to read JSON.
package plan

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"strings"
)

// Upgrade is a repository package moving between versions.
type Upgrade struct {
	Name     string `json:"name"`
	Old      string `json:"old"`
	New      string `json:"new"`
	Repo     string `json:"repo"`
	Download int64  `json:"download"`
	Delta    int64  `json:"delta"`
	Ignored  bool   `json:"ignored"`
}

// AurUpdate is an AUR package, carrying the tag the audit gave it.
//
//	U  upgrade            D  the AUR version is older than what is installed
//	!  needs a look       !! blocked, will not be applied automatically
type AurUpdate struct {
	Name    string   `json:"name"`
	Old     string   `json:"old"`
	New     string   `json:"new"`
	Tag     string   `json:"tag"`
	Note    string   `json:"note"`
	Details []string `json:"details"`
	Ignored bool     `json:"ignored"`
	//: Whether the package could be examined at all. A pointer so that a backend
	//: which predates the field is treated as having audited, rather than
	//: painting every package as unchecked.
	Audited *bool `json:"audited"`
}

// WasAudited reports whether the package was examined. An absent field means an
// older backend, which always audited.
func (a AurUpdate) WasAudited() bool { return a.Audited == nil || *a.Audited }

// Vcs is a version-controlled package. Reported, never rebuilt automatically.
// Vcs is a devel package paru would actually rebuild. The AUR's recorded
// pkgver for a -git package lags whatever paru generated when it last built, so
// the version transition here comes from paru's own devel database rather than
// from a comparison this side could make.
type Vcs struct {
	Name string `json:"name"`
	Old  string `json:"old"`
	New  string `json:"new"`
}

// Step is one command the plan will run, with its full breakdown.
//
// Authored by the backend rather than derived here: the front end cannot know
// what makepkg will do inside a PKGBUILD, and two renderers each working the
// commands out from the same facts is how they drift apart.
type Step struct {
	Index   string   `json:"index"`
	Command string   `json:"command"`
	Detail  string   `json:"detail"`
	Details []string `json:"details"`
	//: the ordered commands the step will actually run
	Queue []Stage `json:"queue"`
}

// Stage is one command in a step's queue.
//
// Assembled by the backend from facts rather than guessed: the PKGBUILD is
// parsed, the alpm hooks are read from disk with their declared triggers, and
// the snapshots are just another hook. `pacman -Syu` is a summary; this is the
// sequence underneath it.
type Stage struct {
	Command string `json:"command"`
	What    string `json:"what"`
	Target  string `json:"target"`
	Level   string `json:"level"` // step | note | hook | warn | danger
	Phase   string `json:"phase"`
	Finding string `json:"finding"` // the audit rule that flagged it
}

// IsWarning reports whether a stage deserves to stand out.
func (s Stage) IsWarning() bool {
	return s.Level == "warn" || s.Level == "danger"
}

// Warning is one kind of warning, with its own page.
type Warning struct {
	Title    string   `json:"title"`
	Headline string   `json:"headline"`
	Details  []string `json:"details"`
	Flagged  int      `json:"flagged"`
	//: the packages this warning concerns, so it can be scoped to a selection
	Packages []string `json:"packages"`
}

// Check is one line of the pre-flight report.
type Check struct {
	OK     bool   `json:"ok"`
	Label  string `json:"label"`
	Detail string `json:"detail"`
	//: a fuller explanation, shown when the row is opened. A check with none is
	//: a single fact and has nothing to open.
	Details []string `json:"details"`
	//: the command that resolves this check, if there is one. Asked about
	//: before it runs, and shown verbatim when it is.
	Action []string `json:"action"`
	//: said out loud before an action runs
	Warning string `json:"warning"`
}

// Flatpak is a pending flatpak update.
type Flatpak struct {
	Application string `json:"application"`
	Version     string `json:"version"`
	Origin      string `json:"origin"`
}

// StaleProcess is a process still running a binary that was replaced on disk.
type StaleProcess struct {
	PID  int    `json:"pid"`
	Name string `json:"name"`
	Path string `json:"path"`
}

// Hygiene is the list of things that rot quietly.
type Hygiene struct {
	CacheReclaimable int64          `json:"cache_reclaimable"`
	Pacnew           []string       `json:"pacnew"`
	Orphans          []string       `json:"orphans"`
	Reboot           string         `json:"reboot"`
	Stale            []StaleProcess `json:"stale_processes"`
}

// Vulnerabilities comes from arch-audit.
type Vulnerabilities struct {
	Fixable   []string `json:"fixable"`
	Unfixable []string `json:"unfixable"`
}

// Plan is the whole document.
type Plan struct {
	Advisories []Advisory `json:"advisories"`
	//: how many advisories have been acknowledged and are therefore absent from
	//: the list above
	AckedCount      int                  `json:"acked_count"`
	Steps           []Step               `json:"steps"`
	Warnings        []Warning            `json:"warnings"`
	Ignored         []string             `json:"ignored"`
	ConfigPath      string               `json:"config_path"`
	DevelNote       string               `json:"devel_note"`
	Repo            []Upgrade            `json:"repo"`
	Aur             []AurUpdate          `json:"aur"`
	Vcs             []Vcs                `json:"vcs"`
	Checks          []Check              `json:"checks"`
	Flatpak         []Flatpak            `json:"flatpak"`
	Vulnerabilities Vulnerabilities      `json:"vulnerabilities"`
	Hygiene         Hygiene              `json:"hygiene"`
	Audits          map[string][]Finding `json:"audits"`
}

// Counts summarises the plan for the header.
func (p *Plan) Counts() (repo, aur, blocked int) {
	for _, a := range p.Aur {
		if a.Tag == "!!" {
			blocked++
		}
	}
	return len(p.Repo), len(p.Aur), blocked
}

// Backend knows how to reach the Python side.
type Backend struct {
	Python string   // interpreter, default "python3"
	Module string   // module to run, default "updater"
	Env    []string // extra environment, e.g. PYTHONPATH for an uninstalled tree
	Args   []string // extra arguments passed through to every invocation
}

// DefaultBackend resolves sensible defaults from the environment.
func DefaultBackend() Backend {
	b := Backend{Python: "python3", Module: "updater"}
	if v := os.Getenv("UPDATER_PYTHON"); v != "" {
		b.Python = v
	}
	if v := os.Getenv("UPDATER_HOME"); v != "" {
		b.Env = append(b.Env, "PYTHONPATH="+v)
	}
	return b
}

// Error carries the backend's stderr so a failure is explained, not just reported.
type Error struct {
	Args   []string
	Stderr string
	Err    error
}

func (e *Error) Error() string {
	msg := strings.TrimSpace(e.Stderr)
	if msg == "" {
		msg = e.Err.Error()
	}
	return fmt.Sprintf("%s %s: %s", e.Args[0], strings.Join(e.Args[1:], " "), msg)
}

// Run executes the backend and returns stdout.
//
// stdout must be pure JSON: the backend is required to send anything human to
// stderr. That rule is not cosmetic -- a stray line on stdout is a parse failure,
// and flatpak already taught this codebase what "exits 0 but printed an error"
// does to a check row.
func (b Backend) Run(ctx context.Context, args ...string) ([]byte, error) {
	full := append([]string{"-m", b.Module}, args...)
	cmd := exec.CommandContext(ctx, b.Python, full...)
	cmd.Env = append(os.Environ(), b.Env...)

	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return nil, &Error{Args: append([]string{b.Python}, full...),
			Stderr: stderr.String(), Err: err}
	}
	return stdout.Bytes(), nil
}

// checksDoc is the shape `updater checks --json` returns.
type checksDoc struct {
	Checks []Check `json:"checks"`
	//: labels this refresh recomputed. Rows outside it are ones it cannot see,
	//: and must survive: the backend recomputes machine-local state, not
	//: whether a reboot is pending or the archive is reachable.
	Covers []string `json:"covers"`
}

// LoadChecks re-runs only the machine-local checks.
//
// What an action can change is this machine's state, not the archive, so a
// refresh after running one asks for these alone. Re-planning would re-poll the
// AUR and scan the security tracker to learn nothing new.
func (b Backend) LoadChecks(ctx context.Context) ([]Check, []string, error) {
	out, err := b.Run(ctx, "checks", "--json")
	if err != nil {
		return nil, nil, err
	}
	var doc checksDoc
	if err := json.Unmarshal(out, &doc); err != nil {
		return nil, nil, fmt.Errorf("could not parse the checks: %w", err)
	}
	return doc.Checks, doc.Covers, nil
}

// Load fetches and parses the plan.
func (b Backend) Load(ctx context.Context) (*Plan, error) {
	args := append([]string{"plan", "--json"}, b.Args...)
	out, err := b.Run(ctx, args...)
	if err != nil {
		return nil, err
	}
	var p Plan
	dec := json.NewDecoder(bytes.NewReader(out))
	// tolerate fields the Go side does not know about yet, so the two halves can
	// be updated independently
	if err := dec.Decode(&p); err != nil {
		return nil, fmt.Errorf("could not parse backend JSON: %w", err)
	}
	return &p, nil
}