AutoK3s v0.9.3 辅助模块超深度逐行分析
分析范围:airgap / sshkey / utils / settings / metrics / cli(kubectl)
代码目录:
/home/lin/project/src/github.com/autok3s-0.9.3总计文件:21个源文件(含测试),约2,400行代码
目录
- [1. 模块定位总览](#1. 模块定位总览)
- [2. airgap 模块------离线包管理](#2. airgap 模块——离线包管理)
- [2.1 archs.go --- 架构支持](#2.1 archs.go — 架构支持)
- [2.2 download.go --- 离线包下载](#2.2 download.go — 离线包下载)
- [2.3 utils.go --- 离线包工具函数](#2.3 utils.go — 离线包工具函数)
- [2.4 file_scp.go --- SCP文件传输](#2.4 file_scp.go — SCP文件传输)
- [2.5 tarfiles.go --- 打包/解包](#2.5 tarfiles.go — 打包/解包)
- [2.6 测试文件分析](#2.6 测试文件分析)
- [2.7 离线包下载流程 Mermaid 图](#2.7 离线包下载流程 Mermaid 图)
- [2.8 SCP传输流程 Mermaid 图](#2.8 SCP传输流程 Mermaid 图)
- [3. sshkey 模块------集群SSH密钥管理](#3. sshkey 模块——集群SSH密钥管理)
- [3.1 clusterkey.go --- 集群密钥存储](#3.1 clusterkey.go — 集群密钥存储)
- [3.2 utils.go --- SSH密钥工具](#3.2 utils.go — SSH密钥工具)
- [3.3 密钥生成流程 Mermaid 图](#3.3 密钥生成流程 Mermaid 图)
- [4. utils 模块------通用工具集](#4. utils 模块——通用工具集)
- [4.1 errgroup.go --- 并发错误组](#4.1 errgroup.go — 并发错误组)
- [4.2 file.go --- 文件操作工具](#4.2 file.go — 文件操作工具)
- [4.3 flag.go --- Flag转换工具](#4.3 flag.go — Flag转换工具)
- [4.4 keys.go --- 密钥生成](#4.4 keys.go — 密钥生成)
- [4.5 registry.go --- Provider注册查询](#4.5 registry.go — Provider注册查询)
- [4.6 ssh.go --- SSH工具函数](#4.6 ssh.go — SSH工具函数)
- [4.7 util.go --- 通用工具](#4.7 util.go — 通用工具)
- [4.8 registry_test.go --- 测试](#4.8 registry_test.go — 测试)
- [4.9 Flag转换流程 Mermaid 图](#4.9 Flag转换流程 Mermaid 图)
- [5. settings 模块------系统设置](#5. settings 模块——系统设置)
- [5.1 setting.go --- 设置键值存储](#5.1 setting.go — 设置键值存储)
- [5.2 script/main.go --- 安装脚本下载器](#5.2 script/main.go — 安装脚本下载器)
- [5.3 script_prod.go --- 生产环境嵌入](#5.3 script_prod.go — 生产环境嵌入)
- [6. metrics 模块------Prometheus指标](#6. metrics 模块——Prometheus指标)
- [7. cli/kubectl 模块------kubectl嵌入](#7. cli/kubectl 模块——kubectl嵌入)
- [8. 模块间依赖关系](#8. 模块间依赖关系)
- [9. 设计模式与架构总结](#9. 设计模式与架构总结)
1. 模块定位总览
┌─────────────────────────────────────────────────────────────────────┐
│ AutoK3s v0.9.3 架构 │
├─────────────────────────────────────────────────────────────────────┤
│ CLI Layer (cmd/) │
│ ├── cobra commands → 调用 utils/flag.go 转换flags │
│ └── kubectl reexec → cli/kubectl/kubectl.go │
├─────────────────────────────────────────────────────────────────────┤
│ Provider Layer (pkg/providers/) │
│ ├── 调用 sshkey 模块管理集群密钥 │
│ ├── 调用 airgap 模块实现离线安装 │
│ └── 调用 utils 模块中的SSH/文件/并发工具 │
├─────────────────────────────────────────────────────────────────────┤
│ Support Modules (本文分析范围) │
│ ├── airgap/ 离线包下载、传输、校验、打包 │
│ ├── sshkey/ 集群SSH密钥生成、存储、分发 │
│ ├── utils/ 通用工具集(Flag/SSH/File/Keys/并发/注册表) │
│ ├── settings/ 系统设置键值存储 + 安装脚本管理 │
│ ├── metrics/ Prometheus遥测指标采集与推送 │
│ └── cli/kubectl/ 嵌入式kubectl │
├─────────────────────────────────────────────────────────────────────┤
│ Common Layer (pkg/common/) │
│ └── DB / CfgPath / Package 等公共依赖 │
└─────────────────────────────────────────────────────────────────────┘
各辅助模块职责:
| 模块 | 职责 | 核心依赖 |
|---|---|---|
| airgap | K3s离线包的下载(GitHub Release)、SCP传输到远程节点、tar打包/解包、sha256校验 | common, settings, hosts/dialer, sftp |
| sshkey | 集群SSH密钥的生成(RSA)、存储到集群上下文目录、密钥验证 | common, utils, golang.org/x/crypto/ssh |
| utils | Flag转换(cobra→struct)、SSH配置生成、密钥对生成、文件操作、并发错误组、registry解析 | cobra, pflag, golang.org/x/crypto/ssh, wharfie |
| settings | 系统级设置键值存储,支持Provider后端;安装脚本管理(嵌入/下载) | net/http, embed |
| metrics | Prometheus指标定义(集群数/模板数/运行状态),定时推送至telemetry.rancher.cn | prometheus client |
| cli/kubectl | 嵌入完整的kubectl命令行工具 | k8s.io/kubectl |
2. airgap 模块------离线包管理
2.1 archs.go --- 架构支持
文件定位:定义支持的CPU架构集合及架构差异计算。
go
// 第1-6行:import
package airgap
import (
"fmt"
"sort"
)
全局变量 (在 download.go 中定义,此处使用):
go
// download.go 第33-38行
ValidatedArch = map[string]bool{
"arm64": true,
"amd64": true,
"arm": true,
"s390s": true, // 注意:s390s 而非 s390x,可能是笔误
}
⚠️ 潜在问题 :
s390s应为s390x(IBM Z架构的标准Go GOARCH值),此处可能是一个拼写错误。
函数逐行分析:
ValidateArchs(archs []string) error(第9-15行)
go
func ValidateArchs(archs []string) error {
for _, arch := range archs {
if !ValidatedArch[arch] {
return fmt.Errorf("arch %s is not validated", arch)
}
}
return nil
}
- 遍历传入的架构列表,逐一检查是否在
ValidatedArch白名单中 - 只要有任何一个不在白名单中,立即返回错误
- 用于在用户指定
--arch参数时的输入验证
GetValidatedArchs() []string(第17-25行)
go
func GetValidatedArchs() []string {
var rtn []string
for arch := range ValidatedArch {
rtn = append(rtn, arch)
}
sort.Strings(rtn)
return rtn
}
- 从map中提取所有支持的架构名
- 排序后返回,确保输出顺序确定性(map遍历顺序在Go中不保证)
- 用于API响应或CLI帮助信息
GetArchDiff(current, target []string) (add, del []string)(第27-41行)
go
func GetArchDiff(current, target []string) (add, del []string) {
currentMap := map[string]bool{}
for _, arch := range current {
currentMap[arch] = true
}
for _, arch := range target {
if !currentMap[arch] {
add = append(add, arch) // target中有但current中没有 → 需要新增
} else {
delete(currentMap, arch) // 从currentMap中移除,剩下的就是要删除的
}
}
for arch := range currentMap {
del = append(del, arch) // currentMap中剩余的 → 需要删除
}
return
}
- 经典的集合差异算法:计算"需要新增的架构"和"需要删除的架构"
- 时间复杂度 O(n+m),利用map实现O(1)查找
- 用于版本更新时判断哪些架构需要重新下载、哪些需要清理
2.2 download.go --- 离线包下载
文件定位:airgap模块的核心,负责从GitHub Release(或阿里云OSS镜像)下载K3s离线安装资源。
常量定义(第17-28行)
go
const (
tmpDirName = ".tmp" // 临时目录名
tmpSuffix = ".tmp" // 临时文件后缀
doneFilename = ".done" // 完成标记文件名
versionFilename = "version.json" // 版本信息文件名
imageListFilename = "k3s-images.txt" // K3s镜像列表文件名
checksumBaseName = "sha256sum" // 校验和文件基础名
checksumExt = ".txt" // 校验和文件扩展名
checksumFilename = checksumBaseName + checksumExt // "sha256sum.txt"
)
这些常量定义了离线包目录结构中的关键文件名:
.done文件作为原子性的完成标记------只有所有文件下载并校验通过后才创建version.json记录当前包的K3s版本和支持架构列表k3s-images.txt是K3s项目自身的镜像清单文件sha256sum.txt是K3s Release提供的校验和文件
全局变量(第30-45行)
go
var (
ErrVersionNotFound = errors.New("version not found")
separator = regexp.MustCompile(" +") // 匹配一个或多个空格
ValidatedArch = map[string]bool{
"arm64": true,
"amd64": true,
"arm": true,
"s390s": true,
}
resourceSuffixes = map[string][]string{
"k3s": {""}, // k3s二进制文件,amd64无后缀
"k3s-airgap-images": {".tar.gz", ".tar"}, // 镜像包,尝试两种压缩格式
checksumBaseName: {checksumExt}, // sha256sum.txt
}
cancelDownloadMap = &sync.Map{} // 包名→cancel函数的并发安全映射
)
resourceSuffixes 定义了每种资源在不同架构下的文件名后缀规则:
k3s:amd64时无后缀(直接是k3s),arm时是k3s-armhf,arm64时是k3s-arm64k3s-airgap-images:尝试.tar.gz优先,回退到.tarsha256sum:固定为sha256sum.txt
cancelDownloadMap 支持取消正在进行的下载------通过包名查找对应的 context.CancelFunc 并调用。
version 结构体及 diff 方法(第47-61行)
go
type version struct {
Version string
Archs []string
}
func (v *version) diff(pkg common.Package) (toAdd, toDel []string) {
if v == nil {
toAdd = pkg.Archs
return
}
if v.Version != pkg.K3sVersion {
toAdd = pkg.Archs
toDel = v.Archs
return
}
return GetArchDiff(v.Archs, pkg.Archs)
}
diff 方法的三种情况:
- 无已有版本(v == nil):所有目标架构都是新增
- 版本号不同:全部重新下载(toAdd = 全部目标),全部删除旧的(toDel = 旧的架构)
- 版本号相同:仅增量更新差异架构
DownloadPackage 入口函数(第64-89行)
go
func DownloadPackage(pkg common.Package, logger logrus.FieldLogger) error {
ctx, cancel := context.WithCancel(context.Background())
downloader := &downloader{
ctx: ctx,
pkg: pkg,
basePath: PackagePath(pkg.Name),
}
downloader.sourceURL = getSourceURL(pkg.K3sVersion)
fields := logrus.Fields{
"package": pkg.Name,
"version": pkg.K3sVersion,
}
if logger != nil {
downloader.logger = logger.WithFields(fields)
} else {
downloader.logger = logrus.WithFields(fields)
}
sort.Strings(downloader.pkg.Archs)
cancelDownloadMap.Store(pkg.Name, cancel)
defer func() {
cancelDownloadMap.Delete(pkg.Name)
cancel()
}()
return downloader.downloadPackage()
}
逐行分析:
- 创建可取消的context
- 构造
downloader结构体,设置基础路径(PackagePath基于~/.autok3s/package/<name>) - 通过
getSourceURL根据设置选择下载源(GitHub或阿里云OSS) - 设置日志字段(包名+版本号),支持外部传入logger或使用默认logger
- 对架构列表排序(确保确定性)
- 将cancel函数存入全局map(支持外部取消)
- defer中清理cancel函数并调用cancel释放资源
- 调用
downloadPackage执行实际下载
downloader 结构体(第91-99行)
go
type downloader struct {
ctx context.Context
sourceURL string
basePath string
imageListContent []byte // 缓存的k3s-images.txt内容
pkg common.Package
logger logrus.FieldLogger
}
imageListContent 在 validateVersion 阶段下载一次,后续每个架构都复用此内容写入本地文件,避免重复下载。
downloadPackage 核心逻辑(第101-157行)
go
func (d *downloader) downloadPackage() (er error) {
// 1. 读取已有版本信息
version, err := versionAndBasePath(d.basePath)
if err != nil {
return err
}
// 2. 计算架构差异
toAddArchs, toDelArchs := version.diff(d.pkg)
// 3. 如果无差异且已完成,跳过下载
if len(toAddArchs) == 0 &&
len(toDelArchs) == 0 &&
isDone(d.basePath) {
d.logger.Infof("the package %s is ready, skip downloading resources.", d.pkg.Name)
if d.pkg.State != common.PackageActive {
return updatePackageState(&d.pkg, common.PackageActive)
}
return nil
}
// 4. 清理需要删除的架构目录
for _, arch := range toDelArchs {
d.logger.Infof("removing package arch %s", arch)
if err := os.RemoveAll(filepath.Join(d.basePath, arch)); err != nil {
return err
}
}
// 5. 按顺序执行reconcile步骤
for _, reconcile := range []struct {
state common.State
f func() error
}{
{
state: common.PackageValidating,
f: d.validateVersion, // 验证版本存在(下载k3s-images.txt)
},
{f: d.writeVersion}, // 写入version.json
{
state: common.PackageDownloading,
f: func() error { // 下载所有架构资源
for _, arch := range d.pkg.Archs {
if err := os.MkdirAll(filepath.Join(d.basePath, arch), 0755); err != nil {
return err
}
d.logger.Infof("download %s resources", arch)
if err := d.downloadArch(arch); err != nil {
return err
}
}
return nil
},
},
{f: func() error { return done(d.basePath) }}, // 标记整体完成
{state: common.PackageVerifying, f: func() error {
_, err := VerifyFiles(d.basePath) // 校验所有文件
return err
}},
} {
// 检查context是否已取消
if d.ctx.Err() != nil {
return d.ctx.Err()
}
// 更新包状态
if reconcile.state != "" {
if err := updatePackageState(&d.pkg, reconcile.state); err != nil {
return err
}
}
// 执行步骤
if err := reconcile.f(); err != nil {
// 失败时将状态设为OutOfSync
if er := updatePackageState(&d.pkg, common.PackageOutOfSync); er != nil {
d.logger.Warnf("failed to set package %s to %s state", d.pkg.Name, common.PackageOutOfSync)
}
return err
}
}
// 6. 设置最终路径和状态
d.pkg.FilePath = d.basePath
if err := updatePackageState(&d.pkg, common.PackageActive); err != nil {
return err
}
return err
}
状态机流转:
PackageValidating → (验证版本) → PackageDownloading → (下载) → PackageVerifying → (校验) → PackageActive
↓ (失败)
PackageOutOfSync
设计亮点:
- 使用匿名struct切片定义有序的执行步骤,代码清晰
- 每个步骤前检查context取消,支持优雅中断
- 任何步骤失败都将状态回退到
OutOfSync,保证状态一致性 .done文件作为幂等性标记------重复调用DownloadPackage时,如果已完成则跳过
downloadArch 单架构下载(第159-198行)
go
func (d *downloader) downloadArch(arch string) error {
// 1. HEAD请求检查架构资源是否存在
if err := d.checkArchExists(arch); err != nil {
return err
}
basePath := filepath.Join(d.basePath, arch)
// 2. 如果该架构已完成,跳过
if isDone(basePath) {
d.logger.Infof("arch %s has downloaded, skipped download process.", arch)
return nil
}
// 3. 写入k3s-images.txt(从validateVersion缓存的content)
archImageList := filepath.Join(basePath, imageListFilename)
if err := ioutil.WriteFile(archImageList, d.imageListContent, 0644); err != nil {
return err
}
// 4. 遍历所有资源类型,下载文件
for basename, v := range resourceSuffixes {
suffixes := getSuffixMapWithArchs(arch, basename, v)
for origin, suffix := range suffixes {
localFileName := basename + origin
fullPath := filepath.Join(basePath, localFileName)
// 4a. 如果本地文件已存在,跳过该资源
if _, err := os.Lstat(fullPath); err == nil {
d.logger.Infof("%s resource %s exists, skip downloading", arch, basename)
break
}
// 4b. 构造远程文件名并下载
resourceName := basename + suffix
d.logger.Infof("downloading %s for %s", localFileName, arch)
err := d.download(fullPath, d.getFileURL(resourceName))
if err != nil && err == context.Canceled {
// 取消错误直接返回
d.logger.Warnf("failed to download resource %s for %s because of context cancel", localFileName, arch)
return err
} else if err != nil {
// 其他错误跳过(尝试下一个suffix)
d.logger.Warnf("failed to download resource %s for %s, skip this resource, %v", localFileName, arch, err)
continue
}
break // 下载成功,跳出suffix循环
}
}
// 5. 校验该架构的所有文件
if err := verifyArchFiles(arch, d.basePath); err != nil {
return err
}
d.logger.Infof("all downloaded files are validated for %s", arch)
// 6. 标记该架构完成
if err := done(basePath); err != nil {
return err
}
d.logger.Infof("k3s resource for %s downloaded.", arch)
return nil
}
多后缀回退机制 :
以 k3s-airgap-images 为例,suffixes = [".tar.gz", ".tar"]:
- 先尝试下载
k3s-airgap-images-arm64.tar.gz - 如果失败,尝试
k3s-airgap-images-arm64.tar - 如果其中一个成功,break跳出循环
这是一个实用的容错设计,因为不同K3s版本可能使用不同的压缩格式。
validateVersion 版本验证(第209-224行)
go
func (d *downloader) validateVersion() error {
sourceURL := getSourceURL(d.pkg.K3sVersion)
downloadURL := fmt.Sprintf("%s/%s", sourceURL, imageListFilename)
resp, err := doRequestWithCtx(d.ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return errors.Wrapf(err, "failed to download image list of k3s version %s, this version may be not validated", d.pkg.K3sVersion)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
content, _ := ioutil.ReadAll(resp.Body)
d.logger.Debugf("failed to download image list resource, status code %d, data %s", resp.StatusCode, string(content))
return ErrVersionNotFound
}
d.imageListContent, err = ioutil.ReadAll(resp.Body)
return err
}
- 下载
k3s-images.txt验证版本是否存在 - 将内容缓存在
d.imageListContent中,后续每个架构复用 - 非2xx状态码返回
ErrVersionNotFound
writeVersion 写入版本文件(第227-234行)
go
func (d *downloader) writeVersion() error {
versionPath := filepath.Join(d.basePath, versionFilename)
versionJSON := versionContent(d.pkg)
_ = os.RemoveAll(versionPath)
_ = os.RemoveAll(getDonePath(d.basePath)) // 同时删除.done标记
d.logger.Info("generating version file")
return ioutil.WriteFile(versionPath, versionJSON, 0644)
}
⚠️ 问题 :
os.RemoveAll的错误被_忽略。如果文件不存在,RemoveAll返回nil;但如果权限不足,错误被吞掉可能导致后续问题。不过RemoveAll对不存在的路径返回nil,所以大多数情况下是安全的。
checkArchExists 架构存在检查(第237-247行)
go
func (d *downloader) checkArchExists(arch string) error {
target := checksumBaseName + "-" + arch + checksumExt
// 例如:sha256sum-arm64.txt
resp, err := doRequestWithCtx(d.ctx, http.MethodHead, d.getFileURL(target), nil)
if err != nil {
return err
}
defer resp.Body.Close()
if int(resp.StatusCode/100) != 2 {
return fmt.Errorf("%s may not exist", arch)
}
return nil
}
- 使用HEAD请求检查特定架构的checksum文件是否存在
int(resp.StatusCode/100) != 2是一种简洁的2xx检查方式- 例如检查
sha256sum-arm64.txt是否存在于Release中
download 单文件下载(第249-273行)
go
func (d *downloader) download(file, fromURL string) error {
resp, err := doRequestWithCtx(d.ctx, http.MethodGet, fromURL, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if int(resp.StatusCode/100) != 2 {
content, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf("failed to download resource %s, %s", fromURL, string(content))
}
tmpFile := file + ".tmp"
_ = os.RemoveAll(tmpFile)
fp, err := os.Create(tmpFile)
if err != nil {
return err
}
defer fp.Close()
if _, err := io.Copy(fp, resp.Body); err != nil {
return err
}
return os.Rename(tmpFile, file)
}
原子写入模式:
- 下载到
.tmp临时文件 - 下载完成后
os.Rename原子重命名为目标文件名 - 如果下载中断,临时文件不会被视为有效文件
- 下次重试时,先删除旧的临时文件
这是文件下载的best practice------rename 在同一文件系统上是原子的,避免了部分写入的文件被误认为已完成。
versionContent / versionAndBasePath(第275-322行)
go
func versionContent(pkg common.Package) []byte {
version := version{
Version: pkg.K3sVersion,
Archs: pkg.Archs,
}
data, _ := json.Marshal(version)
return data
}
- 将Package信息序列化为JSON
- 忽略marshal错误(实际上不会失败,因为结构体只有string和\[\]string)
go
func versionAndBasePath(basePath string) (*version, error) {
rtn, err := os.Lstat(basePath)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
// 1. 路径不存在 → 创建目录,返回nil version
if os.IsNotExist(err) {
if err := os.MkdirAll(basePath, 0755); err != nil {
return nil, err
}
return nil, nil
}
// 2. 路径存在但不是目录 → 错误
if !rtn.IsDir() {
return nil, fmt.Errorf("package path %s must be a directory", basePath)
}
// 3. 读取version.json
versionPath := filepath.Join(basePath, versionFilename)
data, err := ioutil.ReadFile(versionPath)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
v := version{}
if err := json.Unmarshal(data, &v); err != nil {
// JSON解析失败 → 警告但不报错,version视为空
logrus.Warnf("failed to decode existing version json, assuming no version specified, %v", err)
v.Version = ""
} else {
if isDone(basePath) {
return &v, nil // 有version.json且有.done → 直接返回
}
}
// 4. 没有version.json或没有.done → 扫描目录推断已有架构
contents, err := ioutil.ReadDir(basePath)
if err != nil {
return nil, err
}
for _, f := range contents {
if f.IsDir() && ValidatedArch[f.Name()] {
v.Archs = append(v.Archs, f.Name())
}
}
return &v, nil
}
逻辑分支:
- 目录不存在 → 创建,返回nil(全新包)
- 目录存在但有version.json且.done → 返回已有版本(完整包)
- 目录存在但无version.json或无.done → 扫描子目录推断架构列表(部分下载的包)
第3种情况的推断逻辑:扫描目录下的子目录名,如果是有效架构名则加入列表。这是一种自愈机制------即使version.json丢失或损坏,也能从文件系统结构恢复部分信息。
CancelDownload 取消下载(第324-333行)
go
func CancelDownload(name string) error {
f, loaded := cancelDownloadMap.Load(name)
if !loaded {
return fmt.Errorf("no downloader for package %s", name)
}
if cancel, ok := f.(context.CancelFunc); ok {
cancel()
}
return nil
}
- 从全局
sync.Map中查找对应包名的cancel函数 - 调用cancel触发context取消,所有使用该context的HTTP请求和文件操作会中断
- 类型断言确保安全调用
2.3 utils.go --- 离线包工具函数
全局变量(第26-35行)
go
var (
packagePath = filepath.Join(common.CfgPath, "package") // ~/.autok3s/package/
packageTmpBasePath = filepath.Join(packagePath, tmpDirName) // ~/.autok3s/package/.tmp/
downloadSourceMap = map[string]string{
"github": "https://github.com/k3s-io/k3s/releases/download",
"aliyunoss": "https://rancher-mirror.rancher.cn/k3s",
}
client = http.Client{
Timeout: 45 * time.Second, // HTTP客户端超时
}
)
下载源:
github:K3s官方GitHub Releasealiyunoss:Rancher中国镜像站
getSourceURL 构造下载URL(第37-49行)
go
func getSourceURL(version string) string {
source := settings.PackageDownloadSource.Get()
baseURL := downloadSourceMap[source]
if baseURL == "" {
source = "github" // 未知源回退到GitHub
baseURL = downloadSourceMap[source]
}
versionPath := version
if source == "aliyunoss" {
versionPath = strings.ReplaceAll(versionPath, "+", "-")
// 阿里云OSS不支持"+"字符,替换为"-"
}
versionPath = url.QueryEscape(versionPath)
return fmt.Sprintf("%s/%s", baseURL, versionPath)
}
- 从settings读取下载源配置
- 阿里云OSS需要将版本号中的
+替换为-(如v1.25.3+k3s1→v1.25.3-k3s1) url.QueryEscape处理特殊字符
VerifyFiles 包验证(第63-82行)
go
func VerifyFiles(basePath string) (*common.Package, error) {
version, err := versionAndBasePath(basePath)
if err != nil {
return nil, err
}
if version == nil {
return nil, errors.New("version.json is missing")
}
for _, arch := range version.Archs {
archBase := filepath.Join(basePath, arch)
if !isDone(archBase) {
return nil, fmt.Errorf("%s resources aren't available", arch)
}
if err := verifyArchFiles(arch, basePath); err != nil {
return nil, err
}
}
return &common.Package{
Archs: version.Archs,
K3sVersion: version.Version,
}, nil
}
验证流程:
- 读取version.json获取版本和架构列表
- 对每个架构检查
.done标记是否存在 - 对每个架构执行
verifyArchFiles校验文件哈希 - 返回验证通过的Package信息
verifyArchFiles 架构文件校验(第96-126行)
go
func verifyArchFiles(arch, basePath string) error {
archBase := filepath.Join(basePath, arch)
checksumMap, err := getHashMapFromFile(filepath.Join(archBase, checksumFilename))
if err != nil {
return errors.Wrapf(err, "failed to get file hash map for arch %s", arch)
}
for basename, v := range resourceSuffixes {
if basename == checksumBaseName {
continue // 不校验checksum文件本身
}
checked := false
for origin, suffix := range getSuffixMapWithArchs(arch, basename, v) {
localFileName := basename + origin
resourceName := basename + suffix
ok, err := checkFileHash(filepath.Join(archBase, localFileName), checksumMap[resourceName])
if os.IsNotExist(err) {
continue // 文件不存在,尝试下一个suffix
}
if !ok {
return fmt.Errorf("checksum for file %s/%s mismatch", arch, localFileName)
}
checked = true
break
}
if !checked {
return fmt.Errorf("resource %s for %s check fail", basename, arch)
}
}
return nil
}
校验逻辑:
- 读取
sha256sum.txt解析为map[文件名]哈希值 - 对每种资源(k3s二进制、镜像包):
- 尝试所有可能的suffix(如
.tar.gz和.tar) - 找到本地文件后计算sha256,与checksumMap中的值比较
- 匹配则标记checked,不匹配返回错误
- 尝试所有可能的suffix(如
- 所有资源都必须checked,否则报错
getSuffixMapWithArchs 架构后缀映射(第128-142行)
go
func getSuffixMapWithArchs(arch, baseName string, suffixes []string) map[string]string {
rtn := make(map[string]string, len(suffixes))
for _, suffix := range suffixes {
if baseName == "k3s" && arch == "amd64" {
rtn[suffix] = suffix // amd64: k3s → k3s(无架构后缀)
} else if baseName == "k3s" && arch == "arm" {
rtn[suffix] = "-armhf" + suffix // arm: k3s → k3s-armhf
} else {
rtn[suffix] = "-" + arch + suffix // 其他: k3s → k3s-arm64, k3s-airgap-images → k3s-airgap-images-arm64.tar.gz
}
}
return rtn
}
映射规则表:
| 资源 | 架构 | 本地文件名 | 远程文件名 |
|---|---|---|---|
| k3s | amd64 | k3s | k3s |
| k3s | arm | k3s | k3s-armhf |
| k3s | arm64 | k3s | k3s-arm64 |
| k3s-airgap-images | amd64 | k3s-airgap-images.tar.gz | k3s-airgap-images-amd64.tar.gz |
| k3s-airgap-images | arm64 | k3s-airgap-images.tar.gz | k3s-airgap-images-arm64.tar.gz |
| sha256sum | * | sha256sum.txt | sha256sum-amd64.txt / sha256sum-arm64.txt |
注意:本地文件名不包含架构后缀(因为文件已经位于架构子目录中),远程文件名包含架构后缀。
getExt 扩展名解析(第144-155行)
go
func getExt(filename string) (string, string) {
name := filename
var ext, currentExt string
for currentExt = filepath.Ext(name); currentExt != ""; currentExt = filepath.Ext(name) {
ext = currentExt + ext
name = strings.TrimSuffix(name, currentExt)
}
return name, ext
}
- 循环剥离扩展名,处理多段扩展名
- 例如
abc.tar.gz→ name=abc, ext=.tar.gz - 例如
file.txt→ name=file, ext=.txt
getHashMapFromFile 校验和文件解析(第157-175行)
go
func getHashMapFromFile(path string) (map[string]string, error) {
fp, err := os.Open(path)
if err != nil {
return nil, errors.Wrapf(err, "checksum file not found")
}
defer fp.Close()
checksumMap := map[string]string{}
reader := bufio.NewReader(fp)
for {
line, _, err := reader.ReadLine()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
arr := separator.Split(string(line), 2)
checksumMap[filepath.Base(arr[1])] = arr[0]
}
return checksumMap, nil
}
解析 sha256sum.txt 文件格式:
a1b2c3d4... k3s-arm64
e5f6g7h8... k3s-airgap-images-arm64.tar.gz
- 每行用正则
" +"(一个或多个空格)分割为两部分 arr[0]= sha256哈希值,arr[1]= 文件名- 使用
filepath.Base确保只取文件名部分(去除可能的路径前缀)
checkFileHash 文件哈希校验(第177-188行)
go
func checkFileHash(filepath, targetHash string) (bool, error) {
hasher := sha256.New()
fp, err := os.Open(filepath)
if err != nil {
return false, err
}
defer fp.Close()
if _, err := io.Copy(hasher, fp); err != nil {
return false, err
}
return fmt.Sprintf("%x", hasher.Sum(nil)) == targetHash, nil
}
- 创建SHA256 hasher
- 流式读取文件内容并计算哈希(适合大文件)
- 将计算结果格式化为十六进制字符串与目标哈希比较
.done 标记管理(第190-201行)
go
func isDone(basePath string) bool {
done, _ := os.Lstat(getDonePath(basePath))
return done != nil
}
func done(basePath string) error {
return ioutil.WriteFile(getDonePath(basePath), []byte{}, 0644)
}
func getDonePath(basePath string) string {
return filepath.Join(basePath, doneFilename)
}
isDone:检查.done文件是否存在done:创建空内容的.done文件.done文件的存在即为完成信号,内容无关紧要
updatePackageState 状态更新(第203-207行)
go
func updatePackageState(pkg *common.Package, state common.State) error {
pkg.State = state
return common.DefaultDB.SavePackage(*pkg)
}
- 更新内存中的Package状态
- 持久化到数据库
日志文件管理(第209-227行)
go
func GetDownloadFilePath(name string) string {
return filepath.Join(PackagePath(name), "log")
}
func GetLogFile(name string) (logFile *os.File, err error) {
logFilePath := GetDownloadFilePath(name)
if err = os.MkdirAll(filepath.Dir(logFilePath), 0755); err != nil {
return nil, err
}
_, err = os.Stat(logFilePath)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
logFile, err = os.Create(logFilePath) // 文件不存在 → 创建
} else {
logFile, err = os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY, os.ModeAppend) // 文件存在 → 追加
}
return logFile, err
}
- 下载日志存储在
~/.autok3s/package/<name>/log - 支持创建新日志文件或追加到已有日志
2.4 file_scp.go --- SCP文件传输
文件定位:将下载好的离线资源通过SFTP/SSH传输到远程节点,并执行安装。
常量与变量(第26-49行)
go
type fileMap struct {
mode os.FileMode // 文件权限
dataDirSubpath string // k3s data-dir下的子路径
targetPath string // 目标安装路径
}
const (
installScriptName = "install.sh"
defaultDataDirPath = "/var/lib/rancher/k3s"
dataDirParamPrefix = "--data-dir"
dataDirParamPrefixShort = "-d"
)
var (
errArchNotSupport = errors.New("arch not support")
unameCommand = "uname -m"
remoteTmpDir = "/tmp/autok3s"
// 文件类型 → 安装位置映射
remoteFileMap = map[string]fileMap{
"k3s": {
mode: 0755, // 可执行权限
targetPath: "/usr/local/bin", // K3s二进制安装位置
},
"k3s-airgap-images": {
mode: 0644, // 普通文件权限
dataDirSubpath: "agent/images", // 镜像包放置在data-dir/agent/images/
},
installScriptName: {
mode: 0755, // 可执行权限
targetPath: "/usr/local/bin", // 安装脚本放置位置
},
}
// uname输出 → 标准架构名映射
parseArchMap = map[string]string{
"x86_64": "amd64",
"aarch64": "arm64",
"armv7l": "arm",
}
)
安装位置规划:
| 文件 | 权限 | 安装路径 |
|---|---|---|
| k3s 二进制 | 0755 | /usr/local/bin/k3s |
| k3s-airgap-images | 0644 | /var/lib/rancher/k3s/agent/images/ |
| install.sh | 0755 | /usr/local/bin/install.sh |
ScpFiles 核心传输函数(第51-126行)
go
func ScpFiles(logger *logrus.Logger, clusterName string, pkg *common.Package,
dialer *dialer.SSHDialer, extraArgs string) (er error) {
逐段分析:
阶段1:准备(第52-74行)
go
dataPath := getDataPath(extraArgs) // 解析--data-dir参数
conn := dialer.GetClient() // 获取SSH连接
fieldLogger := logger.WithFields(logrus.Fields{
"cluster": clusterName,
"component": "airgap",
})
installScript := settings.InstallScript.Get() // 从settings获取安装脚本
if installScript == "" {
return errors.New("install script must be configured")
}
// 获取远程节点架构
arch, err := getRemoteArch(dialer)
if err != nil {
return err
}
if ok, _ := ValidatedArch[arch]; !ok {
return errors.Wrapf(errArchNotSupport, "remote server arch: %s", arch)
}
if !pkg.Archs.Contains(arch) {
return fmt.Errorf("%s resource doesn't exist in package %s", arch, packagePath)
}
getDataPath解析extraArgs中的--data-dir或-d参数,用于确定镜像包安装位置- 从settings获取安装脚本内容(Base64编码或明文)
- 通过SSH执行
uname -m获取远程节点架构 - 验证架构是否支持且在包中存在
阶段2:建立SFTP连接(第76-82行)
go
fieldLogger.Infof("Get remote server arch %s", arch)
files, err := getScpFileMap(arch, pkg)
if err != nil {
return err
}
scpClient, err := sftp.NewClient(conn)
if err != nil {
return err
}
defer scpClient.Close()
getScpFileMap构建本地文件→远程文件映射- 基于已有SSH连接创建SFTP客户端
- SFTP比传统的SCP更可靠,支持文件权限设置和目录操作
阶段3:传输文件(第84-118行)
go
fieldLogger.Infof("connected to remote server %s with sftp", conn.RemoteAddr())
// 创建远程临时目录
tmpDir := getRemoteTmpDir(clusterName) // /tmp/autok3s/<cluster-name>
if err := scpClient.MkdirAll(tmpDir); err != nil {
return err
}
defer scpClient.RemoveDirectory(tmpDir) // 清理临时目录
for local, remote := range files {
filename := filepath.Base(local)
remoteFileName := filepath.Join(tmpDir, filename)
var source io.Reader
if local == installScriptName {
// 安装脚本从内存中读取(settings中存储)
source = bytes.NewBufferString(installScript)
} else {
// 其他文件从本地磁盘读取
fp, err := os.Open(local)
if err != nil {
return err
}
defer fp.Close()
source = fp
}
// 创建远程文件
rfp, err := scpClient.Create(remoteFileName)
if err != nil {
return err
}
defer rfp.Close()
// 复制文件内容
if _, err := io.Copy(rfp, source); err != nil {
return err
}
// 设置文件权限
if err := scpClient.Chmod(remoteFileName, remote.mode); err != nil {
return err
}
// 执行远程mv命令将文件从临时目录移到目标位置
targetPath := remote.targetPath
if remote.dataDirSubpath != "" {
targetPath = filepath.Join(dataPath, remote.dataDirSubpath)
}
targetFilename := filepath.Join(targetPath, filename)
moveCMD := fmt.Sprintf("mkdir -p %s;mv %s %s", targetPath, remoteFileName, targetFilename)
if output, err := dialer.ExecuteCommands(moveCMD); err != nil {
fieldLogger.Errorf("failed to execute cmd %s, output: %s, %v", moveCMD, output, err)
return err
}
// 错误时的清理defer
defer func(tmpFilename, targetFilename string) {
if er != nil {
fieldLogger.Warnf("error occurs when transferring resources, following resources should be clean later: %s %s", tmpFilename, targetFilename)
}
}(remoteFileName, targetFilename)
}
传输策略:
- 先SFTP上传到
/tmp/autok3s/<cluster-name>/临时目录 - 设置文件权限(0755或0644)
- 通过SSH执行
mkdir -p <target>; mv <tmp> <target>移动到最终位置 - 使用临时目录+mv的方式确保原子性安装
安装脚本的特殊处理 :
安装脚本不从本地文件读取,而是从 settings.InstallScript 内存中读取。这意味着安装脚本可以通过settings动态更新,无需重新下载包。
错误清理:使用defer在发生错误时记录需要清理的文件,但实际只是日志警告,不执行清理。
⚠️ 问题 :
defer rfp.Close()在循环中使用defer,如果文件数量很多,所有文件句柄会延迟到函数结束时才关闭。对于少量文件(这里只有3个)不是问题,但不是最佳实践。
阶段4:完成(第120-126行)
go
fieldLogger.Info("all files transferred")
return nil
}
PreparePackage 包准备函数(第128-166行)
go
func PreparePackage(cluster *types.Cluster) (*common.Package, error) {
clusterName := cluster.Name
packageName := cluster.PackageName
packagePath := cluster.PackagePath
// 1. 如果包名和路径都为空,返回nil(非离线模式)
if packageName == "" && packagePath == "" {
return nil, nil
}
// 2. 如果只有包名,从数据库查询
if packagePath == "" && packageName != "" {
pkgs, err := common.DefaultDB.ListPackages(&packageName)
if err != nil {
return nil, err
}
return &pkgs[0], nil
}
// 3. 如果有包路径,验证路径
fieldLogger := logrus.WithFields(logrus.Fields{
"cluster": clusterName,
"component": "airgap",
})
info, err := os.Lstat(packagePath)
if err != nil {
return nil, err
}
var tmpPath, currentPath string
if !info.IsDir() {
// 3a. 如果是tar.gz文件,解压到临时目录
tmpPath, err = SaveToTmp(packagePath, "cluster-"+clusterName)
if err != nil {
_ = os.RemoveAll(tmpPath)
return nil, err
}
fieldLogger.Infof("created tmp directory %s for package %s, will be removed after", tmpPath, packagePath)
currentPath = tmpPath
} else {
// 3b. 如果是目录,直接使用
currentPath = packagePath
}
// 4. 验证文件
rtn, err := VerifyFiles(currentPath)
if err != nil {
if tmpPath != "" {
_ = os.RemoveAll(currentPath) // 清理临时目录
}
return nil, err
}
rtn.FilePath = currentPath
fieldLogger.Infof("airgap package %s validated", packagePath)
return rtn, nil
}
支持三种包来源:
- 数据库中的包(通过包名查询)------已下载的离线包
- 本地tar.gz文件(通过路径指定)------用户手动导入的包
- 本地目录(通过路径指定)------已解压的包
对于tar.gz文件,使用 SaveToTmp 解压到 /tmp/autok3s/.tmp/cluster-<name>/ 临时目录,使用后清理。
getRemoteArch 远程架构检测(第168-178行)
go
func getRemoteArch(executor hosts.Script) (string, error) {
line, err := executor.ExecuteCommands(unameCommand)
if err != nil {
return "", err
}
line = strings.TrimSuffix(line, "\n")
return parseUnameArch(string(line)), nil
}
func parseUnameArch(output string) string {
rtn, ok := parseArchMap[output]
if ok {
return rtn
}
return output // 未知架构原样返回
}
- 通过SSH执行
uname -m获取远程架构 uname -m返回值映射:x86_64→amd64aarch64→arm64armv7l→arm- 其他 → 原样返回(后续会触发
errArchNotSupport)
getScpFileMap 文件映射构建(第180-202行)
go
func getScpFileMap(arch string, pkg *common.Package) (map[string]fileMap, error) {
var rtn = make(map[string]fileMap, len(remoteFileMap))
archBasePath := filepath.Join(pkg.FilePath, arch)
for key, file := range remoteFileMap {
if key == installScriptName {
rtn[installScriptName] = file // 安装脚本特殊处理
continue
}
hasFile := false
for _, suffix := range resourceSuffixes[key] {
filename := filepath.Join(archBasePath, key+suffix)
if _, err := os.Lstat(filename); err != nil {
continue // 文件不存在,尝试下一个suffix
}
hasFile = true
rtn[filename] = file // 本地完整路径 → 远程文件配置
}
if !hasFile {
return nil, fmt.Errorf("resource file %s is missing in package %s", key, pkg.FilePath)
}
}
return rtn, nil
}
- 返回
map[本地文件路径]远程文件配置 - 安装脚本使用特殊的key
install.sh(不是文件路径,在ScpFiles中特殊处理) - 其他文件检查所有可能的suffix,找到存在的文件
getDataPath 参数解析(第208-236行)
go
func getDataPath(extraArgs string) string {
dataPath := defaultDataDirPath // 默认 /var/lib/rancher/k3s
args := strings.Split(extraArgs, " ")
for i, arg := range args {
var prefix string
if strings.HasPrefix(arg, dataDirParamPrefix) {
prefix = dataDirParamPrefix // --data-dir
}
if strings.HasPrefix(arg, dataDirParamPrefixShort) {
prefix = dataDirParamPrefixShort // -d
}
if prefix == "" {
continue
}
// 情况1: --data-dir /data (空格分隔)
if len(arg) == len(prefix) && i < len(args)-1 {
return args[i+1]
}
// 情况2: --data-dir=/data 或 -d=/data (等号分隔)
if len(arg) > len(prefix) && arg[len(prefix)] == '=' {
return strings.TrimPrefix(arg, prefix+"=")
}
// 其他情况: 返回默认路径
if prefix != "" {
break
}
}
return dataPath
}
支持的参数格式:
| 格式 | 示例 | 解析结果 |
|---|---|---|
| 空格分隔 | --data-dir /data |
/data |
| 等号分隔 | --data-dir=/data |
/data |
| 短选项空格 | -d /data |
/data |
| 短选项等号 | -d=/data |
/data |
| 只有前缀无值 | --data-dir |
默认路径 |
| 无data-dir参数 | --bind-address=0.0.0.0 |
默认路径 |
2.5 tarfiles.go --- 打包/解包
文件定位:tar.gz格式的打包和解包工具,用于离线包的导入导出。
ReadToTmp 解压到临时目录(第1-54行)
go
func ReadToTmp(fp io.Reader, name string) (string, error) {
rtn := TempDir(name) // ~/.autok3s/package/.tmp/<name>
if err := os.MkdirAll(rtn, 0755); err != nil {
return rtn, err
}
gzReader, err := gzip.NewReader(fp)
if err != nil {
return "", err
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
for {
tr, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
fullpath := filepath.Join(rtn, tr.Name)
switch tr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(fullpath, 0755); err != nil {
return "", err
}
case tar.TypeReg:
parent := filepath.Dir(fullpath)
if _, err := os.Lstat(parent); err != nil {
if err := os.MkdirAll(parent, 0755); err != nil {
return "", err
}
}
if err := func(header *tar.Header) error {
outFile, err := os.OpenFile(fullpath,
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
header.FileInfo().Mode())
if err != nil {
return err
}
defer outFile.Close()
if _, err := io.Copy(outFile, tarReader); err != nil {
return err
}
return nil
}(tr); err != nil {
return "", err
}
default:
// ignore unknown type (symlinks, devices, etc.)
}
}
return rtn, nil
}
解压流程:
- 创建目标临时目录
- 创建gzip reader → tar reader 的双层解压链
- 遍历tar条目:
TypeDir:创建目录TypeReg:创建文件并写入内容,保留原始文件权限- 其他类型(符号链接、设备文件等):忽略
⚠️ 安全考虑 :使用
filepath.Join拼接路径,但未检查路径穿越(如../../etc/passwd)。不过由于这些tar包是AutoK3s自身生成的,风险较低。如果接受外部tar包,应添加路径穿越检查。
⚠️ 小问题 :使用os.O_APPEND模式打开文件。如果是新建文件这没有问题,但如果文件已存在(理论上不会,因为前面已MkdirAll到新目录),追加模式可能导致内容重复。应使用os.O_TRUNC更安全。
SaveToTmp 从文件解压(第56-66行)
go
func SaveToTmp(path, name string) (string, error) {
if _, err := os.Lstat(path); err != nil {
return "", err
}
fp, err := os.Open(path)
if err != nil {
return "", err
}
defer fp.Close()
return ReadToTmp(fp, name)
}
- 打开tar.gz文件,委托给
ReadToTmp处理 - 用于用户导入外部tar.gz离线包时
TarAndGzipToWriter 打包到Writer(第68-84行)
go
func TarAndGzipToWriter(from string, to io.Writer) error {
files := map[string]string{}
if err := filepath.Walk(from, func(path string, info fs.FileInfo, err error) error {
if from == path || info.IsDir() {
return nil // 跳过根目录本身和子目录
}
f, _ := filepath.Rel(from, path) // 计算相对路径
files[f] = path // 相对路径 → 绝对路径
return nil
}); err != nil {
return err
}
return createArchive(files, to)
}
- 遍历目录,收集所有文件(跳过目录)
filesmap:相对路径(tar内的路径)→ 绝对路径(本地文件系统路径)- 委托给
createArchive实际打包
TarAndGzip 打包到文件(第86-100行)
go
func TarAndGzip(from, to string) error {
_, err := os.Lstat(to)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
return fmt.Errorf("file %s exists, stop exporting", to) // 目标已存在 → 拒绝覆盖
}
toFile, err := os.Create(to)
if err != nil {
return err
}
defer toFile.Close()
return TarAndGzipToWriter(from, toFile)
}
- 检查目标文件不存在(防止意外覆盖)
- 创建文件并委托给
TarAndGzipToWriter
createArchive / addToArchive 实际打包逻辑(第102-162行)
go
func createArchive(files map[string]string, buf io.Writer) error {
gw := gzip.NewWriter(buf)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
for filename, filepath := range files {
err := addToArchive(tw, filename, filepath)
if err != nil {
return err
}
}
return nil
}
写入链:文件 → tar.Writer → gzip.Writer → buf
defer关闭顺序:tw先关闭(刷新tar),gw后关闭(刷新gzip)------这是正确的关闭顺序。
go
func addToArchive(tw *tar.Writer, filename, path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, info.Name())
if err != nil {
return err
}
header.Name = filename // 使用相对路径作为tar内的文件名
err = tw.WriteHeader(header)
if err != nil {
return err
}
_, err = io.Copy(tw, file)
return err
}
- 从FileInfo创建tar header(包含文件大小、权限、修改时间等)
- 覆盖
header.Name为相对路径,保持目录结构 - 写header后复制文件内容
2.6 测试文件分析
download_test.go
三个测试用例:
-
TestCheckHash :验证
checkFileHash函数- 创建临时文件写入 "abcd\n"
- 使用预计算的SHA256哈希验证
- 测试哈希匹配的正确性
-
TestDone :验证
.done标记机制- 先验证
.done不存在时isDone返回false - 调用
done()创建标记 - 验证
isDone返回true - 清理标记文件
- 先验证
-
TestGetExt:验证多段扩展名解析
abc.tar.gz→ name=abc, ext=.tar.gz
-
TestSuffixWithArch:验证架构后缀映射
- amd64 + k3s →
{"": ""}(无后缀) - arm64 + k3s →
{"": "-arm64"} - arm64 + k3s-airgap-images →
{".tar.gz": "-arm64.tar.gz", ".tar": "-arm64.tar"}
- amd64 + k3s →
file_scp_test.go
仅一个测试:
TestGetDataPath :验证 getDataPath 的各种参数格式解析
- 无参数 → 默认路径
- 无data-dir参数 → 默认路径
--data-dir /data→/data--data-dir=/data→/data-d /data→/data-d=/data→/data--data-dir(无值)→ 默认路径
2.7 离线包下载流程 Mermaid 图
#mermaid-svg-WI0QbByvdBkVvBsj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WI0QbByvdBkVvBsj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WI0QbByvdBkVvBsj .error-icon{fill:#552222;}#mermaid-svg-WI0QbByvdBkVvBsj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WI0QbByvdBkVvBsj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WI0QbByvdBkVvBsj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WI0QbByvdBkVvBsj .marker.cross{stroke:#333333;}#mermaid-svg-WI0QbByvdBkVvBsj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WI0QbByvdBkVvBsj p{margin:0;}#mermaid-svg-WI0QbByvdBkVvBsj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WI0QbByvdBkVvBsj .cluster-label text{fill:#333;}#mermaid-svg-WI0QbByvdBkVvBsj .cluster-label span{color:#333;}#mermaid-svg-WI0QbByvdBkVvBsj .cluster-label span p{background-color:transparent;}#mermaid-svg-WI0QbByvdBkVvBsj .label text,#mermaid-svg-WI0QbByvdBkVvBsj span{fill:#333;color:#333;}#mermaid-svg-WI0QbByvdBkVvBsj .node rect,#mermaid-svg-WI0QbByvdBkVvBsj .node circle,#mermaid-svg-WI0QbByvdBkVvBsj .node ellipse,#mermaid-svg-WI0QbByvdBkVvBsj .node polygon,#mermaid-svg-WI0QbByvdBkVvBsj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WI0QbByvdBkVvBsj .rough-node .label text,#mermaid-svg-WI0QbByvdBkVvBsj .node .label text,#mermaid-svg-WI0QbByvdBkVvBsj .image-shape .label,#mermaid-svg-WI0QbByvdBkVvBsj .icon-shape .label{text-anchor:middle;}#mermaid-svg-WI0QbByvdBkVvBsj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WI0QbByvdBkVvBsj .rough-node .label,#mermaid-svg-WI0QbByvdBkVvBsj .node .label,#mermaid-svg-WI0QbByvdBkVvBsj .image-shape .label,#mermaid-svg-WI0QbByvdBkVvBsj .icon-shape .label{text-align:center;}#mermaid-svg-WI0QbByvdBkVvBsj .node.clickable{cursor:pointer;}#mermaid-svg-WI0QbByvdBkVvBsj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WI0QbByvdBkVvBsj .arrowheadPath{fill:#333333;}#mermaid-svg-WI0QbByvdBkVvBsj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WI0QbByvdBkVvBsj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WI0QbByvdBkVvBsj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WI0QbByvdBkVvBsj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WI0QbByvdBkVvBsj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WI0QbByvdBkVvBsj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WI0QbByvdBkVvBsj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WI0QbByvdBkVvBsj .cluster text{fill:#333;}#mermaid-svg-WI0QbByvdBkVvBsj .cluster span{color:#333;}#mermaid-svg-WI0QbByvdBkVvBsj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WI0QbByvdBkVvBsj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WI0QbByvdBkVvBsj rect.text{fill:none;stroke-width:0;}#mermaid-svg-WI0QbByvdBkVvBsj .icon-shape,#mermaid-svg-WI0QbByvdBkVvBsj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WI0QbByvdBkVvBsj .icon-shape p,#mermaid-svg-WI0QbByvdBkVvBsj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WI0QbByvdBkVvBsj .icon-shape .label rect,#mermaid-svg-WI0QbByvdBkVvBsj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WI0QbByvdBkVvBsj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WI0QbByvdBkVvBsj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WI0QbByvdBkVvBsj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 无差异, 已完成
需要更新
否
是
否
是
是
否, 有其他suffix
否, context取消
是
否
否
是
是
否
否
是
DownloadPackage 入口
创建可取消context
构造downloader结构体
getSourceURL 选择下载源
注册cancel到全局map
versionAndBasePath 读取已有版本
version.diff 计算架构差异
有差异且isDone?
跳过下载, 设为Active
清理toDel架构目录
Step1: PackageValidating
validateVersion: 下载k3s-images.txt
版本存在?
返回ErrVersionNotFound
缓存imageListContent
Step2: writeVersion
删除旧.done, 写version.json
Step3: PackageDownloading
遍历每个架构
checkArchExists: HEAD请求检查
架构存在?
返回错误
写入k3s-images.txt
遍历资源类型
尝试下载文件到.tmp
下载成功?
rename .tmp → 目标文件
还有资源?
verifyArchFiles: SHA256校验
校验通过?
done: 创建.done标记
还有架构?
Step4: done整体标记
Step5: PackageVerifying
VerifyFiles: 全量校验
校验通过?
设为OutOfSync
设为PackageActive
完成
2.8 SCP传输流程 Mermaid 图
#mermaid-svg-ZyNNdhEpnZqjjLvY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZyNNdhEpnZqjjLvY .error-icon{fill:#552222;}#mermaid-svg-ZyNNdhEpnZqjjLvY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZyNNdhEpnZqjjLvY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .marker.cross{stroke:#333333;}#mermaid-svg-ZyNNdhEpnZqjjLvY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZyNNdhEpnZqjjLvY p{margin:0;}#mermaid-svg-ZyNNdhEpnZqjjLvY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .cluster-label text{fill:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .cluster-label span{color:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .cluster-label span p{background-color:transparent;}#mermaid-svg-ZyNNdhEpnZqjjLvY .label text,#mermaid-svg-ZyNNdhEpnZqjjLvY span{fill:#333;color:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .node rect,#mermaid-svg-ZyNNdhEpnZqjjLvY .node circle,#mermaid-svg-ZyNNdhEpnZqjjLvY .node ellipse,#mermaid-svg-ZyNNdhEpnZqjjLvY .node polygon,#mermaid-svg-ZyNNdhEpnZqjjLvY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .rough-node .label text,#mermaid-svg-ZyNNdhEpnZqjjLvY .node .label text,#mermaid-svg-ZyNNdhEpnZqjjLvY .image-shape .label,#mermaid-svg-ZyNNdhEpnZqjjLvY .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZyNNdhEpnZqjjLvY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .rough-node .label,#mermaid-svg-ZyNNdhEpnZqjjLvY .node .label,#mermaid-svg-ZyNNdhEpnZqjjLvY .image-shape .label,#mermaid-svg-ZyNNdhEpnZqjjLvY .icon-shape .label{text-align:center;}#mermaid-svg-ZyNNdhEpnZqjjLvY .node.clickable{cursor:pointer;}#mermaid-svg-ZyNNdhEpnZqjjLvY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .arrowheadPath{fill:#333333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZyNNdhEpnZqjjLvY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZyNNdhEpnZqjjLvY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZyNNdhEpnZqjjLvY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZyNNdhEpnZqjjLvY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .cluster text{fill:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY .cluster span{color:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZyNNdhEpnZqjjLvY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZyNNdhEpnZqjjLvY rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZyNNdhEpnZqjjLvY .icon-shape,#mermaid-svg-ZyNNdhEpnZqjjLvY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZyNNdhEpnZqjjLvY .icon-shape p,#mermaid-svg-ZyNNdhEpnZqjjLvY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZyNNdhEpnZqjjLvY .icon-shape .label rect,#mermaid-svg-ZyNNdhEpnZqjjLvY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZyNNdhEpnZqjjLvY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZyNNdhEpnZqjjLvY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZyNNdhEpnZqjjLvY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
是
否
是
否
是
否
ScpFiles 入口
getDataPath 解析--data-dir
获取SSH连接
从settings获取install.sh
getRemoteArch: uname -m
架构支持?
返回errArchNotSupport
架构在包中?
返回错误
getScpFileMap 构建文件映射
创建SFTP客户端
创建远程临时目录
/tmp/autok3s/cluster-name
遍历文件映射
是install.sh?
从内存读取脚本内容
从本地磁盘读取文件
SFTP Create远程文件
io.Copy传输文件内容
Chmod设置文件权限
是镜像包?
targetPath = dataPath/agent/images
targetPath = /usr/local/bin
SSH执行: mkdir -p target; mv tmp target
还有文件?
清理临时目录
传输完成
3. sshkey 模块------集群SSH密钥管理
3.1 clusterkey.go --- 集群密钥存储
文件定位:将SSH密钥(私钥、公钥、证书)存储到集群上下文目录,支持从数据库密钥库引用。
常量(第14-18行)
go
const (
PrivateKeyFilename = "id_rsa" // 私钥文件名
PublicKeyFilename = "id_rsa.pub" // 公钥文件名
CertificateFilename = "pub.cert" // 证书文件名
)
StoreClusterSSHKeys 核心函数(第20-74行)
go
func StoreClusterSSHKeys(clusterName string, ssh *types.SSH) (*types.SSH, error) {
var rtn *types.SSH
// 1. 检查是否需要SSH密钥
if !NeedSSHKeys(*ssh) {
return nil, nil
}
base := common.GetClusterContextPath(clusterName)
// 2. 如果指定了密钥名但无路径,从数据库查询
if ssh.SSHKeyName != "" && ssh.SSHCertPath == "" && ssh.SSHKeyPath == "" {
keys, err := common.DefaultDB.ListSSHKey(&ssh.SSHKeyName)
if err != nil {
return nil, err
}
if len(keys) != 1 {
return nil, fmt.Errorf("failed to get ssh key %s from db", ssh.SSHKeyName)
}
key := keys[0]
rtn = ssh
rtn.SSHKey = key.SSHKey // 填充私钥内容
rtn.SSHCert = key.SSHCert // 填充证书内容
}
// 3. 保存证书到文件
if ssh.SSHCertPath == "" && ssh.SSHCert != "" {
if rtn == nil {
rtn = ssh
}
certPath := filepath.Join(base, CertificateFilename)
if err := os.RemoveAll(certPath); err != nil {
return nil, err
}
if err := ioutil.WriteFile(certPath, []byte(ssh.SSHCert), 0644); err != nil {
return nil, errors.Wrapf(err, "failed to write cluster ssh cert to file %s", certPath)
}
rtn.SSHCertPath = certPath
}
// 4. 保存私钥到文件
if ssh.SSHKeyPath == "" && ssh.SSHKey != "" {
if rtn == nil {
rtn = ssh
}
keyPath := filepath.Join(base, PrivateKeyFilename)
if err := os.RemoveAll(keyPath); err != nil {
return nil, err
}
if err := ioutil.WriteFile(keyPath, []byte(ssh.SSHKey), 0644); err != nil {
return nil, errors.Wrapf(err, "failed to write cluster ssh private key to file %s", keyPath)
}
rtn.SSHKeyPath = keyPath
}
return rtn, nil
}
逻辑流程:
- 过滤:如果使用SSH Agent或密码认证,不需要密钥文件
- 密钥库引用 :如果指定了
SSHKeyName但没有直接提供密钥内容,从数据库查询 - 证书持久化 :将证书内容写入
<cluster-context>/pub.cert - 私钥持久化 :将私钥内容写入
<cluster-context>/id_rsa
返回值是修改后的SSH结构体,包含本地文件路径。调用者可以使用这些路径进行SSH连接。
⚠️ 安全问题 :私钥文件权限设为
0644(world-readable)。SSH私钥通常应为0600。虽然这是在服务器上存储,但仍有安全隐患。
NeedSSHKeys 判断函数(第76-78行)
go
func NeedSSHKeys(ssh types.SSH) bool {
return !ssh.SSHAgentAuth && ssh.SSHPassword == ""
}
需要SSH密钥的条件:
- 不使用SSH Agent认证
- 且没有设置密码
如果使用了Agent或密码认证,密钥文件不是必需的。
3.2 utils.go --- SSH密钥工具
文件定位:SSH密钥的验证、创建和生成。
NeedPasswordRaw / NeedPassword 密码需求检测(第16-31行)
go
func NeedPasswordRaw(content []byte) (bool, error) {
if _, err := ssh.ParseRawPrivateKey(content); err != nil {
if _, ok := err.(*ssh.PassphraseMissingError); ok {
return true, nil // 需要密码
}
return false, err // 解析错误
}
return false, nil // 不需要密码
}
func NeedPassword(keypath string) (bool, error) {
content, err := utils.GetFileContent(keypath)
if err != nil {
return false, err
}
return NeedPasswordRaw(content)
}
- 通过尝试解析私钥检测是否需要密码
ssh.PassphraseMissingError是Go SSH库的特定错误类型,表示私钥被密码保护NeedPassword是文件路径版本,NeedPasswordRaw是字节数组版本
CreateSSHKey 密钥验证与存储(第33-66行)
go
func CreateSSHKey(key *common.SSHKey) error {
var err error
var publicKey ssh.PublicKey
var privateKey ssh.Signer
// 1. 解析私钥(有/无密码两种情况)
if key.SSHPassphrase != "" {
privateKey, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key.SSHKey), []byte(key.SSHPassphrase))
key.HasPassword = true
} else {
privateKey, err = ssh.ParsePrivateKey([]byte(key.SSHKey))
}
if err != nil {
return fmt.Errorf("failed to parse private key file %v", err)
}
// 2. 验证公钥(如果提供)
if key.SSHPublicKey != "" {
publicKey, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key.SSHPublicKey))
if err != nil {
return fmt.Errorf("failed to parse public key file %v", err)
}
// 比较公钥与私钥是否匹配
target := privateKey.PublicKey().Marshal()
source := publicKey.Marshal()
if !bytes.Equal(target, source) {
return fmt.Errorf("the ssh public key and private key not matched")
}
}
// 3. 验证证书(如果提供)
if key.SSHCert != "" {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.SSHCert))
if err != nil {
return fmt.Errorf("failed to parse certificate to ssh authorized key file")
}
if _, ok := key.(*ssh.Certificate); !ok {
return fmt.Errorf("failed to parse certificate file to ssh certificate")
}
}
// 4. 保存到数据库
return common.DefaultDB.SaveSSHKey(*key)
}
验证三步:
- 私钥解析------验证私钥格式正确,可选密码
- 公钥匹配------如果提供公钥,验证公私钥对是否匹配(通过比较Marshal后的bytes)
- 证书验证------如果提供证书,验证它是合法的SSH证书
⚠️ 变量遮蔽 :第3步中
key, _, _, _, err := ssh.ParseAuthorizedKey(...)使用了:=,创建了一个新的局部变量key,遮蔽了外层的key *common.SSHKey参数。虽然这里只是读取解析结果不影响外层key,但这是一个潜在的代码质量问题。如果后续需要在if块内使用外层key,会出错。
GenerateSSHKey RSA密钥生成(第68-96行)
go
func GenerateSSHKey(key *common.SSHKey) error {
// 1. 生成RSA私钥
privateKey, err := rsa.GenerateKey(rand.Reader, key.Bits)
if err != nil {
return err
}
// 2. 编码私钥为PEM
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
// 3. 如果有密码,加密PEM块
if key.SSHPassphrase != "" {
block, err = x509.EncryptPEMBlock(rand.Reader, block.Type, block.Bytes,
[]byte(key.SSHPassphrase), x509.PEMCipherAES256)
if err != nil {
return err
}
key.HasPassword = true
}
key.SSHKey = string(pem.EncodeToMemory(block))
// 4. 生成公钥
publicKey := privateKey.Public()
sshPublicKey, err := ssh.NewPublicKey(publicKey.(*rsa.PublicKey))
if err != nil {
return err
}
key.SSHPublicKey = string(ssh.MarshalAuthorizedKey(sshPublicKey))
// 5. 保存到数据库
return common.DefaultDB.SaveSSHKey(*key)
}
密钥生成流程:
- 使用
rsa.GenerateKey生成指定位数的RSA密钥对 - 使用
x509.MarshalPKCS1PrivateKey编码为PKCS#1格式 - 如果有密码,使用AES-256加密PEM块
- 从RSA公钥生成SSH格式公钥(
ssh-rsa AAAA...) - 保存到数据库
⚠️ 注意 :使用
x509.EncryptPEMBlock是旧版API,Go 1.16+推荐使用golang.org/x/crypto/ssh的ssh.MarshalPrivateKeywith passphrase。但功能上没有问题。
3.3 密钥生成流程 Mermaid 图
#mermaid-svg-8SyAVGj5kx9e25vR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8SyAVGj5kx9e25vR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8SyAVGj5kx9e25vR .error-icon{fill:#552222;}#mermaid-svg-8SyAVGj5kx9e25vR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8SyAVGj5kx9e25vR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8SyAVGj5kx9e25vR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8SyAVGj5kx9e25vR .marker.cross{stroke:#333333;}#mermaid-svg-8SyAVGj5kx9e25vR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8SyAVGj5kx9e25vR p{margin:0;}#mermaid-svg-8SyAVGj5kx9e25vR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8SyAVGj5kx9e25vR .cluster-label text{fill:#333;}#mermaid-svg-8SyAVGj5kx9e25vR .cluster-label span{color:#333;}#mermaid-svg-8SyAVGj5kx9e25vR .cluster-label span p{background-color:transparent;}#mermaid-svg-8SyAVGj5kx9e25vR .label text,#mermaid-svg-8SyAVGj5kx9e25vR span{fill:#333;color:#333;}#mermaid-svg-8SyAVGj5kx9e25vR .node rect,#mermaid-svg-8SyAVGj5kx9e25vR .node circle,#mermaid-svg-8SyAVGj5kx9e25vR .node ellipse,#mermaid-svg-8SyAVGj5kx9e25vR .node polygon,#mermaid-svg-8SyAVGj5kx9e25vR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8SyAVGj5kx9e25vR .rough-node .label text,#mermaid-svg-8SyAVGj5kx9e25vR .node .label text,#mermaid-svg-8SyAVGj5kx9e25vR .image-shape .label,#mermaid-svg-8SyAVGj5kx9e25vR .icon-shape .label{text-anchor:middle;}#mermaid-svg-8SyAVGj5kx9e25vR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8SyAVGj5kx9e25vR .rough-node .label,#mermaid-svg-8SyAVGj5kx9e25vR .node .label,#mermaid-svg-8SyAVGj5kx9e25vR .image-shape .label,#mermaid-svg-8SyAVGj5kx9e25vR .icon-shape .label{text-align:center;}#mermaid-svg-8SyAVGj5kx9e25vR .node.clickable{cursor:pointer;}#mermaid-svg-8SyAVGj5kx9e25vR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8SyAVGj5kx9e25vR .arrowheadPath{fill:#333333;}#mermaid-svg-8SyAVGj5kx9e25vR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8SyAVGj5kx9e25vR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8SyAVGj5kx9e25vR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8SyAVGj5kx9e25vR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8SyAVGj5kx9e25vR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8SyAVGj5kx9e25vR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8SyAVGj5kx9e25vR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8SyAVGj5kx9e25vR .cluster text{fill:#333;}#mermaid-svg-8SyAVGj5kx9e25vR .cluster span{color:#333;}#mermaid-svg-8SyAVGj5kx9e25vR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8SyAVGj5kx9e25vR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8SyAVGj5kx9e25vR rect.text{fill:none;stroke-width:0;}#mermaid-svg-8SyAVGj5kx9e25vR .icon-shape,#mermaid-svg-8SyAVGj5kx9e25vR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8SyAVGj5kx9e25vR .icon-shape p,#mermaid-svg-8SyAVGj5kx9e25vR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8SyAVGj5kx9e25vR .icon-shape .label rect,#mermaid-svg-8SyAVGj5kx9e25vR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8SyAVGj5kx9e25vR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8SyAVGj5kx9e25vR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8SyAVGj5kx9e25vR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
sshkey.GenerateSSHKey
rsa.GenerateKey
生成RSA密钥对
x509.MarshalPKCS1PrivateKey
编码私钥
有密码?
x509.EncryptPEMBlock
AES-256加密PEM
pem.EncodeToMemory
明文PEM
设置key.SSHKey
从私钥提取公钥
ssh.NewPublicKey
转换为SSH公钥格式
ssh.MarshalAuthorizedKey
编码为authorized_keys格式
设置key.SSHPublicKey
DB.SaveSSHKey
持久化
完成
#mermaid-svg-DbDGRUSg5iSwaKVs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DbDGRUSg5iSwaKVs .error-icon{fill:#552222;}#mermaid-svg-DbDGRUSg5iSwaKVs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DbDGRUSg5iSwaKVs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DbDGRUSg5iSwaKVs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DbDGRUSg5iSwaKVs .marker.cross{stroke:#333333;}#mermaid-svg-DbDGRUSg5iSwaKVs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DbDGRUSg5iSwaKVs p{margin:0;}#mermaid-svg-DbDGRUSg5iSwaKVs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs .cluster-label text{fill:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs .cluster-label span{color:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs .cluster-label span p{background-color:transparent;}#mermaid-svg-DbDGRUSg5iSwaKVs .label text,#mermaid-svg-DbDGRUSg5iSwaKVs span{fill:#333;color:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs .node rect,#mermaid-svg-DbDGRUSg5iSwaKVs .node circle,#mermaid-svg-DbDGRUSg5iSwaKVs .node ellipse,#mermaid-svg-DbDGRUSg5iSwaKVs .node polygon,#mermaid-svg-DbDGRUSg5iSwaKVs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DbDGRUSg5iSwaKVs .rough-node .label text,#mermaid-svg-DbDGRUSg5iSwaKVs .node .label text,#mermaid-svg-DbDGRUSg5iSwaKVs .image-shape .label,#mermaid-svg-DbDGRUSg5iSwaKVs .icon-shape .label{text-anchor:middle;}#mermaid-svg-DbDGRUSg5iSwaKVs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DbDGRUSg5iSwaKVs .rough-node .label,#mermaid-svg-DbDGRUSg5iSwaKVs .node .label,#mermaid-svg-DbDGRUSg5iSwaKVs .image-shape .label,#mermaid-svg-DbDGRUSg5iSwaKVs .icon-shape .label{text-align:center;}#mermaid-svg-DbDGRUSg5iSwaKVs .node.clickable{cursor:pointer;}#mermaid-svg-DbDGRUSg5iSwaKVs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DbDGRUSg5iSwaKVs .arrowheadPath{fill:#333333;}#mermaid-svg-DbDGRUSg5iSwaKVs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DbDGRUSg5iSwaKVs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DbDGRUSg5iSwaKVs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DbDGRUSg5iSwaKVs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DbDGRUSg5iSwaKVs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DbDGRUSg5iSwaKVs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DbDGRUSg5iSwaKVs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DbDGRUSg5iSwaKVs .cluster text{fill:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs .cluster span{color:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DbDGRUSg5iSwaKVs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DbDGRUSg5iSwaKVs rect.text{fill:none;stroke-width:0;}#mermaid-svg-DbDGRUSg5iSwaKVs .icon-shape,#mermaid-svg-DbDGRUSg5iSwaKVs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DbDGRUSg5iSwaKVs .icon-shape p,#mermaid-svg-DbDGRUSg5iSwaKVs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DbDGRUSg5iSwaKVs .icon-shape .label rect,#mermaid-svg-DbDGRUSg5iSwaKVs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DbDGRUSg5iSwaKVs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DbDGRUSg5iSwaKVs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DbDGRUSg5iSwaKVs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
是
否
是
否
是
否
sshkey.StoreClusterSSHKeys
NeedSSHKeys?
!AgentAuth && Password==空
返回nil, nil
有SSHKeyName
且无KeyPath/CertPath?
从DB查询密钥
使用传入的密钥内容
有SSHCert
且无CertPath?
写入pub.cert到
集群上下文目录
有SSHKey
且无KeyPath?
写入id_rsa到
集群上下文目录
返回SSH配置
4. utils 模块------通用工具集
4.1 errgroup.go --- 并发错误组
文件定位:自定义的并发错误组,支持"first error wins"语义。
go
type FirstErrGroup struct {
errChan chan error // 错误通道(缓冲1)
wg *sync.WaitGroup // 等待组
errCount int32 // 错误计数(原子操作)
}
NewFirstErrorGroup 构造函数(第55-60行)
go
func NewFirstErrorGroup() *FirstErrGroup {
return &FirstErrGroup{
wg: &sync.WaitGroup{},
errCount: 0,
errChan: make(chan error, 1), // 缓冲为1,非阻塞发送
}
}
Go 启动goroutine(第21-32行)
go
func (g *FirstErrGroup) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
errCount := atomic.LoadInt32(&g.errCount)
if errCount > 0 {
return // 已有错误,快速返回(fast-fail)
}
if err := f(); err != nil {
g.sendError(err)
}
}()
}
Fast-fail机制:
- 启动新goroutine前检查是否已有错误
- 如果已有错误,直接返回不执行任务
- 这实现了"遇到第一个错误后快速终止"的语义
sendError 发送错误(第34-43行)
go
func (g *FirstErrGroup) sendError(err error) {
if err == nil {
return
}
select {
case g.errChan <- err: // 发送到通道
default: // 通道已满,丢弃
}
atomic.AddInt32(&g.errCount, 1)
}
- 使用
select + default实现非阻塞发送 - errChan缓冲为1,只保留第一个错误
- 原子递增错误计数
FirstError 获取第一个错误(第11-18行)
go
func (g *FirstErrGroup) FirstError() <-chan error {
go func() {
g.wg.Wait()
close(g.errChan)
}()
return g.errChan
}
- 启动goroutine等待所有任务完成
- 完成后关闭errChan
- 返回channel供调用者读取(读取到nil表示无错误,读取到error表示第一个错误)
Wait 等待完成(第45-48行)
go
func (g *FirstErrGroup) Wait() int32 {
g.wg.Wait()
return g.errCount
}
- 等待所有goroutine完成
- 返回错误总数(0表示全部成功)
与标准库 errgroup 的区别:
| 特性 | 标准库 errgroup | FirstErrGroup |
|---|---|---|
| 错误返回 | 返回第一个错误 | 返回错误计数 + 第一个错误channel |
| Fast-fail | 通过context取消 | 通过原子计数提前返回 |
| 错误通道 | 无 | 有(缓冲1) |
| 并发控制 | 可选SetLimit | 无 |
4.2 file.go --- 文件操作工具
EnsureFolderExist 确保目录存在(第20-28行)
go
func EnsureFolderExist(path string) error {
if path == "" {
return fmt.Errorf("path %s cannot be empty", path)
}
err := os.MkdirAll(path, os.ModePerm)
if err != nil && !os.IsExist(err) {
return err
}
return nil
}
os.ModePerm= 0777os.MkdirAll在目录已存在时返回nil,所以!os.IsExist(err)检查实际不会触发- 空路径检查是防御性编程
EnsureFileExist 确保文件存在(第30-42行)
go
func EnsureFileExist(file string) error {
if err := EnsureFolderExist(filepath.Dir(file)); err != nil {
return err
}
if _, err := os.Stat(file); os.IsNotExist(err) {
_, fileE := os.Create(file)
if fileE != nil {
return fileE
}
}
return nil
}
- 先确保父目录存在
- 如果文件不存在则创建空文件
UserHome 获取用户主目录(第44-60行)
go
func UserHome() string {
u, err := user.Current()
if err == nil {
return u.HomeDir
}
if home := os.Getenv(homeEnv); home != "" {
return home
}
homeDrive := os.Getenv(homeDriveEnv)
homePath := os.Getenv(homePathEnv)
if homeDrive != "" && homePath != "" {
return homeDrive + homePath
}
return os.Getenv(userProfileEnv)
}
跨平台主目录查找(按优先级):
user.Current()--- Go标准库跨平台方式HOME环境变量 --- Unix/LinuxHOMEDRIVE+HOMEPATH--- Windows传统方式USERPROFILE--- Windows现代方式
IsFileExists 文件存在检查(第62-74行)
go
func IsFileExists(file string) bool {
filePath := StripUserHome(file) // 展开~路径
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
logrus.Errorf("[autoK3s] file %s is not exist", file)
} else {
logrus.Errorf("[autoK3s] failed to get file %s with error: %v", file, err)
}
return false
}
return true
}
- 先展开
~/为实际主目录路径 - 检查文件是否存在
- 不存在时记录错误日志(注意:即使文件不存在也打Error级别日志,可能过于激进)
4.3 flag.go --- Flag转换工具
文件定位 :将AutoK3s自定义的 types.Flag 转换为 cobra/pflag 的标准FlagSet,支持多种类型和环境变量绑定。
ConvertFlags 核心转换函数(第16-97行)
go
func ConvertFlags(cmd *cobra.Command, fs []types.Flag) *pflag.FlagSet {
for _, f := range fs {
if f.ShortHand == "" {
// 无短选项路径
if cmd.Flags().Lookup(f.Name) == nil {
pf := cmd.Flags()
switch t := f.V.(type) {
case bool:
pf.BoolVar(f.P.(*bool), f.Name, t, f.Usage)
case string:
pf.StringVar(f.P.(*string), f.Name, t, f.Usage)
case map[string]string:
pf.StringToStringVar(f.P.(*map[string]string), f.Name, t, f.Usage)
case []string:
pf.StringArrayVar(f.P.(*[]string), f.Name, t, f.Usage)
case types.StringArray:
pf.Var(newStringArrayValue(t, f.P.(*types.StringArray)), f.Name, f.Usage)
case types.StringMap:
pf.Var(newStringMapValue(t, f.P.(*types.StringMap)), f.Name, f.Usage)
case int:
pf.IntVar(f.P.(*int), f.Name, t, f.Usage)
default:
continue
}
if f.Required {
_ = cobra.MarkFlagRequired(pf, f.Name)
}
}
} else {
// 有短选项路径(几乎相同的逻辑,使用VarP/BoolVarP等)
...
}
// 环境变量绑定
if f.EnvVar != "" {
_ = cmd.Flags().SetAnnotation(f.Name, BashCompEnvVarFlag, []string{f.EnvVar})
}
}
return cmd.Flags()
}
支持的类型映射:
| types.Flag.V 类型 | pflag方法 | 说明 |
|---|---|---|
bool |
BoolVar | 布尔标志 |
string |
StringVar | 字符串 |
map[string]string |
StringToStringVar | 内置map类型 |
[]string |
StringArrayVar | 内置数组类型 |
types.StringArray |
Var (自定义) | 自定义数组类型 |
types.StringMap |
Var (自定义) | 自定义map类型 |
int |
IntVar | 整数 |
⚠️ 代码重复 :有短选项和无短选项的路径几乎完全相同,只是调用了
XxxVarPvsXxxVar。可以通过辅助函数减少重复。
⚠️ 类型断言 :switch t := f.V.(type)中的default: continue意味着不支持的类型会被静默跳过,不报错。这可能导致难以发现的配置错误。
ValidateRequiredFlags 必填标志验证(第103-113行)
go
func ValidateRequiredFlags(flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
requiredAnnotation, found := flag.Annotations[cobra.BashCompOneRequiredFlag]
if !found {
return
}
if (requiredAnnotation[0] == "true") && flag.Value.String() != "" && !flag.Changed {
flag.Changed = true
}
})
}
解决的问题 :cobra的必填检查逻辑是:如果flag没有被 Set 过(flag.Changed == false),则报错。但如果flag有默认值且用户没有显式设置,cobra仍然会报错(因为 flag.Changed 为false)。
此函数通过检查:如果flag是必填的、有非空默认值、但未被显式设置,则将 flag.Changed 设为true,绕过cobra的必填检查。这样有默认值的必填flag不会因为用户没有显式设置而报错。
stringArrayValue 自定义数组类型(第115-172行)
go
type stringArrayValue struct {
value *types.StringArray
changed bool
}
func (s *stringArrayValue) Set(val string) error {
if !s.changed {
*s.value = []string{val} // 第一次设置:替换
s.changed = true
} else {
*s.value = append(*s.value, val) // 后续设置:追加
}
return nil
}
- 第一次
Set替换默认值 - 后续
Set追加(支持--flag a --flag b多次指定) - 实现了 pflag.Value 接口(Set, Type, String, Append, Replace, GetSlice)
stringMapValue 自定义Map类型(第174-224行)
go
func (s *stringMapValue) Set(val string) error {
var ss []string
n := strings.Count(val, "=")
switch n {
case 0:
return fmt.Errorf("%s must be formatted as key=value", val)
case 1:
ss = append(ss, strings.Trim(val, `"`)) // 单个key=value
default:
r := csv.NewReader(strings.NewReader(val)) // CSV解析多个key=value
var err error
ss, err = r.Read()
if err != nil {
return err
}
}
out := make(map[string]string, len(ss))
for _, pair := range ss {
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
return fmt.Errorf("%s must be formatted as key=value", pair)
}
out[kv[0]] = kv[1]
}
if !s.changed {
*s.value = out // 第一次:替换
} else {
for k, v := range out {
(*s.value)[k] = v // 后续:合并
}
}
s.changed = true
return nil
}
解析逻辑:
key=value--- 单个键值对key1=value1,key2=value2--- 多个键值对(CSV格式)- 多次指定
--map a=1 --map b=2--- 合并
String() 方法使用CSV格式序列化为 [a=1,b=2] 格式。
4.4 keys.go --- 密钥生成
文件定位:独立的SSH密钥对生成工具, borrowed from Docker Machine。
KeyPair 结构体(第26-30行)
go
type KeyPair struct {
PrivateKey []byte // DER格式的私钥
PublicKey []byte // SSH格式的公钥
}
NewKeyPair 生成密钥对(第36-55行)
go
func NewKeyPair() (keyPair *KeyPair, err error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048) // 固定2048位
if err != nil {
return nil, ErrKeyGeneration
}
if err := priv.Validate(); err != nil {
return nil, ErrValidation
}
privDer := x509.MarshalPKCS1PrivateKey(priv)
pubSSH, err := gossh.NewPublicKey(&priv.PublicKey)
if err != nil {
return nil, ErrPublicKey
}
return &KeyPair{
PrivateKey: privDer,
PublicKey: gossh.MarshalAuthorizedKey(pubSSH),
}, nil
}
- 固定使用RSA 2048位(与sshkey模块不同,那里支持自定义位数)
- 验证私钥有效性(
priv.Validate()检查数学一致性) - 公钥转换为SSH authorized_keys格式
WriteToFile 写入文件(第57-93行)
go
func (kp *KeyPair) WriteToFile(privateKeyPath string, publicKeyPath string) error {
files := []struct {
File string
Type string
Value []byte
}{
{
File: privateKeyPath,
Value: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Headers: nil, Bytes: kp.PrivateKey}),
},
{
File: publicKeyPath,
Value: kp.PublicKey,
},
}
for _, v := range files {
baseDir := path.Dir(v.File)
if _, err := os.Stat(baseDir); err != nil {
if os.IsNotExist(err) {
if err := os.MkdirAll(baseDir, 0700); err != nil { // 私钥目录0700
return fmt.Errorf("fail to create default ssh dir: %s", err)
}
}
}
f, err := os.Create(v.File)
if err != nil {
return ErrUnableToWriteFile
}
if _, err := f.Write(v.Value); err != nil {
return ErrUnableToWriteFile
}
// Unix-like系统设置私钥0600权限
switch runtime.GOOS {
case "darwin", "freebsd", "linux", "openbsd":
if err := f.Chmod(0600); err != nil {
return err
}
}
}
return nil
}
与sshkey模块的对比:
| 特性 | utils/keys.go | sshkey/utils.go |
|---|---|---|
| 密钥位数 | 固定2048 | 可配置(key.Bits) |
| 密码支持 | 不支持 | 支持(AES-256加密) |
| 存储方式 | 文件 | 数据库 |
| 文件权限 | 0600(正确) | 0644(偏宽松) |
| 跨平台 | 考虑了Windows | 未考虑 |
| 来源 | Docker Machine | 自主实现 |
注意:
utils/keys.go的WriteToFile对所有文件(包括公钥)都设置0600权限,这过于严格。公钥通常可以是0644。
Fingerprint 指纹计算(第95-101行)
go
func (kp *KeyPair) Fingerprint() string {
b, _ := base64.StdEncoding.DecodeString(string(kp.PublicKey))
h := md5.New()
_, _ = io.WriteString(h, string(b))
return fmt.Sprintf("%x", h.Sum(nil))
}
- 将SSH公钥(base64编码)解码为原始字节
- 计算MD5哈希
- 返回十六进制字符串
注意:这是MD5指纹,不是SHA256指纹。现代SSH默认使用SHA256指纹。
GenerateSSHKey / RemoveSSHKey 便捷函数(第103-125行)
go
func GenerateSSHKey(path string) error {
if _, err := os.Stat(path); err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("desired directory for SSH keys does not exist: %s", err)
}
kp, err := NewKeyPair()
if err != nil {
return fmt.Errorf("error generating key pair: %s", err)
}
if err := kp.WriteToFile(path, fmt.Sprintf("%s.pub", path)); err != nil {
return fmt.Errorf("error writing keys to file(s): %s", err)
}
}
return nil
}
func RemoveSSHKey(path string) error {
if _, err := os.Stat(path); err == nil {
_ = os.Remove(path) // 忽略删除错误
}
return nil
}
GenerateSSHKey:如果密钥文件不存在则生成,存在则跳过(幂等)RemoveSSHKey:删除私钥文件,忽略错误(不删除公钥)
4.5 registry.go --- Provider注册查询
文件定位:容器镜像registry配置文件的解析和序列化。
VerifyRegistryFileContent(第14-34行)
go
func VerifyRegistryFileContent(path, content string) (*registries.Registry, error) {
var registry registries.Registry
var err error
contentBytes := []byte(content)
if path != "" && content == "" {
contentBytes, err = ioutil.ReadFile(path)
if err != nil {
return nil, err
}
if len(contentBytes) == 0 {
return nil, fmt.Errorf("registry file %s is empty", path)
}
}
if err := yaml.Unmarshal(contentBytes, ®istry); err != nil {
return nil, err
}
return ®istry, nil
}
- 支持两种输入:文件路径或直接内容字符串
- 优先使用content参数,如果content为空则从path读取
- 使用
rancher/wharfie的registries.Registry类型解析 - YAML格式解析(K3s的registries.yaml)
典型的registries.yaml格式:
yaml
mirrors:
docker.io:
endpoint:
- "https://registry.example.com"
configs:
"registry.example.com":
auth:
username: user
password: pass
RegistryToString(第36-46行)
go
func RegistryToString(registry *registries.Registry) (string, error) {
if registry == nil {
return "", fmt.Errorf("can't save registry file: registry is nil")
}
b, err := yaml.Marshal(registry)
if err != nil {
return "", err
}
return string(b), nil
}
- 将Registry结构序列化为YAML字符串
- 用于持久化存储
4.6 ssh.go --- SSH工具函数
文件定位:SSH客户端配置生成、文件读取等工具函数。
StripUserHome 路径展开(第18-22行)
go
func StripUserHome(path string) string {
if len(path) > 2 && path[:2] == "~/" {
path = filepath.Join(UserHome(), path[2:])
}
return path
}
- 将
~/前缀展开为实际用户主目录 - 简单但实用的路径处理函数
GetFileContent 读取文件内容(第24-30行)
go
func GetFileContent(path string) ([]byte, error) {
buff, err := ioutil.ReadFile(StripUserHome(path))
if err != nil {
return []byte{}, err
}
return buff, nil
}
- 先展开
~/路径 - 读取文件内容
- 错误时返回空字节数组而非nil
SSHPrivateKeyPath / SSHCertificatePath(第32-46行)
go
func SSHPrivateKeyPath(sshKey string) (string, error) {
content, err := GetFileContent(sshKey)
if err != nil {
return "", fmt.Errorf("error while reading SSH key file: %v", err)
}
return string(content), nil
}
func SSHCertificatePath(sshCertPath string) (string, error) {
content, err := GetFileContent(sshCertPath)
if err != nil {
return "", fmt.Errorf("error while reading SSH certificate file: %v", err)
}
return string(content), nil
}
- 读取SSH私钥/证书文件内容并返回字符串
- 这两个函数功能完全相同,只是错误消息不同
GetSSHConfig SSH客户端配置生成(第49-107行)
go
func GetSSHConfig(username, sshPrivateKeyString, passphrase, sshCert string,
password string, timeout time.Duration, useAgentAuth bool) (*ssh.ClientConfig, error) {
config := &ssh.ClientConfig{
User: username,
Timeout: timeout,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // ⚠️ 不验证主机密钥
}
三种认证方式的优先级:
go
// 1. SSH Agent认证
if useAgentAuth {
if sshAgentSock := os.Getenv(sshAuthSock); sshAgentSock != "" {
sshAgent, err := net.Dial("unix", sshAgentSock)
if err != nil {
return config, fmt.Errorf("cannot connect to SSH Auth socket %q: %s", sshAgentSock, err)
}
config.Auth = append(config.Auth, ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers))
return config, nil
}
}
// 2. 私钥认证
else if sshPrivateKeyString != "" {
var signer ssh.Signer
var err error
if passphrase != "" {
signer, err = parsePrivateKeyWithPassphrase(sshPrivateKeyString, passphrase)
} else {
signer, err = parsePrivateKey(sshPrivateKeyString)
}
if err != nil {
return config, err
}
// 2a. 证书认证
if len(sshCert) > 0 {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(sshCert))
if err != nil {
return config, fmt.Errorf("unable to parse SSH certificate: %v", err)
}
if _, ok := key.(*ssh.Certificate); !ok {
return config, fmt.Errorf("unable to cast public key to SSH certificate")
}
signer, err = ssh.NewCertSigner(key.(*ssh.Certificate), signer)
if err != nil {
return config, err
}
}
config.Auth = append(config.Auth, ssh.PublicKeys(signer))
}
// 3. 密码认证
else if password != "" {
config.Auth = append(config.Auth, ssh.Password(password))
}
return config, nil
}
⚠️ 安全注意 :
ssh.InsecureIgnoreHostKey()禁用了主机密钥验证,存在中间人攻击风险。对于自动化工具这是常见妥协,但在安全敏感环境中应注意。
认证方式决策树:
useAgentAuth == true→ 使用SSH Agent(通过UNIX socket)sshPrivateKeyString != ""→ 使用私钥(可选密码+证书)password != ""→ 使用密码
这三种方式是互斥的(if-else链),优先级为 Agent > PrivateKey > Password。
4.7 util.go --- 通用工具
文件定位:各种杂项工具函数的集合。
RandomToken 随机Token生成(第29-36行)
go
func RandomToken(size int) (string, error) {
token := make([]byte, size)
_, err := rand.Read(token) // crypto/rand
if err != nil {
return "", err
}
return hex.EncodeToString(token), err
}
- 使用
crypto/rand生成密码学安全的随机字节 - 编码为十六进制字符串
- 用于生成K3s cluster token等场景
UniqueArray 数组去重(第38-50行)
go
func UniqueArray(origin []string) (unique []string) {
unique = make([]string, 0)
for i := 0; i < len(origin); i++ {
repeat := false
for j := i + 1; j < len(origin); j++ {
if origin[i] == origin[j] {
repeat = true
break
}
}
if !repeat {
unique = append(unique, origin[i])
}
}
return
}
- O(n²)的去重算法------对于每个元素,检查后面是否有重复
- 只保留最后一次出现的元素
- 适用于小数组(如节点列表),大数组应使用map去重
AskForConfirmation / AskForConfirmationWithError 交互确认(第52-66行)
go
func AskForConfirmationWithError(s string, def bool) (rtn bool, err error) {
prompt := survey.Confirm{
Message: s,
Default: def,
}
err = survey.AskOne(&prompt, &rtn)
return
}
func AskForConfirmation(s string, def bool) (rtn bool) {
var err error
if rtn, err = AskForConfirmationWithError(s, def); err != nil {
logrus.Warnf("failed to confirm, %v", err)
}
return
}
- 使用
AlecAivazis/survey库实现交互式确认 - 支持默认值
AskForConfirmation吞掉错误返回默认值(false)
AskForSelectItem 交互选择(第68-86行)
go
func AskForSelectItem(s string, ss map[string]string) string {
reader := bufio.NewReader(os.Stdin)
t := template.New("tmpl")
t, err := t.Parse(tmpl)
if err != nil {
return ""
}
buffer := new(bytes.Buffer)
if err := t.Execute(buffer, ss); err != nil {
return ""
}
fmt.Printf("%s: \n \t%s\n[choose one id]: ", s, buffer.String())
response, err := reader.ReadString('\n')
if err != nil {
logrus.Fatal(err)
}
return ss[strings.ToLower(strings.TrimSpace(response))]
}
- 使用模板渲染选项列表
- 从stdin读取用户输入
- 返回map中对应的值
⚠️ 不一致 :其他函数错误时返回false或空字符串,这里使用
logrus.Fatal直接退出程序。
WaitFor 重试等待(第88-97行)
go
func WaitFor(fn func() (bool, error)) error {
backoff := wait.Backoff{
Duration: 30 * time.Second,
Factor: 1,
Steps: 5,
}
return waitForBackoff(fn, backoff)
}
- 使用K8s的
wait.Backoff实现指数退避重试 - 初始间隔30秒,无退避因子(Factor=1,即固定间隔),最多5步
- 总最大等待时间150秒
ConvertToFields 结构体转schemas字段(第99-122行)
go
func ConvertToFields(obj interface{}) (map[string]schemas.Field, error) {
t := reflect.TypeOf(obj)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("can't convert non struct type obj %v", obj)
}
num := t.NumField()
fields := make(map[string]schemas.Field, 0)
for i := 0; i < num; i++ {
f := t.Field(i)
if v, ok := f.Tag.Lookup("json"); ok {
fieldName := strings.Split(v, ",")[0]
field := schemas.Field{
Type: f.Type.String(),
Default: reflect.ValueOf(obj).Field(i).Interface(),
}
fields[fieldName] = field
}
}
return fields, nil
}
- 使用反射将结构体转换为
schemas.Fieldmap - 只处理有
jsontag的字段 - 提取字段名(取json tag第一个值,如
json:"name,omitempty"→name)、类型和默认值 - 用于API层动态生成schema
MergeConfig 配置合并(第124-148行)
go
func MergeConfig(source, target reflect.Value) {
if source.Kind() == reflect.Ptr {
source = source.Elem()
}
if target.Kind() == reflect.Ptr {
target = target.Elem()
}
for i := 0; i < source.NumField(); i++ {
sField := source.Field(i)
for j := 0; j < target.NumField(); j++ {
tField := target.Field(j)
if sField.Type().Kind() == tField.Type().Kind() &&
source.Type().Field(i).Name == target.Type().Field(j).Name {
if sField.Type().Kind() == reflect.Struct {
MergeConfig(sField, tField) // 递归合并嵌套结构体
} else if sField.Type().Kind() == reflect.Bool {
sField.Set(tField) // bool类型直接覆盖
} else {
if !tField.IsZero() { // 非bool类型只在target非零时覆盖
sField.Set(tField)
}
}
break
}
}
}
}
合并规则:
- 嵌套结构体 → 递归合并
- 布尔字段 → 直接覆盖(即使target为false)
- 其他字段 → 只在target非零值时覆盖
⚠️ 性能:O(n×m) 的字段匹配复杂度(n=source字段数,m=target字段数)。对于小结构体可以接受,大结构体应使用字段名map优化。
⚠️ 可设置性 :sField.Set(tField)要求字段是exported的。未导出字段会panic,但这里没有检查。
StringSupportBase64 Base64解码(第160-169行)
go
func StringSupportBase64(value string) string {
if value == "" {
return value
}
valueByte, err := base64.StdEncoding.DecodeString(value)
if err != nil {
logrus.Debugf("failed decode string %s, got error: %v", value, err)
valueByte = []byte(value)
}
return string(valueByte)
}
- 尝试Base64解码,失败则返回原始字符串
- 用于支持Base64编码的配置值(如安装脚本)
GenerateRand 随机数(第171-175行)
go
func GenerateRand() int {
mrand.Seed(time.Now().UnixNano())
return mrand.Intn(255)
}
- 使用
math/rand生成0-254的随机数 - 每次调用都重新设种子(不推荐,但在并发不频繁时无影响)
IsTerm 终端检测(第177-180行)
go
func IsTerm() bool {
return term.IsTerminal(int(syscall.Stdin))
}
- 检查stdin是否为终端
- 用于决定是否显示交互式提示
CommandExitWithoutHelpInfo 命令包装器(第182-190行)
go
func CommandExitWithoutHelpInfo(f func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) {
return func(cmd *cobra.Command, args []string) {
if err := f(cmd, args); err != nil {
cmd.PrintErr(err)
os.Exit(1)
}
}
}
- 包装cobra命令的RunE函数
- 错误时不显示cobra的帮助信息,只打印错误并退出
- 用于提供更简洁的错误输出
4.8 registry_test.go --- 测试
go
func TestRegistryValidating(t *testing.T) {
content := `
mirrors:
docker.io:
endpoint:
- "https://docker.nju.edu.cn"
`
expected := ®istries.Registry{
Mirrors: map[string]registries.Mirror{
"docker.io": {
Endpoints: []string{"https://docker.nju.edu.cn"},
},
},
}
reg, err := VerifyRegistryFileContent("", content)
if !assert.Nil(t, err, "should not return error for testing") {
return
}
assert.Equal(t, expected, reg)
}
- 测试YAML格式的registry配置解析
- 使用南京大学的Docker镜像作为测试数据
- 验证解析结果与预期结构体匹配
4.9 Flag转换流程 Mermaid 图
#mermaid-svg-i8FmM5wsLiVWqRs8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i8FmM5wsLiVWqRs8 .error-icon{fill:#552222;}#mermaid-svg-i8FmM5wsLiVWqRs8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i8FmM5wsLiVWqRs8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .marker.cross{stroke:#333333;}#mermaid-svg-i8FmM5wsLiVWqRs8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i8FmM5wsLiVWqRs8 p{margin:0;}#mermaid-svg-i8FmM5wsLiVWqRs8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .cluster-label text{fill:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .cluster-label span{color:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .cluster-label span p{background-color:transparent;}#mermaid-svg-i8FmM5wsLiVWqRs8 .label text,#mermaid-svg-i8FmM5wsLiVWqRs8 span{fill:#333;color:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .node rect,#mermaid-svg-i8FmM5wsLiVWqRs8 .node circle,#mermaid-svg-i8FmM5wsLiVWqRs8 .node ellipse,#mermaid-svg-i8FmM5wsLiVWqRs8 .node polygon,#mermaid-svg-i8FmM5wsLiVWqRs8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .rough-node .label text,#mermaid-svg-i8FmM5wsLiVWqRs8 .node .label text,#mermaid-svg-i8FmM5wsLiVWqRs8 .image-shape .label,#mermaid-svg-i8FmM5wsLiVWqRs8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-i8FmM5wsLiVWqRs8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .rough-node .label,#mermaid-svg-i8FmM5wsLiVWqRs8 .node .label,#mermaid-svg-i8FmM5wsLiVWqRs8 .image-shape .label,#mermaid-svg-i8FmM5wsLiVWqRs8 .icon-shape .label{text-align:center;}#mermaid-svg-i8FmM5wsLiVWqRs8 .node.clickable{cursor:pointer;}#mermaid-svg-i8FmM5wsLiVWqRs8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .arrowheadPath{fill:#333333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i8FmM5wsLiVWqRs8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-i8FmM5wsLiVWqRs8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i8FmM5wsLiVWqRs8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-i8FmM5wsLiVWqRs8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .cluster text{fill:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 .cluster span{color:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-i8FmM5wsLiVWqRs8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-i8FmM5wsLiVWqRs8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-i8FmM5wsLiVWqRs8 .icon-shape,#mermaid-svg-i8FmM5wsLiVWqRs8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i8FmM5wsLiVWqRs8 .icon-shape p,#mermaid-svg-i8FmM5wsLiVWqRs8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-i8FmM5wsLiVWqRs8 .icon-shape .label rect,#mermaid-svg-i8FmM5wsLiVWqRs8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i8FmM5wsLiVWqRs8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-i8FmM5wsLiVWqRs8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-i8FmM5wsLiVWqRs8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
bool
string
mapstringstring
\[\]string
types.StringArray
types.StringMap
int
default
同上, 使用VarP或BoolVarP等
是
否
是
否
是
否
是
否
Provider定义types.Flag数组
ConvertFlags 遍历Flag
ShortHand为空?
Lookup检查是否已注册
Lookup检查是否已注册
类型判断
类型判断
BoolVar
StringVar
StringToStringVar
StringArrayVar
Var: newStringArrayValue
Var: newStringMapValue
IntVar
continue 跳过
Required?
MarkFlagRequired
有EnvVar?
SetAnnotation绑定环境变量
继续下一个Flag
还有Flag?
返回FlagSet
CLI执行
ValidateRequiredFlags
必填flag有默认值
但未被显式设置?
设flag.Changed=true
正常执行
5. settings 模块------系统设置
5.1 setting.go --- 设置键值存储
文件定位:系统级设置的管理,支持Provider后端和默认值回退。
Setting 结构体(第15-20行)
go
type Setting struct {
Name string
Default string
Description string
}
Provider 接口(第22-27行)
go
type Provider interface {
Get(name string) string
Set(name, value string) error
SetIfUnset(name, value string) error
SetAll(settings map[string]Setting) error
}
Provider接口允许不同的后端实现(如数据库、环境变量、内存map等)。
预定义设置项(第29-40行)
go
var (
WhitelistDomain = newSetting("whitelist-domain", "", "the domains or ips which allowed in autok3s UI proxy")
EnableMetrics = newSetting("enable-metrics", "promote", "Should enable telemetry or not")
InstallUUID = newSetting("install-uuid", "", "The autok3s instance unique install id")
InstallScript = newSetting("install-script", "", "The k3s offline install script with base64 encode")
ScriptUpdateSource = newSetting("install-script-source-repo", "https://rancher-mirror.rancher.cn/k3s/k3s-install.sh", "The install script auto update source, github or aliyun oss")
PackageDownloadSource = newSetting("package-download-source", "github", "The airgap package download source, github and aliyunoss are validated.")
HelmDashboardEnabled = newSetting("helm-dashboard-enabled", "false", "The helm-dashboard is enabled or not")
HelmDashboardPort = newSetting("helm-dashboard-port", "", "The helm-dashboard server port after enabled")
)
设置项分类:
| 设置名 | 默认值 | 用途 |
|---|---|---|
| whitelist-domain | "" | UI代理白名单域名 |
| enable-metrics | "promote" | 遥测开关(promote=提示用户选择) |
| install-uuid | "" | 实例唯一ID |
| install-script | "" | K3s安装脚本内容 |
| install-script-source-repo | rancher-mirror.rancher.cn | 安装脚本更新源 |
| package-download-source | "github" | 离线包下载源 |
| helm-dashboard-enabled | "false" | Helm Dashboard开关 |
| helm-dashboard-port | "" | Helm Dashboard端口 |
newSetting 注册函数(第42-52行)
go
func newSetting(name, def, desc string) Setting {
s := Setting{
Name: name,
Default: def,
Description: desc,
}
settings[name] = s
return s
}
- 创建Setting并存入全局map
- 在包初始化时调用(var声明中使用)
SetProvider 设置Provider后端(第54-60行)
go
func SetProvider(p Provider) error {
if err := p.SetAll(settings); err != nil {
return err
}
provider = p
return nil
}
- 将所有已注册的设置项同步到Provider
- 设置全局provider
Get / Set 方法(第62-78行)
go
func (s Setting) Get() string {
if provider == nil {
return settings[s.Name].Default // 无provider时返回默认值
}
return provider.Get(s.Name) // 有provider时从后端获取
}
func (s Setting) Set(value string) error {
if provider == nil {
setting := settings[s.Name]
setting.Default = value
settings[s.Name] = setting // 无provider时修改内存中的默认值
return nil
}
return provider.Set(s.Name, value) // 有provider时写入后端
}
双重行为:
- 无Provider:操作内存中的map(用于测试或单机模式)
- 有Provider:操作后端存储(如数据库)
GetScriptFromSource 从远程下载安装脚本(第80-95行)
go
func GetScriptFromSource(writer io.Writer) error {
sourceURL := ScriptUpdateSource.Get()
if _, err := url.Parse(sourceURL); err != nil {
return errors.Wrap(err, "install script source url is not validated")
}
resp, err := http.Get(sourceURL)
if err != nil {
return errors.Wrap(err, "failed to make request to install script source url")
}
defer resp.Body.Close()
if _, err := io.Copy(writer, resp.Body); err != nil {
return errors.Wrap(err, "failed to copy data to target writer")
}
return nil
}
- 从配置的URL下载K3s安装脚本
- 直接流式写入提供的writer(可以是文件或buffer)
- 不检查HTTP状态码(只检查请求错误)
⚠️ 问题:没有检查HTTP响应状态码,如果服务器返回404或500,会将错误页面内容写入文件。
5.2 script/main.go --- 安装脚本下载器
文件定位:独立的小工具程序,用于下载K3s安装脚本到本地文件。
go
package main
const defaultFileName = "install.sh"
func main() {
if len(os.Args) != 2 {
logrus.Fatal("target path should be specified")
}
targetPath := os.Args[1]
info, err := os.Lstat(targetPath)
if err != nil && !os.IsNotExist(err) {
logrus.Fatal(err)
}
if err == nil && info.IsDir() {
targetPath = filepath.Join(strings.TrimSuffix(targetPath, "/"), defaultFileName)
// 如果目标是目录,在目录下创建install.sh
}
targetFile, err := os.OpenFile(targetPath, os.O_RDWR|os.O_CREATE, 0755)
if err != nil {
logrus.Fatalf("failed to create script file, %v", err)
}
defer targetFile.Close()
if err := settings.GetScriptFromSource(targetFile); err != nil {
logrus.Fatalf("failed to get script from source, %v", err)
}
}
用法 :go run script/main.go /path/to/install.sh 或 go run script/main.go /path/to/dir/
行为:
- 接受一个路径参数
- 如果是目录,在目录下创建
install.sh - 如果是文件路径,直接使用
- 从settings配置的URL下载脚本写入文件
- 文件权限0755(可执行)
这是一个构建工具------在编译AutoK3s前运行,预先下载安装脚本。
5.3 script_prod.go --- 生产环境嵌入
go
//go:build prod
// +build prod
package settings
import (
"embed"
)
//go:embed install.sh
var asserts embed.FS
func init() {
data, err := asserts.ReadFile("install.sh")
if err != nil {
panic("install.sh should be included when compiling autok3s")
}
set := settings[InstallScript.Name]
set.Default = string(data)
settings[InstallScript.Name] = set
}
构建标签机制:
- 只有使用
-tags prod编译时才包含此文件 - 使用Go 1.16+的
embed.FS将install.sh嵌入二进制文件 - 在
init()中将嵌入的脚本内容设为InstallScript设置的默认值
两种构建模式:
| 模式 | 构建命令 | 安装脚本来源 |
|---|---|---|
| 开发模式 | go build |
运行时从URL下载 |
| 生产模式 | go build -tags prod |
嵌入二进制文件中 |
⚠️ 变量名 :
asserts应为assets(拼写错误)。
6. metrics 模块------Prometheus指标
文件定位:遥测指标定义和定时推送。
常量与指标定义(第17-38行)
go
const (
metricsEndpoint = "https://telemetry.rancher.cn" // Rancher中国遥测端点
jobName = "autok3s"
)
var (
ClusterCount = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Subsystem: "autok3s",
Name: "cluster_count",
Help: "the cluster count for the current autok3s setup, label by provider and k3s version.",
}, []string{"provider", "k3sversion", "install_uuid"})
TemplateCount = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Subsystem: "autok3s",
Name: "cluster_template_count",
Help: "the cluster template count for the current autok3s setup, label by provider and k3s version.",
}, []string{"provider", "k3sversion", "install_uuid"})
Active = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Subsystem: "autok3s",
Name: "up",
Help: "the autok3s running status",
}, []string{"install_uuid", "version"})
)
三个指标:
| 指标名 | 类型 | 标签 | 用途 |
|---|---|---|---|
| autok3s_cluster_count | GaugeVec | provider, k3sversion, install_uuid | 集群数量 |
| autok3s_cluster_template_count | GaugeVec | provider, k3sversion, install_uuid | 集群模板数量 |
| autok3s_up | GaugeVec | install_uuid, version | 运行状态 |
初始化(第40-48行)
go
var (
defaultRegistry = prometheus.NewRegistry()
enableFunc func() bool
once = &sync.Once{}
pusher *push.Pusher
)
func init() {
defaultRegistry.MustRegister(ClusterCount, TemplateCount, Active)
pusher = push.New(metricsEndpoint, jobName).
Format(expfmt.FmtText).
Gatherer(defaultRegistry)
}
- 创建独立的Registry(不使用默认全局Registry)
- 注册三个指标
- 创建Pusher配置:使用文本格式推送到
telemetry.rancher.cn/metrics/job/autok3s
注意:使用Push模式而非Pull模式------AutoK3s作为CLI工具没有长期运行的服务端口,不适合Prometheus pull。
SetupEnableFunc 设置启用函数(第50-54行)
go
func SetupEnableFunc(f func() bool) {
once.Do(func() {
enableFunc = f
})
}
- 使用
sync.Once确保只设置一次 - 启用函数通常检查
settings.EnableMetrics.Get()
Report 单次推送(第56-67行)
go
func Report() {
if enableFunc == nil || !enableFunc() {
return
}
logrus.Debug("Reporting metrics")
if err := pusher.Push(); err != nil {
if !strings.Contains(err.Error(), "unexpected status code 204") {
logrus.Debug("failed to push metrics to telemetry")
}
}
}
- 检查是否启用遥测
- 推送指标到Pushgateway
- 忽略204状态码错误(Telegraf返回204而非200/202)
ReportEach 定时推送(第69-85行)
go
func ReportEach(ctx context.Context, interval time.Duration) {
t := time.NewTicker(interval)
go func() {
for {
select {
case <-t.C:\n\t\t\t\tReport()\n\t\t\tcase <-ctx.Done():
t.Stop()
return
}
}
}()
}
- 使用Ticker定时推送
- 支持context取消,优雅停止
- 在独立goroutine中运行
指标上报流程:
指标定义 → Registry注册 → Pusher推送 → telemetry.rancher.cn
↑
enableFunc检查
↑
settings.EnableMetrics
7. cli/kubectl 模块------kubectl嵌入
文件定位:将完整的kubectl命令行工具嵌入AutoK3s。
go
package kubectl
import (
"fmt"
"os"
"github.com/spf13/cobra"
"k8s.io/kubectl/pkg/cmd"
)
func Main() {
if err := EmbedCommand().Execute(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func EmbedCommand() *cobra.Command {
c := cmd.NewDefaultKubectlCommand()
c.Short = "Kubectl controls the Kubernetes cluster manager"
return c
}
极其简洁的实现:
EmbedCommand使用k8s.io/kubectl创建完整的kubectl命令树Main执行该命令,错误时输出到stderr并退出- AutoK3s的CLI可以将此cobra命令作为子命令添加
Reexec机制 :
AutoK3s主程序在执行 autok3s kubectl ... 时,可能通过reexec机制调用嵌入的kubectl。这避免了用户单独安装kubectl的需要。
依赖分析:
k8s.io/kubectl/pkg/cmd是Kubernetes官方kubectl包- 嵌入后会增加二进制文件大小(约30-50MB),但提供了完整的kubectl功能
8. 模块间依赖关系
#mermaid-svg-46TYBXkv7vRtQcwg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-46TYBXkv7vRtQcwg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-46TYBXkv7vRtQcwg .error-icon{fill:#552222;}#mermaid-svg-46TYBXkv7vRtQcwg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-46TYBXkv7vRtQcwg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-46TYBXkv7vRtQcwg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-46TYBXkv7vRtQcwg .marker.cross{stroke:#333333;}#mermaid-svg-46TYBXkv7vRtQcwg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-46TYBXkv7vRtQcwg p{margin:0;}#mermaid-svg-46TYBXkv7vRtQcwg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-46TYBXkv7vRtQcwg .cluster-label text{fill:#333;}#mermaid-svg-46TYBXkv7vRtQcwg .cluster-label span{color:#333;}#mermaid-svg-46TYBXkv7vRtQcwg .cluster-label span p{background-color:transparent;}#mermaid-svg-46TYBXkv7vRtQcwg .label text,#mermaid-svg-46TYBXkv7vRtQcwg span{fill:#333;color:#333;}#mermaid-svg-46TYBXkv7vRtQcwg .node rect,#mermaid-svg-46TYBXkv7vRtQcwg .node circle,#mermaid-svg-46TYBXkv7vRtQcwg .node ellipse,#mermaid-svg-46TYBXkv7vRtQcwg .node polygon,#mermaid-svg-46TYBXkv7vRtQcwg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-46TYBXkv7vRtQcwg .rough-node .label text,#mermaid-svg-46TYBXkv7vRtQcwg .node .label text,#mermaid-svg-46TYBXkv7vRtQcwg .image-shape .label,#mermaid-svg-46TYBXkv7vRtQcwg .icon-shape .label{text-anchor:middle;}#mermaid-svg-46TYBXkv7vRtQcwg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-46TYBXkv7vRtQcwg .rough-node .label,#mermaid-svg-46TYBXkv7vRtQcwg .node .label,#mermaid-svg-46TYBXkv7vRtQcwg .image-shape .label,#mermaid-svg-46TYBXkv7vRtQcwg .icon-shape .label{text-align:center;}#mermaid-svg-46TYBXkv7vRtQcwg .node.clickable{cursor:pointer;}#mermaid-svg-46TYBXkv7vRtQcwg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-46TYBXkv7vRtQcwg .arrowheadPath{fill:#333333;}#mermaid-svg-46TYBXkv7vRtQcwg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-46TYBXkv7vRtQcwg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-46TYBXkv7vRtQcwg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-46TYBXkv7vRtQcwg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-46TYBXkv7vRtQcwg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-46TYBXkv7vRtQcwg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-46TYBXkv7vRtQcwg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-46TYBXkv7vRtQcwg .cluster text{fill:#333;}#mermaid-svg-46TYBXkv7vRtQcwg .cluster span{color:#333;}#mermaid-svg-46TYBXkv7vRtQcwg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-46TYBXkv7vRtQcwg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-46TYBXkv7vRtQcwg rect.text{fill:none;stroke-width:0;}#mermaid-svg-46TYBXkv7vRtQcwg .icon-shape,#mermaid-svg-46TYBXkv7vRtQcwg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-46TYBXkv7vRtQcwg .icon-shape p,#mermaid-svg-46TYBXkv7vRtQcwg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-46TYBXkv7vRtQcwg .icon-shape .label rect,#mermaid-svg-46TYBXkv7vRtQcwg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-46TYBXkv7vRtQcwg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-46TYBXkv7vRtQcwg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-46TYBXkv7vRtQcwg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通用层
辅助模块(本文范围)
Provider层
CLI层
embed
依赖
cmd/ cobra commands
cli/kubectl
pkg/providers/*
airgap
sshkey
settings
metrics
utils
common
hosts/dialer
types
script/install.sh
k8s.io/kubectl
关键依赖关系:
- airgap → settings:读取下载源配置和安装脚本
- airgap → hosts/dialer:SSH连接和命令执行
- sshkey → utils:文件读取和密钥操作
- metrics → settings:检查遥测是否启用
- utils → types:使用自定义类型(StringArray, StringMap, Flag, SSH等)
- 所有模块 → common:数据库、配置路径等公共依赖
9. 设计模式与架构总结
9.1 设计模式
| 模式 | 应用位置 | 说明 |
|---|---|---|
| 策略模式 | settings.Provider接口 | 支持不同的设置后端(DB/内存) |
| 状态机 | airgap downloadPackage | Package状态流转:Validating→Downloading→Verifying→Active |
| 原子标记 | airgap .done文件 | 文件存在即为完成标记,原子rename写入 |
| 建造者模式 | downloader结构体 | 逐步构建下载上下文 |
| 快速失败 | FirstErrGroup | 遇到第一个错误后快速终止 |
| 模板方法 | ConvertFlags | 统一的Flag转换流程 |
| 工厂模式 | newSetting/newKeyPair | 统一创建并注册对象 |
| 装饰器 | CommandExitWithoutHelpInfo | 包装cobra命令函数 |
| 单例 | metrics.SetupEnableFunc (sync.Once) | 确保只初始化一次 |
| 回退策略 | resourceSuffixes多后缀 | .tar.gz回退到.tar |
9.2 架构亮点
-
离线包的幂等性设计:
.done文件标记确保重复调用不会重新下载- 版本差异计算实现增量更新
- 临时文件+rename确保原子写入
- SHA256校验保证文件完整性
-
多下载源支持:
- GitHub Release(官方)和阿里云OSS(中国镜像)
- 通过settings动态配置,无需修改代码
- 版本号格式适配(
+→-)
-
SCP传输的原子性:
- 先上传到临时目录
- 再通过
mv原子移动到目标位置 - 错误时记录需清理的文件
-
Flag转换的灵活性:
- 支持自定义类型(StringArray, StringMap)
- 环境变量绑定
- 必填标志的智能处理
-
构建标签区分环境:
- 开发模式:运行时下载安装脚本
- 生产模式(
-tags prod):嵌入安装脚本到二进制
9.3 潜在问题与改进建议
| 问题 | 位置 | 严重度 | 建议 |
|---|---|---|---|
s390s 应为 s390x |
airgap/archs.go ValidatedArch | 中 | 修正拼写 |
| 私钥文件权限0644 | sshkey/clusterkey.go | 高 | 改为0600 |
HostKeyCallback: InsecureIgnoreHostKey() |
utils/ssh.go | 中 | 考虑支持已知主机验证 |
| tar解压未检查路径穿越 | airgap/tarfiles.go | 中 | 添加 filepath.Clean + 前缀检查 |
GetScriptFromSource 不检查HTTP状态码 |
settings/setting.go | 低 | 添加状态码检查 |
asserts 拼写错误 |
settings/script_prod.go | 低 | 改为 assets |
UniqueArray O(n²) 算法 |
utils/util.go | 低 | 大数组应改用map |
MergeConfig 未检查字段可设置性 |
utils/util.go | 中 | 添加 sField.CanSet() 检查 |
| 变量遮蔽 in CreateSSHKey | sshkey/utils.go | 中 | 重命名内部变量 |
os.O_APPEND 模式用于新文件 |
airgap/tarfiles.go | 低 | 改为 os.O_TRUNC |
| errgroup无并发限制 | utils/errgroup.go | 低 | 考虑添加semaphore限制 |
9.4 代码质量评估
| 维度 | 评分 | 说明 |
|---|---|---|
| 功能完整性 | ★★★★☆ | 覆盖了离线安装的完整流程,多架构支持完善 |
| 错误处理 | ★★★☆☆ | 部分错误被吞掉(_ =),状态回退处理到位 |
| 并发安全 | ★★★★☆ | sync.Map用于cancel管理,FirstErrGroup设计合理 |
| 可测试性 | ★★★☆☆ | 测试覆盖较少,关键路径如downloadPackage无测试 |
| 代码复用 | ★★★☆☆ | ConvertFlags有无/短选项两段重复代码 |
| 安全性 | ★★★☆☆ | 私钥权限偏宽松,SSH主机密钥未验证,tar未防穿越 |
| 可维护性 | ★★★★☆ | 模块职责清晰,常量命名规范,日志完善 |
| 跨平台 | ★★★☆☆ | UserHome考虑了Windows,但文件权限仅Unix处理 |
总评:AutoK3s v0.9.3的辅助模块设计整体合理,airgap模块的离线包管理是最大亮点------状态机+原子标记+多源回退的设计体现了工程成熟度。sshkey和utils模块提供了必要的SSH和文件操作支持。settings模块的Provider模式提供了良好的扩展性。metrics模块简洁有效。需要关注的主要是安全性方面的改进(私钥权限、主机密钥验证、路径穿越防护)。