eindelijk weer eens een push
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"golang.org/x/crypto/ssh"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
metrics "k8s.io/metrics/pkg/client/clientset/versioned"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Node and NodePool structs
|
||||
type Node struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
IP string `yaml:"ip" json:"ip"`
|
||||
Status string `yaml:"status" json:"status"`
|
||||
CPU int `yaml:"cpu" json:"cpu"`
|
||||
Memory int `yaml:"memory" json:"memory"`
|
||||
Role string `yaml:"role" json:"role"`
|
||||
Cluster string `yaml:"cluster" json:"cluster"`
|
||||
LastActive string `yaml:"last_active" json:"last_active"`
|
||||
Pods int `yaml:"pods,omitempty" json:"pods,omitempty"`
|
||||
Temperature float64 `yaml:"temperature,omitempty" json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
type NodePool struct {
|
||||
Nodes []Node `yaml:"nodes" json:"nodes"`
|
||||
}
|
||||
|
||||
// --- ENV helpers ---
|
||||
func mustIntEnv(name string, def int) int {
|
||||
if val := os.Getenv(name); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// --- YAML load/save ---
|
||||
func loadPool(file string) (*NodePool, error) {
|
||||
data, err := ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pool NodePool
|
||||
err = yaml.Unmarshal(data, &pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pool, nil
|
||||
}
|
||||
|
||||
func savePool(file string, pool *NodePool) error {
|
||||
data, err := yaml.Marshal(pool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ioutil.WriteFile(file, data, 0644)
|
||||
}
|
||||
|
||||
// --- NodePool init ---
|
||||
func initNodePool(clientset *kubernetes.Clientset, poolFile string) (*NodePool, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
pool, err := loadPool(poolFile)
|
||||
if err != nil {
|
||||
log.Println("Failed to load existing node pool, starting fresh:", err)
|
||||
pool = &NodePool{}
|
||||
}
|
||||
|
||||
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list nodes: %w", err)
|
||||
}
|
||||
|
||||
poolMap := map[string]*Node{}
|
||||
for i := range pool.Nodes {
|
||||
poolMap[pool.Nodes[i].Name] = &pool.Nodes[i]
|
||||
}
|
||||
|
||||
updatedNodes := []Node{}
|
||||
for _, n := range nodes.Items {
|
||||
ip := nodeIP(&n)
|
||||
status := "online"
|
||||
role := "worker"
|
||||
if isControlPlane(&n) {
|
||||
role = "microk8s-controlplane"
|
||||
}
|
||||
|
||||
node := Node{
|
||||
Name: n.Name,
|
||||
IP: ip,
|
||||
Status: status,
|
||||
Role: role,
|
||||
Cluster: os.Getenv("CLUSTER_NAME"),
|
||||
CPU: 0,
|
||||
Memory: 0,
|
||||
LastActive: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if oldNode, ok := poolMap[n.Name]; ok {
|
||||
node.Status = oldNode.Status
|
||||
node.LastActive = oldNode.LastActive
|
||||
node.CPU = oldNode.CPU
|
||||
node.Memory = oldNode.Memory
|
||||
node.Cluster = oldNode.Cluster
|
||||
node.Role = oldNode.Role
|
||||
}
|
||||
|
||||
updatedNodes = append(updatedNodes, node)
|
||||
}
|
||||
|
||||
// Preserve nodes not in the current cluster
|
||||
for _, oldNode := range pool.Nodes {
|
||||
if _, found := poolMap[oldNode.Name]; !found {
|
||||
updatedNodes = append(updatedNodes, oldNode)
|
||||
}
|
||||
}
|
||||
|
||||
pool.Nodes = updatedNodes
|
||||
savePool(poolFile, pool)
|
||||
log.Printf("Initialized node pool with %d nodes", len(pool.Nodes))
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// --- SSH ---
|
||||
func runSSH(host, user, pass, cmd string) (string, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(pass)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", host), config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
out, err := session.CombinedOutput(cmd)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// --- Node helpers ---
|
||||
func isControlPlane(n *v1.Node) bool {
|
||||
if _, ok := n.Labels["node.kubernetes.io/microk8s-controlplane"]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := n.Labels["node-role.kubernetes.io/control-plane"]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nodeIP(n *v1.Node) string {
|
||||
for _, addr := range n.Status.Addresses {
|
||||
if addr.Type == v1.NodeInternalIP {
|
||||
return addr.Address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Utilization ---
|
||||
func updatePerNodeUtilization(cs *kubernetes.Clientset, ms *metrics.Clientset, pool *NodePool, sshUser, sshPass string) error {
|
||||
ctx := context.Background()
|
||||
nodes, err := cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metricsList, err := ms.MetricsV1beta1().NodeMetricses().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
capCPU := map[string]int64{}
|
||||
capMem := map[string]int64{}
|
||||
nodeMap := map[string]*v1.Node{}
|
||||
for i, n := range nodes.Items {
|
||||
capCPU[n.Name] = n.Status.Capacity.Cpu().MilliValue()
|
||||
capMem[n.Name] = n.Status.Capacity.Memory().Value()
|
||||
nodeMap[n.Name] = &nodes.Items[i]
|
||||
}
|
||||
|
||||
usageCPU := map[string]int64{}
|
||||
usageMem := map[string]int64{}
|
||||
for _, m := range metricsList.Items {
|
||||
usageCPU[m.Name] = m.Usage.Cpu().MilliValue()
|
||||
usageMem[m.Name] = m.Usage.Memory().Value()
|
||||
}
|
||||
|
||||
pods, err := cs.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
podCount := map[string]int{}
|
||||
for _, p := range pods.Items {
|
||||
if p.Spec.NodeName != "" {
|
||||
podCount[p.Spec.NodeName]++
|
||||
}
|
||||
}
|
||||
|
||||
for i := range pool.Nodes {
|
||||
n := &pool.Nodes[i]
|
||||
name := n.Name
|
||||
|
||||
cpuCap, ok1 := capCPU[name]
|
||||
memCap, ok2 := capMem[name]
|
||||
cpuUse, ok3 := usageCPU[name]
|
||||
memUse, ok4 := usageMem[name]
|
||||
|
||||
if ok1 && ok2 && ok3 && ok4 && cpuCap > 0 && memCap > 0 {
|
||||
n.CPU = int((cpuUse * 100) / cpuCap)
|
||||
n.Memory = int((memUse * 100) / memCap)
|
||||
}
|
||||
|
||||
n.Pods = podCount[name]
|
||||
|
||||
if kn, ok := nodeMap[name]; ok {
|
||||
n.Temperature = getNodeTemp(kn, sshUser, sshPass)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Temperature ---
|
||||
func getNodeTemp(node *v1.Node, sshUser, sshPass string) float64 {
|
||||
ip := nodeIP(node)
|
||||
out, err := runSSH(ip, sshUser, sshPass, "cat /proc/device-tree/model")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
hw := strings.ToUpper(strings.TrimSpace(out))
|
||||
|
||||
var cmd string
|
||||
switch {
|
||||
case strings.Contains(hw, "RASPBERRY"):
|
||||
cmd = "vcgencmd measure_temp | egrep -o '[0-9]+\\.[0-9]+'"
|
||||
case strings.Contains(hw, "ODROID"):
|
||||
cmd = "awk '{printf \"%3.1f\", $1/1000}' /sys/class/thermal/thermal_zone0/temp"
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
tempStr, err := runSSH(ip, sshUser, sshPass, cmd)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
t, err := strconv.ParseFloat(strings.TrimSpace(tempStr), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// --- Control-plane management ---
|
||||
func ensureControlPlanes(cs *kubernetes.Clientset, pool *NodePool, poolFile, sshUser, sshPass, clusterName string, desired int) {
|
||||
ctx := context.Background()
|
||||
nodes, err := cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Println("failed to list nodes:", err)
|
||||
return
|
||||
}
|
||||
|
||||
cpCount := 0
|
||||
var seedIP string
|
||||
for _, n := range nodes.Items {
|
||||
if isControlPlane(&n) {
|
||||
cpCount++
|
||||
if seedIP == "" {
|
||||
seedIP = nodeIP(&n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cpCount >= desired {
|
||||
return
|
||||
}
|
||||
|
||||
if seedIP == "" {
|
||||
log.Println("no available control-plane node found")
|
||||
return
|
||||
}
|
||||
|
||||
for i := range pool.Nodes {
|
||||
n := &pool.Nodes[i]
|
||||
if n.Status != "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Attempting to activate node %s as control-plane", n.Name)
|
||||
out, err := runSSH(seedIP, sshUser, sshPass, "microk8s add-node")
|
||||
if err != nil {
|
||||
log.Println("add-node failed:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var joinCmd string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "microk8s join") {
|
||||
joinCmd = strings.TrimSpace(line)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if joinCmd == "" {
|
||||
log.Println("no join command found")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = runSSH(n.IP, sshUser, sshPass, joinCmd)
|
||||
if err != nil {
|
||||
log.Println("join failed:", err)
|
||||
return
|
||||
}
|
||||
n.Status = "online"
|
||||
n.Cluster = clusterName
|
||||
n.Role = "microk8s-controlplane"
|
||||
n.LastActive = time.Now().Format(time.RFC3339)
|
||||
log.Printf("Control-plane %s activated", n.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- Worker management ---
|
||||
func activateOneWorker(cs *kubernetes.Clientset, pool *NodePool, poolFile, sshUser, sshPass, clusterName string) {
|
||||
ctx := context.Background()
|
||||
var workerNode *Node
|
||||
for i := range pool.Nodes {
|
||||
n := &pool.Nodes[i]
|
||||
if n.Status == "offline" {
|
||||
workerNode = &pool.Nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if workerNode == nil {
|
||||
log.Println("No offline nodes available — skipping activation")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Attempting to activate %s node as worker node", workerNode.Name)
|
||||
nodes, err := cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Println("Failed to list nodes:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var seedIP string
|
||||
for _, n := range nodes.Items {
|
||||
if isControlPlane(&n) {
|
||||
seedIP = nodeIP(&n)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if seedIP == "" {
|
||||
log.Println("No control-plane node available to generate join command")
|
||||
return
|
||||
}
|
||||
|
||||
out, err := runSSH(seedIP, sshUser, sshPass, "microk8s add-node")
|
||||
if err != nil {
|
||||
log.Println("add-node failed:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var joinCmd string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "microk8s join") {
|
||||
joinCmd = strings.TrimSpace(line)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if joinCmd == "" {
|
||||
log.Println("No join command found from seed control-plane")
|
||||
return
|
||||
}
|
||||
|
||||
joinCmd += " --worker"
|
||||
_, err = runSSH(workerNode.IP, sshUser, sshPass, joinCmd)
|
||||
if err != nil {
|
||||
log.Println("Worker join failed:", err)
|
||||
return
|
||||
}
|
||||
|
||||
workerNode.Status = "online"
|
||||
workerNode.Cluster = clusterName
|
||||
workerNode.Role = "worker"
|
||||
workerNode.LastActive = time.Now().Format(time.RFC3339)
|
||||
log.Printf("Node %s successfully activated as worker node", workerNode.Name)
|
||||
}
|
||||
|
||||
func deactivateOneWorkerSafe(cs *kubernetes.Clientset, pool *NodePool, poolFile, sshUser, sshPass, clusterName string, waitSec int) {
|
||||
ctx := context.Background()
|
||||
nodes, err := cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Println("failed to list nodes:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var workerNode *v1.Node
|
||||
for _, n := range nodes.Items {
|
||||
if !isControlPlane(&n) && !n.Spec.Unschedulable {
|
||||
workerNode = &n
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if workerNode == nil {
|
||||
log.Println("No worker nodes available for deactivation")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Deactivating node: %s", workerNode.Name)
|
||||
ip := nodeIP(workerNode)
|
||||
|
||||
patch := []byte(`{"spec":{"unschedulable":true}}`)
|
||||
_, err = cs.CoreV1().Nodes().Patch(ctx, workerNode.Name, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
|
||||
if err != nil {
|
||||
log.Println("Failed to cordon node:", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = drainNode(cs, workerNode.Name)
|
||||
if err != nil {
|
||||
log.Println("Drain failed, uncordoning node")
|
||||
patch = []byte(`{"spec":{"unschedulable":false}}`)
|
||||
_, _ = cs.CoreV1().Nodes().Patch(ctx, workerNode.Name, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = runSSH(ip, sshUser, sshPass, "microk8s leave")
|
||||
if err != nil {
|
||||
log.Println("microk8s leave failed, keeping node object in cluster")
|
||||
return
|
||||
}
|
||||
|
||||
_ = cs.CoreV1().Nodes().Delete(ctx, workerNode.Name, metav1.DeleteOptions{})
|
||||
|
||||
for i := range pool.Nodes {
|
||||
if pool.Nodes[i].Name == workerNode.Name {
|
||||
pool.Nodes[i].Status = "offline"
|
||||
pool.Nodes[i].Cluster = "none"
|
||||
pool.Nodes[i].Role = "none"
|
||||
pool.Nodes[i].CPU = 0
|
||||
pool.Nodes[i].Memory = 0
|
||||
pool.Nodes[i].LastActive = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drain node ---
|
||||
func drainNode(cs *kubernetes.Clientset, nodeName string) error {
|
||||
ctx := context.Background()
|
||||
pods, err := cs.CoreV1().Pods("").List(ctx, metav1.ListOptions{
|
||||
FieldSelector: fmt.Sprintf("spec.nodeName=%s", nodeName),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pod := range pods.Items {
|
||||
if _, ok := pod.ObjectMeta.Annotations["kubernetes.io/config.mirror"]; ok {
|
||||
continue
|
||||
}
|
||||
if pod.Namespace == "kube-system" {
|
||||
continue
|
||||
}
|
||||
grace := int64(60)
|
||||
_ = cs.CoreV1().Pods(pod.Namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{
|
||||
GracePeriodSeconds: &grace,
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
remaining, _ := cs.CoreV1().Pods("").List(ctx, metav1.ListOptions{
|
||||
FieldSelector: fmt.Sprintf("spec.nodeName=%s", nodeName),
|
||||
})
|
||||
active := 0
|
||||
for _, p := range remaining.Items {
|
||||
if p.Namespace != "kube-system" && p.DeletionTimestamp == nil {
|
||||
active++
|
||||
}
|
||||
}
|
||||
if active == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Cluster utilization ---
|
||||
func clusterUtilization(cs *kubernetes.Clientset, ms *metrics.Clientset) (cpuPct int, memPct int, err error) {
|
||||
ctx := context.Background()
|
||||
nodes, err := cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
metricsList, err := ms.MetricsV1beta1().NodeMetricses().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
var totalCPUCap, totalMemCap, totalCPUUse, totalMemUse int64
|
||||
for _, n := range nodes.Items {
|
||||
totalCPUCap += n.Status.Capacity.Cpu().MilliValue()
|
||||
totalMemCap += n.Status.Capacity.Memory().Value()
|
||||
}
|
||||
|
||||
for _, m := range metricsList.Items {
|
||||
totalCPUUse += m.Usage.Cpu().MilliValue()
|
||||
totalMemUse += m.Usage.Memory().Value()
|
||||
}
|
||||
|
||||
cpuPct = int((totalCPUUse * 100) / totalCPUCap)
|
||||
memPct = int((totalMemUse * 100) / totalMemCap)
|
||||
return cpuPct, memPct, nil
|
||||
}
|
||||
|
||||
// --- Web GUI ---
|
||||
// startWebGUI starts a simple HTTP server showing the node pool
|
||||
func startWebGUI(poolFile string) {
|
||||
// API endpoint for JSON status
|
||||
http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
pool, err := loadPool(poolFile)
|
||||
if err != nil {
|
||||
log.Println("Cannot load node pool:", err)
|
||||
http.Error(w, "cannot load node pool", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(pool)
|
||||
})
|
||||
|
||||
// Serve index.html and static assets (single file)
|
||||
http.Handle("/", http.FileServer(http.Dir("/app/web")))
|
||||
|
||||
go func() {
|
||||
log.Println("Web GUI running at :8080")
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// --- Main ---
|
||||
func main() {
|
||||
poolFile := mustEnv("NODE_POOL_FILE", "node-pool.yaml")
|
||||
sshUser := mustEnv("SSH_USER", "ubuntu")
|
||||
sshPass := mustEnv("SSH_PASS", "Heleen0515")
|
||||
clusterName := mustEnv("CLUSTER_NAME", "cluster1")
|
||||
desiredCP := mustIntEnv("DESIRED_CONTROLPLANES", 1)
|
||||
|
||||
// Kubernetes client
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
log.Fatal("Cannot create in-cluster config:", err)
|
||||
}
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatal("Cannot create clientset:", err)
|
||||
}
|
||||
metricsClient, err := metrics.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatal("Cannot create metrics client:", err)
|
||||
}
|
||||
|
||||
// Initialize node pool
|
||||
pool, err := initNodePool(clientset, poolFile)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize node pool:", err)
|
||||
}
|
||||
|
||||
// Start web GUI
|
||||
startWebGUI(poolFile)
|
||||
|
||||
// Main loop
|
||||
for {
|
||||
err := updatePerNodeUtilization(clientset, metricsClient, pool, sshUser, sshPass)
|
||||
if err != nil {
|
||||
log.Println("updatePerNodeUtilization error:", err)
|
||||
}
|
||||
|
||||
ensureControlPlanes(clientset, pool, poolFile, sshUser, sshPass, clusterName, desiredCP)
|
||||
|
||||
cpuPct, memPct, err := clusterUtilization(clientset, metricsClient)
|
||||
if err == nil {
|
||||
log.Printf("Cluster utilization: CPU %d%%, MEM %d%%", cpuPct, memPct)
|
||||
}
|
||||
|
||||
savePool(poolFile, pool)
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ENV helper ---
|
||||
func mustEnv(key, def string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return def
|
||||
}
|
||||
Reference in New Issue
Block a user