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" +}