Files
odboy bd6f20d7c5 feat: 新增 proc nohup 后台运行、docker save/load 内置实现,移除 Podman 支持
- proc.nohup:后台运行命令并将 stdout/stderr 追加写入日志,
  Unix 下新会话、Windows 下 DETACHED_PROCESS 脱离终端
- docker.save:等价 docker save <镜像> -o <镜像名>.tar,文件名按
  仓库名+标签生成(alpine:3.20 → alpine_3.20.tar),传镜像 ID 时
  先解析出对应名称;docker.load 改为归档文件作位置参数
- 移除 Podman 支持,所有容器命令只走 docker
2026-11-01 07:47:57 +08:00

299 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package resolve picks the most specific registry variant for a detected
// platform, and explains why each candidate did or did not match.
package resolve
import (
"errors"
"fmt"
"strings"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
)
// Scoring weights. A variant must match on every declared constraint; the
// score decides among the variants that do match.
const (
scoreDistroExact = 3
scoreDistroLike = 2
scoreRuntime = 2
)
// ErrNoMatch is returned when no variant fits the environment.
var ErrNoMatch = errors.New("no matching variant")
// ErrAmbiguous is returned when two or more variants tie for best match.
var ErrAmbiguous = errors.New("ambiguous match")
// Candidate is one variant together with the evaluation outcome.
type Candidate struct {
Index int
Variant registry.Variant
Score int
// Matched reports whether every declared constraint was satisfied.
Matched bool
// Reason explains the decision in human-readable form.
Reason string
}
// Result is a successful resolution.
type Result struct {
Command *registry.Command
Info platform.Info
Variant registry.Variant
VariantIdx int
Score int
Candidates []Candidate
}
// NoMatchError describes why resolution failed.
type NoMatchError struct {
CommandID string
Info platform.Info
Candidates []Candidate
}
func (e *NoMatchError) Error() string {
return fmt.Sprintf("命令 %q 不支持当前平台 %s", e.CommandID, DescribeInfo(e.Info))
}
// Unwrap lets callers test with errors.Is(err, ErrNoMatch).
func (e *NoMatchError) Unwrap() error { return ErrNoMatch }
// AmbiguousError reports tied candidates.
type AmbiguousError struct {
CommandID string
Info platform.Info
Candidates []Candidate
}
func (e *AmbiguousError) Error() string {
return fmt.Sprintf("命令 %q 在当前平台 %s 上匹配到多个同样具体的变体", e.CommandID, DescribeInfo(e.Info))
}
// Unwrap lets callers test with errors.Is(err, ErrAmbiguous).
func (e *AmbiguousError) Unwrap() error { return ErrAmbiguous }
// Resolve evaluates every variant of cmd against info and returns the single
// most specific match. The returned Result always carries all candidates so
// callers can render an explanation.
func Resolve(cmd *registry.Command, info platform.Info) (Result, error) {
result := Result{Command: cmd, Info: info, VariantIdx: -1}
var best []Candidate
for i, variant := range cmd.Variants {
score, matched, reason := Evaluate(variant.Match, info)
candidate := Candidate{
Index: i,
Variant: variant,
Score: score,
Matched: matched,
Reason: reason,
}
result.Candidates = append(result.Candidates, candidate)
if !matched {
continue
}
switch {
case len(best) == 0:
best = []Candidate{candidate}
case betterThan(candidate, best[0]):
best = []Candidate{candidate}
case ties(candidate, best[0]):
best = append(best, candidate)
}
}
switch {
case len(best) == 0:
return result, &NoMatchError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates}
case len(best) > 1:
return result, &AmbiguousError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates}
}
result.Variant = best[0].Variant
result.VariantIdx = best[0].Index
result.Score = best[0].Score
return result, nil
}
// ResolveID looks a command up by ID or alias and resolves it.
func ResolveID(reg *registry.Registry, id string, info platform.Info) (Result, error) {
cmd, ok := reg.Lookup(id)
if !ok {
return Result{}, fmt.Errorf("未知命令 %q", id)
}
return Resolve(cmd, info)
}
// Evaluate checks a single variant against the environment. It returns the
// specificity score, whether all constraints matched, and the reasoning.
func Evaluate(m registry.Match, info platform.Info) (score int, matched bool, reason string) {
var notes []string
rejected := ""
reject := func(note string) {
if rejected == "" {
rejected = note
}
}
add := func(note string) { notes = append(notes, note) }
if m.OS != "" {
if m.OS != info.OS {
reject(fmt.Sprintf("os=%s 不匹配(当前 %s)", m.OS, info.OS))
} else {
add("os=" + m.OS)
}
}
if m.Arch != "" {
if m.Arch != info.Arch {
reject(fmt.Sprintf("arch=%s 不匹配(当前 %s)", m.Arch, info.Arch))
} else {
add("arch=" + m.Arch)
}
}
if m.Distro != "" {
if m.Distro != info.Distro {
reject(fmt.Sprintf("distro=%s 不匹配(当前 %s)", m.Distro, orUnknown(info.Distro)))
} else {
score += scoreDistroExact
add(fmt.Sprintf("distro=%s (+%d)", m.Distro, scoreDistroExact))
}
}
if len(m.DistroLike) > 0 {
if !distroLikeMatches(m.DistroLike, info) {
reject(fmt.Sprintf("ID_LIKE 要求 %s(当前 %s)", strings.Join(m.DistroLike, "|"), describeDistro(info)))
} else {
score += scoreDistroLike
add(fmt.Sprintf("ID_LIKE∈{%s} (+%d)", strings.Join(m.DistroLike, ","), scoreDistroLike))
}
}
if m.Runtime != "" {
if !contains(info.Runtimes, m.Runtime) {
reject(fmt.Sprintf("需要运行时 %s(未在 PATH 找到)", m.Runtime))
} else {
score += scoreRuntime
add(fmt.Sprintf("runtime=%s (+%d)", m.Runtime, scoreRuntime))
}
}
if rejected != "" {
return 0, false, rejected
}
if len(notes) == 0 {
return 0, true, "通用变体(无平台约束, 0 分)"
}
return score, true, "匹配 " + strings.Join(notes, ", ")
}
// runtimeRank orders runtimes by preference when two variants are otherwise
// equally specific: docker ranks first and a variant with no runtime
// constraint ranks last.
func runtimeRank(runtime string) int {
switch strings.ToLower(runtime) {
case "docker":
return 0
case "":
return 2
default:
return 1
}
}
// betterThan reports whether a is a strictly better match than b: higher
// score, then the runtime preference, then the more constrained variant.
func betterThan(a, b Candidate) bool {
if a.Score != b.Score {
return a.Score > b.Score
}
if ra, rb := runtimeRank(a.Variant.Match.Runtime), runtimeRank(b.Variant.Match.Runtime); ra != rb {
return ra < rb
}
return specificity(a.Variant.Match) > specificity(b.Variant.Match)
}
// ties reports whether two candidates are equally good, in which case the
// resolution is ambiguous rather than silently picking one.
func ties(a, b Candidate) bool {
return a.Score == b.Score &&
runtimeRank(a.Variant.Match.Runtime) == runtimeRank(b.Variant.Match.Runtime) &&
specificity(a.Variant.Match) == specificity(b.Variant.Match)
}
// specificity counts the constraints a variant declares, so an OS-only
// variant outranks a fully generic one at equal score.
func specificity(m registry.Match) int {
n := 0
if m.OS != "" {
n++
}
if m.Arch != "" {
n++
}
if m.Distro != "" {
n++
}
if len(m.DistroLike) > 0 {
n++
}
if m.Runtime != "" {
n++
}
return n
}
func distroLikeMatches(want []string, info platform.Info) bool {
current := make([]string, 0, 1+len(info.DistroLike))
if info.Distro != "" {
current = append(current, info.Distro)
}
current = append(current, info.DistroLike...)
for _, c := range current {
for _, w := range want {
if strings.EqualFold(c, w) {
return true
}
}
}
return false
}
func contains(values []string, want string) bool {
for _, v := range values {
if strings.EqualFold(v, want) {
return true
}
}
return false
}
func orUnknown(s string) string {
if s == "" {
return "未知"
}
return s
}
func describeDistro(info platform.Info) string {
if info.Distro == "" && len(info.DistroLike) == 0 {
return "未知"
}
if len(info.DistroLike) == 0 {
return info.Distro
}
return info.Distro + " (" + strings.Join(info.DistroLike, " ") + ")"
}
// DescribeInfo renders the environment compactly for error messages.
func DescribeInfo(info platform.Info) string {
s := info.OS + "/" + info.Arch
if info.Distro != "" {
s += ", distro=" + info.Distro
}
if len(info.Runtimes) > 0 {
s += ", runtimes=" + strings.Join(info.Runtimes, ",")
}
return s
}