新增profile模块
This commit is contained in:
290
internal/profile/profile.go
Normal file
290
internal/profile/profile.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 格式版本号,用于向后兼容检查
|
||||||
|
const CurrentVersion = 1
|
||||||
|
|
||||||
|
// 名称合法性正则:1-64 字符,仅允许字母、数字、下划线、连字符
|
||||||
|
var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// Profile 环境配置文件,控制哪些采集器启用及其过滤规则
|
||||||
|
type Profile struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description,omitempty"`
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Collectors CollectorConfig `yaml:"collectors"`
|
||||||
|
Settings map[string]SettingMap `yaml:"settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectorConfig 采集器启用/禁用配置
|
||||||
|
type CollectorConfig struct {
|
||||||
|
Enabled []string `yaml:"enabled,omitempty"` // 启用列表(空=全部启用)
|
||||||
|
Disabled []string `yaml:"disabled,omitempty"` // 显式禁用列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingMap 采集器自定义选项(键值对)
|
||||||
|
type SettingMap map[string]interface{}
|
||||||
|
|
||||||
|
// ProfileMeta 列出 Profile 时的摘要信息
|
||||||
|
type ProfileMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
ModTime time.Time `json:"mod_time"`
|
||||||
|
Collectors int `json:"collectors"` // 启用的采集器数量
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 验证
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Validate 校验 Profile 的完整性和合法性
|
||||||
|
func (p *Profile) Validate() error {
|
||||||
|
// 名称非空且合法
|
||||||
|
if !nameRegex.MatchString(p.Name) {
|
||||||
|
return fmt.Errorf("E1010: Profile 名称 %q 非法,仅允许 1-64 个字母、数字、下划线或连字符", p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本兼容
|
||||||
|
if p.Version < 1 || p.Version > CurrentVersion {
|
||||||
|
return fmt.Errorf("E1016: Profile 版本 %d 不兼容,当前支持最高版本 %d", p.Version, CurrentVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enabled 与 disabled 不能有交集
|
||||||
|
if len(p.Collectors.Enabled) > 0 && len(p.Collectors.Disabled) > 0 {
|
||||||
|
enabledSet := make(map[string]struct{}, len(p.Collectors.Enabled))
|
||||||
|
for _, name := range p.Collectors.Enabled {
|
||||||
|
enabledSet[name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, name := range p.Collectors.Disabled {
|
||||||
|
if _, ok := enabledSet[name]; ok {
|
||||||
|
return fmt.Errorf("E1013: 采集器 %q 同时出现在 enabled 和 disabled 列表中", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCollectorEnabled 判断指定采集器在此 Profile 下是否启用
|
||||||
|
func (p *Profile) IsCollectorEnabled(name string) bool {
|
||||||
|
// 检查是否显式禁用
|
||||||
|
for _, d := range p.Collectors.Disabled {
|
||||||
|
if d == name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果 enabled 列表非空,只有列表中的才启用
|
||||||
|
if len(p.Collectors.Enabled) > 0 {
|
||||||
|
for _, e := range p.Collectors.Enabled {
|
||||||
|
if e == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// enabled 为空表示全部启用
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSetting 获取指定采集器的某个设置项
|
||||||
|
func (p *Profile) GetSetting(collector, key string) (interface{}, bool) {
|
||||||
|
sm, ok := p.Settings[collector]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
val, ok := sm[key]
|
||||||
|
return val, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettingBool 获取布尔类型设置项,不存在时返回 defaultVal
|
||||||
|
func (p *Profile) GetSettingBool(collector, key string, defaultVal bool) bool {
|
||||||
|
val, ok := p.GetSetting(collector, key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
if b, ok := val.(bool); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettingStrings 获取字符串数组类型设置项
|
||||||
|
func (p *Profile) GetSettingStrings(collector, key string) []string {
|
||||||
|
val, ok := p.GetSetting(collector, key)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
result := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// CRUD 操作
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Load 从指定路径加载 Profile 文件
|
||||||
|
func Load(path string) (*Profile, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开 Profile 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
p := &Profile{}
|
||||||
|
if err := yaml.NewDecoder(f).Decode(p); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 Profile 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadByName 根据名称从 profiles 目录加载 Profile
|
||||||
|
func LoadByName(profilesDir, name string) (*Profile, error) {
|
||||||
|
path := filepath.Join(profilesDir, name+".yaml")
|
||||||
|
return Load(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save 将 Profile 保存到指定路径(原子写入:先写临时文件再重命名)
|
||||||
|
func Save(path string, p *Profile) error {
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("创建目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入临时文件
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := yaml.NewEncoder(f)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err := enc.Encode(p); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("序列化 Profile 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("关闭编码器失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("关闭文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原子重命名
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("重命名文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveByName 根据名称保存到 profiles 目录
|
||||||
|
func SaveByName(profilesDir string, p *Profile) error {
|
||||||
|
path := filepath.Join(profilesDir, p.Name+".yaml")
|
||||||
|
return Save(path, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除指定名称的 Profile 文件
|
||||||
|
func Delete(profilesDir, name string) error {
|
||||||
|
path := filepath.Join(profilesDir, name+".yaml")
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("Profile %q 不存在", name)
|
||||||
|
}
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists 检查指定名称的 Profile 是否存在
|
||||||
|
func Exists(profilesDir, name string) bool {
|
||||||
|
path := filepath.Join(profilesDir, name+".yaml")
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 列出目录下所有 Profile 摘要信息
|
||||||
|
func List(profilesDir string) ([]ProfileMeta, error) {
|
||||||
|
entries, err := os.ReadDir(profilesDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("读取 profiles 目录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metas []ProfileMeta
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(profilesDir, entry.Name())
|
||||||
|
p, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
continue // 跳过无效文件
|
||||||
|
}
|
||||||
|
|
||||||
|
info, _ := entry.Info()
|
||||||
|
collectorCount := len(p.Collectors.Enabled)
|
||||||
|
if collectorCount == 0 {
|
||||||
|
collectorCount = -1 // -1 表示全部启用
|
||||||
|
}
|
||||||
|
|
||||||
|
metas = append(metas, ProfileMeta{
|
||||||
|
Name: p.Name,
|
||||||
|
Description: p.Description,
|
||||||
|
Path: path,
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
Collectors: collectorCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return metas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export 将 Profile 以格式化 YAML 写入 Writer(用于 profile show 命令)
|
||||||
|
func Export(w io.Writer, p *Profile) error {
|
||||||
|
enc := yaml.NewEncoder(w)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err := enc.Encode(p); err != nil {
|
||||||
|
return fmt.Errorf("导出 Profile 失败: %w", err)
|
||||||
|
}
|
||||||
|
return enc.Close()
|
||||||
|
}
|
||||||
427
internal/profile/profile_test.go
Normal file
427
internal/profile/profile_test.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 模板与构造
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestNewFromTemplate_Minimal(t *testing.T) {
|
||||||
|
p, err := NewFromTemplate("test-min", TemplateMinimal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 minimal 模板失败: %v", err)
|
||||||
|
}
|
||||||
|
if p.Name != "test-min" {
|
||||||
|
t.Errorf("Name = %q, want %q", p.Name, "test-min")
|
||||||
|
}
|
||||||
|
if p.Version != CurrentVersion {
|
||||||
|
t.Errorf("Version = %d, want %d", p.Version, CurrentVersion)
|
||||||
|
}
|
||||||
|
if len(p.Collectors.Enabled) != 4 {
|
||||||
|
t.Errorf("Enabled 采集器数量 = %d, want 4", len(p.Collectors.Enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromTemplate_Standard(t *testing.T) {
|
||||||
|
p, err := NewFromTemplate("test-std", TemplateStandard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 standard 模板失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Collectors.Enabled) != 7 {
|
||||||
|
t.Errorf("Enabled 采集器数量 = %d, want 7", len(p.Collectors.Enabled))
|
||||||
|
}
|
||||||
|
// standard 应包含 vscode
|
||||||
|
found := false
|
||||||
|
for _, name := range p.Collectors.Enabled {
|
||||||
|
if name == "vscode" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("standard 模板应包含 vscode 采集器")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromTemplate_Full(t *testing.T) {
|
||||||
|
p, err := NewFromTemplate("test-full", TemplateFull)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 full 模板失败: %v", err)
|
||||||
|
}
|
||||||
|
// full 模板 enabled 列表为空(表示全部启用)
|
||||||
|
if len(p.Collectors.Enabled) != 0 {
|
||||||
|
t.Errorf("full 模板 Enabled 应为空, got %d", len(p.Collectors.Enabled))
|
||||||
|
}
|
||||||
|
if len(p.Collectors.Disabled) != 0 {
|
||||||
|
t.Errorf("full 模板 Disabled 应为空, got %d", len(p.Collectors.Disabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFromTemplate_Invalid(t *testing.T) {
|
||||||
|
_, err := NewFromTemplate("test", "invalid")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("无效模板类型应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 验证
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestValidate_ValidProfile(t *testing.T) {
|
||||||
|
p, _ := NewFromTemplate("valid-profile", TemplateStandard)
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
t.Errorf("合法 Profile 验证失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EmptyName(t *testing.T) {
|
||||||
|
p := &Profile{Name: "", Version: 1}
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Error("空名称应验证失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_InvalidNameChars(t *testing.T) {
|
||||||
|
p := &Profile{Name: "has space", Version: 1}
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Error("含空格的名称应验证失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_TooLongName(t *testing.T) {
|
||||||
|
longName := ""
|
||||||
|
for i := 0; i < 65; i++ {
|
||||||
|
longName += "a"
|
||||||
|
}
|
||||||
|
p := &Profile{Name: longName, Version: 1}
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Error("超过 64 字符的名称应验证失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_InvalidVersion(t *testing.T) {
|
||||||
|
p := &Profile{Name: "test", Version: 0}
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Error("版本 0 应验证失败")
|
||||||
|
}
|
||||||
|
p.Version = 999
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Error("超出当前版本应验证失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_EnabledDisabledConflict(t *testing.T) {
|
||||||
|
p := &Profile{
|
||||||
|
Name: "test",
|
||||||
|
Version: 1,
|
||||||
|
Collectors: CollectorConfig{
|
||||||
|
Enabled: []string{"go", "node"},
|
||||||
|
Disabled: []string{"node"}, // 冲突
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Error("enabled 和 disabled 有交集应验证失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// IsCollectorEnabled
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIsCollectorEnabled_AllEnabled(t *testing.T) {
|
||||||
|
p := &Profile{Collectors: CollectorConfig{}}
|
||||||
|
if !p.IsCollectorEnabled("go") {
|
||||||
|
t.Error("enabled 为空时应全部启用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCollectorEnabled_ExplicitEnabled(t *testing.T) {
|
||||||
|
p := &Profile{
|
||||||
|
Collectors: CollectorConfig{
|
||||||
|
Enabled: []string{"go", "node"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !p.IsCollectorEnabled("go") {
|
||||||
|
t.Error("go 应启用")
|
||||||
|
}
|
||||||
|
if p.IsCollectorEnabled("vscode") {
|
||||||
|
t.Error("vscode 不在 enabled 列表中,应禁用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCollectorEnabled_ExplicitDisabled(t *testing.T) {
|
||||||
|
p := &Profile{
|
||||||
|
Collectors: CollectorConfig{
|
||||||
|
Disabled: []string{"ssh"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p.IsCollectorEnabled("ssh") {
|
||||||
|
t.Error("ssh 在 disabled 列表中,应禁用")
|
||||||
|
}
|
||||||
|
if !p.IsCollectorEnabled("go") {
|
||||||
|
t.Error("go 不在 disabled 中,应启用")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// GetSetting / GetSettingBool / GetSettingStrings
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGetSetting(t *testing.T) {
|
||||||
|
p, _ := NewFromTemplate("test", TemplateStandard)
|
||||||
|
val, ok := p.GetSetting("go", "include_tools")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("go.include_tools 应存在")
|
||||||
|
}
|
||||||
|
if val != true {
|
||||||
|
t.Errorf("go.include_tools = %v, want true", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = p.GetSetting("go", "nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Error("不存在的 key 应返回 false")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok = p.GetSetting("nonexistent", "key")
|
||||||
|
if ok {
|
||||||
|
t.Error("不存在的采集器应返回 false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSettingBool(t *testing.T) {
|
||||||
|
p, _ := NewFromTemplate("test", TemplateStandard)
|
||||||
|
if !p.GetSettingBool("go", "include_tools", false) {
|
||||||
|
t.Error("go.include_tools 应为 true")
|
||||||
|
}
|
||||||
|
if p.GetSettingBool("go", "nonexistent", false) {
|
||||||
|
t.Error("不存在的 key 应返回默认值 false")
|
||||||
|
}
|
||||||
|
if !p.GetSettingBool("go", "nonexistent", true) {
|
||||||
|
t.Error("不存在的 key 应返回默认值 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSettingStrings(t *testing.T) {
|
||||||
|
p, _ := NewFromTemplate("test", TemplateStandard)
|
||||||
|
filters := p.GetSettingStrings("go", "tools_filter")
|
||||||
|
if len(filters) == 0 {
|
||||||
|
t.Fatal("go.tools_filter 应有内容")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, f := range filters {
|
||||||
|
if f == "gopls" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("tools_filter 应包含 gopls")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := p.GetSettingStrings("go", "nonexistent")
|
||||||
|
if result != nil {
|
||||||
|
t.Error("不存在的 key 应返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// CRUD 操作
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSaveAndLoad(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p, _ := NewFromTemplate("round-trip", TemplateStandard)
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "round-trip.yaml")
|
||||||
|
if err := Save(path, p); err != nil {
|
||||||
|
t.Fatalf("Save 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Name != p.Name {
|
||||||
|
t.Errorf("Name = %q, want %q", loaded.Name, p.Name)
|
||||||
|
}
|
||||||
|
if loaded.Description != p.Description {
|
||||||
|
t.Errorf("Description 不匹配")
|
||||||
|
}
|
||||||
|
if len(loaded.Collectors.Enabled) != len(p.Collectors.Enabled) {
|
||||||
|
t.Errorf("Enabled 数量不匹配: got %d, want %d", len(loaded.Collectors.Enabled), len(p.Collectors.Enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveByName_LoadByName(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p, _ := NewFromTemplate("my-profile", TemplateMinimal)
|
||||||
|
|
||||||
|
if err := SaveByName(dir, p); err != nil {
|
||||||
|
t.Fatalf("SaveByName 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadByName(dir, "my-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadByName 失败: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Name != "my-profile" {
|
||||||
|
t.Errorf("Name = %q, want %q", loaded.Name, "my-profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_FileNotExist(t *testing.T) {
|
||||||
|
_, err := Load("/nonexistent/path.yaml")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("加载不存在的文件应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSave_InvalidProfile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := &Profile{Name: "", Version: 1} // 名称为空,验证不通过
|
||||||
|
path := filepath.Join(dir, "invalid.yaml")
|
||||||
|
if err := Save(path, p); err == nil {
|
||||||
|
t.Error("保存验证不通过的 Profile 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p, _ := NewFromTemplate("to-delete", TemplateMinimal)
|
||||||
|
SaveByName(dir, p)
|
||||||
|
|
||||||
|
if err := Delete(dir, "to-delete"); err != nil {
|
||||||
|
t.Fatalf("Delete 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Exists(dir, "to-delete") {
|
||||||
|
t.Error("删除后文件应不存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_NotExist(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := Delete(dir, "nonexistent"); err == nil {
|
||||||
|
t.Error("删除不存在的 Profile 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if Exists(dir, "nope") {
|
||||||
|
t.Error("不存在的 Profile 应返回 false")
|
||||||
|
}
|
||||||
|
|
||||||
|
p, _ := NewFromTemplate("exists", TemplateMinimal)
|
||||||
|
SaveByName(dir, p)
|
||||||
|
if !Exists(dir, "exists") {
|
||||||
|
t.Error("已保存的 Profile 应返回 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// 空目录
|
||||||
|
metas, err := List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 空目录失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(metas) != 0 {
|
||||||
|
t.Errorf("空目录应返回 0 个 Profile, got %d", len(metas))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加两个 Profile
|
||||||
|
p1, _ := NewFromTemplate("alpha", TemplateMinimal)
|
||||||
|
p2, _ := NewFromTemplate("beta", TemplateStandard)
|
||||||
|
SaveByName(dir, p1)
|
||||||
|
SaveByName(dir, p2)
|
||||||
|
|
||||||
|
metas, err = List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(metas) != 2 {
|
||||||
|
t.Errorf("应有 2 个 Profile, got %d", len(metas))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList_NonExistentDir(t *testing.T) {
|
||||||
|
metas, err := List("/nonexistent/dir")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("不存在的目录应返回 nil 而非错误: %v", err)
|
||||||
|
}
|
||||||
|
if metas != nil {
|
||||||
|
t.Error("不存在的目录应返回 nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 原子写入验证
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSave_AtomicWrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p, _ := NewFromTemplate("atomic-test", TemplateMinimal)
|
||||||
|
path := filepath.Join(dir, "atomic-test.yaml")
|
||||||
|
|
||||||
|
if err := Save(path, p); err != nil {
|
||||||
|
t.Fatalf("Save 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时文件不应残留
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("临时文件应在保存后被清理")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实文件应存在
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Errorf("目标文件应存在: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// 模板辅助
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIsValidTemplateName(t *testing.T) {
|
||||||
|
if !IsValidTemplateName("minimal") {
|
||||||
|
t.Error("minimal 应是合法模板名")
|
||||||
|
}
|
||||||
|
if !IsValidTemplateName("standard") {
|
||||||
|
t.Error("standard 应是合法模板名")
|
||||||
|
}
|
||||||
|
if !IsValidTemplateName("full") {
|
||||||
|
t.Error("full 应是合法模板名")
|
||||||
|
}
|
||||||
|
if IsValidTemplateName("unknown") {
|
||||||
|
t.Error("unknown 不应是合法模板名")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateDescription(t *testing.T) {
|
||||||
|
for _, tmpl := range ValidTemplateNames() {
|
||||||
|
desc := TemplateDescription(tmpl)
|
||||||
|
if desc == "" || desc == "未知模板" {
|
||||||
|
t.Errorf("模板 %q 应有有效描述", tmpl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllTemplatesValidate(t *testing.T) {
|
||||||
|
for _, tmpl := range ValidTemplateNames() {
|
||||||
|
p, err := NewFromTemplate("test-"+string(tmpl), tmpl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建模板 %q 失败: %v", tmpl, err)
|
||||||
|
}
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
t.Errorf("模板 %q 创建的 Profile 验证失败: %v", tmpl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
internal/profile/templates.go
Normal file
206
internal/profile/templates.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// TemplateName 模板类型
|
||||||
|
type TemplateName string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TemplateMinimal TemplateName = "minimal" // 最小环境:仅运行时 + 包管理器
|
||||||
|
TemplateStandard TemplateName = "standard" // 标准环境:常用开发工具
|
||||||
|
TemplateFull TemplateName = "full" // 完整环境:含敏感数据和字体
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidTemplateNames 返回所有合法的模板名称
|
||||||
|
func ValidTemplateNames() []TemplateName {
|
||||||
|
return []TemplateName{TemplateMinimal, TemplateStandard, TemplateFull}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidTemplateName 检查模板名是否合法
|
||||||
|
func IsValidTemplateName(name string) bool {
|
||||||
|
switch TemplateName(name) {
|
||||||
|
case TemplateMinimal, TemplateStandard, TemplateFull:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromTemplate 根据模板类型创建 Profile
|
||||||
|
// name 为 Profile 名称,tmpl 为模板类型
|
||||||
|
func NewFromTemplate(name string, tmpl TemplateName) (*Profile, error) {
|
||||||
|
switch tmpl {
|
||||||
|
case TemplateMinimal:
|
||||||
|
return newMinimal(name), nil
|
||||||
|
case TemplateStandard:
|
||||||
|
return newStandard(name), nil
|
||||||
|
case TemplateFull:
|
||||||
|
return newFull(name), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知模板类型: %q,可选值: minimal, standard, full", tmpl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// minimal 模板 — 仅运行时 + 包管理器
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func newMinimal(name string) *Profile {
|
||||||
|
return &Profile{
|
||||||
|
Name: name,
|
||||||
|
Description: "最小环境配置 — 仅包含编程语言运行时和包管理器",
|
||||||
|
Version: CurrentVersion,
|
||||||
|
Collectors: CollectorConfig{
|
||||||
|
Enabled: []string{
|
||||||
|
"go",
|
||||||
|
"node",
|
||||||
|
"python",
|
||||||
|
"scoop",
|
||||||
|
},
|
||||||
|
Disabled: []string{
|
||||||
|
"ssh",
|
||||||
|
"font",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Settings: map[string]SettingMap{
|
||||||
|
"go": {
|
||||||
|
"include_tools": true,
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"include_global_packages": true,
|
||||||
|
"exclude_packages": []string{"npm"},
|
||||||
|
},
|
||||||
|
"scoop": {
|
||||||
|
"include_buckets": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// standard 模板 — 常用开发工具
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func newStandard(name string) *Profile {
|
||||||
|
return &Profile{
|
||||||
|
Name: name,
|
||||||
|
Description: "标准开发环境配置 — 包含常用运行时、编辑器、Shell 和 Git",
|
||||||
|
Version: CurrentVersion,
|
||||||
|
Collectors: CollectorConfig{
|
||||||
|
Enabled: []string{
|
||||||
|
"go",
|
||||||
|
"node",
|
||||||
|
"python",
|
||||||
|
"vscode",
|
||||||
|
"powershell",
|
||||||
|
"scoop",
|
||||||
|
"git",
|
||||||
|
},
|
||||||
|
Disabled: []string{
|
||||||
|
"ssh",
|
||||||
|
"font",
|
||||||
|
"env",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Settings: map[string]SettingMap{
|
||||||
|
"go": {
|
||||||
|
"include_tools": true,
|
||||||
|
"tools_filter": []string{"gopls", "dlv", "staticcheck", "goimports"},
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"include_global_packages": true,
|
||||||
|
"exclude_packages": []string{"npm"},
|
||||||
|
},
|
||||||
|
"vscode": {
|
||||||
|
"include_settings": true,
|
||||||
|
"include_keybindings": true,
|
||||||
|
"include_snippets": true,
|
||||||
|
"exclude_extensions": []string{"ms-vscode.remote-*"},
|
||||||
|
},
|
||||||
|
"powershell": {
|
||||||
|
"include_profile": true,
|
||||||
|
"include_modules": true,
|
||||||
|
},
|
||||||
|
"scoop": {
|
||||||
|
"include_buckets": true,
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"include_aliases": true,
|
||||||
|
"skip_credentials": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// full 模板 — 完整环境(含敏感数据和字体)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func newFull(name string) *Profile {
|
||||||
|
return &Profile{
|
||||||
|
Name: name,
|
||||||
|
Description: "完整环境配置 — 包含所有采集器,含环境变量、加密密钥和字体",
|
||||||
|
Version: CurrentVersion,
|
||||||
|
Collectors: CollectorConfig{
|
||||||
|
Enabled: []string{}, // 空列表 = 启用全部
|
||||||
|
Disabled: []string{}, // 无禁用
|
||||||
|
},
|
||||||
|
Settings: map[string]SettingMap{
|
||||||
|
"go": {
|
||||||
|
"include_tools": true,
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"include_global_packages": true,
|
||||||
|
"exclude_packages": []string{"npm"},
|
||||||
|
},
|
||||||
|
"vscode": {
|
||||||
|
"include_settings": true,
|
||||||
|
"include_keybindings": true,
|
||||||
|
"include_snippets": true,
|
||||||
|
},
|
||||||
|
"powershell": {
|
||||||
|
"include_profile": true,
|
||||||
|
"include_modules": true,
|
||||||
|
},
|
||||||
|
"scoop": {
|
||||||
|
"include_buckets": true,
|
||||||
|
"exclude_packages": []string{},
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"include_aliases": true,
|
||||||
|
"skip_credentials": true,
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"include_patterns": []string{
|
||||||
|
"GOPATH", "GOROOT", "GOPROXY",
|
||||||
|
"JAVA_HOME",
|
||||||
|
"NODE_*",
|
||||||
|
"PYTHON*",
|
||||||
|
"CARGO_HOME", "RUSTUP_HOME",
|
||||||
|
},
|
||||||
|
"exclude_patterns": []string{
|
||||||
|
"*SECRET*", "*TOKEN*", "*PASSWORD*", "*KEY*", "*CREDENTIAL*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ssh": {
|
||||||
|
"encrypt": true,
|
||||||
|
},
|
||||||
|
"font": {
|
||||||
|
"formats": []string{".ttf", ".otf"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateDescription 返回模板的简短描述
|
||||||
|
func TemplateDescription(tmpl TemplateName) string {
|
||||||
|
switch tmpl {
|
||||||
|
case TemplateMinimal:
|
||||||
|
return "最小环境 — 仅运行时和包管理器 (go, node, python, scoop)"
|
||||||
|
case TemplateStandard:
|
||||||
|
return "标准环境 — 常用开发工具 (+ vscode, powershell, git)"
|
||||||
|
case TemplateFull:
|
||||||
|
return "完整环境 — 所有采集器 (+ env, ssh, font)"
|
||||||
|
default:
|
||||||
|
return "未知模板"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user