// 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
}