From 1681e849345902ceaa5820bb59def1647b947f94 Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:09:54 +0400 Subject: [PATCH] fix(hermes): make gated dashboard smoke path idempotent --- internal/hermes/hermes.go | 42 +++++++++++++-- internal/hermes/hermes_test.go | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index b4e26a0e..84c088e0 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -109,13 +109,23 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { if opts.IsDefault && !opts.Force { if _, err := os.Stat(deploymentDir); err == nil { - u.Info("Default Hermes instance already configured, re-syncing...") + u.Info("Default Hermes instance already configured.") if err := dns.EnsureHostsEntries(agentruntime.CollectHostnames(cfg, agentruntime.DeploymentRef{ Runtime: agentruntime.Hermes, ID: id, })); err != nil { u.Warnf("Could not update /etc/hosts for Hermes hostnames: %v", err) } + if opts.Sync { + installed, err := hermesDeploymentInstalledForOnboard(cfg, id) + if err != nil { + u.Warnf("Could not check existing Hermes deployment, re-syncing: %v", err) + } else if installed { + u.Success("Default Hermes instance already installed.") + return nil + } + u.Info("Default Hermes deployment not found, re-syncing...") + } if err := writeDeploymentFiles(cfg, id, deploymentDir, currentAgentBaseURL(deploymentDir), u); err != nil { return err } @@ -527,9 +537,35 @@ func strategyMigrationPatchArgs(namespace string) []string { func SyncDefaultModels(cfg *config.Config, u *ui.UI) error { deploymentDir := DeploymentPath(cfg, agentruntime.DefaultInstanceID) if _, err := os.Stat(deploymentDir); os.IsNotExist(err) { - return nil + return setupDefaultForModelSync(cfg, u) + } + return syncDefaultForModelSync(cfg, agentruntime.DefaultInstanceID, u) +} + +var ( + setupDefaultForModelSync = SetupDefault + syncDefaultForModelSync = Sync +) + +var hermesDeploymentInstalledForOnboard = hermesDeploymentInstalled + +func hermesDeploymentInstalled(cfg *config.Config, id string) (bool, error) { + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return false, nil + } + + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + cmd := exec.Command(kubectlBinary, "get", "deployment/hermes", "-n", agentruntime.Namespace(agentruntime.Hermes, id)) + cmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath) + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, err } - return Sync(cfg, agentruntime.DefaultInstanceID, u) + return true, nil } func Skills(cfg *config.Config, id string, args []string) error { diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index 672798ef..46fc04b2 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -1,6 +1,7 @@ package hermes import ( + "errors" "fmt" "os" "path/filepath" @@ -10,6 +11,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/agentruntime" "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" "gopkg.in/yaml.v3" ) @@ -24,6 +26,97 @@ func testConfig(t *testing.T) *config.Config { return &config.Config{ConfigDir: dir, DataDir: dir, BinDir: dir} } +func TestSyncDefaultModels_BootstrapsMissingDefaultInstance(t *testing.T) { + cfg := testConfig(t) + wantErr := errors.New("setup called") + + origSetup := setupDefaultForModelSync + origSync := syncDefaultForModelSync + t.Cleanup(func() { + setupDefaultForModelSync = origSetup + syncDefaultForModelSync = origSync + }) + + setupDefaultForModelSync = func(got *config.Config, _ *ui.UI) error { + if got != cfg { + t.Fatalf("setup cfg = %p, want %p", got, cfg) + } + return wantErr + } + syncDefaultForModelSync = func(_ *config.Config, _ string, _ *ui.UI) error { + t.Fatal("sync should not run when the default deployment is missing") + return nil + } + + if err := SyncDefaultModels(cfg, newTestUI()); !errors.Is(err, wantErr) { + t.Fatalf("SyncDefaultModels() err = %v, want %v", err, wantErr) + } +} + +func TestSyncDefaultModels_SyncsExistingDefaultInstance(t *testing.T) { + cfg := testConfig(t) + if err := os.MkdirAll(DeploymentPath(cfg, agentruntime.DefaultInstanceID), 0o755); err != nil { + t.Fatalf("create default deployment dir: %v", err) + } + wantErr := errors.New("sync called") + + origSetup := setupDefaultForModelSync + origSync := syncDefaultForModelSync + t.Cleanup(func() { + setupDefaultForModelSync = origSetup + syncDefaultForModelSync = origSync + }) + + setupDefaultForModelSync = func(_ *config.Config, _ *ui.UI) error { + t.Fatal("setup should not run when the default deployment exists") + return nil + } + syncDefaultForModelSync = func(got *config.Config, id string, _ *ui.UI) error { + if got != cfg { + t.Fatalf("sync cfg = %p, want %p", got, cfg) + } + if id != agentruntime.DefaultInstanceID { + t.Fatalf("sync id = %q, want %q", id, agentruntime.DefaultInstanceID) + } + return wantErr + } + + if err := SyncDefaultModels(cfg, newTestUI()); !errors.Is(err, wantErr) { + t.Fatalf("SyncDefaultModels() err = %v, want %v", err, wantErr) + } +} + +func TestOnboardDefaultAlreadyInstalledIsIdempotent(t *testing.T) { + cfg := testConfig(t) + if err := os.MkdirAll(DeploymentPath(cfg, agentruntime.DefaultInstanceID), 0o755); err != nil { + t.Fatalf("create default deployment dir: %v", err) + } + + origInstalled := hermesDeploymentInstalledForOnboard + t.Cleanup(func() { + hermesDeploymentInstalledForOnboard = origInstalled + }) + + hermesDeploymentInstalledForOnboard = func(got *config.Config, id string) (bool, error) { + if got != cfg { + t.Fatalf("installed cfg = %p, want %p", got, cfg) + } + if id != agentruntime.DefaultInstanceID { + t.Fatalf("installed id = %q, want %q", id, agentruntime.DefaultInstanceID) + } + return true, nil + } + + if err := Onboard(cfg, OnboardOptions{ + ID: agentruntime.DefaultInstanceID, + Sync: true, + IsDefault: true, + AgentMode: true, + }, newTestUI()); err != nil { + t.Fatalf("Onboard(existing installed default) err = %v", err) + } +} + // TestGenerateConfig_PrimaryIsRoundTrippable guards the LiteLLM model_name // contract end-to-end: whatever string the agent's `model.default` is set to // MUST match a `model_name` entry in the LiteLLM ConfigMap byte-for-byte, @@ -161,6 +254,8 @@ func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { `- "obol-agent.obol.stack"`, "name: hermes-dashboard", "name: GATEWAY_HEALTH_URL", + "HERMES_DASHBOARD_BASIC_AUTH_USERNAME", + "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", } { if !strings.Contains(values, needle) { t.Fatalf("generateValues() missing %q:\n%s", needle, values)