feat: 跨平台命令助手 kopsc 初始实现
- 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令 - 内置 docker.clearsafe、net.ports、pkg.refreshindex - --dry-run 预览、list/explain/platform/version 辅助命令 - 纯 Go 跨平台构建脚本 scripts/build
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// Package platform detects the execution environment that kopsc uses to pick
|
||||
// the most specific command variant: OS/arch, Linux distribution and available
|
||||
// container runtimes.
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OSReleasePath is where Linux distributions publish their identity.
|
||||
const OSReleasePath = "/etc/os-release"
|
||||
|
||||
// Info is the detected environment.
|
||||
type Info struct {
|
||||
OS string // runtime.GOOS, e.g. "linux", "darwin", "windows"
|
||||
Arch string // runtime.GOARCH, e.g. "amd64", "arm64"
|
||||
Distro string // lower-case os-release ID, empty when unknown
|
||||
DistroLike []string // lower-case os-release ID_LIKE entries
|
||||
Runtimes []string // container runtimes found on PATH, e.g. "docker", "podman"
|
||||
}
|
||||
|
||||
// Probe gathers Info. Its dependencies are injectable so detection can be
|
||||
// exercised without touching the real host.
|
||||
type Probe struct {
|
||||
GOOS string
|
||||
GOARCH string
|
||||
ReadFile func(string) ([]byte, error)
|
||||
LookPath func(string) (string, error)
|
||||
RuntimeCandidates []string
|
||||
}
|
||||
|
||||
// System returns a Probe wired to the real process environment.
|
||||
func System() Probe {
|
||||
return Probe{
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ReadFile: os.ReadFile,
|
||||
LookPath: exec.LookPath,
|
||||
RuntimeCandidates: []string{"docker", "podman"},
|
||||
}
|
||||
}
|
||||
|
||||
// Detect inspects the environment. A missing or unreadable os-release file is
|
||||
// not an error: it simply leaves Distro empty, which resolution treats as
|
||||
// "unknown distribution".
|
||||
func (p Probe) Detect() Info {
|
||||
info := Info{OS: p.GOOS, Arch: p.GOARCH}
|
||||
|
||||
if p.GOOS == "linux" && p.ReadFile != nil {
|
||||
if data, err := p.ReadFile(OSReleasePath); err == nil {
|
||||
info.Distro, info.DistroLike = ParseOSRelease(data)
|
||||
}
|
||||
}
|
||||
|
||||
candidates := p.RuntimeCandidates
|
||||
if candidates == nil {
|
||||
candidates = []string{"docker", "podman"}
|
||||
}
|
||||
if p.LookPath != nil {
|
||||
for _, name := range candidates {
|
||||
if _, err := p.LookPath(name); err == nil {
|
||||
info.Runtimes = append(info.Runtimes, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// ParseOSRelease extracts ID and ID_LIKE from os-release content. Values are
|
||||
// lower-cased and unquoted; parsing never fails, malformed lines are skipped.
|
||||
func ParseOSRelease(data []byte) (distro string, like []string) {
|
||||
values := make(map[string]string)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
}
|
||||
|
||||
distro = strings.ToLower(strings.TrimSpace(values["ID"]))
|
||||
for _, field := range strings.Fields(values["ID_LIKE"]) {
|
||||
like = append(like, strings.ToLower(field))
|
||||
}
|
||||
return distro, like
|
||||
}
|
||||
|
||||
// String renders Info for `kopsc platform`.
|
||||
func (i Info) String() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("os: " + i.OS + "\n")
|
||||
b.WriteString("arch: " + i.Arch + "\n")
|
||||
distro := i.Distro
|
||||
if distro == "" {
|
||||
distro = "-"
|
||||
}
|
||||
b.WriteString("distro: " + distro + "\n")
|
||||
if len(i.DistroLike) > 0 {
|
||||
b.WriteString("like: " + strings.Join(i.DistroLike, " ") + "\n")
|
||||
}
|
||||
runtimes := "-"
|
||||
if len(i.Runtimes) > 0 {
|
||||
runtimes = strings.Join(i.Runtimes, ", ")
|
||||
}
|
||||
b.WriteString("runtimes: " + runtimes + "\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const centosStream9 = `NAME="CentOS Stream"
|
||||
VERSION="9"
|
||||
ID="centos"
|
||||
ID_LIKE="rhel fedora"
|
||||
VERSION_ID="9"
|
||||
PLATFORM_ID="platform:el9"
|
||||
`
|
||||
|
||||
const ubuntu2404 = `PRETTY_NAME="Ubuntu 24.04 LTS"
|
||||
NAME="Ubuntu"
|
||||
VERSION_ID="24.04"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
`
|
||||
|
||||
const alpine = `NAME="Alpine Linux"
|
||||
ID=alpine
|
||||
VERSION_ID=3.20.3
|
||||
`
|
||||
|
||||
func TestParseOSRelease(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDistro string
|
||||
wantLike []string
|
||||
}{
|
||||
{"centos stream 9", centosStream9, "centos", []string{"rhel", "fedora"}},
|
||||
{"ubuntu 24.04", ubuntu2404, "ubuntu", []string{"debian"}},
|
||||
{"alpine without ID_LIKE", alpine, "alpine", nil},
|
||||
{"comments and blank lines", "# a comment\n\nID=rocky\nID_LIKE='rhel centos'\n", "rocky", []string{"rhel", "centos"}},
|
||||
{"empty input", "", "", nil},
|
||||
{"malformed line", "ID\nID=debian\n", "debian", nil},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
distro, like := ParseOSRelease([]byte(tc.input))
|
||||
if distro != tc.wantDistro {
|
||||
t.Errorf("distro = %q, want %q", distro, tc.wantDistro)
|
||||
}
|
||||
if !reflect.DeepEqual(like, tc.wantLike) {
|
||||
t.Errorf("like = %v, want %v", like, tc.wantLike)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
goarch string
|
||||
osRelease string
|
||||
osReleaseErr error
|
||||
present []string
|
||||
want Info
|
||||
}{
|
||||
{
|
||||
name: "linux centos with docker",
|
||||
goos: "linux",
|
||||
goarch: "amd64",
|
||||
osRelease: centosStream9,
|
||||
present: []string{"docker"},
|
||||
want: Info{
|
||||
OS: "linux", Arch: "amd64",
|
||||
Distro: "centos", DistroLike: []string{"rhel", "fedora"},
|
||||
Runtimes: []string{"docker"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux centos with podman and docker",
|
||||
goos: "linux",
|
||||
goarch: "arm64",
|
||||
osRelease: centosStream9,
|
||||
present: []string{"docker", "podman"},
|
||||
want: Info{
|
||||
OS: "linux", Arch: "arm64",
|
||||
Distro: "centos", DistroLike: []string{"rhel", "fedora"},
|
||||
Runtimes: []string{"docker", "podman"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux without os-release",
|
||||
goos: "linux",
|
||||
goarch: "amd64",
|
||||
osReleaseErr: errors.New("no such file"),
|
||||
want: Info{OS: "linux", Arch: "amd64"},
|
||||
},
|
||||
{
|
||||
name: "darwin ignores os-release",
|
||||
goos: "darwin",
|
||||
goarch: "arm64",
|
||||
present: []string{"docker"},
|
||||
want: Info{OS: "darwin", Arch: "arm64", Runtimes: []string{"docker"}},
|
||||
},
|
||||
{
|
||||
name: "windows has no runtimes on PATH",
|
||||
goos: "windows",
|
||||
goarch: "amd64",
|
||||
want: Info{OS: "windows", Arch: "amd64"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
probe := Probe{
|
||||
GOOS: tc.goos,
|
||||
GOARCH: tc.goarch,
|
||||
ReadFile: func(path string) ([]byte, error) {
|
||||
if path != OSReleasePath {
|
||||
t.Fatalf("unexpected path %q", path)
|
||||
}
|
||||
if tc.osReleaseErr != nil {
|
||||
return nil, tc.osReleaseErr
|
||||
}
|
||||
return []byte(tc.osRelease), nil
|
||||
},
|
||||
LookPath: func(name string) (string, error) {
|
||||
for _, p := range tc.present {
|
||||
if p == name {
|
||||
return "/usr/bin/" + name, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
RuntimeCandidates: []string{"docker", "podman"},
|
||||
}
|
||||
|
||||
got := probe.Detect()
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("Info = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoString(t *testing.T) {
|
||||
info := Info{OS: "linux", Arch: "amd64", Distro: "centos", DistroLike: []string{"rhel", "fedora"}, Runtimes: []string{"docker"}}
|
||||
out := info.String()
|
||||
for _, want := range []string{"linux", "amd64", "centos", "rhel fedora", "docker"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("String() missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
empty := Info{OS: "windows", Arch: "amd64"}.String()
|
||||
if !strings.Contains(empty, "distro: -") || !strings.Contains(empty, "runtimes: -") {
|
||||
t.Errorf("String() should render placeholders for empty fields:\n%s", empty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user