Skip to content
Open
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
42 changes: 39 additions & 3 deletions internal/hermes/hermes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions internal/hermes/hermes_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hermes

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -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"
)

Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down