| 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 | // Sudo controls privilege-wrapping of the install/remove; the zero value |
| 40 | // (SudoDefault) sudo-wraps, preserving the previous always-sudo behavior. |
| 41 | Sudo SudoPolicy |
| 42 | } |
| 43 | |
| 44 | // Name returns the step name. |
| 45 | func (p Package) Name() string { return "package:" + p.Pkg } |
| 46 | |
| 47 | // Check reports a change is needed when the package's installed state differs |
| 48 | // from the desired State. |
| 49 | func (p Package) Check(fc *legatus.FlowCtx) (bool, error) { |
| 50 | installed, err := p.isInstalled(fc) |
| 51 | if err != nil { |
| 52 | return false, err |
| 53 | } |
| 54 | switch p.State { |
| 55 | case PkgPresent: |
| 56 | return !installed, nil |
| 57 | case PkgAbsent: |
| 58 | return installed, nil |
| 59 | default: |
| 60 | return false, culpa.WithCode(culpa.Errorf("package %s: unknown state %q", p.Pkg, p.State), "PKG_STATE") |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // Apply installs or removes the package. |
| 65 | func (p Package) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 66 | mgr, err := p.effectiveManager(fc) |
| 67 | if err != nil { |
| 68 | return legatus.Outcome{}, err |
| 69 | } |
| 70 | cmd, err := p.applyCmd(mgr, p.Sudo.resolve(fc.Host(), true)) |
| 71 | if err != nil { |
| 72 | return legatus.Outcome{}, err |
| 73 | } |
| 74 | res, err := fc.Conn().Exec(fc.Ctx(), cmd) |
| 75 | if err != nil { |
| 76 | return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "package %s %s", p.State, p.Pkg), "PKG_APPLY") |
| 77 | } |
| 78 | if res.ExitCode != 0 { |
| 79 | return legatus.Outcome{}, culpa.WithCode( |
| 80 | culpa.Errorf("package %s %s: exit %d: %s", p.State, p.Pkg, res.ExitCode, strings.TrimSpace(string(res.Combined()))), |
| 81 | "PKG_APPLY") |
| 82 | } |
| 83 | return legatus.Outcome{Changed: true, Message: string(p.State) + " " + p.Pkg}, nil |
| 84 | } |
| 85 | |
| 86 | // effectiveManager returns the configured Manager, or auto-detects it when |
| 87 | // Manager is empty by probing the host for pacman/apt-get/dnf in that order. An |
| 88 | // unidentifiable host is an error (PC3: never a silent default). |
| 89 | func (p Package) effectiveManager(fc *legatus.FlowCtx) (PkgManager, error) { |
| 90 | if p.Manager != "" { |
| 91 | return p.Manager, nil |
| 92 | } |
| 93 | for _, c := range []struct { |
| 94 | bin string |
| 95 | mgr PkgManager |
| 96 | }{{"pacman", Pacman}, {"apt-get", Apt}, {"dnf", Dnf}} { |
| 97 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "sh", Args: []string{"-c", "command -v " + c.bin}}) |
| 98 | if err != nil { |
| 99 | return "", culpa.WithCode(culpa.Wrapf(err, "probe %s", c.bin), "PKG_MANAGER") |
| 100 | } |
| 101 | if res.ExitCode == 0 { |
| 102 | return c.mgr, nil |
| 103 | } |
| 104 | } |
| 105 | return "", culpa.WithCode( |
| 106 | culpa.Errorf("package %s: no supported manager (pacman/apt-get/dnf) on host", p.Pkg), "PKG_MANAGER") |
| 107 | } |
| 108 | |
| 109 | // isInstalled queries the manager for the package's presence. |
| 110 | func (p Package) isInstalled(fc *legatus.FlowCtx) (bool, error) { |
| 111 | mgr, err := p.effectiveManager(fc) |
| 112 | if err != nil { |
| 113 | return false, err |
| 114 | } |
| 115 | switch mgr { |
| 116 | case Apt: |
| 117 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{ |
| 118 | Path: "dpkg-query", |
| 119 | Args: []string{"-W", "-f=${Status}", p.Pkg}, |
| 120 | }) |
| 121 | if err != nil { |
| 122 | return false, culpa.WithCode(culpa.Wrapf(err, "query %s", p.Pkg), "PKG_PROBE") |
| 123 | } |
| 124 | if res.ExitCode != 0 { |
| 125 | return false, nil |
| 126 | } |
| 127 | return strings.Contains(string(res.Stdout), "install ok installed"), nil |
| 128 | case Dnf: |
| 129 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{ |
| 130 | Path: "rpm", |
| 131 | Args: []string{"-q", p.Pkg}, |
| 132 | }) |
| 133 | if err != nil { |
| 134 | return false, culpa.WithCode(culpa.Wrapf(err, "query %s", p.Pkg), "PKG_PROBE") |
| 135 | } |
| 136 | return res.ExitCode == 0, nil |
| 137 | case Pacman: |
| 138 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{ |
| 139 | Path: "pacman", |
| 140 | Args: []string{"-Q", p.Pkg}, |
| 141 | }) |
| 142 | if err != nil { |
| 143 | return false, culpa.WithCode(culpa.Wrapf(err, "query %s", p.Pkg), "PKG_PROBE") |
| 144 | } |
| 145 | return res.ExitCode == 0, nil |
| 146 | default: |
| 147 | return false, culpa.WithCode(culpa.Errorf("package %s: unknown manager %q", p.Pkg, mgr), "PKG_MANAGER") |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // applyCmd builds the install/remove command for the resolved manager and state, |
| 152 | // sudo-wrapped per the resolved sudo decision. |
| 153 | func (p Package) applyCmd(mgr PkgManager, sudo bool) (legatus.Cmd, error) { |
| 154 | var path string |
| 155 | switch mgr { |
| 156 | case Apt: |
| 157 | path = "apt-get" |
| 158 | case Dnf: |
| 159 | path = "dnf" |
| 160 | case Pacman: |
| 161 | verb := "-S" // present |
| 162 | if p.State == PkgAbsent { |
| 163 | verb = "-R" |
| 164 | } |
| 165 | return legatus.Cmd{Path: "pacman", Args: []string{verb, "--noconfirm", p.Pkg}, Sudo: sudo}, nil |
| 166 | default: |
| 167 | return legatus.Cmd{}, culpa.WithCode(culpa.Errorf("package %s: unknown manager %q", p.Pkg, mgr), "PKG_MANAGER") |
| 168 | } |
| 169 | |
| 170 | var verb string |
| 171 | switch p.State { |
| 172 | case PkgPresent: |
| 173 | verb = "install" |
| 174 | case PkgAbsent: |
| 175 | verb = "remove" |
| 176 | default: |
| 177 | return legatus.Cmd{}, culpa.WithCode(culpa.Errorf("package %s: unknown state %q", p.Pkg, p.State), "PKG_STATE") |
| 178 | } |
| 179 | |
| 180 | return legatus.Cmd{ |
| 181 | Path: path, |
| 182 | Args: []string{"-y", verb, p.Pkg}, |
| 183 | Env: map[string]string{"DEBIAN_FRONTEND": "noninteractive"}, |
| 184 | Sudo: sudo, |
| 185 | }, nil |
| 186 | } |
| 187 | |