Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ All notable changes to this project will be documented in this file.
- **Web UI (Vue 3)**: Upgraded the web frontend to Vue 3 (Vuex → Pinia, vue-resource → axios, vue-cli → Vite, Buefy 1.0). Page components now load dynamically for a quicker initial load, and the codebase was reorganized (page components moved to `views/`).
- **Idle Timeout / Auto-Logout**: Added a configurable inactivity timeout that warns and then logs the user out. Managed from the web UI **Settings** page and backed by a new `GET /api/v1/settings/timeout` route.
- **Podman CI**: Added a GitHub Actions job that builds the Podman `Containerfile` (through the Go build stage) so the Podman build path can't break unnoticed.
- **Experiment Lifecycle Triggers**: Added `phenix exp trigger` for manually invoking app lifecycle stages. Deprecated `phenix exp trigger-running`; use `phenix exp trigger running` instead. The command errors out if a given app isn't part of the experiment or if the requested lifecycle stage isn't applicable to it, and shell completion only suggests apps applicable to the given experiment and stage.

### Changed
- **Log Output**: Default log output format changed to structured JSON on `stderr` for applications.
Expand Down
109 changes: 100 additions & 9 deletions src/go/api/experiment/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,17 @@ func Save(opts ...SaveOption) error {
// Reconfigure executes the 'configure' stage for all apps the given experiment
// is configured to use. It returns any errors encountered while reconfiguring
// the experiment.
//
// Reconfigure is intended to be called after an experiment's stored topology,
// scenario, or deployment settings have changed (e.g. via the config or
// workflow APIs), not as a general on-demand debugging tool. Unlike
// Trigger(ctx, name, app.ActionConfigure), it: refuses to run against a
// running experiment; persists the experiment via config.Update so config
// validation and any registered config hooks run; deletes the experiment's
// minimega bridge (when not using the GRE mesh) so it gets recreated with the
// current settings; and always applies to every app, without support for
// filtering to specific apps. Use Trigger for manually re-running a single
// app's (or apps') configure hook on demand, e.g. for debugging.
func Reconfigure(name string) error {
var err error
c, _ := store.NewConfig("experiment/" + name)
Expand Down Expand Up @@ -1056,12 +1067,25 @@ func Reconfigure(name string) error {
return nil
}

// TriggerRunning executes the 'running' stage for the given apps in the given
// experiment. If no apps are passed, then all experiment apps will have their
// 'running' stage triggered.
func TriggerRunning(ctx context.Context, name string, apps ...string) error {
var err error
c, _ := store.NewConfig("experiment/" + name)
// Trigger executes the given lifecycle stage for the selected apps in an
// experiment. If no apps are passed, the stage is triggered for all apps.
//
// Trigger is intended for manually invoking a lifecycle stage for a specific
// app (or apps) on demand, e.g. for debugging. For the 'configure' stage in
// particular, it is not a replacement for Reconfigure: Trigger doesn't
// validate the experiment config, run config hooks, reset the minimega
// bridge, or refuse to run against a running experiment. Use Reconfigure
// after an experiment's stored topology, scenario, or deployment settings
// have changed.
func Trigger(ctx context.Context, name string, stage app.Action, apps ...string) error {
if !stage.Valid() {
return fmt.Errorf("invalid app lifecycle stage %q", stage)
}

c, err := store.NewConfig("experiment/" + name)
if err != nil {
return fmt.Errorf("creating experiment config for %s: %w", name, err)
}

err = store.Get(c)
if err != nil {
Expand All @@ -1073,18 +1097,85 @@ func TriggerRunning(ctx context.Context, name string, apps ...string) error {
return fmt.Errorf("decoding experiment from config: %w", err2)
}

if !exp.Running() {
if stage == app.ActionRunning && !exp.Running() {
return errors.New("experiment is not running")
}

if err := validateTriggerApps(exp, stage, apps); err != nil {
return err
}

err = app.ApplyApps(
ctx,
exp,
app.Stage(app.ActionRunning),
app.Stage(stage),
app.FilterApp(apps...),
app.Trigger(),
)
if err != nil {
return fmt.Errorf("triggering apps for experiment: %w", err)
return fmt.Errorf("triggering %s stage for experiment apps: %w", stage, err)
}

// Running-stage apps persist their status as they execute and reload the
// experiment between apps.
if stage == app.ActionRunning {
return nil
}

if err := exp.WriteToStore(false); err != nil {
return fmt.Errorf("saving experiment after triggering %s stage: %w", stage, err)
}

return nil
}

// TriggerRunning executes the 'running' stage for the given apps in the given
// experiment. If no apps are passed, then all experiment apps will have their
// 'running' stage triggered.
func TriggerRunning(ctx context.Context, name string, apps ...string) error {
return Trigger(ctx, name, app.ActionRunning, apps...)
}

// validateTriggerApps ensures the given apps are part of the experiment and
// that the requested lifecycle stage is applicable to each of them. If no
// apps are given (meaning "all apps"), it instead ensures at least one app in
// the experiment supports the requested stage.
func validateTriggerApps(exp *types.Experiment, stage app.Action, apps []string) error {
if len(apps) == 0 {
for _, name := range app.ExperimentApps(exp) {
if app.StageApplicable(stage, name) {
return nil
}
}

return fmt.Errorf(
"the %s stage is not applicable for any app in experiment %s",
stage,
exp.Metadata.Name,
)
}

known := make(map[string]struct{})
for _, name := range app.ExperimentApps(exp) {
known[name] = struct{}{}
}

for _, name := range apps {
if _, ok := known[name]; !ok {
return fmt.Errorf(
"app %s is not part of experiment %s",
name,
exp.Metadata.Name,
)
}

if !app.StageApplicable(stage, name) {
return fmt.Errorf(
"the %s stage is not applicable for app %s",
stage,
name,
)
}
}

return nil
Expand Down
158 changes: 158 additions & 0 deletions src/go/api/experiment/experiment_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
package experiment_test

import (
"context"
"strings"
"testing"

"github.com/golang/mock/gomock"

"phenix/api/experiment"
"phenix/app"
"phenix/store"
"phenix/types"
)

// noopApp is a minimal app.App implementation used to register scenario apps
// for Trigger tests without pulling in a real app's side effects.
type noopApp struct{ name string }

func (a *noopApp) Init(...app.Option) error { return nil }
func (a *noopApp) Name() string { return a.name }
func (a *noopApp) Configure(context.Context, *types.Experiment) error { return nil }
func (a *noopApp) PreStart(context.Context, *types.Experiment) error { return nil }
func (a *noopApp) PostStart(context.Context, *types.Experiment) error { return nil }
func (a *noopApp) Running(context.Context, *types.Experiment) error { return nil }
func (a *noopApp) Cleanup(context.Context, *types.Experiment) error { return nil }

func TestList(t *testing.T) {
configs := store.Configs(
[]store.Config{
Expand Down Expand Up @@ -41,3 +57,145 @@ func TestList(t *testing.T) {
t.FailNow()
}
}

func triggerTestConfig(name string, appNames ...string) store.Config {
appConfigs := make([]map[string]any, 0, len(appNames))
for _, n := range appNames {
appConfigs = append(appConfigs, map[string]any{"name": n})
}

return store.Config{
Version: "phenix.sandia.gov/v1",
Kind: "Experiment",
Metadata: store.ConfigMetadata{Name: name},
Spec: map[string]any{
"experimentName": name,
"scenario": map[string]any{
"apps": appConfigs,
},
},
Status: map[string]any{
"startTime": "2024-01-01T00:00:00Z",
},
}
}

func TestTriggerRejectsAppNotInExperiment(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

cfg := triggerTestConfig("trigger-missing-app", "known-app")

m := store.NewMockStore(ctrl)
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(c *store.Config) error {
*c = cfg
return nil
}).AnyTimes()

store.DefaultStore = m //nolint:reassign // monkey patching for test

err := experiment.Trigger(context.Background(), "trigger-missing-app", app.ActionConfigure, "unknown-app")
if err == nil {
t.Fatal("expected error triggering an app not part of the experiment")
}

if !strings.Contains(err.Error(), "not part of experiment") {
t.Fatalf("expected error to mention app not part of experiment, got: %v", err)
}
}

func TestTriggerRejectsInapplicableStageForDefaultApp(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

cfg := triggerTestConfig("trigger-default-app-running")

m := store.NewMockStore(ctrl)
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(c *store.Config) error {
*c = cfg
return nil
}).AnyTimes()

store.DefaultStore = m //nolint:reassign // monkey patching for test

defaultApps := app.DefaultApps()
if len(defaultApps) == 0 {
t.Skip("no default apps registered")
}

err := experiment.Trigger(
context.Background(),
"trigger-default-app-running",
app.ActionRunning,
defaultApps[0],
)
if err == nil {
t.Fatal("expected error triggering running stage for a default app")
}

if !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("expected error to mention stage not applicable, got: %v", err)
}
}

func TestTriggerRejectsAllAppsWhenNoneSupportStage(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// No scenario apps, so the experiment only has default apps, none of which
// support the running stage.
cfg := triggerTestConfig("trigger-all-apps-running-unsupported")

m := store.NewMockStore(ctrl)
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(c *store.Config) error {
*c = cfg
return nil
}).AnyTimes()

store.DefaultStore = m //nolint:reassign // monkey patching for test

err := experiment.Trigger(
context.Background(),
"trigger-all-apps-running-unsupported",
app.ActionRunning,
)
if err == nil {
t.Fatal("expected error triggering running stage for all apps when none support it")
}

if !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("expected error to mention stage not applicable, got: %v", err)
}
}

func TestTriggerAllowsAllAppsWhenAtLeastOneSupportsStage(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

name := "test-all-apps-running-supported"
if err := app.RegisterUserApp(name, func() app.App {
return &noopApp{name: name}
}); err != nil {
t.Fatalf("registering user app: %v", err)
}

cfg := triggerTestConfig("trigger-all-apps-running-supported", name)

m := store.NewMockStore(ctrl)
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(c *store.Config) error {
*c = cfg
return nil
}).AnyTimes()
m.EXPECT().Update(gomock.Any()).Return(nil).AnyTimes()

store.DefaultStore = m //nolint:reassign // monkey patching for test

err := experiment.Trigger(
context.Background(),
"trigger-all-apps-running-supported",
app.ActionRunning,
)
if err != nil {
t.Fatalf("expected no error triggering running stage when a scenario app supports it, got: %v", err)
}
}
Loading
Loading