diff --git a/app/app.go b/app/app.go index aa1c82e..ddae517 100644 --- a/app/app.go +++ b/app/app.go @@ -5,10 +5,8 @@ import ( "flag" "fmt" "log" - "net/http" - "strconv" + "time" - "github.com/gin-gonic/gin" "github.com/luthermonson/go-proxmox" ) @@ -28,25 +26,36 @@ func Run() { token := fmt.Sprintf(`%s@%s!%s`, config.PVE.Token.USER, config.PVE.Token.REALM, config.PVE.Token.ID) client = NewClient(token, config.PVE.Token.Secret) - router := gin.Default() + //router := gin.Default() - router.GET("/version", func(c *gin.Context) { - PVEVersion, err := client.Version() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } else { - c.JSON(http.StatusOK, gin.H{"api-version": APIVersion, "pve-version": PVEVersion}) - } - }) + start := time.Now() + cluster := Cluster{} + cluster.Init(client) + cluster.Rebuild() + elapsed := time.Since(start) - router.GET("/nodes/:node", func(c *gin.Context) { - Node, err := client.Node(c.Param("node")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } else { - c.JSON(http.StatusOK, gin.H{"node": Node}) - } - }) + fmt.Println(cluster) + fmt.Println(elapsed) - router.Run("0.0.0.0:" + strconv.Itoa(config.ListenPort)) + /* + router.GET("/version", func(c *gin.Context) { + PVEVersion, err := client.Version() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } else { + c.JSON(http.StatusOK, gin.H{"api-version": APIVersion, "pve-version": PVEVersion}) + } + }) + + router.GET("/nodes/:node", func(c *gin.Context) { + Node, err := client.Node(c.Param("node")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } else { + c.JSON(http.StatusOK, gin.H{"node": Node}) + } + }) + + router.Run("0.0.0.0:" + strconv.Itoa(config.ListenPort)) + */ } diff --git a/app/model.go b/app/model.go new file mode 100644 index 0000000..873437d --- /dev/null +++ b/app/model.go @@ -0,0 +1,214 @@ +package app + +import ( + "fmt" + "strconv" + "strings" +) + +type Cluster struct { + pve ProxmoxClient + Hosts map[string]*Host + //Instance map[uint]*Instance +} + +func (cluster *Cluster) Init(pve ProxmoxClient) { + cluster.pve = pve +} + +func (cluster *Cluster) Rebuild() error { + cluster.Hosts = make(map[string]*Host) + //cluster.Instance = make(map[uint]*Instance) + + // get all nodes + nodes, err := cluster.pve.Nodes() + if err != nil { + return err + } + // for each node: + for _, hostName := range nodes { + // rebuild node + err := cluster.RebuildNode(hostName) + if err != nil { + return err + } + } + + return nil +} + +func (cluster *Cluster) RebuildNode(hostName string) error { + host, err := cluster.pve.Node(hostName) + if err != nil { + return err + } + cluster.Hosts[hostName] = &host + + // get node's VMs + vms, err := host.VirtualMachines() + if err != nil { + return err + } + for _, vmid := range vms { + err := host.RebuildVM(vmid) + if err != nil { + return err + } + } + + // get node's CTs + cts, err := host.Containers() + if err != nil { + return err + } + for _, vmid := range cts { + err := host.RebuildCT(vmid) + if err != nil { + return err + } + } + + return nil +} + +func (host *Host) RebuildVM(vmid uint) error { + instance, err := host.VirtualMachine(vmid) + if err != nil { + return err + } + + host.Instance[vmid] = &instance + + for volid := range instance.configDisks { + instance.RebuildVolume(host, volid) + } + + for netid := range instance.configNets { + instance.RebuildNet(netid) + } + + return nil +} + +func (host *Host) RebuildCT(vmid uint) error { + instance, err := host.Container(vmid) + if err != nil { + return err + } + + host.Instance[vmid] = &instance + + for volid := range instance.configDisks { + instance.RebuildVolume(host, volid) + } + + for netid := range instance.configNets { + instance.RebuildNet(netid) + } + + return nil +} + +func (instance *Instance) RebuildVolume(host *Host, volid string) error { + volumeDataString := instance.configDisks[volid] + + volume, _, _, err := GetVolumeInfo(*host, volumeDataString) + if err != nil { + return err + } + + instance.Volume[volid] = &volume + + return nil +} + +func (instance *Instance) RebuildNet(netid string) error { + net := instance.configNets[netid] + idnum, err := strconv.ParseUint(strings.TrimPrefix(netid, "net"), 10, 64) + if err != nil { + return err + } + + netinfo, err := GetNetInfo(net) + if err != nil { + return nil + } + + instance.Net[uint(idnum)] = &netinfo + + return nil +} + +func (cluster Cluster) String() string { + r := "" + for _, host := range cluster.Hosts { + r += host.String() + } + return r +} + +func (host Host) String() string { + r := fmt.Sprintf("%s\n\tCores:\t%s\n\tMemory:\t%s\n\tSwap:\t%s\n", host.Name, host.Cores, host.Memory, host.Swap) + + r += "\tHardware:\n" + + for _, superdevice := range host.Hardware { + r += fmt.Sprintf("%s\n", superdevice) + } + + r += "\tInstances:\n" + + for vmid, vm := range host.Instance { + r += fmt.Sprintf("\t\t%d: %s\n", vmid, vm) + } + + return r +} + +func (r Resource) String() string { + return fmt.Sprintf("Totl: %d, Rsrv: %d, Free: %d", r.Total, r.Reserved, r.Free) +} + +func (superdevice HostSuperDevice) String() string { + s := fmt.Sprintf("\t\t%s: %s %s -> ", superdevice.BusID, superdevice.VendorName, superdevice.DeviceName) + numunused := 0 + for _, device := range superdevice.Devices { + if device.Reserved { + s += fmt.Sprintf("%s:(Rsrv %t, %s %s: %s %s)", device.SubID, device.Reserved, superdevice.VendorName, device.SubVendorName, superdevice.DeviceName, device.SubDeviceName) + } else { + numunused++ + } + } + s += fmt.Sprintf("+%d unreserved subdevices", numunused) + return s +} + +func (i Instance) String() string { + if i.Type == VM { + r := fmt.Sprintf("VM, Name: %s, Proctype: %s, Cores: %d, Memory: %d\n", i.Name, i.Proctype, i.Cores, i.Memory) + for k, v := range i.Volume { + r += fmt.Sprintf("\t\t\t%s: %s\n", k, v) + } + for k, v := range i.Net { + r += fmt.Sprintf("\t\t\tnet%d: %s\n", k, v) + } + return r + } else { + r := fmt.Sprintf("CT, Name: %s, Cores: %d, Memory: %d, Swap: %d\n", i.Name, i.Cores, i.Memory, i.Swap) + for k, v := range i.Volume { + r += fmt.Sprintf("\t\t\t%s: %s\n", k, v) + } + for k, v := range i.Net { + r += fmt.Sprintf("\t\t\tnet%d: %s\n", k, v) + } + return r + } +} + +func (v Volume) String() string { + return fmt.Sprintf("id: %s, format: %s, size: %d", v.Volid, v.Format, v.Size) +} + +func (n Net) String() string { + return fmt.Sprintf("rate: %d, vlan: %d", n.Rate, n.VLAN) +} diff --git a/app/proxmox.go b/app/proxmox.go index 70f91ef..fdc45c7 100644 --- a/app/proxmox.go +++ b/app/proxmox.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "fmt" "net/http" + "strconv" "strings" "github.com/luthermonson/go-proxmox" @@ -14,6 +15,14 @@ type ProxmoxClient struct { client *proxmox.Client } +type PVEDevice struct { + BusID string `json:"id"` + DeviceName string `json:"device_name"` + VendorName string `json:"vendor_name"` + SubsystemDeviceName string `json:"subsystem_device_name"` + SubsystemVendorName string `json:"subsystem_vendor_name"` +} + func NewClient(token string, secret string) ProxmoxClient { HTTPClient := http.Client{ Transport: &http.Transport{ @@ -23,8 +32,6 @@ func NewClient(token string, secret string) ProxmoxClient { }, } - println(token, secret) - client := proxmox.NewClient("https://pve.tronnet.net/api2/json", proxmox.WithHTTPClient(&HTTPClient), proxmox.WithAPIToken(token, secret), @@ -42,86 +49,216 @@ func (pve ProxmoxClient) Version() (proxmox.Version, error) { return *version, err } -// Gets and returns a Node's CPU, memory, swap, and Hardware (PCI) resources +// Gets all Nodes names +func (pve ProxmoxClient) Nodes() ([]string, error) { + nodes, err := pve.client.Nodes(context.Background()) + if err != nil { + return nil, err + } + + names := []string{} + for _, node := range nodes { + names = append(names, node.Node) + } + + return names, nil +} + +// Gets a Node's resources but does not recursively expand instances func (pve ProxmoxClient) Node(nodeName string) (Host, error) { host := Host{} - host.Hardware = make(map[string]Device) + host.Hardware = make(map[string]*HostSuperDevice) + host.Instance = make(map[uint]*Instance) node, err := pve.client.Node(context.Background(), nodeName) if err != nil { return host, err } - devices := []Device{} + devices := []PVEDevice{} err = pve.client.Get(context.Background(), fmt.Sprintf("/nodes/%s/hardware/pci", nodeName), &devices) if err != nil { return host, err } - vms, err := node.VirtualMachines(context.Background()) - if err != nil { - return host, err - } - - cts, err := node.Containers(context.Background()) - if err != nil { - return host, err - } - - // temporary helper which maps supersystem devices to each contained subsystem + // map supersystem devices to each contained subsystem // eg 0000:00:05 -> [0000:00:05.0, 0000:00:05.1, 0000:00:05.2, 0000:00:05.3, ...] - DeviceSubsystemMap := make(map[string][]string) for _, device := range devices { - host.Hardware[device.BusID] = device - SupersystemID := strings.Split(device.BusID, ".")[0] - DeviceSubsystemMap[SupersystemID] = append(DeviceSubsystemMap[SupersystemID], device.BusID) - } - - host.Name = node.Name - host.Cores.Total = int64(node.CPUInfo.CPUs) - host.Memory.Total = int64(node.Memory.Total) - host.Swap.Total = int64(node.Swap.Total) - - for _, vm := range vms { - vm, err := node.VirtualMachine(context.Background(), int(vm.VMID)) + SupersystemID, SubsystemID, err := SplitDeviceBusID(device.BusID) if err != nil { return host, err } - host.Cores.Reserved += int64(vm.VirtualMachineConfig.Cores) - host.Memory.Reserved += int64(vm.VirtualMachineConfig.Memory * MiB) + if host.Hardware[SupersystemID] == nil { + host.Hardware[SupersystemID] = &HostSuperDevice{ + BusID: SupersystemID, + DeviceName: device.DeviceName, + VendorName: device.VendorName, + Devices: make(map[string]*HostDevice), + } + } - MarshallVirtualMachineConfig(vm.VirtualMachineConfig) - - for _, v := range vm.VirtualMachineConfig.HostPCIs { - HostPCIBusID := strings.Split(v, ",")[0] - if device, ok := host.Hardware[HostPCIBusID]; ok { // is a specific subsystem of device - device.Reserved = true - host.Hardware[HostPCIBusID] = device - } else if SubsystemBusIDs, ok := DeviceSubsystemMap[HostPCIBusID]; ok { // is a supersystem device containing multiple subsystems - for _, SubsystemBusID := range SubsystemBusIDs { - device := host.Hardware[SubsystemBusID] - device.Reserved = true - host.Hardware[SubsystemBusID] = device - } + if !DeviceBusIDIsSuperDevice(device.BusID) { + host.Hardware[SupersystemID].Devices[SubsystemID] = &HostDevice{ + SubID: SubsystemID, + SubDeviceName: device.SubsystemDeviceName, + SubVendorName: device.SubsystemVendorName, } } } - for _, ct := range cts { - ct, err := node.Container(context.Background(), int(ct.VMID)) - if err != nil { - return host, err - } - - host.Cores.Reserved += int64(ct.ContainerConfig.Cores) - host.Memory.Reserved += int64(ct.ContainerConfig.Memory * MiB) - host.Swap.Reserved += int64(ct.ContainerConfig.Swap * MiB) - } - - host.Cores.Free = host.Cores.Total - host.Cores.Reserved - host.Memory.Free = host.Memory.Total - host.Memory.Reserved - host.Swap.Free = host.Swap.Total - host.Swap.Reserved + host.Name = node.Name + host.Cores.Total = uint64(node.CPUInfo.CPUs) + host.Memory.Total = uint64(node.Memory.Total) + host.Swap.Total = uint64(node.Swap.Total) + host.node = node return host, err } + +// Get all VM IDs on specified host +func (host Host) VirtualMachines() ([]uint, error) { + vms, err := host.node.VirtualMachines(context.Background()) + if err != nil { + return nil, err + } + ids := []uint{} + for _, vm := range vms { + ids = append(ids, uint(vm.VMID)) + } + return ids, nil +} + +// Get a VM's CPU, Memory but does not recursively link Devices, Disks, Drives, Nets +func (host Host) VirtualMachine(VMID uint) (Instance, error) { + instance := Instance{} + vm, err := host.node.VirtualMachine(context.Background(), int(VMID)) + if err != nil { + return instance, err + } + + config := vm.VirtualMachineConfig + config.HostPCIs = config.MergeHostPCIs() + instance.configNets = config.MergeNets() + instance.configDisks = MergeVMDisksAndUnused(config) + + instance.config = config + instance.Type = VM + + instance.Name = vm.Name + instance.Proctype = vm.VirtualMachineConfig.CPU + instance.Cores = uint64(vm.VirtualMachineConfig.Cores) + instance.Memory = uint64(vm.VirtualMachineConfig.Memory) * MiB + instance.Volume = make(map[string]*Volume) + instance.Net = make(map[uint]*Net) + instance.Device = make(map[uint]*InstanceDevice) + + return instance, nil +} + +func MergeVMDisksAndUnused(vmc *proxmox.VirtualMachineConfig) map[string]string { + mergedDisks := vmc.MergeDisks() + for k, v := range vmc.MergeUnuseds() { + mergedDisks[k] = v + } + return mergedDisks +} + +// Get all CT IDs on specified host +func (host Host) Containers() ([]uint, error) { + cts, err := host.node.Containers(context.Background()) + if err != nil { + return nil, err + } + ids := []uint{} + for _, ct := range cts { + ids = append(ids, uint(ct.VMID)) + } + return ids, nil +} + +// Get a CT's CPU, Memory, Swap but does not recursively link Devices, Disks, Drives, Nets +func (host Host) Container(VMID uint) (Instance, error) { + instance := Instance{} + ct, err := host.node.Container(context.Background(), int(VMID)) + if err != nil { + return instance, err + } + + config := ct.ContainerConfig + instance.configNets = config.MergeNets() + instance.configDisks = MergeCTDisksAndUnused(config) + + instance.config = config + instance.Type = CT + + instance.Name = ct.Name + instance.Cores = uint64(ct.ContainerConfig.Cores) + instance.Memory = uint64(ct.ContainerConfig.Memory) * MiB + instance.Swap = uint64(ct.ContainerConfig.Swap) * MiB + instance.Volume = make(map[string]*Volume) + instance.Net = make(map[uint]*Net) + + return instance, nil +} + +func MergeCTDisksAndUnused(cc *proxmox.ContainerConfig) map[string]string { + mergedDisks := make(map[string]string) + for k, v := range cc.MergeUnuseds() { + mergedDisks[k] = v + } + for k, v := range cc.MergeMps() { + mergedDisks[k] = v + } + mergedDisks["rootfs"] = cc.RootFS + return mergedDisks +} + +// get volume fornmat, size, volumeid, and storageid from instance volume data string (eg: local:100/vm-100-disk-0.raw ... ) +func GetVolumeInfo(host Host, volume string) (Volume, string, string, error) { + volumeData := Volume{} + + storageID := strings.Split(volume, ":")[0] + volumeID := strings.Split(volume, ",")[0] + storage, err := host.node.Storage(context.Background(), storageID) + if err != nil { + return volumeData, volumeID, storageID, nil + } + + content, err := storage.GetContent(context.Background()) + if err != nil { + return volumeData, volumeID, storageID, nil + } + + for _, c := range content { + if c.Volid == volumeID { + volumeData.Format = c.Format + volumeData.Size = uint64(c.Size) + volumeData.Volid = volumeID + } + } + + return volumeData, volumeID, storageID, nil +} + +func GetNetInfo(net string) (Net, error) { + n := Net{} + + for _, val := range strings.Split(net, ",") { + if strings.HasPrefix(val, "rate=") { + rate, err := strconv.ParseUint(strings.TrimPrefix(val, "rate="), 10, 64) + if err != nil { + return n, err + } + n.Rate = rate + } else if strings.HasPrefix(val, "tag=") { + vlan, err := strconv.ParseUint(strings.TrimPrefix(val, "tag="), 10, 64) + if err != nil { + return n, err + } + n.VLAN = vlan + } + } + + return n, nil +} diff --git a/app/types.go b/app/types.go index b643992..1a8aa98 100644 --- a/app/types.go +++ b/app/types.go @@ -1,9 +1,11 @@ package app +import "github.com/luthermonson/go-proxmox" + type Resource struct { // number of virtual cores (usually threads) - Reserved int64 - Free int64 - Total int64 + Reserved uint64 + Free uint64 + Total uint64 } type Host struct { @@ -11,47 +13,88 @@ type Host struct { Cores Resource Memory Resource Swap Resource - Storage map[string]Storage - Hardware map[string]Device + Hardware map[string]*HostSuperDevice + //QEMU map[uint]*QEMUInstance + //LXC map[uint]*LXCInstance + Instance map[uint]*Instance + node *proxmox.Node } -type Storage struct{} - +/* type QEMUInstance struct { Name string Proctype string - Cores Resource - Memory Resource - Drive map[int]Volume - Disk map[int]Volume - Net map[int]Net - Device map[int]Device + Cores uint64 + Memory uint64 + Drive map[uint]*Volume + Disk map[uint]*Volume + Net map[uint]*Net + Device map[uint]*InstanceDevice + vm *proxmox.VirtualMachine } type LXCInstance struct { Name string - Cores Resource - Memory Resource - Swap Resource - RootDisk Volume - MP map[int]Volume - Net map[int]Net + Cores uint64 + Memory uint64 + Swap uint64 + RootDisk *Volume + MP map[uint]*Volume + Net map[uint]*Net + ct *proxmox.Container +} +*/ + +type InstanceType bool + +const ( + VM InstanceType = true + CT InstanceType = false +) + +type Instance struct { + Type InstanceType + Name string + Proctype string + Cores uint64 + Memory uint64 + Swap uint64 + Volume map[string]*Volume + Net map[uint]*Net + Device map[uint]*InstanceDevice + config interface{} + configDisks map[string]string + configNets map[string]string + proxmox.ContainerInterface } type Volume struct { - Format string Path string - Size string - Used string + Format string + Size uint64 + Volid string } -type Net struct{} - -type Device struct { - BusID string `json:"id"` - DeviceName string `json:"device_name"` - VendorName string `json:"vendor_name"` - SubsystemDeviceName string `json:"subsystem_device_name"` - SubsystemVendorName string `json:"subsystem_vendor_name"` - Reserved bool +type Net struct { + Rate uint64 + VLAN uint64 +} + +type InstanceDevice struct { + Device []*HostDevice + PCIE bool +} + +type HostSuperDevice struct { + BusID string + DeviceName string + VendorName string + Devices map[string]*HostDevice +} + +type HostDevice struct { + SubID string + SubDeviceName string + SubVendorName string + Reserved bool } diff --git a/app/utils.go b/app/utils.go index 3585045..980e56b 100644 --- a/app/utils.go +++ b/app/utils.go @@ -2,10 +2,10 @@ package app import ( "encoding/json" + "fmt" "log" "os" - - "github.com/luthermonson/go-proxmox" + "strings" ) const MiB = 1024 * 1024 @@ -35,16 +35,38 @@ func GetConfig(configPath string) Config { return config } -func MarshallVirtualMachineConfig(v *proxmox.VirtualMachineConfig) { - v.HostPCIs = make(map[string]string) - v.HostPCIs["hostpci0"] = v.HostPCI0 - v.HostPCIs["hostpci1"] = v.HostPCI1 - v.HostPCIs["hostpci2"] = v.HostPCI2 - v.HostPCIs["hostpci3"] = v.HostPCI3 - v.HostPCIs["hostpci4"] = v.HostPCI4 - v.HostPCIs["hostpci5"] = v.HostPCI5 - v.HostPCIs["hostpci6"] = v.HostPCI6 - v.HostPCIs["hostpci7"] = v.HostPCI7 - v.HostPCIs["hostpci8"] = v.HostPCI8 - v.HostPCIs["hostpci9"] = v.HostPCI9 +// finds the first substring r in s such that s = ... a r b ... +func FindSubstringBetween(s string, a string, b string) (string, error) { + x := strings.Split(s, a) + if len(x) <= 2 { + return "", fmt.Errorf("%s not found in %s", a, s) + } + + y := strings.Split(x[1], b) + if len(y) <= 2 { + return "", fmt.Errorf("%s not found in %s", b, s) + } + + return y[0], nil +} + +// returns if a device pcie bus id is a super device or subsystem device +// +// subsystem devices always has the format xxxx:yy.z, whereas super devices have the format xxxx:yy +func DeviceBusIDIsSuperDevice(BusID string) bool { + return !strings.ContainsRune(BusID, '.') +} + +// splits a device pcie bus id into super device and subsystem device IDs if possible +func SplitDeviceBusID(BusID string) (string, string, error) { + if DeviceBusIDIsSuperDevice(BusID) { + return BusID, "", nil + } else { + x := strings.Split(BusID, ".") + if len(x) != 2 { + return "", "", fmt.Errorf("BusID: %s contained more than one '.'", BusID) + } else { + return x[0], x[1], nil + } + } } diff --git a/go.mod b/go.mod index e4e4924..73a99dd 100644 --- a/go.mod +++ b/go.mod @@ -4,51 +4,15 @@ go 1.23 toolchain go1.23.2 -require ( - github.com/gin-gonic/gin v1.10.0 - github.com/luthermonson/go-proxmox v0.2.0 -) +require github.com/luthermonson/go-proxmox v0.2.0 require ( github.com/buger/goterm v1.0.4 // indirect - github.com/bytedance/sonic v1.12.3 // indirect - github.com/bytedance/sonic/loader v0.2.0 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect github.com/diskfs/go-diskfs v1.4.2 // indirect github.com/djherbis/times v1.6.0 // indirect - github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab // indirect - github.com/gabriel-vasile/mimetype v1.4.6 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.22.1 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jinzhu/copier v0.4.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect - github.com/knz/go-libedit v1.10.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect github.com/magefile/mage v1.15.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pierrec/lz4/v4 v4.1.17 // indirect - github.com/pkg/xattr v0.4.9 // indirect - github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - github.com/ulikunitz/xz v0.5.11 // indirect - golang.org/x/arch v0.11.0 // indirect - golang.org/x/crypto v0.28.0 // indirect - golang.org/x/net v0.30.0 // indirect + github.com/stretchr/testify v1.9.0 // indirect golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - google.golang.org/protobuf v1.35.1 // indirect - gopkg.in/djherbis/times.v1 v1.3.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect )