Compare commits

..

9 Commits

Author SHA1 Message Date
8bc4f4fe04 Tui 模块开发完成,数据正常写入数据库 2026-03-06 16:33:08 +08:00
3f5e333a4d 添加数据到数据库,但无法插入,临时提交保存 2026-03-06 00:29:34 +08:00
f7dcfa4e7d Tui fix focus 2026-02-28 19:29:17 +08:00
13beeb67d1 fix tui quit display 2026-02-27 23:25:43 +08:00
d4e214fe23 Tui 重构代码逻辑 2026-02-27 22:52:15 +08:00
3a5f5ddd5d 优化数据库,日志,命令模块 2026-02-23 18:54:54 +08:00
47a2dfeda1 optimize tui interface and interaction 2026-02-22 20:18:12 +08:00
ce9af9f7d0 add tui command 2026-02-21 20:22:21 +08:00
fbe6aec707 add config.yaml 2026-02-20 20:24:02 +08:00
26 changed files with 3648 additions and 719 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
main
sunhpc
testgui

31
build-sunhpc.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
set -e # 出错立即退出,便于定位问题
# ========== 核心配置(根据你的项目修改) ==========
# 1. 必须和go.mod中的module名称一致关键
MODULE_NAME="sunhpc"
# 2. 编译的入口文件路径你的main.go在cmd/sunhpc下
ENTRY_FILE="./cmd/sunhpc/main.go"
# 3. 输出的可执行文件名称
APP_NAME="sunhpc"
# 4. 自定义版本号
APP_VERSION="v1.0.0"
# ========== 获取Git和编译信息兼容无Git环境 ==========
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
BUILD_TIME=$(date +"%Y-%m-%d-%H:%M:%S")
# ========== 编译核心修复ldflags格式 ==========
# 关键:去掉反斜杠,用单层双引号包裹,内部换行分隔参数
go build -ldflags "
-X ${MODULE_NAME}/pkg/info.Version=${APP_VERSION}
-X ${MODULE_NAME}/pkg/info.BuildTime=${BUILD_TIME}
-X ${MODULE_NAME}/pkg/info.GitCommit=${GIT_COMMIT}
-X ${MODULE_NAME}/pkg/info.GitBranch=${GIT_BRANCH}
" -o ${APP_NAME} ${ENTRY_FILE}
# ========== 验证提示 ==========
echo "- 执行文件:./${APP_NAME}"
echo "- 版本信息:${APP_VERSION} (Git: ${GIT_COMMIT} @ ${GIT_BRANCH})"
echo "- 编译时间:${BUILD_TIME}"

9
cmd/test/main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import (
"sunhpc/pkg/info"
)
func main() {
info.PrintAllInfo()
}

View File

@@ -18,7 +18,7 @@ import (
// - db/*/*.yaml : 匹配data/一级子目录下的所有yaml文件.
// - 如需递归匹配子目录(如data/db/sub/*.yaml),用 data/**/*.yaml(Go.18+)
//
//go:embed db/*.yaml firewall/*.yaml
//go:embed frontend/*.yaml firewall/*.yaml
var ConfigFS embed.FS
// GetConfigFile 获取指定目录下的的单个配置文件内容

View File

@@ -1,123 +0,0 @@
# 基础数据配置文件
version: 1.0
description: "SunHPC 基础数据配置"
# 节点基础数据
nodes:
- name: frontend
cpus: 4
memory: 8192
disk: 100
rack: null
rank: null
arch: x86_64
os: linux
runaction: os
installaction: os
status: active
description: "管理节点"
# 属性基础数据
attributes:
# 国家地区
- node_name: frontend # 通过节点名称关联
attr: country
value: CN
shadow: ""
- node_name: frontend
attr: state
value: Liaoning
shadow: ""
- node_name: frontend
attr: city
value: Shenyang
shadow: ""
# 网络配置
- node_name: frontend
attr: network_type
value: management
shadow: ""
- node_name: frontend
attr: ip_address
value: 192.168.1.100
shadow: ""
- node_name: frontend
attr: subnet_mask
value: 255.255.255.0
shadow: ""
# 硬件信息
- node_name: frontend
attr: manufacturer
value: Dell
shadow: ""
- node_name: frontend
attr: model
value: PowerEdge R740
shadow: ""
# 系统配置
- node_name: frontend
attr: timezone
value: Asia/Shanghai
shadow: ""
- node_name: frontend
attr: language
value: zh_CN.UTF-8
shadow: ""
- node_name: frontend
attr: kernel_version
value: "5.10.0"
shadow: ""
# 软件基础数据
software:
- name: openssl
version: "1.1.1k"
vendor: OpenSSL
install_method: source
is_installed: 0
description: "加密库"
- name: slurm
version: "23.02"
vendor: SchedMD
install_method: source
is_installed: 0
description: "作业调度系统"
- name: openmpi
version: "4.1.5"
vendor: OpenMPI
install_method: source
is_installed: 0
description: "MPI 并行计算库"
# 网络基础数据
networks:
- node_name: frontend
interface: eth0
ip_address: 192.168.1.100
netmask: 255.255.255.0
gateway: 192.168.1.1
type: management
mac: "00:11:22:33:44:55"
# 分区基础数据
partitions:
- node_name: frontend
device: /dev/sda1
mount_point: /boot
size: 1024
fs_type: ext4
- node_name: frontend
device: /dev/sda2
mount_point: /
size: 102400
fs_type: ext4
- node_name: frontend
device: /dev/sda3
mount_point: /home
size: 51200
fs_type: ext4

View File

@@ -0,0 +1,293 @@
# 数据中心/集群基础配置
metadata:
version: "1.0"
last_updated: "2024-01-01"
description: "数据中心基础设施配置"
# 集群配置
cluster:
name: "sunhpc-cluster"
type: "control"
osname: "Rocky Linux"
osversion: "9.7"
location:
country: "China"
city: "Beijing"
timezone:
name: "Asia/Shanghai"
offset: "+08:00"
ntp_servers:
- "ntp1.aliyun.com"
- "ntp2.tencent.com"
- "pool.ntp.org"
environment:
type: "production" # production/staging/development
region: "华北"
availability_zone: "AZ-01"
network:
domain: "sunhpc.local"
dns:
primary: "8.8.8.8"
secondary: "114.114.114.114"
wan:
- interface: "eth0"
address: "202.96.128.86"
netmask: "255.255.255.0"
gateway: "202.96.128.1"
mtu: 1500
type: "public"
description: "public network"
lan:
- interface: "eth1"
address: "192.168.1.100"
netmask: "255.255.255.0"
gateway: ""
mtu: 1500
type: "management"
description: "management network"
disks:
- device: "/dev/sda"
model: "PowerVault ME484"
type: "ssd"
size: "50TB"
vendor: "Dell"
serial: "1234567890"
status: "online"
partition:
- name: "sda1"
usage: "boot partition"
mount: "/boot"
size: "16GB"
fstype: "ext4"
filesystem: "ext4"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
- name: "sda2"
usage: "root partition"
mount: "/"
size: "100GB"
fstype: "ext4"
filesystem: "ext4"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
options: "defaults,noatime"
- name: "sda3"
usage: "home partition"
mount: "/home"
size: "50TB"
fstype: "xfs"
filesystem: "ext4"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
- name: "sda4"
usage: "var partition"
mount: "/var"
size: "150GB"
fstype: "xfs"
filesystem: "xfs"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
- device: "/dev/sdb"
model: "PowerVault ME484"
type: "ssd"
size: "50TB"
vendor: "Dell"
serial: "1234567890"
status: "online"
partition:
- name: "sdb1"
usage: "data partition"
mount: "/data"
size: "50TB"
fstype: "xfs"
filesystem: "xfs"
uuid: "12345678-90ab-cdef-1234-567890abcdef"
firewall:
global_policies:
- name: "默认策略"
input: "drop"
output: "accept"
forward: "drop"
zones:
- name: "public"
interfaces: ["eth0", "eth1"]
services_allowed: ["ssh", "http", "https"]
source_ranges: ["0.0.0.0/0"]
- name: "internal"
interfaces: ["eth2"]
services_allowed: ["ssh", "mysql", "redis", "mongodb", "nfs", "samba"]
source_ranges: ["192.168.0.0/16", "10.0.0.0/8"]
- name: "storage"
interfaces: ["eth3"]
services_allowed: ["iscsi", "nfs", "smb"]
source_ranges: ["172.16.0.0/12"]
rules:
- name: "允许Ping"
protocol: "icmp"
action: "accept"
source: "any"
destination: "any"
- name: "限制SSH访问"
protocol: "tcp"
port: 22
action: "accept"
source: "192.168.1.0/24"
destination: "any"
# 全局服务配置
services:
common_services:
- name: "sshd"
port: 22
protocol: "tcp"
enabled: true
description: "SSH远程登录服务"
- name: "ntpd"
port: 123
protocol: "udp"
enabled: true
description: "时间同步服务"
- name: "rsyslog"
port: 514
protocol: "udp"
enabled: true
description: "日志收集服务"
monitoring_services:
- name: "prometheus"
port: 9090
protocol: "tcp"
enabled: true
description: "监控数据采集"
- name: "grafana"
port: 3000
protocol: "tcp"
enabled: true
description: "监控数据可视化"
- name: "node_exporter"
port: 9100
protocol: "tcp"
enabled: true
description: "节点指标采集"
database_services:
- name: "mysql"
port: 3306
protocol: "tcp"
enabled: true
version: "8.0"
description: "关系型数据库"
- name: "redis"
port: 6379
protocol: "tcp"
enabled: true
version: "6.2"
description: "缓存数据库"
- name: "mongodb"
port: 27017
protocol: "tcp"
enabled: true
version: "5.0"
description: "文档数据库"
# 节点列表
nodes:
# 计算节点
compute_nodes:
- name: "compute-01"
hostname: "compute01.example.local"
role: "compute"
status: "active"
basic_info:
timezone: "Asia/Shanghai"
cpu: "Intel Xeon Gold 6248R 3.0GHz (48核)"
memory: "512GB DDR4"
os: "CentOS 7.9"
kernel: "3.10.0-1160"
virtualization: "KVM"
network:
interfaces:
- name: "eth0"
ip_address: "192.168.1.11"
mac_address: "00:0c:29:xx:xx:01"
network_type: "management"
speed: "1Gbps"
disk:
- device: "/dev/sda"
size: "480GB"
type: "SSD"
mount_point: "/"
filesystem: "xfs"
usage: "系统盘"
- device: "/dev/sdb"
size: "3.6TB"
type: "NVMe"
mount_point: "/data/local"
filesystem: "xfs"
usage: "本地数据盘"
- device: "/dev/sdc"
size: "10TB"
type: "HDD"
mount_point: "/data/shared"
filesystem: "xfs"
usage: "共享存储挂载"
services:
enabled:
- "sshd"
- "ntpd"
- "docker"
- "kubelet"
- "node_exporter"
disabled:
- "firewalld"
- "postfix"
firewall:
enabled: true
rules:
- port: 22
protocol: "tcp"
source: "192.168.1.0/24"
action: "accept"
- port: 10250
protocol: "tcp"
source: "10.10.0.0/16"
action: "accept"
hardware:
manufacturer: "Dell"
model: "PowerEdge R740xd"
serial_number: "ABC123XYZ"
warranty_expiry: "2025-12-31"
location:
rack: "RACK-01"
position: "01U"
power_consumption: "500W"
- name: "compute-02"
hostname: "compute02.example.local"
role: "compute"
status: "active"
# ... 类似配置IP地址递增

View File

@@ -18,7 +18,7 @@ import (
// - db/*/*.yaml : 匹配data/一级子目录下的所有yaml文件.
// - 如需递归匹配子目录(如data/db/sub/*.yaml),用 data/**/*.yaml(Go.18+)
//
//go:embed db/*.yaml services/*.yaml firewall/*.yaml
//go:embed services/*.yaml
var ConfigFS embed.FS
// GetConfigFile 获取指定目录下的的单个配置文件内容

43
go.mod
View File

@@ -3,32 +3,57 @@ module sunhpc
go 1.25.5
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gdamore/tcell/v2 v2.13.8
github.com/go-sql-driver/mysql v1.9.3
github.com/mattn/go-sqlite3 v1.14.34
github.com/rivo/tview v0.42.0
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
go.uber.org/zap v1.27.1
go.yaml.in/yaml/v3 v3.0.4
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
)

104
go.sum
View File

@@ -1,14 +1,44 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -21,22 +51,37 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@@ -56,18 +101,57 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,18 +2,14 @@ package initcmd
import (
"sunhpc/internal/middler/auth"
"sunhpc/pkg/logger"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
// NewConfigCmd 创建 "init config" 命令
func NewInitCfgCmd() *cobra.Command {
var (
force bool
path string
verbose bool
output string
)
cmd := &cobra.Command{
@@ -24,23 +20,19 @@ func NewInitCfgCmd() *cobra.Command {
示例:
sunhpc init config # 生成默认配置文件
sunhpc init config -f # 强制覆盖已有配置文件
sunhpc init config -p /etc/sunhpc/sunhpc.yaml # 指定路径
sunhpc init config -o /etc/sunhpc/sunhpc.yaml # 指定路径
`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := auth.RequireRoot(); err != nil {
return err
}
logger.Info("✅ 配置文件已生成", zap.String("path", path))
return nil
},
}
// 定义局部 flags
cmd.Flags().BoolVarP(&force, "force", "f", false, "强制覆盖已有配置文件")
cmd.Flags().StringVarP(&path, "path", "p", "", "指定配置文件路径")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "显示详细日志")
cmd.Flags().StringVarP(&output, "output", "o", "", "指定配置文件路径")
return cmd
}

View File

@@ -3,7 +3,6 @@ package initcmd
import (
"fmt"
"sunhpc/internal/middler/auth"
"sunhpc/pkg/config"
"sunhpc/pkg/database"
"sunhpc/pkg/logger"
@@ -28,26 +27,30 @@ func NewInitDBCmd() *cobra.Command {
logger.Debug("执行数据库初始化...")
cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("加载配置失败: %w", err)
}
// 初始化数据库
db, err := database.GetInstance(&cfg.Database, nil)
db, err := database.GetDB()
if err != nil {
return fmt.Errorf("数据库连接失败: %w", err)
}
defer db.Close()
if err := db.InitTables(force); err != nil {
return fmt.Errorf("数据库初始化失败: %w", err)
if err := database.InitTables(db, force); err != nil {
logger.Debug(err)
return err
}
// 测试数据库连接
if err := database.TestNodeInsert(db); err != nil {
return fmt.Errorf("数据库测试失败: %w", err)
}
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "强制重新初始化")
cmd.Flags().BoolVarP(
&force, "force", "f", false,
"强制重新初始化")
return cmd
}

View File

@@ -14,6 +14,7 @@ func NewInitCmd() *cobra.Command {
cmd.AddCommand(NewInitDBCmd())
cmd.AddCommand(NewInitCfgCmd())
cmd.AddCommand(NewInitTuiCmd())
return cmd
}

33
internal/cli/init/tui.go Normal file
View File

@@ -0,0 +1,33 @@
package initcmd
import (
"sunhpc/internal/middler/auth"
"sunhpc/pkg/wizard"
"github.com/spf13/cobra"
)
func NewInitTuiCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "tui",
Short: "初始化TUI",
Long: `初始化SunHPC TUI,创建所有表结构和默认数据。
示例:
sunhpc init tui # 初始化TUI
sunhpc init tui --force # 强制重新初始化`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := auth.RequireRoot(); err != nil {
return err
}
return wizard.Run(force)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "强制重新初始化")
return cmd
}

View File

@@ -1,6 +1,9 @@
package cli
import (
"fmt"
"os"
"path/filepath"
initcmd "sunhpc/internal/cli/init"
"sunhpc/pkg/config"
"sunhpc/pkg/logger"
@@ -20,21 +23,49 @@ func NewRootCmd() *cobra.Command {
Use: "sunhpc",
Short: "SunHPC - HPC集群一体化运维工具",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// 加载全局配置(只加载一次)
// 设置 CLI 参数
config.CLIParams.Verbose = verbose
config.CLIParams.NoColor = noColor
config.CLIParams.Config = cfgFile
// 初始化配置目录和默认文件
if err := config.InitConfigs(); err != nil {
fmt.Fprintf(os.Stderr, "初始化配置目录失败: %v\n", err)
os.Exit(1)
}
// 加载配置(后续调用直接返回缓存)
cfg, err := config.LoadConfig()
if err != nil {
// 配置加载失败,使用默认日志配置初始化
logger.Warnf("加载配置失败,使用默认日志配置: %v", err)
logger.Init(logger.LogConfig{})
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
os.Exit(1)
}
// 初始化日志
logger.Init(cfg.Log)
// 记录启动信息
logger.Debugf("SunHPC 启动中...")
logger.Debugf("配置文件: %s", viper.ConfigFileUsed())
logger.Debugf("日志级别: %s", cfg.Log.Level)
logger.Debugf("日志格式: %s", cfg.Log.Format)
logger.Debugf("日志输出: %s", cfg.Log.Output)
logger.Debugf("日志文件: %s", cfg.Log.LogFile)
logger.Debugf("显示颜色: %v", cfg.Log.ShowColor)
// 跳过数据库检查,仅在 init db 时检查
if isInitDbCommand(cmd) {
return
}
// 3. 初始化全局日志(全局只执行一次)
logger.Init(logger.LogConfig{
Verbose: cfg.Log.Verbose,
ShowColor: !cfg.Log.ShowColor,
LogFile: cfg.Log.LogFile,
})
// 检查数据库文件是否存在
dbPath := filepath.Join(cfg.Database.Path, cfg.Database.Name)
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
logger.Warnf("数据库文件不存在: %s", dbPath)
logger.Errorf("请先运行 sunhpc init db 初始化数据库")
os.Exit(1)
}
logger.Debugf("数据库文件存在: %s", dbPath)
},
Run: func(cmd *cobra.Command, args []string) {
@@ -42,23 +73,17 @@ func NewRootCmd() *cobra.Command {
},
}
cmd.PersistentFlags().StringVarP(
&config.CLIParams.Config,
"config", "c",
"", "配置文件路径 (默认:/etc/sunhpc/config.yaml)")
cmd.PersistentFlags().BoolVarP(
&config.CLIParams.Verbose,
"verbose", "v", false, "启用详细日志输出")
cmd.PersistentFlags().BoolVar(
&verbose, "verbose", false,
"启用详细日志输出")
cmd.PersistentFlags().BoolVar(
&config.CLIParams.NoColor,
"no-color", false, "禁用彩色输出")
&noColor, "no-color", false,
"禁用彩色输出")
// 如果指定了 --config 参数,优先使用该配置文件
if config.CLIParams.Config != "" {
viper.SetConfigFile(config.CLIParams.Config)
}
cmd.PersistentFlags().StringVar(
&cfgFile, "config", "",
"配置文件路径 (默认:/etc/sunhpc/config.yaml)")
cmd.AddCommand(initcmd.NewInitCmd())
return cmd
@@ -67,3 +92,14 @@ func NewRootCmd() *cobra.Command {
func Execute() error {
return NewRootCmd().Execute()
}
func isInitDbCommand(cmd *cobra.Command) bool {
// 检查当前命令是否是 db 子命令
if cmd.Name() == "db" {
// 检查父命令是否是 init
if parent := cmd.Parent(); parent != nil {
return parent.Name() == "init"
}
}
return false
}

View File

@@ -4,70 +4,159 @@ import (
"fmt"
"os"
"path/filepath"
"sunhpc/pkg/logger"
"sunhpc/pkg/utils"
"sync"
"github.com/spf13/viper"
"go.yaml.in/yaml/v3"
)
// ==============================================================
// 全局变量
// ==============================================================
var (
GlobalConfig *Config
configOnce sync.Once // 确保配置只加载一次
configMutex sync.RWMutex // 读写锁保护 GlobalConfig
)
// ==============================================================
// 目录常量 (可在 main.go 中通过 flag 覆盖默认值)
// ==============================================================
var (
BaseDir string = utils.DefaultBaseDir
TmplDir string = utils.DefaultTmplDir
LogDir string = utils.DefaultLogDir
)
// ==============================================================
// 配置结构体
// ==============================================================
type Config struct {
Database DatabaseConfig `yaml:"database"`
Log LogConfig `yaml:"log"`
Log logger.LogConfig `mapstructure:"log" yaml:"log"`
Database DatabaseConfig `mapstructure:"database" yaml:"database"`
}
type DatabaseConfig struct {
DSN string `yaml:"dsn"` // 数据库连接字符串
Path string `yaml:"path"` // SQLite: 目录路径
Name string `yaml:"name"` // SQLite: 文件名
DSN string `mapstructure:"dsn" yaml:"dsn"` // 数据库连接字符串
Path string `mapstructure:"path" yaml:"path"` // SQLite: 目录路径
Name string `mapstructure:"name" yaml:"name"` // SQLite: 文件名
Args string `mapstructure:"args" yaml:"args"` // 数据库连接参数
}
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
Verbose bool `yaml:"verbose"`
LogFile string `yaml:"log_file"`
ShowColor bool `yaml:"show_color"`
type CLIParamsType struct {
Verbose bool // -v/--verbose
NoColor bool // --no-color
Config string // -c/--config
}
// --------------------------------- 全局单例配置(核心) ---------------------------------
var (
// GlobalConfig 全局配置单例实例
GlobalConfig *Config
// 命令行参数配置(全局、由root命令绑定)
CLIParams = struct {
Verbose bool // -v/--verbose
NoColor bool // --no-color
Config string // -c/--config
}{}
BaseDir string = "/etc/sunhpc"
LogDir string = "/var/log/sunhpc"
TmplDir string = BaseDir + "/tmpl.d"
appName string = "sunhpc"
defaultDBPath string = "/var/lib/sunhpc"
defaultDBName string = "sunhpc.db"
)
var CLIParams CLIParamsType // 命令行参数
// InitConfigs 初始化所有配置目录等数据
func InitConfigs() error {
dirs := []string{
BaseDir,
TmplDir,
LogDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", d, err)
}
}
// 检查配置文件是否存在,不存在则创建默认配置
configPath := filepath.Join(BaseDir, "sunhpc.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
if err := createDefaultConfig(configPath); err != nil {
return fmt.Errorf("创建默认配置文件失败: %w", err)
}
}
// 读取配置文件,根据配置文件内容初始化相关目录.
cfg, err := LoadConfig()
if err != nil {
return fmt.Errorf("读取配置文件失败: %w", err)
}
// 初始化日志
logger.Init(cfg.Log)
// 确保数据库目录存在
if err := os.MkdirAll(cfg.Database.Path, 0755); err != nil {
logger.Debugf("创建数据库目录 %s 失败: %v", cfg.Database.Path, err)
return err
}
logger.Debugf("创建数据库目录 %s 成功", cfg.Database.Path)
return nil
}
func createDefaultConfig(configPath string) error {
fmt.Printf("设置默认配置文件: %s\n", configPath)
defaultConfig := &Config{
Database: DatabaseConfig{
Path: utils.DefaultDBPath,
Name: utils.DefaultDBName,
Args: utils.DefaultDBArgs,
},
Log: logger.LogConfig{
Level: "info",
Format: "text",
Output: "stdout",
Verbose: false,
LogFile: utils.DefaultLogFile,
ShowColor: true,
},
}
// 序列号并写入
data, err := yaml.Marshal(defaultConfig)
if err != nil {
return fmt.Errorf("序列化配置失败: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
// ----------------------------------- 配置加载(只加载一次) -----------------------------------
func LoadConfig() (*Config, error) {
// 如果已经加载过,直接返回
configMutex.RLock()
if GlobalConfig != nil {
// 如果已经加载过,直接返回
configMutex.RUnlock()
return GlobalConfig, nil
}
configMutex.RUnlock()
configMutex.Lock()
defer configMutex.Unlock()
// 双重检查
if GlobalConfig != nil {
// 如果已经加载过,直接返回
return GlobalConfig, nil
}
viper.SetConfigName("sunhpc")
viper.SetConfigType("yaml")
viper.AddConfigPath(BaseDir)
viper.AddConfigPath(".")
viper.AddConfigPath(filepath.Join(os.Getenv("HOME"), "."))
// 配置文件路径
if CLIParams.Config != "" {
viper.SetConfigFile(CLIParams.Config)
} else {
viper.SetConfigName("sunhpc")
viper.SetConfigType("yaml")
viper.AddConfigPath(BaseDir)
viper.AddConfigPath(".")
viper.AddConfigPath(filepath.Join(os.Getenv("HOME"), "."))
}
// Step 1: 设置默认值(最低优先级)
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", "text")
viper.SetDefault("log.output", "stdout")
viper.SetDefault("log.verbose", false)
viper.SetDefault("log.log_file", filepath.Join(LogDir, "sunhpc.log"))
viper.SetDefault("database.name", "sunhpc.db")
viper.SetDefault("database.path", "/var/lib/sunhpc")
viper.SetDefault("log.log_file", utils.DefaultLogFile)
viper.SetDefault("database.name", utils.DefaultDBName)
viper.SetDefault("database.path", utils.DefaultDBPath)
if err := viper.ReadInConfig(); err != nil {
// 配置文件不存在时,使用默认值
@@ -87,10 +176,12 @@ func LoadConfig() (*Config, error) {
viper.Set("log.show_color", false)
}
fullPath := filepath.Join(
viper.GetString("database.path"), viper.GetString("database.name"))
dsn := fmt.Sprintf(
"%s?_foreign_keys=on&_journal_mode=WAL&_timeout=5000", fullPath)
// 计算派生配置 (如数据库 DSN)
dbPath := viper.GetString("database.path")
dbName := viper.GetString("database.name")
dbArgs := viper.GetString("database.args")
fullPath := filepath.Join(dbPath, dbName)
dsn := fmt.Sprintf("%s?%s", fullPath, dbArgs)
viper.Set("database.dsn", dsn)
// 解码到结构体
@@ -104,57 +195,23 @@ func LoadConfig() (*Config, error) {
return GlobalConfig, nil
}
// InitDirs 创建所有必需目录
func InitDirs() error {
dirs := []string{
BaseDir,
TmplDir,
LogDir,
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", d, err)
}
}
return nil
}
// ==============================================================
// SaveConfig - 保存全局配置到文件、运行时配置
// ==============================================================
func SaveConfig() error {
configMutex.RLock()
defer configMutex.RUnlock()
func (c *Config) WriteDefaultConfig(path string) error {
// 确保目录存在
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
if GlobalConfig == nil {
return fmt.Errorf("全局配置为空")
}
// 生成默认配置
cfg := DefaultConfig(path)
// 序列化为 YAML
data, err := yaml.Marshal(cfg)
configPath := filepath.Join(BaseDir, "config.yaml")
data, err := yaml.Marshal(GlobalConfig)
if err != nil {
return fmt.Errorf("序列化配置失败: %w", err)
}
// 写入文件0644 权限)
return os.WriteFile(path, data, 0644)
}
func DefaultConfig(path string) *Config {
return &Config{
Database: DatabaseConfig{
DSN: fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL&_timeout=5000",
filepath.Join(filepath.Dir(path), defaultDBName)),
Path: filepath.Dir(path),
Name: defaultDBName,
},
Log: LogConfig{
Level: "info",
Format: "text",
Output: "stdout",
LogFile: filepath.Join(filepath.Dir(path), "sunhpc.log"),
Verbose: false,
},
return err
}
return os.WriteFile(configPath, data, 0644)
}
// ResetConfig 重置全局配置为默认值

View File

@@ -1,11 +1,8 @@
package database
import (
"bufio"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
@@ -16,65 +13,167 @@ import (
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
db *sql.DB
logger logger.Logger
}
// =========================================================
// 全局变量
// =========================================================
var (
dbInstance *DB
dbInstance *sql.DB
dbOnce sync.Once
dbMutex sync.RWMutex
dbErr error
)
func GetInstance(dbConfig *config.DatabaseConfig, log logger.Logger) (*DB, error) {
dbOnce.Do(func() {
// 兜底: 未注入则使用全局默认日志实例
if log == nil {
log = logger.DefaultLogger
}
log.Debugf("开始初始化数据库,路径: %s", dbConfig.Path)
// 封装数据库函数使用Go实现
// MapCategory - 根据类别名称查ID
// 查询方式: globalID, err := db.MapCategory(conn, "global")
func MapCategory(conn *sql.DB, catname string) (int, error) {
var id int
query := "select id from categories where name = ?"
fullSQL := ReplaceSQLQuery(query, catname)
// 确认数据库目录存在
if err := os.MkdirAll(dbConfig.Path, 0755); err != nil {
log.Errorf("创建数据库目录失败: %v", err)
dbErr = err
err := conn.QueryRow(query, catname).Scan(&id)
if err == sql.ErrNoRows {
logger.Debugf("未找到类别 %s, 返回ID=0", catname)
return 0, nil // 无匹配返回0
}
logger.Debugf("查询语句: %s , CatName=%s, ID=%d", fullSQL, catname, id)
return id, nil
}
// MapCategoryIndex - 根据类别名称 + 索引名称查ID
// 调用方式: linuxOSID, err := db.MapCategoryIndex(conn, "os", "linux")
func MapCategoryIndex(conn *sql.DB, catindexName, categoryIndex string) (int, error) {
var id int
query := `
select index_id from vmapCategoryIndex
where categoryName = ? and categoryIndex = ?
`
fullSQL := ReplaceSQLQuery(query, catindexName, categoryIndex)
err := conn.QueryRow(query, catindexName, categoryIndex).Scan(&id)
if err == sql.ErrNoRows {
logger.Debugf("未找到索引 %s, 返回ID=0", catindexName)
return 0, nil // 无匹配返回0
}
logger.Debugf("查询语句: %s , CatIndexName=%s, CategoryIndex=%s, ID=%d",
fullSQL, catindexName, categoryIndex, id)
return id, nil
}
// ResolveFirewalls - 解析指定主机的防火墙规则
// 返回解析后的防火墙规则(fwresolved表数据),临时表使用后自动清理
// 调用方式: rows, err := db.ResolveFirewalls(conn, "compute-0-1", "default")
func ResolveFirewalls(conn *sql.DB, hostname, chainname string) (*sql.Rows, error) {
// 步骤1 创建临时表 fresolved1
_, err := conn.Exec(`
DROP TABLE IF EXISTS fresolved1;
CREATE TEMPORARY TABLE fresolved1 AS
SELECT
? AS hostname,
? AS Resolver,
f.*,
r.precedence
FROM
resolvechain r
inner join hostselections hs on r.category = hs.category and r.name = ?
inner join firewalls f on hs.category = f.category and hs.selection = f.catindex
where hs.host = ?;
`, hostname, chainname, chainname, hostname)
if err != nil {
return nil, fmt.Errorf("Create temporary table fresolved1 failed: %w", err)
}
// 步骤2创建临时表 fresolved2
_, err = conn.Exec(`
DROP TABLE IF EXISTS fresolved2;
CREATE TEMPORARY TABLE fresolved2 AS
SELECT
*
FROM
fresolved1;
`)
if err != nil {
return nil, fmt.Errorf("Create temporary table fresolved2 failed: %w", err)
}
// 步骤3创建最终结果表 fwresolved
_, err = conn.Exec(`
DROP TABLE IF EXISTS fwresolved;
CREATE TEMPORARY TABLE fwresolved AS
SELECT
r1.*,
cat.name AS categoryName
FROM
fresolved1 r1
inner join (
select Rulename, MAX(precedence) as precedence
from fresolved2
group by Rulename
) AS r2 on r1.Rulename = r2.Rulename and r1.precedence = r2.precedence
inner join categories cat on r1.category = cat.id;
`)
if err != nil {
return nil, fmt.Errorf("Create temporary table fwresolved failed: %w", err)
}
// 步骤4查询结果并返回
rows, err := conn.Query("SELECT * FROM fwresolved")
if err != nil {
return nil, fmt.Errorf("Query fwresolved failed: %w", err)
}
return rows, nil
}
// =========================================================
// GetDB - 获取数据库连接(单例模式)
// =========================================================
func GetDB() (*sql.DB, error) {
logger.Debug("获取数据库连接...")
dbOnce.Do(func() {
if dbInstance != nil {
return
}
fullPath := filepath.Join(dbConfig.Path, dbConfig.Name)
log.Debugf("数据库路径: %s", fullPath)
// 确保配置已加载
cfg, err := config.LoadConfig()
if err != nil {
dbErr = fmt.Errorf("加载配置失败: %w", err)
return
}
// 构建DSN
dsn := fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL&_timeout=5000&cache=shared",
fullPath)
log.Debugf("DSN: %s", dsn)
logger.Debugf("DSN: %s", cfg.Database.DSN)
// 打开SQLite 连接
sqlDB, err := sql.Open("sqlite3", dsn)
sqlDB, err := sql.Open("sqlite3", cfg.Database.DSN)
if err != nil {
log.Errorf("数据库打开失败: %v", err)
dbErr = err
dbErr = fmt.Errorf("数据库打开失败: %w", err)
return
}
// 设置连接池参数
sqlDB.SetMaxOpenConns(1) // SQLite 只支持单连接
sqlDB.SetMaxIdleConns(1) // 保持一个空闲连接
sqlDB.SetMaxOpenConns(10) // 最大打开连接
sqlDB.SetMaxIdleConns(5) // 保持空闲连接
sqlDB.SetConnMaxLifetime(0) // 禁用连接生命周期超时
sqlDB.SetConnMaxIdleTime(0) // 禁用空闲连接超时
// 测试数据库连接
if err := sqlDB.Ping(); err != nil {
sqlDB.Close()
log.Errorf("数据库连接失败: %v", err)
dbErr = err
dbErr = fmt.Errorf("数据库连接失败: %w", err)
return
}
// 赋值 *DB 类型的单例(而非直接复制 *sql.DB)
log.Info("数据库连接成功")
dbInstance = &DB{sqlDB, log}
var version string
err = sqlDB.QueryRow("select sqlite_version()").Scan(&version)
if err != nil {
version = "unknown"
}
logger.Debugf("数据库版本: %s", version)
logger.Debug("数据库连接成功")
dbInstance = sqlDB
})
if dbErr != nil {
@@ -84,66 +183,51 @@ func GetInstance(dbConfig *config.DatabaseConfig, log logger.Logger) (*DB, error
return dbInstance, nil
}
// Close 关闭数据库连接
func (d *DB) Close() error {
if d.db != nil {
d.logger.Debug("关闭数据库连接")
err := d.db.Close()
if err != nil {
d.logger.Errorf("数据库连接关闭失败: %v", err)
return err
}
d.logger.Info("数据库连接关闭成功")
return nil
}
return nil
}
func InitTables(db *sql.DB, force bool) error {
func confirmAction(prompt string) bool {
reader := bufio.NewReader(os.Stdin)
logger.Warnf("%s [Y/Yes]: ", prompt)
response, err := reader.ReadString('\n')
// 临时关闭外键约束(解决外键依赖删除报错问题)
_, err := db.Exec("PRAGMA foreign_keys = OFF;")
if err != nil {
return false
logger.Errorf("关闭外键约束失败: %v", err)
return err
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}
func (d *DB) InitTables(force bool) error {
d.logger.Info("开始初始化数据库表...")
if force {
// 确认是否强制删除
if !confirmAction("确认强制删除所有表和触发器?") {
d.logger.Info("操作已取消")
os.Exit(0)
return nil
defer func() {
// 延迟恢复外键约束(确保在函数退出时恢复)
_, err := db.Exec("PRAGMA foreign_keys = ON;")
if err != nil {
logger.Errorf("恢复外键约束失败: %v", err)
}
// 强制删除所有表和触发器
d.logger.Debug("强制删除所有表和触发器...")
if err := dropTables(d.db); err != nil {
return fmt.Errorf("删除表失败: %w", err)
}
d.logger.Debug("删除所有表和触发器成功")
if err := dropTriggers(d.db); err != nil {
return fmt.Errorf("删除触发器失败: %w", err)
}
d.logger.Debug("删除所有触发器成功")
}
}()
// ✅ 调用 schema.go 中的函数
for _, ddl := range CreateTableStatements() {
d.logger.Debugf("执行: %s", ddl)
if _, err := d.db.Exec(ddl); err != nil {
for name, ddl := range BaseTables() {
// 删除表或者试图(如果存在)
logger.Debugf("执行删除 - %s", name)
// 先尝试作为表进行删除
query := fmt.Sprintf("DROP TABLE IF EXISTS %s;", name)
logger.Debugf("执行语句: %s", query)
_, err := db.Exec(query)
if err != nil {
// 如果作为表删除失败,尝试作为试图删除
logger.Debugf("删除表失败: %v", err)
query = fmt.Sprintf("DROP VIEW IF EXISTS %s;", name)
logger.Debugf("执行语句: %s", query)
_, err = db.Exec(query)
if err != nil {
return fmt.Errorf("删除失败: %w", err)
}
}
logger.Debugf("执行图表 - %s", name)
logger.Debugf("执行语句: %s", ddl)
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("数据表创建失败: %w", err)
}
}
d.logger.Info("数据库表创建成功")
logger.Info("数据库表创建成功")
/*
使用sqlite3命令 测试数据库是否存在表
✅ 查询所有表
@@ -152,25 +236,157 @@ func (d *DB) InitTables(force bool) error {
select * from sqlite_master where type='table'; # 查看表定义
PRAGMA integrity_check; # 检查数据库完整性
*/
// 添加基础数据
if err := InitBaseData(db); err != nil {
return fmt.Errorf("初始化基础数据失败: %w", err)
}
logger.Info("基础数据初始化成功")
return nil
}
func dropTables(db *sql.DB) error {
// ✅ 调用 schema.go 中的函数
for _, table := range DropTableOrder() {
if _, err := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", table)); err != nil {
func CloseDB() error {
dbMutex.Lock()
defer dbMutex.Unlock()
if dbInstance == nil {
if err := dbInstance.Close(); err != nil {
return err
}
dbInstance = nil
}
return nil
}
func dropTriggers(db *sql.DB) error {
// ✅ 调用 schema.go 中的函数
for _, trigger := range DropTriggerStatements() {
if _, err := db.Exec(fmt.Sprintf("DROP TRIGGER IF EXISTS `%s`", trigger)); err != nil {
// 使用事务回滚测试
func RunTestWithRollback(db *sql.DB, testFunc func(*sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
// 执行测试
if err := testFunc(tx); err != nil {
tx.Rollback()
return err
}
// 回滚事务,所有更改(包括 ID 递增)都会撤销
return tx.Rollback()
}
// 使用示例
func TestNodeInsert(db *sql.DB) error {
logger.Debug("测试数据插入...")
return RunTestWithRollback(db, func(tx *sql.Tx) error {
// 插入测试数据
logger.Debug("执行插入测试数据...")
_, err := tx.Exec(`
INSERT INTO nodes (name, cpus, rack, rank)
VALUES (?, ?, ?, ?)
`, "test-node", 64, 1, 1)
if err != nil {
return err
}
// 验证插入
var count int
logger.Debug("执行查询测试数据...")
err = tx.QueryRow(`
SELECT COUNT(*) FROM nodes WHERE name = ?
`, "test-node").Scan(&count)
if err != nil {
return err
}
logger.Infof("测试数据插入成功,共 %d 条", count)
// 不需要手动删除,回滚会自动撤销
return nil
})
}
// =========================================================
// 带事务执行 SQL 语句,自动提交/回滚
// =========================================================
// 执行单条SQL语句带事务管理
func ExecSingleWithTransaction(sqlStr string) error {
// 复用批量函数将单条SQL语句包装为数组执行
return ExecWithTransaction([]string{sqlStr})
}
// 批量执行 DDL 语句,带事务管理
func ExecWithTransaction(ddl []string) error {
conn, err := GetDB()
if err != nil {
logger.Errorf("获取数据库连接失败: %v", err)
return err
}
// 开始事务
tx, err := conn.Begin()
if err != nil {
logger.Errorf("开始事务失败: %v", err)
return err
}
var finished bool
// 延迟处理:如果函数异常,回滚事务
defer func() {
if r := recover(); r != nil {
if !finished {
// 捕获 panic 并回滚事务
tx.Rollback()
logger.Errorf("事务执行中发生 panic: %v", r)
}
panic(r)
}
}()
// 遍历执行 DDL 语句
for idx, sql := range ddl {
logger.Debugf("执行 DDL 语句 %d: %s", idx+1, sql)
_, err = tx.Exec(sql)
if err != nil {
// 执行失败时,回滚事务
rollbackErr := tx.Rollback()
finished = true // 标记事务已完成
if rollbackErr != nil {
logger.Errorf("执行失败: 回滚失败: %v (原错误: %v, SQL: %s)", rollbackErr, err, sql)
} else {
logger.Errorf("执行失败: 回滚事务: %v, SQL: %s", err, sql)
}
logger.Errorf("执行 %d 条, 失败: %w (SQL: %s)", idx+1, err, sql)
return fmt.Errorf("执行 %d 条, 失败: %w (SQL: %s)", idx+1, err, sql)
}
}
// 所有SQL语句执行成功提交事务
logger.Info("所有SQL语句执行成功,提交事务")
if err := tx.Commit(); err != nil {
logger.Errorf("提交事务失败: %w", err)
return err
}
finished = true // 标记事务已完成
logger.Debugf("成功执行 %d 条 SQL 语句, 事务已提交.", len(ddl))
return nil
}
func ReplaceSQLQuery(query string, args ...interface{}) string {
for _, arg := range args {
switch v := arg.(type) {
case string:
query = strings.Replace(query, "?", fmt.Sprintf("'%s'", v), 1)
case int, int64, float64:
query = strings.Replace(query, "?", fmt.Sprintf("%v", v), 1)
default:
query = strings.Replace(query, "?", fmt.Sprintf("%v", v), 1)
}
}
return strings.TrimSpace(strings.ReplaceAll(query, "\n", " "))
}

View File

@@ -1,294 +1,542 @@
// Package db defines the database schema.
package database
// CurrentSchemaVersion returns the current schema version (for migrations)
func CurrentSchemaVersion() int {
return 1
}
import (
"database/sql"
"fmt"
"sunhpc/pkg/logger"
)
// CreateTableStatements returns a list of CREATE TABLE statements.
func CreateTableStatements() []string {
return []string{
createAliasesTable(),
createAttributesTable(),
createBootactionTable(),
createDistributionsTable(),
createFirewallsTable(),
createNetworksTable(),
createPartitionsTable(),
createPublicKeysTable(),
createSoftwareTable(),
createNodesTable(),
createSubnetsTable(),
createTrg_nodes_before_delete(),
func BaseTables() map[string]string {
return map[string]string{
"appliances": `
CREATE TABLE IF NOT EXISTS appliances (
ID integer primary key autoincrement,
Name varchar(32) not null default '',
Graph varchar(64) not null default 'default',
Node varchar(64) not null default '',
OS varchar(64) not null default 'linux'
);
`,
"memberships": `
CREATE TABLE IF NOT EXISTS memberships (
ID integer primary key autoincrement,
Name varchar(64) not null default '',
Appliance integer(11) default '0',
Distribution integer(11) default '1',
Public varchar(64) not null default 'no'
);
`,
"categories": `
CREATE TABLE IF NOT EXISTS categories (
ID integer primary key autoincrement,
Name varchar(64) not null unique default '0',
Description varchar(255) default null,
UNIQUE(Name)
);
`,
"catindex": `
CREATE TABLE IF NOT EXISTS catindex (
ID integer primary key autoincrement,
Name varchar(64) not null unique default '0',
Category integer not null,
Foreign key(Category) references categories(ID) on delete cascade
);
`,
"resolvechain": `
CREATE TABLE IF NOT EXISTS resolvechain (
ID integer primary key autoincrement,
Name varchar(64) not null default '0',
Category integer(11) not null,
Precedence integer(11) not null default '10',
UNIQUE(Name, Category)
Foreign key(Category) references categories(ID) on delete cascade
);
`,
"nodes": `
CREATE TABLE IF NOT EXISTS nodes (
ID integer primary key autoincrement,
Name varchar default null,
Membership integer default '2',
CPUs integer not null default '1',
Rack varchar default null,
Rank integer default null,
Arch varchar default null,
OS varchar default null,
RunAction varchar(64) default 'os',
InstallAction varchar(64) default 'install'
);
create index if not exists idx_nodes_name on nodes(Name);
`,
"aliases": `
CREATE TABLE IF NOT EXISTS aliases (
ID integer primary key autoincrement,
Node integer not null default '0',
Name varchar default null,
Foreign key(Node) references nodes(ID) on delete cascade
);
create index if not exists idx_aliases_name on aliases(Name);
`,
"networks": `
CREATE TABLE IF NOT EXISTS networks (
ID integer primary key autoincrement,
Node integer not null default '0',
MAC varchar default null,
IP varchar default null,
Name varchar default null,
Device varchar default null,
Subnet integer default null,
Module varchar default null,
VlanID integer default null,
Options varchar default null,
Channel varchar default null,
Foreign key(Node) references nodes(ID) on delete cascade,
Foreign key(Subnet) references subnets(ID) on delete cascade
);
create index if not exists idx_networks_name on networks(Name);
`,
"globalroutes": `
CREATE TABLE IF NOT EXISTS globalroutes (
Network varchar(32) not null default '',
Netmask varchar(32) not null default '',
Gateway varchar(32) not null default '',
Subnet integer default null,
Primary key(Network, Netmask)
Foreign key(Subnet) references subnets(ID) on delete cascade
);
`,
"osroutes": `
CREATE TABLE IF NOT EXISTS osroutes (
OS varchar(64) not null default 'linux',
Network varchar(32) not null default '',
Netmask varchar(32) not null default '',
Gateway varchar(32) not null default '',
Subnet integer default null,
Primary key(OS, Network, Netmask)
Foreign key(Subnet) references subnets(ID) on delete cascade
);
`,
"applianceroutes": `
CREATE TABLE IF NOT EXISTS applianceroutes (
Appliance varchar(11) not null default '0',
Network varchar(32) not null default '',
Netmask varchar(32) not null default '',
Gateway varchar(32) not null default '',
Subnet integer default null,
Primary key(Appliance, Network, Netmask)
Foreign key(Subnet) references subnets(ID) on delete cascade
);
`,
"noderoutes": `
CREATE TABLE IF NOT EXISTS noderoutes (
Node varchar(11) not null default '0',
Network varchar(32) not null default '',
Netmask varchar(32) not null default '',
Gateway varchar(32) not null default '',
Subnet integer default null,
Primary key(Node, Network, Netmask)
Foreign key(Subnet) references subnets(ID) on delete cascade
);
`,
"subnets": `
CREATE TABLE IF NOT EXISTS subnets (
ID integer primary key autoincrement,
name varchar(32) unique not null,
dnszone varchar(64) unique not null,
subnet varchar(32) default null,
netmask varchar(32) default null,
mtu integer(11) default '1500',
servedns boolean default false
);
`,
"publickeys": `
CREATE TABLE IF NOT EXISTS publickeys (
ID integer primary key autoincrement,
Node integer(11) not null default '0',
Public_Key varchar(8192) default null,
Description varchar(8192) default null,
Foreign key(Node) references nodes(ID) on delete cascade
);
`,
"secglobal": `
CREATE TABLE IF NOT EXISTS secglobal (
Attr varchar(128) default null,
Value text,
Enc varchar(128) default null,
Primary key(Attr)
);
`,
"secnodes": `
CREATE TABLE IF NOT EXISTS secnodes (
Attr varchar(128) default null,
Enc varchar(128) default null,
Value text,
Node integer(15) not null default '0',
Primary key(Attr, Node)
);
`,
"attributes": `
CREATE TABLE IF NOT EXISTS attributes (
ID integer primary key autoincrement,
Attr varchar(128) not null,
Value text,
Shadow text,
Category integer(11) not null,
Catindex integer(11) not null,
UNIQUE(Attr, Category, Catindex),
Foreign key(Catindex) references catindex(ID) on delete cascade
);
`,
"partitions": `
CREATE TABLE IF NOT EXISTS partitions (
ID integer primary key autoincrement,
Node integer(15) not null default '0',
Device varchar(128) not null default '',
MountPoint varchar(128) not null default '',
SectorStart varchar(128) not null default '',
PartitionSize varchar(128) not null default '',
FsType varchar(128) not null default '',
PartitionFlags varchar(128) not null default '',
FormatFlags varchar(128) not null default ''
);
`,
"firewalls": `
CREATE TABLE IF NOT EXISTS firewalls (
ID integer primary key autoincrement,
Rulename varchar(128) not null,
Rulesrc varchar(256) not null default 'custom',
InSubnet int(11),
OutSubnet int(11),
Service varchar(256),
Protocol varchar(256),
Action varchar(256),
Chain varchar(256),
Flags varchar(256),
Comment varchar(256),
Category integer(11) not null,
Catindex integer(11) not null,
Check(rulesrc IN ('system', 'custom'))
UNIQUE(Rulename, Category, Catindex),
Foreign key(Catindex) references catindex(ID) on delete cascade
);
`,
"rolls": `
CREATE TABLE IF NOT EXISTS rolls (
ID integer primary key autoincrement,
Name varchar(128) not null default '',
Version varchar(32) not null default '',
Arch varchar(32) not null default '',
OS varchar(64) not null default 'linux',
Enabled varchar(3) not null default 'yes',
Check(Enabled IN ('yes', 'no'))
Check(OS IN ('linux', 'other'))
);
`,
"noderolls": `
CREATE TABLE IF NOT EXISTS noderolls (
Node varchar(11) not null default '0',
RollID varchar(11) not null,
Primary key(Node, RollID)
);
`,
"bootactions": `
CREATE TABLE IF NOT EXISTS bootactions (
ID integer primary key autoincrement,
Action varchar(256) default null,
Kernel varchar(256) default null,
Ramdisk varchar(256) default null,
Args varchar(1024) default null
);
`,
"bootflags": `
CREATE TABLE IF NOT EXISTS bootflags (
ID integer primary key autoincrement,
Node integer(11) not null default '0',
Flags varchar(256) default null
);
`,
"distributions": `
CREATE TABLE IF NOT EXISTS distributions (
ID integer primary key autoincrement,
Name varchar(32) not null default '',
OS varchar(32) default '',
Release varchar(32) default ''
);
`,
"vnet": `
DROP VIEW IF EXISTS vnet;
CREATE VIEW vnet AS
SELECT
n.name AS nodename, /* 查询nodes表中name字段,将字段改名为nodename */
m.name AS membership,
a.name AS appliance,
n.rack, n.rank, /* 查询nodes表中rack和rank字段,使用原始字段名 */
s.name AS subnet,
nt.ip, nt.device, nt.module,
nt.name AS hostname,
s.dnszone AS domainname,
s.netmask, s.mtu
FROM
nodes n /* 主表: 先查询nodes表,别名n */
inner join memberships m on n.membership=m.id /* 连接memberships表,on只保留满足条件的行 */
inner join appliances a on m.appliance=a.id /* 连接appliances表,on只保留满足条件的行 */
inner join networks nt on n.id=nt.node /* 连接networks表,on只保留满足条件的行 */
inner join subnets s on nt.subnet=s.id /* 连接subnets表,on只保留满足条件的行 */
;
`,
"hostselections": `
DROP VIEW IF EXISTS hostselections;
CREATE VIEW hostselections AS
SELECT
n.name AS host,
c.id as category,
ci.id as selection
FROM
nodes n
inner join memberships m on n.membership=m.id -- 节点表关联所属分组
inner join appliances a on m.appliance=a.id -- 分组关联所属应用角色
inner join categories c on
-- 匹配4类分层配置的category(全局/OS/应用/主机)
c.name in ('global', 'os', 'appliance', 'host')
inner join catindex ci on
-- 核心匹配逻辑: category和catindex的name字段一一对应
(c.name = 'global' and ci.name = 'global') or
(c.name = 'os' and ci.name = n.os) or
(c.name = 'appliance' and ci.name = a.name) or
(c.name = 'host' and ci.name = n.name)
;
`,
"vcatindex": `
-- 视图vcatindex: 类别索引可读试图
DROP VIEW IF EXISTS vcatindex;
CREATE VIEW vcatindex AS
SELECT
c.id AS ID,
cat.Name AS Category,
ci.Name AS catindex
FROM
categories cat
inner join catindex ci on ci.category=cat.id
;
`,
"vresolvechain": `
-- 视图vresolvechain: 解析链可读试图
DROP VIEW IF EXISTS vresolvechain;
CREATE VIEW vresolvechain AS
SELECT
r.name AS chain,
cat.name AS category,
precedence
FROM
resolvechain r
inner join categories cat on r.category=cat.id
order by chain, precedence
;
`,
"vattributes": `
-- 视图vattributes: 属性可读试图
DROP VIEW IF EXISTS vattributes;
CREATE VIEW vattributes AS
SELECT
a.id,
attr,
value,
shadow,
cat.name AS category,
ci.name AS catindex
FROM
attributes a
inner join catindex ci on a.catindex=ci.id
inner join categories cat on a.category=cat.id
order by attr, catindex, category
;
`,
"vfirewalls": `
-- 视图vfirewalls: 防火墙规则可读试图
DROP VIEW IF EXISTS vfirewalls;
CREATE VIEW vfirewalls AS
SELECT
f.id,
f.Rulename,
f.Rulesrc,
f.InSubnet,
f.OutSubnet,
f.Service,
f.Protocol,
f.Action,
f.Chain,
f.Flags,
f.Comment,
cat.name AS category,
ci.name AS catindex
FROM
firewalls f
inner join catindex ci on f.catindex=ci.id
inner join categories cat on f.category=cat.id
order by f.Rulename, catindex, category
;
`,
"vhostselections": `
-- 视图vhostselections: 主机选择可读试图
DROP VIEW IF EXISTS vhostselections;
CREATE VIEW vhostselections AS
SELECT
hs.host AS host,
cat.name AS category,
ci.name AS selection
FROM
hostselections hs
inner join categories cat on hs.category=cat.id
inner join catindex ci on hs.selection=ci.id
order by host, category, selection
;
`,
"vmapcategoryindex": `
-- 视图vmapcategoryindex: 类别索引映射可读试图
DROP VIEW IF EXISTS vmapcategoryindex;
CREATE VIEW vmapCategoryIndex AS
SELECT
cat.name AS categoryName,
ci.name AS categoryIndex,
ci.ID AS index_ID
FROM
cateindex ci
inner join categories cat on ci.category=cat.id
;
`,
}
}
// DropTableOrder returns table names in reverse dependency order for safe DROP.
func DropTableOrder() []string {
return []string{
"aliases",
"attributes",
"bootactions",
"distributions",
"firewalls",
"networks",
"partitions",
"publickeys",
"software",
"nodes",
"subnets",
func InitBaseData(conn *sql.DB) error {
logger.Debug("初始化基础数据...")
// ========== 第一步:插入 categories 数据 ==========
categoryData := []struct {
Name string
Description string
}{
{"global", "Global Defaults"},
{"os", "OS Choice(Linux,Sunos)"},
{"appliance", "Logical Appliances"},
{"rack", "Machine Room Racks"},
{"host", "Hosts - Physical AND Virtual"},
}
}
func DropTriggerStatements() []string {
return []string{
"trg_nodes_before_delete",
// 批量插入 categories (忽略重复)
logger.Debug("插入 categories 数据...")
for _, cd := range categoryData {
query := `
insert or ignore into categories (Name, Description)
values (?, ?)
`
fullSQL := ReplaceSQLQuery(query, cd.Name, cd.Description)
logger.Debugf("执行语句: %s", fullSQL)
// 执行 SQL 语句仍用占位符避免SQL注入
result, err := conn.Exec(query, cd.Name, cd.Description)
if err != nil {
return fmt.Errorf("error inserting category %s: %w", cd.Name, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("error getting last insert ID: %w", err)
}
logger.Debugf("执行语句: %s, 插入ID: %d", fullSQL, id)
}
}
// --- Private DDL Functions ---
func createAliasesTable() string {
return `
CREATE TABLE IF NOT EXISTS aliases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
alias TEXT NOT NULL,
CONSTRAINT fk_aliases_node FOREIGN KEY(node_id) REFERENCES nodes(id),
UNIQUE(node_id, alias)
);
create index if not exists idx_aliases_node on aliases (node_id);
`
}
func createAttributesTable() string {
return `
CREATE TABLE IF NOT EXISTS attributes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
attr TEXT NOT NULL,
value TEXT,
shadow TEXT,
CONSTRAINT fk_attributes_node FOREIGN KEY(node_id) REFERENCES nodes(id)
);
create index if not exists idx_attributes_node on attributes (node_id);
`
}
func createBootactionTable() string {
return `
CREATE TABLE IF NOT EXISTS bootactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
action TEXT,
kernel TEXT,
initrd TEXT,
cmdline TEXT,
CONSTRAINT fk_bootactions_node FOREIGN KEY(node_id) REFERENCES nodes(id),
UNIQUE(node_id)
);
create index if not exists idx_bootactions_node on bootactions (node_id);
`
}
func createDistributionsTable() string {
return `
CREATE TABLE IF NOT EXISTS distributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
name TEXT NOT NULL,
version TEXT,
lang TEXT,
os_release TEXT,
constraint distributions_nodes_fk FOREIGN KEY(node_id) REFERENCES nodes(id)
);
create index if not exists idx_distributions_node on distributions (node_id);
`
}
func createFirewallsTable() string {
return `
CREATE TABLE IF NOT EXISTS firewalls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
rulename TEXT NOT NULL,
rulesrc TEXT NOT NULL,
insubnet INTEGER,
outsubnet INTEGER,
service TEXT,
protocol TEXT,
action TEXT,
chain TEXT,
flags TEXT,
comment TEXT,
constraint firewalls_nodes_fk FOREIGN KEY(node_id) REFERENCES nodes(id)
);
create index if not exists idx_firewalls_node on firewalls (node_id);
`
}
func createNetworksTable() string {
return `
CREATE TABLE IF NOT EXISTS networks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
subnet_id INTEGER,
mac TEXT,
ip TEXT,
name TEXT,
device TEXT,
module TEXT,
vlanid INTEGER,
options TEXT,
channel TEXT,
disable_kvm INTEGER NOT NULL DEFAULT 0 CHECK (disable_kvm IN (0, 1)),
constraint networks_nodes_fk FOREIGN KEY(node_id) REFERENCES nodes(id),
constraint networks_subnets_fk FOREIGN KEY(subnet_id) REFERENCES subnets(id)
);
create index if not exists idx_networks_node on networks (node_id);
`
}
func createNodesTable() string {
return `
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cpus INTEGER NOT NULL,
rack INTEGER NOT NULL,
rank INTEGER NOT NULL,
arch TEXT,
os TEXT,
runaction TEXT,
installaction TEXT
);
create index if not exists idx_nodes_name on nodes (name);
`
}
func createPartitionsTable() string {
return `
CREATE TABLE IF NOT EXISTS partitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
device TEXT NOT NULL,
formatflags TEXT NOT NULL,
fstype TEXT NOT NULL,
mountpoint TEXT NOT NULL,
partitionflags TEXT NOT NULL,
partitionid TEXT NOT NULL,
partitionsize TEXT NOT NULL,
sectorstart TEXT NOT NULL,
constraint partitions_nodes_fk FOREIGN KEY(node_id) REFERENCES nodes(id)
);
create index if not exists idx_partitions_node on partitions (node_id);
`
}
func createPublicKeysTable() string {
return `
CREATE TABLE IF NOT EXISTS publickeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER,
publickey TEXT NOT NULL,
description TEXT,
constraint publickeys_nodes_fk FOREIGN KEY(node_id) REFERENCES nodes(id)
);
create index if not exists idx_publickeys_node on publickeys (node_id);
`
}
func createSubnetsTable() string {
return `
CREATE TABLE IF NOT EXISTS subnets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
dnszone TEXT NOT NULL,
subnet TEXT NOT NULL,
netmask TEXT NOT NULL,
mtu INTEGER NOT NULL DEFAULT 1500,
servedns INTEGER NOT NULL DEFAULT 0 CHECK (servedns IN (0, 1)),
UNIQUE(name, dnszone)
);`
}
func createSoftwareTable() string {
return `
CREATE TABLE IF NOT EXISTS software (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pathversion TEXT,
fullversion TEXT,
description TEXT,
website TEXT,
license TEXT,
install_method TEXT NOT NULL CHECK (install_method IN (
'source',
'binary',
'rpm',
'docker',
'apptainer',
'conda',
'mamba',
'spack',
'tarball',
'zipball',
'pip',
'npm',
'custom'
)),
-- 源码编译相关参数
source_url TEXT, -- 源码下载地址
source_checksum TEXT, -- 源码校验和
source_checksum_type TEXT NOT NULL CHECK (source_checksum_type IN (
'md5',
'sha1',
'sha256',
'sha512'
)),
build_dependencies TEXT, -- 编译依赖(JSON格式)
configure_params TEXT, -- 配置参数(JSON格式)
make_params TEXT, -- make参数(JSON格式)
make_install_params TEXT, -- make install参数(JSON格式)
-- 安装路径参数
install_path TEXT NOT NULL, -- 安装路径
env_vars TEXT, -- 环境变量(JSON格式)
-- 状态信息
is_installed INTEGER NOT NULL DEFAULT 0 CHECK (is_installed IN (0, 1)), -- 是否安装
install_date TEXT, -- 安装日期
updated_date TEXT, -- 更新日期
install_user TEXT, -- 安装用户
notes TEXT, -- 安装备注
-- 元数据
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 创建时间
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 更新时间
UNIQUE(name)
);
create index if not exists idx_software_name on software (name);
create index if not exists idx_software_install_method on software (install_method);
create index if not exists idx_software_is_installed on software (is_installed);
`
}
func createTrg_nodes_before_delete() string {
return `
CREATE TRIGGER IF NOT EXISTS trg_nodes_before_delete
BEFORE DELETE ON nodes
FOR EACH ROW
BEGIN
-- 先删除子表的关联记录
DELETE FROM aliases WHERE node_id = OLD.id;
DELETE FROM attributes WHERE node_id = OLD.id;
DELETE FROM bootactions WHERE node_id = OLD.id;
DELETE FROM distributions WHERE node_id = OLD.id;
DELETE FROM firewalls WHERE node_id = OLD.id;
DELETE FROM networks WHERE node_id = OLD.id;
DELETE FROM partitions WHERE node_id = OLD.id;
DELETE FROM publickeys WHERE node_id = OLD.id;
END;
`
// ========== 第二步:插入 catindex 数据 ==========
catindexData := []struct {
Name string
Category string
}{
{"global", "global"},
{"linux", "os"},
{"sunos", "os"},
{"frontend", "appliance"},
{"compute", "appliance"},
{"nas", "appliance"},
{"network", "appliance"},
{"power", "appliance"},
{"devel-server", "appliance"},
{"login", "appliance"},
}
// 批量插入 catindex (忽略重复)
logger.Debug("插入 Catindex 数据...")
for _, ci := range catindexData {
// 动态获取类别ID (复用MapCategory函数)
catID, err := MapCategory(conn, ci.Category)
if err != nil {
return fmt.Errorf("error mapping category %s: %w", ci.Category, err)
}
if catID == 0 {
return fmt.Errorf("category %s not found", ci.Category)
}
// 插入 catindex (忽略重复)
query := `
insert or ignore into catindex (Name, Category)
values (?, ?)
`
fullSQL := ReplaceSQLQuery(query, ci.Name, catID)
// 执行 SQL 语句仍用占位符避免SQL注入
result, err := conn.Exec(query, ci.Name, catID)
if err != nil {
return fmt.Errorf("error inserting catindex %s: %w", ci.Name, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("error getting last insert ID: %w", err)
}
logger.Debugf("执行语句: %s, 插入ID: %d", fullSQL, id)
}
// ========== 第三步:插入 resolvechain 数据 ==========
resolveChainData := []struct {
Name string // 解析链名称,global/linux/sunos
Category string // 类别名称,linux/sunos
Precedence int // 优先级,数值越大优先级越高
}{
{"default", "global", 10},
{"default", "os", 20},
{"default", "appliance", 30},
{"default", "rack", 40},
{"default", "host", 50},
}
// 批量插入 resolvechain (忽略重复)
logger.Debugf("插入 resolvechain 数据...")
for _, rcd := range resolveChainData {
// 动态获取类别ID (复用MapCategory函数)
catID, err := MapCategory(conn, rcd.Category)
if err != nil {
return fmt.Errorf("error mapping category %s: %w", rcd.Category, err)
}
if catID == 0 {
return fmt.Errorf("category %s not found", rcd.Category)
}
// 插入 resolvechain (忽略重复)
query := `
insert or ignore into resolvechain (Name, Category, Precedence)
values (?, ?, ?)
`
fullSQL := ReplaceSQLQuery(query, rcd.Name, catID, rcd.Precedence)
// 执行 SQL 语句仍用占位符避免SQL注入
result, err := conn.Exec(query, rcd.Name, catID, rcd.Precedence)
if err != nil {
return fmt.Errorf("error inserting resolvechain %s: %w", rcd.Name, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("error getting last insert ID: %w", err)
}
logger.Debugf("执行语句: %s, 插入ID: %d", fullSQL, id)
}
return nil
}

162
pkg/info/info.go Normal file
View File

@@ -0,0 +1,162 @@
package info
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
)
// -------------------------- 编译注入的静态信息 --------------------------
var (
Version = "dev" // 应用版本号
BuildTime = "unknown" // 编译时间
GitCommit = "unknown" // Git提交ID
GitBranch = "unknown" // Git分支
)
// -------------------------- 固定常量 --------------------------
const (
AppName = "sunhpc"
linuxProcVersion = "/proc/version"
linuxProcCpuinfo = "/proc/cpuinfo"
)
// -------------------------- 系统信息结构体 --------------------------
// SystemInfo 封装所有系统相关信息Linux专属
type SystemInfo struct {
OS string // 操作系统
Arch string // 系统架构
KernelVersion string // 内核版本
CPUModel string // CPU型号
NumCPU int // CPU核心数
MemTotal string // 总内存
Hostname string // 主机名
GoVersion string // Go运行时版本
}
// -------------------------- 通用工具函数(无错误返回) --------------------------
// readFileFirstLine 读取文件第一行,失败返回空字符串
func readFileFirstLine(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
if scanner.Scan() {
return strings.TrimSpace(scanner.Text())
}
return ""
}
// -------------------------- 核心函数(无错误返回) --------------------------
// readCPUModel 读取CPU型号失败返回 "unknown"
func readCPUModel() string {
file, err := os.Open(linuxProcCpuinfo)
if err != nil {
return "unknown"
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "model name") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return "unknown"
}
// readMemTotal 读取总内存,失败返回 "unknown"
func readMemTotal() string {
file, err := os.Open("/proc/meminfo")
if err != nil {
return "unknown"
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "MemTotal") {
parts := strings.Fields(line)
if len(parts) >= 2 {
memKB := strings.TrimSuffix(parts[1], "kB")
var memTotalKB int
fmt.Sscanf(memKB, "%d", &memTotalKB)
memTotalMB := memTotalKB / 1024
return fmt.Sprintf("%d MB", memTotalMB)
}
}
}
return "unknown"
}
// GetSystemInfo 获取Linux系统信息无错误返回异常字段用默认值填充
func GetSystemInfo() SystemInfo {
// 初始化结构体,先填充基础默认值
sysInfo := SystemInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
NumCPU: runtime.NumCPU(),
GoVersion: runtime.Version(),
// 以下字段先设为默认值,后续尝试覆盖
KernelVersion: "unknown",
CPUModel: "unknown",
MemTotal: "unknown",
Hostname: "unknown",
}
// 1. 获取主机名(失败保留默认值)
if hostname, err := os.Hostname(); err == nil {
sysInfo.Hostname = hostname
}
// 2. 获取内核版本(失败保留默认值)
procVersion := readFileFirstLine(linuxProcVersion)
if procVersion != "" {
versionParts := strings.Split(procVersion, " ")
if len(versionParts) >= 3 {
sysInfo.KernelVersion = versionParts[2]
} else {
sysInfo.KernelVersion = procVersion
}
}
// 3. 获取CPU型号失败已返回 "unknown"
sysInfo.CPUModel = readCPUModel()
// 4. 获取总内存(失败已返回 "unknown"
sysInfo.MemTotal = readMemTotal()
return sysInfo
}
// -------------------------- 辅助函数:打印所有信息 --------------------------
// PrintAllInfo 打印所有公共信息(调试用)
func PrintAllInfo() {
fmt.Println("=== 应用公共信息 ===")
fmt.Printf("应用名称 : %s\n", AppName)
fmt.Printf("版本号 : %s\n", Version)
fmt.Printf("编译时间 : %s\n", BuildTime)
fmt.Printf("Git提交ID : %s\n", GitCommit)
fmt.Printf("Git分支 : %s\n", GitBranch)
fmt.Println("\n=== 系统信息 ===")
sysInfo := GetSystemInfo()
fmt.Printf("操作系统 : %s\n", sysInfo.OS)
fmt.Printf("系统架构 : %s\n", sysInfo.Arch)
fmt.Printf("内核版本 : %s\n", sysInfo.KernelVersion)
fmt.Printf("CPU型号 : %s\n", sysInfo.CPUModel)
fmt.Printf("CPU核心数 : %d\n", sysInfo.NumCPU)
fmt.Printf("总内存 : %s\n", sysInfo.MemTotal)
fmt.Printf("主机名 : %s\n", sysInfo.Hostname)
fmt.Printf("Go版本 : %s\n", sysInfo.GoVersion)
}

View File

@@ -67,21 +67,91 @@ var (
// DefaultLogger 全局默认日志实例(所有模块可直接用,也可注入自定义实现)
DefaultLogger Logger
// once 保证日志只初始化一次
once sync.Once
initOnce sync.Once
)
// LogConfig 日志配置结构体和项目的config包对齐
type LogConfig struct {
Verbose bool // 是否开启详细模式Debug级别
Level string // 日志级别debug/info/warn/error
ShowColor bool // 是否显示彩色输出
LogFile string // 日志文件路径(可选,空则只输出到控制台)
Level string `mapstructure:"level" yaml:"level"`
Format string `mapstructure:"format" yaml:"format"`
Output string `mapstructure:"output" yaml:"output"`
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
LogFile string `mapstructure:"log_file" yaml:"log_file"`
ShowColor bool `mapstructure:"show_color" yaml:"show_color"`
}
type LevelFilterWriter struct {
writer io.Writer
maxLevel logrus.Level // 控制台: 只输出 <= 该级别
minLevel logrus.Level // 文件: 只输出 >= 该级别
isConsole bool // 是否是控制台输出
}
// Write 实现io.Writer接口核心过滤逻辑
func (f *LevelFilterWriter) Write(p []byte) (n int, err error) {
// 解析日志级别适配logrus默认格式和CustomFormatter
logLevel := parseLogLevelFromContent(p)
// 控制台:只输出 Info 及以下级别Trace/Debug/Info
if f.isConsole {
if logLevel <= f.maxLevel {
return f.writer.Write(p)
}
return len(p), nil // 过滤掉返回长度避免Writer报错
}
// 文件:只输出 Warn 及以上级别Warn/Error/Fatal/Panic
if logLevel >= f.minLevel {
return f.writer.Write(p)
}
return len(p), nil
}
// parseLogLevelFromContent 解析日志内容中的级别(兼容自定义格式)
func parseLogLevelFromContent(p []byte) logrus.Level {
content := string(p)
// 适配常见的级别关键字兼容你的CustomFormatter
switch {
case contains(content, "TRACE"):
return logrus.TraceLevel
case contains(content, "DEBUG"):
return logrus.DebugLevel
case contains(content, "INFO"):
return logrus.InfoLevel
case contains(content, "WARN") || contains(content, "WARNING"):
return logrus.WarnLevel
case contains(content, "ERROR"):
return logrus.ErrorLevel
case contains(content, "FATAL"):
return logrus.FatalLevel
case contains(content, "PANIC"):
return logrus.PanicLevel
default:
return logrus.InfoLevel // 解析失败默认Info级别
}
}
// contains 辅助函数:判断字符串是否包含子串
func contains(s, substr string) bool {
return len(s) >= len(substr) && indexOf(s, substr) != -1
}
// indexOf 简易字符串查找(避免依赖额外库)
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
// 默认配置
var defaultConfig = LogConfig{
Verbose: false,
Level: "info",
Format: "text",
Output: "stdout",
Verbose: false,
ShowColor: true,
LogFile: "/var/log/sunhpc/sunhpc.log",
}
@@ -113,6 +183,9 @@ func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) {
colorReset = ""
}
// Debug,打印原始字节,用于调试
// fmt.Printf("%q\n", entry.Message)
// 拼接格式:
// 灰色日期 + 空格 + 带颜色的[级别] + 空格 + 日志内容 + 空格 + 灰色文件行号 + 重置
fmt.Fprintf(&buf, "%s%s%s %s[%s]%s %s %s%s:%d%s\n",
@@ -150,27 +223,6 @@ func getLevelInfo(level logrus.Level) (string, string) {
}
}
// getCallerInfo 获取调用日志的文件和行号跳过logrus内部调用
func _getCallerInfo() (string, int) {
// 跳过的调用栈深度根据实际情况调整这里跳过logrus和logger包的调用
skip := 6
pc, file, line, ok := runtime.Caller(skip)
if !ok {
return "unknown.go", 0
}
// 只保留文件名(如 db.go去掉完整路径
fileName := filepath.Base(file)
// 过滤logrus内部调用可选
funcName := runtime.FuncForPC(pc).Name()
if funcName == "" || filepath.Base(funcName) == "logrus" {
return getCallerInfoWithSkip(skip + 1)
}
return fileName, line
}
func getCallerInfo() (string, int) {
// 从当前调用开始,逐层向上查找
for i := 2; i < 15; i++ { // i从2开始跳过getCallerInfo自身
@@ -203,8 +255,7 @@ func shouldSkipPackage(funcName, file string) bool {
}
// 跳过logger包你自己的包装包
if strings.Contains(funcName, "your/package/logger") ||
strings.Contains(file, "logger") {
if strings.Contains(file, "/logger/") {
return true
}
@@ -227,45 +278,11 @@ func getCallerInfoWithSkip(skip int) (string, int) {
// Init 初始化全局默认日志实例(全局只执行一次)
func Init(cfg LogConfig) {
once.Do(func() {
// 合并配置:传入的配置为空则用默认值
if cfg.Level == "" {
cfg.Level = defaultConfig.Level
}
if cfg.LogFile == "" {
cfg.LogFile = defaultConfig.LogFile
}
initOnce.Do(func() {
// 1. 创建logrus实例
logrusInst := logrus.New()
// 2. 配置输出(控制台 + 文件,可选)
var outputs []io.Writer
outputs = append(outputs, os.Stdout) // 控制台输出
// 如果配置了日志文件,添加文件输出
if cfg.LogFile != "" {
// 确保日志目录存在
dir := filepath.Dir(cfg.LogFile)
if err := os.MkdirAll(dir, 0755); err != nil {
// 目录创建失败,只输出警告,不影响程序运行
logrusInst.Warnf("创建日志目录失败: %v,仅输出到控制台", err)
} else {
file, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err == nil {
outputs = append(outputs, file)
} else {
logrusInst.Warnf("打开日志文件失败: %v,仅输出到控制台", err)
}
}
}
logrusInst.SetOutput(io.MultiWriter(outputs...))
// 3. 配置格式
logrusInst.SetFormatter(&CustomFormatter{
ShowColor: cfg.ShowColor,
})
// 4. 配置日志级别
// 2. 配置日志级别(总开关,必须在输出配置前)
lvl, err := logrus.ParseLevel(cfg.Level)
if err != nil {
lvl = logrus.InfoLevel // 解析失败默认Info级别
@@ -279,6 +296,45 @@ func Init(cfg LogConfig) {
// 启用文件行号(必须开启否则getCallerInfo拿不到数据)
logrusInst.SetReportCaller(true)
// 3. 配置输出(控制台 + 文件,可选)
var outputs []io.Writer
// 控制台输出: 只输出 Info 及以下级别
consoleWriter := &LevelFilterWriter{
writer: os.Stdout,
minLevel: logrus.InfoLevel,
isConsole: true,
}
outputs = append(outputs, consoleWriter)
// 如果配置了日志文件,添加文件输出
if cfg.LogFile != "" {
// 确保日志目录存在
dir := filepath.Dir(cfg.LogFile)
if err := os.MkdirAll(dir, 0755); err != nil {
// 目录创建失败,只输出警告,不影响程序运行
logrusInst.Warnf("创建日志目录失败: %v,仅输出到控制台", err)
} else {
file, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err == nil {
fileWriter := &LevelFilterWriter{
writer: file,
minLevel: logrus.WarnLevel,
isConsole: false,
}
outputs = append(outputs, fileWriter)
} else {
logrusInst.Warnf("打开日志文件失败: %v,仅输出到控制台", err)
}
}
}
logrusInst.SetOutput(io.MultiWriter(outputs...))
// 4. 配置格式
logrusInst.SetFormatter(&CustomFormatter{
ShowColor: cfg.ShowColor,
})
// 5. 赋值给全局默认实例
DefaultLogger = &logrusLogger{logrusInst}
})

View File

@@ -3,6 +3,7 @@ package utils
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"time"
@@ -31,3 +32,46 @@ func GenerateID() (string, error) {
func GetTimestamp() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func OutputMaps(maps map[string]string) []string {
output := []string{}
maxLen := 0
for key := range maps {
if len(key) > maxLen {
maxLen = len(key)
}
}
// 使用动态宽度的格式化字符串输出
// %-*s 的含义
// %: 格式化开始
// -: 左对齐,默认是右对齐
// *: 表示宽度由后续参数指定(maxLen)
// s: 表示字符串类型
for key, value := range maps {
output = append(output, fmt.Sprintf("%-*s: %s", maxLen, key, value))
}
return output
}
// 定义短语
const (
NoAvailableNetworkInterfaces = "No available network interfaces"
)
// 定义目录
const (
DefaultBaseDir string = "/etc/sunhpc"
DefaultTmplDir string = DefaultBaseDir + "/tmpl.d"
DefaultLogDir string = "/var/log/sunhpc"
DefaultLogFile string = DefaultLogDir + "/sunhpc.log"
)
// 定义数据库
const (
DefaultDBName string = "sunhpc.db"
DefaultDBPath string = "/var/lib/sunhpc"
DefaultDBArgs string = "_foreign_keys=on&_journal_mode=WAL&_timeout=5000"
)

571
pkg/wizard/config.go Normal file
View File

@@ -0,0 +1,571 @@
package wizard
import (
"errors"
"fmt"
"net"
"os"
"sunhpc/pkg/database"
"sunhpc/pkg/info"
"sunhpc/pkg/logger"
"sunhpc/pkg/utils"
"github.com/BurntSushi/toml"
)
// 配置项映射:定义每个配置项对应的表名、键名
type ConfigMapping struct {
Title string `toml:"title"`
Base struct {
ClusterName string `toml:"cluster_name"`
Country string `toml:"country"`
State string `toml:"state"`
City string `toml:"city"`
HomePage string `toml:"homepage"`
Contact string `toml:"contact"`
License string `toml:"license"`
BaseDir string `toml:"base_dir"`
WorkDir string `toml:"work_dir"`
DistroDir string `toml:"distro_dir"`
Partition string `toml:"partition"`
Distribution string `toml:"distribution"`
Timezone string `toml:"timezone"`
SafePort string `toml:"safe_port"`
SafeDirs string `toml:"safe_dirs"`
SafeSecurity string `toml:"safe_security"`
PluginDirs string `toml:"plugin_dirs"`
PluginPort string `toml:"plugin_port"`
GangliaAddr string `toml:"ganglia_addr"`
} `toml:"base"`
Pxelinux struct {
NextServer string `toml:"next_server"`
PxeFilename string `toml:"pxe_filename"`
PxeLinuxDir string `toml:"pxelinux_dir"`
BootArgs string `toml:"boot_args"`
} `toml:"pxelinux"`
Public struct {
PublicHostname string `toml:"public_hostname"`
PublicInterface string `toml:"public_interface"`
PublicAddress string `toml:"public_address"`
PublicNetmask string `toml:"public_netmask"`
PublicGateway string `toml:"public_gateway"`
PublicNetwork string `toml:"public_network"`
PublicDomain string `toml:"public_domain"`
PublicCIDR string `toml:"public_cidr"`
PublicDNS string `toml:"public_dns"`
PublicMac string `toml:"public_mac"`
PublicMTU string `toml:"public_mtu"`
PublicNTP string `toml:"public_ntp"`
} `toml:"public"`
Private struct {
PrivateHostname string `toml:"private_hostname"`
PrivateInterface string `toml:"private_interface"`
PrivateAddress string `toml:"private_address"`
PrivateNetmask string `toml:"private_netmask"`
PrivateNetwork string `toml:"private_network"`
PrivateDomain string `toml:"private_domain"`
PrivateCIDR string `toml:"private_cidr"`
PrivateMac string `toml:"private_mac"`
PrivateMTU string `toml:"private_mtu"`
} `toml:"private"`
}
type IPMaskInfo struct {
NetworkAddress string // 网络地址 192.168.1.0
CIDR string // CIDR 格式 192.168.1.0/24
IPAddress string // IP 地址 192.168.1.100
MacAddress string // MAC 地址 00:11:22:33:44:55
Netmask string // 子网掩码 255.255.255.0
PrefixLength int // 前缀长度 24
}
type AttrItem struct {
Key string
Value string
Shadow string
Category int
Catindex int
}
func NewConfigWithDefault() *ConfigMapping {
return &ConfigMapping{
Title: "Cluster Configuration",
Base: struct {
ClusterName string `toml:"cluster_name"`
Country string `toml:"country"`
State string `toml:"state"`
City string `toml:"city"`
HomePage string `toml:"homepage"`
Contact string `toml:"contact"`
License string `toml:"license"`
BaseDir string `toml:"base_dir"`
WorkDir string `toml:"work_dir"`
DistroDir string `toml:"distro_dir"`
Partition string `toml:"partition"`
Distribution string `toml:"distribution"`
Timezone string `toml:"timezone"`
SafePort string `toml:"safe_port"`
SafeDirs string `toml:"safe_dirs"`
SafeSecurity string `toml:"safe_security"`
PluginDirs string `toml:"plugin_dirs"`
PluginPort string `toml:"plugin_port"`
GangliaAddr string `toml:"ganglia_addr"`
}{
ClusterName: "SunHPC_Cluster",
Country: "CN",
State: "Beijing",
City: "Beijing",
HomePage: "https://www.sunhpc.com",
Contact: "admin@sunhpc.com",
License: "MIT",
BaseDir: "install",
WorkDir: "/export",
DistroDir: "/export/sunhpc",
Partition: "default",
Distribution: "sunhpc-dist",
Timezone: "Asia/Shanghai",
SafePort: "372",
SafeDirs: "safe.d",
SafeSecurity: "safe-security",
PluginDirs: "/etc/sunhpc/plugin",
PluginPort: "12123",
GangliaAddr: "224.0.0.3",
},
Pxelinux: struct {
NextServer string `toml:"next_server"`
PxeFilename string `toml:"pxe_filename"`
PxeLinuxDir string `toml:"pxelinux_dir"`
BootArgs string `toml:"boot_args"`
}{
NextServer: "192.168.1.1",
PxeFilename: "pxelinux.0",
PxeLinuxDir: "/tftpboot/pxelinux",
BootArgs: "net.ifnames=0 biosdevname=0",
},
Public: struct {
PublicHostname string `toml:"public_hostname"`
PublicInterface string `toml:"public_interface"`
PublicAddress string `toml:"public_address"`
PublicNetmask string `toml:"public_netmask"`
PublicGateway string `toml:"public_gateway"`
PublicNetwork string `toml:"public_network"`
PublicDomain string `toml:"public_domain"`
PublicCIDR string `toml:"public_cidr"`
PublicDNS string `toml:"public_dns"`
PublicMac string `toml:"public_mac"`
PublicMTU string `toml:"public_mtu"`
PublicNTP string `toml:"public_ntp"`
}{
PublicHostname: "cluster.hpc.org",
PublicInterface: "eth0",
PublicAddress: "",
PublicNetmask: "",
PublicGateway: "",
PublicNetwork: "",
PublicDomain: "hpc.org",
PublicCIDR: "",
PublicDNS: "",
PublicMac: "00:11:22:33:44:55",
PublicMTU: "1500",
PublicNTP: "pool.ntp.org",
},
Private: struct {
PrivateHostname string `toml:"private_hostname"`
PrivateInterface string `toml:"private_interface"`
PrivateAddress string `toml:"private_address"`
PrivateNetmask string `toml:"private_netmask"`
PrivateNetwork string `toml:"private_network"`
PrivateDomain string `toml:"private_domain"`
PrivateCIDR string `toml:"private_cidr"`
PrivateMac string `toml:"private_mac"`
PrivateMTU string `toml:"private_mtu"`
}{
PrivateHostname: "sunhpc",
PrivateInterface: "eth1",
PrivateAddress: "172.16.9.254",
PrivateNetmask: "255.255.255.0",
PrivateNetwork: "172.16.9.0",
PrivateDomain: "example.com",
PrivateCIDR: "172.16.9.0/24",
PrivateMac: "00:11:22:33:44:66",
PrivateMTU: "1500",
},
}
}
func loadConfig() (*ConfigMapping, error) {
configs := NewConfigWithDefault()
cfgfile := "/etc/sunhpc/config.toml"
// 尝试解析配置文件
if _, err := toml.DecodeFile(cfgfile, configs); err != nil {
if errors.Is(err, os.ErrNotExist) {
// 文件不存在,直接返回默认配置
logger.Debugf("Config file %s not exist, use default config", cfgfile)
return configs, nil
}
// 其他错误,返回错误
logger.Debugf("[DEBUG] Parse config file %s failed: %v", cfgfile, err)
return nil, err
}
logger.Debugf("Load config file %s success", cfgfile)
return configs, nil
}
// saveConfig 入口函数:保存所有配置到数据库
func (m *model) saveConfig() error {
m.force = false // 初始化全量覆盖标识
c, err := loadConfig()
if err != nil {
logger.Debugf("[DEBUG] Load config file failed: %v", err)
return err
}
// 合并配置项
result := make(map[string]string)
// base 配置
result["country"] = mergeValue(m.config.Country, c.Base.Country)
result["state"] = mergeValue(m.config.State, c.Base.State)
result["city"] = mergeValue(m.config.City, c.Base.City)
result["contact"] = mergeValue(m.config.Contact, c.Base.Contact)
result["homepage"] = mergeValue(m.config.HomePage, c.Base.HomePage)
result["cluster_name"] = mergeValue(m.config.ClusterName, c.Base.ClusterName)
result["license"] = c.Base.License
result["distribution"] = c.Base.Distribution
result["timezone"] = mergeValue(m.config.Timezone, c.Base.Timezone)
result["base_dir"] = c.Base.BaseDir
result["work_dir"] = c.Base.WorkDir
result["distro_dir"] = mergeValue(m.config.DistroDir, c.Base.DistroDir)
result["partition"] = c.Base.Partition
// safe 配置
result["safe_port"] = c.Base.SafePort
result["safe_dirs"] = c.Base.SafeDirs
result["safe_security"] = c.Base.SafeSecurity
// plugin 配置
result["plugin_dirs"] = c.Base.PluginDirs
result["plugin_port"] = c.Base.PluginPort
// monitor 配置
result["ganglia_addr"] = c.Base.GangliaAddr
// public 配置
result["public_hostname"] = mergeValue(m.config.PublicHostname, c.Public.PublicHostname)
result["public_interface"] = mergeValue(m.config.PublicInterface, c.Public.PublicInterface)
result["public_address"] = mergeValue(m.config.PublicIPAddress, c.Public.PublicAddress)
result["public_netmask"] = mergeValue(m.config.PublicNetmask, c.Public.PublicNetmask)
result["public_gateway"] = mergeValue(m.config.PublicGateway, c.Public.PublicGateway)
// 获取公网网络信息
publicIface := mergeValue(m.config.PublicInterface, c.Public.PublicInterface)
publicInfo, err := GetNetworkInfo(
publicIface, c.Public.PublicAddress, c.Public.PublicNetmask)
if err != nil {
logger.Debugf("[DEBUG] Get public interface %s IP mask info failed: %v",
publicIface, err)
}
result["public_network"] = publicInfo.NetworkAddress
result["public_domain"] = mergeValue(m.config.PublicDomain, c.Public.PublicDomain)
result["public_cidr"] = mergeValue(c.Public.PublicCIDR, publicInfo.CIDR)
result["public_dns"] = c.Public.PublicDNS
result["public_mac"] = publicInfo.MacAddress
result["public_mtu"] = mergeValue(m.config.PublicMTU, c.Public.PublicMTU)
result["public_ntp"] = c.Public.PublicNTP
// private 配置
// 获取内网网络信息
privateIface := mergeValue(m.config.PrivateInterface, c.Private.PrivateInterface)
privateInfo, err := GetNetworkInfo(
privateIface, c.Private.PrivateAddress, c.Private.PrivateNetmask)
if err != nil {
logger.Debugf("[DEBUG] Get private interface %s IP mask info failed: %v",
privateIface, err)
}
result["private_hostname"] = mergeValue(m.config.PrivateHostname, c.Private.PrivateHostname)
result["private_interface"] = mergeValue(m.config.PrivateInterface, c.Private.PrivateInterface)
result["private_address"] = mergeValue(m.config.PrivateIPAddress, c.Private.PrivateAddress)
result["private_netmask"] = mergeValue(m.config.PrivateNetmask, c.Private.PrivateNetmask)
result["private_network"] = privateInfo.NetworkAddress
result["private_domain"] = mergeValue(m.config.PrivateDomain, c.Private.PrivateDomain)
result["private_cidr"] = mergeValue(c.Private.PrivateCIDR, privateInfo.CIDR)
result["private_mac"] = privateInfo.MacAddress
result["private_mtu"] = mergeValue(m.config.PrivateMTU, c.Private.PrivateMTU)
// pxe 配置
result["next_server"] = mergeValue(privateInfo.IPAddress, c.Pxelinux.NextServer)
result["pxe_filename"] = c.Pxelinux.PxeFilename
result["pxelinux_dir"] = c.Pxelinux.PxeLinuxDir
result["boot_args"] = c.Pxelinux.BootArgs
// 插入数据到数据库
if err := insertDataToDB(result); err != nil {
logger.Debugf("[DEBUG] Insert config data to database failed: %v", err)
return err
}
for _, value := range utils.OutputMaps(result) {
logger.Debugf("%s", value)
}
return nil
}
func mergeValue(tui_value, cfg_value string) string {
if tui_value == "" {
return cfg_value
}
return tui_value
}
// 获取系统网络接口
func getNetworkInterfaces() []string {
// 实现获取系统网络接口的逻辑
// 例如:使用 net.Interface() 函数获取系统网络接口
// 返回一个字符串切片,包含系统网络接口的名称
interfaces, err := net.Interfaces()
if err != nil {
return []string{utils.NoAvailableNetworkInterfaces}
}
var result []string
for _, iface := range interfaces {
// 跳过 loopback 接口
if iface.Flags&net.FlagLoopback != 0 {
continue
}
result = append(result, iface.Name)
}
if len(result) == 0 {
return []string{utils.NoAvailableNetworkInterfaces}
}
return result
}
func GetNetworkInfo(iface, ip, mask string) (*IPMaskInfo, error) {
logger.Debugf("Get Network %s, IP %s, mask %s", iface, ip, mask)
// 解析IP
ipAddr := net.ParseIP(ip)
if ipAddr == nil {
logger.Debugf("Invalid IP address: %s", ip)
return nil, fmt.Errorf("invalid IP address: %s", ip)
}
// 解析子网掩码
maskAddr := net.ParseIP(mask)
if maskAddr == nil {
logger.Debugf("Invalid subnet mask: %s", mask)
return nil, fmt.Errorf("invalid subnet mask: %s", mask)
}
// 确保是IPv4地址
ipv4 := ipAddr.To4()
maskv4 := maskAddr.To4()
if ipv4 == nil || maskv4 == nil {
logger.Debugf("Only support IPv4 address")
return nil, fmt.Errorf("only support IPv4 address")
}
// 计算网络地址 (IP & 子网掩码)
network := make([]byte, 4)
for i := 0; i < 4; i++ {
network[i] = ipv4[i] & maskv4[i]
}
networkAddr := fmt.Sprintf(
"%d.%d.%d.%d", network[0], network[1], network[2], network[3])
// 计算前缀长度
prefixLen := 0
for i := 0; i < 4; i++ {
for j := 7; j >= 0; j-- {
if maskv4[i]&(1<<uint(j)) != 0 {
prefixLen++
} else {
break
}
}
}
// 计算CIDR格式
cidr := fmt.Sprintf("%s/%d", networkAddr, prefixLen)
var mac string
// 获取Mac地址
ifaceName, err := net.InterfaceByName(iface)
if err == nil {
mac = ifaceName.HardwareAddr.String()
if mac == "" {
logger.Debugf("Network interface %s has no MAC address", iface)
}
} else {
logger.Debugf("Invalid network interface: %s", iface)
mac = ""
}
return &IPMaskInfo{
NetworkAddress: networkAddr,
CIDR: cidr,
IPAddress: ip,
MacAddress: mac,
Netmask: mask,
PrefixLength: prefixLen,
}, nil
}
func insertDataToDB(result map[string]string) error {
insertData := []string{}
infos := info.GetSystemInfo()
// initrd 配置
bootver := fmt.Sprintf("%s-%s", info.Version, infos.Arch)
vmlinuz := fmt.Sprintf("vmlinuz-%s", bootver)
initrds := fmt.Sprintf("initrd-%s", bootver)
insArgs := fmt.Sprintf("%s inst.ks.sendmac ksdevice=bootif", result["boot_args"])
resArgs := fmt.Sprintf("%s rescue", result["boot_args"])
lesArgs := fmt.Sprintf("%s vnc vncip=%s vncpassword=sunhpc", result["boot_args"], result["private_address"])
bootaction := []string{
fmt.Sprintf("insert or replace into bootactions values (1, 'install', '%s', '%s', '%s');",
vmlinuz, initrds, insArgs),
"insert or replace into bootactions values (2, 'os', 'localboot 0', '', '');",
"insert or replace into bootactions values (3, 'memtest', 'kernel memtest', '', '');",
fmt.Sprintf("insert or replace into bootactions values (4, 'install headless', '%s', '%s', '%s');",
vmlinuz, initrds, lesArgs),
fmt.Sprintf("insert or replace into bootactions values (5, 'rescue', '%s', '%s', '%s');",
vmlinuz, initrds, resArgs),
"insert or replace into bootactions values (6, 'pxeflash', 'kernel memdisk bigraw', 'pxeflash.img', 'keeppxe');",
}
insertData = append(insertData, bootaction...)
attrs := GetAttrs(result)
for _, item := range attrs {
key := item.Key
value := item.Value
shadow := item.Shadow
category := item.Category
catindex := item.Catindex
insertData = append(insertData,
fmt.Sprintf("insert or replace into attributes values (NULL, '%s', '%s', '%s', %d, %d);",
key, value, shadow, category, catindex))
}
nodes := []string{
fmt.Sprintf(
"insert or replace into nodes values (1, '%s', '2', '%d', 0, 0, '%s', '%s', '', 'install');",
result["private_hostname"],
info.GetSystemInfo().NumCPU,
info.GetSystemInfo().Arch,
info.GetSystemInfo().OS),
fmt.Sprintf(
`insert or replace into subnets values (1, 'private', '%s', '%s', '%s', '%s', '1');`,
result["private_domain"],
result["private_network"],
result["private_netmask"],
result["private_mtu"]),
fmt.Sprintf(
`insert or replace into subnets values (2, 'public', '%s', '%s', '%s', '%s', '0');`,
result["public_domain"],
result["public_network"],
result["public_netmask"],
result["public_mtu"]),
fmt.Sprintf(
`insert or replace into networks values (1, 1, '%s', '%s', '%s', '%s', '2', NULL, NULL,NULL,NULL);`,
result["public_mac"],
result["public_address"],
result["private_hostname"],
result["public_interface"]),
fmt.Sprintf(
`insert or replace into networks values (2, 1, '%s', '%s', '%s', '%s', '1', NULL, NULL, NULL, NULL);`,
result["private_mac"],
result["private_address"],
result["private_hostname"],
result["private_interface"]),
}
insertData = append(insertData, nodes...)
if err := database.ExecWithTransaction(insertData); err != nil {
logger.Debugf("[DEBUG] Insert config data to database failed: %v", err)
return err
}
return nil
}
func GetAttrs(results map[string]string) []AttrItem {
attrs := []AttrItem{
{Key: "Info_CertificateCountry", Value: results["country"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Info_CertificateState", Value: results["state"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Info_CertificateCity", Value: results["city"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Info_CertificateOrganization", Value: "DLHP", Shadow: "false", Category: 1, Catindex: 1},
{Key: "Info_Contact", Value: results["contact"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Info_ClusterHostname", Value: results["ClusterHostname"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_WorkDir", Value: results["work_dir"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_DistroDir", Value: results["distro_dir"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_Partition", Value: results["partition"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicHostname", Value: results["public_hostname"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicInterface", Value: results["public_interface"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicAddress", Value: results["public_address"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicMacAddr", Value: results["public_mac"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicNetmask", Value: results["public_netmask"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicGateway", Value: results["public_gateway"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicNetwork", Value: results["public_network"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicDomain", Value: results["public_domain"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicCIDR", Value: results["public_cidr"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicDNS", Value: results["public_dns"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicMTU", Value: results["public_mtu"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PublicNTP", Value: results["public_ntp"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateHostname", Value: results["private_hostname"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateInterface", Value: results["private_interface"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateAddress", Value: results["private_address"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateMacAddr", Value: results["private_mac"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateNetmask", Value: results["private_netmask"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateGateway", Value: results["private_gateway"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateNetwork", Value: results["private_network"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateDomain", Value: results["private_domain"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateCIDR", Value: results["private_cidr"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_PrivateMTU", Value: results["private_mtu"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_Timezone", Value: results["timezone"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_Bootargs", Value: results["boot_args"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_Distribution", Value: results["distribution"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstart_BaseDir", Value: results["base_dir"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "SafePort", Value: results["safe_port"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "SafeDirs", Value: results["safe_dirs"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "SafeSecurity", Value: results["safe_security"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Plugin_dirs", Value: results["plugin_dirs"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Plugin_port", Value: results["plugin_port"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Ganglia_addr", Value: results["ganglia_addr"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Sunhpc_version", Value: results["sunhpc_version"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "DHCP_filename", Value: results["pxe_filename"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "DHCP_nextserver", Value: results["next_server"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Pxelinuxdir", Value: results["pxelinux_dir"], Shadow: "false", Category: 1, Catindex: 1},
{Key: "Kickstartable", Value: "yes", Shadow: "false", Category: 3, Catindex: 4},
{Key: "Kickstartable", Value: "yes", Shadow: "false", Category: 3, Catindex: 5},
{Key: "Kickstartable", Value: "yes", Shadow: "false", Category: 3, Catindex: 6},
{Key: "Kickstartable", Value: "no", Shadow: "false", Category: 3, Catindex: 7},
{Key: "Kickstartable", Value: "no", Shadow: "false", Category: 3, Catindex: 8},
{Key: "Kickstartable", Value: "yes", Shadow: "false", Category: 3, Catindex: 9},
{Key: "Kickstartable", Value: "yes", Shadow: "false", Category: 3, Catindex: 10},
{Key: "Managed", Value: "true", Shadow: "false", Category: 1, Catindex: 1},
{Key: "OS", Value: "linux", Shadow: "false", Category: 1, Catindex: 1},
}
return attrs
}

349
pkg/wizard/focused.go Normal file
View File

@@ -0,0 +1,349 @@
package wizard
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Focusable 定义可聚焦组件的通用接口
type Focusable interface {
// Focus 激活焦点(比如输入框闪烁光标、按钮高亮)
Focus() tea.Cmd
// Blur 失活焦点(取消高亮/闪烁)
Blur()
// IsFocused 判断是否处于焦点状态
IsFocused() bool
// View 渲染组件(和 bubbletea 统一)
View() string
}
// --------------- 为常用组件实现 Focusable 接口 ---------------
// TextInput 适配 bubbles/textinput
type TextInput struct {
textinput.Model
focused bool
}
func NewTextInput(placeholder string, defaultValue string) *TextInput {
ti := textinput.New()
ti.Placeholder = placeholder
ti.SetValue(defaultValue)
ti.Blur()
return &TextInput{Model: ti, focused: false}
}
func (t *TextInput) Focus() tea.Cmd {
t.focused = true
return t.Model.Focus()
}
func (t *TextInput) Blur() {
t.focused = false
t.Model.Blur()
}
func (t *TextInput) IsFocused() bool {
return t.focused
}
// Button 适配 bubbles/button
type Button struct {
label string
focused bool
buttonBlur lipgloss.Style
buttonFocus lipgloss.Style
}
func NewButton(label string) *Button {
return &Button{
label: label,
focused: false,
buttonBlur: btnBaseStyle,
buttonFocus: btnSelectedStyle,
}
}
func (b *Button) Focus() tea.Cmd {
b.focused = true
b.buttonBlur = b.buttonFocus
return nil
}
func (b *Button) Blur() {
b.focused = false
b.buttonBlur = btnBaseStyle
}
func (b *Button) IsFocused() bool {
return b.focused
}
func (b *Button) View() string {
if b.focused {
return b.buttonFocus.Render(b.label)
}
return b.buttonBlur.Render(b.label)
}
// List 适配 bubbles/list
type List struct {
list.Model
focused bool
}
func NewList(items []list.Item) List {
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.SetShowHelp(false)
return List{Model: l, focused: false}
}
func (l *List) Focus() tea.Cmd {
l.focused = true
return nil
}
func (l *List) Blur() {
l.focused = false
}
func (l *List) IsFocused() bool {
return l.focused
}
// FocusManager 焦点管理器
type FocusManager struct {
// 所有可聚焦组件key=唯一标识,比如 "form1.ip_input"、"form1.next_btn"
components map[string]Focusable
// 组件切换顺序(按这个顺序切换焦点)
order []string
// 当前焦点组件的标识
currentFocusID string
// 是否循环切换(到最后一个后回到第一个)
loop bool
}
// NewFocusManager 创建焦点管理器
func NewFocusManager(loop bool) *FocusManager {
return &FocusManager{
components: make(map[string]Focusable),
order: make([]string, 0),
currentFocusID: "",
loop: loop,
}
}
/*
Register 注册可聚焦组件(指定标识和切换顺序)
ID 组件的唯一标识,用于后续切换和获取焦点
例如 "form1.ip_input"、"form1.next_btn"
*/
func (fm *FocusManager) Register(id string, comp Focusable) {
// 防御性检查:避免 components 未初始化为nil导致 panic
if fm.components == nil {
fm.components = make(map[string]Focusable)
}
// 避免重复注册
if _, exists := fm.components[id]; exists {
return
}
// id : accept_btn, form1.reject_btn
// comp: 接受协议按钮, 拒绝协议按钮
fm.components[id] = comp
fm.order = append(fm.order, id)
// 如果是第一个注册的组件,默认聚焦
if fm.currentFocusID == "" {
fm.currentFocusID = id
comp.Focus()
}
}
// Next 切换到下一个组件
func (fm *FocusManager) Next() tea.Cmd {
if len(fm.order) == 0 {
return nil
}
// 1. 找到当前组件的索引
currentIdx := -1
for i, id := range fm.order {
if id == fm.currentFocusID {
currentIdx = i
break
}
}
// 2. 计算下一个索引
nextIdx := currentIdx + 1
if fm.loop && nextIdx >= len(fm.order) {
nextIdx = 0
}
if nextIdx >= len(fm.order) {
return nil // 不循环则到最后一个停止
}
// 3. 切换焦点(当前组件失活,下一个激活)
fm.components[fm.currentFocusID].Blur()
nextID := fm.order[nextIdx]
fm.currentFocusID = nextID
return fm.components[nextID].Focus()
}
// Prev 切换到上一个组件
func (fm *FocusManager) Prev() tea.Cmd {
if len(fm.order) == 0 {
return nil
}
currentIdx := -1
for i, id := range fm.order {
if id == fm.currentFocusID {
currentIdx = i
break
}
}
prevIdx := currentIdx - 1
if fm.loop && prevIdx < 0 {
prevIdx = len(fm.order) - 1
}
if prevIdx < 0 {
return nil
}
//fm.components[fm.currentFocusID].Blur()
fm.components[fm.currentFocusID].Blur()
prevID := fm.order[prevIdx]
fm.currentFocusID = prevID
return fm.components[prevID].Focus()
}
// GetCurrent 获取当前焦点组件
func (fm *FocusManager) GetCurrent() (Focusable, bool) {
comp, exists := fm.components[fm.currentFocusID]
return comp, exists
}
// HandleInput 统一处理焦点切换输入(比如 Tab/Shift+Tab
func (fm *FocusManager) HandleInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "tab": // Tab 下一个
return fm.Next()
case "shift+tab": // Shift+Tab 上一个
return fm.Prev()
case "left": // Left 上一个
return fm.Prev()
case "right": // Right 下一个
return fm.Next()
default:
return nil
}
}
func (m *model) switchPage(targetPage PageType) tea.Cmd {
// 边界检查(不能超出 1-6 页面)
if targetPage < PageAgreement || targetPage > PageSummary {
return nil
}
// 更新当前页面
m.currentPage = targetPage
// 初始化新页面的焦点
m.initPageFocus(targetPage)
// 返回空指令(或返回第一个组件的Focus命令)
return nil
}
func (m *model) initPageFocus(page PageType) {
m.focusManager = NewFocusManager(true)
pageComps, exists := m.pageComponents[page]
if !exists {
return
}
var componentOrder []string
var defaultFocusID string
switch page {
case PageAgreement:
componentOrder = []string{"accept_btn", "reject_btn"}
defaultFocusID = "accept_btn"
case PageData:
componentOrder = []string{
"Homepage_input",
"ClusterName_input",
"Country_input",
"State_input",
"City_input",
"Contact_input",
"Timezone_input",
"DistroDir_input",
"next_btn",
"prev_btn",
}
defaultFocusID = "next_btn"
case PagePublicNetwork:
componentOrder = []string{
"PublicHostname_input",
"PublicInterface_input",
"PublicIPAddress_input",
"PublicNetmask_input",
"PublicGateway_input",
"PublicDomain_input",
"PublicMTU_input",
"next_btn",
"prev_btn",
}
defaultFocusID = "next_btn"
case PageInternalNetwork:
componentOrder = []string{
"PrivateHostname_input",
"PrivateInterface_input",
"PrivateIPAddress_input",
"PrivateNetmask_input",
"PrivateDomain_input",
"PrivateMTU_input",
"next_btn",
"prev_btn",
}
defaultFocusID = "next_btn"
case PageDNS:
componentOrder = []string{
"Pri_DNS_input",
"Sec_DNS_input",
"next_btn",
"prev_btn",
}
defaultFocusID = "next_btn"
case PageSummary:
componentOrder = []string{"confirm_btn", "cancel_btn"}
defaultFocusID = "confirm_btn"
}
for _, compID := range componentOrder {
if comp, exists := pageComps[compID]; exists {
m.focusManager.Register(compID, comp)
}
}
if defaultFocusID != "" {
if currentComp, exists := m.focusManager.GetCurrent(); exists {
currentComp.Blur()
}
if targetComp, exists := pageComps[defaultFocusID]; exists {
m.focusManager.currentFocusID = defaultFocusID
targetComp.Focus()
}
}
}

341
pkg/wizard/model.go Normal file
View File

@@ -0,0 +1,341 @@
package wizard
import (
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
// PageType 页面类型
type PageType int
// 总页码
const TotalPages = 6
// Config 系统配置结构
type Config struct {
// 协议
License string `json:"license"`
AgreementAccepted bool `json:"agreement_accepted"`
// 数据接收
ClusterName string `json:"cluster_name"`
Country string `json:"country"`
State string `json:"state"`
City string `json:"city"`
Contact string `json:"contact"`
Timezone string `json:"timezone"`
HomePage string `json:"homepage"`
DBAddress string `json:"db_address"`
DistroDir string `json:"distro_dir"`
// 公网设置
PublicHostname string `json:"public_hostname"`
PublicInterface string `json:"public_interface"`
PublicIPAddress string `json:"ip_address"`
PublicNetmask string `json:"netmask"`
PublicGateway string `json:"gateway"`
PublicDomain string `json:"public_domain"`
PublicMTU string `json:"public_mtu"`
// 内网配置
PrivateHostname string `json:"private_hostname"`
PrivateInterface string `json:"private_interface"`
PrivateIPAddress string `json:"private_ip"`
PrivateNetmask string `json:"private_mask"`
PrivateDomain string `json:"private_domain"`
PrivateMTU string `json:"private_mtu"`
// DNS 配置
DNSPrimary string `json:"dns_primary"`
DNSSecondary string `json:"dns_secondary"`
}
const (
PageAgreement PageType = iota
PageData
PagePublicNetwork
PageInternalNetwork
PageDNS
PageSummary
)
// model TUI 主模型
type model struct {
config Config // 全局配置
currentPage PageType // 当前页面
totalPages int
textInputs []textinput.Model // 当前页面的输入框
networkInterfaces []string // 所有系统网络接口
width int
height int
err error
quitting bool
done bool
force bool
// 核心1: 按页面分组存储所有组件(6个页面 + 6个map)
pageComponents map[PageType]map[string]Focusable
// 核心2焦点管理器(每次切换页面时重置)
focusManager *FocusManager
}
// defaultConfig 返回默认配置
func defaultConfig() Config {
var (
defaultPublicInterface string
defaultInternalInterface string
)
interfaces := getNetworkInterfaces()
switch len(interfaces) {
case 0:
defaultPublicInterface = ""
defaultInternalInterface = ""
case 1:
defaultPublicInterface = interfaces[0]
defaultInternalInterface = fmt.Sprintf("%s:0", interfaces[0])
case 2:
defaultPublicInterface = interfaces[0]
defaultInternalInterface = interfaces[1]
default:
defaultPublicInterface = interfaces[0]
defaultInternalInterface = interfaces[1]
}
return Config{
License: "This test license is for testing purposes only. Do not use it in production.",
ClusterName: "cluster.hpc.org",
Country: "China",
State: "Beijing",
City: "Beijing",
Contact: "admin@sunhpc.com",
Timezone: "Asia/Shanghai",
HomePage: "www.sunhpc.com",
DBAddress: "/var/lib/sunhpc/sunhpc.db",
DistroDir: "/export/sunhpc",
PublicHostname: "cluster.hpc.org",
PublicInterface: defaultPublicInterface,
PublicIPAddress: "",
PublicNetmask: "",
PublicGateway: "",
PublicDomain: "hpc.org",
PublicMTU: "1500",
PrivateHostname: "cluster",
PrivateInterface: defaultInternalInterface,
PrivateIPAddress: "172.16.9.254",
PrivateNetmask: "255.255.255.0",
PrivateDomain: "local",
PrivateMTU: "1500",
DNSPrimary: "8.8.8.8",
DNSSecondary: "8.8.4.4",
}
}
// initialModel 初始化模型
func initialModel() model {
cfg := defaultConfig()
// 1. 初始化所有页面组件(6个页面)
pageComponents := make(map[PageType]map[string]Focusable)
// ------------------ 页面1协议页面 --------------------
page1Comps := make(map[string]Focusable)
page1Comps["accept_btn"] = NewButton("接受协议")
page1Comps["reject_btn"] = NewButton("拒绝协议")
pageComponents[PageAgreement] = page1Comps
// ------------------ 页面2基础信息页面 --------------------
page2Comps := make(map[string]Focusable)
page2Comps["Homepage_input"] = NewTextInput("Homepage", cfg.HomePage)
page2Comps["ClusterName_input"] = NewTextInput("ClusterName", cfg.ClusterName)
page2Comps["Country_input"] = NewTextInput("Country", cfg.Country)
page2Comps["State_input"] = NewTextInput("State", cfg.State)
page2Comps["City_input"] = NewTextInput("City", cfg.City)
page2Comps["Contact_input"] = NewTextInput("Contact", cfg.Contact)
page2Comps["Timezone_input"] = NewTextInput("Timezone", cfg.Timezone)
page2Comps["DistroDir_input"] = NewTextInput("DistroDir", cfg.DistroDir)
page2Comps["next_btn"] = NewButton("下一步")
page2Comps["prev_btn"] = NewButton("上一步")
pageComponents[PageData] = page2Comps
// ------------------ 页面3公网网络页面 --------------------
page3Comps := make(map[string]Focusable)
page3Comps["PublicHostname_input"] = NewTextInput("PublicHostname", cfg.PublicHostname)
page3Comps["PublicInterface_input"] = NewTextInput("PublicInterface", cfg.PublicInterface)
page3Comps["PublicIPAddress_input"] = NewTextInput("PublicIPAddress", cfg.PublicIPAddress)
page3Comps["PublicNetmask_input"] = NewTextInput("PublicNetmask", cfg.PublicNetmask)
page3Comps["PublicGateway_input"] = NewTextInput("PublicGateway", cfg.PublicGateway)
page3Comps["PublicDomain_input"] = NewTextInput("PublicDomain", cfg.PublicDomain)
page3Comps["PublicMTU_input"] = NewTextInput("PublicMTU", cfg.PublicMTU)
page3Comps["next_btn"] = NewButton("下一步")
page3Comps["prev_btn"] = NewButton("上一步")
pageComponents[PagePublicNetwork] = page3Comps
// ------------------ 页面4内网网络页面 --------------------
page4Comps := make(map[string]Focusable)
page4Comps["PrivateHostname_input"] = NewTextInput("PrivateHostname", cfg.PrivateHostname)
page4Comps["PrivateInterface_input"] = NewTextInput("PrivateInterface", cfg.PrivateInterface)
page4Comps["PrivateIPAddress_input"] = NewTextInput("PrivateIPAddress", cfg.PrivateIPAddress)
page4Comps["PrivateNetmask_input"] = NewTextInput("PrivateNetmask", cfg.PrivateNetmask)
page4Comps["PrivateDomain_input"] = NewTextInput("PrivateDomain", cfg.PrivateDomain)
page4Comps["PrivateMTU_input"] = NewTextInput("PrivateMTU", cfg.PrivateMTU)
page4Comps["next_btn"] = NewButton("下一步")
page4Comps["prev_btn"] = NewButton("上一步")
pageComponents[PageInternalNetwork] = page4Comps
// ------------------ 页面5DNS页面 --------------------
page5Comps := make(map[string]Focusable)
page5Comps["Pri_DNS_input"] = NewTextInput("Pri DNS", cfg.DNSPrimary)
page5Comps["Sec_DNS_input"] = NewTextInput("Sec DNS", cfg.DNSSecondary)
page5Comps["next_btn"] = NewButton("下一步")
page5Comps["prev_btn"] = NewButton("上一步")
pageComponents[PageDNS] = page5Comps
// ------------------ 页面6Summary页面 --------------------
page6Comps := make(map[string]Focusable)
page6Comps["confirm_btn"] = NewButton("Confirm")
page6Comps["cancel_btn"] = NewButton("Cancel")
pageComponents[PageSummary] = page6Comps
// 创建焦点管理器(初始化聚焦页)
fm := NewFocusManager(true)
// 初始化模型
m := model{
config: cfg,
totalPages: 6,
currentPage: PageAgreement, // 初始化聚焦在协议页面
pageComponents: pageComponents,
focusManager: fm,
}
// 初始化当前页 (页1) 的焦点
m.initPageFocus(m.currentPage)
return m
}
// Init 初始化命令
func (m model) Init() tea.Cmd {
return textinput.Blink
}
// Update 处理消息更新
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
//m.quitting = true
return m, tea.Quit
// 1. 焦点切换Tab/Shift+Tab交给管理器处理
case "tab", "shift+tab", "left", "right":
cmd := m.focusManager.HandleInput(msg)
return m, cmd
// 2. 回车键:处理当前焦点组件的点击/确认
case "enter":
currentCompID := m.focusManager.currentFocusID
switch currentCompID {
// 页1accept → 进入页2
case "accept_btn":
return m, m.switchPage(PageData)
// 页1reject → 退出程序
case "reject_btn":
m.quitting = true
return m, tea.Quit
// 通用上一页/下一页逻辑
case "prev_btn":
return m, m.switchPage(m.currentPage - 1)
case "next_btn":
return m, m.switchPage(m.currentPage + 1)
// 页6确认配置 → 退出并保存
case "confirm_btn":
m.saveConfig()
m.done = true
//m.quitting = true
return m, tea.Quit
case "cancel_btn":
m.quitting = true
return m, tea.Quit
}
}
// 其他消息(窗口大小、输入框输入等)...
}
// 处理当前焦点组件的内部更新(比如输入框打字、列表选值)
currentComp, exists := m.focusManager.GetCurrent()
if exists {
// 不同组件的内部更新逻辑(示例)
switch comp := currentComp.(type) {
case *TextInput:
// 输入框更新
newTI, cmd := comp.Model.Update(msg)
comp.Model = newTI
// 保存输入值到全局配置(示例:主机名)
switch m.focusManager.currentFocusID {
// 页2基础信息
case "Homepage_input":
m.config.HomePage = comp.Value()
case "ClusterName_input":
m.config.ClusterName = comp.Value()
case "Country_input":
m.config.Country = comp.Value()
case "State_input":
m.config.State = comp.Value()
case "City_input":
m.config.City = comp.Value()
case "Contact_input":
m.config.Contact = comp.Value()
case "Timezone_input":
m.config.Timezone = comp.Value()
case "DistroDir_input":
m.config.DistroDir = comp.Value()
// 页3公网网络
case "PublicInterface_input":
m.config.PublicInterface = comp.Value()
case "PublicIPAddress_input":
m.config.PublicIPAddress = comp.Value()
case "PublicNetmask_input":
m.config.PublicNetmask = comp.Value()
case "PublicGateway_input":
m.config.PublicGateway = comp.Value()
case "PublicMTU_input":
m.config.PublicMTU = comp.Value()
// 页4内网网络
case "PrivateInterface_input":
m.config.PrivateInterface = comp.Value()
case "PrivateIPAddress_input":
m.config.PrivateIPAddress = comp.Value()
case "PrivateNetmask_input":
m.config.PrivateNetmask = comp.Value()
case "PrivateMTU_input":
m.config.PrivateMTU = comp.Value()
// 页5DNS
case "Pri_DNS_input":
m.config.DNSPrimary = comp.Value()
case "Sec_DNS_input":
m.config.DNSSecondary = comp.Value()
}
return m, cmd
case *List:
// 列表更新
newList, cmd := comp.Model.Update(msg)
comp.Model = newList
return m, cmd
}
}
return m, nil
}

359
pkg/wizard/pages.go Normal file
View File

@@ -0,0 +1,359 @@
package wizard
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
var split_line = splitlineStyle.Render(
"───────────────────────────────────────────────────────────────")
// View 渲染视图
func (m model) View() string {
if m.done {
return successView()
}
if m.quitting {
return quitView()
}
if m.err != nil {
return errorView(m.err)
}
var pageContent string
switch m.currentPage {
case PageAgreement:
pageContent = renderLicensePage(m)
case PageData:
pageContent = renderDataInfoPage(m)
case PagePublicNetwork:
pageContent = renderPublicNetworkPage(m)
case PageInternalNetwork:
pageContent = renderInternalNetworkPage(m)
case PageDNS:
pageContent = renderDNSPage(m)
case PageSummary:
pageContent = renderSummaryPage(m)
default:
pageContent = appStyle.Render("无效页面")
}
return appStyle.Render(pageContent)
}
func makeRow(label, value string) string {
return lipgloss.JoinHorizontal(lipgloss.Left,
labelStyle.Render(label+":"),
valueStyle.Render(value),
)
}
func renderLicensePage(m model) string {
title := titleStyle.Render("SunHPC Software License Agreement")
licenseText := `
───────────────────────────────────────────────────────────────
1. License Grant
This software grants you a non-exclusive, non-transferable
license to use it.
2. Disclaimer of Warranties
This software is provided "as is" without any warranties,
whether express or implied.
3. Limitation of Liability
This software is provided without warranty of any kind,
either express or implied.
In no event shall the author be liable for any damages
arising out of the use of this software.
4. Termination of Agreement
If you violate any of the terms of this agreement,
the license will automatically terminate.
───────────────────────────────────────────────────────────────
PLEASE READ THE ABOVE TERMS CAREFULLY AND CLICK "ACCEPT"
TO AGREE AND FOLLOW THIS AGREEMENT.
───────────────────────────────────────────────────────────────
`
pageComps := m.pageComponents[PageAgreement]
acceptBtn := pageComps["accept_btn"].View()
rejectBtn := pageComps["reject_btn"].View()
// ✅ 添加调试信息(确认 agreementIdx 的值)
// debugInfo := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).
// Render(fmt.Sprintf("[DEBUG: idx=%d]", m.agreementIdx),)
hint := hintStyle.Render("Use Left/Right OR Tab Change,Enter Confirm")
pageContent := lipgloss.JoinVertical(lipgloss.Center,
title, "",
licenseTextStyle.Render(licenseText),
lipgloss.JoinHorizontal(lipgloss.Center, acceptBtn, rejectBtn),
hint,
)
return appStyle.Render(pageContent)
}
// ---------------- 页2基础信息页渲染 ----------------
func renderDataInfoPage(m model) string {
pageComps := m.pageComponents[PageData]
// 拼接基础信息表单
formContent := lipgloss.JoinVertical(lipgloss.Center,
split_line,
makeRow("Homepage", pageComps["Homepage_input"].View()),
split_line,
makeRow("ClusterName", pageComps["ClusterName_input"].View()),
split_line,
makeRow("Country", pageComps["Country_input"].View()),
split_line,
makeRow("State", pageComps["State_input"].View()),
split_line,
makeRow("City", pageComps["City_input"].View()),
split_line,
makeRow("Contact", pageComps["Contact_input"].View()),
split_line,
makeRow("Timezone", pageComps["Timezone_input"].View()),
split_line,
makeRow("DistroDir", pageComps["DistroDir_input"].View()),
split_line,
)
// 按钮区域
btnArea := lipgloss.JoinHorizontal(
lipgloss.Center,
pageComps["next_btn"].View(),
pageComps["prev_btn"].View(),
)
hint := hintStyle.Render("Use Left/Right OR Tab Change,Enter Confirm")
// 页面整体
pageContent := lipgloss.JoinVertical(
lipgloss.Center,
titleStyle.Render("基础信息配置(页2/6)"),
formContent,
btnArea,
hint,
)
return appStyle.Render(pageContent)
}
func renderPublicNetworkPage(m model) string {
pageComps := m.pageComponents[PagePublicNetwork]
// 拼接公网网络表单
formContent := lipgloss.JoinVertical(lipgloss.Center,
split_line,
makeRow("PublicHostname", pageComps["PublicHostname_input"].View()),
split_line,
makeRow("PublicInterface", pageComps["PublicInterface_input"].View()),
split_line,
makeRow("PublicIPAddress", pageComps["PublicIPAddress_input"].View()),
split_line,
makeRow("PublicNetmask", pageComps["PublicNetmask_input"].View()),
split_line,
makeRow("PublicGateway", pageComps["PublicGateway_input"].View()),
split_line,
makeRow("PublicDomain", pageComps["PublicDomain_input"].View()),
split_line,
makeRow("PublicMTU", pageComps["PublicMTU_input"].View()),
split_line,
)
// 按钮区域
btnArea := lipgloss.JoinHorizontal(
lipgloss.Center,
pageComps["next_btn"].View(),
pageComps["prev_btn"].View(),
)
networkInterfaces := getNetworkInterfaces()
autoDetect := infoStyle.Render(
"[*] Auto Detect Interfaces: " + strings.Join(networkInterfaces, ", "))
hint := hintStyle.Render("Use Left/Right OR Tab Change,Enter Confirm")
// 页面整体
pageContent := lipgloss.JoinVertical(
lipgloss.Center,
titleStyle.Render("公网网络配置(页3/6)"),
autoDetect,
formContent,
btnArea,
hint,
)
return appStyle.Render(pageContent)
}
func renderInternalNetworkPage(m model) string {
pageComps := m.pageComponents[PageInternalNetwork]
// 拼接内网网络表单
formContent := lipgloss.JoinVertical(lipgloss.Center,
split_line,
makeRow("PrivateHostname", pageComps["PrivateHostname_input"].View()),
split_line,
makeRow("PrivateInterface", pageComps["PrivateInterface_input"].View()),
split_line,
makeRow("PrivateIPAddress", pageComps["PrivateIPAddress_input"].View()),
split_line,
makeRow("PrivateNetmask", pageComps["PrivateNetmask_input"].View()),
split_line,
makeRow("PrivateDomain", pageComps["PrivateDomain_input"].View()),
split_line,
makeRow("PrivateMTU", pageComps["PrivateMTU_input"].View()),
split_line,
)
// 按钮区域
btnArea := lipgloss.JoinHorizontal(
lipgloss.Center,
pageComps["next_btn"].View(),
pageComps["prev_btn"].View(),
)
hint := hintStyle.Render("Use Left/Right OR Tab Change,Enter Confirm")
// 页面整体
pageContent := lipgloss.JoinVertical(
lipgloss.Center,
titleStyle.Render("内网网络配置(页4/6)"),
formContent,
btnArea,
hint,
)
return appStyle.Render(pageContent)
}
func renderDNSPage(m model) string {
pageComps := m.pageComponents[PageDNS]
// 拼接 DNS 表单
formContent := lipgloss.JoinVertical(lipgloss.Center,
split_line,
makeRow("Pri DNS", pageComps["Pri_DNS_input"].View()),
split_line,
makeRow("Sec DNS", pageComps["Sec_DNS_input"].View()),
split_line,
)
// 按钮区域
btnArea := lipgloss.JoinHorizontal(
lipgloss.Center,
pageComps["next_btn"].View(),
pageComps["prev_btn"].View(),
)
hint := hintStyle.Render("Use Left/Right OR Tab Change,Enter Confirm")
// 页面整体
pageContent := lipgloss.JoinVertical(
lipgloss.Center,
titleStyle.Render("DNS 配置(页5/6)"),
formContent,
btnArea,
hint,
)
return appStyle.Render(pageContent)
}
func renderSummaryPage(m model) string {
pageComps := m.pageComponents[PageSummary]
// 拼接 Summary 表单
formContent := lipgloss.JoinVertical(lipgloss.Center,
split_line,
makeRow("ClusterName", m.config.ClusterName),
split_line,
makeRow("Country", m.config.Country),
split_line,
makeRow("State", m.config.State),
split_line,
makeRow("City", m.config.City),
split_line,
makeRow("Contact", m.config.Contact),
split_line,
makeRow("Timezone", m.config.Timezone),
split_line,
makeRow("Homepage", m.config.HomePage),
split_line,
makeRow("DBPath", m.config.DBAddress),
split_line,
makeRow("DistroDir", m.config.DistroDir),
split_line,
makeRow("PublicInterface", m.config.PublicInterface),
split_line,
makeRow("PublicIPAddress", m.config.PublicIPAddress),
split_line,
makeRow("PublicNetmask", m.config.PublicNetmask),
split_line,
makeRow("PublicGateway", m.config.PublicGateway),
split_line,
makeRow("PrivateInterface", m.config.PrivateInterface),
split_line,
makeRow("PrivateIPAddress", m.config.PrivateIPAddress),
split_line,
makeRow("PrivateNetmask", m.config.PrivateNetmask),
split_line,
makeRow("PrivateMTU", m.config.PrivateMTU),
split_line,
makeRow("Pri DNS", m.config.DNSPrimary),
split_line,
makeRow("Sec DNS", m.config.DNSSecondary),
split_line,
)
// 按钮区域
btnArea := lipgloss.JoinHorizontal(
lipgloss.Center,
pageComps["confirm_btn"].View(),
pageComps["cancel_btn"].View(),
)
hint := hintStyle.Render("Use Left/Right OR Tab Change,Enter Confirm")
// 页面整体
pageContent := lipgloss.JoinVertical(
lipgloss.Center,
titleStyle.Render("确认信息(页6/6)"),
formContent,
btnArea,
hint,
)
return appStyle.Render(pageContent)
}
func successView() string {
content := lipgloss.JoinVertical(lipgloss.Center,
successTitle.Render("Initialization Completed!"), "",
successMsg.Render(
"System configuration has been saved, and the system is initializing..."), "",
)
return appStyle.Render(content)
}
func quitView() string {
content := lipgloss.JoinVertical(lipgloss.Center,
split_line,
errorTitle.Render("Canceled"), "",
errorMsg.Render("Initialization canceled, no configuration saved"),
split_line,
"\n",
)
return quitStyle.Render(content)
}
func errorView(err error) string {
content := lipgloss.JoinVertical(lipgloss.Center,
errorTitle.Render("Error"), "",
errorMsg.Render(err.Error()), "",
hintStyle.Render("Press Ctrl+C to exit"),
)
return appStyle.Render(content)
}

118
pkg/wizard/styles.go Normal file
View File

@@ -0,0 +1,118 @@
package wizard
import "github.com/charmbracelet/lipgloss"
// 颜色定义
var (
primaryColor = lipgloss.Color("#7C3AED")
secondaryColor = lipgloss.Color("#10B981")
titleColor = lipgloss.Color("#8b19a2")
errorColor = lipgloss.Color("#EF4444")
warnColor = lipgloss.Color("#F59E0B")
btnTextColor = lipgloss.Color("#666666") // 深灰色
btnbordColor = lipgloss.Color("#3b4147")
btnFocusColor = lipgloss.Color("#ffffff")
// 背景色设为无,让终端自己的背景色生效,避免黑块
bgColor = lipgloss.Color("#1F2937")
textColor = lipgloss.Color("#FFFFFF")
mutedColor = lipgloss.Color("#B0B0B0")
)
// 容器样式
var (
// 基础布局样式
appStyle = lipgloss.NewStyle().
Padding(1, 1).
MarginBottom(1).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Foreground(textColor).
Align(lipgloss.Center)
//Height(40)
// 标题样式
titleStyle = lipgloss.NewStyle().
Foreground(titleColor).
Padding(0, 1).
Bold(true).
Align(lipgloss.Center)
// 子标题/标签样式
labelStyle = lipgloss.NewStyle().
Width(30).
Align(lipgloss.Right).
PaddingRight(2)
valueStyle = lipgloss.NewStyle().
Foreground(textColor).
Width(50)
// 输入框/列表内容样式
inputBoxStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(btnbordColor).
Padding(0, 1).
Width(50)
// 按钮基础样式
btnBaseStyle = lipgloss.NewStyle().
Foreground(btnTextColor).
Padding(0, 2).
Margin(1, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(btnbordColor)
// 按钮选中/聚焦样式
btnSelectedStyle = lipgloss.NewStyle().
Foreground(btnFocusColor).
Padding(0, 2).
Margin(1, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(btnbordColor)
splitlineStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
// 协议文本样式
licenseTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#f8f8f2")).
Width(76)
// 提示文本样式
hintStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Width(76)
infoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Border(lipgloss.RoundedBorder()).
BorderForeground(btnbordColor)
// 成功/错误提示样式
successTitle = lipgloss.NewStyle().
Foreground(secondaryColor).
Bold(true)
successMsg = lipgloss.NewStyle().
Foreground(textColor)
// quit 提示样式
quitStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Bold(true).
Width(76)
// 错误提示样式
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#ff5555")).
Bold(true).
Width(76)
errorTitle = lipgloss.NewStyle().
Bold(true).
Foreground(errorColor)
errorMsg = lipgloss.NewStyle().
Foreground(textColor)
)

21
pkg/wizard/wizard.go Normal file
View File

@@ -0,0 +1,21 @@
package wizard
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
// Run 启动初始化向导
func Run(force bool) error {
// 创建程序实例
p := tea.NewProgram(initialModel())
// 运行程序
if _, err := p.Run(); err != nil {
return fmt.Errorf("初始化向导运行失败:%w", err)
}
return nil
}