From 796e60382963194ae1f98b2fef7eb0da9de4ea56 Mon Sep 17 00:00:00 2001 From: Casey Glatter Date: Thu, 18 Jan 2024 13:12:33 -0700 Subject: [PATCH] feat: new deploy mode option for CLI and UI This commit replaces the previous `runLocal` setting for experiments with a new `deployMode` setting that can be set via the CLI and the UI when creating new experiments. The value of this new setting must be one of `all`, `no-headnode`, or `only-headnode`, with the default being `no-headnode` to preserve the previous default value of `runLocal: false`. The overall goal of this commit is to support the new option of `all`, which allows experiment VMs to run on the head node as well as all the other compute nodes. The previous `runLocal` setting only supported the current `no-headnode` and `only-headnode` options. --- src/go/api/experiment/experiment.go | 3 +- src/go/api/experiment/option.go | 11 ++++- src/go/cmd/experiment.go | 22 ++++----- src/go/cmd/root.go | 52 ++++++++++++++++++++-- src/go/cmd/ui.go | 10 ++++- src/go/tmpl/templates/minimega_script.tmpl | 4 +- src/go/types/interfaces/experiment.go | 3 +- src/go/types/version/v1/experiment.go | 10 +++-- src/go/util/common/common.go | 30 ++++++++++++- src/go/web/handlers.go | 8 ++++ src/go/web/option.go | 49 +++++++++++++++----- src/go/web/proto/experiment.proto | 1 + src/go/web/rbac/known_policy.go | 3 +- src/go/web/server.go | 14 ++++-- src/go/web/workflow.go | 21 +++++++-- src/js/src/components/Experiments.vue | 14 +++++- 16 files changed, 208 insertions(+), 47 deletions(-) diff --git a/src/go/api/experiment/experiment.go b/src/go/api/experiment/experiment.go index b0c6ddad..5182e4ac 100644 --- a/src/go/api/experiment/experiment.go +++ b/src/go/api/experiment/experiment.go @@ -207,9 +207,10 @@ func Create(ctx context.Context, opts ...CreateOption) error { }, } - specMap := map[string]interface{}{ + specMap := map[string]any{ "experimentName": o.name, "baseDir": o.baseDir, + "deployMode": o.deployMode, "topology": topo, } diff --git a/src/go/api/experiment/option.go b/src/go/api/experiment/option.go index f6c37826..d3d6389d 100644 --- a/src/go/api/experiment/option.go +++ b/src/go/api/experiment/option.go @@ -18,10 +18,13 @@ type createOptions struct { vlanAliases map[string]int schedules map[string]string baseDir string + deployMode common.DeploymentMode } func newCreateOptions(opts ...CreateOption) createOptions { - var o createOptions + o := createOptions{ + deployMode: common.DeployMode, + } for _, opt := range opts { opt(&o) @@ -94,6 +97,12 @@ func CreateWithBaseDirectory(b string) CreateOption { } } +func CreateWithDeployMode(m common.DeploymentMode) CreateOption { + return func(o *createOptions) { + o.deployMode = m + } +} + type SaveOption func(*saveOptions) type saveOptions struct { diff --git a/src/go/cmd/experiment.go b/src/go/cmd/experiment.go index 817ad411..3ed4f776 100644 --- a/src/go/cmd/experiment.go +++ b/src/go/cmd/experiment.go @@ -245,7 +245,7 @@ func newExperimentDeleteCmd() *cobra.Command { desc := `Delete an experiment Used to delete an exisitng experiment; experiment must be stopped. - Using 'all' instead of a specific experiment name will include all + Using 'all' instead of a specific experiment name will include all stopped experiments` cmd := &cobra.Command{ @@ -301,8 +301,8 @@ func newExperimentDeleteCmd() *cobra.Command { func newExperimentScheduleCmd() *cobra.Command { desc := `Schedule an experiment - - Apply an algorithm to a given experiment. Run 'phenix experiment schedulers' + + Apply an algorithm to a given experiment. Run 'phenix experiment schedulers' to return a list of algorithms` cmd := &cobra.Command{ @@ -333,10 +333,10 @@ func newExperimentScheduleCmd() *cobra.Command { func newExperimentStartCmd() *cobra.Command { desc := `Start an experiment - Used to start a stopped experiment, using 'all' instead of a specific - experiment name will include all stopped experiments; dry-run will do + Used to start a stopped experiment, using 'all' instead of a specific + experiment name will include all stopped experiments; dry-run will do everything but call out to minimega. - + NOTE: passing the --honor-run-periodically flag will prevent the CLI from returning. If Ctrl+c is pressed, the experiment will continue to run but the running stage will no longer continue to be triggered for any apps @@ -398,7 +398,7 @@ func newExperimentStartCmd() *cobra.Command { notes.PrettyPrint(ctx, false) - plog.Info("experiment started", "exp", exp.Metadata.Name, "dryrun", dryrun) + plog.Info("experiment started", "exp", exp.Metadata.Name, "dryrun", dryrun, "deploy-mode", exp.Spec.DeployMode()) if periodic { plog.Info("honor-run-periodically flag was passed") @@ -431,7 +431,7 @@ func newExperimentStartCmd() *cobra.Command { func newExperimentStopCmd() *cobra.Command { desc := `Stop an experiment - Used to stop a running experiment, using 'all' instead of a specific + Used to stop a running experiment, using 'all' instead of a specific experiment name will include all running experiments.` cmd := &cobra.Command{ @@ -487,8 +487,8 @@ func newExperimentStopCmd() *cobra.Command { func newExperimentRestartCmd() *cobra.Command { desc := `Restart an experiment - Used to restart a running experiment, using 'all' instead of a specific - experiment name will include all running experiments; dry-run will do + Used to restart a running experiment, using 'all' instead of a specific + experiment name will include all running experiments; dry-run will do everything but call out to minimega.` cmd := &cobra.Command{ @@ -539,7 +539,7 @@ func newExperimentRestartCmd() *cobra.Command { return err.Humanized() } - plog.Info("experiment restarted", "exp", exp.Metadata.Name) + plog.Info("experiment restarted", "exp", exp.Metadata.Name, "dryrun", dryrun, "deploy-mode", exp.Spec.DeployMode()) } return nil diff --git a/src/go/cmd/root.go b/src/go/cmd/root.go index 7ee45479..be425027 100644 --- a/src/go/cmd/root.go +++ b/src/go/cmd/root.go @@ -1,7 +1,12 @@ package cmd import ( + "context" + "encoding/json" "fmt" + "io" + "net" + "net/http" "os" "os/user" "path/filepath" @@ -32,20 +37,57 @@ var rootCmd = &cobra.Command{ Use: "phenix", Short: "A cli application for phēnix", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + common.UnixSocket = viper.GetString("unix-socket") + + // check for global options set by UI server + if common.UnixSocket != "" { + cli := http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", common.UnixSocket) + }, + }, + } + + if resp, err := cli.Get("http://unix/api/v1/options"); err == nil { + defer resp.Body.Close() + + if body, err := io.ReadAll(resp.Body); err == nil { + var options map[string]any + json.Unmarshal(body, &options) + + if mode, _ := options["deploy-mode"].(string); mode != "" { + if deployMode, err := common.ParseDeployMode(mode); err == nil { + common.DeployMode = deployMode + } + } + } + } + } + + plog.NewPhenixHandler() + plog.SetLevelText(viper.GetString("log.level")) + common.PhenixBase = viper.GetString("base-dir.phenix") common.MinimegaBase = viper.GetString("base-dir.minimega") common.HostnameSuffixes = viper.GetString("hostname-suffixes") + // if deploy mode option is set locally by user, use it instead of global from UI + if opt := viper.GetString("deploy-mode"); opt != "" { + mode, err := common.ParseDeployMode(opt) + if err != nil { + return fmt.Errorf("parsing deploy mode: %w", err) + } + + common.DeployMode = mode + } + var ( endpoint = viper.GetString("store.endpoint") errFile = viper.GetString("log.error-file") errOut = viper.GetBool("log.error-stderr") - logLevel = viper.GetString("log.level") ) - plog.NewPhenixHandler() - plog.SetLevelText(logLevel) - common.ErrorFile = errFile common.StoreEndpoint = endpoint @@ -131,6 +173,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&hostnameSuffixes, "hostname-suffixes", "-minimega,-phenix", "hostname suffixes to strip") rootCmd.PersistentFlags().Bool("log.error-stderr", true, "log fatal errors to STDERR") rootCmd.PersistentFlags().String("log.level", "info", "level to log messages at") + rootCmd.PersistentFlags().String("deploy-mode", "", "deploy mode for minimega VMs (options: all | no-headnode | only-headnode)") + rootCmd.PersistentFlags().String("unix-socket", "/tmp/phenix.sock", "phēnix unix socket to listen on (ui subcommand) or connect to") if uid == "0" { os.MkdirAll("/etc/phenix", 0755) diff --git a/src/go/cmd/ui.go b/src/go/cmd/ui.go index 155f934b..fcca75b9 100644 --- a/src/go/cmd/ui.go +++ b/src/go/cmd/ui.go @@ -6,6 +6,7 @@ import ( "time" "phenix/util" + "phenix/util/common" "phenix/util/plog" "phenix/web" @@ -28,7 +29,6 @@ func newUICmd() *cobra.Command { opts := []web.ServerOption{ web.ServeOnEndpoint(viper.GetString("ui.listen-endpoint")), - web.ServeOnUnixSocket(viper.GetString("ui.unix-socket-endpoint")), web.ServeBasePath(viper.GetString("ui.base-path")), web.ServeWithJWTKey(viper.GetString("ui.jwt-signing-key")), web.ServeWithJWTLifetime(viper.GetDuration("ui.jwt-lifetime")), @@ -39,6 +39,12 @@ func newUICmd() *cobra.Command { web.ServeWithProxyAuthHeader(viper.GetString("ui.proxy-auth-header")), } + if endpoint := viper.GetString("ui.unix-socket-endpoint"); endpoint != "" { + plog.Warn("The --ui.unix-socket-endpoint option for the ui subcommand is DEPRECATED. Use the root phenix --unix-socket option instead.") + + common.UnixSocket = endpoint + } + if viper.GetString("ui.log-level") != "" { plog.Warn("The --log-level option for the ui subcommand is DEPRECATED. Use the root phenix --log.level option instead.") } @@ -88,7 +94,7 @@ func newUICmd() *cobra.Command { } cmd.Flags().StringP("listen-endpoint", "e", "0.0.0.0:3000", "endpoint to listen on") - cmd.Flags().String("unix-socket-endpoint", "", "unix socket path to listen on (no auth, only exposes workflow API)") + cmd.Flags().String("unix-socket-endpoint", "", "unix socket path to listen on - DEPRECATED (use root --unix-socket option instead)") cmd.Flags().StringP("base-path", "b", "/", "base path to use for UI (must run behind proxy if not '/')") cmd.Flags().StringP("jwt-signing-key", "k", "", "Secret key used to sign JWT for authentication") cmd.Flags().Duration("jwt-lifetime", 24*time.Hour, "Lifetime of JWT authentication tokens") diff --git a/src/go/tmpl/templates/minimega_script.tmpl b/src/go/tmpl/templates/minimega_script.tmpl index 7daec27d..6044107c 100644 --- a/src/go/tmpl/templates/minimega_script.tmpl +++ b/src/go/tmpl/templates/minimega_script.tmpl @@ -11,7 +11,9 @@ vlans add {{ $alias }} {{ $id }} {{- end }} {{- end }} -{{- if .RunLocal }} +{{- if eq .DeployMode "all" }} +ns add-host localhost +{{- else if eq .DeployMode "only-headnode" }} ns del-host all ns add-host localhost {{- end }} diff --git a/src/go/types/interfaces/experiment.go b/src/go/types/interfaces/experiment.go index 68f12014..12e55f05 100644 --- a/src/go/types/interfaces/experiment.go +++ b/src/go/types/interfaces/experiment.go @@ -23,7 +23,7 @@ type ExperimentSpec interface { Scenario() ScenarioSpec VLANs() VLANSpec Schedules() map[string]string - RunLocal() bool + DeployMode() string SetExperimentName(string) SetBaseDir(string) @@ -32,6 +32,7 @@ type ExperimentSpec interface { SetSchedule(map[string]string) SetTopology(TopologySpec) SetScenario(ScenarioSpec) + SetDeployMode(string) VerifyScenario(context.Context) error ScheduleNode(string, string) error diff --git a/src/go/types/version/v1/experiment.go b/src/go/types/version/v1/experiment.go index eeb7c4ab..a898b9ac 100644 --- a/src/go/types/version/v1/experiment.go +++ b/src/go/types/version/v1/experiment.go @@ -79,7 +79,7 @@ type ExperimentSpec struct { ScenarioF *v2.ScenarioSpec `json:"scenario" yaml:"scenario" structs:"scenario" mapstructure:"scenario"` VLANsF *VLANSpec `json:"vlans" yaml:"vlans" structs:"vlans" mapstructure:"vlans"` SchedulesF map[string]string `json:"schedules" yaml:"schedules" structs:"schedules" mapstructure:"schedules"` - RunLocalF bool `json:"runLocal" yaml:"runLocal" structs:"runLocal" mapstructure:"runLocal"` + DeployModeF string `json:"deployMode" yaml:"deployMode" structs:"deployMode" mapstructure:"deployMode"` } func (this *ExperimentSpec) Init() error { @@ -167,8 +167,12 @@ func (this ExperimentSpec) Schedules() map[string]string { return this.SchedulesF } -func (this ExperimentSpec) RunLocal() bool { - return this.RunLocalF +func (this ExperimentSpec) DeployMode() string { + return this.DeployModeF +} + +func (this *ExperimentSpec) SetDeployMode(mode string) { + this.DeployModeF = mode } func (this *ExperimentSpec) SetExperimentName(name string) { diff --git a/src/go/util/common/common.go b/src/go/util/common/common.go index 7c5ce6e0..c386d6de 100644 --- a/src/go/util/common/common.go +++ b/src/go/util/common/common.go @@ -1,15 +1,28 @@ package common import ( + "fmt" "strings" ) +type DeploymentMode string + +const ( + DEPLOY_MODE_UNSET DeploymentMode = "" + DEPLOY_MODE_NO_HEADNODE DeploymentMode = "no-headnode" + DEPLOY_MODE_ONLY_HEADNODE DeploymentMode = "only-headnode" + DEPLOY_MODE_ALL DeploymentMode = "all" +) + var ( PhenixBase = "/phenix" MinimegaBase = "/tmp/minimega" - LogFile = "/var/log/phenix/phenix.log" - ErrorFile = "/var/log/phenix/error.log" + DeployMode = DEPLOY_MODE_NO_HEADNODE + + LogFile = "/var/log/phenix/phenix.log" + ErrorFile = "/var/log/phenix/error.log" + UnixSocket = "/tmp/phenix.sock" StoreEndpoint string HostnameSuffixes string @@ -22,3 +35,16 @@ func TrimHostnameSuffixes(str string) string { return str } + +func ParseDeployMode(mode string) (DeploymentMode, error) { + switch strings.ToLower(mode) { + case "no-headnode": + return DEPLOY_MODE_NO_HEADNODE, nil + case "only-headnode": + return DEPLOY_MODE_ONLY_HEADNODE, nil + case "all": + return DEPLOY_MODE_ALL, nil + } + + return DEPLOY_MODE_UNSET, fmt.Errorf("unknown deploy mode provided: %s", mode) +} diff --git a/src/go/web/handlers.go b/src/go/web/handlers.go index a20327e7..f2f4df46 100644 --- a/src/go/web/handlers.go +++ b/src/go/web/handlers.go @@ -26,6 +26,7 @@ import ( "phenix/api/vm" "phenix/app" "phenix/store" + "phenix/util/common" "phenix/util/mm" "phenix/util/notes" "phenix/util/plog" @@ -171,6 +172,12 @@ func CreateExperiment(w http.ResponseWriter, r *http.Request) { defer cache.UnlockExperiment(req.Name) + deployMode, err := common.ParseDeployMode(req.DeployMode) + if err != nil { + plog.Warn("error parsing experiment deploy mode ('%s') - using default of '%s'", req.DeployMode, common.DeployMode) + deployMode = common.DeployMode + } + opts := []experiment.CreateOption{ experiment.CreateWithName(req.Name), experiment.CreateWithTopology(req.Topology), @@ -178,6 +185,7 @@ func CreateExperiment(w http.ResponseWriter, r *http.Request) { experiment.CreateWithVLANMin(int(req.VlanMin)), experiment.CreateWithVLANMax(int(req.VlanMax)), experiment.CreatedWithDisabledApplications(req.DisabledApps), + experiment.CreateWithDeployMode(deployMode), } if req.WorkflowBranch != "" { diff --git a/src/go/web/option.go b/src/go/web/option.go index ce60995a..8b65a76e 100644 --- a/src/go/web/option.go +++ b/src/go/web/option.go @@ -1,7 +1,13 @@ package web import ( + "encoding/json" + "net/http" "os" + "phenix/util/common" + "phenix/util/plog" + "phenix/web/rbac" + "phenix/web/weberror" "strings" "time" ) @@ -9,10 +15,9 @@ import ( type ServerOption func(*serverOptions) type serverOptions struct { - endpoint string - unixSocket string - users []string - allowCORS bool + endpoint string + users []string + allowCORS bool tlsKeyPath string tlsCrtPath string @@ -83,12 +88,6 @@ func ServeOnEndpoint(e string) ServerOption { } } -func ServeOnUnixSocket(s string) ServerOption { - return func(o *serverOptions) { - o.unixSocket = s - } -} - func ServeWithJWTKey(k string) ServerOption { return func(o *serverOptions) { o.jwtKey = k @@ -173,3 +172,33 @@ func ServeWithFeatures(f []string) ServerOption { } } } + +// GET /options +func GetOptions(w http.ResponseWriter, r *http.Request) error { + plog.Debug("HTTP handler called", "handler", "GetOptions") + + var ( + ctx = r.Context() + role = ctx.Value("role").(rbac.Role) + ) + + if !role.Allowed("options", "list") { + err := weberror.NewWebError(nil, "listing options not allowed for %s", ctx.Value("user").(string)) + return err.SetStatus(http.StatusForbidden) + } + + options := map[string]any{ + "deploy-mode": common.DeployMode, + } + + body, err := json.Marshal(options) + if err != nil { + err := weberror.NewWebError(err, "unable to process options") + return err.SetStatus(http.StatusInternalServerError) + } + + w.Header().Set("Content-Type", "application/json") + w.Write(body) + + return nil +} diff --git a/src/go/web/proto/experiment.proto b/src/go/web/proto/experiment.proto index 96719d70..8d0525ff 100644 --- a/src/go/web/proto/experiment.proto +++ b/src/go/web/proto/experiment.proto @@ -70,6 +70,7 @@ message CreateExperimentRequest { uint32 vlan_max = 5 [json_name="vlan_max"]; string workflow_branch = 6 [json_name="workflow_branch"]; repeated string disabled_apps = 7 [json_name="disabled_apps"]; + string deploy_mode = 8 [json_name="deploy_mode"]; } message SnapshotRequest { diff --git a/src/go/web/rbac/known_policy.go b/src/go/web/rbac/known_policy.go index 769920db..085a7218 100644 --- a/src/go/web/rbac/known_policy.go +++ b/src/go/web/rbac/known_policy.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// This file was generated at build time 2023-10-25 08:41:25.249681769 -0600 MDT m=+0.221636804 +// This file was generated at build time 2024-01-23 10:14:31.579393538 -0700 MST m=+0.354711363 // This contains all known role checks used in codebase package rbac @@ -38,6 +38,7 @@ var Permissions = []Permission{ {"hosts", "list"}, {"miniconsole", "get"}, {"miniconsole", "post"}, + {"options", "list"}, {"roles", "list"}, {"scenarios", "list"}, {"schemas", "get"}, diff --git a/src/go/web/server.go b/src/go/web/server.go index a69bc3e0..9da2c61c 100644 --- a/src/go/web/server.go +++ b/src/go/web/server.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "phenix/util/common" "phenix/util/plog" "phenix/web/broker" "phenix/web/forward" @@ -248,6 +249,10 @@ func Start(opts ...ServerOption) error { {"/errors/{uuid}", weberror.ErrorHandler(GetError), []string{"GET"}}, } + optionRoutes := []route{ + {"/options", weberror.ErrorHandler(GetOptions), []string{"GET"}}, + } + addRoutesToRouter(api, workflowRoutes...) addRoutesToRouter(api, errorRoutes...) @@ -282,7 +287,7 @@ func Start(opts ...ServerOption) error { plog.Info("using base path", "path", o.basePath) plog.Info("using JWT lifetime", "lifetime", o.jwtLifetime) - if o.unixSocket != "" { + if common.UnixSocket != "" { var ( router = mux.NewRouter().StrictSlash(true) api = router.PathPrefix("/api/v1").Subrouter() @@ -290,15 +295,16 @@ func Start(opts ...ServerOption) error { addRoutesToRouter(api, workflowRoutes...) addRoutesToRouter(api, errorRoutes...) + addRoutesToRouter(api, optionRoutes...) api.Use(middleware.NoAuth) - os.Remove(o.unixSocket) + os.Remove(common.UnixSocket) - plog.Info("starting Unix socket server", "path", o.unixSocket) + plog.Info("starting Unix socket server", "path", common.UnixSocket) server := http.Server{Handler: router} - listener, err := net.Listen("unix", o.unixSocket) + listener, err := net.Listen("unix", common.UnixSocket) if err != nil { return err } diff --git a/src/go/web/workflow.go b/src/go/web/workflow.go index 2f925ab5..6b117860 100644 --- a/src/go/web/workflow.go +++ b/src/go/web/workflow.go @@ -13,6 +13,7 @@ import ( "phenix/store" "phenix/types" "phenix/types/version" + "phenix/util/common" "phenix/util/plog" "phenix/web/broker" "phenix/web/cache" @@ -137,6 +138,7 @@ func ApplyWorkflow(w http.ResponseWriter, r *http.Request) error { experiment.CreateWithSchedules(wf.Schedules), experiment.CreateWithVLANMin(wf.VLANMin()), experiment.CreateWithVLANMax(wf.VLANMax()), + experiment.CreateWithDeployMode(wf.ExperimentDeployMode()), } if err := experiment.Create(ctx, opts...); err != nil { @@ -302,6 +304,7 @@ func ApplyWorkflow(w http.ResponseWriter, r *http.Request) error { } exp.Spec.SetSchedule(schedules) + exp.Spec.SetDeployMode(string(wf.ExperimentDeployMode())) exp.Spec.SetVLANRange(wf.VLANMin(), wf.VLANMax(), true) if err := exp.WriteToStore(false); err != nil { @@ -476,10 +479,11 @@ type workflow struct { Restart *bool `mapstructure:"restart"` } `mapstructure:"auto"` - Topology string `mapstructure:"topology"` - Scenario string `mapstructure:"scenario"` - VLANs map[string]int `mapstructure:"vlans"` - Schedules map[string]string `mapstructue:"schedules"` + Topology string `mapstructure:"topology"` + Scenario string `mapstructure:"scenario"` + VLANs map[string]int `mapstructure:"vlans"` + Schedules map[string]string `mapstructue:"schedules"` + DeployMode string `mapstructure:"deployMode"` VLANRange *struct { Min int `mapstructure:"min"` @@ -543,6 +547,15 @@ func (this workflow) ScheduleMappings() map[string]string { return this.Schedules } +func (this workflow) ExperimentDeployMode() common.DeploymentMode { + mode, err := common.ParseDeployMode(this.DeployMode) + if err != nil { // this will happen if deploy mode isn't provided in workflow config + return common.DeployMode + } + + return mode +} + func (this workflow) VLANMin() int { if this.VLANRange == nil { return 0 diff --git a/src/js/src/components/Experiments.vue b/src/js/src/components/Experiments.vue index ae331ada..6d9dd374 100644 --- a/src/js/src/components/Experiments.vue +++ b/src/js/src/components/Experiments.vue @@ -52,6 +52,13 @@
+ + + + + @@ -584,6 +591,7 @@ vlan_min: +this.createModal.vlan_min, vlan_max: +this.createModal.vlan_max, workflow_branch: this.createModal.branch, + deploy_mode: this.createModal.deploy_mode, disabled_apps: disabledApps } @@ -669,7 +677,8 @@ scenarios: {}, scenario: null, vlan_min: null, - vlan_max: null + vlan_max: null, + deploy_mode: null } }, @@ -764,7 +773,8 @@ scenario: null, vlan_min: null, vlan_max: null, - branch: null + branch: null, + deploy_mode: null, }, experiments: [], topologies: [],