diff --git a/docs/06-production-host-baseline.md b/docs/06-production-host-baseline.md
new file mode 100644
index 0000000..a57da17
--- /dev/null
+++ b/docs/06-production-host-baseline.md
@@ -0,0 +1,262 @@
+# Production Host Baseline 重构计划
+
+## 目标
+
+本分支把 `bootstrapctl` 从“若干初始化脚本的 Go 封装”逐步升级为:
+
+> 面向离线、半离线和受限网络环境的主机事实采集、状态收敛与交付证据工具。
+
+保留现有 `inventory + profile + scan/plan/apply/verify` 使用方式,不直接引入
+Ansible/Kubespray 运行时。成熟项目用于提取规则、发行版差异和测试场景。
+
+## 参考项目的职责
+
+| 项目 | 使用方式 |
+|---|---|
+| k0sctl | 借鉴 phase、facts、validate、cleanup、dry-run 和并发模型 |
+| rig v2 | 后续评估复用 SSH、sudo、远程文件、OS、包管理、服务管理能力 |
+| Kubespray | 提取 kubernetes/preinstall、bootstrap-os 等成熟规则,不直接执行 playbook |
+| dev-sec hardening | 提取 SSH、auditd、PAM、sysctl 和文件权限基线 |
+| ComplianceAsCode/OpenSCAP | 作为外部合规验证和规则映射来源 |
+
+## 规则转换流程
+
+```text
+Kubespray role/task
+ -> 提取目标状态、发行版条件、依赖和验证命令
+ -> 转换成 bootstrapctl Policy
+ -> 转换成 Go Task 的 Check/Apply
+ -> 记录 before/desired/after/effective/evidence
+ -> plan/apply/verify 报告
+```
+
+每条规则都必须明确:
+
+1. 适用的 OS、版本、架构和节点角色
+2. 检查命令与原始证据
+3. 当前值和期望值
+4. 修改动作
+5. 是否立即生效
+6. 是否需要重新登录、重启服务或重启主机
+7. 对现有业务的兼容风险
+
+## 第一阶段:报告协议
+
+新增结构化 `ChangeRecord`,任务逐步输出:
+
+- `category`
+- `resource`
+- `path`
+- `operation`
+- `before`
+- `desired`
+- `after`
+- `effective`
+- `changed`
+- `verified`
+- `status`
+- `evidence`
+- `message`
+- `pending_action`
+
+旧任务仍可只返回 `Summary`,后续逐项迁移。
+
+建议状态:
+
+- `compliant`
+- `needs-change`
+- `would-change`
+- `changed-verified`
+- `changed-pending-relogin`
+- `changed-pending-restart`
+- `changed-pending-reboot`
+- `preserved`
+- `observed-warning`
+- `skipped`
+- `failed`
+
+## 容器运行时存储语义
+
+现有 `StorageLayoutTask` 管理:
+
+- graph root 目录
+- CRI root 目录
+- `/etc/containers/storage.conf`
+
+但三类运行时使用不同配置:
+
+| 运行时 | 实际配置入口 |
+|---|---|
+| Docker | `/etc/docker/daemon.json` 的 `data-root` |
+| containerd | `/etc/containerd/config.toml` 的顶层 `root`、`state` |
+| Podman/CRI-O | `/etc/containers/storage.conf` 的 `graphroot`、`runroot` |
+
+仅创建 `/data/containerd` 并不代表 containerd 已经使用该目录;修改
+`containers/storage.conf` 也不会改变 Docker 的镜像目录。
+
+第一步增加 `runtime-storage-audit`:
+
+- 采集 Docker `DockerRootDir`
+- 采集 containerd 实际 `root`
+- 采集 containers/storage `graphroot`
+- 与 profile 期望值对比
+- 当前仅观测,不自动改写 Docker/containerd,避免覆盖已有 JSON/TOML 配置
+
+下一步增加显式策略:
+
+```yaml
+storage:
+ docker:
+ mode: observe # observe | managed | preserve
+ data_root: /data/docker
+ config_path: /etc/docker/daemon.json
+ restart_policy: report-only
+
+ containerd:
+ mode: observe
+ root: /data/containerd
+ state: /run/containerd
+ config_path: /etc/containerd/config.toml
+ restart_policy: report-only
+
+ containers_storage:
+ mode: managed
+ graph_root: /data/containers/storage
+ run_root: /run/containers/storage
+```
+
+修改配置时必须做“语义合并”,不能直接覆盖完整 daemon.json/config.toml。
+
+## Kubernetes preinstall 领域拆分
+
+不要建立一个巨大的 `kubernetes-preinstall` 任务。按可独立检查和报告的资源拆分:
+
+### 主机事实
+
+- OS ID / VERSION_ID / family
+- architecture
+- kernel
+- init system
+- package manager
+- cgroup version/driver
+- container runtime
+- network interfaces and routes
+- DNS resolver implementation
+
+### 基础身份和时间
+
+- hostname
+- `/etc/hosts`
+- timezone
+- chrony/systemd-timesyncd
+- NTP source and synchronization state
+
+### Kubernetes 内核前置
+
+- swap
+- required kernel modules
+- sysctl
+- cgroup
+- br_netfilter
+- ip_forward
+- conntrack
+- inotify
+- pid/file descriptor limits
+
+### DNS 和 resolv.conf
+
+- `/etc/resolv.conf` 是否为 symlink
+- systemd-resolved / NetworkManager / resolvconf 管理关系
+- nameserver/search/options
+- loopback resolver 风险
+- kubelet `resolvConf` 推荐值
+
+### systemd 和服务限制
+
+- system manager defaults
+- containerd/docker/kubelet service drop-ins
+- `LimitNOFILE`
+- `LimitNPROC`
+- `TasksMax`
+- daemon-reload/restart pending
+
+### 必需软件
+
+- conntrack
+- socat
+- ipset
+- iptables/nft
+- ebtables
+- ethtool
+- chrony
+- audit/logging packages
+
+包名必须通过发行版映射表解析,不能把 apt/yum 判断散落在各任务脚本中。
+
+## 发行版适配方向
+
+后续建立:
+
+```text
+internal/platform
+ release.go
+ capabilities.go
+ package_names.go
+ service_names.go
+ paths.go
+```
+
+任务依赖 capability,而不是在每个任务里重复:
+
+```bash
+if command -v apt ...
+elif command -v dnf ...
+```
+
+示例 capability:
+
+- `systemd`
+- `selinux`
+- `apparmor`
+- `firewalld`
+- `ufw`
+- `systemd-resolved`
+- `network-manager`
+- `dnf`
+- `apt`
+- `rpm`
+- `deb`
+
+## 资源限制迁移
+
+当前 `ulimit` 只管理 PAM 登录会话。目标拆分:
+
+1. `login-resource-limits`
+2. `kernel-resource-limits`
+3. `systemd-default-limits`
+4. `systemd-service-limits`
+
+报告必须区分:
+
+- 配置文件值
+- 当前 SSH/sudo 会话生效值
+- systemd manager 默认值
+- 具体服务进程值
+- 待重新登录/待重启状态
+
+本分支已先迁移 `ulimit`,输出配置值与当前会话 `ulimit` 生效值。
+
+## 后续实施顺序
+
+1. 结构化报告协议
+2. runtime storage audit
+3. 主机事实与发行版 capability
+4. 完整资源限制
+5. timezone/time sync
+6. DNS/resolv.conf
+7. Kubernetes 必需包
+8. kernel modules/sysctl 规则重构
+9. journald/logrotate/auditd/rsyslog
+10. rig v2 POC 与远程执行层迁移决策
+
+每一阶段都要求:单元测试、至少 Ubuntu/Rocky 两类真机验证、重复 apply 幂等验证、JSON/Markdown 报告样例。
diff --git a/internal/facts/host.go b/internal/facts/host.go
new file mode 100644
index 0000000..712b8bd
--- /dev/null
+++ b/internal/facts/host.go
@@ -0,0 +1,118 @@
+package facts
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+)
+
+// HostFacts 是 Kubernetes preinstall 和生产主机基线共同依赖的规范化事实。
+// 原始命令输出在远端编码后传回,解析层只负责结构化和发行版归类,不做修改。
+type HostFacts struct {
+ OSID string `json:"os_id"`
+ OSVersion string `json:"os_version"`
+ OSIDLike string `json:"os_id_like"`
+ OSPrettyName string `json:"os_pretty_name"`
+ DistroFamily string `json:"distro_family"`
+ Arch string `json:"arch"`
+ Kernel string `json:"kernel"`
+ InitSystem string `json:"init_system"`
+ PackageManager string `json:"package_manager"`
+ CgroupVersion string `json:"cgroup_version"`
+ ResolvConfTarget string `json:"resolv_conf_target"`
+ ResolvConfLink string `json:"resolv_conf_link"`
+ Nameservers string `json:"nameservers"`
+ SearchDomains string `json:"search_domains"`
+ DefaultRoute string `json:"default_route"`
+ Timezone string `json:"timezone"`
+ NTPSynchronized string `json:"ntp_synchronized"`
+ TimeSyncService string `json:"time_sync_service"`
+}
+
+// ParseHostFactsOutput 解析 __BT_FACT__|key|base64(value) 格式。
+func ParseHostFactsOutput(output string) (HostFacts, error) {
+ values := map[string]string{}
+ for _, rawLine := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") {
+ line := strings.TrimSpace(rawLine)
+ if !strings.HasPrefix(line, "__BT_FACT__|") {
+ continue
+ }
+ parts := strings.SplitN(line, "|", 3)
+ if len(parts) != 3 {
+ continue
+ }
+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(parts[2]))
+ if err != nil {
+ return HostFacts{}, fmt.Errorf("解析主机事实 %s 失败: %w", parts[1], err)
+ }
+ values[strings.TrimSpace(parts[1])] = strings.TrimSpace(string(decoded))
+ }
+ if len(values) == 0 {
+ return HostFacts{}, fmt.Errorf("未找到主机事实标记")
+ }
+
+ result := HostFacts{
+ OSID: values["os_id"],
+ OSVersion: values["os_version"],
+ OSIDLike: values["os_id_like"],
+ OSPrettyName: values["os_pretty_name"],
+ Arch: NormalizeArch(values["arch"]),
+ Kernel: values["kernel"],
+ InitSystem: values["init_system"],
+ PackageManager: values["package_manager"],
+ CgroupVersion: values["cgroup_version"],
+ ResolvConfTarget: values["resolv_conf_target"],
+ ResolvConfLink: values["resolv_conf_link"],
+ Nameservers: values["nameservers"],
+ SearchDomains: values["search_domains"],
+ DefaultRoute: values["default_route"],
+ Timezone: values["timezone"],
+ NTPSynchronized: normalizeYesNo(values["ntp_synchronized"]),
+ TimeSyncService: values["time_sync_service"],
+ }
+ result.DistroFamily = DetectDistroFamily(result.OSID, result.OSIDLike)
+ return result, nil
+}
+
+func NormalizeArch(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "x86_64", "x86-64", "amd64":
+ return "amd64"
+ case "aarch64", "arm64":
+ return "arm64"
+ case "armv7l", "armv7":
+ return "arm"
+ default:
+ return strings.ToLower(strings.TrimSpace(value))
+ }
+}
+
+func DetectDistroFamily(id, idLike string) string {
+ candidates := strings.Fields(strings.ToLower(strings.TrimSpace(id + " " + idLike)))
+ for _, candidate := range candidates {
+ switch candidate {
+ case "ubuntu", "debian", "linuxmint", "uos":
+ return "debian"
+ case "rhel", "fedora", "centos", "rocky", "almalinux", "anolis", "opencloudos", "openeuler", "kylin":
+ return "rpm"
+ case "sles", "suse", "opensuse", "opensuse-leap":
+ return "suse"
+ case "alpine":
+ return "alpine"
+ case "arch", "manjaro":
+ return "arch"
+ }
+ }
+ return "unknown"
+}
+
+func normalizeYesNo(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "yes", "true", "1", "active", "synchronized":
+ return "yes"
+ case "no", "false", "0", "inactive", "unsynchronized":
+ return "no"
+ default:
+ return strings.ToLower(strings.TrimSpace(value))
+ }
+}
diff --git a/internal/facts/host_test.go b/internal/facts/host_test.go
new file mode 100644
index 0000000..df9a44c
--- /dev/null
+++ b/internal/facts/host_test.go
@@ -0,0 +1,50 @@
+package facts
+
+import (
+ "encoding/base64"
+ "fmt"
+ "testing"
+)
+
+func TestParseHostFactsOutput(t *testing.T) {
+ line := func(key, value string) string {
+ return fmt.Sprintf("__BT_FACT__|%s|%s\n", key, base64.StdEncoding.EncodeToString([]byte(value)))
+ }
+ output := "noise\n" +
+ line("os_id", "rocky") +
+ line("os_version", "9.4") +
+ line("os_id_like", "rhel centos fedora") +
+ line("arch", "x86_64") +
+ line("init_system", "systemd") +
+ line("package_manager", "dnf") +
+ line("ntp_synchronized", "yes")
+
+ got, err := ParseHostFactsOutput(output)
+ if err != nil {
+ t.Fatalf("ParseHostFactsOutput() error = %v", err)
+ }
+ if got.DistroFamily != "rpm" || got.Arch != "amd64" {
+ t.Fatalf("unexpected normalized facts: %+v", got)
+ }
+ if got.NTPSynchronized != "yes" {
+ t.Fatalf("unexpected time sync value: %+v", got)
+ }
+}
+
+func TestDetectDistroFamily(t *testing.T) {
+ tests := []struct {
+ id string
+ idLike string
+ want string
+ }{
+ {id: "ubuntu", idLike: "debian", want: "debian"},
+ {id: "rocky", idLike: "rhel centos fedora", want: "rpm"},
+ {id: "openEuler", idLike: "", want: "rpm"},
+ {id: "alpine", idLike: "", want: "alpine"},
+ }
+ for _, test := range tests {
+ if got := DetectDistroFamily(test.id, test.idLike); got != test.want {
+ t.Fatalf("DetectDistroFamily(%q, %q) = %q, want %q", test.id, test.idLike, got, test.want)
+ }
+ }
+}
diff --git a/internal/report/artifacts_test.go b/internal/report/artifacts_test.go
new file mode 100644
index 0000000..f4a80a9
--- /dev/null
+++ b/internal/report/artifacts_test.go
@@ -0,0 +1,25 @@
+package report
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSaveJSONAlsoWritesMarkdownSidecar(t *testing.T) {
+ dir := t.TempDir()
+ rep := New("verify", "demo", false)
+ rep.Add(TaskResult{Node: "node-01", TaskKey: "host-facts", Title: "facts", Status: "ok", Summary: "observed"})
+
+ jsonPath, err := rep.SaveJSON(dir)
+ if err != nil {
+ t.Fatalf("SaveJSON() error = %v", err)
+ }
+ if _, err := os.Stat(jsonPath); err != nil {
+ t.Fatalf("JSON report missing: %v", err)
+ }
+ markdownPath := filepath.Join(dir, rep.RunID+".md")
+ if _, err := os.Stat(markdownPath); err != nil {
+ t.Fatalf("Markdown sidecar missing: %v", err)
+ }
+}
diff --git a/internal/report/change.go b/internal/report/change.go
new file mode 100644
index 0000000..b7e1fe7
--- /dev/null
+++ b/internal/report/change.go
@@ -0,0 +1,118 @@
+package report
+
+import "strings"
+
+const (
+ ChangeStatusCompliant = "compliant"
+ ChangeStatusNeedsChange = "needs-change"
+ ChangeStatusWouldChange = "would-change"
+ ChangeStatusChangedVerified = "changed-verified"
+ ChangeStatusChangedPendingRelogin = "changed-pending-relogin"
+ ChangeStatusChangedPendingRestart = "changed-pending-restart"
+ ChangeStatusChangedPendingReboot = "changed-pending-reboot"
+ ChangeStatusPreserved = "preserved"
+ ChangeStatusObservedWarning = "observed-warning"
+ ChangeStatusSkipped = "skipped"
+ ChangeStatusFailed = "failed"
+)
+
+// ChangeRecord 描述一个可审计的主机状态变化。
+//
+// 每条记录应该尽量回答:
+// 1. 原值是什么
+// 2. 目标值是什么
+// 3. 做了什么动作
+// 4. 修改后是什么
+// 5. 当前是否已经真正生效
+//
+// 字段暂时统一使用字符串,避免不同任务把 JSON 报告变成难以稳定消费的弱类型结构。
+// 对复杂值可以存放规范化 JSON、摘要或哈希,并把原始证据放到 Evidence。
+type ChangeRecord struct {
+ Category string `json:"category,omitempty"`
+ Resource string `json:"resource"`
+ Path string `json:"path,omitempty"`
+ Operation string `json:"operation,omitempty"`
+ Before string `json:"before,omitempty"`
+ Desired string `json:"desired,omitempty"`
+ After string `json:"after,omitempty"`
+ Effective string `json:"effective,omitempty"`
+ Changed bool `json:"changed"`
+ Verified bool `json:"verified"`
+ Status string `json:"status"`
+ Evidence string `json:"evidence,omitempty"`
+ Message string `json:"message,omitempty"`
+ PendingAction string `json:"pending_action,omitempty"`
+}
+
+// MergeChanges 把 Check 阶段的 before/desired 与 Apply 阶段的 after/effective 合并。
+// Resource + Path 被视为一条记录的稳定身份。
+func MergeChanges(checked, applied []ChangeRecord) []ChangeRecord {
+ if len(checked) == 0 && len(applied) == 0 {
+ return nil
+ }
+
+ merged := make([]ChangeRecord, len(checked))
+ copy(merged, checked)
+ indexes := make(map[string]int, len(merged))
+ for idx := range merged {
+ indexes[changeKey(merged[idx])] = idx
+ }
+
+ for _, update := range applied {
+ key := changeKey(update)
+ idx, exists := indexes[key]
+ if !exists {
+ indexes[key] = len(merged)
+ merged = append(merged, update)
+ continue
+ }
+ merged[idx] = mergeChange(merged[idx], update)
+ }
+ return merged
+}
+
+func changeKey(change ChangeRecord) string {
+ return strings.TrimSpace(change.Resource) + "\x00" + strings.TrimSpace(change.Path)
+}
+
+func mergeChange(base, update ChangeRecord) ChangeRecord {
+ if update.Category != "" {
+ base.Category = update.Category
+ }
+ if update.Resource != "" {
+ base.Resource = update.Resource
+ }
+ if update.Path != "" {
+ base.Path = update.Path
+ }
+ if update.Operation != "" {
+ base.Operation = update.Operation
+ }
+ if update.Before != "" && base.Before == "" {
+ base.Before = update.Before
+ }
+ if update.Desired != "" {
+ base.Desired = update.Desired
+ }
+ if update.After != "" {
+ base.After = update.After
+ }
+ if update.Effective != "" {
+ base.Effective = update.Effective
+ }
+ base.Changed = base.Changed || update.Changed
+ base.Verified = base.Verified || update.Verified
+ if update.Status != "" {
+ base.Status = update.Status
+ }
+ if update.Evidence != "" {
+ base.Evidence = update.Evidence
+ }
+ if update.Message != "" {
+ base.Message = update.Message
+ }
+ if update.PendingAction != "" {
+ base.PendingAction = update.PendingAction
+ }
+ return base
+}
diff --git a/internal/report/change_test.go b/internal/report/change_test.go
new file mode 100644
index 0000000..fca6dbf
--- /dev/null
+++ b/internal/report/change_test.go
@@ -0,0 +1,73 @@
+package report
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestMergeChangesPreservesBeforeAndAddsAfter(t *testing.T) {
+ checked := []ChangeRecord{{
+ Resource: "login-limits.nofile.soft",
+ Path: "/etc/security/limits.d/99-bootstrapctl.conf",
+ Before: "*=65535,root=65535",
+ Desired: "1048576",
+ Status: ChangeStatusNeedsChange,
+ }}
+ applied := []ChangeRecord{{
+ Resource: "login-limits.nofile.soft",
+ Path: "/etc/security/limits.d/99-bootstrapctl.conf",
+ After: "*=1048576,root=1048576",
+ Effective: "65535",
+ Changed: true,
+ Status: ChangeStatusChangedPendingRelogin,
+ PendingAction: "重新登录后验证",
+ }}
+
+ got := MergeChanges(checked, applied)
+ if len(got) != 1 {
+ t.Fatalf("expected one change, got %d", len(got))
+ }
+ if got[0].Before != checked[0].Before || got[0].After != applied[0].After {
+ t.Fatalf("unexpected merge result: %+v", got[0])
+ }
+ if !got[0].Changed || got[0].Status != ChangeStatusChangedPendingRelogin {
+ t.Fatalf("unexpected merged status: %+v", got[0])
+ }
+}
+
+func TestSaveMarkdownIncludesChangeTable(t *testing.T) {
+ rep := New("apply", "demo", false)
+ rep.Add(TaskResult{
+ Node: "node-01",
+ TaskKey: "ulimit",
+ Title: "收敛登录用户资源限制",
+ Status: "changed",
+ Summary: "已写入",
+ Changes: []ChangeRecord{{
+ Category: "resource-limit",
+ Resource: "login-limits.nofile.soft",
+ Before: "65535",
+ Desired: "1048576",
+ After: "1048576",
+ Effective: "65535",
+ Operation: "write-managed-file",
+ Status: ChangeStatusChangedPendingRelogin,
+ }},
+ })
+
+ path, err := rep.SaveMarkdown(t.TempDir())
+ if err != nil {
+ t.Fatalf("SaveMarkdown() error = %v", err)
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ text := string(content)
+ for _, expected := range []string{"主机变更报告", "login-limits.nofile.soft", "changed-pending-relogin"} {
+ if !strings.Contains(text, expected) {
+ t.Fatalf("markdown missing %q:\n%s", expected, text)
+ }
+ }
+}
diff --git a/internal/report/report.go b/internal/report/report.go
index 1bda385..5c6a72b 100644
--- a/internal/report/report.go
+++ b/internal/report/report.go
@@ -20,13 +20,16 @@ type Report struct {
}
type TaskResult struct {
- Node string `json:"node"`
- TaskKey string `json:"task_key"`
- Title string `json:"title"`
- Status string `json:"status"`
- Summary string `json:"summary"`
- StartedAt time.Time `json:"started_at"`
- FinishedAt time.Time `json:"finished_at"`
+ Node string `json:"node"`
+ TaskKey string `json:"task_key"`
+ Title string `json:"title"`
+ Status string `json:"status"`
+ Summary string `json:"summary"`
+ Changes []ChangeRecord `json:"changes,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+ PendingActions []string `json:"pending_actions,omitempty"`
+ StartedAt time.Time `json:"started_at"`
+ FinishedAt time.Time `json:"finished_at"`
}
func New(command, clusterName string, dryRun bool) *Report {
@@ -47,6 +50,7 @@ func (r *Report) Finalize() {
r.FinishedAt = time.Now()
}
+// SaveJSON 保持原有返回值兼容,同时落一份同 RunID 的 Markdown 交付报告。
func (r *Report) SaveJSON(reportDir string) (string, error) {
r.Finalize()
if err := os.MkdirAll(reportDir, 0o755); err != nil {
@@ -60,5 +64,83 @@ func (r *Report) SaveJSON(reportDir string) (string, error) {
if err := os.WriteFile(path, content, 0o644); err != nil {
return "", fmt.Errorf("写入报告失败: %w", err)
}
+ if _, err := r.SaveMarkdown(reportDir); err != nil {
+ return "", fmt.Errorf("JSON 已写入但 Markdown 交付报告生成失败: %w", err)
+ }
return path, nil
}
+
+// SaveMarkdown 输出适合交付审阅的动作报告。
+func (r *Report) SaveMarkdown(reportDir string) (string, error) {
+ if r.FinishedAt.IsZero() {
+ r.Finalize()
+ }
+ if err := os.MkdirAll(reportDir, 0o755); err != nil {
+ return "", fmt.Errorf("创建报告目录失败: %w", err)
+ }
+
+ var builder strings.Builder
+ builder.WriteString("# bootstrapctl 主机变更报告\n\n")
+ builder.WriteString(fmt.Sprintf("- run id: `%s`\n", r.RunID))
+ builder.WriteString(fmt.Sprintf("- command: `%s`\n", r.Command))
+ builder.WriteString(fmt.Sprintf("- cluster: `%s`\n", r.ClusterName))
+ builder.WriteString(fmt.Sprintf("- dry run: `%t`\n", r.DryRun))
+ builder.WriteString(fmt.Sprintf("- started at: `%s`\n", r.StartedAt.Format(time.RFC3339)))
+ builder.WriteString(fmt.Sprintf("- finished at: `%s`\n\n", r.FinishedAt.Format(time.RFC3339)))
+
+ for _, result := range r.Results {
+ builder.WriteString(fmt.Sprintf("## %s / %s\n\n", result.Node, result.Title))
+ builder.WriteString(fmt.Sprintf("- task: `%s`\n", result.TaskKey))
+ builder.WriteString(fmt.Sprintf("- status: `%s`\n", result.Status))
+ builder.WriteString(fmt.Sprintf("- summary: %s\n", result.Summary))
+ if len(result.Warnings) > 0 {
+ builder.WriteString("- warnings:\n")
+ for _, warning := range result.Warnings {
+ builder.WriteString(fmt.Sprintf(" - %s\n", warning))
+ }
+ }
+ if len(result.PendingActions) > 0 {
+ builder.WriteString("- pending actions:\n")
+ for _, action := range result.PendingActions {
+ builder.WriteString(fmt.Sprintf(" - %s\n", action))
+ }
+ }
+ builder.WriteString("\n")
+
+ if len(result.Changes) == 0 {
+ continue
+ }
+ builder.WriteString("| 类别 | 资源 | 修改前 | 目标值 | 修改后 | 生效值 | 动作 | 状态 |\n")
+ builder.WriteString("|---|---|---|---|---|---|---|---|\n")
+ for _, change := range result.Changes {
+ builder.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s | %s | %s |\n",
+ markdownCell(change.Category),
+ markdownCell(change.Resource),
+ markdownCell(change.Before),
+ markdownCell(change.Desired),
+ markdownCell(change.After),
+ markdownCell(change.Effective),
+ markdownCell(change.Operation),
+ markdownCell(change.Status),
+ ))
+ }
+ builder.WriteString("\n")
+ }
+
+ path := filepath.Join(reportDir, r.RunID+".md")
+ if err := os.WriteFile(path, []byte(builder.String()), 0o644); err != nil {
+ return "", fmt.Errorf("写入 Markdown 报告失败: %w", err)
+ }
+ return path, nil
+}
+
+func markdownCell(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "-"
+ }
+ value = strings.ReplaceAll(value, "|", "\\|")
+ value = strings.ReplaceAll(value, "\r\n", "
")
+ value = strings.ReplaceAll(value, "\n", "
")
+ return value
+}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 96101ec..43d0898 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -20,7 +20,7 @@ type Engine struct {
// 它负责:
// 1. 对每个任务先执行 Check
// 2. 根据执行模式决定是仅展示、校验漂移还是正式 Apply
-// 3. 将结果写入统一报告
+// 3. 将摘要、结构化变更、告警和待处理动作写入统一报告
func (e *Engine) Run(ctx context.Context, mode tasks.Mode, taskList []tasks.Task, dryRun bool, rep *report.Report) error {
for _, task := range taskList {
started := time.Now()
@@ -28,15 +28,7 @@ func (e *Engine) Run(ctx context.Context, mode tasks.Mode, taskList []tasks.Task
check, err := task.Check(ctx, e.Executor)
if err != nil {
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: "failed",
- Summary: err.Error(),
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(task, "failed", err.Error(), started, nil, nil, nil))
return fmt.Errorf("任务 %s 失败: %w", task.Key(), err)
}
@@ -49,15 +41,8 @@ func (e *Engine) Run(ctx context.Context, mode tasks.Mode, taskList []tasks.Task
} else {
e.Console.Success("[%s/%s] %s", task.Node(), task.Key(), check.Summary)
}
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: status,
- Summary: check.Summary,
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(task, status, check.Summary, started, check.Changes, check.Warnings, check.PendingActions))
+
case tasks.ModeVerify:
status := "ok"
if check.Needed {
@@ -66,55 +51,24 @@ func (e *Engine) Run(ctx context.Context, mode tasks.Mode, taskList []tasks.Task
} else {
e.Console.Success("[%s/%s] %s", task.Node(), task.Key(), check.Summary)
}
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: status,
- Summary: check.Summary,
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(task, status, check.Summary, started, check.Changes, check.Warnings, check.PendingActions))
+
case tasks.ModeApply:
if !check.Needed {
e.Console.Success("[%s/%s] %s", task.Node(), task.Key(), check.Summary)
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: "ok",
- Summary: check.Summary,
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(task, "ok", check.Summary, started, check.Changes, check.Warnings, check.PendingActions))
continue
}
if dryRun {
e.Console.Warn("[%s/%s] dry-run: %s", task.Node(), task.Key(), check.Summary)
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: "would-change",
- Summary: check.Summary,
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(task, "would-change", check.Summary, started, check.Changes, check.Warnings, check.PendingActions))
continue
}
applyResult, err := task.Apply(ctx, e.Executor)
if err != nil {
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: "failed",
- Summary: err.Error(),
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(task, "failed", err.Error(), started, check.Changes, check.Warnings, check.PendingActions))
return fmt.Errorf("任务 %s 执行失败: %w", task.Key(), err)
}
status := "ok"
@@ -122,15 +76,16 @@ func (e *Engine) Run(ctx context.Context, mode tasks.Mode, taskList []tasks.Task
status = "changed"
}
e.Console.Success("[%s/%s] %s", task.Node(), task.Key(), applyResult.Summary)
- rep.Add(report.TaskResult{
- Node: task.Node(),
- TaskKey: task.Key(),
- Title: task.Title(),
- Status: status,
- Summary: applyResult.Summary,
- StartedAt: started,
- FinishedAt: time.Now(),
- })
+ rep.Add(newTaskResult(
+ task,
+ status,
+ applyResult.Summary,
+ started,
+ report.MergeChanges(check.Changes, applyResult.Changes),
+ appendStrings(check.Warnings, applyResult.Warnings),
+ appendStrings(check.PendingActions, applyResult.PendingActions),
+ ))
+
default:
return fmt.Errorf("不支持的执行模式: %s", mode)
}
@@ -138,3 +93,47 @@ func (e *Engine) Run(ctx context.Context, mode tasks.Mode, taskList []tasks.Task
return nil
}
+
+func newTaskResult(
+ task tasks.Task,
+ status string,
+ summary string,
+ started time.Time,
+ changes []report.ChangeRecord,
+ warnings []string,
+ pendingActions []string,
+) report.TaskResult {
+ return report.TaskResult{
+ Node: task.Node(),
+ TaskKey: task.Key(),
+ Title: task.Title(),
+ Status: status,
+ Summary: summary,
+ Changes: append([]report.ChangeRecord(nil), changes...),
+ Warnings: append([]string(nil), warnings...),
+ PendingActions: append([]string(nil), pendingActions...),
+ StartedAt: started,
+ FinishedAt: time.Now(),
+ }
+}
+
+func appendStrings(first, second []string) []string {
+ if len(first) == 0 && len(second) == 0 {
+ return nil
+ }
+ result := make([]string, 0, len(first)+len(second))
+ seen := make(map[string]struct{}, len(first)+len(second))
+ for _, values := range [][]string{first, second} {
+ for _, value := range values {
+ if value == "" {
+ continue
+ }
+ if _, exists := seen[value]; exists {
+ continue
+ }
+ seen[value] = struct{}{}
+ result = append(result, value)
+ }
+ }
+ return result
+}
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
index 44946fe..43fe1a3 100644
--- a/internal/runner/runner_test.go
+++ b/internal/runner/runner_test.go
@@ -94,3 +94,55 @@ func TestEngineApplyChanged(t *testing.T) {
t.Fatalf("expected changed result, got %+v", rep.Results)
}
}
+
+func TestEngineMergesCheckAndApplyChangeRecords(t *testing.T) {
+ rep := report.New("apply", "demo", false)
+ err := newEngine().Run(context.Background(), tasks.ModeApply, []tasks.Task{
+ fakeTask{
+ key: "ulimit",
+ title: "写入 ulimit 配置",
+ node: "node-1",
+ check: tasks.CheckResult{
+ Needed: true,
+ Summary: "需要更新",
+ Changes: []report.ChangeRecord{{
+ Resource: "login-limits.nofile.soft",
+ Path: "/etc/security/limits.d/99-bootstrapctl.conf",
+ Before: "*=65535,root=65535",
+ Desired: "1048576",
+ Status: report.ChangeStatusNeedsChange,
+ }},
+ },
+ apply: tasks.ApplyResult{
+ Changed: true,
+ Summary: "已写入",
+ Changes: []report.ChangeRecord{{
+ Resource: "login-limits.nofile.soft",
+ Path: "/etc/security/limits.d/99-bootstrapctl.conf",
+ After: "*=1048576,root=1048576",
+ Effective: "65535",
+ Changed: true,
+ Status: report.ChangeStatusChangedPendingRelogin,
+ PendingAction: "重新登录后验证",
+ }},
+ PendingActions: []string{"重新登录后验证"},
+ },
+ },
+ }, false, rep)
+ if err != nil {
+ t.Fatalf("Run() error = %v", err)
+ }
+ if len(rep.Results) != 1 || len(rep.Results[0].Changes) != 1 {
+ t.Fatalf("expected one merged change, got %+v", rep.Results)
+ }
+ change := rep.Results[0].Changes[0]
+ if change.Before != "*=65535,root=65535" || change.After != "*=1048576,root=1048576" {
+ t.Fatalf("unexpected merged change: %+v", change)
+ }
+ if change.Status != report.ChangeStatusChangedPendingRelogin {
+ t.Fatalf("unexpected change status: %+v", change)
+ }
+ if len(rep.Results[0].PendingActions) != 1 {
+ t.Fatalf("expected pending action, got %+v", rep.Results[0].PendingActions)
+ }
+}
diff --git a/internal/tasks/build_test.go b/internal/tasks/build_test.go
index d0b7eb2..798634a 100644
--- a/internal/tasks/build_test.go
+++ b/internal/tasks/build_test.go
@@ -25,8 +25,8 @@ func TestBuildGeneratesExpectedTasks(t *testing.T) {
profile.ApplyDefaults()
taskList := Build(inventory, profile)
- if len(taskList) != 22 {
- t.Fatalf("expected 22 tasks for 2 nodes with 11 tasks each, got %d", len(taskList))
+ if len(taskList) != 26 {
+ t.Fatalf("expected 26 tasks for 2 nodes with 13 tasks each, got %d", len(taskList))
}
}
@@ -51,12 +51,14 @@ func TestBuildIncludesSSHAuthorizedKeyTaskWhenEnabled(t *testing.T) {
profile.SSHKey.ResolvedPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBootstrapCtlExampleKey bootstrapctl@example"
taskList := Build(inventory, profile)
- if len(taskList) != 11 {
- t.Fatalf("expected 11 tasks for 1 node with ssh_authorized_key enabled, got %d", len(taskList))
+ if len(taskList) != 13 {
+ t.Fatalf("expected 13 tasks for 1 node with ssh_authorized_key enabled, got %d", len(taskList))
}
found := false
controllerConfigFound := false
+ runtimeStorageAuditFound := false
+ hostFactsFound := false
for _, task := range taskList {
if task.Key() == "ssh-authorized-key" {
found = true
@@ -64,6 +66,12 @@ func TestBuildIncludesSSHAuthorizedKeyTaskWhenEnabled(t *testing.T) {
if task.Key() == "ssh-controller-client-config" {
controllerConfigFound = true
}
+ if task.Key() == "runtime-storage-audit" {
+ runtimeStorageAuditFound = true
+ }
+ if task.Key() == "host-facts" {
+ hostFactsFound = true
+ }
}
if !found {
t.Fatalf("expected ssh-authorized-key task to be present")
@@ -71,6 +79,12 @@ func TestBuildIncludesSSHAuthorizedKeyTaskWhenEnabled(t *testing.T) {
if !controllerConfigFound {
t.Fatalf("expected ssh-controller-client-config task to be present")
}
+ if !runtimeStorageAuditFound {
+ t.Fatalf("expected runtime-storage-audit task to be present")
+ }
+ if !hostFactsFound {
+ t.Fatalf("expected host-facts task to be present")
+ }
}
func TestBuildIncludesBastionHopTaskWhenNodeUsesBastion(t *testing.T) {
diff --git a/internal/tasks/host_facts.go b/internal/tasks/host_facts.go
new file mode 100644
index 0000000..20f5398
--- /dev/null
+++ b/internal/tasks/host_facts.go
@@ -0,0 +1,206 @@
+package tasks
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/yuanyp8/bootstrapctl/internal/config"
+ "github.com/yuanyp8/bootstrapctl/internal/facts"
+ "github.com/yuanyp8/bootstrapctl/internal/remote"
+ "github.com/yuanyp8/bootstrapctl/internal/report"
+)
+
+// HostFactsTask 对应 k0sctl gather-facts / Kubespray preinstall 的第一层:
+// 先识别发行版和宿主机能力,后续任务再基于 capability 做条件化收敛。
+type HostFactsTask struct {
+ NodeSpec config.NodeConnection
+}
+
+func (t *HostFactsTask) Key() string { return "host-facts" }
+func (t *HostFactsTask) Title() string { return "采集主机平台与 preinstall 事实" }
+func (t *HostFactsTask) Node() string { return t.NodeSpec.Name }
+
+func (t *HostFactsTask) Check(ctx context.Context, exec remote.Executor) (CheckResult, error) {
+ result, err := runScript(ctx, exec, t.NodeSpec, hostFactsScript())
+ if err != nil {
+ return CheckResult{}, err
+ }
+ if result.ExitCode != 0 {
+ return CheckResult{}, fmt.Errorf("采集主机事实失败: %s", strings.TrimSpace(result.Output))
+ }
+
+ hostFacts, err := facts.ParseHostFactsOutput(result.Output)
+ if err != nil {
+ return CheckResult{}, err
+ }
+ changes, warnings := hostFactsChangeRecords(hostFacts)
+ return CheckResult{
+ Needed: false,
+ Summary: fmt.Sprintf("已识别 %s %s / %s / %s", hostFacts.OSID, hostFacts.OSVersion, hostFacts.Arch, hostFacts.InitSystem),
+ Changes: changes,
+ Warnings: warnings,
+ }, nil
+}
+
+func (t *HostFactsTask) Apply(context.Context, remote.Executor) (ApplyResult, error) {
+ return ApplyResult{Changed: false, Summary: "主机事实任务只读,不执行修改"}, nil
+}
+
+func hostFactsScript() string {
+ return `
+emit_fact() {
+ key="$1"
+ value="$2"
+ encoded="$(printf '%s' "$value" | base64 | tr -d '\n')"
+ printf '__BT_FACT__|%s|%s\n' "$key" "$encoded"
+}
+
+os_id="unknown"
+os_version="unknown"
+os_id_like=""
+os_pretty_name="unknown"
+if [ -f /etc/os-release ]; then
+ . /etc/os-release
+ os_id="${ID:-unknown}"
+ os_version="${VERSION_ID:-unknown}"
+ os_id_like="${ID_LIKE:-}"
+ os_pretty_name="${PRETTY_NAME:-unknown}"
+fi
+
+package_manager="unknown"
+for candidate in apt-get dnf yum zypper apk pacman; do
+ if command -v "$candidate" >/dev/null 2>&1; then
+ package_manager="$candidate"
+ break
+ fi
+done
+
+init_system="$(ps -p 1 -o comm= 2>/dev/null | xargs || true)"
+[ -n "$init_system" ] || init_system="unknown"
+
+cgroup_version="v1"
+if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
+ cgroup_version="v2"
+elif mount 2>/dev/null | grep -q 'type cgroup2'; then
+ cgroup_version="v2"
+fi
+
+resolv_conf_target="$(readlink -f /etc/resolv.conf 2>/dev/null || echo /etc/resolv.conf)"
+resolv_conf_link="$(readlink /etc/resolv.conf 2>/dev/null || echo regular-file)"
+nameservers="$(awk '/^[[:space:]]*nameserver[[:space:]]+/ {print $2}' /etc/resolv.conf 2>/dev/null | paste -sd ',' - || true)"
+search_domains="$(awk '/^[[:space:]]*(search|domain)[[:space:]]+/ {$1=""; sub(/^[[:space:]]+/, ""); print}' /etc/resolv.conf 2>/dev/null | paste -sd ',' - || true)"
+default_route="$(ip route show default 2>/dev/null | head -n 1 || true)"
+
+timezone="$(timedatectl show -p Timezone --value 2>/dev/null || true)"
+if [ -z "$timezone" ] && [ -f /etc/timezone ]; then
+ timezone="$(cat /etc/timezone 2>/dev/null || true)"
+fi
+[ -n "$timezone" ] || timezone="unknown"
+
+ntp_synchronized="$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true)"
+[ -n "$ntp_synchronized" ] || ntp_synchronized="unknown"
+
+time_sync_service="missing"
+if command -v systemctl >/dev/null 2>&1; then
+ for service in chronyd chrony systemd-timesyncd ntpd; do
+ if systemctl is-active "$service" >/dev/null 2>&1; then
+ time_sync_service="$service"
+ break
+ fi
+ done
+fi
+
+emit_fact os_id "$os_id"
+emit_fact os_version "$os_version"
+emit_fact os_id_like "$os_id_like"
+emit_fact os_pretty_name "$os_pretty_name"
+emit_fact arch "$(uname -m 2>/dev/null || echo unknown)"
+emit_fact kernel "$(uname -r 2>/dev/null || echo unknown)"
+emit_fact init_system "$init_system"
+emit_fact package_manager "$package_manager"
+emit_fact cgroup_version "$cgroup_version"
+emit_fact resolv_conf_target "$resolv_conf_target"
+emit_fact resolv_conf_link "$resolv_conf_link"
+emit_fact nameservers "$nameservers"
+emit_fact search_domains "$search_domains"
+emit_fact default_route "$default_route"
+emit_fact timezone "$timezone"
+emit_fact ntp_synchronized "$ntp_synchronized"
+emit_fact time_sync_service "$time_sync_service"
+echo OK
+`
+}
+
+func hostFactsChangeRecords(host facts.HostFacts) ([]report.ChangeRecord, []string) {
+ var changes []report.ChangeRecord
+ var warnings []string
+
+ observe := func(category, resource, value, desired, evidence string) report.ChangeRecord {
+ return report.ChangeRecord{
+ Category: category,
+ Resource: resource,
+ Operation: "observe",
+ Before: value,
+ After: value,
+ Effective: value,
+ Desired: desired,
+ Changed: false,
+ Verified: true,
+ Status: report.ChangeStatusCompliant,
+ Evidence: evidence,
+ }
+ }
+
+ changes = append(changes,
+ observe("platform", "os.release", strings.TrimSpace(host.OSPrettyName), "supported-distribution", "/etc/os-release"),
+ observe("platform", "os.family", host.DistroFamily, "known", "ID/ID_LIKE normalization"),
+ observe("platform", "architecture", host.Arch, "amd64-or-arm64", "uname -m"),
+ observe("platform", "kernel.release", host.Kernel, "observed", "uname -r"),
+ observe("platform", "init.system", host.InitSystem, "systemd", "ps -p 1 -o comm="),
+ observe("platform", "package.manager", host.PackageManager, "known", "command discovery"),
+ observe("kubernetes-preinstall", "cgroup.version", host.CgroupVersion, "observed", "/sys/fs/cgroup/cgroup.controllers"),
+ observe("dns", "resolv.conf.target", host.ResolvConfTarget, "observed", "readlink -f /etc/resolv.conf"),
+ observe("dns", "resolv.conf.link", host.ResolvConfLink, "observed", "readlink /etc/resolv.conf"),
+ observe("dns", "nameservers", host.Nameservers, "non-empty", "/etc/resolv.conf"),
+ observe("network", "default-route", host.DefaultRoute, "present", "ip route show default"),
+ observe("time", "timezone", host.Timezone, "configured", "timedatectl show -p Timezone"),
+ observe("time", "ntp.synchronized", host.NTPSynchronized, "yes", "timedatectl show -p NTPSynchronized"),
+ observe("time", "time-sync.service", host.TimeSyncService, "active", "systemctl is-active chronyd/chrony/systemd-timesyncd/ntpd"),
+ )
+
+ warn := func(resource, message string) {
+ warnings = append(warnings, message)
+ for idx := range changes {
+ if changes[idx].Resource != resource {
+ continue
+ }
+ changes[idx].Verified = false
+ changes[idx].Status = report.ChangeStatusObservedWarning
+ changes[idx].Message = message
+ }
+ }
+
+ if host.DistroFamily == "unknown" {
+ warn("os.family", "无法识别发行版 family,后续包名和服务名不能安全自动映射")
+ }
+ if host.InitSystem != "systemd" {
+ warn("init.system", fmt.Sprintf("PID 1 为 %s,当前 Kubernetes 主机策略主要面向 systemd", host.InitSystem))
+ }
+ if host.PackageManager == "unknown" {
+ warn("package.manager", "未识别包管理器,Kubernetes 必需包只能检查不能自动安装")
+ }
+ if strings.TrimSpace(host.Nameservers) == "" {
+ warn("nameservers", "/etc/resolv.conf 未发现 nameserver")
+ }
+ if strings.TrimSpace(host.DefaultRoute) == "" {
+ warn("default-route", "未发现默认路由")
+ }
+ if host.NTPSynchronized != "yes" {
+ warn("ntp.synchronized", fmt.Sprintf("主机时间尚未确认同步,当前值=%s", host.NTPSynchronized))
+ }
+ if host.TimeSyncService == "missing" {
+ warn("time-sync.service", "未发现处于 active 状态的时间同步服务")
+ }
+ return changes, warnings
+}
diff --git a/internal/tasks/host_facts_test.go b/internal/tasks/host_facts_test.go
new file mode 100644
index 0000000..44be09d
--- /dev/null
+++ b/internal/tasks/host_facts_test.go
@@ -0,0 +1,42 @@
+package tasks
+
+import (
+ "testing"
+
+ "github.com/yuanyp8/bootstrapctl/internal/facts"
+ "github.com/yuanyp8/bootstrapctl/internal/report"
+)
+
+func TestHostFactsChangeRecordsWarnOnMissingPreinstallCapabilities(t *testing.T) {
+ changes, warnings := hostFactsChangeRecords(facts.HostFacts{
+ OSPrettyName: "Unknown Linux",
+ DistroFamily: "unknown",
+ Arch: "amd64",
+ Kernel: "6.8.0",
+ InitSystem: "openrc",
+ PackageManager: "unknown",
+ CgroupVersion: "v2",
+ ResolvConfTarget: "/etc/resolv.conf",
+ ResolvConfLink: "regular-file",
+ Nameservers: "",
+ DefaultRoute: "",
+ Timezone: "UTC",
+ NTPSynchronized: "no",
+ TimeSyncService: "missing",
+ })
+ if len(warnings) != 7 {
+ t.Fatalf("expected seven warnings, got %d: %+v", len(warnings), warnings)
+ }
+ if len(changes) == 0 {
+ t.Fatalf("expected structured fact records")
+ }
+ var familyWarning bool
+ for _, change := range changes {
+ if change.Resource == "os.family" && change.Status == report.ChangeStatusObservedWarning {
+ familyWarning = true
+ }
+ }
+ if !familyWarning {
+ t.Fatalf("expected os.family warning record: %+v", changes)
+ }
+}
diff --git a/internal/tasks/runtime_storage_audit.go b/internal/tasks/runtime_storage_audit.go
new file mode 100644
index 0000000..a5b7361
--- /dev/null
+++ b/internal/tasks/runtime_storage_audit.go
@@ -0,0 +1,159 @@
+package tasks
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/yuanyp8/bootstrapctl/internal/config"
+ "github.com/yuanyp8/bootstrapctl/internal/remote"
+ "github.com/yuanyp8/bootstrapctl/internal/report"
+)
+
+// RuntimeStorageAuditTask 观测容器运行时真正消费的数据目录。
+//
+// 现有 StorageLayoutTask 会创建目录并管理 containers/storage.conf,但 Docker
+// 使用 daemon.json,containerd 使用 config.toml。仅仅创建 /data/containerd 并不能
+// 证明 containerd 已经使用它。这个任务先把语义缺口暴露到报告中;后续再按明确
+// policy 分别实现 Docker/containerd 的安全合并与重启策略。
+type RuntimeStorageAuditTask struct {
+ NodeSpec config.NodeConnection
+ ExpectedDockerRoot string
+ ExpectedContainerdRoot string
+ ExpectedContainersRoot string
+ StorageConfPath string
+}
+
+func (t *RuntimeStorageAuditTask) Key() string { return "runtime-storage-audit" }
+func (t *RuntimeStorageAuditTask) Title() string { return "观测容器运行时实际存储目录" }
+func (t *RuntimeStorageAuditTask) Node() string { return t.NodeSpec.Name }
+
+func (t *RuntimeStorageAuditTask) Check(ctx context.Context, exec remote.Executor) (CheckResult, error) {
+ script := fmt.Sprintf(`
+docker_state="missing"
+docker_actual=""
+if command -v docker >/dev/null 2>&1; then
+ docker_state="installed-unavailable"
+ docker_actual="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
+ if [ -z "$docker_actual" ] && [ -f /etc/docker/daemon.json ]; then
+ docker_actual="$(sed -n 's/.*"data-root"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' /etc/docker/daemon.json | head -n 1)"
+ fi
+ [ -n "$docker_actual" ] && docker_state="ready"
+fi
+
+containerd_state="missing"
+containerd_actual=""
+if command -v containerd >/dev/null 2>&1; then
+ containerd_state="installed-unavailable"
+ if [ -f /etc/containerd/config.toml ]; then
+ containerd_actual="$(awk -F= '/^[[:space:]]*root[[:space:]]*=/ {gsub(/[[:space:]\"]/, "", $2); print $2; exit}' /etc/containerd/config.toml)"
+ fi
+ if [ -z "$containerd_actual" ]; then
+ containerd_actual="$(containerd config dump 2>/dev/null | awk -F= '/^[[:space:]]*root[[:space:]]*=/ {gsub(/[[:space:]\"]/, "", $2); print $2; exit}' || true)"
+ fi
+ [ -n "$containerd_actual" ] && containerd_state="ready"
+fi
+
+containers_state="missing"
+containers_actual=""
+if [ -f "%s" ]; then
+ containers_actual="$(awk -F= '/^[[:space:]]*graphroot[[:space:]]*=/ {gsub(/[[:space:]\"]/, "", $2); print $2; exit}' "%s")"
+ if [ -n "$containers_actual" ]; then
+ containers_state="ready"
+ else
+ containers_state="configured-unreadable"
+ fi
+fi
+
+printf '__BT_RUNTIME_STORAGE__|docker|%%s|%%s|%%s\n' "$docker_state" "$docker_actual" "%s"
+printf '__BT_RUNTIME_STORAGE__|containerd|%%s|%%s|%%s\n' "$containerd_state" "$containerd_actual" "%s"
+printf '__BT_RUNTIME_STORAGE__|containers-storage|%%s|%%s|%%s\n' "$containers_state" "$containers_actual" "%s"
+echo OK
+`, t.StorageConfPath, t.StorageConfPath, t.ExpectedDockerRoot, t.ExpectedContainerdRoot, t.ExpectedContainersRoot)
+
+ result, err := runScript(ctx, exec, t.NodeSpec, script)
+ if err != nil {
+ return CheckResult{}, err
+ }
+ if result.ExitCode != 0 {
+ return CheckResult{}, fmt.Errorf("观测容器运行时存储目录失败: %s", strings.TrimSpace(result.Output))
+ }
+
+ changes, warnings := parseRuntimeStorageChanges(result.Output)
+ return CheckResult{
+ Needed: false,
+ Summary: "已采集 Docker、containerd 与 containers/storage 的实际存储目录",
+ Changes: changes,
+ Warnings: warnings,
+ }, nil
+}
+
+func (t *RuntimeStorageAuditTask) Apply(context.Context, remote.Executor) (ApplyResult, error) {
+ return ApplyResult{
+ Changed: false,
+ Summary: "运行时存储任务当前为观测模式,未修改 Docker 或 containerd 配置",
+ }, nil
+}
+
+func parseRuntimeStorageChanges(output string) ([]report.ChangeRecord, []string) {
+ var changes []report.ChangeRecord
+ var warnings []string
+
+ for _, rawLine := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") {
+ line := strings.TrimSpace(rawLine)
+ if !strings.HasPrefix(line, "__BT_RUNTIME_STORAGE__|") {
+ continue
+ }
+ parts := strings.SplitN(line, "|", 5)
+ if len(parts) != 5 {
+ continue
+ }
+
+ runtimeName := strings.TrimSpace(parts[1])
+ state := strings.TrimSpace(parts[2])
+ actual := strings.TrimSpace(parts[3])
+ desired := strings.TrimSpace(parts[4])
+ change := report.ChangeRecord{
+ Category: "container-runtime",
+ Resource: runtimeName + ".data-root",
+ Operation: "observe",
+ Before: actual,
+ After: actual,
+ Effective: actual,
+ Desired: desired,
+ Changed: false,
+ Evidence: runtimeStorageEvidence(runtimeName),
+ }
+
+ switch {
+ case state == "missing":
+ change.Status = report.ChangeStatusSkipped
+ change.Message = "目标主机未检测到该运行时或配置文件"
+ case state != "ready" || actual == "":
+ change.Status = report.ChangeStatusObservedWarning
+ change.Message = "已检测到运行时,但暂时无法确认实际数据目录"
+ warnings = append(warnings, fmt.Sprintf("%s 已安装,但无法确认实际数据目录", runtimeName))
+ case actual == desired:
+ change.Status = report.ChangeStatusCompliant
+ change.Verified = true
+ change.Message = "运行时实际目录与期望值一致"
+ default:
+ change.Status = report.ChangeStatusObservedWarning
+ change.Message = "运行时实际目录与 profile 期望目录不一致;当前仅报告,不自动改写"
+ warnings = append(warnings, fmt.Sprintf("%s 实际目录 %s 与期望目录 %s 不一致", runtimeName, actual, desired))
+ }
+ changes = append(changes, change)
+ }
+ return changes, warnings
+}
+
+func runtimeStorageEvidence(runtimeName string) string {
+ switch runtimeName {
+ case "docker":
+ return "docker info --format '{{.DockerRootDir}}';/etc/docker/daemon.json"
+ case "containerd":
+ return "containerd config dump;/etc/containerd/config.toml"
+ default:
+ return "/etc/containers/storage.conf"
+ }
+}
diff --git a/internal/tasks/structured_results_test.go b/internal/tasks/structured_results_test.go
new file mode 100644
index 0000000..0f33e24
--- /dev/null
+++ b/internal/tasks/structured_results_test.go
@@ -0,0 +1,57 @@
+package tasks
+
+import (
+ "testing"
+
+ "github.com/yuanyp8/bootstrapctl/internal/report"
+)
+
+func TestParseUlimitChangesReportsPendingRelogin(t *testing.T) {
+ output := `
+__BT_LIMIT__|nofile|soft|1048576|1048576|1048576|65535
+__BT_LIMIT__|nofile|hard|1048576|1048576|1048576|65535
+__BT_LIMIT__|nproc|soft|1048576|1048576|1048576|4096
+__BT_LIMIT__|nproc|hard|1048576|1048576|1048576|4096
+OK
+`
+ changes, pending := parseUlimitChanges(output, true)
+ if len(changes) != 4 {
+ t.Fatalf("expected four changes, got %d", len(changes))
+ }
+ if len(pending) != 1 {
+ t.Fatalf("expected one pending action, got %+v", pending)
+ }
+ for _, change := range changes {
+ if change.Status != report.ChangeStatusChangedPendingRelogin {
+ t.Fatalf("unexpected status: %+v", change)
+ }
+ if change.After == "" || change.Effective == "" {
+ t.Fatalf("expected after/effective evidence: %+v", change)
+ }
+ }
+}
+
+func TestParseRuntimeStorageChangesDetectsMismatch(t *testing.T) {
+ output := `
+__BT_RUNTIME_STORAGE__|docker|ready|/var/lib/docker|/data/graphroot
+__BT_RUNTIME_STORAGE__|containerd|ready|/data/containerd|/data/containerd
+__BT_RUNTIME_STORAGE__|containers-storage|missing||/data/graphroot/containers/storage
+OK
+`
+ changes, warnings := parseRuntimeStorageChanges(output)
+ if len(changes) != 3 {
+ t.Fatalf("expected three runtime records, got %d", len(changes))
+ }
+ if len(warnings) != 1 {
+ t.Fatalf("expected one mismatch warning, got %+v", warnings)
+ }
+ if changes[0].Status != report.ChangeStatusObservedWarning {
+ t.Fatalf("expected docker mismatch warning, got %+v", changes[0])
+ }
+ if changes[1].Status != report.ChangeStatusCompliant || !changes[1].Verified {
+ t.Fatalf("expected containerd compliant result, got %+v", changes[1])
+ }
+ if changes[2].Status != report.ChangeStatusSkipped {
+ t.Fatalf("expected missing containers storage to be skipped, got %+v", changes[2])
+ }
+}
diff --git a/internal/tasks/task.go b/internal/tasks/task.go
index 8fb19e9..7e02cfb 100644
--- a/internal/tasks/task.go
+++ b/internal/tasks/task.go
@@ -9,6 +9,7 @@ import (
"github.com/yuanyp8/bootstrapctl/internal/config"
"github.com/yuanyp8/bootstrapctl/internal/remote"
+ "github.com/yuanyp8/bootstrapctl/internal/report"
)
// Mode 表示任务执行模式。
@@ -22,8 +23,10 @@ const (
// Task 是 bootstrapctl 的最小执行单元。
// 每个任务都必须具备:
-// 1. Check:判断是否存在漂移
-// 2. Apply:真正落变更
+// 1. Check:判断是否存在漂移,并尽量记录 before/desired/effective
+// 2. Apply:真正落变更,并记录 after/effective/verified
+//
+// 旧任务可以继续只返回 Summary;新任务应逐步补齐 Changes。
type Task interface {
Key() string
Title() string
@@ -33,24 +36,30 @@ type Task interface {
}
type CheckResult struct {
- Needed bool
- Summary string
+ Needed bool
+ Summary string
+ Changes []report.ChangeRecord
+ Warnings []string
+ PendingActions []string
}
type ApplyResult struct {
- Changed bool
- Summary string
+ Changed bool
+ Summary string
+ Changes []report.ChangeRecord
+ Warnings []string
+ PendingActions []string
}
// Build 根据 inventory 与 profile 展开完整任务列表。
// 当前执行顺序是有意编排的:
-// - 先做 SSH 连通性
-// - 再做主机名 / hosts
+// - 先做 SSH 连通性和主机事实采集
+// - 再做 SSH、账号、主机名和 hosts
// - 再做 swap / SELinux / 防火墙 / 内核网络
-// - 最后做目录与资源限制
+// - 最后做目录、容器运行时存储观测与资源限制
func Build(inventory config.Inventory, profile config.Profile) []Task {
nodes := inventory.ResolveNodes()
- taskList := make([]Task, 0, len(nodes)*14)
+ taskList := make([]Task, 0, len(nodes)*16)
controllerKeyTargets := map[string]struct{}{}
controllerSSHConfigTargets := map[string]struct{}{}
@@ -58,6 +67,10 @@ func Build(inventory config.Inventory, profile config.Profile) []Task {
if profile.Features.SSHConnectivityEnabled() {
taskList = append(taskList, &SSHConnectivityTask{NodeSpec: node})
}
+ // 其它任务都依赖发行版、init、包管理器、DNS、cgroup 和时间事实。
+ // 即使关闭单独的 connectivity 展示任务,也仍然需要执行 facts 采集。
+ taskList = append(taskList, &HostFactsTask{NodeSpec: node})
+
if profile.Features.SSHAuthorizedKeyEnabled() {
if node.Bastion != nil && strings.TrimSpace(node.Bastion.Host) != "" {
bastionNode := bastionConnectionForNode(node)
@@ -97,8 +110,6 @@ func Build(inventory config.Inventory, profile config.Profile) []Task {
}
// bastion -> target 的二跳免密与客户端 SSH config,
// 语义上属于“节点间互信链路”,不应该被“控制端公钥分发”总开关绑死。
- // 这样像 master1 -> node1 这类真实集群节点场景,即使不要求控制端免密,
- // 仍然可以独立收敛跳板机到私网节点的登录体验。
if node.Bastion != nil && strings.TrimSpace(node.Bastion.Host) != "" && profile.SSHKey.EnableBastionHopEnabled() {
taskList = append(taskList, &SSHBastionHopKeyTask{
TargetNodeSpec: node,
@@ -175,6 +186,15 @@ func Build(inventory config.Inventory, profile config.Profile) []Task {
RunRoot: profile.Storage.RunRoot,
GraphDriver: profile.Storage.GraphDriver,
})
+ // 先把真实运行时消费的目录纳入报告。当前仅观测,不自动改写
+ // Docker daemon.json 或 containerd config.toml,避免破坏已有配置。
+ taskList = append(taskList, &RuntimeStorageAuditTask{
+ NodeSpec: node,
+ ExpectedDockerRoot: profile.Storage.GraphRoot,
+ ExpectedContainerdRoot: profile.Storage.CRIRoot,
+ ExpectedContainersRoot: strings.TrimRight(profile.Storage.GraphRoot, "/") + "/containers/storage",
+ StorageConfPath: profile.Storage.StorageConfPath,
+ })
}
if profile.Features.UlimitEnabled() {
taskList = append(taskList, &UlimitTask{
diff --git a/internal/tasks/ulimit.go b/internal/tasks/ulimit.go
index 9c951fa..7c664c5 100644
--- a/internal/tasks/ulimit.go
+++ b/internal/tasks/ulimit.go
@@ -3,12 +3,16 @@ package tasks
import (
"context"
"fmt"
+ "strconv"
"strings"
"github.com/yuanyp8/bootstrapctl/internal/config"
"github.com/yuanyp8/bootstrapctl/internal/remote"
+ "github.com/yuanyp8/bootstrapctl/internal/report"
)
+const ulimitConfigPath = "/etc/security/limits.d/99-bootstrapctl.conf"
+
type UlimitTask struct {
NodeSpec config.NodeConnection
NoFile int
@@ -16,7 +20,7 @@ type UlimitTask struct {
}
func (t *UlimitTask) Key() string { return "ulimit" }
-func (t *UlimitTask) Title() string { return "写入 ulimit 配置" }
+func (t *UlimitTask) Title() string { return "收敛登录用户资源限制" }
func (t *UlimitTask) Node() string { return t.NodeSpec.Name }
func (t *UlimitTask) desiredContent() string {
@@ -31,41 +35,49 @@ root hard nproc %d`, t.NoFile, t.NoFile, t.NProc, t.NProc, t.NoFile, t.NoFile, t
}
func (t *UlimitTask) Check(ctx context.Context, exec remote.Executor) (CheckResult, error) {
- expectedB64 := encodeBase64(t.desiredContent())
- script := fmt.Sprintf(`
-set -e
-target="/etc/security/limits.d/99-bootstrapctl.conf"
-expected="$(printf '%%s' '%s' | base64 -d)"
-if [ -f "$target" ] && [ "$(cat "$target")" = "$expected" ]; then
- echo OK
-else
- echo CHANGE
-fi
-`, expectedB64)
- result, err := runScript(ctx, exec, t.NodeSpec, script)
+ result, err := runScript(ctx, exec, t.NodeSpec, t.renderObservationScript())
if err != nil {
return CheckResult{}, err
}
- output := parseStatusLine(result.Output, "OK", "CHANGE")
- if output == "OK" {
- return CheckResult{Needed: false, Summary: "ulimit 配置已满足要求"}, nil
+ if result.ExitCode != 0 {
+ return CheckResult{}, fmt.Errorf("检查登录用户资源限制失败: %s", strings.TrimSpace(result.Output))
}
- if output == "CHANGE" {
- return CheckResult{Needed: true, Summary: "ulimit 配置需要更新"}, nil
+
+ status := parseStatusLine(result.Output, "OK", "CHANGE")
+ changes, pendingActions := parseUlimitChanges(result.Output, false)
+ switch status {
+ case "OK":
+ summary := "登录用户 limits 配置已满足要求"
+ if len(pendingActions) > 0 {
+ summary += ",但当前会话仍需重新登录后验证生效值"
+ }
+ return CheckResult{
+ Needed: false,
+ Summary: summary,
+ Changes: changes,
+ PendingActions: pendingActions,
+ }, nil
+ case "CHANGE":
+ return CheckResult{
+ Needed: true,
+ Summary: "登录用户 limits 配置需要更新",
+ Changes: changes,
+ }, nil
+ default:
+ return CheckResult{}, fmt.Errorf("无法解析 ulimit 检查结果: %s", status)
}
- return CheckResult{}, fmt.Errorf("无法解析 ulimit 检查结果: %s", output)
}
func (t *UlimitTask) Apply(ctx context.Context, exec remote.Executor) (ApplyResult, error) {
expectedB64 := encodeBase64(t.desiredContent())
script := fmt.Sprintf(`
set -e
-target="/etc/security/limits.d/99-bootstrapctl.conf"
+target=%q
mkdir -p /etc/security/limits.d
printf '%%s' '%s' | base64 -d > "$target"
chmod 644 "$target"
-echo CHANGED
-`, expectedB64)
+`, ulimitConfigPath, expectedB64) + t.renderObservationScript()
+
result, err := runScript(ctx, exec, t.NodeSpec, script)
if err != nil {
return ApplyResult{}, err
@@ -73,8 +85,142 @@ echo CHANGED
if result.ExitCode != 0 {
return ApplyResult{}, fmt.Errorf("写入 ulimit 配置失败: %s", strings.TrimSpace(result.Output))
}
+
+ changes, pendingActions := parseUlimitChanges(result.Output, true)
+ summary := "登录用户 limits 配置已写入 " + ulimitConfigPath
+ if len(pendingActions) > 0 {
+ summary += ";新登录会话生效后需再次 verify"
+ }
return ApplyResult{
- Changed: true,
- Summary: "ulimit 配置已写入 /etc/security/limits.d/99-bootstrapctl.conf",
+ Changed: true,
+ Summary: summary,
+ Changes: changes,
+ PendingActions: pendingActions,
}, nil
}
+
+func (t *UlimitTask) renderObservationScript() string {
+ return fmt.Sprintf(`
+target=%q
+read_limit() {
+ domain="$1"
+ level="$2"
+ item="$3"
+ if [ ! -f "$target" ]; then
+ printf 'missing'
+ return
+ fi
+ value="$(awk -v domain="$domain" -v level="$level" -v item="$item" '$1 == domain && $2 == level && $3 == item {print $4; exit}' "$target")"
+ [ -n "$value" ] && printf '%%s' "$value" || printf 'missing'
+}
+
+emit_limit() {
+ item="$1"
+ level="$2"
+ desired="$3"
+ effective="$4"
+ star="$(read_limit '*' "$level" "$item")"
+ root="$(read_limit 'root' "$level" "$item")"
+ printf '__BT_LIMIT__|%%s|%%s|%%s|%%s|%%s|%%s\n' "$item" "$level" "$star" "$root" "$desired" "$effective"
+ if [ "$star" != "$desired" ] || [ "$root" != "$desired" ]; then
+ need=1
+ fi
+}
+
+need=0
+emit_limit nofile soft %q "$(ulimit -Sn 2>/dev/null || echo unknown)"
+emit_limit nofile hard %q "$(ulimit -Hn 2>/dev/null || echo unknown)"
+emit_limit nproc soft %q "$(ulimit -Su 2>/dev/null || echo unknown)"
+emit_limit nproc hard %q "$(ulimit -Hu 2>/dev/null || echo unknown)"
+
+if [ "$need" -eq 0 ]; then
+ echo OK
+else
+ echo CHANGE
+fi
+`, ulimitConfigPath, strconv.Itoa(t.NoFile), strconv.Itoa(t.NoFile), strconv.Itoa(t.NProc), strconv.Itoa(t.NProc))
+}
+
+func parseUlimitChanges(output string, changed bool) ([]report.ChangeRecord, []string) {
+ var changes []report.ChangeRecord
+ pendingRelogin := false
+
+ for _, rawLine := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") {
+ line := strings.TrimSpace(rawLine)
+ if !strings.HasPrefix(line, "__BT_LIMIT__|") {
+ continue
+ }
+ parts := strings.SplitN(line, "|", 7)
+ if len(parts) != 7 {
+ continue
+ }
+
+ item := strings.TrimSpace(parts[1])
+ level := strings.TrimSpace(parts[2])
+ star := strings.TrimSpace(parts[3])
+ root := strings.TrimSpace(parts[4])
+ desired := strings.TrimSpace(parts[5])
+ effective := strings.TrimSpace(parts[6])
+ configured := star == desired && root == desired
+ effectiveNow := effective == desired
+
+ change := report.ChangeRecord{
+ Category: "resource-limit",
+ Resource: fmt.Sprintf("login-limits.%s.%s", item, level),
+ Path: ulimitConfigPath,
+ Operation: "write-managed-file",
+ Before: fmt.Sprintf("*=%s,root=%s", star, root),
+ Desired: desired,
+ Effective: effective,
+ Changed: changed,
+ Verified: configured && effectiveNow,
+ Evidence: fmt.Sprintf("%s;ulimit -%s%s", ulimitConfigPath, limitLevelFlag(level), limitItemFlag(item)),
+ }
+ if changed {
+ change.After = fmt.Sprintf("*=%s,root=%s", desired, desired)
+ }
+
+ switch {
+ case !configured:
+ change.Status = report.ChangeStatusNeedsChange
+ change.Message = "配置文件中的 wildcard/root 值尚未达到目标"
+ case effectiveNow && changed:
+ change.Status = report.ChangeStatusChangedVerified
+ change.Message = "配置值和当前会话生效值均达到目标"
+ case effectiveNow:
+ change.Status = report.ChangeStatusCompliant
+ change.Message = "配置值和当前会话生效值均达到目标"
+ case changed:
+ change.Status = report.ChangeStatusChangedPendingRelogin
+ change.Message = "配置文件已更新,当前 SSH/sudo 会话仍保留旧限制"
+ change.PendingAction = "重新登录后执行 bootstrapctl verify"
+ pendingRelogin = true
+ default:
+ change.Status = "configured-pending-relogin"
+ change.Message = "配置文件已满足目标,但当前会话仍保留旧限制"
+ change.PendingAction = "重新登录后执行 bootstrapctl verify"
+ pendingRelogin = true
+ }
+ changes = append(changes, change)
+ }
+
+ var pendingActions []string
+ if pendingRelogin {
+ pendingActions = []string{"重新建立 SSH 登录会话后执行 bootstrapctl verify,确认 PAM limits 的实际生效值"}
+ }
+ return changes, pendingActions
+}
+
+func limitLevelFlag(level string) string {
+ if level == "hard" {
+ return "H"
+ }
+ return "S"
+}
+
+func limitItemFlag(item string) string {
+ if item == "nproc" {
+ return "u"
+ }
+ return "n"
+}