package cmd import ( "github.com/Kavinraja-G/node-gizmo/pkg/cmd" "github.com/Kavinraja-G/node-gizmo/utils" ) func init() { // inits the clientset and other generic configs if any utils.InitConfig() } // Execute drives the root 'nodegizmo' command func Execute() error { root := cmd.NewCmdRoot() if err := root.Execute(); err != nil { return err } return nil }
package main import ( "os" "github.com/Kavinraja-G/node-gizmo/cmd" ) func main() { if err := cmd.Execute(); err != nil { os.Exit(1) } }
package cmd import ( "log" "os" "github.com/spf13/cobra" "github.com/spf13/cobra/doc" ) // NewCmdDocs initializes the 'docs' command func NewCmdDocs(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ Use: "docs", Short: "Generates Markdown docs for nodegizmo in the current working directory", Run: func(cmd *cobra.Command, args []string) { cwd, err := os.Getwd() if err != nil { log.Fatal(err) } err = doc.GenMarkdownTree(rootCmd, cwd) if err != nil { log.Fatal(err) } }, } return cmd }
package cmd import ( "context" "errors" "fmt" "log" "os" "time" "github.com/Kavinraja-G/node-gizmo/utils" "github.com/docker/cli/cli/streams" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" k8errors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/remotecommand" ) var ( nodeshellPodNamespace string nodeshellPodImage string nodeshellPodTTL string nodeshellPodNamePrefix = "nodeshell-" podSCPrivileged = true podTerminationGracePeriodSeconds = int64(0) ) // NewCmdNodeExec initialises the 'exec' command func NewCmdNodeExec() *cobra.Command { cmd := &cobra.Command{ Use: "exec <node-name>", Short: "Spawns a nsenter pod to exec into the provided node", Aliases: []string{"ex"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return errors.New("please provide a nodeName to exec") } nodeName := args[0] if !isValidNode(nodeName) { return errors.New(fmt.Sprintf("%v is not a valid node", args[0])) } err := createExecPodInTargetedNode(nodeName) if err != nil { return err } return execIntoNode(cmd, args[0]) }, PostRunE: func(cmd *cobra.Command, args []string) error { return cleanUpNodeshellPods(cmd, args[0]) }, } // additional local flags cmd.Flags().StringVarP(&nodeshellPodNamespace, "namespace", "n", "kube-system", "Namespace where nsenter pod to be created") cmd.Flags().StringVarP(&nodeshellPodImage, "image", "i", "docker.io/alpine:3.18", "Image used by nsenter pod") cmd.Flags().StringVarP(&nodeshellPodTTL, "ttl", "t", "3600", "Time to live (seconds) for the exec container. Defaults to 3600s") return cmd } // isValidNode validates the given node is available in the cluster or not func isValidNode(nodeName string) bool { nodes, err := utils.Cfg.Clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) if err != nil { log.Fatalf("Error while listing the nodes in the cluster: %v", err) } for _, node := range nodes.Items { if node.Name == nodeName { return true } } return false } // createExecPodInTargetedNode creates the nsenter pod in the given node func createExecPodInTargetedNode(nodeName string) error { var nodeshellPodName = fmt.Sprintf("nodeshell-%v", nodeName) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: nodeshellPodName, Namespace: nodeshellPodNamespace, Labels: map[string]string{ "app.kubernetes.io/name": nodeshellPodName, "app.kubernetes.io/component": "exec", "app.kubernetes.io/managed-by": "node-gizmo", }, }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { Name: "nodeshell", Image: nodeshellPodImage, Command: []string{"nsenter"}, Args: []string{"-t", "1", "-m", "-u", "-i", "-n", "sleep", nodeshellPodTTL}, SecurityContext: &corev1.SecurityContext{ Privileged: &podSCPrivileged, }, }, }, RestartPolicy: corev1.RestartPolicyNever, TerminationGracePeriodSeconds: &podTerminationGracePeriodSeconds, HostNetwork: true, HostPID: true, HostIPC: true, Tolerations: []corev1.Toleration{ { Operator: corev1.TolerationOpExists, // this will attract any taints added to nodes }, }, NodeSelector: map[string]string{ "kubernetes.io/hostname": nodeName, }, NodeName: nodeName, }, } _, err := utils.Cfg.Clientset.CoreV1().Pods(nodeshellPodNamespace).Create(context.TODO(), pod, metav1.CreateOptions{}) if err != nil { // validates if the pod already exists if k8errors.IsAlreadyExists(err) { return nil } return err } // wait for exec pod to get RUNNING checkExecPodRunningStatus := func() (bool, error) { pod, err := utils.Cfg.Clientset.CoreV1().Pods(nodeshellPodNamespace).Get(context.TODO(), nodeshellPodName, metav1.GetOptions{}) if err != nil { return false, err } if pod.Status.Phase == corev1.PodRunning { return true, nil } return false, nil } backoff := wait.Backoff{ Steps: 5, // Number of retry steps. Duration: 10 * time.Second, // Initial backoff duration. Factor: 1.0, // Multiplier for each step's duration. Jitter: 0.1, // Jitter to randomize the duration slightly. } // waits exponentially for exec pod to be RUNNING startTime := time.Now() err = wait.ExponentialBackoff(backoff, func() (bool, error) { elapsedWaitTime := time.Since(startTime) log.Printf("Waiting for exec pod %s to be RUNNING. Elapsed time: %v", nodeshellPodName, elapsedWaitTime) return checkExecPodRunningStatus() }) if err != nil { log.Fatalf("exec pod did not reached the RUNNING state: %v", err) } return err } // execIntoNode is the driver function used to exec into the nsenter pod deployed in the targeted node func execIntoNode(cmd *cobra.Command, nodeName string) error { var nodeshellPodName = nodeshellPodNamePrefix + nodeName var podExecCmd = []string{"sh", "-c", "(bash || ash || sh)"} req := utils.Cfg.Clientset.CoreV1().RESTClient().Post().Resource("pods").Name(nodeshellPodName).Namespace(nodeshellPodNamespace).SubResource("exec") opts := &corev1.PodExecOptions{ Command: podExecCmd, Stdin: true, Stdout: true, Stderr: true, TTY: true, } req.VersionedParams(opts, scheme.ParameterCodec) k8sConfig, err := utils.GetKubeConfig() if err != nil { log.Fatalf("Error while getting Kubeconfig: %v", err) return err } // initiate the exec command on the nsenter pod and creates a bidirectional connection to the server exec, err := remotecommand.NewSPDYExecutor(k8sConfig, "POST", req.URL()) if err != nil { log.Fatalf("Error while running exec on nodeshell pod: %v", err) return err } // inspired from https://github.com/kubernetes/client-go/issues/912 input := streams.NewIn(os.Stdin) if err = input.SetRawTerminal(); err != nil { log.Fatalf("Error setting rawTerminal: %v", err) } err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{ Stdin: input, Stdout: os.Stdout, Stderr: os.Stderr, Tty: true, }) return err } // cleanUpNodeshellPods cleans up the nsenter pod once the shell is exited func cleanUpNodeshellPods(cmd *cobra.Command, nodeName string) error { var nodeshellPodName = nodeshellPodNamePrefix + nodeName err := utils.Cfg.Clientset.CoreV1().Pods(nodeshellPodNamespace).Delete(context.TODO(), nodeshellPodName, metav1.DeleteOptions{}) if err != nil { log.Fatalf("Error while creating the nodeshell pod: %v", err) return err } return err }
package nodepool import ( "context" "github.com/Kavinraja-G/node-gizmo/pkg/outputs" "github.com/Kavinraja-G/node-gizmo/utils" "github.com/Kavinraja-G/node-gizmo/pkg" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var sortByHeader string func NewCmdNodepoolInfo() *cobra.Command { cmd := &cobra.Command{ Use: "nodepool", Short: "Displays detailed information about Nodepool", Aliases: []string{"np", "ng"}, RunE: func(cmd *cobra.Command, args []string) error { return showNodePoolInfo(cmd, args) }, } // additional local flags cmd.Flags().StringVarP(&sortByHeader, "sort-by", "", "nodepool", "Sorts output using a valid Column name. Defaults to 'nodepool' if the column name is not valid") return cmd } // showNodePoolInfo driver function for the 'nodepool' command func showNodePoolInfo(cmd *cobra.Command, args []string) error { var genericNodepoolInfos []pkg.GenericNodepoolInfo nodes, err := utils.Cfg.Clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) if err != nil { return err } for _, node := range nodes.Items { cloudProvider, nodepoolID := getNodepoolIDAndProvider(node.Labels) region, zone := pkg.GetNodeTopologyInfo(node.Labels) genericNodepoolInfos = append(genericNodepoolInfos, pkg.GenericNodepoolInfo{ NodepoolID: nodepoolID, Node: node.Name, Provider: cloudProvider, InstanceType: getNodeInstanceType(node.Labels), Region: region, Zone: zone, K8sVersion: node.Status.NodeInfo.KubeletVersion, }) } outputHeaders, outputData := generateNodepoolInfoData(genericNodepoolInfos) outputs.SortOutputBasedOnHeader(outputHeaders, outputData, sortByHeader) outputs.TableOutput(outputHeaders, outputData) return nil } // getNodepoolIDAndProvider returns the cloud provider type for the nodepool (EKS/Karpenter, GKE, AKS, can be Unknown) func getNodepoolIDAndProvider(labels map[string]string) (string, string) { if id, ok := labels[pkg.AwsNodepoolLabel]; ok { return "EKS", id } if id, ok := labels[pkg.GkeNodepoolLabel]; ok { return "GKE", id } if id, ok := labels[pkg.AksNodepoolLabel]; ok { return "AKS", id } if id, ok := labels[pkg.KarpenterNodepool]; ok { return "Karpenter", id } return "Unknown", "Unknown" } // getNodeInstanceType returns the node instanceType based on the instance-type label func getNodeInstanceType(labels map[string]string) string { if val, ok := labels[pkg.NodeInstanceTypeLabel]; ok { return val } return "Unknown" } // generateNodepoolInfoData generates the Nodepool info outputs and the required headers for table-writer func generateNodepoolInfoData(genericNodepoolInfos []pkg.GenericNodepoolInfo) ([]string, [][]string) { var headers = []string{"NODEPOOL", "PROVIDER", "REGION", "ZONE", "INSTANCE-TYPE", "VERSION", "NODES"} var outputData [][]string for _, nodepoolInfo := range genericNodepoolInfos { lineItems := []string{ nodepoolInfo.NodepoolID, nodepoolInfo.Provider, nodepoolInfo.Region, nodepoolInfo.Zone, nodepoolInfo.InstanceType, nodepoolInfo.K8sVersion, nodepoolInfo.Node, } outputData = append(outputData, lineItems) } return headers, outputData }
package nodes import ( "context" "github.com/Kavinraja-G/node-gizmo/pkg" "github.com/Kavinraja-G/node-gizmo/pkg/outputs" "github.com/Kavinraja-G/node-gizmo/utils" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // NewCmdNodeCapacityInfo initializes the 'capacity' command func NewCmdNodeCapacityInfo() *cobra.Command { cmd := &cobra.Command{ Use: "capacity", Short: "Displays Node capacity related information", Aliases: []string{"capacities", "cap"}, RunE: func(cmd *cobra.Command, args []string) error { return showNodeCapacities(cmd, args) }, } return cmd } // showNodeCapacities driver function for 'node capacity' command func showNodeCapacities(cmd *cobra.Command, args []string) error { var nodeCapacityInfo []pkg.NodeCapacities labels, _ = cmd.Flags().GetString("labels") nodes, err := utils.Cfg.Clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{LabelSelector: labels}) if err != nil { return err } for _, node := range nodes.Items { nodeCapacity := pkg.NodeCapacities{ NodeName: node.Name, CPU: node.Status.Capacity.Cpu().String(), Memory: node.Status.Capacity.Memory().Value(), Disk: node.Status.Capacity.Storage().Value(), EphemeralStorage: node.Status.Capacity.StorageEphemeral().Value(), PodCapacity: node.Status.Capacity.Pods().String(), } nodeCapacityInfo = append(nodeCapacityInfo, nodeCapacity) } outputHeaders, outputData := generateNodeCapacityOutputData(nodeCapacityInfo) outputs.SortOutputBasedOnHeader(outputHeaders, outputData, sortByHeader) outputs.TableOutput(outputHeaders, outputData) return nil } // generateNodeCapacityOutputData generates the NodeCapacity outputs and the required headers for table-writer func generateNodeCapacityOutputData(nodeCapacityInfo []pkg.NodeCapacities) ([]string, [][]string) { var headers = []string{"NAME", "CPU", "MEMORY", "DISK", "EPHEMERAL", "POD CAPACITY"} var outputData [][]string for _, nodeCapacity := range nodeCapacityInfo { lineItems := []string{ nodeCapacity.NodeName, nodeCapacity.CPU, utils.PrettyByteSize(nodeCapacity.Memory), utils.PrettyByteSize(nodeCapacity.Disk), utils.PrettyByteSize(nodeCapacity.EphemeralStorage), nodeCapacity.PodCapacity, } outputData = append(outputData, lineItems) } return headers, outputData }
package nodes import ( "context" "fmt" "strings" "github.com/Kavinraja-G/node-gizmo/utils" "github.com/Kavinraja-G/node-gizmo/pkg/outputs" "github.com/Kavinraja-G/node-gizmo/pkg" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var ( showTaints bool showNodeProviderInfo bool showNodeTopologyInfo bool sortByHeader string labels string ) // NewCmdNodeInfo initializes the 'node' command func NewCmdNodeInfo() *cobra.Command { cmd := &cobra.Command{ Use: "node", Short: "Displays generic node related information in the cluster", Aliases: []string{"nodes"}, RunE: func(cmd *cobra.Command, args []string) error { return showNodeInfo(cmd, args) }, TraverseChildren: true, } // additional local flags cmd.Flags().BoolVarP(&showTaints, "show-taints", "t", false, "Shows taints added on a node") cmd.Flags().BoolVarP(&showNodeProviderInfo, "show-providers", "p", false, "Shows cloud provider name for a node") cmd.Flags().BoolVarP(&showNodeTopologyInfo, "show-topology", "T", false, "Shows node topology info like region & zones for a node") cmd.PersistentFlags().StringVarP(&sortByHeader, "sort-by", "", "name", "Sorts output using a valid Column name. Defaults to 'name' if the column name is not valid") cmd.PersistentFlags().StringVarP(&labels, "labels", "l", "", "Filter based on node labels") // additional sub-commands cmd.AddCommand(NewCmdNodeCapacityInfo()) return cmd } // showNodeInfo driver function for generic node info command func showNodeInfo(cmd *cobra.Command, args []string) error { var nodeInfos []pkg.GenericNodeInfo var outputOpts = pkg.OutputOptsForGenericNodeInfo{ ShowTaints: showTaints, ShowNodeProviderInfo: showNodeProviderInfo, ShowNodeTopologyInfo: showNodeTopologyInfo, } nodes, err := utils.Cfg.Clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{LabelSelector: labels}) if err != nil { return err } for _, node := range nodes.Items { genericNodeInfo := pkg.GenericNodeInfo{ NodeName: node.Name, K8sVersion: node.Status.NodeInfo.KubeletVersion, Image: node.Status.NodeInfo.OSImage, Os: node.Status.NodeInfo.OperatingSystem, OsArch: node.Status.NodeInfo.Architecture, NodeStatus: getNodeStatus(node.Status.Conditions), } if ok, _ := cmd.Flags().GetBool("show-taints"); ok { genericNodeInfo.Taints = getNodeTaints(node.Spec.Taints) } if ok, _ := cmd.Flags().GetBool("show-providers"); ok { genericNodeInfo.NodeProvider = getNodeProviderName(node.Spec.ProviderID) } if ok, _ := cmd.Flags().GetBool("show-topology"); ok { genericNodeInfo.NodeTopologyRegion, genericNodeInfo.NodeTopologyZone = pkg.GetNodeTopologyInfo(node.Labels) } nodeInfos = append(nodeInfos, genericNodeInfo) } outputHeaders, outputData := generateNodeInfoOutputData(nodeInfos, outputOpts) outputs.SortOutputBasedOnHeader(outputHeaders, outputData, sortByHeader) outputs.TableOutput(outputHeaders, outputData) return nil } // getNodeProviderName returns the providerName stripped from the providerID in the spec // providerID = aws://someRandomNodeID => "aws" // providerID = nil || "" => "others" func getNodeProviderName(providerID string) string { // providerID format <ProviderName>://<ProviderSpecificNodeID> if providerID != "" { return strings.Split(providerID, ":")[0] } return "others" } // getNodeTaints returns the taints that are added to the node func getNodeTaints(rawTaints []corev1.Taint) []string { var taints []string for _, taint := range rawTaints { taints = append(taints, fmt.Sprintf("%v=%v:%v", taint.Key, taint.Value, taint.Effect)) } return taints } // getNodeStatus returns if the provided node is 'Ready' or 'NotReady' func getNodeStatus(nodeConditions []corev1.NodeCondition) string { for _, nodeCondition := range nodeConditions { if nodeCondition.Type == corev1.NodeReady { return "Ready" } } return "NotReady" } // generateNodeInfoOutputData generates the NodeInfo outputs and the required headers for table-writer func generateNodeInfoOutputData(genericNodeInfos []pkg.GenericNodeInfo, outputOpts pkg.OutputOptsForGenericNodeInfo) ([]string, [][]string) { var headers = []string{"NAME", "VERSION", "IMAGE", "OS", "ARCHITECTURE", "STATUS"} var outputData [][]string if outputOpts.ShowTaints { headers = append(headers, "TAINTS") } if outputOpts.ShowNodeProviderInfo { headers = append(headers, "PROVIDER") } if outputOpts.ShowNodeTopologyInfo { headers = append(headers, "REGION", "ZONE") } for _, nodeInfo := range genericNodeInfos { lineItems := []string{ nodeInfo.NodeName, nodeInfo.K8sVersion, nodeInfo.Image, nodeInfo.Os, nodeInfo.OsArch, nodeInfo.NodeStatus, } if outputOpts.ShowTaints { lineItems = append(lineItems, strings.Join(nodeInfo.Taints, "\n")) } if outputOpts.ShowNodeProviderInfo { lineItems = append(lineItems, nodeInfo.NodeProvider) } if outputOpts.ShowNodeTopologyInfo { lineItems = append(lineItems, nodeInfo.NodeTopologyRegion, nodeInfo.NodeTopologyZone) } outputData = append(outputData, lineItems) } return headers, outputData }
package cmd import ( "github.com/Kavinraja-G/node-gizmo/pkg/cmd/nodepool" "github.com/Kavinraja-G/node-gizmo/pkg/cmd/nodes" "github.com/spf13/cobra" ) // NewCmdRoot initializes the root command 'nodegizmo' func NewCmdRoot() *cobra.Command { cmd := &cobra.Command{ Use: "nodegizmo", Aliases: []string{"ng"}, Short: "Nodegizmo - A CLI utility for your Kubernetes nodes", RunE: func(c *cobra.Command, args []string) error { if err := c.Help(); err != nil { return err } return nil }, } // child commands cmd.AddCommand(NewCmdDocs(cmd)) cmd.AddCommand(NewCmdNodeExec()) cmd.AddCommand(nodes.NewCmdNodeInfo()) cmd.AddCommand(nodepool.NewCmdNodepoolInfo()) return cmd }
package pkg // GetNodeTopologyInfo retrieves region and zone info from topology labels func GetNodeTopologyInfo(labels map[string]string) (string, string) { var region string var zone string if val, ok := labels[TopologyRegionLabel]; ok { region = val } if val, ok := labels[TopologyZoneLabel]; ok { zone = val } return region, zone }
package outputs import ( "k8s.io/utils/strings/slices" "sort" "strings" ) // getSortKeyIdxFromHeader retrieves the index of the sortKey in the header slice func getSortKeyIdxFromHeader(headers []string, sortKey string) int { // defaults to first column always (usually node/nodepool name) idx := 0 if slices.Contains(headers, strings.ToUpper(sortKey)) { idx = slices.Index(headers, strings.ToUpper(sortKey)) } return idx } // SortOutputBasedOnHeader sorts output based on the Header key provided in the flags func SortOutputBasedOnHeader(headers []string, sortSlices [][]string, sortKey string) { sortByHeaderIndex := getSortKeyIdxFromHeader(headers, sortKey) sort.SliceStable(sortSlices, func(i, j int) bool { return sortSlices[i][sortByHeaderIndex] < sortSlices[j][sortByHeaderIndex] }) }
package outputs import ( "os" "github.com/olekukonko/tablewriter" ) // TableOutput renders a table in terminal func TableOutput(headers []string, outputData [][]string) { table := tablewriter.NewWriter(os.Stdout) // enables autoMerge only for nodepool infos if headers[0] == "NODEPOOL" { table.SetAutoMergeCells(true) } // default configs for the table-writer table.SetRowLine(false) table.SetBorder(false) table.SetAutoWrapText(false) table.SetAutoFormatHeaders(true) table.SetHeaderLine(false) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetAutoWrapText(false) table.SetCenterSeparator("") table.SetColumnSeparator("") table.SetRowSeparator("") table.SetTablePadding("\t") // set headers and add the outputData table.SetHeader(headers) table.AppendBulk(outputData) table.Render() }
package utils import ( "log" "path/filepath" k8s "k8s.io/client-go/kubernetes" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/homedir" ) type Config struct { Clientset *k8s.Clientset } var Cfg Config // GetKubeConfig is used to fetch the kubeConfig based on the KUBECONFIG env or '~/.kube/config' location func GetKubeConfig() (*rest.Config, error) { var kubeConfigPath string var defaultKubeConfigPath = "~/.kube/config" if home := homedir.HomeDir(); home != "" { defaultKubeConfigPath = filepath.Join(home, ".kube", "config") } kubeConfigPath = GetEnv("KUBECONFIG", defaultKubeConfigPath) k8sConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigPath) return k8sConfig, err } // k8sAuth is used to get the Kubernetes clientset from the config func k8sAuth() (*k8s.Clientset, error) { k8sConfig, err := GetKubeConfig() if err != nil { log.Fatalf("Error while getting kubeconfig: %v", err) } clientset, err := k8s.NewForConfig(k8sConfig) if err != nil { log.Fatalf("Error while creating the clientset: %v", err) } return clientset, err } // InitConfig initiates a kubernetes clientset & other generic configs with the current context func InitConfig() { var err error Cfg.Clientset, err = k8sAuth() if err != nil { log.Fatalf("Error while authenticating to kubernetes: %v", err) } }
package utils import ( "fmt" "math" "os" ) // GetEnv Helper function for fetching envs with defaults func GetEnv(env, defaults string) string { if val, ok := os.LookupEnv(env); ok && val != "" { return val } return defaults } // PrettyByteSize converts the bytes to human-readable format func PrettyByteSize(b int64) string { bf := float64(b) for _, unit := range []string{"", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"} { if math.Abs(bf) < 1024.0 { return fmt.Sprintf("%3.1f%sB", bf, unit) } bf /= 1024.0 } return fmt.Sprintf("%.1fYiB", bf) }