| 1 | package steps |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "go.bigb.es/auxilia/culpa" |
| 7 | "go.bigb.es/auxilia/legatus" |
| 8 | ) |
| 9 | |
| 10 | // PkgState is the desired state of a package. |
| 11 | type PkgState string |
| 12 | |
| 13 | const ( |
| 14 | // PkgPresent requires the package to be installed. |
| 15 | PkgPresent PkgState = "present" |
| 16 | // PkgAbsent requires the package to be removed. |
| 17 | PkgAbsent PkgState = "absent" |
| 18 | ) |
| 19 | |
| 20 | // PkgManager identifies the host package manager. PC3: this battery carries |
| 21 | // apt/dnf/pacman opinions. An unknown manager is an error, never a silent default. |
| 22 | type PkgManager string |
| 23 | |
| 24 | const ( |
| 25 | // Apt is Debian/Ubuntu apt-get. |
| 26 | Apt PkgManager = "apt" |
| 27 | // Dnf is Fedora/RHEL dnf. |
| 28 | Dnf PkgManager = "dnf" |
| 29 | // Pacman is Arch Linux pacman. |
| 30 | Pacman PkgManager = "pacman" |
| 31 | ) |
| 32 | |
| 33 | // Package manages an OS package. Check queries whether it is installed; Apply |
| 34 | // installs or removes it to converge State. |
| 35 | type Package struct { |
| 36 | Pkg string |
| 37 | State PkgState |
| 38 | Manager PkgManager |
| 39 | } |
| 40 | |
| 41 | // Name returns the step name. |
| 42 | func (p Package) Name() string { return "package:" + p.Pkg } |
| 43 | |
| 44 | // Check reports a change is needed when the package's installed state differs |
| 45 | // from the desired State. |
| 46 | func (p Package) Check(fc *legatus.FlowCtx) (bool, error) { |
| 47 | installed, err := p.isInstalled(fc) |
| 48 | if err != nil { |
| 49 | return false, err |
| 50 | } |
| 51 | switch p.State { |
| 52 | case PkgPresent: |
| 53 | return !installed, nil |
| 54 | case PkgAbsent: |
| 55 | return installed, nil |
| 56 | default: |
| 57 | return false, culpa.WithCode(culpa.Errorf("package %s: unknown state %q", p.Pkg, p.State), "PKG_STATE") |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // Apply installs or removes the package. |
| 62 | func (p Package) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 63 | cmd, err := p.applyCmd() |
| 64 | if err != nil { |
| 65 | return legatus.Outcome{}, err |
| 66 | } |
| 67 | res, err := fc.Conn().Exec(fc.Ctx(), cmd) |
| 68 | if err != nil { |
| 69 | return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "package %s %s", p.State, p.Pkg), "PKG_APPLY") |
| 70 | } |
| 71 | if res.ExitCode != 0 { |
| 72 | return legatus.Outcome{}, culpa.WithCode( |
| 73 | culpa.Errorf("package %s %s: exit %d: %s", p.State, p.Pkg, res.ExitCode, strings.TrimSpace(string(res.Combined()))), |
| 74 | "PKG_APPLY") |
| 75 | } |
| 76 | return legatus.Outcome{Changed: true, Message: string(p.State) + " " + p.Pkg}, nil |
| 77 | } |
| 78 | |
| 79 | // isInstalled queries the manager for the package's presence. |
| 80 | func (p Package) isInstalled(fc *legatus.FlowCtx) (bool, error) { |
| 81 | switch p.Manager { |
| 82 | case Apt: |
| 83 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{ |
| 84 | Path: "dpkg-query", |
| 85 | Args: []string{"-W", "-f=${Status}", p.Pkg}, |
| 86 | }) |
| 87 | if err != nil { |
| 88 | return false, culpa.WithCode(culpa.Wrapf(err, "query %s", p.Pkg), "PKG_PROBE") |
| 89 | } |
| 90 | if res.ExitCode != 0 { |
| 91 | return false, nil |
| 92 | } |
| 93 | return strings.Contains(string(res.Stdout), "install ok installed"), nil |
| 94 | case Dnf: |
| 95 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{ |
| 96 | Path: "rpm", |
| 97 | Args: []string{"-q", p.Pkg}, |
| 98 | }) |
| 99 | if err != nil { |
| 100 | return false, culpa.WithCode(culpa.Wrapf(err, "query %s", p.Pkg), "PKG_PROBE") |
| 101 | } |
| 102 | return res.ExitCode == 0, nil |
| 103 | case Pacman: |
| 104 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{ |
| 105 | Path: "pacman", |
| 106 | Args: []string{"-Q", p.Pkg}, |
| 107 | }) |
| 108 | if err != nil { |
| 109 | return false, culpa.WithCode(culpa.Wrapf(err, "query %s", p.Pkg), "PKG_PROBE") |
| 110 | } |
| 111 | return res.ExitCode == 0, nil |
| 112 | default: |
| 113 | return false, culpa.WithCode(culpa.Errorf("package %s: unknown manager %q", p.Pkg, p.Manager), "PKG_MANAGER") |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // applyCmd builds the install/remove command for the manager and state. |
| 118 | func (p Package) applyCmd() (legatus.Cmd, error) { |
| 119 | var path string |
| 120 | switch p.Manager { |
| 121 | case Apt: |
| 122 | path = "apt-get" |
| 123 | case Dnf: |
| 124 | path = "dnf" |
| 125 | case Pacman: |
| 126 | verb := "-S" // present |
| 127 | if p.State == PkgAbsent { |
| 128 | verb = "-R" |
| 129 | } |
| 130 | return legatus.Cmd{Path: "pacman", Args: []string{verb, "--noconfirm", p.Pkg}, Sudo: true}, nil |
| 131 | default: |
| 132 | return legatus.Cmd{}, culpa.WithCode(culpa.Errorf("package %s: unknown manager %q", p.Pkg, p.Manager), "PKG_MANAGER") |
| 133 | } |
| 134 | |
| 135 | var verb string |
| 136 | switch p.State { |
| 137 | case PkgPresent: |
| 138 | verb = "install" |
| 139 | case PkgAbsent: |
| 140 | verb = "remove" |
| 141 | default: |
| 142 | return legatus.Cmd{}, culpa.WithCode(culpa.Errorf("package %s: unknown state %q", p.Pkg, p.State), "PKG_STATE") |
| 143 | } |
| 144 | |
| 145 | return legatus.Cmd{ |
| 146 | Path: path, |
| 147 | Args: []string{"-y", verb, p.Pkg}, |
| 148 | Env: map[string]string{"DEBIAN_FRONTEND": "noninteractive"}, |
| 149 | Sudo: true, |
| 150 | }, nil |
| 151 | } |
| 152 | |