diff --git a/src/go/api/soh/soh.go b/src/go/api/soh/soh.go index 24ccaac8..620c1962 100644 --- a/src/go/api/soh/soh.go +++ b/src/go/api/soh/soh.go @@ -17,12 +17,6 @@ func Get(expName, statusFilter string) (*Network, error) { // Create an empty network network := new(Network) - // Create structure to format nodes' font - font := Font{ - Color: "whitesmoke", - Align: "center", - } - exp, err := experiment.Get(expName) if err != nil { return nil, fmt.Errorf("unable to get experiment %s: %w", expName, err) @@ -107,7 +101,7 @@ func Get(expName, statusFilter string) (*Network, error) { ID: vm.ID, Label: vm.Name, Image: vm.OSType, - Fonts: font, + Tags: vm.Tags, Status: vmState, } @@ -140,7 +134,7 @@ func Get(expName, statusFilter string) (*Network, error) { ID: ifaceCount, Label: vmIface, Image: "switch", - Fonts: font, + Tags: vm.Tags, Status: "ignore", } diff --git a/src/go/api/soh/types.go b/src/go/api/soh/types.go index 05df56d5..ba0d5dc0 100644 --- a/src/go/api/soh/types.go +++ b/src/go/api/soh/types.go @@ -5,18 +5,13 @@ import ( "time" ) -type Font struct { - Color string `json:"color"` - Align string `json:"align"` -} - type Node struct { - ID int `json:"id"` - Label string `json:"label"` - Image string `json:"image"` - Fonts Font `json:"font"` - Status string `json:"status"` - SOH *HostState `json:"soh"` + ID int `json:"id"` + Label string `json:"label"` + Image string `json:"image"` + Tags map[string]string `json:"tags"` + Status string `json:"status"` + SOH *HostState `json:"soh"` } type Edge struct { diff --git a/src/go/api/vm/option.go b/src/go/api/vm/option.go index ac42cce0..e7cda505 100644 --- a/src/go/api/vm/option.go +++ b/src/go/api/vm/option.go @@ -8,16 +8,18 @@ type iface struct { } type updateOptions struct { - exp string - vm string - cpu int - mem int - disk string - partition int - dnb *bool - iface *iface - host *string - snapshot *bool + exp string + vm string + cpu int + mem int + disk string + partition int + dnb *bool + iface *iface + host *string + snapshot *bool + appendTags bool + tags *map[string]string } func newUpdateOptions(opts ...UpdateOption) updateOptions { @@ -78,15 +80,22 @@ func UpdateWithDNB(b bool) UpdateOption { } } +func UpdateWithHost(h string) UpdateOption { + return func(o *updateOptions) { + o.host = &h + } +} + func UpdateWithSnapshot(b bool) UpdateOption { return func(o *updateOptions) { o.snapshot = &b } } -func UpdateWithHost(h string) UpdateOption { +func UpdateWithTags(t map[string]string, appendTags bool) UpdateOption { return func(o *updateOptions) { - o.host = &h + o.appendTags = appendTags + o.tags = &t } } diff --git a/src/go/api/vm/vm.go b/src/go/api/vm/vm.go index 111aff33..d88b719e 100644 --- a/src/go/api/vm/vm.go +++ b/src/go/api/vm/vm.go @@ -96,6 +96,7 @@ func List(expName string) ([]mm.VM, error) { Type: node.Type(), OSType: node.Hardware().OSType(), Snapshot: snapshot, + Tags: node.Labels(), } for _, iface := range node.Network().Interfaces() { @@ -197,10 +198,11 @@ func Get(expName, vmName string) (*mm.VM, error) { Interfaces: make(map[string]string), DoNotBoot: *node.General().DoNotBoot(), OSType: string(node.Hardware().OSType()), + Snapshot: *node.General().Snapshot(), Metadata: make(map[string]interface{}), Labels: node.Labels(), + Tags: node.Labels(), Annotations: node.Annotations(), - Snapshot: *node.General().Snapshot(), } for _, iface := range node.Network().Interfaces() { @@ -284,22 +286,6 @@ func Update(opts ...UpdateOption) error { return fmt.Errorf("experiment or VM name not provided") } - running := experiment.Running(o.exp) - - if running && o.iface == nil { - return fmt.Errorf("only interface connections can be updated while experiment is running") - } - - // The only setting that can be updated while an experiment is running is the - // VLAN an interface is connected to. - if running { - if o.iface.vlan == "" { - return Disonnect(o.exp, o.vm, o.iface.index) - } else { - return Connect(o.exp, o.vm, o.iface.index, o.iface.vlan) - } - } - exp, err := experiment.Get(o.exp) if err != nil { return fmt.Errorf("unable to get experiment %s: %w", o.exp, err) @@ -310,6 +296,50 @@ func Update(opts ...UpdateOption) error { return fmt.Errorf("unable to find VM %s in experiment %s", o.vm, o.exp) } + // if appending, copy over old labels (keep newer version if present) + if o.tags != nil && o.appendTags { + for k, v := range vm.Labels() { + if _, ok := (*o.tags)[k]; !ok { + (*o.tags)[k] = v + } + } + } + + running := experiment.Running(o.exp) + + // The only settings that can be updated while an experiment is running is the + // VLAN an interface is connected to and the vm's tags + if running { + if o.iface == nil && o.tags == nil { + return fmt.Errorf("only interface connections and tags can be updated while experiment is running") + } + + if o.iface != nil { + if o.iface.vlan == "" { + if err := Disonnect(o.exp, o.vm, o.iface.index); err != nil { + return err + } + } else { + if err := Connect(o.exp, o.vm, o.iface.index, o.iface.vlan); err != nil { + return err + } + } + } + + if o.tags != nil { + // update both the live minimega tags and the experiment spec labels + if err := mm.SetVMTags(mm.NS(o.exp), mm.VMName(o.vm), mm.Tags(*o.tags)); err != nil { + return err + } + + vm.SetLabels(*o.tags) + if err := experiment.Save(experiment.SaveWithName(o.exp), experiment.SaveWithSpec(exp.Spec)); err != nil { + return fmt.Errorf("unable to save experiment with updated VM: %w", err) + } + } + return nil + } + if o.cpu != 0 { vm.Hardware().SetVCPU(o.cpu) } @@ -330,6 +360,10 @@ func Update(opts ...UpdateOption) error { vm.General().SetDoNotBoot(*o.dnb) } + if o.tags != nil { + vm.SetLabels(*o.tags) + } + if o.host != nil { if *o.host == "" { delete(exp.Spec.Schedules(), o.vm) @@ -396,7 +430,7 @@ func Restart(expName, vmName string) error { state, err := mm.GetVMState(mm.NS(expName), mm.VMName(vmName)) if err != nil { - return fmt.Errorf("Retrieving state for VM %s in experiment %s: %w", vmName, expName, err) + return fmt.Errorf("retrieving state for VM %s in experiment %s: %w", vmName, expName, err) } //Using "system_reset" on a VM that is in the "QUIT" state fails @@ -406,7 +440,7 @@ func Restart(expName, vmName string) error { } cmd := mmcli.NewNamespacedCommand(expName) - qmp := fmt.Sprintf(`{ "execute": "system_reset" }`) + qmp := `{ "execute": "system_reset" }` cmd.Command = fmt.Sprintf("vm qmp %s '%s'", vmName, qmp) _, err = mmcli.SingleResponse(mmcli.Run(cmd)) @@ -541,7 +575,7 @@ func ResetDiskState(expName, vmName string) error { cmd.Command = "vm kill " + vmName if err := mmcli.ErrorResponse(mmcli.Run(cmd)); err != nil { - return fmt.Errorf("Killing VM %s in experiment %s: %w", vmName, expName, err) + return fmt.Errorf("killing VM %s in experiment %s: %w", vmName, expName, err) } } @@ -724,7 +758,7 @@ func Snapshot(expName, vmName, out string, cb func(string)) error { fp = fmt.Sprintf("%s/%s", common.MinimegaBase, status[0]["id"]) ) - qmp := fmt.Sprintf(`{ "execute": "query-block" }`) + qmp := `{ "execute": "query-block" }` cmd.Command = fmt.Sprintf("vm qmp %s '%s'", vmName, qmp) res, err := mmcli.SingleResponse(mmcli.Run(cmd)) @@ -755,7 +789,7 @@ func Snapshot(expName, vmName, out string, cb func(string)) error { return fmt.Errorf("starting disk snapshot for VM %s: %w", vmName, err) } - qmp = fmt.Sprintf(`{ "execute": "query-block-jobs" }`) + qmp = `{ "execute": "query-block-jobs" }` cmd.Command = fmt.Sprintf(`vm qmp %s '%s'`, vmName, qmp) for { @@ -1248,7 +1282,7 @@ func MemorySnapshot(expName, vmName, out string, cb func(string)) (string, error } - qmp = fmt.Sprintf(`{ "execute": "query-dump" }`) + qmp = `{ "execute": "query-dump" }` cmd.Command = fmt.Sprintf("vm qmp %s '%s'", vmName, qmp) var ( @@ -1274,7 +1308,7 @@ func MemorySnapshot(expName, vmName, out string, cb func(string)) (string, error if cb != nil { cb("failed") } - return "", fmt.Errorf("no status available for %s: %s", vmName, v) + return "", fmt.Errorf("no status available for %s: %v", vmName, v) } @@ -1282,7 +1316,7 @@ func MemorySnapshot(expName, vmName, out string, cb func(string)) (string, error if cb != nil { cb("failed") } - return "failed", fmt.Errorf("failed to create memory snapshot for %s: %s", vmName, v) + return "failed", fmt.Errorf("failed to create memory snapshot for %s: %v", vmName, v) } @@ -1334,13 +1368,13 @@ func CaptureSubnet(expName, subnet string, vmList []string) ([]mm.Capture, error vms, err := List(expName) if err != nil { - return nil, fmt.Errorf("Getting vm list for %s failed", expName) + return nil, fmt.Errorf("getting vm list for %s failed", expName) } _, refNet, err := net.ParseCIDR(subnet) if err != nil { - return nil, fmt.Errorf("Unable to parse %s", subnet) + return nil, fmt.Errorf("unable to parse %s", subnet) } // Use empty struct for code consistency and @@ -1439,7 +1473,7 @@ func StopCaptureSubnet(expName, subnet string, vmList []string) ([]string, error vms, err := List(expName) if err != nil { - return nil, fmt.Errorf("Getting vm list for %s failed", expName) + return nil, fmt.Errorf("getting vm list for %s failed", expName) } _, refNet, err := net.ParseCIDR(subnet) diff --git a/src/go/tmpl/templates/minimega_script.tmpl b/src/go/tmpl/templates/minimega_script.tmpl index 6044107c..d3718cc1 100644 --- a/src/go/tmpl/templates/minimega_script.tmpl +++ b/src/go/tmpl/templates/minimega_script.tmpl @@ -63,7 +63,7 @@ vm config {{ $config }} {{ $value }} vm config qemu-override "{{ $match }}" "{{ $replacement }}" {{- end }} {{- range $label, $value := .Labels }} -vm config tags {{ $label }} {{ $value }} +vm config tags "{{ $label }}" "{{ escapeNewline $value }}" {{- end }} vm launch {{ .General.VMType }} {{ .General.Hostname }} {{- end }} diff --git a/src/go/tmpl/tmpl.go b/src/go/tmpl/tmpl.go index b270015e..b48858d7 100644 --- a/src/go/tmpl/tmpl.go +++ b/src/go/tmpl/tmpl.go @@ -60,6 +60,9 @@ func GenerateFromTemplate(name string, data interface{}, w io.Writer) error { "stringsJoin": func(s []string, sep string) string { return strings.Join(s, sep) }, + "escapeNewline": func(s string) string { + return strings.ReplaceAll(s, "\n", "\\n") + }, } tmpl := template.Must(template.New(name).Funcs(funcs).Parse(string(MustAsset(name)))) diff --git a/src/go/types/interfaces/topology.go b/src/go/types/interfaces/topology.go index e801506b..debf60c1 100644 --- a/src/go/types/interfaces/topology.go +++ b/src/go/types/interfaces/topology.go @@ -36,6 +36,7 @@ type NodeSpec interface { SetInjections([]NodeInjection) + SetLabels(map[string]string) AddLabel(string, string) AddHardware(string, int, int) NodeHardware AddNetworkInterface(string, string, string) NodeNetworkInterface diff --git a/src/go/types/version/v0/node.go b/src/go/types/version/v0/node.go index 2798f5a7..e0fdbfa6 100644 --- a/src/go/types/version/v0/node.go +++ b/src/go/types/version/v0/node.go @@ -85,6 +85,10 @@ func (this *Node) SetInjections(injections []ifaces.NodeInjection) { this.InjectionsF = injects } +func (this *Node) SetLabels(m map[string]string) { + this.LabelsF = m +} + func (this *Node) AddLabel(k, v string) { this.LabelsF[k] = v } diff --git a/src/go/types/version/v1/node.go b/src/go/types/version/v1/node.go index d19ef391..e6385e2b 100644 --- a/src/go/types/version/v1/node.go +++ b/src/go/types/version/v1/node.go @@ -102,6 +102,10 @@ func (this *Node) SetInjections(injections []ifaces.NodeInjection) { this.InjectionsF = injects } +func (this *Node) SetLabels(m map[string]string) { + this.LabelsF = m +} + func (this *Node) AddLabel(k, v string) { if this.LabelsF == nil { this.LabelsF = make(map[string]string) diff --git a/src/go/util/mm/minimega.go b/src/go/util/mm/minimega.go index 06c8af71..b4fd70e1 100644 --- a/src/go/util/mm/minimega.go +++ b/src/go/util/mm/minimega.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/base64" + "encoding/json" "errors" "fmt" "os" @@ -183,12 +184,9 @@ func (this Minimega) GetVMInfo(opts ...Option) VMs { } s = row["tags"] - s = strings.TrimPrefix(s, "{") - s = strings.TrimSuffix(s, "}") - - if s != "" { - vm.Tags = strings.Split(s, ",") - } + var tags map[string]string + json.Unmarshal([]byte(s), &tags) + vm.Tags = tags // Make sure the VM name is set prior to calling `GetVMCaptures`, as the VM // name is not always set when calling `GetVMInfo`. @@ -486,6 +484,27 @@ func (Minimega) GetVMState(opts ...Option) (string, error) { return status[0]["state"], nil } +func (Minimega) SetVMTags(opts ...Option) error { + o := NewOptions(opts...) + + cmd := mmcli.NewNamespacedCommand(o.ns) + cmd.Command = fmt.Sprintf("clear vm tag %s ", o.vm) + + if err := mmcli.ErrorResponse(mmcli.Run(cmd)); err != nil { + return fmt.Errorf("failed to clear tags for vm %s: %w", o.vm, err) + } + + for k, v := range o.tags { + cmd.Command = fmt.Sprintf("vm tag %s \"%s\" \"%s\"", o.vm, k, v) + + if err := mmcli.ErrorResponse(mmcli.Run(cmd)); err != nil { + return fmt.Errorf("failed to set tag for vm %s: %s=%s %w", o.vm, k, v, err) + } + } + + return nil +} + func (Minimega) ConnectVMInterface(opts ...Option) error { o := NewOptions(opts...) diff --git a/src/go/util/mm/mm.go b/src/go/util/mm/mm.go index 7f5fa3bd..bcd2fe76 100644 --- a/src/go/util/mm/mm.go +++ b/src/go/util/mm/mm.go @@ -19,6 +19,8 @@ type MM interface { GetVMHost(...Option) (string, error) GetVMState(...Option) (string, error) + SetVMTags(...Option) error + ConnectVMInterface(...Option) error DisconnectVMInterface(...Option) error diff --git a/src/go/util/mm/option.go b/src/go/util/mm/option.go index c95d7087..dde92f9f 100644 --- a/src/go/util/mm/option.go +++ b/src/go/util/mm/option.go @@ -17,6 +17,8 @@ type options struct { disk string bridge string + tags map[string]string + injectPart int injects []string @@ -80,6 +82,12 @@ func Bridge(b string) Option { } } +func Tags(t map[string]string) Option { + return func(o *options) { + o.tags = t + } +} + func InjectPartition(p int) Option { return func(o *options) { o.injectPart = p diff --git a/src/go/util/mm/package.go b/src/go/util/mm/package.go index e5d84cb5..e3a01075 100644 --- a/src/go/util/mm/package.go +++ b/src/go/util/mm/package.go @@ -52,6 +52,10 @@ func GetVMState(opts ...Option) (string, error) { return DefaultMM.GetVMState(opts...) } +func SetVMTags(opts ...Option) error { + return DefaultMM.SetVMTags(opts...) +} + func ConnectVMInterface(opts ...Option) error { return DefaultMM.ConnectVMInterface(opts...) } diff --git a/src/go/util/mm/types.go b/src/go/util/mm/types.go index 5f3ea162..4dd90290 100644 --- a/src/go/util/mm/types.go +++ b/src/go/util/mm/types.go @@ -206,30 +206,30 @@ func (this VMs) Paginate(page, size int) VMs { } type VM struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Experiment string `json:"experiment"` - Host string `json:"host"` - IPv4 []string `json:"ipv4"` - CPUs int `json:"cpus"` - RAM int `json:"ram"` - Disk string `json:"disk"` - InjectPartition int `json:"inject_partition` - OSType string `json:"osType"` - DoNotBoot bool `json:"dnb"` - Networks []string `json:"networks"` - Taps []string `json:"taps"` - Captures []Capture `json:"captures"` - State string `json:"state"` - Running bool `json:"running"` - Busy bool `json:"busy"` - CCActive bool `json:"ccActive"` - Uptime float64 `json:"uptime"` - Screenshot string `json:"screenshot,omitempty"` - CdRom string `json:"cdRom"` - Tags []string `json:"tags"` - Snapshot bool `json:"snapshot"` + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Experiment string `json:"experiment"` + Host string `json:"host"` + IPv4 []string `json:"ipv4"` + CPUs int `json:"cpus"` + RAM int `json:"ram"` + Disk string `json:"disk"` + InjectPartition int `json:"inject_partition"` + OSType string `json:"osType"` + DoNotBoot bool `json:"dnb"` + Networks []string `json:"networks"` + Taps []string `json:"taps"` + Captures []Capture `json:"captures"` + State string `json:"state"` + Running bool `json:"running"` + Busy bool `json:"busy"` + CCActive bool `json:"ccActive"` + Uptime float64 `json:"uptime"` + Screenshot string `json:"screenshot,omitempty"` + CdRom string `json:"cdRom"` + Tags map[string]string `json:"tags"` + Snapshot bool `json:"snapshot"` // Used internally to track network <--> IP relationship, since // network ordering from minimega may not be the same as network @@ -263,8 +263,10 @@ func (this VM) Copy() VM { vm.Captures = make([]Capture, len(this.Captures)) copy(vm.Captures, this.Captures) - vm.Tags = make([]string, len(this.Tags)) - copy(vm.Tags, this.Tags) + vm.Tags = make(map[string]string, len(this.Tags)) + for k, v := range this.Tags { + vm.Tags[k] = v + } return vm } diff --git a/src/go/web/handlers.go b/src/go/web/handlers.go index 89a541bd..85e54271 100644 --- a/src/go/web/handlers.go +++ b/src/go/web/handlers.go @@ -1045,6 +1045,12 @@ func UpdateVM(w http.ResponseWriter, r *http.Request) { opts = append(opts, vm.UpdateWithInterface(int(req.Interface.Index), req.Interface.Vlan)) } + if req.TagUpdateMode == proto.TagUpdateMode_SET { + opts = append(opts, vm.UpdateWithTags(req.Tags, false)) + } else if req.TagUpdateMode == proto.TagUpdateMode_ADD { + opts = append(opts, vm.UpdateWithTags(req.Tags, true)) + } + switch req.Boot.(type) { case *proto.UpdateVMRequest_DoNotBoot: opts = append(opts, vm.UpdateWithDNB(req.GetDoNotBoot())) diff --git a/src/go/web/proto/vm.proto b/src/go/web/proto/vm.proto index 91f2bca0..0b8c851b 100644 --- a/src/go/web/proto/vm.proto +++ b/src/go/web/proto/vm.proto @@ -20,7 +20,7 @@ message VM { string experiment = 15; string state = 16; string cd_rom = 17; - repeated string tags = 18; + map tags = 18; bool cc_active = 19; bool external = 20; string delayed_start = 21 [json_name="delayed_start"]; @@ -55,6 +55,14 @@ message VMRedeployRequest { bool injects = 5; } +enum TagUpdateMode { + NONE = 0; + // sets all tags + SET = 1; + // adds (or replaces if already present) tags + ADD = 2; +} + message UpdateVMRequest { string exp = 1; string name = 2; @@ -77,6 +85,8 @@ message UpdateVMRequest { } uint32 inject_partition = 10 [json_name="inject_partition"]; + TagUpdateMode tag_update_mode = 11 [json_name="tag_update_mode"]; + map tags = 12; } message UpdateVMRequestList { diff --git a/src/js/src/components/RunningExperiment.vue b/src/js/src/components/RunningExperiment.vue index b9f3d94e..8346e324 100644 --- a/src/js/src/components/RunningExperiment.vue +++ b/src/js/src/components/RunningExperiment.vue @@ -754,6 +754,18 @@ {{ props.row.uptime | uptime }} + + +
@@ -841,6 +853,8 @@ diff --git a/src/js/src/components/StateOfHealth.vue b/src/js/src/components/StateOfHealth.vue index 8d42d3b2..f8204d7a 100644 --- a/src/js/src/components/StateOfHealth.vue +++ b/src/js/src/components/StateOfHealth.vue @@ -1,20 +1,29 @@ - - + + + + +