flatpak: the package, Flathub with it, and the installations as tools (hq to-be 42 phase 2.9)
The package ships Flathub in /usr/share/flatpak/remotes.d, so the module declares no remote of its own and checks it instead. Eleven Go tools: list, runtimes, remotes (naming the desktop's duplicate user Flathub), updates, unused, disk usage, and install, remove, update and remove-unused, the system installation's acts through sudo -n and the account's without. uninstall --unused has no dry run, so flatpak_unused works it out from flatpak's own answers: an application's runtime and SDK, the extension points of what is used, and pins. Acts run as jobs inside the bundle, because an install outlasts a call.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# flatpak
|
||||
|
||||
Flatpak applications on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are
|
||||
modules, on the two workstations only"; to-be 42 phase 2 step 9).
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| flatpak | package `flatpak` (official repositories) |
|
||||
|
||||
**The Flathub remote comes with the package.** The package ships `flathub.flatpakrepo` in
|
||||
`/usr/share/flatpak/remotes.d`, and flatpak adds every file there to the system installation as a
|
||||
remote. The module therefore declares no remote file of its own. `flatpak_remotes` checks that the
|
||||
system installation has Flathub, and says so when it does not.
|
||||
|
||||
The installed applications and runtimes, and their data, are the operator's: found (ADR 0182).
|
||||
|
||||
## Improves
|
||||
|
||||
- **The laptop gains flatpak**, which only the desktop had, with Flathub.
|
||||
- **Unused runtimes become visible and removable.** On the desktop on 2026-10-04, `/var/lib/flatpak`
|
||||
held 5.1 GB for three applications. They are Nextcloud on KDE 6.7, Warehouse on GNOME 46, and Plex on
|
||||
Freedesktop 23.08. Beside them sat the whole Freedesktop 22.08 platform, both of its GL extensions,
|
||||
and older codec and style extensions that nothing uses. `flatpak_unused` lists them read-only;
|
||||
`flatpak_remove_unused` lets flatpak remove them.
|
||||
- **A duplicate remote is named.** The desktop has Flathub configured twice, once in the system
|
||||
installation and once in the account's, which has nothing installed from it. `flatpak_remotes`
|
||||
reports it.
|
||||
|
||||
## Tools
|
||||
|
||||
All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account. An act on the system
|
||||
installation (the default) goes through `sudo -n`; one on the account's own installation does not.
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `flatpak_list` (r) | applications: id, name, version, branch, origin, installation, size in bytes |
|
||||
| `flatpak_runtimes` (r) | runtimes and extensions, the same way |
|
||||
| `flatpak_remotes` (r) | both installations' remotes, how many refs come from each, findings |
|
||||
| `flatpak_updates` (r) | refs with a newer commit on their remote (needs the network) |
|
||||
| `flatpak_unused` (r) | runtimes nothing needs, computed without changing anything (below), with the space each takes |
|
||||
| `flatpak_disk_usage` (r) | each installation's directory on disk, and the 50 largest refs |
|
||||
| `flatpak_install` (a) | install from a remote (default `flathub`) into the system or the account installation |
|
||||
| `flatpak_remove` (a) | uninstall, keeping the application's data unless `delete_data` |
|
||||
| `flatpak_update` (a) | update one ref, or everything in an installation |
|
||||
| `flatpak_remove_unused` (a) | `uninstall --unused`: flatpak decides |
|
||||
| `flatpak_job` (r) | a long act's state and the end of its output |
|
||||
|
||||
**Unused, computed.** `flatpak uninstall --unused` has no dry run: it asks, and answering *yes* removes.
|
||||
So the read tool works it out from what flatpak already says. A runtime is in use when one of these
|
||||
holds:
|
||||
|
||||
- an installed application names it as its runtime or SDK;
|
||||
- it fills an extension point declared in the metadata of something in use, matched by id
|
||||
(subdirectories included) and accepted version;
|
||||
- it is pinned.
|
||||
|
||||
The SDK is counted as used to err on the safe side. flatpak's own `--unused` may also remove an
|
||||
application's SDK.
|
||||
|
||||
**Acts are jobs.** An install or update downloads hundreds of megabytes. Each act runs inside the
|
||||
tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is answered, and a
|
||||
failed one as an error. One still running is answered with a job id for `flatpak_job`.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
- **laptop:** `flatpak` is installed, with Flathub as the system remote. Nothing else.
|
||||
- **desktop:** nothing on disk; the package, installed by hand, becomes the mesh's.
|
||||
|
||||
## The one-off step for the operator (ADR 0182)
|
||||
|
||||
On the desktop, the account's own Flathub remote duplicates the system's and has nothing installed
|
||||
from it. If it is not wanted: `flatpak remote-delete --user flathub`, once. The mesh did not make it,
|
||||
so it does not remove it.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
The applications and runtimes, the account installation under `~/.local/share/flatpak`, application
|
||||
data under `~/.var/app`, and any remote other than the package's Flathub.
|
||||
@@ -0,0 +1,501 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// refs are an application or runtime id, optionally with arch and branch: org.gimp.GIMP,
|
||||
// org.freedesktop.Platform/x86_64/23.08, app/org.gimp.GIMP/x86_64/stable.
|
||||
var refs = regexp.MustCompile(`^((app|runtime)/)?[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+(/[A-Za-z0-9_]*(/[A-Za-z0-9._-]*)?)?$`)
|
||||
|
||||
func checkRef(r string) error {
|
||||
if !refs.MatchString(r) {
|
||||
return fmt.Errorf("%q is not a flatpak id or ref", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var remoteName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
// Ref is one installed application or runtime.
|
||||
type Ref struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
Arch string `json:"arch"`
|
||||
Ref string `json:"ref"`
|
||||
Origin string `json:"origin"`
|
||||
Installation string `json:"installation"`
|
||||
Size string `json:"size"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// sizeBytes reads flatpak's human size ("275.8 MB", with a no-break space): decimal units.
|
||||
func sizeBytes(s string) int64 {
|
||||
f := strings.Fields(strings.ReplaceAll(s, " ", " "))
|
||||
if len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseFloat(f[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
unit := map[string]float64{"bytes": 1, "byte": 1, "B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12}
|
||||
if len(f) > 1 {
|
||||
if m, ok := unit[f[1]]; ok {
|
||||
n *= m
|
||||
}
|
||||
}
|
||||
return int64(n)
|
||||
}
|
||||
|
||||
func installFlag(inst string) []string {
|
||||
if inst == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"--" + inst}
|
||||
}
|
||||
|
||||
// listRefs runs flatpak list for apps or runtimes.
|
||||
func listRefs(kind, inst string) ([]Ref, error) {
|
||||
args := append([]string{"list", "--" + kind, "--columns=application,name,version,branch,arch,origin,installation,size,ref"}, installFlag(inst)...)
|
||||
r, err := call(Cmd{Name: "flatpak", Args: args})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Ref{}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
f := strings.Split(l, "\t")
|
||||
if len(f) < 9 {
|
||||
continue
|
||||
}
|
||||
size := strings.ReplaceAll(f[7], " ", " ")
|
||||
out = append(out, Ref{ID: f[0], Name: f[1], Version: f[2], Branch: f[3], Arch: f[4], Origin: f[5], Installation: f[6], Size: size, Bytes: sizeBytes(size), Ref: f[8]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAnswer is what flatpak_list and flatpak_runtimes answer.
|
||||
type ListAnswer struct {
|
||||
Count int `json:"count"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Refs []Ref `json:"refs"`
|
||||
}
|
||||
|
||||
// List answers the applications or runtimes.
|
||||
func List(kind, inst string) (ListAnswer, error) {
|
||||
got, err := listRefs(kind, inst)
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
out := ListAnswer{Count: len(got), Refs: got}
|
||||
for _, r := range got {
|
||||
out.Bytes += r.Bytes
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Remote is one remote of one installation.
|
||||
type Remote struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Installation string `json:"installation"`
|
||||
Priority string `json:"priority"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Installed int `json:"installed_from"`
|
||||
}
|
||||
|
||||
// RemotesAnswer is what flatpak_remotes answers.
|
||||
type RemotesAnswer struct {
|
||||
Remotes []Remote `json:"remotes"`
|
||||
Flathub bool `json:"flathub_system"`
|
||||
Findings []string `json:"findings"`
|
||||
}
|
||||
|
||||
// Remotes answers both installations' remotes and what is wrong with them.
|
||||
func Remotes() (RemotesAnswer, error) {
|
||||
r, err := call(Cmd{Name: "flatpak", Args: []string{"remotes", "--show-disabled", "--columns=name,url,options,priority"}})
|
||||
if err != nil {
|
||||
return RemotesAnswer{}, err
|
||||
}
|
||||
all, err := listRefs("app", "")
|
||||
if err != nil {
|
||||
return RemotesAnswer{}, err
|
||||
}
|
||||
rt, err := listRefs("runtime", "")
|
||||
if err != nil {
|
||||
return RemotesAnswer{}, err
|
||||
}
|
||||
from := map[string]int{}
|
||||
for _, x := range append(all, rt...) {
|
||||
from[x.Origin+"\x00"+x.Installation]++
|
||||
}
|
||||
out := RemotesAnswer{Remotes: []Remote{}, Findings: []string{}}
|
||||
byURL := map[string][]string{}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
f := strings.Split(l, "\t")
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
inst := "system"
|
||||
disabled := false
|
||||
for _, o := range strings.Split(f[2], ",") {
|
||||
switch strings.TrimSpace(o) {
|
||||
case "user":
|
||||
inst = "user"
|
||||
case "disabled":
|
||||
disabled = true
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(o), "system") {
|
||||
inst = strings.TrimSpace(o)
|
||||
}
|
||||
}
|
||||
rem := Remote{Name: f[0], URL: f[1], Installation: inst, Priority: f[3], Disabled: disabled, Installed: from[f[0]+"\x00"+inst]}
|
||||
out.Remotes = append(out.Remotes, rem)
|
||||
byURL[strings.TrimRight(rem.URL, "/")] = append(byURL[strings.TrimRight(rem.URL, "/")], rem.Name+" ("+inst+")")
|
||||
if inst == "system" && rem.Name == "flathub" && !disabled {
|
||||
out.Flathub = true
|
||||
}
|
||||
if rem.Installed == 0 {
|
||||
out.Findings = append(out.Findings, fmt.Sprintf("remote %s in the %s installation has nothing installed from it", rem.Name, inst))
|
||||
}
|
||||
}
|
||||
if !out.Flathub {
|
||||
out.Findings = append(out.Findings, "the system installation has no enabled flathub remote: the flatpak package ships one in /usr/share/flatpak/remotes.d, so it was removed or disabled by hand")
|
||||
}
|
||||
for url, names := range byURL {
|
||||
if len(names) > 1 {
|
||||
sort.Strings(names)
|
||||
out.Findings = append(out.Findings, fmt.Sprintf("%s is configured %d times: %s", url, len(names), strings.Join(names, ", ")))
|
||||
}
|
||||
}
|
||||
sort.Strings(out.Findings)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Updates answers what an update would change.
|
||||
func Updates(inst string) (map[string]any, error) {
|
||||
args := append([]string{"remote-ls", "--updates", "--columns=application,branch,version,origin,ref"}, installFlag(inst)...)
|
||||
r, err := call(Cmd{Name: "flatpak", Args: args})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []map[string]string{}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
f := strings.Split(l, "\t")
|
||||
if len(f) >= 5 {
|
||||
out = append(out, map[string]string{"id": f[0], "branch": f[1], "version": f[2], "origin": f[3], "ref": f[4]})
|
||||
}
|
||||
}
|
||||
return map[string]any{"count": len(out), "updates": out}, nil
|
||||
}
|
||||
|
||||
// Extension is one extension point a ref's metadata declares.
|
||||
type Extension struct {
|
||||
ID string
|
||||
Versions []string
|
||||
Subdirs bool
|
||||
}
|
||||
|
||||
// extensionsOf reads [Extension …] groups from a ref's metadata, with the versions each accepts:
|
||||
// versions, else version, else the branch of the ref declaring it.
|
||||
func extensionsOf(metadata, branch string) []Extension {
|
||||
out := []Extension{}
|
||||
var cur *Extension
|
||||
flush := func() {
|
||||
if cur != nil {
|
||||
if len(cur.Versions) == 0 {
|
||||
cur.Versions = []string{branch}
|
||||
}
|
||||
out = append(out, *cur)
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
var version, versions string
|
||||
for _, l := range strings.Split(metadata, "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, "[") {
|
||||
if cur != nil {
|
||||
cur.Versions = pickVersions(versions, version)
|
||||
}
|
||||
flush()
|
||||
version, versions = "", ""
|
||||
if strings.HasPrefix(l, "[Extension ") && strings.HasSuffix(l, "]") {
|
||||
cur = &Extension{ID: strings.TrimSuffix(strings.TrimPrefix(l, "[Extension "), "]")}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
k, v, found := strings.Cut(l, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "version":
|
||||
version = strings.TrimSpace(v)
|
||||
case "versions":
|
||||
versions = strings.TrimSpace(v)
|
||||
case "subdirectories":
|
||||
cur.Subdirs = strings.TrimSpace(v) == "true"
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
cur.Versions = pickVersions(versions, version)
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func pickVersions(versions, version string) []string {
|
||||
if versions != "" {
|
||||
out := []string{}
|
||||
for _, v := range strings.Split(versions, ";") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if version != "" {
|
||||
return []string{version}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fills says whether a runtime fills an extension point.
|
||||
func (e Extension) fills(r Ref) bool {
|
||||
if !(r.ID == e.ID || (e.Subdirs && strings.HasPrefix(r.ID, e.ID+"."))) {
|
||||
return false
|
||||
}
|
||||
for _, v := range e.Versions {
|
||||
if v == r.Branch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UnusedAnswer is what flatpak_unused answers.
|
||||
type UnusedAnswer struct {
|
||||
Count int `json:"count"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Unused []Ref `json:"unused"`
|
||||
Pinned []string `json:"pinned"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func info(inst string, extra ...string) (string, error) {
|
||||
r, err := call(Cmd{Name: "flatpak", Args: append(append([]string{"info"}, installFlag(inst)...), extra...)})
|
||||
return strings.TrimSpace(r.Stdout), err
|
||||
}
|
||||
|
||||
// Unused computes what no installed application needs.
|
||||
func Unused() (UnusedAnswer, error) {
|
||||
apps, err := listRefs("app", "")
|
||||
if err != nil {
|
||||
return UnusedAnswer{}, err
|
||||
}
|
||||
runtimes, err := listRefs("runtime", "")
|
||||
if err != nil {
|
||||
return UnusedAnswer{}, err
|
||||
}
|
||||
key := func(inst, ref string) string {
|
||||
return inst + "\x00" + strings.TrimPrefix(strings.TrimPrefix(ref, "runtime/"), "app/")
|
||||
}
|
||||
installed := map[string]Ref{}
|
||||
for _, r := range runtimes {
|
||||
installed[key(r.Installation, r.Ref)] = r
|
||||
}
|
||||
used := map[string]bool{}
|
||||
type item struct {
|
||||
inst, ref, branch string
|
||||
}
|
||||
queue := []item{}
|
||||
// A user installation's application may use a system runtime; a system one only system runtimes.
|
||||
mark := func(inst, ref string) {
|
||||
for _, where := range []string{inst, "system"} {
|
||||
k := key(where, ref)
|
||||
if r, ok := installed[k]; ok && !used[k] {
|
||||
used[k] = true
|
||||
queue = append(queue, item{where, r.Ref, r.Branch})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range apps {
|
||||
for _, flag := range []string{"--show-runtime", "--show-sdk"} {
|
||||
ref, err := info(a.Installation, flag, a.Ref)
|
||||
if err != nil {
|
||||
return UnusedAnswer{}, err
|
||||
}
|
||||
if ref != "" {
|
||||
mark(a.Installation, ref)
|
||||
}
|
||||
}
|
||||
queue = append(queue, item{a.Installation, a.Ref, a.Branch})
|
||||
}
|
||||
pinned := []string{}
|
||||
for _, inst := range []string{"system", "user"} {
|
||||
r := run(Cmd{Name: "flatpak", Args: []string{"pin", "--" + inst}})
|
||||
if r.Status != 0 || r.Error != "" {
|
||||
continue
|
||||
}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
p := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(p, "runtime/") {
|
||||
pinned = append(pinned, p)
|
||||
mark(inst, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for len(queue) > 0 {
|
||||
it := queue[0]
|
||||
queue = queue[1:]
|
||||
meta, err := info(it.inst, "--show-metadata", it.ref)
|
||||
if err != nil {
|
||||
return UnusedAnswer{}, err
|
||||
}
|
||||
for _, e := range extensionsOf(meta, it.branch) {
|
||||
for k, r := range installed {
|
||||
if !used[k] && e.fills(r) && (r.Installation == it.inst || r.Installation == "system") {
|
||||
used[k] = true
|
||||
queue = append(queue, item{r.Installation, r.Ref, r.Branch})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := UnusedAnswer{Unused: []Ref{}, Pinned: pinned,
|
||||
Note: "Computed without changing anything. flatpak_remove_unused lets flatpak decide, which may differ in detail (a locale or debug extension listed here by nothing)."}
|
||||
for k, r := range installed {
|
||||
if !used[k] {
|
||||
out.Unused = append(out.Unused, r)
|
||||
out.Bytes += r.Bytes
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Unused, func(i, k int) bool { return out.Unused[i].Ref < out.Unused[k].Ref })
|
||||
out.Count = len(out.Unused)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Where each installation lives.
|
||||
var installDirs = map[string]string{"system": "/var/lib/flatpak", "user": ".local/share/flatpak"}
|
||||
|
||||
// DiskAnswer is what flatpak_disk_usage answers.
|
||||
type DiskAnswer struct {
|
||||
Installations map[string]int64 `json:"installation_bytes"`
|
||||
Largest []Ref `json:"largest"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// DiskUsage measures each installation's directory and lists the largest refs.
|
||||
func DiskUsage() (DiskAnswer, error) {
|
||||
out := DiskAnswer{Installations: map[string]int64{}, Largest: []Ref{}}
|
||||
for inst, dir := range installDirs {
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(accountHome(), dir)
|
||||
}
|
||||
// du exits 1 when a file is unreadable and still prints the total of what it could read.
|
||||
r := run(Cmd{Name: "du", Args: []string{"-sb", dir}})
|
||||
if r.Error != "" {
|
||||
return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r)
|
||||
}
|
||||
f := strings.Fields(r.Stdout)
|
||||
if len(f) == 0 {
|
||||
if strings.Contains(r.Stderr, "No such file") {
|
||||
out.Installations[inst] = 0
|
||||
continue
|
||||
}
|
||||
return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r)
|
||||
}
|
||||
n, _ := strconv.ParseInt(f[0], 10, 64)
|
||||
out.Installations[inst] = n
|
||||
if r.Status != 0 {
|
||||
out.Note = "some files were unreadable to the account, so a total is a lower bound"
|
||||
}
|
||||
}
|
||||
apps, err := listRefs("app", "")
|
||||
if err != nil {
|
||||
return DiskAnswer{}, err
|
||||
}
|
||||
rts, err := listRefs("runtime", "")
|
||||
if err != nil {
|
||||
return DiskAnswer{}, err
|
||||
}
|
||||
all := append(apps, rts...)
|
||||
sort.Slice(all, func(i, k int) bool { return all[i].Bytes > all[k].Bytes })
|
||||
if len(all) > 50 {
|
||||
all = all[:50]
|
||||
}
|
||||
out.Largest = all
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func accountHome() string {
|
||||
return homeFrom(getenv)
|
||||
}
|
||||
|
||||
// ActAnswer is what an act answers.
|
||||
type ActAnswer struct {
|
||||
Act string `json:"act"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Installation string `json:"installation"`
|
||||
Job Job `json:"job"`
|
||||
}
|
||||
|
||||
// act runs flatpak as a job; the system installation needs root, the account's does not.
|
||||
func act(verb, ref, inst string, args ...string) (ActAnswer, error) {
|
||||
c := Cmd{Name: "flatpak", Args: append([]string{verb, "--" + inst, "--noninteractive", "-y"}, args...), Root: inst == "system"}
|
||||
j, err := actAsJob(c)
|
||||
if err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
return ActAnswer{Act: verb, Ref: ref, Installation: inst, Job: j}, nil
|
||||
}
|
||||
|
||||
// Install installs a ref from a remote.
|
||||
func Install(ref, remote, inst string) (ActAnswer, error) {
|
||||
if !remoteName.MatchString(remote) {
|
||||
return ActAnswer{}, fmt.Errorf("%q is not a remote's name", remote)
|
||||
}
|
||||
return act("install", ref, inst, remote, ref)
|
||||
}
|
||||
|
||||
// Remove uninstalls a ref.
|
||||
func Remove(ref, inst string, deleteData bool) (ActAnswer, error) {
|
||||
if deleteData {
|
||||
return act("uninstall", ref, inst, "--delete-data", ref)
|
||||
}
|
||||
return act("uninstall", ref, inst, ref)
|
||||
}
|
||||
|
||||
// Update updates one ref, or everything.
|
||||
func Update(ref, inst string) (ActAnswer, error) {
|
||||
if ref == "" {
|
||||
return act("update", "", inst)
|
||||
}
|
||||
return act("update", ref, inst, ref)
|
||||
}
|
||||
|
||||
// RemoveUnused lets flatpak uninstall what it finds unused.
|
||||
func RemoveUnused(inst string) (ActAnswer, error) {
|
||||
return act("uninstall", "", inst, "--unused")
|
||||
}
|
||||
|
||||
// getenv and homeFrom read the account's home the way the runtime gives it.
|
||||
var getenv = func(k string) string { return strings.TrimSpace(os.Getenv(k)) }
|
||||
|
||||
func homeFrom(env func(string) string) string {
|
||||
if h := env("MESH_OPERATOR_HOME"); h != "" {
|
||||
return h
|
||||
}
|
||||
return env("HOME")
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheManifestIsFlatpaksPackageAndFlathubComesWithIt(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
holdsTheBundle(t, m, "flatpak")
|
||||
if got := strings.Join(m.packages(), ","); got != "flatpak" {
|
||||
t.Errorf("packages %s", got)
|
||||
}
|
||||
// The package ships flathub in /usr/share/flatpak/remotes.d: the module declares no remote file.
|
||||
if len(m.Resources) != 1 {
|
||||
t.Errorf("one resource: %v", m.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
const nb = " "
|
||||
|
||||
// The desktop's installation on 2026-10-04, as flatpak list prints it.
|
||||
var apps = "com.nextcloud.desktopclient.nextcloud\tNextcloud Desktop\t3.14\tstable\tx86_64\tflathub\tsystem\t275.8" + nb + "MB\tcom.nextcloud.desktopclient.nextcloud/x86_64/stable\n" +
|
||||
"tv.plex.PlexDesktop\tPlex\t1.9\tstable\tx86_64\tflathub\tsystem\t368.0" + nb + "MB\ttv.plex.PlexDesktop/x86_64/stable\n"
|
||||
|
||||
var runtimes = strings.Join([]string{
|
||||
"org.freedesktop.Platform\tFreedesktop Platform\t22.08.1\t22.08\tx86_64\tflathub\tsystem\t576.7" + nb + "MB\torg.freedesktop.Platform/x86_64/22.08",
|
||||
"org.freedesktop.Platform\tFreedesktop Platform\t23.08.1\t23.08\tx86_64\tflathub\tsystem\t598.3" + nb + "MB\torg.freedesktop.Platform/x86_64/23.08",
|
||||
"org.freedesktop.Platform.GL.default\tMesa\t\t22.08\tx86_64\tflathub\tsystem\t442.1" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/22.08",
|
||||
"org.freedesktop.Platform.GL.default\tMesa\t\t23.08\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08",
|
||||
"org.freedesktop.Platform.GL.default\tMesa\t\t23.08-extra\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08-extra",
|
||||
"org.freedesktop.Platform.openh264\topenh264\t\t2.2.0\tx86_64\tflathub\tsystem\t790.0" + nb + "kB\torg.freedesktop.Platform.openh264/x86_64/2.2.0",
|
||||
"org.freedesktop.Sdk\tSdk\t\t23.08\tx86_64\tflathub\tsystem\t1.6" + nb + "GB\torg.freedesktop.Sdk/x86_64/23.08",
|
||||
"org.kde.Platform\tKDE\t\t6.7\tx86_64\tflathub\tsystem\t931.4" + nb + "MB\torg.kde.Platform/x86_64/6.7",
|
||||
"org.kde.KStyle.Adwaita\tAdwaita\t\t5.15-21.08\tx86_64\tflathub\tsystem\t16.3" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/5.15-21.08",
|
||||
"org.kde.KStyle.Adwaita\tAdwaita\t\t6.7\tx86_64\tflathub\tsystem\t20.6" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/6.7",
|
||||
}, "\n") + "\n"
|
||||
|
||||
const platformMeta = `[Runtime]
|
||||
name=org.freedesktop.Platform
|
||||
|
||||
[Extension org.freedesktop.Platform.GL]
|
||||
versions = 23.08;23.08-extra;1.4
|
||||
version = 1.4
|
||||
directory = lib/x86_64-linux-gnu/GL
|
||||
subdirectories = true
|
||||
|
||||
[Extension org.freedesktop.Platform.openh264]
|
||||
directory = lib/openh264
|
||||
version = 2.2.0
|
||||
|
||||
[Extension org.freedesktop.Platform.Timezones]
|
||||
directory = share/zoneinfo
|
||||
`
|
||||
|
||||
const kdeMeta = `[Runtime]
|
||||
name=org.kde.Platform
|
||||
|
||||
[Extension org.kde.KStyle]
|
||||
directory = lib/plugins/styles
|
||||
subdirectories = true
|
||||
version = 6.7
|
||||
|
||||
[Extension org.freedesktop.Platform.GL]
|
||||
versions = 23.08;1.4
|
||||
subdirectories = true
|
||||
`
|
||||
|
||||
func theDesktop(t *testing.T) *fake {
|
||||
return using(t, func(line string, c Cmd) Result {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "flatpak list --app"):
|
||||
return ok(apps)
|
||||
case strings.HasPrefix(line, "flatpak list --runtime"):
|
||||
return ok(runtimes)
|
||||
case strings.Contains(line, "--show-runtime com.nextcloud"):
|
||||
return ok("org.kde.Platform/x86_64/6.7\n")
|
||||
case strings.Contains(line, "--show-runtime tv.plex"):
|
||||
return ok("org.freedesktop.Platform/x86_64/23.08\n")
|
||||
case strings.Contains(line, "--show-sdk tv.plex"):
|
||||
return ok("org.freedesktop.Sdk/x86_64/23.08\n")
|
||||
case strings.Contains(line, "--show-sdk"):
|
||||
return ok("org.kde.Sdk/x86_64/6.7\n")
|
||||
case strings.Contains(line, "--show-metadata org.freedesktop.Platform/x86_64/23.08"):
|
||||
return ok(platformMeta)
|
||||
case strings.Contains(line, "--show-metadata org.kde.Platform"):
|
||||
return ok(kdeMeta)
|
||||
case strings.Contains(line, "--show-metadata"):
|
||||
return ok("[Application]\nname=x\n")
|
||||
case strings.HasPrefix(line, "flatpak pin"):
|
||||
return ok("")
|
||||
case strings.HasPrefix(line, "flatpak remotes"):
|
||||
return ok("flathub\thttps://dl.flathub.org/repo/\tsystem\t1\nflathub\thttps://dl.flathub.org/repo/\tuser\t1\n")
|
||||
}
|
||||
return Result{Status: 9, Stderr: "unexpected " + line}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnusedIsWhatNoApplicationNeedsDirectlyOrThroughAnExtensionPoint(t *testing.T) {
|
||||
f := theDesktop(t)
|
||||
got, err := Unused()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, r := range got.Unused {
|
||||
names = append(names, r.Ref)
|
||||
}
|
||||
want := "org.freedesktop.Platform.GL.default/x86_64/22.08,org.freedesktop.Platform/x86_64/22.08,org.kde.KStyle.Adwaita/x86_64/5.15-21.08"
|
||||
if strings.Join(names, ",") != want {
|
||||
t.Errorf("unused\n%s\nwant\n%s", strings.Join(names, ","), want)
|
||||
}
|
||||
if got.Bytes != 576700000+442100000+16300000 {
|
||||
t.Errorf("bytes %d", got.Bytes)
|
||||
}
|
||||
for _, l := range f.lines() {
|
||||
if strings.Contains(l, "uninstall") || strings.HasPrefix(l, "sudo") {
|
||||
t.Errorf("unused only reads: %s", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExtensionPointAcceptsItsVersionsOrTheDeclaringBranch(t *testing.T) {
|
||||
ext := extensionsOf(platformMeta, "23.08")
|
||||
if len(ext) != 3 {
|
||||
t.Fatalf("%+v", ext)
|
||||
}
|
||||
gl, h264, tz := ext[0], ext[1], ext[2]
|
||||
if !gl.Subdirs || strings.Join(gl.Versions, ";") != "23.08;23.08-extra;1.4" {
|
||||
t.Errorf("versions win over version: %+v", gl)
|
||||
}
|
||||
if h264.Subdirs || strings.Join(h264.Versions, ";") != "2.2.0" {
|
||||
t.Errorf("%+v", h264)
|
||||
}
|
||||
if strings.Join(tz.Versions, ";") != "23.08" {
|
||||
t.Errorf("no version is the declaring branch: %+v", tz)
|
||||
}
|
||||
if !gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "23.08-extra"}) || gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "22.08"}) {
|
||||
t.Error("a subdirectory extension, by branch")
|
||||
}
|
||||
if h264.fills(Ref{ID: "org.freedesktop.Platform.openh264.x", Branch: "2.2.0"}) {
|
||||
t.Error("without subdirectories only the id itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotesFindTheDuplicateAndTheEmptyOne(t *testing.T) {
|
||||
theDesktop(t)
|
||||
got, err := Remotes()
|
||||
if err != nil || len(got.Remotes) != 2 || !got.Flathub {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if got.Remotes[0].Installed != 12 || got.Remotes[1].Installed != 0 {
|
||||
t.Errorf("%+v", got.Remotes)
|
||||
}
|
||||
all := strings.Join(got.Findings, ";")
|
||||
if !strings.Contains(all, "flathub in the user installation has nothing installed") || !strings.Contains(all, "configured 2 times") {
|
||||
t.Errorf("%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReadsSizesAsBytes(t *testing.T) {
|
||||
theDesktop(t)
|
||||
got, err := List("app", "")
|
||||
if err != nil || got.Count != 2 || got.Refs[0].Bytes != 275800000 || got.Refs[0].Size != "275.8 MB" || got.Bytes != 643800000 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
for in, want := range map[string]int64{"1.6" + nb + "GB": 1600000000, "790.0 kB": 790000, "12 bytes": 12, "": 0} {
|
||||
if b := sizeBytes(in); b != want {
|
||||
t.Errorf("%q: %d", in, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActsOnTheSystemInstallationEscalateAndTheAccountsDoNot(t *testing.T) {
|
||||
f := using(t, func(string, Cmd) Result { return ok("done") })
|
||||
if got, err := Install("org.gimp.GIMP", "flathub", "system"); err != nil || got.Job.Running {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if _, err := Install("org.gimp.GIMP", "flathub", "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Remove("org.gimp.GIMP", "system", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Update("", "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := RemoveUnused("system"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"sudo -n flatpak install --system --noninteractive -y flathub org.gimp.GIMP",
|
||||
"flatpak install --user --noninteractive -y flathub org.gimp.GIMP",
|
||||
"sudo -n flatpak uninstall --system --noninteractive -y --delete-data org.gimp.GIMP",
|
||||
"flatpak update --user --noninteractive -y",
|
||||
"sudo -n flatpak uninstall --system --noninteractive -y --unused",
|
||||
}
|
||||
if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") {
|
||||
t.Errorf("asked\n%s", got)
|
||||
}
|
||||
if _, err := Install("org.gimp.GIMP", "--from=x", "system"); err == nil {
|
||||
t.Error("an option as a remote")
|
||||
}
|
||||
for _, bad := range []string{"--assumeyes", "gimp", "org.gimp.GIMP; rm", "org/../x"} {
|
||||
if checkRef(bad) == nil {
|
||||
t.Errorf("%q accepted as a ref", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"org.gimp.GIMP", "org.freedesktop.Platform/x86_64/23.08", "app/org.gimp.GIMP/x86_64/stable", "org.freedesktop.Platform.GL.default//23.08-extra"} {
|
||||
if err := checkRef(good); err != nil {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedInstallIsAnErrorAndAMissingFlatpakIsSaid(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result {
|
||||
return Result{Status: 1, Stderr: "error: Nothing matches org.nope.Nope in remote flathub"}
|
||||
})
|
||||
if _, err := Install("org.nope.Nope", "flathub", "user"); err == nil || !strings.Contains(err.Error(), "Nothing matches") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
|
||||
if _, err := List("app", ""); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskUsageReadsEachInstallationAndToleratesAnUnreadableFile(t *testing.T) {
|
||||
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
switch {
|
||||
case line == "du -sb /var/lib/flatpak":
|
||||
return Result{Status: 1, Stdout: "5094728394\t/var/lib/flatpak\n", Stderr: "du: cannot read directory 'x': Permission denied"}
|
||||
case line == "du -sb /home/op/.local/share/flatpak":
|
||||
return Result{Status: 1, Stderr: "du: cannot access '/home/op/.local/share/flatpak': No such file or directory"}
|
||||
case strings.Contains(line, "--app"):
|
||||
return ok(apps)
|
||||
}
|
||||
return ok(runtimes)
|
||||
})
|
||||
got, err := DiskUsage()
|
||||
if err != nil || got.Installations["system"] != 5094728394 || got.Installations["user"] != 0 || got.Note == "" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if got.Largest[0].ID != "org.freedesktop.Sdk" {
|
||||
t.Errorf("largest first: %+v", got.Largest[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose):
|
||||
// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an
|
||||
// act is started as a job inside this process, waited on for a while, and answered either finished
|
||||
// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the
|
||||
// runtime restarts the bundle, a running job is cut off, and its id is then unknown.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id.
|
||||
const (
|
||||
JobLimit = 15 * time.Minute
|
||||
JobWait = 18 * time.Second
|
||||
keptJobs = 50
|
||||
)
|
||||
|
||||
// Job is one long act, as its tool answers it.
|
||||
type Job struct {
|
||||
ID string `json:"job"`
|
||||
Command string `json:"command"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished *time.Time `json:"finished,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Status *int `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type jobBook struct {
|
||||
mu sync.Mutex
|
||||
seq int
|
||||
jobs map[string]*Job
|
||||
}
|
||||
|
||||
var jobs = &jobBook{jobs: map[string]*Job{}}
|
||||
|
||||
// startJob runs c in the background, held to JobLimit.
|
||||
func startJob(c Cmd) *Job {
|
||||
c.Timeout = JobLimit
|
||||
name, args := argv(c)
|
||||
jobs.mu.Lock()
|
||||
jobs.seq++
|
||||
j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")),
|
||||
Started: time.Now().UTC(), Running: true, done: make(chan struct{})}
|
||||
jobs.jobs[j.ID] = j
|
||||
jobs.forgetOldest()
|
||||
jobs.mu.Unlock()
|
||||
go func() {
|
||||
r := run(c)
|
||||
var err error
|
||||
if r.Status != 0 || r.Error != "" {
|
||||
err = failure(c, r)
|
||||
}
|
||||
jobs.mu.Lock()
|
||||
now := time.Now().UTC()
|
||||
j.Finished, j.Running = &now, false
|
||||
status := r.Status
|
||||
j.Status = &status
|
||||
if err != nil {
|
||||
j.Error = err.Error()
|
||||
}
|
||||
j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10)
|
||||
j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10
|
||||
jobs.mu.Unlock()
|
||||
close(j.done)
|
||||
}()
|
||||
return j
|
||||
}
|
||||
|
||||
// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held.
|
||||
func (b *jobBook) forgetOldest() {
|
||||
if len(b.jobs) <= keptJobs {
|
||||
return
|
||||
}
|
||||
all := make([]*Job, 0, len(b.jobs))
|
||||
for _, j := range b.jobs {
|
||||
all = append(all, j)
|
||||
}
|
||||
sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) })
|
||||
for _, j := range all {
|
||||
if len(b.jobs) <= keptJobs {
|
||||
return
|
||||
}
|
||||
if !j.Running {
|
||||
delete(b.jobs, j.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands.
|
||||
func awaitJob(j *Job, d time.Duration) Job {
|
||||
select {
|
||||
case <-j.done:
|
||||
case <-time.After(d):
|
||||
}
|
||||
return snapshot(j)
|
||||
}
|
||||
|
||||
func snapshot(j *Job) Job {
|
||||
jobs.mu.Lock()
|
||||
defer jobs.mu.Unlock()
|
||||
c := *j
|
||||
c.done = nil
|
||||
return c
|
||||
}
|
||||
|
||||
// jobByID answers a job by its id, or says it is not known to this process.
|
||||
func jobByID(id string) (Job, error) {
|
||||
jobs.mu.Lock()
|
||||
j, ok := jobs.jobs[id]
|
||||
jobs.mu.Unlock()
|
||||
if !ok {
|
||||
return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs)
|
||||
}
|
||||
return snapshot(j), nil
|
||||
}
|
||||
|
||||
// listJobs answers every job this process knows, newest first.
|
||||
func listJobs() []Job {
|
||||
jobs.mu.Lock()
|
||||
all := make([]*Job, 0, len(jobs.jobs))
|
||||
for _, j := range jobs.jobs {
|
||||
all = append(all, j)
|
||||
}
|
||||
jobs.mu.Unlock()
|
||||
sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) })
|
||||
out := make([]Job, 0, len(all))
|
||||
for _, j := range all {
|
||||
out = append(out, snapshot(j))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first.
|
||||
// A finished job that failed is answered as an error, so a failed act is never read as success.
|
||||
func actAsJob(c Cmd) (Job, error) {
|
||||
j := awaitJob(startJob(c), JobWait)
|
||||
if !j.Running && j.Error != "" {
|
||||
return j, fmt.Errorf("%s (job %s)", j.Error, j.ID)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) {
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
if c.Timeout != JobLimit {
|
||||
t.Errorf("a job is held to JobLimit, not %s", c.Timeout)
|
||||
}
|
||||
if strings.Contains(line, "bad") {
|
||||
return Result{Status: 2, Stderr: "it broke"}
|
||||
}
|
||||
return ok("done")
|
||||
})
|
||||
j, err := actAsJob(Cmd{Name: "good"})
|
||||
if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" {
|
||||
t.Fatalf("%+v %v", j, err)
|
||||
}
|
||||
if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") {
|
||||
t.Fatalf("a failed job: %v", err)
|
||||
}
|
||||
got, err := jobByID(j.ID)
|
||||
if err != nil || got.ID != j.ID {
|
||||
t.Fatalf("by id: %+v %v", got, err)
|
||||
}
|
||||
if _, err := jobByID("nope"); err == nil {
|
||||
t.Fatal("an unknown job")
|
||||
}
|
||||
if len(listJobs()) < 2 {
|
||||
t.Fatal("listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
using(t, func(line string, c Cmd) Result { <-release; return ok("") })
|
||||
j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond)
|
||||
if !j.Running || j.ID == "" {
|
||||
t.Fatalf("%+v", j)
|
||||
}
|
||||
close(release)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
got, _ := jobByID(j.ID)
|
||||
if got.Running {
|
||||
t.Fatalf("finished afterwards: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// Tests of kit.go, the same in each workstation module.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake records the commands asked and answers each from a function of the command line.
|
||||
type fake struct {
|
||||
asked []Cmd
|
||||
answer func(line string, c Cmd) Result
|
||||
}
|
||||
|
||||
func (f *fake) runner() Runner {
|
||||
return func(c Cmd) Result {
|
||||
f.asked = append(f.asked, c)
|
||||
name, args := argv(c)
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
if f.answer == nil {
|
||||
return Result{}
|
||||
}
|
||||
return f.answer(line, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) lines() []string {
|
||||
out := []string{}
|
||||
for _, c := range f.asked {
|
||||
name, args := argv(c)
|
||||
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// using installs a fake runner and a non-root uid for one test.
|
||||
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||
t.Helper()
|
||||
f := &fake{answer: answer}
|
||||
wasRun, wasUID := run, euid
|
||||
run, euid = f.runner(), func() int { return 1000 }
|
||||
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||
return f
|
||||
}
|
||||
|
||||
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||
|
||||
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||
t.Fatalf("not root: %s %v", name, args)
|
||||
}
|
||||
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||
t.Fatalf("a read is run as the account: %s", name)
|
||||
}
|
||||
euid = func() int { return 0 }
|
||||
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||
t.Fatalf("as root no sudo: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
cases := []struct {
|
||||
c Cmd
|
||||
r Result
|
||||
want string
|
||||
}{
|
||||
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||
}
|
||||
for _, k := range cases {
|
||||
err := failure(k.c, k.r)
|
||||
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||
var w bounded
|
||||
big := strings.Repeat("a", MostOutput+10)
|
||||
n, _ := w.Write([]byte(big))
|
||||
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||
if r.Error != "timeout" {
|
||||
t.Fatalf("a slow command: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||
if r.Error != "not-found" {
|
||||
t.Fatalf("a missing program: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||
if r.Stdout != "given" {
|
||||
t.Fatalf("stdin: %+v", r)
|
||||
}
|
||||
start := time.Now()
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if _, err := text(args, "missing"); err == nil {
|
||||
t.Error("a missing required string")
|
||||
}
|
||||
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||
t.Errorf("held to most: %d", n)
|
||||
}
|
||||
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||
t.Error("below least")
|
||||
}
|
||||
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||
t.Error("a fraction")
|
||||
}
|
||||
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||
t.Errorf("list: %v", l)
|
||||
}
|
||||
if b, _ := optFlag(args, "b", false); !b {
|
||||
t.Error("flag")
|
||||
}
|
||||
if err := plainName("name", "--all"); err == nil {
|
||||
t.Error("an option as a name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// The flatpak module's tools (novox/hq research 027/02, 026/05): the applications and runtimes in
|
||||
// both installations, the remotes, pending updates, what nothing uses any more and the space it all
|
||||
// takes; and installing, removing and updating. A Go bundle the node's runtime launches over stdio
|
||||
// (ADR 0188, ADR 0193); it runs as the operator account. An act on the system installation goes
|
||||
// through `sudo -n`; one on the account's own installation does not.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
var providedBy = map[string]string{
|
||||
"flatpak": "the flatpak package, which this module installs",
|
||||
"du": "the coreutils package",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var installationArg = map[string]any{"type": "string", "enum": []string{"system", "user"},
|
||||
"description": "the system installation (default), or the account's own"}
|
||||
|
||||
func installationOf(args map[string]any, def string) (string, error) {
|
||||
i, err := optText(args, "installation", def)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if i == "" {
|
||||
return "", nil
|
||||
}
|
||||
return i, oneOf("installation", i, "system", "user")
|
||||
}
|
||||
|
||||
func refArg(args map[string]any, key string) (string, error) {
|
||||
r, err := text(args, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r, checkRef(r)
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "flatpak_list",
|
||||
Description: "The installed applications, in both installations or one: id, name, version, branch, origin, " +
|
||||
"installation and size. (r)",
|
||||
Input: map[string]any{"installation": installationArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
inst, err := installationOf(args, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return List("app", inst)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_runtimes",
|
||||
Description: "The installed runtimes and extensions, in both installations or one: id, branch, origin, installation and size. (r)",
|
||||
Input: map[string]any{"installation": installationArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
inst, err := installationOf(args, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return List("runtime", inst)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_remotes",
|
||||
Description: "The remotes of both installations, with how many installed refs come from each, and findings: " +
|
||||
"Flathub missing from the system installation, the same remote in both installations, a remote nothing " +
|
||||
"is installed from. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Remotes() },
|
||||
},
|
||||
{
|
||||
Name: "flatpak_updates",
|
||||
Description: "What an update would change: each ref with a newer commit on its remote. Asks the remotes, so it needs the network. (r)",
|
||||
Input: map[string]any{"installation": installationArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
inst, err := installationOf(args, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Updates(inst)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_unused",
|
||||
Description: "The runtimes and extensions no installed application needs any more, computed without changing " +
|
||||
"anything: a runtime is used when an application names it as its runtime or SDK, when it fills an " +
|
||||
"extension point of something used, or when it is pinned. With the space each takes. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Unused() },
|
||||
},
|
||||
{
|
||||
Name: "flatpak_disk_usage",
|
||||
Description: "The space flatpak takes: each installation's directory on disk, and the applications and " +
|
||||
"runtimes by size, largest first. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return DiskUsage() },
|
||||
},
|
||||
{
|
||||
Name: "flatpak_install",
|
||||
Description: "Install an application or runtime from a remote (default flathub), in the system installation " +
|
||||
"(default) or the account's. Answers when finished, or after 18 s with a job to follow with flatpak_job. (a)",
|
||||
Input: map[string]any{
|
||||
"ref": map[string]any{"type": "string", "description": "an application id such as org.gimp.GIMP, or a full ref"},
|
||||
"remote": map[string]any{"type": "string", "description": "the remote (default flathub)"},
|
||||
"installation": installationArg,
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := refArg(args, "ref")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote, err := optText(args, "remote", "flathub")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst, err := installationOf(args, "system")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Install(ref, remote, inst)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_remove",
|
||||
Description: "Uninstall an application or runtime, keeping its data unless delete_data is set. Answers when finished, or with a job to follow. (a)",
|
||||
Input: map[string]any{
|
||||
"ref": map[string]any{"type": "string", "description": "the application id or ref"},
|
||||
"installation": installationArg,
|
||||
"delete_data": map[string]any{"type": "boolean", "description": "remove the application's data in the home too"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := refArg(args, "ref")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst, err := installationOf(args, "system")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
del, err := optFlag(args, "delete_data", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Remove(ref, inst, del)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_update",
|
||||
Description: "Update one ref, or everything in an installation when no ref is given. Answers when finished, or with a job to follow. (a)",
|
||||
Input: map[string]any{
|
||||
"ref": map[string]any{"type": "string", "description": "the application id or ref (default all)"},
|
||||
"installation": installationArg,
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := optText(args, "ref", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ref != "" {
|
||||
if err := checkRef(ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
inst, err := installationOf(args, "system")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Update(ref, inst)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_remove_unused",
|
||||
Description: "Uninstall what flatpak itself finds unused in one installation (system by default). " +
|
||||
"flatpak_unused shows what that is beforehand. Answers when finished, or with a job to follow. (a)",
|
||||
Input: map[string]any{"installation": installationArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
inst, err := installationOf(args, "system")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RemoveUnused(inst)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "flatpak_job",
|
||||
Description: "A long act this module started: running or finished, its exit status and the end of its output. Without job, every act this process knows. (r)",
|
||||
Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := optText(args, "job", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id == "" {
|
||||
return map[string]any{"jobs": listJobs()}, nil
|
||||
}
|
||||
return jobByID(id)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||
// definition so the module's own tests can hold it to what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packages are the packages the module installs, sorted.
|
||||
func (m manifest) packages() []string {
|
||||
out := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" && r["absent"] != true {
|
||||
out = append(out, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// services are the units the module declares, by unit name.
|
||||
func (m manifest) services() map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" {
|
||||
out[r["unit"].(string)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||
t.Helper()
|
||||
registered := []string{}
|
||||
for _, tool := range tools() {
|
||||
registered = append(registered, tool.Name)
|
||||
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||
}
|
||||
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
binary := filepath.Base(cwd)
|
||||
a := m.Build.Artifacts[0]
|
||||
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||
for k, v := range want {
|
||||
if a[k] != v {
|
||||
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||
}
|
||||
}
|
||||
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil {
|
||||
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module flatpak
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"module": "flatpak",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"tools": [
|
||||
"flatpak_list",
|
||||
"flatpak_runtimes",
|
||||
"flatpak_remotes",
|
||||
"flatpak_updates",
|
||||
"flatpak_unused",
|
||||
"flatpak_disk_usage",
|
||||
"flatpak_install",
|
||||
"flatpak_remove",
|
||||
"flatpak_update",
|
||||
"flatpak_remove_unused",
|
||||
"flatpak_job"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "flatpak"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/flatpak-tools",
|
||||
"binary": "flatpak-tools",
|
||||
"loads": [
|
||||
"flatpak-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user